Code viewer for World: Voice Controlled Game
// OpenAI Whispers API Voice Controlled Game - tomesck2 + gowrann2 CA318 2023
// By Kevin James Tomescu - 21435066 - tomesck2 + Niamh Gowran - 21389501 - gowrann2

// Initializing variables
var apikey = "";
var mediaRecorder;
var audioChunks = [];
var recordingLength = 1250; // Length of each recording snippet in milliseconds
var isProcessingAudio = false; // flag for audio processing
var canReceiveCommand = true; // flag for receiving commands
var gravity = 0.025; // Gravity effect
var velocity = 0; // vertical velocity
var velocityX = 0; // Horizontal velocity
var moveSpeed = 5; // Speed for left/right movements

// variables for side scrolling background
var backgroundX = 0;
var backgroundSpeed = -0.02; // negative to have it move left

var score = 0; // Initialize score
var lives = 3; // Start with 3 lives
var collisionCooldown = false; // Cooldown for collision detection

var myObstacle;
var pipeFrequency = 1500; // Milliseconds
var lastPipeTime = Date.now();
var myObstacles = [];

//HTML code for the page, including the API key input and the buttons to start recording.
//The result of the API call is displayed in the div with id="apiResponse".
document.write(`
  <div id="gameContainer" style="text-align: center; margin: auto; width: 50%;">
    <div id="enterkey">
      <h3>Enter API key</h3>
      Enter API key: <input style='width:25vw;' maxlength='2000' id="apikey" value=''>  
      <button onclick='setkey();' class='ab-normbutton'>Set API key</button>
    </div>
    <canvas id="gameCanvas" width="700" height="500"></canvas>
    <div id="gameInfo">
      <p>Say your command when the timer hits 0!</p>
      <p>Commands are Go UP, Go DOWN, Go LEFT, Go RIGHT - speak to control your character.</p>
      <div id="timerDisplay"></div>
      <div id="whispersResult">
        <h3>Whispers API Response:</h3>
        <div id="apiResponse"></div>
      </div>
      <div id="scoreDisplay">
      <h3>Score: <span id="score">0</span></h3>
      </div> 
      <div id="livesDisplay">
        <h3>Lives: <span id="lives">3</span></h3>
      </div>
      <button onclick="resetGame()">Restart Game</button>
  </div>
`);

// CSS for the game container
$("#gameContainer").css({
  textAlign: "center",
  maxWidth: "1000px",
  margin: "auto",
});

// CSS for the canvas
$("#gameCanvas").css({
  border: "3px solid black",
  "background-color": "transparent",
  margin: "20px 0", // Add margin to separate from other elements
});

var backgroundImage = new Image();
backgroundImage.crossOrigin = "Anonymous";
backgroundImage.onload = function () {
  drawBackground();
  drawSquare();
};
backgroundImage.src =
  "https://media.istockphoto.com/id/1171564349/video/retro-land.jpg?s=640x640&k=20&c=UytoK5VuR3nXezFTIryRH6D4mHUE846zUBZhlIJA5cw=";

function drawBackground() {
  // Draw the moving background
  ctx.drawImage(backgroundImage, backgroundX, 0, canvas.width, canvas.height);
  // Draw a second image to the right of the first to loop the background
  ctx.drawImage(
    backgroundImage,
    backgroundX + canvas.width,
    0,
    canvas.width,
    canvas.height
  );
}

function updateScoreDisplay() {
  document.getElementById("score").innerHTML = score;
}

function updateLivesDisplay() {
  document.getElementById("lives").innerHTML = lives;
}

function startGame() {
  myObstacle = new component(10, 300, "green", 300, 120);
  gameLoop(); // Start the game loop
}

function gameLoop() {
  var currentTime = Date.now();
  //myObstacle.update();

  // Update the state of the game
  updateGameState();

  // Clear the canvas
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Update and draw the background
  backgroundX += backgroundSpeed;
  if (backgroundX <= -canvas.width) {
    backgroundX = 0;
  }
  drawBackground();

  // Draw the square
  drawSquare();

  if (currentTime - lastPipeTime > pipeFrequency) {
    var extraDistance = Math.random() * (400 - 200) + 100; // Random additional distance off-screen between 100 and 300
    var obstacleStartX = canvas.width + extraDistance;

    var maxHeight = 200;
    var minHeight = 50;
    var heightTop =
      Math.floor(Math.random() * (maxHeight - minHeight + 1)) + minHeight;
    var heightBottom =
      Math.floor(Math.random() * (maxHeight - minHeight + 1)) + minHeight;

    myObstacles.push(new component(30, heightTop, "green", obstacleStartX, 0));
    myObstacles.push(
      new component(
        30,
        heightBottom,
        "green",
        obstacleStartX,
        canvas.height - heightBottom
      )
    );

    lastPipeTime = currentTime;

    // Adjust pipeFrequency for staggered distances
    pipeFrequency = Math.random() * (2500 - 1500) + 1500; // Random frequency between 1.5 to 2.5 seconds
  }

  // Update and draw each obstacle
  for (var i = 0; i < myObstacles.length; i++) {
    myObstacles[i].x -= 0.5; // Move obstacle
    myObstacles[i].update(); // Draw obstacle
  }

  myObstacles.forEach((obstacle) => {
    if (!obstacle.passed && square.x > obstacle.x + obstacle.width) {
      score++; // Increment score
      obstacle.passed = true; // Mark this pipe as passed
      console.log("Pipe passed");
      updateScoreDisplay();
    }
  });

  if (checkCollision(square, myObstacles)) {
    alert("You hit a pipe!");
    if (lives <= 0) {
      gameOver();
      return;
    }
  }

  // Queue the next loop
  requestAnimationFrame(gameLoop);
}

function gameOver() {
  ctx.font = "48px sans-serif";
  ctx.fillStyle = "red";
  ctx.textAlign = "center";
  ctx.fillText("Game Over!", canvas.width / 2, canvas.height / 2);
}

function resetGame() {
  // Reset game variables
  score = 0;
  lives = 3;
  backgroundX = 0;
  velocity = 0;
  velocityX = 0;
  myObstacles = []; // Reset obstacles array
  lastPipeTime = Date.now(); // Reset pipe timer

  // Update displays
  updateScoreDisplay();
  updateLivesDisplay();

  // Restart the game loop
  requestAnimationFrame(gameLoop);
}

function updateGameState() {
  // Update background position for side-scrolling
  backgroundX += backgroundSpeed;
  if (backgroundX <= -canvas.width) {
    backgroundX = 0;
  }

  // Apply gravity and vertical movement
  velocity += gravity;
  square.y += velocity;

  // Apply horizontal movement
  square.x += velocityX;

  // Implement friction or stopping condition so the square stops moving when commands are not given
  velocityX *= 0.9; // This will slow down the square's movement over time

  // Add boundaries to stop the square from moving outside the canvas
  if (square.x < 0) {
    square.x = 0;
    velocityX = 0;
  } else if (square.x + square.size > canvas.width) {
    square.x = canvas.width - square.size;
    velocityX = 0;
  }

  if (square.y + square.size > canvas.height) {
    square.y = canvas.height - square.size;
    velocity = 0;
  } else if (square.y < 0) {
    square.y = 0;
    velocity = 0;
  }
}

// Set the API key and display a message
// credit to https://ancientbrain.com/viewjs.php?world=2850716357 for the starter code for this function
function setkey() {
  apikey = $("#apikey").val().trim();
  $("#enterkey").html("<b>API key has been set. Setting up...</b>");
  startGame();
  startContinuousRecording().then(() => {
    $("#enterkey").html("<b>Ready to receive commands!</b>");
  });
}

function updateTimerDisplay(timeLeft) {
  $("#timerDisplay").text(timeLeft + "s");
}

// Start recording audio from the microphone
// credit to https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder for mediaRecorder documentation and example
async function startContinuousRecording() {
  return new Promise(async (resolve, reject) => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      mediaRecorder = new MediaRecorder(stream);

      mediaRecorder.ondataavailable = (event) => {
        if (!isProcessingAudio) {
          audioChunks.push(event.data);
          sendAudioToWhispersAPI();
        }
      };

      setInterval(() => {
        if (mediaRecorder.state === "recording") {
          mediaRecorder.stop();
        } else {
          audioChunks = [];
          isProcessingAudio = false; // Reset the audio processing flag
          mediaRecorder.start(); // start recording again
        }
      }, recordingLength);

      resolve();
    } catch (error) {
      reject(error);
    }
  });
}

// Send audio to Flask proxy server and display the result
// credit to https://platform.openai.com/docs/guides/speech-to-text for the API documentation
// credit to https://developer.mozilla.org/en-US/docs/Web/API/FileReader for the FileReader documentation
// credit to https://api.jquery.com/jquery.ajax/ for the jQuery AJAX documentation
// credit to https://developer.mozilla.org/en-US/docs/Glossary/Base64 for the base64 documentation
function sendAudioToWhispersAPI() {
  isProcessingAudio = true; // set the flag to true

  // make sure there are audio chunks available
  if (audioChunks.length === 0) {
    console.error("No audio data available");
    return;
  }

  // Properly construct the Blob from audio chunks
  const audioBlob = new Blob(audioChunks, { type: "audio/wav" });

  // Check if the created object is indeed a Blob
  if (!(audioBlob instanceof Blob)) {
    console.error("Failed to create a Blob:", audioBlob);
    return;
  }

  const reader = new FileReader();

  reader.onloadend = function () {
    const base64data = reader.result;

    // Prepare the request payload, including the model
    const payload = {
      api_endpoint: "https://api.openai.com/v1/audio/transcriptions", // OpenAI Transcriptions API endpoint
      api_key: apikey, // API key
      model: "whisper-1", // Model name
      data: base64data, // Audio data in base64 format, as required by the API
    };

    // send request and handle response
    $.ajax({
      type: "POST",
      url: "https://gowtom-proxy.azurewebsites.net/speech-to-text", // URL of our Flask server
      data: JSON.stringify(payload),
      contentType: "application/json",
      success: function (response) {
        $("#apiResponse").text(JSON.stringify(response, null, 2));
        if (response && response.text) {
          processApiResponse(response.text);
          isProcessingAudio = false;
        } else {
          isProcessingAudio = false;
          console.error("Unexpected response format:", response);
        }
      },
      error: function (_, __, error) {
        $("#apiResponse").html(
          "<font color='red'><b>An error occurred: " + error + "</b></font>"
        );
      },
    });
  };

  reader.readAsDataURL(audioBlob); // Convert blob to base64
}

// Game Logic
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
let square = { x: canvas.width / 2 - 25, y: canvas.height - 50, size: 50 };

function drawSquare() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawBackground();
  ctx.fillStyle = "red";
  ctx.fillRect(square.x, square.y, square.size, square.size);
}

// Process the response from Whispers API to control the game
async function processApiResponse(responseText) {
  if (!canReceiveCommand) return;
  canReceiveCommand = false;

  await moveSquare(
    responseText
      .replace(/[^\w\s]/g, "")
      .toLowerCase()
      .trim()
  );

  var timeLeft = recordingLength / 1000;
  updateTimerDisplay(timeLeft);
  var timer = setInterval(() => {
    timeLeft--;
    updateTimerDisplay(timeLeft);
    if (timeLeft <= 0) {
      clearInterval(timer);
      canReceiveCommand = true;
      updateTimerDisplay(0);
    }
  }, 1000);
}

function component(width, height, color, x, y) {
  this.width = width;
  this.height = height;
  this.x = x;
  this.y = y;
  this.passed = false;
  this.update = function () {
    ctx.fillStyle = color;
    ctx.fillRect(this.x, this.y, this.width, this.height);
  };
}

function checkCollision(square, obstacles) {
  if (collisionCooldown) return false;
  for (var i = 0; i < obstacles.length; i++) {
    var obstacle = obstacles[i];
    var myleft = square.x;
    var myright = square.x + square.size;
    var mytop = square.y;
    var mybottom = square.y + square.size;

    var otherleft = obstacle.x;
    var otherright = obstacle.x + obstacle.width;
    var othertop = obstacle.y;
    var otherbottom = obstacle.y + obstacle.height;

    if (
      mybottom < othertop ||
      mytop > otherbottom ||
      myright < otherleft ||
      myleft > otherright
    ) {
      continue;
    }
    lives -= 1;
    updateLivesDisplay();
    myObstacles.forEach((obstacle) => (obstacle.passed = false));
    collisionCooldown = true;
    setTimeout(() => {
      collisionCooldown = false;
    }, 1000); // Cooldown for 1 second (adjust as needed)

    return true; // collision detected
  }
  return false; // no collision
}

async function moveSquare(direction) {
  const commands = direction.toLowerCase().split(" "); // Split the direction into individual commands

  commands.forEach((command) => {
    if (
      ![
        "up",
        "op",
        "oop",
        "down",
        "left",
        "right",
        "write",
        "ride",
        "uh",
        "oh",
      ].includes(command)
    ) {
      // If the command is not one of the valid directions, log an error message and return
      console.log("Could not move the square, the command was incorrect");
      return;
    }

    // Handle the command and move the character
    switch (command) {
      case "up":
      case "op":
      case "oop":
      case "uh":
      case "oh":
        // Negative velocity to move up
        velocity = -4;
        break;
      case "down":
        velocity = 2.5;
        break;
      case "left":
        velocityX = -moveSpeed;
        break;
      case "right":
      case "write":
      case "ride":
        // Move the square right by adding the moveAmount to its x-coordinate
        velocityX = moveSpeed;
        break;
    }
  });

  drawSquare(); // Redraw the square on the canvas after moving
}

// Credits / references
// https://ancientbrain.com/viewjs.php?world=2850716357
// https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder
// https://platform.openai.com/docs/guides/speech-to-text
// https://developer.mozilla.org/en-US/docs/Web/API/FileReader
// https://api.jquery.com/jquery.ajax/
// https://developer.mozilla.org/en-US/docs/Glossary/Base64
// https://openai.com/research/whisper
// https://github.com/openai/whisper
// https://www.youtube.com/watch?v=jj5ADM2uywg Flappy Bird JS Tutorial
// https://www.w3schools.com/graphics/game_intro.asp - W3Schools HTML JS Flappy Bird Guide