const [EMPTY, FOOD, SUPER_FOOD, DECAY_FOOD, WALL, FIRE, FLAMMABLE, FLAMMABLE_S, HOLE, HOLE_S, SNAKE]=Array(255).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 'F': return SUPER_FOOD; case 'd': return DECAY_FOOD; case 'w': return WALL; case 'o': return HOLE; case 'i': return FIRE; case 'I': return FLAMMABLE; } })(); } } // 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]); } )); // extract the decaying fruits this.decayFood=[]; this.world .forEach((l, x) => l.forEach( (c, y) => { if(c==DECAY_FOOD) this.decaying.push([x, y, 0]); } )); } 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 and flammable tiles if(settings.fires) settings.fires.forEach(([x, y]) => this.world[x][y]=FIRE); if(settings.flammable) settings.flammable.forEach(([x, y]) => this.world[x][y]=FLAMMABLE); // add the food settings.food.forEach(([x, y]) => this.world[x][y]=FOOD); this.fruits=[...settings.food]; // add the super food if(settings.superFood) settings.superFood.forEach(([x, y]) => this.world[x][y]=SUPER_FOOD); // add the decaying food if(settings.decayFood) { settings.decayFood.forEach(([x, y]) => this.world[x][y]=DECAY_FOOD); this.decayFood=settings.decayFood.map(([x, y]) => [x, y, 0]); } else { this.decayFood=[]; } } // 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]; this.length=this.snake.length; // 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, superFruitGrow: false, decayingFruitGrow: false, speedIncrease: true, worldWrap: true, winCondition: 'none', scoreSystem: 'fruit', fireTickSpeed: 10, autoSizeGrow: false, autoSpeedIncrease: false }, rules, settings.rules || {}); } get playTime() { return Date.now()-this.firstStep; } get speed() { return Math.round(1000/this.delay); } getTilesOfType(type) { return this .world .map( (l, x) => l .map( (r, y) => r==type?[x,y]:null ).filter( a => a ) ).flat(); } draw() { const assets=require('assets'); const config=require('config'); // 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 a grid/checkerboard if requested if(config.getS('appearance.grid')=='grid') { 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.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 putTileAnimPercent=(x, y, tile, percent) => putTile(x, y, tile[ Math.min(Math.round(percent*tile.length), tile.length-1) ]); 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 } case FLAMMABLE: case FLAMMABLE_S: putTile(x, y, flammable); break; case SUPER_FOOD: putTileAnim(x, y, superFruit); break; } } } // draw our decaying fruits (they have more information than just XY, so they need to be drawn here this.decayFood.forEach(([x, y, birth]) => putTileAnimPercent(x, y, decayFruit, (this.playTime-birth)/2000) ); // draw our snake (it gets drawn completely differently, so here it goes) 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 ); }); // 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); } 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; case FLAMMABLE_S: this.world[tail[0]][tail[1]]=FLAMMABLE; 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("literally fell out of the world", "exited the grid"); } } switch(this.world[head[0]][head[1]]) { // you hit, you die case WALL: return this.die("thought walls were edible", "hit a wall"); case FIRE: return this.die("burned to a crisp", "hit fire"); case SNAKE: case HOLE_S: case FLAMMABLE_S: return this.die("achieved every dog's dream", "ate their own tail"); // 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("fell harder than their grades", "fell in a hole"); break; // you eat, you get a massive score boost case SUPER_FOOD: this.score+=10; break; // you eat, you get a small score boost case DECAY_FOOD: this.score+=5; this.decayFood=this.decayFood.filter( ([x, y, _]) => !(x==head[0] && y==head[1]) ); break; // you eat, you grow case FOOD: // re-grow the snake partially (can't hit the tail, but it's there for all other intents and purposes this.snake.push(tail); this.length++; // 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.getTilesOfType(EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; this.fruits.push(cell); this.world[cell[0]][cell[1]]=FOOD; } if(this.rules.superFruitGrow) { if(Math.random()<.1) { // 10% chance const emptyCells=this.getTilesOfType(EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; this.world[cell[0]][cell[1]]=SUPER_FOOD; } } if(this.rules.decayingFruitGrow) { if(Math.random()<.2) { // 20% chance const emptyCells=this.getTilesOfType(EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; this.world[cell[0]][cell[1]]=DECAY_FOOD; this.decayFood.push([cell[0], cell[1], this.playTime]); } } if(this.rules.speedIncrease) { this.delay*=this.rules.speedMultiplier; if(this.delay { if(this.playTime>=birth+2000) this.world[x][y]=EMPTY; } ); this.decayFood=this.decayFood.filter( ([_, __, birth]) => this.playTime50 && 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); } // fire tick if(this.tickId%this.rules.fireTickSpeed==0) { const touchingFire=([x, y]) => { const surrounding=[ this.world[x][y-1], this.world[x][y+1], (this.world[x-1]||[])[y], (this.world[x+1]||[])[y] ]; return surrounding.some(tile => tile==FIRE); }; if(this.getTilesOfType(FLAMMABLE_S).some(touchingFire)) return this.die("didn't know oil was flammable", "stood on oil when it caught on fire"); this.getTilesOfType(FLAMMABLE).filter(touchingFire).forEach(([x, y]) => this.world[x][y]=FIRE); } // 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(message='died', reason='died') { this.playing=false; this.endPlayTime=this.playTime; this.death={message, reason}; if(this.callback) this.callback('die'); } handleInputs(inputs) { const config=require('config'); // 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])) 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; // buffering might be disabled if(!config.getB('input.buffer')) { 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;