const { tiles: T, forChar, getType, isPortal, snakeVersion, nonSnakeVersion }=require('tiles'); 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 l.forEach((c, y) => { if(c==T.PORTAL_A) this.portals.a=[x, y]; if(c==T.PORTAL_B) this.portals.b=[x, y]; if(c==T.PORTAL_C) this.portals.c=[x, y]; if(c==T.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]=T.WALL); // add the holes if(settings.holes) settings.holes.forEach(([x, y]) => this.world[x][y]=T.HOLE); // add the fires and flammable tiles if(settings.fires) settings.fires.forEach(([x, y]) => this.world[x][y]=T.FIRE); if(settings.flammable) settings.flammable.forEach(([x, y]) => this.world[x][y]=T.FLAMMABLE); // add the food settings.food.forEach(([x, y]) => this.world[x][y]=T.FOOD); this.fruits=[...settings.food]; // add the super food if(settings.superFood) settings.superFood.forEach(([x, y]) => this.world[x][y]=T.SUPER_FOOD); // add the decaying food if(settings.decayFood) { settings.decayFood.forEach(([x, y]) => this.world[x][y]=T.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]]=T.PORTAL_A; if(settings.portals.b) this.world[settings.portals.b[0]][settings.portals.b[1]]=T.PORTAL_B; if(settings.portals.c) this.world[settings.portals.c[0]][settings.portals.c[1]]=T.PORTAL_C; if(settings.portals.d) this.world[settings.portals.d[0]][settings.portals.d[1]]=T.PORTAL_D; this.portals={...settings.portals}; } else { this.portals={}; } // add the keys if(settings.keys) settings.keys.forEach(([x, y]) => this.world[x][y]=T.KEY); // add the doors if(settings.doors) settings.doors.forEach(([x, y]) => this.world[x][y]=T.DOOR); // add the switches if(settings.switches) settings.switches.forEach(([x, y]) => this.world[x][y]=T.SWITCH_OFF); // add the spikes if(settings.spikesOn) settings.spikesOn.forEach(([x, y]) => this.world[x][y]=T.SPIKES_ON); if(settings.spikesOff) settings.spikesOff.forEach(([x, y]) => this.world[x][y]=T.SPIKES_OFF); } // add the snake to the world settings.snake.forEach(([x, y]) => this.world[x][y]=T.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); } getTile(x, y) { return (this.world[x]||[])[y]; } getTileA([x, y]) { return (this.world[x]||[])[y]; } putTile(x, y, t) { this.world[x][y]=t; } putTileA([x, y], t) { this.world[x][y]=t; } getTilesOfType(type) { return this .world .map( (l, x) => l .map( (r, y) => r==type?[x,y]:null ).filter( a => a ) ).flat(); } replaceTilesOfType(type, newType) { this.world.forEach(l => l.forEach((t, i) => { if(t==type) l[i]=newType; }) ); } draw() { require('renderer').draw(this, this.canvas, this.ctx); } step() { this.tickId++; this.lastDirection=this.direction; // compute our new head let head; if(!this.portaled && isPortal(this.getTileA(this.snake[0]))) { const tile=this.getTileA(this.snake[0]); if(tile==T.PORTAL_A_S) head=this.portals.b; if(tile==T.PORTAL_B_S) head=this.portals.a; if(tile==T.PORTAL_C_S) head=this.portals.d; if(tile==T.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(); this.putTileA(tail, nonSnakeVersion(this.getTileA(tail))); // 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"); } } let tile=this.getTileA(head); switch(getType(tile)) { // you hit, you die case 'wall': switch(tile) { case T.WALL: return this.die("thought walls were edible", "hit a wall"); case T.FIRE: return this.die("burned to a crisp", "hit fire"); case T.DOOR: return this.die("forgot to OPEN the door", "hit a door"); case T.SPIKES_ON: return this.die("thought they were a girl's drink in a nightclub", "hit spikes"); } // congratilations, you played yourself! case 'snake': 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.getTileA(this.snake[0])==T.HOLE_S || this.snake.length>=2 && this.getTileA(this.snake[0])==T.HOLE_S && this.getTileA(this.snake[1])==T.HOLE_S ) return this.die("fell harder than their grades", "fell in a hole"); break; // you eat, you get a massive score boost case 'bonus': this.putTileA(head, T.EMPTY); switch(tile) { case T.SUPER_FOOD: this.score+=10; // you eat, you get a small score boost case T.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.putTileA(head, T.EMPTY); this.replaceTilesOfType(T.DOOR, T.EMPTY); break; // you step on, you trigger case 'switch': { this.putTileA(head, tile==T.SWITCH_ON?T.SWITCH_OFF:T.SWITCH_ON); if(this.getTilesOfType(T.SPIKES_OFF_S).length) return this.die("spiked themselves", "activated spikes"); const oldSpikes=this.getTilesOfType(T.SPIKES_ON); this.replaceTilesOfType(T.SPIKES_OFF, T.SPIKES_ON); oldSpikes.forEach(pos => this.putTileA(pos, T.SPIKES_OFF)); } break; // you eat, you grow case 'food': // re-grow the snake this.snake.push(tail); this.putTileA(tail, snakeVersion(this.getTileA(tail))); this.length++; // remove the fruit from existence this.putTileA(head, T.EMPTY); 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(T.EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; this.fruits.push(cell); this.putTileA(cell, T.FOOD); } if(this.rules.superFruitGrow) { if(Math.random()<.1) { // 10% chance const emptyCells=this.getTilesOfType(T.EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; this.putTileA(cell, T.SUPER_FOOD); } } if(this.rules.decayingFruitGrow) { if(Math.random()<.2) { // 20% chance const emptyCells=this.getTilesOfType(T.EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; this.putTileA(cell, T.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.putTile(x, y, T.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.getTile(x, y-1), this.getTile(x, y+1), this.getTile(x-1, y), this.getTile(x-1, y) ]; return surrounding.some(tile => tile==T.FIRE); }; if(this.getTilesOfType(T.FLAMMABLE_S).some(touchingFire)) return this.die("didn't know oil was flammable", "stood on oil when it caught on fire"); this.getTilesOfType(T.FLAMMABLE).filter(touchingFire).forEach(pos => this.putTileA(pos, T.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;