├── .github └── workflows │ └── build.yaml ├── .gitignore ├── .gitmodules ├── Makefile ├── README.md ├── assets ├── gameplay-screenshot.png ├── icon-400x400.png ├── icon-4096x4096.xcf ├── player-large.png └── title-screen.png ├── build.js ├── install-ect.sh ├── rain.html └── src ├── css └── style.css ├── index.html └── js ├── ai └── character-controller.js ├── entities ├── aggressivity-tracker.js ├── animations │ ├── bird.js │ ├── full-charge.js │ ├── particle.js │ ├── perfect-parry.js │ ├── rain.js │ ├── shield-block.js │ └── swing-effect.js ├── camera.js ├── characters │ ├── character-hud.js │ ├── character-offscreen-indicator.js │ ├── character.js │ ├── corpse.js │ ├── dummy-enemy.js │ ├── enemy.js │ ├── king-enemy.js │ ├── player-hud.js │ └── player.js ├── cursor.js ├── entity.js ├── interpolator.js ├── path.js ├── props │ ├── bush.js │ ├── grass.js │ ├── obstacle.js │ ├── tree.js │ └── water.js └── ui │ ├── announcement.js │ ├── exposition.js │ ├── fade.js │ ├── instruction.js │ ├── label.js │ ├── logo.js │ └── pause-overlay.js ├── globals.js ├── graphics ├── characters │ ├── body.js │ └── exclamation.js ├── create-canvas.js ├── gauge.js ├── text.js ├── with-shadow.js └── wrap.js ├── index.js ├── input ├── keyboard.js ├── mouse.js └── touch.js ├── level ├── gameplay-level.js ├── intro-level.js ├── level.js ├── screenshot-level.js └── test-level.js ├── math.js ├── scene.js ├── sound ├── ZzFXMicro.js ├── sfx.js ├── sonantx.js └── song.js ├── state-machine.js └── util ├── first-item.js ├── regen-entity.js ├── resizer.js └── rng.js /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | concurrency: 11 | group: build-${{ github.ref }} 12 | cancel-in-progress: true 13 | steps: 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | - name: Check out repository code 18 | uses: actions/checkout@v3 19 | - name: Install dependencies 20 | run: make install 21 | - name: Build game 22 | run: make 23 | - name: Upload build/ 24 | uses: actions/upload-artifact@v3 25 | with: 26 | name: build 27 | path: build/ 28 | retention-days: 30 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "js13k-compiler"] 2 | path = js13k-compiler 3 | url = https://github.com/remvst/js13k-compiler 4 | [submodule "Efficient-Compression-Tool"] 5 | path = Efficient-Compression-Tool 6 | url = https://github.com/fhanau/Efficient-Compression-Tool 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | build: 4 | node build.js 5 | 6 | update: install 7 | git submodule update --init --recursive 8 | cd js13k-compiler && git checkout master && git pull && npm install 9 | 10 | install: 11 | git submodule update --init --recursive 12 | brew install node advancecomp || sudo apt-get install -y advancecomp 13 | cd js13k-compiler && npm install 14 | ./install-ect.sh 15 | mkdir -p build 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | > 1254 AD 6 | > 7 | > The Kingdom of Syldavia is being invaded by the Northern Empire. 8 | > 9 | > The Syldavian army is outnumbered and outmatched. 10 | > 11 | > One lone soldier decides to take on the emperor himself. 12 | 13 | # Path To Glory 14 | 15 | **Path To Glory** is my entry for 2023's [JS13K](https://js13kgames.com/). 16 | The theme for the competition was **13th century**. 17 | 18 | The game is a historically inaccurate beat 'em up where you fight waves of enemies until you reach the final boss. 19 | 20 | You can play the game at http://glory.tap2play.io/ 21 | 22 | ## Build 23 | 24 | ```sh 25 | make install 26 | make 27 | ``` 28 | 29 | ## Debugging 30 | 31 | When opening `debug.html`: 32 | - F to speed up time 33 | - G to slow down time 34 | - `level = new TestLevel()` to use test level 35 | - `level = new GameplayLevel()` to skip tutorial 36 | - `level = new GameplayLevel(99)` to jump straight to the final boss 37 | 38 | # License 39 | 40 | Feel free to read the code but don't use it for commercial purposes. The game is the result of a lot of hard work and I wish to maintain all rights to it. 41 | 42 | Please reach out if you wish to distribute the game on your portal. 43 | -------------------------------------------------------------------------------- /assets/gameplay-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvst/knight/3fec1832eb8e21eaa6b46269095da43d9013249e/assets/gameplay-screenshot.png -------------------------------------------------------------------------------- /assets/icon-400x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvst/knight/3fec1832eb8e21eaa6b46269095da43d9013249e/assets/icon-400x400.png -------------------------------------------------------------------------------- /assets/icon-4096x4096.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvst/knight/3fec1832eb8e21eaa6b46269095da43d9013249e/assets/icon-4096x4096.xcf -------------------------------------------------------------------------------- /assets/player-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvst/knight/3fec1832eb8e21eaa6b46269095da43d9013249e/assets/player-large.png -------------------------------------------------------------------------------- /assets/title-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvst/knight/3fec1832eb8e21eaa6b46269095da43d9013249e/assets/title-screen.png -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const compiler = require('./js13k-compiler/src/compiler'); 2 | const spawn = require('child_process').spawn; 3 | const Task = require('./js13k-compiler/src/tasks/task'); 4 | 5 | class ECTZip extends Task { 6 | constructor(filename) { 7 | super(); 8 | this.filename = filename; 9 | } 10 | 11 | execute(input) { 12 | return new Promise((resolve, reject) => { 13 | // Guess I'm hardcoding this :p 14 | const subprocess = spawn('./Efficient-Compression-Tool/build/ect', [ 15 | '-zip', 16 | this.filename, 17 | '-9', 18 | '-strip', 19 | ]); 20 | 21 | subprocess.on('exit', (code) => { 22 | if (code === 0) { 23 | resolve(input); 24 | } else { 25 | reject('ect failed with error code ' + code); 26 | } 27 | }); 28 | }); 29 | } 30 | } 31 | 32 | let belowLayer = -9990; 33 | let aboveLayer = 9990; 34 | 35 | const CONSTANTS = { 36 | "true": 1, 37 | "false": 0, 38 | "const": "let", 39 | "null": 0, 40 | 41 | "LARGE_INT": 9999, 42 | 43 | "CANVAS_WIDTH": 1280, 44 | "CANVAS_HEIGHT": 720, 45 | 46 | "WAVE_COUNT": 8, 47 | 48 | "PLAYER_HEAVY_ATTACK_INDEX": 3, 49 | "PLAYER_HEAVY_CHARGE_TIME": 1, 50 | "PLAYER_PERFECT_PARRY_TIME": 0.15, 51 | "PLAYER_DASH_DURATION": 0.3, 52 | "PLAYER_DASH_DISTANCE": 200, 53 | "PLAYER_MAGNET_RADIUS": 250, 54 | 55 | "STRIKE_WINDUP": 0.05, 56 | "STRIKE_DURATION": 0.15, 57 | 58 | "MAX_AGGRESSION": 6, 59 | 60 | "LAYER_CORPSE": belowLayer--, 61 | "LAYER_WATER": belowLayer--, 62 | "LAYER_PATH": belowLayer--, 63 | "LAYER_LOWER_FADE": belowLayer--, 64 | 65 | "LAYER_CHARACTER_HUD": aboveLayer++, 66 | "LAYER_PARTICLE": aboveLayer++, 67 | "LAYER_ANIMATIONS": aboveLayer++, 68 | "LAYER_WEATHER": aboveLayer++, 69 | "LAYER_PLAYER_HUD": aboveLayer++, 70 | "LAYER_LOGO": aboveLayer++, 71 | "LAYER_FADE": aboveLayer++, 72 | "LAYER_INSTRUCTIONS": aboveLayer++, 73 | 74 | "CHEST_WIDTH_ARMORED": 25, 75 | "CHEST_WIDTH_NAKED": 22, 76 | 77 | "COLOR_SKIN": "'#fec'", 78 | "COLOR_SHIRT": "'#753'", 79 | "COLOR_LEGS": "'#666'", 80 | "COLOR_ARMORED_ARM": "'#666'", 81 | "COLOR_ARMOR": "'#ccc'", 82 | "COLOR_WOOD": "'#634'", 83 | 84 | "DEBUG_AGGRESSIVITY": false, 85 | "DEBUG_CHARACTER_RADII": false, 86 | "DEBUG_CHARACTER_STATE": false, 87 | "DEBUG_CHARACTER_STATS": false, 88 | "DEBUG_CHARACTER_AI": false, 89 | "DEBUG_PLAYER_MAGNET": false, 90 | 91 | "RENDER_PLAYER_ICON": false, 92 | "RENDER_SCREENSHOT": false, 93 | 94 | "INPUT_MODE_MOUSE": 0, 95 | "INPUT_MODE_TOUCH": 1, 96 | "INPUT_MODE_GAMEPAD": 2, 97 | 98 | "TOUCH_JOYSTICK_RADIUS": 50, 99 | "TOUCH_JOYSTICK_MAX_RADIUS": 150, 100 | "TOUCH_BUTTON_RADIUS": 35, 101 | 102 | "RIPPLE_DURATION": 2, 103 | "THUNDER_INTERVAL": 10, 104 | 105 | "SONG_VOLUME": 0.5, 106 | 107 | // Fix for my mangler sucking 108 | "aggressivity-tracker": 'at', 109 | }; 110 | 111 | if (CONSTANTS.RENDER_SCREENSHOT) { 112 | CONSTANTS.CANVAS_HEIGHT = CONSTANTS.CANVAS_WIDTH / (400 / 250); 113 | } 114 | 115 | function copy(obj) { 116 | return JSON.parse(JSON.stringify(obj)); 117 | } 118 | 119 | compiler.run((tasks) => { 120 | function buildJS({ 121 | mangle, 122 | uglify 123 | }) { 124 | // Manually injecting the DEBUG constant 125 | const constants = copy(CONSTANTS); 126 | constants.DEBUG = !uglify; 127 | 128 | const sequence = [ 129 | tasks.label('Building JS'), 130 | tasks.loadFiles([ 131 | "src/js/globals.js", 132 | "src/js/math.js", 133 | "src/js/state-machine.js", 134 | 135 | "src/js/graphics/create-canvas.js", 136 | "src/js/graphics/wrap.js", 137 | "src/js/graphics/with-shadow.js", 138 | "src/js/graphics/characters/exclamation.js", 139 | "src/js/graphics/characters/body.js", 140 | "src/js/graphics/gauge.js", 141 | "src/js/graphics/text.js", 142 | 143 | "src/js/input/keyboard.js", 144 | "src/js/input/mouse.js", 145 | "src/js/input/touch.js", 146 | 147 | "src/js/ai/character-controller.js", 148 | 149 | "src/js/entities/entity.js", 150 | "src/js/entities/camera.js", 151 | "src/js/entities/interpolator.js", 152 | "src/js/entities/cursor.js", 153 | "src/js/entities/path.js", 154 | "src/js/entities/aggressivity-tracker.js", 155 | 156 | "src/js/entities/animations/full-charge.js", 157 | "src/js/entities/animations/shield-block.js", 158 | "src/js/entities/animations/perfect-parry.js", 159 | "src/js/entities/animations/particle.js", 160 | "src/js/entities/animations/swing-effect.js", 161 | "src/js/entities/animations/rain.js", 162 | "src/js/entities/animations/bird.js", 163 | 164 | "src/js/entities/props/grass.js", 165 | "src/js/entities/props/obstacle.js", 166 | "src/js/entities/props/tree.js", 167 | "src/js/entities/props/bush.js", 168 | "src/js/entities/props/water.js", 169 | 170 | "src/js/entities/ui/label.js", 171 | "src/js/entities/ui/fade.js", 172 | "src/js/entities/ui/logo.js", 173 | "src/js/entities/ui/announcement.js", 174 | "src/js/entities/ui/instruction.js", 175 | "src/js/entities/ui/exposition.js", 176 | "src/js/entities/ui/pause-overlay.js", 177 | 178 | "src/js/entities/characters/character-hud.js", 179 | "src/js/entities/characters/player-hud.js", 180 | 181 | "src/js/entities/characters/character.js", 182 | "src/js/entities/characters/player.js", 183 | "src/js/entities/characters/enemy.js", 184 | "src/js/entities/characters/dummy-enemy.js", 185 | "src/js/entities/characters/king-enemy.js", 186 | "src/js/entities/characters/character-offscreen-indicator.js", 187 | 188 | "src/js/entities/characters/corpse.js", 189 | 190 | "src/js/sound/ZzFXMicro.js", 191 | "src/js/sound/sonantx.js", 192 | "src/js/sound/song.js", 193 | 194 | "src/js/level/level.js", 195 | "src/js/level/intro-level.js", 196 | "src/js/level/gameplay-level.js", 197 | constants.DEBUG ? "src/js/level/test-level.js" : null, 198 | constants.DEBUG ? "src/js/level/screenshot-level.js" : null, 199 | 200 | "src/js/util/resizer.js", 201 | "src/js/util/first-item.js", 202 | "src/js/util/rng.js", 203 | "src/js/util/regen-entity.js", 204 | 205 | "src/js/scene.js", 206 | "src/js/index.js", 207 | ].filter(file => !!file)), 208 | tasks.concat(), 209 | tasks.constants(constants), 210 | tasks.macro('evaluate'), 211 | tasks.macro('nomangle'), 212 | ]; 213 | 214 | if (mangle) { 215 | sequence.push(tasks.mangle({ 216 | "skip": [ 217 | "arguments", 218 | "callee", 219 | "flat", 220 | "left", 221 | "px", 222 | "pt", 223 | "movementX", 224 | "movementY", 225 | "imageSmoothingEnabled", 226 | "cursor", 227 | "flatMap", 228 | "monetization", 229 | "yield", 230 | "await", 231 | "async", 232 | "try", 233 | "catch", 234 | "finally", 235 | ], 236 | "force": [ 237 | "a", 238 | "b", 239 | "c", 240 | "d", 241 | "e", 242 | "f", 243 | "g", 244 | "h", 245 | "i", 246 | "j", 247 | "k", 248 | "l", 249 | "m", 250 | "n", 251 | "o", 252 | "p", 253 | "q", 254 | "r", 255 | "s", 256 | "t", 257 | "u", 258 | "v", 259 | "w", 260 | "x", 261 | "y", 262 | "z", 263 | "alpha", 264 | "background", 265 | "direction", 266 | "ended", 267 | "key", 268 | "left", 269 | "level", 270 | "maxDistance", 271 | "remove", 272 | "right", 273 | "speed", 274 | "start", 275 | "item", 276 | "center", 277 | "wrap", 278 | "angle", 279 | "target", 280 | "path", 281 | "step", 282 | "color", 283 | "expand", 284 | "label", 285 | "action", 286 | "normalize", 287 | "duration", 288 | "message", 289 | "name", 290 | "ratio", 291 | "size", 292 | "index", 293 | "controls", 294 | "attack", 295 | "end", 296 | "description", 297 | "resolve", 298 | "reject", 299 | "category", 300 | "update", 301 | "error", 302 | "endTime", 303 | "aggressivity", 304 | "radiusX", 305 | "radiusY", 306 | "state", 307 | "rotation", 308 | "contains", 309 | "zoom", 310 | "object", 311 | "entity", 312 | "Entity", 313 | "entities", 314 | "timeout", 315 | "frame", 316 | "line", 317 | "repeat", 318 | "elements", 319 | "text", 320 | "source", 321 | "frequency", 322 | ] 323 | })); 324 | } 325 | 326 | if (uglify) { 327 | sequence.push(tasks.uglifyES()); 328 | sequence.push(tasks.roadroller()); 329 | } 330 | 331 | return tasks.sequence(sequence); 332 | } 333 | 334 | function buildCSS(uglify) { 335 | const sequence = [ 336 | tasks.label('Building CSS'), 337 | tasks.loadFiles([__dirname + "/src/css/style.css"]), 338 | tasks.concat() 339 | ]; 340 | 341 | if (uglify) { 342 | sequence.push(tasks.uglifyCSS()); 343 | } 344 | 345 | return tasks.sequence(sequence); 346 | } 347 | 348 | function buildHTML(uglify) { 349 | const sequence = [ 350 | tasks.label('Building HTML'), 351 | tasks.loadFiles([__dirname + "/src/index.html"]), 352 | tasks.concat() 353 | ]; 354 | 355 | if (uglify) { 356 | sequence.push(tasks.uglifyHTML()); 357 | } 358 | 359 | return tasks.sequence(sequence); 360 | } 361 | 362 | function buildMain() { 363 | return tasks.sequence([ 364 | tasks.block('Building main files'), 365 | tasks.parallel({ 366 | 'js': buildJS({ 367 | 'mangle': true, 368 | 'uglify': true 369 | }), 370 | 'css': buildCSS(true), 371 | 'html': buildHTML(true) 372 | }), 373 | tasks.combine(), 374 | tasks.output(__dirname + '/build/index.html'), 375 | tasks.label('Building ZIP'), 376 | tasks.zip('index.html'), 377 | 378 | // Regular zip 379 | tasks.output(__dirname + '/build/game.zip'), 380 | tasks.checkSize(__dirname + '/build/game.zip'), 381 | 382 | // ADV zip 383 | tasks.advzip(__dirname + '/build/game.zip'), 384 | tasks.checkSize(__dirname + '/build/game.zip'), 385 | 386 | // ECT zip 387 | new ECTZip(__dirname + '/build/game.zip'), 388 | tasks.checkSize(__dirname + '/build/game.zip'), 389 | ]); 390 | } 391 | 392 | function buildDebug({ 393 | mangle, 394 | suffix 395 | }) { 396 | return tasks.sequence([ 397 | tasks.block('Building debug files'), 398 | tasks.parallel({ 399 | // Debug JS in a separate file 400 | 'debug_js': tasks.sequence([ 401 | buildJS({ 402 | 'mangle': mangle, 403 | 'uglify': false 404 | }), 405 | tasks.output(__dirname + '/build/debug' + suffix + '.js') 406 | ]), 407 | 408 | // Injecting the debug file 409 | 'js': tasks.inject(['debug' + suffix + '.js']), 410 | 411 | 'css': buildCSS(false), 412 | 'html': buildHTML(false) 413 | }), 414 | tasks.combine(), 415 | tasks.output(__dirname + '/build/debug' + suffix + '.html') 416 | ]); 417 | } 418 | 419 | function main() { 420 | return tasks.sequence([ 421 | buildMain(), 422 | buildDebug({ 423 | 'mangle': false, 424 | 'suffix': '' 425 | }), 426 | buildDebug({ 427 | 'mangle': true, 428 | 'suffix': '_mangled' 429 | }) 430 | ]); 431 | } 432 | 433 | return main(); 434 | }); 435 | -------------------------------------------------------------------------------- /install-ect.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pushd Efficient-Compression-Tool 4 | git submodule update --init --recursive 5 | mkdir build 6 | cd build 7 | cmake ../src 8 | make 9 | popd 10 | -------------------------------------------------------------------------------- /rain.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/css/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | width: 100%; 4 | height: 100%; 5 | position: relative; 6 | touch-action: none; 7 | user-select: none; 8 | } 9 | 10 | body { 11 | background: #000; 12 | } 13 | 14 | #t { 15 | position: absolute; 16 | top: 50%; 17 | left: 50%; 18 | transform: translate(-50%, -50%); 19 | } 20 | 21 | #g { 22 | display: block; 23 | } 24 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | PATH TO GLORY 3 | 6 | 7 |
8 | 9 |
10 | 11 | 14 | -------------------------------------------------------------------------------- /src/js/ai/character-controller.js: -------------------------------------------------------------------------------- 1 | class CharacterController { 2 | start(entity) { 3 | this.entity = entity; 4 | } 5 | 6 | // get description() { 7 | // return this.constructor.name; 8 | // } 9 | 10 | cycle() {} 11 | } 12 | 13 | class AI extends CharacterController { 14 | 15 | start(entity) { 16 | super.start(entity); 17 | return new Promise((resolve, reject) => { 18 | this.doResolve = resolve; 19 | this.doReject = reject; 20 | }); 21 | } 22 | 23 | cycle() { 24 | const player = firstItem(this.entity.scene.category('player')); 25 | if (player) { 26 | this.update(player); 27 | } 28 | } 29 | 30 | update(player) { 31 | 32 | } 33 | 34 | resolve() { 35 | const { doResolve } = this; 36 | this.onDone(); 37 | if (doResolve) doResolve(); 38 | } 39 | 40 | reject(error) { 41 | const { doReject } = this; 42 | this.onDone(); 43 | if (doReject) doReject(error); 44 | } 45 | 46 | onDone() { 47 | this.doReject = null; 48 | this.doReject = null; 49 | } 50 | } 51 | 52 | class EnemyAI extends AI { 53 | 54 | constructor() { 55 | super(); 56 | this.ais = new Set(); 57 | } 58 | 59 | cycle(elapsed) { 60 | super.cycle(elapsed); 61 | 62 | for (const ai of this.ais.values()) { 63 | ai.cycle(elapsed); 64 | } 65 | } 66 | 67 | // get description() { 68 | // return Array.from(this.ais).map(ai => ai.description).join('+'); 69 | // } 70 | 71 | async start(entity) { 72 | super.start(entity); 73 | await this.doStart(entity); 74 | } 75 | 76 | async doStart() { 77 | // implement in subclasses 78 | } 79 | 80 | update(player) { 81 | this.entity.controls.aim.x = player.x; 82 | this.entity.controls.aim.y = player.y; 83 | } 84 | 85 | startAI(ai) { 86 | return this.race([ai]); 87 | } 88 | 89 | async race(ais) { 90 | try { 91 | await Promise.race(ais.map(ai => { 92 | this.ais.add(ai); 93 | return ai.start(this.entity); 94 | })); 95 | } finally { 96 | for (const ai of ais) { 97 | ai.reject(Error()); 98 | ai.resolve(); // Allow the AI to clean up 99 | this.ais.delete(ai); 100 | } 101 | } 102 | } 103 | 104 | async sequence(ais) { 105 | for (const ai of ais) { 106 | await this.startAI(ai); 107 | } 108 | } 109 | } 110 | 111 | class Wait extends AI { 112 | 113 | constructor(duration) { 114 | super(); 115 | this.duration = duration; 116 | } 117 | 118 | start(entity) { 119 | this.endTime = entity.age + this.duration; 120 | return super.start(entity); 121 | } 122 | 123 | update() { 124 | if (this.entity.age > this.endTime) { 125 | this.resolve(); 126 | } 127 | } 128 | } 129 | 130 | class Timeout extends AI { 131 | 132 | constructor(duration) { 133 | super(); 134 | this.duration = duration; 135 | } 136 | 137 | start(entity) { 138 | this.endTime = entity.age + this.duration; 139 | return super.start(entity); 140 | } 141 | 142 | update() { 143 | if (this.entity.age > this.endTime) { 144 | this.reject(Error()); 145 | } 146 | } 147 | } 148 | 149 | class BecomeAggressive extends AI { 150 | update() { 151 | const tracker = firstItem(this.entity.scene.category('aggressivity-tracker')); 152 | if (tracker.requestAggression(this.entity)) { 153 | this.resolve(); 154 | } 155 | } 156 | } 157 | 158 | class BecomePassive extends AI { 159 | update() { 160 | const tracker = firstItem(this.entity.scene.category('aggressivity-tracker')); 161 | tracker.cancelAggression(this.entity); 162 | this.resolve(); 163 | } 164 | } 165 | 166 | class ReachPlayer extends AI { 167 | constructor(radiusX, radiusY) { 168 | super(); 169 | this.radiusX = radiusX; 170 | this.radiusY = radiusY; 171 | this.angle = random() * TWO_PI; 172 | } 173 | 174 | update(player) { 175 | const { controls } = this.entity; 176 | 177 | controls.force = 0; 178 | 179 | if (!this.entity.isStrikable(player, this.radiusX, this.radiusY, PI / 2)) { 180 | controls.force = 1; 181 | controls.angle = angleBetween(this.entity, { 182 | x: player.x + cos(this.angle) * this.radiusX, 183 | y: player.y + sin(this.angle) * this.radiusY, 184 | }); 185 | } else { 186 | this.resolve(); 187 | } 188 | } 189 | } 190 | 191 | class Attack extends AI { 192 | constructor(chargeRatio) { 193 | super(); 194 | this.chargeRatio = chargeRatio; 195 | } 196 | 197 | update() { 198 | const { controls } = this.entity; 199 | 200 | controls.attack = true; 201 | 202 | if (this.entity.stateMachine.state.attackPreparationRatio >= this.chargeRatio) { 203 | // Attack was prepared, release! 204 | controls.attack = false; 205 | this.resolve(); 206 | } 207 | } 208 | } 209 | 210 | class RetreatAI extends AI { 211 | constructor(radiusX, radiusY) { 212 | super(); 213 | this.radiusX = radiusX; 214 | this.radiusY = radiusY; 215 | } 216 | 217 | update(player) { 218 | this.entity.controls.force = 0; 219 | 220 | if (this.entity.isStrikable(player, this.radiusX, this.radiusY, PI / 2)) { 221 | // Get away from the player 222 | this.entity.controls.force = 1; 223 | this.entity.controls.angle = angleBetween(player, this.entity); 224 | } else { 225 | this.resolve(); 226 | } 227 | } 228 | 229 | onDone() { 230 | this.entity.controls.force = 0; 231 | } 232 | } 233 | 234 | class HoldShield extends AI { 235 | update() { 236 | this.entity.controls.shield = true; 237 | } 238 | 239 | onDone() { 240 | this.entity.controls.shield = false; 241 | } 242 | } 243 | 244 | class Dash extends AI { 245 | update() { 246 | this.entity.controls.dash = true; 247 | } 248 | 249 | onDone() { 250 | this.entity.controls.dash = false; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/js/entities/aggressivity-tracker.js: -------------------------------------------------------------------------------- 1 | class AggressivityTracker extends Entity { 2 | constructor() { 3 | super(); 4 | this.categories.push('aggressivity-tracker'); 5 | this.currentAggression = 0; 6 | this.aggressive = new Set(); 7 | } 8 | 9 | requestAggression(enemy) { 10 | this.cancelAggression(enemy); 11 | 12 | const { aggression } = enemy; 13 | if (this.currentAggression + aggression > MAX_AGGRESSION) { 14 | return; 15 | } 16 | 17 | this.currentAggression += aggression; 18 | this.aggressive.add(enemy); 19 | return true 20 | } 21 | 22 | cancelAggression(enemy) { 23 | if (this.aggressive.has(enemy)) { 24 | const { aggression } = enemy; 25 | this.currentAggression -= aggression; 26 | this.aggressive.delete(enemy); 27 | } 28 | } 29 | 30 | doRender(camera) { 31 | if (DEBUG && DEBUG_AGGRESSIVITY) { 32 | ctx.fillStyle = '#fff'; 33 | ctx.strokeStyle = '#000'; 34 | ctx.lineWidth = 5; 35 | ctx.textAlign = nomangle('center'); 36 | ctx.textBaseline = nomangle('middle'); 37 | ctx.font = nomangle('12pt Courier'); 38 | 39 | ctx.wrap(() => { 40 | ctx.translate(camera.x, camera.y - 100); 41 | 42 | ctx.strokeText('Agg: ' + this.currentAggression, 0, 0); 43 | ctx.fillText('Agg: ' + this.currentAggression, 0, 0); 44 | }); 45 | 46 | const player = firstItem(this.scene.category('player')); 47 | if (!player) return; 48 | 49 | for (const enemy of this.aggressive) { 50 | ctx.strokeStyle = '#f00'; 51 | ctx.lineWidth = 20; 52 | ctx.globalAlpha = 0.1; 53 | ctx.beginPath(); 54 | ctx.moveTo(enemy.x, enemy.y); 55 | ctx.lineTo(player.x, player.y); 56 | ctx.stroke(); 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/js/entities/animations/bird.js: -------------------------------------------------------------------------------- 1 | class Bird extends Entity { 2 | constructor() { 3 | super(); 4 | this.regen(); 5 | } 6 | 7 | get z() { 8 | return LAYER_WEATHER; 9 | } 10 | 11 | regen() { 12 | this.age = 0; 13 | 14 | let cameraX = 0, cameraY = 0; 15 | if (this.scene) { 16 | const camera = firstItem(this.scene.category('camera')); 17 | cameraX = camera.x; 18 | cameraY = camera.y; 19 | } 20 | this.x = rnd(cameraX - evaluate(CANVAS_WIDTH / 2), cameraX + evaluate(CANVAS_WIDTH / 2)); 21 | this.y = cameraY - evaluate(CANVAS_HEIGHT / 2 + 100); 22 | this.rotation = rnd(PI / 4, PI * 3 / 4); 23 | } 24 | 25 | cycle(elapsed) { 26 | super.cycle(elapsed); 27 | 28 | const camera = firstItem(this.scene.category('camera')); 29 | if (this.y > camera.y + evaluate(CANVAS_HEIGHT / 2 + 300)) { 30 | this.regen(); 31 | } 32 | 33 | this.x += cos(this.rotation) * elapsed * 300; 34 | this.y += sin(this.rotation) * elapsed * 300; 35 | } 36 | 37 | doRender() { 38 | ctx.translate(this.x, this.y + 300); 39 | 40 | ctx.withShadow(() => { 41 | ctx.strokeStyle = ctx.resolveColor('#000'); 42 | ctx.lineWidth = 4; 43 | ctx.beginPath(); 44 | 45 | ctx.translate(0, -300); 46 | 47 | const angle = sin(this.age * TWO_PI * 4) * PI / 16 + PI / 4; 48 | 49 | ctx.lineTo(-cos(angle) * 10, -sin(angle) * 10); 50 | ctx.lineTo(0, 0); 51 | ctx.lineTo(cos(angle) * 10, -sin(angle) * 10); 52 | ctx.stroke(); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/js/entities/animations/full-charge.js: -------------------------------------------------------------------------------- 1 | class FullCharge extends Entity { 2 | 3 | get z() { 4 | return LAYER_ANIMATIONS; 5 | } 6 | 7 | cycle(elapsed) { 8 | super.cycle(elapsed); 9 | if (this.age > 0.25) { 10 | this.remove(); 11 | } 12 | } 13 | 14 | doRender() { 15 | const ratio = this.age / 0.25; 16 | 17 | ctx.translate(this.x, this.y); 18 | ctx.scale(ratio, ratio); 19 | 20 | ctx.globalAlpha = 1 - ratio; 21 | ctx.strokeStyle = '#ff0'; 22 | ctx.lineWidth = 10; 23 | ctx.beginPath(); 24 | ctx.arc(0, 0, 80, 0, TWO_PI); 25 | ctx.stroke(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/js/entities/animations/particle.js: -------------------------------------------------------------------------------- 1 | class Particle extends Entity { 2 | 3 | constructor( 4 | color, 5 | valuesSize, 6 | valuesX, 7 | valuesY, 8 | duration, 9 | ) { 10 | super(); 11 | this.color = color; 12 | this.valuesSize = valuesSize; 13 | this.valuesX = valuesX; 14 | this.valuesY = valuesY; 15 | this.duration = duration; 16 | } 17 | 18 | get z() { 19 | return LAYER_PARTICLE; 20 | } 21 | 22 | cycle(elapsed) { 23 | super.cycle(elapsed); 24 | if (this.age > this.duration) { 25 | this.remove(); 26 | } 27 | } 28 | 29 | interp(property) { 30 | const progress = this.age / this.duration; 31 | return property[0] + progress * (property[1] - property[0]); 32 | } 33 | 34 | doRender() { 35 | const size = this.interp(this.valuesSize); 36 | ctx.translate(this.interp(this.valuesX) - size / 2, this.interp(this.valuesY) - size / 2); 37 | ctx.rotate(PI / 4); 38 | 39 | ctx.fillStyle = this.color; 40 | ctx.globalAlpha = this.interp([1, 0]); 41 | ctx.fillRect(0, 0, size, size); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/js/entities/animations/perfect-parry.js: -------------------------------------------------------------------------------- 1 | class PerfectParry extends Entity { 2 | 3 | constructor() { 4 | super(); 5 | this.affectedBySpeedRatio = false; 6 | } 7 | 8 | get z() { 9 | return LAYER_ANIMATIONS; 10 | } 11 | 12 | cycle(elapsed) { 13 | super.cycle(elapsed); 14 | if (this.age > 0.5) { 15 | this.remove(); 16 | } 17 | } 18 | 19 | doRender() { 20 | const ratio = this.age / 0.5; 21 | ctx.fillStyle = '#fff'; 22 | 23 | ctx.translate(this.x, this.y); 24 | 25 | ctx.globalAlpha = (1 - ratio); 26 | ctx.strokeStyle = '#fff'; 27 | ctx.fillStyle = '#fff'; 28 | ctx.lineWidth = 20; 29 | ctx.beginPath(); 30 | 31 | for (let r = 0 ; r < 1 ; r+= 0.05) { 32 | const angle = r * TWO_PI; 33 | const radius = ratio * rnd(140, 200); 34 | ctx.lineTo( 35 | cos(angle) * radius, 36 | sin(angle) * radius, 37 | ); 38 | } 39 | 40 | // ctx.closePath(); 41 | 42 | // // ctx.arc(0, 0, 100, 0, TWO_PI); 43 | // ctx.stroke(); 44 | ctx.fill(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/js/entities/animations/rain.js: -------------------------------------------------------------------------------- 1 | class Rain extends Entity { 2 | get z() { 3 | return LAYER_WEATHER; 4 | } 5 | 6 | doRender(camera) { 7 | this.rng.reset(); 8 | 9 | ctx.fillStyle = '#0af'; 10 | 11 | this.cancelCameraOffset(camera); 12 | 13 | for (let i = 99 ; i-- ;) { 14 | ctx.wrap(() => { 15 | ctx.translate(evaluate(CANVAS_WIDTH / 2), evaluate(CANVAS_HEIGHT / 2)); 16 | ctx.rotate(this.rng.next(0, PI / 16)); 17 | ctx.translate(-evaluate(CANVAS_WIDTH / 2), -evaluate(CANVAS_HEIGHT / 2)); 18 | 19 | ctx.fillRect( 20 | this.rng.next(0, CANVAS_WIDTH), 21 | this.rng.next(1000, 2000) * (this.age + this.rng.next(0, 10)) % CANVAS_HEIGHT, 22 | 2, 23 | 20, 24 | ); 25 | }); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/js/entities/animations/shield-block.js: -------------------------------------------------------------------------------- 1 | class ShieldBlock extends Entity { 2 | 3 | get z() { 4 | return LAYER_ANIMATIONS; 5 | } 6 | 7 | cycle(elapsed) { 8 | super.cycle(elapsed); 9 | if (this.age > 0.25) { 10 | this.remove(); 11 | } 12 | } 13 | 14 | doRender() { 15 | const ratio = this.age / 0.25; 16 | 17 | ctx.translate(this.x, this.y); 18 | ctx.scale(ratio, ratio); 19 | 20 | ctx.globalAlpha = 1 - ratio; 21 | ctx.strokeStyle = '#fff'; 22 | ctx.lineWidth = 10; 23 | ctx.beginPath(); 24 | ctx.arc(0, 0, 80, 0, TWO_PI); 25 | ctx.stroke(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/js/entities/animations/swing-effect.js: -------------------------------------------------------------------------------- 1 | class SwingEffect extends Entity { 2 | constructor(character, color, fromAngle, toAngle) { 3 | super(); 4 | this.character = character; 5 | this.color = color; 6 | this.fromAngle = fromAngle; 7 | this.toAngle = toAngle; 8 | this.affectedBySpeedRatio = character.affectedBySpeedRatio; 9 | } 10 | 11 | get z() { 12 | return LAYER_ANIMATIONS; 13 | } 14 | 15 | cycle(elapsed) { 16 | super.cycle(elapsed); 17 | if (this.age > 0.2) this.remove(); 18 | } 19 | 20 | doRender() { 21 | ctx.globalAlpha = 1 - this.age / 0.2; 22 | 23 | ctx.translate(this.character.x, this.character.y); 24 | ctx.scale(this.character.facing, 1); 25 | ctx.translate(11, -42); 26 | 27 | ctx.strokeStyle = this.color; 28 | ctx.lineWidth = 40; 29 | ctx.beginPath(); 30 | 31 | for (let r = 0 ; r < 1 ; r += 0.05) { 32 | ctx.wrap(() => { 33 | ctx.rotate( 34 | interpolate( 35 | this.fromAngle * PI / 2, 36 | this.toAngle * PI / 2, 37 | r, 38 | ) 39 | ); 40 | ctx.lineTo(18, -26); 41 | }); 42 | } 43 | 44 | ctx.stroke(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/js/entities/camera.js: -------------------------------------------------------------------------------- 1 | class Camera extends Entity { 2 | constructor() { 3 | super(); 4 | this.categories.push('camera'); 5 | this.zoom = 1; 6 | this.affectedBySpeedRatio = false; 7 | 8 | this.minX = -evaluate(CANVAS_WIDTH / 2); 9 | } 10 | 11 | get appliedZoom() { 12 | // I'm a lazy butt and refuse to update the entire game to have a bit more zoom. 13 | // So instead I do dis ¯\_(ツ)_/¯ 14 | return interpolate(1.2, 3, (this.zoom - 1) / 3); 15 | } 16 | 17 | cycle(elapsed) { 18 | super.cycle(elapsed); 19 | 20 | for (const player of this.scene.category('player')) { 21 | const target = {'x': player.x, 'y': player.y - 60 }; 22 | const distance = dist(this, target); 23 | const angle = angleBetween(this, target); 24 | const appliedDist = min(distance, distance * elapsed * 3); 25 | this.x += appliedDist * cos(angle); 26 | this.y += appliedDist * sin(angle); 27 | } 28 | 29 | this.x = max(this.minX, this.x); 30 | } 31 | 32 | zoomTo(toValue) { 33 | if (this.previousInterpolator) { 34 | this.previousInterpolator.remove(); 35 | } 36 | return this.scene.add(new Interpolator(this, 'zoom', this.zoom, toValue, 1)).await(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/js/entities/characters/character-hud.js: -------------------------------------------------------------------------------- 1 | class CharacterHUD extends Entity { 2 | constructor(character) { 3 | super(); 4 | this.character = character; 5 | 6 | this.healthGauge = new Gauge(() => this.character.health / this.character.maxHealth); 7 | this.staminaGauge = new Gauge(() => this.character.stamina); 8 | } 9 | 10 | get z() { 11 | return LAYER_CHARACTER_HUD; 12 | } 13 | 14 | cycle(elapsed) { 15 | super.cycle(elapsed); 16 | this.healthGauge.cycle(elapsed); 17 | this.staminaGauge.cycle(elapsed); 18 | if (!this.character.health) this.remove(); 19 | } 20 | 21 | doRender() { 22 | if ( 23 | this.character.health > 0.5 && 24 | this.character.age - max(this.character.lastStaminaLoss, this.character.lastDamage) > 2 25 | ) return; 26 | 27 | ctx.translate(this.character.x, this.character.y + 20); 28 | ctx.wrap(() => { 29 | ctx.translate(0, 4); 30 | this.staminaGauge.render(60, 6, staminaGradient, true); 31 | }); 32 | this.healthGauge.render(80, 5, healthGradient); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/js/entities/characters/character-offscreen-indicator.js: -------------------------------------------------------------------------------- 1 | class CharacterOffscreenIndicator extends Entity { 2 | constructor(character) { 3 | super(); 4 | this.character = character; 5 | } 6 | 7 | get z() { 8 | return LAYER_PLAYER_HUD; 9 | } 10 | 11 | cycle(elapsed) { 12 | super.cycle(elapsed); 13 | if (!this.character.health) this.remove(); 14 | } 15 | 16 | doRender(camera) { 17 | if ( 18 | abs(camera.x - this.character.x) < CANVAS_WIDTH / 2 / camera.appliedZoom && 19 | abs(camera.y - this.character.y) < CANVAS_HEIGHT / 2 / camera.appliedZoom 20 | ) return; 21 | 22 | const x = between( 23 | camera.x - (CANVAS_WIDTH / 2 - 50) / camera.appliedZoom, 24 | this.character.x, 25 | camera.x + (CANVAS_WIDTH / 2 - 50) / camera.appliedZoom, 26 | ); 27 | const y = between( 28 | camera.y - (CANVAS_HEIGHT / 2 - 50) / camera.appliedZoom, 29 | this.character.y, 30 | camera.y + (CANVAS_HEIGHT / 2 - 50) / camera.appliedZoom, 31 | ); 32 | ctx.translate(x, y); 33 | 34 | ctx.beginPath(); 35 | ctx.wrap(() => { 36 | ctx.shadowColor = '#000'; 37 | ctx.shadowBlur = 5; 38 | 39 | ctx.fillStyle = '#f00'; 40 | ctx.rotate(angleBetween({x, y}, this.character)); 41 | ctx.arc(0, 0, 20, -PI / 4, PI / 4, true); 42 | ctx.lineTo(40, 0); 43 | ctx.closePath(); 44 | ctx.fill(); 45 | 46 | ctx.shadowBlur = 0; 47 | 48 | ctx.fillStyle = '#fff'; 49 | ctx.beginPath(); 50 | ctx.arc(0, 0, 15, 0, TWO_PI, true); 51 | ctx.fill(); 52 | }); 53 | ctx.clip(); 54 | 55 | ctx.resolveColor = () => '#f00'; 56 | ctx.scale(0.4, 0.4); 57 | ctx.translate(0, 30); 58 | ctx.scale(this.character.facing, 1); 59 | this.character.renderBody(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/js/entities/characters/character.js: -------------------------------------------------------------------------------- 1 | class Character extends Entity { 2 | constructor() { 3 | super(); 4 | this.categories.push('character', 'obstacle'); 5 | 6 | this.renderPadding = 90; 7 | 8 | this.facing = 1; 9 | 10 | this.health = this.maxHealth = 100; 11 | 12 | this.combo = 0; 13 | 14 | this.stamina = 1; 15 | 16 | this.lastDamage = this.lastStaminaLoss = this.lastComboChange = -9; 17 | 18 | this.baseSpeed = 200; 19 | 20 | this.strikeRadiusX = 80; 21 | this.strikeRadiusY = 40; 22 | 23 | this.magnetRadiusX = this.magnetRadiusY = 0; 24 | 25 | this.collisionRadius = 30; 26 | 27 | this.strength = 100; 28 | this.damageCount = this.parryCount = 0; 29 | 30 | this.staminaRecoveryDelay = 99; 31 | 32 | this.setController(this.ai); 33 | 34 | this.gibs = []; 35 | 36 | this.controls = { 37 | 'force': 0, 38 | 'angle': 0, 39 | // 'shield': false, 40 | // 'attack': false, 41 | 'aim': {'x': 0, 'y': 0}, 42 | // 'dash': false, 43 | }; 44 | 45 | this.stateMachine = characterStateMachine({ 46 | entity: this, 47 | }); 48 | } 49 | 50 | setController(controller) { 51 | (this.controller = controller).start(this); 52 | } 53 | 54 | get ai() { 55 | return new AI(); 56 | } 57 | 58 | getColor(color) { 59 | return this.age - this.lastDamage < 0.1 ? '#fff' : color; 60 | } 61 | 62 | cycle(elapsed) { 63 | super.cycle(elapsed); 64 | 65 | this.renderAge = this.age * (this.inWater ? 0.5 : 1) 66 | 67 | this.stateMachine.cycle(elapsed); 68 | 69 | this.controller.cycle(elapsed); 70 | 71 | if (this.inWater && this.controls.force) { 72 | this.loseStamina(elapsed * 0.2); 73 | } 74 | 75 | const speed = this.stateMachine.state.speedRatio * this.baseSpeed; 76 | 77 | this.x += cos(this.controls.angle) * this.controls.force * speed * elapsed; 78 | this.y += sin(this.controls.angle) * this.controls.force * speed * elapsed; 79 | 80 | this.facing = sign(this.controls.aim.x - this.x) || 1; 81 | 82 | // Collisions with other characters and obstacles 83 | for (const obstacle of this.scene.category('obstacle')) { 84 | if (obstacle === this || dist(this, obstacle) > obstacle.collisionRadius) continue; 85 | const angle = angleBetween(this, obstacle); 86 | this.x = obstacle.x - cos(angle) * obstacle.collisionRadius; 87 | this.y = obstacle.y - sin(angle) * obstacle.collisionRadius; 88 | } 89 | 90 | // Stamina regen 91 | if (this.age - this.lastStaminaLoss > this.staminaRecoveryDelay || this.stateMachine.state.exhausted) { 92 | this.stamina = min(1, this.stamina + elapsed * 0.3); 93 | } 94 | 95 | // Combo reset 96 | if (this.age - this.lastComboChange > 5) { 97 | this.updateCombo(-99); 98 | } 99 | } 100 | 101 | updateCombo(value) { 102 | this.combo = max(0, this.combo + value); 103 | this.lastComboChange = this.age; 104 | } 105 | 106 | isStrikable(victim, radiusX, radiusY) { 107 | return this.strikability(victim, radiusX, radiusY, PI / 2) > 0; 108 | } 109 | 110 | isWithinRadii(character, radiusX, radiusY) { 111 | return abs(character.x - this.x) < radiusX && 112 | abs(character.y - this.y) < radiusY; 113 | } 114 | 115 | strikability(victim, radiusX, radiusY, fov) { 116 | if (victim === this || !radiusX || !radiusY) return 0; 117 | 118 | const angleToVictim = angleBetween(this, victim); 119 | const aimAngle = angleBetween(this, this.controls.aim); 120 | const angleScore = 1 - abs(normalize(angleToVictim - aimAngle)) / (fov / 2); 121 | 122 | const dX = abs(this.x - victim.x); 123 | const adjustedDY = abs(this.y - victim.y) / (radiusY / radiusX); 124 | 125 | const adjustedDistance = hypot(dX, adjustedDY); 126 | const distanceScore = 1 - adjustedDistance / radiusX; 127 | 128 | return distanceScore < 0 || angleScore < 0 129 | ? 0 130 | : (distanceScore + pow(angleScore, 3)); 131 | } 132 | 133 | pickVictims(radiusX, radiusY, fov) { 134 | return Array 135 | .from(this.scene.category(this.targetTeam)) 136 | .filter((victim) => this.strikability(victim, radiusX, radiusY, fov) > 0); 137 | } 138 | 139 | pickVictim(radiusX, radiusY, fov) { 140 | return this.pickVictims(radiusX, radiusY, fov) 141 | .reduce((acc, other) => { 142 | if (!acc) return other; 143 | 144 | return this.strikability(other, radiusX, radiusX, fov) > this.strikability(acc, radiusX, radiusY, fov) 145 | ? other 146 | : acc; 147 | }, null); 148 | 149 | } 150 | 151 | lunge() { 152 | const victim = this.pickVictim(this.magnetRadiusX, this.magnetRadiusY, PI / 2); 153 | victim 154 | ? this.dash( 155 | angleBetween(this, victim), 156 | max(0, dist(this, victim) - this.strikeRadiusY / 2), 157 | 0.1, 158 | ) 159 | : this.dash( 160 | angleBetween(this, this.controls.aim), 161 | 40, 162 | 0.1, 163 | ); 164 | } 165 | 166 | strike(relativeStrength) { 167 | sound(...[.1,,400,.1,.01,,3,.92,17,,,,,2,,,,1.04]); 168 | 169 | for (const victim of this.pickVictims(this.strikeRadiusX, this.strikeRadiusY, TWO_PI)) { 170 | const angle = angleBetween(this, victim); 171 | if (victim.stateMachine.state.shielded) { 172 | victim.facing = sign(this.x - victim.x) || 1; 173 | victim.parryCount++; 174 | 175 | // Push back 176 | this.dash(angle + PI, 20, 0.1); 177 | 178 | if (victim.stateMachine.state.perfectParry) { 179 | // Perfect parry, victim gets stamina back, we lose ours 180 | victim.stamina = 1; 181 | victim.updateCombo(1); 182 | victim.displayLabel(nomangle('Perfect Block!')); 183 | 184 | const animation = this.scene.add(new PerfectParry()); 185 | animation.x = victim.x; 186 | animation.y = victim.y - 30; 187 | 188 | this.perfectlyBlocked = true; // Disable "exhausted" label 189 | this.loseStamina(1); 190 | 191 | for (const parryVictim of this.scene.category(victim.targetTeam)) { 192 | if (victim.isWithinRadii(parryVictim, victim.strikeRadiusX * 2, victim.strikeRadiusY * 2)) { 193 | parryVictim.dash(angleBetween(victim, parryVictim), 100, 0.2); 194 | } 195 | } 196 | 197 | (async () => { 198 | this.scene.speedRatio = 0.1; 199 | 200 | const camera = firstItem(this.scene.category('camera')); 201 | await camera.zoomTo(2); 202 | await this.scene.delay(3 * this.scene.speedRatio); 203 | await camera.zoomTo(1); 204 | this.scene.speedRatio = 1; 205 | })(); 206 | 207 | sound(...[2.14,,1e3,.01,.2,.31,3,3.99,,.9,,,.08,1.9,,,.22,.34,.12]); 208 | } else { 209 | // Regular parry, victim loses stamina 210 | victim.loseStamina(relativeStrength * this.strength / 100); 211 | victim.displayLabel(nomangle('Blocked!')); 212 | 213 | const animation = this.scene.add(new ShieldBlock()); 214 | animation.x = victim.x; 215 | animation.y = victim.y - 30; 216 | 217 | sound(...[2.03,,200,,.04,.12,1,1.98,,,,,,-2.4,,,.1,.59,.05,.17]); 218 | } 219 | } else { 220 | victim.damage(~~(this.strength * relativeStrength)); 221 | victim.dash(angle, this.strength * relativeStrength, 0.1); 222 | 223 | // Regen a bit of health after a kill 224 | if (!victim.health) { 225 | this.heal(this.maxHealth * 0.1); 226 | } 227 | 228 | this.updateCombo(1); 229 | 230 | const impactX = victim.x + rnd(-20, 20); 231 | const impactY = victim.y - 30 + rnd(-20, 20); 232 | const size = rnd(1, 2); 233 | 234 | for (let i = 0 ; i < 20 ; i++) { 235 | this.scene.add(new Particle( 236 | '#900', 237 | [size, size + rnd(3, 6)], 238 | [impactX, impactX + rnd(-30, 30)], 239 | [impactY, impactY + rnd(-30, 30)], 240 | rnd(0.2, 0.4), 241 | )); 242 | } 243 | } 244 | } 245 | } 246 | 247 | displayLabel(text, color) { 248 | if (this.lastLabel) this.lastLabel.remove(); 249 | 250 | this.lastLabel = this.scene.add(new Label(text, color)); 251 | this.lastLabel.x = this.x; 252 | this.lastLabel.y = this.y - 90; 253 | } 254 | 255 | loseStamina(amount) { 256 | this.stamina = max(0, this.stamina - amount); 257 | this.lastStaminaLoss = this.age; 258 | } 259 | 260 | damage(amount) { 261 | this.health = max(0, this.health - amount); 262 | this.lastDamage = this.age; 263 | this.damageCount++; 264 | 265 | if (!this.stateMachine.state.exhausted) this.loseStamina(amount / this.maxHealth * 0.3); 266 | this.updateCombo(-99); 267 | this.displayLabel('' + amount, this.damageLabelColor); 268 | 269 | // Death 270 | if (!this.health) this.die(); 271 | } 272 | 273 | heal() {} 274 | 275 | doRender() { 276 | const { inWater, renderAge } = this; 277 | 278 | ctx.translate(this.x, this.y); 279 | 280 | if (DEBUG && DEBUG_CHARACTER_RADII) { 281 | ctx.wrap(() => { 282 | ctx.lineWidth = 10; 283 | ctx.strokeStyle = '#f00'; 284 | ctx.globalAlpha = 0.1; 285 | ctx.beginPath(); 286 | ctx.ellipse(0, 0, this.strikeRadiusX, this.strikeRadiusY, 0, 0, TWO_PI); 287 | ctx.stroke(); 288 | 289 | ctx.beginPath(); 290 | ctx.ellipse(0, 0, this.magnetRadiusX, this.magnetRadiusY, 0, 0, TWO_PI); 291 | ctx.stroke(); 292 | }); 293 | } 294 | 295 | const orig = ctx.resolveColor || (x => x); 296 | ctx.resolveColor = x => this.getColor(orig(x)); 297 | 298 | ctx.withShadow(() => { 299 | if (inWater) { 300 | ctx.beginPath(); 301 | ctx.rect(-150, -150, 300, 150); 302 | ctx.clip(); 303 | 304 | ctx.translate(0, 10); 305 | } 306 | 307 | let { facing } = this; 308 | const { dashAngle } = this.stateMachine.state; 309 | if (dashAngle !== undefined) { 310 | facing = sign(cos(dashAngle)); 311 | 312 | ctx.translate(0, -30); 313 | ctx.rotate(this.stateMachine.state.age / PLAYER_DASH_DURATION * facing * TWO_PI); 314 | ctx.translate(0, 30); 315 | } 316 | 317 | ctx.scale(facing, 1); 318 | 319 | ctx.wrap(() => this.renderBody(renderAge)); 320 | }); 321 | 322 | if (DEBUG) { 323 | ctx.fillStyle = '#fff'; 324 | ctx.strokeStyle = '#000'; 325 | ctx.lineWidth = 3; 326 | ctx.textAlign = nomangle('center'); 327 | ctx.textBaseline = nomangle('middle'); 328 | ctx.font = nomangle('12pt Courier'); 329 | 330 | const bits = []; 331 | if (DEBUG_CHARACTER_STATE) { 332 | bits.push(...[ 333 | nomangle('State: ') + this.stateMachine.state.constructor.name, 334 | nomangle('HP: ') + ~~this.health + '/' + this.maxHealth, 335 | ]); 336 | } 337 | 338 | if (DEBUG_CHARACTER_AI) { 339 | bits.push(...[ 340 | nomangle('AI: ') + this.controller.constructor.name, 341 | ]); 342 | } 343 | 344 | if (DEBUG_CHARACTER_STATS) { 345 | bits.push(...[ 346 | nomangle('Speed: ') + this.baseSpeed, 347 | nomangle('Strength: ') + this.strength, 348 | nomangle('Aggro: ') + this.aggression, 349 | ]); 350 | } 351 | 352 | let y = -90; 353 | for (const text of bits.reverse()) { 354 | ctx.strokeText(text, 0, y); 355 | ctx.fillText(text, 0, y); 356 | 357 | y -= 20; 358 | } 359 | } 360 | } 361 | 362 | dash(angle, distance, duration) { 363 | this.scene.add(new Interpolator(this, 'x', this.x, this.x + cos(angle) * distance, duration)); 364 | this.scene.add(new Interpolator(this, 'y', this.y, this.y + sin(angle) * distance, duration)); 365 | } 366 | 367 | die() { 368 | const duration = 1; 369 | 370 | const gibs = this.gibs.concat([true, false].map((sliceUp) => () => { 371 | ctx.slice(30, sliceUp, 0.5); 372 | ctx.translate(0, 30); 373 | this.renderBody(); 374 | })); 375 | 376 | for (const step of gibs) { 377 | const bit = this.scene.add(new Corpse(step)); 378 | bit.x = this.x; 379 | bit.y = this.y; 380 | 381 | const angle = angleBetween(this, this.controls.aim) + PI + rnd(-1, 1) * PI / 4; 382 | const distance = rnd(30, 60); 383 | this.scene.add(new Interpolator(bit, 'x', bit.x, bit.x + cos(angle) * distance, duration, easeOutQuint)); 384 | this.scene.add(new Interpolator(bit, 'y', bit.y, bit.y + sin(angle) * distance, duration, easeOutQuint)); 385 | this.scene.add(new Interpolator(bit, 'rotation', 0, pick([-1, 1]) * rnd(PI / 4, PI), duration, easeOutQuint)); 386 | } 387 | 388 | this.poof(); 389 | 390 | this.displayLabel(nomangle('Slain!'), this.damageLabelColor); 391 | 392 | this.remove(); 393 | 394 | sound(...[2.1,,400,.03,.1,.4,4,4.9,.6,.3,,,.13,1.9,,.1,.08,.32]); 395 | } 396 | 397 | poof() { 398 | for (let i = 0 ; i < 80 ; i++) { 399 | const angle = random() * TWO_PI; 400 | const dist = random() * 40; 401 | 402 | const x = this.x + cos(angle) * dist; 403 | const y = this.y - 30 + sin(angle) * dist; 404 | 405 | this.scene.add(new Particle( 406 | '#fff', 407 | [10, 20], 408 | [x, x + rnd(-20, 20)], 409 | [y, y + rnd(-20, 20)], 410 | rnd(0.5, 1), 411 | )); 412 | } 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /src/js/entities/characters/corpse.js: -------------------------------------------------------------------------------- 1 | class Corpse extends Entity { 2 | constructor(renderElement, sliceType) { 3 | super(); 4 | this.renderElement = renderElement; 5 | this.sliceType = sliceType; 6 | } 7 | 8 | get z() { 9 | return LAYER_CORPSE; 10 | } 11 | 12 | cycle(elapsed) { 13 | super.cycle(elapsed); 14 | if (this.age > 5) this.remove(); 15 | 16 | if (this.age < 0.5) { 17 | this.scene.add(new Particle( 18 | '#900', 19 | [3, 6], 20 | [this.x, this.x + rnd(-20, 20)], 21 | [this.y, this.y + rnd(-20, 20)], 22 | rnd(0.5, 1), 23 | )); 24 | } 25 | } 26 | 27 | doRender() { 28 | if (this.age > 3 && this.age % 0.25 < 0.125) return; 29 | 30 | ctx.translate(this.x, this.y); 31 | ctx.rotate(this.rotation); 32 | this.renderElement(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/js/entities/characters/dummy-enemy.js: -------------------------------------------------------------------------------- 1 | class DummyEnemy extends Enemy { 2 | constructor() { 3 | super(); 4 | this.categories.push('enemy'); 5 | 6 | this.health = LARGE_INT; 7 | } 8 | 9 | renderBody() { 10 | ctx.wrap(() => { 11 | ctx.fillStyle = ctx.resolveColor(COLOR_WOOD); 12 | ctx.fillRect(-2, 0, 4, -20); 13 | }); 14 | ctx.renderChest(this, COLOR_WOOD, CHEST_WIDTH_NAKED); 15 | ctx.renderHead(this, COLOR_WOOD); 16 | } 17 | 18 | dash() {} 19 | } 20 | -------------------------------------------------------------------------------- /src/js/entities/characters/enemy.js: -------------------------------------------------------------------------------- 1 | class Enemy extends Character { 2 | 3 | constructor() { 4 | super(); 5 | this.categories.push('enemy'); 6 | this.targetTeam = 'player'; 7 | } 8 | 9 | remove() { 10 | super.remove(); 11 | 12 | // Cancel any remaining aggression 13 | firstItem(this.scene.category('aggressivity-tracker')) 14 | .cancelAggression(this); 15 | } 16 | 17 | die() { 18 | super.die(); 19 | 20 | for (const player of this.scene.category('player')) { 21 | player.score += ~~(100 * this.aggression * player.combo); 22 | } 23 | } 24 | 25 | damage(amount) { 26 | super.damage(amount); 27 | sound(...[1.6,,278,,.01,.01,2,.7,-7.1,,,,.07,1,,,.09,.81,.08]); 28 | } 29 | } 30 | 31 | createEnemyAI = ({ 32 | shield, 33 | attackCount, 34 | }) => { 35 | class EnemyTypeAI extends EnemyAI { 36 | async doStart() { 37 | while (true) { 38 | // Try to be near the player 39 | await this.startAI(new ReachPlayer(300, 300)); 40 | 41 | // Wait for our turn to attack 42 | try { 43 | await this.race([ 44 | new Timeout(3), 45 | new BecomeAggressive(), 46 | ]); 47 | } catch (e) { 48 | // We failed to become aggressive, start a new loop 49 | continue; 50 | } 51 | 52 | await this.startAI(new BecomeAggressive()); 53 | 54 | // Okay we're allowed to be aggro, let's do it! 55 | let failedToAttack; 56 | try { 57 | await this.race([ 58 | new Timeout(500 / this.entity.baseSpeed), 59 | new ReachPlayer(this.entity.strikeRadiusX, this.entity.strikeRadiusY), 60 | ]); 61 | 62 | for (let i = attackCount ; i-- ; ) { 63 | await this.startAI(new Attack(0.5)); 64 | } 65 | await this.startAI(new Wait(0.5)); 66 | } catch (e) { 67 | failedToAttack = true; 68 | } 69 | 70 | // We're done attacking, let's allow someone else to be aggro 71 | await this.startAI(new BecomePassive()); 72 | 73 | // Retreat a bit so we're not too close to the player 74 | const dash = !shield && !failedToAttack && random() < 0.5; 75 | await this.race([ 76 | new RetreatAI(300, 300), 77 | new Wait(dash ? 0.1 : 4), 78 | dash 79 | ? new Dash() 80 | : (shield ? new HoldShield() : new AI()), 81 | ]); 82 | await this.startAI(new Wait(1)); 83 | 84 | // Rinse and repeat 85 | } 86 | } 87 | } 88 | 89 | return EnemyTypeAI; 90 | } 91 | 92 | createEnemyType = ({ 93 | stick, sword, axe, 94 | shield, armor, superArmor, 95 | attackCount, 96 | }) => { 97 | const ai = createEnemyAI({ shield, attackCount }); 98 | 99 | const weight = 0 100 | + (!!armor * 0.2) 101 | + (!!superArmor * 0.3) 102 | + (!!axe * 0.1) 103 | + (!!(sword || shield) * 0.3); 104 | 105 | const protection = 0 106 | + (!!shield * 0.3) 107 | + (!!armor * 0.5) 108 | + (!!superArmor * 0.7); 109 | 110 | class EnemyType extends Enemy { 111 | constructor() { 112 | super(); 113 | 114 | this.aggression = 1; 115 | if (sword) this.aggression += 1; 116 | if (axe) this.aggression += 2; 117 | 118 | this.health = this.maxHealth = ~~interpolate(100, 400, protection); 119 | this.strength = axe ? 35 : (sword ? 25 : 10); 120 | this.baseSpeed = interpolate(120, 50, weight); 121 | 122 | if (stick) this.gibs.push(() => ctx.renderStick()); 123 | if (sword) this.gibs.push(() => ctx.renderSword()); 124 | if (shield) this.gibs.push(() => ctx.renderShield()); 125 | if (axe) this.gibs.push(() => ctx.renderAxe()); 126 | 127 | this.stateMachine = characterStateMachine({ 128 | entity: this, 129 | chargeTime: 0.5, 130 | staggerTime: interpolate(0.3, 0.1, protection), 131 | }); 132 | } 133 | 134 | get ai() { 135 | return new ai(this); 136 | } 137 | 138 | renderBody() { 139 | ctx.renderAttackIndicator(this); 140 | ctx.renderLegs(this, COLOR_LEGS); 141 | ctx.renderArm(this, armor || superArmor ? COLOR_LEGS : COLOR_SKIN, () => { 142 | if (stick) ctx.renderStick(this) 143 | if (sword) ctx.renderSword(this); 144 | if (axe) ctx.renderAxe(this); 145 | }); 146 | ctx.renderChest( 147 | this, 148 | armor 149 | ? COLOR_ARMOR 150 | : (superArmor ? '#444' : COLOR_SKIN), 151 | CHEST_WIDTH_NAKED, 152 | ); 153 | 154 | ctx.renderHead( 155 | this, 156 | superArmor ? '#666' : COLOR_SKIN, 157 | superArmor ? '#000' : COLOR_SKIN, 158 | ); 159 | 160 | if (shield) ctx.renderArmAndShield(this, armor || superArmor ? COLOR_LEGS : COLOR_SKIN); 161 | ctx.renderExhaustion(this, -70); 162 | ctx.renderExclamation(this); 163 | } 164 | } 165 | 166 | return EnemyType; 167 | }; 168 | 169 | shield = { shield: true }; 170 | sword = { sword: true, attackCount: 2 }; 171 | stick = { stick: true, attackCount: 3 }; 172 | axe = { axe: true, attackCount: 1 }; 173 | armor = { armor: true }; 174 | superArmor = { superArmor: true }; 175 | 176 | ENEMY_TYPES = [ 177 | // Weapon 178 | StickEnemy = createEnemyType({ ...stick, }), 179 | AxeEnemy = createEnemyType({ ...axe, }), 180 | SwordEnemy = createEnemyType({ ...sword, }), 181 | 182 | // Weapon + armor 183 | SwordArmorEnemy = createEnemyType({ ...sword, ...armor, }), 184 | AxeArmorEnemy = createEnemyType({ ...axe, ...armor, }), 185 | 186 | // Weapon + armor + shield 187 | AxeShieldArmorEnemy = createEnemyType({ ...axe, ...shield, ...armor, }), 188 | SwordShieldArmorEnemy = createEnemyType({ ...sword, ...shield, ...armor, }), 189 | 190 | // Tank 191 | SwordShieldTankEnemy = createEnemyType({ ...sword, ...shield, ...superArmor, }), 192 | AxeShieldTankEnemy = createEnemyType({ ...axe, ...shield, ...superArmor, }), 193 | ]; 194 | 195 | WAVE_SETTINGS = [ 196 | ENEMY_TYPES.slice(0, 3), 197 | ENEMY_TYPES.slice(0, 4), 198 | ENEMY_TYPES.slice(0, 5), 199 | ENEMY_TYPES.slice(0, 7), 200 | ENEMY_TYPES, 201 | ]; 202 | -------------------------------------------------------------------------------- /src/js/entities/characters/king-enemy.js: -------------------------------------------------------------------------------- 1 | class KingEnemy extends Enemy { 2 | constructor() { 3 | super(); 4 | 5 | this.gibs = [ 6 | () => ctx.renderSword(), 7 | () => ctx.renderShield(), 8 | ]; 9 | 10 | this.health = this.maxHealth = 600; 11 | this.strength = 40; 12 | this.baseSpeed = 100; 13 | 14 | this.stateMachine = characterStateMachine({ 15 | entity: this, 16 | chargeTime: 0.5, 17 | staggerTime: 0.2, 18 | }); 19 | } 20 | 21 | renderBody() { 22 | ctx.renderAttackIndicator(this); 23 | ctx.renderLegs(this, '#400'); 24 | ctx.renderArm(this, '#400', () => ctx.renderSword()); 25 | ctx.renderHead(this, COLOR_SKIN); 26 | ctx.renderCrown(this); 27 | ctx.renderChest(this, '#900', CHEST_WIDTH_ARMORED); 28 | ctx.renderArmAndShield(this, '#400'); 29 | ctx.renderExhaustion(this, -70); 30 | ctx.renderExclamation(this); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/js/entities/characters/player-hud.js: -------------------------------------------------------------------------------- 1 | class PlayerHUD extends Entity { 2 | constructor(player) { 3 | super(); 4 | this.player = player; 5 | 6 | this.healthGauge = new Gauge(() => this.player.health / this.player.maxHealth); 7 | this.staminaGauge = new Gauge(() => this.player.stamina); 8 | this.progressGauge = new Gauge(() => this.progress); 9 | 10 | this.healthGauge.regenRate = 0.1; 11 | this.progressGauge.regenRate = 0.1; 12 | 13 | this.progressGauge.displayedValue = 0; 14 | 15 | this.progress = 0; 16 | this.progressAlpha = 0; 17 | 18 | this.dummyPlayer = new Player(); 19 | this.dummyKing = new KingEnemy(); 20 | 21 | this.affectedBySpeedRatio = false; 22 | } 23 | 24 | get z() { 25 | return LAYER_PLAYER_HUD; 26 | } 27 | 28 | cycle(elapsed) { 29 | super.cycle(elapsed); 30 | this.healthGauge.cycle(elapsed); 31 | this.staminaGauge.cycle(elapsed); 32 | this.progressGauge.cycle(elapsed); 33 | } 34 | 35 | doRender(camera) { 36 | this.cancelCameraOffset(camera); 37 | 38 | ctx.wrap(() => { 39 | ctx.translate(CANVAS_WIDTH / 2, 50); 40 | ctx.wrap(() => { 41 | ctx.translate(0, 10); 42 | this.staminaGauge.render(300, 20, staminaGradient, true); 43 | }); 44 | this.healthGauge.render(400, 20, healthGradient); 45 | }); 46 | 47 | ctx.wrap(() => { 48 | ctx.globalAlpha = this.progressAlpha; 49 | 50 | ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT - 150); 51 | this.progressGauge.render(600, 10, '#fff', false, WAVE_COUNT); 52 | 53 | ctx.resolveColor = () => '#fff'; 54 | ctx.shadowColor = '#000'; 55 | ctx.shadowBlur = 1; 56 | 57 | ctx.wrap(() => { 58 | ctx.translate(interpolate(-300, 300, this.progressGauge.displayedValue), 20); 59 | ctx.scale(0.5, 0.5); 60 | this.dummyPlayer.renderBody(); 61 | }); 62 | 63 | ctx.wrap(() => { 64 | ctx.translate(300, 20); 65 | ctx.scale(-0.5, 0.5); 66 | this.dummyKing.renderBody(); 67 | }); 68 | }); 69 | 70 | ctx.wrap(() => { 71 | ctx.translate(CANVAS_WIDTH / 2, 90); 72 | 73 | ctx.fillStyle = '#fff'; 74 | ctx.strokeStyle = '#000'; 75 | ctx.lineWidth = 4; 76 | ctx.textBaseline = nomangle('top'); 77 | ctx.textAlign = nomangle('center'); 78 | ctx.font = nomangle('bold 16pt Times New Roman'); 79 | ctx.strokeText(nomangle('SCORE: ') + this.player.score.toLocaleString(), 0, 0); 80 | ctx.fillText(nomangle('SCORE: ') + this.player.score.toLocaleString(), 0, 0); 81 | }); 82 | 83 | if (this.player.combo > 0) { 84 | ctx.wrap(() => { 85 | ctx.translate(CANVAS_WIDTH / 2 + 200, 70); 86 | 87 | ctx.fillStyle = '#fff'; 88 | ctx.strokeStyle = '#000'; 89 | ctx.lineWidth = 4; 90 | ctx.textBaseline = nomangle('middle'); 91 | ctx.textAlign = nomangle('right'); 92 | ctx.font = nomangle('bold 36pt Times New Roman'); 93 | 94 | ctx.rotate(-PI / 32); 95 | 96 | const ratio = min(1, (this.player.age - this.player.lastComboChange) / 0.1); 97 | ctx.scale(1 + 1 - ratio, 1 + 1 - ratio); 98 | 99 | ctx.strokeText('X' + this.player.combo, 0, 0); 100 | ctx.fillText('X' + this.player.combo, 0, 0); 101 | }); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/js/entities/characters/player.js: -------------------------------------------------------------------------------- 1 | FOV_GRADIENT = [0, 255].map(red => createCanvas(1, 1, ctx => { 2 | const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, PLAYER_MAGNET_RADIUS); 3 | grad.addColorStop(0, 'rgba(' + red + ',0,0,.1)'); 4 | grad.addColorStop(1, 'rgba(' + red + ',0,0,0)'); 5 | return grad; 6 | })); 7 | 8 | class Player extends Character { 9 | constructor() { 10 | super(); 11 | this.categories.push('player'); 12 | 13 | this.targetTeam = 'enemy'; 14 | 15 | this.score = 0; 16 | 17 | this.baseSpeed = 250; 18 | this.strength = 30; 19 | 20 | this.staminaRecoveryDelay = 2; 21 | 22 | this.magnetRadiusX = this.magnetRadiusY = PLAYER_MAGNET_RADIUS; 23 | 24 | this.affectedBySpeedRatio = false; 25 | 26 | this.damageLabelColor = '#f00'; 27 | 28 | this.gibs = [ 29 | () => ctx.renderSword(), 30 | () => ctx.renderShield(), 31 | ]; 32 | 33 | this.stateMachine = characterStateMachine({ 34 | entity: this, 35 | chargeTime: PLAYER_HEAVY_CHARGE_TIME, 36 | perfectParryTime: PLAYER_PERFECT_PARRY_TIME, 37 | releaseAttackBetweenStrikes: true, 38 | staggerTime: 0.2, 39 | }); 40 | } 41 | 42 | get ai() { 43 | return new PlayerController(); 44 | } 45 | 46 | damage(amount) { 47 | super.damage(amount); 48 | sound(...[2.07,,71,.01,.05,.03,2,.14,,,,,.01,1.5,,.1,.19,.95,.05,.16]); 49 | } 50 | 51 | getColor(color) { 52 | return this.age - this.lastDamage < 0.1 ? '#f00' : super.getColor(color); 53 | } 54 | 55 | heal(amount) { 56 | amount = ~~min(this.maxHealth - this.health, amount); 57 | this.health += amount 58 | 59 | for (let i = amount ; --i > 0 ;) { 60 | setTimeout(() => { 61 | const angle = random() * TWO_PI; 62 | const dist = random() * 40; 63 | 64 | const x = this.x + rnd(-10, 10); 65 | const y = this.y - 30 + sin(angle) * dist; 66 | 67 | this.scene.add(new Particle( 68 | '#0f0', 69 | [5, 10], 70 | [x, x + rnd(-10, 10)], 71 | [y, y + rnd(-30, -60)], 72 | rnd(1, 1.5), 73 | )); 74 | }, i * 100); 75 | } 76 | } 77 | 78 | render() { 79 | const victim = this.pickVictim(this.magnetRadiusX, this.magnetRadiusY, PI / 2); 80 | if (victim) { 81 | ctx.wrap(() => { 82 | if (RENDER_SCREENSHOT) return; 83 | 84 | ctx.globalAlpha = 0.2; 85 | ctx.strokeStyle = '#f00'; 86 | ctx.lineWidth = 5; 87 | ctx.setLineDash([10, 10]); 88 | ctx.beginPath(); 89 | ctx.moveTo(this.x, this.y); 90 | ctx.lineTo(victim.x, victim.y); 91 | ctx.stroke(); 92 | }); 93 | } 94 | 95 | ctx.wrap(() => { 96 | if (RENDER_SCREENSHOT) return; 97 | 98 | ctx.translate(this.x, this.y); 99 | 100 | const aimAngle = angleBetween(this, this.controls.aim); 101 | ctx.fillStyle = FOV_GRADIENT[+!!victim]; 102 | ctx.beginPath(); 103 | ctx.arc(0, 0, this.magnetRadiusX, aimAngle - PI / 4, aimAngle + PI / 4); 104 | ctx.lineTo(0, 0); 105 | ctx.fill(); 106 | }); 107 | 108 | if (DEBUG && DEBUG_PLAYER_MAGNET) { 109 | ctx.wrap(() => { 110 | ctx.fillStyle = '#0f0'; 111 | for (let x = this.x - this.magnetRadiusX - 20 ; x < this.x + this.magnetRadiusX + 20 ; x += 4) { 112 | for (let y = this.y - this.magnetRadiusY - 20 ; y < this.y + this.magnetRadiusY + 20 ; y += 4) { 113 | ctx.globalAlpha = this.strikability({ x, y }, this.magnetRadiusX, this.magnetRadiusY, PI / 2); 114 | ctx.fillRect(x - 2, y - 2, 4, 4); 115 | } 116 | } 117 | }); 118 | ctx.wrap(() => { 119 | for (const victim of this.scene.category(this.targetTeam)) { 120 | const strikability = this.strikability(victim, this.magnetRadiusX, this.magnetRadiusY, PI / 2); 121 | if (!strikability) continue; 122 | ctx.lineWidth = strikability * 30; 123 | ctx.strokeStyle = '#ff0'; 124 | ctx.beginPath(); 125 | ctx.moveTo(this.x, this.y); 126 | ctx.lineTo(victim.x, victim.y); 127 | ctx.stroke(); 128 | } 129 | }); 130 | } 131 | 132 | super.render(); 133 | } 134 | 135 | renderBody() { 136 | ctx.renderLegs(this, COLOR_LEGS); 137 | ctx.renderArm(this, COLOR_LEGS, () => ctx.renderSword()); 138 | ctx.renderHead(this, COLOR_SKIN); 139 | ctx.renderChest(this, COLOR_ARMOR, CHEST_WIDTH_ARMORED); 140 | ctx.renderArmAndShield(this, COLOR_LEGS); 141 | ctx.renderExhaustion(this, -70); 142 | } 143 | } 144 | 145 | class PlayerController extends CharacterController { 146 | // get description() { 147 | // return 'Player'; 148 | // } 149 | 150 | cycle() { 151 | let x = 0, y = 0; 152 | if (DOWN[37] || DOWN[65]) x = -1; 153 | if (DOWN[38] || DOWN[87]) y = -1; 154 | if (DOWN[39] || DOWN[68]) x = 1; 155 | if (DOWN[40] || DOWN[83]) y = 1; 156 | 157 | const camera = firstItem(this.entity.scene.category('camera')); 158 | 159 | if (x || y) this.entity.controls.angle = atan2(y, x); 160 | this.entity.controls.force = x || y ? 1 : 0; 161 | this.entity.controls.shield = DOWN[16] || MOUSE_RIGHT_DOWN || TOUCH_SHIELD_BUTTON.down; 162 | this.entity.controls.attack = MOUSE_DOWN || TOUCH_ATTACK_BUTTON.down; 163 | this.entity.controls.dash = DOWN[32] || DOWN[17] || TOUCH_DASH_BUTTON.down; 164 | 165 | const mouseRelX = (MOUSE_POSITION.x - CANVAS_WIDTH / 2) / (CANVAS_WIDTH / 2); 166 | const mouseRelY = (MOUSE_POSITION.y - CANVAS_HEIGHT / 2) / (CANVAS_HEIGHT / 2); 167 | 168 | this.entity.controls.aim.x = this.entity.x + mouseRelX * CANVAS_WIDTH / 2 / camera.appliedZoom; 169 | this.entity.controls.aim.y = this.entity.y + mouseRelY * CANVAS_HEIGHT / 2 / camera.appliedZoom; 170 | 171 | if (inputMode == INPUT_MODE_TOUCH) { 172 | const { touch } = TOUCH_JOYSTICK; 173 | this.entity.controls.aim.x = this.entity.x + (touch.x - TOUCH_JOYSTICK.x); 174 | this.entity.controls.aim.y = this.entity.y + (touch.y - TOUCH_JOYSTICK.y); 175 | 176 | this.entity.controls.angle = angleBetween(TOUCH_JOYSTICK, touch); 177 | this.entity.controls.force = TOUCH_JOYSTICK.touchIdentifier < 0 178 | ? 0 179 | : min(1, dist(touch, TOUCH_JOYSTICK) / TOUCH_JOYSTICK_RADIUS); 180 | } 181 | 182 | if (x) this.entity.facing = x; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/js/entities/cursor.js: -------------------------------------------------------------------------------- 1 | class Cursor extends Entity { 2 | constructor(player) { 3 | super(); 4 | this.player = player; 5 | } 6 | 7 | get z() { 8 | return LAYER_PLAYER_HUD; 9 | } 10 | 11 | doRender() { 12 | if (inputMode == INPUT_MODE_TOUCH) return; 13 | ctx.translate(this.player.controls.aim.x, this.player.controls.aim.y); 14 | 15 | ctx.fillStyle = '#000'; 16 | ctx.rotate(PI / 4); 17 | ctx.fillRect(-15, -5, 30, 10); 18 | ctx.rotate(PI / 2); 19 | ctx.fillRect(-15, -5, 30, 10); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/js/entities/entity.js: -------------------------------------------------------------------------------- 1 | class Entity { 2 | constructor() { 3 | this.x = this.y = this.rotation = this.age = 0; 4 | this.categories = []; 5 | 6 | this.rng = new RNG(); 7 | 8 | this.renderPadding = Infinity; 9 | 10 | this.affectedBySpeedRatio = true; 11 | } 12 | 13 | get z() { 14 | return this.y; 15 | } 16 | 17 | get inWater() { 18 | if (this.scene) 19 | for (const water of this.scene.category('water')) { 20 | if (water.contains(this)) return true; 21 | } 22 | } 23 | 24 | cycle(elapsed) { 25 | this.age += elapsed; 26 | } 27 | 28 | render() { 29 | const camera = firstItem(this.scene.category('camera')); 30 | if ( 31 | isBetween(camera.x - CANVAS_WIDTH / 2 - this.renderPadding, this.x, camera.x + CANVAS_WIDTH / 2 + this.renderPadding) && 32 | isBetween(camera.y - CANVAS_HEIGHT / 2 - this.renderPadding, this.y, camera.y + CANVAS_HEIGHT / 2 + this.renderPadding) 33 | ) { 34 | this.rng.reset(); 35 | this.doRender(camera); 36 | } 37 | } 38 | 39 | doRender(camera) { 40 | 41 | } 42 | 43 | remove() { 44 | this.scene.remove(this); 45 | } 46 | 47 | cancelCameraOffset(camera) { 48 | ctx.translate(camera.x, camera.y); 49 | ctx.scale(1 / camera.appliedZoom, 1 / camera.appliedZoom); 50 | ctx.translate(evaluate(-CANVAS_WIDTH / 2), evaluate(-CANVAS_HEIGHT / 2)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/js/entities/interpolator.js: -------------------------------------------------------------------------------- 1 | class Interpolator extends Entity { 2 | 3 | constructor( 4 | object, 5 | property, 6 | fromValue, 7 | toValue, 8 | duration, 9 | easing = linear, 10 | ) { 11 | super(); 12 | this.object = object; 13 | this.property = property; 14 | this.fromValue = fromValue; 15 | this.toValue = toValue; 16 | this.duration = duration; 17 | this.easing = easing; 18 | 19 | this.affectedBySpeedRatio = object.affectedBySpeedRatio; 20 | 21 | this.cycle(0); 22 | } 23 | 24 | await() { 25 | return new Promise(resolve => this.resolve = resolve); 26 | } 27 | 28 | cycle(elapsed) { 29 | super.cycle(elapsed); 30 | 31 | const progress = this.age / this.duration; 32 | 33 | this.object[this.property] = interpolate(this.fromValue, this.toValue, this.easing(progress)); 34 | 35 | if (progress > 1) { 36 | this.remove(); 37 | if (this.resolve) this.resolve(); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/js/entities/path.js: -------------------------------------------------------------------------------- 1 | class Path extends Entity { 2 | 3 | get z() { 4 | return LAYER_PATH; 5 | } 6 | 7 | doRender(camera) { 8 | ctx.strokeStyle = '#dc9'; 9 | ctx.lineWidth = 70; 10 | 11 | ctx.fillStyle = '#fff'; 12 | 13 | ctx.beginPath(); 14 | for (let x = roundToNearest(camera.x - CANVAS_WIDTH * 2, 300) ; x < camera.x + CANVAS_WIDTH ; x += 300) { 15 | const y = this.scene.pathCurve(x); 16 | ctx.lineTo(x, y); 17 | } 18 | ctx.stroke(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/js/entities/props/bush.js: -------------------------------------------------------------------------------- 1 | class Bush extends Entity { 2 | 3 | constructor() { 4 | super(); 5 | this.renderPadding = 100; 6 | } 7 | 8 | cycle(elapsed) { 9 | super.cycle(elapsed); 10 | regenEntity(this, CANVAS_WIDTH / 2 + 50, CANVAS_HEIGHT / 2 + 50); 11 | } 12 | 13 | doRender() { 14 | ctx.translate(this.x, this.y); 15 | 16 | ctx.withShadow(() => { 17 | this.rng.reset(); 18 | 19 | let x = 0; 20 | for (let i = 0 ; i < 5 ; i++) { 21 | ctx.wrap(() => { 22 | ctx.fillStyle = ctx.resolveColor('green'); 23 | ctx.translate(x, 0); 24 | ctx.rotate(sin((this.age + this.rng.next(0, 5)) * TWO_PI / this.rng.next(4, 8)) * this.rng.next(PI / 32, PI / 16)); 25 | ctx.fillRect(-10, 0, 20, -this.rng.next(20, 60)); 26 | }); 27 | 28 | x += this.rng.next(5, 15); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/js/entities/props/grass.js: -------------------------------------------------------------------------------- 1 | class Grass extends Entity { 2 | 3 | constructor() { 4 | super(); 5 | this.renderPadding = 100; 6 | } 7 | 8 | cycle(elapsed) { 9 | super.cycle(elapsed); 10 | regenEntity(this, CANVAS_WIDTH / 2 + 50, CANVAS_HEIGHT / 2 + 50); 11 | } 12 | 13 | doRender() { 14 | ctx.translate(this.x, this.y); 15 | 16 | ctx.withShadow(() => { 17 | this.rng.reset(); 18 | 19 | let x = 0; 20 | for (let i = 0 ; i < (inputMode == INPUT_MODE_TOUCH ? 2 : 5) ; i++) { 21 | ctx.wrap(() => { 22 | ctx.fillStyle = ctx.resolveColor('#ab8'); 23 | ctx.translate(x, 0); 24 | ctx.rotate(sin((this.age + this.rng.next(0, 5)) * TWO_PI / this.rng.next(4, 8)) * this.rng.next(PI / 16, PI / 4)); 25 | ctx.fillRect(-2, 0, 4, -this.rng.next(5, 30)); 26 | }); 27 | 28 | x += this.rng.next(5, 15); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/js/entities/props/obstacle.js: -------------------------------------------------------------------------------- 1 | class Obstacle extends Entity { 2 | 3 | constructor() { 4 | super(); 5 | this.categories.push('obstacle'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/js/entities/props/tree.js: -------------------------------------------------------------------------------- 1 | class Tree extends Obstacle { 2 | 3 | constructor() { 4 | super(); 5 | 6 | this.trunkWidth = this.rng.next(10, 20); 7 | this.trunkHeight = this.rng.next(100, 250); 8 | 9 | this.collisionRadius = 20; 10 | this.alpha = 1; 11 | 12 | this.renderPadding = this.trunkHeight + 60; 13 | } 14 | 15 | cycle(elapsed) { 16 | super.cycle(elapsed); 17 | 18 | if (!this.noRegen) regenEntity(this, CANVAS_WIDTH / 2 + 200, CANVAS_HEIGHT / 2 + 400); 19 | 20 | this.rng.reset(); 21 | 22 | let targetAlpha = 1; 23 | for (const character of this.scene.category('player')) { 24 | if ( 25 | isBetween(this.x - 100, character.x, this.x + 100) && 26 | isBetween(this.y - this.trunkHeight - 50, character.y, this.y) 27 | ) { 28 | targetAlpha = 0.2; 29 | break; 30 | } 31 | } 32 | 33 | this.alpha += between(-elapsed * 2, targetAlpha - this.alpha, elapsed * 2); 34 | } 35 | 36 | doRender() { 37 | ctx.translate(this.x, this.y); 38 | 39 | ctx.withShadow(() => { 40 | this.rng.reset(); 41 | 42 | ctx.wrap(() => { 43 | ctx.rotate(sin((this.age + this.rng.next(0, 10)) * TWO_PI / this.rng.next(4, 16)) * this.rng.next(PI / 32, PI / 64)); 44 | ctx.fillStyle = ctx.resolveColor('#a65'); 45 | 46 | if (!ctx.isShadow) { 47 | ctx.globalAlpha = this.alpha; 48 | } 49 | 50 | if (!ctx.isShadow) ctx.fillRect(0, 0, this.trunkWidth, -this.trunkHeight); 51 | 52 | ctx.translate(0, -this.trunkHeight); 53 | 54 | ctx.beginPath(); 55 | ctx.fillStyle = ctx.resolveColor('#060'); 56 | 57 | for (let i = 0 ; i < 5 ; i++) { 58 | const angle = i / 5 * TWO_PI; 59 | const dist = this.rng.next(20, 50); 60 | const x = cos(angle) * dist; 61 | const y = sin(angle) * dist * 0.5; 62 | const radius = this.rng.next(20, 40); 63 | 64 | ctx.wrap(() => { 65 | ctx.translate(x, y); 66 | ctx.rotate(PI / 4); 67 | ctx.rotate(sin((this.age + this.rng.next(0, 10)) * TWO_PI / this.rng.next(2, 8)) * PI / 32); 68 | ctx.rect(-radius, -radius, radius * 2, radius * 2); 69 | }); 70 | } 71 | 72 | if (ctx.isShadow) ctx.rect(0, 0, this.trunkWidth, this.trunkHeight); 73 | 74 | ctx.fill(); 75 | }); 76 | 77 | ctx.clip(); 78 | 79 | if (!ctx.isShadow) { 80 | for (const character of this.scene.category('enemy')) { 81 | if ( 82 | isBetween(this.x - 100, character.x, this.x + 100) && 83 | isBetween(this.y - this.trunkHeight - 50, character.y, this.y) 84 | ) { 85 | ctx.resolveColor = () => character instanceof Player ? '#888' : '#400'; 86 | ctx.wrap(() => { 87 | ctx.translate(character.x - this.x, character.y - this.y); 88 | ctx.scale(character.facing, 1); 89 | ctx.globalAlpha = this.alpha; 90 | character.renderBody(); 91 | }); 92 | } 93 | } 94 | } 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/js/entities/props/water.js: -------------------------------------------------------------------------------- 1 | class Water extends Entity { 2 | constructor() { 3 | super(); 4 | this.categories.push('water'); 5 | this.width = this.height = 0; 6 | } 7 | 8 | get z() { 9 | return LAYER_WATER; 10 | } 11 | 12 | get inWater() { 13 | return false; 14 | } 15 | 16 | cycle(elapsed) { 17 | super.cycle(elapsed); 18 | this.renderPadding = max(this.width, this.height) / 2; 19 | regenEntity(this, CANVAS_WIDTH * 2, CANVAS_HEIGHT * 2, max(this.width, this.height)); 20 | } 21 | 22 | contains(point) { 23 | const xInSelf = point.x - this.x; 24 | const yInSelf = point.y - this.y; 25 | 26 | const xInSelfRotated = xInSelf * cos(this.rotation) + yInSelf * sin(this.rotation); 27 | const yInSelfRotated = -xInSelf * sin(this.rotation) + yInSelf * cos(this.rotation); 28 | 29 | return abs(xInSelfRotated) < this.width / 2 && abs(yInSelfRotated) < this.height / 2; 30 | } 31 | 32 | doRender() { 33 | this.rng.reset(); 34 | 35 | ctx.wrap(() => { 36 | ctx.fillStyle = '#08a'; 37 | ctx.translate(this.x, this.y); 38 | ctx.rotate(this.rotation); 39 | ctx.beginPath(); 40 | ctx.rect(-this.width / 2, -this.height / 2, this.width, this.height); 41 | ctx.fill(); 42 | ctx.clip(); 43 | 44 | // Ripples 45 | ctx.rotate(-this.rotation); 46 | ctx.scale(1, 0.5); 47 | ctx.strokeStyle = '#fff'; 48 | ctx.lineWidth = 4; 49 | 50 | for (let i = 3; i-- ; ) { 51 | const relativeAge = (this.age + this.rng.next(0, 20)) / RIPPLE_DURATION; 52 | const ratio = min(1, relativeAge % (RIPPLE_DURATION / 2)); 53 | 54 | ctx.globalAlpha = (1 - ratio) / 2; 55 | ctx.beginPath(); 56 | ctx.arc( 57 | ((this.rng.next(0, this.width) + ~~relativeAge * this.width * 0.7) % this.width) - this.width / 2, 58 | ((this.rng.next(0, this.height) + ~~relativeAge * this.height * 0.7) % this.height) - this.width / 2, 59 | ratio * this.rng.next(20, 60), 60 | 0, 61 | TWO_PI, 62 | ); 63 | ctx.stroke(); 64 | } 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/js/entities/ui/announcement.js: -------------------------------------------------------------------------------- 1 | class Announcement extends Entity { 2 | constructor(text) { 3 | super(); 4 | this.text = text; 5 | this.affectedBySpeedRatio = false; 6 | } 7 | 8 | get z() { 9 | return LAYER_LOGO; 10 | } 11 | 12 | cycle(elapsed) { 13 | super.cycle(elapsed); 14 | if (this.age > 5) this.remove(); 15 | } 16 | 17 | doRender(camera) { 18 | this.cancelCameraOffset(camera); 19 | 20 | ctx.globalAlpha = this.age < 1 21 | ? interpolate(0, 1, this.age) 22 | : interpolate(1, 0, this.age - 4); 23 | 24 | ctx.wrap(() => { 25 | ctx.translate(40, evaluate(CANVAS_HEIGHT - 40)); 26 | 27 | ctx.fillStyle = '#fff'; 28 | ctx.strokeStyle = '#000'; 29 | ctx.lineWidth = 4; 30 | ctx.textAlign = nomangle('left'); 31 | ctx.textBaseline = nomangle('alphabetic'); 32 | ctx.font = nomangle('72pt Times New Roman'); 33 | ctx.strokeText(this.text, 0, 0); 34 | ctx.fillText(this.text, 0, 0); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/js/entities/ui/exposition.js: -------------------------------------------------------------------------------- 1 | class Exposition extends Entity { 2 | 3 | constructor(text) { 4 | super(); 5 | this.text = text; 6 | this.alpha = 1; 7 | } 8 | 9 | get z() { 10 | return LAYER_INSTRUCTIONS; 11 | } 12 | 13 | doRender(camera) { 14 | if (!this.text) return; 15 | 16 | this.cancelCameraOffset(camera); 17 | 18 | ctx.translate(150, evaluate(CANVAS_HEIGHT / 2)); 19 | 20 | ctx.textBaseline = nomangle('middle'); 21 | ctx.textAlign = nomangle('left'); 22 | ctx.fillStyle = '#fff'; 23 | ctx.font = nomangle('24pt Times New Roman'); 24 | 25 | let y = -this.text.length / 2 * 50; 26 | let lineIndex = 0; 27 | for (const line of this.text) { 28 | ctx.globalAlpha = between(0, (this.age - lineIndex * 3), 1) * this.alpha; 29 | ctx.fillText(line, 0, y); 30 | y += 75; 31 | lineIndex++; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/js/entities/ui/fade.js: -------------------------------------------------------------------------------- 1 | class Fade extends Entity { 2 | constructor() { 3 | super(); 4 | this.alpha = 1; 5 | } 6 | 7 | get z() { 8 | return LAYER_FADE; 9 | } 10 | 11 | doRender(camera) { 12 | this.cancelCameraOffset(camera); 13 | 14 | ctx.fillStyle = '#000'; 15 | ctx.globalAlpha = this.alpha; 16 | ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/js/entities/ui/instruction.js: -------------------------------------------------------------------------------- 1 | class Instruction extends Entity { 2 | 3 | get z() { 4 | return LAYER_INSTRUCTIONS; 5 | } 6 | 7 | cycle(elapsed) { 8 | super.cycle(elapsed); 9 | 10 | if (this.text != this.previousText) { 11 | this.previousText = this.text; 12 | this.textAge = 0; 13 | } 14 | this.textAge += elapsed; 15 | } 16 | 17 | doRender(camera) { 18 | if (!this.text || GAME_PAUSED) return; 19 | 20 | this.cancelCameraOffset(camera); 21 | 22 | ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT * 5 / 6); 23 | 24 | ctx.scale( 25 | interpolate(1.2, 1, this.textAge * 8), 26 | interpolate(1.2, 1, this.textAge * 8), 27 | ); 28 | ctx.renderInstruction(this.text); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/js/entities/ui/label.js: -------------------------------------------------------------------------------- 1 | class Label extends Entity { 2 | constructor(text, color = '#fff') { 3 | super(); 4 | this.text = text.toUpperCase(); 5 | this.color = color; 6 | } 7 | 8 | get z() { 9 | return LAYER_PLAYER_HUD; 10 | } 11 | 12 | cycle(elapsed) { 13 | super.cycle(elapsed); 14 | if (this.age > 1 && !this.infinite) this.remove(); 15 | } 16 | 17 | doRender() { 18 | ctx.translate(this.x, interpolate(this.y + 20, this.y, this.age / 0.25)); 19 | if (!this.infinite) ctx.globalAlpha = interpolate(0, 1, this.age / 0.25); 20 | 21 | ctx.font = nomangle('bold 14pt Arial'); 22 | ctx.fillStyle = this.color; 23 | ctx.strokeStyle = '#000'; 24 | ctx.lineWidth = 3; 25 | ctx.textAlign = nomangle('center'); 26 | ctx.textBaseline = nomangle('middle'); 27 | 28 | ctx.shadowColor = '#000'; 29 | ctx.shadowOffsetX = ctx.shadowOffsetY = 1; 30 | 31 | ctx.strokeText(this.text, 0, 0); 32 | ctx.fillText(this.text, 0, 0); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/js/entities/ui/logo.js: -------------------------------------------------------------------------------- 1 | class Logo extends Entity { 2 | constructor() { 3 | super(); 4 | this.alpha = 1; 5 | } 6 | 7 | get z() { 8 | return LAYER_LOGO; 9 | } 10 | 11 | doRender(camera) { 12 | if (GAME_PAUSED) return; 13 | 14 | ctx.globalAlpha = this.alpha; 15 | 16 | ctx.wrap(() => { 17 | this.cancelCameraOffset(camera); 18 | 19 | ctx.fillStyle = '#000'; 20 | ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); 21 | 22 | ctx.translate(evaluate(CANVAS_WIDTH / 2), evaluate(CANVAS_HEIGHT / 3)); 23 | ctx.renderLargeText([ 24 | [nomangle('P'), 192, -30], 25 | [nomangle('ATH'), 96, 30], 26 | [nomangle('TO'), 36, 20], 27 | [nomangle('G'), 192], 28 | [nomangle('LORY'), 96], 29 | ]); 30 | }); 31 | 32 | for (const player of this.scene.category('player')) { 33 | player.doRender(camera); 34 | if (BEATEN) ctx.renderCrown(player); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/js/entities/ui/pause-overlay.js: -------------------------------------------------------------------------------- 1 | class PauseOverlay extends Entity { 2 | get z() { 3 | return LAYER_LOGO + 1; 4 | } 5 | 6 | doRender(camera) { 7 | if (!GAME_PAUSED) return; 8 | 9 | this.cancelCameraOffset(camera); 10 | 11 | ctx.fillStyle = 'rgba(0,0,0,0.5)'; 12 | ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); 13 | 14 | ctx.wrap(() => { 15 | ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 3); 16 | 17 | ctx.renderLargeText([ 18 | [nomangle('G'), 192], 19 | [nomangle('AME'), 96, 30], 20 | [nomangle('P'), 192, -30], 21 | [nomangle('AUSED'), 96], 22 | ]); 23 | }); 24 | 25 | ctx.wrap(() => { 26 | ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT * 3 / 4); 27 | ctx.renderInstruction(nomangle('[P] or [ESC] to resume')); 28 | }); 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/js/globals.js: -------------------------------------------------------------------------------- 1 | const w = window; 2 | 3 | let can; 4 | let ctx; 5 | 6 | let GAME_PAUSED; 7 | let BEATEN; 8 | 9 | canvasPrototype = CanvasRenderingContext2D.prototype; 10 | 11 | inputMode = navigator.userAgent.match(nomangle(/andro|ipho|ipa|ipo/i)) ? INPUT_MODE_TOUCH : INPUT_MODE_MOUSE; 12 | -------------------------------------------------------------------------------- /src/js/graphics/characters/body.js: -------------------------------------------------------------------------------- 1 | canvasPrototype.renderSword = function() { 2 | with (this) wrap(() => { 3 | fillStyle = resolveColor('#444'); 4 | fillRect(-10, -2, 20, 4); 5 | fillRect(-3, 0, 6, 12); 6 | 7 | fillStyle = resolveColor('#fff'); 8 | beginPath(); 9 | moveTo(-3, 0); 10 | lineTo(-5, -35); 11 | lineTo(0, -40); 12 | lineTo(5, -35); 13 | lineTo(3, 0); 14 | fill(); 15 | }); 16 | }; 17 | 18 | canvasPrototype.renderAxe = function() { 19 | with (this) wrap(() => { 20 | fillStyle = resolveColor(COLOR_WOOD); 21 | fillRect(-2, 12, 4, -40); 22 | 23 | translate(0, -20); 24 | 25 | const radius = 10; 26 | 27 | fillStyle = resolveColor('#eee'); 28 | 29 | beginPath(); 30 | arc(0, 0, radius, -PI / 4, PI / 4); 31 | arc(0, radius * hypot(1, 1), radius, -PI / 4, -PI * 3 / 4, true); 32 | arc(0, 0, radius, PI * 3 / 4, -PI * 3 / 4); 33 | arc(0, -radius * hypot(1, 1), radius, PI * 3 / 4, PI / 4, true); 34 | fill(); 35 | }); 36 | }; 37 | 38 | canvasPrototype.renderShield = function() { 39 | with (this) wrap(() => { 40 | fillStyle = resolveColor('#fff'); 41 | 42 | for (const [bitScale, col] of [[0.8, resolveColor('#fff')], [0.6, resolveColor('#888')]]) { 43 | fillStyle = col; 44 | scale(bitScale, bitScale); 45 | beginPath(); 46 | moveTo(0, -15); 47 | lineTo(15, -10); 48 | lineTo(12, 10); 49 | lineTo(0, 25); 50 | lineTo(-12, 10); 51 | lineTo(-15, -10); 52 | fill(); 53 | } 54 | }); 55 | }; 56 | 57 | canvasPrototype.renderLegs = function(entity, color) { 58 | with (this) wrap(() => { 59 | const { age } = entity; 60 | 61 | translate(0, -32); 62 | 63 | // Left leg 64 | wrap(() => { 65 | fillStyle = resolveColor(color); 66 | translate(-6, 12); 67 | if (entity.controls.force) rotate(-sin(age * TWO_PI * 4) * PI / 16); 68 | fillRect(-4, 0, 8, 20); 69 | }); 70 | 71 | // Right leg 72 | wrap(() => { 73 | fillStyle = resolveColor(color); 74 | translate(6, 12); 75 | if (entity.controls.force) rotate(sin(age * TWO_PI * 4) * PI / 16); 76 | fillRect(-4, 0, 8, 20); 77 | }); 78 | }); 79 | }; 80 | 81 | canvasPrototype.renderChest = function(entity, color, width = 25) { 82 | with (this) wrap(() => { 83 | const { renderAge } = entity; 84 | 85 | translate(0, -32); 86 | 87 | // Breathing 88 | translate(0, sin(renderAge * TWO_PI / 5) * 0.5); 89 | rotate(sin(renderAge * TWO_PI / 5) * PI / 128); 90 | 91 | fillStyle = resolveColor(color); 92 | if (entity.controls.force) rotate(-sin(renderAge * TWO_PI * 4) * PI / 64); 93 | fillRect(-width / 2, -15, width, 30); 94 | }); 95 | } 96 | 97 | canvasPrototype.renderHead = function(entity, color, slitColor = null) { 98 | with (this) wrap(() => { 99 | const { renderAge } = entity; 100 | 101 | fillStyle = resolveColor(color); 102 | translate(0, -54); 103 | if (entity.controls.force) rotate(-sin(renderAge * TWO_PI * 4) * PI / 32); 104 | fillRect(-6, -7, 12, 15); 105 | 106 | fillStyle = resolveColor(slitColor); 107 | if (slitColor) fillRect(4, -5, -6, 4); 108 | }); 109 | } 110 | 111 | canvasPrototype.renderCrown = function(entity) { 112 | with (this) wrap(() => { 113 | fillStyle = resolveColor('#ff0'); 114 | translate(0, -70); 115 | 116 | beginPath(); 117 | lineTo(-8, 0); 118 | lineTo(-4, 6); 119 | lineTo(0, 0); 120 | lineTo(4, 6); 121 | lineTo(8, 0); 122 | lineTo(8, 12); 123 | lineTo(-8, 12); 124 | fill(); 125 | }); 126 | } 127 | 128 | canvasPrototype.renderStick = function() { 129 | this.fillStyle = this.resolveColor('#444'); 130 | this.fillRect(-3, 10, 6, -40); 131 | } 132 | 133 | canvasPrototype.renderArm = function(entity, color, renderTool) { 134 | with (this) wrap(() => { 135 | if (!entity.health) return; 136 | 137 | const { renderAge } = entity; 138 | 139 | translate(11, -42); 140 | 141 | fillStyle = resolveColor(color); 142 | if (entity.controls.force) rotate(-sin(renderAge * TWO_PI * 4) * PI / 32); 143 | rotate(entity.stateMachine.state.swordRaiseRatio * PI / 2); 144 | 145 | // Breathing 146 | rotate(sin(renderAge * TWO_PI / 5) * PI / 32); 147 | 148 | fillRect(0, -3, 20, 6); 149 | 150 | translate(18, -6); 151 | renderTool(); 152 | }); 153 | } 154 | 155 | canvasPrototype.renderArmAndShield = function(entity, armColor) { 156 | with (this) wrap(() => { 157 | const { renderAge } = entity; 158 | 159 | translate(0, -32); 160 | 161 | fillStyle = resolveColor(armColor); 162 | translate(-10, -8); 163 | if (entity.controls.force) rotate(-sin(renderAge * TWO_PI * 4) * PI / 32); 164 | rotate(PI / 3); 165 | rotate(entity.stateMachine.state.shieldRaiseRatio * -PI / 3); 166 | 167 | // Breathing 168 | rotate(sin(renderAge * TWO_PI / 5) * PI / 64); 169 | 170 | const armLength = 10 + 15 * entity.stateMachine.state.shieldRaiseRatio; 171 | fillRect(0, -3, armLength, 6); 172 | 173 | // Shield 174 | wrap(() => { 175 | translate(armLength, 0); 176 | renderShield(); 177 | }); 178 | }); 179 | }; 180 | 181 | canvasPrototype.renderExhaustion = function(entity, y) { 182 | if (!entity.health) return; 183 | 184 | if (entity.stateMachine.state.exhausted) { 185 | this.wrap(() => { 186 | this.translate(0, y); 187 | this.fillStyle = this.resolveColor('#ff0'); 188 | for (let r = 0 ; r < 1 ; r += 0.15) { 189 | const angle = r * TWO_PI + entity.age * PI; 190 | this.fillRect(cos(angle) * 15, sin(angle) * 15 * 0.5, 4, 4); 191 | } 192 | }); 193 | } 194 | }; 195 | 196 | canvasPrototype.renderAttackIndicator = function(entity) { 197 | if (RENDER_SCREENSHOT) return; 198 | 199 | with (this) wrap(() => { 200 | if (!entity.health) return; 201 | 202 | const progress = entity.stateMachine.state.attackPreparationRatio; 203 | if (progress > 0 && !this.isShadow) { 204 | strokeStyle = 'rgba(255,0,0,1)'; 205 | fillStyle = 'rgba(255,0,0,.5)'; 206 | globalAlpha = interpolate(0.5, 0, progress); 207 | lineWidth = 10; 208 | beginPath(); 209 | scale(1 - progress, 1 - progress); 210 | ellipse(0, 0, entity.strikeRadiusX, entity.strikeRadiusY, 0, 0, TWO_PI); 211 | fill(); 212 | stroke(); 213 | } 214 | }); 215 | }; 216 | 217 | canvasPrototype.renderExclamation = function(entity) { 218 | with (this) wrap(() => { 219 | if (!entity.health) return; 220 | 221 | translate(0, -100 + pick([-2, 2])); 222 | 223 | if (entity.stateMachine.state.attackPreparationRatio > 0 && !isShadow) { 224 | const progress = min(1, 2 * entity.stateMachine.state.age / 0.25); 225 | scale(progress, progress); 226 | drawImage(exclamation, -exclamation.width / 2, -exclamation.height / 2); 227 | } 228 | }); 229 | }; 230 | -------------------------------------------------------------------------------- /src/js/graphics/characters/exclamation.js: -------------------------------------------------------------------------------- 1 | exclamation = createCanvas(50, 50, (ctx, can) => { 2 | ctx.fillStyle = '#fff'; 3 | ctx.translate(can.width / 2, can.width / 2); 4 | for (let r = 0, i = 0 ; r < 1 ; r += 0.05, i++) { 5 | const distance = i % 2 ? can.width / 2 : can.width / 3; 6 | ctx.lineTo( 7 | cos(r * TWO_PI) * distance, 8 | sin(r * TWO_PI) * distance, 9 | ) 10 | } 11 | ctx.fill(); 12 | 13 | ctx.font = nomangle('bold 18pt Arial'); 14 | ctx.fillStyle = '#f00'; 15 | ctx.textAlign = nomangle('center'); 16 | ctx.textBaseline = nomangle('middle'); 17 | ctx.fillText('!!!', 0, 0); 18 | }); 19 | -------------------------------------------------------------------------------- /src/js/graphics/create-canvas.js: -------------------------------------------------------------------------------- 1 | createCanvas = (w, h, render) => { 2 | const can = document.createElement('canvas'); 3 | can.width = w; 4 | can.height = h; 5 | 6 | const ctx = can.getContext('2d'); 7 | 8 | return render(ctx, can) || can; 9 | }; 10 | 11 | canvasPrototype.slice = (radius, sliceUp, ratio) => { 12 | ctx.beginPath(); 13 | if (sliceUp) { 14 | ctx.moveTo(-radius, -radius); 15 | ctx.lineTo(radius, -radius); 16 | } else { 17 | ctx.lineTo(-radius, radius); 18 | ctx.lineTo(radius, radius); 19 | } 20 | 21 | ctx.lineTo(radius, -radius * ratio); 22 | ctx.lineTo(-radius, radius * ratio); 23 | ctx.clip(); 24 | }; 25 | -------------------------------------------------------------------------------- /src/js/graphics/gauge.js: -------------------------------------------------------------------------------- 1 | healthGradient = createCanvas(400, 1, (ctx) => { 2 | const grad = ctx.createLinearGradient(-200, 0, 200, 0); 3 | grad.addColorStop(0, '#900'); 4 | grad.addColorStop(1, '#f44'); 5 | return grad; 6 | }); 7 | 8 | staminaGradient = createCanvas(400, 1, (ctx) => { 9 | const grad = ctx.createLinearGradient(-200, 0, 200, 0); 10 | grad.addColorStop(0, '#07f'); 11 | grad.addColorStop(1, '#0ef'); 12 | return grad; 13 | }); 14 | 15 | class Gauge { 16 | constructor(getValue) { 17 | this.getValue = getValue; 18 | this.value = this.displayedValue = 1; 19 | this.regenRate = 0.5; 20 | } 21 | 22 | cycle(elapsed) { 23 | this.displayedValue += between( 24 | -elapsed * 0.5, 25 | this.getValue() - this.displayedValue, 26 | elapsed * this.regenRate, 27 | ); 28 | } 29 | 30 | render(width, height, color, half, ridgeCount) { 31 | function renderGauge( 32 | width, 33 | height, 34 | value, 35 | color, 36 | ) { 37 | ctx.wrap(() => { 38 | const displayedMaxX = interpolate(height / 2, width, value); 39 | if (value === 0) return; 40 | 41 | ctx.translate(-width / 2, 0); 42 | 43 | ctx.fillStyle = color; 44 | ctx.beginPath(); 45 | ctx.lineTo(0, height / 2); 46 | 47 | if (!half) { 48 | ctx.lineTo(height / 2, 0); 49 | ctx.lineTo(displayedMaxX - height / 2, 0); 50 | } 51 | 52 | ctx.lineTo(displayedMaxX, height / 2); 53 | ctx.lineTo(displayedMaxX - height / 2, height); 54 | ctx.lineTo(height / 2, height); 55 | ctx.fill(); 56 | }) 57 | } 58 | 59 | ctx.wrap(() => { 60 | ctx.wrap(() => { 61 | ctx.globalAlpha *= 0.5; 62 | renderGauge(width + 8, height + 4, 1, '#000'); 63 | }); 64 | 65 | ctx.translate(0, 2); 66 | renderGauge(width, height, this.displayedValue, '#fff'); 67 | renderGauge(width, height, min(this.displayedValue, this.getValue()), color); 68 | 69 | ctx.globalAlpha *= 0.5; 70 | ctx.fillStyle = '#000'; 71 | for (const r = 1 / ridgeCount ; r < 1 ; r += 1 / ridgeCount) { 72 | ctx.fillRect(r * width - width / 2, 0, 1, height); 73 | } 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/js/graphics/text.js: -------------------------------------------------------------------------------- 1 | LOGO_GRADIENT = createCanvas(1, 1, (ctx) => { 2 | const grad = ctx.createLinearGradient(0, 0, 0, -150); 3 | grad.addColorStop(0, '#888'); 4 | grad.addColorStop(0.7, '#eee'); 5 | grad.addColorStop(1, '#888'); 6 | return grad; 7 | }); 8 | 9 | canvasPrototype.renderLargeText = function (bits) { 10 | with (this) { 11 | textBaseline = nomangle('alphabetic'); 12 | textAlign = nomangle('left'); 13 | fillStyle = LOGO_GRADIENT; 14 | strokeStyle = '#000'; 15 | lineWidth = 4; 16 | shadowColor = '#000'; 17 | 18 | let x = 0; 19 | for (const [text, size, offsetWidth] of bits) { 20 | font = size + nomangle('px Times New Roman'); 21 | x += measureText(text).width + (offsetWidth || 0); 22 | } 23 | 24 | translate(-x / 2, 0); 25 | 26 | x = 0; 27 | for (const [text, size, offsetWidth] of bits) { 28 | font = size + nomangle('px Times New Roman'); 29 | 30 | shadowBlur = 5; 31 | strokeText(text, x, 0); 32 | 33 | shadowBlur = 0; 34 | fillText(text, x, 0); 35 | 36 | x += measureText(text).width + (offsetWidth || 0); 37 | } 38 | 39 | return x; 40 | } 41 | }; 42 | 43 | canvasPrototype.renderInstruction = function(text) { 44 | with (this) { 45 | textBaseline = nomangle('middle'); 46 | textAlign = nomangle('center'); 47 | strokeStyle = '#000'; 48 | lineWidth = 4; 49 | font = nomangle('18pt Times New Roman'); 50 | 51 | const width = measureText(text).width + 20; 52 | fillStyle = 'rgba(0,0,0,.5)'; 53 | fillRect(-width / 2, 0, width, 40); 54 | 55 | fillStyle = '#fff'; 56 | strokeText(text, 0, 20); 57 | fillText(text, 0, 20); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/js/graphics/with-shadow.js: -------------------------------------------------------------------------------- 1 | canvasPrototype.resolveColor = x => x; 2 | 3 | canvasPrototype.withShadow = function(render) { 4 | this.wrap(() => { 5 | this.isShadow = true; 6 | this.resolveColor = () => 'rgba(0,0,0,.2)'; 7 | 8 | ctx.scale(1, 0.5); 9 | ctx.transform(1, 0, 0.5, 1, 0, 0); // shear the context 10 | render(); 11 | }); 12 | 13 | this.wrap(() => { 14 | this.isShadow = false; 15 | render(); 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/js/graphics/wrap.js: -------------------------------------------------------------------------------- 1 | canvasPrototype.wrap = function(f) { 2 | const { resolveColor } = this; 3 | this.save(); 4 | f(); 5 | this.restore(); 6 | this.resolveColor = resolveColor || (x => x); 7 | }; 8 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | onload = () => { 2 | can = document.querySelector(nomangle('canvas')); 3 | can.width = CANVAS_WIDTH; 4 | can.height = CANVAS_HEIGHT; 5 | 6 | ctx = can.getContext('2d'); 7 | 8 | // if (inputMode == INPUT_MODE_TOUCH) { 9 | // can.width *= 0.5; 10 | // can.height *= 0.5; 11 | // ctx.scale(0.5, 0.5); 12 | // } 13 | 14 | onresize(); 15 | 16 | if (RENDER_PLAYER_ICON) { 17 | oncontextmenu = () => {}; 18 | ctx.wrap(() => { 19 | can.width *= 10; 20 | can.height *= 10; 21 | ctx.scale(10, 10); 22 | 23 | ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2) 24 | ctx.scale(5, 5); 25 | ctx.translate(0, 30); 26 | new Player().renderBody(); 27 | }); 28 | return; 29 | } 30 | 31 | frame(); 32 | }; 33 | 34 | let lastFrame = performance.now(); 35 | 36 | const level = new IntroLevel(); 37 | if (RENDER_SCREENSHOT) level = new ScreenshotLevel(); 38 | 39 | frame = () => { 40 | const current = performance.now(); 41 | const elapsed = (current - lastFrame) / 1000; 42 | lastFrame = current; 43 | 44 | // Game update 45 | if (!RENDER_SCREENSHOT) level.cycle(elapsed); 46 | 47 | // Rendering 48 | ctx.wrap(() => level.scene.render()); 49 | 50 | if (DEBUG && !RENDER_SCREENSHOT) { 51 | ctx.fillStyle = '#fff'; 52 | ctx.strokeStyle = '#000'; 53 | ctx.textAlign = nomangle('left'); 54 | ctx.textBaseline = nomangle('bottom'); 55 | ctx.font = nomangle('14pt Courier'); 56 | ctx.lineWidth = 3; 57 | 58 | let y = CANVAS_HEIGHT - 10; 59 | for (const line of [ 60 | nomangle('FPS: ') + ~~(1 / elapsed), 61 | nomangle('Entities: ') + level.scene.entities.size, 62 | ].reverse()) { 63 | ctx.strokeText(line, 10, y); 64 | ctx.fillText(line, 10, y); 65 | y -= 20; 66 | } 67 | } 68 | 69 | requestAnimationFrame(frame); 70 | } 71 | -------------------------------------------------------------------------------- /src/js/input/keyboard.js: -------------------------------------------------------------------------------- 1 | let DOWN = {}; 2 | onkeydown = e => { 3 | if (e.keyCode == 27 || e.keyCode == 80) { 4 | GAME_PAUSED = !GAME_PAUSED; 5 | setSongVolume(GAME_PAUSED ? 0 : SONG_VOLUME); 6 | } 7 | DOWN[e.keyCode] = true 8 | }; 9 | onkeyup = e => DOWN[e.keyCode] = false; 10 | 11 | // Reset inputs when window loses focus 12 | onblur = onfocus = () => { 13 | DOWN = {}; 14 | MOUSE_RIGHT_DOWN = MOUSE_DOWN = false; 15 | }; 16 | -------------------------------------------------------------------------------- /src/js/input/mouse.js: -------------------------------------------------------------------------------- 1 | MOUSE_DOWN = false; 2 | MOUSE_RIGHT_DOWN = false; 3 | MOUSE_POSITION = {x: 0, y: 0}; 4 | 5 | onmousedown = (evt) => evt.button == 2 ? MOUSE_RIGHT_DOWN = true : MOUSE_DOWN = true; 6 | onmouseup = (evt) => evt.button == 2 ? MOUSE_RIGHT_DOWN = false : MOUSE_DOWN = false; 7 | onmousemove = (evt) => getEventPosition(evt, can, MOUSE_POSITION); 8 | 9 | oncontextmenu = (evt) => evt.preventDefault(); 10 | 11 | getEventPosition = (event, can, out) => { 12 | if (!can) return; 13 | const canvasRect = can.getBoundingClientRect(); 14 | out.x = (event.pageX - canvasRect.left) / canvasRect.width * can.width; 15 | out.y = (event.pageY - canvasRect.top) / canvasRect.height * can.height; 16 | } 17 | -------------------------------------------------------------------------------- /src/js/input/touch.js: -------------------------------------------------------------------------------- 1 | class MobileJoystick { 2 | constructor() { 3 | this.x = this.y = 0; 4 | this.touch = {'x': 0, 'y': 0}; 5 | this.touchIdentifier = -1; 6 | } 7 | 8 | render() { 9 | if (this.touchIdentifier < 0) return; 10 | 11 | const extraForceRatio = between(0, (dist(this, this.touch) - TOUCH_JOYSTICK_RADIUS) / (TOUCH_JOYSTICK_MAX_RADIUS - TOUCH_JOYSTICK_RADIUS), 1); 12 | const radius = (1 - extraForceRatio) * TOUCH_JOYSTICK_RADIUS; 13 | 14 | TOUCH_CONTROLS_CTX.globalAlpha = interpolate(0.5, 0, extraForceRatio); 15 | TOUCH_CONTROLS_CTX.strokeStyle = '#fff'; 16 | TOUCH_CONTROLS_CTX.lineWidth = 2; 17 | TOUCH_CONTROLS_CTX.fillStyle = 'rgba(0,0,0,0.5)'; 18 | TOUCH_CONTROLS_CTX.beginPath(); 19 | TOUCH_CONTROLS_CTX.arc(this.x, this.y, radius * devicePixelRatio, 0, TWO_PI); 20 | TOUCH_CONTROLS_CTX.fill(); 21 | TOUCH_CONTROLS_CTX.stroke(); 22 | 23 | TOUCH_CONTROLS_CTX.globalAlpha = 0.5; 24 | TOUCH_CONTROLS_CTX.fillStyle = '#fff'; 25 | TOUCH_CONTROLS_CTX.beginPath(); 26 | TOUCH_CONTROLS_CTX.arc(this.touch.x, this.touch.y, 30 * devicePixelRatio, 0, TWO_PI); 27 | TOUCH_CONTROLS_CTX.fill(); 28 | } 29 | } 30 | 31 | class MobileButton { 32 | constructor( 33 | x, 34 | y, 35 | label, 36 | ) { 37 | this.x = x; 38 | this.y = y; 39 | this.label = label; 40 | } 41 | 42 | render() { 43 | TOUCH_CONTROLS_CTX.translate(this.x(), this.y()); 44 | 45 | TOUCH_CONTROLS_CTX.scale(devicePixelRatio, devicePixelRatio); 46 | 47 | TOUCH_CONTROLS_CTX.strokeStyle = '#fff'; 48 | TOUCH_CONTROLS_CTX.lineWidth = 2; 49 | TOUCH_CONTROLS_CTX.fillStyle = this.down ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'; 50 | TOUCH_CONTROLS_CTX.beginPath(); 51 | TOUCH_CONTROLS_CTX.arc(0, 0, TOUCH_BUTTON_RADIUS, 0, TWO_PI); 52 | TOUCH_CONTROLS_CTX.fill(); 53 | TOUCH_CONTROLS_CTX.stroke(); 54 | 55 | TOUCH_CONTROLS_CTX.font = nomangle('16pt Courier'); 56 | TOUCH_CONTROLS_CTX.textAlign = nomangle('center'); 57 | TOUCH_CONTROLS_CTX.textBaseline = nomangle('middle'); 58 | TOUCH_CONTROLS_CTX.fillStyle = '#fff'; 59 | TOUCH_CONTROLS_CTX.fillText(this.label, 0, 0); 60 | } 61 | } 62 | 63 | updateTouches = (touches) => { 64 | for (const button of TOUCH_BUTTONS) { 65 | button.down = false; 66 | for (const touch of touches) { 67 | getEventPosition(touch, TOUCH_CONTROLS_CANVAS, touch); 68 | if ( 69 | abs(button.x() - touch.x) < TOUCH_BUTTON_RADIUS * devicePixelRatio && 70 | abs(button.y() - touch.y) < TOUCH_BUTTON_RADIUS * devicePixelRatio 71 | ) { 72 | button.down = true; 73 | } 74 | } 75 | } 76 | 77 | let movementTouch; 78 | for (const touch of touches) { 79 | if ( 80 | touch.identifier === TOUCH_JOYSTICK.touchIdentifier || 81 | touch.x < TOUCH_CONTROLS_CANVAS.width / 2 82 | ) { 83 | movementTouch = touch; 84 | break; 85 | } 86 | } 87 | 88 | if (movementTouch) { 89 | if (TOUCH_JOYSTICK.touchIdentifier < 0) { 90 | TOUCH_JOYSTICK.x = movementTouch.x; 91 | TOUCH_JOYSTICK.y = movementTouch.y; 92 | } 93 | TOUCH_JOYSTICK.touchIdentifier = movementTouch.identifier; 94 | TOUCH_JOYSTICK.touch.x = movementTouch.x; 95 | TOUCH_JOYSTICK.touch.y = movementTouch.y; 96 | } else { 97 | TOUCH_JOYSTICK.touchIdentifier = -1; 98 | } 99 | }; 100 | 101 | ontouchstart = (event) => { 102 | inputMode = INPUT_MODE_TOUCH; 103 | event.preventDefault(); 104 | updateTouches(event.touches); 105 | }; 106 | 107 | ontouchmove = (event) => { 108 | event.preventDefault(); 109 | updateTouches(event.touches); 110 | }; 111 | 112 | ontouchend = (event) => { 113 | event.preventDefault(); 114 | updateTouches(event.touches); 115 | 116 | if (onclick) onclick(); 117 | }; 118 | 119 | renderTouchControls = () => { 120 | TOUCH_CONTROLS_CANVAS.style.display = inputMode == INPUT_MODE_TOUCH ? 'block' : 'hidden'; 121 | TOUCH_CONTROLS_CANVAS.width = innerWidth * devicePixelRatio; 122 | TOUCH_CONTROLS_CANVAS.height = innerHeight * devicePixelRatio; 123 | 124 | for (const button of TOUCH_BUTTONS.concat([TOUCH_JOYSTICK])) { 125 | TOUCH_CONTROLS_CTX.wrap(() => button.render()); 126 | } 127 | 128 | requestAnimationFrame(renderTouchControls); 129 | } 130 | 131 | TOUCH_CONTROLS_CANVAS = document.createElement(nomangle('canvas')); 132 | TOUCH_CONTROLS_CTX = TOUCH_CONTROLS_CANVAS.getContext('2d'); 133 | 134 | TOUCH_BUTTONS = [ 135 | TOUCH_ATTACK_BUTTON = new MobileButton( 136 | () => TOUCH_CONTROLS_CANVAS.width - 175 * devicePixelRatio, 137 | () => TOUCH_CONTROLS_CANVAS.height - 75 * devicePixelRatio, 138 | nomangle('ATK'), 139 | ), 140 | TOUCH_SHIELD_BUTTON = new MobileButton( 141 | () => TOUCH_CONTROLS_CANVAS.width - 75 * devicePixelRatio, 142 | () => TOUCH_CONTROLS_CANVAS.height - 75 * devicePixelRatio, 143 | nomangle('DEF'), 144 | ), 145 | TOUCH_DASH_BUTTON = new MobileButton( 146 | () => TOUCH_CONTROLS_CANVAS.width - 125 * devicePixelRatio, 147 | () => TOUCH_CONTROLS_CANVAS.height - 150 * devicePixelRatio, 148 | nomangle('ROLL'), 149 | ), 150 | ]; 151 | 152 | TOUCH_JOYSTICK = new MobileJoystick(); 153 | 154 | if (inputMode === INPUT_MODE_TOUCH) { 155 | document.body.appendChild(TOUCH_CONTROLS_CANVAS); 156 | renderTouchControls(); 157 | } 158 | -------------------------------------------------------------------------------- /src/js/level/gameplay-level.js: -------------------------------------------------------------------------------- 1 | class GameplayLevel extends Level { 2 | constructor(waveIndex = 0, score = 0) { 3 | super(); 4 | 5 | const { scene } = this; 6 | 7 | let waveStartScore = score; 8 | 9 | const player = firstItem(scene.category('player')); 10 | player.x = waveIndex * CANVAS_WIDTH; 11 | player.y = scene.pathCurve(player.x); 12 | player.score = score; 13 | 14 | const camera = firstItem(scene.category('camera')); 15 | camera.cycle(99); 16 | 17 | const playerHUD = scene.add(new PlayerHUD(player)); 18 | scene.add(new Path()); 19 | 20 | for (let i = 0 ; i < 15 ; i++) { 21 | const tree = scene.add(new Tree()); 22 | tree.x = rnd(-1, 1) * CANVAS_WIDTH / 2; 23 | tree.y = rnd(-1, 1) * CANVAS_HEIGHT / 2; 24 | } 25 | 26 | for (let i = 0 ; i < 20 ; i++) { 27 | const water = scene.add(new Water()); 28 | water.width = rnd(100, 200); 29 | water.height = rnd(200, 400); 30 | water.rotation = random() * TWO_PI; 31 | water.x = random() * CANVAS_WIDTH * 5; 32 | water.y = random() * CANVAS_HEIGHT * 5; 33 | } 34 | 35 | // Respawn when far from the path 36 | (async () => { 37 | while (true) { 38 | await scene.waitFor(() => abs(player.y - scene.pathCurve(player.x)) > 1000 || player.x < camera.minX - CANVAS_WIDTH / 2); 39 | 40 | const x = max(camera.minX + CANVAS_WIDTH, player.x); 41 | await this.respawn(x, scene.pathCurve(x)); 42 | } 43 | })(); 44 | 45 | async function slowMo() { 46 | player.affectedBySpeedRatio = true; 47 | scene.speedRatio = 0.2; 48 | await camera.zoomTo(3); 49 | await scene.delay(1.5 * scene.speedRatio); 50 | await camera.zoomTo(1); 51 | scene.speedRatio = 1; 52 | player.affectedBySpeedRatio = false; 53 | } 54 | 55 | function spawnWave(enemyCount, enemyTypes) { 56 | return Array.apply(null, Array(enemyCount)).map(() => { 57 | const enemy = scene.add(new (pick(enemyTypes))()); 58 | enemy.x = player.x + rnd(-CANVAS_WIDTH / 2, CANVAS_WIDTH / 2); 59 | enemy.y = player.y + pick([-1, 1]) * (evaluate(CANVAS_HEIGHT / 2) + rnd(50, 100)); 60 | scene.add(new CharacterHUD(enemy)); 61 | scene.add(new CharacterOffscreenIndicator(enemy)); 62 | return enemy 63 | }); 64 | } 65 | 66 | // Scenario 67 | (async () => { 68 | const fade = scene.add(new Fade()); 69 | await scene.add(new Interpolator(fade, 'alpha', 1, 0, 2)).await(); 70 | 71 | scene.add(new Announcement(nomangle('The Path'))); 72 | await scene.delay(2); 73 | 74 | playerHUD.progress = playerHUD.progressGauge.displayedValue = waveIndex / WAVE_COUNT; 75 | 76 | let nextWaveX = player.x + CANVAS_WIDTH; 77 | for ( ; waveIndex < WAVE_COUNT ; waveIndex++) { 78 | // Show progress 79 | (async () => { 80 | await scene.delay(1); 81 | await scene.add(new Interpolator(playerHUD, 'progressAlpha', 0, 1, 1)).await(); 82 | playerHUD.progress = waveIndex / WAVE_COUNT; 83 | 84 | // Regen a bit of health 85 | player.heal(player.maxHealth * 0.5); 86 | 87 | await scene.delay(3); 88 | await scene.add(new Interpolator(playerHUD, 'progressAlpha', 1, 0, 1)).await(); 89 | })(); 90 | 91 | const instruction = scene.add(new Instruction()); 92 | (async () => { 93 | await scene.delay(10), 94 | instruction.text = nomangle('Follow the path to the right'); 95 | })(); 96 | 97 | await scene.waitFor(() => player.x >= nextWaveX); 98 | 99 | instruction.remove(); 100 | waveStartScore = player.score; 101 | 102 | this.scene.add(new Announcement(nomangle('Wave ') + (waveIndex + 1))); 103 | 104 | const waveEnemies = spawnWave( 105 | 3 + waveIndex, 106 | WAVE_SETTINGS[min(WAVE_SETTINGS.length - 1, waveIndex)], 107 | ); 108 | 109 | // Wait until all enemies are defeated 110 | await Promise.all(waveEnemies.map(enemy => scene.waitFor(() => enemy.health <= 0))); 111 | slowMo(); 112 | 113 | this.scene.add(new Announcement(nomangle('Wave Cleared'))); 114 | 115 | nextWaveX = player.x + evaluate(CANVAS_WIDTH * 2); 116 | camera.minX = player.x - CANVAS_WIDTH; 117 | } 118 | 119 | // Last wave, reach the king 120 | await scene.waitFor(() => player.x >= nextWaveX); 121 | const king = scene.add(new KingEnemy()); 122 | king.x = camera.x + CANVAS_WIDTH + 50; 123 | king.y = scene.pathCurve(king.x); 124 | scene.add(new CharacterHUD(king)); 125 | 126 | await scene.waitFor(() => king.x - player.x < 400); 127 | await scene.add(new Interpolator(fade, 'alpha', 0, 1, 2 * scene.speedRatio)).await(); 128 | 129 | // Make sure the player is near the king 130 | player.x = king.x - 400; 131 | player.y = scene.pathCurve(player.x); 132 | 133 | const expo = scene.add(new Exposition([ 134 | nomangle('At last, he faced the emperor.'), 135 | ])); 136 | 137 | await scene.delay(3); 138 | await scene.add(new Interpolator(expo, 'alpha', 1, 0, 2)).await(); 139 | await scene.add(new Interpolator(fade, 'alpha', 1, 0, 2)).await(); 140 | 141 | // Give the king an AI so they can start fighting 142 | const aiType = createEnemyAI({ 143 | shield: true, 144 | attackCount: 3, 145 | }); 146 | king.setController(new aiType()); 147 | scene.add(new CharacterOffscreenIndicator(king)); 148 | 149 | // Spawn some mobs 150 | spawnWave(5, WAVE_SETTINGS[WAVE_SETTINGS.length - 1]); 151 | 152 | await scene.waitFor(() => king.health <= 0); 153 | 154 | player.health = player.maxHealth = 999; 155 | BEATEN = true; 156 | 157 | // Final slomo 158 | await slowMo(); 159 | await scene.add(new Interpolator(fade, 'alpha', 0, 1, 2 * scene.speedRatio)).await(); 160 | 161 | // Congrats screen 162 | const finalExpo = scene.add(new Exposition([ 163 | nomangle('After an epic fight, the emperor was defeated.'), 164 | nomangle('Our hero\'s quest was complete.'), 165 | nomangle('Historians estimate his final score was ') + player.score.toLocaleString() + '.', 166 | ])); 167 | await scene.add(new Interpolator(finalExpo, 'alpha', 0, 1, 2 * scene.speedRatio)).await(); 168 | await scene.delay(9 * scene.speedRatio); 169 | await scene.add(new Interpolator(finalExpo, 'alpha', 1, 0, 2 * scene.speedRatio)).await(); 170 | 171 | // Back to intro 172 | level = new IntroLevel(); 173 | })(); 174 | 175 | // Game over 176 | (async () => { 177 | await scene.waitFor(() => player.health <= 0); 178 | 179 | slowMo(); 180 | 181 | const fade = scene.add(new Fade()); 182 | await scene.add(new Interpolator(fade, 'alpha', 0, 1, 2 * scene.speedRatio)).await(); 183 | scene.speedRatio = 2; 184 | 185 | const expo = scene.add(new Exposition([ 186 | // Story 187 | pick([ 188 | nomangle('Failing never affected his will, only his score.'), 189 | nomangle('Giving up was never an option.'), 190 | nomangle('His first attempts weren\'t successful.'), 191 | nomangle('After licking his wounds, he resumed his quest.'), 192 | ]), 193 | 194 | // Tip 195 | pick([ 196 | nomangle('His shield would not fail him again ([SHIFT] / [RIGHT CLICK])'), 197 | nomangle('Rolling would help him dodge attacks ([SPACE] / [CTRL])'), 198 | nomangle('Heavy attacks would be key to his success'), 199 | ]), 200 | ])); 201 | 202 | await scene.delay(6); 203 | await scene.add(new Interpolator(expo, 'alpha', 1, 0, 2)).await(); 204 | 205 | // Start a level where we left off 206 | level = new GameplayLevel(waveIndex, max(0, waveStartScore - 5000)); // TODO figure out a value 207 | })(); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/js/level/intro-level.js: -------------------------------------------------------------------------------- 1 | class IntroLevel extends Level { 2 | constructor() { 3 | super(); 4 | 5 | const { scene } = this; 6 | 7 | for (let r = 0 ; r < 1 ; r += 1 / 15) { 8 | const tree = scene.add(new Tree()); 9 | tree.noRegen = true; 10 | tree.x = cos(r * TWO_PI) * 600 + rnd(-20, 20); 11 | tree.y = sin(r * TWO_PI) * 600 + rnd(-20, 20); 12 | } 13 | 14 | const camera = firstItem(scene.category('camera')); 15 | camera.zoom = 3; 16 | camera.cycle(99); 17 | 18 | const player = firstItem(scene.category('player')); 19 | player.health = LARGE_INT; 20 | player.setController(new CharacterController()); 21 | 22 | // Respawn when leaving the area 23 | (async () => { 24 | while (true) { 25 | await scene.waitFor(() => distP(player.x, player.y, 0, 0) > 650); 26 | await this.respawn(0, 0); 27 | } 28 | })(); 29 | 30 | (async () => { 31 | const logo = scene.add(new Logo()); 32 | const fade = scene.add(new Fade()); 33 | await scene.add(new Interpolator(fade, 'alpha', 1, 0, 2)).await(); 34 | 35 | const msg = scene.add(new Instruction()); 36 | msg.text = nomangle('[CLICK] to follow the path'); 37 | await new Promise(r => onclick = r); 38 | msg.text = ''; 39 | 40 | playSong(); 41 | 42 | can.style[nomangle('cursor')] = 'none'; 43 | 44 | player.setController(new PlayerController()); 45 | await scene.add(new Interpolator(logo, 'alpha', 1, 0, 2)).await(); 46 | await camera.zoomTo(1); 47 | 48 | scene.add(new Announcement(nomangle('Prologue'))) 49 | 50 | // Movement tutorial 51 | msg.text = nomangle('Use [ARROW KEYS] or [WASD] to move'); 52 | await scene.waitFor(() => distP(player.x, player.y, 0, 0) > 200); 53 | logo.remove(); 54 | 55 | msg.text = ''; 56 | 57 | await scene.delay(1); 58 | 59 | // Roll tutorial 60 | await this.repeat( 61 | msg, 62 | nomangle('Press [SPACE] or [CTRL] to roll'), 63 | async () => { 64 | await scene.waitFor(() => player.stateMachine.state.dashAngle !== undefined); 65 | await scene.waitFor(() => player.stateMachine.state.dashAngle === undefined); 66 | }, 67 | 3, 68 | ); 69 | 70 | // Attack tutorial 71 | const totalAttackCount = () => Array 72 | .from(scene.category('enemy')) 73 | .reduce((acc, enemy) => enemy.damageCount + acc, 0); 74 | 75 | for (let r = 0 ; r < 1 ; r += 1 / 5) { 76 | const enemy = scene.add(new DummyEnemy()); 77 | enemy.x = cos(r * TWO_PI) * 200; 78 | enemy.y = sin(r * TWO_PI) * 200; 79 | enemy.poof(); 80 | } 81 | 82 | await this.repeat( 83 | msg, 84 | nomangle('[LEFT CLICK] to strike a dummy'), 85 | async () => { 86 | const initial = totalAttackCount(); 87 | await scene.waitFor(() => totalAttackCount() > initial); 88 | }, 89 | 10, 90 | ); 91 | 92 | // Charge tutorial 93 | await this.repeat( 94 | msg, 95 | nomangle('Hold [LEFT CLICK] to charge a heavy attack'), 96 | async () => { 97 | await scene.waitFor(() => player.stateMachine.state.attackPreparationRatio >= 1); 98 | 99 | const initial = totalAttackCount(); 100 | await scene.waitFor(() => totalAttackCount() > initial); 101 | }, 102 | 3, 103 | ); 104 | 105 | // Shield tutorial 106 | const SwordArmorEnemy = createEnemyType({ sword: true, armor: true, attackCount: 1, }); 107 | const enemy = scene.add(new SwordArmorEnemy()); 108 | enemy.health = LARGE_INT; 109 | enemy.x = camera.x + CANVAS_WIDTH / 2 / camera.zoom + 20; 110 | enemy.y = -99; 111 | scene.add(new CharacterOffscreenIndicator(enemy)); 112 | 113 | await this.repeat( 114 | msg, 115 | nomangle('Hold [RIGHT CLICK] or [SHIFT] to block attacks'), 116 | async () => { 117 | const initial = player.parryCount; 118 | await scene.waitFor(() => player.parryCount > initial); 119 | }, 120 | 3, 121 | ); 122 | 123 | scene.add(new CharacterHUD(enemy)); 124 | 125 | enemy.health = enemy.maxHealth = 100; 126 | msg.text = nomangle('Now slay them!'); 127 | await scene.waitFor(() => enemy.health <= 0); 128 | 129 | msg.text = ''; 130 | await scene.delay(1); 131 | 132 | await scene.add(new Interpolator(fade, 'alpha', 0, 1, 2)).await(); 133 | 134 | const expo = scene.add(new Exposition([ 135 | nomangle('1254 AD'), 136 | nomangle('The Kingdom of Syldavia is being invaded by the Northern Empire.'), 137 | nomangle('The Syldavian army is outnumbered and outmatched.'), 138 | nomangle('One lone soldier decides to take on the emperor himself.'), 139 | ])); 140 | 141 | await scene.delay(15); 142 | 143 | await scene.add(new Interpolator(expo, 'alpha', 1, 0, 2)).await(); 144 | 145 | level = new GameplayLevel(); 146 | })(); 147 | 148 | (async () => { 149 | const enemy = scene.add(new DummyEnemy()); 150 | enemy.y = -550; 151 | enemy.poof(); 152 | 153 | const label = scene.add(new Label(nomangle('Skip'))); 154 | label.y = enemy.y - 30; 155 | label.infinite = true; 156 | 157 | while (true) { 158 | const { damageCount } = enemy; 159 | await scene.waitFor(() => enemy.damageCount > damageCount); 160 | 161 | if (confirm(nomangle('Skip intro?'))) { 162 | level = new GameplayLevel(); 163 | } 164 | } 165 | })(); 166 | } 167 | 168 | async repeat(msg, instruction, script, count) { 169 | for (let i = 0 ; i < count ; i++) { 170 | msg.text = instruction + ' (' + i + '/' + count + ')'; 171 | await script(); 172 | } 173 | 174 | msg.text = instruction + ' (' + count + '/' + count + ')'; 175 | 176 | await this.scene.delay(1); 177 | msg.text = ''; 178 | await this.scene.delay(1); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/js/level/level.js: -------------------------------------------------------------------------------- 1 | class Level { 2 | constructor() { 3 | this.scene = new Scene(); 4 | 5 | this.scene.add(new Camera()); 6 | 7 | DOWN = {}; 8 | MOUSE_DOWN = MOUSE_RIGHT_DOWN = false; 9 | 10 | this.scene.add(new AggressivityTracker()); 11 | 12 | const player = this.scene.add(new Player()); 13 | this.scene.add(new Cursor(player)); 14 | 15 | this.scene.add(new Rain()); 16 | this.scene.add(new PauseOverlay()); 17 | 18 | for (let i = 2 ; i-- ; ) this.scene.add(new Bird()); 19 | 20 | for (let i = 0 ; i < 400 ; i++) { 21 | const grass = new Grass(); 22 | grass.x = rnd(-2, 2) * CANVAS_WIDTH; 23 | grass.y = rnd(-2, 2) * CANVAS_HEIGHT; 24 | this.scene.add(grass); 25 | } 26 | 27 | for (let i = 0 ; i < 20 ; i++) { 28 | const bush = new Bush(); 29 | bush.x = random() * 10000; 30 | this.scene.add(bush); 31 | } 32 | } 33 | 34 | cycle(elapsed) { 35 | this.scene.cycle(elapsed); 36 | } 37 | 38 | async respawn(x, y) { 39 | const fade = this.scene.add(new Fade()); 40 | await this.scene.add(new Interpolator(fade, 'alpha', 0, 1, 1)).await(); 41 | const player = firstItem(this.scene.category('player')); 42 | const camera = firstItem(this.scene.category('camera')); 43 | player.x = x; 44 | player.y = y; 45 | camera.cycle(999); 46 | await this.scene.add(new Interpolator(fade, 'alpha', 1, 0, 1)).await(); 47 | fade.remove(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/js/level/screenshot-level.js: -------------------------------------------------------------------------------- 1 | class ScreenshotLevel extends Level { 2 | constructor() { 3 | super(); 4 | 5 | oncontextmenu = () => {}; 6 | 7 | const player = firstItem(this.scene.category('player')); 8 | player.age = 0.4; 9 | 10 | MOUSE_POSITION.x = Number.MAX_SAFE_INTEGER; 11 | MOUSE_POSITION.y = CANVAS_HEIGHT / 2; 12 | DOWN[39] = true; 13 | 14 | const camera = firstItem(this.scene.category('camera')); 15 | camera.zoom = 2; 16 | camera.cycle(99); 17 | 18 | this.scene.add(new Path()); 19 | 20 | for (const entity of Array.from(this.scene.entities)) { 21 | if (entity instanceof Bush) entity.remove(); 22 | if (entity instanceof Bird) entity.remove(); 23 | if (entity instanceof Cursor) entity.remove(); 24 | } 25 | 26 | const announcement = this.scene.add(new Announcement(nomangle('Path to Glory'))); 27 | announcement.age = 1; 28 | 29 | const bird1 = this.scene.add(new Bird()); 30 | bird1.x = player.x + 100; 31 | bird1.y = player.y - 200; 32 | 33 | const bird2 = this.scene.add(new Bird()); 34 | bird2.x = player.x + 150; 35 | bird2.y = player.y - 150; 36 | 37 | const bird3 = this.scene.add(new Bird()); 38 | bird3.x = player.x - 250; 39 | bird3.y = player.y + 50; 40 | 41 | const tree1 = this.scene.add(new Tree()); 42 | tree1.x = player.x - 200; 43 | tree1.y = player.y - 50; 44 | 45 | const tree2 = this.scene.add(new Tree()); 46 | tree2.x = player.x + 200; 47 | tree2.y = player.y - 150; 48 | 49 | const tree3 = this.scene.add(new Tree()); 50 | tree3.x = player.x + 300; 51 | tree3.y = player.y + 150; 52 | 53 | const bush1 = this.scene.add(new Bush()); 54 | bush1.x = player.x + 100; 55 | bush1.y = player.y - 50; 56 | 57 | const bush2 = this.scene.add(new Bush()); 58 | bush2.x = player.x - 200; 59 | bush2.y = player.y + 50; 60 | 61 | const bush3 = this.scene.add(new Bush()); 62 | bush3.x = player.x + 50; 63 | bush3.y = player.y - 200; 64 | 65 | const water1 = this.scene.add(new Water()); 66 | water1.x = player.x - 100; 67 | water1.y = player.y - 350; 68 | water1.rotation = PI / 8; 69 | water1.width = 200; 70 | water1.height = 200; 71 | 72 | const water2 = this.scene.add(new Water()); 73 | water2.x = player.x + 350; 74 | water2.y = player.y - 150; 75 | water2.rotation = PI / 8; 76 | water2.width = 200; 77 | water2.height = 200; 78 | 79 | const enemy1 = this.scene.add(new KingEnemy()); 80 | enemy1.x = player.x + 180; 81 | enemy1.y = player.y - 30; 82 | enemy1.setController(new AI()); 83 | enemy1.controls.aim.x = player.x; 84 | enemy1.controls.aim.y = player.y; 85 | enemy1.controls.attack = true; 86 | enemy1.cycle(0); 87 | enemy1.cycle(0.1); 88 | 89 | const enemy2 = this.scene.add(new AxeShieldTankEnemy()); 90 | enemy2.x = player.x - 100; 91 | enemy2.y = player.y - 100; 92 | enemy2.setController(new AI()); 93 | enemy2.controls.aim.x = player.x; 94 | enemy2.controls.aim.y = player.y; 95 | enemy2.controls.force = 1; 96 | enemy2.age = 0.6; 97 | enemy2.controls.angle = angleBetween(enemy2, player) 98 | 99 | this.cycle(0); // helps regen grass 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/js/level/test-level.js: -------------------------------------------------------------------------------- 1 | class TestLevel extends Level { 2 | constructor() { 3 | super(); 4 | 5 | const player = firstItem(this.scene.category('player')); 6 | player.health = player.maxHealth = LARGE_INT; 7 | 8 | this.scene.add(new PlayerHUD(player)); 9 | 10 | const camera = firstItem(this.scene.category('camera')); 11 | // camera.zoom = 3; 12 | 13 | // player.health = player.maxHealth = Number.MAX_SAFE_INTEGER; 14 | 15 | this.scene.add(new Path()) 16 | 17 | for (let r = 0 ; r < 1 ; r += 1 / 5) { 18 | const enemy = this.scene.add(new StickEnemy()); 19 | enemy.x = cos(r * TWO_PI) * 100; 20 | enemy.y = -400 + sin(r * TWO_PI) * 100; 21 | enemy.setController(new AI()); 22 | enemy.health = enemy.maxHealth = LARGE_INT; 23 | enemy.poof(); 24 | 25 | this.scene.add(new CharacterHUD(enemy)); 26 | this.scene.add(new CharacterOffscreenIndicator(enemy)); 27 | } 28 | 29 | // const king = this.scene.add(new KingEnemy()); 30 | // king.x = 400; 31 | // this.scene.add(new CharacterHUD(king)); 32 | 33 | // for (let r = 0 ; r < 1 ; r += 1 / 10) { 34 | // const type = pick(ENEMY_TYPES); 35 | // const enemy = this.scene.add(new type()); 36 | // enemy.x = cos(r * TWO_PI) * 400; 37 | // enemy.y = sin(r * TWO_PI) * 400; 38 | // enemy.poof(); 39 | 40 | // this.scene.add(new CharacterHUD(enemy)); 41 | // } 42 | 43 | for (let i = 0 ; i < 20 ; i++) { 44 | const tree = new Tree(); 45 | tree.x = random() * 10000; 46 | // this.scene.add(tree); 47 | } 48 | 49 | // (async () => { 50 | // let y = 0; 51 | // for (const type of ENEMY_TYPES) { 52 | // const enemy = this.scene.add(new type()); 53 | // enemy.x = player.x + 200; 54 | // enemy.y = player.y; 55 | // enemy.poof(); 56 | 57 | // this.scene.add(new CharacterHUD(enemy)); 58 | 59 | // await this.scene.waitFor(() => enemy.health <= 0); 60 | // await this.scene.delay(1); 61 | // } 62 | // })(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/js/math.js: -------------------------------------------------------------------------------- 1 | between = (a, b, c) => b < a ? a : (b > c ? c : b); 2 | isBetween = (a, b, c) => a <= b && b <= c || a >= b && b >= c; 3 | rnd = (min, max) => random() * (max - min) + min; 4 | distP = (x1, y1, x2, y2) => hypot(x1 - x2, y1 - y2); 5 | dist = (a, b) => distP(a.x, a.y, b.x, b.y); 6 | normalize = x => moduloWithNegative(x, PI); 7 | angleBetween = (a, b) => atan2(b.y - a.y, b.x - a.x); 8 | roundToNearest = (x, precision) => round(x / precision) * precision; 9 | pick = a => a[~~(random() * a.length)]; 10 | interpolate = (from, to, ratio) => between(0, ratio, 1) * (to - from) + from; 11 | 12 | // Easing 13 | linear = x => x; 14 | easeOutQuint = x => 1 - pow(1 - x, 5); 15 | 16 | // Modulo centered around zero: the result will be between -y and +y 17 | moduloWithNegative = (x, y) => { 18 | x = x % (y * 2); 19 | if (x > y) { 20 | x -= y * 2; 21 | } 22 | if (x < -y) { 23 | x += y * 2; 24 | } 25 | return x; 26 | }; 27 | 28 | // Make Math global 29 | Object.getOwnPropertyNames(Math).forEach(n => w[n] = w[n] || Math[n]); 30 | 31 | TWO_PI = PI * 2; 32 | -------------------------------------------------------------------------------- /src/js/scene.js: -------------------------------------------------------------------------------- 1 | 2 | class Scene { 3 | constructor() { 4 | this.entities = new Set(); 5 | this.categories = new Map(); 6 | this.sortedEntities = []; 7 | 8 | this.speedRatio = 1; 9 | this.onCycle = new Set(); 10 | } 11 | 12 | add(entity) { 13 | if (this.entities.has(entity)) return; 14 | this.entities.add(entity); 15 | entity.scene = this; 16 | 17 | this.sortedEntities.push(entity); 18 | 19 | for (const category of entity.categories) { 20 | if (!this.categories.has(category)) { 21 | this.categories.set(category, new Set([entity])); 22 | } else { 23 | this.categories.get(category).add(entity); 24 | } 25 | } 26 | 27 | return entity; 28 | } 29 | 30 | category(category) { 31 | return this.categories.get(category) || []; 32 | } 33 | 34 | remove(entity) { 35 | this.entities.delete(entity); 36 | 37 | for (const category of entity.categories) { 38 | if (this.categories.has(category)) { 39 | this.categories.get(category).delete(entity); 40 | } 41 | } 42 | 43 | const index = this.sortedEntities.indexOf(entity); 44 | if (index >= 0) this.sortedEntities.splice(index, 1); 45 | } 46 | 47 | cycle(elapsed) { 48 | if (DEBUG && DOWN[70]) elapsed *= 3; 49 | if (DEBUG && DOWN[71]) elapsed *= 0.1; 50 | if (GAME_PAUSED) return; 51 | 52 | for (const entity of this.entities) { 53 | entity.cycle(elapsed * (entity.affectedBySpeedRatio ? this.speedRatio : 1)); 54 | } 55 | 56 | for (const onCycle of this.onCycle) { 57 | onCycle(); 58 | } 59 | } 60 | 61 | pathCurve(x) { 62 | const main = sin(x * TWO_PI / 2000) * 200; 63 | const wiggle = sin(x * TWO_PI / 1000) * 100; 64 | return main + wiggle; 65 | } 66 | 67 | render() { 68 | const camera = firstItem(this.category('camera')); 69 | 70 | // Background 71 | ctx.fillStyle = '#996'; 72 | ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); 73 | 74 | // Thunder 75 | if (camera.age % THUNDER_INTERVAL < 0.3 && camera.age % 0.2 < 0.1) { 76 | ctx.wrap(() => { 77 | ctx.globalAlpha = 0.3; 78 | ctx.fillStyle = '#fff'; 79 | ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); 80 | }); 81 | } 82 | 83 | ctx.wrap(() => { 84 | ctx.scale(camera.appliedZoom, camera.appliedZoom); 85 | ctx.translate( 86 | CANVAS_WIDTH / 2 / camera.appliedZoom - camera.x, 87 | CANVAS_HEIGHT / 2 / camera.appliedZoom - camera.y, 88 | ); 89 | 90 | this.sortedEntities.sort((a, b) => a.z - b.z); 91 | 92 | for (const entity of this.sortedEntities) { 93 | ctx.wrap(() => entity.render()); 94 | } 95 | }); 96 | } 97 | 98 | async waitFor(condition) { 99 | return new Promise((resolve) => { 100 | const checker = () => { 101 | if (condition()) { 102 | this.onCycle.delete(checker); 103 | resolve(); 104 | } 105 | }; 106 | this.onCycle.add(checker); 107 | }) 108 | } 109 | 110 | async delay(timeout) { 111 | const entity = this.add(new Entity()); 112 | await this.waitFor(() => entity.age > timeout); 113 | entity.remove(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/js/sound/ZzFXMicro.js: -------------------------------------------------------------------------------- 1 | // ZzFX - Zuper Zmall Zound Zynth - Micro Edition 2 | // MIT License - Copyright 2019 Frank Force 3 | // https://github.com/KilledByAPixel/ZzFX 4 | 5 | // This is a minified build of zzfx for use in size coding projects. 6 | // You can use zzfxV to set volume. 7 | // Feel free to minify it further for your own needs! 8 | 9 | // 'use strict'; 10 | 11 | /////////////////////////////////////////////////////////////////////////////// 12 | 13 | // ZzFXMicro - Zuper Zmall Zound Zynth - v1.1.8 14 | 15 | // ==ClosureCompiler== 16 | // @compilation_level ADVANCED_OPTIMIZATIONS 17 | // @output_file_name ZzFXMicro.min.js 18 | // @js_externs zzfx, zzfxG, zzfxP, zzfxV, zzfxX 19 | // @language_out ECMASCRIPT_2019 20 | // ==/ClosureCompiler== 21 | 22 | const zzfx = (...z)=> zzfxP(zzfxG(...z)); // generate and play sound 23 | const zzfxV = .3; // volume 24 | const zzfxR = 44100; // sample rate 25 | const zzfxX = new AudioContext; // audio context 26 | const zzfxP = (...samples)=> // play samples 27 | { 28 | // create buffer and source 29 | let buffer = zzfxX.createBuffer(samples.length, samples[0].length, zzfxR), 30 | source = zzfxX.createBufferSource(); 31 | 32 | // copy samples to buffer and play 33 | samples.map((d,i)=> buffer.getChannelData(i).nomangle(set)(d)); 34 | source.buffer = buffer; 35 | source.connect(zzfxX.destination); 36 | return source; 37 | } 38 | const zzfxG = // generate samples 39 | ( 40 | // parameters 41 | volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, 42 | release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, 43 | pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, 44 | bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0 45 | )=> 46 | { 47 | // init parameters 48 | let PI2 = PI*2, 49 | sign = v => v>0?1:-1, 50 | startSlide = slide *= 500 * PI2 / zzfxR / zzfxR, 51 | startFrequency = frequency *= (1 + randomness*2*random() - randomness) 52 | * PI2 / zzfxR, 53 | b=[], t=0, tm=0, i=0, j=1, r=0, c=0, s=0, f, length; 54 | 55 | // scale by sample rate 56 | attack = attack * zzfxR + 9; // minimum attack to prevent pop 57 | decay *= zzfxR; 58 | sustain *= zzfxR; 59 | release *= zzfxR; 60 | delay *= zzfxR; 61 | deltaSlide *= 500 * PI2 / zzfxR**3; 62 | modulation *= PI2 / zzfxR; 63 | pitchJump *= PI2 / zzfxR; 64 | pitchJumpTime *= zzfxR; 65 | repeatTime = repeatTime * zzfxR | 0; 66 | 67 | // generate waveform 68 | for(length = attack + decay + sustain + release + delay | 0; 69 | i < length; b[i++] = s) 70 | { 71 | if (!(++c%(bitCrush*100|0))) // bit crush 72 | { 73 | s = shape? shape>1? shape>2? shape>3? // wave shape 74 | sin((t%PI2)**3) : // 4 noise 75 | max(min(tan(t),1),-1): // 3 tan 76 | 1-(2*t/PI2%2+2)%2: // 2 saw 77 | 1-4*abs(round(t/PI2)-t/PI2): // 1 triangle 78 | sin(t); // 0 sin 79 | 80 | s = (repeatTime ? 81 | 1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo 82 | : 1) * 83 | sign(s)*(abs(s)**shapeCurve) * // curve 0=square, 2=pointy 84 | volume * zzfxV * ( // envelope 85 | i < attack ? i/attack : // attack 86 | i < attack + decay ? // decay 87 | 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff 88 | i < attack + decay + sustain ? // sustain 89 | sustainVolume : // sustain volume 90 | i < length - delay ? // release 91 | (length - i - delay)/release * // release falloff 92 | sustainVolume : // release volume 93 | 0); // post release 94 | 95 | s = delay ? s/2 + (delay > i ? 0 : // delay 96 | (i pitchJumpTime) // pitch jump 105 | { 106 | frequency += pitchJump; // apply pitch jump 107 | startFrequency += pitchJump; // also apply to start 108 | j = 0; // reset pitch jump time 109 | } 110 | 111 | if (repeatTime && !(++r % repeatTime)) // repeat 112 | { 113 | frequency = startFrequency; // reset frequency 114 | slide = startSlide; // reset slide 115 | j = j || 1; // reset pitch jump time 116 | } 117 | } 118 | 119 | return b; 120 | } 121 | 122 | sound = (...def) => zzfx(...def).nomangle(start)(); 123 | -------------------------------------------------------------------------------- /src/js/sound/sfx.js: -------------------------------------------------------------------------------- 1 | zzfx(...[1.03,,329,.02,.09,.05,2,.56,,-0.2,,,,1.5,,1.1,,.72,.02,.3]); // Hit 153 2 | zzfx(...[2.04,,475,.01,.03,.06,4,1.9,-8.7,,,,.09,,36,.2,.17,.67,.04]); // Shoot 118 3 | zzfx(...[1.61,,41,.01,.07,.07,,1.38,6.2,,,,,.9,,,.04,.56,.1,.21]); // Shoot 183 4 | zzfx(...[1.61,.8,18,.1,.07,.08,,.9,5.1,,,,,,,,,.56,.14]); // Shoot 183 5 | zzfx(...[1.99,,427,.01,,.07,,.62,6.7,-0.7,,,,.2,,,.11,.76,.05]); // Shoot 192 6 | zzfx(...[,,300,,.02,.04,,1.2,1,,,,,2.4,,1,,.61,.06]); // Shoot 220 7 | zzfx(...[2.22,,700,.05,1,1,1,3.65,.4,.9,,,,.6,,,.38,.44,.1]); // Explosion 223 8 | 9 | // Swing, can maybe mutate 10 | zzfx(...[1.04,,73,.01,.09,,1,.92,17,,,,,1.4,,,,.6,.02]); // Jump 255 11 | zzfx(...[1.04,,400,.01,.09,,1,.92,17,,,,,3,,,,.6,.02]); // Loaded Sound 256 12 | 13 | // Swing better 14 | zzfx(...[.5,,400,.1,.01,,3,.92,17,,,,,2,,,,1.04]); // Loaded Sound 256 15 | zzfx(...[.5,,1e3,.1,.01,,3,.92,17,,,,,2,,,,.5]); // Loaded Sound 256 16 | 17 | // Shield hit 18 | zzfx(...[2.03,,200,,.04,.12,1,1.98,,,,,,-2.4,,,.1,.59,.05,.17]); // Pickup 280 19 | 20 | // Hit player 21 | zzfx(...[2.07,,71,.01,.05,.03,2,.14,,,,,.01,1.5,,.1,.19,.95,.05,.16]); // Hit 316 22 | 23 | // Game over 24 | zzfx(...[2,,727,.01,.03,.53,3,1.39,.9,.1,,,,1.9,-44,.4,.39,.31,.12]); // Explosion 334 25 | 26 | // Kill 27 | zzfx(...[2.1,,400,.03,.1,.4,4,4.9,.6,.3,,,.13,1.9,,.1,.08,.32]); // Explosion 348 28 | 29 | zzfx(...[1.66,,163,.01,.04,.03,,.1,-8.8,.1,,,,,,.1,.03,.59,.05]); // Shoot 370 30 | 31 | // Hit enemy 32 | zzfx(...[1.6,,278,,.01,.01,2,.7,-7.1,,,,.07,1,,,.09,.81,.08]); // Shoot 377 33 | 34 | zzfx(...[,,397,.01,.21,.45,,1.56,-0.1,,471,.05,.13,,,,,.63,.15]); // Powerup 410 35 | 36 | // Wave start 37 | zzfx(...[2.09,,200,.05,,.5,,1.83,4.5,,,,.07,,,,.04,.5,.05]); // Shoot 421 38 | zzfx(...[2.11,0,65.40639,.04,.36,.5,2,.3,,,,,,,,,.19,.38,.06]); // Music 423 39 | 40 | // Wave progress 41 | zzfx(...[2.29,,28,.07,.12,.35,1,.74,,-0.5,-174,.16,.19,,,.1,.05,.66,.29,.47]); // Powerup 416 42 | 43 | // Wave end 44 | zzfx(...[1.35,,440,.08,.11,.44,,.55,.3,-1.6,489,.16,.02,,,,.07,.85,.24,.16]); // Powerup 476 45 | 46 | // Heal 47 | zzfx(...[1.21,,575,.09,.16,.29,1,1.97,,,204,.01,.06,,22,.1,,.62,.16,.39]); // Powerup 504 48 | 49 | // Perfect parry 50 | zzfx(...[2.14,,1e3,.01,.2,.31,3,3.99,,.9,,,.08,1.9,,,.22,.34,.12]); // Explosion 642 51 | 52 | // Thunder 53 | zzfx(...[2.11,,508,.02,.12,1,1,.46,3,.1,,,.15,.1,1.6,3,.3,.39,.11,.1]); // Explosion 683 54 | -------------------------------------------------------------------------------- /src/js/sound/sonantx.js: -------------------------------------------------------------------------------- 1 | // 2 | // Sonant-X 3 | // 4 | // Copyright (c) 2014 Nicolas Vanhoren 5 | // 6 | // Sonant-X is a fork of js-sonant by Marcus Geelnard and Jake Taylor. It is 7 | // still published using the same license (zlib license, see below). 8 | // 9 | // Copyright (c) 2011 Marcus Geelnard 10 | // Copyright (c) 2008-2009 Jake Taylor 11 | // 12 | // This software is provided 'as-is', without any express or implied 13 | // warranty. In no event will the authors be held liable for any damages 14 | // arising from the use of this software. 15 | // 16 | // Permission is granted to anyone to use this software for any purpose, 17 | // including commercial applications, and to alter it and redistribute it 18 | // freely, subject to the following restrictions: 19 | // 20 | // 1. The origin of this software must not be misrepresented; you must not 21 | // claim that you wrote the original software. If you use this software 22 | // in a product, an acknowledgment in the product documentation would be 23 | // appreciated but is not required. 24 | // 25 | // 2. Altered source versions must be plainly marked as such, and must not be 26 | // misrepresented as being the original software. 27 | // 28 | // 3. This notice may not be removed or altered from any source 29 | // distribution. 30 | 31 | 32 | const WAVE_SPS = 44100; // Samples per second 33 | const WAVE_CHAN = 2; // Channels 34 | const MAX_TIME = 33; // maximum time, in millis, that the generator can use consecutively 35 | 36 | let audioCtx; 37 | 38 | // Oscillators 39 | function osc_sin(value) 40 | { 41 | return sin(value * 6.283184); 42 | } 43 | 44 | function osc_square(value) { 45 | return osc_sin(value) < 0 ? -1 : 1; 46 | } 47 | 48 | function osc_saw(value) 49 | { 50 | return (value % 1) - 0.5; 51 | } 52 | 53 | function osc_tri(value) 54 | { 55 | const v2 = (value % 1) * 4; 56 | return v2 < 2 ? v2 - 1 : 3 - v2; 57 | } 58 | 59 | // Array of oscillator functions 60 | const oscillators = [ 61 | osc_sin, 62 | osc_square, 63 | osc_saw, 64 | osc_tri 65 | ]; 66 | 67 | function getnotefreq(n) 68 | { 69 | return 0.00390625 * pow(1.059463094, n - 128); 70 | } 71 | 72 | function genBuffer(waveSize, callBack) { 73 | setTimeout(() => { 74 | // Create the channel work buffer 75 | var buf = new Uint8Array(waveSize * WAVE_CHAN * 2); 76 | var b = buf.length - 2; 77 | var iterate = () => { 78 | var begin = new Date(); 79 | var count = 0; 80 | while(b >= 0) 81 | { 82 | buf[b] = 0; 83 | buf[b + 1] = 128; 84 | b -= 2; 85 | count += 1; 86 | if (count % 1000 === 0 && (new Date() - begin) > MAX_TIME) { 87 | setTimeout(iterate, 0); 88 | return; 89 | } 90 | } 91 | setTimeout(() => callBack(buf), 0); 92 | }; 93 | setTimeout(iterate, 0); 94 | }, 0); 95 | } 96 | 97 | function applyDelay(chnBuf, waveSamples, instr, rowLen, callBack) { 98 | const p1 = (instr.fx_delay_time * rowLen) >> 1; 99 | const t1 = instr.fx_delay_amt / 255; 100 | 101 | let n1 = 0; 102 | const iterate = () => { 103 | const beginning = new Date(); 104 | let count = 0; 105 | while (n1 < waveSamples - p1) { 106 | var b1 = 4 * n1; 107 | var l = 4 * (n1 + p1); 108 | 109 | // Left channel = left + right[-p1] * t1 110 | var x1 = chnBuf[l] + (chnBuf[l+1] << 8) + 111 | (chnBuf[b1+2] + (chnBuf[b1+3] << 8) - 32768) * t1; 112 | chnBuf[l] = x1 & 255; 113 | chnBuf[l+1] = (x1 >> 8) & 255; 114 | 115 | // Right channel = right + left[-p1] * t1 116 | x1 = chnBuf[l+2] + (chnBuf[l+3] << 8) + 117 | (chnBuf[b1] + (chnBuf[b1+1] << 8) - 32768) * t1; 118 | chnBuf[l+2] = x1 & 255; 119 | chnBuf[l+3] = (x1 >> 8) & 255; 120 | ++n1; 121 | count += 1; 122 | if (count % 1000 === 0 && (new Date() - beginning) > MAX_TIME) { 123 | setTimeout(iterate, 0); 124 | return; 125 | } 126 | } 127 | setTimeout(callBack, 0); 128 | }; 129 | setTimeout(iterate, 0); 130 | } 131 | 132 | class AudioGenerator { 133 | 134 | constructor(mixBuf) { 135 | this.mixBuf = mixBuf; 136 | this.waveSize = mixBuf.length / WAVE_CHAN / 2; 137 | } 138 | 139 | getWave() { 140 | const mixBuf = this.mixBuf; 141 | const waveSize = this.waveSize; 142 | // Local variables 143 | let b, k, x, wave, l1, l2, y; 144 | 145 | // Turn critical object properties into local variables (performance) 146 | const waveBytes = waveSize * WAVE_CHAN * 2; 147 | 148 | // Convert to a WAVE file (in a binary string) 149 | l1 = waveBytes - 8; 150 | l2 = l1 - 36; 151 | wave = String.fromCharCode(82,73,70,70, 152 | l1 & 255,(l1 >> 8) & 255,(l1 >> 16) & 255,(l1 >> 24) & 255, 153 | 87,65,86,69,102,109,116,32,16,0,0,0,1,0,2,0, 154 | 68,172,0,0,16,177,2,0,4,0,16,0,100,97,116,97, 155 | l2 & 255,(l2 >> 8) & 255,(l2 >> 16) & 255,(l2 >> 24) & 255); 156 | b = 0; 157 | while (b < waveBytes) { 158 | // This is a GC & speed trick: don't add one char at a time - batch up 159 | // larger partial strings 160 | x = ""; 161 | for (k = 0; k < 256 && b < waveBytes; ++k, b += 2) 162 | { 163 | // Note: We amplify and clamp here 164 | y = 4 * (mixBuf[b] + (mixBuf[b+1] << 8) - 32768); 165 | y = y < -32768 ? -32768 : (y > 32767 ? 32767 : y); 166 | x += String.fromCharCode(y & 255, (y >> 8) & 255); 167 | } 168 | wave += x; 169 | } 170 | return wave; 171 | } 172 | 173 | getAudioBuffer(callBack) { 174 | if (!audioCtx) { 175 | audioCtx = new AudioContext(); 176 | } 177 | 178 | const mixBuf = this.mixBuf; 179 | const waveSize = this.waveSize; 180 | 181 | const buffer = audioCtx.createBuffer(WAVE_CHAN, this.waveSize, WAVE_SPS); // Create Mono Source Buffer from Raw Binary 182 | const lchan = buffer.getChannelData(0); 183 | const rchan = buffer.getChannelData(1); 184 | let b = 0; 185 | const iterate = () => { 186 | var beginning = new Date(); 187 | var count = 0; 188 | while (b < waveSize) { 189 | var y = 4 * (mixBuf[b * 4] + (mixBuf[(b * 4) + 1] << 8) - 32768); 190 | y = y < -32768 ? -32768 : (y > 32767 ? 32767 : y); 191 | lchan[b] = y / 32768; 192 | y = 4 * (mixBuf[(b * 4) + 2] + (mixBuf[(b * 4) + 3] << 8) - 32768); 193 | y = y < -32768 ? -32768 : (y > 32767 ? 32767 : y); 194 | rchan[b] = y / 32768; 195 | b += 1; 196 | count += 1; 197 | if (count % 1000 === 0 && new Date() - beginning > MAX_TIME) { 198 | setTimeout(iterate, 0); 199 | return; 200 | } 201 | } 202 | setTimeout(() => callBack(buffer), 0); 203 | }; 204 | setTimeout(iterate, 0); 205 | } 206 | } 207 | 208 | class SoundGenerator { 209 | 210 | constructor(instr, rowLen) { 211 | this.instr = instr; 212 | this.rowLen = rowLen || 5605; 213 | 214 | this.osc_lfo = oscillators[instr.lfo_waveform]; 215 | this.osc1 = oscillators[instr.osc1_waveform]; 216 | this.osc2 = oscillators[instr.osc2_waveform]; 217 | this.attack = instr.env_attack; 218 | this.sustain = instr.env_sustain; 219 | this.release = instr.env_release; 220 | this.panFreq = pow(2, instr.fx_pan_freq - 8) / this.rowLen; 221 | this.lfoFreq = pow(2, instr.lfo_freq - 8) / this.rowLen; 222 | } 223 | 224 | genSound(n, chnBuf, currentpos) { 225 | var c1 = 0; 226 | var c2 = 0; 227 | 228 | // Precalculate frequencues 229 | var o1t = getnotefreq(n + (this.instr.osc1_oct - 8) * 12 + this.instr.osc1_det) * (1 + 0.0008 * this.instr.osc1_detune); 230 | var o2t = getnotefreq(n + (this.instr.osc2_oct - 8) * 12 + this.instr.osc2_det) * (1 + 0.0008 * this.instr.osc2_detune); 231 | 232 | // State variable init 233 | var q = this.instr.fx_resonance / 255; 234 | var low = 0; 235 | var band = 0; 236 | for (var j = this.attack + this.sustain + this.release - 1; j >= 0; --j) 237 | { 238 | let k = j + currentpos; 239 | 240 | // LFO 241 | const lfor = this.osc_lfo(k * this.lfoFreq) * this.instr.lfo_amt / 512 + 0.5; 242 | 243 | // Envelope 244 | let e = 1; 245 | if (j < this.attack) 246 | e = j / this.attack; 247 | else if (j >= this.attack + this.sustain) 248 | e -= (j - this.attack - this.sustain) / this.release; 249 | 250 | // Oscillator 1 251 | var t = o1t; 252 | if (this.instr.lfo_osc1_freq) t += lfor; 253 | if (this.instr.osc1_xenv) t *= e * e; 254 | c1 += t; 255 | var rsample = this.osc1(c1) * this.instr.osc1_vol; 256 | 257 | // Oscillator 2 258 | t = o2t; 259 | if (this.instr.osc2_xenv) t *= e * e; 260 | c2 += t; 261 | rsample += this.osc2(c2) * this.instr.osc2_vol; 262 | 263 | // Noise oscillator 264 | if(this.instr.noise_fader) rsample += (2*random()-1) * this.instr.noise_fader * e; 265 | 266 | rsample *= e / 255; 267 | 268 | // State variable filter 269 | var f = this.instr.fx_freq; 270 | if(this.instr.lfo_fx_freq) f *= lfor; 271 | f = 1.5 * sin(f * 3.141592 / WAVE_SPS); 272 | low += f * band; 273 | var high = q * (rsample - band) - low; 274 | band += f * high; 275 | switch(this.instr.fx_filter) 276 | { 277 | case 1: // Hipass 278 | rsample = high; 279 | break; 280 | case 2: // Lopass 281 | rsample = low; 282 | break; 283 | case 3: // Bandpass 284 | rsample = band; 285 | break; 286 | case 4: // Notch 287 | rsample = low + high; 288 | break; 289 | default: 290 | } 291 | 292 | // Panning & master volume 293 | t = osc_sin(k * this.panFreq) * this.instr.fx_pan_amt / 512 + 0.5; 294 | rsample *= 39 * this.instr.env_master; 295 | 296 | // Add to 16-bit channel buffer 297 | k = k * 4; 298 | if (k + 3 < chnBuf.length) { 299 | var x = chnBuf[k] + (chnBuf[k+1] << 8) + rsample * (1 - t); 300 | chnBuf[k] = x & 255; 301 | chnBuf[k+1] = (x >> 8) & 255; 302 | x = chnBuf[k+2] + (chnBuf[k+3] << 8) + rsample * t; 303 | chnBuf[k+2] = x & 255; 304 | chnBuf[k+3] = (x >> 8) & 255; 305 | } 306 | } 307 | } 308 | 309 | createAudioBuffer(n, callBack) { 310 | this.getAudioGenerator(n, ag => { 311 | ag.getAudioBuffer(callBack); 312 | }); 313 | } 314 | 315 | getAudioGenerator(n, callBack) { 316 | var bufferSize = (this.attack + this.sustain + this.release - 1) + (32 * this.rowLen); 317 | var self = this; 318 | genBuffer(bufferSize, buffer => { 319 | self.genSound(n, buffer, 0); 320 | applyDelay(buffer, bufferSize, self.instr, self.rowLen, function() { 321 | callBack(new AudioGenerator(buffer)); 322 | }); 323 | }); 324 | } 325 | } 326 | 327 | class MusicGenerator { 328 | 329 | constructor(song) { 330 | this.song = song; 331 | // Wave data configuration 332 | this.waveSize = WAVE_SPS * song.songLen; // Total song size (in samples) 333 | } 334 | 335 | generateTrack(instr, mixBuf, callBack) { 336 | genBuffer(this.waveSize, chnBuf => { 337 | // Preload/precalc some properties/expressions (for improved performance) 338 | var waveSamples = this.waveSize, 339 | waveBytes = this.waveSize * WAVE_CHAN * 2, 340 | rowLen = this.song.rowLen, 341 | endPattern = this.song.endPattern, 342 | soundGen = new SoundGenerator(instr, rowLen); 343 | 344 | let currentpos = 0; 345 | let p = 0; 346 | let row = 0; 347 | const recordSounds = () => { 348 | var beginning = new Date(); 349 | while (true) { 350 | if (row === 32) { 351 | row = 0; 352 | p += 1; 353 | continue; 354 | } 355 | if (p === endPattern - 1) { 356 | setTimeout(delay, 0); 357 | return; 358 | } 359 | var cp = instr.p[p]; 360 | if (cp) { 361 | var n = instr.c[cp - 1].n[row]; 362 | if (n) { 363 | soundGen.genSound(n, chnBuf, currentpos); 364 | } 365 | } 366 | currentpos += rowLen; 367 | row += 1; 368 | if (new Date() - beginning > MAX_TIME) { 369 | setTimeout(recordSounds, 0); 370 | return; 371 | } 372 | } 373 | }; 374 | 375 | const delay = () => applyDelay(chnBuf, waveSamples, instr, rowLen, finalize); 376 | 377 | var b2 = 0; 378 | const finalize = () => { 379 | const beginning = new Date(); 380 | let count = 0; 381 | 382 | // Add to mix buffer 383 | while(b2 < waveBytes) { 384 | var x2 = mixBuf[b2] + (mixBuf[b2+1] << 8) + chnBuf[b2] + (chnBuf[b2+1] << 8) - 32768; 385 | mixBuf[b2] = x2 & 255; 386 | mixBuf[b2+1] = (x2 >> 8) & 255; 387 | b2 += 2; 388 | count += 1; 389 | if (count % 1000 === 0 && (new Date() - beginning) > MAX_TIME) { 390 | setTimeout(finalize, 0); 391 | return; 392 | } 393 | } 394 | setTimeout(callBack, 0); 395 | }; 396 | setTimeout(recordSounds, 0); 397 | }); 398 | } 399 | 400 | getAudioGenerator(callBack) { 401 | genBuffer(this.waveSize, mixBuf => { 402 | let t = 0; 403 | const recu = () => { 404 | if (t < this.song.songData.length) { 405 | t += 1; 406 | this.generateTrack(this.song.songData[t - 1], mixBuf, recu); 407 | } else { 408 | callBack(new AudioGenerator(mixBuf)); 409 | } 410 | }; 411 | recu(); 412 | }); 413 | } 414 | 415 | createAudioBuffer(callBack) { 416 | this.getAudioGenerator(ag => ag.getAudioBuffer(callBack)); 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /src/js/sound/song.js: -------------------------------------------------------------------------------- 1 | ZEROES = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; 2 | 3 | SONG = { 4 | "rowLen": 5513, 5 | "endPattern": 10, 6 | "songData": [ 7 | { 8 | "osc1_oct": 7, 9 | "osc1_det": 0, 10 | "osc1_detune": 0, 11 | "osc1_xenv": 1, 12 | "osc1_vol": 255, 13 | "osc1_waveform": 0, 14 | "osc2_oct": 7, 15 | "osc2_det": 0, 16 | "osc2_detune": 0, 17 | "osc2_xenv": 1, 18 | "osc2_vol": 255, 19 | "osc2_waveform": 0, 20 | "noise_fader": 0, 21 | "env_attack": 100, 22 | "env_sustain": 0, 23 | "env_release": 3636, 24 | "env_master": 254, 25 | "fx_filter": 2, 26 | "fx_freq": 500, 27 | "fx_resonance": 254, 28 | "fx_delay_time": 0, 29 | "fx_delay_amt": 27, 30 | "fx_pan_freq": 0, 31 | "fx_pan_amt": 0, 32 | "lfo_osc1_freq": 0, 33 | "lfo_fx_freq": 0, 34 | "lfo_freq": 0, 35 | "lfo_amt": 0, 36 | "lfo_waveform": 0, 37 | "p": [ 38 | 2, 39 | 2, 40 | 2, 41 | 2, 42 | 2, 43 | 2, 44 | 2, 45 | 2, 46 | 2 47 | ], 48 | "c": [ 49 | { 50 | "n": ZEROES 51 | }, 52 | { 53 | "n": [ 54 | 135, 55 | 0, 56 | 0, 57 | 0, 58 | 0, 59 | 0, 60 | 0, 61 | 0, 62 | 135, 63 | 0, 64 | 0, 65 | 0, 66 | 0, 67 | 0, 68 | 0, 69 | 0, 70 | 135, 71 | 0, 72 | 0, 73 | 0, 74 | 0, 75 | 0, 76 | 0, 77 | 0, 78 | 135, 79 | 0, 80 | 0, 81 | 0, 82 | 0, 83 | 0, 84 | 0, 85 | 0 86 | ] 87 | } 88 | ] 89 | }, 90 | { 91 | "osc1_oct": 8, 92 | "osc1_det": 0, 93 | "osc1_detune": 0, 94 | "osc1_xenv": 1, 95 | "osc1_vol": 221, 96 | "osc1_waveform": 0, 97 | "osc2_oct": 8, 98 | "osc2_det": 0, 99 | "osc2_detune": 0, 100 | "osc2_xenv": 1, 101 | "osc2_vol": 210, 102 | "osc2_waveform": 0, 103 | "noise_fader": 255, 104 | "env_attack": 50, 105 | "env_sustain": 150, 106 | "env_release": 15454, 107 | "env_master": 229, 108 | "fx_filter": 3, 109 | "fx_freq": 11024, 110 | "fx_resonance": 240, 111 | "fx_delay_time": 6, 112 | "fx_delay_amt": 24, 113 | "fx_pan_freq": 0, 114 | "fx_pan_amt": 20, 115 | "lfo_osc1_freq": 0, 116 | "lfo_fx_freq": 1, 117 | "lfo_freq": 7, 118 | "lfo_amt": 64, 119 | "lfo_waveform": 0, 120 | "p": [ 121 | 3, 122 | 3, 123 | 3, 124 | 3, 125 | 3, 126 | 3, 127 | 3, 128 | 3, 129 | 3 130 | ], 131 | "c": [ 132 | { 133 | "n": ZEROES, 134 | }, 135 | { 136 | "n": ZEROES 137 | }, 138 | { 139 | "n": [ 140 | 0, 141 | 0, 142 | 0, 143 | 0, 144 | 134, 145 | 0, 146 | 0, 147 | 0, 148 | 0, 149 | 0, 150 | 0, 151 | 0, 152 | 134, 153 | 0, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | 0, 160 | 134, 161 | 0, 162 | 0, 163 | 0, 164 | 0, 165 | 0, 166 | 0, 167 | 0, 168 | 134, 169 | 0, 170 | 0, 171 | 0 172 | ] 173 | } 174 | ] 175 | }, 176 | { 177 | "osc1_oct": 7, 178 | "osc1_det": 0, 179 | "osc1_detune": 0, 180 | "osc1_xenv": 0, 181 | "osc1_vol": 192, 182 | "osc1_waveform": 1, 183 | "osc2_oct": 6, 184 | "osc2_det": 0, 185 | "osc2_detune": 9, 186 | "osc2_xenv": 0, 187 | "osc2_vol": 192, 188 | "osc2_waveform": 1, 189 | "noise_fader": 0, 190 | "env_attack": 137, 191 | "env_sustain": 2000, 192 | "env_release": 4611, 193 | "env_master": 192, 194 | "fx_filter": 1, 195 | "fx_freq": 982, 196 | "fx_resonance": 89, 197 | "fx_delay_time": 6, 198 | "fx_delay_amt": 25, 199 | "fx_pan_freq": 6, 200 | "fx_pan_amt": 77, 201 | "lfo_osc1_freq": 0, 202 | "lfo_fx_freq": 1, 203 | "lfo_freq": 3, 204 | "lfo_amt": 69, 205 | "lfo_waveform": 0, 206 | "p": [ 207 | 0, 208 | 0, 209 | 0, 210 | 0, 211 | 0, 212 | 0, 213 | 4, 214 | 4 215 | ], 216 | "c": [ 217 | { 218 | "n": ZEROES 219 | }, 220 | { 221 | "n": ZEROES 222 | }, 223 | { 224 | "n": ZEROES 225 | }, 226 | { 227 | "n": [ 228 | 137, 229 | 0, 230 | 0, 231 | 144, 232 | 0, 233 | 0, 234 | 142, 235 | 0, 236 | 0, 237 | 144, 238 | 0, 239 | 0, 240 | 0, 241 | 149, 242 | 0, 243 | 0, 244 | 144, 245 | 0, 246 | 0, 247 | 142, 248 | 0, 249 | 0, 250 | 144, 251 | 0, 252 | 0, 253 | 0, 254 | 0, 255 | 0, 256 | 0, 257 | 0, 258 | 0, 259 | 0 260 | ] 261 | } 262 | ] 263 | }, 264 | { 265 | "osc1_oct": 7, 266 | "osc1_det": 0, 267 | "osc1_detune": 0, 268 | "osc1_xenv": 0, 269 | "osc1_vol": 255, 270 | "osc1_waveform": 1, 271 | "osc2_oct": 7, 272 | "osc2_det": 0, 273 | "osc2_detune": 9, 274 | "osc2_xenv": 0, 275 | "osc2_vol": 154, 276 | "osc2_waveform": 1, 277 | "noise_fader": 0, 278 | "env_attack": 197, 279 | "env_sustain": 88, 280 | "env_release": 10614, 281 | "env_master": 45, 282 | "fx_filter": 0, 283 | "fx_freq": 11025, 284 | "fx_resonance": 255, 285 | "fx_delay_time": 2, 286 | "fx_delay_amt": 146, 287 | "fx_pan_freq": 3, 288 | "fx_pan_amt": 47, 289 | "lfo_osc1_freq": 0, 290 | "lfo_fx_freq": 0, 291 | "lfo_freq": 0, 292 | "lfo_amt": 0, 293 | "lfo_waveform": 0, 294 | "p": [ 295 | 0, 296 | 5, 297 | 5, 298 | 0, 299 | 0, 300 | 0, 301 | 0, 302 | 0, 303 | 5 304 | ], 305 | "c": [ 306 | { 307 | "n": ZEROES 308 | }, 309 | { 310 | "n": ZEROES 311 | }, 312 | { 313 | "n": ZEROES 314 | }, 315 | { 316 | "n": ZEROES 317 | }, 318 | { 319 | "n": [ 320 | 125, 321 | 0, 322 | 0, 323 | 132, 324 | 0, 325 | 0, 326 | 130, 327 | 0, 328 | 0, 329 | 132, 330 | 0, 331 | 0, 332 | 137, 333 | 0, 334 | 0, 335 | 132, 336 | 0, 337 | 0, 338 | 130, 339 | 0, 340 | 0, 341 | 132, 342 | 0, 343 | 0, 344 | 0, 345 | 0, 346 | 0, 347 | 0, 348 | 0, 349 | 0, 350 | 0, 351 | 0 352 | ] 353 | } 354 | ] 355 | }, 356 | { 357 | "osc1_oct": 9, 358 | "osc1_det": 0, 359 | "osc1_detune": 0, 360 | "osc1_xenv": 0, 361 | "osc1_vol": 255, 362 | "osc1_waveform": 0, 363 | "osc2_oct": 9, 364 | "osc2_det": 0, 365 | "osc2_detune": 12, 366 | "osc2_xenv": 0, 367 | "osc2_vol": 255, 368 | "osc2_waveform": 0, 369 | "noise_fader": 0, 370 | "env_attack": 100, 371 | "env_sustain": 0, 372 | "env_release": 14545, 373 | "env_master": 70, 374 | "fx_filter": 0, 375 | "fx_freq": 0, 376 | "fx_resonance": 240, 377 | "fx_delay_time": 2, 378 | "fx_delay_amt": 157, 379 | "fx_pan_freq": 3, 380 | "fx_pan_amt": 47, 381 | "lfo_osc1_freq": 0, 382 | "lfo_fx_freq": 0, 383 | "lfo_freq": 0, 384 | "lfo_amt": 0, 385 | "lfo_waveform": 0, 386 | "p": [ 387 | 0, 388 | 0, 389 | 0, 390 | 6, 391 | 6 392 | ], 393 | "c": [ 394 | { 395 | "n": ZEROES 396 | }, 397 | { 398 | "n": ZEROES 399 | }, 400 | { 401 | "n": ZEROES 402 | }, 403 | { 404 | "n": ZEROES 405 | }, 406 | { 407 | "n": ZEROES 408 | }, 409 | { 410 | "n": [ 411 | 137, 412 | 0, 413 | 0, 414 | 132, 415 | 0, 416 | 0, 417 | 130, 418 | 0, 419 | 0, 420 | 132, 421 | 0, 422 | 0, 423 | 137, 424 | 0, 425 | 0, 426 | 132, 427 | 0, 428 | 0, 429 | 130, 430 | 0, 431 | 0, 432 | 132, 433 | 0, 434 | 0, 435 | 0, 436 | 0, 437 | 0, 438 | 0, 439 | 0, 440 | 0, 441 | 0, 442 | 0 443 | ] 444 | } 445 | ] 446 | } 447 | ], 448 | "songLen": 31 449 | } 450 | 451 | playSong = () => new MusicGenerator(SONG).createAudioBuffer(buffer => { 452 | const source = audioCtx.createBufferSource(); 453 | source.buffer = buffer; 454 | source.loop = true; 455 | 456 | const gainNode = audioCtx.createGain(); 457 | gainNode.gain.value = SONG_VOLUME; 458 | gainNode.connect(audioCtx.destination); 459 | source.connect(gainNode); 460 | source.nomangle(start)(); 461 | 462 | playSong = () => 0; 463 | setSongVolume = (x) => gainNode.gain.value = x; 464 | }); 465 | 466 | setSongVolume = () => 0; 467 | -------------------------------------------------------------------------------- /src/js/state-machine.js: -------------------------------------------------------------------------------- 1 | class StateMachine { 2 | transitionToState(state) { 3 | state.stateMachine = this; 4 | state.previous = this.state || new State(); 5 | state.onEnter(); 6 | this.state = state; 7 | } 8 | 9 | cycle(elapsed) { 10 | this.state.cycle(elapsed); 11 | } 12 | } 13 | 14 | class State { 15 | 16 | constructor() { 17 | this.age = 0; 18 | } 19 | 20 | get swordRaiseRatio() { return 0; } 21 | get shieldRaiseRatio() { return 0; } 22 | get speedRatio() { return 1; } 23 | get attackPreparationRatio() { return 0; } 24 | 25 | onEnter() { 26 | 27 | } 28 | 29 | cycle(elapsed) { 30 | this.age += elapsed; 31 | } 32 | } 33 | 34 | characterStateMachine = ({ 35 | entity, 36 | chargeTime, 37 | perfectParryTime, 38 | releaseAttackBetweenStrikes, 39 | staggerTime, 40 | }) => { 41 | const { controls } = entity; 42 | const stateMachine = new StateMachine(); 43 | 44 | const attackDamagePattern = [ 45 | 0.7, 46 | 0.8, 47 | 0.9, 48 | 1, 49 | 3, 50 | ]; 51 | 52 | chargeTime = chargeTime || 1; 53 | perfectParryTime = perfectParryTime || 0; 54 | staggerTime = staggerTime || 0; 55 | 56 | class MaybeExhaustedState extends State { 57 | cycle(elapsed) { 58 | super.cycle(elapsed); 59 | if (entity.stamina === 0) { 60 | stateMachine.transitionToState(new Exhausted()); 61 | } 62 | if (entity.age - entity.lastDamage < staggerTime) { 63 | stateMachine.transitionToState(new Staggered()); 64 | } 65 | } 66 | } 67 | 68 | class Idle extends MaybeExhaustedState { 69 | get swordRaiseRatio() { return interpolate(this.previous.swordRaiseRatio, 0, this.age / 0.1); } 70 | get shieldRaiseRatio() { return interpolate(this.previous.shieldRaiseRatio, 0, this.age / 0.1); } 71 | 72 | get speedRatio() { 73 | return entity.inWater ? 0.5 : 1; 74 | } 75 | 76 | cycle(elapsed) { 77 | super.cycle(elapsed); 78 | if (controls.shield) { 79 | stateMachine.transitionToState(new Shielding()); 80 | } else if (controls.attack) { 81 | stateMachine.transitionToState(new Charging()); 82 | } else if (controls.dash) { 83 | stateMachine.transitionToState(new Dashing()); 84 | } 85 | } 86 | } 87 | 88 | class Shielding extends MaybeExhaustedState { 89 | get speedRatio() { 90 | return 0.5; 91 | } 92 | 93 | get shieldRaiseRatio() { return interpolate(0, 1, this.age / 0.1); } 94 | get swordRaiseRatio() { return interpolate(0, -1, this.age / 0.1); } 95 | get shielded() { return true; } 96 | get perfectParry() { return this.age < perfectParryTime; } 97 | 98 | cycle(elapsed) { 99 | super.cycle(elapsed); 100 | if (!controls.shield) { 101 | stateMachine.transitionToState(new Idle()); 102 | } 103 | } 104 | } 105 | 106 | class Dashing extends State { 107 | 108 | get swordRaiseRatio() { 109 | return interpolate(this.previous.swordRaiseRatio, -1, this.age / (PLAYER_DASH_DURATION / 2)); 110 | } 111 | 112 | onEnter() { 113 | this.dashAngle = entity.controls.angle; 114 | 115 | entity.dash(entity.controls.angle, PLAYER_DASH_DISTANCE, PLAYER_DASH_DURATION); 116 | sound(...[1.99,,427,.01,,.07,,.62,6.7,-0.7,,,,.2,,,.11,.76,.05]); 117 | 118 | entity.loseStamina(0.15); 119 | } 120 | 121 | cycle(elapsed) { 122 | super.cycle(elapsed); 123 | 124 | if (this.age > PLAYER_DASH_DURATION) { 125 | stateMachine.transitionToState(new Idle()); 126 | } 127 | } 128 | } 129 | 130 | class Charging extends MaybeExhaustedState { 131 | constructor(counter = 0) { 132 | super(); 133 | this.counter = counter; 134 | } 135 | 136 | get speedRatio() { 137 | return 0.5; 138 | } 139 | 140 | get attackPreparationRatio() { 141 | return this.age / chargeTime; 142 | } 143 | 144 | get swordRaiseRatio() { 145 | return interpolate(this.previous.swordRaiseRatio, -1, this.attackPreparationRatio); 146 | } 147 | 148 | cycle(elapsed) { 149 | const { attackPreparationRatio } = this; 150 | 151 | super.cycle(elapsed); 152 | 153 | if (!controls.attack) { 154 | const counter = this.age >= 1 ? attackDamagePattern.length - 1 : this.counter; 155 | stateMachine.transitionToState(new Strike(counter)); 156 | } 157 | 158 | if (attackPreparationRatio < 1 && this.attackPreparationRatio >= 1) { 159 | const animation = entity.scene.add(new FullCharge()); 160 | animation.x = entity.x - entity.facing * 20; 161 | animation.y = entity.y - 60; 162 | } 163 | } 164 | } 165 | 166 | class Strike extends MaybeExhaustedState { 167 | constructor(counter = 0) { 168 | super(); 169 | this.counter = counter; 170 | this.prepareRatio = -min(PLAYER_HEAVY_ATTACK_INDEX, this.counter + 1) * 0.4; 171 | } 172 | 173 | get swordRaiseRatio() { 174 | return this.age < STRIKE_WINDUP 175 | ? interpolate( 176 | this.previous.swordRaiseRatio, 177 | this.prepareRatio, 178 | this.age / STRIKE_WINDUP, 179 | ) 180 | : interpolate( 181 | this.prepareRatio, 182 | 1, 183 | (this.age - STRIKE_WINDUP) / (STRIKE_DURATION - STRIKE_WINDUP), 184 | ); 185 | } 186 | 187 | onEnter() { 188 | entity.lunge(); 189 | 190 | this.anim = new SwingEffect( 191 | entity, 192 | this.counter == attackDamagePattern.length - 1 ? '#ff0' : '#fff', 193 | this.prepareRatio, 194 | 0, 195 | ); 196 | } 197 | 198 | cycle(elapsed) { 199 | super.cycle(elapsed); 200 | 201 | if (this.age >= STRIKE_WINDUP) { 202 | entity.scene.add(this.anim); 203 | this.anim.toAngle = this.swordRaiseRatio; 204 | } 205 | 206 | if (controls.attack) this.didTryToAttackAgain = true; 207 | if (controls.dash) this.didTryToDash = true; 208 | 209 | if (this.age > 0.15) { 210 | entity.strike(attackDamagePattern[this.counter]); 211 | 212 | if (this.didTryToDash) { 213 | stateMachine.transitionToState(new Dashing()); 214 | return; 215 | } 216 | 217 | stateMachine.transitionToState( 218 | this.counter < PLAYER_HEAVY_ATTACK_INDEX 219 | ? this.didTryToAttackAgain 220 | ? new Charging(this.counter + 1) 221 | : new LightRecover(this.counter) 222 | : new HeavyRecover() 223 | ); 224 | } 225 | } 226 | } 227 | 228 | class LightRecover extends MaybeExhaustedState { 229 | constructor(counter) { 230 | super(); 231 | this.counter = counter; 232 | } 233 | 234 | get swordRaiseRatio() { 235 | const start = 1; 236 | const end = 0; 237 | 238 | const ratio = min(1, this.age / 0.05); 239 | return ratio * (end - start) + start; 240 | } 241 | 242 | cycle(elapsed) { 243 | super.cycle(elapsed); 244 | 245 | if (!controls.attack || !releaseAttackBetweenStrikes) { 246 | this.readyToAttack = true; 247 | } 248 | 249 | if (this.age > 0.3) { 250 | stateMachine.transitionToState(new Idle()); 251 | } else if (controls.attack && this.readyToAttack) { 252 | stateMachine.transitionToState(new Charging(this.counter + 1)); 253 | } else if (controls.shield) { 254 | stateMachine.transitionToState(new Shielding()); 255 | } else if (controls.dash) { 256 | stateMachine.transitionToState(new Dashing()); 257 | } 258 | } 259 | } 260 | 261 | class HeavyRecover extends MaybeExhaustedState { 262 | 263 | get swordRaiseRatio() { 264 | const start = 1; 265 | const end = 0; 266 | 267 | const ratio = min(this.age / 0.5, 1); 268 | return ratio * (end - start) + start; 269 | } 270 | 271 | cycle(elapsed) { 272 | super.cycle(elapsed); 273 | 274 | if (this.age > 0.5) { 275 | stateMachine.transitionToState(new Idle()); 276 | } else if (controls.dash) { 277 | stateMachine.transitionToState(new Dashing()); 278 | } 279 | } 280 | } 281 | 282 | class Exhausted extends State { 283 | get swordRaiseRatio() { 284 | return interpolate(this.previous.swordRaiseRatio, 1, this.age / 0.2); 285 | } 286 | 287 | get exhausted() { 288 | return true; 289 | } 290 | 291 | get speedRatio() { 292 | return 0.5; 293 | } 294 | 295 | onEnter() { 296 | if (!entity.perfectlyBlocked) entity.displayLabel(nomangle('Exhausted')); 297 | entity.perfectBlocked = false; 298 | } 299 | 300 | cycle(elapsed) { 301 | super.cycle(elapsed); 302 | 303 | if (entity.stamina >= 1) { 304 | stateMachine.transitionToState(new Idle()); 305 | } 306 | } 307 | } 308 | 309 | class Staggered extends State { 310 | get swordRaiseRatio() { 311 | return this.previous.swordRaiseRatio; 312 | } 313 | 314 | get speedRatio() { 315 | return 0.5; 316 | } 317 | 318 | cycle(elapsed) { 319 | super.cycle(elapsed); 320 | 321 | if (this.age >= staggerTime) { 322 | stateMachine.transitionToState(new Idle()); 323 | } 324 | } 325 | } 326 | 327 | stateMachine.transitionToState(new Idle()); 328 | 329 | return stateMachine; 330 | } 331 | -------------------------------------------------------------------------------- /src/js/util/first-item.js: -------------------------------------------------------------------------------- 1 | firstItem = (iterable) => { 2 | for (const item of iterable) { 3 | return item; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/js/util/regen-entity.js: -------------------------------------------------------------------------------- 1 | regenEntity = (entity, radiusX, radiusY, pathMinDist = 50) => { 2 | const camera = firstItem(entity.scene.category('camera')); 3 | let regen = false; 4 | while (entity.x < camera.x - radiusX) { 5 | entity.x += radiusX * 2; 6 | regen = true; 7 | } 8 | 9 | while (entity.x > camera.x + radiusX) { 10 | entity.x -= radiusX * 2; 11 | regen = true; 12 | } 13 | 14 | while (entity.y < camera.y - radiusY) { 15 | entity.y += radiusX * 2; 16 | } 17 | 18 | while (entity.y > camera.y + radiusY) { 19 | entity.y -= radiusX * 2; 20 | } 21 | 22 | while (regen) { 23 | entity.y = entity.scene.pathCurve(entity.x) + rnd(pathMinDist, 500) * pick([-1, 1]); 24 | const distToPath = abs(entity.y - entity.scene.pathCurve(entity.x)); 25 | regen = distToPath < pathMinDist || entity.inWater; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/js/util/resizer.js: -------------------------------------------------------------------------------- 1 | onresize = () => { 2 | let windowWidth = innerWidth, 3 | windowHeight = innerHeight, 4 | 5 | availableRatio = windowWidth / windowHeight, // available ratio 6 | canvasRatio = CANVAS_WIDTH / CANVAS_HEIGHT, // base ratio 7 | appliedWidth, 8 | appliedHeight, 9 | containerStyle = nomangle(t).style; 10 | 11 | if (availableRatio <= canvasRatio) { 12 | appliedWidth = windowWidth; 13 | appliedHeight = appliedWidth / canvasRatio; 14 | } else { 15 | appliedHeight = windowHeight; 16 | appliedWidth = appliedHeight * canvasRatio; 17 | } 18 | 19 | containerStyle.width = appliedWidth + 'px'; 20 | containerStyle.height = appliedHeight + 'px'; 21 | }; 22 | -------------------------------------------------------------------------------- /src/js/util/rng.js: -------------------------------------------------------------------------------- 1 | class RNG { 2 | constructor() { 3 | this.index = 0; 4 | this.elements = Array.apply(null, Array(50)).map(() => random()); 5 | } 6 | 7 | next(min = 0, max = 1) { 8 | return this.elements[this.index++ % this.elements.length] * (max - min) + min; 9 | } 10 | 11 | reset() { 12 | this.index = 0; 13 | } 14 | } 15 | --------------------------------------------------------------------------------