Add snake game
This commit is contained in:
parent
f9b86b3dab
commit
16da9da3c3
6 changed files with 435 additions and 12 deletions
14
index.html
14
index.html
|
@ -9,8 +9,18 @@
|
|||
</head>
|
||||
<body>
|
||||
<h1>KyFlo Snake</h1>
|
||||
|
||||
<canvas></canvas>
|
||||
<div class="row menu">
|
||||
<div class="col s6 offset-s3">
|
||||
<div class="col s10 offset-s1 input-field">
|
||||
<input type="text" id="nickname">
|
||||
<label for="nickname">Nickname :</label>
|
||||
</div>
|
||||
<button class="col s8 offset-s2">Level 1</button>
|
||||
<button class="col s8 offset-s2">Level 2</button>
|
||||
<button class="col s8 offset-s2">Level 3</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||
<script type="module" src="sources/js/index.js"></script>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
|
|
94
sources/js/Tile.js
Normal file
94
sources/js/Tile.js
Normal file
|
@ -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"
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
|
|
Reference in a new issue