refactored code and added win/lose popups

This commit is contained in:
Nathan DECHER 2020-04-05 16:14:39 +02:00
parent 40ec4fa09a
commit 4746f34537
6 changed files with 353 additions and 146 deletions

View file

@ -1,13 +1,16 @@
{ {
"touchscreen": { "touchscreen": {
"enabled": true,
"mode": "swipe", "mode": "swipe",
"deadzone": 50, "deadzone": 50,
"buffer": false "buffer": false
}, },
"keyboard": { "keyboard": {
"enabled": true,
"buffer": false "buffer": false
}, },
"gamepad": { "gamepad": {
"enabled": true,
"deadzone": 0.5, "deadzone": 0.5,
"buffer": true "buffer": true
} }

View file

@ -12,7 +12,8 @@
"levelDisplay": "Level <n>", "levelDisplay": "Level <n>",
"levels": [ "levels": [
1, 2, 3, 4, 5 1, 2, 3, 4, 5
] ],
"nextLevel": true
}, },
"arcade": { "arcade": {
"desc": "Have fun just like in the good ol' days, walls wrap around, fruits respawn and speed increases", "desc": "Have fun just like in the good ol' days, walls wrap around, fruits respawn and speed increases",
@ -31,6 +32,7 @@
"Survival", "Survival",
"Versus" "Versus"
], ],
"nextLevel": false,
"levelDesc": [ "levelDesc": [
"The old classic, try to get as high as a score as you can", "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", "Get a score as high as you can in 30 seconds",

152
src/js/input.js Normal file
View file

@ -0,0 +1,152 @@
let currentInputs={};
let handlers=[];
let config;
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, clearBuffer=false) => {
const {angle, magnitude}=toAngleMagnitude(x, y);
if(magnitude>threshold) {
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(clearBuffer) inputs.clearBuffer=true;
if(fn) fn(angle, magnitude);
}
}
const handleCrosspad=(() => {
const fn=e =>
handleAngleMagnitude(
e.touches[0].clientX-window.innerWidth/2,
e.touches[0].clientY-window.innerHeight/2,
0,
null,
!config.touchscreen.buffer
);
return {
touchstart: fn,
touchmove: fn
};
})();
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;
if(!config.keyboard.buffer) inputs.clearBuffer=true;
}
};
const handleJoystick=(() => {
let center={
x: 0,
y: 0
};
return {
touchstart: e => {
center.x=e.touches[0].clientX;
center.y=e.touches[0].clientY;
},
touchmove: e =>
handleAngleMagnitude(
e.touches[0].clientX-center.x,
e.touches[0].clientY-center.y,
config.touchscreen.deadzone,
null,
!config.touchscreen.buffer
)
}
});
const handleSwipe=(() => {
let center={
x: 0,
y: 0
};
let resetCenter=e => {
center.x=e.touches[0].clientX;
center.y=e.touches[0].clientY;
};
return {
touchstart: resetCenter,
touchmove: e =>
handleAngleMagnitude(
e.touches[0].clientX-center.x,
e.touches[0].clientY-center.y,
config.touchscreen.deadzone,
() => resetCenter(e),
!config.touchscreen.buffer
)
}
});
const handleGamepads={
frame: () => {
const gp=navigator.getGamepads()[0];
let inputs=currentInputs;
if(!gp || !gp.axes) return;
handleAngleMagnitude(
gp.axes[0],
gp.axes[1],
config.gamepad.deadzone,
null,
!config.gamepad.buffer
);
}
};
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);
};
const disableHandler=handler => {
let idx=handlers.indexOf(handler);
if(idx!=-1) handlers.splice(idx, 1);
};
const updateConfig=cfg =>
config=cfg;
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,
enableHandler, disableHandler,
framefn: handleEvent.bind(null, 'frame'),
availableHandlers: {
keyboard: handleKeyboard,
gamepad: handleGamepads,
touchscreenCrosspad: handleCrosspad,
touchscreenJoystick: handleJoystick,
touchscreenSwipe: handleSwipe
},
updateConfig
};

18
src/js/levels.js Normal file
View file

@ -0,0 +1,18 @@
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 clearCache=() =>
Object
.keys(cache)
.forEach(key => delete cache[key]);
return module.exports={
get,
clearCache
};

View file

@ -1,22 +1,35 @@
(async () => { (async () => {
location.hash='';
// load modules
const assets=require('assets'); const assets=require('assets');
const Popup=require('popup'); const Popup=require('popup');
const SnekGame=require('snek'); const SnekGame=require('snek');
const input=require('input');
const levels=require('levels');
// get a known state
await new Promise(ok => assets.onReady(ok)); await new Promise(ok => assets.onReady(ok));
location.hash='menu';
// get our DOM in check
const main=document.querySelector('main'); const main=document.querySelector('main');
const nav=main.querySelector('nav'); const nav=main.querySelector('nav');
const canvas=main.querySelector('canvas'); const canvas=main.querySelector('canvas');
const config=assets.get('config'); // load config
const config=assets.get('config'); //TODO use an actual config module
// load data from server
const levelList=assets.get('levelList');
// get our global variables
let currentGame=null; let currentGame=null;
let currentInputs={};
const resizeCanvas=() => { // forward-declare functions
let resizeCanvas, getLevel, startGame, handleWin, handleDeath, menu, help;
// handle window resize and fullscreen
resizeCanvas=() => {
if(document.fullscreenElement) { if(document.fullscreenElement) {
canvas.width=screen.width; canvas.width=screen.width;
canvas.height=screen.height; canvas.height=screen.height;
@ -27,8 +40,35 @@
}; };
resizeCanvas(); resizeCanvas();
window.addEventListener('resize', resizeCanvas); window.addEventListener('resize', resizeCanvas);
window.addEventListener('keydown', async e => {
if(e.key=='f') {
if(document.fullscreenElement) await document.exitFullscreen();
else await main.requestFullscreen();
resizeCanvas();
}
});
const levelList=assets.get('levelList'); // get a level for a category and an id
getLevel=(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
};
};
// buid menu from level list
Object.keys(levelList).forEach(category => { Object.keys(levelList).forEach(category => {
const cat=levelList[category]; const cat=levelList[category];
@ -41,16 +81,10 @@
const ul=section.appendChild(document.createElement('ul')); const ul=section.appendChild(document.createElement('ul'));
cat.levels.forEach((level, i) => { cat.levels.forEach((level, i) => {
level=''+level; const {displayName, fileName, levelString}=getLevel(category, level);
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 li=ul.appendChild(document.createElement('li'));
const a=li.appendChild(document.createElement('a')); const a=li.appendChild(document.createElement('a'));
a.href='#'+category+'/'+fileName; a.href='#'+levelString;
a.innerText=displayName; a.innerText=displayName;
if(cat.levelDesc) { if(cat.levelDesc) {
const span=li.appendChild(document.createElement('span')); const span=li.appendChild(document.createElement('span'));
@ -59,45 +93,62 @@
}); });
}); });
const handleGamepads=() => { // start a new game
const gp=navigator.getGamepads()[0]; startGame=async (category, levelId, filename) => {
let inputs=currentInputs; // stop any running games
if(!gp || !gp.axes) return; if(currentGame) currentGame.playing=false;
const magnitude=Math.hypot(gp.axes[0], gp.axes[1]); // load rules and level from cache or server
const angle=((Math.atan2(gp.axes[0], gp.axes[1])+2*Math.PI)%(2*Math.PI))/Math.PI;
if(magnitude>config.gamepad.deadzone) {
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(!config.gamepad.buffer) inputs.clearBuffer=true;
};
const startGame=async (category, filename) => {
//TODO migrate relevant code here
};
window.addEventListener('hashchange', async () => {
nav.classList.add('hidden');
const [_, category, filename]=location.hash.match(/([a-zA-Z0-9_-]+?)\/(.+)/);
const rules=levelList[category].rules || {}; const rules=levelList[category].rules || {};
const level=await (async () => { const level=await levels.get(filename);
const resp=await fetch('levels/'+filename);
return await resp.json();
})();
const snek=new SnekGame(level, canvas, rules); // create the game and attach the callbacks
canvas.classList.remove('hidden'); const snek=currentGame=new SnekGame(level, canvas, rules);
snek.start();
snek.callback=evt => { snek.callback=evt => {
if(evt=='tick') { if(evt=='tick') {
if(navigator.getGamepads) handleGamepads(); input.framefn();
snek.handleInputs(currentInputs); snek.handleInputs(input.inputs);
} else if(evt=='win') { } else if(evt=='win') {
handleWin(snek);
} else if(evt=='die') {
handleDeath(snek);
}
};
// setup the DOM
nav.classList.add('hidden');
canvas.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=() => {
// stop any running games
if(currentGame) currentGame.playing=false;
// setup the DOM
nav.classList.remove('hidden');
canvas.classList.add('hidden');
};
// display the win popup
handleWin=async snek => {
// get userdata back
const {category, levelId, filename}=snek.userdata;
// create and configure popup
let popup=new Popup("Finished!"); let popup=new Popup("Finished!");
popup.addStrong("You won!"); popup.addStrong("You won!");
popup.addContent({ popup.addContent({
@ -107,102 +158,83 @@
}); });
popup.buttons={ popup.buttons={
retry: "Retry", retry: "Retry",
next: "Next level",
menu: "Main menu" menu: "Main menu"
}; };
popup.display(); if(levelList[category].nextLevel) {
//TODO do something with the result 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') {
let nextId=(+levelId)+1;
let {levelString}=getLevel(category, nextId)
location.hash=levelString;
} }
}; };
currentGame=snek;
});
window.addEventListener('keydown', async e => { // display the death popup
e.preventDefault(); handleDeath=async snek => {
if(e.key=='f') { // get userdata back
if(document.fullscreenElement) await document.exitFullscreen(); const {category, levelId, filename}=snek.userdata;
else await main.requestFullscreen();
resizeCanvas(); // create and configure popup
let popup=new Popup("Finished!");
popup.addStrong("You died...");
popup.addContent({
"Time": snek.playTime/1000+'s',
"Score": snek.score,
"Final length": snek.snake.length
});
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';
} }
};
let inputs=currentInputs; // handle page navigation
if(e.key=='ArrowUp') inputs.up=true; window.addEventListener('hashchange', () => {
else if(e.key=='ArrowDown') inputs.down=true; const hash=location.hash.substr(1);
else if(e.key=='ArrowLeft') inputs.left=true;
else if(e.key=='ArrowRight') inputs.right=true;
if(!config.keyboard.buffer) inputs.clearBuffer=true; if(hash=='' || hash=='menu') return menu();
else if(hash=='help') return help();
const [_, category, levelId, filename]=location.hash.match(/([a-zA-Z0-9_-]+?)\/([a-zA-Z0-9_-]+?)\/(.+)/);
startGame(category, levelId, filename);
}); });
// enable input methods according to config
if(config.keyboard.enabled) {
input.enableHandler(input.availableHandlers.keyboard);
}
if(config.gamepad.enabled) {
input.enableHandler(input.availableHandlers.gamepad);
}
if(config.touchscreen.enabled) {
if(config.touchscreen.mode=='crosspad') { if(config.touchscreen.mode=='crosspad') {
const handleTouch=e => { input.enableHandler(input.availableHandlers.touchscreenCrosspad);
let x=e.touches[0].clientX-window.innerWidth/2; } else if(config.touchscreen.mode=='joystick') {
let y=e.touches[0].clientY-window.innerHeight/2; input.enableHandler(input.availableHandlers.touchscreenJoystick);
const angle=((Math.atan2(x, y)+2*Math.PI)%(2*Math.PI))/Math.PI; } else if(config.touchscreen.mode=='swipe') {
input.enableHandler(input.availableHandlers.touchscreenSwipe);
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(!config.touchscreen.buffer) inputs.clearBuffer=true;
};
window.addEventListener('touchstart', handleTouch);
window.addEventListener('touchmove', handleTouch);
} }
if(config.touchscreen.mode=='joystick') {
let center={x: 0, y: 0};
window.center=center;
window.addEventListener('touchstart', e => {
center.x=e.touches[0].clientX;
center.y=e.touches[0].clientY;
});
window.addEventListener('touchmove', e => {
let x=e.touches[0].clientX-center.x;
let y=e.touches[0].clientY-center.y;
const angle=((Math.atan2(x, y)+2*Math.PI)%(2*Math.PI))/Math.PI;
const magnitude=Math.hypot(x, y);
let inputs=currentInputs;
if(magnitude>config.touchscreen.deadzone) {
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;
} }
input.updateConfig(config);
if(!config.touchscreen.buffer) inputs.clearBuffer=true;
});
}
if(config.touchscreen.mode=='swipe') {
let center={x: 0, y: 0};
window.center=center;
window.addEventListener('touchstart', e => {
center.x=e.touches[0].clientX;
center.y=e.touches[0].clientY;
});
window.addEventListener('touchmove', e => {
let x=e.touches[0].clientX-center.x;
let y=e.touches[0].clientY-center.y;
const angle=((Math.atan2(x, y)+2*Math.PI)%(2*Math.PI))/Math.PI;
const magnitude=Math.hypot(x, y);
let inputs=currentInputs;
if(magnitude>config.touchscreen.deadzone) {
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;
center.x=e.touches[0].clientX;
center.y=e.touches[0].clientY;
}
if(!config.touchscreen.buffer) inputs.clearBuffer=true;
});
}
})(); })();

View file

@ -46,7 +46,7 @@ class Popup {
this.content.push(objToDom({[Popup.STRONG]: cnt})); this.content.push(objToDom({[Popup.STRONG]: cnt}));
} }
async display() { async display(parent=document.body) {
let outer=document.createElement('div'); let outer=document.createElement('div');
outer.classList.add('popup'); outer.classList.add('popup');
let popup=outer.appendChild(document.createElement('div')); let popup=outer.appendChild(document.createElement('div'));
@ -67,7 +67,7 @@ class Popup {
}); });
buttons.forEach(btn => buttonSection.appendChild(btn)); buttons.forEach(btn => buttonSection.appendChild(btn));
document.body.appendChild(outer); parent.appendChild(outer);
const code=await Promise.race(buttons.map(btn => new Promise(ok => { const code=await Promise.race(buttons.map(btn => new Promise(ok => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
@ -76,7 +76,7 @@ class Popup {
}); });
}))); })));
document.body.removeChild(outer); parent.removeChild(outer);
return code; return code;
} }
} }