From 16da9da3c393eda997689fcbaac432393168910a Mon Sep 17 00:00:00 2001 From: flifloo Date: Thu, 18 Mar 2021 10:29:12 +0100 Subject: [PATCH] Add snake game --- index.html | 14 ++- sources/css/style.css | 31 +++++- sources/js/Game.js | 230 +++++++++++++++++++++++++++++++++++++++++- sources/js/Snake.js | 74 +++++++++++++- sources/js/Tile.js | 94 +++++++++++++++++ sources/js/index.js | 4 +- 6 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 sources/js/Tile.js diff --git a/index.html b/index.html index e6f551d..2eb9169 100644 --- a/index.html +++ b/index.html @@ -9,8 +9,18 @@

KyFlo Snake

- - + + diff --git a/sources/css/style.css b/sources/css/style.css index a252740..41e5d9d 100644 --- a/sources/css/style.css +++ b/sources/css/style.css @@ -1,17 +1,44 @@ @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); body{ - height: 100%; + height: 100vh; width: 100%; margin: 0; padding: 0; background-image: url("../images/yargon-kerman-webp-net-gifmaker-39.gif"); background-repeat: no-repeat; - background-size: 100%; + background-size: cover; + backdrop-filter: blur(4px); } h1{ font-family: 'Press Start 2P', cursive; text-align: center; color: yellow; + margin: 0; } + +.menu>div{ + background-color: black; +} + +.menu button{ + display: block; + text-align: center; + background-color: transparent; + border: 3px double #FF4294; + margin-bottom: 10px; + margin-top: 10px; + padding: 10px; + font-size: 20px; + color: #FF4294; +} + +#canvas { + height: 100%; + width: 100%; + right: 0; + top: 0; + position: fixed; + background: white; +} \ No newline at end of file diff --git a/sources/js/Game.js b/sources/js/Game.js index 90a7db2..85b7ea3 100644 --- a/sources/js/Game.js +++ b/sources/js/Game.js @@ -1,5 +1,231 @@ -export class Game { - constructor(options) { +import {Tile, tiles} from "./Tile.js" +import {Snake, directions} from "./Snake.js"; + +export class Game { + /** + * Generate a new game of Snake + * @param {HTMLCanvasElement} canvas + * @param {[int, int]} size + * @param {directions} direction + * @param {int} snakeSpeed + * @param {int} appleSpeed + * @param {int} lives + */ + constructor(canvas, {size = [15, 15], direction = directions.RIGHT, snakeSpeed = 500, appleSpeed = 5000, lives = 3} = {}) { + if (canvas && canvas.nodeName === "CANVAS") + this.ctx = canvas.getContext("2d"); + else + throw new InvalidGameOption("canvas"); + + if (size && Array.isArray(size) && size.length === 2 && size.filter(s => typeof s === "number" && s > 0 && s % 1 === 0).length === size.length) + this.size = size; + else + throw new InvalidGameOption("size"); + + if (direction && Object.values(directions).find(([x,y]) => direction[0] === x && direction[1] === y)) + this.direction = this.lastDirection = direction; + else + throw new InvalidGameOption("direction"); + + if (snakeSpeed && typeof snakeSpeed === "number" && snakeSpeed > 0 && snakeSpeed % 1 === 0) + this.snakeSpeed = snakeSpeed; + else + throw new InvalidGameOption("snakeSpeed"); + + if (appleSpeed && typeof appleSpeed === "number" && appleSpeed > 0 && appleSpeed % 1 === 0) + this.appleSpeed = appleSpeed; + else + throw new InvalidGameOption("appleSpeed"); + + this.world = []; + this.apple = false; + this.score = 0; + this.lives = lives; + } + + /** + * Init the world with empty tiles + */ + initWorld() { + for (let x = 0; x < this.size[0]; x++) + for (let y = 0; y < this.size[1]; y++) { + if (!this.world[x]) + this.world[x] = []; + this.world[x][y] = new Tile(x, y, tiles.EMPTY, this); + } + } + + /** + * Init the canvas + */ + initCanvas() { + this.ctx.canvas.width = window.innerWidth; + this.ctx.canvas.height = window.innerHeight; + + this.ctx.canvas.ownerDocument.addEventListener("keyup", ev => { + switch (ev.key) { + case "ArrowUp": + if (this.lastDirection !== directions.DOWN) + this.direction = directions.UP; + break; + case "ArrowRight": + if (this.lastDirection !== directions.LEFT) + this.direction = directions.RIGHT; + break; + case "ArrowDown": + if (this.lastDirection !== directions.UP) + this.direction = directions.DOWN; + break; + case "ArrowLeft": + if (this.lastDirection !== directions.RIGHT) + this.direction = directions.LEFT; + break; + } + }); + } + + /** + * Draw the grid + */ + drawGrid() { + for (const l of this.world) + for (const t of l) + t.draw() + } + + /** + * Start the party + */ + start() { + this.snake = new Snake({startPos: this.size.map(s => Math.floor(s/2))}); + + this.initWorld(); + this.updateSnake(); + this.drawGrid(); + this.initCanvas(); + + this.mainInterval = setInterval(() => this.main(), this.snakeSpeed); + this.appleInterval = setInterval(() => this.appleGenerator(), this.appleSpeed); + } + + /** + * Stop the party + */ + stop() { + clearInterval(this.mainInterval); + clearInterval(this.appleInterval); + + // Clear the grid + this.ctx.globalCompositeOperation = "destination-out"; + this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); + this.ctx.globalCompositeOperation = "source-over"; + } + + /** + * Restart the party + */ + restart() { + this.stop(); + this.start(); + } + + /** + * The main loop pf the game + */ + main() { + try { + this.moveSnake(); + this.drawGrid(); + } catch (err) { + if (err instanceof GameOver) { + this.lives--; + if (this.lives <= 0) { + alert(`Game over !\nYour score is: ${this.score}`); + this.stop(); + } else + this.restart(); + } else { + console.error(err); + alert("An error occurred !"); + } + } + } + + /** + * Update the snake on the world and check for collision + */ + updateSnake() { + for (const [x, y] of this.snake.body) + if (!(x in this.world) || !(y in this.world[x])) + throw new GameOver(); + else { + const t = this.world[x][y]; + if (t.type === tiles.APPLE) { + this.snake.eat(); + this.apple = false; + this.score++; + } + t.type = tiles.SNAKE; + } + } + + /** + * Move the snake + */ + moveSnake() { + this.snake.move(this.direction); + this.lastDirection = this.direction; + + for (const l of this.world) + for (const t of l) + if (t.type === tiles.SNAKE) + t.type = tiles.EMPTY; + + this.updateSnake(); + } + + /** + * Main loop fir apple generation + */ + appleGenerator() { + if (!this.apple) { + let pos = this.randCoords(); + + this.world[pos[0]][pos[1]].type = tiles.APPLE; + this.apple = true; + } + } + + /** + * Generate random coordinates of empty tiles + * @returns {[int, int]} + */ + randCoords() { + let pos; + + do { + pos = []; + + for (const s of this.size) + pos.push(Math.floor(Math.random() * s)) + } while (this.world[pos[0]][pos[1]].type !== tiles.EMPTY); + + return pos; + } +} + +export class InvalidGameOption extends Error { + /** + * @param {string} name + */ + constructor(name) { + super(`Invalid Game option: ${name}`); + } +} + +export class GameOver extends Error { + constructor() { + super("Game Over"); } } diff --git a/sources/js/Snake.js b/sources/js/Snake.js index d454848..40192d3 100644 --- a/sources/js/Snake.js +++ b/sources/js/Snake.js @@ -1,6 +1,70 @@ -class Snake { - constructor(startPos = [0,0], size = 4) { - this.size = size; - this.body = [startPos, startPos*size] +import {GameOver} from "./Game.js"; + +export class Snake { + /** + * The snake of the game + * @param options + */ + constructor({startPos = [8,8], size = 4, startDirection = directions.RIGHT} = {}) { + if (startPos && Array.isArray(startPos) && startPos.length === 2 && startPos.filter(s => typeof s === "number" && s > 0 && s % 1 === 0).length === startPos.length) + this.body = [startPos]; + else + throw new InvalidSnakeOption("startPos"); + + if (size && typeof size === "number" && size > 0 && size % 1 === 0) + this.size = size; + else + throw new InvalidSnakeOption("size"); + + // Generate snake body + for (let s = 1; s < this.size; s++) { + const pos = this.body[this.body.length - 1].map((n, i) => n - startDirection[i]); + if (pos.find(v => v < 0)) + throw new InvalidSnakeOption("startPos too small"); + this.body.push(pos); + } + + this.eating = false; } -} \ No newline at end of file + + /** + * Move the snake body + * @param {directions} direction + */ + move(direction) { + if (!this.eating) + this.body.pop(); + else + this.eating = false; + + const pos = this.body[0].map((n, i) => n + direction[i]); + + if (this.body.find(([x,y]) => pos[0] === x && pos[1] === y)) + throw new GameOver(); + + this.body.unshift(pos); + } + + /** + * Enable eating + */ + eat() { + this.eating = true; + } +} + +export const directions = { + UP: [0, -1], + RIGHT: [1, 0], + DOWN: [0, 1], + LEFT: [-1, 0] +}; + +export class InvalidSnakeOption extends Error { + /** + * @param {string} name + */ + constructor(name) { + super(`Invalid Snake option: ${name}`); + } +} diff --git a/sources/js/Tile.js b/sources/js/Tile.js new file mode 100644 index 0000000..bdd0837 --- /dev/null +++ b/sources/js/Tile.js @@ -0,0 +1,94 @@ +import {Game} from "./Game.js"; + +export class Tile { + /** + * A tile of the game grid + * @param {int} x + * @param {int} y + * @param {tiles} type + * @param {Game} game + */ + constructor(x, y, type = tiles.EMPTY, game) { + if (typeof x === "number" && x >= 0 && x % 1 === 0) + this.x = x; + else + throw new InvalidTileOption("x"); + + if (typeof y === "number" && y >= 0 && y % 1 === 0) + this.y = y; + else + throw new InvalidTileOption("y"); + + if (type && Object.values(tiles).find(t => t === type)) + this.type = type; + else + throw new InvalidTileOption("type"); + + if (game && game instanceof Game) + this.game = game; + else + throw new InvalidTileOption("game"); + } + + /** + * Draw the tile on the grid + */ + draw() { + const canvasPos = this.getCanvasPos(), size = this.getSize(); + + this.game.ctx.beginPath(); + + this.game.ctx.globalCompositeOperation = "destination-out"; + this.game.ctx.fillRect(...canvasPos, ...size); + this.game.ctx.globalCompositeOperation = "source-over"; + + switch (this.type) { + case tiles.EMPTY: + this.game.ctx.strokeStyle = "#000000"; + this.game.ctx.rect(...canvasPos, ...size); + break; + case tiles.APPLE: + this.game.ctx.fillStyle = "#ff0000"; + this.game.ctx.fillRect(...canvasPos, ...size); + break; + case tiles.SNAKE: + this.game.ctx.fillStyle = "#00ff5f"; + this.game.ctx.fillRect(...canvasPos, ...size); + break; + } + + this.game.ctx.stroke(); + } + + /** + * Get the tile position on the canvas + * @returns {[int, int]} + */ + getCanvasPos() { + return [this.getSize()[0]*this.x, this.getSize()[1]*this.y] + } + + /** + * Get the tile size on the canvas + * @returns {[int, int]} + */ + getSize() { + const s = Math.min(Math.round(this.game.ctx.canvas.width/this.game.size[0]), Math.round(this.game.ctx.canvas.height/this.game.size[1])); + return [s, s] + } +} + +export class InvalidTileOption extends Error { + /** + * @param {string} name + */ + constructor(name) { + super(`Invalid Tile option: ${name}`); + } +} + +export const tiles = { + EMPTY: "empty", + APPLE: "apple", + SNAKE: "snake" +}; diff --git a/sources/js/index.js b/sources/js/index.js index 9c94b92..53a4e9f 100644 --- a/sources/js/index.js +++ b/sources/js/index.js @@ -1,6 +1,8 @@ -import { Game } from "/sources/js/Game" +import { Game } from "./Game.js" +const game = new Game(document.getElementById("canvas"), {snakeSpeed: 200}); document.addEventListener('DOMContentLoaded', function() { M.AutoInit(); + game.start(); });