const [ EMPTY, SNAKE, FOOD, SUPER_FOOD, DECAY_FOOD, WALL, FIRE, FLAMMABLE, FLAMMABLE_S, HOLE, HOLE_S, PORTAL_A, PORTAL_A_S, PORTAL_B, PORTAL_B_S, PORTAL_C, PORTAL_C_S, PORTAL_D, PORTAL_D_S, KEY, DOOR, SWITCH_ON, SWITCH_ON_S, SWITCH_OFF, SWITCH_OFF_S, SPIKES_OFF, SPIKES_OFF_S, SPIKES_ON ]=Array(255).keys(); class SnekGame { constructor(settings, canvas, rules) { // setup the delay this.delay=settings.delay || Infinity; // score starts at 0 this.score=0; // 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; case 'A': return PORTAL_A; case 'B': return PORTAL_B; case 'C': return PORTAL_C; case 'D': return PORTAL_D; case 'k': return KEY; case 'K': return DOOR; case 's': return SWITCH_OFF; case 'S': return SPIKES_ON; case 't': return SPIKES_OFF; } })(); } } // extract the dimensions this.dimensions=[this.world.length, this.world[0].length]; // extract the fruits this.fruits=this.getTilesOfType(FOOD); // extract the decaying fruits this.decayFood=this.getTilesOfType(DECAY_FOOD); // extract the portals this.portals={}; this.world.forEach((l, x) => l.forEach((c, y) => { if(c==PORTAL_A) this.portals.a=[x, y]; if(c==PORTAL_B) this.portals.b=[x, y]; if(c==PORTAL_C) this.portals.c=[x, y]; if(c==PORTAL_D) this.portals.d=[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 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 portals if(settings.portals) { if(settings.portals.a) this.world[settings.portals.a[0]][settings.portals.a[1]]=PORTAL_A; if(settings.portals.b) this.world[settings.portals.b[0]][settings.portals.b[1]]=PORTAL_B; if(settings.portals.c) this.world[settings.portals.c[0]][settings.portals.c[1]]=PORTAL_C; if(settings.portals.d) this.world[settings.portals.d[0]][settings.portals.d[1]]=PORTAL_D; this.portals={...settings.portals}; } else { this.portals={}; } // add the keys if(settings.keys) settings.keys.forEach(([x, y]) => this.world[x][y]=KEY); // add the doors if(settings.doors) settings.doors.forEach(([x, y]) => this.world[x][y]=DOOR); // add the switches if(settings.switches) settings.switches.forEach(([x, y]) => this.world[x][y]=SWITCH_OFF); // add the spikes if(settings.spikesOn) settings.spikesOn.forEach(([x, y]) => this.world[x][y]=SPIKES_ON); if(settings.spikesOff) settings.spikesOff.forEach(([x, y]) => this.world[x][y]=SPIKES_OFF); } // 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, timeFlow: true }, rules, settings.rules || {}); // reset direction if time doesn't flow if(!this.rules.timeFlow) { this.lastDirection=[0, 0]; this.direction=[0, 0]; } // set score if move-based if(this.rules.scoreSystem=='moves') { this.score=this.rules.moveCount; } } 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; case PORTAL_A: case PORTAL_A_S: putTileAnim(x, y, portalA); break; case PORTAL_B: case PORTAL_B_S: putTileAnim(x, y, portalB); break; case PORTAL_C: case PORTAL_C_S: putTileAnim(x, y, portalC); break; case PORTAL_D: case PORTAL_D_S: putTileAnim(x, y, portalD); break; case KEY: putTile(x, y, key); break; case DOOR: putTile(x, y, door); break; case SWITCH_ON: case SWITCH_ON_S: putTile(x, y, switchTile.on); break; case SWITCH_OFF: case SWITCH_OFF_S: putTile(x, y, switchTile.off); break; case SPIKES_ON: putTile(x, y, spikes.on); break; case SPIKES_OFF: case SPIKES_OFF_S: putTile(x, y, spikes.off); 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 the lines between portals if(Object.keys(this.portals).length) { this.ctx.strokeStyle='rgba(128, 128, 128, 20%)'; this.ctx.lineCap='round'; this.ctx.lineWidth=cellSize*.15; const drawTunnel=([xa, ya], [xb, yb]) => { const angle=(Math.floor(Date.now()/10)%360)*(Math.PI/180); for(let i=0; i<=1; i++) { const dx=cellSize/3*Math.cos(angle+i*Math.PI); const dy=cellSize/3*Math.sin(angle+i*Math.PI); this.ctx.beginPath(); this.ctx.moveTo( offsetX+cellSize*(xa+1/2)+dx, offsetY+cellSize*(ya+1/2)+dy ); this.ctx.lineTo( offsetX+cellSize*(xb+1/2)+dx, offsetY+cellSize*(yb+1/2)+dy ); this.ctx.stroke(); } }; if(this.portals.a && this.portals.b) drawTunnel(this.portals.a, this.portals.b); if(this.portals.c && this.portals.d) drawTunnel(this.portals.c, this.portals.d); } // 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 let head; if(!this.portaled && [PORTAL_A_S, PORTAL_B_S, PORTAL_C_S, PORTAL_D_S].includes(this.world[this.snake[0][0]][this.snake[0][1]])) { const tile=this.world[this.snake[0][0]][this.snake[0][1]]; if(tile==PORTAL_A_S) head=this.portals.b; if(tile==PORTAL_B_S) head=this.portals.a; if(tile==PORTAL_C_S) head=this.portals.d; if(tile==PORTAL_D_S) head=this.portals.c; this.portaled=true; } else { head=[ this.snake[0][0]+this.direction[0], this.snake[0][1]+this.direction[1] ]; this.portaled=false; } // 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; case PORTAL_A_S: this.world[tail[0]][tail[1]]=PORTAL_A; break; case PORTAL_B_S: this.world[tail[0]][tail[1]]=PORTAL_B; break; case PORTAL_C_S: this.world[tail[0]][tail[1]]=PORTAL_C; break; case PORTAL_D_S: this.world[tail[0]][tail[1]]=PORTAL_D; break; case SWITCH_ON_S: this.world[tail[0]][tail[1]]=SWITCH_ON; break; case SWITCH_OFF_S: this.world[tail[0]][tail[1]]=SWITCH_OFF; break; case SPIKES_OFF_S: this.world[tail[0]][tail[1]]=SPIKES_OFF; 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 DOOR: return this.die("forgot to OPEN the door", "hit a door"); case SPIKES_ON: return this.die("thought they were a girl's drink in a nightclub", "hit spikes"); // congratilations, you played yourself! case SNAKE: case HOLE_S: case FLAMMABLE_S: case PORTAL_A_S: case PORTAL_B_S: case PORTAL_C_S: case PORTAL_D_S: case SWITCH_OFF_S: case SWITCH_ON_S: case SPIKES_OFF_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 destroy all doors case KEY: this.getTilesOfType(DOOR).forEach(([x, y]) => this.world[x][y]=EMPTY); break; // you step on, you trigger case SWITCH_ON: case SWITCH_OFF: { this.world[head[0]][head[1]]=this.world[head[0]][head[1]]==SWITCH_ON?SWITCH_OFF:SWITCH_ON; if(this.getTilesOfType(SPIKES_OFF_S).length) return this.die("spiked themselves", "activated spikes"); const oldSpikes=this.getTilesOfType(SPIKES_ON); this.getTilesOfType(SPIKES_OFF).forEach(([x, y]) => this.world[x][y]=SPIKES_ON); oldSpikes.forEach(([x, y]) => this.world[x][y]=SPIKES_OFF); } 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); } // THE WORLD! if(!this.rules.timeFlow) { this.lastDirection=[0, 0]; this.direction=[0, 0]; } // 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(); } if(this.rules.scoreSystem=='moves') { if(this.score) this.score--; } } tick() { if(!this.playing) return; if(!this.lastStep) this.lastStep=this.firstStep; this.draw(); if(this.callback) this.callback('tick'); if(this.rules.timeFlow && 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; requestAnimationFrame(() => this.tick()); } } return module.exports=SnekGame;