const [EMPTY, FOOD, WALL, FIRE, HOLE, HOLE_S, SNAKE]=Array(7).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 { switch(settings.world[y][x]) { case ' ': return EMPTY; case 'f': return FOOD; case 'w': return WALL; case 'o': return HOLE; case 'i': return FIRE; } })(); } } // 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 this.world[x][y]=WALL); // add the holes if(settings.holes) settings.holes.forEach(([x, y]) => this.world[x][y]=HOLE); // add the fires if(settings.fires) settings.fires.forEach(([x, y]) => this.world[x][y]=FIRE); // 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 fire=assets.get('fire'); const putTile=(x, y, tile) => this.ctx.drawImage( tile, offsetX+cellSize*x, offsetY+cellSize*y, cellSize, cellSize ); const putTileAnim=(x, y, tile) => putTile(x, y, tile[ Math.floor(Date.now()/1000*60+x+y)%tile.length ]); 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 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 FIRE: 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]) ); // increase score this.score++; // 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.delay50 && 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 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; this.score=0; requestAnimationFrame(() => this.tick()); } } return module.exports=SnekGame;