├── .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 | `
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 | `
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 |
29 |
30 |
31 |
Instructions
32 |
33 | - H Show these instructions
34 | - < + > Cycle pre-built levels
35 | - P Create new platform
36 | - S + U Create new up spike
37 | - S + D Create new down spike
38 | - S + L Create new left spike
39 | - S + R Create new right spike
40 | - SHIFT +
Click Remove platform/spike
41 | Double Click Toggle platform layer
42 |
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 | 
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 | 
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 | 
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 |
27 |
28 |
78 |
79 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
Instructions
186 |
187 | - H Show these instructions
188 | - P Create new platform
189 | - S + U Create new up spike
190 | - S + D Create new down spike
191 | - S + L Create new left spike
192 | - S + R Create new right spike
193 | - SHIFT +
Click Remove platform/spike
194 | - GPlay this level
195 | - ESCBack to menu
196 | Double Click Toggle platform layer
197 |
198 |
199 |
200 |
201 |
202 |
--------------------------------------------------------------------------------