incanto 0.12.0 → 0.13.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.
Files changed (82) hide show
  1. package/README.md +22 -0
  2. package/bin/incanto-new.mjs +87 -0
  3. package/dist/2d.d.ts +2 -2
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +194 -71
  6. package/dist/3d.js +74 -4
  7. package/dist/{behavior-Bod1AyJS.d.ts → behavior-CPibUfnH.d.ts} +4 -2
  8. package/dist/{create-game-C_xf4gm9.js → create-game-Ct86zczq.js} +5 -5
  9. package/dist/{create-game-B2fewJUy.js → create-game-J6g4QInv.js} +5 -5
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/{duplicate-BEvGBtb_.js → duplicate-BINwP0WG.js} +1 -1
  12. package/dist/{gameplay-Dn3yidJw.js → gameplay-sSqrUcnz.js} +75 -3
  13. package/dist/gameplay.d.ts +37 -2
  14. package/dist/gameplay.js +2 -2
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +4 -4
  17. package/dist/{loader-B4OEXDZ8.js → loader-BZqOKfI2.js} +3 -1
  18. package/dist/{loader-BcUDfNHn.d.ts → loader-DILt9PGC.d.ts} +1 -1
  19. package/dist/net.d.ts +1 -1
  20. package/dist/net.js +3 -3
  21. package/dist/{pathfinding-BSiXjd6W.d.ts → pathfinding-RWYkNKx9.d.ts} +1 -1
  22. package/dist/{physics-2d-B6D864oZ.js → physics-2d-BiIdl51r.js} +2 -2
  23. package/dist/{physics-3d-RLNaxRyw.js → physics-3d-DhaAaKJo.js} +2 -2
  24. package/dist/react.d.ts +1 -1
  25. package/dist/react.js +1 -1
  26. package/dist/{register-Cwc-yCYx.js → register-BFFE1Mh1.js} +2 -2
  27. package/dist/{register-ysLR3WQS.js → register-BFw4c83z.js} +508 -123
  28. package/dist/{register-BD6zgrv_.js → register-Djwckzgx.js} +2 -2
  29. package/dist/{register-DJZXxwAn.js → register-nObreUQR.js} +1 -1
  30. package/dist/test.d.ts +2 -2
  31. package/dist/test.js +10 -10
  32. package/editor/assets/{agent8-D_4QY8Aq.js → agent8-BF8Jfbkl.js} +1 -1
  33. package/editor/assets/{index-s0DZuroe.js → index-DSq0gdhE.js} +74 -74
  34. package/editor/index.html +1 -1
  35. package/package.json +4 -2
  36. package/schemas/scene.schema.json +263 -0
  37. package/skills/incanto-3d-character.md +35 -0
  38. package/skills/incanto-building-3d-games.md +48 -0
  39. package/skills/incanto-gameplay-behaviors.md +23 -1
  40. package/skills/incanto-node-reference.md +51 -0
  41. package/templates-app/beacon-isle-3d/PROJECT/Context.md +44 -0
  42. package/templates-app/beacon-isle-3d/PROJECT/Requirements.md +16 -0
  43. package/templates-app/beacon-isle-3d/PROJECT/Status.md +38 -0
  44. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +24 -0
  45. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +38 -0
  46. package/templates-app/beacon-isle-3d/generate-world.ts +814 -0
  47. package/templates-app/beacon-isle-3d/index.html +68 -0
  48. package/templates-app/beacon-isle-3d/package.json +26 -0
  49. package/templates-app/beacon-isle-3d/src/behaviors.ts +570 -0
  50. package/templates-app/beacon-isle-3d/src/game.scene.json +2992 -0
  51. package/templates-app/beacon-isle-3d/src/main.ts +33 -0
  52. package/templates-app/beacon-isle-3d/tsconfig.json +13 -0
  53. package/templates-app/beacon-isle-3d/verify.ts +285 -0
  54. package/templates-app/beacon-isle-3d/vite.config.ts +5 -0
  55. package/templates-app/tps-3d/PROJECT/Context.md +49 -0
  56. package/templates-app/tps-3d/PROJECT/Requirements.md +38 -0
  57. package/templates-app/tps-3d/PROJECT/Status.md +41 -0
  58. package/templates-app/tps-3d/PROJECT/Structure.md +44 -0
  59. package/templates-app/tps-3d/docs/project-3d-rules.md +38 -0
  60. package/templates-app/tps-3d/index.html +244 -0
  61. package/templates-app/tps-3d/package.json +25 -0
  62. package/templates-app/tps-3d/src/behaviors.ts +405 -0
  63. package/templates-app/tps-3d/src/game.scene.json +677 -0
  64. package/templates-app/tps-3d/src/main.ts +64 -0
  65. package/templates-app/tps-3d/tsconfig.json +13 -0
  66. package/templates-app/tps-3d/verify.ts +167 -0
  67. package/templates-app/tps-3d/vite.config.ts +5 -0
  68. package/templates-app/village-quest-3d/PROJECT/Context.md +39 -0
  69. package/templates-app/village-quest-3d/PROJECT/Requirements.md +36 -0
  70. package/templates-app/village-quest-3d/PROJECT/Status.md +26 -0
  71. package/templates-app/village-quest-3d/PROJECT/Structure.md +30 -0
  72. package/templates-app/village-quest-3d/docs/project-3d-rules.md +38 -0
  73. package/templates-app/village-quest-3d/index.html +68 -0
  74. package/templates-app/village-quest-3d/package.json +25 -0
  75. package/templates-app/village-quest-3d/src/behaviors.ts +529 -0
  76. package/templates-app/village-quest-3d/src/grove.scene.json +826 -0
  77. package/templates-app/village-quest-3d/src/main.ts +35 -0
  78. package/templates-app/village-quest-3d/src/quest.ts +18 -0
  79. package/templates-app/village-quest-3d/src/village.scene.json +680 -0
  80. package/templates-app/village-quest-3d/tsconfig.json +13 -0
  81. package/templates-app/village-quest-3d/verify.ts +254 -0
  82. package/templates-app/village-quest-3d/vite.config.ts +5 -0
@@ -0,0 +1,68 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2220%22 fill=%22%232a4a6b%22/><text x=%2250%22 y=%2268%22 font-size=%2255%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-weight=%22bold%22 fill=%22%23ffd166%22>bi</text></svg>" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Beacon Isle — Incanto 3D template</title>
8
+ <style>
9
+ html,
10
+ body {
11
+ margin: 0;
12
+ padding: 0;
13
+ height: 100%;
14
+ overflow: hidden;
15
+ font-family: Arial, sans-serif;
16
+ background: #10130d;
17
+ }
18
+ canvas {
19
+ display: block;
20
+ width: 100vw;
21
+ height: 100vh;
22
+ }
23
+ .loading-container {
24
+ position: fixed;
25
+ inset: 0;
26
+ z-index: 50;
27
+ display: flex;
28
+ flex-direction: column;
29
+ align-items: center;
30
+ justify-content: center;
31
+ background: black;
32
+ color: white;
33
+ }
34
+ .loading-title {
35
+ font-size: 1.5rem;
36
+ font-weight: bold;
37
+ margin-bottom: 1rem;
38
+ }
39
+ .progress-track {
40
+ width: 240px;
41
+ height: 8px;
42
+ border-radius: 4px;
43
+ background: #2a2f26;
44
+ overflow: hidden;
45
+ }
46
+ #progress {
47
+ width: 0%;
48
+ height: 100%;
49
+ background: #ffd166;
50
+ transition: width 0.15s;
51
+ }
52
+ #progress-text {
53
+ margin-top: 8px;
54
+ font-size: 0.85rem;
55
+ color: #aaa;
56
+ }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <canvas></canvas>
61
+ <div id="loading" class="loading-container">
62
+ <div class="loading-title">Beacon Isle</div>
63
+ <div class="progress-track"><div id="progress"></div></div>
64
+ <div id="progress-text">0%</div>
65
+ </div>
66
+ <script type="module" src="/src/main.ts"></script>
67
+ </body>
68
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "beacon-isle-3d",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "typecheck": "tsc --noEmit",
9
+ "check": "incanto-check src",
10
+ "verify": "bun verify.ts",
11
+ "world": "bun generate-world.ts"
12
+ },
13
+ "dependencies": {
14
+ "@dimforge/rapier2d-compat": "0.19.3",
15
+ "@dimforge/rapier3d-compat": "0.19.3",
16
+ "@pixiv/three-vrm": "^3.5.3",
17
+ "incanto": "^0.13.0",
18
+ "three": "^0.184.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^24.0.0",
22
+ "@types/three": "^0.184.1",
23
+ "typescript": "^6.0.3",
24
+ "vite": "^8.0.16"
25
+ }
26
+ }
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Beacon Isle's game logic — two custom behaviors on top of engine built-ins.
3
+ *
4
+ * IsleDirector — the quest brain: keeper dialogue, TERRAIN-NAV shade AI
5
+ * (buildTerrainNav + findPath: patrol rings around each ward,
6
+ * hunt the player across the island surface), ward relighting,
7
+ * win fireworks, death/respawn.
8
+ * SwordStrike — the melee-clip strike from Emberwood, retargeted at shades.
9
+ *
10
+ * Movement/camera/animations, NPC proximity, dialogue, HUD, touch controls,
11
+ * contact damage — all engine surface declared in game.scene.json.
12
+ */
13
+ import type {
14
+ AudioPlayer,
15
+ Engine,
16
+ JsonObject,
17
+ Node,
18
+ UiBanner,
19
+ UiDialogue,
20
+ UiText,
21
+ Voice,
22
+ } from 'incanto';
23
+ import { Behavior, findPath } from 'incanto';
24
+ import type { MeshInstance3D, Node3D, Terrain3D, Trail3D } from 'incanto/3d';
25
+ import { buildTerrainNav, Particles3D, type TerrainNav } from 'incanto/3d';
26
+ import { type DayNight, goToScene } from 'incanto/gameplay';
27
+ import gameJsonRaw from './game.scene.json';
28
+
29
+ const gameJson = gameJsonRaw as unknown as JsonObject;
30
+
31
+ /** Cross-respawn quest state (module singleton survives goToScene). */
32
+ export const quest = {
33
+ accepted: false,
34
+ lit: new Set<number>(),
35
+ bossRisen: false,
36
+ bossDown: false,
37
+ done: false,
38
+ };
39
+ export function resetQuest(): void {
40
+ quest.accepted = false;
41
+ quest.lit.clear();
42
+ quest.bossRisen = false;
43
+ quest.bossDown = false;
44
+ quest.done = false;
45
+ }
46
+
47
+ interface Positioned {
48
+ position: number[];
49
+ }
50
+
51
+ const AGGRO = 9;
52
+ const CALM = 13;
53
+ const PATROL_SPEED = 2.6;
54
+ const HUNT_SPEED = 3.6;
55
+ const WARD_LIGHT_RANGE = 4;
56
+
57
+ export class IsleDirector extends Behavior {
58
+ private wind: Voice | null = null;
59
+ private nav: TerrainNav | null = null;
60
+ private repathClock = 0;
61
+ private hunting = new Set<string>();
62
+ private respawnIn = 0;
63
+ private bobClock = 0;
64
+ private fireworksIn = -1;
65
+ private glideActive = false;
66
+
67
+ private get dialogue(): UiDialogue {
68
+ return this.node.getNode('HUD/Dialogue') as UiDialogue;
69
+ }
70
+
71
+ override onReady(): void {
72
+ const engine = this.node.tree?.engine;
73
+ this.wind = engine?.sfx.startVoice('wind', 0.7) ?? null;
74
+ const surface = this.node.getNode('Terrain/Ground/Surface') as Terrain3D;
75
+ const sea = this.node.getNode('Terrain/Sea') as unknown as Positioned;
76
+ this.nav = buildTerrainNav(surface, {
77
+ cellSize: 3,
78
+ maxSlopeDeg: 38,
79
+ minHeight: (sea.position[1] ?? 0) + 0.6,
80
+ });
81
+ this.assignPatrols();
82
+ this.syncQuestLine();
83
+ this.dialogue.on('choiceMade', (index) => {
84
+ if (index === 0 && !quest.accepted) {
85
+ quest.accepted = true;
86
+ (this.node.getNode('Keeper/AcceptSfx') as AudioPlayer).play();
87
+ this.syncQuestLine();
88
+ }
89
+ });
90
+ // rebuild already-lit wards after a respawn
91
+ for (const i of quest.lit) this.applyWardLit(i, false);
92
+ }
93
+
94
+ override onExitTree(): void {
95
+ this.wind?.stop();
96
+ }
97
+
98
+ /** Ring patrols AROUND each ward, on the island surface. */
99
+ private assignPatrols(): void {
100
+ const nav = this.nav;
101
+ if (!nav) return;
102
+ for (const node of this.node.tree?.getNodesInGroup('shade') ?? []) {
103
+ const shade = node as Node3D;
104
+ const wardIdx = wardOf(shade);
105
+ const ward = this.node.getNodeOrNull(`Ward${wardIdx}`) as unknown as Positioned | null;
106
+ if (!ward) continue;
107
+ const [wx, , wz] = ward.position;
108
+ const [sx, , sz] = shade.position;
109
+ const from = nav.toCell(sx ?? 0, sz ?? 0);
110
+ const to = nav.toCell((wx ?? 0) - 8, (wz ?? 0) + 7);
111
+ const out = findPath(nav.grid, from, to, { diagonal: false });
112
+ if (!out || out.length < 2) continue;
113
+ const ring = [...out, ...out.slice(1, -1).reverse()];
114
+ const follow = shade.behavior as unknown as { setPath(w: number[][]): void; loop: boolean };
115
+ follow.loop = true;
116
+ follow.setPath(ring.map(([cx, cy]) => hover(nav.toWorld(cx, cy))));
117
+ }
118
+ }
119
+
120
+ override update(dt: number): void {
121
+ const engine = this.node.tree?.engine;
122
+ this.ambient(dt, engine);
123
+ this.huntOrPatrol(dt);
124
+ this.syncHp();
125
+ this.checkWards();
126
+ this.driveTimeOfDay(dt);
127
+ this.bossBrain(dt);
128
+ this.glide(engine);
129
+ if (engine && this.dialogue.active && engine.input.justPressed('interact')) {
130
+ this.dialogue.advance();
131
+ }
132
+ if (this.respawnIn > 0) {
133
+ this.respawnIn -= dt;
134
+ if (this.respawnIn <= 0 && engine) goToScene(engine, gameJson, { fadeSeconds: 0.6 });
135
+ }
136
+ if (this.fireworksIn >= 0) {
137
+ this.fireworksIn -= dt;
138
+ if (this.fireworksIn < 0) this.launchFireworks();
139
+ }
140
+ }
141
+
142
+ private ambient(dt: number, engine: Engine | null | undefined): void {
143
+ const controller = this.node.getNode('Player/Controller') as unknown as {
144
+ state: string;
145
+ } | null;
146
+ if (controller && this.wind && engine) {
147
+ const w =
148
+ controller.state === 'fastRun'
149
+ ? { pitch: 1.5, volume: 0.5 }
150
+ : controller.state === 'run'
151
+ ? { pitch: 1.15, volume: 0.34 }
152
+ : { pitch: 0.85, volume: 0.2 };
153
+ this.wind.set(w);
154
+ }
155
+ const dash = this.node.getNodeOrNull('Player/DashTrail') as Trail3D | null;
156
+ if (dash && controller) dash.emitting = controller.state === 'fastRun';
157
+ // shades hover-bob and face their travel
158
+ this.bobClock += dt;
159
+ for (const node of this.node.tree?.getNodesInGroup('shade') ?? []) {
160
+ const shade = node as Node3D & { _lastPos?: number[] };
161
+ const body = shade.getNodeOrNull('Body') as Node3D | null;
162
+ if (body)
163
+ body.position = [0, 0.12 * Math.sin(this.bobClock * 2.4 + (shade.position[0] ?? 0)), 0];
164
+ const [x, , z] = shade.position;
165
+ const last = shade._lastPos ?? [x ?? 0, 0, z ?? 0];
166
+ const dx = (x ?? 0) - (last[0] ?? 0);
167
+ const dz = (z ?? 0) - (last[2] ?? 0);
168
+ if (dx * dx + dz * dz > 1e-8) shade.rotation = [0, (Math.atan2(dx, dz) * 180) / Math.PI, 0];
169
+ shade._lastPos = [x ?? 0, 0, z ?? 0];
170
+ }
171
+ }
172
+
173
+ /** Shades hunt across the ISLAND: findPath re-pathing on the terrain nav. */
174
+ private huntOrPatrol(dt: number): void {
175
+ this.repathClock -= dt;
176
+ if (this.repathClock > 0) return;
177
+ this.repathClock = 0.4;
178
+ const nav = this.nav;
179
+ const player = this.node.getNodeOrNull('Player') as unknown as Positioned | null;
180
+ if (!nav || !player) return;
181
+ const [px, , pz] = player.position;
182
+ for (const node of this.node.tree?.getNodesInGroup('shade') ?? []) {
183
+ const shade = node as Node3D;
184
+ const follow = shade.behavior as unknown as {
185
+ setPath(w: number[][]): void;
186
+ loop: boolean;
187
+ speed: number;
188
+ };
189
+ const [sx, , sz] = shade.position;
190
+ const dist = Math.hypot((px ?? 0) - (sx ?? 0), (pz ?? 0) - (sz ?? 0));
191
+ const was = this.hunting.has(shade.name);
192
+ if (dist < (was ? CALM : AGGRO)) {
193
+ if (!was) {
194
+ this.hunting.add(shade.name);
195
+ (shade.getNodeOrNull('HissSfx') as AudioPlayer | null)?.play();
196
+ }
197
+ const cells = findPath(
198
+ nav.grid,
199
+ nav.toCell(sx ?? 0, sz ?? 0),
200
+ nav.toCell(px ?? 0, pz ?? 0),
201
+ {
202
+ diagonal: false,
203
+ },
204
+ );
205
+ if (cells && cells.length > 1) {
206
+ follow.loop = false;
207
+ follow.speed = HUNT_SPEED;
208
+ follow.setPath(cells.slice(1).map(([cx, cy]) => hover(nav.toWorld(cx, cy))));
209
+ }
210
+ } else if (was) {
211
+ this.hunting.delete(shade.name);
212
+ follow.speed = PATROL_SPEED;
213
+ }
214
+ }
215
+ // drifting hunters that lost aggro pick their patrol back up
216
+ if (this.hunting.size === 0) return;
217
+ }
218
+
219
+ private syncHp(): void {
220
+ const health = this.node.getNodeOrNull('Player')?.behavior as unknown as {
221
+ current: number;
222
+ max: number;
223
+ } | null;
224
+ const bar = this.node.getNode('HUD/HP') as unknown as { value: number; max: number };
225
+ if (health) {
226
+ bar.value = health.current;
227
+ bar.max = health.max;
228
+ }
229
+ }
230
+
231
+ /** The island's clock is the QUEST: dusk deepens toward night while wards
232
+ * are dark, each lit ward pulls the light back, victory brings morning. */
233
+ private driveTimeOfDay(dt: number): void {
234
+ const sky = this.node.getNodeOrNull('Sky')?.behavior as DayNight | null;
235
+ if (!sky) return;
236
+ // ATMOSPHERE RULE: keep the sun ABOVE the horizon — below it the sky
237
+ // (and its IBL) go black and no ambient floor can save the frame.
238
+ const targetHour = quest.done
239
+ ? 9.5 // morning after the fireworks
240
+ : quest.bossDown
241
+ ? 8.7
242
+ : quest.bossRisen
243
+ ? 17.8 // sun ~+2 deg — deep red horizon, still lit (below 18 the sky dies)
244
+ : 17.5 - quest.lit.size * 0.35; // each ward buys back the light
245
+ sky.hour += (targetHour - sky.hour) * Math.min(1, dt * 0.4);
246
+ }
247
+
248
+ /** The Shadeheart: rises when the third ward lights, hunts via the same
249
+ * terrain nav, falls after five strikes (SwordStrike counts them). */
250
+ private bossBrain(dt: number): void {
251
+ const boss = this.node.getNodeOrNull('Boss') as Node3D | null;
252
+ if (!boss) return;
253
+ if (!quest.bossRisen || quest.bossDown) return;
254
+ if (!boss.visible) {
255
+ boss.visible = true;
256
+ (boss.getNode('EyeGlow') as unknown as { intensity: number }).intensity = 7;
257
+ (boss.getNode('Dread') as Trail3D).emitting = true;
258
+ (boss.getNode('RoarSfx') as AudioPlayer).play();
259
+ (this.node.getNode('HUD/Banner') as UiBanner).show('THE SHADEHEART RISES');
260
+ }
261
+ // slow relentless pursuit across the island (re-path with the pack timer)
262
+ this.bossRepath -= dt;
263
+ if (this.bossRepath > 0) return;
264
+ this.bossRepath = 0.6;
265
+ const nav = this.nav;
266
+ const player = this.node.getNodeOrNull('Player') as unknown as Positioned | null;
267
+ if (!nav || !player) return;
268
+ const [bx, , bz] = boss.position;
269
+ const cells = findPath(
270
+ nav.grid,
271
+ nav.toCell(bx ?? 0, bz ?? 0),
272
+ nav.toCell(player.position[0] ?? 0, player.position[2] ?? 0),
273
+ { diagonal: false },
274
+ );
275
+ if (cells && cells.length > 1) {
276
+ const follow = boss.behavior as unknown as { setPath(w: number[][]): void };
277
+ follow.setPath(cells.slice(1).map(([cx, cy]) => nav.toWorld(cx, cy)));
278
+ }
279
+ // face the player
280
+ const dx = (player.position[0] ?? 0) - (bx ?? 0);
281
+ const dz = (player.position[2] ?? 0) - (bz ?? 0);
282
+ if (dx * dx + dz * dz > 1) boss.rotation = [0, (Math.atan2(dx, dz) * 180) / Math.PI, 0];
283
+ }
284
+
285
+ private bossRepath = 0;
286
+
287
+ /** Hold JUMP while falling to glide — soft descent + a little forward push. */
288
+ private glide(engine: Engine | null | undefined): void {
289
+ const player = this.node.getNodeOrNull('Player') as unknown as
290
+ | (Node & Positioned & { linearVelocity: number[] })
291
+ | null;
292
+ const controller = this.node.getNodeOrNull('Player/Controller') as unknown as {
293
+ state: string;
294
+ } | null;
295
+ const dash = this.node.getNodeOrNull('Player/DashTrail') as Trail3D | null;
296
+ if (!engine || !player || !controller) return;
297
+ const falling = controller.state === 'airborne' && (player.linearVelocity[1] ?? 0) < -1;
298
+ const wantGlide = falling && engine.input.isPressed('jump');
299
+ if (wantGlide) {
300
+ const v = player.linearVelocity;
301
+ player.linearVelocity = [(v[0] ?? 0) * 1.01, Math.max(v[1] ?? 0, -1.6), (v[2] ?? 0) * 1.01];
302
+ if (!this.glideActive && dash) dash.emitting = true;
303
+ this.glideActive = true;
304
+ } else if (this.glideActive) {
305
+ this.glideActive = false;
306
+ }
307
+ }
308
+
309
+ /** Standing at a DARK ward with its shades down + pressing E relights it. */
310
+ private checkWards(): void {
311
+ const engine = this.node.tree?.engine;
312
+ if (!engine || !quest.accepted || !engine.input.justPressed('interact')) return;
313
+ const player = this.node.getNode('Player') as unknown as Positioned;
314
+ const [px, , pz] = player.position;
315
+ for (let i = 1; i <= 3; i++) {
316
+ if (quest.lit.has(i)) continue;
317
+ const ward = this.node.getNodeOrNull(`Ward${i}`) as unknown as (Node & Positioned) | null;
318
+ if (!ward) continue;
319
+ const [wx, , wz] = ward.position;
320
+ if (Math.hypot((wx ?? 0) - (px ?? 0), (wz ?? 0) - (pz ?? 0)) > WARD_LIGHT_RANGE) continue;
321
+ const guards = this.node.tree?.getNodesInGroup(`shade-ward${i}`) ?? [];
322
+ if (guards.length > 0) {
323
+ if (!this.dialogue.active) {
324
+ this.dialogue.say('???', 'The shades cling to this ward — drive them off first.');
325
+ }
326
+ return;
327
+ }
328
+ quest.lit.add(i);
329
+ this.applyWardLit(i, true);
330
+ if (quest.lit.size === 3 && !quest.bossDown) {
331
+ quest.bossRisen = true; // the Shadeheart answers the light
332
+ }
333
+ this.syncQuestLine();
334
+ return;
335
+ }
336
+ }
337
+
338
+ private applyWardLit(i: number, celebrate: boolean): void {
339
+ const ward = this.node.getNodeOrNull(`Ward${i}`);
340
+ if (!ward) return;
341
+ const crystal = ward.getNode('Crystal') as MeshInstance3D;
342
+ crystal.material = { ...crystal.material, emissiveIntensity: 2.6 };
343
+ (ward.getNode('Glow') as unknown as { intensity: number }).intensity = 2.4;
344
+ if (celebrate) {
345
+ (ward.getNode('LitFx') as Particles3D).replay();
346
+ (ward.getNode('ChimeSfx') as AudioPlayer).play();
347
+ }
348
+ }
349
+
350
+ private syncQuestLine(): void {
351
+ const quest3 = this.node.getNode('HUD/Quest') as UiText;
352
+ if (quest.done) quest3.text = 'The isle is safe. The lighthouse burns bright.';
353
+ else if (quest.bossDown) quest3.text = 'Return to the keeper [E]';
354
+ else if (quest.bossRisen) quest3.text = 'SLAY THE SHADEHEART — 5 strikes';
355
+ else if (quest.accepted) quest3.text = `Relight the ancient wards — ${quest.lit.size}/3 lit`;
356
+ else quest3.text = 'Find the lighthouse keeper [E]';
357
+ }
358
+
359
+ /** Keeper.interacted → here. */
360
+ onTalk(): void {
361
+ const talk = this.dialogue;
362
+ if (talk.active) return;
363
+ (this.node.getNode('Keeper/TalkSfx') as AudioPlayer).play();
364
+ if (quest.done) {
365
+ talk.say('Keeper', 'The wards hold. Sleep easy, wardlighter.');
366
+ return;
367
+ }
368
+ if (quest.bossRisen && !quest.bossDown) {
369
+ talk.say('Keeper', 'The Shadeheart itself?! Your blade, wardlighter — five true strikes!');
370
+ return;
371
+ }
372
+ if (quest.bossDown) {
373
+ quest.done = true;
374
+ talk.say('Keeper', 'The three lights... the isle breathes again!');
375
+ talk.say('Keeper', 'You have my thanks — and the isle has its WARDLIGHTER.');
376
+ talk.on('dialogueFinished', () => {
377
+ if (!quest.done) return;
378
+ (this.node.getNode('HUD/Banner') as UiBanner).show('BEACON ISLE SHINES');
379
+ (this.node.getNode('WinSfx') as AudioPlayer).play();
380
+ this.fireworksIn = 0.4;
381
+ this.syncQuestLine();
382
+ });
383
+ return;
384
+ }
385
+ if (quest.accepted) {
386
+ talk.say(
387
+ 'Keeper',
388
+ `${3 - quest.lit.size} ward(s) still dark. The shades guard them — your blade knows what to do.`,
389
+ );
390
+ return;
391
+ }
392
+ talk.say('Keeper', 'Traveler! The three ancient wards went dark last night...');
393
+ talk.say(
394
+ 'Keeper',
395
+ 'Shades gather where the light died. Drive them off and relight the wards?',
396
+ ['I will light them', 'Not yet'],
397
+ );
398
+ }
399
+
400
+ /** Player.died → here. */
401
+ onPlayerDown(): void {
402
+ if (this.respawnIn > 0) return;
403
+ this.respawnIn = 1.6;
404
+ (this.node.getNode('HUD/Banner') as UiBanner).show('THE SHADES TOOK YOU');
405
+ }
406
+
407
+ /** The win moment: staggered firework bursts over the lighthouse. */
408
+ private launchFireworks(): void {
409
+ const anchor = this.node.getNodeOrNull('Lighthouse') as unknown as Positioned | null;
410
+ const [lx, ly, lz] = anchor?.position ?? [0, 20, 0];
411
+ const colors = ['#ff6b6b', '#ffd166', '#6be3ff', '#b78bff'];
412
+ colors.forEach((color, i) => {
413
+ const poof = new Particles3D(`Firework${i}`);
414
+ poof.position = [(lx ?? 0) + (i - 1.5) * 4, (ly ?? 0) + 16 + (i % 2) * 3, lz ?? 0];
415
+ poof.preset = 'explosion';
416
+ poof.rate = 0;
417
+ poof.burst = 60;
418
+ poof.blend = 'add';
419
+ poof.sizeStart = 96;
420
+ poof.sizeEnd = 18;
421
+ poof.colorStart = color;
422
+ poof.renderOrder = 100;
423
+ poof.on('finished', () => poof.free());
424
+ this.node.addChild(poof);
425
+ });
426
+ }
427
+ }
428
+
429
+ /** Ward index from a shade's group tag (`shade-ward2` → 2). */
430
+ function wardOf(shade: Node): number {
431
+ for (const g of shade.groups) {
432
+ const m = /^shade-ward(\d)$/.exec(g);
433
+ if (m) return Number(m[1]);
434
+ }
435
+ return 1;
436
+ }
437
+
438
+ /** Shades float: waypoints ride the surface + a hover offset. */
439
+ function hover([x, y, z]: [number, number, number]): number[] {
440
+ return [x, y + 0.9, z];
441
+ }
442
+
443
+ // ---- SwordStrike (Emberwood's melee, retargeted at shades) -------------------------
444
+
445
+ const SWING_SECONDS = 0.55;
446
+ const STRIKE_RANGE = 2.6;
447
+
448
+ interface AnimatedSkin {
449
+ animationUpper: string;
450
+ }
451
+
452
+ export class SwordStrike extends Behavior {
453
+ private swinging = 0;
454
+ private pendingKill: Node | null = null;
455
+
456
+ private get player(): Node & Positioned {
457
+ return this.node.parent as unknown as Node & Positioned;
458
+ }
459
+
460
+ private get skin(): (Node & AnimatedSkin) | null {
461
+ return this.player.getNodeOrNull('Skin') as unknown as (Node & AnimatedSkin) | null;
462
+ }
463
+
464
+ override update(dt: number): void {
465
+ const engine = this.node.tree?.engine;
466
+ if (!engine) return;
467
+ const trail = this.player.getNodeOrNull('SwordMount/SwordTip/SwingTrail') as Trail3D | null;
468
+ if (!trail) return;
469
+ if (this.swinging > 0) {
470
+ this.swinging -= dt;
471
+ const t = 1 - Math.max(0, this.swinging) / SWING_SECONDS;
472
+ if (this.pendingKill && t >= 0.5) this.fell(this.pendingKill);
473
+ if (this.swinging <= 0) trail.emitting = false;
474
+ return;
475
+ }
476
+ if (!engine.input.justPressed('attack')) return;
477
+ this.swinging = SWING_SECONDS;
478
+ trail.emitting = true;
479
+ const skin = this.skin;
480
+ // UPPER-BODY layer: the attack plays from the spine up while the legs
481
+ // keep running — one-shots auto-clear when the clip ends.
482
+ if (skin) skin.animationUpper = '$anims/attack';
483
+ (this.player.getNode('SwingSfx') as AudioPlayer).play();
484
+ this.pendingKill = this.nearestShade();
485
+ }
486
+
487
+ private nearestShade(): Node | null {
488
+ const [px, , pz] = this.player.position;
489
+ let best: Node | null = null;
490
+ let bestD = STRIKE_RANGE;
491
+ for (const group of ['shade', 'boss'] as const) {
492
+ const reach = group === 'boss' ? STRIKE_RANGE + 1.6 : STRIKE_RANGE; // it is LARGE
493
+ for (const foe of this.node.tree?.getNodesInGroup(group) ?? []) {
494
+ if (group === 'boss' && !(foe as unknown as { visible: boolean }).visible) continue;
495
+ const [sx, , sz] = (foe as unknown as Positioned).position;
496
+ const d = Math.hypot((sx ?? 0) - (px ?? 0), (sz ?? 0) - (pz ?? 0));
497
+ if (d < (group === 'boss' ? reach : bestD)) {
498
+ bestD = d;
499
+ best = foe;
500
+ if (group === 'boss') return foe; // the boss takes strike priority
501
+ }
502
+ }
503
+ }
504
+ return best;
505
+ }
506
+
507
+ private fell(shade: Node): void {
508
+ this.pendingKill = null;
509
+ if (!shade.tree) return;
510
+ const at = (shade as unknown as Positioned).position;
511
+ (this.player.getNode('HitSfx') as AudioPlayer).play();
512
+ if (shade.isInGroup('boss')) {
513
+ this.strikeBoss(shade);
514
+ return;
515
+ }
516
+ const poof = new Particles3D('Dissolve');
517
+ poof.position = [at[0] ?? 0, (at[1] ?? 0) + 0.4, at[2] ?? 0];
518
+ poof.preset = 'magic';
519
+ poof.rate = 0;
520
+ poof.burst = 32;
521
+ poof.blend = 'add';
522
+ poof.sizeStart = 72;
523
+ poof.sizeEnd = 14;
524
+ poof.renderOrder = 100;
525
+ poof.on('finished', () => poof.free());
526
+ this.node.getRoot().addChild(poof);
527
+ shade.free();
528
+ }
529
+
530
+ /** Five true strikes: flinch flash + recoil, then the fall. */
531
+ private strikeBoss(boss: Node): void {
532
+ const hits = (bossHits.get(boss) ?? 0) + 1;
533
+ bossHits.set(boss, hits);
534
+ const b = boss as unknown as Positioned & Node;
535
+ if (hits < 5) {
536
+ // recoil away from the player + eye flare
537
+ const [px, , pz] = this.player.position;
538
+ const [bx, by, bz] = b.position;
539
+ const dx = (bx ?? 0) - (px ?? 0);
540
+ const dz = (bz ?? 0) - (pz ?? 0);
541
+ const len = Math.hypot(dx, dz) || 1;
542
+ b.position = [(bx ?? 0) + (dx / len) * 1.5, by ?? 0, (bz ?? 0) + (dz / len) * 1.5];
543
+ const glow = boss.getNodeOrNull('EyeGlow') as unknown as { intensity: number } | null;
544
+ if (glow) glow.intensity = 3 + hits * 1.5;
545
+ (boss.getNodeOrNull('RoarSfx') as AudioPlayer | null)?.play();
546
+ return;
547
+ }
548
+ quest.bossDown = true;
549
+ const at = b.position;
550
+ for (let i = 0; i < 3; i++) {
551
+ const poof = new Particles3D(`BossFall${i}`);
552
+ poof.position = [(at[0] ?? 0) + (i - 1) * 1.5, (at[1] ?? 0) + 2 + i, at[2] ?? 0];
553
+ poof.preset = 'explosion';
554
+ poof.rate = 0;
555
+ poof.burst = 50;
556
+ poof.blend = 'add';
557
+ poof.sizeStart = 110;
558
+ poof.sizeEnd = 20;
559
+ poof.renderOrder = 100;
560
+ poof.on('finished', () => poof.free());
561
+ this.node.getRoot().addChild(poof);
562
+ }
563
+ boss.free();
564
+ }
565
+ }
566
+
567
+ /** Boss strike bookkeeping (module scope — survives behavior instances). */
568
+ const bossHits = new WeakMap<Node, number>();
569
+
570
+ export const BEHAVIORS = { IsleDirector, SwordStrike };