25 Commits

Author SHA1 Message Date
Nathan DECHER 46ac7bbf27 updated help.html 2020-04-17 12:09:20 +02:00
Nathan DECHER 71eace6ad8 updated README.md 2020-04-17 12:08:25 +02:00
Nathan DECHER a34318b4f2 updated README.md 2020-04-15 12:39:13 +02:00
Nathan DECHER 3c32dd5aed updated Dockerfile 2020-04-15 12:15:02 +02:00
Nathan DECHER 5cf47cd241 fixed install script crash 2020-04-15 12:13:34 +02:00
Nathan DECHER 200c716393 updated README.md 2020-04-15 12:10:28 +02:00
Nathan DECHER 142024a315 added built assets 2020-04-15 12:08:21 +02:00
Nathan DECHER 3e8eb69329 refactored game to separate tile management, game code and render code (closes #39) 2020-04-15 11:39:15 +02:00
Nathan DECHER 45e7a0d323 updated Dockerfile 2020-04-15 02:39:03 +02:00
Nathan DECHER 7df0f14e99 fixed Make clean 2020-04-15 02:30:55 +02:00
Nathan DECHER 1cfa5e38f3 added dependency on bc to readme 2020-04-15 02:27:17 +02:00
Nathan DECHER 17356e0d8f cut down even more on build times with shrink step for animations 2020-04-15 02:21:22 +02:00
Nathan DECHER fb1653e231 cut down 75% of build time for portals (closes #41) 2020-04-15 02:04:09 +02:00
Nathan DECHER 7121c230fc added switches and spikes (closes #6) and puzzle 3 (closes #34) 2020-04-14 18:21:07 +02:00
Nathan DECHER 7b20b0e8ec updated help 2020-04-14 16:41:08 +02:00
Nathan DECHER 3d50bf805d configEditor, explain why input is disabled 2020-04-14 16:31:11 +02:00
Nathan DECHER bbb34e63b6 configEditor, only show keys in debug mode 2020-04-14 16:26:47 +02:00
Nathan DECHER 7d97132aa1 progressbar, centered percentage 2020-04-14 16:26:22 +02:00
Nathan DECHER c850d9ffa9 fixed loading bar colors 2020-04-14 15:54:14 +02:00
Nathan DECHER c90bfd5103 added keys and doors (closes #5) with temp assets 2020-04-14 15:43:00 +02:00
Nathan DECHER a27b8fcd9e added portals (closes #4) and first level of puzzle mode 2020-04-14 14:32:45 +02:00
Nathan DECHER 52a2e41e05 added hover to menu icon 2020-04-14 11:16:21 +02:00
Nathan DECHER 17ab288b63 added hover to buttons 2020-04-14 11:10:42 +02:00
Nathan DECHER f607034a33 added hover to links 2020-04-14 11:07:49 +02:00
Nathan DECHER a934d5537b changed progressbar colors 2020-04-14 10:59:27 +02:00
48 changed files with 952 additions and 357 deletions
-3
View File
@@ -1,9 +1,6 @@
node_modules/ node_modules/
public/assets/*.png
public/assets/*.json
public/css/*.css public/css/*.css
public/js/*.js public/js/*.js
public/favicon.ico
build/*.png build/*.png
db.sqlite db.sqlite
+1 -1
View File
@@ -1,3 +1,3 @@
FROM alpine:3.11.5 FROM alpine:3.11.5
RUN apk add --no-cache nodejs npm git imagemagick make python3 python2 alpine-sdk && npm i -g node-gyp && git clone https://gitdab.com/Codinget/Snek /snek && cd /snek && npm i --unsafe-perm && apk del git make imagemagick python2 python3 alpine-sdk && printf '#!/bin/sh\ncd /snek\nnpm start\n' > start.sh && chmod +x start.sh RUN apk add --no-cache nodejs npm git python3 python2 alpine-sdk && npm i -g node-gyp && git clone https://gitdab.com/Codinget/Snek /snek && cd /snek && npm i --unsafe-perm && apk del git python2 python3 alpine-sdk && printf '#!/bin/sh\ncd /snek\nnpm start\n' > start.sh && chmod +x start.sh
CMD /snek/start.sh CMD /snek/start.sh
+36 -12
View File
@@ -1,12 +1,16 @@
.PHONY: all clean .PHONY: all clean mrproper
SIZE = 32
TEMPSIZE = $(shell echo $(SIZE) '*4' | bc)
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_ANIM = $(foreach angle, $(shell seq 0 6 359), build/portal-a$(angle).png)
IMAGES = $(foreach name, apple wall oil, public/assets/$(name)32.png) IMAGES = $(foreach name, apple wall oil key door, public/assets/$(name)$(SIZE).png)
TILESETS = $(foreach name, hole, public/assets/$(name)-ts.png) TILESETS = $(foreach name, hole switch spikes, 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
@@ -31,35 +35,54 @@ public/assets/%32.png: assets/%.png
convert $^ -resize 32x $@ convert $^ -resize 32x $@
public/assets/%256.png: assets/%.png public/assets/%256.png: assets/%.png
convert $^ -resize 256x $@ convert $^ -resize 256x $@
public/assets/%$(SIZE).png: assets/%.png
convert $^ -resize $(SIZE)x $@
public/assets/%32.png: assets/%.jpg public/assets/%32.png: assets/%.jpg
convert $^ -resize 32x $@ convert $^ -resize 32x $@
public/assets/%256.png: assets/%.jpg public/assets/%256.png: assets/%.jpg
convert $^ -resize 256x $@ convert $^ -resize 256x $@
public/assets/%$(SIZE).png: assets/%.jpg
convert $^ -resize $(SIZE)x $@
build/%-smol.png: assets/%.png
convert $^ -resize $(TEMPSIZE)x\> $@
public/assets/%-ts.png: assets/%.png public/assets/%-ts.png: assets/%.png
convert $^ -scale 32x $@ convert $^ -scale $(SIZE)x $@
public/assets/fire-anim.png: $(FIRE_ANIM) public/assets/fire-anim.png: $(FIRE_ANIM)
convert $^ -append $@ convert $^ -append $@
build/fire%.png: assets/fire.png build/fire%.png: build/fire-smol.png
convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize $(SIZE)x $@
public/assets/peach-decay-anim.png: $(PEACH_DECAY_ANIM) public/assets/peach-decay-anim.png: $(PEACH_DECAY_ANIM)
convert $^ -append $@ convert $^ -append $@
build/peach-decay%.png: assets/peach.png build/peach-decay%.png: build/peach-smol.png
convert $^ -modulate 100,$(shell echo $@ | sed 's/[^0-9]*//g') -resize 32x $@ convert $^ -modulate 100,$(shell echo $@ | sed 's/[^0-9]*//g') -resize $(SIZE)x $@
public/assets/peach-rainbow-anim.png: $(PEACH_RAINBOW_ANIM) public/assets/peach-rainbow-anim.png: $(PEACH_RAINBOW_ANIM)
convert $^ -append $@ convert $^ -append $@
build/peach-rainbow%.png: assets/peach.png build/peach-rainbow%.png: build/peach-smol.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 $(SIZE)x $@
build/portal-a%.png: build/portal-smol.png
convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize $(SIZE)x $@
public/assets/portal-a-anim.png: $(PORTAL_ANIM)
convert $^ -append $@
public/assets/portal-b-anim.png: public/assets/portal-a-anim.png
convert $^ -modulate 100,100,200 $@
public/assets/portal-c-anim.png: public/assets/portal-a-anim.png
convert $^ -modulate 100,100,150 $@
public/assets/portal-d-anim.png: public/assets/portal-a-anim.png
convert $^ -modulate 100,100,50 $@
public/assets/%.json: assets/%.json public/assets/%.json: assets/%.json
cp $^ $@ ln -s ../../$^ $@
public/css/snek.css: src/less/snek.less $(wildcard src/less/*.less) public/css/snek.css: src/less/snek.less $(wildcard src/less/*.less)
node_modules/.bin/lessc $< $@ node_modules/.bin/lessc $< $@
@@ -69,4 +92,5 @@ public/js/snek.js: $(wildcard src/js/*.js)
clean: clean:
rm -f build/*.* rm -f build/*.*
mrproper: clean
rm -f $(OUTPUT) rm -f $(OUTPUT)
+20 -3
View File
@@ -7,7 +7,7 @@ A "simple" Snake, done as my final JS class project
## Features ## Features
- 60 FPS 2D animations - 60 FPS 2D animations
- arcade and speedrun game modes - arcade, speedrun and puzzle game modes
- touchscreen and controller support - touchscreen and controller support
- playable at [snek.codinget.me](https://snek.codinget.me) - playable at [snek.codinget.me](https://snek.codinget.me)
@@ -17,21 +17,38 @@ A "simple" Snake, done as my final JS class project
- GNU Coreutils are known to work - GNU Coreutils are known to work
- On Windows, WSL is known to work - On Windows, WSL is known to work
- Imagemagick, with the `convert` tool in the PATH - Imagemagick, with the `convert` tool in the PATH
- `bc`
- Make - Make
- Node.js and npm, both in the PATH - Node.js and npm, both in the PATH
- Node.js 10 and 12 are known to work - Node.js 10 and 12 are known to work
- node-gyp and python are required for the database - node-gyp and python are required for the database
- (if you have already used native modules, you have them)
## Prod dependencies (direct)
- Node.js and npm, both in the PATH
- Node.js 10 and 12 are known to work
- node-gyp and python are required for the database
- (if you have already used native modules, you have them)
## Prod dependencies (docker)
- Docker
## Running the game (dev) ## Running the game (dev)
- `git clone` this repo - `git clone` this repo
- `npm install` the dependencies (this will also build the ressources and initialize the database) - `npm install` the dependencies (this will also build the less and js and initialize the database)
- `npm start` the server - `npm start` the server
- `make` every time you change something
## Running the game (prod) ## Running the game (prod, docker)
- Get the [Dockerfile](https://gitdab.com/Codinget/Snek/raw/branch/master/Dockerfile) - Get the [Dockerfile](https://gitdab.com/Codinget/Snek/raw/branch/master/Dockerfile)
- `docker build` it - `docker build` it
- `docker run -it -p80:3000` the container - `docker run -it -p80:3000` the container
- ideally, put it behind a reverse proxy - ideally, put it behind a reverse proxy
## Running the game (prod, direct)
- `git clone` this repo
- `npm install` the dependencies (this will also build the less and js and initialize the database)
- `npm start` the server
## License ## License
[MIT](https://opensource.org/licenses/MIT) [MIT](https://opensource.org/licenses/MIT)
+1 -1
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'
}); });
+2
View File
@@ -1,4 +1,6 @@
{ {
"debug": false,
"player.name": "Player", "player.name": "Player",
"player.leaderboards": false, "player.leaderboards": false,
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+20
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, 2, 3
],
"nextLevel": true
} }
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+14
View File
@@ -1,5 +1,19 @@
const DB=require('better-sqlite3'); const DB=require('better-sqlite3');
const fs=require('fs'); const fs=require('fs');
const child_process=require('child_process');
// prepare database
console.log('Preparing database');
if(fs.existsSync('db.sqlite')) fs.unlinkSync('db.sqlite');
const db=new DB('db.sqlite'); const db=new DB('db.sqlite');
db.exec(fs.readFileSync('init.sql', 'utf8')); db.exec(fs.readFileSync('init.sql', 'utf8'));
// compile less
console.log('Compiling less');
child_process.execFileSync('node_modules/.bin/lessc', ['src/less/snek.less', 'public/css/snek.css']);
// merge js
console.log('Merging js');
const jsFiles=fs.readdirSync('src/js').map(f => 'src/js/'+f);
const merged=child_process.execFileSync('node', ['mergejs.js', ...jsFiles]);
fs.writeFileSync('public/js/snek.js', merged);
+21
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]
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"world": [
"k wA wf",
" w wB",
" w ww",
" fw ",
" w ",
" K f"
],
"snake": [
[0, 5]
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"world": [
"A i B",
" i s",
" Cik ",
"SSSSSSSSSSSiiiii",
" i f",
" i ",
" s itttt",
" t wKKKK",
" w ",
" wD "
],
"snake": [
[0, 9],
[1, 9],
[2, 9]
],
"rules": {
"moveCount": 60
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"prepare": "rm -f db.sqlite && make clean all && node install.js" "prepare": "node install.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+1
View File
@@ -0,0 +1 @@
../../assets/config.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+1
View File
@@ -0,0 +1 @@
../../assets/levelList.json
+1
View File
@@ -0,0 +1 @@
../../assets/metaConfig.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+1
View File
@@ -0,0 +1 @@
../../assets/snake.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+19
View File
@@ -47,6 +47,22 @@
A timed game only lasts for 30 seconds, and the goal is to get as high a score as possible. A timed game only lasts for 30 seconds, and the goal is to get as high a score as possible.
</p> </p>
</section> </section>
<section>
<h3>Survival</h3>
<p>
In survival mode, the playfield doesn't loop, fruits don't spawn, and you can't win.<br>
This modes get progressively harder, with speed increases, and snake growth.<br>
At first, this process is slow, but it gets faster with time.
</p>
</section>
<section>
<h3>Puzzle mode</h3>
<p>
In puzzle mode, time doesn't flow until you move.<br>
Weird tiles make their debut, like portals, keys, etc.<br>
Your goal is to get the fruits in as little moves as you can.
</p>
</section>
</article> </article>
<article> <article>
<h2>Tiles</h2> <h2>Tiles</h2>
@@ -58,6 +74,9 @@
<li><em>Oil</em> is flammable and will periodically catch on fire, which will kill you. It is otherwise perfectly safe</li> <li><em>Oil</em> is flammable and will periodically catch on fire, which will kill you. It is otherwise perfectly safe</li>
<li><em>Super fruits</em> give you 10 points, and sometimes spawn in arcade mode</li> <li><em>Super fruits</em> give you 10 points, and sometimes spawn in arcade mode</li>
<li><em>Decaying fruits</em> give you 5 points and sometimes spawn in arcade mode, but they also decay after 2 seconds and disappear</li> <li><em>Decaying fruits</em> give you 5 points and sometimes spawn in arcade mode, but they also decay after 2 seconds and disappear</li>
<li><em>Portals</em> teleport you to the corresponding portal</li>
<li><em>Keys</em> make <em>Doors</em> disappear</li>
<li><em>Switches</em> enable or disable <em>Spikes</em></li>
</ul> </ul>
</article> </article>
</main> </main>
+19 -5
View File
@@ -8,6 +8,14 @@ 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: 'key', filename: 'key32.png', type: 'image' },
{ name: 'door', filename: 'door32.png', type: 'image' },
{ name: 'switch', filename: 'switch-ts.png', type: 'image' },
{ name: 'spikes', filename: 'spikes-ts.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' },
@@ -15,10 +23,16 @@ 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: 1, 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 },
{ from: 'switch', type: 'tileset', steps: 1, tiles: ['on', 'off'] },
{ from: 'spikes', type: 'tileset', steps: 1, tiles: ['off', 'on'] }
]; ];
const cvs=document.createElement('canvas'); const cvs=document.createElement('canvas');
@@ -28,8 +42,8 @@ cvs.classList.add('progressBar');
cvs.classList.add('hiddenBottom'); cvs.classList.add('hiddenBottom');
const bar=new ProgressBar(assetSpecs.length*2+tasks.reduce((a, t) => a+t.steps, 0)); const bar=new ProgressBar(assetSpecs.length*2+tasks.reduce((a, t) => a+t.steps, 0));
bar.addUpdateListener(() => bar.draw(cvs)); bar.addUpdateListener(() => bar.draw(cvs, '#fba49b', '#930a16'));
bar.draw(cvs); bar.draw(cvs, '#fba49b', '#930a16');
document.body.appendChild(cvs); document.body.appendChild(cvs);
setTimeout(() => cvs.classList.remove('hiddenBottom'), 0); setTimeout(() => cvs.classList.remove('hiddenBottom'), 0);
+3 -3
View File
@@ -22,7 +22,7 @@ class ConfigEditor extends Popup {
let id='cfgInput-'+(lastCEId++)+'-'+key.replace(/\./g, '-'); let id='cfgInput-'+(lastCEId++)+'-'+key.replace(/\./g, '-');
let label=span.appendChild(document.createElement('label')); let label=span.appendChild(document.createElement('label'));
label.innerText=data.name; label.innerText=data.name;
label.title=key; if(config.getB('debug')) label.title=key;
let input; let input;
if(data.type=='boolean') { if(data.type=='boolean') {
@@ -67,7 +67,7 @@ class ConfigEditor extends Popup {
input.disabled= input.disabled=
data.excludes data.excludes
.some(key => config.getB(key)); .some(key => config.getB(key));
input.title=input.disabled?`Disable ${data.excludes.join(',')} to enable`:''; input.title=input.disabled?`Disable '${data.excludes.map(k => metaConfig[k].name).join('\', \'')}' to enable`:'';
}; };
setEnabled(); setEnabled();
@@ -78,7 +78,7 @@ class ConfigEditor extends Popup {
} else if(data.parent) { } else if(data.parent) {
const setEnabled=() => { const setEnabled=() => {
input.disabled=!config.getB(data.parent); input.disabled=!config.getB(data.parent);
input.title=input.disabled?`Enable ${data.parent} to enable`:''; input.title=input.disabled?`Enable '${metaConfig[data.parent].name}' to enable`:'';
}; };
setEnabled(); setEnabled();
+1 -1
View File
@@ -34,7 +34,7 @@ class ProgressBar {
ctx.fillRect(0, 0, canvas.width*this.completeCount/this.taskCount, canvas.height); ctx.fillRect(0, 0, canvas.width*this.completeCount/this.taskCount, canvas.height);
ctx.fillStyle=textColor; ctx.fillStyle=textColor;
ctx.textAlign='center'; ctx.textAlign='center';
ctx.textBaseline='center'; ctx.textBaseline='middle';
ctx.font=`${canvas.height/2}px 'Fira Code'`; ctx.font=`${canvas.height/2}px 'Fira Code'`;
ctx.fillText(this.percent+'%', canvas.width/2, canvas.height/2); ctx.fillText(this.percent+'%', canvas.width/2, canvas.height/2);
} }
+329
View File
@@ -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<game.dimensions[0]; x++) {
ctx.moveTo(offsetX+x*cellSize, offsetY);
ctx.lineTo(offsetX+x*cellSize, canvas.height-offsetY);
}
for(let y=1; y<game.dimensions[1]; y++) {
ctx.moveTo(offsetX, offsetY+y*cellSize);
ctx.lineTo(canvas.width-offsetX, offsetY+y*cellSize);
}
ctx.stroke();
} else if(config.getS('appearance.grid')=='checkerboard') {
ctx.fillStyle='rgba(0, 0, 0, 10%)';
for(let x=0; x<game.dimensions[0]; x++) {
for(let y=(x+1)%2; y<game.dimensions[1]; y+=2) {
ctx.fillRect(offsetX+x*cellSize, offsetY+y*cellSize, cellSize, cellSize);
}
}
}
// draw our tiles
for(let x=0; x<game.dimensions[0]; x++) {
for(let y=0; y<game.dimensions[1]; y++) {
switch(game.world[x][y]) {
case T.WALL:
putTile(x, y, wall);
break;
case T.FIRE:
putTileAnim(x, y, fire);
break;
case T.HOLE:
case T.HOLE_S: {
putTile(x, y, hole.base);
let adj=checkAdj(x, y);
Object
.keys(adj)
.filter(k => 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
};
+183 -327
View File
@@ -1,9 +1,18 @@
const [EMPTY, FOOD, SUPER_FOOD, DECAY_FOOD, WALL, FIRE, FLAMMABLE, FLAMMABLE_S, HOLE, HOLE_S, SNAKE]=Array(255).keys(); const {
tiles: T,
forChar,
getType,
isPortal,
snakeVersion, nonSnakeVersion
}=require('tiles');
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
@@ -13,18 +22,7 @@ class SnekGame {
for(let x=0; x<this.world.length; x++) { for(let x=0; x<this.world.length; x++) {
this.world[x]=Array(settings.world.length); this.world[x]=Array(settings.world.length);
for(let y=0; y<this.world[x].length; y++) { for(let y=0; y<this.world[x].length; y++) {
this.world[x][y]=(() => { this.world[x][y]=forChar(settings.world[y][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;
}
})();
} }
} }
@@ -32,22 +30,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(T.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(T.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==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 } else { // dimension and objects
// get the dimensions // get the dimensions
@@ -57,37 +54,61 @@ class SnekGame {
this.world=Array(settings.dimensions[0]); this.world=Array(settings.dimensions[0]);
for(let i=0; i<settings.dimensions[0]; i++) { for(let i=0; i<settings.dimensions[0]; i++) {
this.world[i]=Array(settings.dimensions[1]); this.world[i]=Array(settings.dimensions[1]);
this.world[i].fill(EMPTY); this.world[i].fill(T.EMPTY);
} }
// add the walls // add the walls
if(settings.walls) settings.walls.forEach(([x, y]) => this.world[x][y]=WALL); if(settings.walls) settings.walls.forEach(([x, y]) => this.world[x][y]=T.WALL);
// add the holes // 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 // add the fires and flammable tiles
if(settings.fires) settings.fires.forEach(([x, y]) => this.world[x][y]=FIRE); 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]=FLAMMABLE); if(settings.flammable) settings.flammable.forEach(([x, y]) => this.world[x][y]=T.FLAMMABLE);
// add the food // 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]; this.fruits=[...settings.food];
// add the super 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 // add the decaying food
if(settings.decayFood) { 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]); this.decayFood=settings.decayFood.map(([x, y]) => [x, y, 0]);
} 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]]=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 // 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 // get the head and initial direction
@@ -121,8 +142,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() {
@@ -133,6 +166,19 @@ class SnekGame {
return Math.round(1000/this.delay); 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) { getTilesOfType(type) {
return this return this
.world .world
@@ -145,235 +191,16 @@ class SnekGame {
) )
).flat(); ).flat();
} }
replaceTilesOfType(type, newType) {
this.world.forEach(l =>
l.forEach((t, i) => {
if(t==type) l[i]=newType;
})
);
}
draw() { draw() {
const assets=require('assets'); require('renderer').draw(this, this.canvas, this.ctx);
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.dimensions[0]; x++) {
this.ctx.moveTo(offsetX+x*cellSize, offsetY);
this.ctx.lineTo(offsetX+x*cellSize, this.canvas.height-offsetY);
}
for(let y=1; y<this.dimensions[1]; y++) {
this.ctx.moveTo(offsetX, offsetY+y*cellSize);
this.ctx.lineTo(this.canvas.width-offsetX, offsetY+y*cellSize);
}
this.ctx.stroke();
} else if(config.getS('appearance.grid')=='checkerboard') {
this.ctx.fillStyle='rgba(0, 0, 0, 10%)';
for(let x=0; x<this.dimensions[0]; x++) {
for(let y=(x+1)%2; y<this.dimensions[1]; y+=2) {
this.ctx.fillRect(offsetX+x*cellSize, offsetY+y*cellSize, cellSize, cellSize);
}
}
}
// draw our tiles
const wall=assets.get('wall');
const hole=assets.get('hole');
const fire=assets.get('fire');
const flammable=assets.get('flammable');
const superFruit=assets.get('superFruit');
const decayFruit=assets.get('decayFruit');
const putTile=(x, y, tile) => 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<this.dimensions[0]; x++) {
for(let y=0; y<this.dimensions[1]; y++) {
switch(this.world[x][y]) {
case WALL:
putTile(x, y, wall);
break;
case FIRE:
putTileAnim(x, y, fire);
break;
case HOLE:
case HOLE_S: {
putTile(x, y, hole.base);
let adj=checkAdj(x, y);
Object
.keys(adj)
.filter(k => 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;
}
}
}
// 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 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() { step() {
@@ -381,23 +208,25 @@ 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 && isPortal(this.getTileA(this.snake[0]))) {
this.snake[0][1]+this.direction[1] 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 // get our tail out of the way
const tail=this.snake.pop(); const tail=this.snake.pop();
switch(this.world[tail[0]][tail[1]]) { this.putTileA(tail, nonSnakeVersion(this.getTileA(tail)));
case HOLE_S:
this.world[tail[0]][tail[1]]=HOLE;
break;
case FLAMMABLE_S:
this.world[tail[0]][tail[1]]=FLAMMABLE;
break;
default:
this.world[tail[0]][tail[1]]=EMPTY;
}
// check for out of world conditions // check for out of world conditions
if(head[0]<0 || head[0]>=this.dimensions[0] || head[1]<0 || head[1]>=this.dimensions[1]) { if(head[0]<0 || head[0]>=this.dimensions[0] || head[1]<0 || head[1]>=this.dimensions[1]) {
@@ -409,48 +238,72 @@ class SnekGame {
} }
} }
switch(this.world[head[0]][head[1]]) { let tile=this.getTileA(head);
switch(getType(tile)) {
// you hit, you die // you hit, you die
case WALL: return this.die("thought walls were edible", "hit a wall"); case 'wall':
case FIRE: return this.die("burned to a crisp", "hit fire"); switch(tile) {
case SNAKE: case T.WALL: return this.die("thought walls were edible", "hit a wall");
case HOLE_S: case T.FIRE: return this.die("burned to a crisp", "hit fire");
case FLAMMABLE_S: 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"); 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
case HOLE: case 'hole':
if( if(
this.snake.length==0 || this.snake.length==0 ||
this.snake.length==1 && 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.snake.length>=2 &&
this.world[this.snake[0][0]][this.snake[0][1]]==HOLE_S && this.getTileA(this.snake[0])==T.HOLE_S &&
this.world[this.snake[1][0]][this.snake[1][1]]==HOLE_S this.getTileA(this.snake[1])==T.HOLE_S
) return this.die("fell harder than their grades", "fell in a hole"); ) return this.die("fell harder than their grades", "fell in a hole");
break; break;
// you eat, you get a massive score boost // you eat, you get a massive score boost
case SUPER_FOOD: case 'bonus':
this.score+=10; 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; break;
// you eat, you get a small score boost // you step on, you trigger
case DECAY_FOOD: case 'switch': {
this.score+=5; this.putTileA(head, tile==T.SWITCH_ON?T.SWITCH_OFF:T.SWITCH_ON);
this.decayFood=this.decayFood.filter( if(this.getTilesOfType(T.SPIKES_OFF_S).length) return this.die("spiked themselves", "activated spikes");
([x, y, _]) => !(x==head[0] && y==head[1]) const oldSpikes=this.getTilesOfType(T.SPIKES_ON);
); this.replaceTilesOfType(T.SPIKES_OFF, T.SPIKES_ON);
break; oldSpikes.forEach(pos => this.putTileA(pos, T.SPIKES_OFF));
} break;
// you eat, you grow // you eat, you grow
case FOOD: case 'food':
// re-grow the snake partially (can't hit the tail, but it's there for all other intents and purposes // re-grow the snake
this.snake.push(tail); this.snake.push(tail);
this.putTileA(tail, snakeVersion(this.getTileA(tail)));
this.length++; this.length++;
// remove the fruit from existence // remove the fruit from existence
this.world[head[0]][head[1]]=SNAKE; this.putTileA(head, T.EMPTY);
this.fruits=this.fruits.filter( this.fruits=this.fruits.filter(
([x, y]) => !(x==head[0] && y==head[1]) ([x, y]) => !(x==head[0] && y==head[1])
); );
@@ -460,26 +313,26 @@ class SnekGame {
// custom rules // custom rules
if(this.rules.fruitRegrow) { if(this.rules.fruitRegrow) {
const emptyCells=this.getTilesOfType(EMPTY); const emptyCells=this.getTilesOfType(T.EMPTY);
const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
this.fruits.push(cell); this.fruits.push(cell);
this.world[cell[0]][cell[1]]=FOOD; this.putTileA(cell, T.FOOD);
} }
if(this.rules.superFruitGrow) { if(this.rules.superFruitGrow) {
if(Math.random()<.1) { // 10% chance 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)]; 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(this.rules.decayingFruitGrow) {
if(Math.random()<.2) { // 20% chance 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)]; 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]); this.decayFood.push([cell[0], cell[1], this.playTime]);
} }
} }
@@ -491,22 +344,14 @@ class SnekGame {
} }
// move our head forward // move our head forward
switch(this.world[head[0]][head[1]]) { tile=this.getTileA(head);
case HOLE: this.putTileA(head, snakeVersion(tile));
this.world[head[0]][head[1]]=HOLE_S;
break;
case FLAMMABLE:
this.world[head[0]][head[1]]=FLAMMABLE_S;
break;
default:
this.world[head[0]][head[1]]=SNAKE;
}
this.snake.unshift(head); this.snake.unshift(head);
// decay decaying food // decay decaying food
this.decayFood.forEach( this.decayFood.forEach(
([x, y, birth]) => { ([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( this.decayFood=this.decayFood.filter(
@@ -527,15 +372,21 @@ class SnekGame {
if(this.tickId%this.rules.fireTickSpeed==0) { if(this.tickId%this.rules.fireTickSpeed==0) {
const touchingFire=([x, y]) => { const touchingFire=([x, y]) => {
const surrounding=[ const surrounding=[
this.world[x][y-1], this.getTile(x, y-1),
this.world[x][y+1], this.getTile(x, y+1),
(this.world[x-1]||[])[y], this.getTile(x-1, y),
(this.world[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"); 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(FLAMMABLE).filter(touchingFire).forEach(([x, y]) => this.world[x][y]=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 // victory condition
@@ -548,6 +399,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 +409,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 +472,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());
} }
} }
+221
View File
@@ -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
};
+10
View File
@@ -108,10 +108,20 @@
background: @accentbg; background: @accentbg;
font-weight: bold; font-weight: bold;
cursor: pointer;
border-radius: 1rem; border-radius: 1rem;
border: 0; border: 0;
padding: 2rem; padding: 2rem;
margin: 1rem; margin: 1rem;
transition: box-shadow .5s;
&:hover {
color: @fg;
text-decoration: underline;
box-shadow: black 0 0 2rem;
}
} }
} }
} }
+12
View File
@@ -40,6 +40,11 @@ a {
display: contents; display: contents;
} }
a:hover {
color: @fg;
text-decoration: underline;
}
em { em {
font-style: italic; font-style: italic;
} }
@@ -71,6 +76,9 @@ header img, footer img {
} }
header img { header img {
height: 8rem; height: 8rem;
&:hover {
border-color: @fg;
}
} }
footer img { footer img {
height: 4rem; height: 4rem;
@@ -86,6 +94,10 @@ header ul {
a { a {
color: @fg; color: @fg;
&:hover {
color: @accentfg;
}
} }
} }
} }