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": {
"enabled": true,
"mode": "swipe",
"deadzone": 50,
"buffer": false
},
"keyboard": {
"enabled": true,
"buffer": false
},
"gamepad": {
"enabled": true,
"deadzone": 0.5,
"buffer": true
}

View File

@ -12,7 +12,8 @@
"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",
@ -31,6 +32,7 @@
"Survival",
"Versus"
],
"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",

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 () => {
location.hash='';
// load modules
const assets=require('assets');
const Popup=require('popup');
const SnekGame=require('snek');
const input=require('input');
const levels=require('levels');
// 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 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 currentInputs={};
const resizeCanvas=() => {
// forward-declare functions
let resizeCanvas, getLevel, startGame, handleWin, handleDeath, menu, help;
// handle window resize and fullscreen
resizeCanvas=() => {
if(document.fullscreenElement) {
canvas.width=screen.width;
canvas.height=screen.height;
@ -27,8 +40,35 @@
};
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 => {
const cat=levelList[category];
@ -41,16 +81,10 @@
const ul=section.appendChild(document.createElement('ul'));
cat.levels.forEach((level, i) => {
level=''+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 {displayName, fileName, levelString}=getLevel(category, level);
const li=ul.appendChild(document.createElement('li'));
const a=li.appendChild(document.createElement('a'));
a.href='#'+category+'/'+fileName;
a.href='#'+levelString;
a.innerText=displayName;
if(cat.levelDesc) {
const span=li.appendChild(document.createElement('span'));
@ -59,150 +93,148 @@
});
});
const handleGamepads=() => {
const gp=navigator.getGamepads()[0];
let inputs=currentInputs;
if(!gp || !gp.axes) return;
// start a new game
startGame=async (category, levelId, filename) => {
// stop any running games
if(currentGame) currentGame.playing=false;
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>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_-]+?)\/(.+)/);
// load rules and level from cache or server
const rules=levelList[category].rules || {};
const level=await (async () => {
const resp=await fetch('levels/'+filename);
return await resp.json();
})();
const level=await levels.get(filename);
const snek=new SnekGame(level, canvas, rules);
canvas.classList.remove('hidden');
snek.start();
// create the game and attach the callbacks
const snek=currentGame=new SnekGame(level, canvas, rules);
snek.callback=evt => {
if(evt=='tick') {
if(navigator.getGamepads) handleGamepads();
snek.handleInputs(currentInputs);
input.framefn();
snek.handleInputs(input.inputs);
} else if(evt=='win') {
let popup=new Popup("Finished!");
popup.addStrong("You won!");
popup.addContent({
"Time": snek.playTime/1000+'s',
"Score": snek.score,
"Final length": snek.snake.length
});
popup.buttons={
retry: "Retry",
next: "Next level",
menu: "Main menu"
};
popup.display();
//TODO do something with the result
handleWin(snek);
} else if(evt=='die') {
handleDeath(snek);
}
};
currentGame=snek;
});
window.addEventListener('keydown', async e => {
e.preventDefault();
if(e.key=='f') {
if(document.fullscreenElement) await document.exitFullscreen();
else await main.requestFullscreen();
resizeCanvas();
// 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!");
popup.addStrong("You won!");
popup.addContent({
"Time": snek.playTime/1000+'s',
"Score": snek.score,
"Final length": snek.snake.length
});
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";
}
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;
// show the actual popup
let result=await popup.display(main);
if(!config.keyboard.buffer) inputs.clearBuffer=true;
// 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;
}
};
// display the death popup
handleDeath=async snek => {
// get userdata back
const {category, levelId, filename}=snek.userdata;
// 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';
}
};
// handle page navigation
window.addEventListener('hashchange', () => {
const hash=location.hash.substr(1);
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);
});
if(config.touchscreen.mode=='crosspad') {
const handleTouch=e => {
let x=e.touches[0].clientX-window.innerWidth/2;
let y=e.touches[0].clientY-window.innerHeight/2;
const angle=((Math.atan2(x, y)+2*Math.PI)%(2*Math.PI))/Math.PI;
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);
// enable input methods according to config
if(config.keyboard.enabled) {
input.enableHandler(input.availableHandlers.keyboard);
}
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;
}
if(!config.touchscreen.buffer) inputs.clearBuffer=true;
});
if(config.gamepad.enabled) {
input.enableHandler(input.availableHandlers.gamepad);
}
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;
});
if(config.touchscreen.enabled) {
if(config.touchscreen.mode=='crosspad') {
input.enableHandler(input.availableHandlers.touchscreenCrosspad);
} else if(config.touchscreen.mode=='joystick') {
input.enableHandler(input.availableHandlers.touchscreenJoystick);
} else if(config.touchscreen.mode=='swipe') {
input.enableHandler(input.availableHandlers.touchscreenSwipe);
}
}
input.updateConfig(config);
})();

View File

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