added portals (closes #4) and first level of puzzle mode

This commit is contained in:
Nathan DECHER 2020-04-14 14:32:45 +02:00
parent 52a2e41e05
commit a27b8fcd9e
7 changed files with 230 additions and 26 deletions

View file

@ -3,10 +3,14 @@
FIRE_ANIM = $(foreach angle, $(shell seq 0 6 359), build/fire$(angle).png) 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_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) 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) IMAGES = $(foreach name, apple wall oil, public/assets/$(name)32.png)
TILESETS = $(foreach name, hole, public/assets/$(name)-ts.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) JSON = $(foreach name, snake levelList config metaConfig, public/assets/$(name).json)
ICON = public/assets/icon32.png public/assets/icon256.png public/favicon.ico ICON = public/assets/icon32.png public/assets/icon256.png public/favicon.ico
CSS = public/css/snek.css 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 build/peach-rainbow%.png: assets/peach.png
convert $^ -modulate 100,100,$(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ 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 public/assets/%.json: assets/%.json
cp $^ $@ cp $^ $@

2
api.js
View file

@ -78,7 +78,7 @@ api.post('/leaderboards/:category/:id', (req, res) => {
err: 'Invalid time' err: 'Invalid time'
}); });
const speed=req.body.speed; 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, ok: false,
err: 'Invalid speed' err: 'Invalid speed'
}); });

View file

@ -41,5 +41,25 @@
"Get a score as high as you can in 30 seconds", "Get a score as high as you can in 30 seconds",
"Survive for as long as you can in an increasingly difficult game" "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<n>.json",
"levelDisplay": "Level <n>",
"levels": [
1
],
"nextLevel": true
} }
} }

BIN
assets/portal.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

21
levels/puzzle1.json Normal file
View file

@ -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]
}
}

View file

@ -8,6 +8,10 @@ const assetSpecs=[
{ name: 'flammable', filename: 'oil32.png', type: 'image' }, { name: 'flammable', filename: 'oil32.png', type: 'image' },
{ name: 'hole', filename: 'hole-ts.png', type: 'image' }, { name: 'hole', filename: 'hole-ts.png', type: 'image' },
{ name: 'fire', filename: 'fire-anim.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: 'snake', filename: 'snake.json', type: 'json' },
{ name: 'levelList', filename: 'levelList.json', type: 'json' }, { name: 'levelList', filename: 'levelList.json', type: 'json' },
{ name: 'config', filename: 'config.json', type: 'json' }, { name: 'config', filename: 'config.json', type: 'json' },
@ -16,7 +20,11 @@ const assetSpecs=[
const tasks=[ const tasks=[
{ from: 'hole', type: 'tileset', steps: 3, tiles: ['base', 'ul', 'dr', 'dl', 'ur', 'l', 'r', 'd', 'u'] }, { 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: 'superFruit', type: 'animation', steps: 5 },
{ from: 'decayFruit', type: 'animation', steps: 5 } { from: 'decayFruit', type: 'animation', steps: 5 }
]; ];

View file

@ -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 { class SnekGame {
constructor(settings, canvas, rules) { constructor(settings, canvas, rules) {
// setup the delay // 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 // world is given in the level
if(settings.world) { // explicitly if(settings.world) { // explicitly
@ -23,6 +26,10 @@ class SnekGame {
case 'o': return HOLE; case 'o': return HOLE;
case 'i': return FIRE; case 'i': return FIRE;
case 'I': return FLAMMABLE; 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]; this.dimensions=[this.world.length, this.world[0].length];
// extract the fruits // extract the fruits
this.fruits=[]; this.fruits=this.getTilesOfType(FOOD);
this.world
.forEach((l, x) => l.forEach(
(c, y) => {
if(c==FOOD) this.fruits.push([x, y]);
}
));
// extract the decaying fruits // extract the decaying fruits
this.decayFood=[]; this.decayFood=this.getTilesOfType(DECAY_FOOD);
this.world
.forEach((l, x) => l.forEach( // extract the portals
(c, y) => { this.portals={};
if(c==DECAY_FOOD) this.decaying.push([x, y, 0]); 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 } else { // dimension and objects
// get the dimensions // get the dimensions
@ -84,6 +90,17 @@ class SnekGame {
} else { } else {
this.decayFood=[]; 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 // add the snake to the world
@ -121,8 +138,20 @@ class SnekGame {
scoreSystem: 'fruit', scoreSystem: 'fruit',
fireTickSpeed: 10, fireTickSpeed: 10,
autoSizeGrow: false, autoSizeGrow: false,
autoSpeedIncrease: false autoSpeedIncrease: false,
timeFlow: true
}, rules, settings.rules || {}); }, 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() { get playTime() {
@ -192,6 +221,10 @@ class SnekGame {
const flammable=assets.get('flammable'); const flammable=assets.get('flammable');
const superFruit=assets.get('superFruit'); const superFruit=assets.get('superFruit');
const decayFruit=assets.get('decayFruit'); 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( const putTile=(x, y, tile) => this.ctx.drawImage(
tile, tile,
offsetX+cellSize*x, offsetX+cellSize*x,
@ -249,6 +282,23 @@ class SnekGame {
case SUPER_FOOD: case SUPER_FOOD:
putTileAnim(x, y, superFruit); putTileAnim(x, y, superFruit);
break; 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) 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) // draw our snake (it gets drawn completely differently, so here it goes)
const snake=assets.get('snake'); const snake=assets.get('snake');
this.ctx.fillStyle=snake.color; this.ctx.fillStyle=snake.color;
@ -381,10 +457,21 @@ class SnekGame {
this.lastDirection=this.direction; this.lastDirection=this.direction;
// compute our new head // compute our new head
const head=[ let head;
this.snake[0][0]+this.direction[0], 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]])) {
this.snake[0][1]+this.direction[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 // get our tail out of the way
const tail=this.snake.pop(); const tail=this.snake.pop();
@ -395,6 +482,18 @@ class SnekGame {
case FLAMMABLE_S: case FLAMMABLE_S:
this.world[tail[0]][tail[1]]=FLAMMABLE; this.world[tail[0]][tail[1]]=FLAMMABLE;
break; 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: default:
this.world[tail[0]][tail[1]]=EMPTY; this.world[tail[0]][tail[1]]=EMPTY;
} }
@ -416,6 +515,10 @@ class SnekGame {
case SNAKE: case SNAKE:
case HOLE_S: case HOLE_S:
case FLAMMABLE_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"); 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 // if either 3 consecutive segments or the whole snake is on a hole, you die
@ -498,6 +601,18 @@ class SnekGame {
case FLAMMABLE: case FLAMMABLE:
this.world[head[0]][head[1]]=FLAMMABLE_S; this.world[head[0]][head[1]]=FLAMMABLE_S;
break; 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: default:
this.world[head[0]][head[1]]=SNAKE; 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); 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 // victory condition
if(this.rules.winCondition=='fruit') { if(this.rules.winCondition=='fruit') {
if(!this.fruits.length) return this.win(); if(!this.fruits.length) return this.win();
@ -548,6 +669,9 @@ class SnekGame {
if(this.rules.winCondition=='score') { if(this.rules.winCondition=='score') {
if(this.score>=this.rules.scoreObjective) return this.win(); if(this.score>=this.rules.scoreObjective) return this.win();
} }
if(this.rules.scoreSystem=='moves') {
if(this.score) this.score--;
}
} }
tick() { tick() {
@ -555,10 +679,13 @@ class SnekGame {
if(!this.lastStep) this.lastStep=this.firstStep; if(!this.lastStep) this.lastStep=this.firstStep;
this.draw(); this.draw();
if(this.callback) this.callback('tick'); if(this.callback) this.callback('tick');
if(this.lastStep+this.delay<Date.now()) { if(this.rules.timeFlow && this.lastStep+this.delay<Date.now()) {
this.lastStep+=this.delay; this.lastStep+=this.delay;
this.step(); this.step();
} }
if(!this.rules.timeFlow && (this.direction[0]!=0 || this.direction[1]!=0)) {
this.step();
}
requestAnimationFrame(() => this.tick()); requestAnimationFrame(() => this.tick());
} }
@ -615,7 +742,6 @@ class SnekGame {
this.firstStep=Date.now(); this.firstStep=Date.now();
this.tickId=0; this.tickId=0;
this.playing=true; this.playing=true;
this.score=0;
requestAnimationFrame(() => this.tick()); requestAnimationFrame(() => this.tick());
} }
} }