├── .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 |
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 |
--------------------------------------------------------------------------------