diff --git a/assets/config.json b/assets/config.json index 1d206cb..28f6d7b 100644 --- a/assets/config.json +++ b/assets/config.json @@ -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 } diff --git a/assets/levelList.json b/assets/levelList.json index 14f63e9..96cebb8 100644 --- a/assets/levelList.json +++ b/assets/levelList.json @@ -12,7 +12,8 @@ "levelDisplay": "Level ", "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", diff --git a/src/js/input.js b/src/js/input.js new file mode 100644 index 0000000..cc3dbd8 --- /dev/null +++ b/src/js/input.js @@ -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 +}; diff --git a/src/js/levels.js b/src/js/levels.js new file mode 100644 index 0000000..a5b1fea --- /dev/null +++ b/src/js/levels.js @@ -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 +}; diff --git a/src/js/main.js b/src/js/main.js index 465fbd8..a914b9b 100644 --- a/src/js/main.js +++ b/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(//g, id) + .replace(//g, id.toLowerCase()); + const fileName=cat.levelFilename + .replace(//g, id) + .replace(//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(//g, level) - .replace(//g, level.toLowerCase()); - const fileName=cat.levelFilename - .replace(//g, level) - .replace(//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); })(); diff --git a/src/js/popup.js b/src/js/popup.js index c08677c..9934397 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -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; } }