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.