incanto 0.11.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 (83) 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 +246 -71
  6. package/dist/3d.js +143 -4
  7. package/dist/{behavior-Bod1AyJS.d.ts → behavior-CPibUfnH.d.ts} +4 -2
  8. package/dist/{create-game-CdWhP6ve.js → create-game-Ct86zczq.js} +5 -5
  9. package/dist/{create-game-DZ_4Nfq8.js → create-game-J6g4QInv.js} +75 -24
  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 -30
  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/{audio-player-Dyjg5k92.d.ts → pathfinding-RWYkNKx9.d.ts} +29 -2
  22. package/dist/{physics-2d-DweTURFt.js → physics-2d-BiIdl51r.js} +8 -4
  23. package/dist/{physics-3d-Br97Ans2.js → physics-3d-DhaAaKJo.js} +8 -4
  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-BxNvAcLA.js → agent8-BF8Jfbkl.js} +1 -1
  33. package/editor/assets/{index-DMCQXowF.js → index-DSq0gdhE.js} +53 -41
  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 +37 -1
  38. package/skills/incanto-building-3d-games.md +83 -0
  39. package/skills/incanto-editor.md +3 -3
  40. package/skills/incanto-gameplay-behaviors.md +23 -1
  41. package/skills/incanto-node-reference.md +51 -0
  42. package/templates-app/beacon-isle-3d/PROJECT/Context.md +44 -0
  43. package/templates-app/beacon-isle-3d/PROJECT/Requirements.md +16 -0
  44. package/templates-app/beacon-isle-3d/PROJECT/Status.md +38 -0
  45. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +24 -0
  46. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +38 -0
  47. package/templates-app/beacon-isle-3d/generate-world.ts +814 -0
  48. package/templates-app/beacon-isle-3d/index.html +68 -0
  49. package/templates-app/beacon-isle-3d/package.json +26 -0
  50. package/templates-app/beacon-isle-3d/src/behaviors.ts +570 -0
  51. package/templates-app/beacon-isle-3d/src/game.scene.json +2992 -0
  52. package/templates-app/beacon-isle-3d/src/main.ts +33 -0
  53. package/templates-app/beacon-isle-3d/tsconfig.json +13 -0
  54. package/templates-app/beacon-isle-3d/verify.ts +285 -0
  55. package/templates-app/beacon-isle-3d/vite.config.ts +5 -0
  56. package/templates-app/tps-3d/PROJECT/Context.md +49 -0
  57. package/templates-app/tps-3d/PROJECT/Requirements.md +38 -0
  58. package/templates-app/tps-3d/PROJECT/Status.md +41 -0
  59. package/templates-app/tps-3d/PROJECT/Structure.md +44 -0
  60. package/templates-app/tps-3d/docs/project-3d-rules.md +38 -0
  61. package/templates-app/tps-3d/index.html +244 -0
  62. package/templates-app/tps-3d/package.json +25 -0
  63. package/templates-app/tps-3d/src/behaviors.ts +405 -0
  64. package/templates-app/tps-3d/src/game.scene.json +677 -0
  65. package/templates-app/tps-3d/src/main.ts +64 -0
  66. package/templates-app/tps-3d/tsconfig.json +13 -0
  67. package/templates-app/tps-3d/verify.ts +167 -0
  68. package/templates-app/tps-3d/vite.config.ts +5 -0
  69. package/templates-app/village-quest-3d/PROJECT/Context.md +39 -0
  70. package/templates-app/village-quest-3d/PROJECT/Requirements.md +36 -0
  71. package/templates-app/village-quest-3d/PROJECT/Status.md +26 -0
  72. package/templates-app/village-quest-3d/PROJECT/Structure.md +30 -0
  73. package/templates-app/village-quest-3d/docs/project-3d-rules.md +38 -0
  74. package/templates-app/village-quest-3d/index.html +68 -0
  75. package/templates-app/village-quest-3d/package.json +25 -0
  76. package/templates-app/village-quest-3d/src/behaviors.ts +529 -0
  77. package/templates-app/village-quest-3d/src/grove.scene.json +826 -0
  78. package/templates-app/village-quest-3d/src/main.ts +35 -0
  79. package/templates-app/village-quest-3d/src/quest.ts +18 -0
  80. package/templates-app/village-quest-3d/src/village.scene.json +680 -0
  81. package/templates-app/village-quest-3d/tsconfig.json +13 -0
  82. package/templates-app/village-quest-3d/verify.ts +254 -0
  83. package/templates-app/village-quest-3d/vite.config.ts +5 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Emberwood's three custom behaviors. Everything else — third-person movement
3
+ * + camera + declarative `animations`, proximity NPC (Interactable), wolves
4
+ * walking their patrols (PathFollow), the dialogue box, banners and buttons —
5
+ * is built-in engine surface wired in the scene JSONs.
6
+ *
7
+ * VillageDirector — the quest brain: Elder dialogue (UiDialogue + choices),
8
+ * the grove gate, the wind voice, sprint dash trail.
9
+ * GroveDirector — builds the hedge maze FROM the pathfinding grid (the
10
+ * grid IS the level), computes wolf patrol rings with
11
+ * findPath, watches for "all wolves down".
12
+ * SwordStrike — click/F: a sword arc (Trail3D burst) that fells the
13
+ * nearest wolf in reach.
14
+ */
15
+ import type {
16
+ AudioPlayer,
17
+ Engine,
18
+ JsonObject,
19
+ Node,
20
+ UiBanner,
21
+ UiButton,
22
+ UiDialogue,
23
+ UiText,
24
+ Voice,
25
+ } from 'incanto';
26
+ import { Behavior, findPath, gridFromRows } from 'incanto';
27
+ import { mergeSolidRects, parseCells } from 'incanto/2d';
28
+ import type { CharacterController3D, Node3D, Trail3D } from 'incanto/3d';
29
+ import { MeshInstance3D, Particles3D, StaticBody3D } from 'incanto/3d';
30
+ import { goToScene } from 'incanto/gameplay';
31
+ import groveJsonRaw from './grove.scene.json';
32
+ import { quest, resetQuest } from './quest';
33
+ import villageJsonRaw from './village.scene.json';
34
+
35
+ const groveJson = groveJsonRaw as unknown as JsonObject;
36
+ const villageJson = villageJsonRaw as unknown as JsonObject;
37
+
38
+ /** The grove maze — '#' hedge, '.' walkable. One string is the whole level:
39
+ * hedge colliders, wolf patrol pathfinding and spawn points all read it. */
40
+ export const GROVE_GRID = [
41
+ '###############',
42
+ '#.....#.......#',
43
+ '#.###.#.#####.#',
44
+ '#.#...#.....#.#',
45
+ '#.#.#######.#.#',
46
+ '#...#.....#...#',
47
+ '#.###.###.#.###',
48
+ '#.....#.#.....#',
49
+ '#####.#.#.###.#',
50
+ '#.....#...#...#',
51
+ '###############',
52
+ ] as const;
53
+
54
+ const CELL = 2; // meters per grid cell
55
+ const GRID_W = GROVE_GRID[0].length;
56
+ const GRID_H = GROVE_GRID.length;
57
+
58
+ /** Grid cell → world meters (grid centered on the origin). */
59
+ export function cellToWorld(cx: number, cy: number, y = 0): number[] {
60
+ return [(cx - (GRID_W - 1) / 2) * CELL, y, (cy - (GRID_H - 1) / 2) * CELL];
61
+ }
62
+
63
+ // ---- helpers shared by the directors ---------------------------------------------
64
+
65
+ interface Positioned {
66
+ position: number[];
67
+ }
68
+
69
+ function windFor(state: string): { pitch: number; volume: number } {
70
+ switch (state) {
71
+ case 'fastRun':
72
+ return { pitch: 1.5, volume: 0.55 };
73
+ case 'run':
74
+ return { pitch: 1.2, volume: 0.35 };
75
+ case 'walk':
76
+ return { pitch: 1.0, volume: 0.25 };
77
+ default:
78
+ return { pitch: 0.85, volume: 0.15 };
79
+ }
80
+ }
81
+
82
+ /** Wind ambience + sprint dash trail — shared per-frame polish. */
83
+ function ambientUpdate(engine: Engine | null | undefined, root: Node, wind: Voice | null): void {
84
+ const controller = root.getNode('Player/Controller') as CharacterController3D | null;
85
+ if (!controller) return;
86
+ const state = controller.state;
87
+ if (wind && engine) {
88
+ const w = windFor(state);
89
+ wind.set({ pitch: w.pitch, volume: w.volume });
90
+ }
91
+ const dash = root.getNode('Player/DashTrail') as Trail3D | null;
92
+ if (dash) dash.emitting = state === 'fastRun';
93
+ }
94
+
95
+ // ---- VillageDirector --------------------------------------------------------------
96
+
97
+ export class VillageDirector extends Behavior {
98
+ private wind: Voice | null = null;
99
+ private gateHinted = false;
100
+
101
+ private get dialogue(): UiDialogue {
102
+ return this.node.getNode('HUD/Dialogue') as UiDialogue;
103
+ }
104
+
105
+ override onReady(): void {
106
+ const engine = this.node.tree?.engine;
107
+ this.wind = engine?.sfx.startVoice('wind', 0.6) ?? null;
108
+ const quest3 = this.node.getNode('HUD/Quest') as UiText;
109
+ if (quest.cleared && !quest.done) quest3.text = 'Return to the Elder [E]';
110
+ else if (quest.done) quest3.text = 'Emberwood is safe.';
111
+ else if (quest.accepted) quest3.text = 'Clear the grove — through the north gate';
112
+
113
+ this.dialogue.on('choiceMade', (index) => {
114
+ if (index === 0 && !quest.accepted) {
115
+ quest.accepted = true;
116
+ (this.node.getNode('Elder/AcceptSfx') as AudioPlayer).play();
117
+ quest3.text = 'Clear the grove — through the north gate';
118
+ }
119
+ });
120
+ }
121
+
122
+ override onExitTree(): void {
123
+ this.wind?.stop();
124
+ }
125
+
126
+ override update(): void {
127
+ const engine = this.node.tree?.engine;
128
+ ambientUpdate(engine, this.node, this.wind);
129
+ // E advances an open dialogue (same key as "talk" — no extra binding)
130
+ if (engine && this.dialogue.active && engine.input.justPressed('interact')) {
131
+ this.dialogue.advance();
132
+ }
133
+ }
134
+
135
+ /** Elder.interacted → here (scene connection). */
136
+ onTalk(): void {
137
+ const talk = this.dialogue;
138
+ if (talk.active) return; // the update() advance already handled this press
139
+ (this.node.getNode('Elder/TalkSfx') as AudioPlayer).play();
140
+ if (quest.done) {
141
+ talk.say('Elder', 'Emberwood breathes easy again. Rest, hero.');
142
+ return;
143
+ }
144
+ if (quest.cleared) {
145
+ quest.done = true;
146
+ talk.say('Elder', 'The howling has stopped... you really did it!');
147
+ talk.say('Elder', 'Emberwood owes you everything. QUEST COMPLETE.');
148
+ const banner = this.node.getNode('HUD/Banner') as UiBanner;
149
+ talk.on('dialogueFinished', () => {
150
+ banner.show('QUEST COMPLETE');
151
+ (this.node.getNode('HUD/RestartBtn') as UiButton).visible = true;
152
+ (this.node.getNode('HUD/Quest') as UiText).text = 'Emberwood is safe.';
153
+ });
154
+ return;
155
+ }
156
+ if (quest.accepted) {
157
+ talk.say('Elder', 'The wolves still prowl the grove. North gate, hero.');
158
+ return;
159
+ }
160
+ talk.say('Elder', 'Ah, traveler. Wolves haunt our grove — three of them, bold as thunder.');
161
+ talk.say('Elder', 'None of us dares the hedges anymore. Will you drive them out?', [
162
+ 'I will go',
163
+ 'Not yet',
164
+ ]);
165
+ }
166
+
167
+ /** GroveGate.triggerEnter → here. */
168
+ onGateEnter(other: Node): void {
169
+ if (!other.isInGroup('player')) return;
170
+ if (!quest.accepted) {
171
+ if (!this.gateHinted && !this.dialogue.active) {
172
+ this.gateHinted = true;
173
+ this.dialogue.say('???', 'A low growl rolls from the hedges. Speak to the Elder first.');
174
+ }
175
+ return;
176
+ }
177
+ const engine = this.node.tree?.engine;
178
+ if (engine) goToScene(engine, groveJson, { fadeSeconds: 0.5 });
179
+ }
180
+
181
+ /** HUD/RestartBtn.pressed → here. */
182
+ onRestart(): void {
183
+ resetQuest();
184
+ const engine = this.node.tree?.engine;
185
+ if (engine) goToScene(engine, villageJson, { fadeSeconds: 0.5 });
186
+ }
187
+ }
188
+
189
+ // ---- GroveDirector ----------------------------------------------------------------
190
+
191
+ /** Patrol assignments: wolf name → [from, to] grid cells (open cells only). */
192
+ const PATROLS: Record<string, [[number, number], [number, number]]> = {
193
+ Wolf1: [
194
+ [1, 1],
195
+ [3, 5],
196
+ ],
197
+ Wolf2: [
198
+ [7, 1],
199
+ [13, 3],
200
+ ],
201
+ Wolf3: [
202
+ [1, 9],
203
+ [9, 9],
204
+ ],
205
+ };
206
+
207
+ const AGGRO_RANGE = 6;
208
+ const CALM_RANGE = 9;
209
+ const PATROL_SPEED = 2.4;
210
+ const HUNT_SPEED = 3.4;
211
+
212
+ export class GroveDirector extends Behavior {
213
+ private wind: Voice | null = null;
214
+ private clearedShown = false;
215
+ private repathClock = 0;
216
+ private hunting = new Set<string>();
217
+ private respawnIn = 0;
218
+
219
+ override onReady(): void {
220
+ const engine = this.node.tree?.engine;
221
+ this.wind = engine?.sfx.startVoice('wind', 0.8) ?? null;
222
+ this.buildHedges();
223
+ this.assignPatrols();
224
+ }
225
+
226
+ override onExitTree(): void {
227
+ this.wind?.stop();
228
+ }
229
+
230
+ /** The same grid that routes the wolves becomes the physical maze:
231
+ * parseCells + mergeSolidRects collapse ~90 hedge cells to ~20 boxes. */
232
+ private buildHedges(): void {
233
+ const holder = this.node.getNode('Maze') as Node3D;
234
+ const gridRows = parseCells(GROVE_GRID as unknown as string[], { '#': 0 });
235
+ const rects = mergeSolidRects(gridRows, new Set([0]));
236
+ rects.forEach((r, i) => {
237
+ const w = r.w * CELL;
238
+ const d = r.h * CELL;
239
+ // taller than the player's eye line (1.94) — corridors must read as
240
+ // corridors, and the camera's sphere probe needs a wall to catch
241
+ const center = cellToWorld(r.x + r.w / 2 - 0.5, r.y + r.h / 2 - 0.5, 1.3);
242
+ const body = new StaticBody3D(`Hedge${i}`);
243
+ body.position = center;
244
+ body.collider = { shape: 'box', size: [w, 2.6, d] };
245
+ const skin = new MeshInstance3D('Skin');
246
+ skin.mesh = 'box';
247
+ skin.size = [w, 2.6, d];
248
+ skin.material = { color: '#41633a', roughness: 0.95 };
249
+ skin.castShadow = true;
250
+ skin.receiveShadow = true;
251
+ body.addChild(skin);
252
+ holder.addChild(body);
253
+ });
254
+ }
255
+
256
+ /** findPath (A*, no corner cutting) → PathFollow ring per wolf. */
257
+ private assignPatrols(): void {
258
+ const grid = gridFromRows(GROVE_GRID as unknown as string[]);
259
+ for (const [name, [from, to]] of Object.entries(PATROLS)) {
260
+ const wolf = this.node.getNode(`Wolves/${name}`) as Node3D | null;
261
+ if (!wolf) continue;
262
+ const cells = findPath(grid, from, to, { diagonal: false });
263
+ if (!cells) continue;
264
+ // out + back (minus the duplicated endpoints) = a loopable ring
265
+ const ring = [...cells, ...cells.slice(1, -1).reverse()];
266
+ const follow = wolf.behavior as unknown as {
267
+ setPath(w: number[][]): void;
268
+ loop: boolean;
269
+ };
270
+ follow.loop = true;
271
+ follow.setPath(ring.map(([cx, cy]) => cellToWorld(cx, cy, 0.45)));
272
+ wolf.position = cellToWorld(from[0], from[1], 0.45);
273
+ }
274
+ }
275
+
276
+ override update(dt: number): void {
277
+ const engine = this.node.tree?.engine;
278
+ ambientUpdate(engine, this.node, this.wind);
279
+ this.faceWolvesForward();
280
+ this.huntOrPatrol(dt);
281
+ this.syncHpBar();
282
+ if (this.respawnIn > 0) {
283
+ this.respawnIn -= dt;
284
+ if (this.respawnIn <= 0 && engine) goToScene(engine, groveJson, { fadeSeconds: 0.5 });
285
+ return;
286
+ }
287
+ const left = this.node.tree?.getNodesInGroup('wolf').length ?? 0;
288
+ (this.node.getNode('HUD/Quest') as UiText).text =
289
+ left > 0 ? `Wolves left: ${left}` : 'Grove cleared — take the portal home';
290
+ if (left === 0 && !this.clearedShown) {
291
+ this.clearedShown = true;
292
+ quest.cleared = true;
293
+ (this.node.getNode('HUD/Banner') as UiBanner).show('GROVE CLEARED');
294
+ (this.node.getNode('WinSfx') as AudioPlayer).play();
295
+ }
296
+ }
297
+
298
+ /** Wolves HUNT: within aggro range they re-path to the player THROUGH the
299
+ * maze (findPath again — the patrol grid doubles as the nav grid); once the
300
+ * player slips away they resume their patrol ring. */
301
+ private huntOrPatrol(dt: number): void {
302
+ this.repathClock -= dt;
303
+ if (this.repathClock > 0) return;
304
+ this.repathClock = 0.35; // re-path a few times a second, not every frame
305
+ const player = this.node.getNode('Player') as unknown as Positioned | null;
306
+ if (!player) return;
307
+ const [px, , pz] = player.position;
308
+ const grid = gridFromRows(GROVE_GRID as unknown as string[]);
309
+ const toCell = (x: number, z: number): [number, number] => [
310
+ Math.round(x / CELL + (GRID_W - 1) / 2),
311
+ Math.round(z / CELL + (GRID_H - 1) / 2),
312
+ ];
313
+ for (const node of this.node.tree?.getNodesInGroup('wolf') ?? []) {
314
+ const wolf = node as Node3D;
315
+ const follow = wolf.behavior as unknown as {
316
+ setPath(w: number[][]): void;
317
+ loop: boolean;
318
+ speed: number;
319
+ };
320
+ const [wx, , wz] = wolf.position;
321
+ const dist = Math.hypot((px ?? 0) - (wx ?? 0), (pz ?? 0) - (wz ?? 0));
322
+ const wasHunting = this.hunting.has(wolf.name);
323
+ if (dist < (wasHunting ? CALM_RANGE : AGGRO_RANGE)) {
324
+ if (!wasHunting) {
325
+ this.hunting.add(wolf.name);
326
+ (wolf.getNode('GrowlSfx') as AudioPlayer | null)?.play();
327
+ }
328
+ const cells = findPath(grid, toCell(wx ?? 0, wz ?? 0), toCell(px ?? 0, pz ?? 0), {
329
+ diagonal: false,
330
+ });
331
+ if (cells && cells.length > 1) {
332
+ follow.loop = false;
333
+ follow.speed = HUNT_SPEED;
334
+ follow.setPath(cells.slice(1).map(([cx, cy]) => cellToWorld(cx, cy, 0.45)));
335
+ }
336
+ } else if (wasHunting) {
337
+ this.hunting.delete(wolf.name);
338
+ follow.speed = PATROL_SPEED;
339
+ this.reassignPatrol(wolf.name);
340
+ }
341
+ }
342
+ }
343
+
344
+ private reassignPatrol(name: string): void {
345
+ const patrol = PATROLS[name];
346
+ const wolf = this.node.getNode(`Wolves/${name}`) as Node3D | null;
347
+ if (!patrol || !wolf) return;
348
+ const grid = gridFromRows(GROVE_GRID as unknown as string[]);
349
+ // walk back to the patrol start, then resume the ring
350
+ const [wx, , wz] = wolf.position;
351
+ const here: [number, number] = [
352
+ Math.round((wx ?? 0) / CELL + (GRID_W - 1) / 2),
353
+ Math.round((wz ?? 0) / CELL + (GRID_H - 1) / 2),
354
+ ];
355
+ const back = findPath(grid, here, patrol[0], { diagonal: false }) ?? [];
356
+ const ring = findPath(grid, patrol[0], patrol[1], { diagonal: false }) ?? [];
357
+ const path = [...back.slice(1), ...ring.slice(1), ...ring.slice(1, -1).reverse()];
358
+ const follow = wolf.behavior as unknown as { setPath(w: number[][]): void; loop: boolean };
359
+ follow.loop = true;
360
+ follow.setPath(path.map(([cx, cy]) => cellToWorld(cx, cy, 0.45)));
361
+ }
362
+
363
+ private syncHpBar(): void {
364
+ const health = this.node.getNode('Player')?.behavior as unknown as {
365
+ current: number;
366
+ max: number;
367
+ } | null;
368
+ const bar = this.node.getNode('HUD/HP') as unknown as { value: number; max: number } | null;
369
+ if (health && bar) {
370
+ bar.value = health.current;
371
+ bar.max = health.max;
372
+ }
373
+ }
374
+
375
+ /** Player.died → here (scene connection): YOU FELL, then a fresh grove. */
376
+ onPlayerDown(): void {
377
+ if (this.respawnIn > 0) return;
378
+ this.respawnIn = 1.6;
379
+ (this.node.getNode('HUD/Banner') as UiBanner).show('YOU FELL');
380
+ }
381
+
382
+ /** PathFollow moves; facing is ours — +Z-forward rule: yaw = atan2(dx, dz). */
383
+ private faceWolvesForward(): void {
384
+ for (const node of this.node.tree?.getNodesInGroup('wolf') ?? []) {
385
+ const wolf = node as Node3D & { _lastPos?: number[] };
386
+ const [x, , z] = wolf.position;
387
+ const last = wolf._lastPos ?? [x ?? 0, 0, z ?? 0];
388
+ const dx = (x ?? 0) - (last[0] ?? 0);
389
+ const dz = (z ?? 0) - (last[2] ?? 0);
390
+ if (dx * dx + dz * dz > 1e-8) {
391
+ wolf.rotation = [0, (Math.atan2(dx, dz) * 180) / Math.PI, 0];
392
+ }
393
+ wolf._lastPos = [x ?? 0, 0, z ?? 0];
394
+ }
395
+ }
396
+
397
+ /** ReturnPortal.triggerEnter → here. */
398
+ onPortalEnter(other: Node): void {
399
+ if (!other.isInGroup('player')) return;
400
+ const engine = this.node.tree?.engine;
401
+ if (engine) goToScene(engine, villageJson, { fadeSeconds: 0.5 });
402
+ }
403
+ }
404
+
405
+ // ---- SwordStrike ------------------------------------------------------------------
406
+
407
+ const SWING_SECONDS = 0.55;
408
+ const STRIKE_RANGE = 2.6;
409
+
410
+ interface AnimatedSkin {
411
+ animationUpper: string;
412
+ }
413
+
414
+ /** On `attack`: play the melee clip — the blade rides the hand bone
415
+ * (BoneAttachment3D), so the Trail3D arc IS the real swing — and fell the
416
+ * nearest wolf mid-swing. */
417
+ export class SwordStrike extends Behavior {
418
+ private swinging = 0;
419
+ private pendingKill: Node | null = null;
420
+
421
+ private get player(): Node & Positioned {
422
+ return this.node.parent as unknown as Node & Positioned;
423
+ }
424
+
425
+ private get skin(): (Node & AnimatedSkin) | null {
426
+ return this.player.getNode('Skin') as unknown as (Node & AnimatedSkin) | null;
427
+ }
428
+
429
+ override update(dt: number): void {
430
+ const engine = this.node.tree?.engine;
431
+ if (!engine) return;
432
+ const trail = this.player.getNode('SwordMount/SwordTip/SwingTrail') as Trail3D | null;
433
+ if (!trail) return;
434
+
435
+ if (this.swinging > 0) {
436
+ this.swinging -= dt;
437
+ const t = 1 - Math.max(0, this.swinging) / SWING_SECONDS; // 0 → 1
438
+ if (this.pendingKill && t >= 0.5) this.fell(this.pendingKill);
439
+ if (this.swinging <= 0) trail.emitting = false;
440
+ return;
441
+ }
442
+
443
+ if (!engine.input.justPressed('attack')) return;
444
+ this.swinging = SWING_SECONDS;
445
+ trail.emitting = true;
446
+ const skin = this.skin;
447
+ // UPPER-BODY layer: the attack plays from the spine up while the legs
448
+ // keep running — one-shots auto-clear when the clip ends.
449
+ if (skin) skin.animationUpper = '$anims/attack';
450
+ (this.player.getNode('SwingSfx') as AudioPlayer).play();
451
+ this.pendingKill = this.nearestWolf();
452
+ }
453
+
454
+ private nearestWolf(): Node | null {
455
+ const [px, , pz] = this.player.position;
456
+ let best: Node | null = null;
457
+ let bestD = STRIKE_RANGE;
458
+ for (const wolf of this.node.tree?.getNodesInGroup('wolf') ?? []) {
459
+ const [wx, , wz] = (wolf as unknown as Positioned).position;
460
+ const d = Math.hypot((wx ?? 0) - (px ?? 0), (wz ?? 0) - (pz ?? 0));
461
+ if (d < bestD) {
462
+ bestD = d;
463
+ best = wolf;
464
+ }
465
+ }
466
+ return best;
467
+ }
468
+
469
+ private fell(wolf: Node): void {
470
+ this.pendingKill = null;
471
+ if (!wolf.tree) return; // already gone
472
+ const at = (wolf as unknown as Positioned).position;
473
+ (this.player.getNode('HitSfx') as AudioPlayer).play();
474
+ // two hits per wolf: the first FLINCHES it (knockback along the strike)
475
+ const hits = (wolfHits.get(wolf) ?? 0) + 1;
476
+ wolfHits.set(wolf, hits);
477
+ if (hits < 2) {
478
+ const [px, , pz] = this.player.position;
479
+ const dx = (at[0] ?? 0) - (px ?? 0);
480
+ const dz = (at[2] ?? 0) - (pz ?? 0);
481
+ const len = Math.hypot(dx, dz) || 1;
482
+ (wolf as unknown as Positioned).position = [
483
+ (at[0] ?? 0) + (dx / len) * 1.2,
484
+ at[1] ?? 0.45,
485
+ (at[2] ?? 0) + (dz / len) * 1.2,
486
+ ];
487
+ (wolf.getNodeOrNull('GrowlSfx') as AudioPlayer | null)?.play();
488
+ flashWolf(wolf);
489
+ return;
490
+ }
491
+ // a one-shot dust poof where the wolf fell (self-removing)
492
+ const poof = new Particles3D('Poof');
493
+ poof.position = [at[0] ?? 0, (at[1] ?? 0) + 0.3, at[2] ?? 0];
494
+ poof.preset = 'magic';
495
+ poof.rate = 0;
496
+ poof.burst = 26;
497
+ poof.blend = 'normal';
498
+ poof.sizeStart = 64;
499
+ poof.sizeEnd = 12;
500
+ poof.renderOrder = 100;
501
+ poof.on('finished', () => poof.free());
502
+ this.node.getRoot().addChild(poof);
503
+ wolf.free();
504
+ }
505
+ }
506
+
507
+ /** Strike bookkeeping: hits per wolf (module-scope — survives behavior instances). */
508
+ const wolfHits = new WeakMap<Node, number>();
509
+
510
+ /** Red hurt-flash on every mesh of the wolf, reverted a beat later. */
511
+ function flashWolf(wolf: Node): void {
512
+ const meshes: { material: Record<string, unknown> }[] = [];
513
+ const walk = (n: Node): void => {
514
+ const m = (n as unknown as { material?: Record<string, unknown> }).material;
515
+ if (m && typeof m === 'object')
516
+ meshes.push(n as unknown as { material: Record<string, unknown> });
517
+ for (const c of n.children) walk(c);
518
+ };
519
+ walk(wolf);
520
+ const originals = meshes.map((m) => m.material);
521
+ for (const m of meshes) m.material = { ...m.material, color: '#ff5544' };
522
+ setTimeout(() => {
523
+ meshes.forEach((m, i) => {
524
+ if (wolf.tree) m.material = originals[i] as Record<string, unknown>;
525
+ });
526
+ }, 130);
527
+ }
528
+
529
+ export const BEHAVIORS = { VillageDirector, GroveDirector, SwordStrike };