Lord of The Maze – Midterm – Dachi Tarughishvili

Sketch (Fullscreen) https://editor.p5js.org/dt2307/full/vrBxuAsfN

(I have embedded here just in case but please open it in separate tab for everything to properly work (most importantly audio))

Idea: Lord of the Rings inspired game, where a player takes initiative to escape the maze, avoid the orcs and flying monster, find the ring and reach the mount of doom

Project summary: This game takes inspiration from the epic fantasy novel The Lord of the Rings, which my previous project Eye of Sauron was also based on. In this maze-style game, the main character Frodo must navigate through a maze and reach Mount Doom, the volcanic location in Mordor where the powerful ring was forged and the only place it can be destroyed. Roaming orcs patrol the maze pathways, which Frodo must avoid. Coming into direct contact with an orc reduces Frodo’s health. If Frodo loses all three health points, the game is over. If Frodo successfully reaches Mount Doom in time, the player wins the game and an image is displayed. The goal is to guide Frodo through the maze while evading orcs in order to make it to Mount Doom and destroy the ring. However, there is a catch, if you reach mount of doom without obtaining the ring, flying monster will start chasing you and at that point you should get the ring as soon ass possible to evade it. Once you capture the ring, Sauron animation will be displayed (based on my previous project with the perlin noise after Coding Train intro). After that you can see game become more gloomy as colors start to change, background included. Fortunately, due to magical powers of the ring you are granted an invisibility buff which lasts for certain amount of time. The visual cue is there for player by reducing Frodo’s transparency as well as audio cue which gets more frequent with more pulses indicating when you are gonna run out. Finally, you are able to reach mount of doom and destroy the ring if you get through the remaining orcs!

Inspiration: this game is inspired by lord of the rings movies (books):  The Fellowship of the Ring (2001), The Two Towers (2002), and The Return of the King (2003). I want to recreate an experience where player gets to have their own journey, traversing long distance, making strategic choices, avoiding the danger and reaching destination similar to what happens in the movies.

Visuals: the maze itself is black on green canvas. Characters have their own images (orc, frodo, mount of doom etc.). They are in pixel art style to give players a nostalgic feeling which also makes whole game work on this platform much smoother. The main menu screen as well as instructions and game won game over screen are AI generated, but text on top is using custom font.

Process and Challenges: I made sure to utilize an object-oriented approach. There were several development hurdles. Firstly, after designing the maze layout and slowly incorporating it into code to test functionality, I struggled greatly with collision detection (characters could access the maze improperly from certain sides) which took substantial time to correct. Additionally, programming the repetitive orc movements to patrol the maze appropriately relied heavily on trial-and-error to determine optimal pathways. (And lots of Googling!). Last few days, I also added sounds which were not too difficult but took some time to pick right soundtracks and make it working. Volume slider was a bit tricky as I head to read documentation online because I did not like the way its default behavior worked. I also added countdown which lets player see their current time as well as total time they took to beat the challenge. Additionally, I fixed issue with ring, and volume slider being displayed over game over screen and such. I added even more soundtracks, for getting the ring and spawning the ring. Moreover, I implemented features such as flying monster which spawns and moves towards frodo if he goes to mount of doom without picking up the ring. Upon picking up the ring, I added a feature based on my last project where eye of sauron animation gets displayed (which was done using perlin noise). This comes with change in background as well as another feature – Invisibility. In simple terms, frodo becomes more transparent visually, a sound for invisibility starts playing and in specific timeframe he is immune to enemies. I added another orc near ring to make getting it more challenging. Last but not least, ring gets spawned only if Frodo reaches certain area in the map, to ensure that player can’t just camp at base and wait for ring to spawn if there was a timer instead, making game much simpler.

Here are some development progress pictures (I have not included every one of them) :

Code:

I have separate JS classes for different functions of the game.

Lets go over most of them (briefly, more details are in code comments):

Drawing UI Class: takes care of top bar with health, volume and timer texts.

function drawUI() {
  // Draw Health text
  fill(255);
  textSize(14);
  noStroke();
  text("Lives: " + playerHealth, 55, 11);

  // Draw Volume text
  fill(255);
  textSize(14);
  noStroke();
  text("Volume:", 150, 11);
  // Make sure volume slider is visible
  volumeSlider.style('display', 'block');

  // Draw Timer
  fill(255);
  textSize(14);
  text("Time: " + playTime.toFixed(1) + "s", width - 60, 11);

  // Set volume based on slider value
  initialVolume = volumeSlider.value();
  backgroundMusic.setVolume(initialVolume);
}

Orc class: takes care of spawning as well as moving orcs (also makes sure they don’t go in the maze)

class Orc {
  constructor(pointA, pointB, spawn) {
    this.pointA = pointA; //start
    this.pointB = pointB; //end
    this.size = 20;
    this.speed = 1.2;

    //initial spawn
    this.x = spawn.x;
    this.y = spawn.y;

    // target
    this.currentTarget = this.pointA;
  }

  display() {
    image(orcImg, this.x, this.y, this.size, this.size);
  }

  move() {
    let dx = this.currentTarget.x - this.x;
    let dy = this.currentTarget.y - this.y;
    let length = sqrt(dx * dx + dy * dy); //direction vector

    if (length > 0) {
      dx /= length; //normalize vector for consistent speed
      dy /= length;
      
      //calculate new position
      let newPosX = this.x + dx * this.speed;
      let newPosY = this.y + dy * this.speed;

      if ( //if new position is in bound and does not collide with walls
        newPosX > 0 &&
        newPosX < width - this.size &&
        newPosY > 0 &&
        newPosY < height - this.size &&
        maze[getRow(newPosY)][getCol(newPosX)] !== '#'
      ) {
        this.x = newPosX;
        this.y = newPosY;

        // check if orc reached target
        if (dist(this.x, this.y, this.currentTarget.x, this.currentTarget.y) < this.speed) {
          // switch points
          this.currentTarget = this.currentTarget === this.pointA ? this.pointB : this.pointA;
        }
      }
    }
  }
}

function generateLevel() {
  orcs = [];
  orcs.push(new Orc({ x: 28, y: 350 }, { x: 28, y: 180 }, { x: 28, y: 180 }));
  orcs.push(new Orc({ x: 605, y: 100 }, { x: 605, y: 400 }, { x: 605, y: 180 }));
  orcs.push(new Orc({ x: 452, y: 420 }, { x: 452, y: 250 }, { x: 452, y: 250 }));
  orcs.push(new Orc({ x: 260, y: 605 }, { x: 455, y: 605 }, { x: 455, y: 605 }));
  orcs.push(new Orc({ x: 300, y: 100 }, { x: 200, y: 100 }, { x: 200, y: 100 }));
  // orcs and their pathways
}

Player class: initializes player, as well as deals with maze collision and invisibility buff.

class Player {
  constructor() {
    this.size = 20; 
    this.speed = 3;
    this.spawn();
  }

  display() {
    if (millis() < invincibleUntil) {
      tint(255, 63); //25% transparency
    } else {
      tint(255, 255); 
    }
    image(playerImg, this.x, this.y, this.size, this.size);
  }
  move() {
    
    if (eyeOfSauronActive) { //cant move if active
      return; 
    }
    let newX = this.x;
    let newY = this.y;
    //movement
    if (keyIsDown(LEFT_ARROW) && this.x > 0) {
      newX -= this.speed;
    } else if (keyIsDown(RIGHT_ARROW) && this.x < width - this.size) {
      newX += this.speed;
    }
    if (keyIsDown(UP_ARROW) && this.y > 0) {
      newY -= this.speed;
    } else if (keyIsDown(DOWN_ARROW) && this.y < height - this.size) {
      newY += this.speed;
    }

    if (!this.collidesWithWall(newX, this.y) && !this.collidesWithWall(newX, newY) && !this.collidesWithWall(this.x, newY) && !this.collidesWithWall(newX, newY)) {
      this.x = newX;
      this.y = newY; //updates if there are no collisions with walls
    }
  }
  //collision
  collidesWithWall(x, y) {
    
    //calculates grid indices with helpers
    let left = getCol(x);
    let right = getCol(x + this.size - 1);
    let top = getRow(y);
    let bottom = getRow(y + this.size - 1);
    
    //checks if any grids around player position has # (meaning wall)
    return ( //returns true if collision happens if not false ( or conditions)
      maze[top][left] === '#' ||
      maze[top][right] === '#' ||
      maze[bottom][left] === '#' ||
      maze[bottom][right] === '#'
    );
  }
    //initial spawn
  spawn() {
    this.x = 30;
    this.y = 30;
  }
}

Ring Class: takes care of spawning golden ring as well as checking its collision for player and determining invisibility buff time

const ringSpawnLocation = { row: 10, col: 20 }; //properties
let goldenRingRadius = 10;

function checkRingCollision() {
  if (goldenRing && dist(player.x, player.y, goldenRing.x, goldenRing.y) < goldenRing.size) { //if ring exists and distance is less than rings radius
    //activate sauron and invis buff sequence
    collidedWithRing = true;
    eyeOfSauronActive = true;
    invincibleUntil = millis() + 40000; // make Frodo invincible for 30 seconds
    monsterSpawned = false;
     setTimeout(() => {
      invisibilitySound.play();
    }, 20000);
    if (!sauronSound.isPlaying() && !sauronSoundStarted) {
      sauronSound.play();
      sauronSoundStarted = true;
      goldenRing = null;
    }
  }
}

//creating golden ring
function createGoldenRing(x, y, size) {
  return {
    x,
    y,
    size,
  };
}



//drawing golden ring
function drawGoldenRing() {
  
  image(ringImage, goldenRing.x - goldenRingRadius, goldenRing.y - goldenRingRadius, goldenRingRadius * 2, goldenRingRadius * 2);
}

Game Management Class:  takes care of different game states, main menu state, game win, gameplay, gameover etc. It also displays main menu, instructions and helps with clearing as well as reloading objects and variables upon restart

function startGame() {
  gameStarted = true;
  this.remove(); // remove the Start Game button
  volumeSlider.style('display', 'block'); //displays volume slider

}

function returnToMainMenu() {
  currentScreen = 'mainMenu';
  backButton.hide(); // hide the back button when returning to main menu
}

function showInstructions() {
  currentScreen = 'instructions';
  backButton.show(); // show the back button when instructions are visible
}


function createRestartButton() {
  if (restartButton) {
    restartButton.remove(); // ensure any existing button is removed
  }
  let buttonWidth = 140; 
  let buttonHeight = 35; 
  let buttonX = (width - buttonWidth) / 2; // 
  let buttonY = 200; 
  restartButton = createButton('');
  restartButton.position(buttonX, buttonY);
  restartButton.size(buttonWidth, buttonHeight);
  restartButton.style('background-color', 'transparent');
  restartButton.style('border', 'none'); 
  restartButton.style('cursor', 'pointer');
  restartButton.mousePressed(restartGame);

  // change cursor on hover
  restartButton.mouseOver(() => restartButton.style('cursor', 'pointer'));
}


function gameWin() {
  gameState = 'win';
  backgroundMusic.stop();
  
  //  the game win image
  background(gameWinImg);

  //  text on top of the image
  textSize(31);
  stroke(0);
  strokeWeight(4);
  fill(255); 
  textAlign(CENTER, CENTER);
  text("You've reached the Mount of Doom!", width / 2, height / 2 -50);
  text("Journey Length: " + playTime.toFixed(1) + " seconds", width / 2, height / 2);

  winSound.play();
  if (monsterSound.isPlaying()) {
    monsterSound.stop();
  }
  volumeSlider.style('display', 'none');
  
  createRestartButton();
  noLoop(); //game pause
}


function gameOver() {
  gameState = 'gameOver';
  backgroundMusic.stop();
  
  // the game over image
  background(gameOverImg);
  
  //  text on top of the image
  textSize(45);
  stroke(0);
  strokeWeight(4);
  fill(255); 
  textAlign(CENTER, CENTER);
  text("Game Over!", width / 2, 100);
  text("Survival Time: " + playTime.toFixed(1) + " seconds", width / 2, 150);

  gameoverSound.play();
  if (monsterSound.isPlaying()) {
    monsterSound.stop();
  }
  
  volumeSlider.style('display', 'none');

  createRestartButton();
  noLoop(); // pause
}


function restartGame() {
  // stop sounds
  if (gameoverSound.isPlaying()) {
    gameoverSound.stop();
  }
  if (backgroundMusic.isPlaying()) {
    backgroundMusic.stop();
  }
  if (monsterSound.isPlaying()) {
    monsterSound.stop();
  }
  if (dyingSound.isPlaying()) {  
    dyingSound.stop();
  }
  if (sauronSound.isPlaying()) {
    sauronSound.stop();
  }
  if (invisibilitySound.isPlaying()) {
    invisibilitySound.stop();
  }
  if (winSound.isPlaying()) {
    winSound.stop();
  }

  // remove the restart button if it exists
  if (restartButton) {
    restartButton.remove();
    restartButton = null;
  }

  // reset the game state and variables for a new game
  resetGameState();

  // reset startTime to the current time to restart the timer
  startTime = millis();

  // ensure the game loop is running if it was stopped
  loop();
}



function resetGameState() {
  // reset game flags and variables
  gameStarted = true;
  gameState = 'playing';
  playerHealth = 3;
  playTime = 0;
  monsterSpawned = false;
  collidedWithRing = false;
  goldenRingSpawned = false;
  eyeOfSauronActive = false;
  eyeOfSauronDeactivated = false;
  eyeSize = 15;
  currentLevel = 1;
  
  // reset positions and states of game entities
  player.spawn();
  orcs = []; // clear existing orcs
  generateLevel(); // repopulate the orcs array

  if (volumeSlider) {
    volumeSlider.remove(); // ensure existing slider is removed before creating a new one
  }
  
  //new slider
  volumeSlider = createSlider(0, 1, 1, 0.01); 
  volumeSlider.position(180, 1.5);
  volumeSlider.style('width', '100px');
  volumeSlider.style('color', 'black');
  volumeSlider.style('outline', 'none');
  volumeSlider.style('background', '#white');
  volumeSlider.style('opacity', '0.7');
  volumeSlider.input(() => backgroundMusic.setVolume(volumeSlider.value()));

  // reset the background music volume and play it if not already playing
  backgroundMusic.setVolume(1); // set initial volume
  if (!backgroundMusic.isPlaying()) {
    backgroundMusic.loop();
  }

  // ensure the game loop is running if it was stopped
  loop();
}

Maze class: takes care of maze layout as well as drawing maze. There are two layouts, first one is official game one and second one is for quick testing. It uses helper functions to divide canvas into grids and then draws a maze if it finds # in a grid. It is using a graphic which uses a wall texture and for other places in grids we have grass texture.

let maze = [
  "##########################",
  "#        #   # #   #   # #",
  "# #### # # #   # # # # # #",
  "#   #  ##### ### # # # # #",
  "# #### #     #   # # # # #",
  "#      # # # # ### # # # #",
  "#####    # # # # # # # # #",
  "#   #  ### ### # # # # # #",
  "# # #  # #     # # # #   #",
  "# # # ## ####### ### ### #",
  "# #      #         # # # #",
  "# ################ # # # #",
  "#        #   #     # # # #",
  "# ######## ### ### # # # #",
  "#        # # # # # # # # #",
  "######## # #   # # # # # #",
  "# #    # # # ### # # # # #",
  "#    #   # # #     # # # #",
  "# ## ##### # # ##### # # #",
  "# #  #       #   #   #   #",
  "# #  # ######### # ### ###",
  "# ####   # #   # # # #   #",
  "# #  # # # #   # # # # ###",
  "# ## ### # # ### # # #    ",
  "#        #         #      ",
  "##########################",
];


// let maze = [
//   "                          ",
//   "#        #   # #   #   # #",
//   "# #### # # #   # # # # # #",
//   "#   #  ##### ### # # # # #",
//   "# #### #     #   # # # # #",
//   "#      # # # # ### # # # #",
//   "#####    # # # # # # # # #",
//   "#   #  ### ### # # # # # #",
//   "# # #  # #     # # # #   #",
//   "# # # ## ####### ### ### #",
//   "# #      #         # # # #",
//   "# ################ # # # #",
//   "#        #   #     # # # #",
//   "# ######## ### ### # # # #",
//   "#        # # # # # # # # #",
//   "######## # #   # # # # # #",
//   "# #    # # # ### # # # # #",
//   "#    #   # # #     # # # #",
//   "# ## ##### # # ##### # # #",
//   "# #  #       #   #   #   #",
//   "# #  # ######### # ### ###",
//   "# ####   # #   # # # #   #",
//   "# #  # # # #   # # # # ###",
//   "# ## ### # # ### # # #    ",
//   "#        #         #      ",
//   "##########################",
// ];

// helper functions for row and col 
function getRow(y) {
  return floor(y / 30);
}

function getCol(x) {
  return floor(x / 30);
}

function drawMaze() {
  for (let i = 0; i < maze.length; i++) {
    for (let j = 0; j < maze[i].length; j++) {
      let x = j * 25;
      let y = i * 25;
      if (maze[i][j] === '#' && !eyeOfSauronActive) {
        // Draw wall texture only if Eye of Sauron is not active
        image(wallBuffer, x, y, 25, 25, x, y, 25, 25);
      } else if (drawGrass && maze[i][j] !== '#') {
        // Draw grass texture over the green areas (paths) if drawGrass is true
        // and the current cell is not a wall.
        image(grassBuffer, x, y, 25, 25, x, y, 25, 25);
      }
    }
  }
}

MountDoom Class: creates mount doom, uses a function for tracking and moving monster towards Frodo as well as a function which determines if Frodo is inside mount of doom range.

function moveMonsterTowardsFrodo() {
  let dx = player.x - monsterX;
  let dy = player.y - monsterY;
  let angle = atan2(dy, dx); //angle between monster and player
  monsterX += monsterSpeed * cos(angle);
  monsterY += monsterSpeed * sin(angle);
  //update monster position based on calculated angle
}


class MountOfDoom {
  constructor() {
    this.x = width - 75;
    this.y = height - 95;
    this.size = 75;
  }
}


function createMountOfDoom() {
  return new MountOfDoom();
}


function playerReachedMountOfDoom() {
  return (
    !monsterSpawned && //monster has not spawned and its in bounds
    player.x + player.size > mountOfDoom.x &&
    player.x < mountOfDoom.x + mountOfDoom.size &&
    player.y + player.size > mountOfDoom.y &&
    player.y < mountOfDoom.y + mountOfDoom.size
  );
}

This is Eye of Sauron class:

it takes care of Eye of Sauron animation (used from one of the previous projects). This is drawn using various perlin noise loops. It also has activation and eye increase rate after predetermined time period. (It times well with audio e.g. death = engulfed in darkness).

let orange = 165; // clicking color variable
let size_t = 100; // clicking size variable

function drawEyeOfSauron() {
  background(0, 0, 0, 3);
  push();
  translate(width / 2, height / 2);
  let noiseMax = 5; // fixed value for spikiness
  let alphaValue = 400;

  
  eyeSize += 0.05;

  // outer shape
  stroke(255, 10, 0, alphaValue);
  noFill();
  beginShape();
  for (let a = 0; a < TWO_PI; a += 0.1) {
    let xoff = map(10 * cos(a + phase), -1, 1, 0, noiseMax);
    let yoff = map(sin(a + phase), -1, 1, 0, noiseMax);
    let r = map(noise(xoff, yoff, zoff), 0, 1, 100, 220) * (eyeSize / 20); // scale based on eyesize
    let x = r * cos(a);
    let y = r * sin(a);
    vertex(x, y);
  }
  endShape(CLOSE);

  // orange glow for the first outer shape
  fill(255, orange, 0, alphaValue * 0.5); // lower transp
  beginShape();
  for (let a = 0; a < TWO_PI; a += 0.1) {
    let xoff = map(8 * cos(a + phase), -1, 1, 0, noiseMax);
    let yoff = map(8 * sin(a + phase), -1, 1, 0, noiseMax);
    let r = map(noise(xoff, yoff, zoff), 0, 1, 0, size_t) * (eyeSize / 20); // scale based on eyesize
    let x = r * cos(a);
    let y = r * sin(a);
    vertex(x, y);
  }
  endShape(CLOSE);

  // second glow
  fill(255, 165, 0, alphaValue * 0.5);
  beginShape();
  for (let a = 0; a < TWO_PI; a += 0.1) {
    let xoff = map(10 * cos(a + phase + 1), -1, 1, 0, noiseMax); // different phase
    let yoff = map(10 * sin(a + phase + 1), -1, 1, 0, noiseMax);
    let r = map(noise(xoff, yoff, zoff), 0, 1, 50, 220) * (eyeSize / 20); // scale based on eyesize
    let x = r * cos(a);
    let y = r * sin(a);
    vertex(x, y);
  }
  endShape(CLOSE);

  // inner pupil black which is a vertical ellipse
  fill(0); // black
  beginShape();
  for (let a = 0; a < TWO_PI; a += 0.1) {
    let xoff = map(5 * cos(a + phase), -1, 1, 0, noiseMax);
    let yoff = map(5 * sin(a + phase), -1, 1, 0, noiseMax);
    let rx = map(noise(xoff, yoff, zoff), 0, 1, 5, 20) * (eyeSize / 20); // scale based on eyesize
    let ry = map(noise(yoff, xoff, zoff), 0, 1, 50, 120) * (eyeSize / 20); // scale based on eyesize
    let x = rx * cos(a);
    let y = ry * sin(a);
    vertex(x, y);
  }
  endShape(CLOSE);

  zoff += 0.008;
  phase += 0.008;
 
  if (eyeOfSauronActive && sauronSound.isPlaying()) {
    let timeRemaining = sauronSound.duration() - sauronSound.currentTime();
    if (timeRemaining < 0.7) { 
      eyeSize += 50;
    }
  }

  pop();
}

And lastly, the code I am most proud, where everything comes together is my sketch code:

This is where, variables, preload and setup is made. You can see detailed list in the code but in summary it takes care of initializing objects, creating buttons, slider as well as separate graphic for textures.

The next section is draw function which has different if conditions for different states. For example, if game has not started are we in instructions or main menu. We also have additional drawings, game state function, and references to previous classes to make everything initialize and work well together. Getting everything work well together was through multiple hours of trial and error but eventually the experience created was pretty fluid with no significant performance or visual bugs.

//Variables

//Time
let startTime; 
let playTime = 0;
let mountOfDoomTime = 0;

//Objects
let playerImg, orcImg, mountOfDoomImg;
let player, orcs, playerHealth, mountOfDoom;

//Audio

let volumeSlider;
let winSound;
let backgroundMusicStarted = false;

//ring
let goldenRing;
let goldenRingSpawned = false;
let collidedWithRing = false;

//sauron
let eyeSize = 15;
let eyeOfSauronActive = false;
let isSauronSoundLowered = false;
let eyeOfSauronDeactivated = false;
let zoff = 0;
let phase = 0;
let noiseMax = 0;
let sauronSoundStarted = false;

//monster
let monsterImg;
let monsterSpawned = false;
let monsterSpeed = 0.3;
let monsterX, monsterY;
let monsterCheck = false;
let monsterSizeMultiplier = 0.2;

//buff
let invincibleUntil = 0;

//state 
let gameWinImg, gameOverImg;
let gameState = 'playing'; 
let gameStarted = false;

//font
let pixelFont;

//for managing 
let restartButton;
let mainmenu;
let currentScreen = 'mainMenu';
let newBackgroundImg;

//Maze Management Misc
let drawGrass = true;

//Preload

function preload() {
  playerImg = loadImage('frodo.png');
  orcImg = loadImage('orc.png');
  mountOfDoomImg = loadImage('volcano.png');
  backgroundMusic = loadSound('lotr.mp3');
  dyingSound = loadSound('dying.mp3');
  gameoverSound = loadSound('gameoversound.mp3');
  winSound = loadSound('win.mp3');//all the sounds
  ringImage = loadImage('ring.png');
  sauronSound = loadSound("sauron.mp3")
  monsterImg = loadImage('monster.gif');
  invisibilitySound = loadSound('invisible.mp3');
  ringSpawnSound = loadSound('ringspawn.mp3');
  monsterSound = loadSound('monster.mp3');
  gameWinImg = loadImage('game_won.png');
  gameOverImg = loadImage('game_over.png');
  pixelFont = loadFont('alagard.ttf');
  mainmenu = loadImage('mainmenu.png')
  newBackgroundImg = loadImage('instructions.png');
  grassTexture = loadImage('grass.jpeg');
  wallTexture = loadImage('wall.jpg');
  
}

//Safari Bug (audio does not autoplay, not a problem on chromium)
function keyPressed() {
  // start background music when a key is pressed
  if (!backgroundMusicStarted) {
    backgroundMusicStarted = true;
    backgroundMusic.play();
  }
}

//Setup

function setup() {
  textFont(pixelFont); //using external font
  frameRate(60);
  startTime = millis(); //for calculating journey time in the end
  let initialVolume = 1;
  createCanvas(650, 650);
  generateLevel(); // ?
  player = new Player(); //initialize player
  playerHealth = 3; //initialize player health
  window.addEventListener('keydown', function (e) {
    if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
      e.preventDefault(); // prevent default arrow key behavior for safari bug (moving screen)
    }
  });
  
  //monster
  monsterX = width / 2;
  monsterY = height / 2;
 
  mountOfDoom = createMountOfDoom();
  
  //vol slider
  volumeSlider = createSlider(0, 1, initialVolume, 0.01);
  volumeSlider.position(180, 1.5);
  volumeSlider.style('width', '100px');
  volumeSlider.style('color', 'black');
  volumeSlider.style('outline', 'none');
  volumeSlider.style('background', '#white');
  volumeSlider.style('outline', 'none');
  volumeSlider.style('opacity', '0.7');
  volumeSlider.style('transition', 'opacity .2s');


  // mouse over effect
  volumeSlider.mouseOver(() => {
    volumeSlider.style('opacity', '1');
  });

  volumeSlider.mouseOut(() => {
    volumeSlider.style('opacity', '0.7');
  });

  // webkit for browsers
  volumeSlider.style('::-webkit-slider-thumb', 'width: 25px; height: 25px; background: #04AA6D; cursor: pointer;');
  
  volumeSlider.style('display', 'none');

  backgroundMusic.loop();
  
  //start button
  let startButton = createButton(''); //empty because i couldnt get button itself have external font, so it realies on empty button and actual clickable text superimposed on it
  let buttonWidth = 140; 
  let buttonHeight = 35; 
  let buttonX = (width - buttonWidth) / 2; // center the button horizontally
  let buttonY = 175; 
  startButton.position(buttonX, buttonY);
  startButton.size(buttonWidth, buttonHeight);
  startButton.style('background-color', 'transparent');
  startButton.style('border', 'none'); // no border
  startButton.style('cursor', 'pointer');

  // start game on click
  startButton.mousePressed(startGame);
  
  // question, instruciton button
  
  questionMarkButton = createButton('?'); 
  questionMarkButton.position(width/2-45, 15); 
  questionMarkButton.style('background-color', 'transparent');
  questionMarkButton.style('border', 'none');
  questionMarkButton.style('color', '#FFFFFF'); // text color
  questionMarkButton.style('font-size', '50px'); // size 
  questionMarkButton.style('background-color', 'black'); //  background color 
  questionMarkButton.style('color', '#FFFFFF');  //question mark color
  questionMarkButton.style('padding', '7px 35px'); // pading
  questionMarkButton.style('border-radius', '5px'); // rounded corners

  // managing mouse over effect
  questionMarkButton.mousePressed(showInstructions);

  questionMarkButton.mouseOver(() => questionMarkButton.style('color', '#FFC109')); // change color on hover
  questionMarkButton.mouseOut(() => questionMarkButton.style('color', '#FFFFFF')); // revert color on mouse not hovering

  // arrow button 
  backButton = createButton('←'); 
  backButton.position(10, 10); 
  backButton.mousePressed(returnToMainMenu);
  backButton.hide(); // hide it initially
  
  //buffer for creating another canvas instance
  grassBuffer = createGraphics(width, height);
  
  // grass texture with reduced transparency
  grassBuffer.tint(255, 100); // a bit transparent for visuals
  grassBuffer.image(grassTexture, 0, 0, width, height);
  
  // buffer for the wall texture
  wallBuffer = createGraphics(width, height);

  // scale the buffer before drawing the image
  wallBuffer.push(); // save
  wallBuffer.scale(2); // scale up
  wallBuffer.image(wallTexture, 0, 0, width * 2, height * 2); 
  wallBuffer.pop(); // restore
  
}

//Draw

function draw() {
  if (!gameStarted) { //for menu and instructions
    if (currentScreen === 'mainMenu') { //main menu
      
      background(mainmenu); //background image
      //text properties
      textSize(38);
      stroke(0);
      strokeWeight(5);
      textAlign(CENTER, CENTER);
      text('Lord of the Maze', width / 2, 150);

      textSize(24); //start
      let startGameText = "Begin Journey!";
      let startGameWidth = textWidth(startGameText);
      let startGameX = width / 2 - startGameWidth / 2;
      let startGameY = 180 - 2; // y position of text
      let startGameHeight = 24; // height of the text

      // mouse hover detection based on text position and size
      if (mouseX >= startGameX && mouseX <= startGameX + startGameWidth && mouseY >= startGameY && mouseY <= startGameY + startGameHeight) {
        fill("#FFC109"); // change color to indicate hover
      } else {
        fill("#FFFFFF"); // defauult color
      }

      textAlign(CENTER, CENTER);
      text(startGameText, width / 2, 180 + 12); // draw begin jorney text
      questionMarkButton.show();

    } else if (currentScreen === 'instructions') {
      background(newBackgroundImg); // show the instructions background
      fill(255); //  text color
      textSize(20); //  text size
      textAlign(CENTER, CENTER);
      text("Oh valiant traveler, embroiled in a quest most dire: \n to traverse the winding labyrinths of Middle-earth and \n consign the accursed One Ring to the molten depths  of Mount Doom. \n Be forewarned, the path is fraught with peril, \n and the all-seeing Eye of Sauron ever seeks to ensnare thee. \n \nEmploy the sacred Arrow Keys \n to navigate the maze's enigmatic corridors. \n Each stride shall bring thee closer to thy destiny or doom. \n Avoid orcs! Find one ring and reach Mount of Doom", width / 2, 130);
      questionMarkButton.hide();
      
    }
  } else {
      if (gameState === 'playing') {
        questionMarkButton.hide();
      // change background color based on whether the Eye of Sauron has deactivated
      if (!eyeOfSauronActive && !eyeOfSauronDeactivated) {
        background(178, 223, 138); // original color before Eye of Sauron appears
      } else if (eyeOfSauronDeactivated) {
        background(210, 180, 140); // new color after Eye of Sauron disappears
      }
        
        if (!eyeOfSauronActive) {
          image(mountOfDoomImg, mountOfDoom.x, mountOfDoom.y, mountOfDoom.size, mountOfDoom.size);
        }
      
      //more drawing
      drawMaze();
      playTime = (millis() - startTime) / 1000;
      drawUI();
      player.move();
      player.display();

      //stoo invisibility buff sound
      if (millis() >= invincibleUntil && invisibilitySound.isPlaying()) {
        invisibilitySound.stop();
      }

      noTint();
      orcs.forEach(orc => {
        orc.move();
        orc.display();
      });

      checkCollisions();

      if (playerReachedMountOfDoom()) {
        if (!collidedWithRing) {
          // spawn monster in the center only if they still havent collected ring
          monsterSpawned = true;
          monsterCheck = true;

        } else {

          gameWin();
          volumeSlider.remove();
        }
      }

    let newMonsterWidth = monsterImg.width * monsterSizeMultiplier;
    let newMonsterHeight = monsterImg.height * monsterSizeMultiplier;

    // draw the monster 
    if (monsterSpawned) {
      if (!monsterSound.isPlaying()) {
        monsterSound.loop(); 
      }

      moveMonsterTowardsFrodo();

      let newMonsterWidth = monsterImg.width * monsterSizeMultiplier;
      let newMonsterHeight = monsterImg.height * monsterSizeMultiplier;
      image(monsterImg, monsterX, monsterY, newMonsterWidth, newMonsterHeight);
      
      //monster touches frodo looses

      if (dist(player.x, player.y, monsterX, monsterY) < player.size) {
        gameOver();
      }
    } else {
      if (monsterSound.isPlaying()) {
        monsterSound.stop();
      }
    }
    
    //golden ring

    if (goldenRingSpawned && gameState === 'playing' && goldenRing != null) {
      drawGoldenRing();
      checkRingCollision();
    }

      if (orcs.length == 0) {
        currentLevel++;
        generateLevel();
      }
      
       

    //golden ring and specific location
        
      if (!goldenRingSpawned && getRow(player.y) === ringSpawnLocation.row && getCol(player.x) === ringSpawnLocation.col) {
        goldenRing = createGoldenRing(width / 2 + 138, height / 2 - 110, 15);
        goldenRingSpawned = true;
        ringSpawnSound.play(); 
      }


    //eye of sauron and managing music
      if (eyeOfSauronActive ) {
          drawGrass = false; // to not draw grass during eye of sauron animation
          drawEyeOfSauron();
          if (!sauronSound.isPlaying() && sauronSoundStarted) {
            eyeOfSauronActive = false;
            sauronSoundStarted = false;
            eyeOfSauronDeactivated = true; 
            backgroundMusic.setVolume(initialVolume);
          }
        } else {
          drawGrass = true; // resume drawing grass when eye of sauron is not active
        }
    //another game over condition 
      if (playerHealth <= 0) {
        gameOver();
      }
        
        //restart button
        
         if (gameState === 'gameOver' || gameState === 'win') {
    fill(255); 
    textAlign(CENTER, CENTER);
    textSize(24); 
    textFont(pixelFont); 
    text("Restart Game", width / 2, 200); // y position to match the button's
     }


    } 
  }
}


//Collisions


function checkCollisions() {
  if (millis() < invincibleUntil) {
    return; // skip collision check if Frodo is invincible
  }

  orcs.forEach(orc => {
    if ( //orc dimensions
      player.x < orc.x + orc.size &&
      player.x + player.size > orc.x &&
      player.y < orc.y + orc.size &&
      player.y + player.size > orc.y
    ) {
      playerHealth--; //substract player health
      player.spawn(); //respawn
      dyingSound.play(); //play death sound
    }
  });
}

//Helper Functions


function getRow(y) { //convert y into row index (25 units for maze)
  return floor(y / 25);
}

function getCol(x) { //convert x into column index (25 units for maze)
  return floor(x / 25);
}

Conclusion and Future Considerations

In the end, I am very happy with how things turned out. Despite numerous problems and many more solutions and trials and errors, I developed a project that stands strong in almost every department we studied – there is a bit of everything. I hope it did at least some level of justice to inspiration source and achieved a design aesthetic that is consistent throughout. The scope of the project, will initially seemed simple, actually turned out to be much more complex when I started working on it, as there are lots of moving elements (literally and figuratively). This does not mean that it’s perfect of course. There are some improvements and suggestions to be made. For example, I could potentially add more monster types and more interactions and hidden surprises. The scale of the maze could be larger as well. Additionally, this is only one part of Hero’s journey. Story could be extended to what happens after reaching mount of doom. This calls for additional level. Moreover, the maze is fixed and static. It would be interesting to try procedural maze generation technique, so it is a unique maze each time game is loaded. On a final note, I hope you enjoy my game and I will definitely expand it in the future.

 

Leave a Reply