diff --git a/Makefile b/Makefile index b6b42b1..76f3c67 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,14 @@ FIRE_ANIM = $(foreach angle, $(shell seq 0 6 359), build/fire$(angle).png) PEACH_DECAY_ANIM = $(foreach percent, $(shell seq 99 -1 0), build/peach-decay$(percent).png) PEACH_RAINBOW_ANIM = $(foreach percent, $(shell seq 100 2 299), build/peach-rainbow$(percent).png) +PORTAL_A_ANIM = $(foreach angle, $(shell seq 0 6 359), build/portal-a$(angle).png) +PORTAL_B_ANIM = $(foreach angle, $(shell seq 0 6 359), build/portal-b$(angle).png) +PORTAL_C_ANIM = $(foreach angle, $(shell seq 0 6 359), build/portal-c$(angle).png) +PORTAL_D_ANIM = $(foreach angle, $(shell seq 0 6 359), build/portal-d$(angle).png) IMAGES = $(foreach name, apple wall oil, public/assets/$(name)32.png) TILESETS = $(foreach name, hole, public/assets/$(name)-ts.png) -ANIMATIONS = $(foreach name, fire peach-decay peach-rainbow, public/assets/$(name)-anim.png) +ANIMATIONS = $(foreach name, fire peach-decay peach-rainbow portal-a portal-b portal-c portal-d, public/assets/$(name)-anim.png) JSON = $(foreach name, snake levelList config metaConfig, public/assets/$(name).json) ICON = public/assets/icon32.png public/assets/icon256.png public/favicon.ico CSS = public/css/snek.css @@ -58,6 +62,31 @@ public/assets/peach-rainbow-anim.png: $(PEACH_RAINBOW_ANIM) build/peach-rainbow%.png: assets/peach.png convert $^ -modulate 100,100,$(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ +build/portal-b.png: assets/portal.png + convert $^ -modulate 100,100,200 $@ +build/portal-c.png: assets/portal.png + convert $^ -modulate 100,100,150 $@ +build/portal-d.png: assets/portal.png + convert $^ -modulate 100,100,50 $@ + +build/portal-a%.png: assets/portal.png + convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ +build/portal-b%.png: build/portal-b.png + convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ +build/portal-c%.png: build/portal-c.png + convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ +build/portal-d%.png: build/portal-d.png + convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ + +public/assets/portal-a-anim.png: $(PORTAL_A_ANIM) + convert $^ -append $@ +public/assets/portal-b-anim.png: $(PORTAL_B_ANIM) + convert $^ -append $@ +public/assets/portal-c-anim.png: $(PORTAL_C_ANIM) + convert $^ -append $@ +public/assets/portal-d-anim.png: $(PORTAL_D_ANIM) + convert $^ -append $@ + public/assets/%.json: assets/%.json cp $^ $@ diff --git a/api.js b/api.js index 9c3a8b2..d837747 100644 --- a/api.js +++ b/api.js @@ -78,7 +78,7 @@ api.post('/leaderboards/:category/:id', (req, res) => { err: 'Invalid time' }); const speed=req.body.speed; - if((typeof speed)!='number' || speed%1 || speed<1) return res.status(400).json({ + if((typeof speed)!='number' || speed%1 || speed<0) return res.status(400).json({ ok: false, err: 'Invalid speed' }); diff --git a/assets/levelList.json b/assets/levelList.json index 1e960a1..1bedb1b 100644 --- a/assets/levelList.json +++ b/assets/levelList.json @@ -41,5 +41,25 @@ "Get a score as high as you can in 30 seconds", "Survive for as long as you can in an increasingly difficult game" ] + }, + "puzzle": { + "desc": "Time doesn't flow in these puzzles. Try getting the fruits in as little moves as possible", + "rules": { + "fruitRegrow": false, + "timeFlow": false, + "speedIncrease": false, + "worldWrap": false, + "winCondition": "fruit", + "scoreSystem": "moves", + "moveCount": 50, + "uploadOnDeath": false, + "leaderboardsSort": "score" + }, + "levelFilename": "puzzle.json", + "levelDisplay": "Level ", + "levels": [ + 1 + ], + "nextLevel": true } } diff --git a/assets/portal.png b/assets/portal.png new file mode 100755 index 0000000..7569324 Binary files /dev/null and b/assets/portal.png differ diff --git a/levels/puzzle1.json b/levels/puzzle1.json new file mode 100644 index 0000000..4f34363 --- /dev/null +++ b/levels/puzzle1.json @@ -0,0 +1,21 @@ +{ + "dimensions": [15, 10], + "walls": [ + [5, 0], [5, 1], [5, 2], [5, 3], [5, 4], [5, 5], [5, 6], [5, 7], [5, 8], [5, 9], + [10, 0], [10, 1], [10, 2], [10, 3], [10, 4], [10, 5], [10, 6], [10, 7], [10, 8], [10, 9] + ], + "food": [ + [4, 5], + [8, 7], + [14, 9] + ], + "snake": [ + [0, 0] + ], + "portals": { + "a": [4, 9], + "b": [6, 0], + "c": [9, 9], + "d": [11, 0] + } +} diff --git a/src/js/assets.js b/src/js/assets.js index f289e4b..028a592 100644 --- a/src/js/assets.js +++ b/src/js/assets.js @@ -8,6 +8,10 @@ const assetSpecs=[ { name: 'flammable', filename: 'oil32.png', type: 'image' }, { name: 'hole', filename: 'hole-ts.png', type: 'image' }, { name: 'fire', filename: 'fire-anim.png', type: 'image' }, + { name: 'portalA', filename: 'portal-a-anim.png', type: 'image' }, + { name: 'portalB', filename: 'portal-b-anim.png', type: 'image' }, + { name: 'portalC', filename: 'portal-c-anim.png', type: 'image' }, + { name: 'portalD', filename: 'portal-d-anim.png', type: 'image' }, { name: 'snake', filename: 'snake.json', type: 'json' }, { name: 'levelList', filename: 'levelList.json', type: 'json' }, { name: 'config', filename: 'config.json', type: 'json' }, @@ -16,7 +20,11 @@ const assetSpecs=[ const tasks=[ { from: 'hole', type: 'tileset', steps: 3, tiles: ['base', 'ul', 'dr', 'dl', 'ur', 'l', 'r', 'd', 'u'] }, - { from: 'fire', type: 'animation', steps: 6 }, + { from: 'fire', type: 'animation', steps: 3 }, + { from: 'portalA', type: 'animation', steps: 3 }, + { from: 'portalB', type: 'animation', steps: 3 }, + { from: 'portalC', type: 'animation', steps: 3 }, + { from: 'portalD', type: 'animation', steps: 3 }, { from: 'superFruit', type: 'animation', steps: 5 }, { from: 'decayFruit', type: 'animation', steps: 5 } ]; diff --git a/src/js/snek.js b/src/js/snek.js index d2c5a8d..1806b51 100644 --- a/src/js/snek.js +++ b/src/js/snek.js @@ -1,9 +1,12 @@ -const [EMPTY, FOOD, SUPER_FOOD, DECAY_FOOD, WALL, FIRE, FLAMMABLE, FLAMMABLE_S, HOLE, HOLE_S, SNAKE]=Array(255).keys(); +const [EMPTY, 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, SNAKE]=Array(255).keys(); class SnekGame { constructor(settings, canvas, rules) { // setup the delay - this.delay=settings.delay; + this.delay=settings.delay || Infinity; + + // score starts at 0 + this.score=0; // world is given in the level if(settings.world) { // explicitly @@ -23,6 +26,10 @@ class SnekGame { 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; } })(); } @@ -32,22 +39,21 @@ class SnekGame { 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]); - } - )); + this.fruits=this.getTilesOfType(FOOD); // 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]); - } - )); + 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 @@ -84,6 +90,17 @@ class SnekGame { } 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 snake to the world @@ -121,8 +138,20 @@ class SnekGame { scoreSystem: 'fruit', fireTickSpeed: 10, autoSizeGrow: false, - autoSpeedIncrease: 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() { @@ -192,6 +221,10 @@ class SnekGame { const flammable=assets.get('flammable'); const superFruit=assets.get('superFruit'); const decayFruit=assets.get('decayFruit'); + const portalA=assets.get('portalA'); + const portalB=assets.get('portalB'); + const portalC=assets.get('portalC'); + const portalD=assets.get('portalD'); const putTile=(x, y, tile) => this.ctx.drawImage( tile, offsetX+cellSize*x, @@ -249,6 +282,23 @@ class SnekGame { 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; } } } @@ -258,6 +308,32 @@ class SnekGame { 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; @@ -381,10 +457,21 @@ class SnekGame { this.lastDirection=this.direction; // compute our new head - const head=[ - this.snake[0][0]+this.direction[0], - this.snake[0][1]+this.direction[1] - ]; + 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(); @@ -395,6 +482,18 @@ class SnekGame { 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; default: this.world[tail[0]][tail[1]]=EMPTY; } @@ -416,6 +515,10 @@ class SnekGame { case SNAKE: case HOLE_S: case FLAMMABLE_S: + case PORTAL_A_S: + case PORTAL_B_S: + case PORTAL_C_S: + case PORTAL_D_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 @@ -498,6 +601,18 @@ class SnekGame { 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; default: this.world[head[0]][head[1]]=SNAKE; } @@ -538,6 +653,12 @@ class SnekGame { 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(); @@ -548,6 +669,9 @@ class SnekGame { 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() { @@ -555,10 +679,13 @@ class SnekGame { if(!this.lastStep) this.lastStep=this.firstStep; this.draw(); if(this.callback) this.callback('tick'); - if(this.lastStep+this.delay this.tick()); } @@ -615,7 +742,6 @@ class SnekGame { this.firstStep=Date.now(); this.tickId=0; this.playing=true; - this.score=0; requestAnimationFrame(() => this.tick()); } }