incanto 0.20.0 → 0.22.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.
@@ -1,1978 +0,0 @@
1
- import { _ as registerBehavior, m as Behavior, n as loadScene } from "./loader-BZqOKfI2.js";
2
- import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
- import { t as jsonClone } from "./json-BLk7H2Qa.js";
4
- import { t as duplicateNode } from "./duplicate-BINwP0WG.js";
5
- //#region src/gameplay/spatial.ts
6
- /** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
7
- function hasPosition$1(node) {
8
- return Array.isArray(node.position);
9
- }
10
- /**
11
- * Movement behaviors are dimension-agnostic — they read/write `this.node.position`
12
- * directly. Attaching one to a non-spatial node (no `position` array) would be a
13
- * silent no-op, so assert it at ready (engine philosophy: hard load-time errors).
14
- */
15
- function requirePosition(behavior) {
16
- const node = behavior.node;
17
- if (!hasPosition$1(node)) {
18
- const behaviorName = behavior.constructor.name;
19
- throw new IncantoError("PROP_TYPE_MISMATCH", `${behaviorName} on '${node.getPath()}' needs a spatial node with a numeric 'position' array (a Node2D/Node3D subtype). '${node.name}' has none.`, { path: node.getPath() });
20
- }
21
- return node;
22
- }
23
- /** Euclidean distance between two position arrays (works for 2D and 3D). */
24
- function distance$1(a, b) {
25
- let sum = 0;
26
- const n = Math.max(a.length, b.length);
27
- for (let i = 0; i < n; i++) {
28
- const d = (a[i] ?? 0) - (b[i] ?? 0);
29
- sum += d * d;
30
- }
31
- return Math.sqrt(sum);
32
- }
33
- /** Component-wise `a + b` (dimension = max of the two; missing components are 0). */
34
- function add(a, b) {
35
- const n = Math.max(a.length, b.length);
36
- const out = new Array(n);
37
- for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
38
- return out;
39
- }
40
- /** Vector length. */
41
- function length(v) {
42
- let sum = 0;
43
- for (const c of v) sum += c * c;
44
- return Math.sqrt(sum);
45
- }
46
- /** Unit vector (zero-length stays zero). */
47
- function normalize(v) {
48
- const len = length(v);
49
- if (len === 0) return v.map(() => 0);
50
- return v.map((c) => c / len);
51
- }
52
- /**
53
- * Step `from` toward `to` by at most `maxStep`. Returns the new position and
54
- * whether the destination was reached this step (clamped — no overshoot).
55
- */
56
- function moveToward(from, to, maxStep) {
57
- const n = Math.max(from.length, to.length);
58
- let sum = 0;
59
- for (let i = 0; i < n; i++) {
60
- const d = (to[i] ?? 0) - (from[i] ?? 0);
61
- sum += d * d;
62
- }
63
- const dist = Math.sqrt(sum);
64
- const position = new Array(n);
65
- if (dist <= maxStep || dist === 0) {
66
- for (let i = 0; i < n; i++) position[i] = to[i] ?? 0;
67
- return {
68
- position,
69
- reached: true
70
- };
71
- }
72
- const f = maxStep / dist;
73
- for (let i = 0; i < n; i++) {
74
- const c = from[i] ?? 0;
75
- position[i] = c + ((to[i] ?? 0) - c) * f;
76
- }
77
- return {
78
- position,
79
- reached: false
80
- };
81
- }
82
- /**
83
- * Frame-rate-independent lerp factor for an exponential chase. `smoothing` is a
84
- * 0..1 retention factor: 0 snaps instantly; higher values chase more slowly.
85
- * (60fps reference, matching Camera2D's `follow`.)
86
- */
87
- function smoothingFactor(smoothing, dt) {
88
- const s = Math.min(Math.max(smoothing, 0), .99);
89
- return s <= 0 ? 1 : 1 - s ** (dt * 60);
90
- }
91
- //#endregion
92
- //#region src/gameplay/chase.ts
93
- /**
94
- * Home in on a target node — the workhorse enemy AI. Each frame moves toward
95
- * `target.position` at `speed`, stopping once within `stopRange` (melee reach).
96
- * Dimension-agnostic.
97
- *
98
- * - `reachedTarget` fires once when it first enters `stopRange` (re-arms after
99
- * it leaves), so wire it to an attack.
100
- * - `loseRange` (>0) gives up the chase when the target gets that far away,
101
- * emitting `lostTarget` once.
102
- * - `moveParent` (default false) moves the parent node instead of this one.
103
- * A node carries ONE behavior, so an enemy whose ROOT must hold `Health`
104
- * (e.g. for `Health.freeOnDeath` clone-safe cleanup) puts `Chase` on a CHILD
105
- * and sets `moveParent:true` — the AI child drives the whole entity. Distance
106
- * is measured from the moved node (the parent).
107
- */
108
- var Chase = class extends Behavior {
109
- static props = {
110
- /** NodePath of the node to chase (resolved from THIS behavior's node). */
111
- target: { default: "" },
112
- /** Units per second toward the target. */
113
- speed: { default: 60 },
114
- /** Stop (and emit reachedTarget) when within this distance. */
115
- stopRange: { default: 0 },
116
- /** Give up (emit lostTarget) beyond this distance (0 = never lose). */
117
- loseRange: { default: 0 },
118
- /** Move the parent node instead of this one (AI-on-a-child pattern). */
119
- moveParent: { default: false }
120
- };
121
- static signals = ["reachedTarget", "lostTarget"];
122
- target = "";
123
- speed = 60;
124
- stopRange = 0;
125
- loseRange = 0;
126
- moveParent = false;
127
- inRange = false;
128
- lost = false;
129
- onReady() {
130
- if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `Chase on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
131
- if (this.moveParent) {
132
- const parent = this.node.parent;
133
- if (!parent || !hasPosition$1(parent)) throw new IncantoError("PROP_TYPE_MISMATCH", `Chase on '${this.node.getPath()}': "moveParent" needs a spatial parent (a Node2D/Node3D) to move. '${this.node.name}' has none.`, {
134
- prop: "moveParent",
135
- path: this.node.getPath()
136
- });
137
- } else requirePosition(this);
138
- }
139
- update(dt) {
140
- const mover = this.moveParent ? this.node.parent : this.node;
141
- if (!mover || !hasPosition$1(mover)) return;
142
- const target = this.node.getNodeOrNull(this.target);
143
- if (!target || !hasPosition$1(target)) return;
144
- const d = distance$1(mover.position, target.position);
145
- if (this.loseRange > 0 && d > this.loseRange) {
146
- if (!this.lost) {
147
- this.lost = true;
148
- this.inRange = false;
149
- this.emit("lostTarget");
150
- }
151
- return;
152
- }
153
- this.lost = false;
154
- if (this.stopRange > 0 && d <= this.stopRange) {
155
- if (!this.inRange) {
156
- this.inRange = true;
157
- this.emit("reachedTarget");
158
- }
159
- return;
160
- }
161
- this.inRange = false;
162
- const step = Math.min(this.speed * dt, this.stopRange > 0 ? d - this.stopRange : d);
163
- const { position } = moveToward(mover.position, target.position, Math.max(0, step));
164
- mover.position = position;
165
- }
166
- };
167
- //#endregion
168
- //#region src/gameplay/collector.ts
169
- /**
170
- * A tally for the "collect N things" pattern, living on the collector itself
171
- * (usually the player). Adds its node to `group` at ready so Pickups whose
172
- * `collectorGroup` matches will count it as a collector.
173
- *
174
- * Wire a Pickup's `collected(value, other) → Collector.collect` (the value is
175
- * the first arg) — `total` accumulates and `totalChanged(total)` fires.
176
- * (Pickup also notifies a global ScoreKeeper; Collector is the per-actor tally,
177
- * e.g. for split-screen or "each player's coins".)
178
- */
179
- var Collector = class extends Behavior {
180
- static props = {
181
- /** Group this collector joins (Pickups match this against `collectorGroup`). */
182
- group: { default: "player" } };
183
- static signals = ["totalChanged"];
184
- group = "player";
185
- /** Running sum of collected values. */
186
- total = 0;
187
- onReady() {
188
- this.node.addToGroup(this.group);
189
- }
190
- /** Add `value` to the tally and emit `totalChanged`. */
191
- collect(value) {
192
- this.total += value;
193
- this.emit("totalChanged", this.total);
194
- }
195
- };
196
- //#endregion
197
- //#region src/gameplay/health.ts
198
- /**
199
- * Hit points with regeneration and post-hit invulnerability (i-frames) —
200
- * the universal "this thing can be hurt and can die" behavior.
201
- *
202
- * Other behaviors hurt it through `damage(n)` (e.g. `DamageOnContact` finds a
203
- * target's Health and calls it); games heal/kill via `heal(n)` / `kill()`.
204
- * Wire its `died` signal to a `ScoreKeeper.loseLife`, a respawn, etc.
205
- *
206
- * `freeOnDeath` (default false) `queueFree()`s the Health node itself when it
207
- * dies. Because it is NODE-LOCAL it clones perfectly — this is THE way to free
208
- * dying SPAWNED entities. Scene-level `connections` (e.g. `died → queueFree`)
209
- * are NOT copied onto Spawner/WaveSpawner clones, so a `died` connection only
210
- * ever wires the template, never the live clones; `freeOnDeath` needs no
211
- * connection and so works on every clone.
212
- *
213
- * Dimension-agnostic: no node geometry, just numbers — testable headlessly.
214
- */
215
- var Health = class extends Behavior {
216
- static props = {
217
- /** Maximum hit points; `current` starts here. */
218
- max: { default: 100 },
219
- /** Hit points regained per second (0 = no regen). */
220
- regenPerSec: { default: 0 },
221
- /** Seconds of immunity after taking a hit (0 = no i-frames). */
222
- invulnerableFor: { default: 0 },
223
- /** queueFree() this node when it dies (clone-safe spawned-entity cleanup). */
224
- freeOnDeath: { default: false }
225
- };
226
- static signals = [
227
- "damaged",
228
- "healed",
229
- "died"
230
- ];
231
- max = 100;
232
- regenPerSec = 0;
233
- invulnerableFor = 0;
234
- freeOnDeath = false;
235
- /** Current hit points (set to `max` on ready). */
236
- current = 0;
237
- invulnTimer = 0;
238
- dead = false;
239
- /** True once `current` has hit 0 — `died` fires at most once per life. */
240
- get isDead() {
241
- return this.dead;
242
- }
243
- /** Seconds of i-frames remaining (0 = vulnerable). */
244
- get invulnerableRemaining() {
245
- return this.invulnTimer;
246
- }
247
- onReady() {
248
- if (!(this.max > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Health on '${this.node.getPath()}': "max" must be > 0, got ${this.max}.`, { prop: "max" });
249
- if (this.regenPerSec < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Health on '${this.node.getPath()}': "regenPerSec" must be >= 0, got ${this.regenPerSec}.`, { prop: "regenPerSec" });
250
- if (this.invulnerableFor < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Health on '${this.node.getPath()}': "invulnerableFor" must be >= 0, got ${this.invulnerableFor}.`, { prop: "invulnerableFor" });
251
- this.current = this.max;
252
- }
253
- update(dt) {
254
- if (this.invulnTimer > 0) this.invulnTimer = Math.max(0, this.invulnTimer - dt);
255
- if (this.regenPerSec > 0 && !this.dead && this.current < this.max) this.setCurrent(this.current + this.regenPerSec * dt);
256
- }
257
- /** Apply `n` damage. No-op while invulnerable or already dead. Emits `damaged`. */
258
- damage(n) {
259
- if (this.dead || n <= 0) return;
260
- if (this.invulnTimer > 0) return;
261
- this.setCurrent(this.current - n);
262
- this.emit("damaged", n, this.current);
263
- if (this.invulnerableFor > 0) this.invulnTimer = this.invulnerableFor;
264
- if (this.current <= 0) this.die();
265
- }
266
- /** Restore `n` hit points (clamped to `max`). No-op when dead. Emits `healed`. */
267
- heal(n) {
268
- if (this.dead || n <= 0) return;
269
- this.setCurrent(this.current + n);
270
- this.emit("healed", n, this.current);
271
- }
272
- /** Drop to 0 and die immediately (ignores i-frames). */
273
- kill() {
274
- if (this.dead) return;
275
- this.setCurrent(0);
276
- this.die();
277
- }
278
- setCurrent(value) {
279
- this.current = Math.max(0, Math.min(this.max, value));
280
- }
281
- die() {
282
- if (this.dead) return;
283
- this.dead = true;
284
- this.current = 0;
285
- this.emit("died");
286
- if (this.freeOnDeath) this.node.queueFree();
287
- }
288
- };
289
- //#endregion
290
- //#region src/gameplay/trigger.ts
291
- /**
292
- * Subscribe a behavior to its own node's `triggerEnter(other)` signal, after
293
- * asserting the node can actually emit it — Pickup/DamageOnContact must sit on
294
- * an Area2D/Area3D (or any node declaring the unified collision signals).
295
- * Fails loudly (the engine philosophy) instead of silently never firing.
296
- */
297
- function onTriggerEnter(behavior, fn) {
298
- const node = behavior.node;
299
- if (!node.declaredSignalNames().includes("triggerEnter")) {
300
- const behaviorName = behavior.constructor.name;
301
- throw new IncantoError("UNKNOWN_SIGNAL", `${behaviorName} on '${node.getPath()}' needs a node that emits 'triggerEnter' — attach it to an Area2D/Area3D (or another body that declares the collision signals). '${node.name}' declares: [${node.declaredSignalNames().join(", ")}].`, {
302
- signal: "triggerEnter",
303
- path: node.getPath()
304
- });
305
- }
306
- node.on("triggerEnter", (other) => fn(other));
307
- }
308
- //#endregion
309
- //#region src/gameplay/damage-on-contact.ts
310
- /**
311
- * Deals damage to whatever it touches — projectiles, spikes, lava, enemy
312
- * hitboxes. Must sit on an Area2D/Area3D (listens to `triggerEnter`).
313
- *
314
- * On overlap it finds the contacted entity's `Health` behavior (see
315
- * `findHealth` for the exact search order) and calls `damage(amount)`, then
316
- * emits `dealtDamage(amount, targetNode)`.
317
- *
318
- * - `targetGroup` (default '' = anything) gates who can be hurt: only a target
319
- * whose Health-owner node is in that group takes damage. This is how you stop
320
- * enemies killing each other — give the player's weapon `targetGroup:'enemy'`
321
- * and each enemy's contact hitbox `targetGroup:'player'`.
322
- * - `oncePerTarget` (default) prevents re-hitting the same node (a stationary
323
- * hazard would otherwise drain a resting body every frame).
324
- * - `repeatEvery` (seconds, 0 = off) re-damages targets that STAY overlapped —
325
- * lava pools, poison clouds, an enemy standing on you. Contact events fire
326
- * only on entry/exit; this is the "and it keeps hurting" knob.
327
- * - `destroySelf` frees the hazard after a hit (single-use projectiles).
328
- *
329
- * SCORING PATTERN (clone-safe): wire the KILLER's `dealtDamage` →
330
- * `ScoreKeeper.addScore`. The weapon is usually a non-cloned node (it lives on
331
- * the player), so a scene connection on it survives — unlike a connection on a
332
- * spawned enemy, which never clones.
333
- */
334
- var DamageOnContact = class extends Behavior {
335
- static props = {
336
- /** Hit points to remove per contact. */
337
- amount: { default: 10 },
338
- /** Only damage targets whose Health-owner is in this group ('' = any). */
339
- targetGroup: { default: "" },
340
- /** Damage each distinct target at most once. */
341
- oncePerTarget: { default: true },
342
- /** Re-damage targets still overlapping every N seconds (0 = entry only). */
343
- repeatEvery: { default: 0 },
344
- /** queueFree() this node after the first successful hit. */
345
- destroySelf: { default: false }
346
- };
347
- static signals = ["dealtDamage"];
348
- amount = 10;
349
- targetGroup = "";
350
- oncePerTarget = true;
351
- repeatEvery = 0;
352
- destroySelf = false;
353
- hit = /* @__PURE__ */ new WeakSet();
354
- /** Targets currently overlapping → seconds until their next repeat tick. */
355
- inside = /* @__PURE__ */ new Map();
356
- onReady() {
357
- onTriggerEnter(this, (other) => {
358
- this.inside.set(other, this.repeatEvery);
359
- this.tryDamage(other);
360
- });
361
- this.node.on("triggerExit", (other) => {
362
- this.inside.delete(other);
363
- });
364
- }
365
- update(dt) {
366
- if (this.repeatEvery <= 0 || this.inside.size === 0) return;
367
- for (const [target, left] of this.inside) {
368
- if (!target.tree) {
369
- this.inside.delete(target);
370
- continue;
371
- }
372
- const next = left - dt;
373
- if (next > 0) this.inside.set(target, next);
374
- else {
375
- this.inside.set(target, this.repeatEvery);
376
- this.tryDamage(target, { repeat: true });
377
- }
378
- }
379
- }
380
- tryDamage(other, opts) {
381
- if (!opts?.repeat && this.oncePerTarget && this.hit.has(other)) return;
382
- const found = findHealth(other);
383
- if (!found) return;
384
- if (this.targetGroup !== "" && !found.owner.isInGroup(this.targetGroup)) return;
385
- if (this.oncePerTarget) this.hit.add(other);
386
- found.health.damage(this.amount);
387
- this.emit("dealtDamage", this.amount, found.owner);
388
- if (this.destroySelf) this.node.queueFree();
389
- }
390
- };
391
- /**
392
- * Find the nearest `Health` to a contacted node and the node that owns it.
393
- *
394
- * A real entity rarely carries Health on the exact collider that overlaps: the
395
- * Area/Body arrangement usually delivers a different node than the one holding
396
- * the Health behavior (e.g. Health on the entity ROOT, the overlapping collider
397
- * a child `Hitbox` Area). The search, in order of nearness:
398
- *
399
- * 1. the contacted node itself, then its DESCENDANTS (breadth-first), then
400
- * 2. each ANCESTOR node in turn (climbing toward the entity root) — the
401
- * ancestor NODE only, NOT its other subtrees.
402
- *
403
- * Step 2 deliberately never descends into an ancestor's *sibling* branches:
404
- * doing so let a hazard collider whose ancestor chain reaches the scene root
405
- * find — and damage — an unrelated entity on the far side of the level (the
406
- * scene root's subtree contains every entity). Health belongs on the contacted
407
- * collider's own subtree or on a direct ancestor (the entity root) — both of
408
- * which this covers without ever crossing into another entity.
409
- *
410
- * The first `Health` found wins; its node is returned as `owner` (used for the
411
- * `targetGroup` check and the `dealtDamage` target). Returns null when no Health
412
- * exists in the contacted node's own subtree or ancestor chain.
413
- */
414
- function findHealth(node) {
415
- const queue = [node];
416
- while (queue.length > 0) {
417
- const n = queue.shift();
418
- if (n.behavior instanceof Health) return {
419
- health: n.behavior,
420
- owner: n
421
- };
422
- queue.push(...n.children);
423
- }
424
- for (let n = node.parent; n; n = n.parent) if (n.behavior instanceof Health) return {
425
- health: n.behavior,
426
- owner: n
427
- };
428
- return null;
429
- }
430
- //#endregion
431
- //#region src/gameplay/day-night.ts
432
- var DayNight = class extends Behavior {
433
- static signals = ["dayPhaseChanged"];
434
- static props = {
435
- /** Real seconds for a full 24h cycle. */
436
- daySeconds: { default: 240 },
437
- /** Clock position at scene start (0-24; 12 = noon). */
438
- startHour: { default: 10 },
439
- /** Noon sun elevation, degrees. */
440
- maxSunElevationDeg: { default: 55 },
441
- /** How dark midnight gets: 0 = unchanged, 1 = black. */
442
- nightDarkness: { default: .75 },
443
- /** Freeze the clock (drive `hour` yourself). */
444
- paused: { default: false }
445
- };
446
- daySeconds = 240;
447
- startHour = 10;
448
- maxSunElevationDeg = 55;
449
- nightDarkness = .75;
450
- paused = false;
451
- /** Current clock hour (0-24). Write it to jump the time of day. */
452
- hour = 0;
453
- phase = "";
454
- applyClock = 0;
455
- baseAmbient = .3;
456
- baseExposure = 1;
457
- captured = false;
458
- onReady() {
459
- this.hour = this.startHour;
460
- }
461
- update(dt) {
462
- if (!this.paused) this.hour = (this.hour + dt / this.daySeconds * 24) % 24;
463
- this.applyClock -= dt;
464
- if (this.applyClock > 0) return;
465
- this.applyClock = .2;
466
- this.applyHour();
467
- const next = phaseOf(this.hour);
468
- if (next !== this.phase) {
469
- this.phase = next;
470
- this.emit("dayPhaseChanged", next);
471
- }
472
- }
473
- applyHour() {
474
- const engine = this.node.tree?.engine;
475
- const env = engine?.scene?.environment;
476
- if (!engine || !env?.sky) return;
477
- if (!this.captured) {
478
- this.captured = true;
479
- this.baseAmbient = env.ambient?.intensity ?? .3;
480
- this.baseExposure = env.exposure ?? 1;
481
- }
482
- const t = (this.hour - 6) / 12 * Math.PI;
483
- const sun = Math.sin(t);
484
- env.sky.elevationDeg = sun * this.maxSunElevationDeg + (sun < 0 ? sun * 10 : 0);
485
- env.sky.azimuthDeg = this.hour / 24 * 360 + 90;
486
- const daylight = Math.max(0, sun);
487
- const dark = this.nightDarkness;
488
- env.ambient = {
489
- ...env.ambient ?? {},
490
- intensity: this.baseAmbient * (1 - dark + dark * daylight)
491
- };
492
- env.exposure = this.baseExposure * (1 - dark * .55 * (1 - daylight));
493
- }
494
- };
495
- function phaseOf(hour) {
496
- if (hour < 5 || hour >= 20) return "night";
497
- if (hour < 7.5) return "dawn";
498
- if (hour < 17.5) return "day";
499
- return "dusk";
500
- }
501
- //#endregion
502
- //#region src/gameplay/follow-camera.ts
503
- /**
504
- * Make the node it sits on chase a target's position — THE camera-follow
505
- * behavior. Put it on a `Camera2D`/`Camera3D` (whose `position` is the view
506
- * center/eye) and point `target` at the player; every frame the camera lerps
507
- * toward `target.position + offset`.
508
- *
509
- * Dimension-agnostic: works on `[x,y]` (2D) and `[x,y,z]` (3D) positions.
510
- *
511
- * - `smoothing` (0..1) is a per-frame retention factor: 0 = instant snap,
512
- * 0.85–0.95 = smooth drag (frame-rate independent at a 60fps reference).
513
- * - `deadzone` keeps the camera still until the target drifts that far from the
514
- * desired point — no jitter when the player makes tiny moves.
515
- */
516
- var FollowCamera = class extends Behavior {
517
- static props = {
518
- /** NodePath of the node to follow (relative to this node, or absolute). */
519
- target: { default: "" },
520
- /** Constant offset added to the target's position ([x,y] or [x,y,z]). */
521
- offset: { default: [] },
522
- /** 0..1 retention factor: 0 = snap, higher = smoother chase. */
523
- smoothing: { default: 0 },
524
- /** Don't move until the target is at least this far from the desired point. */
525
- deadzone: { default: 0 }
526
- };
527
- static signals = [];
528
- target = "";
529
- offset = [];
530
- smoothing = 0;
531
- deadzone = 0;
532
- onReady() {
533
- if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
534
- if (this.deadzone < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "deadzone" must be >= 0, got ${this.deadzone}.`, { prop: "deadzone" });
535
- requirePosition(this);
536
- }
537
- shakeMag = 0;
538
- shakeT = 0;
539
- shakeFalloff = .28;
540
- /** Kick the camera (impacts, explosions). Composes with the follow. */
541
- shake(magnitude, seconds = .28) {
542
- this.shakeMag = Math.max(this.shakeMag, magnitude);
543
- this.shakeT = seconds;
544
- this.shakeFalloff = seconds;
545
- }
546
- update(dt) {
547
- const cam = this.node;
548
- if (!hasPosition$1(cam)) return;
549
- const target = cam.getNodeOrNull(this.target);
550
- if (!target || !hasPosition$1(target)) return;
551
- const desired = add(target.position, this.offset);
552
- if (!(this.deadzone > 0 && distance$1(cam.position, desired) <= this.deadzone)) {
553
- const f = smoothingFactor(this.smoothing, dt);
554
- if (f >= 1) cam.position = [...desired];
555
- else cam.position = cam.position.map((c, i) => c + ((desired[i] ?? c) - c) * f);
556
- }
557
- if (this.shakeT > 0) {
558
- this.shakeT -= dt;
559
- const k = this.shakeMag * Math.max(0, this.shakeT) / this.shakeFalloff;
560
- if (this.shakeT <= 0) this.shakeMag = 0;
561
- const jolted = cam.position.slice();
562
- for (let i = 0; i < jolted.length; i++) {
563
- const scale = jolted.length >= 3 && i === 1 ? .5 : 1;
564
- jolted[i] = (jolted[i] ?? 0) + (this.engine.rng.next() * 2 - 1) * k * scale;
565
- }
566
- cam.position = jolted;
567
- }
568
- }
569
- };
570
- //#endregion
571
- //#region src/gameplay/game-flow.ts
572
- /**
573
- * Reload the CURRENT scene from its source JSON — fresh nodes, reset physics,
574
- * rewired input. The restart primitive every game-over screen wants.
575
- */
576
- function restartScene(engine) {
577
- const source = engine.scene?.source;
578
- if (!source) return;
579
- engine.timeScale = 1;
580
- engine.setScene(loadScene(jsonClone(source)));
581
- }
582
- /**
583
- * Swap to another scene behind a black fade (title→game→next level).
584
- * Headless (no DOM) the swap is immediate. Restores timeScale to 1.
585
- */
586
- function goToScene(engine, sceneJson, opts) {
587
- const fade = opts?.fadeSeconds ?? .4;
588
- const swap = () => {
589
- engine.timeScale = 1;
590
- engine.setScene(loadScene(jsonClone(sceneJson)));
591
- };
592
- if (typeof document === "undefined" || fade <= 0) {
593
- swap();
594
- return;
595
- }
596
- const id = "incanto-scene-fade";
597
- let el = document.getElementById(id);
598
- if (!el) {
599
- el = document.createElement("div");
600
- el.id = id;
601
- el.style.cssText = "position:fixed;inset:0;z-index:10001;background:#000;opacity:0;pointer-events:none;";
602
- document.body.appendChild(el);
603
- }
604
- el.style.transition = `opacity ${fade / 2}s`;
605
- el.style.opacity = "1";
606
- setTimeout(() => {
607
- swap();
608
- const out = document.getElementById(id);
609
- if (out) {
610
- out.style.opacity = "0";
611
- setTimeout(() => out.remove(), fade * 500 + 60);
612
- }
613
- }, fade * 500 + 30);
614
- }
615
- /**
616
- * The win/lose/restart state machine 6+ examples hand-rolled as `over`/`win`
617
- * booleans. Attach to any node (the scene root is natural):
618
- *
619
- * const flow = root.behavior as GameFlow; // script: { "name": "GameFlow" }
620
- * flow.gameOver('YOU DIED'); // freezes time, sticky banner
621
- * flow.win('AREA CLEAR');
622
- * flow.pause(); flow.resume();
623
- *
624
- * While in `gameover`/`won`, pressing `restartAction` (default action name
625
- * 'restart' — declare it in the scene input map, or leave undeclared and call
626
- * `flow.restart()` yourself) reloads the scene from source. Banners render
627
- * through a `%Banner` UiBanner when one exists; otherwise states are silent
628
- * (drive your own UI off the `flowChanged` signal).
629
- */
630
- var GameFlow = class extends Behavior {
631
- static signals = ["flowChanged"];
632
- static props = {
633
- restartAction: { default: "restart" },
634
- freezeOnEnd: { default: true },
635
- bannerPath: { default: "%Banner" }
636
- };
637
- /** Input action that restarts from gameover/won (declare it in `input{}`). */
638
- restartAction = "restart";
639
- /** Freeze `engine.timeScale` on gameover/won (banner UI keeps rendering). */
640
- freezeOnEnd = true;
641
- /** Where the flow looks for a UiBanner ('' = never). */
642
- bannerPath = "%Banner";
643
- state = "playing";
644
- frozeScale = false;
645
- gameOver(text = "GAME OVER", color = "#ef4444") {
646
- this.transition("gameover", text, color);
647
- }
648
- win(text = "YOU WIN", color = "#4ade80") {
649
- this.transition("won", text, color);
650
- }
651
- pause() {
652
- if (this.state !== "playing") return;
653
- this.state = "paused";
654
- this.freeze();
655
- this.node.emit("flowChanged", this.state);
656
- }
657
- resume() {
658
- if (this.state !== "paused") return;
659
- this.state = "playing";
660
- this.thaw();
661
- this.node.emit("flowChanged", this.state);
662
- }
663
- restart() {
664
- this.thaw();
665
- restartScene(this.engine);
666
- }
667
- /** Fade to another scene (next level, back to title). */
668
- goToScene(sceneJson, opts) {
669
- this.thaw();
670
- goToScene(this.engine, sceneJson, opts);
671
- }
672
- transition(state, text, color) {
673
- if (this.state === "gameover" || this.state === "won") return;
674
- this.state = state;
675
- if (this.freezeOnEnd) this.freeze();
676
- if (this.bannerPath !== "") this.node.getNodeOrNull(this.bannerPath)?.show(text, {
677
- color,
678
- seconds: 0
679
- });
680
- this.node.emit("flowChanged", state);
681
- }
682
- freeze() {
683
- if (this.frozeScale) return;
684
- this.frozeScale = true;
685
- this.engine.timeScale = 0;
686
- }
687
- thaw() {
688
- if (!this.frozeScale) return;
689
- this.frozeScale = false;
690
- this.engine.timeScale = 1;
691
- }
692
- update() {
693
- if (this.state !== "gameover" && this.state !== "won") return;
694
- try {
695
- if (this.engine.input.justPressed(this.restartAction)) this.restart();
696
- } catch {}
697
- }
698
- };
699
- //#endregion
700
- //#region src/gameplay/interactable.ts
701
- function hasPosition(node) {
702
- return Array.isArray(node.position);
703
- }
704
- /** Euclidean distance between two position arrays (works for 2D [x,y] and 3D [x,y,z]). */
705
- function distance(a, b) {
706
- let sum = 0;
707
- const n = Math.max(a.length, b.length);
708
- for (let i = 0; i < n; i++) {
709
- const d = (a[i] ?? 0) - (b[i] ?? 0);
710
- sum += d * d;
711
- }
712
- return Math.sqrt(sum);
713
- }
714
- /**
715
- * A proximity-gated "press to use" — doors, levers, chests, NPCs. Each frame,
716
- * if an actor in `actorGroup` is within `range` (distance on `position` arrays,
717
- * 2D or 3D) and the `action` input was just pressed, emits
718
- * `interacted(actor)` (the nearest in-range actor).
719
- *
720
- * Wire `interacted → YourBehavior.someMethod`, or read it from a connection.
721
- */
722
- var Interactable = class extends Behavior {
723
- static props = {
724
- /** Input action (button) that triggers interaction. */
725
- action: { default: "interact" },
726
- /** Maximum distance an actor may be to interact. */
727
- range: { default: 2 },
728
- /** Only nodes in this group can interact. */
729
- actorGroup: { default: "player" }
730
- };
731
- static signals = ["interacted"];
732
- action = "interact";
733
- range = 2;
734
- actorGroup = "player";
735
- update() {
736
- const engine = this.node.tree?.engine;
737
- if (!engine?.scene) return;
738
- if (!engine.input.justPressed(this.action)) return;
739
- if (!hasPosition(this.node)) return;
740
- const self = this.node.position;
741
- let nearest = null;
742
- let nearestDist = Number.POSITIVE_INFINITY;
743
- const actors = this.node.tree?.getNodesInGroup(this.actorGroup) ?? collectGroup(this.node.getRoot(), this.actorGroup);
744
- for (const actor of actors) {
745
- if (!hasPosition(actor)) continue;
746
- const d = distance(self, actor.position);
747
- if (d <= this.range && d < nearestDist) {
748
- nearest = actor;
749
- nearestDist = d;
750
- }
751
- }
752
- if (nearest) this.emit("interacted", nearest);
753
- }
754
- };
755
- function collectGroup(node, group) {
756
- const out = [];
757
- const walk = (n) => {
758
- if (n.isInGroup(group)) out.push(n);
759
- for (const c of n.children) walk(c);
760
- };
761
- walk(node);
762
- return out;
763
- }
764
- //#endregion
765
- //#region src/gameplay/juice.ts
766
- /**
767
- * Game-feel primitives ("juice"): the weapon cooldown, camera shake, screen
768
- * flash and hit-stop that action examples kept hand-rolling.
769
- */
770
- /**
771
- * A fire-rate / ability cooldown — replaces the copy-pasted
772
- * `this.clock += dt * 1000; if (now > nextFire)` pattern:
773
- *
774
- * private gun = new Cooldown(0.2);
775
- * override update(dt: number): void {
776
- * this.gun.tick(dt);
777
- * if (this.engine.input.isPressed('fire') && this.gun.tryUse()) this.shoot();
778
- * }
779
- */
780
- var Cooldown = class {
781
- seconds;
782
- remaining = 0;
783
- constructor(seconds) {
784
- this.seconds = seconds;
785
- }
786
- /** Advance time. Call once per update with the frame dt. */
787
- tick(dt) {
788
- if (this.remaining > 0) this.remaining -= dt;
789
- }
790
- get ready() {
791
- return this.remaining <= 0;
792
- }
793
- /** 0..1 — how far through the cooldown we are (1 = ready). UiBar-friendly. */
794
- get progress() {
795
- return this.seconds <= 0 ? 1 : Math.min(1, Math.max(0, 1 - this.remaining / this.seconds));
796
- }
797
- /** Consume if ready. Returns whether the action should fire. */
798
- tryUse() {
799
- if (this.remaining > 0) return false;
800
- this.remaining = this.seconds;
801
- return true;
802
- }
803
- /** Force-ready (pickups that reset your reload). */
804
- reset() {
805
- this.remaining = 0;
806
- }
807
- };
808
- const SHAKE_FALLOFF_SECONDS = .28;
809
- /**
810
- * Camera shake as a standalone behavior for cameras WITHOUT another script.
811
- * (FollowCamera has this built in — one script per node.) Composes with any
812
- * other position writer by applying only the DELTA of its own offset, so the
813
- * camera returns exactly to where the other writer left it.
814
- *
815
- * (camera.behavior as CameraShake).shake(8); // pixels (2D) / meters·100 feel (3D: use ~0.2)
816
- */
817
- var CameraShake = class extends Behavior {
818
- static props = { falloff: { default: SHAKE_FALLOFF_SECONDS } };
819
- /** Seconds a shake takes to decay to zero. */
820
- falloff = SHAKE_FALLOFF_SECONDS;
821
- magnitude = 0;
822
- t = 0;
823
- prev = [];
824
- shake(magnitude, seconds) {
825
- this.magnitude = Math.max(this.magnitude, magnitude);
826
- this.t = seconds ?? this.falloff;
827
- }
828
- update(dt) {
829
- const node = requirePosition(this);
830
- const pos = node.position;
831
- for (let i = 0; i < this.prev.length; i++) pos[i] = (pos[i] ?? 0) - (this.prev[i] ?? 0);
832
- if (this.t <= 0) {
833
- if (this.prev.length > 0) {
834
- node.position = pos.slice();
835
- this.prev.length = 0;
836
- }
837
- return;
838
- }
839
- this.t -= dt;
840
- const k = this.magnitude * Math.max(0, this.t) / this.falloff;
841
- if (this.t <= 0) this.magnitude = 0;
842
- const next = pos.slice();
843
- if (this.prev.length !== next.length) this.prev = new Array(next.length).fill(0);
844
- for (let i = 0; i < next.length; i++) {
845
- const scale = next.length >= 3 && i === 1 ? .5 : 1;
846
- this.prev[i] = (this.engine.rng.next() * 2 - 1) * k * scale;
847
- next[i] = (next[i] ?? 0) + this.prev[i];
848
- }
849
- node.position = next;
850
- }
851
- };
852
- /**
853
- * Full-screen color flash (damage red, pickup white). DOM overlay above
854
- * everything; headless no-op. Repeated calls restart the fade.
855
- */
856
- function screenFlash(color = "#ffffff", opacity = .35, seconds = .25) {
857
- if (typeof document === "undefined") return;
858
- const id = "incanto-screen-flash";
859
- let el = document.getElementById(id);
860
- if (!el) {
861
- el = document.createElement("div");
862
- el.id = id;
863
- el.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:0;transition:opacity .05s;";
864
- document.body.appendChild(el);
865
- }
866
- el.style.background = color;
867
- el.style.transition = "opacity .05s";
868
- el.style.opacity = String(opacity);
869
- const fade = () => {
870
- el.style.transition = `opacity ${seconds}s`;
871
- el.style.opacity = "0";
872
- };
873
- if (typeof requestAnimationFrame === "function") requestAnimationFrame(fade);
874
- else fade();
875
- }
876
- /**
877
- * Hit-stop: freeze game time for `seconds` of REAL time, then restore the
878
- * previous timeScale. Stacking calls extend the freeze instead of fighting.
879
- */
880
- function hitStop(engine, seconds = .08) {
881
- const state = hitStops.get(engine);
882
- if (state) {
883
- state.until = Math.max(state.until, realNow() + seconds * 1e3);
884
- return;
885
- }
886
- const restore = engine.timeScale;
887
- const entry = {
888
- until: realNow() + seconds * 1e3,
889
- restore
890
- };
891
- hitStops.set(engine, entry);
892
- engine.timeScale = 0;
893
- const pump = () => {
894
- if (realNow() >= entry.until) {
895
- engine.timeScale = entry.restore;
896
- hitStops.delete(engine);
897
- return;
898
- }
899
- schedule(pump);
900
- };
901
- schedule(pump);
902
- }
903
- const hitStops = /* @__PURE__ */ new Map();
904
- function realNow() {
905
- return typeof performance !== "undefined" ? performance.now() : Date.now();
906
- }
907
- function schedule(fn) {
908
- if (typeof requestAnimationFrame === "function") requestAnimationFrame(fn);
909
- else setTimeout(fn, 16);
910
- }
911
- //#endregion
912
- //#region src/gameplay/lifetime.ts
913
- /**
914
- * Self-destruct after a fixed time — bullets, particles, temporary spawns,
915
- * pickups that vanish. Accumulates `dt`; on elapse emits `expired` then
916
- * `queueFree()`s its node.
917
- *
918
- * With `startOnSignal: true` the countdown is armed manually via `startTimer()`
919
- * (wire a signal → `startTimer`), so the lifetime begins on an event rather
920
- * than at spawn.
921
- */
922
- var Lifetime = class extends Behavior {
923
- static props = {
924
- /** Seconds before the node frees itself. */
925
- seconds: { default: 1 },
926
- /** Defer the countdown until `startTimer()` is called (default: start at ready). */
927
- startOnSignal: { default: false }
928
- };
929
- static signals = ["expired"];
930
- seconds = 1;
931
- startOnSignal = false;
932
- elapsed = 0;
933
- running = false;
934
- fired = false;
935
- onReady() {
936
- if (!(this.seconds > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Lifetime on '${this.node.getPath()}': "seconds" must be > 0, got ${this.seconds}.`, { prop: "seconds" });
937
- this.running = !this.startOnSignal;
938
- }
939
- /** Arm (or re-arm) the countdown from zero. */
940
- startTimer() {
941
- this.elapsed = 0;
942
- this.fired = false;
943
- this.running = true;
944
- }
945
- update(dt) {
946
- if (!this.running || this.fired) return;
947
- this.elapsed += dt;
948
- if (this.elapsed >= this.seconds) {
949
- this.fired = true;
950
- this.running = false;
951
- this.emit("expired");
952
- this.node.queueFree();
953
- }
954
- }
955
- };
956
- //#endregion
957
- //#region src/gameplay/tween.ts
958
- const EASE_NAMES = [
959
- "linear",
960
- "easeIn",
961
- "easeOut",
962
- "easeInOut"
963
- ];
964
- /**
965
- * Map normalized time `t` through an easing curve to an eased progress in
966
- * `[0,1]`. `t` is clamped to `[0,1]` so callers never overshoot:
967
- * - `linear` — constant speed
968
- * - `easeIn` — quadratic, slow start (`t²`)
969
- * - `easeOut` — quadratic, slow end (`1-(1-t)²`)
970
- * - `easeInOut` — slow start AND end (smooth in/out)
971
- */
972
- function ease(curve, t) {
973
- const x = t < 0 ? 0 : t > 1 ? 1 : t;
974
- switch (curve) {
975
- case "linear": return x;
976
- case "easeIn": return x * x;
977
- case "easeOut": return 1 - (1 - x) * (1 - x);
978
- case "easeInOut": return x < .5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
979
- }
980
- }
981
- //#endregion
982
- //#region src/gameplay/move-to.ts
983
- /**
984
- * Tween the node from where it starts to a fixed `to` position over `duration`
985
- * seconds, through an easing curve — opening doors, sliding platforms, UI
986
- * pop-ins, scripted moves. Emits `arrived` once at the end.
987
- *
988
- * `startOnSignal: true` arms it manually via `start()` (wire a signal → `start`)
989
- * so the move plays on an event; otherwise it begins at ready. Dimension-
990
- * agnostic (`[x,y]` / `[x,y,z]`).
991
- */
992
- var MoveTo = class extends Behavior {
993
- static props = {
994
- /** Destination position ([x,y] or [x,y,z]). */
995
- to: { default: [] },
996
- /** Seconds the move takes. */
997
- duration: { default: 1 },
998
- /** Easing curve. */
999
- ease: {
1000
- default: "easeInOut",
1001
- options: [...EASE_NAMES]
1002
- },
1003
- /** Defer the move until start() is called (default: begin at ready). */
1004
- startOnSignal: { default: false }
1005
- };
1006
- static signals = ["arrived"];
1007
- to = [];
1008
- duration = 1;
1009
- ease = "easeInOut";
1010
- startOnSignal = false;
1011
- from = [];
1012
- elapsed = 0;
1013
- running = false;
1014
- arrivedFired = false;
1015
- onReady() {
1016
- if (!(this.duration > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `MoveTo on '${this.node.getPath()}': "duration" must be > 0, got ${this.duration}.`, { prop: "duration" });
1017
- if (!EASE_NAMES.includes(this.ease)) throw new IncantoError("PROP_TYPE_MISMATCH", `MoveTo on '${this.node.getPath()}': "ease" must be one of [${EASE_NAMES.join(", ")}], got '${this.ease}'.`, { prop: "ease" });
1018
- const node = requirePosition(this);
1019
- this.from = [...node.position];
1020
- if (!this.startOnSignal) this.running = true;
1021
- }
1022
- /** Begin (or restart) the move from the node's CURRENT position. */
1023
- start() {
1024
- const node = this.node;
1025
- this.from = [...node.position];
1026
- this.elapsed = 0;
1027
- this.arrivedFired = false;
1028
- this.running = true;
1029
- }
1030
- update(dt) {
1031
- if (!this.running) return;
1032
- const node = this.node;
1033
- this.elapsed += dt;
1034
- const t = Math.min(1, this.elapsed / this.duration);
1035
- const k = ease(this.ease, t);
1036
- node.position = this.from.map((c, i) => c + ((this.to[i] ?? c) - c) * k);
1037
- if (t >= 1 && !this.arrivedFired) {
1038
- this.arrivedFired = true;
1039
- this.running = false;
1040
- this.emit("arrived");
1041
- }
1042
- }
1043
- };
1044
- //#endregion
1045
- //#region src/gameplay/oscillate.ts
1046
- const AXES = [
1047
- "x",
1048
- "y",
1049
- "z"
1050
- ];
1051
- const MODES = [
1052
- "position",
1053
- "rotation",
1054
- "scale"
1055
- ];
1056
- const AXIS_INDEX = {
1057
- x: 0,
1058
- y: 1,
1059
- z: 2
1060
- };
1061
- /**
1062
- * Continuous sine motion around a value — floating platforms, bobbing pickups,
1063
- * spinning/pulsing coins. Drives one `axis` of the node's `position`, `rotation`
1064
- * (`mode: 'rotation'` — a "spin" when on z), or `scale` (a "pulse"):
1065
- *
1066
- * value = start + amplitude · sin(2π · frequency · t)
1067
- *
1068
- * The baseline (`start`) is captured at ready, so it layers on top of authored
1069
- * transforms. Dimension-agnostic; on a 2D node `rotation` is the scalar spin
1070
- * (use `axis: 'z'`).
1071
- */
1072
- var Oscillate = class extends Behavior {
1073
- static props = {
1074
- /** Which component to drive (rotation on a 2D node is 'z'). */
1075
- axis: {
1076
- default: "y",
1077
- options: [...AXES]
1078
- },
1079
- /** Peak displacement from the baseline. */
1080
- amplitude: { default: 1 },
1081
- /** Cycles per second. */
1082
- frequency: { default: 1 },
1083
- /** What to oscillate. */
1084
- mode: {
1085
- default: "position",
1086
- options: [...MODES]
1087
- }
1088
- };
1089
- static signals = [];
1090
- axis = "y";
1091
- amplitude = 1;
1092
- frequency = 1;
1093
- mode = "position";
1094
- base = 0;
1095
- time = 0;
1096
- onReady() {
1097
- if (!AXES.includes(this.axis)) throw new IncantoError("PROP_TYPE_MISMATCH", `Oscillate on '${this.node.getPath()}': "axis" must be one of [${AXES.join(", ")}], got '${this.axis}'.`, { prop: "axis" });
1098
- if (!MODES.includes(this.mode)) throw new IncantoError("PROP_TYPE_MISMATCH", `Oscillate on '${this.node.getPath()}': "mode" must be one of [${MODES.join(", ")}], got '${this.mode}'.`, { prop: "mode" });
1099
- this.base = this.readChannel();
1100
- }
1101
- update(dt) {
1102
- this.time += dt;
1103
- const offset = this.amplitude * Math.sin(2 * Math.PI * this.frequency * this.time);
1104
- this.writeChannel(this.base + offset);
1105
- }
1106
- /** Whether the target channel is the node's scalar 2D rotation. */
1107
- isScalarRotation() {
1108
- return this.mode === "rotation" && typeof this.node.rotation === "number";
1109
- }
1110
- readChannel() {
1111
- if (this.isScalarRotation()) return this.node.rotation;
1112
- const arr = this.node[this.mode];
1113
- if (!Array.isArray(arr)) throw new IncantoError("PROP_TYPE_MISMATCH", `Oscillate on '${this.node.getPath()}': node has no '${this.mode}' array to oscillate.`, { prop: "mode" });
1114
- return arr[AXIS_INDEX[this.axis]] ?? 0;
1115
- }
1116
- writeChannel(value) {
1117
- if (this.isScalarRotation()) {
1118
- this.node.rotation = value;
1119
- return;
1120
- }
1121
- const next = [...this.node[this.mode]];
1122
- next[AXIS_INDEX[this.axis]] = value;
1123
- this.node[this.mode] = next;
1124
- }
1125
- };
1126
- //#endregion
1127
- //#region src/gameplay/path-follow.ts
1128
- /**
1129
- * Walk a list of waypoints at a constant speed, then stop — the delivery
1130
- * end of `findPath`:
1131
- *
1132
- * const cells = findPath(grid, start, goal);
1133
- * (npc.behavior as PathFollow).setPath(cells.map(([cx, cy]) => [cx * TILE, cy * TILE]));
1134
- *
1135
- * Waypoints are world positions in the node's own units (2D px / 3D m; use
1136
- * [x, y] or [x, y, z] to match the node). Emits `waypointReached(index)`
1137
- * per point and `arrived()` at the end. `loop: true` patrols the ring.
1138
- */
1139
- var PathFollow = class extends Behavior {
1140
- static signals = ["waypointReached", "arrived"];
1141
- static props = {
1142
- speed: { default: 120 },
1143
- loop: { default: false }
1144
- };
1145
- /** Units per second (px in 2D, meters in 3D). */
1146
- speed = 120;
1147
- loop = false;
1148
- waypoints = [];
1149
- index = 0;
1150
- /** True while there is somewhere left to go. */
1151
- get moving() {
1152
- return this.index < this.waypoints.length;
1153
- }
1154
- setPath(waypoints) {
1155
- this.waypoints = waypoints.map((w) => [...w]);
1156
- this.index = 0;
1157
- }
1158
- /** Stop in place (keeps the node where it is). */
1159
- stop() {
1160
- this.waypoints = [];
1161
- this.index = 0;
1162
- }
1163
- update(dt) {
1164
- if (!this.moving) return;
1165
- const node = requirePosition(this);
1166
- let budget = this.speed * dt;
1167
- while (budget > 0 && this.index < this.waypoints.length) {
1168
- const target = this.waypoints[this.index];
1169
- const step = moveToward(node.position, target, budget);
1170
- const travelled = distanceBetween(node.position, step.position);
1171
- node.position = step.position;
1172
- budget -= travelled;
1173
- if (step.reached) {
1174
- this.node.emit("waypointReached", this.index);
1175
- this.index += 1;
1176
- if (this.index >= this.waypoints.length) if (this.loop && this.waypoints.length > 1) this.index = 0;
1177
- else {
1178
- this.node.emit("arrived");
1179
- return;
1180
- }
1181
- } else return;
1182
- }
1183
- }
1184
- };
1185
- function distanceBetween(a, b) {
1186
- let sum = 0;
1187
- const n = Math.max(a.length, b.length);
1188
- for (let i = 0; i < n; i++) {
1189
- const d = (b[i] ?? 0) - (a[i] ?? 0);
1190
- sum += d * d;
1191
- }
1192
- return Math.sqrt(sum);
1193
- }
1194
- //#endregion
1195
- //#region src/gameplay/patrol.ts
1196
- /**
1197
- * Walk the node along a fixed list of waypoints at constant speed — guards,
1198
- * platforms, moving hazards. `points` are positions (`[x,y]` / `[x,y,z]`) OR
1199
- * node paths whose `position` is read each frame (so you can author markers in
1200
- * the scene). On arrival emits `reachedPoint(index)`; `pauseAt` holds at each
1201
- * point before moving on.
1202
- *
1203
- * - `loop` (default) — after the last point, head back to the first.
1204
- * - `mode: 'pingpong'` — reverse direction at each end instead of wrapping.
1205
- */
1206
- var Patrol = class extends Behavior {
1207
- static props = {
1208
- /** Waypoints: array of position arrays OR node-path strings to read. */
1209
- points: { default: [] },
1210
- /** Units per second along the path. */
1211
- speed: { default: 60 },
1212
- /** Wrap to the first point after the last (ignored in pingpong). */
1213
- loop: { default: true },
1214
- /** 'loop' wraps; 'pingpong' reverses at the ends. */
1215
- mode: {
1216
- default: "loop",
1217
- options: ["loop", "pingpong"]
1218
- },
1219
- /** Seconds to wait at each reached point before continuing. */
1220
- pauseAt: { default: 0 }
1221
- };
1222
- static signals = ["reachedPoint"];
1223
- points = [];
1224
- speed = 60;
1225
- loop = true;
1226
- mode = "loop";
1227
- pauseAt = 0;
1228
- index = 0;
1229
- direction = 1;
1230
- pauseTimer = 0;
1231
- onReady() {
1232
- if (!Array.isArray(this.points) || this.points.length === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Patrol on '${this.node.getPath()}': "points" must be a non-empty array of position arrays or node paths.`, { prop: "points" });
1233
- if (this.mode !== "loop" && this.mode !== "pingpong") throw new IncantoError("PROP_TYPE_MISMATCH", `Patrol on '${this.node.getPath()}': "mode" must be 'loop' or 'pingpong', got '${this.mode}'.`, { prop: "mode" });
1234
- requirePosition(this);
1235
- }
1236
- update(dt) {
1237
- if (this.pauseTimer > 0) {
1238
- this.pauseTimer = Math.max(0, this.pauseTimer - dt);
1239
- return;
1240
- }
1241
- const node = this.node;
1242
- const target = this.pointAt(this.index);
1243
- if (!target) return;
1244
- const { position, reached } = moveToward(node.position, target, this.speed * dt);
1245
- node.position = position;
1246
- if (reached) {
1247
- this.emit("reachedPoint", this.index);
1248
- this.advance();
1249
- if (this.pauseAt > 0) this.pauseTimer = this.pauseAt;
1250
- }
1251
- }
1252
- /** Resolve waypoint `i` to a position, reading node paths live. */
1253
- pointAt(i) {
1254
- const raw = this.points[i];
1255
- if (Array.isArray(raw)) return raw;
1256
- if (typeof raw === "string") {
1257
- const marker = this.node.getNodeOrNull(raw);
1258
- return Array.isArray(marker?.position) ? marker.position : null;
1259
- }
1260
- return null;
1261
- }
1262
- advance() {
1263
- const last = this.points.length - 1;
1264
- if (this.mode === "pingpong") {
1265
- if (this.index >= last) this.direction = -1;
1266
- else if (this.index <= 0) this.direction = 1;
1267
- this.index = Math.min(last, Math.max(0, this.index + this.direction));
1268
- return;
1269
- }
1270
- if (this.index < last) this.index += 1;
1271
- else if (this.loop) this.index = 0;
1272
- }
1273
- };
1274
- //#endregion
1275
- //#region src/gameplay/pickup.ts
1276
- /**
1277
- * A collectible that vanishes when a collector overlaps it. Must sit on an
1278
- * Area2D/Area3D (it listens to the unified `triggerEnter`).
1279
- *
1280
- * On overlap with a node in `collectorGroup`, emits `collected(value, other)`
1281
- * then `queueFree()`s itself. Wire `collected → ScoreKeeper.addScore` (the
1282
- * value is the first arg) or `collected → Collector.collect`.
1283
- */
1284
- var Pickup = class extends Behavior {
1285
- static props = {
1286
- /** Worth of this pickup (passed as `collected`'s first arg). */
1287
- value: { default: 1 },
1288
- /** Free-form label for the kind of pickup ('coin', 'gem', 'key', …). */
1289
- kind: { default: "coin" },
1290
- /** Only nodes in this group collect it. */
1291
- collectorGroup: { default: "player" }
1292
- };
1293
- static signals = ["collected"];
1294
- value = 1;
1295
- kind = "coin";
1296
- collectorGroup = "player";
1297
- collectedAlready = false;
1298
- onReady() {
1299
- onTriggerEnter(this, (other) => this.tryCollect(other));
1300
- }
1301
- tryCollect(other) {
1302
- if (this.collectedAlready) return;
1303
- if (!other.isInGroup(this.collectorGroup)) return;
1304
- this.collectedAlready = true;
1305
- this.emit("collected", this.value, other);
1306
- this.node.queueFree();
1307
- }
1308
- };
1309
- //#endregion
1310
- //#region src/gameplay/projectile.ts
1311
- /**
1312
- * Constant-velocity motion in a straight line — bullets, arrows, thrown rocks.
1313
- * Pure movement by design: pair it with `DamageOnContact` (deal damage on hit),
1314
- * `Lifetime` (auto-despawn), and an Area collider on the same node. Compose,
1315
- * don't conflate.
1316
- *
1317
- * `direction` is either an explicit vector (`[x,y]` / `[x,y,z]`, normalized) or
1318
- * the string `'forward'`, derived from the node's `rotation` (2D: degrees
1319
- * clockwise, +x at 0). It is a union-typed prop, so its schema default is
1320
- * `null` (the engine's "any JSON" escape hatch) and `null` means `'forward'`.
1321
- * `gravity` adds a constant downward (+y, the 2D y-down convention) pull, for
1322
- * lobbed/arcing shots.
1323
- */
1324
- var Projectile = class extends Behavior {
1325
- static props = {
1326
- /** Units per second along `direction`. */
1327
- speed: { default: 300 },
1328
- /** A vector ([x,y(,z)]) OR the string 'forward' (null = 'forward', from rotation). */
1329
- direction: { default: null },
1330
- /** Constant +y (downward, 2D y-down) acceleration; 0 = straight line. */
1331
- gravity: { default: 0 }
1332
- };
1333
- static signals = [];
1334
- speed = 300;
1335
- direction = null;
1336
- gravity = 0;
1337
- velocity = [];
1338
- onReady() {
1339
- if (this.speed === 0 && this.gravity === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': a projectile with "speed" 0 and "gravity" 0 never moves — set one of them.`, { prop: "speed" });
1340
- const node = requirePosition(this);
1341
- const dir = this.resolveDirection();
1342
- this.velocity = dir.map((c) => c * this.speed);
1343
- while (this.velocity.length < node.position.length) this.velocity.push(0);
1344
- }
1345
- update(dt) {
1346
- const node = this.node;
1347
- if (this.gravity !== 0) this.velocity[1] = (this.velocity[1] ?? 0) + this.gravity * dt;
1348
- const pos = node.position;
1349
- const n = Math.max(pos.length, this.velocity.length);
1350
- const next = new Array(n);
1351
- for (let i = 0; i < n; i++) next[i] = (pos[i] ?? 0) + (this.velocity[i] ?? 0) * dt;
1352
- node.position = next;
1353
- }
1354
- /** Resolve `direction` to a unit vector, deriving 'forward' from rotation. */
1355
- resolveDirection() {
1356
- if (Array.isArray(this.direction)) return normalize(this.direction);
1357
- if (this.direction === null || this.direction === "forward") return this.forwardFromRotation();
1358
- throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': "direction" must be a vector or 'forward', got ${JSON.stringify(this.direction)}.`, { prop: "direction" });
1359
- }
1360
- forwardFromRotation() {
1361
- const rot = this.node.rotation;
1362
- if (typeof rot === "number") {
1363
- const rad = rot * Math.PI / 180;
1364
- return [Math.cos(rad), Math.sin(rad)];
1365
- }
1366
- if (Array.isArray(rot)) {
1367
- const yaw = (rot[1] ?? 0) * Math.PI / 180;
1368
- const pitch = (rot[0] ?? 0) * Math.PI / 180;
1369
- return normalize([
1370
- -Math.sin(yaw) * Math.cos(pitch),
1371
- Math.sin(pitch),
1372
- -Math.cos(yaw) * Math.cos(pitch)
1373
- ]);
1374
- }
1375
- return [1, 0];
1376
- }
1377
- };
1378
- //#endregion
1379
- //#region src/gameplay/score-keeper.ts
1380
- /**
1381
- * The game's state hub — score, lives, and win/lose detection. Put it on the
1382
- * Root/Game node and wire gameplay signals into its methods (e.g. a Pickup's
1383
- * `collected → addScore`, a Health's `died → loseLife`).
1384
- *
1385
- * - `score` rises via `addScore` / `setScore`; reaching `scoreToWin` (>0)
1386
- * emits `won` once.
1387
- * - `lives` (>0) shrinks via `loseLife`; reaching 0 emits `lost` once.
1388
- */
1389
- var ScoreKeeper = class extends Behavior {
1390
- static props = {
1391
- /** Starting score. */
1392
- score: { default: 0 },
1393
- /** Starting lives (0 = lives disabled, loseLife is inert). */
1394
- lives: { default: 0 },
1395
- /** Score that triggers `won` (0 = disabled). */
1396
- scoreToWin: { default: 0 }
1397
- };
1398
- static signals = [
1399
- "scoreChanged",
1400
- "won",
1401
- "lost",
1402
- "lifeLost"
1403
- ];
1404
- score = 0;
1405
- lives = 0;
1406
- scoreToWin = 0;
1407
- hasWon = false;
1408
- hasLost = false;
1409
- onReady() {
1410
- if (this.lives < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `ScoreKeeper on '${this.node.getPath()}': "lives" must be >= 0, got ${this.lives}.`, { prop: "lives" });
1411
- if (this.scoreToWin < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `ScoreKeeper on '${this.node.getPath()}': "scoreToWin" must be >= 0, got ${this.scoreToWin}.`, { prop: "scoreToWin" });
1412
- }
1413
- /** Add `n` to the score (negative subtracts), emit `scoreChanged`, check win. */
1414
- addScore(n) {
1415
- this.setScore(this.score + n);
1416
- }
1417
- /** Set the score to `value`, emit `scoreChanged`, check win. */
1418
- setScore(value) {
1419
- this.score = value;
1420
- this.emit("scoreChanged", this.score);
1421
- if (this.scoreToWin > 0 && !this.hasWon && this.score >= this.scoreToWin) {
1422
- this.hasWon = true;
1423
- this.emit("won");
1424
- }
1425
- }
1426
- /** Lose one life (no-op when lives disabled or already lost). Emits `lifeLost`, then `lost` at 0. */
1427
- loseLife() {
1428
- if (this.lives <= 0 || this.hasLost) return;
1429
- this.lives -= 1;
1430
- this.emit("lifeLost", this.lives);
1431
- if (this.lives <= 0) {
1432
- this.hasLost = true;
1433
- this.emit("lost");
1434
- }
1435
- }
1436
- };
1437
- //#endregion
1438
- //#region src/gameplay/spawn-source.ts
1439
- /**
1440
- * Shared spawn mechanism for Spawner/WaveSpawner.
1441
- *
1442
- * A `prefab` is a node PATH to a TEMPLATE node in the scene (usually a hidden
1443
- * `visible: false` child of the spawner). We `duplicateNode` it (the same
1444
- * serialize→rebuild clone used by the editor — scripts and nested children come
1445
- * along, the clone gets a fresh identity) and add it as a sibling-style child of
1446
- * the spawner's node. Keeping spawning to clone-a-template makes it CORE-PURE
1447
- * (no NetworkManager / scene registry, no three) and headlessly verifiable.
1448
- */
1449
- var SpawnSource = class {
1450
- behavior;
1451
- /** Templates already resolved-and-detached, keyed by their prefab path. */
1452
- detached = /* @__PURE__ */ new Map();
1453
- constructor(behavior) {
1454
- this.behavior = behavior;
1455
- }
1456
- /**
1457
- * Resolve a template path to its node, hard-failing if it's missing, and
1458
- * DETACH it from the live tree so the template never updates or renders.
1459
- *
1460
- * A `visible:false` template is still a live node: its behaviors tick (e.g. a
1461
- * Chase walks the invisible template onto the player) and a renderer may set
1462
- * it up. Removing it from the tree on first resolution makes it inert — the
1463
- * detached node is held here purely as a clone source. Clones go INTO the
1464
- * tree; the template stays out. Idempotent and cached, so WaveSpawner's
1465
- * per-spawn re-resolution returns the same held template.
1466
- */
1467
- resolveTemplate(prefab) {
1468
- const cached = this.detached.get(prefab);
1469
- if (cached) return cached;
1470
- const node = this.behavior.node;
1471
- const template = prefab === "" ? null : node.getNodeOrNull(prefab);
1472
- if (!template) {
1473
- const behaviorName = this.behavior.constructor.name;
1474
- throw new IncantoError("NODE_NOT_FOUND", `${behaviorName} on '${node.getPath()}': "prefab" '${prefab}' does not resolve to a template node. Point it at a (usually hidden) child node to clone.`, {
1475
- prop: "prefab",
1476
- path: node.getPath()
1477
- });
1478
- }
1479
- if (template.parent) template.parent.removeChild(template);
1480
- this.detached.set(prefab, template);
1481
- return template;
1482
- }
1483
- /**
1484
- * Clone `template`, make it visible, attach it under `parent` (defaults to the
1485
- * spawner's node), and return the clone.
1486
- *
1487
- * Placement: with no `at`, the clone keeps the template's authored position;
1488
- * with `at`, it spawns at the spawner's position plus `at` (a non-spatial
1489
- * spawner like a plain Node treats `at` as absolute).
1490
- */
1491
- spawn(template, opts) {
1492
- const clone = duplicateNode(template);
1493
- const at = opts?.at;
1494
- if (at && at.length > 0 && hasPosition$1(clone)) clone.position = add(hasPosition$1(this.behavior.node) ? this.behavior.node.position : [], at);
1495
- if (typeof clone.visible === "boolean") clone.visible = true;
1496
- (opts?.parent ?? this.behavior.node).addChild(clone);
1497
- return clone;
1498
- }
1499
- };
1500
- //#endregion
1501
- //#region src/gameplay/spawner.ts
1502
- /**
1503
- * Drip-feed clones of a template into the scene on a timer — enemy generators,
1504
- * pickup fountains, particle emitters. `prefab` is a node PATH to a template
1505
- * (usually a hidden `visible: false` child) that gets cloned each `interval`.
1506
- *
1507
- * - `max` caps LIVE instances (0 = unlimited); the count drops automatically as
1508
- * spawned children free themselves (e.g. via `Lifetime` or `Health.died`), so
1509
- * the spawner refills.
1510
- * - `total` caps LIFETIME spawns (0 = infinite); on the last one it emits
1511
- * `finished` and stops.
1512
- * - `autoStart` (default) begins ticking at ready; otherwise call `start()`.
1513
- *
1514
- * Emits `spawned(node)` per spawn. Methods: `spawn()`, `start()`, `stop()`.
1515
- */
1516
- var Spawner = class extends Behavior {
1517
- static props = {
1518
- /** Node path of the template to clone. */
1519
- prefab: { default: "" },
1520
- /** Seconds between spawns. */
1521
- interval: { default: 1 },
1522
- /** Max LIVE instances (0 = unlimited). */
1523
- max: { default: 0 },
1524
- /** Offset/spawn point ([x,y(,z)]) added to the spawner's position. */
1525
- at: { default: [] },
1526
- /** Begin ticking at ready. */
1527
- autoStart: { default: true },
1528
- /** Total LIFETIME spawns (0 = infinite). */
1529
- total: { default: 0 }
1530
- };
1531
- static signals = ["spawned", "finished"];
1532
- prefab = "";
1533
- interval = 1;
1534
- max = 0;
1535
- at = [];
1536
- autoStart = true;
1537
- total = 0;
1538
- source = new SpawnSource(this);
1539
- template;
1540
- live = /* @__PURE__ */ new Set();
1541
- timer = 0;
1542
- running = false;
1543
- spawnedCount = 0;
1544
- done = false;
1545
- /** Live (un-freed) spawned instances. */
1546
- get liveCount() {
1547
- this.prune();
1548
- return this.live.size;
1549
- }
1550
- /** @internal The detached template node (test/debug only). */
1551
- _templateForTest() {
1552
- return this.template;
1553
- }
1554
- onReady() {
1555
- this.template = this.source.resolveTemplate(this.prefab);
1556
- this.running = this.autoStart;
1557
- this.timer = this.interval;
1558
- }
1559
- /** Begin (or resume) interval spawning. */
1560
- start() {
1561
- if (this.done) return;
1562
- this.running = true;
1563
- }
1564
- /** Pause interval spawning (spawn() still works). */
1565
- stop() {
1566
- this.running = false;
1567
- }
1568
- /** Spawn one immediately (ignores the timer; still respects max/total). */
1569
- spawn() {
1570
- this.prune();
1571
- if (this.done) return null;
1572
- if (this.max > 0 && this.live.size >= this.max) return null;
1573
- const clone = this.source.spawn(this.template, this.at.length > 0 ? { at: this.at } : void 0);
1574
- this.live.add(clone);
1575
- this.spawnedCount += 1;
1576
- this.emit("spawned", clone);
1577
- if (this.total > 0 && this.spawnedCount >= this.total) {
1578
- this.done = true;
1579
- this.running = false;
1580
- this.emit("finished");
1581
- }
1582
- return clone;
1583
- }
1584
- update(dt) {
1585
- if (!this.running) return;
1586
- this.timer -= dt;
1587
- if (this.timer > 0) return;
1588
- this.timer += this.interval;
1589
- this.spawn();
1590
- }
1591
- /** Drop instances that have been freed (parent cleared on free/queueFree). */
1592
- prune() {
1593
- for (const node of this.live) if (node.parent === null) this.live.delete(node);
1594
- }
1595
- };
1596
- //#endregion
1597
- //#region src/gameplay/wander.ts
1598
- /**
1599
- * Aimless roaming inside a circle around the spawn point — idle critters,
1600
- * ambient wildlife, restless guards. Picks a random destination within `radius`
1601
- * of where it started, walks there at `speed`, then (every `changeEvery`
1602
- * seconds, or on arrival) picks a new one. Uses `this.rng`, so a seeded engine
1603
- * wanders identically every run (replayable, test-stable).
1604
- *
1605
- * Dimension-agnostic: roams in the plane of however many position components
1606
- * the node has (2D `[x,y]`, 3D `[x,z]` ground plane keeping y).
1607
- */
1608
- var Wander = class extends Behavior {
1609
- static props = {
1610
- /** Units per second while roaming. */
1611
- speed: { default: 40 },
1612
- /** Roam radius around the spawn point. */
1613
- radius: { default: 50 },
1614
- /** Seconds before forcibly choosing a new destination. */
1615
- changeEvery: { default: 2 }
1616
- };
1617
- static signals = [];
1618
- speed = 40;
1619
- radius = 50;
1620
- changeEvery = 2;
1621
- origin = [];
1622
- goal = [];
1623
- timer = 0;
1624
- onReady() {
1625
- if (!(this.radius > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Wander on '${this.node.getPath()}': "radius" must be > 0, got ${this.radius}.`, { prop: "radius" });
1626
- const node = requirePosition(this);
1627
- this.origin = [...node.position];
1628
- this.goal = [...node.position];
1629
- this.pickGoal();
1630
- }
1631
- update(dt) {
1632
- const node = this.node;
1633
- this.timer -= dt;
1634
- const { position, reached } = moveToward(node.position, this.goal, this.speed * dt);
1635
- node.position = position;
1636
- if (reached || this.timer <= 0) this.pickGoal();
1637
- }
1638
- pickGoal() {
1639
- this.timer = this.changeEvery;
1640
- const dims = this.origin.length;
1641
- const next = new Array(dims);
1642
- for (let attempt = 0; attempt < 8; attempt++) {
1643
- for (let i = 0; i < dims; i++) next[i] = (this.origin[i] ?? 0) + this.rng.range(-this.radius, this.radius);
1644
- if (distance$1(next, this.origin) <= this.radius) {
1645
- this.goal = next;
1646
- return;
1647
- }
1648
- }
1649
- this.goal = next.map((c, i) => {
1650
- const o = this.origin[i] ?? 0;
1651
- const d = distance$1(next, this.origin) || 1;
1652
- return o + (c - o) / d * this.radius;
1653
- });
1654
- }
1655
- };
1656
- //#endregion
1657
- //#region src/gameplay/wave-spawner.ts
1658
- /**
1659
- * Sequenced enemy waves — the survivor/tower-defense backbone. Each entry in
1660
- * `waves` is `{ prefab, count, interval, delayBefore }`: after `delayBefore`
1661
- * seconds it spawns `count` clones of `prefab` (a hidden template path) one
1662
- * every `interval` seconds, then waits until they're ALL cleared (freed) before
1663
- * starting the next wave.
1664
- *
1665
- * Signals: `waveStarted(i)` when a wave begins spawning, `waveCleared(i)` when
1666
- * its last instance frees, `allCleared` after the final wave clears.
1667
- */
1668
- var WaveSpawner = class extends Behavior {
1669
- static props = {
1670
- /** Array of { prefab, count, interval, delayBefore }. */
1671
- waves: { default: [] },
1672
- /** Begin the first wave's countdown at ready. */
1673
- autoStart: { default: true }
1674
- };
1675
- static signals = [
1676
- "waveStarted",
1677
- "waveCleared",
1678
- "allCleared"
1679
- ];
1680
- waves = [];
1681
- autoStart = true;
1682
- source = new SpawnSource(this);
1683
- parsed = [];
1684
- live = /* @__PURE__ */ new Set();
1685
- waveIndex = -1;
1686
- phase = "idle";
1687
- timer = 0;
1688
- spawnedThisWave = 0;
1689
- announced = false;
1690
- onReady() {
1691
- if (!Array.isArray(this.waves) || this.waves.length === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `WaveSpawner on '${this.node.getPath()}': "waves" must be a non-empty array of { prefab, count, interval, delayBefore }.`, { prop: "waves" });
1692
- this.parsed = this.waves.map((raw, i) => this.parseWave(raw, i));
1693
- for (const w of this.parsed) this.source.resolveTemplate(w.prefab);
1694
- if (this.autoStart) this.beginWave(0);
1695
- }
1696
- /** Start (or restart from) wave 0. */
1697
- start() {
1698
- this.beginWave(0);
1699
- }
1700
- update(dt) {
1701
- if (this.phase === "idle" || this.phase === "done") return;
1702
- const wave = this.parsed[this.waveIndex];
1703
- if (!wave) return;
1704
- if (!this.announced) {
1705
- this.announced = true;
1706
- this.emit("waveStarted", this.waveIndex);
1707
- }
1708
- if (this.phase === "delay") {
1709
- this.timer -= dt;
1710
- if (this.timer <= 0) {
1711
- this.phase = "spawning";
1712
- this.timer = 0;
1713
- }
1714
- return;
1715
- }
1716
- if (this.phase === "spawning") {
1717
- this.timer -= dt;
1718
- while (this.timer <= 0 && this.spawnedThisWave < wave.count) {
1719
- const clone = this.source.spawn(this.source.resolveTemplate(wave.prefab));
1720
- this.live.add(clone);
1721
- this.spawnedThisWave += 1;
1722
- this.timer += wave.interval;
1723
- }
1724
- if (this.spawnedThisWave >= wave.count) this.phase = "clearing";
1725
- return;
1726
- }
1727
- if (this.phase === "clearing") {
1728
- this.prune();
1729
- if (this.live.size === 0) {
1730
- this.emit("waveCleared", this.waveIndex);
1731
- const next = this.waveIndex + 1;
1732
- if (next < this.parsed.length) this.beginWave(next);
1733
- else {
1734
- this.phase = "done";
1735
- this.emit("allCleared");
1736
- }
1737
- }
1738
- }
1739
- }
1740
- beginWave(i) {
1741
- this.waveIndex = i;
1742
- this.spawnedThisWave = 0;
1743
- this.announced = false;
1744
- this.live.clear();
1745
- const wave = this.parsed[i];
1746
- if (!wave) return;
1747
- if (wave.delayBefore > 0) {
1748
- this.phase = "delay";
1749
- this.timer = wave.delayBefore;
1750
- } else {
1751
- this.phase = "spawning";
1752
- this.timer = 0;
1753
- }
1754
- }
1755
- prune() {
1756
- for (const node of this.live) if (node.parent === null) this.live.delete(node);
1757
- }
1758
- parseWave(raw, i) {
1759
- const w = raw;
1760
- const prefab = typeof w?.prefab === "string" ? w.prefab : "";
1761
- if (prefab === "") throw new IncantoError("PROP_TYPE_MISMATCH", `WaveSpawner on '${this.node.getPath()}': wave ${i} needs a "prefab" template path.`, { prop: "waves" });
1762
- return {
1763
- prefab,
1764
- count: typeof w?.count === "number" ? w.count : 1,
1765
- interval: typeof w?.interval === "number" ? w.interval : .5,
1766
- delayBefore: typeof w?.delayBefore === "number" ? w.delayBefore : 0
1767
- };
1768
- }
1769
- };
1770
- //#endregion
1771
- //#region src/gameplay/zombie-ai.ts
1772
- /**
1773
- * The staple "zombie / monster" AI: SHAMBLE around on its own, then LOCK ON and
1774
- * CHARGE a target (the player) once it wanders within `aggroRange` — giving up
1775
- * again past `deAggroRange` (hysteresis, so it doesn't flicker at the boundary).
1776
- * One behavior covers both phases so a single node can be a complete enemy
1777
- * (`Wander` + `Chase` can't co-exist — a node carries ONE behavior).
1778
- *
1779
- * While NOT aggroed it roams: with `goalTarget` set it DRIFTS toward that node
1780
- * (e.g. the objective the horde is marching on) with random lateral jitter, so it
1781
- * reads as wandering yet still advances; without one it roams around its spawn.
1782
- *
1783
- * Emits `movementStateChanged('idle'|'walk'|'run')` on every change so a skin can
1784
- * swap animation clips (shamble while roaming, sprint while charging), plus
1785
- * `enteredAggro` / `exitedAggro` (wire to a growl, a glow, a speed-up).
1786
- *
1787
- * Dimension-agnostic; in 3D it moves only in the ground plane (x,z) and leaves
1788
- * the up axis (y) to physics, so a CharacterBody3D settles on terrain.
1789
- *
1790
- * `moveParent` (default false) moves the parent instead of this node — the
1791
- * AI-on-a-child pattern, so the entity ROOT can hold `Health` (clone-safe
1792
- * `freeOnDeath`) while this child drives movement.
1793
- */
1794
- var ZombieAI = class extends Behavior {
1795
- static props = {
1796
- /** NodePath of the node to charge once near (usually the player). */
1797
- aggroTarget: { default: "" },
1798
- /** Start charging when the target is within this distance. */
1799
- aggroRange: { default: 12 },
1800
- /** Give up the charge past this distance (0 = aggroRange × 1.5). */
1801
- deAggroRange: { default: 0 },
1802
- /** Units/second while charging the target. */
1803
- chaseSpeed: { default: 4 },
1804
- /** Units/second while roaming. */
1805
- wanderSpeed: { default: 1.4 },
1806
- /** Roam jitter radius (around spawn, or around the drift point). */
1807
- wanderRadius: { default: 8 },
1808
- /** Seconds before forcibly choosing a new roam goal. */
1809
- wanderChangeEvery: { default: 3 },
1810
- /** Optional NodePath to DRIFT toward while roaming (e.g. the objective). */
1811
- goalTarget: { default: "" },
1812
- /** Stop (and idle) this close to the active target. */
1813
- stopRange: { default: 1.2 },
1814
- /** Move the parent node instead of this one (AI-on-a-child pattern). */
1815
- moveParent: { default: false }
1816
- };
1817
- static signals = [
1818
- "enteredAggro",
1819
- "exitedAggro",
1820
- "movementStateChanged"
1821
- ];
1822
- aggroTarget = "";
1823
- aggroRange = 12;
1824
- deAggroRange = 0;
1825
- chaseSpeed = 4;
1826
- wanderSpeed = 1.4;
1827
- wanderRadius = 8;
1828
- wanderChangeEvery = 3;
1829
- goalTarget = "";
1830
- stopRange = 1.2;
1831
- moveParent = false;
1832
- aggro = false;
1833
- started = false;
1834
- home = [];
1835
- goal = [];
1836
- timer = 0;
1837
- lastState = "";
1838
- /** The node we actually move (the parent under moveParent, else this one). */
1839
- mover() {
1840
- const n = this.moveParent ? this.node.parent : this.node;
1841
- return n && hasPosition$1(n) ? n : null;
1842
- }
1843
- effectiveDeAggro() {
1844
- return this.deAggroRange > 0 ? this.deAggroRange : this.aggroRange * 1.5;
1845
- }
1846
- onReady() {
1847
- if (this.moveParent) {
1848
- const parent = this.node.parent;
1849
- if (!parent || !hasPosition$1(parent)) throw new IncantoError("PROP_TYPE_MISMATCH", `ZombieAI on '${this.node.getPath()}': "moveParent" needs a spatial parent (a Node2D/Node3D) to move. '${this.node.name}' has none.`, {
1850
- prop: "moveParent",
1851
- path: this.node.getPath()
1852
- });
1853
- } else requirePosition(this);
1854
- }
1855
- update(dt) {
1856
- const mover = this.mover();
1857
- if (!mover) return;
1858
- if (!this.started) {
1859
- this.started = true;
1860
- this.home = [...mover.position];
1861
- this.pickGoal();
1862
- }
1863
- const target = this.aggroTarget ? this.node.getNodeOrNull(this.aggroTarget) : null;
1864
- const targetPos = target && hasPosition$1(target) ? target.position : null;
1865
- const aggroDist = targetPos ? this.groundDistance(mover.position, targetPos) : Number.POSITIVE_INFINITY;
1866
- if (!this.aggro && aggroDist <= this.aggroRange) {
1867
- this.aggro = true;
1868
- this.emit("enteredAggro");
1869
- } else if (this.aggro && aggroDist >= this.effectiveDeAggro()) {
1870
- this.aggro = false;
1871
- this.emit("exitedAggro");
1872
- this.pickGoal();
1873
- }
1874
- if (this.aggro && targetPos) {
1875
- const stopped = this.approach(mover, targetPos, this.chaseSpeed, dt);
1876
- this.setState(stopped ? "idle" : "run");
1877
- return;
1878
- }
1879
- this.timer -= dt;
1880
- const reached = this.approach(mover, this.goal, this.wanderSpeed, dt);
1881
- this.setState(reached ? "idle" : "walk");
1882
- if (reached || this.timer <= 0) this.pickGoal();
1883
- }
1884
- /** Move `mover` toward `to` in the GROUND PLANE; returns true once within
1885
- * stopRange (no overshoot). Leaves the up axis (3D y) to physics. */
1886
- approach(mover, to, speed, dt) {
1887
- const cur = mover.position;
1888
- if (this.groundDistance(cur, to) <= this.stopRange) return true;
1889
- const up = cur.length >= 3 ? 1 : -1;
1890
- const goal = [...to];
1891
- if (up >= 0) goal[up] = cur[up] ?? 0;
1892
- const horiz = this.groundDistance(cur, goal);
1893
- const { position } = moveToward(cur, goal, Math.min(speed * dt, Math.max(0, horiz - this.stopRange)));
1894
- mover.position = position;
1895
- return false;
1896
- }
1897
- /** Distance ignoring the up axis in 3D (so terrain height never blocks aggro). */
1898
- groundDistance(a, b) {
1899
- if (a.length >= 3) {
1900
- const dx = (a[0] ?? 0) - (b[0] ?? 0);
1901
- const dz = (a[2] ?? 0) - (b[2] ?? 0);
1902
- return Math.hypot(dx, dz);
1903
- }
1904
- return distance$1(a, b);
1905
- }
1906
- setState(state) {
1907
- if (state === this.lastState) return;
1908
- this.lastState = state;
1909
- this.emit("movementStateChanged", state);
1910
- }
1911
- pickGoal() {
1912
- this.timer = this.wanderChangeEvery;
1913
- const mover = this.mover();
1914
- const cur = mover ? mover.position : this.home;
1915
- const up = cur.length >= 3 ? 1 : -1;
1916
- let basis = this.home;
1917
- const goalNode = this.goalTarget ? this.node.getNodeOrNull(this.goalTarget) : null;
1918
- if (goalNode && hasPosition$1(goalNode)) {
1919
- const gp = goalNode.position;
1920
- basis = cur.map((c, i) => c + ((gp[i] ?? 0) - c) * .4);
1921
- }
1922
- const next = [...basis];
1923
- for (let i = 0; i < next.length; i++) {
1924
- if (i === up) continue;
1925
- next[i] = (basis[i] ?? 0) + this.rng.range(-this.wanderRadius, this.wanderRadius);
1926
- }
1927
- if (up >= 0) next[up] = cur[up] ?? 0;
1928
- this.goal = next;
1929
- }
1930
- };
1931
- //#endregion
1932
- //#region src/gameplay/index.ts
1933
- /**
1934
- * incanto/gameplay — batteries-included, JSON-wireable game logic.
1935
- *
1936
- * Ready-made `Behavior`s for the things every game re-invents: health, score,
1937
- * pickups, lifetimes, contact damage, interaction. All pure logic (no `three`),
1938
- * dimension-agnostic, composed through scene-JSON `script` blobs + `connections`.
1939
- *
1940
- * `createGame2D`/`createGame3D` auto-register these (before user behaviors, so
1941
- * a same-named user behavior always wins). Opt out with `gameplay: false`, or
1942
- * register a subset yourself via `registerBehavior(name, Class)`.
1943
- */
1944
- /** Every built-in gameplay behavior, keyed by its registration name. */
1945
- const GAMEPLAY_BEHAVIORS = {
1946
- CameraShake,
1947
- DayNight,
1948
- GameFlow,
1949
- PathFollow,
1950
- Health,
1951
- Lifetime,
1952
- ScoreKeeper,
1953
- Pickup,
1954
- Collector,
1955
- DamageOnContact,
1956
- Interactable,
1957
- FollowCamera,
1958
- Patrol,
1959
- Chase,
1960
- Wander,
1961
- ZombieAI,
1962
- MoveTo,
1963
- Oscillate,
1964
- Spawner,
1965
- WaveSpawner,
1966
- Projectile
1967
- };
1968
- /**
1969
- * Register all built-in gameplay behaviors. Idempotent and hot-reload tolerant
1970
- * (`replace: true`) — calling it twice, or after a user already registered a
1971
- * same-named behavior, is safe; pass `replace: false` to fail on conflicts.
1972
- */
1973
- function registerGameplayBehaviors(opts) {
1974
- const replace = opts?.replace ?? true;
1975
- for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
1976
- }
1977
- //#endregion
1978
- export { FollowCamera as C, Health as D, DamageOnContact as E, Collector as O, restartScene as S, phaseOf as T, hitStop as _, Wander as a, GameFlow as b, Projectile as c, PathFollow as d, Oscillate as f, Cooldown as g, CameraShake as h, WaveSpawner as i, Chase as k, Pickup as l, Lifetime as m, registerGameplayBehaviors as n, Spawner as o, MoveTo as p, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, Patrol as u, screenFlash as v, DayNight as w, goToScene as x, Interactable as y };