Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46ac7bbf27 | |||
| 71eace6ad8 | |||
| a34318b4f2 | |||
| 3c32dd5aed | |||
| 5cf47cd241 | |||
| 200c716393 | |||
| 142024a315 | |||
| 3e8eb69329 | |||
| 45e7a0d323 | |||
| 7df0f14e99 | |||
| 1cfa5e38f3 | |||
| 17356e0d8f | |||
| fb1653e231 | |||
| 7121c230fc | |||
| 7b20b0e8ec | |||
| 3d50bf805d | |||
| bbb34e63b6 | |||
| 7d97132aa1 | |||
| c850d9ffa9 | |||
| c90bfd5103 | |||
| a27b8fcd9e | |||
| 52a2e41e05 | |||
| 17ab288b63 | |||
| f607034a33 | |||
| a934d5537b | |||
| efa4d9f80f | |||
| 5a569cbaf9 | |||
| b0106f9df0 | |||
| 7b083fda11 | |||
| 1ca8e46461 | |||
| db52ff95ae | |||
| 462668e5cd | |||
| 5775f6c96c | |||
| 363f85c06c | |||
| 5cd4ac2dfe | |||
| a5efd14fc8 | |||
| 03cb1b83b9 | |||
| 5bf5f49f03 | |||
| edd9d5c15a | |||
| ea79ba1dfa | |||
| a87b4679f4 | |||
| e34e7e07bd | |||
| 0923ad56dd | |||
| 65b33afa05 | |||
| 530260ac54 | |||
| bf06218815 | |||
| 2a9418c9c4 | |||
| 338934866b | |||
| 23535d17d8 | |||
| db655feedb | |||
| 6edd23f25f | |||
| 0578bfb6ad | |||
| d339dd0a06 | |||
| a7e2d1c201 | |||
| 3a571a9c30 | |||
| b12ac2fab9 | |||
| 2a67ab40ee | |||
| 7dd95f262c | |||
| 4746f34537 | |||
| 40ec4fa09a | |||
| fe22b85387 | |||
| 41cb894ef9 | |||
| 3997e94a68 | |||
| 76f1765be7 | |||
| 45ab40ebcb | |||
| e34c6c75bb | |||
| c5b94b59c5 | |||
| 02e72795cc | |||
| f050baafbc | |||
| 0e0cd3a7d1 | |||
| fc50821ff0 | |||
| 95751be63e | |||
| 93ba24a53a | |||
| 352c3aa16d | |||
| 86cd935f03 | |||
| 4bf204cae4 | |||
| bb932ff863 | |||
| 6b7ff7e86d | |||
| 1df62ff1fe | |||
| 10585fef98 | |||
| de390dff8a | |||
| 03e0c97280 | |||
| beb9598f69 | |||
| daef55781e | |||
| bd6d9f3399 | |||
| 7362b4dc5c | |||
| fe2902cdea | |||
| becfaf9eaf | |||
| 7de127b93e |
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
public/css/*.css
|
||||||
|
public/js/*.js
|
||||||
|
build/*.png
|
||||||
|
db.sqlite
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM alpine:3.11.5
|
||||||
|
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
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
.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)
|
||||||
|
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_ANIM = $(foreach angle, $(shell seq 0 6 359), build/portal-a$(angle).png)
|
||||||
|
|
||||||
|
IMAGES = $(foreach name, apple wall oil key door, public/assets/$(name)$(SIZE).png)
|
||||||
|
TILESETS = $(foreach name, hole switch spikes, public/assets/$(name)-ts.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
|
||||||
|
JS = public/js/snek.js
|
||||||
|
|
||||||
|
OUTPUT = $(IMAGES) $(TILESETS) $(ANIMATIONS) $(JSON) $(ICON) $(CSS) $(JS)
|
||||||
|
|
||||||
|
all: images tilesets animations json icon css js
|
||||||
|
|
||||||
|
images: $(IMAGES)
|
||||||
|
tilesets: $(TILESETS)
|
||||||
|
animations: $(ANIMATIONS)
|
||||||
|
json: $(JSON)
|
||||||
|
icon: $(ICON)
|
||||||
|
css: $(CSS)
|
||||||
|
js: $(JS)
|
||||||
|
|
||||||
|
public/favicon.ico: assets/icon.jpg
|
||||||
|
convert $^ -resize 32x $@
|
||||||
|
|
||||||
|
public/assets/%32.png: assets/%.png
|
||||||
|
convert $^ -resize 32x $@
|
||||||
|
public/assets/%256.png: assets/%.png
|
||||||
|
convert $^ -resize 256x $@
|
||||||
|
public/assets/%$(SIZE).png: assets/%.png
|
||||||
|
convert $^ -resize $(SIZE)x $@
|
||||||
|
|
||||||
|
public/assets/%32.png: assets/%.jpg
|
||||||
|
convert $^ -resize 32x $@
|
||||||
|
public/assets/%256.png: assets/%.jpg
|
||||||
|
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
|
||||||
|
convert $^ -scale $(SIZE)x $@
|
||||||
|
|
||||||
|
public/assets/fire-anim.png: $(FIRE_ANIM)
|
||||||
|
convert $^ -append $@
|
||||||
|
|
||||||
|
build/fire%.png: build/fire-smol.png
|
||||||
|
convert $^ -distort ScaleRotateTranslate $(shell echo $@ | sed 's/[^0-9]*//g') -resize $(SIZE)x $@
|
||||||
|
|
||||||
|
public/assets/peach-decay-anim.png: $(PEACH_DECAY_ANIM)
|
||||||
|
convert $^ -append $@
|
||||||
|
|
||||||
|
build/peach-decay%.png: build/peach-smol.png
|
||||||
|
convert $^ -modulate 100,$(shell echo $@ | sed 's/[^0-9]*//g') -resize $(SIZE)x $@
|
||||||
|
|
||||||
|
public/assets/peach-rainbow-anim.png: $(PEACH_RAINBOW_ANIM)
|
||||||
|
convert $^ -append $@
|
||||||
|
|
||||||
|
build/peach-rainbow%.png: build/peach-smol.png
|
||||||
|
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
|
||||||
|
ln -s ../../$^ $@
|
||||||
|
|
||||||
|
public/css/snek.css: src/less/snek.less $(wildcard src/less/*.less)
|
||||||
|
node_modules/.bin/lessc $< $@
|
||||||
|
|
||||||
|
public/js/snek.js: $(wildcard src/js/*.js)
|
||||||
|
node mergejs.js $^ > $@
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f build/*.*
|
||||||
|
mrproper: clean
|
||||||
|
rm -f $(OUTPUT)
|
||||||
@@ -1,3 +1,54 @@
|
|||||||
# Snek
|
# Snek
|
||||||
|

|
||||||
|
|
||||||
A simple Snake, done as my final JS class project
|
A "simple" Snake, done as my final JS class project
|
||||||
|
|
||||||
|
[Original subject](https://perso.liris.cnrs.fr/pierre-antoine.champin/enseignement/intro-js/s6.html)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- 60 FPS 2D animations
|
||||||
|
- arcade, speedrun and puzzle game modes
|
||||||
|
- touchscreen and controller support
|
||||||
|
- playable at [snek.codinget.me](https://snek.codinget.me)
|
||||||
|
|
||||||
|
## Dev dependencies
|
||||||
|
- All the POSIX tools, most importantly a POSIX-compliant shell, `echo`, `rm`, `seq` and `sed`
|
||||||
|
- Busybox is known to work
|
||||||
|
- GNU Coreutils are known to work
|
||||||
|
- On Windows, WSL is known to work
|
||||||
|
- Imagemagick, with the `convert` tool in the PATH
|
||||||
|
- `bc`
|
||||||
|
- Make
|
||||||
|
- 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 (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)
|
||||||
|
- `git clone` this repo
|
||||||
|
- `npm install` the dependencies (this will also build the less and js and initialize the database)
|
||||||
|
- `npm start` the server
|
||||||
|
- `make` every time you change something
|
||||||
|
|
||||||
|
## Running the game (prod, docker)
|
||||||
|
- Get the [Dockerfile](https://gitdab.com/Codinget/Snek/raw/branch/master/Dockerfile)
|
||||||
|
- `docker build` it
|
||||||
|
- `docker run -it -p80:3000` the container
|
||||||
|
- 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
|
||||||
|
[MIT](https://opensource.org/licenses/MIT)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
const DB=require('better-sqlite3');
|
||||||
|
const {Router}=require('express');
|
||||||
|
|
||||||
|
const db=new DB('db.sqlite');
|
||||||
|
const api=new Router();
|
||||||
|
|
||||||
|
const leaderboardSelect='SELECT username, score, length, time, speed FROM leaderboards';
|
||||||
|
const getLeaderboardsBy={
|
||||||
|
score: db.prepare(`${leaderboardSelect} WHERE mode=? ORDER BY score DESC LIMIT ? OFFSET ?`),
|
||||||
|
timeA: db.prepare(`${leaderboardSelect} WHERE mode=? ORDER BY time ASC LIMIT ? OFFSET ?`),
|
||||||
|
timeD: db.prepare(`${leaderboardSelect} WHERE mode=? ORDER BY time DESC LIMIT ? OFFSET ?`)
|
||||||
|
};
|
||||||
|
const addLeaderboard=db.prepare(`
|
||||||
|
INSERT
|
||||||
|
INTO leaderboards(mode, username, score, length, time, speed)
|
||||||
|
VALUES(@mode, @username, @score, @length, @time, @speed)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const levelList=require('./assets/levelList.json');
|
||||||
|
const validMode=mode => {
|
||||||
|
try {
|
||||||
|
const [category, name]=mode.split('/');
|
||||||
|
return levelList[category].levels.map(l => ''+l).includes(name);
|
||||||
|
} catch(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
api.get('/leaderboards/:category/:id', (req, res) => {
|
||||||
|
const sort=req.query.sort || 'score';
|
||||||
|
if(!['score', 'timeA', 'timeD'].includes(sort)) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid sort'
|
||||||
|
});
|
||||||
|
const results=+req.query.results || 20;
|
||||||
|
if((typeof results)!='number' || results<1 || results >100) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid result count'
|
||||||
|
});
|
||||||
|
const page=+req.query.page || 1;
|
||||||
|
if((typeof page)!='number' || page<1) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid page'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data=getLeaderboardsBy[sort]
|
||||||
|
.all(req.params.category+'/'+req.params.id, results, (page-1)*results);
|
||||||
|
return res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/leaderboards/:category/:id', (req, res) => {
|
||||||
|
const mode=req.params.category+'/'+req.params.id;
|
||||||
|
if((typeof mode)!='string' || !validMode(mode)) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid mode'
|
||||||
|
});
|
||||||
|
const username=req.body.username;
|
||||||
|
if((typeof username)!='string' || username.length>100) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid username'
|
||||||
|
});
|
||||||
|
const score=req.body.score;
|
||||||
|
if((typeof score)!='number' || score%1 || score<0) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid score'
|
||||||
|
});
|
||||||
|
const length=req.body.length;
|
||||||
|
if((typeof length)!='number' || length%1 || length<1) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid length'
|
||||||
|
});
|
||||||
|
const time=req.body.time;
|
||||||
|
if((typeof time)!='number' || time%1 || time<0) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid time'
|
||||||
|
});
|
||||||
|
const speed=req.body.speed;
|
||||||
|
if((typeof speed)!='number' || speed%1 || speed<0) return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Invalid speed'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
addLeaderboard.run({
|
||||||
|
mode, username,
|
||||||
|
score, length,
|
||||||
|
time, speed
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
res.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
err: 'Failed to add score'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return module.exports=exports=api;
|
||||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"debug": false,
|
||||||
|
|
||||||
|
"player.name": "Player",
|
||||||
|
"player.leaderboards": false,
|
||||||
|
|
||||||
|
"input.touchscreen.crosspad.enabled": false,
|
||||||
|
"input.touchscreen.crosspad.overlay": true,
|
||||||
|
|
||||||
|
"input.touchscreen.joystick.enabled": true,
|
||||||
|
"input.touchscreen.joystick.overlay": true,
|
||||||
|
"input.touchscreen.joystick.deadzone": 10,
|
||||||
|
|
||||||
|
"input.touchscreen.swipe.enabled": false,
|
||||||
|
"input.touchscreen.swipe.deadzone": 50,
|
||||||
|
|
||||||
|
"input.gamepad.enabled": true,
|
||||||
|
"input.gamepad.deadzone": 0.5,
|
||||||
|
|
||||||
|
"input.keyboard.enabled": true,
|
||||||
|
|
||||||
|
"input.buffer": false,
|
||||||
|
|
||||||
|
"appearance.grid": "none",
|
||||||
|
"appearance.timer": "both"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"speedrun": {
|
||||||
|
"desc": "Get all the fruits as fast as possible without touching the walls",
|
||||||
|
"rules": {
|
||||||
|
"fruitRegrow": false,
|
||||||
|
"speedIncrease": false,
|
||||||
|
"worldWrap": false,
|
||||||
|
"winCondition": "fruit",
|
||||||
|
"scoreSystem": "speedrun",
|
||||||
|
"uploadOnDeath": false,
|
||||||
|
"leaderboardsSort": "timeA"
|
||||||
|
},
|
||||||
|
"levelFilename": "level<n>.json",
|
||||||
|
"levelDisplay": "Level <n>",
|
||||||
|
"levels": [
|
||||||
|
1, 2, 3, 4, 5
|
||||||
|
],
|
||||||
|
"nextLevel": true
|
||||||
|
},
|
||||||
|
"arcade": {
|
||||||
|
"desc": "Have fun just like in the good ol' days, walls wrap around, fruits respawn and speed increases",
|
||||||
|
"rules": {
|
||||||
|
"fruitRegrow": true,
|
||||||
|
"speedIncrease": true,
|
||||||
|
"speedMultiplier": 0.9,
|
||||||
|
"speedCap": 50,
|
||||||
|
"worldWrap": true,
|
||||||
|
"uploadOnDeath": true,
|
||||||
|
"leaderboardsSort": "score"
|
||||||
|
},
|
||||||
|
"levelFilename": "arcade-<l>.json",
|
||||||
|
"levelDisplay": "<n>",
|
||||||
|
"levels": [
|
||||||
|
"Arcade",
|
||||||
|
"Timed",
|
||||||
|
"Survival"
|
||||||
|
],
|
||||||
|
"nextLevel": false,
|
||||||
|
"levelDesc": [
|
||||||
|
"The old classic, try to get as high as a score as you can",
|
||||||
|
"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<n>.json",
|
||||||
|
"levelDisplay": "Level <n>",
|
||||||
|
"levels": [
|
||||||
|
1, 2, 3
|
||||||
|
],
|
||||||
|
"nextLevel": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{
|
||||||
|
"player": {
|
||||||
|
"name": "Player settings"
|
||||||
|
},
|
||||||
|
"player.name": {
|
||||||
|
"name": "Player name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"player.leaderboards": {
|
||||||
|
"name": "Upload scores to leaderboards",
|
||||||
|
"type": "boolean",
|
||||||
|
"needsBackend": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"input": {
|
||||||
|
"name": "Input settings"
|
||||||
|
},
|
||||||
|
|
||||||
|
"input.buffer": {
|
||||||
|
"name": "Enable input buffering",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
|
||||||
|
"input.touchscreen": {
|
||||||
|
"name": "Touchscreen settings"
|
||||||
|
},
|
||||||
|
|
||||||
|
"input.touchscreen.crosspad": {
|
||||||
|
"name": "Crosspad mode"
|
||||||
|
},
|
||||||
|
"input.touchscreen.crosspad.enabled": {
|
||||||
|
"name": "Enable crosspad",
|
||||||
|
"type": "boolean",
|
||||||
|
"excludes": [
|
||||||
|
"input.touchscreen.joystick.enabled",
|
||||||
|
"input.touchscreen.swipe.enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"input.touchscreen.crosspad.overlay": {
|
||||||
|
"name": "Show overlay",
|
||||||
|
"type": "boolean",
|
||||||
|
"parent": "input.touchscreen.crosspad.enabled"
|
||||||
|
},
|
||||||
|
|
||||||
|
"input.touchscreen.joystick": {
|
||||||
|
"name": "Joystick mode"
|
||||||
|
},
|
||||||
|
"input.touchscreen.joystick.enabled": {
|
||||||
|
"name": "Enable joystick",
|
||||||
|
"type": "boolean",
|
||||||
|
"excludes": [
|
||||||
|
"input.touchscreen.crosspad.enabled",
|
||||||
|
"input.touchscreen.swipe.enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"input.touchscreen.joystick.overlay": {
|
||||||
|
"name": "Show overlay",
|
||||||
|
"type": "boolean",
|
||||||
|
"parent": "input.touchscreen.joystick.enabled"
|
||||||
|
},
|
||||||
|
"input.touchscreen.joystick.deadzone": {
|
||||||
|
"name": "Deadzone",
|
||||||
|
"type": "number",
|
||||||
|
"parent": "input.touchscreen.joystick.enabled",
|
||||||
|
"bounds": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 100,
|
||||||
|
"inc": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"input.touchscreen.swipe": {
|
||||||
|
"name": "Swipe mode"
|
||||||
|
},
|
||||||
|
"input.touchscreen.swipe.enabled": {
|
||||||
|
"name": "Enable swipe",
|
||||||
|
"type": "boolean",
|
||||||
|
"excludes": [
|
||||||
|
"input.touchscreen.crosspad.enabled",
|
||||||
|
"input.touchscreen.joystick.enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"input.touchscreen.swipe.deadzone": {
|
||||||
|
"name": "Deadzone",
|
||||||
|
"type": "number",
|
||||||
|
"parent": "input.touchscreen.swipe.enabled",
|
||||||
|
"bounds": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 100,
|
||||||
|
"inc": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"input.gamepad": {
|
||||||
|
"name": "Gamepad settings"
|
||||||
|
},
|
||||||
|
"input.gamepad.enabled": {
|
||||||
|
"name": "Enable gamepad",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"input.gamepad.deadzone": {
|
||||||
|
"name": "Deadzone",
|
||||||
|
"type": "number",
|
||||||
|
"parent": "input.gamepad.enabled",
|
||||||
|
"bounds": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"inc": 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"input.keyboard": {
|
||||||
|
"name": "Keyboard settings"
|
||||||
|
},
|
||||||
|
"input.keyboard.enabled": {
|
||||||
|
"name": "Enable keyboard",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
|
||||||
|
"appearance": {
|
||||||
|
"name": "Appearance"
|
||||||
|
},
|
||||||
|
"appearance.grid": {
|
||||||
|
"name": "Grid type",
|
||||||
|
"type": "choice",
|
||||||
|
"bounds": {
|
||||||
|
"choices": [
|
||||||
|
"none",
|
||||||
|
"grid",
|
||||||
|
"checkerboard"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appearance.timer": {
|
||||||
|
"name": "Timer display",
|
||||||
|
"type": "choice",
|
||||||
|
"bounds": {
|
||||||
|
"choices": [
|
||||||
|
"none",
|
||||||
|
"border",
|
||||||
|
"number",
|
||||||
|
"both"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"color": "#fba49b",
|
||||||
|
"join": "round",
|
||||||
|
"cap": "round",
|
||||||
|
"headSize": 0.8,
|
||||||
|
"tailSize": 0.4,
|
||||||
|
"tailWrapSize": 0.1
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 382 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
const express=require('express');
|
||||||
|
|
||||||
|
const app=express();
|
||||||
|
const PORT=process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
app.use('/api', require('./api'));
|
||||||
|
app.get('/api/has-nodejs', (req, res) => {
|
||||||
|
res.json("yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Listening on 0.0.0.0:${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE leaderboards (
|
||||||
|
mode TEXT,
|
||||||
|
username TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
length INTEGER,
|
||||||
|
time INTEGER,
|
||||||
|
speed INTEGER
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const DB=require('better-sqlite3');
|
||||||
|
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');
|
||||||
|
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);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"dimensions": [32, 32],
|
||||||
|
"delay": 200,
|
||||||
|
"food": [
|
||||||
|
[16, 16]
|
||||||
|
],
|
||||||
|
"snake": [
|
||||||
|
[16, 12],
|
||||||
|
[16, 11],
|
||||||
|
[16, 10]
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"superFruitGrow": true,
|
||||||
|
"decayingFruitGrow": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"world": [
|
||||||
|
" w ",
|
||||||
|
" w ",
|
||||||
|
" wwwwwww w wwwwwww ",
|
||||||
|
" w w ",
|
||||||
|
" w w ",
|
||||||
|
" w wwwwwwwwwwwww w ",
|
||||||
|
" w w ",
|
||||||
|
" w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
"www w wwwwwwwwwwwwwww w www",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w ",
|
||||||
|
" w w ",
|
||||||
|
" w wwwwwwwwwwwww w ",
|
||||||
|
" w w ",
|
||||||
|
" w w ",
|
||||||
|
" wwwwwww w wwwwwww ",
|
||||||
|
" w ",
|
||||||
|
" w "
|
||||||
|
],
|
||||||
|
"delay": 200,
|
||||||
|
"snake": [
|
||||||
|
[16, 3],
|
||||||
|
[15, 3],
|
||||||
|
[14, 3]
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"worldWrap": false,
|
||||||
|
"autoSpeedIncrease": true,
|
||||||
|
"autoSpeadIncreaseTicks": 10,
|
||||||
|
"autoSizeGrow": true,
|
||||||
|
"autoSizeGrowTicks": 100,
|
||||||
|
"scoreSystem": "survival",
|
||||||
|
"leaderboardsSort": "timeD"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"dimensions": [32, 32],
|
||||||
|
"delay": 100,
|
||||||
|
"food": [
|
||||||
|
[16, 5]
|
||||||
|
],
|
||||||
|
"snake": [
|
||||||
|
[16, 12],
|
||||||
|
[16, 11],
|
||||||
|
[16, 10]
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"speedMultiplier": 0.8,
|
||||||
|
"gameDuration": 30000,
|
||||||
|
"winCondition": "time"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"dimensions": [80, 40],
|
||||||
|
"delay": 200,
|
||||||
|
"walls": [
|
||||||
|
[5,5], [5,6], [5,7], [5,8], [70, 35], [71, 35], [72, 35]
|
||||||
|
],
|
||||||
|
"food": [
|
||||||
|
[10,10]
|
||||||
|
],
|
||||||
|
"snake": [
|
||||||
|
[60,20],
|
||||||
|
[60,19],
|
||||||
|
[60,18]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"world": [
|
||||||
|
" ",
|
||||||
|
" f ",
|
||||||
|
" fw w wf ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" w ",
|
||||||
|
" f "
|
||||||
|
],
|
||||||
|
"snake": [
|
||||||
|
[6,6],
|
||||||
|
[6,7],
|
||||||
|
[6,8],
|
||||||
|
[6,9]
|
||||||
|
],
|
||||||
|
"delay": 150
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"world": [
|
||||||
|
" w ",
|
||||||
|
" w ",
|
||||||
|
" wwwwwww w wwwwwww ",
|
||||||
|
" w f w ",
|
||||||
|
" w w ",
|
||||||
|
" w wwwwwwwwwwwww w ",
|
||||||
|
" w w ",
|
||||||
|
" w f w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
"wwwf w fwwwwwwwwwwwwwwwf w fwww",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w w w ",
|
||||||
|
" w f w ",
|
||||||
|
" w w ",
|
||||||
|
" w wwwwwwwwwwwww w ",
|
||||||
|
" w w ",
|
||||||
|
" w f w ",
|
||||||
|
" wwwwwww w wwwwwww ",
|
||||||
|
" w ",
|
||||||
|
" w "
|
||||||
|
],
|
||||||
|
"delay": 200,
|
||||||
|
"snake": [
|
||||||
|
[16, 4],
|
||||||
|
[15, 4],
|
||||||
|
[14, 4]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"world": [
|
||||||
|
" o ",
|
||||||
|
" o ",
|
||||||
|
" ooooo o ooooo ",
|
||||||
|
" f ",
|
||||||
|
" o o ",
|
||||||
|
" o ooooooooooooo o ",
|
||||||
|
" o o ",
|
||||||
|
" o f o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o ",
|
||||||
|
"ooof o foooooo oooooof o fooo",
|
||||||
|
" o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o o o ",
|
||||||
|
" o f o ",
|
||||||
|
" o o ",
|
||||||
|
" o ooooooooooooo o ",
|
||||||
|
" o o ",
|
||||||
|
" f ",
|
||||||
|
" ooooo o ooooo ",
|
||||||
|
" o ",
|
||||||
|
" o "
|
||||||
|
],
|
||||||
|
"delay": 100,
|
||||||
|
"snake": [
|
||||||
|
[16, 4]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"world": [
|
||||||
|
" f f",
|
||||||
|
" o ",
|
||||||
|
" o ",
|
||||||
|
" w ",
|
||||||
|
"wwwooowwiIIIIIf",
|
||||||
|
" f I ",
|
||||||
|
" I ",
|
||||||
|
" I ",
|
||||||
|
" i f"
|
||||||
|
],
|
||||||
|
"delay": 200,
|
||||||
|
"snake": [
|
||||||
|
[0, 0]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"world": [
|
||||||
|
"k wA wf",
|
||||||
|
" w wB",
|
||||||
|
" w ww",
|
||||||
|
" fw ",
|
||||||
|
" w ",
|
||||||
|
" K f"
|
||||||
|
],
|
||||||
|
"snake": [
|
||||||
|
[0, 5]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const fs=require('fs');
|
||||||
|
|
||||||
|
const requireFn=`
|
||||||
|
const require=function require(name) {
|
||||||
|
if(require.cache[name]) return require.cache[name];
|
||||||
|
if(!require.source[name]) throw new Error("Cannot require "+name+": not found");
|
||||||
|
require.cache[name]=require.source[name]({}) || true;
|
||||||
|
return require.cache[name];
|
||||||
|
};
|
||||||
|
require.cache=Object.create(null);
|
||||||
|
require.source=Object.create(null);
|
||||||
|
window.require=require;
|
||||||
|
`;
|
||||||
|
|
||||||
|
let outputCode=[requireFn];
|
||||||
|
process.argv
|
||||||
|
.slice(2)
|
||||||
|
.map(a => [a, a.match(/([a-zA-Z_][a-zA-Z0-9_-]*).js$/)[1]])
|
||||||
|
.forEach(([modFile, modName]) => {
|
||||||
|
const modSource=fs.readFileSync(modFile, 'utf8');
|
||||||
|
outputCode.push(`
|
||||||
|
require.source['${modName}']=(a => a.bind(a)) (function ${modName}(module) {
|
||||||
|
'use strict';
|
||||||
|
${modSource}
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeSync(1, outputCode.map(a => a.trim()).join('\n'));
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "snek",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A simple Snake",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"prepare": "node install.js"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@ssh.gitdab.com:Codinget/Snek.git"
|
||||||
|
},
|
||||||
|
"author": "Codinget",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^6.0.1",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"less": "^3.11.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
../../assets/config.json
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
../../assets/levelList.json
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../assets/metaConfig.json
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1 @@
|
|||||||
|
../../assets/snake.json
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Snek - Help</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="favicon" href="favicon.ico">
|
||||||
|
<link rel="stylesheet" href="css/snek.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="help">
|
||||||
|
<article>
|
||||||
|
<h2>Controls</h2>
|
||||||
|
<p>
|
||||||
|
On keyboard, the game is played with the arrow keys.<br>
|
||||||
|
You can use the <code>f</code> key to go fullscreen.<br>
|
||||||
|
You can also use the <code>r</code> key to quickly restart a game.<br>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
On mobile or with a touchscreen, the game can be played in 3 modes.<br>
|
||||||
|
In crosspad mode, the screen is divided into 4 regions, and each one corresponds to a direction.<br>
|
||||||
|
In joystick mode, the point where you press your finger is the center of a virtual joystick, and moving it will trigger the directions.<br>
|
||||||
|
In swipe mode, you just swipe to move.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h2>Game modes</h2>
|
||||||
|
<section>
|
||||||
|
<h3>Speedrun mode</h3>
|
||||||
|
<p>
|
||||||
|
In speedrun mode, the goal is to collect all the fruits as fast as possible.<br>
|
||||||
|
Attempting to go through the edge of the playfield will kill you.<br>
|
||||||
|
Each level introduces a new mechanic.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Arcade</h3>
|
||||||
|
<p>
|
||||||
|
Arcade mode is mostly like the classics.<br>
|
||||||
|
In arcade mode, you can go through the edge of the playfield and come out on the other side.<br>
|
||||||
|
Super fruits and decaying fruits may also appear. They are worth <em>10</em> and <em>5</em> points respectively.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Timed</h3>
|
||||||
|
<p>
|
||||||
|
Timed mode is a bare-bones mode with a looping playfield.<br>
|
||||||
|
A timed game only lasts for 30 seconds, and the goal is to get as high a score as possible.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
<h2>Tiles</h2>
|
||||||
|
<ul>
|
||||||
|
<li><em>Walls</em> kill you if you touch them</li>
|
||||||
|
<li><em>Fruits</em> give you one point and make you grow. In arcade gamemodes, they also make the game go faster</li>
|
||||||
|
<li><em>Holes</em> kill you if the entire snake is over a hole, or the head and 3 subsequent tiles are over a hole</li>
|
||||||
|
<li><em>Fire</em> kills you if you touch it</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>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>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Snek</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="favicon" href="favicon.ico">
|
||||||
|
<link rel="stylesheet" href="css/snek.css">
|
||||||
|
<script src="js/snek.js"></script>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', () => require('main'));
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="loading">
|
||||||
|
<header>
|
||||||
|
<a href="#"><img src="assets/icon256.png"></a>
|
||||||
|
<h1>Snek</h1>
|
||||||
|
<h2>A "simple" Snake</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#">Menu</a></li>
|
||||||
|
<li class="loaded"><a href="#settings">Config</a></li>
|
||||||
|
<li class="loaded"><a href="#help">Help</a></li>
|
||||||
|
<li class="server loaded"><a href="#leaderboards">Leaderboards</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<nav></nav>
|
||||||
|
<canvas class="hidden"></canvas>
|
||||||
|
<div id="hud" class="hidden">
|
||||||
|
<div class="status">
|
||||||
|
<span class="speed"></span>
|
||||||
|
<span class="score"></span>
|
||||||
|
<span class="time"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<img src="assets/icon32.png">
|
||||||
|
<p>
|
||||||
|
Snek by <a href="https://codinget.me">Codinget</a> on <a href="https://gitdab.com/Codinget/Snek">GitDab</a>
|
||||||
|
<span class="serverless loaded">running without Nodejs backend</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Original <a href="https://perso.liris.cnrs.fr/pierre-antoine.champin/enseignement/intro-js/s6.html">subject</a> by <a href="https://perso.liris.cnrs.fr/pierre-antoine.champin/">P.A. Champin</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../levels/
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
const ProgressBar=require('progress');
|
||||||
|
|
||||||
|
const assetSpecs=[
|
||||||
|
{ name: 'fruit', filename: 'apple32.png', type: 'image' },
|
||||||
|
{ name: 'superFruit', filename: 'peach-rainbow-anim.png', type: 'image' },
|
||||||
|
{ name: 'decayFruit', filename: 'peach-decay-anim.png', type: 'image' },
|
||||||
|
{ name: 'wall', filename: 'wall32.png', type: 'image' },
|
||||||
|
{ 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: '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: 'levelList', filename: 'levelList.json', type: 'json' },
|
||||||
|
{ name: 'config', filename: 'config.json', type: 'json' },
|
||||||
|
{ name: 'metaConfig', filename: 'metaConfig.json', type: 'json' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const tasks=[
|
||||||
|
{ from: 'hole', type: 'tileset', steps: 1, tiles: ['base', 'ul', 'dr', 'dl', 'ur', 'l', 'r', 'd', 'u'] },
|
||||||
|
{ 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 },
|
||||||
|
{ from: 'switch', type: 'tileset', steps: 1, tiles: ['on', 'off'] },
|
||||||
|
{ from: 'spikes', type: 'tileset', steps: 1, tiles: ['off', 'on'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const cvs=document.createElement('canvas');
|
||||||
|
cvs.width=400;
|
||||||
|
cvs.height=50;
|
||||||
|
cvs.classList.add('progressBar');
|
||||||
|
cvs.classList.add('hiddenBottom');
|
||||||
|
|
||||||
|
const bar=new ProgressBar(assetSpecs.length*2+tasks.reduce((a, t) => a+t.steps, 0));
|
||||||
|
bar.addUpdateListener(() => bar.draw(cvs, '#fba49b', '#930a16'));
|
||||||
|
bar.draw(cvs, '#fba49b', '#930a16');
|
||||||
|
|
||||||
|
document.body.appendChild(cvs);
|
||||||
|
setTimeout(() => cvs.classList.remove('hiddenBottom'), 0);
|
||||||
|
|
||||||
|
bar.addReadyListener(() => {
|
||||||
|
cvs.classList.add('hiddenBottom');
|
||||||
|
setTimeout(() => document.body.removeChild(cvs), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
//XXX purposefully slow down asset loading
|
||||||
|
const sleep=(ms=500) => new Promise(ok => setTimeout(ok, ms*Math.random()));
|
||||||
|
|
||||||
|
const loadAsset=async (asset) => {
|
||||||
|
const response=await fetch('assets/'+asset.filename);
|
||||||
|
await sleep();
|
||||||
|
bar.update();
|
||||||
|
let result;
|
||||||
|
switch(asset.type) {
|
||||||
|
case 'json':
|
||||||
|
result=await response.json();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
result=await createImageBitmap(await response.blob());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep();
|
||||||
|
bar.update();
|
||||||
|
return [asset.name, result];
|
||||||
|
};
|
||||||
|
|
||||||
|
let assets=Object.create(null);
|
||||||
|
let ready=false;
|
||||||
|
let readyListeners=[];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let arr=await Promise
|
||||||
|
.all(
|
||||||
|
assetSpecs.map(a => loadAsset(a))
|
||||||
|
);
|
||||||
|
|
||||||
|
arr.forEach(([name, value]) => {
|
||||||
|
assets[name]=value;
|
||||||
|
});
|
||||||
|
|
||||||
|
for(let task of tasks) {
|
||||||
|
const source=assets[task.from];
|
||||||
|
switch(task.type) {
|
||||||
|
case 'tileset': {
|
||||||
|
let asset=assets[task.from]=Object.create(null);
|
||||||
|
for(let tId in task.tiles) {
|
||||||
|
const tName=task.tiles[tId];
|
||||||
|
asset[tName]=await createImageBitmap(source, 0, source.width*tId, source.width, source.width);
|
||||||
|
if(tId%(task.tiles.length/task.steps)==0) {
|
||||||
|
await sleep(100);
|
||||||
|
bar.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'animation': {
|
||||||
|
let anim=assets[task.from]=[];
|
||||||
|
let frameCount=source.height/source.width;
|
||||||
|
for(let i=0; i<frameCount; i++) {
|
||||||
|
anim[i]=await createImageBitmap(source, 0, source.width*i, source.width, source.width);
|
||||||
|
if(i%(frameCount/task.steps)==0) {
|
||||||
|
await sleep(100);
|
||||||
|
bar.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ready=true;
|
||||||
|
readyListeners.forEach(fn => fn.bind(fn)());
|
||||||
|
readyListeners=null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const onReady=(fn) => {
|
||||||
|
if(ready) fn.bind(fn)();
|
||||||
|
else readyListeners.push(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
const get=(name) => {
|
||||||
|
let asset=assets[name];
|
||||||
|
if(!asset) throw new Error("Unknown asset: "+name);
|
||||||
|
return asset;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onReady, get
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const assets=require('assets');
|
||||||
|
|
||||||
|
let watchers=Object.create(null);
|
||||||
|
let lastWatchCode=1;
|
||||||
|
|
||||||
|
const toBoolean=v => {
|
||||||
|
if(v=='false' || v==false) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const notify=(key, value) => {
|
||||||
|
const interested=watchers[key];
|
||||||
|
if(interested) Object
|
||||||
|
.keys(interested)
|
||||||
|
.map(key => interested[key])
|
||||||
|
.forEach(fn => fn(key, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const get=key => {
|
||||||
|
let confVal=localStorage.getItem('config.'+key);
|
||||||
|
if(confVal===null) return assets.get('config')[key];
|
||||||
|
return confVal;
|
||||||
|
};
|
||||||
|
const getB=key => toBoolean(get(key));
|
||||||
|
const getN=key => +get(key);
|
||||||
|
const getS=key => ''+get(key);
|
||||||
|
|
||||||
|
const set=(key, value) => {
|
||||||
|
localStorage.setItem('config.'+key, value);
|
||||||
|
notify(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove=key => {
|
||||||
|
localStorage.removeItem('config.'+key, value);
|
||||||
|
notify(key, assets.get('config')[key]);
|
||||||
|
};
|
||||||
|
const clear=() =>
|
||||||
|
Object
|
||||||
|
.keys(assets.get('config'))
|
||||||
|
.forEach(remove);
|
||||||
|
|
||||||
|
const watch=(key, fn) => {
|
||||||
|
if(!watchers[key]) watchers[key]=[];
|
||||||
|
const code='w'+lastWatchCode++;
|
||||||
|
watchers[key][code]=fn;
|
||||||
|
return code;
|
||||||
|
};
|
||||||
|
const watchB=(key, fn) => watch(key, (k, v) => fn(k, toBoolean(v)));
|
||||||
|
const watchN=(key, fn) => watch(key, (k, v) => fn(k, +v));
|
||||||
|
const watchS=(key, fn) => watch(key, (k, v) => fn(k, ''+v));
|
||||||
|
|
||||||
|
const unwatch=(key, code) => {
|
||||||
|
if(!watchers[key]) return;
|
||||||
|
delete watchers[key][code];
|
||||||
|
};
|
||||||
|
|
||||||
|
const list=() =>
|
||||||
|
Object
|
||||||
|
.keys(assets.get('config'));
|
||||||
|
const dict=() => {
|
||||||
|
let dict=Object.create(null);
|
||||||
|
Object
|
||||||
|
.keys(assets.get('config'))
|
||||||
|
.forEach(
|
||||||
|
key => dict[key]={
|
||||||
|
raw: get(key),
|
||||||
|
b: getB(key),
|
||||||
|
n: getN(key),
|
||||||
|
s: getS(key)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return dict;
|
||||||
|
};
|
||||||
|
|
||||||
|
return module.exports={
|
||||||
|
get, getB, getN, getS,
|
||||||
|
set,
|
||||||
|
remove, clear,
|
||||||
|
watch, watchB, watchN, watchS,
|
||||||
|
unwatch,
|
||||||
|
list, dict
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
const config=require('config');
|
||||||
|
const assets=require('assets');
|
||||||
|
const Popup=require('popup');
|
||||||
|
|
||||||
|
let lastCEId=0;
|
||||||
|
class ConfigEditor extends Popup {
|
||||||
|
constructor() {
|
||||||
|
super("Config editor", [], {ok: "OK"});
|
||||||
|
|
||||||
|
const metaConfig=assets.get('metaConfig');
|
||||||
|
|
||||||
|
this.watchers=[];
|
||||||
|
|
||||||
|
for(let key in metaConfig) {
|
||||||
|
const level=Array.from(key).reduce((a, c) => a+c=='.', 0)+2;
|
||||||
|
const data=metaConfig[key];
|
||||||
|
|
||||||
|
if(!data.type) {
|
||||||
|
this.addHeading(data.name);
|
||||||
|
} else {
|
||||||
|
let span=document.createElement('span');
|
||||||
|
let id='cfgInput-'+(lastCEId++)+'-'+key.replace(/\./g, '-');
|
||||||
|
let label=span.appendChild(document.createElement('label'));
|
||||||
|
label.innerText=data.name;
|
||||||
|
if(config.getB('debug')) label.title=key;
|
||||||
|
|
||||||
|
let input;
|
||||||
|
if(data.type=='boolean') {
|
||||||
|
input=document.createElement('input');
|
||||||
|
input.type='checkbox';
|
||||||
|
input.checked=config.getB(key);
|
||||||
|
input.addEventListener('change', () => config.set(key, input.checked));
|
||||||
|
} else if(data.type=='choice') {
|
||||||
|
input=document.createElement('select');
|
||||||
|
data.bounds.choices.forEach(choice => {
|
||||||
|
let option=document.createElement('option');
|
||||||
|
option.value=choice;
|
||||||
|
option.innerText=choice;
|
||||||
|
input.appendChild(option);
|
||||||
|
});
|
||||||
|
input.value=config.getS(key);
|
||||||
|
input.addEventListener('change', () => config.set(key, input.value));
|
||||||
|
} else if(data.type=='number') {
|
||||||
|
input=document.createElement('input');
|
||||||
|
input.type='number';
|
||||||
|
if(data.bounds) {
|
||||||
|
input.setAttribute('min', data.bounds.min);
|
||||||
|
input.setAttribute('max', data.bounds.max);
|
||||||
|
input.setAttribute('step', data.bounds.inc);
|
||||||
|
}
|
||||||
|
input.value=config.getN(key);
|
||||||
|
input.addEventListener('change', () => config.set(key, input.value));
|
||||||
|
} else if(data.type=='string') {
|
||||||
|
input=document.createElement('input');
|
||||||
|
input.type='text';
|
||||||
|
input.value=config.getS(key);
|
||||||
|
input.addEventListener('change', () => config.set(key, input.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
input.setAttribute('id', id);
|
||||||
|
span.appendChild(input);
|
||||||
|
label.setAttribute('for', id);
|
||||||
|
this.addContent(span);
|
||||||
|
|
||||||
|
if(data.excludes) {
|
||||||
|
const setEnabled=() => {
|
||||||
|
input.disabled=
|
||||||
|
data.excludes
|
||||||
|
.some(key => config.getB(key));
|
||||||
|
input.title=input.disabled?`Disable '${data.excludes.map(k => metaConfig[k].name).join('\', \'')}' to enable`:'';
|
||||||
|
};
|
||||||
|
|
||||||
|
setEnabled();
|
||||||
|
data.excludes.forEach(key => {
|
||||||
|
let c=config.watchB(key, setEnabled);
|
||||||
|
this.watchers.push([key, c]);
|
||||||
|
});
|
||||||
|
} else if(data.parent) {
|
||||||
|
const setEnabled=() => {
|
||||||
|
input.disabled=!config.getB(data.parent);
|
||||||
|
input.title=input.disabled?`Enable '${metaConfig[data.parent].name}' to enable`:'';
|
||||||
|
};
|
||||||
|
|
||||||
|
setEnabled();
|
||||||
|
let c=config.watchB(data.parent, setEnabled);
|
||||||
|
this.watchers.push([data.parent, c]);
|
||||||
|
}
|
||||||
|
if(data.needsBackend) {
|
||||||
|
if(window.serverless) {
|
||||||
|
input.disabled=true;
|
||||||
|
input.title="Needs backend";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.large=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
discard() {
|
||||||
|
this.watchers.forEach(([k, c]) => config.unwatch(k, c));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return module.exports={
|
||||||
|
show: async () => {
|
||||||
|
let editor=new ConfigEditor();
|
||||||
|
await editor.display();
|
||||||
|
editor.discard();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
const config=require('config');
|
||||||
|
|
||||||
|
let currentInputs={};
|
||||||
|
let handlers=[];
|
||||||
|
let hud;
|
||||||
|
|
||||||
|
const toAngleMagnitude=(x, y) => {
|
||||||
|
return {
|
||||||
|
angle: ((Math.atan2(x, y)+2*Math.PI)%(2*Math.PI))/Math.PI,
|
||||||
|
magnitude: Math.hypot(x, y)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAngleMagnitude=(x, y, threshold=0, fn=null) => {
|
||||||
|
const {angle, magnitude}=toAngleMagnitude(x, y);
|
||||||
|
|
||||||
|
if(magnitude>threshold) {
|
||||||
|
let inputs=currentInputs;
|
||||||
|
if(angle>.25 && angle <.75) inputs.right=true;
|
||||||
|
else if(angle>.75 && angle<1.25) inputs.up=true;
|
||||||
|
else if(angle>1.25 && angle<1.75) inputs.left=true;
|
||||||
|
else inputs.down=true;
|
||||||
|
|
||||||
|
if(fn) fn(angle, magnitude);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeChild=(parent, child) => {
|
||||||
|
if(child.parentNode==parent) parent.removeChild(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCrosspad=(() => {
|
||||||
|
const ns='http://www.w3.org/2000/svg';
|
||||||
|
const cross=document.createElementNS(ns, 'svg');
|
||||||
|
cross.classList.add('crosspadOverlay');
|
||||||
|
cross.setAttribute('width', 1000);
|
||||||
|
cross.setAttribute('height', 1000);
|
||||||
|
let dr=document.createElementNS(ns, 'line');
|
||||||
|
dr.setAttribute('x1', 0);
|
||||||
|
dr.setAttribute('y1', 0);
|
||||||
|
dr.setAttribute('x2', 1000);
|
||||||
|
dr.setAttribute('y2', 1000);
|
||||||
|
dr.setAttribute('stroke', 'black');
|
||||||
|
cross.appendChild(dr);
|
||||||
|
let dl=document.createElementNS(ns, 'line');
|
||||||
|
dl.setAttribute('x1', 1000);
|
||||||
|
dl.setAttribute('y1', 0);
|
||||||
|
dl.setAttribute('x2', 0);
|
||||||
|
dl.setAttribute('y2', 1000);
|
||||||
|
dl.setAttribute('stroke', 'black');
|
||||||
|
cross.appendChild(dl);
|
||||||
|
|
||||||
|
let useOverlay=false;
|
||||||
|
let enabled=false;
|
||||||
|
const displayOverlay=() => {
|
||||||
|
if(useOverlay && enabled) hud.appendChild(cross);
|
||||||
|
else removeChild(hud, cross);
|
||||||
|
};
|
||||||
|
config.watchB('input.touchscreen.crosspad.overlay', (k, v) => {
|
||||||
|
useOverlay=v;
|
||||||
|
displayOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fn=e =>
|
||||||
|
handleAngleMagnitude(
|
||||||
|
e.touches[0].clientX-window.innerWidth/2,
|
||||||
|
e.touches[0].clientY-window.innerHeight/2,
|
||||||
|
0,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const init=() => {
|
||||||
|
useOverlay=config.getB('input.touchscreen.crosspad.overlay');
|
||||||
|
enabled=true;
|
||||||
|
displayOverlay();
|
||||||
|
};
|
||||||
|
const fini=() => {
|
||||||
|
enabled=false;
|
||||||
|
displayOverlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
touchstart: fn,
|
||||||
|
touchmove: fn,
|
||||||
|
init, fini
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleKeyboard={
|
||||||
|
keydown: e => {
|
||||||
|
let inputs=currentInputs;
|
||||||
|
if(e.key=='ArrowUp') inputs.up=true;
|
||||||
|
else if(e.key=='ArrowDown') inputs.down=true;
|
||||||
|
else if(e.key=='ArrowLeft') inputs.left=true;
|
||||||
|
else if(e.key=='ArrowRight') inputs.right=true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoystick=(() => {
|
||||||
|
let cvs=document.createElement('canvas');
|
||||||
|
cvs.classList.add('joystickOverlay');
|
||||||
|
let ctx=cvs.getContext('2d');
|
||||||
|
let enabled=false;
|
||||||
|
let useOverlay=false;
|
||||||
|
let firstTouch=false;
|
||||||
|
|
||||||
|
let center={
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
};
|
||||||
|
let deadzone;
|
||||||
|
|
||||||
|
const displayOverlay=() => {
|
||||||
|
if(!enabled || !useOverlay || !firstTouch) return removeChild(hud, cvs);
|
||||||
|
|
||||||
|
cvs.width=cvs.height=4*deadzone+120;
|
||||||
|
hud.appendChild(cvs);
|
||||||
|
ctx.clearRect(0, 0, cvs.width, cvs.width);
|
||||||
|
|
||||||
|
ctx.strokeStyle='black';
|
||||||
|
ctx.lineWidth=1;
|
||||||
|
ctx.beginPath();
|
||||||
|
const rad=2*deadzone+50;
|
||||||
|
ctx.moveTo(rad*Math.cos(Math.PI/4)+cvs.width/2, rad*Math.sin(Math.PI/4)+cvs.width/2);
|
||||||
|
ctx.lineTo(rad*Math.cos(Math.PI/4+Math.PI)+cvs.width/2, rad*Math.sin(Math.PI/4+Math.PI)+cvs.width/2);
|
||||||
|
ctx.moveTo(rad*Math.cos(-Math.PI/4)+cvs.width/2, rad*Math.sin(-Math.PI/4)+cvs.width/2);
|
||||||
|
ctx.lineTo(rad*Math.cos(-Math.PI/4+Math.PI)+cvs.width/2, rad*Math.sin(-Math.PI/4+Math.PI)+cvs.width/2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle='gray';
|
||||||
|
ctx.lineWidth=2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cvs.width/2, cvs.width/2, deadzone, deadzone, 0, 0, Math.PI*2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth=3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cvs.width/2, cvs.width/2, deadzone*2+50, deadzone*2+50, 0, 0, Math.PI*2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
cvs.style.left=center.x+'px';
|
||||||
|
cvs.style.top=center.y+'px';
|
||||||
|
};
|
||||||
|
|
||||||
|
const init=() => {
|
||||||
|
enabled=true;
|
||||||
|
deadzone=config.getN('input.touchscreen.joystick.deadzone');
|
||||||
|
useOverlay=config.getB('input.touchscreen.joystick.overlay');
|
||||||
|
displayOverlay();
|
||||||
|
};
|
||||||
|
const fini=() => {
|
||||||
|
enabled=false;
|
||||||
|
displayOverlay();
|
||||||
|
};
|
||||||
|
config.watchN('input.touchscreen.joystick.deadzone', (k, v) => {
|
||||||
|
deadzone=v;
|
||||||
|
displayOverlay();
|
||||||
|
});
|
||||||
|
config.watchB('input.touchscreen.joystick.overlay', (k, v) => {
|
||||||
|
useOverlay=v;
|
||||||
|
displayOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
init, fini,
|
||||||
|
touchstart: e => {
|
||||||
|
center.x=e.touches[0].clientX;
|
||||||
|
center.y=e.touches[0].clientY;
|
||||||
|
firstTouch=true;
|
||||||
|
displayOverlay();
|
||||||
|
},
|
||||||
|
touchmove: e =>
|
||||||
|
handleAngleMagnitude(
|
||||||
|
e.touches[0].clientX-center.x,
|
||||||
|
e.touches[0].clientY-center.y,
|
||||||
|
deadzone,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleSwipe=(() => {
|
||||||
|
let center={
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
};
|
||||||
|
let deadzone;
|
||||||
|
|
||||||
|
let resetCenter=e => {
|
||||||
|
center.x=e.touches[0].clientX;
|
||||||
|
center.y=e.touches[0].clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const init=() => {
|
||||||
|
deadzone=config.getN('input.touchscreen.swipe.deadzone');
|
||||||
|
};
|
||||||
|
config.watchN('input.touchscreen.swipe.deadzone', (k, v) => {
|
||||||
|
deadzone=v;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
touchstart: resetCenter,
|
||||||
|
touchmove: e =>
|
||||||
|
handleAngleMagnitude(
|
||||||
|
e.touches[0].clientX-center.x,
|
||||||
|
e.touches[0].clientY-center.y,
|
||||||
|
deadzone,
|
||||||
|
() => resetCenter(e)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleGamepads=(() => {
|
||||||
|
let deadzone;
|
||||||
|
|
||||||
|
const init=() => {
|
||||||
|
deadzone=config.getN('input.touchscreen.swipe.deadzone');
|
||||||
|
};
|
||||||
|
config.watchN('input.touchscreen.swipe.deadzone', (k, v) => {
|
||||||
|
deadzone=v;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
frame: () => {
|
||||||
|
const gp=navigator.getGamepads()[0];
|
||||||
|
let inputs=currentInputs;
|
||||||
|
if(!gp || !gp.axes) return;
|
||||||
|
|
||||||
|
handleAngleMagnitude(
|
||||||
|
gp.axes[0],
|
||||||
|
gp.axes[1],
|
||||||
|
deadzone,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleEvent=(type, evt) => {
|
||||||
|
for(let handler of handlers) {
|
||||||
|
let fn=handler[type];
|
||||||
|
if(fn) fn(evt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableHandler=handler => {
|
||||||
|
if(!handlers.includes(handler)) {
|
||||||
|
handlers.push(handler);
|
||||||
|
if(handler.init) handler.init();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const disableHandler=handler => {
|
||||||
|
let idx=handlers.indexOf(handler);
|
||||||
|
if(idx!=-1) {
|
||||||
|
handlers.splice(idx, 1);
|
||||||
|
if(handler.fini) handler.fini();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const linkHandler=(handler, key) => {
|
||||||
|
if(config.getB(key)) enableHandler(handler);
|
||||||
|
config.watchB(key, (k, v) => {
|
||||||
|
if(v) enableHandler(handler);
|
||||||
|
else disableHandler(handler);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const init=({hud: hudElem}) => {
|
||||||
|
hud=hudElem;
|
||||||
|
linkHandler(handleCrosspad, 'input.touchscreen.crosspad.enabled');
|
||||||
|
linkHandler(handleJoystick, 'input.touchscreen.joystick.enabled');
|
||||||
|
linkHandler(handleSwipe, 'input.touchscreen.swipe.enabled');
|
||||||
|
linkHandler(handleGamepads, 'input.gamepad.enabled');
|
||||||
|
linkHandler(handleKeyboard, 'input.keyboard.enabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear=() =>
|
||||||
|
Object
|
||||||
|
.keys(currentInputs)
|
||||||
|
.forEach(key => delete currentInputs[key]);
|
||||||
|
|
||||||
|
for(let type of ['keydown', 'touchstart', 'touchmove']) {
|
||||||
|
window.addEventListener(type, handleEvent.bind(null, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
return module.exports={
|
||||||
|
inputs: currentInputs,
|
||||||
|
clear,
|
||||||
|
framefn: handleEvent.bind(null, 'frame'),
|
||||||
|
init
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const Popup=require('popup');
|
||||||
|
const levels=require('levels');
|
||||||
|
const config=require('config');
|
||||||
|
|
||||||
|
const upload=async (mode, win, snek) => {
|
||||||
|
if(!win && !snek.rules.uploadOnDeath) return;
|
||||||
|
if(window.serverless) return;
|
||||||
|
|
||||||
|
const username=config.getS('player.name');
|
||||||
|
const score=snek.score;
|
||||||
|
const length=snek.length;
|
||||||
|
const time=snek.endPlayTime;
|
||||||
|
const speed=snek.speed;
|
||||||
|
|
||||||
|
const rst=await fetch('api/leaderboards/'+mode, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
score, length,
|
||||||
|
time, speed
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const dat=await rst.json();
|
||||||
|
if(!dat.ok) console.error(rst.err);
|
||||||
|
};
|
||||||
|
|
||||||
|
const show=async (mode='speedrun/1', page=1) => {
|
||||||
|
let popup=new Popup("Leaderboards: "+mode);
|
||||||
|
|
||||||
|
const [category, id]=mode.split('/');
|
||||||
|
let modes=[];
|
||||||
|
(() => {
|
||||||
|
Object.keys(window.levelList).forEach(cat => {
|
||||||
|
window.levelList[cat].levels.forEach(lvl => {
|
||||||
|
modes.push(cat+'/'+lvl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
const prevMode=() => {
|
||||||
|
let idx=modes.indexOf(mode);
|
||||||
|
return modes[idx-1]||modes[modes.length-1];
|
||||||
|
};
|
||||||
|
const nextMode=() => {
|
||||||
|
let idx=modes.indexOf(mode);
|
||||||
|
return modes[idx+1]||modes[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules=await levels.getRules(category, id);
|
||||||
|
const sort=rules.leaderboardsSort;
|
||||||
|
const rst=await fetch('api/leaderboards/'+mode+'?sort='+sort+'&page='+page+'&results=10');
|
||||||
|
const {ok, data, err}=await rst.json();
|
||||||
|
|
||||||
|
popup.buttons.close="Close";
|
||||||
|
popup.buttons.modeP="Previous mode";
|
||||||
|
popup.buttons.modeN="Next mode";
|
||||||
|
popup.large=true;
|
||||||
|
popup.animation=false;
|
||||||
|
|
||||||
|
if(ok) {
|
||||||
|
popup.addStrong("Page "+page);
|
||||||
|
if(data.length==10) popup.buttons.next="Next page";
|
||||||
|
if(page>1) popup.buttons.prev="Previous page";
|
||||||
|
|
||||||
|
if(data.length==0) {
|
||||||
|
popup.addEm("No data");
|
||||||
|
} else {
|
||||||
|
const rpad=(n, digits=2, pad=' ') =>
|
||||||
|
((''+n).length>=digits)?(''+n):(rpad(pad+n, digits, pad));
|
||||||
|
|
||||||
|
popup.addTable(data.map(({username, score, length, speed, time}, i) => {
|
||||||
|
return {
|
||||||
|
rank: '#'+(i+(page-1)*10+1),
|
||||||
|
username,
|
||||||
|
score: score+'pts',
|
||||||
|
length,
|
||||||
|
speed: speed+'tps',
|
||||||
|
time: rpad(Math.floor(time/60000), 2, '0')+
|
||||||
|
':'+rpad(Math.floor(time/1000)%60, 2, '0')+
|
||||||
|
':'+rpad(time%1000, 3, '0')
|
||||||
|
};
|
||||||
|
}), [
|
||||||
|
'rank',
|
||||||
|
'username',
|
||||||
|
'score',
|
||||||
|
'length',
|
||||||
|
'speed',
|
||||||
|
'time'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
popup.addStrong("Error loading leaderboards");
|
||||||
|
popup.addEm(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Popup.dismiss();
|
||||||
|
const verb=await popup.display();
|
||||||
|
if(verb=='next') return show(mode, page+1);
|
||||||
|
else if(verb=='prev') return show(mode, page-1);
|
||||||
|
else if(verb=='modeP') return show(prevMode());
|
||||||
|
else if(verb=='modeN') return show(nextMode());
|
||||||
|
location.hash='';
|
||||||
|
};
|
||||||
|
|
||||||
|
return module.exports={
|
||||||
|
upload, show
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const cache=Object.create(null);
|
||||||
|
|
||||||
|
const get=async filename => {
|
||||||
|
if(cache[filename]) return cache[filename];
|
||||||
|
const req=await fetch('levels/'+filename);
|
||||||
|
const json=await req.json();
|
||||||
|
return cache[filename]=json;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInfo=(category, id) => {
|
||||||
|
const cat=levelList[category];
|
||||||
|
id=''+id;
|
||||||
|
|
||||||
|
const displayName=cat.levelDisplay
|
||||||
|
.replace(/<n>/g, id)
|
||||||
|
.replace(/<l>/g, id.toLowerCase());
|
||||||
|
const fileName=cat.levelFilename
|
||||||
|
.replace(/<n>/g, id)
|
||||||
|
.replace(/<l>/g, id.toLowerCase());
|
||||||
|
const levelString=category+'/'+id+'/'+fileName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName,
|
||||||
|
fileName,
|
||||||
|
levelString
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRules=async (category, id) => {
|
||||||
|
const {fileName}=getInfo(category, id);
|
||||||
|
const json=await get(fileName);
|
||||||
|
return Object.assign({}, window.levelList[category].rules, json.rules);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache=() =>
|
||||||
|
Object
|
||||||
|
.keys(cache)
|
||||||
|
.forEach(key => delete cache[key]);
|
||||||
|
|
||||||
|
return module.exports={
|
||||||
|
get, getRules, getInfo,
|
||||||
|
clearCache
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
(async () => {
|
||||||
|
|
||||||
|
// load modules
|
||||||
|
const assets=require('assets');
|
||||||
|
const Popup=require('popup');
|
||||||
|
const SnekGame=require('snek');
|
||||||
|
const configEditor=require('configEditor');
|
||||||
|
const input=require('input');
|
||||||
|
const levels=require('levels');
|
||||||
|
const config=require('config');
|
||||||
|
const leaderboards=require('leaderboards');
|
||||||
|
|
||||||
|
// get a known state
|
||||||
|
await new Promise(ok => assets.onReady(ok));
|
||||||
|
location.hash='menu';
|
||||||
|
|
||||||
|
// get our DOM in check
|
||||||
|
const main=document.querySelector('main');
|
||||||
|
const nav=main.querySelector('nav');
|
||||||
|
const canvas=main.querySelector('canvas');
|
||||||
|
const hud=main.querySelector('#hud');
|
||||||
|
|
||||||
|
// load data from server
|
||||||
|
const levelList=window.levelList=assets.get('levelList');
|
||||||
|
|
||||||
|
// detect if we're running with a server
|
||||||
|
const serverless=window.serverless=await (async() => {
|
||||||
|
const res=await fetch('api/has-nodejs');
|
||||||
|
if(!res.ok) return true;
|
||||||
|
const msg=await res.json();
|
||||||
|
return msg!='yes';
|
||||||
|
})();
|
||||||
|
if(serverless) {
|
||||||
|
document.body.classList.add('serverless');
|
||||||
|
} else {
|
||||||
|
document.body.classList.add('server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag the body as loaded
|
||||||
|
document.body.classList.remove('loading');
|
||||||
|
|
||||||
|
// get our global variables
|
||||||
|
let currentGame=null;
|
||||||
|
|
||||||
|
// forward-declare functions
|
||||||
|
let resizeCanvas, startGame, stopGame, handleWin, handleDeath, menu, help, settings, showLeaderboards, restart, updateHud;
|
||||||
|
|
||||||
|
// handle window resize and fullscreen
|
||||||
|
resizeCanvas=() => {
|
||||||
|
if(document.fullscreenElement) {
|
||||||
|
canvas.width=screen.width;
|
||||||
|
canvas.height=screen.height;
|
||||||
|
} else {
|
||||||
|
canvas.width=main.clientWidth;
|
||||||
|
canvas.height=main.clientHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
resizeCanvas();
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
window.addEventListener('keydown', async e => {
|
||||||
|
if(e.target.tagName.toLowerCase()=='input') return;
|
||||||
|
if(e.key=='f') {
|
||||||
|
if(document.fullscreenElement) await document.exitFullscreen();
|
||||||
|
else await main.requestFullscreen();
|
||||||
|
resizeCanvas();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// buid menu from level list
|
||||||
|
Object.keys(levelList).forEach(category => {
|
||||||
|
const cat=levelList[category];
|
||||||
|
|
||||||
|
const section=nav.appendChild(document.createElement('section'));
|
||||||
|
const h1=section.appendChild(document.createElement('h1'));
|
||||||
|
h1.innerText=category[0].toUpperCase()+category.slice(1)+" Mode";
|
||||||
|
|
||||||
|
const p=section.appendChild(document.createElement('p'));
|
||||||
|
p.innerText=cat.desc;
|
||||||
|
|
||||||
|
const ul=section.appendChild(document.createElement('ul'));
|
||||||
|
cat.levels.forEach((level, i) => {
|
||||||
|
const {displayName, fileName, levelString}=levels.getInfo(category, level);
|
||||||
|
const li=ul.appendChild(document.createElement('li'));
|
||||||
|
const a=li.appendChild(document.createElement('a'));
|
||||||
|
a.href='#'+levelString;
|
||||||
|
a.innerText=displayName;
|
||||||
|
if(cat.levelDesc) {
|
||||||
|
const span=li.appendChild(document.createElement('span'));
|
||||||
|
span.innerText=cat.levelDesc[i];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// stop a running game
|
||||||
|
stopGame=() => {
|
||||||
|
if(currentGame) {
|
||||||
|
// stop the actual game
|
||||||
|
currentGame.playing=false;
|
||||||
|
|
||||||
|
// setup the DOM
|
||||||
|
nav.classList.remove('hidden');
|
||||||
|
canvas.classList.add('hidden');
|
||||||
|
hud.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// display the leaderboards
|
||||||
|
showLeaderboards=() => {
|
||||||
|
stopGame();
|
||||||
|
leaderboards.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// start a new game
|
||||||
|
startGame=async (category, levelId, filename) => {
|
||||||
|
// stop any running games and clear popups
|
||||||
|
stopGame();
|
||||||
|
Popup.dismiss();
|
||||||
|
|
||||||
|
// load rules and level from cache or server
|
||||||
|
const rules=levelList[category].rules || {};
|
||||||
|
const level=await levels.get(filename);
|
||||||
|
|
||||||
|
// stop any running games and clear popups again
|
||||||
|
stopGame();
|
||||||
|
Popup.dismiss();
|
||||||
|
|
||||||
|
// create the game and attach the callbacks and config
|
||||||
|
const snek=currentGame=new SnekGame(level, canvas, rules);
|
||||||
|
snek.callback=evt => {
|
||||||
|
if(evt=='tick') {
|
||||||
|
input.framefn();
|
||||||
|
snek.handleInputs(input.inputs);
|
||||||
|
updateHud();
|
||||||
|
} else if(evt=='win') {
|
||||||
|
handleWin(snek);
|
||||||
|
} else if(evt=='die') {
|
||||||
|
handleDeath(snek);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// setup the DOM
|
||||||
|
nav.classList.add('hidden');
|
||||||
|
canvas.classList.remove('hidden');
|
||||||
|
hud.classList.remove('hidden');
|
||||||
|
|
||||||
|
// push some userdata to the snake
|
||||||
|
snek.userdata={
|
||||||
|
category,
|
||||||
|
levelId,
|
||||||
|
filename
|
||||||
|
};
|
||||||
|
|
||||||
|
// reset the inputs
|
||||||
|
input.clear();
|
||||||
|
|
||||||
|
// start the actual game
|
||||||
|
snek.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
// return to the menu
|
||||||
|
menu=() => {
|
||||||
|
stopGame();
|
||||||
|
Popup.dismiss();
|
||||||
|
};
|
||||||
|
|
||||||
|
// show config editor
|
||||||
|
settings=async () => {
|
||||||
|
stopGame();
|
||||||
|
Popup.dismiss();
|
||||||
|
await configEditor.show();
|
||||||
|
location.hash='menu';
|
||||||
|
};
|
||||||
|
|
||||||
|
// show help page
|
||||||
|
help=async () => {
|
||||||
|
stopGame();
|
||||||
|
Popup.dismiss();
|
||||||
|
let iframe=document.createElement('iframe');
|
||||||
|
iframe.src='help.html';
|
||||||
|
iframe.style.width='100%';
|
||||||
|
iframe.style.height='100%';
|
||||||
|
await new Popup(
|
||||||
|
"Help",
|
||||||
|
[iframe],
|
||||||
|
{ok: "OK"},
|
||||||
|
true
|
||||||
|
).display();
|
||||||
|
location.hash='menu';
|
||||||
|
};
|
||||||
|
|
||||||
|
// display the win popup
|
||||||
|
handleWin=async snek => {
|
||||||
|
// hide the HUD
|
||||||
|
hud.classList.add('hidden');
|
||||||
|
|
||||||
|
// fetch userdata from the game
|
||||||
|
const {category, levelId, filename}=snek.userdata;
|
||||||
|
|
||||||
|
// upload scores
|
||||||
|
if(config.getB('player.leaderboards')) leaderboards.upload(category+'/'+levelId, true, snek);
|
||||||
|
|
||||||
|
// create and configure popup
|
||||||
|
let popup=new Popup("Finished!");
|
||||||
|
popup.addStrong("You won!");
|
||||||
|
popup.addContent({
|
||||||
|
"Time": snek.endPlayTime/1000+'s',
|
||||||
|
"Score": snek.score,
|
||||||
|
"Final length": snek.length,
|
||||||
|
"Final speed": snek.speed+'tps'
|
||||||
|
});
|
||||||
|
popup.buttons={
|
||||||
|
retry: "Retry",
|
||||||
|
menu: "Main menu"
|
||||||
|
};
|
||||||
|
if(levelList[category].nextLevel) {
|
||||||
|
let nextId=(+levelId)+1;
|
||||||
|
if(levelList[category].levels.includes(nextId)) popup.buttons.next="Next level";
|
||||||
|
}
|
||||||
|
|
||||||
|
// show the actual popup
|
||||||
|
let result=await popup.display(main);
|
||||||
|
|
||||||
|
// act on it
|
||||||
|
if(result=='retry') {
|
||||||
|
startGame(category, levelId, filename);
|
||||||
|
} else if(result=='menu') {
|
||||||
|
location.hash='menu';
|
||||||
|
} else if(result=='next') {
|
||||||
|
const {category, levelId}=snek.userdata;
|
||||||
|
let nextId=(+levelId)+1;
|
||||||
|
let {levelString}=levels.getInfo(category, nextId)
|
||||||
|
location.hash=levelString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// display the death popup
|
||||||
|
handleDeath=async snek => {
|
||||||
|
// hide the HUD
|
||||||
|
hud.classList.add('hidden');
|
||||||
|
|
||||||
|
// fetch userdata from the game
|
||||||
|
const {category, levelId, filename}=snek.userdata;
|
||||||
|
|
||||||
|
// upload scores
|
||||||
|
if(config.getB('player.leaderboards')) leaderboards.upload(category+'/'+levelId, false, snek);
|
||||||
|
|
||||||
|
// create and configure popup
|
||||||
|
let popup=new Popup("Finished!");
|
||||||
|
popup.addStrong(config.getS('player.name')+' '+snek.death.message);
|
||||||
|
popup.addEm('('+config.getS('player.name')+' '+snek.death.reason+')');
|
||||||
|
popup.addContent({
|
||||||
|
"Time": snek.endPlayTime/1000+'s',
|
||||||
|
"Score": snek.score,
|
||||||
|
"Final length": snek.length,
|
||||||
|
"Final speed": snek.speed+'tps'
|
||||||
|
});
|
||||||
|
popup.buttons={
|
||||||
|
retry: "Retry",
|
||||||
|
menu: "Main menu"
|
||||||
|
};
|
||||||
|
|
||||||
|
// show the actual popup
|
||||||
|
let result=await popup.display(main);
|
||||||
|
|
||||||
|
// act on it
|
||||||
|
if(result=='retry') {
|
||||||
|
startGame(category, levelId, filename);
|
||||||
|
} else if(result=='menu') {
|
||||||
|
location.hash='menu';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// draw status hud
|
||||||
|
updateHud=() => {
|
||||||
|
// stay safe
|
||||||
|
if(!currentGame) return;
|
||||||
|
|
||||||
|
// get the actual elements
|
||||||
|
const speedDisplay=document.querySelector('#hud .speed');
|
||||||
|
const scoreDisplay=document.querySelector('#hud .score');
|
||||||
|
const timeDisplay=document.querySelector('#hud .time');
|
||||||
|
|
||||||
|
// rpad is useful
|
||||||
|
const rpad=(n, digits=2, pad=' ') =>
|
||||||
|
((''+n).length>=digits)?(''+n):(rpad(pad+n, digits, pad));
|
||||||
|
|
||||||
|
// actually do the hud
|
||||||
|
speedDisplay.innerText=rpad(currentGame.speed, 2, '0')+'tps';
|
||||||
|
scoreDisplay.innerText=currentGame.score;
|
||||||
|
timeDisplay.innerText=rpad(Math.floor(currentGame.playTime/60000), 2, '0')+
|
||||||
|
':'+rpad(Math.floor(currentGame.playTime/1000)%60, 2, '0')+
|
||||||
|
':'+rpad(currentGame.playTime%1000, 3, '0');
|
||||||
|
};
|
||||||
|
|
||||||
|
// quick restart
|
||||||
|
restart=() => {
|
||||||
|
if(currentGame && currentGame.playing) {
|
||||||
|
const {category, levelId, filename}=currentGame.userdata;
|
||||||
|
startGame(category, levelId, filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if(e.key=='r') restart();
|
||||||
|
});
|
||||||
|
(() => {
|
||||||
|
let restartbtn=hud.appendChild(document.createElement('span'));
|
||||||
|
restartbtn.classList.add('restart');
|
||||||
|
restartbtn.addEventListener('click', restart);
|
||||||
|
restartbtn.addEventListener('touchend', restart);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// handle page navigation
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const hash=location.hash.substr(1);
|
||||||
|
|
||||||
|
if(hash=='' || hash=='menu') return menu();
|
||||||
|
else if(hash=='help') return help();
|
||||||
|
else if(hash=='settings') return settings();
|
||||||
|
else if(hash=='leaderboards') return showLeaderboards();
|
||||||
|
|
||||||
|
const [_, category, levelId, filename]=location.hash.match(/([a-zA-Z0-9_-]+?)\/([a-zA-Z0-9_-]+?)\/(.+)/);
|
||||||
|
startGame(category, levelId, filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
// enable input methods overlay
|
||||||
|
input.init({hud});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const objToDom=obj => {
|
||||||
|
if(obj instanceof Node) {
|
||||||
|
return obj;
|
||||||
|
} else if(obj[Popup.EM]) {
|
||||||
|
let em=document.createElement('em');
|
||||||
|
em.appendChild(document.createTextNode(obj[Popup.EM]));
|
||||||
|
return em;
|
||||||
|
} else if(obj[Popup.STRONG]) {
|
||||||
|
let em=document.createElement('strong');
|
||||||
|
em.appendChild(document.createTextNode(obj[Popup.STRONG]));
|
||||||
|
return em;
|
||||||
|
} else if(typeof obj=='string' || typeof obj=='number') {
|
||||||
|
return document.createTextNode(obj+'');
|
||||||
|
} else if(Array.isArray(obj)) {
|
||||||
|
let ul=document.createElement('ul');
|
||||||
|
obj.forEach(elem => ul.appendChild(objToDom(elem)));
|
||||||
|
return ul;
|
||||||
|
} else {
|
||||||
|
let table=document.createElement('table');
|
||||||
|
table.classList.add('dual');
|
||||||
|
Object
|
||||||
|
.keys(obj)
|
||||||
|
.forEach(key => {
|
||||||
|
let tr=table.appendChild(document.createElement('tr'));
|
||||||
|
tr.appendChild(document.createElement('th')).appendChild(document.createTextNode(key));
|
||||||
|
tr.appendChild(document.createElement('td')).appendChild(objToDom(obj[key]));
|
||||||
|
});
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Popup {
|
||||||
|
constructor(title, content=[], buttons={}, large=false) {
|
||||||
|
this.title=title;
|
||||||
|
this.content=content.map(objToDom);
|
||||||
|
this.buttons={...buttons};
|
||||||
|
this.large=large;
|
||||||
|
this.animation=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
addContent(cnt) {
|
||||||
|
this.content.push(objToDom(cnt));
|
||||||
|
}
|
||||||
|
addText(cnt) {
|
||||||
|
this.content.push(document.createTextNode(cnt));
|
||||||
|
}
|
||||||
|
addEm(cnt) {
|
||||||
|
this.content.push(objToDom({[Popup.EM]: cnt}));
|
||||||
|
}
|
||||||
|
addStrong(cnt) {
|
||||||
|
this.content.push(objToDom({[Popup.STRONG]: cnt}));
|
||||||
|
}
|
||||||
|
addHeading(cnt, level=2) {
|
||||||
|
let hn=document.createElement('h'+level);
|
||||||
|
hn.innerText=cnt;
|
||||||
|
this.content.push(hn);
|
||||||
|
}
|
||||||
|
addTable(data, heading=Object.keys(data)) {
|
||||||
|
let table=document.createElement('table');
|
||||||
|
table.classList.add('table');
|
||||||
|
let thead=table.appendChild(document.createElement('thead'));
|
||||||
|
let headingRow=thead.appendChild(document.createElement('tr'));
|
||||||
|
heading.forEach(key => {
|
||||||
|
let th=headingRow.appendChild(document.createElement('th'));
|
||||||
|
th.innerText=key;
|
||||||
|
});
|
||||||
|
let tbody=table.appendChild(document.createElement('tbody'));
|
||||||
|
data.forEach(row => {
|
||||||
|
let tr=tbody.appendChild(document.createElement('tr'));
|
||||||
|
heading.forEach(key => {
|
||||||
|
let td=tr.appendChild(document.createElement('td'));
|
||||||
|
td.innerText=row[key];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.content.push(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
async display(parent=document.body) {
|
||||||
|
let outer=document.createElement('div');
|
||||||
|
outer.classList.add('popup');
|
||||||
|
let popup=outer.appendChild(document.createElement('div'));
|
||||||
|
popup.classList.add('content');
|
||||||
|
if(this.large) popup.classList.add('large');
|
||||||
|
if(this.animation) outer.classList.add('animation');
|
||||||
|
|
||||||
|
let title=popup.appendChild(document.createElement('h1'));
|
||||||
|
title.innerText=this.title;
|
||||||
|
|
||||||
|
let contentSection=popup.appendChild(document.createElement('section'));
|
||||||
|
this.content.forEach(elem => contentSection.appendChild(elem));
|
||||||
|
|
||||||
|
let buttonSection=popup.appendChild(document.createElement('section'));
|
||||||
|
let buttons=Object.keys(this.buttons).map(btn => {
|
||||||
|
let button=document.createElement('button');
|
||||||
|
button.innerText=this.buttons[btn];
|
||||||
|
button.dataset.code=btn;
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
buttons.forEach(btn => buttonSection.appendChild(btn));
|
||||||
|
|
||||||
|
parent.appendChild(outer);
|
||||||
|
Popup.displayed.push(this);
|
||||||
|
|
||||||
|
const btnActions=buttons.map(btn => new Promise(ok => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
return ok(btn.dataset.code);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
const dismissAction=new Promise(ok => this.dismiss=ok);
|
||||||
|
|
||||||
|
const code=await Promise.race(btnActions.concat([dismissAction]));
|
||||||
|
|
||||||
|
parent.removeChild(outer);
|
||||||
|
Popup.displayed.splice(Popup.displayed.indexOf(this), 1);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Popup.EM=Symbol('EM');
|
||||||
|
Popup.STRONG=Symbol('STRONG');
|
||||||
|
|
||||||
|
Popup.displayed=[];
|
||||||
|
Popup.dismiss=arg => {
|
||||||
|
Popup.displayed.forEach(p => p.dismiss(arg));
|
||||||
|
};
|
||||||
|
|
||||||
|
return module.exports=Popup;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
class ProgressBar {
|
||||||
|
constructor(taskCount) {
|
||||||
|
this.taskCount=taskCount;
|
||||||
|
this.completeCount=0;
|
||||||
|
this.updateListeneres=[];
|
||||||
|
}
|
||||||
|
|
||||||
|
get percent() {
|
||||||
|
return Math.floor(this.completeCount/this.taskCount*100);
|
||||||
|
}
|
||||||
|
|
||||||
|
get ready() {
|
||||||
|
return this.completeCount==this.taskCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
addUpdateListener(fn) {
|
||||||
|
this.updateListeneres.push(fn.bind(this));
|
||||||
|
}
|
||||||
|
addReadyListener(fn) {
|
||||||
|
this.updateListeneres.push(() => {
|
||||||
|
if(this.ready) fn.bind(this)();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.completeCount++;
|
||||||
|
this.updateListeneres.forEach(l => l(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(canvas, bgColor='red', textColor='black') {
|
||||||
|
let ctx=canvas.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle=bgColor;
|
||||||
|
ctx.fillRect(0, 0, canvas.width*this.completeCount/this.taskCount, canvas.height);
|
||||||
|
ctx.fillStyle=textColor;
|
||||||
|
ctx.textAlign='center';
|
||||||
|
ctx.textBaseline='middle';
|
||||||
|
ctx.font=`${canvas.height/2}px 'Fira Code'`;
|
||||||
|
ctx.fillText(this.percent+'%', canvas.width/2, canvas.height/2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProgressBar;
|
||||||
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
const {
|
||||||
|
tiles: T,
|
||||||
|
forChar,
|
||||||
|
getType,
|
||||||
|
isPortal,
|
||||||
|
snakeVersion, nonSnakeVersion
|
||||||
|
}=require('tiles');
|
||||||
|
|
||||||
|
class SnekGame {
|
||||||
|
constructor(settings, canvas, rules) {
|
||||||
|
// setup the delay
|
||||||
|
this.delay=settings.delay || Infinity;
|
||||||
|
|
||||||
|
// score starts at 0
|
||||||
|
this.score=0;
|
||||||
|
|
||||||
|
// world is given in the level
|
||||||
|
if(settings.world) { // explicitly
|
||||||
|
|
||||||
|
// convert the world
|
||||||
|
this.world=Array(settings.world[0].length);
|
||||||
|
for(let x=0; x<this.world.length; x++) {
|
||||||
|
this.world[x]=Array(settings.world.length);
|
||||||
|
for(let y=0; y<this.world[x].length; y++) {
|
||||||
|
this.world[x][y]=forChar(settings.world[y][x]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the dimensions
|
||||||
|
this.dimensions=[this.world.length, this.world[0].length];
|
||||||
|
|
||||||
|
// extract the fruits
|
||||||
|
this.fruits=this.getTilesOfType(T.FOOD);
|
||||||
|
|
||||||
|
// extract the decaying fruits
|
||||||
|
this.decayFood=this.getTilesOfType(T.DECAY_FOOD);
|
||||||
|
|
||||||
|
// extract the portals
|
||||||
|
this.portals={};
|
||||||
|
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
|
||||||
|
|
||||||
|
// get the dimensions
|
||||||
|
this.dimensions=[...settings.dimensions];
|
||||||
|
|
||||||
|
// build an empty world
|
||||||
|
this.world=Array(settings.dimensions[0]);
|
||||||
|
for(let i=0; i<settings.dimensions[0]; i++) {
|
||||||
|
this.world[i]=Array(settings.dimensions[1]);
|
||||||
|
this.world[i].fill(T.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the walls
|
||||||
|
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]=T.HOLE);
|
||||||
|
|
||||||
|
// add the fires and flammable tiles
|
||||||
|
if(settings.fires) settings.fires.forEach(([x, y]) => this.world[x][y]=T.FIRE);
|
||||||
|
if(settings.flammable) settings.flammable.forEach(([x, y]) => this.world[x][y]=T.FLAMMABLE);
|
||||||
|
|
||||||
|
// add the food
|
||||||
|
settings.food.forEach(([x, y]) => this.world[x][y]=T.FOOD);
|
||||||
|
this.fruits=[...settings.food];
|
||||||
|
|
||||||
|
// add the super food
|
||||||
|
if(settings.superFood) settings.superFood.forEach(([x, y]) => this.world[x][y]=T.SUPER_FOOD);
|
||||||
|
|
||||||
|
// add the decaying food
|
||||||
|
if(settings.decayFood) {
|
||||||
|
settings.decayFood.forEach(([x, y]) => this.world[x][y]=T.DECAY_FOOD);
|
||||||
|
this.decayFood=settings.decayFood.map(([x, y]) => [x, y, 0]);
|
||||||
|
} else {
|
||||||
|
this.decayFood=[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the portals
|
||||||
|
if(settings.portals) {
|
||||||
|
if(settings.portals.a) this.world[settings.portals.a[0]][settings.portals.a[1]]=T.PORTAL_A;
|
||||||
|
if(settings.portals.b) this.world[settings.portals.b[0]][settings.portals.b[1]]=T.PORTAL_B;
|
||||||
|
if(settings.portals.c) this.world[settings.portals.c[0]][settings.portals.c[1]]=T.PORTAL_C;
|
||||||
|
if(settings.portals.d) this.world[settings.portals.d[0]][settings.portals.d[1]]=T.PORTAL_D;
|
||||||
|
this.portals={...settings.portals};
|
||||||
|
} else {
|
||||||
|
this.portals={};
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the keys
|
||||||
|
if(settings.keys) settings.keys.forEach(([x, y]) => this.world[x][y]=T.KEY);
|
||||||
|
|
||||||
|
// add the doors
|
||||||
|
if(settings.doors) settings.doors.forEach(([x, y]) => this.world[x][y]=T.DOOR);
|
||||||
|
|
||||||
|
// add the switches
|
||||||
|
if(settings.switches) settings.switches.forEach(([x, y]) => this.world[x][y]=T.SWITCH_OFF);
|
||||||
|
|
||||||
|
// add the spikes
|
||||||
|
if(settings.spikesOn) settings.spikesOn.forEach(([x, y]) => this.world[x][y]=T.SPIKES_ON);
|
||||||
|
if(settings.spikesOff) settings.spikesOff.forEach(([x, y]) => this.world[x][y]=T.SPIKES_OFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the snake to the world
|
||||||
|
settings.snake.forEach(([x, y]) => this.world[x][y]=T.SNAKE);
|
||||||
|
|
||||||
|
|
||||||
|
// get the head and initial direction
|
||||||
|
this.head=[...settings.snake[0]];
|
||||||
|
if(settings.snake.length>=2) this.direction=[
|
||||||
|
settings.snake[0][0]-settings.snake[1][0],
|
||||||
|
settings.snake[0][1]-settings.snake[1][1]
|
||||||
|
];
|
||||||
|
else this.direction=[
|
||||||
|
1,
|
||||||
|
0
|
||||||
|
];
|
||||||
|
this.lastDirection=this.direction
|
||||||
|
|
||||||
|
// store the snake
|
||||||
|
this.snake=[...settings.snake];
|
||||||
|
this.length=this.snake.length;
|
||||||
|
|
||||||
|
// get our canvas, like, if we want to actually draw
|
||||||
|
this.canvas=canvas;
|
||||||
|
this.ctx=canvas.getContext('2d');
|
||||||
|
|
||||||
|
// load the custom rules
|
||||||
|
this.rules=Object.assign({
|
||||||
|
fruitRegrow: true,
|
||||||
|
superFruitGrow: false,
|
||||||
|
decayingFruitGrow: false,
|
||||||
|
speedIncrease: true,
|
||||||
|
worldWrap: true,
|
||||||
|
winCondition: 'none',
|
||||||
|
scoreSystem: 'fruit',
|
||||||
|
fireTickSpeed: 10,
|
||||||
|
autoSizeGrow: false,
|
||||||
|
autoSpeedIncrease: false,
|
||||||
|
timeFlow: true
|
||||||
|
}, rules, settings.rules || {});
|
||||||
|
|
||||||
|
// reset direction if time doesn't flow
|
||||||
|
if(!this.rules.timeFlow) {
|
||||||
|
this.lastDirection=[0, 0];
|
||||||
|
this.direction=[0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// set score if move-based
|
||||||
|
if(this.rules.scoreSystem=='moves') {
|
||||||
|
this.score=this.rules.moveCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get playTime() {
|
||||||
|
return Date.now()-this.firstStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
get speed() {
|
||||||
|
return Math.round(1000/this.delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTile(x, y) {
|
||||||
|
return (this.world[x]||[])[y];
|
||||||
|
}
|
||||||
|
getTileA([x, y]) {
|
||||||
|
return (this.world[x]||[])[y];
|
||||||
|
}
|
||||||
|
putTile(x, y, t) {
|
||||||
|
this.world[x][y]=t;
|
||||||
|
}
|
||||||
|
putTileA([x, y], t) {
|
||||||
|
this.world[x][y]=t;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTilesOfType(type) {
|
||||||
|
return this
|
||||||
|
.world
|
||||||
|
.map(
|
||||||
|
(l, x) => l
|
||||||
|
.map(
|
||||||
|
(r, y) => r==type?[x,y]:null
|
||||||
|
).filter(
|
||||||
|
a => a
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
}
|
||||||
|
replaceTilesOfType(type, newType) {
|
||||||
|
this.world.forEach(l =>
|
||||||
|
l.forEach((t, i) => {
|
||||||
|
if(t==type) l[i]=newType;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
require('renderer').draw(this, this.canvas, this.ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
step() {
|
||||||
|
this.tickId++;
|
||||||
|
this.lastDirection=this.direction;
|
||||||
|
|
||||||
|
// compute our new head
|
||||||
|
let head;
|
||||||
|
if(!this.portaled && isPortal(this.getTileA(this.snake[0]))) {
|
||||||
|
const tile=this.getTileA(this.snake[0]);
|
||||||
|
if(tile==T.PORTAL_A_S) head=this.portals.b;
|
||||||
|
if(tile==T.PORTAL_B_S) head=this.portals.a;
|
||||||
|
if(tile==T.PORTAL_C_S) head=this.portals.d;
|
||||||
|
if(tile==T.PORTAL_D_S) head=this.portals.c;
|
||||||
|
this.portaled=true;
|
||||||
|
} else {
|
||||||
|
head=[
|
||||||
|
this.snake[0][0]+this.direction[0],
|
||||||
|
this.snake[0][1]+this.direction[1]
|
||||||
|
];
|
||||||
|
this.portaled=false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get our tail out of the way
|
||||||
|
const tail=this.snake.pop();
|
||||||
|
this.putTileA(tail, nonSnakeVersion(this.getTileA(tail)));
|
||||||
|
|
||||||
|
// check for out of world conditions
|
||||||
|
if(head[0]<0 || head[0]>=this.dimensions[0] || head[1]<0 || head[1]>=this.dimensions[1]) {
|
||||||
|
if(this.rules.worldWrap) {
|
||||||
|
head[0]=(head[0]+this.dimensions[0])%this.dimensions[0];
|
||||||
|
head[1]=(head[1]+this.dimensions[1])%this.dimensions[1];
|
||||||
|
} else {
|
||||||
|
return this.die("literally fell out of the world", "exited the grid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tile=this.getTileA(head);
|
||||||
|
switch(getType(tile)) {
|
||||||
|
// you hit, you die
|
||||||
|
case 'wall':
|
||||||
|
switch(tile) {
|
||||||
|
case T.WALL: return this.die("thought walls were edible", "hit a wall");
|
||||||
|
case T.FIRE: return this.die("burned to a crisp", "hit fire");
|
||||||
|
case T.DOOR: return this.die("forgot to OPEN the door", "hit a door");
|
||||||
|
case T.SPIKES_ON: return this.die("thought they were a girl's drink in a nightclub", "hit spikes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// congratilations, you played yourself!
|
||||||
|
case 'snake':
|
||||||
|
return this.die("achieved every dog's dream", "ate their own tail");
|
||||||
|
|
||||||
|
// if either 3 consecutive segments or the whole snake is on a hole, you die
|
||||||
|
case 'hole':
|
||||||
|
if(
|
||||||
|
this.snake.length==0 ||
|
||||||
|
this.snake.length==1 &&
|
||||||
|
this.getTileA(this.snake[0])==T.HOLE_S ||
|
||||||
|
this.snake.length>=2 &&
|
||||||
|
this.getTileA(this.snake[0])==T.HOLE_S &&
|
||||||
|
this.getTileA(this.snake[1])==T.HOLE_S
|
||||||
|
) return this.die("fell harder than their grades", "fell in a hole");
|
||||||
|
break;
|
||||||
|
|
||||||
|
// you eat, you get a massive score boost
|
||||||
|
case 'bonus':
|
||||||
|
this.putTileA(head, T.EMPTY);
|
||||||
|
switch(tile) {
|
||||||
|
case T.SUPER_FOOD:
|
||||||
|
this.score+=10;
|
||||||
|
|
||||||
|
// you eat, you get a small score boost
|
||||||
|
case T.DECAY_FOOD:
|
||||||
|
this.score+=5;
|
||||||
|
this.decayFood=this.decayFood.filter(
|
||||||
|
([x, y, _]) => !(x==head[0] && y==head[1])
|
||||||
|
);
|
||||||
|
} break;
|
||||||
|
|
||||||
|
// you eat, you destroy all doors
|
||||||
|
case 'key':
|
||||||
|
this.putTileA(head, T.EMPTY);
|
||||||
|
this.replaceTilesOfType(T.DOOR, T.EMPTY);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// you step on, you trigger
|
||||||
|
case 'switch': {
|
||||||
|
this.putTileA(head, tile==T.SWITCH_ON?T.SWITCH_OFF:T.SWITCH_ON);
|
||||||
|
if(this.getTilesOfType(T.SPIKES_OFF_S).length) return this.die("spiked themselves", "activated spikes");
|
||||||
|
const oldSpikes=this.getTilesOfType(T.SPIKES_ON);
|
||||||
|
this.replaceTilesOfType(T.SPIKES_OFF, T.SPIKES_ON);
|
||||||
|
oldSpikes.forEach(pos => this.putTileA(pos, T.SPIKES_OFF));
|
||||||
|
} break;
|
||||||
|
|
||||||
|
// you eat, you grow
|
||||||
|
case 'food':
|
||||||
|
// re-grow the snake
|
||||||
|
this.snake.push(tail);
|
||||||
|
this.putTileA(tail, snakeVersion(this.getTileA(tail)));
|
||||||
|
this.length++;
|
||||||
|
|
||||||
|
// remove the fruit from existence
|
||||||
|
this.putTileA(head, T.EMPTY);
|
||||||
|
this.fruits=this.fruits.filter(
|
||||||
|
([x, y]) => !(x==head[0] && y==head[1])
|
||||||
|
);
|
||||||
|
|
||||||
|
// increase score
|
||||||
|
this.score++;
|
||||||
|
|
||||||
|
// custom rules
|
||||||
|
if(this.rules.fruitRegrow) {
|
||||||
|
const emptyCells=this.getTilesOfType(T.EMPTY);
|
||||||
|
|
||||||
|
const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
|
||||||
|
this.fruits.push(cell);
|
||||||
|
this.putTileA(cell, T.FOOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.rules.superFruitGrow) {
|
||||||
|
if(Math.random()<.1) { // 10% chance
|
||||||
|
const emptyCells=this.getTilesOfType(T.EMPTY);
|
||||||
|
const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
|
||||||
|
this.putTileA(cell, T.SUPER_FOOD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.rules.decayingFruitGrow) {
|
||||||
|
if(Math.random()<.2) { // 20% chance
|
||||||
|
const emptyCells=this.getTilesOfType(T.EMPTY);
|
||||||
|
const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
|
||||||
|
this.putTileA(cell, T.DECAY_FOOD);
|
||||||
|
this.decayFood.push([cell[0], cell[1], this.playTime]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.rules.speedIncrease) {
|
||||||
|
this.delay*=this.rules.speedMultiplier;
|
||||||
|
if(this.delay<this.rules.speedCap) this.delay=this.rules.speedCap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// move our head forward
|
||||||
|
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.putTile(x, y, T.EMPTY);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.decayFood=this.decayFood.filter(
|
||||||
|
([_, __, birth]) => this.playTime<birth+2000
|
||||||
|
);
|
||||||
|
|
||||||
|
// automatic speed increase
|
||||||
|
if(this.rules.autoSpeedIncrease) {
|
||||||
|
if(this.delay>50 && this.tickId%this.rules.autoSpeadIncreaseTicks==0) this.delay--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// automatic size grow
|
||||||
|
if(this.rules.autoSizeGrow) {
|
||||||
|
if(this.tickId%this.rules.autoSizeGrowTicks==0) this.snake.push(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire tick
|
||||||
|
if(this.tickId%this.rules.fireTickSpeed==0) {
|
||||||
|
const touchingFire=([x, y]) => {
|
||||||
|
const surrounding=[
|
||||||
|
this.getTile(x, y-1),
|
||||||
|
this.getTile(x, y+1),
|
||||||
|
this.getTile(x-1, y),
|
||||||
|
this.getTile(x-1, y)
|
||||||
|
];
|
||||||
|
return surrounding.some(tile => tile==T.FIRE);
|
||||||
|
};
|
||||||
|
if(this.getTilesOfType(T.FLAMMABLE_S).some(touchingFire)) return this.die("didn't know oil was flammable", "stood on oil when it caught on fire");
|
||||||
|
this.getTilesOfType(T.FLAMMABLE).filter(touchingFire).forEach(pos => this.putTileA(pos, T.FIRE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE WORLD!
|
||||||
|
if(!this.rules.timeFlow) {
|
||||||
|
this.lastDirection=[0, 0];
|
||||||
|
this.direction=[0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// victory condition
|
||||||
|
if(this.rules.winCondition=='fruit') {
|
||||||
|
if(!this.fruits.length) return this.win();
|
||||||
|
}
|
||||||
|
if(this.rules.winCondition=='time') {
|
||||||
|
if(this.playTime>=this.rules.gameDuration) return this.win();
|
||||||
|
}
|
||||||
|
if(this.rules.winCondition=='score') {
|
||||||
|
if(this.score>=this.rules.scoreObjective) return this.win();
|
||||||
|
}
|
||||||
|
if(this.rules.scoreSystem=='moves') {
|
||||||
|
if(this.score) this.score--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
if(!this.playing) return;
|
||||||
|
if(!this.lastStep) this.lastStep=this.firstStep;
|
||||||
|
this.draw();
|
||||||
|
if(this.callback) this.callback('tick');
|
||||||
|
if(this.rules.timeFlow && this.lastStep+this.delay<Date.now()) {
|
||||||
|
this.lastStep+=this.delay;
|
||||||
|
this.step();
|
||||||
|
}
|
||||||
|
if(!this.rules.timeFlow && (this.direction[0]!=0 || this.direction[1]!=0)) {
|
||||||
|
this.step();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => this.tick());
|
||||||
|
}
|
||||||
|
|
||||||
|
win() {
|
||||||
|
this.playing=false;
|
||||||
|
this.endPlayTime=this.playTime;
|
||||||
|
if(this.callback) this.callback('win');
|
||||||
|
}
|
||||||
|
|
||||||
|
die(message='died', reason='died') {
|
||||||
|
this.playing=false;
|
||||||
|
this.endPlayTime=this.playTime;
|
||||||
|
this.death={message, reason};
|
||||||
|
if(this.callback) this.callback('die');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputs(inputs) {
|
||||||
|
const config=require('config');
|
||||||
|
|
||||||
|
// change direction if the input is valid
|
||||||
|
const trySet=(dir) => {
|
||||||
|
if(!dir.every((e, i) => e==this.lastDirection[i] || e==-this.lastDirection[i])) {
|
||||||
|
this.direction=dir;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reduce buffer duration
|
||||||
|
Object
|
||||||
|
.keys(inputs)
|
||||||
|
.forEach(k => {
|
||||||
|
let v=inputs[k];
|
||||||
|
if(v===true) v=5;
|
||||||
|
v--;
|
||||||
|
if(!v) delete inputs[k];
|
||||||
|
else inputs[k]=v;
|
||||||
|
});
|
||||||
|
|
||||||
|
// try all inputs in order and unbuffer them if valid
|
||||||
|
if(inputs.left && trySet([-1, 0])) delete inputs.left;
|
||||||
|
else if(inputs.right && trySet([ 1, 0])) delete inputs.right;
|
||||||
|
else if(inputs.up && trySet([ 0,-1])) delete inputs.up;
|
||||||
|
else if(inputs.down && trySet([ 0, 1])) delete inputs.down;
|
||||||
|
|
||||||
|
// buffering might be disabled
|
||||||
|
if(!config.getB('input.buffer')) {
|
||||||
|
Object
|
||||||
|
.keys(inputs)
|
||||||
|
.forEach(k => delete inputs[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.firstStep=Date.now();
|
||||||
|
this.tickId=0;
|
||||||
|
this.playing=true;
|
||||||
|
requestAnimationFrame(() => this.tick());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return module.exports=SnekGame;
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
html5doctor.com Reset Stylesheet
|
||||||
|
v1.6.1
|
||||||
|
Last Updated: 2010-09-17
|
||||||
|
Author: Richard Clark - http://richclarkdesign.com
|
||||||
|
Twitter: @rich_clark
|
||||||
|
*/
|
||||||
|
|
||||||
|
html, body, div, span, object, iframe,
|
||||||
|
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||||
|
abbr, address, cite, code,
|
||||||
|
del, dfn, em, img, ins, kbd, q, samp,
|
||||||
|
small, strong, sub, sup, var,
|
||||||
|
b, i,
|
||||||
|
dl, dt, dd, ol, ul, li,
|
||||||
|
fieldset, form, label, legend,
|
||||||
|
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||||
|
article, aside, canvas, details, figcaption, figure,
|
||||||
|
footer, header, hgroup, menu, nav, section, summary,
|
||||||
|
time, mark, audio, video {
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
border:0;
|
||||||
|
outline:0;
|
||||||
|
font-size:100%;
|
||||||
|
vertical-align:baseline;
|
||||||
|
background:transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height:1;
|
||||||
|
}
|
||||||
|
|
||||||
|
article,aside,details,figcaption,figure,
|
||||||
|
footer,header,hgroup,menu,nav,section {
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul {
|
||||||
|
list-style:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote, q {
|
||||||
|
quotes:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote:before, blockquote:after,
|
||||||
|
q:before, q:after {
|
||||||
|
content:'';
|
||||||
|
content:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
font-size:100%;
|
||||||
|
vertical-align:baseline;
|
||||||
|
background:transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* change colours to suit your needs */
|
||||||
|
ins {
|
||||||
|
background-color:#ff9;
|
||||||
|
color:#000;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* change colours to suit your needs */
|
||||||
|
mark {
|
||||||
|
background-color:#ff9;
|
||||||
|
color:#000;
|
||||||
|
font-style:italic;
|
||||||
|
font-weight:bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title], dfn[title] {
|
||||||
|
border-bottom:1px dotted;
|
||||||
|
cursor:help;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse:collapse;
|
||||||
|
border-spacing:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* change border colour to suit your needs */
|
||||||
|
hr {
|
||||||
|
display:block;
|
||||||
|
height:1px;
|
||||||
|
border:0;
|
||||||
|
border-top:1px solid #cccccc;
|
||||||
|
margin:1em 0;
|
||||||
|
padding:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
vertical-align:middle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.help {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
article {
|
||||||
|
margin-bottom: 2ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 1ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin-left: 2ex;
|
||||||
|
font-size: 1.6em;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#hud {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '⟳';
|
||||||
|
font-size: 5vh;
|
||||||
|
opacity: .5;
|
||||||
|
color: @accentfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosspadOverlay {
|
||||||
|
pointer-events: none;
|
||||||
|
top: 50vh;
|
||||||
|
left: 50vw;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.joystickOverlay {
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: .2rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
|
||||||
|
.score::before {
|
||||||
|
content: '\1f34e';
|
||||||
|
}
|
||||||
|
.time::before {
|
||||||
|
content: '\23f1';
|
||||||
|
}
|
||||||
|
.speed::before {
|
||||||
|
content: '\1f684';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
nav {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
section {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 50vh;
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style-type: disc;
|
||||||
|
|
||||||
|
* {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
margin: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
@keyframes popupAppear {
|
||||||
|
0% {
|
||||||
|
background: rgba(0, 0, 0, 0%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: rgba(0, 0, 0, 90%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
&.animation {
|
||||||
|
animation: popupAppear 1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
background: rgba(0, 0, 0, 90%);
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
border-radius: 2rem;
|
||||||
|
background: @bg;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: black 0 0 1rem;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
font-size: 1.4rem;
|
||||||
|
|
||||||
|
&.large {
|
||||||
|
width: 80vw;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > section {
|
||||||
|
margin: 1rem;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
tr {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: right;
|
||||||
|
&::after {
|
||||||
|
content: ':';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 0 .5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
td, th {
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin-right: 1ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: inline;
|
||||||
|
color: @accentfg;
|
||||||
|
background: @accentbg;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
margin: 1rem;
|
||||||
|
|
||||||
|
transition: box-shadow .5s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @fg;
|
||||||
|
text-decoration: underline;
|
||||||
|
box-shadow: black 0 0 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.progressBar {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: black 0 0 2rem;
|
||||||
|
border-radius: 100vh;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: top .5s ease-in-out;
|
||||||
|
|
||||||
|
&.hiddenBottom {
|
||||||
|
top: 200vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// full CSS reset
|
||||||
|
@import 'fullreset.less';
|
||||||
|
|
||||||
|
// load the font
|
||||||
|
@import url('https://fonts.googleapis.com/css?family=Fira+Code:400,700&display=swap');
|
||||||
|
html {
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup REM units
|
||||||
|
html {
|
||||||
|
font-size: 62.5% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup the colors and styles
|
||||||
|
@accentbg: #fba49b;
|
||||||
|
@bg: #ffefdf;
|
||||||
|
@accentfg: #930a16;
|
||||||
|
@fg: #23090d;
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: @fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, strong, a {
|
||||||
|
color: @accentfg;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-bottom: .1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p + p {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: inherit;
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: @fg;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: @accentbg;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
header, footer {
|
||||||
|
background: @accentbg;
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 4px solid @accentfg;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup the layout
|
||||||
|
html, body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
header img, footer img {
|
||||||
|
float: left;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
header img {
|
||||||
|
height: 8rem;
|
||||||
|
&:hover {
|
||||||
|
border-color: @fg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer img {
|
||||||
|
height: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header ul {
|
||||||
|
display: flex;
|
||||||
|
list-style-type: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-right: 1ex;
|
||||||
|
font-size: 2rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: @fg;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @accentfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header, footer, main {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
background: @bg;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.serverless .server {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
body.server .serverless {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
body.loading .loaded {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup the progress bar
|
||||||
|
@import 'progressBar';
|
||||||
|
|
||||||
|
// setup the main menu
|
||||||
|
@import 'mainMenu';
|
||||||
|
|
||||||
|
// setup the popups
|
||||||
|
@import 'popup';
|
||||||
|
|
||||||
|
// setup the hud
|
||||||
|
@import 'hud';
|
||||||
|
|
||||||
|
// setup the help file
|
||||||
|
@import 'help';
|
||||||