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,1501 @@
|
|
|
1
|
+
import { _ as registerBehavior, m as Behavior } from "./loader-CGs_G-r0.js";
|
|
2
|
+
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
3
|
+
import { t as duplicateNode } from "./duplicate-DP2WPYom.js";
|
|
4
|
+
//#region src/gameplay/spatial.ts
|
|
5
|
+
/** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
|
|
6
|
+
function hasPosition$1(node) {
|
|
7
|
+
return Array.isArray(node.position);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Movement behaviors are dimension-agnostic — they read/write `this.node.position`
|
|
11
|
+
* directly. Attaching one to a non-spatial node (no `position` array) would be a
|
|
12
|
+
* silent no-op, so assert it at ready (engine philosophy: hard load-time errors).
|
|
13
|
+
*/
|
|
14
|
+
function requirePosition(behavior) {
|
|
15
|
+
const node = behavior.node;
|
|
16
|
+
if (!hasPosition$1(node)) {
|
|
17
|
+
const behaviorName = behavior.constructor.name;
|
|
18
|
+
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() });
|
|
19
|
+
}
|
|
20
|
+
return node;
|
|
21
|
+
}
|
|
22
|
+
/** Euclidean distance between two position arrays (works for 2D and 3D). */
|
|
23
|
+
function distance$1(a, b) {
|
|
24
|
+
let sum = 0;
|
|
25
|
+
const n = Math.max(a.length, b.length);
|
|
26
|
+
for (let i = 0; i < n; i++) {
|
|
27
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
28
|
+
sum += d * d;
|
|
29
|
+
}
|
|
30
|
+
return Math.sqrt(sum);
|
|
31
|
+
}
|
|
32
|
+
/** Component-wise `a + b` (dimension = max of the two; missing components are 0). */
|
|
33
|
+
function add(a, b) {
|
|
34
|
+
const n = Math.max(a.length, b.length);
|
|
35
|
+
const out = new Array(n);
|
|
36
|
+
for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Component-wise `a - b`. */
|
|
40
|
+
function sub(a, b) {
|
|
41
|
+
const n = Math.max(a.length, b.length);
|
|
42
|
+
const out = new Array(n);
|
|
43
|
+
for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) - (b[i] ?? 0);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/** Vector length. */
|
|
47
|
+
function length(v) {
|
|
48
|
+
let sum = 0;
|
|
49
|
+
for (const c of v) sum += c * c;
|
|
50
|
+
return Math.sqrt(sum);
|
|
51
|
+
}
|
|
52
|
+
/** Unit vector (zero-length stays zero). */
|
|
53
|
+
function normalize(v) {
|
|
54
|
+
const len = length(v);
|
|
55
|
+
if (len === 0) return v.map(() => 0);
|
|
56
|
+
return v.map((c) => c / len);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Step `from` toward `to` by at most `maxStep`. Returns the new position and
|
|
60
|
+
* whether the destination was reached this step (clamped — no overshoot).
|
|
61
|
+
*/
|
|
62
|
+
function moveToward(from, to, maxStep) {
|
|
63
|
+
const delta = sub(to, from);
|
|
64
|
+
const dist = length(delta);
|
|
65
|
+
if (dist <= maxStep || dist === 0) return {
|
|
66
|
+
position: [...to],
|
|
67
|
+
reached: true
|
|
68
|
+
};
|
|
69
|
+
const f = maxStep / dist;
|
|
70
|
+
return {
|
|
71
|
+
position: from.map((c, i) => c + (delta[i] ?? 0) * f),
|
|
72
|
+
reached: false
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Frame-rate-independent lerp factor for an exponential chase. `smoothing` is a
|
|
77
|
+
* 0..1 retention factor: 0 snaps instantly; higher values chase more slowly.
|
|
78
|
+
* (60fps reference, matching Camera2D's `follow`.)
|
|
79
|
+
*/
|
|
80
|
+
function smoothingFactor(smoothing, dt) {
|
|
81
|
+
const s = Math.min(Math.max(smoothing, 0), .99);
|
|
82
|
+
return s <= 0 ? 1 : 1 - s ** (dt * 60);
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/gameplay/chase.ts
|
|
86
|
+
/**
|
|
87
|
+
* Home in on a target node — the workhorse enemy AI. Each frame moves toward
|
|
88
|
+
* `target.position` at `speed`, stopping once within `stopRange` (melee reach).
|
|
89
|
+
* Dimension-agnostic.
|
|
90
|
+
*
|
|
91
|
+
* - `reachedTarget` fires once when it first enters `stopRange` (re-arms after
|
|
92
|
+
* it leaves), so wire it to an attack.
|
|
93
|
+
* - `loseRange` (>0) gives up the chase when the target gets that far away,
|
|
94
|
+
* emitting `lostTarget` once.
|
|
95
|
+
* - `moveParent` (default false) moves the parent node instead of this one.
|
|
96
|
+
* A node carries ONE behavior, so an enemy whose ROOT must hold `Health`
|
|
97
|
+
* (e.g. for `Health.freeOnDeath` clone-safe cleanup) puts `Chase` on a CHILD
|
|
98
|
+
* and sets `moveParent:true` — the AI child drives the whole entity. Distance
|
|
99
|
+
* is measured from the moved node (the parent).
|
|
100
|
+
*/
|
|
101
|
+
var Chase = class extends Behavior {
|
|
102
|
+
static props = {
|
|
103
|
+
/** NodePath of the node to chase (resolved from THIS behavior's node). */
|
|
104
|
+
target: { default: "" },
|
|
105
|
+
/** Units per second toward the target. */
|
|
106
|
+
speed: { default: 60 },
|
|
107
|
+
/** Stop (and emit reachedTarget) when within this distance. */
|
|
108
|
+
stopRange: { default: 0 },
|
|
109
|
+
/** Give up (emit lostTarget) beyond this distance (0 = never lose). */
|
|
110
|
+
loseRange: { default: 0 },
|
|
111
|
+
/** Move the parent node instead of this one (AI-on-a-child pattern). */
|
|
112
|
+
moveParent: { default: false }
|
|
113
|
+
};
|
|
114
|
+
static signals = ["reachedTarget", "lostTarget"];
|
|
115
|
+
target = "";
|
|
116
|
+
speed = 60;
|
|
117
|
+
stopRange = 0;
|
|
118
|
+
loseRange = 0;
|
|
119
|
+
moveParent = false;
|
|
120
|
+
inRange = false;
|
|
121
|
+
lost = false;
|
|
122
|
+
onReady() {
|
|
123
|
+
if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `Chase on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
|
|
124
|
+
if (this.moveParent) {
|
|
125
|
+
const parent = this.node.parent;
|
|
126
|
+
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.`, {
|
|
127
|
+
prop: "moveParent",
|
|
128
|
+
path: this.node.getPath()
|
|
129
|
+
});
|
|
130
|
+
} else requirePosition(this);
|
|
131
|
+
}
|
|
132
|
+
update(dt) {
|
|
133
|
+
const mover = this.moveParent ? this.node.parent : this.node;
|
|
134
|
+
if (!mover || !hasPosition$1(mover)) return;
|
|
135
|
+
const target = this.node.getNodeOrNull(this.target);
|
|
136
|
+
if (!target || !hasPosition$1(target)) return;
|
|
137
|
+
const d = distance$1(mover.position, target.position);
|
|
138
|
+
if (this.loseRange > 0 && d > this.loseRange) {
|
|
139
|
+
if (!this.lost) {
|
|
140
|
+
this.lost = true;
|
|
141
|
+
this.inRange = false;
|
|
142
|
+
this.emit("lostTarget");
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.lost = false;
|
|
147
|
+
if (this.stopRange > 0 && d <= this.stopRange) {
|
|
148
|
+
if (!this.inRange) {
|
|
149
|
+
this.inRange = true;
|
|
150
|
+
this.emit("reachedTarget");
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.inRange = false;
|
|
155
|
+
const step = Math.min(this.speed * dt, this.stopRange > 0 ? d - this.stopRange : d);
|
|
156
|
+
const { position } = moveToward(mover.position, target.position, Math.max(0, step));
|
|
157
|
+
mover.position = position;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/gameplay/collector.ts
|
|
162
|
+
/**
|
|
163
|
+
* A tally for the "collect N things" pattern, living on the collector itself
|
|
164
|
+
* (usually the player). Adds its node to `group` at ready so Pickups whose
|
|
165
|
+
* `collectorGroup` matches will count it as a collector.
|
|
166
|
+
*
|
|
167
|
+
* Wire a Pickup's `collected(value, other) → Collector.collect` (the value is
|
|
168
|
+
* the first arg) — `total` accumulates and `totalChanged(total)` fires.
|
|
169
|
+
* (Pickup also notifies a global ScoreKeeper; Collector is the per-actor tally,
|
|
170
|
+
* e.g. for split-screen or "each player's coins".)
|
|
171
|
+
*/
|
|
172
|
+
var Collector = class extends Behavior {
|
|
173
|
+
static props = {
|
|
174
|
+
/** Group this collector joins (Pickups match this against `collectorGroup`). */
|
|
175
|
+
group: { default: "player" } };
|
|
176
|
+
static signals = ["totalChanged"];
|
|
177
|
+
group = "player";
|
|
178
|
+
/** Running sum of collected values. */
|
|
179
|
+
total = 0;
|
|
180
|
+
onReady() {
|
|
181
|
+
this.node.addToGroup(this.group);
|
|
182
|
+
}
|
|
183
|
+
/** Add `value` to the tally and emit `totalChanged`. */
|
|
184
|
+
collect(value) {
|
|
185
|
+
this.total += value;
|
|
186
|
+
this.emit("totalChanged", this.total);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/gameplay/health.ts
|
|
191
|
+
/**
|
|
192
|
+
* Hit points with regeneration and post-hit invulnerability (i-frames) —
|
|
193
|
+
* the universal "this thing can be hurt and can die" behavior.
|
|
194
|
+
*
|
|
195
|
+
* Other behaviors hurt it through `damage(n)` (e.g. `DamageOnContact` finds a
|
|
196
|
+
* target's Health and calls it); games heal/kill via `heal(n)` / `kill()`.
|
|
197
|
+
* Wire its `died` signal to a `ScoreKeeper.loseLife`, a respawn, etc.
|
|
198
|
+
*
|
|
199
|
+
* `freeOnDeath` (default false) `queueFree()`s the Health node itself when it
|
|
200
|
+
* dies. Because it is NODE-LOCAL it clones perfectly — this is THE way to free
|
|
201
|
+
* dying SPAWNED entities. Scene-level `connections` (e.g. `died → queueFree`)
|
|
202
|
+
* are NOT copied onto Spawner/WaveSpawner clones, so a `died` connection only
|
|
203
|
+
* ever wires the template, never the live clones; `freeOnDeath` needs no
|
|
204
|
+
* connection and so works on every clone.
|
|
205
|
+
*
|
|
206
|
+
* Dimension-agnostic: no node geometry, just numbers — testable headlessly.
|
|
207
|
+
*/
|
|
208
|
+
var Health = class extends Behavior {
|
|
209
|
+
static props = {
|
|
210
|
+
/** Maximum hit points; `current` starts here. */
|
|
211
|
+
max: { default: 100 },
|
|
212
|
+
/** Hit points regained per second (0 = no regen). */
|
|
213
|
+
regenPerSec: { default: 0 },
|
|
214
|
+
/** Seconds of immunity after taking a hit (0 = no i-frames). */
|
|
215
|
+
invulnerableFor: { default: 0 },
|
|
216
|
+
/** queueFree() this node when it dies (clone-safe spawned-entity cleanup). */
|
|
217
|
+
freeOnDeath: { default: false }
|
|
218
|
+
};
|
|
219
|
+
static signals = [
|
|
220
|
+
"damaged",
|
|
221
|
+
"healed",
|
|
222
|
+
"died"
|
|
223
|
+
];
|
|
224
|
+
max = 100;
|
|
225
|
+
regenPerSec = 0;
|
|
226
|
+
invulnerableFor = 0;
|
|
227
|
+
freeOnDeath = false;
|
|
228
|
+
/** Current hit points (set to `max` on ready). */
|
|
229
|
+
current = 0;
|
|
230
|
+
invulnTimer = 0;
|
|
231
|
+
dead = false;
|
|
232
|
+
/** True once `current` has hit 0 — `died` fires at most once per life. */
|
|
233
|
+
get isDead() {
|
|
234
|
+
return this.dead;
|
|
235
|
+
}
|
|
236
|
+
/** Seconds of i-frames remaining (0 = vulnerable). */
|
|
237
|
+
get invulnerableRemaining() {
|
|
238
|
+
return this.invulnTimer;
|
|
239
|
+
}
|
|
240
|
+
onReady() {
|
|
241
|
+
if (!(this.max > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Health on '${this.node.getPath()}': "max" must be > 0, got ${this.max}.`, { prop: "max" });
|
|
242
|
+
if (this.regenPerSec < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Health on '${this.node.getPath()}': "regenPerSec" must be >= 0, got ${this.regenPerSec}.`, { prop: "regenPerSec" });
|
|
243
|
+
if (this.invulnerableFor < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Health on '${this.node.getPath()}': "invulnerableFor" must be >= 0, got ${this.invulnerableFor}.`, { prop: "invulnerableFor" });
|
|
244
|
+
this.current = this.max;
|
|
245
|
+
}
|
|
246
|
+
update(dt) {
|
|
247
|
+
if (this.invulnTimer > 0) this.invulnTimer = Math.max(0, this.invulnTimer - dt);
|
|
248
|
+
if (this.regenPerSec > 0 && !this.dead && this.current < this.max) this.setCurrent(this.current + this.regenPerSec * dt);
|
|
249
|
+
}
|
|
250
|
+
/** Apply `n` damage. No-op while invulnerable or already dead. Emits `damaged`. */
|
|
251
|
+
damage(n) {
|
|
252
|
+
if (this.dead || n <= 0) return;
|
|
253
|
+
if (this.invulnTimer > 0) return;
|
|
254
|
+
this.setCurrent(this.current - n);
|
|
255
|
+
this.emit("damaged", n, this.current);
|
|
256
|
+
if (this.invulnerableFor > 0) this.invulnTimer = this.invulnerableFor;
|
|
257
|
+
if (this.current <= 0) this.die();
|
|
258
|
+
}
|
|
259
|
+
/** Restore `n` hit points (clamped to `max`). No-op when dead. Emits `healed`. */
|
|
260
|
+
heal(n) {
|
|
261
|
+
if (this.dead || n <= 0) return;
|
|
262
|
+
this.setCurrent(this.current + n);
|
|
263
|
+
this.emit("healed", n, this.current);
|
|
264
|
+
}
|
|
265
|
+
/** Drop to 0 and die immediately (ignores i-frames). */
|
|
266
|
+
kill() {
|
|
267
|
+
if (this.dead) return;
|
|
268
|
+
this.setCurrent(0);
|
|
269
|
+
this.die();
|
|
270
|
+
}
|
|
271
|
+
setCurrent(value) {
|
|
272
|
+
this.current = Math.max(0, Math.min(this.max, value));
|
|
273
|
+
}
|
|
274
|
+
die() {
|
|
275
|
+
if (this.dead) return;
|
|
276
|
+
this.dead = true;
|
|
277
|
+
this.current = 0;
|
|
278
|
+
this.emit("died");
|
|
279
|
+
if (this.freeOnDeath) this.node.queueFree();
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/gameplay/trigger.ts
|
|
284
|
+
/**
|
|
285
|
+
* Subscribe a behavior to its own node's `triggerEnter(other)` signal, after
|
|
286
|
+
* asserting the node can actually emit it — Pickup/DamageOnContact must sit on
|
|
287
|
+
* an Area2D/Area3D (or any node declaring the unified collision signals).
|
|
288
|
+
* Fails loudly (the engine philosophy) instead of silently never firing.
|
|
289
|
+
*/
|
|
290
|
+
function onTriggerEnter(behavior, fn) {
|
|
291
|
+
const node = behavior.node;
|
|
292
|
+
if (!node.declaredSignalNames().includes("triggerEnter")) {
|
|
293
|
+
const behaviorName = behavior.constructor.name;
|
|
294
|
+
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(", ")}].`, {
|
|
295
|
+
signal: "triggerEnter",
|
|
296
|
+
path: node.getPath()
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
node.on("triggerEnter", (other) => fn(other));
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/gameplay/damage-on-contact.ts
|
|
303
|
+
/**
|
|
304
|
+
* Deals damage to whatever it touches — projectiles, spikes, lava, enemy
|
|
305
|
+
* hitboxes. Must sit on an Area2D/Area3D (listens to `triggerEnter`).
|
|
306
|
+
*
|
|
307
|
+
* On overlap it finds the contacted entity's `Health` behavior (see
|
|
308
|
+
* `findHealth` for the exact search order) and calls `damage(amount)`, then
|
|
309
|
+
* emits `dealtDamage(amount, targetNode)`.
|
|
310
|
+
*
|
|
311
|
+
* - `targetGroup` (default '' = anything) gates who can be hurt: only a target
|
|
312
|
+
* whose Health-owner node is in that group takes damage. This is how you stop
|
|
313
|
+
* enemies killing each other — give the player's weapon `targetGroup:'enemy'`
|
|
314
|
+
* and each enemy's contact hitbox `targetGroup:'player'`.
|
|
315
|
+
* - `oncePerTarget` (default) prevents re-hitting the same node (a stationary
|
|
316
|
+
* hazard would otherwise drain a resting body every frame).
|
|
317
|
+
* - `destroySelf` frees the hazard after a hit (single-use projectiles).
|
|
318
|
+
*
|
|
319
|
+
* SCORING PATTERN (clone-safe): wire the KILLER's `dealtDamage` →
|
|
320
|
+
* `ScoreKeeper.addScore`. The weapon is usually a non-cloned node (it lives on
|
|
321
|
+
* the player), so a scene connection on it survives — unlike a connection on a
|
|
322
|
+
* spawned enemy, which never clones.
|
|
323
|
+
*/
|
|
324
|
+
var DamageOnContact = class extends Behavior {
|
|
325
|
+
static props = {
|
|
326
|
+
/** Hit points to remove per contact. */
|
|
327
|
+
amount: { default: 10 },
|
|
328
|
+
/** Only damage targets whose Health-owner is in this group ('' = any). */
|
|
329
|
+
targetGroup: { default: "" },
|
|
330
|
+
/** Damage each distinct target at most once. */
|
|
331
|
+
oncePerTarget: { default: true },
|
|
332
|
+
/** queueFree() this node after the first successful hit. */
|
|
333
|
+
destroySelf: { default: false }
|
|
334
|
+
};
|
|
335
|
+
static signals = ["dealtDamage"];
|
|
336
|
+
amount = 10;
|
|
337
|
+
targetGroup = "";
|
|
338
|
+
oncePerTarget = true;
|
|
339
|
+
destroySelf = false;
|
|
340
|
+
hit = /* @__PURE__ */ new WeakSet();
|
|
341
|
+
onReady() {
|
|
342
|
+
onTriggerEnter(this, (other) => this.tryDamage(other));
|
|
343
|
+
}
|
|
344
|
+
tryDamage(other) {
|
|
345
|
+
if (this.oncePerTarget && this.hit.has(other)) return;
|
|
346
|
+
const found = findHealth(other);
|
|
347
|
+
if (!found) return;
|
|
348
|
+
if (this.targetGroup !== "" && !found.owner.isInGroup(this.targetGroup)) return;
|
|
349
|
+
if (this.oncePerTarget) this.hit.add(other);
|
|
350
|
+
found.health.damage(this.amount);
|
|
351
|
+
this.emit("dealtDamage", this.amount, found.owner);
|
|
352
|
+
if (this.destroySelf) this.node.queueFree();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* Find the nearest `Health` to a contacted node and the node that owns it.
|
|
357
|
+
*
|
|
358
|
+
* A real entity rarely carries Health on the exact collider that overlaps: the
|
|
359
|
+
* Area/Body arrangement usually delivers a different node than the one holding
|
|
360
|
+
* the Health behavior (e.g. Health on the entity ROOT, the overlapping collider
|
|
361
|
+
* a child `Hitbox` Area). The search, in order of nearness:
|
|
362
|
+
*
|
|
363
|
+
* 1. the contacted node itself, then its DESCENDANTS (breadth-first), then
|
|
364
|
+
* 2. each ANCESTOR node in turn (climbing toward the entity root) — the
|
|
365
|
+
* ancestor NODE only, NOT its other subtrees.
|
|
366
|
+
*
|
|
367
|
+
* Step 2 deliberately never descends into an ancestor's *sibling* branches:
|
|
368
|
+
* doing so let a hazard collider whose ancestor chain reaches the scene root
|
|
369
|
+
* find — and damage — an unrelated entity on the far side of the level (the
|
|
370
|
+
* scene root's subtree contains every entity). Health belongs on the contacted
|
|
371
|
+
* collider's own subtree or on a direct ancestor (the entity root) — both of
|
|
372
|
+
* which this covers without ever crossing into another entity.
|
|
373
|
+
*
|
|
374
|
+
* The first `Health` found wins; its node is returned as `owner` (used for the
|
|
375
|
+
* `targetGroup` check and the `dealtDamage` target). Returns null when no Health
|
|
376
|
+
* exists in the contacted node's own subtree or ancestor chain.
|
|
377
|
+
*/
|
|
378
|
+
function findHealth(node) {
|
|
379
|
+
const queue = [node];
|
|
380
|
+
while (queue.length > 0) {
|
|
381
|
+
const n = queue.shift();
|
|
382
|
+
if (n.behavior instanceof Health) return {
|
|
383
|
+
health: n.behavior,
|
|
384
|
+
owner: n
|
|
385
|
+
};
|
|
386
|
+
queue.push(...n.children);
|
|
387
|
+
}
|
|
388
|
+
for (let n = node.parent; n; n = n.parent) if (n.behavior instanceof Health) return {
|
|
389
|
+
health: n.behavior,
|
|
390
|
+
owner: n
|
|
391
|
+
};
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/gameplay/follow-camera.ts
|
|
396
|
+
/**
|
|
397
|
+
* Make the node it sits on chase a target's position — THE camera-follow
|
|
398
|
+
* behavior. Put it on a `Camera2D`/`Camera3D` (whose `position` is the view
|
|
399
|
+
* center/eye) and point `target` at the player; every frame the camera lerps
|
|
400
|
+
* toward `target.position + offset`.
|
|
401
|
+
*
|
|
402
|
+
* Dimension-agnostic: works on `[x,y]` (2D) and `[x,y,z]` (3D) positions.
|
|
403
|
+
*
|
|
404
|
+
* - `smoothing` (0..1) is a per-frame retention factor: 0 = instant snap,
|
|
405
|
+
* 0.85–0.95 = smooth drag (frame-rate independent at a 60fps reference).
|
|
406
|
+
* - `deadzone` keeps the camera still until the target drifts that far from the
|
|
407
|
+
* desired point — no jitter when the player makes tiny moves.
|
|
408
|
+
*/
|
|
409
|
+
var FollowCamera = class extends Behavior {
|
|
410
|
+
static props = {
|
|
411
|
+
/** NodePath of the node to follow (relative to this node, or absolute). */
|
|
412
|
+
target: { default: "" },
|
|
413
|
+
/** Constant offset added to the target's position ([x,y] or [x,y,z]). */
|
|
414
|
+
offset: { default: [] },
|
|
415
|
+
/** 0..1 retention factor: 0 = snap, higher = smoother chase. */
|
|
416
|
+
smoothing: { default: 0 },
|
|
417
|
+
/** Don't move until the target is at least this far from the desired point. */
|
|
418
|
+
deadzone: { default: 0 }
|
|
419
|
+
};
|
|
420
|
+
static signals = [];
|
|
421
|
+
target = "";
|
|
422
|
+
offset = [];
|
|
423
|
+
smoothing = 0;
|
|
424
|
+
deadzone = 0;
|
|
425
|
+
onReady() {
|
|
426
|
+
if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
|
|
427
|
+
if (this.deadzone < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "deadzone" must be >= 0, got ${this.deadzone}.`, { prop: "deadzone" });
|
|
428
|
+
requirePosition(this);
|
|
429
|
+
}
|
|
430
|
+
update(dt) {
|
|
431
|
+
const cam = this.node;
|
|
432
|
+
if (!hasPosition$1(cam)) return;
|
|
433
|
+
const target = cam.getNodeOrNull(this.target);
|
|
434
|
+
if (!target || !hasPosition$1(target)) return;
|
|
435
|
+
const desired = add(target.position, this.offset);
|
|
436
|
+
if (this.deadzone > 0 && distance$1(cam.position, desired) <= this.deadzone) return;
|
|
437
|
+
const f = smoothingFactor(this.smoothing, dt);
|
|
438
|
+
if (f >= 1) {
|
|
439
|
+
cam.position = [...desired];
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
cam.position = cam.position.map((c, i) => c + ((desired[i] ?? c) - c) * f);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
//#endregion
|
|
446
|
+
//#region src/gameplay/interactable.ts
|
|
447
|
+
function hasPosition(node) {
|
|
448
|
+
return Array.isArray(node.position);
|
|
449
|
+
}
|
|
450
|
+
/** Euclidean distance between two position arrays (works for 2D [x,y] and 3D [x,y,z]). */
|
|
451
|
+
function distance(a, b) {
|
|
452
|
+
let sum = 0;
|
|
453
|
+
const n = Math.max(a.length, b.length);
|
|
454
|
+
for (let i = 0; i < n; i++) {
|
|
455
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
456
|
+
sum += d * d;
|
|
457
|
+
}
|
|
458
|
+
return Math.sqrt(sum);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* A proximity-gated "press to use" — doors, levers, chests, NPCs. Each frame,
|
|
462
|
+
* if an actor in `actorGroup` is within `range` (distance on `position` arrays,
|
|
463
|
+
* 2D or 3D) and the `action` input was just pressed, emits
|
|
464
|
+
* `interacted(actor)` (the nearest in-range actor).
|
|
465
|
+
*
|
|
466
|
+
* Wire `interacted → YourBehavior.someMethod`, or read it from a connection.
|
|
467
|
+
*/
|
|
468
|
+
var Interactable = class extends Behavior {
|
|
469
|
+
static props = {
|
|
470
|
+
/** Input action (button) that triggers interaction. */
|
|
471
|
+
action: { default: "interact" },
|
|
472
|
+
/** Maximum distance an actor may be to interact. */
|
|
473
|
+
range: { default: 2 },
|
|
474
|
+
/** Only nodes in this group can interact. */
|
|
475
|
+
actorGroup: { default: "player" }
|
|
476
|
+
};
|
|
477
|
+
static signals = ["interacted"];
|
|
478
|
+
action = "interact";
|
|
479
|
+
range = 2;
|
|
480
|
+
actorGroup = "player";
|
|
481
|
+
update() {
|
|
482
|
+
const engine = this.node.tree?.engine;
|
|
483
|
+
if (!engine?.scene) return;
|
|
484
|
+
if (!engine.input.justPressed(this.action)) return;
|
|
485
|
+
if (!hasPosition(this.node)) return;
|
|
486
|
+
const self = this.node.position;
|
|
487
|
+
let nearest = null;
|
|
488
|
+
let nearestDist = Number.POSITIVE_INFINITY;
|
|
489
|
+
const actors = this.node.tree?.getNodesInGroup(this.actorGroup) ?? collectGroup(this.node.getRoot(), this.actorGroup);
|
|
490
|
+
for (const actor of actors) {
|
|
491
|
+
if (!hasPosition(actor)) continue;
|
|
492
|
+
const d = distance(self, actor.position);
|
|
493
|
+
if (d <= this.range && d < nearestDist) {
|
|
494
|
+
nearest = actor;
|
|
495
|
+
nearestDist = d;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (nearest) this.emit("interacted", nearest);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
function collectGroup(node, group) {
|
|
502
|
+
const out = [];
|
|
503
|
+
const walk = (n) => {
|
|
504
|
+
if (n.isInGroup(group)) out.push(n);
|
|
505
|
+
for (const c of n.children) walk(c);
|
|
506
|
+
};
|
|
507
|
+
walk(node);
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/gameplay/lifetime.ts
|
|
512
|
+
/**
|
|
513
|
+
* Self-destruct after a fixed time — bullets, particles, temporary spawns,
|
|
514
|
+
* pickups that vanish. Accumulates `dt`; on elapse emits `expired` then
|
|
515
|
+
* `queueFree()`s its node.
|
|
516
|
+
*
|
|
517
|
+
* With `startOnSignal: true` the countdown is armed manually via `startTimer()`
|
|
518
|
+
* (wire a signal → `startTimer`), so the lifetime begins on an event rather
|
|
519
|
+
* than at spawn.
|
|
520
|
+
*/
|
|
521
|
+
var Lifetime = class extends Behavior {
|
|
522
|
+
static props = {
|
|
523
|
+
/** Seconds before the node frees itself. */
|
|
524
|
+
seconds: { default: 1 },
|
|
525
|
+
/** Defer the countdown until `startTimer()` is called (default: start at ready). */
|
|
526
|
+
startOnSignal: { default: false }
|
|
527
|
+
};
|
|
528
|
+
static signals = ["expired"];
|
|
529
|
+
seconds = 1;
|
|
530
|
+
startOnSignal = false;
|
|
531
|
+
elapsed = 0;
|
|
532
|
+
running = false;
|
|
533
|
+
fired = false;
|
|
534
|
+
onReady() {
|
|
535
|
+
if (!(this.seconds > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Lifetime on '${this.node.getPath()}': "seconds" must be > 0, got ${this.seconds}.`, { prop: "seconds" });
|
|
536
|
+
this.running = !this.startOnSignal;
|
|
537
|
+
}
|
|
538
|
+
/** Arm (or re-arm) the countdown from zero. */
|
|
539
|
+
startTimer() {
|
|
540
|
+
this.elapsed = 0;
|
|
541
|
+
this.fired = false;
|
|
542
|
+
this.running = true;
|
|
543
|
+
}
|
|
544
|
+
update(dt) {
|
|
545
|
+
if (!this.running || this.fired) return;
|
|
546
|
+
this.elapsed += dt;
|
|
547
|
+
if (this.elapsed >= this.seconds) {
|
|
548
|
+
this.fired = true;
|
|
549
|
+
this.running = false;
|
|
550
|
+
this.emit("expired");
|
|
551
|
+
this.node.queueFree();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/gameplay/tween.ts
|
|
557
|
+
const EASE_NAMES = [
|
|
558
|
+
"linear",
|
|
559
|
+
"easeIn",
|
|
560
|
+
"easeOut",
|
|
561
|
+
"easeInOut"
|
|
562
|
+
];
|
|
563
|
+
/**
|
|
564
|
+
* Map normalized time `t` through an easing curve to an eased progress in
|
|
565
|
+
* `[0,1]`. `t` is clamped to `[0,1]` so callers never overshoot:
|
|
566
|
+
* - `linear` — constant speed
|
|
567
|
+
* - `easeIn` — quadratic, slow start (`t²`)
|
|
568
|
+
* - `easeOut` — quadratic, slow end (`1-(1-t)²`)
|
|
569
|
+
* - `easeInOut` — slow start AND end (smooth in/out)
|
|
570
|
+
*/
|
|
571
|
+
function ease(curve, t) {
|
|
572
|
+
const x = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
573
|
+
switch (curve) {
|
|
574
|
+
case "linear": return x;
|
|
575
|
+
case "easeIn": return x * x;
|
|
576
|
+
case "easeOut": return 1 - (1 - x) * (1 - x);
|
|
577
|
+
case "easeInOut": return x < .5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
//#endregion
|
|
581
|
+
//#region src/gameplay/move-to.ts
|
|
582
|
+
/**
|
|
583
|
+
* Tween the node from where it starts to a fixed `to` position over `duration`
|
|
584
|
+
* seconds, through an easing curve — opening doors, sliding platforms, UI
|
|
585
|
+
* pop-ins, scripted moves. Emits `arrived` once at the end.
|
|
586
|
+
*
|
|
587
|
+
* `startOnSignal: true` arms it manually via `start()` (wire a signal → `start`)
|
|
588
|
+
* so the move plays on an event; otherwise it begins at ready. Dimension-
|
|
589
|
+
* agnostic (`[x,y]` / `[x,y,z]`).
|
|
590
|
+
*/
|
|
591
|
+
var MoveTo = class extends Behavior {
|
|
592
|
+
static props = {
|
|
593
|
+
/** Destination position ([x,y] or [x,y,z]). */
|
|
594
|
+
to: { default: [] },
|
|
595
|
+
/** Seconds the move takes. */
|
|
596
|
+
duration: { default: 1 },
|
|
597
|
+
/** Easing curve. */
|
|
598
|
+
ease: {
|
|
599
|
+
default: "easeInOut",
|
|
600
|
+
options: [...EASE_NAMES]
|
|
601
|
+
},
|
|
602
|
+
/** Defer the move until start() is called (default: begin at ready). */
|
|
603
|
+
startOnSignal: { default: false }
|
|
604
|
+
};
|
|
605
|
+
static signals = ["arrived"];
|
|
606
|
+
to = [];
|
|
607
|
+
duration = 1;
|
|
608
|
+
ease = "easeInOut";
|
|
609
|
+
startOnSignal = false;
|
|
610
|
+
from = [];
|
|
611
|
+
elapsed = 0;
|
|
612
|
+
running = false;
|
|
613
|
+
arrivedFired = false;
|
|
614
|
+
onReady() {
|
|
615
|
+
if (!(this.duration > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `MoveTo on '${this.node.getPath()}': "duration" must be > 0, got ${this.duration}.`, { prop: "duration" });
|
|
616
|
+
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" });
|
|
617
|
+
const node = requirePosition(this);
|
|
618
|
+
this.from = [...node.position];
|
|
619
|
+
if (!this.startOnSignal) this.running = true;
|
|
620
|
+
}
|
|
621
|
+
/** Begin (or restart) the move from the node's CURRENT position. */
|
|
622
|
+
start() {
|
|
623
|
+
const node = this.node;
|
|
624
|
+
this.from = [...node.position];
|
|
625
|
+
this.elapsed = 0;
|
|
626
|
+
this.arrivedFired = false;
|
|
627
|
+
this.running = true;
|
|
628
|
+
}
|
|
629
|
+
update(dt) {
|
|
630
|
+
if (!this.running) return;
|
|
631
|
+
const node = this.node;
|
|
632
|
+
this.elapsed += dt;
|
|
633
|
+
const t = Math.min(1, this.elapsed / this.duration);
|
|
634
|
+
const k = ease(this.ease, t);
|
|
635
|
+
node.position = this.from.map((c, i) => c + ((this.to[i] ?? c) - c) * k);
|
|
636
|
+
if (t >= 1 && !this.arrivedFired) {
|
|
637
|
+
this.arrivedFired = true;
|
|
638
|
+
this.running = false;
|
|
639
|
+
this.emit("arrived");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/gameplay/oscillate.ts
|
|
645
|
+
const AXES = [
|
|
646
|
+
"x",
|
|
647
|
+
"y",
|
|
648
|
+
"z"
|
|
649
|
+
];
|
|
650
|
+
const MODES = [
|
|
651
|
+
"position",
|
|
652
|
+
"rotation",
|
|
653
|
+
"scale"
|
|
654
|
+
];
|
|
655
|
+
const AXIS_INDEX = {
|
|
656
|
+
x: 0,
|
|
657
|
+
y: 1,
|
|
658
|
+
z: 2
|
|
659
|
+
};
|
|
660
|
+
/**
|
|
661
|
+
* Continuous sine motion around a value — floating platforms, bobbing pickups,
|
|
662
|
+
* spinning/pulsing coins. Drives one `axis` of the node's `position`, `rotation`
|
|
663
|
+
* (`mode: 'rotation'` — a "spin" when on z), or `scale` (a "pulse"):
|
|
664
|
+
*
|
|
665
|
+
* value = start + amplitude · sin(2π · frequency · t)
|
|
666
|
+
*
|
|
667
|
+
* The baseline (`start`) is captured at ready, so it layers on top of authored
|
|
668
|
+
* transforms. Dimension-agnostic; on a 2D node `rotation` is the scalar spin
|
|
669
|
+
* (use `axis: 'z'`).
|
|
670
|
+
*/
|
|
671
|
+
var Oscillate = class extends Behavior {
|
|
672
|
+
static props = {
|
|
673
|
+
/** Which component to drive (rotation on a 2D node is 'z'). */
|
|
674
|
+
axis: {
|
|
675
|
+
default: "y",
|
|
676
|
+
options: [...AXES]
|
|
677
|
+
},
|
|
678
|
+
/** Peak displacement from the baseline. */
|
|
679
|
+
amplitude: { default: 1 },
|
|
680
|
+
/** Cycles per second. */
|
|
681
|
+
frequency: { default: 1 },
|
|
682
|
+
/** What to oscillate. */
|
|
683
|
+
mode: {
|
|
684
|
+
default: "position",
|
|
685
|
+
options: [...MODES]
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
static signals = [];
|
|
689
|
+
axis = "y";
|
|
690
|
+
amplitude = 1;
|
|
691
|
+
frequency = 1;
|
|
692
|
+
mode = "position";
|
|
693
|
+
base = 0;
|
|
694
|
+
time = 0;
|
|
695
|
+
onReady() {
|
|
696
|
+
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" });
|
|
697
|
+
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" });
|
|
698
|
+
this.base = this.readChannel();
|
|
699
|
+
}
|
|
700
|
+
update(dt) {
|
|
701
|
+
this.time += dt;
|
|
702
|
+
const offset = this.amplitude * Math.sin(2 * Math.PI * this.frequency * this.time);
|
|
703
|
+
this.writeChannel(this.base + offset);
|
|
704
|
+
}
|
|
705
|
+
/** Whether the target channel is the node's scalar 2D rotation. */
|
|
706
|
+
isScalarRotation() {
|
|
707
|
+
return this.mode === "rotation" && typeof this.node.rotation === "number";
|
|
708
|
+
}
|
|
709
|
+
readChannel() {
|
|
710
|
+
if (this.isScalarRotation()) return this.node.rotation;
|
|
711
|
+
const arr = this.node[this.mode];
|
|
712
|
+
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" });
|
|
713
|
+
return arr[AXIS_INDEX[this.axis]] ?? 0;
|
|
714
|
+
}
|
|
715
|
+
writeChannel(value) {
|
|
716
|
+
if (this.isScalarRotation()) {
|
|
717
|
+
this.node.rotation = value;
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const next = [...this.node[this.mode]];
|
|
721
|
+
next[AXIS_INDEX[this.axis]] = value;
|
|
722
|
+
this.node[this.mode] = next;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/gameplay/patrol.ts
|
|
727
|
+
/**
|
|
728
|
+
* Walk the node along a fixed list of waypoints at constant speed — guards,
|
|
729
|
+
* platforms, moving hazards. `points` are positions (`[x,y]` / `[x,y,z]`) OR
|
|
730
|
+
* node paths whose `position` is read each frame (so you can author markers in
|
|
731
|
+
* the scene). On arrival emits `reachedPoint(index)`; `pauseAt` holds at each
|
|
732
|
+
* point before moving on.
|
|
733
|
+
*
|
|
734
|
+
* - `loop` (default) — after the last point, head back to the first.
|
|
735
|
+
* - `mode: 'pingpong'` — reverse direction at each end instead of wrapping.
|
|
736
|
+
*/
|
|
737
|
+
var Patrol = class extends Behavior {
|
|
738
|
+
static props = {
|
|
739
|
+
/** Waypoints: array of position arrays OR node-path strings to read. */
|
|
740
|
+
points: { default: [] },
|
|
741
|
+
/** Units per second along the path. */
|
|
742
|
+
speed: { default: 60 },
|
|
743
|
+
/** Wrap to the first point after the last (ignored in pingpong). */
|
|
744
|
+
loop: { default: true },
|
|
745
|
+
/** 'loop' wraps; 'pingpong' reverses at the ends. */
|
|
746
|
+
mode: {
|
|
747
|
+
default: "loop",
|
|
748
|
+
options: ["loop", "pingpong"]
|
|
749
|
+
},
|
|
750
|
+
/** Seconds to wait at each reached point before continuing. */
|
|
751
|
+
pauseAt: { default: 0 }
|
|
752
|
+
};
|
|
753
|
+
static signals = ["reachedPoint"];
|
|
754
|
+
points = [];
|
|
755
|
+
speed = 60;
|
|
756
|
+
loop = true;
|
|
757
|
+
mode = "loop";
|
|
758
|
+
pauseAt = 0;
|
|
759
|
+
index = 0;
|
|
760
|
+
direction = 1;
|
|
761
|
+
pauseTimer = 0;
|
|
762
|
+
onReady() {
|
|
763
|
+
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" });
|
|
764
|
+
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" });
|
|
765
|
+
requirePosition(this);
|
|
766
|
+
}
|
|
767
|
+
update(dt) {
|
|
768
|
+
if (this.pauseTimer > 0) {
|
|
769
|
+
this.pauseTimer = Math.max(0, this.pauseTimer - dt);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const node = this.node;
|
|
773
|
+
const target = this.pointAt(this.index);
|
|
774
|
+
if (!target) return;
|
|
775
|
+
const { position, reached } = moveToward(node.position, target, this.speed * dt);
|
|
776
|
+
node.position = position;
|
|
777
|
+
if (reached) {
|
|
778
|
+
this.emit("reachedPoint", this.index);
|
|
779
|
+
this.advance();
|
|
780
|
+
if (this.pauseAt > 0) this.pauseTimer = this.pauseAt;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** Resolve waypoint `i` to a position, reading node paths live. */
|
|
784
|
+
pointAt(i) {
|
|
785
|
+
const raw = this.points[i];
|
|
786
|
+
if (Array.isArray(raw)) return raw;
|
|
787
|
+
if (typeof raw === "string") {
|
|
788
|
+
const marker = this.node.getNodeOrNull(raw);
|
|
789
|
+
return Array.isArray(marker?.position) ? marker.position : null;
|
|
790
|
+
}
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
advance() {
|
|
794
|
+
const last = this.points.length - 1;
|
|
795
|
+
if (this.mode === "pingpong") {
|
|
796
|
+
if (this.index >= last) this.direction = -1;
|
|
797
|
+
else if (this.index <= 0) this.direction = 1;
|
|
798
|
+
this.index = Math.min(last, Math.max(0, this.index + this.direction));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (this.index < last) this.index += 1;
|
|
802
|
+
else if (this.loop) this.index = 0;
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/gameplay/pickup.ts
|
|
807
|
+
/**
|
|
808
|
+
* A collectible that vanishes when a collector overlaps it. Must sit on an
|
|
809
|
+
* Area2D/Area3D (it listens to the unified `triggerEnter`).
|
|
810
|
+
*
|
|
811
|
+
* On overlap with a node in `collectorGroup`, emits `collected(value, other)`
|
|
812
|
+
* then `queueFree()`s itself. Wire `collected → ScoreKeeper.addScore` (the
|
|
813
|
+
* value is the first arg) or `collected → Collector.collect`.
|
|
814
|
+
*/
|
|
815
|
+
var Pickup = class extends Behavior {
|
|
816
|
+
static props = {
|
|
817
|
+
/** Worth of this pickup (passed as `collected`'s first arg). */
|
|
818
|
+
value: { default: 1 },
|
|
819
|
+
/** Free-form label for the kind of pickup ('coin', 'gem', 'key', …). */
|
|
820
|
+
kind: { default: "coin" },
|
|
821
|
+
/** Only nodes in this group collect it. */
|
|
822
|
+
collectorGroup: { default: "player" }
|
|
823
|
+
};
|
|
824
|
+
static signals = ["collected"];
|
|
825
|
+
value = 1;
|
|
826
|
+
kind = "coin";
|
|
827
|
+
collectorGroup = "player";
|
|
828
|
+
collectedAlready = false;
|
|
829
|
+
onReady() {
|
|
830
|
+
onTriggerEnter(this, (other) => this.tryCollect(other));
|
|
831
|
+
}
|
|
832
|
+
tryCollect(other) {
|
|
833
|
+
if (this.collectedAlready) return;
|
|
834
|
+
if (!other.isInGroup(this.collectorGroup)) return;
|
|
835
|
+
this.collectedAlready = true;
|
|
836
|
+
this.emit("collected", this.value, other);
|
|
837
|
+
this.node.queueFree();
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/gameplay/projectile.ts
|
|
842
|
+
/**
|
|
843
|
+
* Constant-velocity motion in a straight line — bullets, arrows, thrown rocks.
|
|
844
|
+
* Pure movement by design: pair it with `DamageOnContact` (deal damage on hit),
|
|
845
|
+
* `Lifetime` (auto-despawn), and an Area collider on the same node. Compose,
|
|
846
|
+
* don't conflate.
|
|
847
|
+
*
|
|
848
|
+
* `direction` is either an explicit vector (`[x,y]` / `[x,y,z]`, normalized) or
|
|
849
|
+
* the string `'forward'`, derived from the node's `rotation` (2D: degrees
|
|
850
|
+
* clockwise, +x at 0). It is a union-typed prop, so its schema default is
|
|
851
|
+
* `null` (the engine's "any JSON" escape hatch) and `null` means `'forward'`.
|
|
852
|
+
* `gravity` adds a constant downward (+y, the 2D y-down convention) pull, for
|
|
853
|
+
* lobbed/arcing shots.
|
|
854
|
+
*/
|
|
855
|
+
var Projectile = class extends Behavior {
|
|
856
|
+
static props = {
|
|
857
|
+
/** Units per second along `direction`. */
|
|
858
|
+
speed: { default: 300 },
|
|
859
|
+
/** A vector ([x,y(,z)]) OR the string 'forward' (null = 'forward', from rotation). */
|
|
860
|
+
direction: { default: null },
|
|
861
|
+
/** Constant +y (downward, 2D y-down) acceleration; 0 = straight line. */
|
|
862
|
+
gravity: { default: 0 }
|
|
863
|
+
};
|
|
864
|
+
static signals = [];
|
|
865
|
+
speed = 300;
|
|
866
|
+
direction = null;
|
|
867
|
+
gravity = 0;
|
|
868
|
+
velocity = [];
|
|
869
|
+
onReady() {
|
|
870
|
+
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" });
|
|
871
|
+
const node = requirePosition(this);
|
|
872
|
+
const dir = this.resolveDirection();
|
|
873
|
+
this.velocity = dir.map((c) => c * this.speed);
|
|
874
|
+
while (this.velocity.length < node.position.length) this.velocity.push(0);
|
|
875
|
+
}
|
|
876
|
+
update(dt) {
|
|
877
|
+
const node = this.node;
|
|
878
|
+
if (this.gravity !== 0) this.velocity[1] = (this.velocity[1] ?? 0) + this.gravity * dt;
|
|
879
|
+
node.position = add(node.position, this.velocity.map((v) => v * dt));
|
|
880
|
+
}
|
|
881
|
+
/** Resolve `direction` to a unit vector, deriving 'forward' from rotation. */
|
|
882
|
+
resolveDirection() {
|
|
883
|
+
if (Array.isArray(this.direction)) return normalize(this.direction);
|
|
884
|
+
if (this.direction === null || this.direction === "forward") return this.forwardFromRotation();
|
|
885
|
+
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" });
|
|
886
|
+
}
|
|
887
|
+
forwardFromRotation() {
|
|
888
|
+
const rot = this.node.rotation;
|
|
889
|
+
if (typeof rot === "number") {
|
|
890
|
+
const rad = rot * Math.PI / 180;
|
|
891
|
+
return [Math.cos(rad), Math.sin(rad)];
|
|
892
|
+
}
|
|
893
|
+
if (Array.isArray(rot)) {
|
|
894
|
+
const yaw = (rot[1] ?? 0) * Math.PI / 180;
|
|
895
|
+
const pitch = (rot[0] ?? 0) * Math.PI / 180;
|
|
896
|
+
return normalize([
|
|
897
|
+
-Math.sin(yaw) * Math.cos(pitch),
|
|
898
|
+
Math.sin(pitch),
|
|
899
|
+
-Math.cos(yaw) * Math.cos(pitch)
|
|
900
|
+
]);
|
|
901
|
+
}
|
|
902
|
+
return [1, 0];
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/gameplay/score-keeper.ts
|
|
907
|
+
/**
|
|
908
|
+
* The game's state hub — score, lives, and win/lose detection. Put it on the
|
|
909
|
+
* Root/Game node and wire gameplay signals into its methods (e.g. a Pickup's
|
|
910
|
+
* `collected → addScore`, a Health's `died → loseLife`).
|
|
911
|
+
*
|
|
912
|
+
* - `score` rises via `addScore` / `setScore`; reaching `scoreToWin` (>0)
|
|
913
|
+
* emits `won` once.
|
|
914
|
+
* - `lives` (>0) shrinks via `loseLife`; reaching 0 emits `lost` once.
|
|
915
|
+
*/
|
|
916
|
+
var ScoreKeeper = class extends Behavior {
|
|
917
|
+
static props = {
|
|
918
|
+
/** Starting score. */
|
|
919
|
+
score: { default: 0 },
|
|
920
|
+
/** Starting lives (0 = lives disabled, loseLife is inert). */
|
|
921
|
+
lives: { default: 0 },
|
|
922
|
+
/** Score that triggers `won` (0 = disabled). */
|
|
923
|
+
scoreToWin: { default: 0 }
|
|
924
|
+
};
|
|
925
|
+
static signals = [
|
|
926
|
+
"scoreChanged",
|
|
927
|
+
"won",
|
|
928
|
+
"lost",
|
|
929
|
+
"lifeLost"
|
|
930
|
+
];
|
|
931
|
+
score = 0;
|
|
932
|
+
lives = 0;
|
|
933
|
+
scoreToWin = 0;
|
|
934
|
+
hasWon = false;
|
|
935
|
+
hasLost = false;
|
|
936
|
+
onReady() {
|
|
937
|
+
if (this.lives < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `ScoreKeeper on '${this.node.getPath()}': "lives" must be >= 0, got ${this.lives}.`, { prop: "lives" });
|
|
938
|
+
if (this.scoreToWin < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `ScoreKeeper on '${this.node.getPath()}': "scoreToWin" must be >= 0, got ${this.scoreToWin}.`, { prop: "scoreToWin" });
|
|
939
|
+
}
|
|
940
|
+
/** Add `n` to the score (negative subtracts), emit `scoreChanged`, check win. */
|
|
941
|
+
addScore(n) {
|
|
942
|
+
this.setScore(this.score + n);
|
|
943
|
+
}
|
|
944
|
+
/** Set the score to `value`, emit `scoreChanged`, check win. */
|
|
945
|
+
setScore(value) {
|
|
946
|
+
this.score = value;
|
|
947
|
+
this.emit("scoreChanged", this.score);
|
|
948
|
+
if (this.scoreToWin > 0 && !this.hasWon && this.score >= this.scoreToWin) {
|
|
949
|
+
this.hasWon = true;
|
|
950
|
+
this.emit("won");
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/** Lose one life (no-op when lives disabled or already lost). Emits `lifeLost`, then `lost` at 0. */
|
|
954
|
+
loseLife() {
|
|
955
|
+
if (this.lives <= 0 || this.hasLost) return;
|
|
956
|
+
this.lives -= 1;
|
|
957
|
+
this.emit("lifeLost", this.lives);
|
|
958
|
+
if (this.lives <= 0) {
|
|
959
|
+
this.hasLost = true;
|
|
960
|
+
this.emit("lost");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
//#endregion
|
|
965
|
+
//#region src/gameplay/spawn-source.ts
|
|
966
|
+
/**
|
|
967
|
+
* Shared spawn mechanism for Spawner/WaveSpawner.
|
|
968
|
+
*
|
|
969
|
+
* A `prefab` is a node PATH to a TEMPLATE node in the scene (usually a hidden
|
|
970
|
+
* `visible: false` child of the spawner). We `duplicateNode` it (the same
|
|
971
|
+
* serialize→rebuild clone used by the editor — scripts and nested children come
|
|
972
|
+
* along, the clone gets a fresh identity) and add it as a sibling-style child of
|
|
973
|
+
* the spawner's node. Keeping spawning to clone-a-template makes it CORE-PURE
|
|
974
|
+
* (no NetworkManager / scene registry, no three) and headlessly verifiable.
|
|
975
|
+
*/
|
|
976
|
+
var SpawnSource = class {
|
|
977
|
+
behavior;
|
|
978
|
+
/** Templates already resolved-and-detached, keyed by their prefab path. */
|
|
979
|
+
detached = /* @__PURE__ */ new Map();
|
|
980
|
+
constructor(behavior) {
|
|
981
|
+
this.behavior = behavior;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Resolve a template path to its node, hard-failing if it's missing, and
|
|
985
|
+
* DETACH it from the live tree so the template never updates or renders.
|
|
986
|
+
*
|
|
987
|
+
* A `visible:false` template is still a live node: its behaviors tick (e.g. a
|
|
988
|
+
* Chase walks the invisible template onto the player) and a renderer may set
|
|
989
|
+
* it up. Removing it from the tree on first resolution makes it inert — the
|
|
990
|
+
* detached node is held here purely as a clone source. Clones go INTO the
|
|
991
|
+
* tree; the template stays out. Idempotent and cached, so WaveSpawner's
|
|
992
|
+
* per-spawn re-resolution returns the same held template.
|
|
993
|
+
*/
|
|
994
|
+
resolveTemplate(prefab) {
|
|
995
|
+
const cached = this.detached.get(prefab);
|
|
996
|
+
if (cached) return cached;
|
|
997
|
+
const node = this.behavior.node;
|
|
998
|
+
const template = prefab === "" ? null : node.getNodeOrNull(prefab);
|
|
999
|
+
if (!template) {
|
|
1000
|
+
const behaviorName = this.behavior.constructor.name;
|
|
1001
|
+
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.`, {
|
|
1002
|
+
prop: "prefab",
|
|
1003
|
+
path: node.getPath()
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
if (template.parent) template.parent.removeChild(template);
|
|
1007
|
+
this.detached.set(prefab, template);
|
|
1008
|
+
return template;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Clone `template`, make it visible, attach it under `parent` (defaults to the
|
|
1012
|
+
* spawner's node), and return the clone.
|
|
1013
|
+
*
|
|
1014
|
+
* Placement: with no `at`, the clone keeps the template's authored position;
|
|
1015
|
+
* with `at`, it spawns at the spawner's position plus `at` (a non-spatial
|
|
1016
|
+
* spawner like a plain Node treats `at` as absolute).
|
|
1017
|
+
*/
|
|
1018
|
+
spawn(template, opts) {
|
|
1019
|
+
const clone = duplicateNode(template);
|
|
1020
|
+
const at = opts?.at;
|
|
1021
|
+
if (at && at.length > 0 && hasPosition$1(clone)) clone.position = add(hasPosition$1(this.behavior.node) ? this.behavior.node.position : [], at);
|
|
1022
|
+
if (typeof clone.visible === "boolean") clone.visible = true;
|
|
1023
|
+
(opts?.parent ?? this.behavior.node).addChild(clone);
|
|
1024
|
+
return clone;
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/gameplay/spawner.ts
|
|
1029
|
+
/**
|
|
1030
|
+
* Drip-feed clones of a template into the scene on a timer — enemy generators,
|
|
1031
|
+
* pickup fountains, particle emitters. `prefab` is a node PATH to a template
|
|
1032
|
+
* (usually a hidden `visible: false` child) that gets cloned each `interval`.
|
|
1033
|
+
*
|
|
1034
|
+
* - `max` caps LIVE instances (0 = unlimited); the count drops automatically as
|
|
1035
|
+
* spawned children free themselves (e.g. via `Lifetime` or `Health.died`), so
|
|
1036
|
+
* the spawner refills.
|
|
1037
|
+
* - `total` caps LIFETIME spawns (0 = infinite); on the last one it emits
|
|
1038
|
+
* `finished` and stops.
|
|
1039
|
+
* - `autoStart` (default) begins ticking at ready; otherwise call `start()`.
|
|
1040
|
+
*
|
|
1041
|
+
* Emits `spawned(node)` per spawn. Methods: `spawn()`, `start()`, `stop()`.
|
|
1042
|
+
*/
|
|
1043
|
+
var Spawner = class extends Behavior {
|
|
1044
|
+
static props = {
|
|
1045
|
+
/** Node path of the template to clone. */
|
|
1046
|
+
prefab: { default: "" },
|
|
1047
|
+
/** Seconds between spawns. */
|
|
1048
|
+
interval: { default: 1 },
|
|
1049
|
+
/** Max LIVE instances (0 = unlimited). */
|
|
1050
|
+
max: { default: 0 },
|
|
1051
|
+
/** Offset/spawn point ([x,y(,z)]) added to the spawner's position. */
|
|
1052
|
+
at: { default: [] },
|
|
1053
|
+
/** Begin ticking at ready. */
|
|
1054
|
+
autoStart: { default: true },
|
|
1055
|
+
/** Total LIFETIME spawns (0 = infinite). */
|
|
1056
|
+
total: { default: 0 }
|
|
1057
|
+
};
|
|
1058
|
+
static signals = ["spawned", "finished"];
|
|
1059
|
+
prefab = "";
|
|
1060
|
+
interval = 1;
|
|
1061
|
+
max = 0;
|
|
1062
|
+
at = [];
|
|
1063
|
+
autoStart = true;
|
|
1064
|
+
total = 0;
|
|
1065
|
+
source = new SpawnSource(this);
|
|
1066
|
+
template;
|
|
1067
|
+
live = /* @__PURE__ */ new Set();
|
|
1068
|
+
timer = 0;
|
|
1069
|
+
running = false;
|
|
1070
|
+
spawnedCount = 0;
|
|
1071
|
+
done = false;
|
|
1072
|
+
/** Live (un-freed) spawned instances. */
|
|
1073
|
+
get liveCount() {
|
|
1074
|
+
this.prune();
|
|
1075
|
+
return this.live.size;
|
|
1076
|
+
}
|
|
1077
|
+
/** @internal The detached template node (test/debug only). */
|
|
1078
|
+
_templateForTest() {
|
|
1079
|
+
return this.template;
|
|
1080
|
+
}
|
|
1081
|
+
onReady() {
|
|
1082
|
+
this.template = this.source.resolveTemplate(this.prefab);
|
|
1083
|
+
this.running = this.autoStart;
|
|
1084
|
+
this.timer = this.interval;
|
|
1085
|
+
}
|
|
1086
|
+
/** Begin (or resume) interval spawning. */
|
|
1087
|
+
start() {
|
|
1088
|
+
if (this.done) return;
|
|
1089
|
+
this.running = true;
|
|
1090
|
+
}
|
|
1091
|
+
/** Pause interval spawning (spawn() still works). */
|
|
1092
|
+
stop() {
|
|
1093
|
+
this.running = false;
|
|
1094
|
+
}
|
|
1095
|
+
/** Spawn one immediately (ignores the timer; still respects max/total). */
|
|
1096
|
+
spawn() {
|
|
1097
|
+
this.prune();
|
|
1098
|
+
if (this.done) return null;
|
|
1099
|
+
if (this.max > 0 && this.live.size >= this.max) return null;
|
|
1100
|
+
const clone = this.source.spawn(this.template, this.at.length > 0 ? { at: this.at } : void 0);
|
|
1101
|
+
this.live.add(clone);
|
|
1102
|
+
this.spawnedCount += 1;
|
|
1103
|
+
this.emit("spawned", clone);
|
|
1104
|
+
if (this.total > 0 && this.spawnedCount >= this.total) {
|
|
1105
|
+
this.done = true;
|
|
1106
|
+
this.running = false;
|
|
1107
|
+
this.emit("finished");
|
|
1108
|
+
}
|
|
1109
|
+
return clone;
|
|
1110
|
+
}
|
|
1111
|
+
update(dt) {
|
|
1112
|
+
if (!this.running) return;
|
|
1113
|
+
this.timer -= dt;
|
|
1114
|
+
if (this.timer > 0) return;
|
|
1115
|
+
this.timer += this.interval;
|
|
1116
|
+
this.spawn();
|
|
1117
|
+
}
|
|
1118
|
+
/** Drop instances that have been freed (parent cleared on free/queueFree). */
|
|
1119
|
+
prune() {
|
|
1120
|
+
for (const node of this.live) if (node.parent === null) this.live.delete(node);
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
//#endregion
|
|
1124
|
+
//#region src/gameplay/wander.ts
|
|
1125
|
+
/**
|
|
1126
|
+
* Aimless roaming inside a circle around the spawn point — idle critters,
|
|
1127
|
+
* ambient wildlife, restless guards. Picks a random destination within `radius`
|
|
1128
|
+
* of where it started, walks there at `speed`, then (every `changeEvery`
|
|
1129
|
+
* seconds, or on arrival) picks a new one. Uses `this.rng`, so a seeded engine
|
|
1130
|
+
* wanders identically every run (replayable, test-stable).
|
|
1131
|
+
*
|
|
1132
|
+
* Dimension-agnostic: roams in the plane of however many position components
|
|
1133
|
+
* the node has (2D `[x,y]`, 3D `[x,z]` ground plane keeping y).
|
|
1134
|
+
*/
|
|
1135
|
+
var Wander = class extends Behavior {
|
|
1136
|
+
static props = {
|
|
1137
|
+
/** Units per second while roaming. */
|
|
1138
|
+
speed: { default: 40 },
|
|
1139
|
+
/** Roam radius around the spawn point. */
|
|
1140
|
+
radius: { default: 50 },
|
|
1141
|
+
/** Seconds before forcibly choosing a new destination. */
|
|
1142
|
+
changeEvery: { default: 2 }
|
|
1143
|
+
};
|
|
1144
|
+
static signals = [];
|
|
1145
|
+
speed = 40;
|
|
1146
|
+
radius = 50;
|
|
1147
|
+
changeEvery = 2;
|
|
1148
|
+
origin = [];
|
|
1149
|
+
goal = [];
|
|
1150
|
+
timer = 0;
|
|
1151
|
+
onReady() {
|
|
1152
|
+
if (!(this.radius > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Wander on '${this.node.getPath()}': "radius" must be > 0, got ${this.radius}.`, { prop: "radius" });
|
|
1153
|
+
const node = requirePosition(this);
|
|
1154
|
+
this.origin = [...node.position];
|
|
1155
|
+
this.goal = [...node.position];
|
|
1156
|
+
this.pickGoal();
|
|
1157
|
+
}
|
|
1158
|
+
update(dt) {
|
|
1159
|
+
const node = this.node;
|
|
1160
|
+
this.timer -= dt;
|
|
1161
|
+
const { position, reached } = moveToward(node.position, this.goal, this.speed * dt);
|
|
1162
|
+
node.position = position;
|
|
1163
|
+
if (reached || this.timer <= 0) this.pickGoal();
|
|
1164
|
+
}
|
|
1165
|
+
pickGoal() {
|
|
1166
|
+
this.timer = this.changeEvery;
|
|
1167
|
+
const dims = this.origin.length;
|
|
1168
|
+
const next = new Array(dims);
|
|
1169
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1170
|
+
for (let i = 0; i < dims; i++) next[i] = (this.origin[i] ?? 0) + this.rng.range(-this.radius, this.radius);
|
|
1171
|
+
if (distance$1(next, this.origin) <= this.radius) {
|
|
1172
|
+
this.goal = next;
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
this.goal = next.map((c, i) => {
|
|
1177
|
+
const o = this.origin[i] ?? 0;
|
|
1178
|
+
const d = distance$1(next, this.origin) || 1;
|
|
1179
|
+
return o + (c - o) / d * this.radius;
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
//#endregion
|
|
1184
|
+
//#region src/gameplay/wave-spawner.ts
|
|
1185
|
+
/**
|
|
1186
|
+
* Sequenced enemy waves — the survivor/tower-defense backbone. Each entry in
|
|
1187
|
+
* `waves` is `{ prefab, count, interval, delayBefore }`: after `delayBefore`
|
|
1188
|
+
* seconds it spawns `count` clones of `prefab` (a hidden template path) one
|
|
1189
|
+
* every `interval` seconds, then waits until they're ALL cleared (freed) before
|
|
1190
|
+
* starting the next wave.
|
|
1191
|
+
*
|
|
1192
|
+
* Signals: `waveStarted(i)` when a wave begins spawning, `waveCleared(i)` when
|
|
1193
|
+
* its last instance frees, `allCleared` after the final wave clears.
|
|
1194
|
+
*/
|
|
1195
|
+
var WaveSpawner = class extends Behavior {
|
|
1196
|
+
static props = {
|
|
1197
|
+
/** Array of { prefab, count, interval, delayBefore }. */
|
|
1198
|
+
waves: { default: [] },
|
|
1199
|
+
/** Begin the first wave's countdown at ready. */
|
|
1200
|
+
autoStart: { default: true }
|
|
1201
|
+
};
|
|
1202
|
+
static signals = [
|
|
1203
|
+
"waveStarted",
|
|
1204
|
+
"waveCleared",
|
|
1205
|
+
"allCleared"
|
|
1206
|
+
];
|
|
1207
|
+
waves = [];
|
|
1208
|
+
autoStart = true;
|
|
1209
|
+
source = new SpawnSource(this);
|
|
1210
|
+
parsed = [];
|
|
1211
|
+
live = /* @__PURE__ */ new Set();
|
|
1212
|
+
waveIndex = -1;
|
|
1213
|
+
phase = "idle";
|
|
1214
|
+
timer = 0;
|
|
1215
|
+
spawnedThisWave = 0;
|
|
1216
|
+
announced = false;
|
|
1217
|
+
onReady() {
|
|
1218
|
+
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" });
|
|
1219
|
+
this.parsed = this.waves.map((raw, i) => this.parseWave(raw, i));
|
|
1220
|
+
for (const w of this.parsed) this.source.resolveTemplate(w.prefab);
|
|
1221
|
+
if (this.autoStart) this.beginWave(0);
|
|
1222
|
+
}
|
|
1223
|
+
/** Start (or restart from) wave 0. */
|
|
1224
|
+
start() {
|
|
1225
|
+
this.beginWave(0);
|
|
1226
|
+
}
|
|
1227
|
+
update(dt) {
|
|
1228
|
+
if (this.phase === "idle" || this.phase === "done") return;
|
|
1229
|
+
const wave = this.parsed[this.waveIndex];
|
|
1230
|
+
if (!wave) return;
|
|
1231
|
+
if (!this.announced) {
|
|
1232
|
+
this.announced = true;
|
|
1233
|
+
this.emit("waveStarted", this.waveIndex);
|
|
1234
|
+
}
|
|
1235
|
+
if (this.phase === "delay") {
|
|
1236
|
+
this.timer -= dt;
|
|
1237
|
+
if (this.timer <= 0) {
|
|
1238
|
+
this.phase = "spawning";
|
|
1239
|
+
this.timer = 0;
|
|
1240
|
+
}
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
if (this.phase === "spawning") {
|
|
1244
|
+
this.timer -= dt;
|
|
1245
|
+
while (this.timer <= 0 && this.spawnedThisWave < wave.count) {
|
|
1246
|
+
const clone = this.source.spawn(this.source.resolveTemplate(wave.prefab));
|
|
1247
|
+
this.live.add(clone);
|
|
1248
|
+
this.spawnedThisWave += 1;
|
|
1249
|
+
this.timer += wave.interval;
|
|
1250
|
+
}
|
|
1251
|
+
if (this.spawnedThisWave >= wave.count) this.phase = "clearing";
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
if (this.phase === "clearing") {
|
|
1255
|
+
this.prune();
|
|
1256
|
+
if (this.live.size === 0) {
|
|
1257
|
+
this.emit("waveCleared", this.waveIndex);
|
|
1258
|
+
const next = this.waveIndex + 1;
|
|
1259
|
+
if (next < this.parsed.length) this.beginWave(next);
|
|
1260
|
+
else {
|
|
1261
|
+
this.phase = "done";
|
|
1262
|
+
this.emit("allCleared");
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
beginWave(i) {
|
|
1268
|
+
this.waveIndex = i;
|
|
1269
|
+
this.spawnedThisWave = 0;
|
|
1270
|
+
this.announced = false;
|
|
1271
|
+
this.live.clear();
|
|
1272
|
+
const wave = this.parsed[i];
|
|
1273
|
+
if (!wave) return;
|
|
1274
|
+
if (wave.delayBefore > 0) {
|
|
1275
|
+
this.phase = "delay";
|
|
1276
|
+
this.timer = wave.delayBefore;
|
|
1277
|
+
} else {
|
|
1278
|
+
this.phase = "spawning";
|
|
1279
|
+
this.timer = 0;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
prune() {
|
|
1283
|
+
for (const node of this.live) if (node.parent === null) this.live.delete(node);
|
|
1284
|
+
}
|
|
1285
|
+
parseWave(raw, i) {
|
|
1286
|
+
const w = raw;
|
|
1287
|
+
const prefab = typeof w?.prefab === "string" ? w.prefab : "";
|
|
1288
|
+
if (prefab === "") throw new IncantoError("PROP_TYPE_MISMATCH", `WaveSpawner on '${this.node.getPath()}': wave ${i} needs a "prefab" template path.`, { prop: "waves" });
|
|
1289
|
+
return {
|
|
1290
|
+
prefab,
|
|
1291
|
+
count: typeof w?.count === "number" ? w.count : 1,
|
|
1292
|
+
interval: typeof w?.interval === "number" ? w.interval : .5,
|
|
1293
|
+
delayBefore: typeof w?.delayBefore === "number" ? w.delayBefore : 0
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region src/gameplay/zombie-ai.ts
|
|
1299
|
+
/**
|
|
1300
|
+
* The staple "zombie / monster" AI: SHAMBLE around on its own, then LOCK ON and
|
|
1301
|
+
* CHARGE a target (the player) once it wanders within `aggroRange` — giving up
|
|
1302
|
+
* again past `deAggroRange` (hysteresis, so it doesn't flicker at the boundary).
|
|
1303
|
+
* One behavior covers both phases so a single node can be a complete enemy
|
|
1304
|
+
* (`Wander` + `Chase` can't co-exist — a node carries ONE behavior).
|
|
1305
|
+
*
|
|
1306
|
+
* While NOT aggroed it roams: with `goalTarget` set it DRIFTS toward that node
|
|
1307
|
+
* (e.g. the objective the horde is marching on) with random lateral jitter, so it
|
|
1308
|
+
* reads as wandering yet still advances; without one it roams around its spawn.
|
|
1309
|
+
*
|
|
1310
|
+
* Emits `movementStateChanged('idle'|'walk'|'run')` on every change so a skin can
|
|
1311
|
+
* swap animation clips (shamble while roaming, sprint while charging), plus
|
|
1312
|
+
* `enteredAggro` / `exitedAggro` (wire to a growl, a glow, a speed-up).
|
|
1313
|
+
*
|
|
1314
|
+
* Dimension-agnostic; in 3D it moves only in the ground plane (x,z) and leaves
|
|
1315
|
+
* the up axis (y) to physics, so a CharacterBody3D settles on terrain.
|
|
1316
|
+
*
|
|
1317
|
+
* `moveParent` (default false) moves the parent instead of this node — the
|
|
1318
|
+
* AI-on-a-child pattern, so the entity ROOT can hold `Health` (clone-safe
|
|
1319
|
+
* `freeOnDeath`) while this child drives movement.
|
|
1320
|
+
*/
|
|
1321
|
+
var ZombieAI = class extends Behavior {
|
|
1322
|
+
static props = {
|
|
1323
|
+
/** NodePath of the node to charge once near (usually the player). */
|
|
1324
|
+
aggroTarget: { default: "" },
|
|
1325
|
+
/** Start charging when the target is within this distance. */
|
|
1326
|
+
aggroRange: { default: 12 },
|
|
1327
|
+
/** Give up the charge past this distance (0 = aggroRange × 1.5). */
|
|
1328
|
+
deAggroRange: { default: 0 },
|
|
1329
|
+
/** Units/second while charging the target. */
|
|
1330
|
+
chaseSpeed: { default: 4 },
|
|
1331
|
+
/** Units/second while roaming. */
|
|
1332
|
+
wanderSpeed: { default: 1.4 },
|
|
1333
|
+
/** Roam jitter radius (around spawn, or around the drift point). */
|
|
1334
|
+
wanderRadius: { default: 8 },
|
|
1335
|
+
/** Seconds before forcibly choosing a new roam goal. */
|
|
1336
|
+
wanderChangeEvery: { default: 3 },
|
|
1337
|
+
/** Optional NodePath to DRIFT toward while roaming (e.g. the objective). */
|
|
1338
|
+
goalTarget: { default: "" },
|
|
1339
|
+
/** Stop (and idle) this close to the active target. */
|
|
1340
|
+
stopRange: { default: 1.2 },
|
|
1341
|
+
/** Move the parent node instead of this one (AI-on-a-child pattern). */
|
|
1342
|
+
moveParent: { default: false }
|
|
1343
|
+
};
|
|
1344
|
+
static signals = [
|
|
1345
|
+
"enteredAggro",
|
|
1346
|
+
"exitedAggro",
|
|
1347
|
+
"movementStateChanged"
|
|
1348
|
+
];
|
|
1349
|
+
aggroTarget = "";
|
|
1350
|
+
aggroRange = 12;
|
|
1351
|
+
deAggroRange = 0;
|
|
1352
|
+
chaseSpeed = 4;
|
|
1353
|
+
wanderSpeed = 1.4;
|
|
1354
|
+
wanderRadius = 8;
|
|
1355
|
+
wanderChangeEvery = 3;
|
|
1356
|
+
goalTarget = "";
|
|
1357
|
+
stopRange = 1.2;
|
|
1358
|
+
moveParent = false;
|
|
1359
|
+
aggro = false;
|
|
1360
|
+
started = false;
|
|
1361
|
+
home = [];
|
|
1362
|
+
goal = [];
|
|
1363
|
+
timer = 0;
|
|
1364
|
+
lastState = "";
|
|
1365
|
+
/** The node we actually move (the parent under moveParent, else this one). */
|
|
1366
|
+
mover() {
|
|
1367
|
+
const n = this.moveParent ? this.node.parent : this.node;
|
|
1368
|
+
return n && hasPosition$1(n) ? n : null;
|
|
1369
|
+
}
|
|
1370
|
+
effectiveDeAggro() {
|
|
1371
|
+
return this.deAggroRange > 0 ? this.deAggroRange : this.aggroRange * 1.5;
|
|
1372
|
+
}
|
|
1373
|
+
onReady() {
|
|
1374
|
+
if (this.moveParent) {
|
|
1375
|
+
const parent = this.node.parent;
|
|
1376
|
+
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.`, {
|
|
1377
|
+
prop: "moveParent",
|
|
1378
|
+
path: this.node.getPath()
|
|
1379
|
+
});
|
|
1380
|
+
} else requirePosition(this);
|
|
1381
|
+
}
|
|
1382
|
+
update(dt) {
|
|
1383
|
+
const mover = this.mover();
|
|
1384
|
+
if (!mover) return;
|
|
1385
|
+
if (!this.started) {
|
|
1386
|
+
this.started = true;
|
|
1387
|
+
this.home = [...mover.position];
|
|
1388
|
+
this.pickGoal();
|
|
1389
|
+
}
|
|
1390
|
+
const target = this.aggroTarget ? this.node.getNodeOrNull(this.aggroTarget) : null;
|
|
1391
|
+
const targetPos = target && hasPosition$1(target) ? target.position : null;
|
|
1392
|
+
const aggroDist = targetPos ? this.groundDistance(mover.position, targetPos) : Number.POSITIVE_INFINITY;
|
|
1393
|
+
if (!this.aggro && aggroDist <= this.aggroRange) {
|
|
1394
|
+
this.aggro = true;
|
|
1395
|
+
this.emit("enteredAggro");
|
|
1396
|
+
} else if (this.aggro && aggroDist >= this.effectiveDeAggro()) {
|
|
1397
|
+
this.aggro = false;
|
|
1398
|
+
this.emit("exitedAggro");
|
|
1399
|
+
this.pickGoal();
|
|
1400
|
+
}
|
|
1401
|
+
if (this.aggro && targetPos) {
|
|
1402
|
+
const stopped = this.approach(mover, targetPos, this.chaseSpeed, dt);
|
|
1403
|
+
this.setState(stopped ? "idle" : "run");
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
this.timer -= dt;
|
|
1407
|
+
const reached = this.approach(mover, this.goal, this.wanderSpeed, dt);
|
|
1408
|
+
this.setState(reached ? "idle" : "walk");
|
|
1409
|
+
if (reached || this.timer <= 0) this.pickGoal();
|
|
1410
|
+
}
|
|
1411
|
+
/** Move `mover` toward `to` in the GROUND PLANE; returns true once within
|
|
1412
|
+
* stopRange (no overshoot). Leaves the up axis (3D y) to physics. */
|
|
1413
|
+
approach(mover, to, speed, dt) {
|
|
1414
|
+
const cur = mover.position;
|
|
1415
|
+
if (this.groundDistance(cur, to) <= this.stopRange) return true;
|
|
1416
|
+
const up = cur.length >= 3 ? 1 : -1;
|
|
1417
|
+
const goal = [...to];
|
|
1418
|
+
if (up >= 0) goal[up] = cur[up] ?? 0;
|
|
1419
|
+
const horiz = this.groundDistance(cur, goal);
|
|
1420
|
+
const { position } = moveToward(cur, goal, Math.min(speed * dt, Math.max(0, horiz - this.stopRange)));
|
|
1421
|
+
mover.position = position;
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
/** Distance ignoring the up axis in 3D (so terrain height never blocks aggro). */
|
|
1425
|
+
groundDistance(a, b) {
|
|
1426
|
+
if (a.length >= 3) {
|
|
1427
|
+
const dx = (a[0] ?? 0) - (b[0] ?? 0);
|
|
1428
|
+
const dz = (a[2] ?? 0) - (b[2] ?? 0);
|
|
1429
|
+
return Math.hypot(dx, dz);
|
|
1430
|
+
}
|
|
1431
|
+
return distance$1(a, b);
|
|
1432
|
+
}
|
|
1433
|
+
setState(state) {
|
|
1434
|
+
if (state === this.lastState) return;
|
|
1435
|
+
this.lastState = state;
|
|
1436
|
+
this.emit("movementStateChanged", state);
|
|
1437
|
+
}
|
|
1438
|
+
pickGoal() {
|
|
1439
|
+
this.timer = this.wanderChangeEvery;
|
|
1440
|
+
const mover = this.mover();
|
|
1441
|
+
const cur = mover ? mover.position : this.home;
|
|
1442
|
+
const up = cur.length >= 3 ? 1 : -1;
|
|
1443
|
+
let basis = this.home;
|
|
1444
|
+
const goalNode = this.goalTarget ? this.node.getNodeOrNull(this.goalTarget) : null;
|
|
1445
|
+
if (goalNode && hasPosition$1(goalNode)) {
|
|
1446
|
+
const gp = goalNode.position;
|
|
1447
|
+
basis = cur.map((c, i) => c + ((gp[i] ?? 0) - c) * .4);
|
|
1448
|
+
}
|
|
1449
|
+
const next = [...basis];
|
|
1450
|
+
for (let i = 0; i < next.length; i++) {
|
|
1451
|
+
if (i === up) continue;
|
|
1452
|
+
next[i] = (basis[i] ?? 0) + this.rng.range(-this.wanderRadius, this.wanderRadius);
|
|
1453
|
+
}
|
|
1454
|
+
if (up >= 0) next[up] = cur[up] ?? 0;
|
|
1455
|
+
this.goal = next;
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
//#endregion
|
|
1459
|
+
//#region src/gameplay/index.ts
|
|
1460
|
+
/**
|
|
1461
|
+
* incanto/gameplay — batteries-included, JSON-wireable game logic.
|
|
1462
|
+
*
|
|
1463
|
+
* Ready-made `Behavior`s for the things every game re-invents: health, score,
|
|
1464
|
+
* pickups, lifetimes, contact damage, interaction. All pure logic (no `three`),
|
|
1465
|
+
* dimension-agnostic, composed through scene-JSON `script` blobs + `connections`.
|
|
1466
|
+
*
|
|
1467
|
+
* `createGame2D`/`createGame3D` auto-register these (before user behaviors, so
|
|
1468
|
+
* a same-named user behavior always wins). Opt out with `gameplay: false`, or
|
|
1469
|
+
* register a subset yourself via `registerBehavior(name, Class)`.
|
|
1470
|
+
*/
|
|
1471
|
+
/** Every built-in gameplay behavior, keyed by its registration name. */
|
|
1472
|
+
const GAMEPLAY_BEHAVIORS = {
|
|
1473
|
+
Health,
|
|
1474
|
+
Lifetime,
|
|
1475
|
+
ScoreKeeper,
|
|
1476
|
+
Pickup,
|
|
1477
|
+
Collector,
|
|
1478
|
+
DamageOnContact,
|
|
1479
|
+
Interactable,
|
|
1480
|
+
FollowCamera,
|
|
1481
|
+
Patrol,
|
|
1482
|
+
Chase,
|
|
1483
|
+
Wander,
|
|
1484
|
+
ZombieAI,
|
|
1485
|
+
MoveTo,
|
|
1486
|
+
Oscillate,
|
|
1487
|
+
Spawner,
|
|
1488
|
+
WaveSpawner,
|
|
1489
|
+
Projectile
|
|
1490
|
+
};
|
|
1491
|
+
/**
|
|
1492
|
+
* Register all built-in gameplay behaviors. Idempotent and hot-reload tolerant
|
|
1493
|
+
* (`replace: true`) — calling it twice, or after a user already registered a
|
|
1494
|
+
* same-named behavior, is safe; pass `replace: false` to fail on conflicts.
|
|
1495
|
+
*/
|
|
1496
|
+
function registerGameplayBehaviors(opts) {
|
|
1497
|
+
const replace = opts?.replace ?? true;
|
|
1498
|
+
for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
|
|
1499
|
+
}
|
|
1500
|
+
//#endregion
|
|
1501
|
+
export { Health as _, Wander as a, Projectile as c, Oscillate as d, MoveTo as f, DamageOnContact as g, FollowCamera as h, WaveSpawner as i, Pickup as l, Interactable as m, registerGameplayBehaviors as n, Spawner as o, Lifetime as p, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, Patrol as u, Collector as v, Chase as y };
|