diff --git a/src/js/renderer.js b/src/js/renderer.js new file mode 100644 index 0000000..37fea3a --- /dev/null +++ b/src/js/renderer.js @@ -0,0 +1,329 @@ +const assets=require('assets'); +const config=require('config'); +const {tiles: T}=require('tiles'); + +// declare our tiles +let snake, + wall, hole, + fire, flammable, + fruit, superFruit, decayFruit, + portalA, portalB, portalC, portalD, + key, door, + switchTile, spikes; + +// load our tiles +assets.onReady(() => { + wall=assets.get('wall'); + hole=assets.get('hole'); + fire=assets.get('fire'); + flammable=assets.get('flammable'); + superFruit=assets.get('superFruit'); + decayFruit=assets.get('decayFruit'); + portalA=assets.get('portalA'); + portalB=assets.get('portalB'); + portalC=assets.get('portalC'); + portalD=assets.get('portalD'); + key=assets.get('key'); + door=assets.get('door'); + switchTile=assets.get('switch'); + spikes=assets.get('spikes'); + snake=assets.get('snake'); + fruit=assets.get('fruit'); +}); + +const draw=(game, canvas=game.canvas, ctx=canvas.getContext('2d')) => { + // clear the canvas, because it's easier than having to deal with everything + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // get the cell size and offset + const cellSize=Math.min( + canvas.width/game.dimensions[0], + canvas.height/game.dimensions[1] + ); + const offsetX=(canvas.width-cellSize*game.dimensions[0])/2; + const offsetY=(canvas.height-cellSize*game.dimensions[1])/2; + + // tile draw functions + const putTile=(x, y, tile) => 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) + ]); + + // adjascence check + const checkAdj=(x, y) => { + let adj={}; + adj.u=game.world[x][y-1]; + adj.d=game.world[x][y+1]; + adj.l=(game.world[x-1] || [])[y]; + adj.r=(game.world[x+1] || [])[y]; + adj.ul=(game.world[x-1] || [])[y-1]; + adj.ur=(game.world[x+1] || [])[y-1]; + adj.dl=(game.world[x-1] || [])[y+1]; + adj.dr=(game.world[x+1] || [])[y+1]; + return adj; + }; + + // draw a grid/checkerboard if requested + if(config.getS('appearance.grid')=='grid') { + ctx.strokeStyle='rgba(0, 0, 0, 50%)'; + ctx.lineCap='square'; + ctx.lineWidth=1; + ctx.beginPath(); + for(let x=1; x adj[k]==T.HOLE || adj[k]==T.HOLE_S) + .forEach(k => putTile(x, y, hole[k])); + } break; + + case T.FLAMMABLE: + case T.FLAMMABLE_S: + putTile(x, y, flammable); + break; + + case T.SUPER_FOOD: + putTileAnim(x, y, superFruit); + break; + + case T.PORTAL_A: + case T.PORTAL_A_S: + putTileAnim(x, y, portalA); + break; + case T.PORTAL_B: + case T.PORTAL_B_S: + putTileAnim(x, y, portalB); + break; + case T.PORTAL_C: + case T.PORTAL_C_S: + putTileAnim(x, y, portalC); + break; + case T.PORTAL_D: + case T.PORTAL_D_S: + putTileAnim(x, y, portalD); + break; + + case T.KEY: + putTile(x, y, key); + break; + case T.DOOR: + putTile(x, y, door); + break; + + case T.SWITCH_ON: + case T.SWITCH_ON_S: + putTile(x, y, switchTile.on); + break; + case T.SWITCH_OFF: + case T.SWITCH_OFF_S: + putTile(x, y, switchTile.off); + break; + + case T.SPIKES_ON: + putTile(x, y, spikes.on); + break; + case T.SPIKES_OFF: + case T.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 + game.decayFood.forEach(([x, y, birth]) => + putTileAnimPercent(x, y, decayFruit, (game.playTime-birth)/2000) + ); + + // draw the lines between portals + if(Object.keys(game.portals).length) { + ctx.strokeStyle='rgba(128, 128, 128, 20%)'; + ctx.lineCap='round'; + 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); + ctx.beginPath(); + ctx.moveTo( + offsetX+cellSize*(xa+1/2)+dx, + offsetY+cellSize*(ya+1/2)+dy + ); + ctx.lineTo( + offsetX+cellSize*(xb+1/2)+dx, + offsetY+cellSize*(yb+1/2)+dy + ); + ctx.stroke(); + } + }; + if(game.portals.a && game.portals.b) drawTunnel(game.portals.a, game.portals.b); + if(game.portals.c && game.portals.d) drawTunnel(game.portals.c, game.portals.d); + } + + // draw our snake (it gets drawn completely differently, so here it goes) + { + ctx.fillStyle=snake.color; + ctx.strokeStyle=snake.color; + ctx.lineCap=snake.cap; + ctx.lineJoin=snake.join; + ctx.lineWidth=cellSize*snake.tailSize; + + ctx.beginPath(); + ctx.ellipse( + offsetX+cellSize*(game.snake[0][0]+1/2), + offsetY+cellSize*(game.snake[0][1]+1/2), + cellSize/2*snake.headSize, + cellSize/2*snake.headSize, + 0, + 0, + Math.PI*2 + ); + ctx.fill(); + + ctx.beginPath(); + game.snake.forEach(([x, y], i, a) => { + 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) { + ctx.lineWidth=cellSize*snake.tailWrapSize; + } else { + ctx.lineWidth=cellSize*snake.tailSize; + } + ctx.stroke(); + ctx.beginPath() + ctx.moveTo( + offsetX+cellSize*(x+1/2), + offsetY+cellSize*(y+1/2) + ); + }); + 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 + game.fruits.forEach(([x, y]) => { + 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(game.rules.winCondition=='time') { + if(config.getS('appearance.timer')=='border' || config.getS('appearance.timer')=='both') { + let remaining=(game.rules.gameDuration-game.playTime)/game.rules.gameDuration; + const w=game.dimensions[0]*cellSize; + const h=game.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; + + ctx.strokeStyle='#930a16'; + ctx.lineJoin='miter'; + ctx.lineCap='round'; + ctx.lineWidth=5; + ctx.beginPath(); + ctx.moveTo(canvas.width/2, offsetY+2); + + let sp=Math.min(wp/2, remaining); + remaining-=sp; + ctx.lineTo(pdst(canvas.width/2, w+offsetX-2, sp/wp*2), offsetY+2); + if(remaining) { + sp=Math.min(hp, remaining); + remaining-=sp; + ctx.lineTo(w+offsetX-2, pdst(offsetY+2, offsetY+h-2, sp/hp)); + } + if(remaining) { + sp=Math.min(wp, remaining); + remaining-=sp; + ctx.lineTo(pdst(w+offsetX-2, offsetX+2, sp/wp), offsetY+h-2); + } + if(remaining) { + sp=Math.min(hp, remaining); + remaining-=sp; + ctx.lineTo(offsetX+2, pdst(offsetY+h-2, offsetY+2, sp/hp)); + } + if(remaining) { + ctx.lineTo(pdst(offsetX+2, canvas.width/2, remaining/wp*2), offsetY+2); + } + ctx.stroke(); + } + if(config.getS('appearance.timer')=='number' || config.getS('appearance.timer')=='both') { + let remaining=''+Math.ceil((game.rules.gameDuration-game.playTime)/1000); + while(remaining.length<(''+game.rules.gameDuration/1000).length) remaining='0'+remaining; + + ctx.fillStyle='#930a16'; + ctx.textAlign='center'; + ctx.textBaseline='middle'; + ctx.font='4rem "Fira Code"'; + ctx.fillText(remaining, canvas.width/2, canvas.height/2); + } + } + + // draw the border around our game area + { + ctx.fillStyle='black'; + ctx.fillRect(0, 0, canvas.width, offsetY); + ctx.fillRect(0, 0, offsetX, canvas.height); + ctx.fillRect(offsetX+cellSize*game.dimensions[0], 0, offsetX, canvas.height); + ctx.fillRect(0, offsetY+cellSize*game.dimensions[1], canvas.width, offsetY); + } +}; + +return module.exports={ + draw +}; diff --git a/src/js/snek.js b/src/js/snek.js index 3cf507d..e3cd454 100644 --- a/src/js/snek.js +++ b/src/js/snek.js @@ -1,13 +1,10 @@ -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(); +const { + tiles: T, + forChar, + getType, + isPortal, + snakeVersion, nonSnakeVersion +}=require('tiles'); class SnekGame { constructor(settings, canvas, rules) { @@ -25,27 +22,7 @@ class SnekGame { 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; - } - })(); + this.world[x][y]=forChar(settings.world[y][x]); } } @@ -53,19 +30,19 @@ class SnekGame { this.dimensions=[this.world.length, this.world[0].length]; // extract the fruits - this.fruits=this.getTilesOfType(FOOD); + this.fruits=this.getTilesOfType(T.FOOD); // extract the decaying fruits - this.decayFood=this.getTilesOfType(DECAY_FOOD); + this.decayFood=this.getTilesOfType(T.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]; + 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 @@ -77,29 +54,29 @@ class SnekGame { this.world=Array(settings.dimensions[0]); for(let i=0; i this.world[x][y]=WALL); + if(settings.walls) settings.walls.forEach(([x, y]) => this.world[x][y]=T.WALL); // add the holes - if(settings.holes) settings.holes.forEach(([x, y]) => this.world[x][y]=HOLE); + 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]=FIRE); - if(settings.flammable) settings.flammable.forEach(([x, y]) => this.world[x][y]=FLAMMABLE); + 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]=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]=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]=DECAY_FOOD); + 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=[]; @@ -107,31 +84,31 @@ class SnekGame { // 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; + 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]=KEY); + 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]=DOOR); + 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]=SWITCH_OFF); + 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]=SPIKES_ON); - if(settings.spikesOff) settings.spikesOff.forEach(([x, y]) => this.world[x][y]=SPIKES_OFF); + 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]=SNAKE); + settings.snake.forEach(([x, y]) => this.world[x][y]=T.SNAKE); // get the head and initial direction @@ -189,6 +166,19 @@ class SnekGame { 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 @@ -201,310 +191,16 @@ class SnekGame { ) ).flat(); } + replaceTilesOfType(type, newType) { + this.world.forEach(l => + l.forEach((t, i) => { + if(t==type) l[i]=newType; + }) + ); + } 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); + require('renderer').draw(this, this.canvas, this.ctx); } step() { @@ -513,12 +209,12 @@ class SnekGame { // 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; + 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=[ @@ -530,37 +226,7 @@ class SnekGame { // 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; - } + 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]) { @@ -572,74 +238,72 @@ class SnekGame { } } - switch(this.world[head[0]][head[1]]) { + let tile=this.getTileA(head); + switch(getType(tile)) { // 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"); + 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: - 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: + 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: + case 'hole': if( this.snake.length==0 || this.snake.length==1 && - this.world[this.snake[0][0]][this.snake[0][1]]==HOLE_S || + this.getTileA(this.snake[0])==T.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 + 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 SUPER_FOOD: - this.score+=10; - break; + 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 DECAY_FOOD: - this.score+=5; - this.decayFood=this.decayFood.filter( - ([x, y, _]) => !(x==head[0] && y==head[1]) - ); - break; + // 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.getTilesOfType(DOOR).forEach(([x, y]) => this.world[x][y]=EMPTY); + case 'key': + this.putTileA(head, T.EMPTY); + this.replaceTilesOfType(T.DOOR, T.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); + 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 partially (can't hit the tail, but it's there for all other intents and purposes + 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.world[head[0]][head[1]]=SNAKE; + this.putTileA(head, T.EMPTY); this.fruits=this.fruits.filter( ([x, y]) => !(x==head[0] && y==head[1]) ); @@ -649,26 +313,26 @@ class SnekGame { // custom rules if(this.rules.fruitRegrow) { - const emptyCells=this.getTilesOfType(EMPTY); + const emptyCells=this.getTilesOfType(T.EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; this.fruits.push(cell); - this.world[cell[0]][cell[1]]=FOOD; + this.putTileA(cell, T.FOOD); } if(this.rules.superFruitGrow) { if(Math.random()<.1) { // 10% chance - const emptyCells=this.getTilesOfType(EMPTY); + const emptyCells=this.getTilesOfType(T.EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; - this.world[cell[0]][cell[1]]=SUPER_FOOD; + this.putTileA(cell, T.SUPER_FOOD); } } if(this.rules.decayingFruitGrow) { if(Math.random()<.2) { // 20% chance - const emptyCells=this.getTilesOfType(EMPTY); + const emptyCells=this.getTilesOfType(T.EMPTY); const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; - this.world[cell[0]][cell[1]]=DECAY_FOOD; + this.putTileA(cell, T.DECAY_FOOD); this.decayFood.push([cell[0], cell[1], this.playTime]); } } @@ -680,43 +344,14 @@ class SnekGame { } // move our head forward - switch(this.world[head[0]][head[1]]) { - case HOLE: - this.world[head[0]][head[1]]=HOLE_S; - break; - case FLAMMABLE: - this.world[head[0]][head[1]]=FLAMMABLE_S; - break; - case PORTAL_A: - this.world[head[0]][head[1]]=PORTAL_A_S; - break; - case PORTAL_B: - this.world[head[0]][head[1]]=PORTAL_B_S; - break; - case PORTAL_C: - this.world[head[0]][head[1]]=PORTAL_C_S; - break; - case PORTAL_D: - this.world[head[0]][head[1]]=PORTAL_D_S; - break; - case SWITCH_ON: - this.world[head[0]][head[1]]=SWITCH_ON_S; - break; - case SWITCH_OFF: - this.world[head[0]][head[1]]=SWITCH_OFF_S; - break; - case SPIKES_OFF: - this.world[head[0]][head[1]]=SPIKES_OFF_S; - break; - default: - this.world[head[0]][head[1]]=SNAKE; - } + tile=this.getTileA(head); + this.putTileA(head, snakeVersion(tile)); this.snake.unshift(head); // decay decaying food this.decayFood.forEach( ([x, y, birth]) => { - if(this.playTime>=birth+2000) this.world[x][y]=EMPTY; + if(this.playTime>=birth+2000) this.putTile(x, y, T.EMPTY); } ); this.decayFood=this.decayFood.filter( @@ -737,15 +372,15 @@ class SnekGame { 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] + 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==FIRE); + return surrounding.some(tile => tile==T.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); + 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! diff --git a/src/js/tiles.js b/src/js/tiles.js new file mode 100644 index 0000000..387af12 --- /dev/null +++ b/src/js/tiles.js @@ -0,0 +1,221 @@ +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(); + +const tiles={ + 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 +}; + +const tileNames=(() => { + let tileNames=[]; + Object.keys(tiles).forEach(key => tileNames[tiles[key]]=key); + return tileNames; +})(); + +const getName=t => + tileNames[t] || `Unknown tile ${t}`; + +const forChar=c => { + switch(c) { + 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; + } + throw TypeError(`'${c}' doesn't correspond to any tile`); +}; + +const charFor=t => { + switch(t) { + case EMPTY: return ' '; + case FOOD: return 'f'; + case SUPER_FOOD: return 'F'; + case DECAY_FOOD: return 'd'; + case WALL: return 'w'; + case HOLE: return 'o'; + case FIRE: return 'i'; + case FLAMMABLE: return 'I'; + case PORTAL_A: return 'A'; + case PORTAL_B: return 'B'; + case PORTAL_C: return 'C'; + case PORTAL_D: return 'D'; + case KEY: return 'k'; + case DOOR: return 'K'; + case SWITCH_OFF: return 's'; + case SPIKES_ON: return 'S'; + case SPIKES_OFF: return 't'; + } + throw TypeError(`'${getName(t)}' doesn't have a corresponding character'`); +}; + +const snakeVersion=t => { + switch(t) { + case EMPTY: return SNAKE; + case HOLE: return HOLE_S; + case FLAMMABLE: return FLAMMABLE_S; + case PORTAL_A: return PORTAL_A_S; + case PORTAL_B: return PORTAL_B_S; + case PORTAL_C: return PORTAL_C_S; + case PORTAL_D: return PORTAL_D_S; + case SWITCH_OFF: return SWITCH_OFF_S; + case SWITCH_ON: return SWITCH_ON_S; + case SPIKES_OFF: return SPIKES_OFF_S; + } + throw TypeError(`'${getName(t)}' doesn't have a snake version'`); +}; + +const nonSnakeVersion=t => { + switch(t) { + case SNAKE: return EMPTY; + case HOLE_S: return HOLE; + case FLAMMABLE_S: return FLAMMABLE; + case PORTAL_A_S: return PORTAL_A; + case PORTAL_B_S: return PORTAL_B; + case PORTAL_C_S: return PORTAL_C; + case PORTAL_D_S: return PORTAL_D; + case SWITCH_OFF_S: return SWITCH_OFF; + case SWITCH_ON_S: return SWITCH_ON; + case SPIKES_OFF_S: return SPIKES_OFF; + } + throw TypeError(`'${getName(t)}' doesn't have a non-snake version'`); +}; + +const isSnakeVersion=t => { + switch(t) { + 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 true; + } + return false; +}; + +const isNonSnakeVersion=t => { + switch(t) { + case EMPTY: + case HOLE: + case FLAMMABLE: + case PORTAL_A: case PORTAL_B: case PORTAL_C: case PORTAL_D: + case SWITCH_OFF: case SWITCH_ON: + case SPIKES_OFF: + return true; + } + return false; +}; + +const isSafe=t => { + switch(t) { + case EMPTY: + case HOLE: + case FLAMMABLE: + case PORTAL_A: case PORTAL_B: case PORTAL_C: case PORTAL_D: + case SWITCH_OFF: case SWITCH_ON: + case SPIKES_OFF: + case FOOD: case SUPER_FOOD: case DECAY_FOOD: + case KEY: + return true; + } + return false; +}; + +const isWall=t => { + switch(t) { + case WALL: + case FIRE: + case DOOR: + case SPIKES_ON: + return true; + } + return false; +}; + +const getType=t => { + if(isSnakeVersion(t)) return 'snake'; + if(isWall(t)) return 'wall'; + + switch(t) { + case EMPTY: + case FLAMMABLE: + case SPIKES_OFF: + return 'empty'; + + case FOOD: + return 'food'; + + case SUPER_FOOD: + case DECAY_FOOD: + return 'bonus'; + + case HOLE: + return 'hole'; + + case PORTAL_A: + case PORTAL_B: + case PORTAL_C: + case PORTAL_D: + return 'portal'; + + case KEY: + return 'key'; + + case SWITCH_ON: + case SWITCH_OFF: + return 'switch'; + } + + throw TypeError(`'${getName(t)}' isn't a valid tile`); +}; + +const isPortal=t => { + switch(t) { + case PORTAL_A: + case PORTAL_B: + case PORTAL_C: + case PORTAL_D: + case PORTAL_A_S: + case PORTAL_B_S: + case PORTAL_C_S: + case PORTAL_D_S: + return true; + } + return false; +}; + +return module.exports={ + tiles, + tileNames, getName, + forChar, charFor, + snakeVersion, nonSnakeVersion, isSnakeVersion, isNonSnakeVersion, + isSafe, isWall, isPortal, + getType +};