Snek/src/js/snek.js

504 lines
13 KiB
JavaScript
Raw Normal View History

2020-03-26 18:26:47 +00:00
const [EMPTY, FOOD, WALL, FIRE, HOLE, HOLE_S, SNAKE]=Array(7).keys();
2020-03-23 19:11:39 +00:00
class SnekGame {
2020-03-25 14:57:20 +00:00
constructor(settings, canvas, rules) {
2020-03-25 17:29:28 +00:00
// 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;
2020-03-26 17:05:12 +00:00
case 'o': return HOLE;
2020-03-26 18:26:47 +00:00
case 'i': return FIRE;
2020-03-25 17:29:28 +00:00
}
})();
}
}
2020-03-26 17:05:12 +00:00
2020-03-25 17:29:28 +00:00
// 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);
2020-03-25 17:29:28 +00:00
2020-03-26 17:05:12 +00:00
// add the holes
if(settings.holes) settings.holes.forEach(([x, y]) => this.world[x][y]=HOLE);
2020-03-26 18:26:47 +00:00
// add the fires
if(settings.fires) settings.fires.forEach(([x, y]) => this.world[x][y]=FIRE);
2020-03-25 17:29:28 +00:00
// add the food
settings.food.forEach(([x, y]) => this.world[x][y]=FOOD);
this.fruits=[...settings.food];
2020-03-25 14:57:20 +00:00
}
2020-03-25 17:29:28 +00:00
// add the snake to the world
2020-03-23 19:11:39 +00:00
settings.snake.forEach(([x, y]) => this.world[x][y]=SNAKE);
// get the head and initial direction
this.head=[...settings.snake[0]];
2020-03-26 17:05:12 +00:00
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
2020-03-23 19:11:39 +00:00
];
this.lastDirection=this.direction
2020-03-23 19:11:39 +00:00
2020-03-25 17:29:28 +00:00
// store the snake
2020-03-23 19:11:39 +00:00
this.snake=[...settings.snake];
// get our canvas, like, if we want to actually draw
this.canvas=canvas;
this.ctx=canvas.getContext('2d');
2020-04-04 20:59:50 +00:00
2020-03-25 14:57:20 +00:00
// load the custom rules
this.rules=Object.assign({
fruitRegrow: true,
speedIncrease: true,
worldWrap: true,
winCondition: 'none',
2020-03-25 17:29:28 +00:00
scoreSystem: 'fruit',
netPlay: false,
autoSizeGrow: false,
autoSpeedIncrease: false
2020-03-25 17:29:28 +00:00
}, rules, settings.rules || {});
}
get playTime() {
return Date.now()-this.firstStep;
2020-03-23 19:11:39 +00:00
}
draw() {
const assets=require('assets');
const config=require('config');
2020-03-23 19:11:39 +00:00
// clear the canvas, because it's easier than having to deal with everything
2020-03-25 14:57:20 +00:00
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
2020-03-23 19:11:39 +00:00
// get the cell size and offset
const cellSize=Math.min(
2020-03-25 14:57:20 +00:00
this.canvas.width/this.dimensions[0],
2020-03-23 19:11:39 +00:00
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;
2020-04-05 16:23:11 +00:00
// draw a grid/checkerboard if requested
if(config.getS('appearance.grid')=='grid') {
2020-04-05 16:23:11 +00:00
this.ctx.strokeStyle='rgba(0, 0, 0, 50%)';
this.ctx.lineCap='square';
this.ctx.lineWidth=1;
this.ctx.beginPath();
for(let x=1; x<this.dimensions[0]; x++) {
this.ctx.moveTo(offsetX+x*cellSize, offsetY);
this.ctx.lineTo(offsetX+x*cellSize, this.canvas.height-offsetY);
}
for(let y=1; y<this.dimensions[1]; y++) {
this.ctx.moveTo(offsetX, offsetY+y*cellSize);
this.ctx.lineTo(this.canvas.width-offsetX, offsetY+y*cellSize);
}
this.ctx.stroke();
} else if(config.getS('appearance.grid')=='checkerboard') {
2020-04-05 16:23:11 +00:00
this.ctx.fillStyle='rgba(0, 0, 0, 10%)';
for(let x=0; x<this.dimensions[0]; x++) {
for(let y=(x+1)%2; y<this.dimensions[1]; y+=2) {
this.ctx.fillRect(offsetX+x*cellSize, offsetY+y*cellSize, cellSize, cellSize);
}
}
}
// draw our tiles
2020-03-24 12:01:24 +00:00
const wall=assets.get('wall');
2020-03-26 17:05:12 +00:00
const hole=assets.get('hole');
2020-03-26 18:26:47 +00:00
const fire=assets.get('fire');
2020-03-26 17:05:12 +00:00
const putTile=(x, y, tile) => this.ctx.drawImage(
tile,
offsetX+cellSize*x,
offsetY+cellSize*y,
cellSize,
cellSize
);
2020-03-26 19:12:19 +00:00
const putTileAnim=(x, y, tile) => putTile(x, y, tile[
Math.floor(Date.now()/1000*60+x+y)%tile.length
]);
2020-03-26 17:05:12 +00:00
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;
};
2020-03-23 19:11:39 +00:00
for(let x=0; x<this.dimensions[0]; x++) {
2020-03-25 14:57:20 +00:00
for(let y=0; y<this.dimensions[1]; y++) {
2020-03-23 19:11:39 +00:00
switch(this.world[x][y]) {
case WALL:
2020-03-26 17:05:12 +00:00
putTile(x, y, wall);
break;
2020-03-26 18:26:47 +00:00
case FIRE:
2020-03-26 19:12:19 +00:00
putTileAnim(x, y, fire);
2020-03-26 18:26:47 +00:00
break;
2020-04-04 20:59:50 +00:00
case HOLE:
2020-03-26 17:05:12 +00:00
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]));
2020-03-23 19:11:39 +00:00
break;
2020-03-26 17:05:12 +00:00
// technically, this works for all shapes
// however, the tileset only handles convex shapes
}
2020-03-23 19:11:39 +00:00
}
}
}
// draw our snake
2020-03-24 12:01:24 +00:00
const snake=assets.get('snake');
2020-03-23 19:11:39 +00:00
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) => {
2020-03-23 19:11:39 +00:00
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)
);
2020-03-23 19:11:39 +00:00
});
this.ctx.stroke();
// our fruit has a nice animation to it between .8 and 1.2 scale
const ms=Date.now();
2020-03-25 14:57:20 +00:00
const fruitScale=Math.sin(ms/400*Math.PI)*.2+1
2020-03-24 12:01:24 +00:00
const fruit=assets.get('fruit');
2020-03-23 19:11:39 +00:00
this.fruits.forEach(([x, y]) => {
this.ctx.drawImage(
fruit,
offsetX+cellSize*x+(1-fruitScale)*cellSize/2,
2020-03-25 17:29:28 +00:00
offsetY+cellSize*y+(1-fruitScale)*cellSize/2,
2020-03-23 19:11:39 +00:00
cellSize*fruitScale,
cellSize*fruitScale
);
});
// show the timer
if(this.rules.winCondition=='time') {
if(config.getS('appearance.timer')=='border' || config.getS('appearance.timer')=='both') {
let remaining=(this.rules.gameDuration-this.playTime)/this.rules.gameDuration;
const w=this.dimensions[0]*cellSize;
const h=this.dimensions[1]*cellSize;
const p=w*2+h*2;
const wp=w/p;
const hp=h/p;
const pdst=(st, ed, frac) =>
(ed-st)*frac+st;
this.ctx.strokeStyle='#930a16';
this.ctx.lineJoin='miter';
this.ctx.lineCap='round';
this.ctx.lineWidth=5;
this.ctx.beginPath();
this.ctx.moveTo(this.canvas.width/2, offsetY+2);
let sp=Math.min(wp/2, remaining);
remaining-=sp;
this.ctx.lineTo(pdst(this.canvas.width/2, w+offsetX-2, sp/wp*2), offsetY+2);
if(remaining) {
sp=Math.min(hp, remaining);
remaining-=sp;
this.ctx.lineTo(w+offsetX-2, pdst(offsetY+2, offsetY+h-2, sp/hp));
}
if(remaining) {
sp=Math.min(wp, remaining);
remaining-=sp;
this.ctx.lineTo(pdst(w+offsetX-2, offsetX+2, sp/wp), offsetY+h-2);
}
if(remaining) {
sp=Math.min(hp, remaining);
remaining-=sp;
this.ctx.lineTo(offsetX+2, pdst(offsetY+h-2, offsetY+2, sp/hp));
}
if(remaining) {
this.ctx.lineTo(pdst(offsetX+2, this.canvas.width/2, remaining/wp*2), offsetY+2);
}
this.ctx.stroke();
}
if(config.getS('appearance.timer')=='number' || config.getS('appearance.timer')=='both') {
let remaining=''+Math.ceil((this.rules.gameDuration-this.playTime)/1000);
while(remaining.length<(''+this.rules.gameDuration/1000).length) remaining='0'+remaining;
this.ctx.fillStyle='#930a16';
this.ctx.textAlign='center';
this.ctx.textBaseline='middle';
this.ctx.font='4rem "Fira Code"';
this.ctx.fillText(remaining, this.canvas.width/2, this.canvas.height/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);
2020-03-23 19:11:39 +00:00
}
step() {
this.tickId++;
this.lastDirection=this.direction;
2020-03-23 19:11:39 +00:00
// 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();
2020-03-26 17:05:12 +00:00
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;
}
2020-03-23 19:11:39 +00:00
2020-03-25 14:57:20 +00:00
// 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();
}
}
2020-03-23 19:11:39 +00:00
switch(this.world[head[0]][head[1]]) {
// you hit, you die
case WALL:
2020-03-26 18:26:47 +00:00
case FIRE:
2020-03-23 19:11:39 +00:00
case SNAKE:
2020-03-26 17:05:12 +00:00
case HOLE_S:
2020-03-23 19:11:39 +00:00
return this.die();
2020-03-26 17:05:12 +00:00
// 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;
2020-03-23 19:11:39 +00:00
// you eat, you don't die
2020-03-25 14:57:20 +00:00
case FOOD:
2020-03-23 19:11:39 +00:00
// 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;
2020-03-25 17:29:28 +00:00
this.fruits=this.fruits.filter(
([x, y]) => !(x==head[0] && y==head[1])
2020-03-23 19:11:39 +00:00
);
2020-04-04 20:59:50 +00:00
// increase score
this.score++;
2020-03-23 19:11:39 +00:00
// custom rules
2020-03-25 14:57:20 +00:00
if(this.rules.fruitRegrow) {
2020-03-23 19:11:39 +00:00
const emptyCells=this.world
.map(
(l, x) => l
.map(
(r, y) => r==EMPTY?[x,y]:null
).filter(
2020-04-04 20:59:50 +00:00
a => a
2020-03-23 19:11:39 +00:00
)
).flat();
const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
this.fruits.push(cell);
2020-03-25 14:57:20 +00:00
this.world[cell[0]][cell[1]]=FOOD;
2020-03-23 19:11:39 +00:00
}
if(this.rules.speedIncrease) {
this.delay*=this.rules.speedMultiplier;
if(this.delay<this.rules.speedCap) this.delay=this.rules.speedCap;
}
2020-03-23 19:11:39 +00:00
}
// move our head forward
2020-03-26 17:05:12 +00:00
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;
}
2020-03-23 19:11:39 +00:00
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);
}
2020-03-23 19:11:39 +00:00
// victory condition
2020-03-25 17:29:28 +00:00
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();
}
2020-03-23 19:11:39 +00:00
}
tick() {
2020-03-25 14:57:20 +00:00
if(!this.playing) return;
2020-03-23 19:11:39 +00:00
if(!this.lastStep) this.lastStep=this.firstStep;
2020-03-25 14:57:20 +00:00
this.draw();
2020-03-25 17:29:28 +00:00
if(this.callback) this.callback('tick');
2020-03-25 14:57:20 +00:00
if(this.lastStep+this.delay<Date.now()) {
this.lastStep+=this.delay;
2020-03-23 19:11:39 +00:00
this.step();
}
requestAnimationFrame(() => this.tick());
}
win() {
this.playing=false;
2020-03-25 17:29:28 +00:00
this.endPlayTime=this.playTime;
if(this.callback) this.callback('win');
2020-03-23 19:11:39 +00:00
}
die() {
this.playing=false;
2020-03-25 17:29:28 +00:00
this.endPlayTime=this.playTime;
if(this.callback) this.callback('die');
2020-03-23 19:11:39 +00:00
}
2020-03-25 14:57:20 +00:00
handleInputs(inputs) {
const config=require('config');
2020-03-26 17:05:12 +00:00
// change direction if the input is valid
2020-03-25 14:57:20 +00:00
const trySet=(dir) => {
if(!dir.every((e, i) => e==this.lastDirection[i] || e==-this.lastDirection[i])) {
this.direction=dir;
return true;
}
2020-03-25 14:57:20 +00:00
}
2020-03-26 17:05:12 +00:00
// 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;
});
2020-03-26 17:05:12 +00:00
// try all inputs in order and unbuffer them if valid
if(inputs.left && trySet([-1, 0])) delete inputs.left;
else if(inputs.right && trySet([ 1, 0])) delete inputs.right;
else if(inputs.up && trySet([ 0,-1])) delete inputs.up;
else if(inputs.down && trySet([ 0, 1])) delete inputs.down;
2020-03-26 11:04:18 +00:00
2020-03-26 17:05:12 +00:00
// buffering might be disabled
if(!config.getB('input.buffer')) {
2020-03-26 11:04:18 +00:00
Object
.keys(inputs)
.forEach(k => delete inputs[k]);
}
2020-03-25 14:57:20 +00:00
}
2020-03-23 19:11:39 +00:00
start() {
this.firstStep=Date.now();
this.tickId=0;
2020-03-25 14:57:20 +00:00
this.playing=true;
2020-04-04 20:59:50 +00:00
this.score=0;
2020-03-23 19:11:39 +00:00
requestAnimationFrame(() => this.tick());
}
}
2020-03-24 09:46:01 +00:00
2020-04-04 20:59:50 +00:00
return module.exports=SnekGame;