├── .gitignore ├── src ├── dimensions.js ├── create.js ├── sleep.js ├── counter.js ├── goal.js ├── bar.js ├── title.js ├── controls.js ├── keys.js ├── guy.js ├── spikes.js ├── body.js ├── levels.js ├── tinymusic.js ├── sound.js └── editor.js ├── refs ├── onoff.otf ├── onoff.sketch ├── onoff_theme.m4a └── onoff_font.sketch ├── editor.js ├── postcss.config.js ├── bin └── build ├── package.json ├── LICENSE ├── editor.html ├── styles.css ├── index.js ├── README.md └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | onoff 2 | onoff.zip 3 | -------------------------------------------------------------------------------- /src/dimensions.js: -------------------------------------------------------------------------------- 1 | export const WIDTH = 768 2 | export const HEIGHT = 480 3 | -------------------------------------------------------------------------------- /refs/onoff.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/starzonmyarmz/js13k-2018/HEAD/refs/onoff.otf -------------------------------------------------------------------------------- /refs/onoff.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/starzonmyarmz/js13k-2018/HEAD/refs/onoff.sketch -------------------------------------------------------------------------------- /refs/onoff_theme.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/starzonmyarmz/js13k-2018/HEAD/refs/onoff_theme.m4a -------------------------------------------------------------------------------- /refs/onoff_font.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/starzonmyarmz/js13k-2018/HEAD/refs/onoff_font.sketch -------------------------------------------------------------------------------- /src/create.js: -------------------------------------------------------------------------------- 1 | export default (name) => ( 2 | document.createElementNS('http://www.w3.org/2000/svg', name) 3 | ) 4 | -------------------------------------------------------------------------------- /editor.js: -------------------------------------------------------------------------------- 1 | import levels from './src/levels.js' 2 | import Editor from './src/editor.js' 3 | 4 | new Editor(levels) 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('cssnano')({ 4 | preset: 'default', 5 | }), 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /src/sleep.js: -------------------------------------------------------------------------------- 1 | export default (delay) => new Promise((resolve, reject) => { 2 | let start = performance.now() 3 | requestAnimationFrame(function check (now) { 4 | if (now >= start + delay) return resolve() 5 | requestAnimationFrame(check) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | mkdir -p onoff 6 | postcss styles.css > onoff/styles.css 7 | html-minifier \ 8 | --collapse-whitespace \ 9 | --remove-comments \ 10 | --remove-attribute-quotes \ 11 | --remove-optional-tags \ 12 | --remove-tag-whitespace \ 13 | index.html > onoff/index.html 14 | rollup -f es index.js | uglifyjs -cm --toplevel > onoff/index.js 15 | zip onoff.zip onoff/* 16 | rm onoff/* 17 | unzip onoff.zip 18 | ls -lh onoff.zip 19 | -------------------------------------------------------------------------------- /src/counter.js: -------------------------------------------------------------------------------- 1 | import Body from './body.js' 2 | import create from './create.js' 3 | 4 | export default class Counter extends Body { 5 | constructor (element) { 6 | super(element) 7 | this.value = 0 8 | } 9 | 10 | get value () { 11 | return this._value 12 | } 13 | 14 | set value (value) { 15 | this._value = value || 0 16 | 17 | this.element.innerHTML = '' 18 | let index = 0 19 | for (let c of this.value.toString()) { 20 | const number = new Body(create('rect')) 21 | number.element.setAttribute('fill', `url(#n${c})`) 22 | number.width = 10 23 | number.height = 16 24 | number.x = 12 * index++ 25 | this.append(number) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/goal.js: -------------------------------------------------------------------------------- 1 | import Body from './body.js' 2 | import create from './create.js' 3 | 4 | export default class Goal extends Body { 5 | constructor (x, y) { 6 | super(create('svg')) 7 | this.element.innerHTML = ` 8 | 9 | 10 | ` 11 | this.width = 22 12 | this.height = 20 13 | this.load(x, y) 14 | } 15 | 16 | load (x, y) { 17 | this.x = x 18 | this.y = y 19 | } 20 | 21 | toJSON () { 22 | return [Math.round(this.x), Math.round(this.y)] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/bar.js: -------------------------------------------------------------------------------- 1 | import Body from './body.js' 2 | import create from './create.js' 3 | 4 | export default class Bar extends Body { 5 | constructor (x, y, width, height, on) { 6 | super(create('rect')) 7 | this.width = width 8 | this.height = height 9 | this.x = x 10 | this.y = y 11 | this.on = on 12 | } 13 | 14 | get on () { 15 | return !!this._on 16 | } 17 | 18 | set on (value) { 19 | this._on = !!value 20 | this.element.classList.toggle('light', this.on) 21 | this.element.classList.toggle('dark', !this.on) 22 | } 23 | 24 | toJSON () { 25 | return [ 26 | Math.round(this.x), 27 | Math.round(this.y), 28 | Math.round(this.width), 29 | Math.round(this.height), 30 | Number(this.on) 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js13k-2018", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "./bin/build", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "server": "http-server -op 1313 ." 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/starzonmyarmz/js13k-2018.git" 14 | }, 15 | "author": "Brad Dunbar (http://braddunbar.net/)", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/starzonmyarmz/js13k-2018/issues" 19 | }, 20 | "homepage": "https://github.com/starzonmyarmz/js13k-2018#readme", 21 | "dependencies": { 22 | "cssnano": "4.1.0", 23 | "html-minifier": "3.5.20", 24 | "http-server": "0.11.1", 25 | "postcss-cli": "6.0.0", 26 | "rollup": "0.65.0", 27 | "uglify-es": "3.3.9" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Daniel Marino and Brad Dunbar 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/title.js: -------------------------------------------------------------------------------- 1 | import Body from './body.js' 2 | 3 | const START = 0 4 | const CONTROLS = 1 5 | const EDITOR = 2 6 | const ITEMS = [START, CONTROLS, EDITOR] 7 | 8 | export default class Title extends Body { 9 | constructor (game) { 10 | super(document.getElementById('title')) 11 | this.game = game 12 | this.items = [].slice.call(this.element.querySelectorAll('.menu .item')) 13 | this.selected = START 14 | } 15 | 16 | keydown ({key}) { 17 | switch (key) { 18 | case 'ArrowUp': 19 | this.selected -= 1 20 | break 21 | case 'ArrowDown': 22 | this.selected += 1 23 | break 24 | case 'Enter': 25 | this.choose() 26 | break 27 | } 28 | } 29 | 30 | choose () { 31 | switch (this.selected) { 32 | case START: 33 | this.game.scene.index = 0 34 | this.game.scene.paused = false 35 | this.game.state = 'play' 36 | break 37 | case EDITOR: 38 | this.game.state = 'edit' 39 | break 40 | case CONTROLS: 41 | this.game.state = 'controls' 42 | break 43 | } 44 | } 45 | 46 | get selected () { 47 | return this._selected 48 | } 49 | 50 | set selected (value) { 51 | this._selected = Math.min(ITEMS.length - 1, Math.max(0, value || 0)) 52 | 53 | this.items.forEach((item, index) => { 54 | item.classList.toggle('selected', index === this.selected) 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/controls.js: -------------------------------------------------------------------------------- 1 | import Body from './body.js' 2 | import {DOWN, PRESSED} from './keys.js' 3 | 4 | class Key extends Body { 5 | constructor (id, pressed) { 6 | super(document.getElementById(id)) 7 | this.pressed = pressed 8 | } 9 | 10 | tick () { 11 | this.element.classList.toggle('dark', !this.pressed()) 12 | this.element.classList.toggle('light', this.pressed()) 13 | } 14 | } 15 | 16 | export default class Controls extends Body { 17 | constructor (game) { 18 | super(document.getElementById('controls')) 19 | this.game = game 20 | this.keys = [ 21 | new Key('key-w', () => DOWN.has('w')), 22 | new Key('key-a', () => DOWN.has('a')), 23 | new Key('key-d', () => DOWN.has('d')), 24 | new Key('key-space', () => DOWN.has(' ')), 25 | new Key('button-toggle', () => PRESSED.has(1)), 26 | new Key('button-jump', () => PRESSED.has(0)), 27 | new Key('button-left', () => PRESSED.has(14)), 28 | new Key('button-right', () => PRESSED.has(15)), 29 | ] 30 | } 31 | 32 | keydown ({key}) { 33 | switch (key) { 34 | case 'Enter': 35 | this.game.state = 'title' 36 | break 37 | case 'ArrowUp': 38 | case 'ArrowDown': 39 | this.element.querySelector('.menu .item').classList.add('selected') 40 | break 41 | } 42 | } 43 | 44 | tick () { 45 | if (this.hidden) return 46 | for (const key of this.keys) key.tick() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/keys.js: -------------------------------------------------------------------------------- 1 | export const DOWN = new Set 2 | export const PRESSED = new Set 3 | 4 | const NO_DEFAULT = new Set([ 5 | 'w', 6 | 'a', 7 | 's', 8 | 'd', 9 | ' ', 10 | 'ArrowUp', 11 | 'ArrowDown', 12 | 'ArrowLeft', 13 | 'ArrowRight' 14 | ]) 15 | 16 | export const upKey = () => ( 17 | DOWN.has('w') || DOWN.has('ArrowUp') || PRESSED.has(0) || PRESSED.has(12) 18 | ) 19 | 20 | export const leftKey = () => ( 21 | DOWN.has('a') || DOWN.has('ArrowLeft') || PRESSED.has(14) 22 | ) 23 | 24 | export const rightKey = () => ( 25 | DOWN.has('d') || DOWN.has('ArrowRight') || PRESSED.has(15) 26 | ) 27 | 28 | document.addEventListener('keydown', (event) => { 29 | DOWN.add(event.key) 30 | if (NO_DEFAULT.has(event.key)) event.preventDefault() 31 | }) 32 | 33 | document.addEventListener('keyup', ({key}) => { 34 | DOWN.delete(key) 35 | }) 36 | 37 | const HANDLERS = new Map 38 | export const onPress = (index, f) => { 39 | if (!HANDLERS.has(index)) HANDLERS.set(index, []) 40 | HANDLERS.get(index).push(f) 41 | } 42 | 43 | requestAnimationFrame(function tick (time) { 44 | const pad = navigator.getGamepads()[0] 45 | if (!pad) { 46 | PRESSED.clear() 47 | return 48 | } 49 | pad.buttons.forEach((button, index) => { 50 | if (button.pressed) { 51 | if (!PRESSED.has(index)) { 52 | const handlers = HANDLERS.get(index) 53 | if (handlers) handlers.forEach((f) => f()) 54 | } 55 | PRESSED.add(index) 56 | } else { 57 | PRESSED.delete(index) 58 | } 59 | }) 60 | requestAnimationFrame(tick) 61 | }) 62 | -------------------------------------------------------------------------------- /src/guy.js: -------------------------------------------------------------------------------- 1 | import {leftKey, rightKey} from './keys.js' 2 | import Body from './body.js' 3 | import create from './create.js' 4 | 5 | export default class Guy extends Body { 6 | constructor (x, y) { 7 | super(create('svg')) 8 | this.element.innerHTML = ` 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ` 22 | this.load(x, y) 23 | this.height = 48 24 | this.width = 26 25 | this.speed = 360 26 | this.vx = 0 27 | this.vy = 0 28 | } 29 | 30 | tick (scale) { 31 | if (leftKey() && !rightKey()) { 32 | this.vx = -scale(this.speed) 33 | this.faceLeft = true 34 | } else if (rightKey() && !leftKey()) { 35 | this.vx = scale(this.speed) 36 | this.faceLeft = false 37 | } else { 38 | this.vx = 0 39 | } 40 | 41 | this.walking = leftKey() || rightKey() 42 | } 43 | 44 | get faceLeft () { 45 | return !!this._faceLeft 46 | } 47 | 48 | set faceLeft (value) { 49 | this._faceLeft = !!value 50 | this.element.classList.toggle('left', this.faceLeft) 51 | } 52 | 53 | get walking () { 54 | return !!this._walking 55 | } 56 | 57 | set walking (value) { 58 | this._walking = !!value 59 | this.element.classList.toggle('walk', this.walking) 60 | } 61 | 62 | load (x, y) { 63 | this.x = x 64 | this.y = y 65 | } 66 | 67 | toJSON () { 68 | return [Math.round(this.x), Math.round(this.y)] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |

Instructions

32 | 43 | 44 | 45 |
46 | 47 | -------------------------------------------------------------------------------- /src/spikes.js: -------------------------------------------------------------------------------- 1 | import Body from './body.js' 2 | import create from './create.js' 3 | 4 | export default class Spikes extends Body { 5 | constructor (x, y, width, height, on, direction) { 6 | super(create('svg')) 7 | this.rect = create('rect') 8 | this.rect.setAttribute('x', '0') 9 | this.rect.setAttribute('y', '0') 10 | this.rect.setAttribute('width', '100%') 11 | this.rect.setAttribute('height', '100%') 12 | this.element.appendChild(this.rect) 13 | this.width = width 14 | this.height = height 15 | this.x = x 16 | this.y = y 17 | this.on = on 18 | this.direction = direction 19 | } 20 | 21 | get isUp () { 22 | return this.direction === 'up' 23 | } 24 | 25 | get isDown () { 26 | return this.direction === 'down' 27 | } 28 | 29 | get isLeft () { 30 | return this.direction === 'left' 31 | } 32 | 33 | get isRight () { 34 | return this.direction === 'right' 35 | } 36 | 37 | get width () { 38 | return super.width 39 | } 40 | 41 | set width (value) { 42 | super.width = value 43 | if (this.isUp || this.isDown) { 44 | this.element.setAttribute('width', Math.round(this.width / 16) * 16) 45 | } 46 | } 47 | 48 | get height () { 49 | return super.height 50 | } 51 | 52 | set height (value) { 53 | super.height = value 54 | if (this.isLeft || this.isRight) { 55 | this.element.setAttribute('height', Math.round(this.height / 16) * 16) 56 | } 57 | } 58 | 59 | get on () { 60 | return !!this._on 61 | } 62 | 63 | set on (value) { 64 | this._on = !!value 65 | this.element.classList.toggle('light', this.on) 66 | this.element.classList.toggle('dark', !this.on) 67 | } 68 | 69 | get direction () { 70 | return this._direction 71 | } 72 | 73 | set direction (value) { 74 | this._direction = value 75 | this.rect.setAttribute('fill', `url(#spike-${this.direction})`) 76 | } 77 | 78 | toJSON () { 79 | return [ 80 | Math.round(this.x), 81 | Math.round(this.y), 82 | this.isUp || this.isDown ? Math.round(this.width / 16) * 16 : this.width, 83 | this.isLeft || this.isRight ? Math.round(this.height / 16) * 16 : this.height, 84 | Number(this.on), 85 | this.direction 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/body.js: -------------------------------------------------------------------------------- 1 | export default class Body { 2 | constructor (element) { 3 | this.element = element 4 | this.bounds = {} 5 | } 6 | 7 | get hidden () { 8 | return this.element.hasAttribute('hidden') 9 | } 10 | 11 | set hidden (value) { 12 | if (value) { 13 | this.element.setAttribute('hidden', '') 14 | } else { 15 | this.element.removeAttribute('hidden') 16 | } 17 | } 18 | 19 | get x () { 20 | return this._x 21 | } 22 | 23 | set x (value) { 24 | this._x = value || 0 25 | this.element.setAttribute('x', Math.round(this.x)) 26 | } 27 | 28 | get y () { 29 | return this._y 30 | } 31 | 32 | set y (value) { 33 | this._y = value || 0 34 | this.element.setAttribute('y', Math.round(this.y)) 35 | } 36 | 37 | get width () { 38 | return this._width 39 | } 40 | 41 | set width (value) { 42 | this._width = Math.max(0, value || 0) 43 | this.element.setAttribute('width', Math.round(this.width)) 44 | } 45 | 46 | get height () { 47 | return this._height 48 | } 49 | 50 | set height (value) { 51 | this._height = Math.max(0, value || 0) 52 | this.element.setAttribute('height', Math.round(this.height)) 53 | } 54 | 55 | get top () { 56 | return this.y 57 | } 58 | 59 | get bottom () { 60 | return this.y + this.height 61 | } 62 | 63 | set bottom (value) { 64 | this.y = (value || 0) - this.height 65 | } 66 | 67 | get left () { 68 | return this.x 69 | } 70 | 71 | get right () { 72 | return this.x + this.width 73 | } 74 | 75 | set right (value) { 76 | this.x = (value || 0) - this.width 77 | } 78 | 79 | set bottom (value) { 80 | this.y = value - this.height 81 | } 82 | 83 | isLeftOf (other) { 84 | return this.right <= other.left 85 | } 86 | 87 | isRightOf (other) { 88 | return this.left >= other.right 89 | } 90 | 91 | isAbove (other) { 92 | return this.bottom <= other.top 93 | } 94 | 95 | isBelow (other) { 96 | return this.top >= other.bottom 97 | } 98 | 99 | overlaps (other) { 100 | return this.left < other.right && 101 | this.right > other.left && 102 | this.top < other.bottom && 103 | this.bottom > other.top 104 | } 105 | 106 | append ({element}) { 107 | this.element.appendChild(element) 108 | } 109 | 110 | remove () { 111 | this.element.remove() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent: #333; 3 | --secondary: #fff; 4 | } 5 | 6 | * { 7 | box-sizing: border-box; 8 | } 9 | 10 | html, body { 11 | height: 100%; 12 | } 13 | 14 | body { 15 | background: #e5e5e5; 16 | align-items: center; 17 | display: flex; 18 | line-height: 1.4; 19 | margin: 0; 20 | } 21 | 22 | svg { 23 | max-height: 100vh; 24 | width: 100%; 25 | overflow: visible; 26 | shape-rendering: crispEdges; 27 | vertical-align: middle; 28 | } 29 | 30 | 31 | 32 | /* Title */ 33 | 34 | .menu .text { 35 | opacity: 0.1; 36 | } 37 | 38 | .menu .selected .text { 39 | opacity: 1; 40 | } 41 | 42 | .menu .stars { 43 | display: none; 44 | shape-rendering: auto; 45 | } 46 | 47 | .menu .selected .stars { 48 | display: block; 49 | } 50 | 51 | 52 | /* HUD */ 53 | 54 | #hud { 55 | opacity: 0.1; 56 | } 57 | 58 | 59 | /* Guy */ 60 | 61 | #inner-guy { 62 | transform-origin: 13px; 63 | } 64 | 65 | .left #inner-guy { 66 | transform: scaleX(-1); 67 | } 68 | 69 | .walk #left_foot, 70 | .walk #right_foot { 71 | animation: walk 0.2s infinite; 72 | } 73 | 74 | .walk #right_foot { 75 | animation-delay: 0.1s; 76 | } 77 | 78 | .walk #head { 79 | animation: walk 0.4s infinite; 80 | position: relative; 81 | z-index: 5; 82 | } 83 | 84 | .on #guy .accent, 85 | .off #face { 86 | fill: var(--accent); 87 | } 88 | 89 | .on #face, 90 | .off #guy .accent { 91 | fill: var(--secondary); 92 | } 93 | 94 | 95 | 96 | /* Death Animation */ 97 | 98 | #death rect { 99 | display: none; 100 | } 101 | 102 | .dying #death rect { 103 | animation: death 0.8s infinite; 104 | display: initial; 105 | opacity: 0; 106 | } 107 | 108 | @keyframes death { 109 | 0% { 110 | opacity: 1; 111 | transform: rotate(45deg); 112 | } 113 | 50% { 114 | opacity: 0; 115 | transform: rotate(0); 116 | } 117 | } 118 | 119 | 120 | 121 | /* Goal */ 122 | 123 | #inner-goal { 124 | animation: pulse 2s infinite; 125 | shape-rendering: auto; 126 | transform-origin: 12px 12px; 127 | } 128 | 129 | .finish #inner-goal { 130 | animation: none; 131 | } 132 | 133 | #inner-goal-finish { 134 | transition: transform 0.5s; 135 | transform-origin: 12px 12px; 136 | } 137 | 138 | .finish #inner-goal-finish { 139 | transform: scale(150); 140 | } 141 | 142 | 143 | 144 | /* Swap Foreground/Background */ 145 | 146 | .on > svg { 147 | background: var(--secondary); 148 | color: var(--accent); 149 | fill: var(--accent); 150 | } 151 | 152 | .off > svg { 153 | background: var(--accent); 154 | color: var(--secondary); 155 | fill: var(--secondary); 156 | } 157 | 158 | .on .dark, .off .light { 159 | opacity: 0.1; 160 | } 161 | 162 | 163 | 164 | /* Animations */ 165 | 166 | @keyframes walk { 167 | 50% { 168 | transform: translateY(-2px); 169 | } 170 | } 171 | 172 | @keyframes pulse { 173 | 50% { 174 | transform: scale(0.7); 175 | } 176 | } 177 | 178 | 179 | 180 | /* Misc */ 181 | 182 | [hidden] { 183 | display: none !important; 184 | } 185 | 186 | 187 | 188 | /* Editor */ 189 | 190 | #dialog { 191 | background: #fff; 192 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 193 | font-family: 'helvetica neue', helvetica, arial, sans-serif; 194 | font-size: 16px; 195 | right: 10px; 196 | padding: 25px; 197 | position: fixed; 198 | top: 10px; 199 | width: 400px; 200 | } 201 | 202 | h3 { 203 | margin: 0 0 15px; 204 | } 205 | 206 | ul { 207 | list-style: none; 208 | margin: 0 0 20px; 209 | padding: 0; 210 | } 211 | 212 | li { 213 | margin-bottom: 10px; 214 | } 215 | 216 | kbd { 217 | border: 2px solid #444; 218 | border-radius: 2px; 219 | display: inline-block; 220 | font-weight: bold; 221 | line-height: 1; 222 | padding: 4px 6px; 223 | } 224 | 225 | span { 226 | margin-left: 10px; 227 | } 228 | -------------------------------------------------------------------------------- /src/levels.js: -------------------------------------------------------------------------------- 1 | const getRandomInt = function(min, max) { 2 | min = Math.ceil(min) 3 | max = Math.floor(max) 4 | return Math.floor(Math.random() * (max - min)) + min 5 | } 6 | 7 | export default [ 8 | // https://cl.ly/0e72dd890b93 9 | [[24,239],[724,244],[[0,288,330,192,1],[438,288,330,192,1]],[]], 10 | // https://cl.ly/80f69e0e3b74 11 | [[371,51],[724,404],[[0,100,768,16,1],[0,216,768,16,0],[0,332,768,16,1],[0,448,768,32,0]],[]], 12 | // https://cl.ly/a751117681a8 13 | [[24,239],[724,244],[[0,288,330,192,1],[438,288,330,192,0]],[]], 14 | // https://cl.ly/ffd71d417828 15 | [[23,263],[724,268],[[0,312,768,8,1],[380,0,8,312,1],[0,408,768,72,0]],[]], 16 | // https://cl.ly/7ce0a55e6c23 17 | [[116, 96], [628, 412], [ 18 | [64, 448, 128, 32, false], 19 | [320, 448, 128, 32, true], 20 | [576, 448, 128, 32, false] 21 | ], []], 22 | // https://cl.ly/e7a2d2ecc0e7 23 | [[24,399],[604,152],[[0,448,768,32,1],[128,320,512,8,1],[632,328,8,120,1],[128,192,512,8,1],[128,200,8,120,1],[640,384,128,8,0],[0,256,128,8,0]],[]], 24 | // https://cl.ly/c3c9ccaf76a3 25 | [[16,275],[566,248],[[0,0,768,64,1],[0,64,128,192,1],[0,324,248,92,1],[0,416,768,64,1],[192,132,180,96,1],[440,64,100,224,1],[620,112,72,245,1],[192,228,56,96,1],[300,288,320,69,1]],[]], 26 | // https://cl.ly/91292cb165a6 27 | [[16,367],[704,84],[[0,416,244,64,1],[524,128,244,352,1],[288,320,64,160,0],[416,224,64,256,0]],[]], 28 | // https://cl.ly/dd353a0a4faf 29 | [[104,175],[176,32],[[96,224,56,8,1],[96,232,56,8,0],[144,72,8,152,1],[152,72,8,168,0],[160,72,128,92,1],[160,164,256,92,1],[160,256,384,92,1],[544,256,8,92,0],[160,348,512,92,1],[160,440,512,8,0],[552,340,120,8,0]],[]], 30 | // https://cl.ly/752bc2a6a72f 31 | [[16,422],[724,416],[[0,472,48,8,1],[0,376,48,8,0],[0,280,48,8,1],[0,184,96,8,0],[384,456,384,24,1]],[]], 32 | // https://cl.ly/13b3b6c2966d 33 | [[16,239],[724,244],[[0,288,768,192,1],[336,0,96,288,1]],[]], 34 | // https://cl.ly/74d75c6b6df7 35 | [[16, 56], [724, 216], 36 | [0, 1, 2, 3, 4, 5, 6, 7].map((x) => [x * 96, getRandomInt(240, 300), getRandomInt(24, 72), getRandomInt(24, 180), getRandomInt(0, 2)]) 37 | , []], 38 | // https://cl.ly/129369ca9d9d 39 | [[48,383],[696,384],[[48,432,24,24,1],[156,348,24,24,0],[48,264,24,24,1],[156,180,24,24,0],[264,180,24,24,1],[372,180,24,24,0],[480,180,24,24,1],[588,180,24,24,0],[696,264,24,24,1],[588,348,24,24,0],[696,432,24,24,1]],[]], 40 | // https://cl.ly/de15c9f04a7d 41 | [[24, 8], [724, getRandomInt(128, 416)], 42 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23].map((x) => [x * 32, getRandomInt(64, 464), 8, 8, getRandomInt(0, 2)]) 43 | , []], 44 | // https://cl.ly/aef7878e8263 45 | [[372,391],[372,20],[[320,440,128,8,1],[320,344,128,8,0],[320,248,128,8,1],[320,152,128,8,0]],[[320,448,128,8,1,"down"],[320,352,128,8,0,"down"],[320,256,128,8,1,"down"],[320,160,128,8,0,"down"]]], 46 | // https://cl.ly/bfc474be92d1 47 | [[372,15],[372,418],[[320,64,128,16,1],[256,80,256,128,0],[320,208,128,16,1],[348,224,72,160,1],[348,384,8,96,1],[356,384,8,96,0],[412,384,8,96,1],[404,384,8,96,0]],[[312,64,8,16,1,"left"],[448,64,8,16,1,"right"],[312,208,8,16,1,"left"],[448,208,8,16,1,"right"]]], 48 | // https://cl.ly/38831ab3bb46 49 | [[24, 64], [724, getRandomInt(128, 416)], [[0, getRandomInt(128, 352), 768, 2, true]], 50 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((x) => 51 | [x * 48, getRandomInt(128, 352), 16, 8, getRandomInt(0, 2), 'up'] 52 | ) 53 | ], 54 | [[371, 20], [372, 372], [ 55 | [336, 320, 96, 8, true], 56 | [336, 416, 96, 8, true], 57 | [336, 328, 8, 88, true], 58 | [424, 328, 8, 88, true], 59 | ], [ 60 | [336, 312, 96, 8, true, 'up'] 61 | ]], 62 | // https://cl.ly/bb5826e004eb 63 | [[371, 20], [584, 404], [ 64 | [0, 152, 368, 8, true], 65 | [0, 248, 448, 8, true], 66 | [0, 344, 528, 8, true], 67 | ], [ 68 | [0, 144, 368, 8, true, 'up'], 69 | [0, 240, 448, 8, true, 'up'], 70 | [0, 336, 528, 8, true, 'up'], 71 | ]], 72 | // https://cl.ly/130ada902b79 73 | [[12, 312], [725, 307], [ 74 | [0, 152, 768, 72, true], 75 | [0, 362, 64, 8, true], 76 | [192, 362, 128, 8, true], 77 | [448, 362, 128, 8, true], 78 | [704, 362, 64, 8, true] 79 | ], [ 80 | [0, 224, 768, 8, true, 'down'], 81 | ]], 82 | // https://cl.ly/be19660c5789 83 | [[371, 20], [372, 404], [ 84 | [320, 384, 128, 8, true], 85 | [320, 272, 128, 8, false], 86 | [320, 168, 128, 8, true], 87 | ], [ 88 | [320, 376, 128, 8, true, 'up'], 89 | [320, 264, 128, 8, false, 'up'], 90 | [320, 160, 128, 8, true, 'up'], 91 | ]], 92 | // https://cl.ly/52c9d3f5f7e6 93 | [[63,263],[660,272],[[48,312,96,96,1],[144,124,48,284,1],[192,124,96,96,1],[192,312,96,96,0],[288,124,48,284,1],[336,312,96,96,1],[432,124,48,284,1],[480,124,96,96,1],[480,312,96,96,0],[576,124,48,284,1],[624,312,96,96,1]],[[232,304,16,8,0,"up"],[520,304,16,8,0,"up"]]], 94 | // https://cl.ly/f83f4b0bb02d 95 | [[148, 254], [600, 266], [ 96 | [94, 176, 8, 128, true], 97 | [222, 176, 8, 128, true], 98 | [94, 168, 136, 8, true], 99 | [94, 304, 136, 8, true], 100 | [318, 176, 8, 128, true], 101 | [446, 176, 8, 128, true], 102 | [318, 168, 136, 8, true], 103 | [318, 304, 136, 8, true], 104 | [542, 176, 8, 128, true], 105 | [670, 176, 8, 128, true], 106 | [542, 168, 136, 8, true], 107 | [542, 304, 136, 8, true] 108 | ], []], 109 | // https://cl.ly/0d180032f589 110 | [[400, 20], [372, 432], [], [ 111 | [0, 96, 384, 8, true, 'up'], 112 | [0, 104, 384, 8, false, 'down'], 113 | [400, 240, 368, 8, true, 'up'], 114 | [400, 248, 368, 8, false, 'down'], 115 | [0, 384, 384, 8, true, 'up'], 116 | [0, 392, 384, 8, false, 'down'], 117 | ]], 118 | // https://cl.ly/d11a48fe9b24 119 | [[368,14],[269,419],[],[[328,32,8,80,1,"right"],[320,32,8,80,0,"left"],[424,32,8,80,1,"left"],[432,32,8,80,0,"right"],[280,175,8,80,1,"right"],[272,174,8,80,0,"left"],[376,175,8,80,1,"left"],[384,175,8,80,0,"right"],[232,320,8,80,1,"right"],[224,319,8,80,0,"left"],[328,320,8,80,1,"left"],[336,319,8,80,0,"right"]]] 120 | ] 121 | -------------------------------------------------------------------------------- /src/tinymusic.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Private stuffz 3 | */ 4 | 5 | var enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb', 6 | middleC = 440 * Math.pow( Math.pow( 2, 1 / 12 ), -9 ), 7 | numeric = /^[0-9.]+$/, 8 | octaveOffset = 4, 9 | space = /\s+/, 10 | num = /(\d+)/, 11 | offsets = {}; 12 | 13 | // populate the offset lookup (note distance from C, in semitones) 14 | enharmonics.split('|').forEach(function( val, i ) { 15 | val.split('-').forEach(function( note ) { 16 | offsets[ note ] = i; 17 | }); 18 | }); 19 | 20 | /* 21 | * Note class 22 | * 23 | * new Note ('A4 q') === 440Hz, quarter note 24 | * new Note ('- e') === 0Hz (basically a rest), eigth note 25 | * new Note ('A4 es') === 440Hz, dotted eighth note (eighth + sixteenth) 26 | * new Note ('A4 0.0125') === 440Hz, 32nd note (or any arbitrary 27 | * divisor/multiple of 1 beat) 28 | * 29 | */ 30 | 31 | // create a new Note instance from a string 32 | export const Note = function Note( str ) { 33 | var couple = str.split( space ); 34 | // frequency, in Hz 35 | this.frequency = Note.getFrequency( couple[ 0 ] ) || 0; 36 | // duration, as a ratio of 1 beat (quarter note = 1, half note = 0.5, etc.) 37 | this.duration = Note.getDuration( couple[ 1 ] ) || 0; 38 | } 39 | 40 | // convert a note name (e.g. 'A4') to a frequency (e.g. 440.00) 41 | Note.getFrequency = function( name ) { 42 | var couple = name.split( num ), 43 | distance = offsets[ couple[ 0 ] ], 44 | octaveDiff = ( couple[ 1 ] || octaveOffset ) - octaveOffset, 45 | freq = middleC * Math.pow( Math.pow( 2, 1 / 12 ), distance ); 46 | return freq * Math.pow( 2, octaveDiff ); 47 | }; 48 | 49 | // convert a duration string (e.g. 'q') to a number (e.g. 1) 50 | // also accepts numeric strings (e.g '0.125') 51 | // and compund durations (e.g. 'es' for dotted-eight or eighth plus sixteenth) 52 | Note.getDuration = function( symbol ) { 53 | return numeric.test( symbol ) ? parseFloat( symbol ) : 54 | symbol.toLowerCase().split('').reduce(function( prev, curr ) { 55 | return prev + ( curr === 'w' ? 4 : curr === 'h' ? 2 : 56 | curr === 'q' ? 1 : curr === 'e' ? 0.5 : 57 | curr === 's' ? 0.25 : 0 ); 58 | }, 0 ); 59 | }; 60 | 61 | /* 62 | * Sequence class 63 | */ 64 | 65 | // create a new Sequence 66 | export const Sequence = function Sequence( ac, tempo, arr ) { 67 | this.ac = ac || new AudioContext(); 68 | this.createFxNodes(); 69 | this.tempo = tempo || 120; 70 | this.loop = true; 71 | this.smoothing = 0; 72 | this.staccato = 0; 73 | this.notes = []; 74 | this.push.apply( this, arr || [] ); 75 | } 76 | 77 | // create gain and EQ nodes, then connect 'em 78 | Sequence.prototype.createFxNodes = function() { 79 | var eq = [ [ 'bass', 100 ], [ 'mid', 1000 ], [ 'treble', 2500 ] ], 80 | prev = this.gain = this.ac.createGain(); 81 | eq.forEach(function( config, filter ) { 82 | filter = this[ config[ 0 ] ] = this.ac.createBiquadFilter(); 83 | filter.type = 'peaking'; 84 | filter.frequency.value = config[ 1 ]; 85 | prev.connect( prev = filter ); 86 | }.bind( this )); 87 | prev.connect( this.ac.destination ); 88 | return this; 89 | }; 90 | 91 | // accepts Note instances or strings (e.g. 'A4 e') 92 | Sequence.prototype.push = function() { 93 | Array.prototype.forEach.call( arguments, function( note ) { 94 | this.notes.push( note instanceof Note ? note : new Note( note ) ); 95 | }.bind( this )); 96 | return this; 97 | }; 98 | 99 | // create a custom waveform as opposed to "sawtooth", "triangle", etc 100 | Sequence.prototype.createCustomWave = function( real, imag ) { 101 | // Allow user to specify only one array and dupe it for imag. 102 | if ( !imag ) { 103 | imag = real; 104 | } 105 | 106 | // Wave type must be custom to apply period wave. 107 | this.waveType = 'custom'; 108 | 109 | // Reset customWave 110 | this.customWave = [ new Float32Array( real ), new Float32Array( imag ) ]; 111 | }; 112 | 113 | // recreate the oscillator node (happens on every play) 114 | Sequence.prototype.createOscillator = function() { 115 | this.stop(); 116 | this.osc = this.ac.createOscillator(); 117 | 118 | // customWave should be an array of Float32Arrays. The more elements in 119 | // each Float32Array, the dirtier (saw-like) the wave is 120 | if ( this.customWave ) { 121 | this.osc.setPeriodicWave( 122 | this.ac.createPeriodicWave.apply( this.ac, this.customWave ) 123 | ); 124 | } else { 125 | this.osc.type = this.waveType || 'square'; 126 | } 127 | 128 | this.osc.connect( this.gain ); 129 | return this; 130 | }; 131 | 132 | // schedules this.notes[ index ] to play at the given time 133 | // returns an AudioContext timestamp of when the note will *end* 134 | Sequence.prototype.scheduleNote = function( index, when ) { 135 | var duration = 60 / this.tempo * this.notes[ index ].duration, 136 | cutoff = duration * ( 1 - ( this.staccato || 0 ) ); 137 | 138 | this.setFrequency( this.notes[ index ].frequency, when ); 139 | 140 | if ( this.smoothing && this.notes[ index ].frequency ) { 141 | this.slide( index, when, cutoff ); 142 | } 143 | 144 | this.setFrequency( 0, when + cutoff ); 145 | return when + duration; 146 | }; 147 | 148 | // get the next note 149 | Sequence.prototype.getNextNote = function( index ) { 150 | return this.notes[ index < this.notes.length - 1 ? index + 1 : 0 ]; 151 | }; 152 | 153 | // how long do we wait before beginning the slide? (in seconds) 154 | Sequence.prototype.getSlideStartDelay = function( duration ) { 155 | return duration - Math.min( duration, 60 / this.tempo * this.smoothing ); 156 | }; 157 | 158 | // slide the note at into the next note at the given time, 159 | // and apply staccato effect if needed 160 | Sequence.prototype.slide = function( index, when, cutoff ) { 161 | var next = this.getNextNote( index ), 162 | start = this.getSlideStartDelay( cutoff ); 163 | this.setFrequency( this.notes[ index ].frequency, when + start ); 164 | this.rampFrequency( next.frequency, when + cutoff ); 165 | return this; 166 | }; 167 | 168 | // set frequency at time 169 | Sequence.prototype.setFrequency = function( freq, when ) { 170 | this.osc.frequency.setValueAtTime( freq, when ); 171 | return this; 172 | }; 173 | 174 | // ramp to frequency at time 175 | Sequence.prototype.rampFrequency = function( freq, when ) { 176 | this.osc.frequency.linearRampToValueAtTime( freq, when ); 177 | return this; 178 | }; 179 | 180 | // run through all notes in the sequence and schedule them 181 | Sequence.prototype.play = function( when ) { 182 | when = typeof when === 'number' ? when : this.ac.currentTime; 183 | 184 | this.createOscillator(); 185 | this.osc.start( when ); 186 | 187 | this.notes.forEach(function( note, i ) { 188 | when = this.scheduleNote( i, when ); 189 | }.bind( this )); 190 | 191 | this.osc.stop( when ); 192 | this.osc.onended = this.loop ? this.play.bind( this, when ) : null; 193 | 194 | return this; 195 | }; 196 | 197 | // stop playback, null out the oscillator, cancel parameter automation 198 | Sequence.prototype.stop = function() { 199 | if ( this.osc ) { 200 | this.osc.onended = null; 201 | this.osc.disconnect(); 202 | this.osc = null; 203 | } 204 | return this; 205 | }; 206 | -------------------------------------------------------------------------------- /src/sound.js: -------------------------------------------------------------------------------- 1 | import {Note, Sequence} from './tinymusic.js' 2 | const ac = new AudioContext() 3 | 4 | export const MUSIC_LOW_A = new Sequence(ac, 100, [ 5 | 'B2 q', 6 | '- q', 7 | 'Db3 q', 8 | '- q', 9 | 'D3 q', 10 | '- q', 11 | 'Gb3 q', 12 | 'Bb3 q', 13 | 'B2 q', 14 | '- q', 15 | 'Db3 q', 16 | '- q', 17 | 'D3 q', 18 | '- q', 19 | 'Gb3 q', 20 | 'B2 q', 21 | 'B2 q', 22 | '- q', 23 | 'Db3 q', 24 | '- q', 25 | 'D3 q', 26 | '- q', 27 | 'Gb3 q', 28 | 'Bb3 q', 29 | 'B2 q', 30 | '- q', 31 | 'Db3 q', 32 | '- q', 33 | 'D3 q', 34 | '- q', 35 | 'Gb3 q', 36 | 'B2 q', 37 | '- 32' 38 | ]) 39 | 40 | export const MUSIC_MID_A = new Sequence(ac, 100, [ 41 | '- w', 42 | '- h', 43 | 'D3 q', 44 | 'Db3 q', 45 | '- w', 46 | '- h', 47 | 'D3 q', 48 | 'Gb3 q', 49 | '- w', 50 | '- h', 51 | 'D3 q', 52 | 'Db3 q', 53 | '- w', 54 | '- h', 55 | 'D3 q', 56 | 'Gb3 q', 57 | '- 32' 58 | ]) 59 | 60 | export const MUSIC_HIGH_A = new Sequence(ac, 100, [ 61 | 'B4 e', 62 | '- e', 63 | 'Bb4 e', 64 | '- e', 65 | 'A4 s', 66 | 'A4 s', 67 | 'Ab4 e', 68 | 'G4 e', 69 | '- e', 70 | 'Gb4 s', 71 | 'B4 s', 72 | 'Gb4 e', 73 | 'D4 e', 74 | 'B3 e', 75 | 'D4 e', 76 | '- e', 77 | 'Db4 e', 78 | '- e', 79 | 'B4 e', 80 | '- e', 81 | 'Bb4 e', 82 | '- e', 83 | 'A4 s', 84 | 'A4 s', 85 | 'Ab4 e', 86 | 'G4 e', 87 | '- e', 88 | 'Gb4 s', 89 | 'B4 s', 90 | 'Gb4 e', 91 | 'D4 e', 92 | 'B3 e', 93 | 'D4 e', 94 | 'Db4 e', 95 | 'B3 e', 96 | '- e', 97 | 'B4 e', 98 | '- e', 99 | 'Bb4 e', 100 | '- e', 101 | 'A4 s', 102 | 'A4 s', 103 | 'Ab4 e', 104 | 'G4 e', 105 | '- e', 106 | 'Gb4 s', 107 | 'B4 s', 108 | 'Gb4 e', 109 | 'D4 e', 110 | 'B3 e', 111 | 'D4 e', 112 | '- e', 113 | 'Db4 e', 114 | '- e', 115 | 'B4 e', 116 | '- e', 117 | 'Bb4 e', 118 | '- e', 119 | 'A4 s', 120 | 'A4 s', 121 | 'Ab4 e', 122 | 'G4 e', 123 | '- e', 124 | 'Gb4 s', 125 | 'B4 s', 126 | 'Gb4 e', 127 | 'D4 e', 128 | 'B3 e', 129 | 'D4 e', 130 | 'Db4 e', 131 | 'B3 e', 132 | '- e', 133 | '- 32' 134 | ]) 135 | 136 | export const MUSIC_LOW_B = new Sequence(ac, 100, [ 137 | '- 32', 138 | 'G3 e', 139 | 'E3 e', 140 | 'D3 e', 141 | 'C3 e', 142 | 'A2 e', 143 | 'G2 e', 144 | 'C2 e', 145 | 'Bb2 e', 146 | 'B2 e', 147 | 'Db3 e', 148 | 'D3 e', 149 | 'E3 e', 150 | 'Gb3 e', 151 | 'G3 e', 152 | 'Gb3 e', 153 | 'Bb2 e', 154 | 'G3 e', 155 | 'E3 e', 156 | 'D3 e', 157 | 'C3 e', 158 | 'A2 e', 159 | 'G2 e', 160 | 'C2 e', 161 | 'Bb2 e', 162 | 'B2 e', 163 | 'Db3 e', 164 | 'D3 e', 165 | 'E3 e', 166 | 'D3 e', 167 | 'Db3 e', 168 | 'B2 e', 169 | '- e', 170 | 'G3 e', 171 | 'E3 e', 172 | 'D3 e', 173 | 'C3 e', 174 | 'A2 e', 175 | 'G2 e', 176 | 'C2 e', 177 | 'Bb2 e', 178 | 'B2 e', 179 | 'Db3 e', 180 | 'D3 e', 181 | 'E3 e', 182 | 'Gb3 e', 183 | 'G3 e', 184 | 'Gb3 e', 185 | 'Bb2 e', 186 | 'G3 e', 187 | 'E3 e', 188 | 'D3 e', 189 | 'C3 e', 190 | 'A2 e', 191 | 'G2 e', 192 | 'C2 e', 193 | 'Bb2 e', 194 | 'B2 e', 195 | 'Db3 e', 196 | 'D3 e', 197 | 'E3 e', 198 | 'D3 e', 199 | 'Db3 e', 200 | 'B2 e', 201 | '- e' 202 | ]) 203 | 204 | export const MUSIC_MID_B = new Sequence(ac, 100, [ 205 | '- 32', 206 | 'G4 w', 207 | 'Gb4 w', 208 | 'G4 w', 209 | 'Gb4 w', 210 | 'G4 w', 211 | 'Gb4 w', 212 | 'G4 w', 213 | 'Gb4 w' 214 | ]) 215 | 216 | export const MUSIC_HIGH_B = new Sequence(ac, 100, [ 217 | '- 32', 218 | 'C4 w', 219 | 'D4 w', 220 | 'C4 w', 221 | 'D4 w', 222 | 'C4 w', 223 | 'D4 w', 224 | 'C4 w', 225 | 'D4 w' 226 | ]) 227 | 228 | export const MUSIC_WINNING_LOW = new Sequence(ac, 200, [ 229 | 'C3 q', 230 | '- q', 231 | 'G3 q', 232 | '- q', 233 | 'C3 q', 234 | '- q', 235 | 'G3 q', 236 | 'G2 q', 237 | 'C3 q', 238 | '- q', 239 | 'G3 q', 240 | '- q', 241 | 'B2 q', 242 | '- q', 243 | 'B2 q', 244 | 'A2 q', 245 | 'G2 q', 246 | '- h', 247 | '- q', 248 | 'E3 q', 249 | '- h', 250 | '- q', 251 | 'G2 q', 252 | '- w', 253 | '- h', 254 | 'B2 q' 255 | ]) 256 | 257 | export const MUSIC_WINNING_HIGH = new Sequence(ac, 200, [ 258 | 'G4 e', 259 | 'Gb4 e', 260 | 'G4 e', 261 | 'Gb4 e', 262 | 'G4 q', 263 | 'A4 q', 264 | 'G4 h', 265 | 'C4 q', 266 | 'E4 q', 267 | 'G4 e', 268 | 'Gb4 e', 269 | 'G4 e', 270 | 'Gb4 e', 271 | 'G4 q', 272 | 'E4 q', 273 | 'F4 h', 274 | 'D4 q', 275 | 'E4 q', 276 | 'F4 e', 277 | 'E4 e', 278 | 'F4 e', 279 | 'E4 e', 280 | 'F4 q', 281 | 'G4 q', 282 | 'E4 e', 283 | 'D4 e', 284 | 'E4 e', 285 | 'D4 e', 286 | 'E4 q', 287 | 'F4 q', 288 | 'G3 e', 289 | 'A3 e', 290 | 'B3 e', 291 | 'C4 e', 292 | 'D4 e', 293 | 'E4 e', 294 | 'F4 e', 295 | 'E4 e', 296 | 'D4 e', 297 | 'C4 e', 298 | 'B3 e', 299 | 'A3 e', 300 | 'G3 q', 301 | 'E4 q' 302 | ]) 303 | 304 | MUSIC_WINNING_LOW.staccato = 0.3 305 | MUSIC_WINNING_HIGH.staccato = 0.5 306 | 307 | MUSIC_WINNING_LOW.waveType = 'sine' 308 | 309 | MUSIC_WINNING_LOW.gain.gain.value = 0.7 310 | MUSIC_WINNING_HIGH.gain.gain.value = 0.3 311 | 312 | 313 | MUSIC_LOW_A.staccato = 0.5 314 | MUSIC_LOW_B.staccato = 0.3 315 | MUSIC_MID_A.staccato = 0.5 316 | MUSIC_HIGH_A.staccato = 0.5 317 | 318 | MUSIC_LOW_A.waveType = 'sine' 319 | MUSIC_LOW_B.waveType = 'sine' 320 | MUSIC_MID_A.waveType = 'sine' 321 | 322 | MUSIC_LOW_A.gain.gain.value = 0.4 323 | MUSIC_LOW_B.gain.gain.value = 0.6 324 | MUSIC_MID_A.gain.gain.value = 0.4 325 | MUSIC_HIGH_A.gain.gain.value = 0.4 326 | 327 | // Fade the Mid/High B in and out 328 | 329 | let fade = 1 330 | let direction = 'up' 331 | 332 | setInterval(function() { 333 | if (direction === 'up') { 334 | fade += 1 335 | if (fade > 9) { 336 | direction = 'down' 337 | fade -= 1 338 | } 339 | } 340 | 341 | if (direction === 'down') { 342 | fade -= 1 343 | if (fade < 1) { 344 | direction = 'up' 345 | fade += 2 346 | } 347 | } 348 | 349 | MUSIC_MID_B.gain.gain.value = fade * 0.01 350 | MUSIC_HIGH_B.gain.gain.value = fade * 0.01 351 | }, 300) 352 | 353 | export const playMusic = () => { 354 | MUSIC_LOW_A.play() 355 | MUSIC_MID_A.play() 356 | MUSIC_HIGH_A.play() 357 | MUSIC_LOW_B.play() 358 | MUSIC_MID_B.play() 359 | MUSIC_HIGH_B.play() 360 | MUSIC_WINNING_LOW.stop() 361 | MUSIC_WINNING_HIGH.stop() 362 | } 363 | 364 | export const playWin = () => { 365 | MUSIC_LOW_A.stop() 366 | MUSIC_MID_A.stop() 367 | MUSIC_HIGH_A.stop() 368 | MUSIC_LOW_B.stop() 369 | MUSIC_MID_B.stop() 370 | MUSIC_HIGH_B.stop() 371 | MUSIC_WINNING_LOW.play() 372 | MUSIC_WINNING_HIGH.play() 373 | } 374 | 375 | playMusic() 376 | 377 | // Sound Effects 378 | 379 | export const JUMP_FX = new Sequence(ac, 320, [ 380 | 'Bb3 e', 381 | 'G5 e', 382 | 'Bb4 e' 383 | ]) 384 | 385 | export const ON_FX = new Sequence(ac, 400, [ 386 | 'Bb6 e', 387 | 'D6 e' 388 | ]) 389 | 390 | export const OFF_FX = new Sequence(ac, 400, [ 391 | 'D6 e', 392 | 'Bb6 e' 393 | ]) 394 | 395 | export const GOAL_FX = new Sequence(ac, 280, [ 396 | 'C4 s', 397 | 'G4 s', 398 | 'C5 h' 399 | ]) 400 | 401 | export const DEATH_FX = new Sequence(ac, 280, [ 402 | 'Bb3 e', 403 | 'Bb2 q' 404 | ]) 405 | 406 | JUMP_FX.loop = false 407 | GOAL_FX.loop = false 408 | DEATH_FX.loop = false 409 | ON_FX.loop = false 410 | OFF_FX.loop = false 411 | 412 | JUMP_FX.smoothing = 1 413 | DEATH_FX.smoothing = 0.5 414 | 415 | GOAL_FX.staccato = 0.2 416 | ON_FX.staccato = 0.5 417 | OFF_FX.staccato = 0.5 418 | 419 | DEATH_FX.waveType = 'sawtooth' 420 | 421 | DEATH_FX.bass.gain.value = 10 422 | 423 | JUMP_FX.gain.gain.value = 0.3 424 | GOAL_FX.gain.gain.value = 0.6 425 | DEATH_FX.gain.gain.value = 0.4 426 | ON_FX.gain.gain.value = 0.3 427 | OFF_FX.gain.gain.value = 0.3 428 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {onPress, upKey} from './src/keys.js' 2 | import levels from './src/levels.js' 3 | import sleep from './src/sleep.js' 4 | import Body from './src/body.js' 5 | import Goal from './src/goal.js' 6 | import Guy from './src/guy.js' 7 | import Bar from './src/bar.js' 8 | import Title from './src/title.js' 9 | import {GOAL_FX, JUMP_FX, DEATH_FX, ON_FX, OFF_FX, playWin, playMusic} from './src/sound.js' 10 | import create from './src/create.js' 11 | import {WIDTH, HEIGHT} from './src/dimensions.js' 12 | import Counter from './src/counter.js' 13 | import Spikes from './src/spikes.js' 14 | import Controls from './src/controls.js' 15 | import Editor from './src/editor.js' 16 | 17 | class Scene extends Body { 18 | constructor (game, levels) { 19 | super(document.getElementById('game')) 20 | this.deaths = new Counter(document.getElementById('death-counter')) 21 | this.stars = new Counter(document.getElementById('level-counter')) 22 | this.congrats = new Body(document.getElementById('congrats')) 23 | this.esc = new Body(document.getElementById('esc')) 24 | this.game = game 25 | this.levels = levels 26 | this.bars = [] 27 | this.spikes = [] 28 | this.paused = false 29 | this.guy = new Guy 30 | this.append(this.guy) 31 | this.goal = new Goal 32 | this.append(this.goal) 33 | this.index = 0 34 | } 35 | 36 | get fromURL () { 37 | return !!this._fromURL 38 | } 39 | 40 | set fromURL (value) { 41 | this._fromURL = !!value 42 | this.esc.hidden = !this.fromURL 43 | } 44 | 45 | keydown ({key}) { 46 | switch (key) { 47 | case 'Enter': 48 | if (this.finished) { 49 | this.fromURL = false 50 | this.levels = levels 51 | this.game.state = 'title' 52 | playMusic() 53 | } 54 | break 55 | case 'Escape': 56 | if (this.fromURL) { 57 | this.fromURL = false 58 | this.levels = levels 59 | this.game.state = 'title' 60 | playMusic() 61 | } 62 | break 63 | } 64 | } 65 | 66 | get on () { 67 | return this._on 68 | } 69 | 70 | set on (value) { 71 | this._on = value 72 | document.body.classList.toggle('on', value) 73 | document.body.classList.toggle('off', !value) 74 | } 75 | 76 | get index () { 77 | return this._index 78 | } 79 | 80 | set index (value) { 81 | this._index = Math.min(this.levels.length, Math.max(value || 0)) 82 | 83 | this.on = true 84 | this.stars.value = this.index 85 | while (this.bars.length) this.bars.pop().remove() 86 | while (this.spikes.length) this.spikes.pop().remove() 87 | 88 | if (this.finished) { 89 | this.guy.hidden = true 90 | this.congrats.hidden = false 91 | playWin() 92 | return 93 | } 94 | 95 | const [guy, goal, bars, spikes] = this.level 96 | this.guy.load(...guy) 97 | this.guy.hidden = false 98 | this.goal.load(...goal) 99 | this.goal.hidden = false 100 | this.congrats.hidden = true 101 | 102 | for (const values of bars) { 103 | const bar = new Bar(...values) 104 | this.append(bar) 105 | this.bars.push(bar) 106 | } 107 | 108 | for (const values of spikes) { 109 | const spike = new Spikes(...values) 110 | this.append(spike) 111 | this.spikes.push(spike) 112 | } 113 | } 114 | 115 | get level () { 116 | return this.levels[this.index] 117 | } 118 | 119 | get finished () { 120 | return this.index >= this.levels.length 121 | } 122 | 123 | async advance () { 124 | GOAL_FX.play() 125 | this.paused = true 126 | document.body.classList.add('finish') 127 | await sleep(1000) 128 | this.index += 1 129 | document.body.classList.remove('finish') 130 | await sleep(1000) 131 | if (this.finished) { 132 | this.goal.hidden = true 133 | } else { 134 | this.paused = false 135 | } 136 | } 137 | 138 | async death () { 139 | DEATH_FX.play() 140 | this.deaths.value += 1 141 | this.paused = true 142 | const death = document.getElementById('death') 143 | death.setAttribute('x', this.guy.x - 32 + this.guy.width / 2) 144 | death.setAttribute('y', this.guy.y - 32 + this.guy.height / 2) 145 | this.guy.element.setAttribute('hidden', true) 146 | document.body.classList.add('dying') 147 | await sleep(700) 148 | document.body.classList.remove('dying') 149 | this.reset() 150 | this.guy.element.removeAttribute('hidden') 151 | this.paused = false 152 | } 153 | 154 | reset () { 155 | this.guy.load(...this.level[0]) 156 | } 157 | 158 | lost () { 159 | return this.guy.bottom > HEIGHT || this.bars.some((bar) => 160 | bar.on === this.on && bar.overlaps(this.guy) 161 | ) || this.spikes.some((spike) => 162 | spike.on === this.on && spike.overlaps(this.guy) 163 | ) 164 | } 165 | 166 | setBounds (body) { 167 | const {bounds} = body 168 | 169 | bounds.left = -body.left 170 | bounds.right = WIDTH - body.right 171 | bounds.top = -body.top 172 | bounds.bottom = HEIGHT - body.bottom + 1 173 | 174 | for (const bar of this.bars) { 175 | if (bar.on !== this.on) continue 176 | 177 | if (bar.top < body.bottom && bar.bottom > body.top) { 178 | if (bar.isRightOf(body)) { 179 | bounds.right = Math.min(bounds.right, bar.left - body.right) 180 | } else if (bar.isLeftOf(body)) { 181 | bounds.left = Math.max(bounds.left, bar.right - body.left) 182 | } 183 | } 184 | 185 | if (bar.left < body.right && bar.right > body.left) { 186 | if (bar.isBelow(body)) { 187 | bounds.bottom = Math.min(bounds.bottom, bar.top - body.bottom) 188 | } else if (bar.isAbove(body)) { 189 | bounds.top = Math.max(bounds.top, bar.bottom - body.top) 190 | } 191 | } 192 | } 193 | 194 | return bounds 195 | } 196 | 197 | tick (scale) { 198 | if (this.paused || this.hidden) return 199 | 200 | this.guy.tick(scale) 201 | 202 | const {left, right} = this.setBounds(this.guy) 203 | this.guy.x += Math.min(right, Math.max(left, this.guy.vx)) 204 | 205 | const {top, bottom} = this.setBounds(this.guy) 206 | this.guy.y += Math.min(bottom, Math.max(top, this.guy.vy)) 207 | 208 | if (bottom === 0) { 209 | this.guy.vy = upKey() ? -scale(1200) : 0 210 | if (upKey()) JUMP_FX.play() 211 | } else { 212 | this.guy.vy = Math.min(scale(600), this.guy.vy + scale(120)) 213 | } 214 | 215 | if (this.lost()) { 216 | this.death() 217 | } else if (this.guy.overlaps(this.goal)) { 218 | this.advance() 219 | } 220 | } 221 | } 222 | 223 | class Game { 224 | constructor () { 225 | this.title = new Title(this) 226 | this.controls = new Controls(this) 227 | this.scene = new Scene(this, levels) 228 | this.editor = new Editor( 229 | [[[100, 300], [500, 300], [[84,361,362,48,1]], [[446,401,176,8,1,"up"]]]], 230 | this 231 | ) 232 | this.dialog = document.getElementById('dialog') 233 | onPress(1, this.toggle.bind(this)) 234 | document.addEventListener('keydown', this.keydown.bind(this)) 235 | } 236 | 237 | toggle () { 238 | this.scene.on = !this.scene.on 239 | if (this.scene.on) OFF_FX.play() 240 | else ON_FX.play() 241 | } 242 | 243 | keydown (event) { 244 | if (event.key === ' ') this.toggle() 245 | if (!this.scene.hidden) this.scene.keydown(event) 246 | else if (!this.controls.hidden) this.controls.keydown(event) 247 | else if (!this.title.hidden) this.title.keydown(event) 248 | } 249 | 250 | get state () { 251 | return this._state 252 | } 253 | 254 | set state (value) { 255 | this._state = value 256 | 257 | this.scene.hidden = this.state !== 'play' 258 | this.title.hidden = this.state !== 'title' 259 | this.controls.hidden = this.state !== 'controls' 260 | this.editor.hidden = this.state !== 'edit' 261 | this.dialog.hidden = this.state !== 'edit' 262 | } 263 | 264 | tick (scale) { 265 | this.scene.tick(scale) 266 | this.controls.tick(scale) 267 | } 268 | } 269 | 270 | const game = new Game 271 | 272 | const level = new URL(window.location).searchParams.get('level') 273 | if (level) { 274 | try { 275 | game.scene.levels = [JSON.parse(level)] 276 | game.scene.fromURL = true 277 | game.scene.index = 0 278 | game.state = 'play' 279 | } catch (error) {} 280 | } 281 | 282 | let previous = 0 283 | requestAnimationFrame(function tick (time) { 284 | // To deal with different frame rates, we define per-second speeds and adjust 285 | // them according to the time since the last frame was rendered. 286 | const duration = time - previous 287 | game.tick((value) => Math.round(value * duration / 1000)) 288 | previous = time 289 | requestAnimationFrame(tick) 290 | }) 291 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ONOFF 2 | ===== 3 | 4 | A game created for [js13kGames 2018](https://js13kgames.com/entries/onoff) 5 | 6 | Navigate through 25 brain-teasing levels in this fast-paced, hand-crafted platformer. You'll dodge spikes, jump over pits, and toggle between dimensions. 7 | 8 | - Create and share your own levels 9 | - Gamepad support (your mileage may vary)! 10 | - Custom game engine and physics 11 | - Custom design and music/sfx 12 | 13 | --- 14 | 15 | Post Mortem 16 | =========== 17 | 18 | In one of my weekly developer emails I'm subscribed to, I saw a link for JS13KGames. I've always wanted to create a video game, so I was instantly intrigued. After checking out the details, I really wanted to do it. In fact, I already had a concept that I came up with a couple of years ago. I know enough JavaScript to get by, but I don't know it inside/out. I really wanted to participate, but I knew if I wanted to really make the game I envisioned I'd need some help. So I asked my buddy, [Brad](https://github.com/braddunbar), who is a whiz at JavaScript. He said "Yes!" without any hesitation when I asked him about it! We had actually made a [game](https://game.getharvest.com/) before together (in three days!), so we already had some experience under our belt. 19 | 20 | ### Concept 21 | 22 | As I mentioned, A couple of years ago I came up with the basic concept/mechanic of toggling layers on and off to create various platforming challenges. I had even sketched out several level designs, and cobbled together a super basic prototype using [Phaser.io](http://phaser.io/). It felt good then, so I really didn't need to put in a lot of extra to refine the mechanic. Right at the start, I knew that our character movement would consist of moving left/right and jumping. I also knew that there would be spikes that would instakill our character. 23 | 24 | There are [other ideas](https://github.com/starzonmyarmz/js13k-2018/projects/1) that we had early on such as moving platforms/spikes, enemies, variances in gravity, and an in game timer, but we ended up punting on - mostly due to time and size constraints. That said, I'm really glad we punted on those mechanics. Aside from adding complexity to our code, I think having the constraints that we did made us explore more in level design - as well as simplifying game play. 25 | 26 | ### Art Style, Design, and UX 27 | 28 | I had originally envisioned the game having a pixel art style, but with the 13K and 30 day limits, I decided to make this as easy as possible. I knew that designing a minimum of 20 levels would take up quite a bit of time, and exploring pixel sprites and background designs would have prevented me from being able to create enough levels to make the game worth playing. Before I created any mockups I decided to stick to two colors (I suppose three if you count the 'off' layer). 29 | 30 | I didn't want to have to use text to explain how to play the game. So as far as _educating_ the player, I took inspiration from countless video games (but specifically Super Mario World, Super Metroid, Megaman X), and started out by creating the first three levels that would introduce the game mechanics. I then kept exploring various level designs until I had about six or seven solid levels. 31 | 32 | ![original idea about education](https://i.imgur.com/cndNSyh.png) 33 | 34 | _Original idea for educating game controls to player_ 35 | 36 | While Brad started developing the mechanics, I solicited the help of my son, to help me design the character. What we ended up going with in the final game is pretty darn close to what we first designed. It went through some variations, but in the end we kept coming back to our robot-looking character. In my mind I could see his legs moving and his head bobbing. He originally had rounded corners, but that ended up making the `svg` output more complicated, so in the end we ditched them. 37 | 38 | ![character ideas](https://i.imgur.com/7dEDwT2.png) 39 | 40 | _Other character ideas_ 41 | 42 | I liked the idea of the _toggle_ concept being carried through all the various screens, and not just the levels. So I designed the title, end, and controller screens with this in mind. 43 | 44 | ![typeface](https://i.imgur.com/S2H19VS.png) 45 | 46 | _[Custom typeface](https://github.com/starzonmyarmz/js13k-2018/blob/gh-pages/refs/onoff.otf) designed for ONOFF_ 47 | 48 | ### Music and Sound Effects 49 | 50 | I procrastinated with sound for two weeks or so. At first I got hung up on the options available for creating music and sound effects, until I came across [TinyMusic](https://github.com/kevincennis/TinyMusic). I really liked the simplicity of it, that I was able to focus on music, and not get hung up on the plethora of styles and effects. 51 | 52 | The [original track](http://jsfiddle.net/0k6tLnfd/8/) I wrote was more driving, but lacked personality. I hemmed and hawed for a week, then one morning I just started humming a really silly tune, and it felt just right for the game. The second part of that song (with the walking bass line) came a week later. I was experimenting to see if I could get chords [fade in and out](https://github.com/starzonmyarmz/js13k-2018/blob/gh-pages/src/sound.js#L327-L351). Once I got that working the bass line just worked itself out, and I had the second half to my song. 53 | 54 | For sound effects, I had found some effects, I really liked on [OpenGameArt](https://opengameart.org/) - however the file sizes were huge. No amount of resampling I tried got them even close to 13K. So I ended up trying to _recreate_ those sounds using TinyMusic. I'm pretty happy with how they turned out, and think they fit the game even better than the sound effects I had found. 55 | 56 | ### Development 57 | 58 | We didn't think it was necessary to use `canvas`, and opted use `svg` for the large majority of the game. This gave us the benefit of being able scale graphics without having to implement a lot of extra code, use CSS to flip the colors when the layers were toggled, as well as easily develop graphics. In a lot of cases, I hard coded graphics such as the [death animation](https://github.com/starzonmyarmz/js13k-2018/blob/gh-pages/index.html#L135-L159) or the death counter, and then Brad would step in and wire it up with JavaScript. 59 | 60 | I ran every graphic I created through [SVGOMG](https://jakearchibald.github.io/svgomg/) which helped keep our SVG super lightweight. However in some cases, I found it more performant to use plain old `rect`s or take advantage of SVG `pattern`s for the [numbers used](https://github.com/starzonmyarmz/js13k-2018/blob/gh-pages/index.html#L107-L118) in the death and level counter, as well as the [spikes](https://github.com/starzonmyarmz/js13k-2018/blob/gh-pages/index.html#L80-L93). 61 | 62 | Knowing that Brad would be the mastermind behind 99% of the JavaScript, I ultimately left the decision to him whether we should roll our own game mechanics or use an existing library. We both felt it would be more fun to write our own. Not only that, we would have a lot more control over the amount of code needed. I did hang out with him quite a bit, and watch him build a lot of the mechanics. Using ES6 made the vast majority of the code easier to write, and we didn't end up having to polyfill anything if I recall. 63 | 64 | For level design, Brad had created the ability to bootstrap levels quickly just by passing in an array of coordinates, and that would spit out the character, goal, and platforms - including the layer they should exist on, and if they should be spikes or not. After getting a little burnt out on level design, I messed around with creating some dynamically generated levels. That presented a different challenge of introducing RNG while keeping the levels completable. 65 | 66 | We shot for the stars, and developed right up to the end. Within the last 12 hours before the deadline we rolled out Gamepad support, the ability to share and play custom levels, and wrote another song to play when the game is completed. 67 | 68 | ### Tooling and 13k 69 | 70 | Our tooling was pretty minimal. All we did was run our JS, CSS, and HTML through minifiers - that's it. We decided to build everything as minimal as possible from the beginning, so we didn't really have to go back and rework anything to hit the 13k limit. 71 | 72 | In fact, we were always so far under 13k that with five days left, Brad decided to roll out a custom level editor! That actually turned out to be a huge help because it made it a lot faster to test out ideas, and generate level code that we could just paste into our game. We though it was so cool we decided to include it as a bonus with a day left before the deadline! 73 | 74 | ### Playtesting 75 | 76 | Aside from having our kids play the game frequently (and they did!) we invited a few friends to play with about a week left in the competition. We received some really good feedback - though nothing too major. All in all - they felt that the game mechanics made sense, and was fairly easy to figure out. Looking back I wish we had people play test it sooner - only because we were scrambling around with a few days left to finish building the game. If we had received more drastic feedback, we would have been in a really tight spot. 77 | 78 | ### Wrap Up 79 | 80 | Brad and I had a blast working on the js13k this year! I'm really grateful to Brad and all his help, our friends who took the time to playtest and give us feedback, and especially our families who gave endless support, and endured hours and hours of that music loop! 81 | 82 | If you haven't yet, make sure you checkout our game [ONOFF](https://js13kgames.com/entries/onoff)! 83 | -------------------------------------------------------------------------------- /src/editor.js: -------------------------------------------------------------------------------- 1 | import Bar from './bar.js' 2 | import Guy from './guy.js' 3 | import Goal from './goal.js' 4 | import Body from './body.js' 5 | import create from './create.js' 6 | import Spikes from './spikes.js' 7 | import {DOWN} from './keys.js' 8 | 9 | const PADDING = 3 10 | 11 | const svg = document.getElementById('editor') 12 | const point = svg.createSVGPoint() 13 | const translate = ({clientX, clientY}) => { 14 | point.x = clientX 15 | point.y = clientY 16 | return point.matrixTransform(svg.getScreenCTM().inverse()) 17 | } 18 | 19 | let drag = null 20 | let previous = null 21 | 22 | document.addEventListener('mouseup', () => { 23 | drag = previous = null 24 | }) 25 | 26 | document.addEventListener('mousedown', (event) => { 27 | if (event.button !== 0) return 28 | previous = translate(event) 29 | }) 30 | 31 | document.addEventListener('mousemove', (event) => { 32 | if (!drag) return 33 | const {x, y} = translate(event) 34 | drag({ 35 | x: x - previous.x, 36 | y: y - previous.y 37 | }) 38 | previous = {x, y} 39 | }) 40 | 41 | document.getElementById('close-dialog').addEventListener('click', () => { 42 | document.getElementById('dialog').hidden = true 43 | }) 44 | 45 | class EditableBar extends Bar { 46 | constructor (...args) { 47 | super(...args) 48 | this.element.addEventListener('dblclick', this.dblclick.bind(this)) 49 | this.element.addEventListener('mousemove', this.mousemove.bind(this)) 50 | this.element.addEventListener('mousedown', this.mousedown.bind(this)) 51 | } 52 | 53 | dblclick () { 54 | this.on = !this.on 55 | } 56 | 57 | mousemove (event) { 58 | const {x, y} = translate(event) 59 | this.element.style.cursor = this.cursor(this.region(x, y)) 60 | } 61 | 62 | mousedown (event) { 63 | if (event.button !== 0) return 64 | const {x, y} = translate(event) 65 | drag = this.resize.bind(this, this.region(x, y)) 66 | } 67 | 68 | resize (region, {x, y}) { 69 | switch (region) { 70 | case 'm': 71 | this.x += x 72 | this.y += y 73 | break 74 | case 'n': 75 | this.y += y 76 | this.height -= y 77 | break 78 | case 's': 79 | this.height += y 80 | break 81 | case 'e': 82 | this.width += x 83 | break 84 | case 'w': 85 | this.x += x 86 | this.width -= x 87 | break 88 | case 'nw': 89 | this.resize('n', {x, y}) 90 | this.resize('w', {x, y}) 91 | break 92 | case 'ne': 93 | this.resize('n', {x, y}) 94 | this.resize('e', {x, y}) 95 | break 96 | case 'sw': 97 | this.resize('s', {x, y}) 98 | this.resize('w', {x, y}) 99 | break 100 | case 'se': 101 | this.resize('s', {x, y}) 102 | this.resize('e', {x, y}) 103 | break 104 | } 105 | } 106 | 107 | region (x, y) { 108 | x -= this.x 109 | y -= this.y 110 | 111 | if (x <= PADDING) { 112 | if (y <= PADDING) return 'nw' 113 | else if (y < this.height - PADDING) return 'w' 114 | else return 'sw' 115 | } 116 | 117 | else if (x < this.width - PADDING) { 118 | if (y <= PADDING) return 'n' 119 | else if (y < this.height - PADDING) return 'm' 120 | else return 's' 121 | } 122 | 123 | else { 124 | if (y <= PADDING) return 'ne' 125 | else if (y < this.height - PADDING) return 'e' 126 | else return 'se' 127 | } 128 | } 129 | 130 | cursor (region) { 131 | switch (region) { 132 | case 'n': 133 | case 's': 134 | return 'ns-resize' 135 | case 'e': 136 | case 'w': 137 | return 'ew-resize' 138 | case 'nw': 139 | case 'se': 140 | return 'nwse-resize' 141 | case 'ne': 142 | case 'sw': 143 | return 'nesw-resize' 144 | case 'm': 145 | return 'move' 146 | } 147 | } 148 | } 149 | 150 | class EditableSpikes extends Spikes { 151 | constructor (...args) { 152 | super(...args) 153 | this.element.style.cursor = 'move' 154 | this.element.addEventListener('dblclick', this.dblclick.bind(this)) 155 | this.element.addEventListener('mousedown', this.mousedown.bind(this)) 156 | this.element.addEventListener('mousemove', this.mousemove.bind(this)) 157 | } 158 | 159 | get direction () { 160 | return super.direction 161 | } 162 | 163 | set direction (value) { 164 | super.direction = value 165 | this.rect.setAttribute('fill', `url(#edit-spike-${this.direction})`) 166 | } 167 | 168 | dblclick () { 169 | this.on = !this.on 170 | } 171 | 172 | mousemove (event) { 173 | const {x, y} = translate(event) 174 | this.element.style.cursor = this.cursor(this.region(x, y)) 175 | } 176 | 177 | mousedown (event) { 178 | if (event.button !== 0) return 179 | const {x, y} = translate(event) 180 | drag = this.resize.bind(this, this.region(x, y)) 181 | } 182 | 183 | resize (region, {x, y}) { 184 | switch (region) { 185 | case 'n': 186 | this.y += y 187 | this.height -= y 188 | break 189 | case 's': 190 | this.height += y 191 | break 192 | case 'e': 193 | this.width += x 194 | break 195 | case 'w': 196 | this.x += x 197 | this.width -= x 198 | break 199 | case 'm': 200 | this.x += x 201 | this.y += y 202 | break 203 | } 204 | } 205 | 206 | region (x, y) { 207 | x -= this.x 208 | y -= this.y 209 | 210 | if (this.isUp || this.isDown) { 211 | if (x <= PADDING) return 'w' 212 | else if (x < this.width - PADDING) return 'm' 213 | else return 'e' 214 | } 215 | 216 | else { 217 | if (y <= PADDING) return 'n' 218 | else if (y < this.height - PADDING) return 'm' 219 | else return 's' 220 | } 221 | } 222 | 223 | cursor (region) { 224 | switch (region) { 225 | case 'n': 226 | case 's': 227 | return 'ns-resize' 228 | case 'e': 229 | case 'w': 230 | return 'ew-resize' 231 | case 'm': 232 | return 'move' 233 | } 234 | } 235 | } 236 | 237 | class EditableGuy extends Guy { 238 | constructor (...args) { 239 | super(...args) 240 | this.element.style.cursor = 'move' 241 | this.element.addEventListener('mousedown', this.mousedown.bind(this)) 242 | } 243 | 244 | mousedown (event) { 245 | if (event.button !== 0) return 246 | drag = ({x, y}) => { 247 | this.x += x 248 | this.y += y 249 | } 250 | } 251 | } 252 | 253 | class EditableGoal extends Goal { 254 | constructor (...args) { 255 | super(...args) 256 | this.element.style.cursor = 'move' 257 | this.element.addEventListener('mousedown', this.mousedown.bind(this)) 258 | } 259 | 260 | mousedown (event) { 261 | if (event.button !== 0) return 262 | drag = ({x, y}) => { 263 | this.x += x 264 | this.y += y 265 | } 266 | } 267 | } 268 | 269 | export default class Editor extends Body { 270 | constructor (levels, game) { 271 | super(document.getElementById('editor')) 272 | this.bars = [] 273 | this.spikes = [] 274 | this.levels = levels 275 | this.game = game 276 | this.guy = new EditableGuy 277 | this.append(this.guy) 278 | this.goal = new EditableGoal 279 | this.append(this.goal) 280 | this.level = 0 281 | document.addEventListener('keydown', this.keydown.bind(this)) 282 | } 283 | 284 | addBar (bar) { 285 | this.bars.push(bar) 286 | this.append(bar) 287 | bar.element.addEventListener('click', ({shiftKey}) => { 288 | if (!shiftKey) return 289 | bar.remove() 290 | this.bars = this.bars.filter((other) => other === bar) 291 | }) 292 | } 293 | 294 | addSpike (spike) { 295 | this.spikes.push(spike) 296 | this.append(spike) 297 | spike.element.addEventListener('click', ({shiftKey}) => { 298 | if (!shiftKey) return 299 | spike.remove() 300 | this.spikes = this.spikes.filter((other) => other === spike) 301 | }) 302 | } 303 | 304 | get level () { 305 | return this._level 306 | } 307 | 308 | set level (value) { 309 | this._level = Math.max(0, Math.min(this.levels.length - 1, value)) 310 | 311 | const [guy, goal, bars, spikes] = this.levels[this.level] 312 | this.guy.load(...guy) 313 | this.goal.load(...goal) 314 | while (this.bars.length) this.bars.pop().remove() 315 | for (const args of bars) { 316 | this.addBar(new EditableBar(...args)) 317 | } 318 | while (this.spikes.length) this.spikes.pop().remove() 319 | for (const args of spikes) { 320 | this.addSpike(new EditableSpikes(...args)) 321 | } 322 | } 323 | 324 | keydown ({key}) { 325 | if (this.hidden) return 326 | 327 | switch (key) { 328 | case 'ArrowRight': 329 | this.level += 1 330 | break 331 | case 'ArrowLeft': 332 | this.level -= 1 333 | break 334 | case 'p': 335 | this.addBar(new EditableBar(0, 0, 48, 48, true)) 336 | break 337 | case 'c': 338 | navigator.clipboard.writeText(JSON.stringify(this)) 339 | break 340 | case 'u': 341 | case 'd': 342 | case 'l': 343 | case 'r': 344 | if (!DOWN.has('s')) return 345 | this.addSpike(key === 'u' || key === 'd' 346 | ? new EditableSpikes(0, 0, 64, 8, true, key === 'u' ? 'up' : 'down') 347 | : new EditableSpikes(0, 0, 8, 64, true, key === 'l' ? 'left' : 'right') 348 | ) 349 | break 350 | case 'h': 351 | document.getElementById('dialog').hidden = !document.getElementById('dialog').hidden 352 | break 353 | case 'g': 354 | const url = new URL(window.location) 355 | url.searchParams.set('level', JSON.stringify(this)) 356 | window.location = url.toString() 357 | break 358 | case 'Escape': 359 | if (this.game) this.game.state = 'title' 360 | break 361 | } 362 | } 363 | 364 | toJSON () { 365 | return [this.guy, this.goal, this.bars, this.spikes] 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 78 | 79 | 161 | 162 | 183 | 184 | 201 | 202 | --------------------------------------------------------------------------------