├── .gitignore ├── src ├── js │ ├── mobile.js │ ├── storage.js │ ├── share.js │ ├── utils │ │ └── dim.js │ ├── monetization.js │ ├── inputs │ │ ├── keyboard.js │ │ └── pointer.js │ ├── speech.js │ ├── text.js │ ├── sound.js │ ├── utils.js │ └── game.js ├── img │ ├── charset.webp │ ├── tileset.webp │ └── charset.ryanmalm.png └── index.html ├── assets └── charset.ase ├── scripts ├── htmlmin.json ├── terser.config.js └── zipSize.js ├── LICENSE ├── package.json ├── TODO ├── .github └── workflows │ └── main.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | keys.json 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /src/js/mobile.js: -------------------------------------------------------------------------------- 1 | export const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent); -------------------------------------------------------------------------------- /assets/charset.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herebefrogs/gamejam-boilerplate/HEAD/assets/charset.ase -------------------------------------------------------------------------------- /src/img/charset.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herebefrogs/gamejam-boilerplate/HEAD/src/img/charset.webp -------------------------------------------------------------------------------- /src/img/tileset.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herebefrogs/gamejam-boilerplate/HEAD/src/img/tileset.webp -------------------------------------------------------------------------------- /src/img/charset.ryanmalm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herebefrogs/gamejam-boilerplate/HEAD/src/img/charset.ryanmalm.png -------------------------------------------------------------------------------- /scripts/htmlmin.json: -------------------------------------------------------------------------------- 1 | { 2 | "collapseBooleanAttributes": false, 3 | "collapseInlineTagWhitespace": true, 4 | "collapseWhitespace": true, 5 | "removeAttributeQuotes": true, 6 | "removeComments": true, 7 | "minifyCSS": true 8 | } -------------------------------------------------------------------------------- /scripts/terser.config.js: -------------------------------------------------------------------------------- 1 | { 2 | "ecma": 9, 3 | "module": true, 4 | "toplevel": true, 5 | "compress": { 6 | "keep_fargs": false, 7 | "passes": 5, 8 | "pure_funcs": ["assert", "debug"], 9 | "pure_getters": true, 10 | "unsafe": true, 11 | "unsafe_arrows": true, 12 | "unsafe_comps": true, 13 | "unsafe_math": true, 14 | "unsafe_methods": true 15 | }, 16 | "mangle": true 17 | } 18 | -------------------------------------------------------------------------------- /scripts/zipSize.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | // report zip size and remaining bytes 4 | const size = fs.statSync('dist/game.zip').size; 5 | const limit = 1024 * 13; 6 | const remaining = limit - size; 7 | const percentage = Math.round((remaining / limit) * 100 * 100) / 100; 8 | console.log('\n-------------'); 9 | console.log(`USED: ${size} BYTES`); 10 | console.log(`REMAINING: ${remaining} BYTES (${percentage}% of 13k budget)`); 11 | console.log('-------------\n'); 12 | 13 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/js/storage.js: -------------------------------------------------------------------------------- 1 | // Storage wrapper API 2 | 3 | /** 4 | * Save a value in localStorage under a key, automatically prefixed by the JS13KGAMES year and game title (hardcoded) 5 | * @params {*} key to save value under 6 | * @params {*} value to save 7 | */ 8 | export const save = (key, value) => localStorage.setItem(`2020.workingTitle.${key}`, value); 9 | 10 | /** 11 | * Retrieve a value in localStorage from its key, automatically prefixed by the JS13KGAMES year and game title (hardcoded) 12 | * @params {*} key to load value from 13 | */ 14 | export const load = key => localStorage.getItem(`2020.workingTitle.${key}`); 15 | -------------------------------------------------------------------------------- /src/js/share.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Share data with other apps. Invoke native sharing mechanisms first, 3 | * and fallback to Twitter sharing (text+url only) if not available 4 | * 5 | * @param {*} data data to be shared, formatted like 6 | * the data attribute of Web Share API's share() function 7 | * (title:, text:, url:, files:) 8 | */ 9 | export const share = async (data) => { 10 | if (navigator.canShare && navigator.canShare(data)) { 11 | try { 12 | await navigator.share(data); 13 | } catch { 14 | // silencio Bruno! 15 | } 16 | } else { 17 | // twitter only 18 | open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(data.text)}&url=${encodeURIComponent(data.url)}`, '_blank'); 19 | } 20 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jerome Lecomte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/js/utils/dim.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the size and position of a rectangle. 3 | * 4 | * NOTE: y axis is going downward (origin in top left corner) 5 | * like screen, DOM, and canvas, so top is above 6 | * 7 | * @param {*} x top left corner x coordinate 8 | * @param {*} y top left corner y coordinate 9 | * @param {*} w width 10 | * @param {*} h height 11 | */ 12 | export const rect = (x, y, w, h) => ({ 13 | // setters 14 | x, 15 | y, 16 | width: w, 17 | height: h, 18 | // readonly, inspired by DOMRect 19 | get left() { return this.width > 0 ? this.x : this.x + this.width }, 20 | get top() { return this.height > 0 ? this.y : this.y + this.height }, 21 | get right() { return this.width > 0 ? this.x + this.width : this.x }, 22 | get bottom() { return this.height > 0 ? this.y + this.height : this.y }, 23 | // utilities for collision detection 24 | contains: function(x, y) { return this.left <= x && x <= this.right && this.top <= y && y <= this.bottom }, 25 | intersects: function(r) { return this.left < r.right && r.left < this.right && this.top < r.bottom && r.top < this.bottom }, 26 | intersection: function(r) { 27 | if (!this.intersects(r)) return rect(0, 0, 0, 0); 28 | 29 | const left = Math.max(this.left, r.left); 30 | const top = Math.max(this.top, r.top); 31 | return rect( 32 | left, 33 | top, 34 | Math.min(this.right, r.right) - left, 35 | Math.min(this.bottom, r.bottom) - top 36 | ) 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /src/js/monetization.js: -------------------------------------------------------------------------------- 1 | // Web Monetization wrapper API 2 | 3 | let monetizationEnabled = false; 4 | let paid = 0; 5 | let currency = ''; 6 | 7 | 8 | export const isMonetizationEnabled = () => monetizationEnabled; 9 | 10 | export const monetizationEarned = () => `${Math.round(paid * 1000000000) / 1000000000} ${currency.toLowerCase()}`; 11 | 12 | function disableMonetization() { 13 | // flag monetization as active 14 | monetizationEnabled = false; 15 | } 16 | 17 | function enableMonetization() { 18 | // flag monetization as active 19 | monetizationEnabled = true; 20 | } 21 | 22 | function paymentCounter({ detail }) { 23 | enableMonetization(); 24 | paid += detail.amount / Math.pow(10, detail.assetScale); 25 | currency = detail.assetCode; 26 | } 27 | 28 | /** 29 | * Check for Web Monetization support and trigger the provided callback function 30 | * when web monetization has started (e.g. user is confirmed to be a Coil subscriber) 31 | */ 32 | export function checkMonetization() { 33 | if (document.monetization) { 34 | // check if Web Monetization has started 35 | if (document.monetization.state === 'started') { 36 | enableMonetization(); 37 | }; 38 | 39 | // setup a listener for when Web Monetization has finished starting 40 | document.monetization.addEventListener('monetizationstart', enableMonetization); 41 | document.monetization.addEventListener('monetizationprogress', paymentCounter); 42 | document.monetization.addEventListener('monetizationstop', disableMonetization); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/js/inputs/keyboard.js: -------------------------------------------------------------------------------- 1 | /** Keyboard input 2 | * Record time at which each key gets pressed 3 | * and provide utilities to queries which keys are pressed or released 4 | * 5 | * Note: importing any public function of this module 6 | * will install the keyboard event listeners 7 | */ 8 | 9 | /* private */ 10 | 11 | // time at which each key was pressed 12 | // key = KeyboardEvent.code 13 | // value = time in ms at which keyboard event was first emitted (repeats are filtered out) 14 | const KEYS = {}; 15 | 16 | const _isKeyDown = code => KEYS[code] || 0; 17 | 18 | const _releaseKey = code => delete KEYS[code]; 19 | 20 | addEventListener('keydown', e => { 21 | // prevent itch.io from scrolling the page up/down 22 | e.preventDefault(); 23 | 24 | if (!e.repeat) { 25 | KEYS[e.code] = performance.now(); 26 | } 27 | }); 28 | 29 | addEventListener('keyup', e => _releaseKey(e.code)); 30 | 31 | 32 | 33 | 34 | /* public API */ 35 | 36 | // returns the most recent key pressed amongst the array passed as argument (or 0 if none were) 37 | export const isKeyDown = (...codes) => Math.max(...codes.map(code => _isKeyDown(code))) 38 | 39 | // retuns the list of keys currently pressed 40 | export const whichKeyDown = () => Object.keys(KEYS).filter(code => _isKeyDown(code)); 41 | 42 | // returns if any key is currently pressed 43 | export const anyKeyDown = () => whichKeyDown().length; 44 | 45 | // return true if a key can be released (must be currently pressed) or false if it can't 46 | // note: this "consumes" the key pressed by releasing it (only if it was pressed) 47 | export const isKeyUp = code => _isKeyDown(code) ? _releaseKey(code) : false; 48 | 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gamejam-boilerplate", 3 | "version": "1.2.0", 4 | "description": "Boilerplate for Game Jams like JS13KGames", 5 | "main": "src/js/game.js", 6 | "scripts": { 7 | "clean": "rm -rf dist && mkdir dist", 8 | "build": "run-s clean build:*", 9 | "build:js": "esbuild src/js/game.js --bundle --format=iife --loader:.webp=dataurl | terser --config-file scripts/terser.config.js -o dist/game.js", 10 | "build:html": "grep -v '", 38 | "bugs": { 39 | "url": "https://github.com/herebefrogs/gamejam-boilerplate/issues" 40 | }, 41 | "homepage": "https://github.com/herebefrogs/gamejam-boilerplate#readme" 42 | } 43 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | scratchpad: 2 | - lots of x,y also have a corresponding w,h. That could be captured in a rect(x,y,w,h) structure 3 | - ala https://developer.mozilla.org/en-US/docs/Web/API/DOMRect 4 | - with some convenience methods for x1,y1 in rect? and rect1 intersect with rect? 5 | 6 | 7 | - add CCapture.js for recording in-progress movies during development 8 | 9 | game engine 10 | =========== 11 | - text rendering shouldn't go on an overlay by default 12 | - improved inputs 13 | - keyboard: double key press 14 | - pointer: double click 15 | - classic D-PAD behaviour (need a name for my current implementation) 16 | - classic D-PAD rendering (ala Remi overlay circle/range with smaller overlay circle/pad) 17 | - gamepad 18 | - lerp-smoothing? 19 | - tinyfont.js? https://github.com/darkwebdev/tinyfont.js 20 | - support multiple voices for speech synthesis? 21 | - ECS 22 | - rectangle class for bounding box and quick AABB collision logic 23 | - useful for camera window & player, camera window & off screen entities 24 | - System base class 25 | - Component base class 26 | - inputs 27 | - velocity 28 | - position 29 | - shape 30 | - display list/nested shape/skeleton animation 31 | - Entity base class 32 | - UI widgets for game menus 33 | 34 | build chain 35 | =========== 36 | custom: 37 | - replace all const by var before JS gets inlined in HTML 38 | - replace all the global variables by arguments with default value of the IIFE (e.g. const foo = 'bar' => (foo = 'bar') => { ... }) 39 | libs: 40 | - ECT (https://github.com/fhanau/Efficient-Compression-Tool) in place of AdvZip? 41 | - Mac build https://github.com/fhanau/Efficient-Compression-Tool/releases/download/v0.8.3/ect-0.8.3-macOS.zip (need manual install & permission shenanigans) 42 | - npm package https://www.npmjs.com/package/ect-bin (but didn't seem to be available in command line afterwards... postinstall failed?) 43 | - avif in place of png/webp (not enough browser support yet) 44 | 45 | -------------------------------------------------------------------------------- /src/js/speech.js: -------------------------------------------------------------------------------- 1 | import { choice } from './utils'; 2 | 3 | // cache utterances by message so they can be reused 4 | const utterances = {}; 5 | 6 | // voices are loaded asynchronously, but the API doesn't return a promise 7 | // so attempt to load voices for 1 second before giving up 8 | const getVoices = () => new Promise((resolve, reject) => { 9 | let attempts = 0; 10 | 11 | let id = setInterval(() => { 12 | attempts += 1; 13 | if (speechSynthesis.getVoices().length) { 14 | resolve(speechSynthesis.getVoices()); 15 | clearInterval(id); 16 | } 17 | else if (attempts >= 100) { 18 | reject([]); 19 | clearInterval(id); 20 | } 21 | }, 10); 22 | }); 23 | 24 | 25 | // Speech Synthesis wrapper API 26 | 27 | /** 28 | * Initialize speech synthesis voices and return a function to speak text 29 | * using a random voice from the ones matching the navigator's language value 30 | * @return function to pronounce a text using a random voice from the ones matching the navigator's language value 31 | */ 32 | export async function initSpeech() { 33 | // find all suitable voices 34 | const allVoices = await getVoices(); 35 | let localVoices = allVoices.filter(voice => ( 36 | // exact match of language and country variant 37 | navigator.language === voice.lang 38 | // or partial match on the language, regardless of the country variant 39 | || (new RegExp(`^${navigator.language.split('-')[0]}`)).test(voice.lang) 40 | )); 41 | 42 | if (localVoices.length) { 43 | // choose a voice randomly 44 | const voice = choice(localVoices); 45 | 46 | // return a function to speak a message in that voice 47 | return function(text) { 48 | // retrieved a cached utterance of this message, or create a new utterance 49 | const utterance = utterances[text] || (utterances[text] = new SpeechSynthesisUtterance(text)); 50 | utterance.voice = voice; 51 | speechSynthesis.speak(utterance); 52 | } 53 | } else { 54 | return function() { 55 | // no-op since no suitable voice is available 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow build the latest commit from the main branch 2 | # and publishes the result to the Github Pages branch 3 | 4 | name: CI 5 | 6 | # triggers the workflow on push or pull request events for the main branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | 11 | jobs: 12 | # workflow contains a single job called "build" 13 | build: 14 | # type of runner that the job will run on 15 | runs-on: ubuntu-latest 16 | 17 | # sequence of tasks that will be executed as part of the job 18 | steps: 19 | # input branch 20 | - name: Checkout main branch 21 | uses: actions/checkout@v2 22 | 23 | # output branch 24 | - name: Checkout Github Pages branch 25 | uses: actions/checkout@v2 26 | with: 27 | ref: gh-pages 28 | path: gh-pages 29 | 30 | # setup runtime & dependencies 31 | - name: Setup Node 32 | uses: actions/setup-node@v1 33 | with: 34 | node-version: '14.x' 35 | 36 | - name: Install dependencies 37 | run: npm install 38 | 39 | - name: Build game 40 | run: | 41 | npm run clean 42 | npm run build:js 43 | npm run build:html 44 | npm run build:zip 45 | 46 | - name: Prepare commit message 47 | run: | 48 | # capture commit subject and ID 49 | git log -1 --format='%s (%H)%n' > commit-msg 50 | # capture zip size 51 | npm run --silent build:zipSize >> commit-msg 52 | # preview commit message 53 | cat commit-msg 54 | 55 | - name: Commit game to Github Pages 56 | run: | 57 | cp dist/index.html gh-pages/ 58 | cd gh-pages 59 | git status 60 | git config user.name 'github-actions' 61 | git config user.email 'github-actions@github.com' 62 | git commit -F ../commit-msg index.html 63 | git push 64 | 65 | # - name: Publish game to Itch.io via Butler 66 | # uses: josephbmanley/butler-publish-itchio-action@master 67 | # env: 68 | # BUTLER_CREDENTIALS: ${{ secrets.BUTLER_CREDENTIALS }} 69 | # CHANNEL: html 70 | # ITCH_GAME:100*d&&(E=0,F=A*h*Math.sin(B*b*l/I-y),F=p(F=i?1C?0:(Cf*I&&(h+=e*l/I,w+=e*l/I,H=0),m&&++D>m*I&&(h=w,o=v,D=1,H=H||1);return J}; 20 | 21 | // zzfx() - the universal entry point -- returns a AudioBufferSourceNode 22 | const zzfx=(...t)=>zzfxP(zzfxG(...t)) 23 | 24 | // ZzFXM (v2.0.2) | (C) Keith Clark | MIT | https://github.com/keithclark/ZzFXM 25 | const zzfxM=(f,n,o,t=125)=>{let z,e,l,r,g,h,x,a,u,c,d,i,m,p,G,M,R=[],b=[],j=[],k=0,q=1,s={},v=zzfxR/t*60>>2;for(;q;k++)R=[q=a=d=m=0],o.map((t,d)=>{for(x=n[t][k]||[0,0,0],q|=!!n[t][k],G=m+(n[t][0].length-2-!a)*v,e=2,r=m;e v-99&&u?i+=(i<1)/99:0)h=(1-i)*R[p++]/2||0,b[r]=(b[r]||0)+h*M-h,j[r]=(j[r++]||0)+h*M+h;g&&(i=g%1,M=x[1]||0,(g|=0)&&(R=s[[c=x[p=0]||0,g]]=s[[c,g]]||(z=[...f[c]],z[2]*=2**((g-12)/12),zzfxG(...z))))}m=G});return[b,j]} 26 | 27 | 28 | // Music wrapper API 29 | 30 | /** 31 | * Generate ZzFX Music song data 32 | * @param {*} songs: array of song parameteres in ZzFX Music format https://github.com/keithclark/ZzFXM#song-format 33 | * @returns array of song data fit for ZzFX Music player 34 | */ 35 | export const loadSongs = songs => songs.map(song => zzfxM(...song)); 36 | 37 | /** 38 | * Play a ZzFX Music song 39 | * @param {*} songData song data generated by loadSongs/zzfxM 40 | * @returns an Audio Node to control the song playing (and stop if for example) 41 | */ 42 | export const playSong = songData => zzfxP(...songData); 43 | 44 | /** 45 | * Play a ZzfX sound 46 | * @param {*} soundData sound parameters in ZzFX format https://github.com/KilledByAPixel/ZzFX#zzfx-is-a-javascript-sound-effect-engine-and-creation-tool 47 | * @returns ? 48 | */ 49 | export const playSound = soundData => zzfx(...soundData); 50 | -------------------------------------------------------------------------------- /src/js/utils.js: -------------------------------------------------------------------------------- 1 | // PSEUDO RANDOM NUMBER GENERATOR 2 | 3 | // current Pseudo Random Number Generator (PRNG) 4 | // NOTE: default to non-deterministic Math.random() in case initRand() doesn't get called 5 | let prng = Math.random; 6 | 7 | // initialize a new deterninistic PRNG from the provided seed and store the seed in the URL 8 | export function setRandSeed(seed) { 9 | prng = seedRand(seed); 10 | 11 | // save seed in URL 12 | const url = new URL(location); 13 | url.searchParams.set('seed', seed); 14 | history.pushState({}, '', url); 15 | } 16 | 17 | // return a seed value retrieved from the URL (if present) or generated (if missing or asked to change) 18 | export function getRandSeed(changeSeed = false) { 19 | // attempt to read seed from URL 20 | let seed = new URLSearchParams(location.search).get('seed'); 21 | 22 | return (!seed || changeSeed) ? createRandSeed() : seed; 23 | } 24 | 25 | /** 26 | * Create a seeded random number generator. 27 | * Copied from Kontra.js by Steven Lambert 28 | * 29 | * ``` 30 | * let rand = seedRand('kontra'); 31 | * console.log(rand()); // => always 0.33761959057301283 32 | * ``` 33 | * @see https://github.com/straker/kontra/blob/main/src/helpers.js 34 | * @see https://stackoverflow.com/a/47593316/2124254 35 | * @see https://github.com/bryc/code/blob/master/jshash/PRNGs.md 36 | * 37 | * @function seedRand 38 | * @param {String} str - String to seed the random number generator. 39 | * @returns {() => Number} Seeded random number generator function. 40 | */ 41 | function seedRand(str) { 42 | // based on the above references, this was the smallest code yet decent 43 | // quality seed random function 44 | 45 | // first create a suitable hash of the seed string using xfnv1a 46 | // @see https://github.com/bryc/code/blob/master/jshash/PRNGs.md#addendum-a-seed-generating-functions 47 | for(var i = 0, h = 2166136261 >>> 0; i < str.length; i++) { 48 | h = Math.imul(h ^ str.charCodeAt(i), 16777619); 49 | } 50 | h += h << 13; h ^= h >>> 7; 51 | h += h << 3; h ^= h >>> 17; 52 | let seed = (h += h << 5) >>> 0; 53 | 54 | // then return the seed function and discard the first result 55 | // @see https://github.com/bryc/code/blob/master/jshash/PRNGs.md#lcg-lehmer-rng 56 | let rand = () => (2 ** 31 - 1 & (seed = Math.imul(48271, seed))) / 2 ** 31; 57 | rand(); 58 | return rand; 59 | } 60 | 61 | // return a new seed made of a combination of 6 letters or numbers 62 | function createRandSeed() { 63 | // base64-encoding a random number between 0 and 1, discarding the first 3 characters (always MC4) and keeping the next 6 64 | return btoa(prng()).slice(3, 9) 65 | } 66 | 67 | export function rand(min = 0, max = 1) { 68 | return prng() * (max + 1 - min) + min; 69 | }; 70 | 71 | export function randInt(min = 0, max = 1) { 72 | return Math.floor(rand(min, max)); 73 | }; 74 | 75 | export function choice(values) { 76 | return values[randInt(0, values.length - 1)]; 77 | }; 78 | 79 | export const clamp = (v, min, max) => Math.max(min, Math.min(v, max)); 80 | 81 | // LERP 82 | 83 | /** 84 | * Return a value between min and max based on current time in range [0...1] 85 | * @param {*} min min value 86 | * @param {*} max max value 87 | * @param {*} t current time in range [0...1] 88 | */ 89 | export function lerp(min, max, t) { 90 | if (t < 0) return min; 91 | if (t > 1) return max; 92 | return min * (1 - t) + max * t; 93 | } 94 | 95 | /** 96 | * Return a value from an array of values based on current time in range [0...1] 97 | * @param {*} values array of values to pick from 98 | * @param {*} t current time in range [0...1], mapped to an index in values 99 | */ 100 | export function lerpArray(values, t) { 101 | if (t < 0) return values[0]; 102 | if (t > 1) return values[values.length - 1]; 103 | 104 | return values[Math.floor((values.length - 1) * t)]; 105 | } 106 | 107 | /** 108 | * Return a value between the values of an array based on current time in range [0...1] 109 | * @param {*} values array of values to pick from 110 | * @param {*} t current time in range [0...1], mapped to an index in values 111 | */ 112 | export function smoothLerpArray(values, t) { 113 | if (t <= 0) return values[0]; 114 | if (t >= 1) return values[values.length - 1]; 115 | 116 | const start = Math.floor((values.length - 1) * t); 117 | const min = values[start]; 118 | const max = values[Math.ceil((values.length - 1) * t)]; 119 | // t * number of intervals - interval start index 120 | const delta = t * (values.length - 1) - start; 121 | return lerp(min, max, delta); 122 | } 123 | 124 | // IMAGE LOADING 125 | 126 | export function loadImg(dataUri) { 127 | return new Promise(function(resolve) { 128 | var img = new Image(); 129 | img.onload = function() { 130 | resolve(img); 131 | }; 132 | img.src = dataUri; 133 | }); 134 | }; 135 | -------------------------------------------------------------------------------- /src/js/inputs/pointer.js: -------------------------------------------------------------------------------- 1 | /** Pointer events 2 | * Record pointer location and click times. 3 | * 4 | * Note: importing any public function of this module 5 | * will install the keyboard event listeners 6 | */ 7 | 8 | import { clamp, lerp } from '../utils'; 9 | 10 | /* private */ 11 | 12 | // screen position of pointer 13 | let x = 0; 14 | let y = 0; 15 | // vector/direction of pointer motion, in range [-1, 1]; 16 | let vX = 0; 17 | let vY = 0; 18 | // pointer container, used to detect direction reversal for each axis 19 | let minX = 0; 20 | let minY = 0; 21 | let maxX = 0; 22 | let maxY = 0; 23 | // minimum distance to cover before pointer direction considered reversed 24 | // aka click "size" in px 25 | let MIN_DISTANCE = 30; 26 | // click time 27 | let pointerDownTime = 0; 28 | // last pointer event (for canvas space calculations) 29 | let lastEvent; 30 | 31 | // NOTE: 32 | // - pointer events are universal (mouse, touch, pen) 33 | // - if necessary distinguish multi-touch or multiple pens with e.pointerId 34 | // - listening for mouse events would double pointer events 35 | // - listening touch events only work for mobile and would not capture mouse events 36 | addEventListener('pointerdown', e => { 37 | e.preventDefault(); 38 | lastEvent = e; 39 | 40 | pointerDownTime = performance.now(); 41 | [x, y] = [maxX, maxY] = [minX, minY] = pointerLocation(); 42 | }); 43 | 44 | addEventListener('pointermove', e => { 45 | e.preventDefault(); 46 | lastEvent = e; 47 | 48 | [x, y] = pointerLocation(); 49 | 50 | if (pointerDownTime) { 51 | setPointerDirection(); 52 | } 53 | }); 54 | 55 | addEventListener('pointerup', e => { 56 | e.preventDefault(); 57 | lastEvent = e; 58 | 59 | pointerDownTime = 0; 60 | vX = vY = minX = minY = maxX = maxY = 0; 61 | }); 62 | 63 | // for multiple pointers, use e.pointerId to differentiate (on desktop, mouse is always 1, on mobile every pointer even has a different id incrementing by 1) 64 | // for surface area of touch contact, use e.width and e.height (in CSS pixel) mutiplied by window.devicePixelRatio (for device pixels aka canvas pixels) 65 | // for canvas space coordinate, use e.layerX and .layerY when e.target = c 66 | // { id: e.pointerId, x: e.x, y: e.y, w: e.width*window.devicePixelRatio, h: e.height*window.devicePixelRatio} 67 | const pointerLocation = () => [Math.floor(lastEvent.pageX), Math.floor(lastEvent.pageY)]; 68 | 69 | function setPointerDirection() { 70 | // touch moving further right 71 | if (x > maxX) { 72 | maxX = x; 73 | vX = lerp(0, 1, (maxX - minX) / MIN_DISTANCE) 74 | } 75 | // pointer moving further left 76 | else if (x < minX) { 77 | minX = x; 78 | vX = -lerp(0, 1, (maxX - minX) / MIN_DISTANCE) 79 | } 80 | // pointer reversing left while moving right before 81 | else if (x < maxX && vX >= 0) { 82 | minX = x; 83 | vX = 0; 84 | } 85 | // pointer reversing right while moving left before 86 | else if (minX < x && vX <= 0) { 87 | maxX = x; 88 | vX = 0; 89 | } 90 | 91 | // pointer moving further down 92 | if (y > maxY) { 93 | maxY = y; 94 | vY = lerp(0, 1, (maxY - minY) / MIN_DISTANCE) 95 | 96 | } 97 | // pointer moving further up 98 | else if (y < minY) { 99 | minY = y; 100 | vY = -lerp(0, 1, (maxY - minY) / MIN_DISTANCE) 101 | 102 | } 103 | // pointer reversing up while moving down before 104 | else if (y < maxY && vY >= 0) { 105 | minY = y; 106 | vY = 0; 107 | } 108 | // pointer reversing down while moving up before 109 | else if (minY < y && vY <= 0) { 110 | maxY = y; 111 | vY = 0; 112 | } 113 | }; 114 | 115 | 116 | /* public API */ 117 | 118 | export const isPointerDown = () => pointerDownTime; 119 | 120 | export const isPointerUp = () => isPointerDown() ? pointerDownTime = 0 || true : false; 121 | 122 | export const pointerScreenPosition = () => [x, y]; 123 | 124 | export const pointerCanvasPosition = (canvasWidth, canvasHeight) => { 125 | // canvas is centered horizontally 126 | // x/pageX/y/pageY are in screen space, must be offset by canvas position then scaled down 127 | // to be converted in canvas space 128 | return [ 129 | clamp( 130 | (lastEvent.x || lastEvent.pageX) - (innerWidth - canvasWidth)/2, 131 | 0, canvasWidth 132 | ), 133 | clamp( 134 | lastEvent.y || lastEvent.pageY, 135 | 0, canvasHeight 136 | ) 137 | ].map(Math.round); 138 | } 139 | 140 | export const pointerDirection = () => [vX, vY]; 141 | 142 | // TODO verify and delete 143 | /* 144 | function addDebugTouch(x, y) { 145 | touches.push([x / innerWidth * VIEWPORT.width, y / innerHeight * VIEWPORT.height]); 146 | if (touches.length > 10) { 147 | touches = touches.slice(touches.length - 10); 148 | } 149 | }; 150 | 151 | function renderDebugTouch() { 152 | let x = maxX / innerWidth * VIEWPORT.width; 153 | let y = maxY / innerHeight * VIEWPORT.height; 154 | renderDebugTouchBound(x, x, 0, VIEWPORT.height, '#f00'); 155 | renderDebugTouchBound(0, VIEWPORT.width, y, y, '#f00'); 156 | x = minX / innerWidth * VIEWPORT.width; 157 | y = minY / innerHeight * VIEWPORT.height; 158 | renderDebugTouchBound(x, x, 0, VIEWPORT.height, '#ff0'); 159 | renderDebugTouchBound(0, VIEWPORT.width, y, y, '#ff0'); 160 | 161 | if (touches.length) { 162 | VIEWPORT_CTX.strokeStyle = VIEWPORT_CTX.fillStyle = '#02d'; 163 | VIEWPORT_CTX.beginPath(); 164 | [x, y] = touches[0]; 165 | VIEWPORT_CTX.moveTo(x, y); 166 | touches.forEach(function([x, y]) { 167 | VIEWPORT_CTX.lineTo(x, y); 168 | }); 169 | VIEWPORT_CTX.stroke(); 170 | VIEWPORT_CTX.closePath(); 171 | VIEWPORT_CTX.beginPath(); 172 | [x, y] = touches[touches.length - 1]; 173 | VIEWPORT_CTX.arc(x, y, 2, 0, 2 * Math.PI) 174 | VIEWPORT_CTX.fill(); 175 | VIEWPORT_CTX.closePath(); 176 | } 177 | }; 178 | 179 | function renderDebugTouchBound(_minX, _maxX, _minY, _maxY, color) { 180 | VIEWPORT_CTX.strokeStyle = color; 181 | VIEWPORT_CTX.beginPath(); 182 | VIEWPORT_CTX.moveTo(_minX, _minY); 183 | VIEWPORT_CTX.lineTo(_maxX, _maxY); 184 | VIEWPORT_CTX.stroke(); 185 | VIEWPORT_CTX.closePath(); 186 | }; 187 | */ 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Game Jam Boilerplate 2 | ==================== 3 | 4 | A small, extendable boilerplate for 2D canvas games with keyboard, mouse and touch support. It comes with a build script to watch for changes and livereload the game in your browser during development, and to package your game into a ZIP archive for gamejam submission. 5 | 6 | Getting Started 7 | --------------- 8 | 9 | ``` 10 | npm start 11 | -> build game, open a browser, watch source and livereload browser on changes 12 | 13 | npm run build 14 | -> build game for gamejam submission (no sourcemap and livereload script) 15 | ``` 16 | 17 | Understanding the game engine 18 | ----------------------------- 19 | The entry point of your game is `index.html`. It contains minimal styling, a canvas, and a script tag to load the main JS game file. It's also missing a lot of HTML markup to make it a valid document, but modern browsers will add them automatically, therefore saving some bytes. 20 | 21 | The first JS function to trigger is the `onload` event handler. It will set the document's title to your game's name, scale the canvas up to fill out the full window while preserving its aspect ratio, load the character set image (bitmap alphabet) and tile set image (all the game sprites), then fire off the main game loop with the `toggleLoop` function. 22 | 23 | The canvas size is controlled by the 2 global variables `WIDTH` and `HEIGHT`. Change them to increase the game map and/or game resolution. 24 | 25 | The main game loop is handled by the `loop` function and does 4 things: 26 | - schedule the loop to be called again via the `requestAnimationFrame` function. 27 | - render the current game state via the `render` function. 28 | - calculate the elapsed time since the last loop 29 | - update the current game state via the `update` function. 30 | 31 | Both `render` and `update` use a state machine approach to do different things depending which screen the game is on. This boilerplate comes with 3 screens which you're encourage to extend to your needs: 32 | - `TITLE_SCREEN`: display the name of your game, some credits or control instructions 33 | - `GAME_SCREEN`: when the player actually plays your game 34 | - `END_SCREEN`: display score and restart the game once the game is won or lost 35 | 36 | The `update` function follows a simplified Entity-Component-System. 37 | An entity (player, ennemies...) keeps track of their position, control input, current action (moving, standing still, dead...), sprite frame (which frame in the animation), speed and size. `update` goes through every entity in the game and update their animation frame and position based on the player's control inputs and the time elapsed since the last loop. It will then performed a simple bounding-box collision check (`testAABBCollision` function) and resolve any collision by pushing the 2 entities at their edge so they touch but don't overlap each other (`correctAABBCollision` function). It will also make sure no entity leaves the viewport (`constrainToViewport` function). 38 | 39 | The `render` function goes through every entity and will display the proper bitmap at the entity's position in the viewport. The global variable `ATLAS` catalogs every existing entity type (hero, foe...) and their properties, like `speed` and animation frames under their action name (e.g. `move` is an array of coordinate and dimensions to find the proper entity bitmap in the `tileset` image). 40 | 41 | The game is automatically paused if the player change browser tab (`onvisibilitychange` event handler). 42 | 43 | Keyboard control is achieved by the `onkeydown` and `onkeyup` event handlers, which only record the direction in which the player wants to move so as no to block the event thread. 44 | Mouse and touchscreen support is achieved by the `ontouchstart/onpointerdown`, `ontouchmove/onpointermove` and `ontouchend/onpointerup` event handlers. 45 | 46 | The boilerplate will recognize the Konami code on the title screen. You're then free to enable any behaviour or cheat you see fit. 47 | 48 | Understanding the build script 49 | ------------------------------ 50 | The build script starts by wiping clean the `dist` directory. That's where it will serve the game from during development, and where it will save the game's optimized ZIP for gamejam submission. 51 | 52 | Next, it builds the JS code with `esbuild` & `terser`. Code bundler `esbuild` will follow all the JS import/require and inline them into a single IIFE. Any unused function will be removed by `esbuild`'s tree-shaking. WebP images will be automatically embedded as Base64-encoded data URLs, reducing the number of files. The resulting code will be piped into the `terser` minifier to optimize the bundle for size. During development, sourcemaps will be enabled. 53 | 54 | This is where things diverge a bit: 55 | - During development: 56 | - `esbuild` will watch for JS changes and rebuild the JS bundle into the `dist` directory. 57 | - `chodikar` will watch for images changes in the `src` directory and call `esbuild` again (since the new images need to be inlined in the JS bundle). 58 | - `browser-sync` will serve the JS file from the `dist` directory and `index.html` from the `src` directory on localhost over HTTPS (useful for A-Frame development). Any changes to the `dist` directory or `index.html` will livereload the new version of the game in your browser. 59 | - For gamejam submission: 60 | - `html-inline` will inline any CSS and JS files referenced by a `src` attribute into `index.html` . 61 | - `html-minifier` will then optimize the inlined CSS and HTML markup. 62 | - At this point, all your game assets are in a single file `index.html`, which will then be zipped. 63 | - The resulting ZIP is futher optimzed by `AdvZIP` (part of the AdvanceComp suite). 64 | - Finally, a small report will tell you how big the ZIP is and what's your size budget left if you're participating to JS13KGAMES. 65 | 66 | Assets 67 | ------ 68 | Even though the game engine is agnostic to the type of images used, the build script is configured for WebP (which has better compression than PNGs and is supported by all modern browsers). 69 | 70 | Web Monetization 71 | ---------------- 72 | To enable Web Monetization, uncomment the call to `checkMonetization` in `onload`. This will add listeners to handle monetization events. At the appropriate time in your code, check `isMonetizationEnabled` to decide if extra features should be accessible or not. Remember to update the value of the `monetization` meta tag in `src/index.html` to your payment pointer. 73 | 74 | Special Thanks & Credits 75 | ------------------------ 76 | - Eoin McGrath for his original build script 77 | - [Peters](https://twitter.com/p1100i) and [flo-](https://twitter.com/fl0ptimus_prime) for their pixel font from Glitch Hunter 78 | - [Ryan Malm](https://twitter.com/ryanmalm) for sharing his Twitter message code 79 | - [Maxime Euziere](https://twitter.com/MaximeEuziere) for his switch/case approach to handling game screens in update/render/input handlers 80 | - Florent Cailhol for suggesting Terser in place of UglifyJS 81 | - [Matt](https://twitter.com/Smflyf) for pointing out the existence of `advzip-bin` 82 | - [Frank Force](https://twitter.com/KilledByAPixel) and [Keith Clark](https://keithclark.co.uk/) for their über smoll sound & music players, [ZzFX](https://github.com/KilledByAPixel/ZzFX) and [ZzFX Music](https://github.com/keithclark/ZzFXM) respectively 83 | - [Steven Lambert](https://twitter.com/StevenKLambert) for his Pseudo Random Number Generator from Kontra.js 84 | -------------------------------------------------------------------------------- /src/js/game.js: -------------------------------------------------------------------------------- 1 | import { isKeyDown, anyKeyDown, isKeyUp } from './inputs/keyboard'; 2 | import { isPointerDown, isPointerUp, pointerCanvasPosition, pointerDirection } from './inputs/pointer'; 3 | import { isMobile } from './mobile'; 4 | import { checkMonetization, isMonetizationEnabled } from './monetization'; 5 | import { share } from './share'; 6 | import { loadSongs, playSound, playSong } from './sound'; 7 | import { initSpeech } from './speech'; 8 | import { save, load } from './storage'; 9 | import { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT, CHARSET_SIZE, initCharset, renderText, initTextBuffer, clearTextBuffer, renderAnimatedText } from './text'; 10 | import { getRandSeed, setRandSeed, lerp, loadImg } from './utils'; 11 | import TILESET from '../img/tileset.webp'; 12 | 13 | 14 | const konamiCode = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','KeyB','KeyA']; 15 | let konamiIndex = 0; 16 | 17 | // GAMEPLAY VARIABLES 18 | 19 | const TITLE_SCREEN = 0; 20 | const GAME_SCREEN = 1; 21 | const END_SCREEN = 2; 22 | let screen = TITLE_SCREEN; 23 | 24 | // factor by which to reduce both velX and velY when player moving diagonally 25 | // so they don't seem to move faster than when traveling vertically or horizontally 26 | const NORMALIZE_DIAGONAL = Math.cos(Math.PI / 4); 27 | const TIME_TO_FULL_SPEED = 150; // in millis, duration till going full speed in any direction 28 | 29 | let countdown; // in seconds 30 | let hero; 31 | let entities; 32 | 33 | let speak; 34 | 35 | // RENDER VARIABLES 36 | 37 | let cameraX = 0; // camera/viewport position in map 38 | let cameraY = 0; 39 | const CAMERA_WIDTH = 320; // camera/viewport size 40 | const CAMERA_HEIGHT = 240; 41 | // camera-window & edge-snapping settings 42 | const CAMERA_WINDOW_X = 100; 43 | const CAMERA_WINDOW_Y = 50; 44 | const CAMERA_WINDOW_WIDTH = CAMERA_WIDTH - 2*CAMERA_WINDOW_X; 45 | const CAMERA_WINDOW_HEIGHT = CAMERA_HEIGHT - 2*CAMERA_WINDOW_Y; 46 | 47 | const CTX = c.getContext('2d'); // visible canvas 48 | const BUFFER = c.cloneNode(); // backbuffer 49 | const BUFFER_CTX = BUFFER.getContext('2d'); 50 | BUFFER.width = 640; // backbuffer size 51 | BUFFER.height = 480; 52 | const MAP = c.cloneNode(); // static elements of the map/world cached once 53 | const MAP_CTX = MAP.getContext('2d'); 54 | MAP.width = 640; // map size, same as backbuffer 55 | MAP.height = 480; 56 | const TEXT = initTextBuffer(c, CAMERA_WIDTH, CAMERA_HEIGHT); // text buffer 57 | 58 | 59 | const ATLAS = { 60 | hero: { 61 | move: [ 62 | { x: 0, y: 0, w: 16, h: 18 }, 63 | { x: 16, y: 0, w: 16, h: 18 }, 64 | { x: 32, y: 0, w: 16, h: 18 }, 65 | { x: 48, y: 0, w: 16, h: 18 }, 66 | { x: 64, y: 0, w: 16, h: 18 }, 67 | ], 68 | speed: 100, 69 | }, 70 | foe: { 71 | move: [ 72 | { x: 0, y: 0, w: 16, h: 18 }, 73 | ], 74 | speed: 0, 75 | }, 76 | }; 77 | const FRAME_DURATION = 0.1; // duration of 1 animation frame, in seconds 78 | let tileset; // characters sprite, embedded as a base64 encoded dataurl by build script 79 | 80 | // LOOP VARIABLES 81 | 82 | let currentTime; 83 | let elapsedTime; 84 | let lastTime; 85 | let requestId; 86 | let running = true; 87 | 88 | // GAMEPLAY HANDLERS 89 | 90 | function unlockExtraContent() { 91 | // NOTE: remember to update the value of the monetization meta tag in src/index.html to your payment pointer 92 | } 93 | 94 | function startGame() { 95 | // setRandSeed(getRandSeed()); 96 | // if (isMonetizationEnabled()) { unlockExtraContent() } 97 | konamiIndex = 0; 98 | countdown = 60; 99 | cameraX = cameraY = 0; 100 | hero = createEntity('hero', CAMERA_WIDTH / 2, CAMERA_HEIGHT / 2); 101 | entities = [ 102 | hero, 103 | createEntity('foe', 10, 10), 104 | createEntity('foe', 630 - 16, 10), 105 | createEntity('foe', 630 - 16, 470 - 18), 106 | createEntity('foe', 300, 200), 107 | createEntity('foe', 400, 300), 108 | createEntity('foe', 500, 400), 109 | createEntity('foe', 10, 470 - 18), 110 | createEntity('foe', 100, 100), 111 | createEntity('foe', 100, 118), 112 | createEntity('foe', 116, 118), 113 | createEntity('foe', 116, 100), 114 | ]; 115 | renderMap(); 116 | screen = GAME_SCREEN; 117 | }; 118 | 119 | function testAABBCollision(entity1, entity2) { 120 | const test = { 121 | entity1MaxX: entity1.x + entity1.w, 122 | entity1MaxY: entity1.y + entity1.h, 123 | entity2MaxX: entity2.x + entity2.w, 124 | entity2MaxY: entity2.y + entity2.h, 125 | }; 126 | 127 | test.collide = entity1.x < test.entity2MaxX 128 | && test.entity1MaxX > entity2.x 129 | && entity1.y < test.entity2MaxY 130 | && test.entity1MaxY > entity2.y; 131 | 132 | return test; 133 | }; 134 | 135 | // entity1 collided into entity2 136 | function correctAABBCollision(entity1, entity2, test) { 137 | const { entity1MaxX, entity1MaxY, entity2MaxX, entity2MaxY } = test; 138 | 139 | const deltaMaxX = entity1MaxX - entity2.x; 140 | const deltaMaxY = entity1MaxY - entity2.y; 141 | const deltaMinX = entity2MaxX - entity1.x; 142 | const deltaMinY = entity2MaxY - entity1.y; 143 | 144 | // AABB collision response (homegrown wall sliding, not physically correct 145 | // because just pushing along one axis by the distance overlapped) 146 | 147 | // entity1 moving down/right 148 | if (entity1.velX > 0 && entity1.velY > 0) { 149 | if (deltaMaxX < deltaMaxY) { 150 | // collided right side first 151 | entity1.x -= deltaMaxX; 152 | } else { 153 | // collided top side first 154 | entity1.y -= deltaMaxY; 155 | } 156 | } 157 | // entity1 moving up/right 158 | else if (entity1.velX > 0 && entity1.velY < 0) { 159 | if (deltaMaxX < deltaMinY) { 160 | // collided right side first 161 | entity1.x -= deltaMaxX; 162 | } else { 163 | // collided bottom side first 164 | entity1.y += deltaMinY; 165 | } 166 | } 167 | // entity1 moving right 168 | else if (entity1.velX > 0) { 169 | entity1.x -= deltaMaxX; 170 | } 171 | // entity1 moving down/left 172 | else if (entity1.velX < 0 && entity1.velY > 0) { 173 | if (deltaMinX < deltaMaxY) { 174 | // collided left side first 175 | entity1.x += deltaMinX; 176 | } else { 177 | // collided top side first 178 | entity1.y -= deltaMaxY; 179 | } 180 | } 181 | // entity1 moving up/left 182 | else if (entity1.velX < 0 && entity1.velY < 0) { 183 | if (deltaMinX < deltaMinY) { 184 | // collided left side first 185 | entity1.x += deltaMinX; 186 | } else { 187 | // collided bottom side first 188 | entity1.y += deltaMinY; 189 | } 190 | } 191 | // entity1 moving left 192 | else if (entity1.velX < 0) { 193 | entity1.x += deltaMinX; 194 | } 195 | // entity1 moving down 196 | else if (entity1.velY > 0) { 197 | entity1.y -= deltaMaxY; 198 | } 199 | // entity1 moving up 200 | else if (entity1.velY < 0) { 201 | entity1.y += deltaMinY; 202 | } 203 | }; 204 | 205 | function constrainToViewport(entity) { 206 | if (entity.x < 0) { 207 | entity.x = 0; 208 | } else if (entity.x > MAP.width - entity.w) { 209 | entity.x = MAP.width - entity.w; 210 | } 211 | if (entity.y < 0) { 212 | entity.y = 0; 213 | } else if (entity.y > MAP.height - entity.h) { 214 | entity.y = MAP.height - entity.h; 215 | } 216 | }; 217 | 218 | 219 | function updateCameraWindow() { 220 | // TODO try to simplify the formulae below with this variable so it's easier to visualize 221 | // const cameraEdgeLeftX = cameraX + CAMERA_WINDOW_X; 222 | // const cameraEdgeTopY = cameraY + CAMERA_WINDOW_Y; 223 | // const cameraEdgeRightX = cameraEdgeLeftX + CAMERA_WINDOW_WIDTH; 224 | // const cameraEdgeBottomY = cameraEdgeTopY + CAMERA_WINDOW_HEIGHT; 225 | 226 | // edge snapping 227 | if (0 < cameraX && hero.x < cameraX + CAMERA_WINDOW_X) { 228 | cameraX = Math.max(0, hero.x - CAMERA_WINDOW_X); 229 | } 230 | else if (cameraX + CAMERA_WINDOW_X + CAMERA_WINDOW_WIDTH < MAP.width && hero.x + hero.w > cameraX + CAMERA_WINDOW_X + CAMERA_WINDOW_WIDTH) { 231 | cameraX = Math.min(MAP.width - CAMERA_WIDTH, hero.x + hero.w - (CAMERA_WINDOW_X + CAMERA_WINDOW_WIDTH)); 232 | } 233 | if (0 < cameraY && hero.y < cameraY + CAMERA_WINDOW_Y) { 234 | cameraY = Math.max(0, hero.y - CAMERA_WINDOW_Y); 235 | } 236 | else if (cameraY + CAMERA_WINDOW_Y + CAMERA_WINDOW_HEIGHT < MAP.height && hero.y + hero.h > cameraY + CAMERA_WINDOW_Y + CAMERA_WINDOW_HEIGHT) { 237 | cameraY = Math.min(MAP.height - CAMERA_HEIGHT, hero.y + hero.h - (CAMERA_WINDOW_Y + CAMERA_WINDOW_HEIGHT)); 238 | } 239 | }; 240 | 241 | // TODO move to utils (or dedicated utils package) 242 | function velocityForTarget(srcX, srcY, destX, destY) { 243 | const hypotenuse = Math.hypot(destX - srcX, destY - srcY) 244 | const adjacent = destX - srcX; 245 | const opposite = destY - srcY; 246 | // [ 247 | // velX = cos(alpha), 248 | // velY = sin(alpha), 249 | // alpha (TODO is zero at the top?) 250 | // ] 251 | return [ 252 | adjacent / hypotenuse, 253 | opposite / hypotenuse, 254 | Math.atan2(opposite / hypotenuse, adjacent / hypotenuse) + Math.PI/2, 255 | ]; 256 | } 257 | 258 | // TODO move to utils (or dedicated utils package) 259 | function positionOnCircle(centerX, centerY, radius, angle) { 260 | return [ 261 | centerX + radius * Math.cos(angle), 262 | centerY + radius * Math.sin(angle) 263 | ]; 264 | } 265 | 266 | function createEntity(type, x = 0, y = 0) { 267 | const action = 'move'; 268 | const sprite = ATLAS[type][action][0]; 269 | return { 270 | action, 271 | frame: 0, 272 | frameTime: 0, 273 | h: sprite.h, 274 | moveDown: 0, 275 | moveLeft: 0, 276 | moveRight: 0, 277 | moveUp: 0, 278 | velX: 0, 279 | velY: 0, 280 | speed: ATLAS[type].speed, 281 | type, 282 | w: sprite.w, 283 | x, 284 | y, 285 | }; 286 | }; 287 | 288 | function updateEntity(entity) { 289 | // update animation frame 290 | entity.frameTime += elapsedTime; 291 | if (entity.frameTime > FRAME_DURATION) { 292 | entity.frameTime -= FRAME_DURATION; 293 | entity.frame += 1; 294 | entity.frame %= ATLAS[entity.type][entity.action].length; 295 | } 296 | // update position 297 | const scale = entity.velX && entity.velY ? NORMALIZE_DIAGONAL : 1; 298 | const distance = entity.speed * elapsedTime * scale; 299 | entity.x += distance * entity.velX; 300 | entity.y += distance * entity.velY; 301 | }; 302 | 303 | const pointerMapPosition = () => { 304 | const [x, y] = pointerCanvasPosition(c.width, c.height); 305 | return [x*CAMERA_WIDTH/c.width + cameraX, y*CAMERA_HEIGHT/c.height + cameraY].map(Math.round); 306 | } 307 | 308 | function processInputs() { 309 | switch (screen) { 310 | case TITLE_SCREEN: 311 | if (isKeyUp(konamiCode[konamiIndex])) { 312 | konamiIndex++; 313 | } 314 | if (anyKeyDown() || isPointerUp()) { 315 | startGame(); 316 | } 317 | break; 318 | case GAME_SCREEN: 319 | if (isPointerDown()) { 320 | [hero.velX, hero.velY] = pointerDirection(); 321 | } else { 322 | hero.moveLeft = isKeyDown( 323 | 'ArrowLeft', 324 | 'KeyA', // English Keyboard layout 325 | 'KeyQ' // French keyboard layout 326 | ); 327 | hero.moveRight = isKeyDown( 328 | 'ArrowRight', 329 | 'KeyD' 330 | ); 331 | hero.moveUp = isKeyDown( 332 | 'ArrowUp', 333 | 'KeyW', // English Keyboard layout 334 | 'KeyZ' // French keyboard layout 335 | ); 336 | hero.moveDown = isKeyDown( 337 | 'ArrowDown', 338 | 'KeyS' 339 | ); 340 | 341 | if (hero.moveLeft || hero.moveRight) { 342 | hero.velX = (hero.moveLeft > hero.moveRight ? -1 : 1) * lerp(0, 1, (currentTime - Math.max(hero.moveLeft, hero.moveRight)) / TIME_TO_FULL_SPEED) 343 | } else { 344 | hero.velX = 0; 345 | } 346 | if (hero.moveDown || hero.moveUp) { 347 | hero.velY = (hero.moveUp > hero.moveDown ? -1 : 1) * lerp(0, 1, (currentTime - Math.max(hero.moveUp, hero.moveDown)) / TIME_TO_FULL_SPEED) 348 | } else { 349 | hero.velY = 0; 350 | } 351 | } 352 | break; 353 | case END_SCREEN: 354 | if (isKeyUp('KeyT')) { 355 | // TODO can I share an image of the game? 356 | share({ 357 | title: document.title, 358 | text: 'Check this game template made by @herebefrogs', 359 | url: 'https://bit.ly/gmjblp' 360 | }); 361 | } 362 | if (anyKeyDown() || isPointerUp()) { 363 | screen = TITLE_SCREEN; 364 | } 365 | break; 366 | } 367 | } 368 | 369 | function update() { 370 | processInputs(); 371 | 372 | switch (screen) { 373 | case GAME_SCREEN: 374 | countdown -= elapsedTime; 375 | if (countdown < 0) { 376 | screen = END_SCREEN; 377 | } 378 | entities.forEach(updateEntity); 379 | entities.slice(1).forEach((entity) => { 380 | const test = testAABBCollision(hero, entity); 381 | if (test.collide) { 382 | correctAABBCollision(hero, entity, test); 383 | } 384 | }); 385 | constrainToViewport(hero); 386 | updateCameraWindow(); 387 | break; 388 | } 389 | }; 390 | 391 | // RENDER HANDLERS 392 | 393 | function blit() { 394 | // copy camera portion of the backbuffer onto visible canvas, scaling it to screen dimensions 395 | CTX.drawImage( 396 | BUFFER, 397 | cameraX, cameraY, CAMERA_WIDTH, CAMERA_HEIGHT, 398 | 0, 0, c.width, c.height 399 | ); 400 | CTX.drawImage( 401 | TEXT, 402 | 0, 0, CAMERA_WIDTH, CAMERA_HEIGHT, 403 | 0, 0, c.width, c.height 404 | ); 405 | }; 406 | 407 | function render() { 408 | clearTextBuffer(); 409 | 410 | switch (screen) { 411 | case TITLE_SCREEN: 412 | BUFFER_CTX.fillStyle = '#fff'; 413 | BUFFER_CTX.fillRect(0, 0, BUFFER.width, BUFFER.height); 414 | renderText('title screen', CHARSET_SIZE, CHARSET_SIZE); 415 | renderText(isMobile ? 'tap to start' : 'press any key', CAMERA_WIDTH / 2, CAMERA_HEIGHT / 2, ALIGN_CENTER); 416 | if (konamiIndex === konamiCode.length) { 417 | renderText('konami mode on', BUFFER.width - CHARSET_SIZE, CHARSET_SIZE, ALIGN_RIGHT); 418 | } 419 | break; 420 | case GAME_SCREEN: 421 | // clear backbuffer by drawing static map elements 422 | // TODO could also just draw the camera visible portion of the map 423 | BUFFER_CTX.drawImage(MAP, 0, 0, BUFFER.width, BUFFER.height); 424 | // TODO could also skip every entity not in the camera visible portion 425 | entities.forEach(entity => renderEntity(entity)); 426 | renderText('game screen', CHARSET_SIZE, CHARSET_SIZE); 427 | renderCountdown(); 428 | // debugCameraWindow(); 429 | // uncomment to debug mobile input handlers 430 | // renderDebugTouch(); 431 | break; 432 | case END_SCREEN: 433 | BUFFER_CTX.fillStyle = '#fff'; 434 | BUFFER_CTX.fillRect(0, 0, BUFFER.width, BUFFER.height); 435 | renderText('end screen', CHARSET_SIZE, CHARSET_SIZE); 436 | // renderText(monetizationEarned(), TEXT.width - CHARSET_SIZE, TEXT.height - 2*CHARSET_SIZE, ALIGN_RIGHT); 437 | break; 438 | } 439 | 440 | blit(); 441 | }; 442 | 443 | function renderCountdown() { 444 | const minutes = ((countdown + 1) / 60) | 0; // | 0 is the same as Math.trunc to get the integer part 445 | const seconds = (countdown + 1 - minutes * 60) | 0; // +1 to round up to the next second e.g. 2.7s is still 3s left until 2.0s 446 | renderText(`${minutes}:${seconds <= 9 ? '0' : ''}${seconds}`, CAMERA_WIDTH - CHARSET_SIZE, CHARSET_SIZE, ALIGN_RIGHT); 447 | }; 448 | 449 | function renderEntity(entity, ctx = BUFFER_CTX) { 450 | const sprite = ATLAS[entity.type][entity.action][entity.frame]; 451 | // TODO skip draw if image outside of visible canvas 452 | ctx.drawImage( 453 | tileset, 454 | sprite.x, sprite.y, sprite.w, sprite.h, 455 | Math.round(entity.x), Math.round(entity.y), sprite.w, sprite.h 456 | ); 457 | }; 458 | 459 | function debugCameraWindow() { 460 | BUFFER_CTX.strokeStyle = '#d00'; 461 | BUFFER_CTX.lineWidth = 1; 462 | BUFFER_CTX.strokeRect(cameraX + CAMERA_WINDOW_X, cameraY + CAMERA_WINDOW_Y, CAMERA_WINDOW_WIDTH, CAMERA_WINDOW_HEIGHT); 463 | }; 464 | 465 | function renderMap() { 466 | MAP_CTX.fillStyle = '#fff'; 467 | MAP_CTX.fillRect(0, 0, MAP.width, MAP.height); 468 | // TODO cache map by rendering static entities on the MAP canvas 469 | }; 470 | 471 | // LOOP HANDLERS 472 | 473 | function loop() { 474 | if (running) { 475 | requestId = requestAnimationFrame(loop); 476 | currentTime = performance.now(); 477 | elapsedTime = (currentTime - lastTime) / 1000; 478 | update(); 479 | render(); 480 | lastTime = currentTime; 481 | } 482 | }; 483 | 484 | function toggleLoop(value) { 485 | running = value; 486 | if (running) { 487 | lastTime = performance.now(); 488 | loop(); 489 | } else { 490 | cancelAnimationFrame(requestId); 491 | } 492 | }; 493 | 494 | // EVENT HANDLERS 495 | 496 | // the real "main" of the game 497 | onload = async (e) => { 498 | document.title = 'Game Jam Boilerplate'; 499 | 500 | onresize(); 501 | //checkMonetization(); 502 | 503 | await initCharset(); 504 | tileset = await loadImg(TILESET); 505 | // speak = await initSpeech(); 506 | 507 | toggleLoop(true); 508 | }; 509 | 510 | onresize = onrotate = function() { 511 | // scale canvas to fit screen while maintaining aspect ratio 512 | scaleToFit = Math.min(innerWidth / BUFFER.width, innerHeight / BUFFER.height); 513 | c.width = BUFFER.width * scaleToFit; 514 | c.height = BUFFER.height * scaleToFit; 515 | 516 | // disable smoothing on image scaling 517 | CTX.imageSmoothingEnabled = MAP_CTX.imageSmoothingEnabled = BUFFER_CTX.imageSmoothingEnabled = false; 518 | 519 | // fix key events not received on itch.io when game loads in full screen 520 | window.focus(); 521 | }; 522 | 523 | // UTILS 524 | 525 | document.onvisibilitychange = function(e) { 526 | // pause loop and game timer when switching tabs 527 | toggleLoop(!e.target.hidden); 528 | }; 529 | 530 | addEventListener('keydown', e => { 531 | if (!e.repeat && screen === GAME_SCREEN && e.code === 'KeyP') { 532 | // Pause game as soon as key is pressed 533 | toggleLoop(!running); 534 | } 535 | }) 536 | 537 | --------------------------------------------------------------------------------