base game working

This commit is contained in:
Nathan DECHER 2020-03-25 15:57:20 +01:00
parent daef55781e
commit beb9598f69
10 changed files with 270 additions and 30 deletions

View file

@ -5,38 +5,40 @@ APPLE = public/assets/apple32.png
WALL = public/assets/wall32.png WALL = public/assets/wall32.png
SNAKE = public/assets/snake.json SNAKE = public/assets/snake.json
LEVEL_LIST = public/assets/levelList.json
CSS = public/css/snek.css CSS = public/css/snek.css
JS = public/js/snek.js JS = public/js/snek.js
OUTPUT = $(ICON) $(APPLE) $(WALL) $(SNAKE) $(CSS) $(JS) OUTPUT = $(ICON) $(APPLE) $(WALL) $(SNAKE) $(LEVEL_LIST) $(CSS) $(JS)
all: icon apple wall snake css js all: icon apple wall snake levelList css js
icon: $(ICON) icon: $(ICON)
apple: $(APPLE) apple: $(APPLE)
wall: $(WALL) wall: $(WALL)
snake: $(SNAKE) snake: $(SNAKE)
levelList: $(LEVEL_LIST)
css: $(CSS) css: $(CSS)
js: $(JS) js: $(JS)
public/assets/icon32.png: assets/icon.jpg
convert $^ -resize 32x $@
public/assets/icon256.png: assets/icon.jpg
convert $^ -resize 256x $@
public/favicon.ico: assets/icon.jpg public/favicon.ico: assets/icon.jpg
convert $^ -resize 32x $@ convert $^ -resize 32x $@
public/assets/apple32.png: assets/apple.png public/assets/%32.png: assets/%.png
convert $^ -resize 32x $@ convert $^ -resize 32x $@
public/assets/%256.png: assets/%.png
convert $^ -resize 256x $@
public/assets/wall32.png: assets/wall.png public/assets/%32.png: assets/%.jpg
convert $^ -resize 32x $@ convert $^ -resize 32x $@
public/assets/%256.png: assets/%.jpg
convert $^ -resize 256x $@
public/assets/snake.json: assets/snake.json public/assets/%.json: assets/%.json
cp $^ $@ cp $^ $@
public/css/snek.css: src/less/snek.less $(wildcard src/less/*.less) public/css/snek.css: src/less/snek.less $(wildcard src/less/*.less)

30
assets/levelList.json Normal file
View file

@ -0,0 +1,30 @@
{
"speedrun": {
"rules": {
"fruitRegrow": false,
"speedIncrease": false,
"worldWrap": false,
"winCondition": "fruit",
"scoreSystem": "time"
},
"levelFilename": "level<n>.json",
"levelDisplay": "Level <n>",
"levels": [
1, 2, 3, 4, 5
]
},
"arcade": {
"rules": {
"fruitRegrow": true,
"speedIncrease": true,
"worldWrap": true
},
"levelFilename": "arcade-<l>.json",
"levelDisplay": "<n>",
"levels": [
"Arcade",
"Timed",
"Survival"
]
}
}

15
levels/level1.json Normal file
View file

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

View file

@ -6,7 +6,9 @@
<link rel="favicon" href="favicon.ico"> <link rel="favicon" href="favicon.ico">
<link rel="stylesheet" href="css/snek.css"> <link rel="stylesheet" href="css/snek.css">
<script src="js/snek.js"></script> <script src="js/snek.js"></script>
<script>window.addEventListener('load', () => require('assets'));</script> <script>
window.addEventListener('load', () => require('main'));
</script>
</head> </head>
<body> <body>
<header> <header>
@ -15,7 +17,8 @@
<h2>A simple Snake</h2> <h2>A simple Snake</h2>
</header> </header>
<main> <main>
<img src="assets/apple32.png"> <nav></nav>
<canvas class="hidden"></canvas>
</main> </main>
<footer> <footer>
<img src="assets/icon32.png"> <img src="assets/icon32.png">

1
public/levels Symbolic link
View file

@ -0,0 +1 @@
../levels/

View file

@ -3,7 +3,8 @@ const ProgressBar=require('progress');
const assetSpecs=[ const assetSpecs=[
{ name: 'fruit', filename: 'apple32.png', type: 'image' }, { name: 'fruit', filename: 'apple32.png', type: 'image' },
{ name: 'wall', filename: 'wall32.png', type: 'image' }, { name: 'wall', filename: 'wall32.png', type: 'image' },
{ name: 'snake', filename: 'snake.json', type: 'json' } { name: 'snake', filename: 'snake.json', type: 'json' },
{ name: 'levelList', filename: 'levelList.json', type: 'json' }
]; ];
const cvs=document.createElement('canvas'); const cvs=document.createElement('canvas');
@ -64,7 +65,7 @@ Promise
const onReady=(fn) => { const onReady=(fn) => {
if(ready) fn.bind(fn)(); if(ready) fn.bind(fn)();
else readyListeners.push(ready); else readyListeners.push(fn);
}; };
const get=(name) => { const get=(name) => {

101
src/js/main.js Normal file
View file

@ -0,0 +1,101 @@
(async () => {
location.hash='';
const assets=require('assets');
await new Promise(ok => assets.onReady(ok));
const main=document.querySelector('main');
const nav=main.querySelector('nav');
const canvas=main.querySelector('canvas');
let currentGame=null;
let currentInputs={};
const 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);
const levelList=assets.get('levelList');
Object.keys(levelList).forEach(category => {
const nav=document.querySelector('nav');
const section=nav.appendChild(document.createElement('section'));
const h1=section.appendChild(document.createElement('h1'));
h1.innerText=category[0].toUpperCase()+category.slice(1)+" Mode";
const ul=section.appendChild(document.createElement('ul'));
levelList[category].levels.forEach(level => {
level=''+level;
const cat=levelList[category];
const displayName=cat.levelDisplay
.replace(/<n>/g, level)
.replace(/<l>/g, level.toLowerCase());
const fileName=cat.levelFilename
.replace(/<n>/g, level)
.replace(/<l>/g, level.toLowerCase());
const li=ul.appendChild(document.createElement('li'));
const a=li.appendChild(document.createElement('a'));
a.href='#'+category+'/'+fileName;
a.innerText=displayName;
});
});
const handleGamepads=() => {
const gp=navigator.getGamepads()[0];
let inputs=currentInputs;
console.log(gp);
if(!gp || !gp.axes) return;
const magnitude=Math.hypot(gp.axes[0], gp.axes[1]);
const angle=((Math.atan2(gp.axes[0], gp.axes[1])+2*Math.PI)%(2*Math.PI))/Math.PI;
if(magnitude>.5) {
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;
}
};
window.addEventListener('hashchange', async () => {
nav.classList.add('hidden');
const [_, category, filename]=location.hash.match(/([a-zA-Z0-9_-]+?)\/(.+)/);
const rules=levelList[category].rules || {};
const level=await (async () => {
const resp=await fetch('levels/'+filename);
return await resp.json();
})();
console.log(rules, level);
const SnekGame=require('snek');
const snek=new SnekGame(level, canvas, rules);
canvas.classList.remove('hidden');
snek.start();
snek.callback=() => {
if(navigator.getGamepads) handleGamepads();
snek.handleInputs(currentInputs);
};
currentGame=snek;
});
window.addEventListener('keydown', async e => {
if(e.key=='f' && !canvas.classList.contains('hidden')) {
if(document.fullscreenElement) await document.exitFullscreen();
else await canvas.requestFullscreen();
resizeCanvas();
}
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;
});
})();

View file

@ -5,11 +5,15 @@ const [EMPTY, FOOD, WALL, SNAKE]=Array(4).keys();
const ifNaN=(v, r) => isNaN(v)?r:v; const ifNaN=(v, r) => isNaN(v)?r:v;
class SnekGame { class SnekGame {
constructor(settings, canvas) { constructor(settings, canvas, rules) {
// build the world // build the world
this.dimensions=[...settings.dimensions]; this.dimensions=[...settings.dimensions];
this.world=Array(settings.dimensions[0]) this.world=Array(settings.dimensions[0]);
.forEach((_, i, a) => a[i]=Array(settings.dimensions[1]).fill(EMPTY)); for(let i=0; i<settings.dimensions[0]; i++) {
this.world[i]=Array(settings.dimensions[1]);
this.world[i].fill(EMPTY);
}
console.log(this);
settings.walls.forEach(([x, y]) => this.world[x][y]=WALL); settings.walls.forEach(([x, y]) => this.world[x][y]=WALL);
settings.food.forEach(([x, y]) => this.world[x][y]=FOOD); settings.food.forEach(([x, y]) => this.world[x][y]=FOOD);
settings.snake.forEach(([x, y]) => this.world[x][y]=SNAKE); settings.snake.forEach(([x, y]) => this.world[x][y]=SNAKE);
@ -20,8 +24,8 @@ class SnekGame {
// get the head and initial direction // get the head and initial direction
this.head=[...settings.snake[0]]; this.head=[...settings.snake[0]];
this.direction=[ this.direction=[
ifNaN(settings.snake[1][0]-settings.snake[0][0], 1), ifNaN(settings.snake[0][0]-settings.snake[1][0], 1),
ifNaN(settings.snake[1][1]-settings.snake[0][1], 0) ifNaN(settings.snake[0][1]-settings.snake[1][1], 0)
]; ];
// get the snake and the fruits themselves // get the snake and the fruits themselves
@ -32,24 +36,40 @@ class SnekGame {
this.canvas=canvas; this.canvas=canvas;
this.ctx=canvas.getContext('2d'); this.ctx=canvas.getContext('2d');
//TODO this.gl=canvas.getContext('webgl'); //TODO this.gl=canvas.getContext('webgl');
// load the custom rules
this.rules=Object.assign({
fruitRegrow: true,
speedIncrease: true,
worldWrap: true,
winCondition: 'none',
scoreSystem: 'fruit'
}, rules, settings);
} }
draw() { draw() {
// clear the canvas, because it's easier than having to deal with everything // clear the canvas, because it's easier than having to deal with everything
this.ctx.clearRect(0, 0, this.canvas.with, this.canvas.height); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// get the cell size and offset // get the cell size and offset
const cellSize=Math.min( const cellSize=Math.min(
this.canvas.with/this.dimensions[0], this.canvas.width/this.dimensions[0],
this.canvas.height/this.dimensions[1] this.canvas.height/this.dimensions[1]
); );
const offsetX=(this.canvas.width-cellSize*this.dimensions[0])/2; const offsetX=(this.canvas.width-cellSize*this.dimensions[0])/2;
const offsetY=(this.canvas.height-cellSize*this.dimensions[1])/2; const offsetY=(this.canvas.height-cellSize*this.dimensions[1])/2;
// draw the border around our game area
this.ctx.fillStyle='black';
this.ctx.fillRect(0, 0, this.canvas.width, offsetY);
this.ctx.fillRect(0, 0, offsetX, this.canvas.height);
this.ctx.fillRect(offsetX+cellSize*this.dimensions[0], 0, offsetX, this.canvas.height);
this.ctx.fillRect(0, offsetY+cellSize*this.dimensions[1], this.canvas.width, offsetY);
// draw our walls // draw our walls
const wall=assets.get('wall'); const wall=assets.get('wall');
for(let x=0; x<this.dimensions[0]; x++) { for(let x=0; x<this.dimensions[0]; x++) {
for(let y=0; x<this.dimensions[1]; y++) { for(let y=0; y<this.dimensions[1]; y++) {
switch(this.world[x][y]) { switch(this.world[x][y]) {
case WALL: case WALL:
this.ctx.drawImage( this.ctx.drawImage(
@ -95,7 +115,7 @@ class SnekGame {
// our fruit has a nice animation to it between .8 and 1.2 scale // our fruit has a nice animation to it between .8 and 1.2 scale
const ms=Date.now(); const ms=Date.now();
const fruitScale=Math.sin(ms/1000*Math.PI)*.2+1 const fruitScale=Math.sin(ms/400*Math.PI)*.2+1
const fruit=assets.get('fruit'); const fruit=assets.get('fruit');
this.fruits.forEach(([x, y]) => { this.fruits.forEach(([x, y]) => {
this.ctx.drawImage( this.ctx.drawImage(
@ -119,6 +139,16 @@ class SnekGame {
const tail=this.snake.pop(); const tail=this.snake.pop();
this.world[tail[0]][tail[1]]=EMPTY; this.world[tail[0]][tail[1]]=EMPTY;
// 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();
}
}
switch(this.world[head[0]][head[1]]) { switch(this.world[head[0]][head[1]]) {
// you hit, you die // you hit, you die
case WALL: case WALL:
@ -126,7 +156,7 @@ class SnekGame {
return this.die(); return this.die();
// you eat, you don't die // you eat, you don't die
case FRUIT: case FOOD:
// re-grow the snake // re-grow the snake
this.snake.push(tail); this.snake.push(tail);
this.world[tail[0]][tail[1]]=SNAKE; this.world[tail[0]][tail[1]]=SNAKE;
@ -141,7 +171,7 @@ class SnekGame {
); );
// custom rules // custom rules
if(this.rules.regrowFruits) { if(this.rules.fruitRegrow) {
const emptyCells=this.world const emptyCells=this.world
.map( .map(
(l, x) => l (l, x) => l
@ -153,7 +183,7 @@ class SnekGame {
).flat(); ).flat();
const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)]; const cell=emptyCells[Math.floor(Math.random()*emptyCells.length)];
this.fruits.push(cell); this.fruits.push(cell);
this.world[cell[0]][cell[1]]=FRUIT; this.world[cell[0]][cell[1]]=FOOD;
} }
} }
@ -166,12 +196,14 @@ class SnekGame {
} }
tick() { tick() {
if(!this.playing) return;
if(!this.lastStep) this.lastStep=this.firstStep; if(!this.lastStep) this.lastStep=this.firstStep;
if(this.lastStep+delay<Date.now()) { this.draw();
this.lastStep+=delay; if(this.callback) this.callback();
if(this.lastStep+this.delay<Date.now()) {
this.lastStep+=this.delay;
this.step(); this.step();
} }
this.draw();
requestAnimationFrame(() => this.tick()); requestAnimationFrame(() => this.tick());
} }
@ -188,8 +220,26 @@ class SnekGame {
console.log("You bad lol"); console.log("You bad lol");
} }
handleInputs(inputs) {
const trySet=(dir) => {
if(!(this.direction[0]==-dir[0] && this.direction[1]==-dir[1])) this.direction=dir;
}
if(inputs.left) {
trySet([-1, 0]);
} else if(inputs.right) {
trySet([ 1, 0]);
} else if(inputs.up) {
trySet([ 0,-1]);
} else if(inputs.down) {
trySet([ 0, 1]);
}
Object.keys(inputs).forEach(k => delete inputs[k]);
}
start() { start() {
this.firstStep=Date.now(); this.firstStep=Date.now();
this.playing=true;
requestAnimationFrame(() => this.tick()); requestAnimationFrame(() => this.tick());
} }
} }

16
src/less/mainMenu.less Normal file
View file

@ -0,0 +1,16 @@
nav {
font-size: 1.6rem;
display: flex;
section {
flex: 1;
}
ul {
padding-left: 2rem;
}
li {
list-style-type: disc;
}
}

View file

@ -28,6 +28,10 @@ h1, h2, h3, h4, h5, h6, strong, a {
font-weight: bold; font-weight: bold;
} }
h1, h2, h3, h4, h5, h6 {
margin-bottom: .1em;
}
a { a {
text-decoration: inherit; text-decoration: inherit;
} }
@ -68,6 +72,15 @@ header, footer, main {
} }
main { main {
flex: 1; flex: 1;
position: relative;
canvas {
position: absolute;
top: 0;
left: 0;
background: @bg;
}
} }
h1 { h1 {
@ -80,5 +93,13 @@ p {
font-size: 1.6rem; font-size: 1.6rem;
} }
.hidden {
display: none !important;
}
// setup the progress bar // setup the progress bar
@import 'progressBar'; @import 'progressBar';
// setup the main menu
@import 'mainMenu';