incanto 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES.md +88 -0
- package/assets/audio/attacked.mp3 +0 -0
- package/assets/audio/explosion.mp3 +0 -0
- package/assets/audio/gold_loot.mp3 +0 -0
- package/assets/audio/heal.mp3 +0 -0
- package/assets/audio/hit_metal_bang.mp3 +0 -0
- package/assets/audio/ice_spear.mp3 +0 -0
- package/assets/audio/monster_died.mp3 +0 -0
- package/assets/audio/slash.mp3 +0 -0
- package/assets/audio/smite.mp3 +0 -0
- package/assets/audio/spells_cast.mp3 +0 -0
- package/assets/audio/ui_click.wav +0 -0
- package/assets/audio/walk.mp3 +0 -0
- package/assets/catalog.json +390 -0
- package/assets/characters/2dbasic.json +41 -0
- package/assets/characters/2dbasic.png +0 -0
- package/assets/characters/ghost.json +46 -0
- package/assets/characters/ghost.png +0 -0
- package/assets/characters/goblin.json +40 -0
- package/assets/characters/goblin.png +0 -0
- package/assets/characters/medieval-knight.json +41 -0
- package/assets/characters/medieval-knight.png +0 -0
- package/assets/effects/swoosh.png +0 -0
- package/assets/items/box.png +0 -0
- package/assets/items/buff_potion.png +0 -0
- package/assets/items/coin.png +0 -0
- package/assets/items/gem.png +0 -0
- package/assets/items/gold.png +0 -0
- package/assets/items/hp_potion.png +0 -0
- package/assets/items/locked_item_box.png +0 -0
- package/assets/items/map.png +0 -0
- package/assets/items/resurrection_potion.png +0 -0
- package/assets/items/super_box.png +0 -0
- package/assets/items/trap.png +0 -0
- package/assets/tiles/floor00.jpg +0 -0
- package/assets/tiles/minecraft-tiles.png +0 -0
- package/assets/tiles/wall00.jpg +0 -0
- package/assets/vegetation/ash_color.png +0 -0
- package/assets/vegetation/aspen_color.png +0 -0
- package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
- package/assets/vegetation/ground/dirt_color.jpg +0 -0
- package/assets/vegetation/ground/dirt_normal.jpg +0 -0
- package/assets/vegetation/ground/grass.jpg +0 -0
- package/assets/vegetation/oak_color.png +0 -0
- package/assets/vegetation/pine_color.png +0 -0
- package/bin/incanto-assets.mjs +107 -0
- package/bin/incanto-check.mjs +107 -0
- package/bin/incanto-editor.mjs +343 -0
- package/bin/incanto-env.mjs +144 -0
- package/bin/incanto-model.mjs +296 -0
- package/bin/incanto-play.mjs +219 -0
- package/bin/incanto-skills.mjs +71 -0
- package/dist/2d.d.ts +642 -0
- package/dist/2d.js +44 -0
- package/dist/3d.d.ts +1860 -0
- package/dist/3d.js +5 -0
- package/dist/agent8-DzU2fFyH.js +129 -0
- package/dist/audio-player-DqUR3XFs.d.ts +110 -0
- package/dist/behavior-BAQq7HGM.d.ts +851 -0
- package/dist/create-game-BdjpTHrW.js +1725 -0
- package/dist/create-game-CZHROKcT.js +527 -0
- package/dist/debug-draw-CZmOYjL2.js +13 -0
- package/dist/debug.d.ts +66 -0
- package/dist/debug.js +658 -0
- package/dist/duplicate-DP2WPYom.js +22 -0
- package/dist/env.d.ts +430 -0
- package/dist/env.js +3152 -0
- package/dist/errors-BMFaY68Q.d.ts +33 -0
- package/dist/errors-BpWbnbb_.js +13 -0
- package/dist/gameplay-Ccruc3Wd.js +1501 -0
- package/dist/gameplay.d.ts +543 -0
- package/dist/gameplay.js +2 -0
- package/dist/heightmap-CroQPEER.js +185 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +62 -0
- package/dist/json-BLk7H2Qa.js +30 -0
- package/dist/loader-CGs_G-r0.js +919 -0
- package/dist/loader-Mo0KghCv.d.ts +41 -0
- package/dist/net.d.ts +427 -0
- package/dist/net.js +772 -0
- package/dist/noise-CGUMx44x.js +82 -0
- package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
- package/dist/particle-sim-DYuSUxvK.js +1319 -0
- package/dist/physics-2d-KuMWPTf6.js +288 -0
- package/dist/physics-3d-Dl67vOLT.js +434 -0
- package/dist/react.d.ts +65 -0
- package/dist/react.js +209 -0
- package/dist/register-BuUV1_KB.js +561 -0
- package/dist/register-CNlYAS1_.js +10634 -0
- package/dist/register-DPEV9_9t.js +851 -0
- package/dist/register-Dasmnurl.js +374 -0
- package/dist/registry-BVJ2HbCn.js +132 -0
- package/dist/rng-DP-SR7eg.js +38 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-CcoWb32N.d.ts +104 -0
- package/dist/test.d.ts +158 -0
- package/dist/test.js +275 -0
- package/dist/touch-031PxtCR.js +208 -0
- package/dist/vite.d.ts +26 -0
- package/dist/vite.js +57 -0
- package/editor/assets/GameServer-C56iOUgF.js +1 -0
- package/editor/assets/agent8-Bp7QFI7v.js +1 -0
- package/editor/assets/index-DF3tMeKJ.css +1 -0
- package/editor/assets/index-Dl2pjA8e.js +7365 -0
- package/editor/assets/rapier-CEuLKeCu.js +1 -0
- package/editor/assets/rapier-DE6a0vmv.js +1 -0
- package/editor/index.html +169 -0
- package/package.json +97 -0
- package/schemas/scene.schema.json +4254 -0
- package/skills/README.md +9 -0
- package/skills/incanto-3d-character.md +229 -0
- package/skills/incanto-3d-models.md +151 -0
- package/skills/incanto-assets.md +118 -0
- package/skills/incanto-audio.md +309 -0
- package/skills/incanto-behaviors-and-scripts.md +169 -0
- package/skills/incanto-building-2d-games.md +242 -0
- package/skills/incanto-building-3d-games.md +245 -0
- package/skills/incanto-editor.md +163 -0
- package/skills/incanto-environment.md +743 -0
- package/skills/incanto-gameplay-behaviors.md +707 -0
- package/skills/incanto-multiplayer.md +264 -0
- package/skills/incanto-node-reference.md +797 -0
- package/skills/incanto-physics-and-input.md +164 -0
- package/skills/incanto-scene-json-authoring.md +325 -0
- package/skills/incanto-verifying-your-game.md +191 -0
- package/skills/incanto-web-integration.md +96 -0
- package/templates/agent8-server.js +84 -0
- package/templates/agent8-server.ts +138 -0
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
import { y as Signal } from "./loader-CGs_G-r0.js";
|
|
2
|
+
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
3
|
+
import { t as Rng } from "./rng-DP-SR7eg.js";
|
|
4
|
+
import { l as synthSfx } from "./register-BuUV1_KB.js";
|
|
5
|
+
import { n as jsonEquals } from "./json-BLk7H2Qa.js";
|
|
6
|
+
//#region src/core/audio/buses.ts
|
|
7
|
+
/**
|
|
8
|
+
* Global volume state — `master` × `sfx`/`music` gain (0..1) plus a `muted`
|
|
9
|
+
* flag. PURE state (no WebAudio): games and AI set it to control overall
|
|
10
|
+
* loudness/mute; the renderer reads `effectiveVolume()` when it actually plays
|
|
11
|
+
* a sound. Lives on the engine as `engine.audio` (instance-scoped, no
|
|
12
|
+
* singletons). The WebAudio wiring is in the adapter; this just holds the
|
|
13
|
+
* numbers so they're fully testable in `node`.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* engine.audio.master = 0.8; // dim everything
|
|
17
|
+
* engine.audio.music = 0.4; // quieter background music
|
|
18
|
+
* engine.audio.muted = true; // mute toggle
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
var AudioBuses = class {
|
|
22
|
+
/** Fires whenever any value changes (drives live re-gain of playing sounds). */
|
|
23
|
+
changed = new Signal();
|
|
24
|
+
_master = 1;
|
|
25
|
+
_sfx = 1;
|
|
26
|
+
_music = 1;
|
|
27
|
+
_muted = false;
|
|
28
|
+
get master() {
|
|
29
|
+
return this._master;
|
|
30
|
+
}
|
|
31
|
+
set master(v) {
|
|
32
|
+
this._master = this.assign(this._master, v);
|
|
33
|
+
}
|
|
34
|
+
get sfx() {
|
|
35
|
+
return this._sfx;
|
|
36
|
+
}
|
|
37
|
+
set sfx(v) {
|
|
38
|
+
this._sfx = this.assign(this._sfx, v);
|
|
39
|
+
}
|
|
40
|
+
get music() {
|
|
41
|
+
return this._music;
|
|
42
|
+
}
|
|
43
|
+
set music(v) {
|
|
44
|
+
this._music = this.assign(this._music, v);
|
|
45
|
+
}
|
|
46
|
+
get muted() {
|
|
47
|
+
return this._muted;
|
|
48
|
+
}
|
|
49
|
+
set muted(v) {
|
|
50
|
+
if (v === this._muted) return;
|
|
51
|
+
this._muted = v;
|
|
52
|
+
this.changed.emit();
|
|
53
|
+
}
|
|
54
|
+
/** Final gain for a sound on `bus` with its own `sourceVolume` (all clamped). */
|
|
55
|
+
effectiveVolume(bus, sourceVolume) {
|
|
56
|
+
if (this._muted) return 0;
|
|
57
|
+
const busGain = bus === "music" ? this._music : this._sfx;
|
|
58
|
+
return this._master * busGain * clamp01$2(sourceVolume);
|
|
59
|
+
}
|
|
60
|
+
/** Clamp + finite-guard a new value; emit only on a real change. */
|
|
61
|
+
assign(current, next) {
|
|
62
|
+
if (!Number.isFinite(next)) return current;
|
|
63
|
+
const clamped = clamp01$2(next);
|
|
64
|
+
if (clamped !== current) this.changed.emit();
|
|
65
|
+
return clamped;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
function clamp01$2(v) {
|
|
69
|
+
if (!Number.isFinite(v)) return 0;
|
|
70
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/core/audio/crossfade.ts
|
|
74
|
+
/**
|
|
75
|
+
* PURE crossfade / fade envelope math for the music manager. No WebAudio: just
|
|
76
|
+
* the gain curves over time, so the loudness behaviour is unit-testable in
|
|
77
|
+
* `node` and the headless music state machine can be validated without a backend.
|
|
78
|
+
* The adapter applies these gains to real GainNodes / element volumes each frame.
|
|
79
|
+
*/
|
|
80
|
+
/** Linear 0→1 (`in`) or 1→0 (`out`) over `duration` seconds at elapsed `t`. */
|
|
81
|
+
function fadeGain(t, duration, dir) {
|
|
82
|
+
const p = progress(t, duration);
|
|
83
|
+
return dir === "in" ? p : 1 - p;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Equal-power crossfade gains at elapsed `t` over `seconds`. The outgoing track
|
|
87
|
+
* fades cos(¼π·p) and the incoming sin(¼π·p) so `out² + in² ≈ 1` throughout —
|
|
88
|
+
* constant perceived loudness, no mid-fade dip (the classic equal-power law).
|
|
89
|
+
* `p` is `t/seconds` clamped to [0,1]; a zero-second fade swaps instantly.
|
|
90
|
+
*/
|
|
91
|
+
function crossfadeGains(t, seconds) {
|
|
92
|
+
const angle = progress(t, seconds) * Math.PI / 2;
|
|
93
|
+
return {
|
|
94
|
+
out: clamp01$1(Math.cos(angle)),
|
|
95
|
+
in: clamp01$1(Math.sin(angle))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Normalized progress 0..1; a non-positive duration is instantly complete. */
|
|
99
|
+
function progress(t, duration) {
|
|
100
|
+
if (!Number.isFinite(t)) return 1;
|
|
101
|
+
if (duration <= 0) return 1;
|
|
102
|
+
return clamp01$1(t / duration);
|
|
103
|
+
}
|
|
104
|
+
function clamp01$1(v) {
|
|
105
|
+
if (!Number.isFinite(v)) return 0;
|
|
106
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
107
|
+
}
|
|
108
|
+
function resolveCtor$1() {
|
|
109
|
+
const g = globalThis;
|
|
110
|
+
return g.AudioContext ?? g.webkitAudioContext ?? null;
|
|
111
|
+
}
|
|
112
|
+
function createContext$1() {
|
|
113
|
+
const Ctor = resolveCtor$1();
|
|
114
|
+
return Ctor ? new Ctor() : null;
|
|
115
|
+
}
|
|
116
|
+
function createElement() {
|
|
117
|
+
if (typeof Audio === "undefined") return null;
|
|
118
|
+
return new Audio();
|
|
119
|
+
}
|
|
120
|
+
/** A no-op track for the headless / missing-backend path. */
|
|
121
|
+
const INERT = {
|
|
122
|
+
src: "",
|
|
123
|
+
setGain() {},
|
|
124
|
+
setLoop() {},
|
|
125
|
+
play() {},
|
|
126
|
+
stop() {}
|
|
127
|
+
};
|
|
128
|
+
var WebAudioTrack = class {
|
|
129
|
+
src;
|
|
130
|
+
el;
|
|
131
|
+
gainNode;
|
|
132
|
+
constructor(src, el, ctx) {
|
|
133
|
+
this.src = src;
|
|
134
|
+
this.el = el;
|
|
135
|
+
try {
|
|
136
|
+
el.crossOrigin = "anonymous";
|
|
137
|
+
} catch {}
|
|
138
|
+
el.src = src;
|
|
139
|
+
const source = ctx.createMediaElementSource(el);
|
|
140
|
+
this.gainNode = ctx.createGain();
|
|
141
|
+
source.connect(this.gainNode);
|
|
142
|
+
this.gainNode.connect(ctx.destination);
|
|
143
|
+
}
|
|
144
|
+
setGain(gain) {
|
|
145
|
+
this.gainNode.gain.value = gain;
|
|
146
|
+
}
|
|
147
|
+
setLoop(loop) {
|
|
148
|
+
this.el.loop = loop;
|
|
149
|
+
}
|
|
150
|
+
play() {
|
|
151
|
+
this.el.play().catch(() => {});
|
|
152
|
+
}
|
|
153
|
+
stop() {
|
|
154
|
+
this.el.pause();
|
|
155
|
+
this.el.currentTime = 0;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var WebAudioMusicBackend = class {
|
|
159
|
+
ctx = null;
|
|
160
|
+
resolved = false;
|
|
161
|
+
/** True once a backend is present (lazily created on first use). */
|
|
162
|
+
get available() {
|
|
163
|
+
return this.ensure() !== null;
|
|
164
|
+
}
|
|
165
|
+
ensure() {
|
|
166
|
+
if (!this.resolved) {
|
|
167
|
+
this.ctx = createContext$1();
|
|
168
|
+
this.resolved = true;
|
|
169
|
+
}
|
|
170
|
+
return this.ctx;
|
|
171
|
+
}
|
|
172
|
+
createTrack(src) {
|
|
173
|
+
const ctx = this.ensure();
|
|
174
|
+
const el = createElement();
|
|
175
|
+
if (!ctx || !el) return {
|
|
176
|
+
...INERT,
|
|
177
|
+
src
|
|
178
|
+
};
|
|
179
|
+
if (ctx.state === "suspended") ctx.resume();
|
|
180
|
+
return new WebAudioTrack(src, el, ctx);
|
|
181
|
+
}
|
|
182
|
+
unlock() {
|
|
183
|
+
const ctx = this.ensure();
|
|
184
|
+
if (ctx && ctx.state === "suspended") ctx.resume();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Single-track background-music manager: plays ONE music track, crossfading to
|
|
189
|
+
* a new one (equal-power) or fading in/out. Lives on the engine as
|
|
190
|
+
* `engine.music`; routes through `engine.audio` (the music bus by default) so
|
|
191
|
+
* the global volume/mute sliders dim it for free.
|
|
192
|
+
*
|
|
193
|
+
* The state machine (current/outgoing track, fade phase + timer) is fully
|
|
194
|
+
* testable headlessly via an injected backend; the real backend is WebAudio +
|
|
195
|
+
* HTMLAudio and a no-op when there's no AudioContext. Drive `tick(dt)` once per
|
|
196
|
+
* frame to advance fades (the engine wires this to `updated`).
|
|
197
|
+
*
|
|
198
|
+
* ```ts
|
|
199
|
+
* engine.music.play('incanto/assets/audio/theme.mp3'); // loop, full
|
|
200
|
+
* engine.music.crossfadeTo('boss.mp3', 3); // 3s equal-power swap
|
|
201
|
+
* engine.music.stop(2); // fade out over 2s
|
|
202
|
+
* engine.music.setVolume(0.5); // per-manager volume
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
var MusicManager = class {
|
|
206
|
+
buses;
|
|
207
|
+
backend = null;
|
|
208
|
+
resolved = false;
|
|
209
|
+
/** The current (incoming) track; an outgoing one lives during a fade. */
|
|
210
|
+
active = null;
|
|
211
|
+
outgoing = null;
|
|
212
|
+
_bus = "music";
|
|
213
|
+
_volume = 1;
|
|
214
|
+
constructor(buses) {
|
|
215
|
+
this.buses = buses;
|
|
216
|
+
}
|
|
217
|
+
/** Source of the logically-current track, or null when nothing is playing. */
|
|
218
|
+
get current() {
|
|
219
|
+
return this.active?.track.src ?? null;
|
|
220
|
+
}
|
|
221
|
+
/** Per-manager volume (0..1), on top of the bus gain. */
|
|
222
|
+
get volume() {
|
|
223
|
+
return this._volume;
|
|
224
|
+
}
|
|
225
|
+
ensure() {
|
|
226
|
+
if (!this.resolved) {
|
|
227
|
+
const b = new WebAudioMusicBackend();
|
|
228
|
+
this.backend = b && b.available === false ? null : b;
|
|
229
|
+
this.resolved = true;
|
|
230
|
+
}
|
|
231
|
+
return this.backend;
|
|
232
|
+
}
|
|
233
|
+
/** Resume a gesture-suspended context + (re)start any pending track. */
|
|
234
|
+
unlock() {
|
|
235
|
+
this.ensure()?.unlock();
|
|
236
|
+
this.active?.track.play();
|
|
237
|
+
}
|
|
238
|
+
/** Play a single track (replacing any current one immediately, no fade). */
|
|
239
|
+
play(src, opts = {}) {
|
|
240
|
+
const backend = this.ensure();
|
|
241
|
+
if (!backend) return;
|
|
242
|
+
this.killOutgoing();
|
|
243
|
+
this.active?.track.stop();
|
|
244
|
+
this._bus = opts.bus ?? "music";
|
|
245
|
+
const track = backend.createTrack(src);
|
|
246
|
+
track.setLoop(opts.loop ?? true);
|
|
247
|
+
const fadeIn = Math.max(0, opts.fadeIn ?? 0);
|
|
248
|
+
this.active = {
|
|
249
|
+
track,
|
|
250
|
+
phase: fadeIn > 0 ? "fadeIn" : "steady",
|
|
251
|
+
t: 0,
|
|
252
|
+
duration: fadeIn,
|
|
253
|
+
freeOnEnd: false
|
|
254
|
+
};
|
|
255
|
+
track.setGain(fadeIn > 0 ? 0 : this.busGain());
|
|
256
|
+
track.play();
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Equal-power crossfade to a new track over `seconds`. With no current track
|
|
260
|
+
* it's a fade-in. Crossfading to the already-current src is a no-op.
|
|
261
|
+
*/
|
|
262
|
+
crossfadeTo(src, seconds = 2) {
|
|
263
|
+
const backend = this.ensure();
|
|
264
|
+
if (!backend) return;
|
|
265
|
+
if (this.active && this.active.track.src === src && this.active.phase !== "fadeOut") return;
|
|
266
|
+
const secs = Math.max(0, seconds);
|
|
267
|
+
if (!this.active) {
|
|
268
|
+
this.play(src, { fadeIn: secs });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.killOutgoing();
|
|
272
|
+
this.outgoing = {
|
|
273
|
+
...this.active,
|
|
274
|
+
phase: "crossOut",
|
|
275
|
+
t: 0,
|
|
276
|
+
duration: secs,
|
|
277
|
+
freeOnEnd: true
|
|
278
|
+
};
|
|
279
|
+
const track = backend.createTrack(src);
|
|
280
|
+
track.setLoop(true);
|
|
281
|
+
track.setGain(0);
|
|
282
|
+
track.play();
|
|
283
|
+
this.active = {
|
|
284
|
+
track,
|
|
285
|
+
phase: "crossIn",
|
|
286
|
+
t: 0,
|
|
287
|
+
duration: secs,
|
|
288
|
+
freeOnEnd: false
|
|
289
|
+
};
|
|
290
|
+
this.applyGains();
|
|
291
|
+
}
|
|
292
|
+
/** Stop the current track, optionally fading out over `fadeOut` seconds. */
|
|
293
|
+
stop(fadeOut = 0) {
|
|
294
|
+
this.killOutgoing();
|
|
295
|
+
const a = this.active;
|
|
296
|
+
this.active = null;
|
|
297
|
+
if (!a) return;
|
|
298
|
+
const secs = Math.max(0, fadeOut);
|
|
299
|
+
if (secs <= 0) {
|
|
300
|
+
a.track.stop();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.outgoing = {
|
|
304
|
+
...a,
|
|
305
|
+
phase: "fadeOut",
|
|
306
|
+
t: 0,
|
|
307
|
+
duration: secs,
|
|
308
|
+
freeOnEnd: true
|
|
309
|
+
};
|
|
310
|
+
this.applyGains();
|
|
311
|
+
}
|
|
312
|
+
/** Set the per-manager volume (re-applied to live tracks on the next tick). */
|
|
313
|
+
setVolume(v) {
|
|
314
|
+
this._volume = clamp01(v);
|
|
315
|
+
}
|
|
316
|
+
/** Advance fades by `dt` seconds and (re)apply bus×fade gains. Frame-driven. */
|
|
317
|
+
tick(dt) {
|
|
318
|
+
if (this.active && (this.active.phase === "fadeIn" || this.active.phase === "crossIn")) {
|
|
319
|
+
this.active.t += dt;
|
|
320
|
+
if (this.active.t >= this.active.duration) this.active.phase = "steady";
|
|
321
|
+
}
|
|
322
|
+
if (this.outgoing) {
|
|
323
|
+
this.outgoing.t += dt;
|
|
324
|
+
if (this.outgoing.t >= this.outgoing.duration) {
|
|
325
|
+
this.outgoing.track.setGain(0);
|
|
326
|
+
this.outgoing.track.stop();
|
|
327
|
+
this.outgoing = null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
this.applyGains();
|
|
331
|
+
}
|
|
332
|
+
/** Effective bus gain for THIS manager (master × bus × manager volume). */
|
|
333
|
+
busGain() {
|
|
334
|
+
return this.buses.effectiveVolume(this._bus, this._volume);
|
|
335
|
+
}
|
|
336
|
+
/** Push the current fade-derived gains onto both tracks. */
|
|
337
|
+
applyGains() {
|
|
338
|
+
const base = this.busGain();
|
|
339
|
+
if (this.active?.phase === "crossIn" && this.outgoing?.phase === "crossOut") {
|
|
340
|
+
const g = crossfadeGains(this.active.t, this.active.duration);
|
|
341
|
+
this.active.track.setGain(base * g.in);
|
|
342
|
+
this.outgoing.track.setGain(base * g.out);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (this.active) {
|
|
346
|
+
const g = this.active.phase === "fadeIn" ? fadeGain(this.active.t, this.active.duration, "in") : 1;
|
|
347
|
+
this.active.track.setGain(base * g);
|
|
348
|
+
}
|
|
349
|
+
if (this.outgoing) {
|
|
350
|
+
const g = this.outgoing.phase === "fadeOut" || this.outgoing.phase === "crossOut" ? fadeGain(this.outgoing.t, this.outgoing.duration, "out") : 0;
|
|
351
|
+
this.outgoing.track.setGain(base * g);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
killOutgoing() {
|
|
355
|
+
this.outgoing?.track.stop();
|
|
356
|
+
this.outgoing = null;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
function clamp01(v) {
|
|
360
|
+
if (!Number.isFinite(v)) return 0;
|
|
361
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/core/audio/webaudio-sfx.ts
|
|
365
|
+
/** Position a WebAudio AudioListener from a pose (modern AudioParam or legacy). */
|
|
366
|
+
function applyListenerPose(ctxListener, pose) {
|
|
367
|
+
const [px, py, pz] = pose.position;
|
|
368
|
+
const [fx, fy, fz] = pose.forward;
|
|
369
|
+
const [ux, uy, uz] = pose.up;
|
|
370
|
+
const l = ctxListener;
|
|
371
|
+
if (l.positionX && l.forwardX && l.upX) {
|
|
372
|
+
l.positionX.value = px;
|
|
373
|
+
if (l.positionY) l.positionY.value = py;
|
|
374
|
+
if (l.positionZ) l.positionZ.value = pz;
|
|
375
|
+
l.forwardX.value = fx;
|
|
376
|
+
if (l.forwardY) l.forwardY.value = fy;
|
|
377
|
+
if (l.forwardZ) l.forwardZ.value = fz;
|
|
378
|
+
l.upX.value = ux;
|
|
379
|
+
if (l.upY) l.upY.value = uy;
|
|
380
|
+
if (l.upZ) l.upZ.value = uz;
|
|
381
|
+
} else {
|
|
382
|
+
l.setPosition?.(px, py, pz);
|
|
383
|
+
l.setOrientation?.(fx, fy, fz, ux, uy, uz);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Position a PannerNode at an emitter (modern AudioParam or legacy). */
|
|
387
|
+
function applyPannerPosition(panner, pos) {
|
|
388
|
+
const p = panner;
|
|
389
|
+
if (p.positionX) {
|
|
390
|
+
p.positionX.value = pos[0];
|
|
391
|
+
if (p.positionY) p.positionY.value = pos[1];
|
|
392
|
+
if (p.positionZ) p.positionZ.value = pos[2];
|
|
393
|
+
} else p.setPosition?.(pos[0], pos[1], pos[2]);
|
|
394
|
+
}
|
|
395
|
+
function resolveCtor() {
|
|
396
|
+
const g = globalThis;
|
|
397
|
+
return g.AudioContext ?? g.webkitAudioContext ?? null;
|
|
398
|
+
}
|
|
399
|
+
/** True when a real (or injected) AudioContext backend exists. */
|
|
400
|
+
function isAudioContextAvailable() {
|
|
401
|
+
return resolveCtor() !== null;
|
|
402
|
+
}
|
|
403
|
+
function createContext() {
|
|
404
|
+
const Ctor = resolveCtor();
|
|
405
|
+
if (!Ctor) return null;
|
|
406
|
+
return new Ctor();
|
|
407
|
+
}
|
|
408
|
+
/** Cache key for a synthesized waveform (params identity + variation knobs). */
|
|
409
|
+
function cacheKey(params, opts, sampleRate) {
|
|
410
|
+
return `${params.wave}|${params.baseFreq}|${params.freqRamp}|${params.attack}|${params.sustain}|${params.decay}|${params.duty}|${params.vibratoDepth}|${params.vibratoRate}|${params.volume}|${opts.pitch ?? 1}|${opts.seed ?? 0}|${sampleRate}`;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* One AudioContext + a PCM cache. Instance-scoped (held by the renderer's game
|
|
414
|
+
* instance, not a singleton). `play()` synthesizes (cached) and fires a
|
|
415
|
+
* one-shot source at the given gain.
|
|
416
|
+
*/
|
|
417
|
+
var SfxEngine = class {
|
|
418
|
+
ctx = null;
|
|
419
|
+
resolved = false;
|
|
420
|
+
pcmCache = /* @__PURE__ */ new Map();
|
|
421
|
+
/** True once a backend is present (lazily created on first use). */
|
|
422
|
+
get available() {
|
|
423
|
+
return this.ensure() !== null;
|
|
424
|
+
}
|
|
425
|
+
ensure() {
|
|
426
|
+
if (!this.resolved) {
|
|
427
|
+
this.ctx = createContext();
|
|
428
|
+
this.resolved = true;
|
|
429
|
+
}
|
|
430
|
+
return this.ctx;
|
|
431
|
+
}
|
|
432
|
+
/** Resume a gesture-suspended context (wired to the first user gesture). */
|
|
433
|
+
unlock() {
|
|
434
|
+
const ctx = this.ensure();
|
|
435
|
+
if (ctx && ctx.state === "suspended") ctx.resume();
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Play a preset at `gain` (0..1, already bus-multiplied by the caller). Each
|
|
439
|
+
* call spawns its own source so rapid repeats overlap instead of cutting off.
|
|
440
|
+
* Headless → no-op.
|
|
441
|
+
*/
|
|
442
|
+
play(params, gain, opts = {}) {
|
|
443
|
+
const ctx = this.ensure();
|
|
444
|
+
if (!ctx) return;
|
|
445
|
+
if (ctx.state === "suspended") ctx.resume();
|
|
446
|
+
if (gain <= 0) return;
|
|
447
|
+
const sampleRate = ctx.sampleRate || 44100;
|
|
448
|
+
const synthOpts = {
|
|
449
|
+
sampleRate,
|
|
450
|
+
pitch: opts.pitch,
|
|
451
|
+
seed: opts.seed
|
|
452
|
+
};
|
|
453
|
+
const key = cacheKey(params, synthOpts, sampleRate);
|
|
454
|
+
let pcm = this.pcmCache.get(key);
|
|
455
|
+
if (!pcm) {
|
|
456
|
+
pcm = synthSfx(params, synthOpts);
|
|
457
|
+
this.pcmCache.set(key, pcm);
|
|
458
|
+
}
|
|
459
|
+
const buffer = ctx.createBuffer(1, pcm.length, sampleRate);
|
|
460
|
+
buffer.getChannelData(0).set(pcm);
|
|
461
|
+
const gainNode = ctx.createGain();
|
|
462
|
+
gainNode.gain.value = gain;
|
|
463
|
+
if (opts.spatial && typeof ctx.createPanner === "function") {
|
|
464
|
+
const s = opts.spatial;
|
|
465
|
+
const panner = ctx.createPanner();
|
|
466
|
+
panner.panningModel = "HRTF";
|
|
467
|
+
panner.distanceModel = s.rolloff;
|
|
468
|
+
panner.refDistance = Math.max(0, s.refDistance);
|
|
469
|
+
panner.maxDistance = Math.max(s.refDistance, s.maxDistance);
|
|
470
|
+
panner.rolloffFactor = s.rolloffFactor ?? 1;
|
|
471
|
+
applyPannerPosition(panner, s.position);
|
|
472
|
+
if (ctx.listener) applyListenerPose(ctx.listener, s.listener);
|
|
473
|
+
gainNode.connect(panner);
|
|
474
|
+
panner.connect(ctx.destination);
|
|
475
|
+
} else gainNode.connect(ctx.destination);
|
|
476
|
+
const source = ctx.createBufferSource();
|
|
477
|
+
source.buffer = buffer;
|
|
478
|
+
source.connect(gainNode);
|
|
479
|
+
source.start();
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/core/frame-stats.ts
|
|
484
|
+
/**
|
|
485
|
+
* Rolling window of REAL frame timestamps — the math behind `engine.stats()`
|
|
486
|
+
* fps/frameMs. Pure and renderer-free: push tick times in, read averages out.
|
|
487
|
+
*
|
|
488
|
+
* Only `Engine.tick(nowMs)` feeds it; headless `step()` advances sim time
|
|
489
|
+
* without wall-clock frames, so stepped runs honestly report 0 fps.
|
|
490
|
+
*/
|
|
491
|
+
var FrameStatsRing = class {
|
|
492
|
+
capacity;
|
|
493
|
+
times;
|
|
494
|
+
head = 0;
|
|
495
|
+
count = 0;
|
|
496
|
+
constructor(capacity = 60) {
|
|
497
|
+
this.capacity = capacity;
|
|
498
|
+
this.times = new Array(capacity);
|
|
499
|
+
}
|
|
500
|
+
/** Record one frame timestamp (ms). Evicts the oldest at capacity. */
|
|
501
|
+
push(nowMs) {
|
|
502
|
+
this.times[(this.head + this.count) % this.capacity] = nowMs;
|
|
503
|
+
if (this.count < this.capacity) this.count += 1;
|
|
504
|
+
else this.head = (this.head + 1) % this.capacity;
|
|
505
|
+
}
|
|
506
|
+
/** Frames per second over the window — 0 with fewer than two samples. */
|
|
507
|
+
fps() {
|
|
508
|
+
const span = this.spanMs();
|
|
509
|
+
return span > 0 ? (this.count - 1) / span * 1e3 : 0;
|
|
510
|
+
}
|
|
511
|
+
/** Mean delta between frames in ms — 0 with fewer than two samples. */
|
|
512
|
+
frameMs() {
|
|
513
|
+
const span = this.spanMs();
|
|
514
|
+
return span > 0 ? span / (this.count - 1) : 0;
|
|
515
|
+
}
|
|
516
|
+
/** Forget all samples (engine.stop() — a paused game has no frame rate). */
|
|
517
|
+
clear() {
|
|
518
|
+
this.head = 0;
|
|
519
|
+
this.count = 0;
|
|
520
|
+
}
|
|
521
|
+
spanMs() {
|
|
522
|
+
if (this.count < 2) return 0;
|
|
523
|
+
const oldest = this.times[this.head];
|
|
524
|
+
return this.times[(this.head + this.count - 1) % this.capacity] - oldest;
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/core/input.ts
|
|
529
|
+
/** Keys typed into editable elements belong to the page UI, not the game. */
|
|
530
|
+
function isEditableTarget(target) {
|
|
531
|
+
const el = target;
|
|
532
|
+
return !!el && (el.isContentEditable === true || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Declarative input from scene JSON `input{}` — DOM-free logic (feed key state
|
|
536
|
+
* via `handleKey`, or wire a browser with `attachKeyboard(window)`).
|
|
537
|
+
*
|
|
538
|
+
* Convention: vector2 follows the 2D y-down space (up = -y). `justPressed` /
|
|
539
|
+
* `justReleased` are one-frame edges, settled by the Engine each tick.
|
|
540
|
+
*/
|
|
541
|
+
var InputMap = class {
|
|
542
|
+
actions = /* @__PURE__ */ new Map();
|
|
543
|
+
down = /* @__PURE__ */ new Set();
|
|
544
|
+
pressedEdge = /* @__PURE__ */ new Set();
|
|
545
|
+
releasedEdge = /* @__PURE__ */ new Set();
|
|
546
|
+
detach = null;
|
|
547
|
+
/** Load (or extend with) scene-JSON action declarations. Hard-validates shape. */
|
|
548
|
+
declare(decls) {
|
|
549
|
+
for (const [name, raw] of Object.entries(decls)) {
|
|
550
|
+
const decl = raw;
|
|
551
|
+
if (decl.type === "button") {
|
|
552
|
+
const keys = decl.keys;
|
|
553
|
+
if (!Array.isArray(keys) || keys.some((k) => typeof k !== "string")) throw new IncantoError("BAD_FORMAT", `Input action '${name}': button "keys" must be an array of KeyboardEvent.code strings.`);
|
|
554
|
+
if (decl.touch !== void 0 && decl.touch !== "button") throw new IncantoError("BAD_FORMAT", `Input action '${name}': button "touch" must be 'button', got ${JSON.stringify(decl.touch)}.`);
|
|
555
|
+
this.actions.set(name, {
|
|
556
|
+
type: "button",
|
|
557
|
+
keys: [...keys],
|
|
558
|
+
...decl.touch === "button" ? { touch: "button" } : {}
|
|
559
|
+
});
|
|
560
|
+
} else if (decl.type === "vector2") {
|
|
561
|
+
const dirs = decl.keys;
|
|
562
|
+
for (const dir of [
|
|
563
|
+
"up",
|
|
564
|
+
"down",
|
|
565
|
+
"left",
|
|
566
|
+
"right"
|
|
567
|
+
]) if (!Array.isArray(dirs?.[dir])) throw new IncantoError("BAD_FORMAT", `Input action '${name}': vector2 "keys" needs arrays for up/down/left/right.`);
|
|
568
|
+
if (decl.touch !== void 0 && decl.touch !== "joystick") throw new IncantoError("BAD_FORMAT", `Input action '${name}': vector2 "touch" must be 'joystick', got ${JSON.stringify(decl.touch)}.`);
|
|
569
|
+
const d = dirs;
|
|
570
|
+
this.actions.set(name, {
|
|
571
|
+
type: "vector2",
|
|
572
|
+
keys: {
|
|
573
|
+
up: [...d.up],
|
|
574
|
+
down: [...d.down],
|
|
575
|
+
left: [...d.left],
|
|
576
|
+
right: [...d.right]
|
|
577
|
+
},
|
|
578
|
+
...decl.touch === "joystick" ? { touch: "joystick" } : {}
|
|
579
|
+
});
|
|
580
|
+
} else throw new IncantoError("BAD_FORMAT", `Input action '${name}': "type" must be 'button' or 'vector2', got ${JSON.stringify(decl.type)}.`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** Feed a key state change (code = KeyboardEvent.code, e.g. 'Space', 'KeyW'). */
|
|
584
|
+
handleKey(code, isDown) {
|
|
585
|
+
if (isDown) {
|
|
586
|
+
if (!this.down.has(code)) {
|
|
587
|
+
this.down.add(code);
|
|
588
|
+
this.pressedEdge.add(code);
|
|
589
|
+
}
|
|
590
|
+
} else if (this.down.has(code)) {
|
|
591
|
+
this.down.delete(code);
|
|
592
|
+
this.releasedEdge.add(code);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
injectedDown = /* @__PURE__ */ new Set();
|
|
596
|
+
injectedPressed = /* @__PURE__ */ new Set();
|
|
597
|
+
injectedReleased = /* @__PURE__ */ new Set();
|
|
598
|
+
injectedVectors = /* @__PURE__ */ new Map();
|
|
599
|
+
/**
|
|
600
|
+
* Press a button ACTION directly — no key codes involved. This is how
|
|
601
|
+
* scripted gameplay tests and touch buttons drive the game by intent
|
|
602
|
+
* (`press('jump')`) instead of reverse-engineering keybinds.
|
|
603
|
+
*/
|
|
604
|
+
pressAction(action) {
|
|
605
|
+
this.button(action);
|
|
606
|
+
if (!this.injectedDown.has(action)) {
|
|
607
|
+
this.injectedDown.add(action);
|
|
608
|
+
this.injectedPressed.add(action);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/** Release an injected button action (yields one justReleased frame). */
|
|
612
|
+
releaseAction(action) {
|
|
613
|
+
this.button(action);
|
|
614
|
+
if (this.injectedDown.delete(action)) this.injectedReleased.add(action);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Feed an analog direction for a vector2 ACTION — virtual joysticks and
|
|
618
|
+
* scripted runs. Persists until replaced; (0, 0) clears the injection.
|
|
619
|
+
* Combines with key state in getVector (clamped to unit length).
|
|
620
|
+
*/
|
|
621
|
+
setActionVector(action, x, y) {
|
|
622
|
+
if (this.get(action).type !== "vector2") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a button — use pressAction/releaseAction, not setActionVector.`);
|
|
623
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new IncantoError("BAD_FORMAT", `setActionVector('${action}') needs finite numbers, got (${x}, ${y}).`);
|
|
624
|
+
if (x === 0 && y === 0) this.injectedVectors.delete(action);
|
|
625
|
+
else this.injectedVectors.set(action, {
|
|
626
|
+
x,
|
|
627
|
+
y
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Actions the scene wants on-screen touch controls for, in declaration
|
|
632
|
+
* order (`"touch": "joystick"` on vector2, `"touch": "button"` on buttons).
|
|
633
|
+
* The touch overlay (core/touch.ts) renders these.
|
|
634
|
+
*/
|
|
635
|
+
touchControls() {
|
|
636
|
+
const out = [];
|
|
637
|
+
for (const [name, def] of this.actions) if (def.touch) out.push({
|
|
638
|
+
action: name,
|
|
639
|
+
kind: def.touch
|
|
640
|
+
});
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
/** Drop all injected action state (held buttons, vectors, pending edges). */
|
|
644
|
+
clearInjected() {
|
|
645
|
+
this.injectedDown.clear();
|
|
646
|
+
this.injectedPressed.clear();
|
|
647
|
+
this.injectedReleased.clear();
|
|
648
|
+
this.injectedVectors.clear();
|
|
649
|
+
}
|
|
650
|
+
dx = 0;
|
|
651
|
+
dy = 0;
|
|
652
|
+
wheel = 0;
|
|
653
|
+
/** Mouse buttons feed the same code space as keys: Mouse0/Mouse1/Mouse2. */
|
|
654
|
+
handleMouseButton(button, isDown) {
|
|
655
|
+
this.handleKey(`Mouse${button}`, isDown);
|
|
656
|
+
}
|
|
657
|
+
/** Accumulate look deltas (movementX/Y under pointer lock, else move deltas). */
|
|
658
|
+
handlePointerMove(dx, dy) {
|
|
659
|
+
this.dx += dx;
|
|
660
|
+
this.dy += dy;
|
|
661
|
+
}
|
|
662
|
+
handleWheel(deltaY) {
|
|
663
|
+
this.wheel += deltaY;
|
|
664
|
+
}
|
|
665
|
+
/** Drain the accumulated pointer delta (read once per frame). */
|
|
666
|
+
pointerDelta() {
|
|
667
|
+
const d = {
|
|
668
|
+
x: this.dx,
|
|
669
|
+
y: this.dy
|
|
670
|
+
};
|
|
671
|
+
this.dx = 0;
|
|
672
|
+
this.dy = 0;
|
|
673
|
+
return d;
|
|
674
|
+
}
|
|
675
|
+
/** Drain the accumulated wheel delta. */
|
|
676
|
+
wheelDelta() {
|
|
677
|
+
const w = this.wheel;
|
|
678
|
+
this.wheel = 0;
|
|
679
|
+
return w;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Wire browser pointer events on a canvas: buttons → Mouse0/1/2 codes,
|
|
683
|
+
* movement → pointerDelta (movementX/Y so pointer lock just works),
|
|
684
|
+
* wheel → wheelDelta. `lockOnClick` requests pointer lock on mousedown
|
|
685
|
+
* (the FPS pattern).
|
|
686
|
+
*/
|
|
687
|
+
attachPointer(target, opts) {
|
|
688
|
+
let buttonsHeld = 0;
|
|
689
|
+
const onDown = (e) => {
|
|
690
|
+
buttonsHeld++;
|
|
691
|
+
this.handleMouseButton(e.button, true);
|
|
692
|
+
if (opts?.lockOnClick && document.pointerLockElement !== target) target.requestPointerLock?.();
|
|
693
|
+
};
|
|
694
|
+
const onUp = (e) => {
|
|
695
|
+
buttonsHeld = Math.max(0, buttonsHeld - 1);
|
|
696
|
+
this.handleMouseButton(e.button, false);
|
|
697
|
+
};
|
|
698
|
+
const onMove = (e) => {
|
|
699
|
+
if (document.pointerLockElement === target || buttonsHeld > 0) this.handlePointerMove(e.movementX, e.movementY);
|
|
700
|
+
};
|
|
701
|
+
const onWheel = (e) => {
|
|
702
|
+
e.preventDefault();
|
|
703
|
+
this.handleWheel(e.deltaY);
|
|
704
|
+
};
|
|
705
|
+
const onContext = (e) => e.preventDefault();
|
|
706
|
+
target.addEventListener("mousedown", onDown);
|
|
707
|
+
window.addEventListener("mouseup", onUp);
|
|
708
|
+
window.addEventListener("mousemove", onMove);
|
|
709
|
+
target.addEventListener("wheel", onWheel, { passive: false });
|
|
710
|
+
target.addEventListener("contextmenu", onContext);
|
|
711
|
+
const detach = () => {
|
|
712
|
+
target.removeEventListener("mousedown", onDown);
|
|
713
|
+
window.removeEventListener("mouseup", onUp);
|
|
714
|
+
window.removeEventListener("mousemove", onMove);
|
|
715
|
+
target.removeEventListener("wheel", onWheel);
|
|
716
|
+
target.removeEventListener("contextmenu", onContext);
|
|
717
|
+
};
|
|
718
|
+
const prev = this.detach;
|
|
719
|
+
this.detach = () => {
|
|
720
|
+
prev?.();
|
|
721
|
+
detach();
|
|
722
|
+
};
|
|
723
|
+
return detach;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Wire browser keyboard events. Returns (and chains into dispose()) a detach
|
|
727
|
+
* function.
|
|
728
|
+
*
|
|
729
|
+
* By default the browser default is prevented for keys BOUND to a declared
|
|
730
|
+
* action — an embedded game must not scroll its host page on Space/arrows.
|
|
731
|
+
* Keys typed into editable elements (inputs, textareas, contenteditable)
|
|
732
|
+
* are ignored entirely so DOM UI overlays keep working.
|
|
733
|
+
*/
|
|
734
|
+
attachKeyboard(target, opts) {
|
|
735
|
+
const mode = opts?.preventDefault ?? "bound";
|
|
736
|
+
const onDown = (e) => {
|
|
737
|
+
if (isEditableTarget(e.target)) return;
|
|
738
|
+
if ((e.ctrlKey || e.metaKey) && !e.code.startsWith("Control") && !e.code.startsWith("Meta")) return;
|
|
739
|
+
if (mode === "bound" && this.codeIsBound(e.code)) e.preventDefault?.();
|
|
740
|
+
if (!e.repeat) this.handleKey(e.code, true);
|
|
741
|
+
};
|
|
742
|
+
const onUp = (e) => {
|
|
743
|
+
if (isEditableTarget(e.target)) return;
|
|
744
|
+
this.handleKey(e.code, false);
|
|
745
|
+
};
|
|
746
|
+
target.addEventListener("keydown", onDown);
|
|
747
|
+
target.addEventListener("keyup", onUp);
|
|
748
|
+
const detach = () => {
|
|
749
|
+
target.removeEventListener("keydown", onDown);
|
|
750
|
+
target.removeEventListener("keyup", onUp);
|
|
751
|
+
};
|
|
752
|
+
const prev = this.detach;
|
|
753
|
+
this.detach = () => {
|
|
754
|
+
prev?.();
|
|
755
|
+
detach();
|
|
756
|
+
};
|
|
757
|
+
return detach;
|
|
758
|
+
}
|
|
759
|
+
/** Whether any declared action binds this key code. */
|
|
760
|
+
codeIsBound(code) {
|
|
761
|
+
for (const action of this.actions.values()) if (action.type === "button") {
|
|
762
|
+
if (action.keys.includes(code)) return true;
|
|
763
|
+
} else {
|
|
764
|
+
const k = action.keys;
|
|
765
|
+
if (k.up.includes(code) || k.down.includes(code) || k.left.includes(code) || k.right.includes(code)) return true;
|
|
766
|
+
}
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
dispose() {
|
|
770
|
+
this.detach?.();
|
|
771
|
+
this.detach = null;
|
|
772
|
+
}
|
|
773
|
+
isPressed(action) {
|
|
774
|
+
return this.button(action).keys.some((k) => this.down.has(k)) || this.injectedDown.has(action);
|
|
775
|
+
}
|
|
776
|
+
justPressed(action) {
|
|
777
|
+
return this.button(action).keys.some((k) => this.pressedEdge.has(k)) || this.injectedPressed.has(action);
|
|
778
|
+
}
|
|
779
|
+
justReleased(action) {
|
|
780
|
+
return this.button(action).keys.some((k) => this.releasedEdge.has(k)) || this.injectedReleased.has(action);
|
|
781
|
+
}
|
|
782
|
+
/** Normalized direction for a vector2 action (y-down: up = -y). */
|
|
783
|
+
getVector(action) {
|
|
784
|
+
const def = this.get(action);
|
|
785
|
+
if (def.type !== "vector2") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a button — use isPressed/justPressed, not getVector.`);
|
|
786
|
+
const active = (codes) => codes.some((k) => this.down.has(k)) ? 1 : 0;
|
|
787
|
+
let x = active(def.keys.right) - active(def.keys.left);
|
|
788
|
+
let y = active(def.keys.down) - active(def.keys.up);
|
|
789
|
+
const injected = this.injectedVectors.get(action);
|
|
790
|
+
if (injected) {
|
|
791
|
+
x += injected.x;
|
|
792
|
+
y += injected.y;
|
|
793
|
+
}
|
|
794
|
+
const len = Math.hypot(x, y);
|
|
795
|
+
if (len > 1) {
|
|
796
|
+
x /= len;
|
|
797
|
+
y /= len;
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
x,
|
|
801
|
+
y
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
/** Consume one-frame edges. The Engine calls this at the end of every tick. */
|
|
805
|
+
endFrame() {
|
|
806
|
+
this.pressedEdge.clear();
|
|
807
|
+
this.releasedEdge.clear();
|
|
808
|
+
this.injectedPressed.clear();
|
|
809
|
+
this.injectedReleased.clear();
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Drop all action declarations and injected action state (physical key
|
|
813
|
+
* state is kept). The Engine calls this on setScene so keybinds never
|
|
814
|
+
* bleed between scenes.
|
|
815
|
+
*/
|
|
816
|
+
clear() {
|
|
817
|
+
this.actions.clear();
|
|
818
|
+
this.pressedEdge.clear();
|
|
819
|
+
this.releasedEdge.clear();
|
|
820
|
+
this.clearInjected();
|
|
821
|
+
}
|
|
822
|
+
get(action) {
|
|
823
|
+
const def = this.actions.get(action);
|
|
824
|
+
if (!def) throw new IncantoError("BAD_FORMAT", `Unknown input action '${action}'. Declared actions: [${[...this.actions.keys()].join(", ")}].`);
|
|
825
|
+
return def;
|
|
826
|
+
}
|
|
827
|
+
button(action) {
|
|
828
|
+
const def = this.get(action);
|
|
829
|
+
if (def.type !== "button") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a vector2 — use getVector, not button queries.`);
|
|
830
|
+
return def;
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
//#endregion
|
|
834
|
+
//#region src/core/log.ts
|
|
835
|
+
/**
|
|
836
|
+
* The engine's log channel (`engine.log`, `this.log` in a Behavior): a ring
|
|
837
|
+
* buffer plus a live `added` signal, so debug overlays and headless test
|
|
838
|
+
* harnesses can tail game logs without scraping the browser console.
|
|
839
|
+
*/
|
|
840
|
+
var LogManager = class {
|
|
841
|
+
/** Fires once per entry, after it is buffered. */
|
|
842
|
+
added = new Signal();
|
|
843
|
+
buffer = [];
|
|
844
|
+
capacity;
|
|
845
|
+
seq = 0;
|
|
846
|
+
constructor(capacity = 1e3) {
|
|
847
|
+
this.capacity = capacity;
|
|
848
|
+
}
|
|
849
|
+
debug(...parts) {
|
|
850
|
+
this.push("debug", parts);
|
|
851
|
+
}
|
|
852
|
+
info(...parts) {
|
|
853
|
+
this.push("info", parts);
|
|
854
|
+
}
|
|
855
|
+
warn(...parts) {
|
|
856
|
+
this.push("warn", parts);
|
|
857
|
+
}
|
|
858
|
+
error(...parts) {
|
|
859
|
+
this.push("error", parts);
|
|
860
|
+
}
|
|
861
|
+
/** Buffered entries, oldest first (capped at capacity). */
|
|
862
|
+
entries() {
|
|
863
|
+
return this.buffer;
|
|
864
|
+
}
|
|
865
|
+
/** Empty the buffer. The sequence keeps counting (entries stay unique). */
|
|
866
|
+
clear() {
|
|
867
|
+
this.buffer.length = 0;
|
|
868
|
+
}
|
|
869
|
+
push(level, parts) {
|
|
870
|
+
this.seq += 1;
|
|
871
|
+
const entry = {
|
|
872
|
+
seq: this.seq,
|
|
873
|
+
timeMs: globalThis.performance?.now() ?? 0,
|
|
874
|
+
level,
|
|
875
|
+
parts
|
|
876
|
+
};
|
|
877
|
+
this.buffer.push(entry);
|
|
878
|
+
if (this.buffer.length > this.capacity) this.buffer.splice(0, this.buffer.length - this.capacity);
|
|
879
|
+
this.added.emit(entry);
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
//#endregion
|
|
883
|
+
//#region src/core/engine.ts
|
|
884
|
+
const MAX_DT_SECONDS = .25;
|
|
885
|
+
function rafScheduler(cb) {
|
|
886
|
+
let live = true;
|
|
887
|
+
let id = requestAnimationFrame(function loop(t) {
|
|
888
|
+
if (!live) return;
|
|
889
|
+
cb(t);
|
|
890
|
+
id = requestAnimationFrame(loop);
|
|
891
|
+
});
|
|
892
|
+
return () => {
|
|
893
|
+
live = false;
|
|
894
|
+
cancelAnimationFrame(id);
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* The game loop: drives a Scene's tree with fixed-timestep `fixedUpdate`
|
|
899
|
+
* (physics/network window) and variable `update` (everything else).
|
|
900
|
+
*
|
|
901
|
+
* Instance-scoped by design — multiple engines can coexist. Headless-testable
|
|
902
|
+
* via the injectable scheduler and the public `tick(nowMs)`.
|
|
903
|
+
*/
|
|
904
|
+
var Engine = class {
|
|
905
|
+
/** Emitted after the tree's variable update each frame, with dt seconds. */
|
|
906
|
+
updated = new Signal();
|
|
907
|
+
/** Emitted after each fixed step, with the fixed dt seconds. */
|
|
908
|
+
fixedUpdated = new Signal();
|
|
909
|
+
/**
|
|
910
|
+
* Emitted after a scene swap completes (input map redeclared) with the new
|
|
911
|
+
* scene, and with null on dispose — overlays (touch controls, debug panels)
|
|
912
|
+
* rebuild themselves here.
|
|
913
|
+
*/
|
|
914
|
+
sceneChanged = new Signal();
|
|
915
|
+
/** Declarative input — scene `input{}` declarations load on setScene. */
|
|
916
|
+
input = new InputMap();
|
|
917
|
+
/** Seeded randomness for game logic (deterministic when `seed` is set). */
|
|
918
|
+
rng;
|
|
919
|
+
/** The engine log channel (debug overlay + headless harness tail this). */
|
|
920
|
+
log = new LogManager();
|
|
921
|
+
/** Global volume buses — `master` × `sfx`/`music` gain + `muted`. AudioPlayer
|
|
922
|
+
* routes its volume through this; games set it for global volume/mute. */
|
|
923
|
+
audio = new AudioBuses();
|
|
924
|
+
/** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer
|
|
925
|
+
* presets play through it; games may call `engine.sfx.play(...)` directly. */
|
|
926
|
+
sfx = new SfxEngine();
|
|
927
|
+
/** Single-track background-music manager (crossfade/loop, headless-safe).
|
|
928
|
+
* `engine.music.play(src)` / `crossfadeTo(src, secs)` / `stop(fadeOut)`;
|
|
929
|
+
* routes through the music bus and is advanced each frame by the loop. */
|
|
930
|
+
music = new MusicManager(this.audio);
|
|
931
|
+
fixedStep;
|
|
932
|
+
maxFixedSteps;
|
|
933
|
+
scheduler;
|
|
934
|
+
_scene = null;
|
|
935
|
+
lastMs = null;
|
|
936
|
+
accumulator = 0;
|
|
937
|
+
disposeScheduler = null;
|
|
938
|
+
frameStats = new FrameStatsRing();
|
|
939
|
+
constructor(opts = {}) {
|
|
940
|
+
this.fixedStep = 1 / (opts.fixedHz ?? 60);
|
|
941
|
+
this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
|
|
942
|
+
this.scheduler = opts.scheduler ?? rafScheduler;
|
|
943
|
+
this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
|
|
944
|
+
}
|
|
945
|
+
get scene() {
|
|
946
|
+
return this._scene;
|
|
947
|
+
}
|
|
948
|
+
/** Replace the active scene. The previous scene's root is freed. */
|
|
949
|
+
setScene(scene) {
|
|
950
|
+
if (scene === this._scene) return;
|
|
951
|
+
this._scene?.tree._setEngine(null);
|
|
952
|
+
this._scene?.root.free();
|
|
953
|
+
this._scene = scene;
|
|
954
|
+
scene.tree._setEngine(this);
|
|
955
|
+
this.input.clear();
|
|
956
|
+
if (scene.input) this.input.declare(scene.input);
|
|
957
|
+
this.sceneChanged.emit(scene);
|
|
958
|
+
}
|
|
959
|
+
start() {
|
|
960
|
+
if (this.disposeScheduler) return;
|
|
961
|
+
this.disposeScheduler = this.scheduler((now) => this.tick(now));
|
|
962
|
+
}
|
|
963
|
+
stop() {
|
|
964
|
+
this.disposeScheduler?.();
|
|
965
|
+
this.disposeScheduler = null;
|
|
966
|
+
this.lastMs = null;
|
|
967
|
+
this.accumulator = 0;
|
|
968
|
+
this.frameStats.clear();
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Live performance counters, queryable at ANY time. fps/frameMs come from a
|
|
972
|
+
* rolling window of REAL `tick` timestamps — headless `step()` runs report 0.
|
|
973
|
+
* Node count walks the active tree on demand. Renderer counters (triangles,
|
|
974
|
+
* draw calls) live on `renderer.stats()` / the merged `game.stats()`.
|
|
975
|
+
*/
|
|
976
|
+
stats() {
|
|
977
|
+
return {
|
|
978
|
+
fps: this.frameStats.fps(),
|
|
979
|
+
frameMs: this.frameStats.frameMs(),
|
|
980
|
+
nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
|
|
981
|
+
running: this.disposeScheduler !== null
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Render interpolation factor in [0,1]: how far the wall clock has advanced
|
|
986
|
+
* INTO the next fixed step. Renderers lerp physics bodies between their last
|
|
987
|
+
* two fixed-step transforms by this much so motion looks smooth even when the
|
|
988
|
+
* display refresh doesn't divide evenly into the 60Hz fixed step (the classic
|
|
989
|
+
* fixed-timestep judder). Only meaningful in the real-time `tick()` loop;
|
|
990
|
+
* the headless `step()` path doesn't bank wall-clock time.
|
|
991
|
+
*/
|
|
992
|
+
get interpolationAlpha() {
|
|
993
|
+
return this.fixedStep > 0 ? Math.min(this.accumulator / this.fixedStep, 1) : 1;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Full teardown in one call: stop the loop, free the scene tree, detach
|
|
997
|
+
* every input listener. The single-unmount story for SPA embedding —
|
|
998
|
+
* renderers own GPU resources and keep their own dispose().
|
|
999
|
+
*/
|
|
1000
|
+
dispose() {
|
|
1001
|
+
this.stop();
|
|
1002
|
+
if (this._scene) {
|
|
1003
|
+
this._scene.tree._setEngine(null);
|
|
1004
|
+
this._scene.root.free();
|
|
1005
|
+
this._scene = null;
|
|
1006
|
+
this.sceneChanged.emit(null);
|
|
1007
|
+
}
|
|
1008
|
+
this.input.dispose();
|
|
1009
|
+
this.input.clear();
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Advance exactly ONE fixed step and one variable update (both dt = the
|
|
1013
|
+
* fixed step), bypassing the wall-clock accumulator entirely. Drift-free
|
|
1014
|
+
* by construction — the unit of time for headless harnesses (incanto/test
|
|
1015
|
+
* runScript): every input edge is visible to BOTH fixedUpdate and update
|
|
1016
|
+
* exactly once.
|
|
1017
|
+
*/
|
|
1018
|
+
step() {
|
|
1019
|
+
const scene = this._scene;
|
|
1020
|
+
if (!scene) return;
|
|
1021
|
+
scene.tree.fixedUpdate(this.fixedStep);
|
|
1022
|
+
this.fixedUpdated.emit(this.fixedStep);
|
|
1023
|
+
scene.tree.update(this.fixedStep);
|
|
1024
|
+
this.music.tick(this.fixedStep);
|
|
1025
|
+
this.updated.emit(this.fixedStep);
|
|
1026
|
+
this.input.endFrame();
|
|
1027
|
+
}
|
|
1028
|
+
/** Advance the loop manually. First call after (re)start only primes the clock. */
|
|
1029
|
+
tick(nowMs) {
|
|
1030
|
+
this.frameStats.push(nowMs);
|
|
1031
|
+
if (this.lastMs === null) {
|
|
1032
|
+
this.lastMs = nowMs;
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const dt = Math.min((nowMs - this.lastMs) / 1e3, MAX_DT_SECONDS);
|
|
1036
|
+
this.lastMs = nowMs;
|
|
1037
|
+
const scene = this._scene;
|
|
1038
|
+
if (!scene) return;
|
|
1039
|
+
this.accumulator += dt;
|
|
1040
|
+
let steps = 0;
|
|
1041
|
+
while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
|
|
1042
|
+
scene.tree.fixedUpdate(this.fixedStep);
|
|
1043
|
+
this.fixedUpdated.emit(this.fixedStep);
|
|
1044
|
+
this.accumulator -= this.fixedStep;
|
|
1045
|
+
steps += 1;
|
|
1046
|
+
}
|
|
1047
|
+
if (this.accumulator >= this.fixedStep) this.accumulator %= this.fixedStep;
|
|
1048
|
+
scene.tree.update(dt);
|
|
1049
|
+
this.music.tick(dt);
|
|
1050
|
+
this.updated.emit(dt);
|
|
1051
|
+
this.input.endFrame();
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
function countNodes(root) {
|
|
1055
|
+
let n = 1;
|
|
1056
|
+
for (const child of root.children) n += countNodes(child);
|
|
1057
|
+
return n;
|
|
1058
|
+
}
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/core/particle-presets.ts
|
|
1061
|
+
const PARTICLE_PRESETS = {
|
|
1062
|
+
fire: {
|
|
1063
|
+
rate: 60,
|
|
1064
|
+
lifetime: [.4, .9],
|
|
1065
|
+
speed: [30, 70],
|
|
1066
|
+
directionDeg: -90,
|
|
1067
|
+
spreadDeg: 35,
|
|
1068
|
+
gravity: [0, -30],
|
|
1069
|
+
sizeStart: 14,
|
|
1070
|
+
sizeEnd: 3,
|
|
1071
|
+
colorStart: "#ffcf5a",
|
|
1072
|
+
colorEnd: "#e8401f",
|
|
1073
|
+
alphaStart: .9,
|
|
1074
|
+
alphaEnd: 0,
|
|
1075
|
+
blend: "add"
|
|
1076
|
+
},
|
|
1077
|
+
smoke: {
|
|
1078
|
+
rate: 20,
|
|
1079
|
+
lifetime: [1.5, 2.5],
|
|
1080
|
+
speed: [10, 25],
|
|
1081
|
+
directionDeg: -90,
|
|
1082
|
+
spreadDeg: 40,
|
|
1083
|
+
gravity: [0, -12],
|
|
1084
|
+
sizeStart: 10,
|
|
1085
|
+
sizeEnd: 26,
|
|
1086
|
+
colorStart: "#8a8a8a",
|
|
1087
|
+
colorEnd: "#3a3a3a",
|
|
1088
|
+
alphaStart: .35,
|
|
1089
|
+
alphaEnd: 0,
|
|
1090
|
+
blend: "normal"
|
|
1091
|
+
},
|
|
1092
|
+
sparks: {
|
|
1093
|
+
rate: 80,
|
|
1094
|
+
lifetime: [.3, .7],
|
|
1095
|
+
speed: [120, 260],
|
|
1096
|
+
spreadDeg: 360,
|
|
1097
|
+
gravity: [0, 240],
|
|
1098
|
+
drag: 1,
|
|
1099
|
+
sizeStart: 4,
|
|
1100
|
+
sizeEnd: 1,
|
|
1101
|
+
colorStart: "#fff3b0",
|
|
1102
|
+
colorEnd: "#ff9d2e",
|
|
1103
|
+
blend: "add"
|
|
1104
|
+
},
|
|
1105
|
+
fireworks: {
|
|
1106
|
+
rate: 0,
|
|
1107
|
+
burst: 120,
|
|
1108
|
+
lifetime: [.8, 1.6],
|
|
1109
|
+
speed: [80, 240],
|
|
1110
|
+
spreadDeg: 360,
|
|
1111
|
+
gravity: [0, 90],
|
|
1112
|
+
drag: .6,
|
|
1113
|
+
sizeStart: 5,
|
|
1114
|
+
sizeEnd: 1,
|
|
1115
|
+
colorStart: "#9ad8ff",
|
|
1116
|
+
colorEnd: "#ff5ad8",
|
|
1117
|
+
blend: "add"
|
|
1118
|
+
},
|
|
1119
|
+
explosion: {
|
|
1120
|
+
rate: 0,
|
|
1121
|
+
burst: 60,
|
|
1122
|
+
lifetime: [.25, .6],
|
|
1123
|
+
speed: [150, 420],
|
|
1124
|
+
spreadDeg: 360,
|
|
1125
|
+
drag: 3,
|
|
1126
|
+
sizeStart: 16,
|
|
1127
|
+
sizeEnd: 2,
|
|
1128
|
+
colorStart: "#ffe08a",
|
|
1129
|
+
colorEnd: "#ff3b1f",
|
|
1130
|
+
blend: "add"
|
|
1131
|
+
},
|
|
1132
|
+
flash: {
|
|
1133
|
+
rate: 0,
|
|
1134
|
+
burst: 8,
|
|
1135
|
+
lifetime: [.08, .16],
|
|
1136
|
+
speed: [0, 30],
|
|
1137
|
+
spreadDeg: 360,
|
|
1138
|
+
sizeStart: 64,
|
|
1139
|
+
sizeEnd: 96,
|
|
1140
|
+
colorStart: "#ffffff",
|
|
1141
|
+
colorEnd: "#ffffff",
|
|
1142
|
+
alphaStart: 1,
|
|
1143
|
+
alphaEnd: 0,
|
|
1144
|
+
blend: "add"
|
|
1145
|
+
},
|
|
1146
|
+
lightning: {
|
|
1147
|
+
rate: 90,
|
|
1148
|
+
lifetime: [.05, .12],
|
|
1149
|
+
speed: [400, 900],
|
|
1150
|
+
directionDeg: -90,
|
|
1151
|
+
spreadDeg: 14,
|
|
1152
|
+
sizeStart: 6,
|
|
1153
|
+
sizeEnd: 2,
|
|
1154
|
+
colorStart: "#eaf2ff",
|
|
1155
|
+
colorEnd: "#7fb4ff",
|
|
1156
|
+
blend: "add"
|
|
1157
|
+
},
|
|
1158
|
+
rain: {
|
|
1159
|
+
rate: 160,
|
|
1160
|
+
lifetime: [.8, 1.2],
|
|
1161
|
+
speed: [300, 420],
|
|
1162
|
+
directionDeg: 90,
|
|
1163
|
+
spreadDeg: 6,
|
|
1164
|
+
sizeStart: 3,
|
|
1165
|
+
sizeEnd: 3,
|
|
1166
|
+
colorStart: "#9ec8ff",
|
|
1167
|
+
colorEnd: "#9ec8ff",
|
|
1168
|
+
alphaStart: .5,
|
|
1169
|
+
alphaEnd: .2,
|
|
1170
|
+
blend: "normal"
|
|
1171
|
+
},
|
|
1172
|
+
snow: {
|
|
1173
|
+
rate: 40,
|
|
1174
|
+
lifetime: [3, 5],
|
|
1175
|
+
speed: [20, 50],
|
|
1176
|
+
directionDeg: 90,
|
|
1177
|
+
spreadDeg: 30,
|
|
1178
|
+
drag: .2,
|
|
1179
|
+
sizeStart: 5,
|
|
1180
|
+
sizeEnd: 4,
|
|
1181
|
+
colorStart: "#ffffff",
|
|
1182
|
+
colorEnd: "#ffffff",
|
|
1183
|
+
alphaStart: .9,
|
|
1184
|
+
alphaEnd: .6,
|
|
1185
|
+
blend: "normal"
|
|
1186
|
+
},
|
|
1187
|
+
magic: {
|
|
1188
|
+
rate: 50,
|
|
1189
|
+
lifetime: [.6, 1.4],
|
|
1190
|
+
speed: [20, 80],
|
|
1191
|
+
spreadDeg: 360,
|
|
1192
|
+
gravity: [0, -20],
|
|
1193
|
+
sizeStart: 7,
|
|
1194
|
+
sizeEnd: 1,
|
|
1195
|
+
colorStart: "#9dffe8",
|
|
1196
|
+
colorEnd: "#7a5cff",
|
|
1197
|
+
blend: "add"
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
const PARTICLE_PRESET_NAMES = Object.keys(PARTICLE_PRESETS);
|
|
1201
|
+
/**
|
|
1202
|
+
* Apply the delta rule across three layers: schema default < preset < the
|
|
1203
|
+
* scene's explicit props. A prop still equal to its SCHEMA default is
|
|
1204
|
+
* considered "unset" and takes the preset value.
|
|
1205
|
+
*/
|
|
1206
|
+
function applyParticlePreset(target, presetName, schema) {
|
|
1207
|
+
if (presetName === "custom") return;
|
|
1208
|
+
const preset = PARTICLE_PRESETS[presetName];
|
|
1209
|
+
if (!preset) return;
|
|
1210
|
+
for (const [key, presetValue] of Object.entries(preset)) {
|
|
1211
|
+
const def = schema[key]?.default;
|
|
1212
|
+
const current = target[key];
|
|
1213
|
+
if (def !== void 0 && jsonEquals(current, def)) target[key] = Array.isArray(presetValue) ? [...presetValue] : presetValue;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/core/particle-sim.ts
|
|
1218
|
+
const FIELDS = 9;
|
|
1219
|
+
var ParticleSim = class {
|
|
1220
|
+
config;
|
|
1221
|
+
rng;
|
|
1222
|
+
data;
|
|
1223
|
+
alive = 0;
|
|
1224
|
+
spawnAccumulator = 0;
|
|
1225
|
+
everSpawned = false;
|
|
1226
|
+
constructor(config, rng) {
|
|
1227
|
+
this.config = config;
|
|
1228
|
+
this.rng = rng;
|
|
1229
|
+
this.data = new Float64Array(Math.max(1, config.maxParticles) * FIELDS);
|
|
1230
|
+
}
|
|
1231
|
+
get count() {
|
|
1232
|
+
return this.alive;
|
|
1233
|
+
}
|
|
1234
|
+
/** True when nothing is alive and at least one particle has ever spawned. */
|
|
1235
|
+
get done() {
|
|
1236
|
+
return this.everSpawned && this.alive === 0;
|
|
1237
|
+
}
|
|
1238
|
+
/** Spawn n particles immediately (fireworks, explosions, flashes). */
|
|
1239
|
+
burst(n) {
|
|
1240
|
+
for (let i = 0; i < n; i++) this.spawn();
|
|
1241
|
+
}
|
|
1242
|
+
update(dt) {
|
|
1243
|
+
if (this.config.rate > 0) {
|
|
1244
|
+
this.spawnAccumulator += this.config.rate * dt;
|
|
1245
|
+
while (this.spawnAccumulator >= 1) {
|
|
1246
|
+
this.spawnAccumulator -= 1;
|
|
1247
|
+
this.spawn();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const d = this.data;
|
|
1251
|
+
const [gx, gy, gz] = this.config.gravity;
|
|
1252
|
+
const damp = this.config.drag > 0 ? Math.exp(-this.config.drag * dt) : 1;
|
|
1253
|
+
let i = 0;
|
|
1254
|
+
while (i < this.alive) {
|
|
1255
|
+
const base = i * FIELDS;
|
|
1256
|
+
const age = d[base + 6] + dt;
|
|
1257
|
+
if (age >= d[base + 7]) {
|
|
1258
|
+
this.kill(i);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
d[base + 6] = age;
|
|
1262
|
+
d[base + 3] = (d[base + 3] + gx * dt) * damp;
|
|
1263
|
+
d[base + 4] = (d[base + 4] + gy * dt) * damp;
|
|
1264
|
+
d[base + 5] = (d[base + 5] + gz * dt) * damp;
|
|
1265
|
+
d[base + 0] = d[base + 0] + d[base + 3] * dt;
|
|
1266
|
+
d[base + 1] = d[base + 1] + d[base + 4] * dt;
|
|
1267
|
+
d[base + 2] = d[base + 2] + d[base + 5] * dt;
|
|
1268
|
+
i += 1;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
forEach(fn) {
|
|
1272
|
+
const d = this.data;
|
|
1273
|
+
for (let i = 0; i < this.alive; i++) {
|
|
1274
|
+
const base = i * FIELDS;
|
|
1275
|
+
const age = d[base + 6];
|
|
1276
|
+
const life = d[base + 7];
|
|
1277
|
+
fn({
|
|
1278
|
+
x: d[base + 0],
|
|
1279
|
+
y: d[base + 1],
|
|
1280
|
+
z: d[base + 2],
|
|
1281
|
+
vx: d[base + 3],
|
|
1282
|
+
vy: d[base + 4],
|
|
1283
|
+
vz: d[base + 5],
|
|
1284
|
+
age,
|
|
1285
|
+
life,
|
|
1286
|
+
t: life > 0 ? age / life : 1,
|
|
1287
|
+
seed: d[base + 8]
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
spawn() {
|
|
1292
|
+
if (this.alive >= this.config.maxParticles) return;
|
|
1293
|
+
const base = this.alive * FIELDS;
|
|
1294
|
+
const d = this.data;
|
|
1295
|
+
const c = this.config;
|
|
1296
|
+
const angle = (c.directionDeg + (this.rng.next() - .5) * c.spreadDeg) * Math.PI / 180;
|
|
1297
|
+
const speed = c.speed[0] + this.rng.next() * (c.speed[1] - c.speed[0]);
|
|
1298
|
+
const zTilt = c.spreadZ ? (this.rng.next() - .5) * (c.spreadDeg * Math.PI / 180) : 0;
|
|
1299
|
+
const planar = Math.cos(zTilt);
|
|
1300
|
+
d[base + 0] = 0;
|
|
1301
|
+
d[base + 1] = 0;
|
|
1302
|
+
d[base + 2] = 0;
|
|
1303
|
+
d[base + 3] = Math.cos(angle) * speed * planar;
|
|
1304
|
+
d[base + 4] = Math.sin(angle) * speed * planar;
|
|
1305
|
+
d[base + 5] = Math.sin(zTilt) * speed;
|
|
1306
|
+
d[base + 6] = 0;
|
|
1307
|
+
d[base + 7] = c.lifetime[0] + this.rng.next() * (c.lifetime[1] - c.lifetime[0]);
|
|
1308
|
+
d[base + 8] = this.rng.next();
|
|
1309
|
+
this.alive += 1;
|
|
1310
|
+
this.everSpawned = true;
|
|
1311
|
+
}
|
|
1312
|
+
kill(index) {
|
|
1313
|
+
const last = this.alive - 1;
|
|
1314
|
+
if (index !== last) this.data.copyWithin(index * FIELDS, last * FIELDS, last * FIELDS + FIELDS);
|
|
1315
|
+
this.alive = last;
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
//#endregion
|
|
1319
|
+
export { Engine as a, SfxEngine as c, WebAudioMusicBackend as d, crossfadeGains as f, applyParticlePreset as i, isAudioContextAvailable as l, AudioBuses as m, PARTICLE_PRESETS as n, LogManager as o, fadeGain as p, PARTICLE_PRESET_NAMES as r, InputMap as s, ParticleSim as t, MusicManager as u };
|