refactored code and added win/lose popups
This commit is contained in:
parent
40ec4fa09a
commit
4746f34537
6 changed files with 353 additions and 146 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
152
src/js/input.js
Normal 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
18
src/js/levels.js
Normal 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
|
||||
};
|
294
src/js/main.js
294
src/js/main.js
|
@ -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,45 +93,62 @@
|
|||
});
|
||||
});
|
||||
|
||||
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') {
|
||||
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!");
|
||||
popup.addStrong("You won!");
|
||||
popup.addContent({
|
||||
|
@ -107,102 +158,83 @@
|
|||
});
|
||||
popup.buttons={
|
||||
retry: "Retry",
|
||||
next: "Next level",
|
||||
menu: "Main menu"
|
||||
};
|
||||
popup.display();
|
||||
//TODO do something with the result
|
||||
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') {
|
||||
let nextId=(+levelId)+1;
|
||||
let {levelString}=getLevel(category, nextId)
|
||||
location.hash=levelString;
|
||||
}
|
||||
};
|
||||
currentGame=snek;
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', async e => {
|
||||
e.preventDefault();
|
||||
if(e.key=='f') {
|
||||
if(document.fullscreenElement) await document.exitFullscreen();
|
||||
else await main.requestFullscreen();
|
||||
resizeCanvas();
|
||||
// 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';
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
// handle page navigation
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash=location.hash.substr(1);
|
||||
|
||||
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') {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
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.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;
|
||||
});
|
||||
}
|
||||
|
||||
input.updateConfig(config);
|
||||
})();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue