|
|
|
const [EMPTY, FOOD, WALL, HOLE, HOLE_S, SNAKE]=Array(6).keys();
|
|
|
|
|
|
|
|
class SnekGame {
|
|
|
|
constructor(settings, canvas, rules) {
|
|
|
|
// setup the delay
|
|
|
|
this.delay=settings.delay;
|
|
|
|
|
|
|
|
// world is given in the level
|
|
|
|
if(settings.world) { // explicitly
|
|
|
|
|
|
|
|
// convert the world
|
|
|
|
this.world=Array(settings.world[0].length);
|
|
|
|
for(let x=0; x<this.world.length; x++) {
|
|
|
|
this.world[x]=Array(settings.world.length);
|
|
|
|
for(let y=0; y<this.world[x].length; y++) {
|
|
|
|
this.world[x][y]=(() => {
|
|
|
|
switch(settings.world[y][x]) {
|
|
|
|
case ' ': return EMPTY;
|
|
|
|
case 'f': return FOOD;
|
|
|
|
case 'w': return WALL;
|
|
|
|
case 'o': return HOLE;
|
|
|
|
}
|
|
|
|
})();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// extract the dimensions
|
|
|
|
this.dimensions=[this.world.length, this.world[0].length];
|
|
|
|
|
|
|
|
// extract the fruits
|
|
|
|
this.fruits=[];
|
|
|
|
this.world
|
|
|
|
.forEach((l, x) => l.forEach(
|
|
|
|
(c, y) => {
|
|
|
|
if(c==FOOD) this.fruits.push([x, y]);
|
|
|
|
}
|
|
|
|
));
|
|
|
|
} else { // dimension and objects
|
|
|
|
|
|
|
|
// get the dimensions
|
|
|
|
this.dimensions=[...settings.dimensions];
|
|
|
|
|
|
|
|
// build an empty world
|
|
|
|
this.world=Array(settings.dimensions[0]);
|
|
|
|
for(let i=0; i<settings.dimensions[0]; i++) {
|
|
|
|
this.world[i]=Array(settings.dimensions[1]);
|
|
|
|
this.world[i].fill(EMPTY);
|
|
|
|
}
|
|
|
|
|
|
|
|
// add the walls
|
|
|
|
if(settings.walls) settings.walls.forEach(([x, y]) => this.world[x][y]=WALL);
|
|
|
|
|
|
|
|
// add the holes
|
|
|
|
if(settings.holes) settings.holes.forEach(([x, y]) => this.world[x][y]=HOLE);
|
|
|
|
|
|
|
|
// add the food
|
|
|
|
settings.food.forEach(([x, y]) => this.world[x][y]=FOOD);
|
|
|
|
this.fruits=[...settings.food];
|
|
|
|
}
|
|
|
|
|
|
|
|
// add the snake to the world
|
|
|
|
settings.snake.forEach(([x, y]) => this.world[x][y]=SNAKE);
|
|
|
|
|
|
|
|
|
|
|
|
// get the head and initial direction
|
|
|
|
this.head=[...settings.snake[0]];
|
|
|
|
if(settings.snake.length>=2) this.direction=[
|
|
|
|
settings.snake[0][0]-settings.snake[1][0],
|
|
|
|
settings.snake[0][1]-settings.snake[1][1]
|
|
|
|
];
|
|
|
|
else this.direction=[
|
|
|
|
1,
|
|
|
|
0
|
|
|
|
];
|
|
|
|
this.lastDirection=this.direction
|
|
|
|
|
|
|
|
// store the snake
|
|
|
|
this.snake=[...settings.snake];
|
|
|
|
|
|
|
|
// get our canvas, like, if we want to actually draw
|
|
|
|
this.canvas=canvas;
|
|
|
|
this.ctx=canvas.getContext('2d');
|
|
|
|
|
|
|
|
// load the custom rules
|
|
|
|
this.rules=Object.assign({
|
|
|
|
fruitRegrow: true,
|
|
|
|
speedIncrease: true,
|
|
|
|
worldWrap: true,
|
|
|
|
winCondition: 'none',
|
|
|
|
scoreSystem: 'fruit',
|
|
|
|
netPlay: false,
|
|
|
|
autoSizeGrow: false,
|
|
|
|
autoSpeedIncrease: false
|
|
|
|
}, rules, settings.rules || {});
|
|
|
|
}
|
|
|
|
|
|
|
|
get playTime() {
|
|
|
|
return Date.now()-this.firstStep;
|
|
|
|
}
|
|
|
|
|
|
|
|
draw() {
|
|
|
|
const assets=require('assets');
|
|
|
|
|
|
|
|
// clear the canvas, because it's easier than having to deal with everything
|
|
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
|
|
|
|
// get the cell size and offset
|
|
|
|
const cellSize=Math.min(
|
|
|
|
this.canvas.width/this.dimensions[0],
|
|
|
|
this.canvas.height/this.dimensions[1]
|
|
|
|
);
|
|
|
|
const offsetX=(this.canvas.width-cellSize*this.dimensions[0])/2;
|
|
|
|
const offsetY=(this.canvas.height-cellSize*this.dimensions[1])/2;
|
|
|
|
|
|
|
|
// draw the border around our game area
|
|
|
|
this.ctx.fillStyle='black';
|
|
|
|
this.ctx.fillRect(0, 0, this.canvas.width, offsetY);
|
|
|
|
this.ctx.fillRect(0, 0, offsetX, this.canvas.height);
|
|
|
|
this.ctx.fillRect(offsetX+cellSize*this.dimensions[0], 0, offsetX, this.canvas.height);
|
|
|
|
this.ctx.fillRect(0, offsetY+cellSize*this.dimensions[1], this.canvas.width, offsetY);
|
|
|
|
|
|
|
|
// draw our walls
|
|
|
|
const wall=assets.get('wall');
|
|
|
|
const hole=assets.get('hole');
|
|
|
|
|
|
|
|
const putTile=(x, y, tile) => this.ctx.drawImage(
|
|
|
|
tile,
|
|
|
|
offsetX+cellSize*x,
|
|
|
|
offsetY+cellSize*y,
|
|
|
|
cellSize,
|
|
|
|
cellSize
|
|
|
|
);
|
|
|
|
const checkAdj=(x, y) => {
|
|
|
|
let adj={};
|
|
|
|
adj.u=this.world[x][y-1];
|
|
|
|
adj.d=this.world[x][y+1];
|
|
|
|
adj.l=(this.world[x-1] || [])[y];
|
|
|
|
adj.r=(this.world[x+1] || [])[y];
|
|
|
|
adj.ul=(this.world[x-1] || [])[y-1];
|
|
|
|
adj.ur=(this.world[x+1] || [])[y-1];
|
|
|
|
adj.dl=(this.world[x-1] || [])[y+1];
|
|
|
|
adj.dr=(this.world[x+1] || [])[y+1];
|
|
|
|
return adj;
|
|
|
|
};
|
|
|
|
for(let x=0; x<this.dimensions[0]; x++) {
|
|
|
|
for(let y=0; y<this.dimensions[1]; y++) {
|
|
|
|
switch(this.world[x][y]) {
|
|
|
|
case WALL:
|
|
|
|
putTile(x, y, wall);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case HOLE:
|
|
|
|
case HOLE_S: {
|
|
|
|
putTile(x, y, hole.base);
|
|
|
|
let adj=checkAdj(x, y);
|
|
|
|
Object
|
|
|
|
.keys(adj)
|
|
|
|
.filter(k => adj[k]==HOLE || adj[k]==HOLE_S)
|
|
|
|
.forEach(k => putTile(x, y, hole[k]));
|
|
|
|
break;
|
|
|
|
// technically, this works for all shapes
|
|
|
|
// however, the tileset only handles convex shapes
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// draw our snake
|
|
|
|
const snake=assets.get('snake');
|
|
|
|
this.ctx.fillStyle=snake.color;
|
|
|
|
this.ctx.strokeStyle=snake.color;
|
|
|
|
this.ctx.lineCap=snake.cap;
|
|
|
|
this.ctx.lineJoin=snake.join;
|
|
|
|
this.ctx.lineWidth=cellSize*snake.tailSize;
|
|
|
|
|
|
|
|
this.ctx.beginPath();
|
|
|
|
this.ctx.ellipse(
|
|
|
|
offsetX+cellSize*(this.snake[0][0]+1/2),
|
|
|
|
offsetY+cellSize*(this.snake[0][1]+1/2),
|
|
|
|
cellSize/2*snake.headSize,
|
|
|
|
cellSize/2*snake.headSize,
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
Math.PI*2
|
|
|
|
);
|
|
|
|
this.ctx.fill();
|
|
|
|
|
|
|
|
this.ctx.beginPath();
|
|
|
|
this.snake.forEach(([x, y], i, a) => {
|
|
|
|
this.ctx.lineTo(
|
|
|
|
offsetX+cellSize*(x+1/2),
|
|
|
|
offsetY+cellSize*(y+1/2)
|
|
|
|
);
|
|
|
|
if(i!=0 && Math.hypot(x-a[i-1][0], y-a[i-1][1])>1) {
|
|
|
|
this.ctx.lineWidth=cellSize*snake.tailWrapSize;
|
|
|
|
} else {
|
|
|
|
this.ctx.lineWidth=cellSize*snake.tailSize;
|
|
|
|
}
|
|
|
|
this.ctx.stroke();
|
|
|
|
this.ctx.beginPath()
|
|
|
|
this.ctx.moveTo(
|
|
|
|
offsetX+cellSize*(x+1/2),
|
|
|
|
offsetY+cellSize*(y+1/2)
|
|
|
|
);
|
|
|
|
});
|
|
|
|
this.ctx.stroke();
|
|
|
|
|
|
|
|
// our fruit has a nice animation to it between .8 and 1.2 scale
|
|
|
|
const ms=Date.now();
|
|
|
|
const fruitScale=Math.sin(ms/400*Math.PI)*.2+1
|
|
|
|
const fruit=assets.get('fruit');
|
|
|
|
this.fruits.forEach(([x, y]) => {
|
|
|
|
this.ctx.drawImage(
|
|
|
|
fruit,
|
|
|
|
offsetX+cellSize*x+(1-fruitScale)*cellSize/2,
|
|
|
|
offsetY+cellSize*y+(1-fruitScale)*cellSize/2,
|
|
|
|
cellSize*fruitScale,
|
|
|
|
cellSize*fruitScale
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
step() {
|
|
|
|
this.tickId++;
|
|
|
|
this.lastDirection=this.direction;
|
|
|
|
|
|
|
|
// compute our new head
|
|
|
|
const head=[
|
|
|
|
this.snake[0][0]+this.direction[0],
|
|
|
|
this.snake[0][1]+this.direction[1]
|
|
|
|
];
|
|
|
|
|
|
|
|
// get our tail out of the way
|
|
|
|
const tail=this.snake.pop();
|
|
|
|
switch(this.world[tail[0]][tail[1]]) {
|
|
|
|
case HOLE_S:
|
|
|
|
this.world[tail[0]][tail[1]]=HOLE;
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
this.world[tail[0]][tail[1]]=EMPTY;
|
|
|
|
}
|
|
|
|
|
|
|
|
// check for out of world conditions
|
|
|
|
if(head[0]<0 || head[0]>=this.dimensions[0] || head[1]<0 || head[1]>=this.dimensions[1]) {
|
|
|
|
if(this.rules.worldWrap) {
|
|
|
|
head[0]=(head[0]+this.dimensions[0])%this.dimensions[0];
|
|
|
|
head[1]=(head[1]+this.dimensions[1])%this.dimensions[1];
|
|
|
|
} else {
|
|
|
|
return this.die();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
switch(this.world[head[0]][head[1]]) {
|
|
|
|
// you hit, you die
|
|
|
|
case WALL:
|
|
|
|
case SNAKE:
|
|
|
|
case HOLE_S:
|
|
|
|
return this.die();
|
|
|
|
|
|
|
|
// if either 3 consecutive segments or the whole snake is on a hole, you die
|
|
|
|
case HOLE:
|
|
|
|
if(
|
|
|
|
this.snake.length==0 ||
|
|
|
|
this.snake.length==1 &&
|
|
|
|
this.world[this.snake[0][0]][this.snake[0][1]]==HOLE_S ||
|
|
|
|
this.snake.length>=2 &&
|
|
|
|
this.world[this.snake[0][0]][this.snake[0][1]]==HOLE_S &&
|
|
|
|
this.world[this.snake[1][0]][this.snake[1][1]]==HOLE_S
|
|
|
|
) return this.die();
|
|
|
|
break;
|
|
|
|
|
|
|
|
// you eat, you don't die
|
|
|
|
case FOOD:
|
|
|
|
// re-grow the snake
|
|
|
|
this.snake.push(tail);
|
|
|
|
this.world[tail[0]][tail[1]]=SNAKE;
|
|
|
|
|
|
|
|
// remove the fruit from existence
|
|
|
|
this.world[head[0]][head[1]]=SNAKE;
|
|
|
|
this.fruits=this.fruits.filter(
|
|
|
|
([x, y]) => !(x==head[0] && y==head[1])
|
|
|
|
);
|
|
|
|
|
|
|
|
// custom rules
|
|
|
|
if(this.rules.fruitRegrow) {
|
|
|
|
const emptyCells=this.world
|
|
|
|
.map(
|
|
|
|
(l, x) => l
|
|
|
|
.map(
|
|
|
|
(r, y) => r==EMPTY?[x,y]:null
|
|
|
|
).filter(
|
|
|
|
a => a
|
|
|
|
)
|
|
|
|
).flat();
|
|
|
|
const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
|
|
|
|
this.fruits.push(cell);
|
|
|
|
this.world[cell[0]][cell[1]]=FOOD;
|
|
|
|
}
|
|
|
|
|
|
|
|
if(this.rules.speedIncrease) {
|
|
|
|
this.delay*=this.rules.speedMultiplier;
|
|
|
|
if(this.delay<this.rules.speedCap) this.delay=this.rules.speedCap;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// move our head forward
|
|
|
|
switch(this.world[head[0]][head[1]]) {
|
|
|
|
case HOLE:
|
|
|
|
this.world[head[0]][head[1]]=HOLE_S;
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
this.world[head[0]][head[1]]=SNAKE;
|
|
|
|
}
|
|
|
|
this.snake.unshift(head);
|
|
|
|
|
|
|
|
// automatic speed increase
|
|
|
|
if(this.rules.autoSpeedIncrease) {
|
|
|
|
if(this.delay>50 && this.tickId%this.rules.autoSpeadIncreaseTicks==0) this.delay--;
|
|
|
|
}
|
|
|
|
|
|
|
|
// automatic size grow
|
|
|
|
if(this.rules.autoSizeGrow) {
|
|
|
|
if(this.tickId%this.rules.autoSizeGrowTicks==0) this.snake.push(tail);
|
|
|
|
}
|
|
|
|
|
|
|
|
// victory condition
|
|
|
|
if(this.rules.winCondition=='fruit') {
|
|
|
|
if(!this.fruits.length) return this.win();
|
|
|
|
}
|
|
|
|
if(this.rules.winCondition=='time') {
|
|
|
|
if(this.playTime>=this.rules.gameDuration) return this.win();
|
|
|
|
}
|
|
|
|
if(this.rules.winCondition=='score') {
|
|
|
|
if(this.score>=this.rules.scoreObjective) return this.win();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
tick() {
|
|
|
|
if(!this.playing) return;
|
|
|
|
if(!this.lastStep) this.lastStep=this.firstStep;
|
|
|
|
this.draw();
|
|
|
|
if(this.callback) this.callback('tick');
|
|
|
|
if(this.lastStep+this.delay<Date.now()) {
|
|
|
|
this.lastStep+=this.delay;
|
|
|
|
this.step();
|
|
|
|
}
|
|
|
|
requestAnimationFrame(() => this.tick());
|
|
|
|
}
|
|
|
|
|
|
|
|
win() {
|
|
|
|
this.playing=false;
|
|
|
|
this.endPlayTime=this.playTime;
|
|
|
|
if(this.callback) this.callback('win');
|
|
|
|
}
|
|
|
|
|
|
|
|
die() {
|
|
|
|
this.playing=false;
|
|
|
|
this.endPlayTime=this.playTime;
|
|
|
|
if(this.callback) this.callback('die');
|
|
|
|
}
|
|
|
|
|
|
|
|
handleInputs(inputs) {
|
|
|
|
// change direction if the input is valid
|
|
|
|
const trySet=(dir) => {
|
|
|
|
if(!dir.every((e, i) => e==this.lastDirection[i] || e==-this.lastDirection[i])) {
|
|
|
|
this.direction=dir;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// reduce buffer duration
|
|
|
|
Object
|
|
|
|
.keys(inputs)
|
|
|
|
.forEach(k => {
|
|
|
|
let v=inputs[k];
|
|
|
|
if(v===true) v=5;
|
|
|
|
v--;
|
|
|
|
if(!v) delete inputs[k];
|
|
|
|
else inputs[k]=v;
|
|
|
|
});
|
|
|
|
|
|
|
|
// try all inputs in order and unbuffer them if valid
|
|
|
|
if(inputs.left && trySet([-1, 0])) return delete inputs.left;
|
|
|
|
else if(inputs.right && trySet([ 1, 0])) return delete inputs.right;
|
|
|
|
else if(inputs.up && trySet([ 0,-1])) return delete inputs.up;
|
|
|
|
else if(inputs.down && trySet([ 0, 1])) return delete inputs.down;
|
|
|
|
|
|
|
|
// buffering might be disabled
|
|
|
|
if(inputs.clearBuffer) {
|
|
|
|
Object
|
|
|
|
.keys(inputs)
|
|
|
|
.forEach(k => delete inputs[k]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
start() {
|
|
|
|
this.firstStep=Date.now();
|
|
|
|
this.tickId=0;
|
|
|
|
this.playing=true;
|
|
|
|
requestAnimationFrame(() => this.tick());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports=SnekGame;
|
|
|
|
return SnekGame;
|