Last active
March 9, 2023 13:42
-
-
Save rydemods/b63cc024da0264c4c89c00e4264cd9e0 to your computer and use it in GitHub Desktop.
pacman
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function createGameState() { | |
const ROWS = 20; | |
const COLS = 20; | |
const EMPTY = 0; | |
const PLAYER = 2; | |
const PELLET = 1; | |
const GHOST = 3; | |
const board = []; | |
for (let i = 0; i < ROWS; i++) { | |
const row = []; | |
for (let j = 0; j < COLS; j++) { | |
row.push(EMPTY); | |
} | |
board.push(row); | |
} | |
function addPlayer(playerPos) { | |
board[playerPos.y][playerPos.x] = PLAYER; | |
} | |
function addPellets(pelletPositions) { | |
pelletPositions.forEach((pos) => { | |
board[pos.y][pos.x] = PELLET; | |
}); | |
} | |
function addGhost(ghostPos) { | |
board[ghostPos.y][ghostPos.x] = GHOST; | |
} | |
const player = { | |
position: { x: 9, y: 17 }, | |
direction: "left", | |
}; | |
const pellets = [ | |
{ position: { x: 1, y: 1 } }, | |
{ position: { x: 18, y: 1 } }, | |
{ position: { x: 1, y: 18 } }, | |
{ position: { x: 18, y: 18 } }, | |
]; | |
const ghosts = [ | |
{ position: { x: 9, y: 9 } }, | |
{ position: { x: 10, y: 9 } }, | |
]; | |
addPlayer(player.position); | |
addPellets(pellets.map((p) => p.position)); | |
ghosts.forEach((g) => addGhost(g.position)); | |
const gameState = { | |
board, | |
player, | |
pellets, | |
ghosts, | |
score: 0, | |
gameOver: false, | |
}; | |
return gameState; | |
} | |
class Ghost { | |
constructor( | |
scaledTileSize, mazeArray, pacman, name, level, characterUtil, blinky, | |
) { | |
this.scaledTileSize = scaledTileSize; | |
this.mazeArray = mazeArray; | |
this.pacman = pacman; | |
this.name = name; | |
this.level = level; | |
this.characterUtil = characterUtil; | |
this.blinky = blinky; | |
this.animationTarget = document.getElementById(name); | |
this.reset(); | |
} | |
/** | |
* Rests the character to its default state | |
* @param {Boolean} fullGameReset | |
*/ | |
reset(fullGameReset) { | |
if (fullGameReset) { | |
delete this.defaultSpeed; | |
delete this.cruiseElroy; | |
} | |
this.setDefaultMode(); | |
this.setMovementStats(this.pacman, this.name, this.level); | |
this.setSpriteAnimationStats(); | |
this.setStyleMeasurements(this.scaledTileSize, this.spriteFrames); | |
this.setDefaultPosition(this.scaledTileSize, this.name); | |
this.setSpriteSheet(this.name, this.direction, this.mode); | |
} | |
/** | |
* Sets the default mode and idleMode behavior | |
*/ | |
setDefaultMode() { | |
this.allowCollision = true; | |
this.defaultMode = 'scatter'; | |
this.mode = 'scatter'; | |
if (this.name !== 'blinky') { | |
this.idleMode = 'idle'; | |
} | |
} | |
/** | |
* Sets various properties related to the ghost's movement | |
* @param {Object} pacman - Pacman's speed is used as the base for the ghosts' speeds | |
* @param {('inky'|'blinky'|'pinky'|'clyde')} name - The name of the current ghost | |
*/ | |
setMovementStats(pacman, name, level) { | |
const pacmanSpeed = pacman.velocityPerMs; | |
const levelAdjustment = level / 100; | |
this.slowSpeed = pacmanSpeed * (0.75 + levelAdjustment); | |
this.mediumSpeed = pacmanSpeed * (0.875 + levelAdjustment); | |
this.fastSpeed = pacmanSpeed * (1 + levelAdjustment); | |
if (!this.defaultSpeed) { | |
this.defaultSpeed = this.slowSpeed; | |
} | |
this.scaredSpeed = pacmanSpeed * 0.5; | |
this.transitionSpeed = pacmanSpeed * 0.4; | |
this.eyeSpeed = pacmanSpeed * 2; | |
this.velocityPerMs = this.defaultSpeed; | |
this.moving = false; | |
switch (name) { | |
case 'blinky': | |
this.defaultDirection = this.characterUtil.directions.left; | |
break; | |
case 'pinky': | |
this.defaultDirection = this.characterUtil.directions.down; | |
break; | |
case 'inky': | |
this.defaultDirection = this.characterUtil.directions.up; | |
break; | |
case 'clyde': | |
this.defaultDirection = this.characterUtil.directions.up; | |
break; | |
default: | |
this.defaultDirection = this.characterUtil.directions.left; | |
break; | |
} | |
this.direction = this.defaultDirection; | |
} | |
/** | |
* Sets values pertaining to the ghost's spritesheet animation | |
*/ | |
setSpriteAnimationStats() { | |
this.display = true; | |
this.loopAnimation = true; | |
this.animate = true; | |
this.msBetweenSprites = 250; | |
this.msSinceLastSprite = 0; | |
this.spriteFrames = 2; | |
this.backgroundOffsetPixels = 0; | |
this.animationTarget.style.backgroundPosition = '0px 0px'; | |
} | |
/** | |
* Sets css property values for the ghost | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
* @param {number} spriteFrames - The number of frames in the ghost's spritesheet | |
*/ | |
setStyleMeasurements(scaledTileSize, spriteFrames) { | |
// The ghosts are the size of 2x2 game tiles. | |
this.measurement = scaledTileSize * 2; | |
this.animationTarget.style.height = `${this.measurement}px`; | |
this.animationTarget.style.width = `${this.measurement}px`; | |
const bgSize = this.measurement * spriteFrames; | |
this.animationTarget.style.backgroundSize = `${bgSize}px`; | |
} | |
/** | |
* Sets the default position and direction for the ghosts at the game's start | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
* @param {('inky'|'blinky'|'pinky'|'clyde')} name - The name of the current ghost | |
*/ | |
setDefaultPosition(scaledTileSize, name) { | |
switch (name) { | |
case 'blinky': | |
this.defaultPosition = { | |
top: scaledTileSize * 10.5, | |
left: scaledTileSize * 13, | |
}; | |
break; | |
case 'pinky': | |
this.defaultPosition = { | |
top: scaledTileSize * 13.5, | |
left: scaledTileSize * 13, | |
}; | |
break; | |
case 'inky': | |
this.defaultPosition = { | |
top: scaledTileSize * 13.5, | |
left: scaledTileSize * 11, | |
}; | |
break; | |
case 'clyde': | |
this.defaultPosition = { | |
top: scaledTileSize * 13.5, | |
left: scaledTileSize * 15, | |
}; | |
break; | |
default: | |
this.defaultPosition = { | |
top: 0, | |
left: 0, | |
}; | |
break; | |
} | |
this.position = Object.assign({}, this.defaultPosition); | |
this.oldPosition = Object.assign({}, this.position); | |
this.animationTarget.style.top = `${this.position.top}px`; | |
this.animationTarget.style.left = `${this.position.left}px`; | |
} | |
/** | |
* Chooses a movement Spritesheet depending upon direction | |
* @param {('inky'|'blinky'|'pinky'|'clyde')} name - The name of the current ghost | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
*/ | |
setSpriteSheet(name, direction, mode) { | |
let emotion = ''; | |
if (this.defaultSpeed !== this.slowSpeed) { | |
emotion = (this.defaultSpeed === this.mediumSpeed) | |
? '_annoyed' : '_angry'; | |
} | |
if (mode === 'scared') { | |
this.animationTarget.style.backgroundImage = 'url(app/style/graphics/' | |
+ `spriteSheets/characters/ghosts/scared_${this.scaredColor}.svg)`; | |
} else if (mode === 'eyes') { | |
this.animationTarget.style.backgroundImage = 'url(app/style/graphics/' | |
+ `spriteSheets/characters/ghosts/eyes_${direction}.svg)`; | |
} else { | |
this.animationTarget.style.backgroundImage = 'url(app/style/graphics/' | |
+ `spriteSheets/characters/ghosts/${name}/${name}_${direction}` | |
+ `${emotion}.svg)`; | |
} | |
} | |
/** | |
* Checks to see if the ghost is currently in the 'tunnels' on the outer edges of the maze | |
* @param {({x: number, y: number})} gridPosition - The current x-y position on the 2D Maze Array | |
* @returns {Boolean} | |
*/ | |
isInTunnel(gridPosition) { | |
return ( | |
gridPosition.y === 14 | |
&& (gridPosition.x < 6 || gridPosition.x > 21) | |
); | |
} | |
/** | |
* Checks to see if the ghost is currently in the 'Ghost House' in the center of the maze | |
* @param {({x: number, y: number})} gridPosition - The current x-y position on the 2D Maze Array | |
* @returns {Boolean} | |
*/ | |
isInGhostHouse(gridPosition) { | |
return ( | |
(gridPosition.x > 9 && gridPosition.x < 18) | |
&& (gridPosition.y > 11 && gridPosition.y < 17) | |
); | |
} | |
/** | |
* Checks to see if the tile at the given coordinates of the Maze is an open position | |
* @param {Array} mazeArray - 2D array representing the game board | |
* @param {number} y - The target row | |
* @param {number} x - The target column | |
* @returns {(false | { x: number, y: number})} - x-y pair if the tile is free, false otherwise | |
*/ | |
getTile(mazeArray, y, x) { | |
let tile = false; | |
if (mazeArray[y] && mazeArray[y][x] && mazeArray[y][x] !== 'X') { | |
tile = { | |
x, | |
y, | |
}; | |
} | |
return tile; | |
} | |
/** | |
* Returns a list of all of the possible moves for the ghost to make on the next turn | |
* @param {({x: number, y: number})} gridPosition - The current x-y position on the 2D Maze Array | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {Array} mazeArray - 2D array representing the game board | |
* @returns {object} | |
*/ | |
determinePossibleMoves(gridPosition, direction, mazeArray) { | |
const { x, y } = gridPosition; | |
const possibleMoves = { | |
up: this.getTile(mazeArray, y - 1, x), | |
down: this.getTile(mazeArray, y + 1, x), | |
left: this.getTile(mazeArray, y, x - 1), | |
right: this.getTile(mazeArray, y, x + 1), | |
}; | |
// Ghosts are not allowed to turn around at crossroads | |
possibleMoves[this.characterUtil.getOppositeDirection(direction)] = false; | |
Object.keys(possibleMoves).forEach((tile) => { | |
if (possibleMoves[tile] === false) { | |
delete possibleMoves[tile]; | |
} | |
}); | |
return possibleMoves; | |
} | |
/** | |
* Uses the Pythagorean Theorem to measure the distance between a given postion and Pacman | |
* @param {({x: number, y: number})} position - An x-y position on the 2D Maze Array | |
* @param {({x: number, y: number})} pacman - Pacman's current x-y position on the 2D Maze Array | |
* @returns {number} | |
*/ | |
calculateDistance(position, pacman) { | |
return Math.sqrt( | |
((position.x - pacman.x) ** 2) + ((position.y - pacman.y) ** 2), | |
); | |
} | |
/** | |
* Gets a position a number of spaces in front of Pacman's direction | |
* @param {({x: number, y: number})} pacmanGridPosition | |
* @param {number} spaces | |
*/ | |
getPositionInFrontOfPacman(pacmanGridPosition, spaces) { | |
const target = Object.assign({}, pacmanGridPosition); | |
const pacDirection = this.pacman.direction; | |
const propToChange = (pacDirection === 'up' || pacDirection === 'down') | |
? 'y' : 'x'; | |
const tileOffset = (pacDirection === 'up' || pacDirection === 'left') | |
? (spaces * -1) : spaces; | |
target[propToChange] += tileOffset; | |
return target; | |
} | |
/** | |
* Determines Pinky's target, which is four tiles in front of Pacman's direction | |
* @param {({x: number, y: number})} pacmanGridPosition | |
* @returns {({x: number, y: number})} | |
*/ | |
determinePinkyTarget(pacmanGridPosition) { | |
return this.getPositionInFrontOfPacman( | |
pacmanGridPosition, 4, | |
); | |
} | |
/** | |
* Determines Inky's target, which is a mirror image of Blinky's position | |
* reflected across a point two tiles in front of Pacman's direction. | |
* Example @ app\style\graphics\spriteSheets\references\inky_target.png | |
* @param {({x: number, y: number})} pacmanGridPosition | |
* @returns {({x: number, y: number})} | |
*/ | |
determineInkyTarget(pacmanGridPosition) { | |
const blinkyGridPosition = this.characterUtil.determineGridPosition( | |
this.blinky.position, this.scaledTileSize, | |
); | |
const pivotPoint = this.getPositionInFrontOfPacman( | |
pacmanGridPosition, 2, | |
); | |
return { | |
x: pivotPoint.x + (pivotPoint.x - blinkyGridPosition.x), | |
y: pivotPoint.y + (pivotPoint.y - blinkyGridPosition.y), | |
}; | |
} | |
/** | |
* Clyde targets Pacman when the two are far apart, but retreats to the | |
* lower-left corner when the two are within eight tiles of each other | |
* @param {({x: number, y: number})} gridPosition | |
* @param {({x: number, y: number})} pacmanGridPosition | |
* @returns {({x: number, y: number})} | |
*/ | |
determineClydeTarget(gridPosition, pacmanGridPosition) { | |
const distance = this.calculateDistance(gridPosition, pacmanGridPosition); | |
return (distance > 8) ? pacmanGridPosition : { x: 0, y: 30 }; | |
} | |
/** | |
* Determines the appropriate target for the ghost's AI | |
* @param {('inky'|'blinky'|'pinky'|'clyde')} name - The name of the current ghost | |
* @param {({x: number, y: number})} gridPosition - The current x-y position on the 2D Maze Array | |
* @param {({x: number, y: number})} pacmanGridPosition - x-y position on the 2D Maze Array | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
* @returns {({x: number, y: number})} | |
*/ | |
getTarget(name, gridPosition, pacmanGridPosition, mode) { | |
// Ghosts return to the ghost-house after eaten | |
if (mode === 'eyes') { | |
return { x: 13.5, y: 10 }; | |
} | |
// Ghosts run from Pacman if scared | |
if (mode === 'scared') { | |
return pacmanGridPosition; | |
} | |
// Ghosts seek out corners in Scatter mode | |
if (mode === 'scatter') { | |
switch (name) { | |
case 'blinky': | |
// Blinky will chase Pacman, even in Scatter mode, if he's in Cruise Elroy form | |
return (this.cruiseElroy ? pacmanGridPosition : { x: 27, y: 0 }); | |
case 'pinky': | |
return { x: 0, y: 0 }; | |
case 'inky': | |
return { x: 27, y: 30 }; | |
case 'clyde': | |
return { x: 0, y: 30 }; | |
default: | |
return { x: 0, y: 0 }; | |
} | |
} | |
switch (name) { | |
// Blinky goes after Pacman's position | |
case 'blinky': | |
return pacmanGridPosition; | |
case 'pinky': | |
return this.determinePinkyTarget(pacmanGridPosition); | |
case 'inky': | |
return this.determineInkyTarget(pacmanGridPosition); | |
case 'clyde': | |
return this.determineClydeTarget(gridPosition, pacmanGridPosition); | |
default: | |
// TODO: Other ghosts | |
return pacmanGridPosition; | |
} | |
} | |
/** | |
* Calls the appropriate function to determine the best move depending on the ghost's name | |
* @param {('inky'|'blinky'|'pinky'|'clyde')} name - The name of the current ghost | |
* @param {Object} possibleMoves - All of the moves the ghost could choose to make this turn | |
* @param {({x: number, y: number})} gridPosition - The current x-y position on the 2D Maze Array | |
* @param {({x: number, y: number})} pacmanGridPosition - x-y position on the 2D Maze Array | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
* @returns {('up'|'down'|'left'|'right')} | |
*/ | |
determineBestMove( | |
name, possibleMoves, gridPosition, pacmanGridPosition, mode, | |
) { | |
let bestDistance = (mode === 'scared') ? 0 : Infinity; | |
let bestMove; | |
const target = this.getTarget(name, gridPosition, pacmanGridPosition, mode); | |
Object.keys(possibleMoves).forEach((move) => { | |
const distance = this.calculateDistance( | |
possibleMoves[move], target, | |
); | |
const betterMove = (mode === 'scared') | |
? (distance > bestDistance) | |
: (distance < bestDistance); | |
if (betterMove) { | |
bestDistance = distance; | |
bestMove = move; | |
} | |
}); | |
return bestMove; | |
} | |
/** | |
* Determines the best direction for the ghost to travel in during the current frame | |
* @param {('inky'|'blinky'|'pinky'|'clyde')} name - The name of the current ghost | |
* @param {({x: number, y: number})} gridPosition - The current x-y position on the 2D Maze Array | |
* @param {({x: number, y: number})} pacmanGridPosition - x-y position on the 2D Maze Array | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {Array} mazeArray - 2D array representing the game board | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
* @returns {('up'|'down'|'left'|'right')} | |
*/ | |
determineDirection( | |
name, gridPosition, pacmanGridPosition, direction, mazeArray, mode, | |
) { | |
let newDirection = direction; | |
const possibleMoves = this.determinePossibleMoves( | |
gridPosition, direction, mazeArray, | |
); | |
if (Object.keys(possibleMoves).length === 1) { | |
[newDirection] = Object.keys(possibleMoves); | |
} else if (Object.keys(possibleMoves).length > 1) { | |
newDirection = this.determineBestMove( | |
name, possibleMoves, gridPosition, pacmanGridPosition, mode, | |
); | |
} | |
return newDirection; | |
} | |
/** | |
* Handles movement for idle Ghosts in the Ghost House | |
* @param {*} elapsedMs | |
* @param {*} position | |
* @param {*} velocity | |
* @returns {({ top: number, left: number})} | |
*/ | |
handleIdleMovement(elapsedMs, position, velocity) { | |
const newPosition = Object.assign({}, this.position); | |
if (position.y <= 13.5) { | |
this.direction = this.characterUtil.directions.down; | |
} else if (position.y >= 14.5) { | |
this.direction = this.characterUtil.directions.up; | |
} | |
if (this.idleMode === 'leaving') { | |
if (position.x === 13.5 && (position.y > 10.8 && position.y < 11)) { | |
this.idleMode = undefined; | |
newPosition.top = this.scaledTileSize * 10.5; | |
this.direction = this.characterUtil.directions.left; | |
window.dispatchEvent(new Event('releaseGhost')); | |
} else if (position.x > 13.4 && position.x < 13.6) { | |
newPosition.left = this.scaledTileSize * 13; | |
this.direction = this.characterUtil.directions.up; | |
} else if (position.y > 13.9 && position.y < 14.1) { | |
newPosition.top = this.scaledTileSize * 13.5; | |
this.direction = (position.x < 13.5) | |
? this.characterUtil.directions.right | |
: this.characterUtil.directions.left; | |
} | |
} | |
newPosition[this.characterUtil.getPropertyToChange(this.direction)] | |
+= this.characterUtil.getVelocity(this.direction, velocity) * elapsedMs; | |
return newPosition; | |
} | |
/** | |
* Sets idleMode to 'leaving', allowing the ghost to leave the Ghost House | |
*/ | |
endIdleMode() { | |
this.idleMode = 'leaving'; | |
} | |
/** | |
* Handle the ghost's movement when it is snapped to the x-y grid of the Maze Array | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
* @param {({x: number, y: number})} gridPosition - x-y position during the current frame | |
* @param {number} velocity - The distance the character should travel in a single millisecond | |
* @param {({x: number, y: number})} pacmanGridPosition - x-y position on the 2D Maze Array | |
* @returns {({ top: number, left: number})} | |
*/ | |
handleSnappedMovement(elapsedMs, gridPosition, velocity, pacmanGridPosition) { | |
const newPosition = Object.assign({}, this.position); | |
this.direction = this.determineDirection( | |
this.name, gridPosition, pacmanGridPosition, this.direction, | |
this.mazeArray, this.mode, | |
); | |
newPosition[this.characterUtil.getPropertyToChange(this.direction)] | |
+= this.characterUtil.getVelocity(this.direction, velocity) * elapsedMs; | |
return newPosition; | |
} | |
/** | |
* Determines if an eaten ghost is at the entrance of the Ghost House | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
* @param {({x: number, y: number})} position - x-y position during the current frame | |
* @returns {Boolean} | |
*/ | |
enteringGhostHouse(mode, position) { | |
return ( | |
mode === 'eyes' | |
&& position.y === 11 | |
&& (position.x > 13.4 && position.x < 13.6) | |
); | |
} | |
/** | |
* Determines if an eaten ghost has reached the center of the Ghost House | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
* @param {({x: number, y: number})} position - x-y position during the current frame | |
* @returns {Boolean} | |
*/ | |
enteredGhostHouse(mode, position) { | |
return ( | |
mode === 'eyes' | |
&& position.x === 13.5 | |
&& (position.y > 13.8 && position.y < 14.2) | |
); | |
} | |
/** | |
* Determines if a restored ghost is at the exit of the Ghost House | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
* @param {({x: number, y: number})} position - x-y position during the current frame | |
* @returns {Boolean} | |
*/ | |
leavingGhostHouse(mode, position) { | |
return ( | |
mode !== 'eyes' | |
&& position.x === 13.5 | |
&& (position.y > 10.8 && position.y < 11) | |
); | |
} | |
/** | |
* Handles entering and leaving the Ghost House after a ghost is eaten | |
* @param {({x: number, y: number})} gridPosition - x-y position during the current frame | |
* @returns {({x: number, y: number})} | |
*/ | |
handleGhostHouse(gridPosition) { | |
const gridPositionCopy = Object.assign({}, gridPosition); | |
if (this.enteringGhostHouse(this.mode, gridPosition)) { | |
this.direction = this.characterUtil.directions.down; | |
gridPositionCopy.x = 13.5; | |
this.position = this.characterUtil.snapToGrid( | |
gridPositionCopy, this.direction, this.scaledTileSize, | |
); | |
} | |
if (this.enteredGhostHouse(this.mode, gridPosition)) { | |
this.direction = this.characterUtil.directions.up; | |
gridPositionCopy.y = 14; | |
this.position = this.characterUtil.snapToGrid( | |
gridPositionCopy, this.direction, this.scaledTileSize, | |
); | |
this.mode = this.defaultMode; | |
window.dispatchEvent(new Event('restoreGhost')); | |
} | |
if (this.leavingGhostHouse(this.mode, gridPosition)) { | |
gridPositionCopy.y = 11; | |
this.position = this.characterUtil.snapToGrid( | |
gridPositionCopy, this.direction, this.scaledTileSize, | |
); | |
this.direction = this.characterUtil.directions.left; | |
} | |
return gridPositionCopy; | |
} | |
/** | |
* Handle the ghost's movement when it is inbetween tiles on the x-y grid of the Maze Array | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
* @param {({x: number, y: number})} gridPosition - x-y position during the current frame | |
* @param {number} velocity - The distance the character should travel in a single millisecond | |
* @returns {({ top: number, left: number})} | |
*/ | |
handleUnsnappedMovement(elapsedMs, gridPosition, velocity) { | |
const gridPositionCopy = this.handleGhostHouse(gridPosition); | |
const desired = this.characterUtil.determineNewPositions( | |
this.position, this.direction, velocity, elapsedMs, this.scaledTileSize, | |
); | |
if (this.characterUtil.changingGridPosition( | |
gridPositionCopy, desired.newGridPosition, | |
)) { | |
return this.characterUtil.snapToGrid( | |
gridPositionCopy, this.direction, this.scaledTileSize, | |
); | |
} | |
return desired.newPosition; | |
} | |
/** | |
* Determines the new Ghost position | |
* @param {number} elapsedMs | |
* @returns {({ top: number, left: number})} | |
*/ | |
handleMovement(elapsedMs) { | |
let newPosition; | |
const gridPosition = this.characterUtil.determineGridPosition( | |
this.position, this.scaledTileSize, | |
); | |
const pacmanGridPosition = this.characterUtil.determineGridPosition( | |
this.pacman.position, this.scaledTileSize, | |
); | |
const velocity = this.determineVelocity( | |
gridPosition, this.mode, | |
); | |
if (this.idleMode) { | |
newPosition = this.handleIdleMovement( | |
elapsedMs, gridPosition, velocity, | |
); | |
} else if (JSON.stringify(this.position) === JSON.stringify( | |
this.characterUtil.snapToGrid( | |
gridPosition, this.direction, this.scaledTileSize, | |
), | |
)) { | |
newPosition = this.handleSnappedMovement( | |
elapsedMs, gridPosition, velocity, pacmanGridPosition, | |
); | |
} else { | |
newPosition = this.handleUnsnappedMovement( | |
elapsedMs, gridPosition, velocity, | |
); | |
} | |
newPosition = this.characterUtil.handleWarp( | |
newPosition, this.scaledTileSize, this.mazeArray, | |
); | |
this.checkCollision(gridPosition, pacmanGridPosition); | |
return newPosition; | |
} | |
/** | |
* Changes the defaultMode to chase or scatter, and turns the ghost around | |
* if needed | |
* @param {('chase'|'scatter')} newMode | |
*/ | |
changeMode(newMode) { | |
this.defaultMode = newMode; | |
const gridPosition = this.characterUtil.determineGridPosition( | |
this.position, this.scaledTileSize, | |
); | |
if ((this.mode === 'chase' || this.mode === 'scatter') | |
&& !this.cruiseElroy) { | |
this.mode = newMode; | |
if (!this.isInGhostHouse(gridPosition)) { | |
this.direction = this.characterUtil.getOppositeDirection( | |
this.direction, | |
); | |
} | |
} | |
} | |
/** | |
* Toggles a scared ghost between blue and white, then updates its spritsheet | |
*/ | |
toggleScaredColor() { | |
this.scaredColor = (this.scaredColor === 'blue') | |
? 'white' : 'blue'; | |
this.setSpriteSheet(this.name, this.direction, this.mode); | |
} | |
/** | |
* Sets the ghost's mode to SCARED, turns the ghost around, | |
* and changes spritesheets accordingly | |
*/ | |
becomeScared() { | |
const gridPosition = this.characterUtil.determineGridPosition( | |
this.position, this.scaledTileSize, | |
); | |
if (this.mode !== 'eyes') { | |
if (!this.isInGhostHouse(gridPosition) && this.mode !== 'scared') { | |
this.direction = this.characterUtil.getOppositeDirection( | |
this.direction, | |
); | |
} | |
this.mode = 'scared'; | |
this.scaredColor = 'blue'; | |
this.setSpriteSheet(this.name, this.direction, this.mode); | |
} | |
} | |
/** | |
* Returns the scared ghost to chase/scatter mode and sets its spritesheet | |
*/ | |
endScared() { | |
this.mode = this.defaultMode; | |
this.setSpriteSheet(this.name, this.direction, this.mode); | |
} | |
/** | |
* Speeds up the ghost (used for Blinky as Pacdots are eaten) | |
*/ | |
speedUp() { | |
this.cruiseElroy = true; | |
if (this.defaultSpeed === this.slowSpeed) { | |
this.defaultSpeed = this.mediumSpeed; | |
} else if (this.defaultSpeed === this.mediumSpeed) { | |
this.defaultSpeed = this.fastSpeed; | |
} | |
} | |
/** | |
* Resets defaultSpeed to slow and updates the spritesheet | |
*/ | |
resetDefaultSpeed() { | |
this.defaultSpeed = this.slowSpeed; | |
this.cruiseElroy = false; | |
this.setSpriteSheet(this.name, this.direction, this.mode); | |
} | |
/** | |
* Sets a flag to indicate when the ghost should pause its movement | |
* @param {Boolean} newValue | |
*/ | |
pause(newValue) { | |
this.paused = newValue; | |
} | |
/** | |
* Checks if the ghost contacts Pacman - starts the death sequence if so | |
* @param {({x: number, y: number})} position - An x-y position on the 2D Maze Array | |
* @param {({x: number, y: number})} pacman - Pacman's current x-y position on the 2D Maze Array | |
*/ | |
checkCollision(position, pacman) { | |
if (this.calculateDistance(position, pacman) < 1 | |
&& this.mode !== 'eyes' | |
&& this.allowCollision) { | |
if (this.mode === 'scared') { | |
window.dispatchEvent(new CustomEvent('eatGhost', { | |
detail: { | |
ghost: this, | |
}, | |
})); | |
this.mode = 'eyes'; | |
} else { | |
window.dispatchEvent(new Event('deathSequence')); | |
} | |
} | |
} | |
/** | |
* Determines the appropriate speed for the ghost | |
* @param {({x: number, y: number})} position - An x-y position on the 2D Maze Array | |
* @param {('chase'|'scatter'|'scared'|'eyes')} mode - The character's behavior mode | |
* @returns {number} | |
*/ | |
determineVelocity(position, mode) { | |
if (mode === 'eyes') { | |
return this.eyeSpeed; | |
} | |
if (this.paused) { | |
return 0; | |
} | |
if (this.isInTunnel(position) || this.isInGhostHouse(position)) { | |
return this.transitionSpeed; | |
} | |
if (mode === 'scared') { | |
return this.scaredSpeed; | |
} | |
return this.defaultSpeed; | |
} | |
/** | |
* Updates the css position, hides if there is a stutter, and animates the spritesheet | |
* @param {number} interp - The animation accuracy as a percentage | |
*/ | |
draw(interp) { | |
const newTop = this.characterUtil.calculateNewDrawValue( | |
interp, 'top', this.oldPosition, this.position, | |
); | |
const newLeft = this.characterUtil.calculateNewDrawValue( | |
interp, 'left', this.oldPosition, this.position, | |
); | |
this.animationTarget.style.top = `${newTop}px`; | |
this.animationTarget.style.left = `${newLeft}px`; | |
this.animationTarget.style.visibility = this.display | |
? this.characterUtil.checkForStutter(this.position, this.oldPosition) | |
: 'hidden'; | |
const updatedProperties = this.characterUtil.advanceSpriteSheet(this); | |
this.msSinceLastSprite = updatedProperties.msSinceLastSprite; | |
this.animationTarget = updatedProperties.animationTarget; | |
this.backgroundOffsetPixels = updatedProperties.backgroundOffsetPixels; | |
} | |
/** | |
* Handles movement logic for the ghost | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
*/ | |
update(elapsedMs) { | |
this.oldPosition = Object.assign({}, this.position); | |
if (this.moving) { | |
this.position = this.handleMovement(elapsedMs); | |
this.setSpriteSheet(this.name, this.direction, this.mode); | |
this.msSinceLastSprite += elapsedMs; | |
} | |
} | |
} | |
class Pacman { | |
constructor(scaledTileSize, mazeArray, characterUtil) { | |
this.scaledTileSize = scaledTileSize; | |
this.mazeArray = mazeArray; | |
this.characterUtil = characterUtil; | |
this.animationTarget = document.getElementById('pacman'); | |
this.pacmanArrow = document.getElementById('pacman-arrow'); | |
this.reset(); | |
} | |
/** | |
* Rests the character to its default state | |
*/ | |
reset() { | |
this.setMovementStats(this.scaledTileSize); | |
this.setSpriteAnimationStats(); | |
this.setStyleMeasurements(this.scaledTileSize, this.spriteFrames); | |
this.setDefaultPosition(this.scaledTileSize); | |
this.setSpriteSheet(this.direction); | |
this.pacmanArrow.style.backgroundImage = 'url(app/style/graphics/' | |
+ `spriteSheets/characters/pacman/arrow_${this.direction}.svg)`; | |
} | |
/** | |
* Sets various properties related to Pacman's movement | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
*/ | |
setMovementStats(scaledTileSize) { | |
this.velocityPerMs = this.calculateVelocityPerMs(scaledTileSize); | |
this.desiredDirection = this.characterUtil.directions.left; | |
this.direction = this.characterUtil.directions.left; | |
this.moving = false; | |
} | |
/** | |
* Sets values pertaining to Pacman's spritesheet animation | |
*/ | |
setSpriteAnimationStats() { | |
this.specialAnimation = false; | |
this.display = true; | |
this.animate = true; | |
this.loopAnimation = true; | |
this.msBetweenSprites = 50; | |
this.msSinceLastSprite = 0; | |
this.spriteFrames = 4; | |
this.backgroundOffsetPixels = 0; | |
this.animationTarget.style.backgroundPosition = '0px 0px'; | |
} | |
/** | |
* Sets css property values for Pacman and Pacman's Arrow | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
* @param {number} spriteFrames - The number of frames in Pacman's spritesheet | |
*/ | |
setStyleMeasurements(scaledTileSize, spriteFrames) { | |
this.measurement = scaledTileSize * 2; | |
this.animationTarget.style.height = `${this.measurement}px`; | |
this.animationTarget.style.width = `${this.measurement}px`; | |
this.animationTarget.style.backgroundSize = `${ | |
this.measurement * spriteFrames | |
}px`; | |
this.pacmanArrow.style.height = `${this.measurement * 2}px`; | |
this.pacmanArrow.style.width = `${this.measurement * 2}px`; | |
this.pacmanArrow.style.backgroundSize = `${this.measurement * 2}px`; | |
} | |
/** | |
* Sets the default position and direction for Pacman at the game's start | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
*/ | |
setDefaultPosition(scaledTileSize) { | |
this.defaultPosition = { | |
top: scaledTileSize * 22.5, | |
left: scaledTileSize * 13, | |
}; | |
this.position = Object.assign({}, this.defaultPosition); | |
this.oldPosition = Object.assign({}, this.position); | |
this.animationTarget.style.top = `${this.position.top}px`; | |
this.animationTarget.style.left = `${this.position.left}px`; | |
} | |
/** | |
* Calculates how fast Pacman should move in a millisecond | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
*/ | |
calculateVelocityPerMs(scaledTileSize) { | |
// In the original game, Pacman moved at 11 tiles per second. | |
const velocityPerSecond = scaledTileSize * 11; | |
return velocityPerSecond / 1000; | |
} | |
/** | |
* Chooses a movement Spritesheet depending upon direction | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
*/ | |
setSpriteSheet(direction) { | |
this.animationTarget.style.backgroundImage = 'url(app/style/graphics/' | |
+ `spriteSheets/characters/pacman/pacman_${direction}.svg)`; | |
} | |
prepDeathAnimation() { | |
this.loopAnimation = false; | |
this.msBetweenSprites = 125; | |
this.spriteFrames = 12; | |
this.specialAnimation = true; | |
this.backgroundOffsetPixels = 0; | |
const bgSize = this.measurement * this.spriteFrames; | |
this.animationTarget.style.backgroundSize = `${bgSize}px`; | |
this.animationTarget.style.backgroundImage = 'url(app/style/' | |
+ 'graphics/spriteSheets/characters/pacman/pacman_death.svg)'; | |
this.animationTarget.style.backgroundPosition = '0px 0px'; | |
this.pacmanArrow.style.backgroundImage = ''; | |
} | |
/** | |
* Changes Pacman's desiredDirection, updates the PacmanArrow sprite, and sets moving to true | |
* @param {Event} e - The keydown event to evaluate | |
* @param {Boolean} startMoving - If true, Pacman will move upon key press | |
*/ | |
changeDirection(newDirection, startMoving) { | |
this.desiredDirection = newDirection; | |
this.pacmanArrow.style.backgroundImage = 'url(app/style/graphics/' | |
+ `spriteSheets/characters/pacman/arrow_${this.desiredDirection}.svg)`; | |
if (startMoving) { | |
this.moving = true; | |
} | |
} | |
/** | |
* Updates the position of the leading arrow in front of Pacman | |
* @param {({top: number, left: number})} position - Pacman's position during the current frame | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
*/ | |
updatePacmanArrowPosition(position, scaledTileSize) { | |
this.pacmanArrow.style.top = `${position.top - scaledTileSize}px`; | |
this.pacmanArrow.style.left = `${position.left - scaledTileSize}px`; | |
} | |
/** | |
* Handle Pacman's movement when he is snapped to the x-y grid of the Maze Array | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
* @returns {({ top: number, left: number})} | |
*/ | |
handleSnappedMovement(elapsedMs) { | |
const desired = this.characterUtil.determineNewPositions( | |
this.position, this.desiredDirection, this.velocityPerMs, | |
elapsedMs, this.scaledTileSize, | |
); | |
const alternate = this.characterUtil.determineNewPositions( | |
this.position, this.direction, this.velocityPerMs, | |
elapsedMs, this.scaledTileSize, | |
); | |
if (this.characterUtil.checkForWallCollision( | |
desired.newGridPosition, this.mazeArray, this.desiredDirection, | |
)) { | |
if (this.characterUtil.checkForWallCollision( | |
alternate.newGridPosition, this.mazeArray, this.direction, | |
)) { | |
this.moving = false; | |
return this.position; | |
} | |
return alternate.newPosition; | |
} | |
this.direction = this.desiredDirection; | |
this.setSpriteSheet(this.direction); | |
return desired.newPosition; | |
} | |
/** | |
* Handle Pacman's movement when he is inbetween tiles on the x-y grid of the Maze Array | |
* @param {({x: number, y: number})} gridPosition - x-y position during the current frame | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
* @returns {({ top: number, left: number})} | |
*/ | |
handleUnsnappedMovement(gridPosition, elapsedMs) { | |
const desired = this.characterUtil.determineNewPositions( | |
this.position, this.desiredDirection, this.velocityPerMs, | |
elapsedMs, this.scaledTileSize, | |
); | |
const alternate = this.characterUtil.determineNewPositions( | |
this.position, this.direction, this.velocityPerMs, | |
elapsedMs, this.scaledTileSize, | |
); | |
if (this.characterUtil.turningAround( | |
this.direction, this.desiredDirection, | |
)) { | |
this.direction = this.desiredDirection; | |
this.setSpriteSheet(this.direction); | |
return desired.newPosition; | |
} if (this.characterUtil.changingGridPosition( | |
gridPosition, alternate.newGridPosition, | |
)) { | |
return this.characterUtil.snapToGrid( | |
gridPosition, this.direction, this.scaledTileSize, | |
); | |
} | |
return alternate.newPosition; | |
} | |
/** | |
* Updates the css position, hides if there is a stutter, and animates the spritesheet | |
* @param {number} interp - The animation accuracy as a percentage | |
*/ | |
draw(interp) { | |
const newTop = this.characterUtil.calculateNewDrawValue( | |
interp, 'top', this.oldPosition, this.position, | |
); | |
const newLeft = this.characterUtil.calculateNewDrawValue( | |
interp, 'left', this.oldPosition, this.position, | |
); | |
this.animationTarget.style.top = `${newTop}px`; | |
this.animationTarget.style.left = `${newLeft}px`; | |
this.animationTarget.style.visibility = this.display | |
? this.characterUtil.checkForStutter(this.position, this.oldPosition) | |
: 'hidden'; | |
this.pacmanArrow.style.visibility = this.animationTarget.style.visibility; | |
this.updatePacmanArrowPosition(this.position, this.scaledTileSize); | |
const updatedProperties = this.characterUtil.advanceSpriteSheet(this); | |
this.msSinceLastSprite = updatedProperties.msSinceLastSprite; | |
this.animationTarget = updatedProperties.animationTarget; | |
this.backgroundOffsetPixels = updatedProperties.backgroundOffsetPixels; | |
} | |
/** | |
* Handles movement logic for Pacman | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
*/ | |
update(elapsedMs) { | |
this.oldPosition = Object.assign({}, this.position); | |
if (this.moving) { | |
const gridPosition = this.characterUtil.determineGridPosition( | |
this.position, this.scaledTileSize, | |
); | |
if (JSON.stringify(this.position) === JSON.stringify( | |
this.characterUtil.snapToGrid( | |
gridPosition, this.direction, this.scaledTileSize, | |
), | |
)) { | |
this.position = this.handleSnappedMovement(elapsedMs); | |
} else { | |
this.position = this.handleUnsnappedMovement(gridPosition, elapsedMs); | |
} | |
this.position = this.characterUtil.handleWarp( | |
this.position, this.scaledTileSize, this.mazeArray, | |
); | |
} | |
if (this.moving || this.specialAnimation) { | |
this.msSinceLastSprite += elapsedMs; | |
} | |
} | |
} | |
class GameCoordinator { | |
constructor() { | |
this.gameUi = document.getElementById('game-ui'); | |
this.rowTop = document.getElementById('row-top'); | |
this.mazeDiv = document.getElementById('maze'); | |
this.mazeImg = document.getElementById('maze-img'); | |
this.mazeCover = document.getElementById('maze-cover'); | |
this.pointsDisplay = document.getElementById('points-display'); | |
this.highScoreDisplay = document.getElementById('high-score-display'); | |
this.extraLivesDisplay = document.getElementById('extra-lives'); | |
this.fruitDisplay = document.getElementById('fruit-display'); | |
this.mainMenu = document.getElementById('main-menu-container'); | |
this.gameStartButton = document.getElementById('game-start'); | |
this.pauseButton = document.getElementById('pause-button'); | |
this.soundButton = document.getElementById('sound-button'); | |
this.leftCover = document.getElementById('left-cover'); | |
this.rightCover = document.getElementById('right-cover'); | |
this.pausedText = document.getElementById('paused-text'); | |
this.bottomRow = document.getElementById('bottom-row'); | |
this.movementButtons = document.getElementById('movement-buttons'); | |
this.mazeArray = [ | |
['XXXXXXXXXXXXXXXXXXXXXXXXXXXX'], | |
['XooooooooooooXXooooooooooooX'], | |
['XoXXXXoXXXXXoXXoXXXXXoXXXXoX'], | |
['XOXXXXoXXXXXoXXoXXXXXoXXXXOX'], | |
['XoXXXXoXXXXXoXXoXXXXXoXXXXoX'], | |
['XooooooooooooooooooooooooooX'], | |
['XoXXXXoXXoXXXXXXXXoXXoXXXXoX'], | |
['XoXXXXoXXoXXXXXXXXoXXoXXXXoX'], | |
['XooooooXXooooXXooooXXooooooX'], | |
['XXXXXXoXXXXX XX XXXXXoXXXXXX'], | |
['XXXXXXoXXXXX XX XXXXXoXXXXXX'], | |
['XXXXXXoXX XXoXXXXXX'], | |
['XXXXXXoXX XXXXXXXX XXoXXXXXX'], | |
['XXXXXXoXX X X XXoXXXXXX'], | |
[' o X X o '], | |
['XXXXXXoXX X X XXoXXXXXX'], | |
['XXXXXXoXX XXXXXXXX XXoXXXXXX'], | |
['XXXXXXoXX XXoXXXXXX'], | |
['XXXXXXoXX XXXXXXXX XXoXXXXXX'], | |
['XXXXXXoXX XXXXXXXX XXoXXXXXX'], | |
['XooooooooooooXXooooooooooooX'], | |
['XoXXXXoXXXXXoXXoXXXXXoXXXXoX'], | |
['XoXXXXoXXXXXoXXoXXXXXoXXXXoX'], | |
['XOooXXooooooo oooooooXXooOX'], | |
['XXXoXXoXXoXXXXXXXXoXXoXXoXXX'], | |
['XXXoXXoXXoXXXXXXXXoXXoXXoXXX'], | |
['XooooooXXooooXXooooXXooooooX'], | |
['XoXXXXXXXXXXoXXoXXXXXXXXXXoX'], | |
['XoXXXXXXXXXXoXXoXXXXXXXXXXoX'], | |
['XooooooooooooooooooooooooooX'], | |
['XXXXXXXXXXXXXXXXXXXXXXXXXXXX'], | |
]; | |
this.maxFps = 120; | |
this.tileSize = 8; | |
this.scale = this.determineScale(1); | |
this.scaledTileSize = this.tileSize * this.scale; | |
this.firstGame = true; | |
this.movementKeys = { | |
// WASD | |
87: 'up', | |
83: 'down', | |
65: 'left', | |
68: 'right', | |
// Arrow Keys | |
38: 'up', | |
40: 'down', | |
37: 'left', | |
39: 'right', | |
}; | |
this.fruitPoints = { | |
1: 100, | |
2: 300, | |
3: 500, | |
4: 700, | |
5: 1000, | |
6: 2000, | |
7: 3000, | |
8: 5000, | |
}; | |
this.mazeArray.forEach((row, rowIndex) => { | |
this.mazeArray[rowIndex] = row[0].split(''); | |
}); | |
this.gameStartButton.addEventListener( | |
'click', | |
this.startButtonClick.bind(this), | |
); | |
this.pauseButton.addEventListener('click', this.handlePauseKey.bind(this)); | |
this.soundButton.addEventListener( | |
'click', | |
this.soundButtonClick.bind(this), | |
); | |
const head = document.getElementsByTagName('head')[0]; | |
const link = document.createElement('link'); | |
link.rel = 'stylesheet'; | |
link.href = 'build/app.css'; | |
link.onload = this.preloadAssets.bind(this); | |
head.appendChild(link); | |
} | |
/** | |
* Recursive method which determines the largest possible scale the game's graphics can use | |
* @param {Number} scale | |
*/ | |
determineScale(scale) { | |
const availableScreenHeight = Math.min( | |
document.documentElement.clientHeight, | |
window.innerHeight || 0, | |
); | |
const availableScreenWidth = Math.min( | |
document.documentElement.clientWidth, | |
window.innerWidth || 0, | |
); | |
const scaledTileSize = this.tileSize * scale; | |
// The original Pac-Man game leaves 5 tiles of height (3 above, 2 below) surrounding the | |
// maze for the UI. See app\style\graphics\spriteSheets\references\mazeGridSystemReference.png | |
// for reference. | |
const mazeTileHeight = this.mazeArray.length + 5; | |
const mazeTileWidth = this.mazeArray[0][0].split('').length; | |
if ( | |
scaledTileSize * mazeTileHeight < availableScreenHeight | |
&& scaledTileSize * mazeTileWidth < availableScreenWidth | |
) { | |
return this.determineScale(scale + 1); | |
} | |
return scale - 1; | |
} | |
/** | |
* Reveals the game underneath the loading covers and starts gameplay | |
*/ | |
startButtonClick() { | |
this.leftCover.style.left = '-50%'; | |
this.rightCover.style.right = '-50%'; | |
this.mainMenu.style.opacity = 0; | |
this.gameStartButton.disabled = true; | |
setTimeout(() => { | |
this.mainMenu.style.visibility = 'hidden'; | |
}, 1000); | |
this.reset(); | |
if (this.firstGame) { | |
this.firstGame = false; | |
this.init(); | |
} | |
this.startGameplay(true); | |
} | |
/** | |
* Toggles the master volume for the soundManager, and saves the preference to storage | |
*/ | |
soundButtonClick() { | |
const newVolume = this.soundManager.masterVolume === 1 ? 0 : 1; | |
this.soundManager.setMasterVolume(newVolume); | |
localStorage.setItem('volumePreference', newVolume); | |
this.setSoundButtonIcon(newVolume); | |
} | |
/** | |
* Sets the icon for the sound button | |
*/ | |
setSoundButtonIcon(newVolume) { | |
this.soundButton.innerHTML = newVolume === 0 ? 'volume_off' : 'volume_up'; | |
} | |
/** | |
* Displays an error message in the event assets are unable to download | |
*/ | |
displayErrorMessage() { | |
const loadingContainer = document.getElementById('loading-container'); | |
const errorMessage = document.getElementById('error-message'); | |
loadingContainer.style.opacity = 0; | |
setTimeout(() => { | |
loadingContainer.remove(); | |
errorMessage.style.opacity = 1; | |
errorMessage.style.visibility = 'visible'; | |
}, 1500); | |
} | |
/** | |
* Load all assets into a hidden Div to pre-load them into memory. | |
* There is probably a better way to read all of these file names. | |
*/ | |
preloadAssets() { | |
return new Promise((resolve) => { | |
const loadingContainer = document.getElementById('loading-container'); | |
const loadingPacman = document.getElementById('loading-pacman'); | |
const loadingDotMask = document.getElementById('loading-dot-mask'); | |
const imgBase = 'app/style/graphics/spriteSheets/'; | |
const imgSources = [ | |
// Pacman | |
`${imgBase}characters/pacman/arrow_down.svg`, | |
`${imgBase}characters/pacman/arrow_left.svg`, | |
`${imgBase}characters/pacman/arrow_right.svg`, | |
`${imgBase}characters/pacman/arrow_up.svg`, | |
`${imgBase}characters/pacman/pacman_death.svg`, | |
`${imgBase}characters/pacman/pacman_error.svg`, | |
`${imgBase}characters/pacman/pacman_down.svg`, | |
`${imgBase}characters/pacman/pacman_left.svg`, | |
`${imgBase}characters/pacman/pacman_right.svg`, | |
`${imgBase}characters/pacman/pacman_up.svg`, | |
// Blinky | |
`${imgBase}characters/ghosts/blinky/blinky_down_angry.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_down_annoyed.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_down.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_left_angry.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_left_annoyed.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_left.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_right_angry.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_right_annoyed.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_right.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_up_angry.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_up_annoyed.svg`, | |
`${imgBase}characters/ghosts/blinky/blinky_up.svg`, | |
// Clyde | |
`${imgBase}characters/ghosts/clyde/clyde_down.svg`, | |
`${imgBase}characters/ghosts/clyde/clyde_left.svg`, | |
`${imgBase}characters/ghosts/clyde/clyde_right.svg`, | |
`${imgBase}characters/ghosts/clyde/clyde_up.svg`, | |
// Inky | |
`${imgBase}characters/ghosts/inky/inky_down.svg`, | |
`${imgBase}characters/ghosts/inky/inky_left.svg`, | |
`${imgBase}characters/ghosts/inky/inky_right.svg`, | |
`${imgBase}characters/ghosts/inky/inky_up.svg`, | |
// Pinky | |
`${imgBase}characters/ghosts/pinky/pinky_down.svg`, | |
`${imgBase}characters/ghosts/pinky/pinky_left.svg`, | |
`${imgBase}characters/ghosts/pinky/pinky_right.svg`, | |
`${imgBase}characters/ghosts/pinky/pinky_up.svg`, | |
// Ghosts Common | |
`${imgBase}characters/ghosts/eyes_down.svg`, | |
`${imgBase}characters/ghosts/eyes_left.svg`, | |
`${imgBase}characters/ghosts/eyes_right.svg`, | |
`${imgBase}characters/ghosts/eyes_up.svg`, | |
`${imgBase}characters/ghosts/scared_blue.svg`, | |
`${imgBase}characters/ghosts/scared_white.svg`, | |
// Dots | |
`${imgBase}pickups/pacdot.svg`, | |
`${imgBase}pickups/powerPellet.svg`, | |
// Fruit | |
`${imgBase}pickups/apple.svg`, | |
`${imgBase}pickups/bell.svg`, | |
`${imgBase}pickups/cherry.svg`, | |
`${imgBase}pickups/galaxian.svg`, | |
`${imgBase}pickups/key.svg`, | |
`${imgBase}pickups/melon.svg`, | |
`${imgBase}pickups/orange.svg`, | |
`${imgBase}pickups/strawberry.svg`, | |
// Text | |
`${imgBase}text/ready.svg`, | |
// Points | |
`${imgBase}text/100.svg`, | |
`${imgBase}text/200.svg`, | |
`${imgBase}text/300.svg`, | |
`${imgBase}text/400.svg`, | |
`${imgBase}text/500.svg`, | |
`${imgBase}text/700.svg`, | |
`${imgBase}text/800.svg`, | |
`${imgBase}text/1000.svg`, | |
`${imgBase}text/1600.svg`, | |
`${imgBase}text/2000.svg`, | |
`${imgBase}text/3000.svg`, | |
`${imgBase}text/5000.svg`, | |
// Maze | |
`${imgBase}maze/maze_blue.svg`, | |
// Misc | |
'app/style/graphics/extra_life.png', | |
]; | |
const audioBase = 'app/style/audio/'; | |
const audioSources = [ | |
`${audioBase}game_start.mp3`, | |
`${audioBase}pause.mp3`, | |
`${audioBase}pause_beat.mp3`, | |
`${audioBase}siren_1.mp3`, | |
`${audioBase}siren_2.mp3`, | |
`${audioBase}siren_3.mp3`, | |
`${audioBase}power_up.mp3`, | |
`${audioBase}extra_life.mp3`, | |
`${audioBase}eyes.mp3`, | |
`${audioBase}eat_ghost.mp3`, | |
`${audioBase}death.mp3`, | |
`${audioBase}fruit.mp3`, | |
`${audioBase}dot_1.mp3`, | |
`${audioBase}dot_2.mp3`, | |
]; | |
const totalSources = imgSources.length + audioSources.length; | |
this.remainingSources = totalSources; | |
loadingPacman.style.left = '0'; | |
loadingDotMask.style.width = '0'; | |
Promise.all([ | |
this.createElements(imgSources, 'img', totalSources, this), | |
this.createElements(audioSources, 'audio', totalSources, this), | |
]) | |
.then(() => { | |
loadingContainer.style.opacity = 0; | |
resolve(); | |
setTimeout(() => { | |
loadingContainer.remove(); | |
this.mainMenu.style.opacity = 1; | |
this.mainMenu.style.visibility = 'visible'; | |
}, 1500); | |
}) | |
.catch(this.displayErrorMessage); | |
}); | |
} | |
/** | |
* Iterates through a list of sources and updates the loading bar as the assets load in | |
* @param {String[]} sources | |
* @param {('img'|'audio')} type | |
* @param {Number} totalSources | |
* @param {Object} gameCoord | |
* @returns {Promise} | |
*/ | |
createElements(sources, type, totalSources, gameCoord) { | |
const loadingContainer = document.getElementById('loading-container'); | |
const preloadDiv = document.getElementById('preload-div'); | |
const loadingPacman = document.getElementById('loading-pacman'); | |
const containerWidth = loadingContainer.scrollWidth | |
- loadingPacman.scrollWidth; | |
const loadingDotMask = document.getElementById('loading-dot-mask'); | |
const gameCoordRef = gameCoord; | |
return new Promise((resolve, reject) => { | |
let loadedSources = 0; | |
sources.forEach((source) => { | |
const element = type === 'img' ? new Image() : new Audio(); | |
preloadDiv.appendChild(element); | |
const elementReady = () => { | |
gameCoordRef.remainingSources -= 1; | |
loadedSources += 1; | |
const percent = 1 - gameCoordRef.remainingSources / totalSources; | |
loadingPacman.style.left = `${percent * containerWidth}px`; | |
loadingDotMask.style.width = loadingPacman.style.left; | |
if (loadedSources === sources.length) { | |
resolve(); | |
} | |
}; | |
if (type === 'img') { | |
element.onload = elementReady; | |
element.onerror = reject; | |
} else { | |
element.addEventListener('canplaythrough', elementReady); | |
element.onerror = reject; | |
} | |
element.src = source; | |
if (type === 'audio') { | |
element.load(); | |
} | |
}); | |
}); | |
} | |
/** | |
* Resets gameCoordinator values to their default states | |
*/ | |
reset() { | |
this.activeTimers = []; | |
this.points = 0; | |
this.level = 1; | |
this.lives = 2; | |
this.extraLifeGiven = false; | |
this.remainingDots = 0; | |
this.allowKeyPresses = true; | |
this.allowPacmanMovement = false; | |
this.allowPause = false; | |
this.cutscene = true; | |
this.highScore = localStorage.getItem('highScore'); | |
if (this.firstGame) { | |
setInterval(() => { | |
this.collisionDetectionLoop(); | |
}, 500); | |
this.pacman = new Pacman( | |
this.scaledTileSize, | |
this.mazeArray, | |
new CharacterUtil(), | |
); | |
this.blinky = new Ghost( | |
this.scaledTileSize, | |
this.mazeArray, | |
this.pacman, | |
'blinky', | |
this.level, | |
new CharacterUtil(), | |
); | |
this.pinky = new Ghost( | |
this.scaledTileSize, | |
this.mazeArray, | |
this.pacman, | |
'pinky', | |
this.level, | |
new CharacterUtil(), | |
); | |
this.inky = new Ghost( | |
this.scaledTileSize, | |
this.mazeArray, | |
this.pacman, | |
'inky', | |
this.level, | |
new CharacterUtil(), | |
this.blinky, | |
); | |
this.clyde = new Ghost( | |
this.scaledTileSize, | |
this.mazeArray, | |
this.pacman, | |
'clyde', | |
this.level, | |
new CharacterUtil(), | |
); | |
this.fruit = new Pickup( | |
'fruit', | |
this.scaledTileSize, | |
13.5, | |
17, | |
this.pacman, | |
this.mazeDiv, | |
100, | |
); | |
} | |
this.entityList = [ | |
this.pacman, | |
this.blinky, | |
this.pinky, | |
this.inky, | |
this.clyde, | |
this.fruit, | |
]; | |
this.ghosts = [this.blinky, this.pinky, this.inky, this.clyde]; | |
this.scaredGhosts = []; | |
this.eyeGhosts = 0; | |
if (this.firstGame) { | |
this.drawMaze(this.mazeArray, this.entityList); | |
this.soundManager = new SoundManager(); | |
this.setUiDimensions(); | |
} else { | |
this.pacman.reset(); | |
this.ghosts.forEach((ghost) => { | |
ghost.reset(true); | |
}); | |
this.pickups.forEach((pickup) => { | |
if (pickup.type !== 'fruit') { | |
this.remainingDots += 1; | |
pickup.reset(); | |
this.entityList.push(pickup); | |
} | |
}); | |
} | |
this.pointsDisplay.innerHTML = '00'; | |
this.highScoreDisplay.innerHTML = this.highScore || '00'; | |
this.clearDisplay(this.fruitDisplay); | |
const volumePreference = parseInt( | |
localStorage.getItem('volumePreference') || 1, | |
10, | |
); | |
this.setSoundButtonIcon(volumePreference); | |
this.soundManager.setMasterVolume(volumePreference); | |
} | |
/** | |
* Calls necessary setup functions to start the game | |
*/ | |
init() { | |
this.registerEventListeners(); | |
this.gameEngine = new GameEngine(this.maxFps, this.entityList); | |
this.gameEngine.start(); | |
} | |
/** | |
* Adds HTML elements to draw on the webpage by iterating through the 2D maze array | |
* @param {Array} mazeArray - 2D array representing the game board | |
* @param {Array} entityList - List of entities to be used throughout the game | |
*/ | |
drawMaze(mazeArray, entityList) { | |
this.pickups = [this.fruit]; | |
this.mazeDiv.style.height = `${this.scaledTileSize * 31}px`; | |
this.mazeDiv.style.width = `${this.scaledTileSize * 28}px`; | |
this.gameUi.style.width = `${this.scaledTileSize * 28}px`; | |
this.bottomRow.style.minHeight = `${this.scaledTileSize * 2}px`; | |
this.dotContainer = document.getElementById('dot-container'); | |
mazeArray.forEach((row, rowIndex) => { | |
row.forEach((block, columnIndex) => { | |
if (block === 'o' || block === 'O') { | |
const type = block === 'o' ? 'pacdot' : 'powerPellet'; | |
const points = block === 'o' ? 10 : 50; | |
const dot = new Pickup( | |
type, | |
this.scaledTileSize, | |
columnIndex, | |
rowIndex, | |
this.pacman, | |
this.dotContainer, | |
points, | |
); | |
entityList.push(dot); | |
this.pickups.push(dot); | |
this.remainingDots += 1; | |
} | |
}); | |
}); | |
} | |
setUiDimensions() { | |
this.gameUi.style.fontSize = `${this.scaledTileSize}px`; | |
this.rowTop.style.marginBottom = `${this.scaledTileSize}px`; | |
} | |
/** | |
* Loop which periodically checks which pickups are nearby Pacman. | |
* Pickups which are far away will not be considered for collision detection. | |
*/ | |
collisionDetectionLoop() { | |
if (this.pacman.position) { | |
const maxDistance = this.pacman.velocityPerMs * 750; | |
const pacmanCenter = { | |
x: this.pacman.position.left + this.scaledTileSize, | |
y: this.pacman.position.top + this.scaledTileSize, | |
}; | |
// Set this flag to TRUE to see how two-phase collision detection works! | |
const debugging = false; | |
this.pickups.forEach((pickup) => { | |
pickup.checkPacmanProximity(maxDistance, pacmanCenter, debugging); | |
}); | |
} | |
} | |
/** | |
* Displays "Ready!" and allows Pacman to move after a breif delay | |
* @param {Boolean} initialStart - Special condition for the game's beginning | |
*/ | |
startGameplay(initialStart) { | |
if (initialStart) { | |
this.soundManager.play('game_start'); | |
} | |
this.scaredGhosts = []; | |
this.eyeGhosts = 0; | |
this.allowPacmanMovement = false; | |
const left = this.scaledTileSize * 11; | |
const top = this.scaledTileSize * 16.5; | |
const duration = initialStart ? 4500 : 2000; | |
const width = this.scaledTileSize * 6; | |
const height = this.scaledTileSize * 2; | |
this.displayText({ left, top }, 'ready', duration, width, height); | |
this.updateExtraLivesDisplay(); | |
new Timer(() => { | |
this.allowPause = true; | |
this.cutscene = false; | |
this.soundManager.setCutscene(this.cutscene); | |
this.soundManager.setAmbience(this.determineSiren(this.remainingDots)); | |
this.allowPacmanMovement = true; | |
this.pacman.moving = true; | |
this.ghosts.forEach((ghost) => { | |
const ghostRef = ghost; | |
ghostRef.moving = true; | |
}); | |
this.ghostCycle('scatter'); | |
this.idleGhosts = [this.pinky, this.inky, this.clyde]; | |
this.releaseGhost(); | |
}, duration); | |
} | |
/** | |
* Clears out all children nodes from a given display element | |
* @param {String} display | |
*/ | |
clearDisplay(display) { | |
while (display.firstChild) { | |
display.removeChild(display.firstChild); | |
} | |
} | |
/** | |
* Displays extra life images equal to the number of remaining lives | |
*/ | |
updateExtraLivesDisplay() { | |
this.clearDisplay(this.extraLivesDisplay); | |
for (let i = 0; i < this.lives; i += 1) { | |
const extraLifePic = document.createElement('img'); | |
extraLifePic.setAttribute('src', 'app/style/graphics/extra_life.svg'); | |
extraLifePic.style.height = `${this.scaledTileSize * 2}px`; | |
this.extraLivesDisplay.appendChild(extraLifePic); | |
} | |
} | |
/** | |
* Displays a rolling log of the seven most-recently eaten fruit | |
* @param {String} rawImageSource | |
*/ | |
updateFruitDisplay(rawImageSource) { | |
const parsedSource = rawImageSource.slice( | |
rawImageSource.indexOf('(') + 1, | |
rawImageSource.indexOf(')'), | |
); | |
if (this.fruitDisplay.children.length === 7) { | |
this.fruitDisplay.removeChild(this.fruitDisplay.firstChild); | |
} | |
const fruitPic = document.createElement('img'); | |
fruitPic.setAttribute('src', parsedSource); | |
fruitPic.style.height = `${this.scaledTileSize * 2}px`; | |
this.fruitDisplay.appendChild(fruitPic); | |
} | |
/** | |
* Cycles the ghosts between 'chase' and 'scatter' mode | |
* @param {('chase'|'scatter')} mode | |
*/ | |
ghostCycle(mode) { | |
const delay = mode === 'scatter' ? 7000 : 20000; | |
const nextMode = mode === 'scatter' ? 'chase' : 'scatter'; | |
this.ghostCycleTimer = new Timer(() => { | |
this.ghosts.forEach((ghost) => { | |
ghost.changeMode(nextMode); | |
}); | |
this.ghostCycle(nextMode); | |
}, delay); | |
} | |
/** | |
* Releases a ghost from the Ghost House after a delay | |
*/ | |
releaseGhost() { | |
if (this.idleGhosts.length > 0) { | |
const delay = Math.max((8 - (this.level - 1) * 4) * 1000, 0); | |
this.endIdleTimer = new Timer(() => { | |
this.idleGhosts[0].endIdleMode(); | |
this.idleGhosts.shift(); | |
}, delay); | |
} | |
} | |
/** | |
* Register listeners for various game sequences | |
*/ | |
registerEventListeners() { | |
window.addEventListener('keydown', this.handleKeyDown.bind(this)); | |
window.addEventListener('awardPoints', this.awardPoints.bind(this)); | |
window.addEventListener('deathSequence', this.deathSequence.bind(this)); | |
window.addEventListener('dotEaten', this.dotEaten.bind(this)); | |
window.addEventListener('powerUp', this.powerUp.bind(this)); | |
window.addEventListener('eatGhost', this.eatGhost.bind(this)); | |
window.addEventListener('restoreGhost', this.restoreGhost.bind(this)); | |
window.addEventListener('addTimer', this.addTimer.bind(this)); | |
window.addEventListener('removeTimer', this.removeTimer.bind(this)); | |
window.addEventListener('releaseGhost', this.releaseGhost.bind(this)); | |
const directions = ['up', 'down', 'left', 'right']; | |
directions.forEach((direction) => { | |
document | |
.getElementById(`button-${direction}`) | |
.addEventListener('touchstart', () => { | |
this.changeDirection(direction); | |
}); | |
}); | |
} | |
/** | |
* Calls Pacman's changeDirection event if certain conditions are met | |
* @param {({'up'|'down'|'left'|'right'})} direction | |
*/ | |
changeDirection(direction) { | |
if (this.allowKeyPresses && this.gameEngine.running) { | |
this.pacman.changeDirection(direction, this.allowPacmanMovement); | |
} | |
} | |
/** | |
* Calls various class functions depending upon the pressed key | |
* @param {Event} e - The keydown event to evaluate | |
*/ | |
handleKeyDown(e) { | |
if (e.keyCode === 27) { | |
// ESC key | |
this.handlePauseKey(); | |
} else if (e.keyCode === 81) { | |
// Q | |
this.soundButtonClick(); | |
} else if (this.movementKeys[e.keyCode]) { | |
this.changeDirection(this.movementKeys[e.keyCode]); | |
} | |
} | |
/** | |
* Handle behavior for the pause key | |
*/ | |
handlePauseKey() { | |
if (this.allowPause) { | |
this.allowPause = false; | |
setTimeout(() => { | |
if (!this.cutscene) { | |
this.allowPause = true; | |
} | |
}, 500); | |
this.gameEngine.changePausedState(this.gameEngine.running); | |
this.soundManager.play('pause'); | |
if (this.gameEngine.started) { | |
this.soundManager.resumeAmbience(); | |
this.gameUi.style.filter = 'unset'; | |
this.movementButtons.style.filter = 'unset'; | |
this.pausedText.style.visibility = 'hidden'; | |
this.pauseButton.innerHTML = 'pause'; | |
this.activeTimers.forEach((timer) => { | |
timer.resume(); | |
}); | |
} else { | |
this.soundManager.stopAmbience(); | |
this.soundManager.setAmbience('pause_beat', true); | |
this.gameUi.style.filter = 'blur(5px)'; | |
this.movementButtons.style.filter = 'blur(5px)'; | |
this.pausedText.style.visibility = 'visible'; | |
this.pauseButton.innerHTML = 'play_arrow'; | |
this.activeTimers.forEach((timer) => { | |
timer.pause(); | |
}); | |
} | |
} | |
} | |
/** | |
* Adds points to the player's total | |
* @param {({ detail: { points: Number }})} e - Contains a quantity of points to add | |
*/ | |
awardPoints(e) { | |
this.points += e.detail.points; | |
this.pointsDisplay.innerText = this.points; | |
if (this.points > (this.highScore || 0)) { | |
this.highScore = this.points; | |
this.highScoreDisplay.innerText = this.points; | |
localStorage.setItem('highScore', this.highScore); | |
} | |
if (this.points >= 10000 && !this.extraLifeGiven) { | |
this.extraLifeGiven = true; | |
this.soundManager.play('extra_life'); | |
this.lives += 1; | |
this.updateExtraLivesDisplay(); | |
} | |
if (e.detail.type === 'fruit') { | |
const left = e.detail.points >= 1000 | |
? this.scaledTileSize * 12.5 | |
: this.scaledTileSize * 13; | |
const top = this.scaledTileSize * 16.5; | |
const width = e.detail.points >= 1000 | |
? this.scaledTileSize * 3 | |
: this.scaledTileSize * 2; | |
const height = this.scaledTileSize * 2; | |
this.displayText({ left, top }, e.detail.points, 2000, width, height); | |
this.soundManager.play('fruit'); | |
this.updateFruitDisplay( | |
this.fruit.determineImage('fruit', e.detail.points), | |
); | |
} | |
} | |
/** | |
* Animates Pacman's death, subtracts a life, and resets character positions if | |
* the player has remaining lives. | |
*/ | |
deathSequence() { | |
this.allowPause = false; | |
this.cutscene = true; | |
this.soundManager.setCutscene(this.cutscene); | |
this.soundManager.stopAmbience(); | |
this.removeTimer({ detail: { timer: this.fruitTimer } }); | |
this.removeTimer({ detail: { timer: this.ghostCycleTimer } }); | |
this.removeTimer({ detail: { timer: this.endIdleTimer } }); | |
this.removeTimer({ detail: { timer: this.ghostFlashTimer } }); | |
this.allowKeyPresses = false; | |
this.pacman.moving = false; | |
this.ghosts.forEach((ghost) => { | |
const ghostRef = ghost; | |
ghostRef.moving = false; | |
}); | |
new Timer(() => { | |
this.ghosts.forEach((ghost) => { | |
const ghostRef = ghost; | |
ghostRef.display = false; | |
}); | |
this.pacman.prepDeathAnimation(); | |
this.soundManager.play('death'); | |
if (this.lives > 0) { | |
this.lives -= 1; | |
new Timer(() => { | |
this.mazeCover.style.visibility = 'visible'; | |
new Timer(() => { | |
this.allowKeyPresses = true; | |
this.mazeCover.style.visibility = 'hidden'; | |
this.pacman.reset(); | |
this.ghosts.forEach((ghost) => { | |
ghost.reset(); | |
}); | |
this.fruit.hideFruit(); | |
this.startGameplay(); | |
}, 500); | |
}, 2250); | |
} else { | |
this.gameOver(); | |
} | |
}, 750); | |
} | |
/** | |
* Displays GAME OVER text and displays the menu so players can play again | |
*/ | |
gameOver() { | |
localStorage.setItem('highScore', this.highScore); | |
new Timer(() => { | |
this.displayText( | |
{ | |
left: this.scaledTileSize * 9, | |
top: this.scaledTileSize * 16.5, | |
}, | |
'game_over', | |
4000, | |
this.scaledTileSize * 10, | |
this.scaledTileSize * 2, | |
); | |
this.fruit.hideFruit(); | |
new Timer(() => { | |
this.leftCover.style.left = '0'; | |
this.rightCover.style.right = '0'; | |
setTimeout(() => { | |
this.mainMenu.style.opacity = 1; | |
this.gameStartButton.disabled = false; | |
this.mainMenu.style.visibility = 'visible'; | |
}, 1000); | |
}, 2500); | |
}, 2250); | |
} | |
/** | |
* Handle events related to the number of remaining dots | |
*/ | |
dotEaten() { | |
this.remainingDots -= 1; | |
this.soundManager.playDotSound(); | |
if (this.remainingDots === 174 || this.remainingDots === 74) { | |
this.createFruit(); | |
} | |
if (this.remainingDots === 40 || this.remainingDots === 20) { | |
this.speedUpBlinky(); | |
} | |
if (this.remainingDots === 0) { | |
this.advanceLevel(); | |
} | |
} | |
/** | |
* Creates a bonus fruit for ten seconds | |
*/ | |
createFruit() { | |
this.removeTimer({ detail: { timer: this.fruitTimer } }); | |
this.fruit.showFruit(this.fruitPoints[this.level] || 5000); | |
this.fruitTimer = new Timer(() => { | |
this.fruit.hideFruit(); | |
}, 10000); | |
} | |
/** | |
* Speeds up Blinky and raises the background noise pitch | |
*/ | |
speedUpBlinky() { | |
this.blinky.speedUp(); | |
if (this.scaredGhosts.length === 0 && this.eyeGhosts === 0) { | |
this.soundManager.setAmbience(this.determineSiren(this.remainingDots)); | |
} | |
} | |
/** | |
* Determines the correct siren ambience | |
* @param {Number} remainingDots | |
* @returns {String} | |
*/ | |
determineSiren(remainingDots) { | |
let sirenNum; | |
if (remainingDots > 40) { | |
sirenNum = 1; | |
} else if (remainingDots > 20) { | |
sirenNum = 2; | |
} else { | |
sirenNum = 3; | |
} | |
return `siren_${sirenNum}`; | |
} | |
/** | |
* Resets the gameboard and prepares the next level | |
*/ | |
advanceLevel() { | |
this.allowPause = false; | |
this.cutscene = true; | |
this.soundManager.setCutscene(this.cutscene); | |
this.allowKeyPresses = false; | |
this.soundManager.stopAmbience(); | |
this.entityList.forEach((entity) => { | |
const entityRef = entity; | |
entityRef.moving = false; | |
}); | |
this.removeTimer({ detail: { timer: this.fruitTimer } }); | |
this.removeTimer({ detail: { timer: this.ghostCycleTimer } }); | |
this.removeTimer({ detail: { timer: this.endIdleTimer } }); | |
this.removeTimer({ detail: { timer: this.ghostFlashTimer } }); | |
const imgBase = 'app/style//graphics/spriteSheets/maze/'; | |
new Timer(() => { | |
this.ghosts.forEach((ghost) => { | |
const ghostRef = ghost; | |
ghostRef.display = false; | |
}); | |
this.mazeImg.src = `${imgBase}maze_white.svg`; | |
new Timer(() => { | |
this.mazeImg.src = `${imgBase}maze_blue.svg`; | |
new Timer(() => { | |
this.mazeImg.src = `${imgBase}maze_white.svg`; | |
new Timer(() => { | |
this.mazeImg.src = `${imgBase}maze_blue.svg`; | |
new Timer(() => { | |
this.mazeImg.src = `${imgBase}maze_white.svg`; | |
new Timer(() => { | |
this.mazeImg.src = `${imgBase}maze_blue.svg`; | |
new Timer(() => { | |
this.mazeCover.style.visibility = 'visible'; | |
new Timer(() => { | |
this.mazeCover.style.visibility = 'hidden'; | |
this.level += 1; | |
this.allowKeyPresses = true; | |
this.entityList.forEach((entity) => { | |
const entityRef = entity; | |
if (entityRef.level) { | |
entityRef.level = this.level; | |
} | |
entityRef.reset(); | |
if (entityRef instanceof Ghost) { | |
entityRef.resetDefaultSpeed(); | |
} | |
if ( | |
entityRef instanceof Pickup | |
&& entityRef.type !== 'fruit' | |
) { | |
this.remainingDots += 1; | |
} | |
}); | |
this.startGameplay(); | |
}, 500); | |
}, 250); | |
}, 250); | |
}, 250); | |
}, 250); | |
}, 250); | |
}, 250); | |
}, 2000); | |
} | |
/** | |
* Flashes ghosts blue and white to indicate the end of the powerup | |
* @param {Number} flashes - Total number of elapsed flashes | |
* @param {Number} maxFlashes - Total flashes to show | |
*/ | |
flashGhosts(flashes, maxFlashes) { | |
if (flashes === maxFlashes) { | |
this.scaredGhosts.forEach((ghost) => { | |
ghost.endScared(); | |
}); | |
this.scaredGhosts = []; | |
if (this.eyeGhosts === 0) { | |
this.soundManager.setAmbience(this.determineSiren(this.remainingDots)); | |
} | |
} else if (this.scaredGhosts.length > 0) { | |
this.scaredGhosts.forEach((ghost) => { | |
ghost.toggleScaredColor(); | |
}); | |
this.ghostFlashTimer = new Timer(() => { | |
this.flashGhosts(flashes + 1, maxFlashes); | |
}, 250); | |
} | |
} | |
/** | |
* Upon eating a power pellet, sets the ghosts to 'scared' mode | |
*/ | |
powerUp() { | |
if (this.remainingDots !== 0) { | |
this.soundManager.setAmbience('power_up'); | |
} | |
this.removeTimer({ detail: { timer: this.ghostFlashTimer } }); | |
this.ghostCombo = 0; | |
this.scaredGhosts = []; | |
this.ghosts.forEach((ghost) => { | |
if (ghost.mode !== 'eyes') { | |
this.scaredGhosts.push(ghost); | |
} | |
}); | |
this.scaredGhosts.forEach((ghost) => { | |
ghost.becomeScared(); | |
}); | |
const powerDuration = Math.max((7 - this.level) * 1000, 0); | |
this.ghostFlashTimer = new Timer(() => { | |
this.flashGhosts(0, 9); | |
}, powerDuration); | |
} | |
/** | |
* Determines the quantity of points to give based on the current combo | |
*/ | |
determineComboPoints() { | |
return 100 * (2 ** this.ghostCombo); | |
} | |
/** | |
* Upon eating a ghost, award points and temporarily pause movement | |
* @param {CustomEvent} e - Contains a target ghost object | |
*/ | |
eatGhost(e) { | |
const pauseDuration = 1000; | |
const { position, measurement } = e.detail.ghost; | |
this.pauseTimer({ detail: { timer: this.ghostFlashTimer } }); | |
this.pauseTimer({ detail: { timer: this.ghostCycleTimer } }); | |
this.pauseTimer({ detail: { timer: this.fruitTimer } }); | |
this.soundManager.play('eat_ghost'); | |
this.scaredGhosts = this.scaredGhosts.filter( | |
ghost => ghost.name !== e.detail.ghost.name, | |
); | |
this.eyeGhosts += 1; | |
this.ghostCombo += 1; | |
const comboPoints = this.determineComboPoints(); | |
window.dispatchEvent( | |
new CustomEvent('awardPoints', { | |
detail: { | |
points: comboPoints, | |
}, | |
}), | |
); | |
this.displayText(position, comboPoints, pauseDuration, measurement); | |
this.allowPacmanMovement = false; | |
this.pacman.display = false; | |
this.pacman.moving = false; | |
e.detail.ghost.display = false; | |
e.detail.ghost.moving = false; | |
this.ghosts.forEach((ghost) => { | |
const ghostRef = ghost; | |
ghostRef.animate = false; | |
ghostRef.pause(true); | |
ghostRef.allowCollision = false; | |
}); | |
new Timer(() => { | |
this.soundManager.setAmbience('eyes'); | |
this.resumeTimer({ detail: { timer: this.ghostFlashTimer } }); | |
this.resumeTimer({ detail: { timer: this.ghostCycleTimer } }); | |
this.resumeTimer({ detail: { timer: this.fruitTimer } }); | |
this.allowPacmanMovement = true; | |
this.pacman.display = true; | |
this.pacman.moving = true; | |
e.detail.ghost.display = true; | |
e.detail.ghost.moving = true; | |
this.ghosts.forEach((ghost) => { | |
const ghostRef = ghost; | |
ghostRef.animate = true; | |
ghostRef.pause(false); | |
ghostRef.allowCollision = true; | |
}); | |
}, pauseDuration); | |
} | |
/** | |
* Decrements the count of "eye" ghosts and updates the ambience | |
*/ | |
restoreGhost() { | |
this.eyeGhosts -= 1; | |
if (this.eyeGhosts === 0) { | |
const sound = this.scaredGhosts.length > 0 | |
? 'power_up' | |
: this.determineSiren(this.remainingDots); | |
this.soundManager.setAmbience(sound); | |
} | |
} | |
/** | |
* Creates a temporary div to display points on screen | |
* @param {({ left: number, top: number })} position - CSS coordinates to display the points at | |
* @param {Number} amount - Amount of points to display | |
* @param {Number} duration - Milliseconds to display the points before disappearing | |
* @param {Number} width - Image width in pixels | |
* @param {Number} height - Image height in pixels | |
*/ | |
displayText(position, amount, duration, width, height) { | |
const pointsDiv = document.createElement('div'); | |
pointsDiv.style.position = 'absolute'; | |
pointsDiv.style.backgroundSize = `${width}px`; | |
pointsDiv.style.backgroundImage = 'url(app/style/graphics/' | |
+ `spriteSheets/text/${amount}.svg`; | |
pointsDiv.style.width = `${width}px`; | |
pointsDiv.style.height = `${height || width}px`; | |
pointsDiv.style.top = `${position.top}px`; | |
pointsDiv.style.left = `${position.left}px`; | |
pointsDiv.style.zIndex = 2; | |
this.mazeDiv.appendChild(pointsDiv); | |
new Timer(() => { | |
this.mazeDiv.removeChild(pointsDiv); | |
}, duration); | |
} | |
/** | |
* Pushes a Timer to the activeTimers array | |
* @param {({ detail: { timer: Object }})} e | |
*/ | |
addTimer(e) { | |
this.activeTimers.push(e.detail.timer); | |
} | |
/** | |
* Checks if a Timer with a matching ID exists | |
* @param {({ detail: { timer: Object }})} e | |
* @returns {Boolean} | |
*/ | |
timerExists(e) { | |
return !!(e.detail.timer || {}).timerId; | |
} | |
/** | |
* Pauses a timer | |
* @param {({ detail: { timer: Object }})} e | |
*/ | |
pauseTimer(e) { | |
if (this.timerExists(e)) { | |
e.detail.timer.pause(true); | |
} | |
} | |
/** | |
* Resumes a timer | |
* @param {({ detail: { timer: Object }})} e | |
*/ | |
resumeTimer(e) { | |
if (this.timerExists(e)) { | |
e.detail.timer.resume(true); | |
} | |
} | |
/** | |
* Removes a Timer from activeTimers | |
* @param {({ detail: { timer: Object }})} e | |
*/ | |
removeTimer(e) { | |
if (this.timerExists(e)) { | |
window.clearTimeout(e.detail.timer.timerId); | |
this.activeTimers = this.activeTimers.filter( | |
timer => timer.timerId !== e.detail.timer.timerId, | |
); | |
} | |
} | |
} | |
class GameEngine { | |
constructor(maxFps, entityList) { | |
this.fpsDisplay = document.getElementById('fps-display'); | |
this.elapsedMs = 0; | |
this.lastFrameTimeMs = 0; | |
this.entityList = entityList; | |
this.maxFps = maxFps; | |
this.timestep = 1000 / this.maxFps; | |
this.fps = this.maxFps; | |
this.framesThisSecond = 0; | |
this.lastFpsUpdate = 0; | |
this.frameId = 0; | |
this.running = false; | |
this.started = false; | |
} | |
/** | |
* Toggles the paused/running status of the game | |
* @param {Boolean} running - Whether the game is currently in motion | |
*/ | |
changePausedState(running) { | |
if (running) { | |
this.stop(); | |
} else { | |
this.start(); | |
} | |
} | |
/** | |
* Updates the on-screen FPS counter once per second | |
* @param {number} timestamp - The amount of MS which has passed since starting the game engine | |
*/ | |
updateFpsDisplay(timestamp) { | |
if (timestamp > this.lastFpsUpdate + 1000) { | |
this.fps = (this.framesThisSecond + this.fps) / 2; | |
this.lastFpsUpdate = timestamp; | |
this.framesThisSecond = 0; | |
} | |
this.framesThisSecond += 1; | |
this.fpsDisplay.textContent = `${Math.round(this.fps)} FPS`; | |
} | |
/** | |
* Calls the draw function for every member of the entityList | |
* @param {number} interp - The animation accuracy as a percentage | |
* @param {Array} entityList - List of entities to be used throughout the game | |
*/ | |
draw(interp, entityList) { | |
entityList.forEach((entity) => { | |
if (typeof entity.draw === 'function') { | |
entity.draw(interp); | |
} | |
}); | |
} | |
/** | |
* Calls the update function for every member of the entityList | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
* @param {Array} entityList - List of entities to be used throughout the game | |
*/ | |
update(elapsedMs, entityList) { | |
entityList.forEach((entity) => { | |
if (typeof entity.update === 'function') { | |
entity.update(elapsedMs); | |
} | |
}); | |
} | |
/** | |
* In the event that a ton of unsimulated frames pile up, discard all of these frames | |
* to prevent crashing the game | |
*/ | |
panic() { | |
this.elapsedMs = 0; | |
} | |
/** | |
* Draws an initial frame, resets a few tracking variables related to animation, and calls | |
* the mainLoop function to start the engine | |
*/ | |
start() { | |
if (!this.started) { | |
this.started = true; | |
this.frameId = requestAnimationFrame((firstTimestamp) => { | |
this.draw(1, []); | |
this.running = true; | |
this.lastFrameTimeMs = firstTimestamp; | |
this.lastFpsUpdate = firstTimestamp; | |
this.framesThisSecond = 0; | |
this.frameId = requestAnimationFrame((timestamp) => { | |
this.mainLoop(timestamp); | |
}); | |
}); | |
} | |
} | |
/** | |
* Stops the engine and cancels the current animation frame | |
*/ | |
stop() { | |
this.running = false; | |
this.started = false; | |
cancelAnimationFrame(this.frameId); | |
} | |
/** | |
* The loop which will process all necessary frames to update the game's entities | |
* prior to animating them | |
*/ | |
processFrames() { | |
let numUpdateSteps = 0; | |
while (this.elapsedMs >= this.timestep) { | |
this.update(this.timestep, this.entityList); | |
this.elapsedMs -= this.timestep; | |
numUpdateSteps += 1; | |
if (numUpdateSteps >= this.maxFps) { | |
this.panic(); | |
break; | |
} | |
} | |
} | |
/** | |
* A single cycle of the engine which checks to see if enough time has passed, and, if so, | |
* will kick off the loops to update and draw the game's entities. | |
* @param {number} timestamp - The amount of MS which has passed since starting the game engine | |
*/ | |
engineCycle(timestamp) { | |
if (timestamp < this.lastFrameTimeMs + (1000 / this.maxFps)) { | |
this.frameId = requestAnimationFrame((nextTimestamp) => { | |
this.mainLoop(nextTimestamp); | |
}); | |
return; | |
} | |
this.elapsedMs += timestamp - this.lastFrameTimeMs; | |
this.lastFrameTimeMs = timestamp; | |
this.updateFpsDisplay(timestamp); | |
this.processFrames(); | |
this.draw(this.elapsedMs / this.timestep, this.entityList); | |
this.frameId = requestAnimationFrame((nextTimestamp) => { | |
this.mainLoop(nextTimestamp); | |
}); | |
} | |
/** | |
* The endless loop which will kick off engine cycles so long as the game is running | |
* @param {number} timestamp - The amount of MS which has passed since starting the game engine | |
*/ | |
mainLoop(timestamp) { | |
this.engineCycle(timestamp); | |
} | |
} | |
class Pickup { | |
constructor(type, scaledTileSize, column, row, pacman, mazeDiv, points) { | |
this.type = type; | |
this.pacman = pacman; | |
this.mazeDiv = mazeDiv; | |
this.points = points; | |
this.nearPacman = false; | |
this.fruitImages = { | |
100: 'cherry', | |
300: 'strawberry', | |
500: 'orange', | |
700: 'apple', | |
1000: 'melon', | |
2000: 'galaxian', | |
3000: 'bell', | |
5000: 'key', | |
}; | |
this.setStyleMeasurements(type, scaledTileSize, column, row, points); | |
} | |
/** | |
* Resets the pickup's visibility | |
*/ | |
reset() { | |
this.animationTarget.style.visibility = (this.type === 'fruit') | |
? 'hidden' : 'visible'; | |
} | |
/** | |
* Sets various style measurements for the pickup depending on its type | |
* @param {('pacdot'|'powerPellet'|'fruit')} type - The classification of pickup | |
* @param {number} scaledTileSize | |
* @param {number} column | |
* @param {number} row | |
* @param {number} points | |
*/ | |
setStyleMeasurements(type, scaledTileSize, column, row, points) { | |
if (type === 'pacdot') { | |
this.size = scaledTileSize * 0.25; | |
this.x = (column * scaledTileSize) + ((scaledTileSize / 8) * 3); | |
this.y = (row * scaledTileSize) + ((scaledTileSize / 8) * 3); | |
} else if (type === 'powerPellet') { | |
this.size = scaledTileSize; | |
this.x = (column * scaledTileSize); | |
this.y = (row * scaledTileSize); | |
} else { | |
this.size = scaledTileSize * 2; | |
this.x = (column * scaledTileSize) - (scaledTileSize * 0.5); | |
this.y = (row * scaledTileSize) - (scaledTileSize * 0.5); | |
} | |
this.center = { | |
x: column * scaledTileSize, | |
y: row * scaledTileSize, | |
}; | |
this.animationTarget = document.createElement('div'); | |
this.animationTarget.style.position = 'absolute'; | |
this.animationTarget.style.backgroundSize = `${this.size}px`; | |
this.animationTarget.style.backgroundImage = this.determineImage( | |
type, points, | |
); | |
this.animationTarget.style.height = `${this.size}px`; | |
this.animationTarget.style.width = `${this.size}px`; | |
this.animationTarget.style.top = `${this.y}px`; | |
this.animationTarget.style.left = `${this.x}px`; | |
this.mazeDiv.appendChild(this.animationTarget); | |
if (type === 'powerPellet') { | |
this.animationTarget.classList.add('power-pellet'); | |
} | |
this.reset(); | |
} | |
/** | |
* Determines the Pickup image based on type and point value | |
* @param {('pacdot'|'powerPellet'|'fruit')} type - The classification of pickup | |
* @param {Number} points | |
* @returns {String} | |
*/ | |
determineImage(type, points) { | |
let image = ''; | |
if (type === 'fruit') { | |
image = this.fruitImages[points] || 'cherry'; | |
} else { | |
image = type; | |
} | |
return `url(app/style/graphics/spriteSheets/pickups/${image}.svg)`; | |
} | |
/** | |
* Shows a bonus fruit, resetting its point value and image | |
* @param {number} points | |
*/ | |
showFruit(points) { | |
this.points = points; | |
this.animationTarget.style.backgroundImage = this.determineImage( | |
this.type, points, | |
); | |
this.animationTarget.style.visibility = 'visible'; | |
} | |
/** | |
* Makes the fruit invisible (happens if Pacman was too slow) | |
*/ | |
hideFruit() { | |
this.animationTarget.style.visibility = 'hidden'; | |
} | |
/** | |
* Returns true if the Pickup is touching a bounding box at Pacman's center | |
* @param {({ x: number, y: number, size: number})} pickup | |
* @param {({ x: number, y: number, size: number})} originalPacman | |
*/ | |
checkForCollision(pickup, originalPacman) { | |
const pacman = Object.assign({}, originalPacman); | |
pacman.x += (pacman.size * 0.25); | |
pacman.y += (pacman.size * 0.25); | |
pacman.size /= 2; | |
return (pickup.x < pacman.x + pacman.size | |
&& pickup.x + pickup.size > pacman.x | |
&& pickup.y < pacman.y + pacman.size | |
&& pickup.y + pickup.size > pacman.y); | |
} | |
/** | |
* Checks to see if the pickup is close enough to Pacman to be considered for collision detection | |
* @param {number} maxDistance - The maximum distance Pacman can travel per cycle | |
* @param {({ x:number, y:number })} pacmanCenter - The center of Pacman's hitbox | |
* @param {Boolean} debugging - Flag to change the appearance of pickups for testing | |
*/ | |
checkPacmanProximity(maxDistance, pacmanCenter, debugging) { | |
if (this.animationTarget.style.visibility !== 'hidden') { | |
const distance = Math.sqrt( | |
((this.center.x - pacmanCenter.x) ** 2) | |
+ ((this.center.y - pacmanCenter.y) ** 2), | |
); | |
this.nearPacman = (distance <= maxDistance); | |
if (debugging) { | |
this.animationTarget.style.background = this.nearPacman | |
? 'lime' : 'red'; | |
} | |
} | |
} | |
/** | |
* Checks if the pickup is visible and close to Pacman | |
* @returns {Boolean} | |
*/ | |
shouldCheckForCollision() { | |
return this.animationTarget.style.visibility !== 'hidden' | |
&& this.nearPacman; | |
} | |
/** | |
* If the Pickup is still visible, it checks to see if it is colliding with Pacman. | |
* It will turn itself invisible and cease collision-detection after the first | |
* collision with Pacman. | |
*/ | |
update() { | |
if (this.shouldCheckForCollision()) { | |
if (this.checkForCollision( | |
{ | |
x: this.x, | |
y: this.y, | |
size: this.size, | |
}, { | |
x: this.pacman.position.left, | |
y: this.pacman.position.top, | |
size: this.pacman.measurement, | |
}, | |
)) { | |
this.animationTarget.style.visibility = 'hidden'; | |
window.dispatchEvent(new CustomEvent('awardPoints', { | |
detail: { | |
points: this.points, | |
type: this.type, | |
}, | |
})); | |
if (this.type === 'pacdot') { | |
window.dispatchEvent(new Event('dotEaten')); | |
} else if (this.type === 'powerPellet') { | |
window.dispatchEvent(new Event('dotEaten')); | |
window.dispatchEvent(new Event('powerUp')); | |
} | |
} | |
} | |
} | |
} | |
class CharacterUtil { | |
constructor() { | |
this.directions = { | |
up: 'up', | |
down: 'down', | |
left: 'left', | |
right: 'right', | |
}; | |
} | |
/** | |
* Check if a given character has moved more than five in-game tiles during a frame. | |
* If so, we want to temporarily hide the object to avoid 'animation stutter'. | |
* @param {({top: number, left: number})} position - Position during the current frame | |
* @param {({top: number, left: number})} oldPosition - Position during the previous frame | |
* @returns {('hidden'|'visible')} - The new 'visibility' css property value for the character. | |
*/ | |
checkForStutter(position, oldPosition) { | |
let stutter = false; | |
const threshold = 5; | |
if (position && oldPosition) { | |
if (Math.abs(position.top - oldPosition.top) > threshold | |
|| Math.abs(position.left - oldPosition.left) > threshold) { | |
stutter = true; | |
} | |
} | |
return stutter ? 'hidden' : 'visible'; | |
} | |
/** | |
* Check which CSS property needs to be changed given the character's current direction | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @returns {('top'|'left')} | |
*/ | |
getPropertyToChange(direction) { | |
switch (direction) { | |
case this.directions.up: | |
case this.directions.down: | |
return 'top'; | |
default: | |
return 'left'; | |
} | |
} | |
/** | |
* Calculate the velocity for the character's next frame. | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {number} velocityPerMs - The distance to travel in a single millisecond | |
* @returns {number} - Moving down or right is positive, while up or left is negative. | |
*/ | |
getVelocity(direction, velocityPerMs) { | |
switch (direction) { | |
case this.directions.up: | |
case this.directions.left: | |
return velocityPerMs * -1; | |
default: | |
return velocityPerMs; | |
} | |
} | |
/** | |
* Determine the next value which will be used to draw the character's position on screen | |
* @param {number} interp - The percentage of the desired timestamp between frames | |
* @param {('top'|'left')} prop - The css property to be changed | |
* @param {({top: number, left: number})} oldPosition - Position during the previous frame | |
* @param {({top: number, left: number})} position - Position during the current frame | |
* @returns {number} - New value for css positioning | |
*/ | |
calculateNewDrawValue(interp, prop, oldPosition, position) { | |
return oldPosition[prop] + (position[prop] - oldPosition[prop]) * interp; | |
} | |
/** | |
* Convert the character's css position to a row-column on the maze array | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
* @returns {({x: number, y: number})} | |
*/ | |
determineGridPosition(position, scaledTileSize) { | |
return { | |
x: (position.left / scaledTileSize) + 0.5, | |
y: (position.top / scaledTileSize) + 0.5, | |
}; | |
} | |
/** | |
* Check to see if a character's disired direction results in turning around | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {('up'|'down'|'left'|'right')} desiredDirection - Character's desired orientation | |
* @returns {boolean} | |
*/ | |
turningAround(direction, desiredDirection) { | |
return desiredDirection === this.getOppositeDirection(direction); | |
} | |
/** | |
* Calculate the opposite of a given direction | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @returns {('up'|'down'|'left'|'right')} | |
*/ | |
getOppositeDirection(direction) { | |
switch (direction) { | |
case this.directions.up: | |
return this.directions.down; | |
case this.directions.down: | |
return this.directions.up; | |
case this.directions.left: | |
return this.directions.right; | |
default: | |
return this.directions.left; | |
} | |
} | |
/** | |
* Calculate the proper rounding function to assist with collision detection | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @returns {Function} | |
*/ | |
determineRoundingFunction(direction) { | |
switch (direction) { | |
case this.directions.up: | |
case this.directions.left: | |
return Math.floor; | |
default: | |
return Math.ceil; | |
} | |
} | |
/** | |
* Check to see if the character's next frame results in moving to a new tile on the maze array | |
* @param {({x: number, y: number})} oldPosition - Position during the previous frame | |
* @param {({x: number, y: number})} position - Position during the current frame | |
* @returns {boolean} | |
*/ | |
changingGridPosition(oldPosition, position) { | |
return ( | |
Math.floor(oldPosition.x) !== Math.floor(position.x) | |
|| Math.floor(oldPosition.y) !== Math.floor(position.y) | |
); | |
} | |
/** | |
* Check to see if the character is attempting to run into a wall of the maze | |
* @param {({x: number, y: number})} desiredNewGridPosition - Character's target tile | |
* @param {Array} mazeArray - The 2D array representing the game's maze | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @returns {boolean} | |
*/ | |
checkForWallCollision(desiredNewGridPosition, mazeArray, direction) { | |
const roundingFunction = this.determineRoundingFunction( | |
direction, this.directions, | |
); | |
const desiredX = roundingFunction(desiredNewGridPosition.x); | |
const desiredY = roundingFunction(desiredNewGridPosition.y); | |
let newGridValue; | |
if (Array.isArray(mazeArray[desiredY])) { | |
newGridValue = mazeArray[desiredY][desiredX]; | |
} | |
return (newGridValue === 'X'); | |
} | |
/** | |
* Returns an object containing the new position and grid position based upon a direction | |
* @param {({top: number, left: number})} position - css position during the current frame | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {number} velocityPerMs - The distance to travel in a single millisecond | |
* @param {number} elapsedMs - The amount of MS that have passed since the last update | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
* @returns {object} | |
*/ | |
determineNewPositions( | |
position, direction, velocityPerMs, elapsedMs, scaledTileSize, | |
) { | |
const newPosition = Object.assign({}, position); | |
newPosition[this.getPropertyToChange(direction)] | |
+= this.getVelocity(direction, velocityPerMs) * elapsedMs; | |
const newGridPosition = this.determineGridPosition( | |
newPosition, scaledTileSize, | |
); | |
return { | |
newPosition, | |
newGridPosition, | |
}; | |
} | |
/** | |
* Calculates the css position when snapping the character to the x-y grid | |
* @param {({x: number, y: number})} position - The character's position during the current frame | |
* @param {('up'|'down'|'left'|'right')} direction - The character's current travel orientation | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
* @returns {({top: number, left: number})} | |
*/ | |
snapToGrid(position, direction, scaledTileSize) { | |
const newPosition = Object.assign({}, position); | |
const roundingFunction = this.determineRoundingFunction( | |
direction, this.directions, | |
); | |
switch (direction) { | |
case this.directions.up: | |
case this.directions.down: | |
newPosition.y = roundingFunction(newPosition.y); | |
break; | |
default: | |
newPosition.x = roundingFunction(newPosition.x); | |
break; | |
} | |
return { | |
top: (newPosition.y - 0.5) * scaledTileSize, | |
left: (newPosition.x - 0.5) * scaledTileSize, | |
}; | |
} | |
/** | |
* Returns a modified position if the character needs to warp | |
* @param {({top: number, left: number})} position - css position during the current frame | |
* @param {({x: number, y: number})} gridPosition - x-y position during the current frame | |
* @param {number} scaledTileSize - The dimensions of a single tile | |
* @returns {({top: number, left: number})} | |
*/ | |
handleWarp(position, scaledTileSize, mazeArray) { | |
const newPosition = Object.assign({}, position); | |
const gridPosition = this.determineGridPosition(position, scaledTileSize); | |
if (gridPosition.x < -0.75) { | |
newPosition.left = (scaledTileSize * (mazeArray[0].length - 0.75)); | |
} else if (gridPosition.x > (mazeArray[0].length - 0.25)) { | |
newPosition.left = (scaledTileSize * -1.25); | |
} | |
return newPosition; | |
} | |
/** | |
* Advances spritesheet by one frame if needed | |
* @param {Object} character - The character which needs to be animated | |
*/ | |
advanceSpriteSheet(character) { | |
const { | |
msSinceLastSprite, | |
animationTarget, | |
backgroundOffsetPixels, | |
} = character; | |
const updatedProperties = { | |
msSinceLastSprite, | |
animationTarget, | |
backgroundOffsetPixels, | |
}; | |
const ready = (character.msSinceLastSprite > character.msBetweenSprites) | |
&& character.animate; | |
if (ready) { | |
updatedProperties.msSinceLastSprite = 0; | |
if (character.backgroundOffsetPixels | |
< (character.measurement * (character.spriteFrames - 1)) | |
) { | |
updatedProperties.backgroundOffsetPixels += character.measurement; | |
} else if (character.loopAnimation) { | |
updatedProperties.backgroundOffsetPixels = 0; | |
} | |
const style = `-${updatedProperties.backgroundOffsetPixels}px 0px`; | |
updatedProperties.animationTarget.style.backgroundPosition = style; | |
} | |
return updatedProperties; | |
} | |
} | |
class SoundManager { | |
constructor() { | |
this.baseUrl = 'app/style/audio/'; | |
this.fileFormat = 'mp3'; | |
this.masterVolume = 1; | |
this.paused = false; | |
this.cutscene = true; | |
const AudioContext = window.AudioContext || window.webkitAudioContext; | |
this.ambience = new AudioContext(); | |
} | |
/** | |
* Sets the cutscene flag to determine if players should be able to resume ambience | |
* @param {Boolean} newValue | |
*/ | |
setCutscene(newValue) { | |
this.cutscene = newValue; | |
} | |
/** | |
* Sets the master volume for all sounds and stops/resumes ambience | |
* @param {(0|1)} newVolume | |
*/ | |
setMasterVolume(newVolume) { | |
this.masterVolume = newVolume; | |
if (this.soundEffect) { | |
this.soundEffect.volume = this.masterVolume; | |
} | |
if (this.dotPlayer) { | |
this.dotPlayer.volume = this.masterVolume; | |
} | |
if (this.masterVolume === 0) { | |
this.stopAmbience(); | |
} else { | |
this.resumeAmbience(this.paused); | |
} | |
} | |
/** | |
* Plays a single sound effect | |
* @param {String} sound | |
*/ | |
play(sound) { | |
this.soundEffect = new Audio(`${this.baseUrl}${sound}.${this.fileFormat}`); | |
this.soundEffect.volume = this.masterVolume; | |
this.soundEffect.play(); | |
} | |
/** | |
* Special method for eating dots. The dots should alternate between two | |
* sound effects, but not too quickly. | |
*/ | |
playDotSound() { | |
this.queuedDotSound = true; | |
if (!this.dotPlayer) { | |
this.queuedDotSound = false; | |
this.dotSound = (this.dotSound === 1) ? 2 : 1; | |
this.dotPlayer = new Audio( | |
`${this.baseUrl}dot_${this.dotSound}.${this.fileFormat}`, | |
); | |
this.dotPlayer.onended = this.dotSoundEnded.bind(this); | |
this.dotPlayer.volume = this.masterVolume; | |
this.dotPlayer.play(); | |
} | |
} | |
/** | |
* Deletes the dotSound player and plays another dot sound if needed | |
*/ | |
dotSoundEnded() { | |
this.dotPlayer = undefined; | |
if (this.queuedDotSound) { | |
this.playDotSound(); | |
} | |
} | |
/** | |
* Loops an ambient sound | |
* @param {String} sound | |
*/ | |
async setAmbience(sound, keepCurrentAmbience) { | |
if (!this.fetchingAmbience && !this.cutscene) { | |
if (!keepCurrentAmbience) { | |
this.currentAmbience = sound; | |
this.paused = false; | |
} else { | |
this.paused = true; | |
} | |
if (this.ambienceSource) { | |
this.ambienceSource.stop(); | |
} | |
if (this.masterVolume !== 0) { | |
this.fetchingAmbience = true; | |
const response = await fetch( | |
`${this.baseUrl}${sound}.${this.fileFormat}`, | |
); | |
const arrayBuffer = await response.arrayBuffer(); | |
const audioBuffer = await this.ambience.decodeAudioData(arrayBuffer); | |
this.ambienceSource = this.ambience.createBufferSource(); | |
this.ambienceSource.buffer = audioBuffer; | |
this.ambienceSource.connect(this.ambience.destination); | |
this.ambienceSource.loop = true; | |
this.ambienceSource.start(); | |
this.fetchingAmbience = false; | |
} | |
} | |
} | |
/** | |
* Resumes the ambience | |
*/ | |
resumeAmbience(paused) { | |
if (this.ambienceSource) { | |
// Resetting the ambience since an AudioBufferSourceNode can only | |
// have 'start()' called once | |
if (paused) { | |
this.setAmbience('pause_beat', true); | |
} else { | |
this.setAmbience(this.currentAmbience); | |
} | |
} | |
} | |
/** | |
* Stops the ambience | |
*/ | |
stopAmbience() { | |
if (this.ambienceSource) { | |
this.ambienceSource.stop(); | |
} | |
} | |
} | |
class Timer { | |
constructor(callback, delay) { | |
this.callback = callback; | |
this.remaining = delay; | |
this.resume(); | |
} | |
/** | |
* Pauses the timer marks whether the pause came from the player | |
* or the system | |
* @param {Boolean} systemPause | |
*/ | |
pause(systemPause) { | |
window.clearTimeout(this.timerId); | |
this.remaining -= new Date() - this.start; | |
this.oldTimerId = this.timerId; | |
if (systemPause) { | |
this.pausedBySystem = true; | |
} | |
} | |
/** | |
* Creates a new setTimeout based upon the remaining time, giving the | |
* illusion of 'resuming' the old setTimeout | |
* @param {Boolean} systemResume | |
*/ | |
resume(systemResume) { | |
if (systemResume || !this.pausedBySystem) { | |
this.pausedBySystem = false; | |
this.start = new Date(); | |
this.timerId = window.setTimeout(() => { | |
this.callback(); | |
window.dispatchEvent(new CustomEvent('removeTimer', { | |
detail: { | |
timer: this, | |
}, | |
})); | |
}, this.remaining); | |
if (!this.oldTimerId) { | |
window.dispatchEvent(new CustomEvent('addTimer', { | |
detail: { | |
timer: this, | |
}, | |
})); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment