460 lines
12 KiB
JavaScript
460 lines
12 KiB
JavaScript
/**
|
|
* Game config
|
|
*/
|
|
const number_levels = 2;
|
|
const cell_width = 20;
|
|
const cell_height = 20;
|
|
|
|
/**
|
|
* Cells content
|
|
*/
|
|
let i = 0;
|
|
const EMPTY = i++;
|
|
const SNAKE = i++;
|
|
const WALL = i++;
|
|
const FOOD = i++;
|
|
delete i;
|
|
|
|
/**
|
|
* Get & return an element by its ID
|
|
*
|
|
* @param {String} id The ID of the element to get
|
|
* @return {HTMLElement} The target element
|
|
*/
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
/**
|
|
* Draw a line in a canvas rendering context
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Target context
|
|
* @param {Number} x1 Starting x
|
|
* @param {Number} y1 Starting y
|
|
* @param {Number} x2 Ending x
|
|
* @param {Number} y2 Ending y
|
|
*/
|
|
function drawLine(ctx, x1, y1, x2, y2) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2,y2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
/**
|
|
* Generate a random number
|
|
*
|
|
* @param {Number} min Minimum value
|
|
* @param {Number} max Maximum value
|
|
*/
|
|
function randInt(min, max) {
|
|
return Math.floor((Math.random()*max) + min)
|
|
}
|
|
|
|
/**
|
|
* Play an audio file
|
|
*
|
|
* @param {String} url The URL of the audio file to play
|
|
* @returns {HTMLAudioElement} Generated audio element
|
|
*/
|
|
function playAudio(url) {
|
|
const audio = document.createElement("audio");
|
|
audio.src = url
|
|
audio.play();
|
|
return audio;
|
|
}
|
|
|
|
// Get elements
|
|
const startScreen = byId("startScreen")
|
|
const gameScreen = byId("gameScreen")
|
|
const rulesScreen = byId("rulesScreen")
|
|
const levelChoicesTarget = byId("levelChoice")
|
|
const stopGameBtn = byId("menuBtn")
|
|
const canvasTarget = byId("canvasTarget")
|
|
const scoreTarget = byId("scoreTarget")
|
|
const rulesBtn = byId("rulesBtn")
|
|
|
|
// Get map texture
|
|
const imgGrass = new Image();
|
|
imgGrass.src = './assets/map/grass.png';
|
|
|
|
// Get wall texture
|
|
const imgWall = new Image();
|
|
imgWall.src = './assets/wall/wall.jpg';
|
|
|
|
// Get food texture
|
|
const imgApple = new Image();
|
|
imgApple.src = './assets/food/apple.png';
|
|
|
|
// Get snake texture
|
|
let imgSnakeHeadUp = new Image();
|
|
imgSnakeHeadUp.src = './assets/snake/snake_head_up.png';
|
|
let imgSnakeHeadLeft = new Image();
|
|
imgSnakeHeadLeft.src = './assets/snake/snake_head_left.png';
|
|
let imgSnakeHeadRight = new Image();
|
|
imgSnakeHeadRight.src = './assets/snake/snake_head_right.png';
|
|
let imgSnakeHeadDown = new Image();
|
|
imgSnakeHeadDown.src = './assets/snake/snake_head_down.png';
|
|
const imgSnakeBody = new Image();
|
|
imgSnakeBody.src = './assets/snake/snake_body.png';
|
|
|
|
|
|
/**
|
|
* Show main screen
|
|
*/
|
|
function showMainScreen() {
|
|
startScreen.style.display = "unset";
|
|
gameScreen.style.display = "none";
|
|
rulesScreen.style.display = "none";
|
|
}
|
|
|
|
/**
|
|
* Show rules screen
|
|
*/
|
|
function showRulesScreen() {
|
|
startScreen.style.display = "none";
|
|
gameScreen.style.display = "none";
|
|
rulesScreen.style.display = "unset";
|
|
}
|
|
|
|
|
|
/**
|
|
* Start a new game
|
|
*
|
|
* The scope of the function is the main
|
|
* game scope
|
|
*/
|
|
async function startGame(gameID) {
|
|
startScreen.style.display = "none";
|
|
gameScreen.style.display = "unset";
|
|
rulesScreen.style.display = "none";
|
|
|
|
// Fetch level information
|
|
let level;
|
|
try {
|
|
level = await (await fetch("levels/"+gameID+".json")).json();
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Could not load game level!");
|
|
return;
|
|
}
|
|
|
|
// Start audio
|
|
const audioEl = playAudio("/assets/game.mp3")
|
|
audioEl.loop = true;
|
|
|
|
// Create & apply the canvas
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = cell_width * level.dimensions[1];
|
|
canvas.height = cell_height * level.dimensions[0];
|
|
canvasTarget.appendChild(canvas);
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Initialize the map & snake arrays
|
|
/** @type {Array<Array<Number>>} */
|
|
let map = [];
|
|
let snake = [];
|
|
let score = 0;
|
|
|
|
/// First with empty cells...
|
|
for(let i = 0; i < level.dimensions[0]; i++) {
|
|
let cell = [];
|
|
for(let j = 0; j < level.dimensions[1]; j++) {
|
|
cell.push(EMPTY);
|
|
}
|
|
map.push(cell)
|
|
}
|
|
|
|
/// ... then with cells content
|
|
level.walls.forEach((w) => {
|
|
map[w[0]-1][w[1]-1] = WALL
|
|
})
|
|
|
|
level.food.forEach((f) => {
|
|
map[f[0]-1][f[1]-1] = FOOD
|
|
})
|
|
|
|
level.snake.forEach((s) => {
|
|
map[s[0]-1][s[1]-1] = SNAKE
|
|
snake.push([s[0]-1, s[1]-1]);
|
|
})
|
|
|
|
// Initialize pressed key
|
|
let key = level.firstKey;
|
|
|
|
/**
|
|
* Step function
|
|
*
|
|
* I placed this function here to inherit
|
|
* the map, snake, canvas & ctx variables...
|
|
*/
|
|
let currDelay = level.delay;
|
|
setTimeout(() => step(), currDelay);
|
|
function step() {
|
|
|
|
// Check if a game was destroyed
|
|
if(!canvas.isConnected) {
|
|
audioEl.pause();
|
|
return;
|
|
}
|
|
|
|
// Move the snake if required
|
|
if(key) {
|
|
let newHead = Array.from(snake[snake.length-1]);
|
|
let increaseSize = false;
|
|
|
|
// Make the snake move
|
|
switch(key) {
|
|
case "ArrowDown":
|
|
newHead[0]++;
|
|
break;
|
|
|
|
case "ArrowUp":
|
|
newHead[0]--;
|
|
break;
|
|
|
|
case "ArrowLeft":
|
|
newHead[1]--;
|
|
break;
|
|
|
|
case "ArrowRight":
|
|
newHead[1]++;
|
|
break;
|
|
}
|
|
|
|
if(newHead[0] < 0 || newHead[1] < 0 ||
|
|
newHead[0] >= level.dimensions[0] || newHead[1] >= level.dimensions[1]) {
|
|
gameOver();
|
|
return;
|
|
}
|
|
|
|
// Trigger appropriate action
|
|
switch(map[newHead[0]][newHead[1]]) {
|
|
|
|
case FOOD:
|
|
increaseSize = true;
|
|
playAudio("assets/eat.mp3")
|
|
score++;
|
|
break;
|
|
|
|
case SNAKE:
|
|
case WALL:
|
|
gameOver();
|
|
break;
|
|
|
|
}
|
|
|
|
// Push new snake position
|
|
snake.push(newHead);
|
|
map[newHead[0]][newHead[1]] = SNAKE
|
|
|
|
// Remove the end of the snake if he has not eaten anything
|
|
if(!increaseSize) {
|
|
const oldPos = snake.shift()
|
|
map[oldPos[0]][oldPos[1]] = EMPTY
|
|
}
|
|
|
|
// Check if the user has won
|
|
// Note : The user win the game if there is no food left
|
|
// So we search for a line in the map where there is food
|
|
// If none is found, it means that the user has won the game
|
|
//
|
|
// Optimization : We do it only if the user has just increazed its
|
|
// size => the snake has eaten a piece of food
|
|
if(increaseSize && map.find((row) => row.find((el) => el == FOOD) != undefined) == undefined) {
|
|
winGame()
|
|
return;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Refresh score
|
|
scoreTarget.innerHTML = score;
|
|
|
|
|
|
// Redraw screen
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
let pattern = ctx.createPattern(imgGrass, 'repeat');
|
|
ctx.fillStyle = pattern;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// First, draw the grid
|
|
ctx.lineWidth = 0.3;
|
|
|
|
for(let i = 0; i <= level.dimensions[1]; i++) {
|
|
drawLine(ctx, i*cell_width, 0, i*cell_width, canvas.height)
|
|
}
|
|
for(let i = 0; i <= level.dimensions[0]; i++) {
|
|
drawLine(ctx, 0, i*cell_height, canvas.width, i*cell_height, canvas.height)
|
|
}
|
|
|
|
|
|
// Now draw the map
|
|
for(let y = 0; y < map.length; y++) {
|
|
for(let x = 0; x < map[y].length; x++) {
|
|
|
|
// Adapt rendering to the element to display
|
|
switch(map[y][x]) {
|
|
|
|
case WALL:
|
|
ctx.drawImage(imgWall, x*cell_width, y*cell_height, cell_width, cell_height);
|
|
break;
|
|
|
|
case FOOD:
|
|
ctx.drawImage(imgApple, x*cell_width, y*cell_height, cell_width, cell_height);
|
|
break;
|
|
|
|
case SNAKE:
|
|
ctx.drawImage(imgSnakeBody, x*cell_width, y*cell_height, cell_width, cell_height);
|
|
|
|
// Check if it is the head of the snake
|
|
// If it is the case, we clear the rectangle and put its head instead
|
|
const headPos = snake[snake.length-1];
|
|
|
|
if(headPos[0] == y && headPos[1] == x) {
|
|
ctx.clearRect(x*cell_width+1, y*cell_height+1, cell_width-2, cell_height-2);
|
|
// Restore the background
|
|
ctx.drawImage(imgGrass, x*cell_width+1, y*cell_height+1, cell_width-2, cell_height-2);
|
|
|
|
// Head Orientation
|
|
if(key == "ArrowDown")
|
|
ctx.drawImage(imgSnakeHeadDown, x*cell_width, y*cell_height, cell_width, cell_height);
|
|
if(key == "ArrowUp" || !key)
|
|
ctx.drawImage(imgSnakeHeadUp, x*cell_width, y*cell_height, cell_width, cell_height);
|
|
if(key == "ArrowLeft")
|
|
ctx.drawImage(imgSnakeHeadLeft, x*cell_width, y*cell_height, cell_width, cell_height);
|
|
if(key == "ArrowRight")
|
|
ctx.drawImage(imgSnakeHeadRight, x*cell_width, y*cell_height, cell_width, cell_height);
|
|
}
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|
|
// Manage automated acceleration of snake
|
|
if(level.hasOwnProperty("acceleration"))
|
|
currDelay -= level.acceleration;
|
|
if(level.hasOwnProperty("minDelay") && currDelay <= level.minDelay)
|
|
currDelay = level.minDelay
|
|
|
|
setTimeout(() => step(), currDelay);
|
|
}
|
|
|
|
/**
|
|
* Call this function once the user loose the game
|
|
*/
|
|
function gameOver() {
|
|
audioEl.pause();
|
|
playAudio("assets/gameOver.mp3");
|
|
alert("Game over !!! (Score: " + score + ")");
|
|
location.href = "#";
|
|
}
|
|
|
|
/**
|
|
* Call this function when the user win the game
|
|
*/
|
|
function winGame() {
|
|
audioEl.pause();
|
|
playAudio("assets/win.mp3");
|
|
alert("You win !!!");
|
|
location.href = "#";
|
|
}
|
|
|
|
// Listen for key press events
|
|
document.body.addEventListener("keydown", (ev) => {
|
|
if(!canvas.isConnected)
|
|
return;
|
|
if(["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(ev.key))
|
|
key = ev.key;
|
|
|
|
if(ev.key == "p") {
|
|
audioEl.pause()
|
|
alert("Game paused. Close this dialog to resume");
|
|
audioEl.play()
|
|
}
|
|
});
|
|
|
|
// Automatically generate new map element if required
|
|
if(level.randWallFreq) {
|
|
const int = setInterval(() => {
|
|
if(!canvas.isConnected)
|
|
clearInterval(int);
|
|
else {
|
|
const pos = [randInt(0, map.length), randInt(0, map[0].length)]
|
|
if(map[pos[0]][pos[1]] == EMPTY)
|
|
map[pos[0]][pos[1]] = WALL;
|
|
}
|
|
}, level.randWallFreq);
|
|
}
|
|
|
|
// Automatically generate new map element if required
|
|
if(level.randFoodFreq) {
|
|
const int = setInterval(() => {
|
|
if(!canvas.isConnected)
|
|
clearInterval(int);
|
|
else {
|
|
const pos = [randInt(0, map.length), randInt(0, map[0].length)]
|
|
if(map[pos[0]][pos[1]] == EMPTY)
|
|
map[pos[0]][pos[1]] = FOOD;
|
|
}
|
|
}, level.randFoodFreq);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change the currently active window
|
|
*/
|
|
function changeWindow() {
|
|
|
|
// Make sure there are not canvas left in the background (to make sure
|
|
// no interval is running for an old game)
|
|
canvasTarget.innerHTML = ""
|
|
|
|
// Try to get game ID
|
|
const gameID = Number(window.location.hash.substr(1));
|
|
|
|
if(gameID > 0)
|
|
startGame(gameID);
|
|
else{
|
|
if(gameID == -1)
|
|
showRulesScreen();
|
|
else
|
|
showMainScreen();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize page
|
|
|
|
/// Listen to events
|
|
window.addEventListener("hashchange", (e) => changeWindow())
|
|
|
|
|
|
// Make game levels form lives
|
|
for (let index = 1; index < number_levels + 1; index++) {
|
|
levelChoicesTarget.innerHTML += "<li>" +
|
|
"<li><a href='#"+index+"'>" +
|
|
" Level " + index + "</a></li>";
|
|
}
|
|
|
|
|
|
|
|
// Stop game
|
|
stopGameBtn.addEventListener("click", () => {
|
|
location.href = "#";
|
|
})
|
|
|
|
// Refresh current window
|
|
changeWindow(); |