incanto 0.72.0 → 0.74.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 (84) hide show
  1. package/bin/incanto-check.mjs +114 -42
  2. package/bin/incanto-verify.mjs +9 -2
  3. package/dist/2d.d.ts +29 -4
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +137 -15
  6. package/dist/3d.js +6 -6
  7. package/dist/{agent8-BqZ0_O1u.js → agent8-CHTT4unP.js} +1 -1
  8. package/dist/{audio-player-ioZ7VKC6.d.ts → audio-player-BOrk4eQx.d.ts} +1 -1
  9. package/dist/{behavior-Bf9P9oB2.d.ts → behavior-J1cmVah0.d.ts} +8 -0
  10. package/dist/{create-game-CmFAtLZp.js → create-game-BAiA-FjP.js} +7 -7
  11. package/dist/{create-game-C9rWqqAC.js → create-game-ssvGvP7W.js} +7 -7
  12. package/dist/debug.d.ts +1 -1
  13. package/dist/debug.js +1 -1
  14. package/dist/editor.js +965 -893
  15. package/dist/{environment-presets-BeHoaGhy.js → environment-presets-BkqlWewf.js} +277 -60
  16. package/dist/{gameplay-BlQe-M07.js → gameplay-Bxe1sMVT.js} +611 -39
  17. package/dist/gameplay.d.ts +141 -7
  18. package/dist/gameplay.js +2 -2
  19. package/dist/index.d.ts +6 -4
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-CBfQzB6S.d.ts → loader-BAeWMYfF.d.ts} +1 -1
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +2 -2
  24. package/dist/{physics-2d-DrIgTlLk.js → physics-2d-C-SfvjzH.js} +94 -11
  25. package/dist/{physics-3d-BzOCw1VS.js → physics-3d-rOzeg890.js} +202 -15
  26. package/dist/{picking-C9McVJrO.js → picking-BMV34Pjl.js} +2 -2
  27. package/dist/react.d.ts +1 -1
  28. package/dist/react.js +1 -1
  29. package/dist/{register-5IYfooAN.js → register-CjociOtt.js} +19 -4
  30. package/dist/{register-C8HuRkOf.js → register-DRQyZKGU.js} +45 -9
  31. package/dist/{replay-datiAE-b.js → replay-B6BbgGM_.js} +42 -6
  32. package/dist/{replay-CBEChqXq.d.ts → replay-wZK5Ok5c.d.ts} +1 -1
  33. package/dist/{save-slots-CEuJPUle.js → save-slots-CEUGeLuB.js} +11 -2
  34. package/dist/{split-screen-Bm5DTElG.js → split-screen-BxZ5rBe7.js} +4 -4
  35. package/dist/{split-screen-DkqLng6q.d.ts → split-screen-D6sx8z3Z.d.ts} +2 -2
  36. package/dist/{sprite-animation-CqR2o3SA.js → sprite-animation-Bz3DHD_4.js} +1 -1
  37. package/dist/{src-DRSsRleD.js → src-CqtvYtSN.js} +1 -1
  38. package/dist/{test-Djuq3Vhq.js → test-CEl0y2Sw.js} +162 -30
  39. package/dist/test.d.ts +56 -5
  40. package/dist/test.js +2 -2
  41. package/dist/{touch-BnCyPA0G.js → touch-CioTZB-y.js} +1 -1
  42. package/dist/vite.js +3 -3
  43. package/editor/assets/{agent8-CMKz2cax.js → agent8-BlzturGi.js} +1 -1
  44. package/editor/assets/{debug-DLrQm-az.js → debug-BMUi8usj.js} +1 -1
  45. package/editor/assets/{index-BEKuxYcE.js → index-CrUCQoaB.js} +53 -53
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +634 -10
  49. package/skills/incanto-3d-character.md +113 -5
  50. package/skills/incanto-assets.md +9 -3
  51. package/skills/incanto-behaviors-and-scripts.md +26 -2
  52. package/skills/incanto-building-2d-games.md +9 -0
  53. package/skills/incanto-building-3d-games.md +45 -0
  54. package/skills/incanto-editor.md +6 -1
  55. package/skills/incanto-environment.md +7 -2
  56. package/skills/incanto-game-feel.md +16 -11
  57. package/skills/incanto-gameplay-behaviors.md +179 -16
  58. package/skills/incanto-node-reference.md +124 -45
  59. package/skills/incanto-physics-and-input.md +67 -5
  60. package/skills/incanto-scene-json-authoring.md +9 -3
  61. package/skills/incanto-verifying-your-game.md +19 -2
  62. package/templates-app/beacon-isle-3d/PROJECT/Status.md +8 -0
  63. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +3 -0
  64. package/templates-app/beacon-isle-3d/package.json +1 -1
  65. package/templates-app/beacon-isle-3d/src/game.scene.json +136 -0
  66. package/templates-app/beacon-isle-3d/verify.ts +144 -0
  67. package/templates-app/molehill-2d/package.json +1 -1
  68. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  69. package/templates-app/platformer-2d/PROJECT/Structure.md +3 -0
  70. package/templates-app/platformer-2d/package.json +1 -1
  71. package/templates-app/platformer-2d/src/game.scene.json +126 -0
  72. package/templates-app/platformer-2d/verify.ts +54 -0
  73. package/templates-app/star-survivor/package.json +1 -1
  74. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  75. package/templates-app/tps-3d/PROJECT/Status.md +40 -1
  76. package/templates-app/tps-3d/PROJECT/Structure.md +19 -0
  77. package/templates-app/tps-3d/package.json +1 -1
  78. package/templates-app/tps-3d/src/behaviors.ts +9 -0
  79. package/templates-app/tps-3d/src/cover.scene.json +26 -0
  80. package/templates-app/tps-3d/src/game.scene.json +331 -75
  81. package/templates-app/tps-3d/src/main.ts +14 -0
  82. package/templates-app/tps-3d/src/pillar.scene.json +25 -0
  83. package/templates-app/tps-3d/verify.ts +582 -3
  84. package/templates-app/village-quest-3d/package.json +1 -1
@@ -18,9 +18,15 @@
18
18
  */
19
19
  import type { Node } from 'incanto';
20
20
  import { registerBehavior } from 'incanto';
21
- import { auditScene, registerAllNodes, runScript } from 'incanto/test';
21
+ import { auditScene, feelReport, registerAllNodes, runScript } from 'incanto/test';
22
22
  import { HudUpdater, Shoot } from './src/behaviors';
23
+ import coverJson from './src/cover.scene.json';
23
24
  import sceneJson from './src/game.scene.json';
25
+ import pillarJson from './src/pillar.scene.json';
26
+
27
+ /** The prefabs `game.scene.json` places — bun has no `import.meta.glob`, so by name. */
28
+ const resolveScene = (path: string): never =>
29
+ (({ 'cover.scene.json': coverJson, 'pillar.scene.json': pillarJson })[path] ?? null) as never;
24
30
 
25
31
  const behaviors = { Shoot, HudUpdater };
26
32
 
@@ -65,11 +71,13 @@ const ok = (label: string, cond: boolean): void => {
65
71
  {
66
72
  let won = 0;
67
73
  let moved = false;
74
+ let upperArmed = false;
68
75
  let sawEnemy = false;
69
76
  let chaseClosedIn = false;
70
77
  let maxScore = 0;
71
78
 
72
79
  const result = await runScript(sceneJson, {
80
+ resolveScene,
73
81
  durationMs: 60000,
74
82
  seed: 1,
75
83
  behaviors,
@@ -128,6 +136,13 @@ const ok = (label: string, cond: boolean): void => {
128
136
  ctx.engine.input.pressAction('fire');
129
137
  ctx.engine.step();
130
138
  ctx.engine.input.releaseAction('fire');
139
+ // The shot rides the UPPER-BODY layer, so the legs keep running.
140
+ // Headless there is no mixer to clear it, which is exactly what
141
+ // lets this read that the shot ARMED it.
142
+ const skin = ctx.getNode('/root/Player/Skin') as unknown as {
143
+ animationUpper: string;
144
+ };
145
+ if (skin.animationUpper === '$anims/shoot') upperArmed = true;
131
146
  }
132
147
  ctx.engine.step();
133
148
  const score = (ctx.getNode('/root').behavior as unknown as { score: number }).score;
@@ -139,6 +154,10 @@ const ok = (label: string, cond: boolean): void => {
139
154
  });
140
155
 
141
156
  ok('holding move walked the player', moved);
157
+ ok(
158
+ 'a shot plays on the upper body (animationUpper = $anims/shoot) while the legs keep their clip',
159
+ upperArmed,
160
+ );
142
161
  ok('a wave produced enemies', sawEnemy);
143
162
  ok('an enemy closed in on the player (Chase.moveParent)', chaseClosedIn);
144
163
  ok('hitscan kills scored points', maxScore >= 5);
@@ -152,6 +171,7 @@ const ok = (label: string, cond: boolean): void => {
152
171
  let damaged = 0;
153
172
 
154
173
  const result = await runScript(sceneJson, {
174
+ resolveScene,
155
175
  durationMs: 8000,
156
176
  seed: 2,
157
177
  behaviors,
@@ -193,7 +213,12 @@ const ok = (label: string, cond: boolean): void => {
193
213
  let hp = 100;
194
214
  let hits = 0;
195
215
  const result = await runScript(sceneJson, {
196
- durationMs: 20000,
216
+ resolveScene,
217
+ // 26 s, not 20: the first enemy arrives at ~17 s (a spawn pile at z −20,
218
+ // a detour round the 0.6 m Ramp), so a 20 s window made "keeps hurting"
219
+ // an arrival-time test that any extra collider in the arena could tip —
220
+ // adding two slopes at the far corner took it from 3 hits to 2.
221
+ durationMs: 26000,
197
222
  seed: 5,
198
223
  behaviors,
199
224
  steps: [
@@ -205,7 +230,7 @@ const ok = (label: string, cond: boolean): void => {
205
230
  },
206
231
  },
207
232
  {
208
- atMs: 19000,
233
+ atMs: 25000,
209
234
  label: 'read the health the contact actually took',
210
235
  do: (ctx) => {
211
236
  hp = (ctx.getNode('/root/Player').behavior as unknown as { current: number }).current;
@@ -218,4 +243,558 @@ const ok = (label: string, cond: boolean): void => {
218
243
  ok('runScript reported no failures (CONTACT path)', result.ok);
219
244
  }
220
245
 
246
+ // ---- FEEL: the controller's game-feel table, MEASURED -----------------------------
247
+ // `jumpCutMultiplier`, `maxJumps`, `airControl` and `fallGravity` were documented
248
+ // in the 3D character skill and set by no 3D example; this starter carries them
249
+ // now. Each is asserted by its EFFECT — the shape of the jump — never by reading
250
+ // the prop back, which is the difference between a scene that declares feel and
251
+ // a character that has it.
252
+ {
253
+ const feel = await feelReport(sceneJson, { behaviors, resolveScene });
254
+ const tapped = feel.jumpApex ?? 0;
255
+ const held = feel.heldJumpApex ?? tapped;
256
+ ok(
257
+ `jumpCutMultiplier: a tap (${tapped} u) is a shorter hop than a hold (${held} u)`,
258
+ tapped > 0.05 && held > tapped * 1.5,
259
+ );
260
+ ok(
261
+ `maxJumps 2: a second press in the air climbs higher (${feel.doubleJump?.apex ?? '—'} u vs ${tapped} u)`,
262
+ (feel.doubleJump?.apex ?? 0) > tapped * 1.4,
263
+ );
264
+ ok(
265
+ `coyote window is measured with a double jump declared (${feel.coyoteMs} ms)`,
266
+ (feel.coyoteMs ?? 0) >= 50,
267
+ );
268
+ ok(
269
+ `jump buffer is measured with a double jump declared (${feel.jumpBufferMs} ms)`,
270
+ (feel.jumpBufferMs ?? 0) >= 50,
271
+ );
272
+ }
273
+
274
+ // ---- STEP + JUMP: the two controller numbers a level is built against ---------
275
+ // `stepHeight` and `jumpVelocity` were set by no example. The arena now has a
276
+ // 0.3 m curb and a 0.9 m ledge at z −12, and each is asserted by what the player
277
+ // can DO about it — walk over the one, be stopped by the other — and the jump
278
+ // by how much higher a faster launch reaches, never by reading a prop back.
279
+ {
280
+ type Vec = { position: number[] };
281
+ let curbX = 0;
282
+ let floorY = 0;
283
+ let curbY = 0;
284
+ let ledgeX = 0;
285
+ const result = await runScript(sceneJson, {
286
+ resolveScene: resolveScene as never,
287
+ behaviors,
288
+ durationMs: 6000,
289
+ steps: [
290
+ {
291
+ atMs: 100,
292
+ do: (ctx) => {
293
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
294
+ player.position = [0, 1.4, -12];
295
+ ctx.engine.input.setActionVector('move', 1, 0);
296
+ for (let i = 0; i < 180; i++) {
297
+ ctx.engine.step();
298
+ const [x = 0, y = 0] = player.position;
299
+ if (i === 40) floorY = y; // settled, still short of the curb
300
+ if (x > 5.7 && x < 6.3) curbY = Math.max(curbY, y); // on top of it
301
+ }
302
+ curbX = player.position[0] ?? 0;
303
+ ctx.engine.input.setActionVector('move', 0, 0);
304
+ player.position = [0, 1.4, -12];
305
+ ctx.engine.input.setActionVector('move', -1, 0);
306
+ for (let i = 0; i < 180; i++) ctx.engine.step();
307
+ ledgeX = player.position[0] ?? 0;
308
+ ctx.engine.input.setActionVector('move', 0, 0);
309
+ },
310
+ },
311
+ ],
312
+ });
313
+ ok('runScript reported no failures (STEP path)', result.ok);
314
+ ok(
315
+ `stepHeight 0.4: the player walks OVER the 0.3 m curb at x 6 (reached x ${curbX.toFixed(1)}; floor y ${floorY.toFixed(2)} → curb y ${curbY.toFixed(2)})`,
316
+ curbX > 7 && curbY - floorY > 0.2,
317
+ );
318
+ ok(
319
+ `…and the 0.9 m ledge at x −6 STOPS it (x ${ledgeX.toFixed(1)})`,
320
+ ledgeX > -5.7 && ledgeX < -4.5,
321
+ );
322
+ // The same scene with the launch back at the default: the prop is asserted
323
+ // by the DIFFERENCE it makes, which is the only thing a level cares about.
324
+ const text = JSON.stringify(sceneJson);
325
+ ok(
326
+ 'the scene declares jumpVelocity 5 (the comparison needs it)',
327
+ text.includes('"jumpVelocity":5'),
328
+ );
329
+ const slower = JSON.parse(text.replace('"jumpVelocity":5', '"jumpVelocity":4'));
330
+ const fast = await feelReport(sceneJson, { behaviors, resolveScene });
331
+ const slow = await feelReport(slower, { behaviors, resolveScene });
332
+ ok(
333
+ `jumpVelocity 5 vs 4: a faster launch reaches higher (${fast.heldJumpApex ?? fast.jumpApex} u vs ${slow.heldJumpApex ?? slow.jumpApex} u)`,
334
+ (fast.heldJumpApex ?? 0) > (slow.heldJumpApex ?? 0) * 1.3,
335
+ );
336
+ }
337
+
338
+ // ---- SLOPE: what an enemy can climb -----------------------------------------------
339
+ // `CharacterBody3D.slopeLimitDeg` was set by no example. The enemy prefab now
340
+ // declares 50°, and the arena has a 40° hill and a 70° cliff (at z −20, away from
341
+ // the fight), each rising 4 m of slab onto a plateau. One enemy is put at the
342
+ // foot of each with the player on top: it must climb the hill and reach, and
343
+ // must NOT climb the cliff. Asserted by where the enemy GOT, never by the prop.
344
+ {
345
+ type Vec = { position: number[] };
346
+ const climb = (
347
+ ctx: { getNode(p: string): unknown; engine: { step(): void } },
348
+ enemy: Vec,
349
+ foot: [number, number],
350
+ top: [number, number, number],
351
+ ): { rose: number; gap: number } => {
352
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
353
+ enemy.position = [foot[0], 0.95, foot[1]];
354
+ player.position = [top[0], top[1] + 0.85, top[2]];
355
+ let rose = 0;
356
+ for (let i = 0; i < 240; i++) {
357
+ ctx.engine.step();
358
+ rose = Math.max(rose, (enemy.position[1] ?? 0) - 0.95);
359
+ }
360
+ const gap = Math.hypot((enemy.position[0] ?? 0) - top[0], (enemy.position[2] ?? 0) - top[2]);
361
+ player.position = [0, 1.4, 18]; // off the plateau before the contact damage adds up
362
+ return { rose, gap };
363
+ };
364
+ let hill = { rose: 0, gap: 99 };
365
+ let cliff = { rose: 0, gap: 99 };
366
+ const result = await runScript(sceneJson, {
367
+ resolveScene: resolveScene as never,
368
+ behaviors,
369
+ durationMs: 6000,
370
+ steps: [
371
+ {
372
+ atMs: 3500,
373
+ label: 'one enemy at the foot of the hill, then of the cliff',
374
+ do: (ctx) => {
375
+ const enemy = liveEnemies(ctx)[0] as unknown as Vec | undefined;
376
+ if (!enemy) return;
377
+ hill = climb(ctx, enemy, [6.97, -20], [13.03, 2.571, -20]);
378
+ cliff = climb(ctx, enemy, [15.82, -20], [20.18, 3.759, -20]);
379
+ },
380
+ },
381
+ ],
382
+ });
383
+ ok('runScript reported no failures (SLOPE path)', result.ok);
384
+ ok(
385
+ `slopeLimitDeg 50: the enemy climbs the 40° hill and reaches the player on top (rose ${hill.rose.toFixed(2)} m, ${hill.gap.toFixed(1)} m short)`,
386
+ hill.rose > 2 && hill.gap < 3,
387
+ );
388
+ ok(
389
+ `…and cannot climb the 70° cliff (rose ${cliff.rose.toFixed(2)} m, ${cliff.gap.toFixed(1)} m short)`,
390
+ cliff.rose < 1 && cliff.gap > 3,
391
+ );
392
+ }
393
+
394
+ // ---- RIG: what the controller does about the world around it ---------------------
395
+ // `cameraCollision` and `platformCarry` were set by no example. The scene now
396
+ // declares both, the arena has a moving `Lift` (a StaticBody3D walked by
397
+ // Patrol), and each is asserted by an effect: how close the camera comes when
398
+ // a wall is behind you, and whether standing still on a moving floor keeps you
399
+ // on it. `floatHeight` was composed too and taken back out: the hover spring
400
+ // sags ~0.13 m under gravity, so below that the capsule rests on contact and
401
+ // the prop changes nothing (0.01 → y 0.839, 0.05 → 0.840, 0.3 → 1.008) — the
402
+ // rest height is reported here, not asserted, and the skill says why.
403
+ {
404
+ type Vec = { position: number[] };
405
+ let restY = 0;
406
+ let camGap = 99;
407
+ let liftTravel = 0;
408
+ let riderDrift = 99;
409
+ const result = await runScript(sceneJson, {
410
+ resolveScene: resolveScene as never,
411
+ behaviors,
412
+ durationMs: 8000,
413
+ steps: [
414
+ {
415
+ atMs: 100,
416
+ do: (ctx) => {
417
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
418
+ const cam = ctx.getNode('/root/Camera') as unknown as Vec;
419
+ const lift = ctx.getNode('/root/Arena/Lift') as unknown as Vec;
420
+ // where the capsule rests on open floor (half 0.5 + radius 0.34)
421
+ player.position = [0, 1.4, 12];
422
+ for (let i = 0; i < 120; i++) ctx.engine.step();
423
+ restY = player.position[1] ?? 0;
424
+ // cameraCollision: the boom points +z; 1.25 m from Wall2 (z 24.25) the
425
+ // camera cannot sit 4.2 m back
426
+ player.position = [10, 1.4, 23];
427
+ for (let i = 0; i < 120; i++) ctx.engine.step();
428
+ camGap = Math.hypot(
429
+ (cam.position[0] ?? 0) - (player.position[0] ?? 0),
430
+ (cam.position[2] ?? 0) - (player.position[2] ?? 0),
431
+ );
432
+ // platformCarry: stand on the Lift and do nothing for two seconds
433
+ const [lx = 0, , lz = 0] = lift.position;
434
+ player.position = [lx, 0.6 + 0.9, lz];
435
+ for (let i = 0; i < 30; i++) ctx.engine.step(); // settle
436
+ const x0 = lift.position[0] ?? 0;
437
+ const p0 = player.position[0] ?? 0;
438
+ for (let i = 0; i < 120; i++) ctx.engine.step();
439
+ liftTravel = (lift.position[0] ?? 0) - x0;
440
+ riderDrift = Math.abs((player.position[0] ?? 0) - p0 - liftTravel);
441
+ },
442
+ },
443
+ ],
444
+ });
445
+ ok('runScript reported no failures (RIG path)', result.ok);
446
+ ok(
447
+ `the capsule rests at y ${restY.toFixed(3)} on open floor (0.84 = half 0.5 + radius 0.34)`,
448
+ restY > 0.8 && restY < 0.9,
449
+ );
450
+ ok(
451
+ `cameraCollision: with a wall behind, the camera closes to ${camGap.toFixed(2)} m (4.2 in the open)`,
452
+ camGap < 2.2,
453
+ );
454
+ ok(
455
+ `platformCarry: the Lift travelled ${liftTravel.toFixed(2)} m and the rider drifted only ${riderDrift.toFixed(2)} m off it`,
456
+ Math.abs(liftTravel) > 2.5 && riderDrift < 0.5,
457
+ );
458
+ }
459
+
460
+ // ---- CATCH: an arena you cannot leave for good --------------------------------
461
+ // The Hill and Cliff plateaus, the Lift and a double jump at jumpVelocity 5 —
462
+ // three compositions of this round — add up to a route over the 3 m walls:
463
+ // 24 seeded playtests fell out of the world 3 times. The walls stay; a
464
+ // `Respawn` under the player catches anything that gets over them.
465
+ {
466
+ type Vec = { position: number[] };
467
+ let caughtAt: number[] = [];
468
+ const result = await runScript(sceneJson, {
469
+ resolveScene: resolveScene as never,
470
+ behaviors,
471
+ durationMs: 4000,
472
+ steps: [
473
+ {
474
+ atMs: 100,
475
+ do: (ctx) => {
476
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
477
+ player.position = [-14, 4.8, -26]; // where the trace put it: over Wall1
478
+ for (let i = 0; i < 240; i++) ctx.engine.step();
479
+ caughtAt = [...player.position];
480
+ },
481
+ },
482
+ ],
483
+ });
484
+ ok('runScript reported no failures (CATCH path)', result.ok);
485
+ ok(
486
+ `a player over the wall is caught and put back in the arena (y ${caughtAt[1]?.toFixed(2)}, z ${caughtAt[2]?.toFixed(1)})`,
487
+ (caughtAt[1] ?? -99) > 0 &&
488
+ Math.abs(caughtAt[2] ?? 99) < 24 &&
489
+ Math.abs(caughtAt[0] ?? 99) < 24,
490
+ );
491
+ }
492
+
493
+ // ---- RANGE: a mortar that lobs shells ---------------------------------------------
494
+ // `Spawner.at`/`autoStart`, `Projectile.direction`/`gravity` and a `Lifetime`
495
+ // riding a clone were set by no example. The arena's far corner has a Mortar
496
+ // (autoStart false, at [0,1,0]) whose Shell flies [0.8,0.6,0] at 8 u/s under
497
+ // `gravity: 9.81`. A POSITIVE gravity pulls DOWN in the scene's own convention
498
+ // now (+y in 2D, −y in 3D); it used to add to +y regardless, and 9.81 in this
499
+ // y-up scene LIFTED the shell — measured: y 19.36 at 1.5 s and still climbing
500
+ // at 59.8 m when the Lifetime freed it. Asserted by
501
+ // where the clone appears, which way its y goes and when it is freed — never
502
+ // by reading a prop back.
503
+ {
504
+ type Vec = { position: number[] };
505
+ type Sp = { liveCount: number; spawn(): Node | null };
506
+ let idleLive = -1;
507
+ let born: number[] = [];
508
+ const ys: number[] = [];
509
+ const xs: number[] = [];
510
+ let freedAtFrame = -1;
511
+ const result = await runScript(sceneJson, {
512
+ resolveScene: resolveScene as never,
513
+ behaviors,
514
+ durationMs: 8000,
515
+ steps: [
516
+ {
517
+ atMs: 100,
518
+ do: (ctx) => {
519
+ const sp = ctx.getNode('/root/Arena/Mortar').behavior as unknown as Sp;
520
+ for (let i = 0; i < 180; i++) ctx.engine.step(); // 3 s: autoStart false ticks nothing
521
+ idleLive = sp.liveCount;
522
+ const shell = sp.spawn();
523
+ if (!shell) return;
524
+ const s = shell as unknown as Vec;
525
+ born = [...s.position];
526
+ for (let i = 0; i < 240; i++) {
527
+ ctx.engine.step();
528
+ if (shell.parent === null) {
529
+ freedAtFrame = i + 1;
530
+ break;
531
+ }
532
+ xs.push(s.position[0] ?? 0);
533
+ ys.push(s.position[1] ?? 0);
534
+ }
535
+ },
536
+ },
537
+ ],
538
+ });
539
+ ok('runScript reported no failures (RANGE path)', result.ok);
540
+ ok(
541
+ `autoStart false: after 3 s the mortar has spawned ${idleLive} shells on its own`,
542
+ idleLive === 0,
543
+ );
544
+ ok(
545
+ `at [0,1,0]: the shell appears 1 m over the mortar (local ${born.map((v) => v.toFixed(2)).join(', ')})`,
546
+ Math.abs(born[0] ?? 9) < 0.01 &&
547
+ Math.abs((born[1] ?? 9) - 1) < 0.01 &&
548
+ Math.abs(born[2] ?? 9) < 0.01,
549
+ );
550
+ const apex = Math.max(...ys);
551
+ const apexAt = ys.indexOf(apex) / 60;
552
+ const y15 = ys[89] ?? Number.NaN;
553
+ const x15 = xs[89] ?? Number.NaN;
554
+ ok(
555
+ `gravity 9.81 pulls the shell DOWN in a y-up scene: apex ${apex.toFixed(2)} m at ${apexAt.toFixed(2)} s, y ${y15.toFixed(2)} at 1.5 s, x ${x15.toFixed(2)} (a lob at vy 4.8 peaks at 2.17 m at 0.49 s and is under the floor by 1.5 s; +9.81 climbed to 19.4 m)`,
556
+ apex < 2.5 && apexAt < 0.7 && y15 < 0,
557
+ );
558
+ ok(
559
+ `Lifetime 3 s + freeParent frees the shell at frame ${freedAtFrame} (180 = 3 s)`,
560
+ freedAtFrame >= 178 && freedAtFrame <= 184,
561
+ );
562
+ }
563
+
564
+ // ---- FACING: an enemy that turns to look where it walks ---------------------------
565
+ // `Chase.facePath`/`turnSpeed` were set by no example. The enemy's AI now points
566
+ // `facePath` at its Skin (the Eye rides the Skin, so the turn is visible) at
567
+ // 3 rad/s. Asserted by the Skin's yaw: settled on one heading, then how far it
568
+ // has turned 0.1 s after the target moves 90° round — a snap would be there already.
569
+ {
570
+ type Vec = { position: number[] };
571
+ type Rot = { rotation: number[] };
572
+ let yawSettled = 99;
573
+ let yawAfter100ms = 99;
574
+ let yawAfter1s = 99;
575
+ const result = await runScript(sceneJson, {
576
+ resolveScene: resolveScene as never,
577
+ behaviors,
578
+ durationMs: 6000,
579
+ steps: [
580
+ {
581
+ atMs: 3500,
582
+ do: (ctx) => {
583
+ const enemy = liveEnemies(ctx)[0] as (Node & Vec) | undefined;
584
+ if (!enemy) return;
585
+ const skin = enemy.findChild('Skin') as unknown as Rot;
586
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
587
+ enemy.position = [-16, 0.95, 14];
588
+ player.position = [-16, 1.4, 4]; // travel −z → yaw 180
589
+ for (let i = 0; i < 90; i++) ctx.engine.step();
590
+ yawSettled = skin.rotation[1] ?? 99;
591
+ player.position = [-6, 1.4, enemy.position[2] ?? 10]; // travel +x → yaw 90
592
+ for (let i = 0; i < 6; i++) ctx.engine.step();
593
+ yawAfter100ms = skin.rotation[1] ?? 99;
594
+ for (let i = 0; i < 54; i++) ctx.engine.step();
595
+ yawAfter1s = skin.rotation[1] ?? 99;
596
+ player.position = [0, 1.4, 18];
597
+ },
598
+ },
599
+ ],
600
+ });
601
+ ok('runScript reported no failures (FACING path)', result.ok);
602
+ ok(
603
+ `facePath ../Skin: walking −z the skin settles at yaw ${yawSettled.toFixed(1)}° (180 = +Z-forward turned about)`,
604
+ Math.abs(Math.abs(yawSettled) - 180) < 3,
605
+ );
606
+ ok(
607
+ `turnSpeed 3 rad/s: 0.1 s after the target moves round to +x the skin has turned to ${yawAfter100ms.toFixed(1)}° (≈163 expected — 17° of the 90; 90 would be a snap)`,
608
+ yawAfter100ms > 150 && yawAfter100ms < 175,
609
+ );
610
+ ok(`…and after 1 s it faces +x at ${yawAfter1s.toFixed(1)}°`, Math.abs(yawAfter1s - 90) < 5);
611
+ }
612
+
613
+ // ---- LIFT: a deck that turns to face where it travels (Patrol.facePath/turnSpeed) ---
614
+ // The same pair on `Patrol`, set by no example either. The Lift's Skin now turns
615
+ // toward its travel at 2 rad/s; a pingpong reversal is a 180° turn that should
616
+ // take π/2 s. Asserted by how far the deck has turned 0.25 s and 2 s after the
617
+ // reversal the harness watches for.
618
+ {
619
+ type Vec = { position: number[] };
620
+ type Rot = { rotation: number[] };
621
+ let flipAtFrame = -1;
622
+ let yawBefore = 99;
623
+ let yawQuarter = 99;
624
+ let yawLater = 99;
625
+ const result = await runScript(sceneJson, {
626
+ resolveScene: resolveScene as never,
627
+ behaviors,
628
+ durationMs: 12000,
629
+ steps: [
630
+ {
631
+ atMs: 100,
632
+ do: (ctx) => {
633
+ const lift = ctx.getNode('/root/Arena/Lift') as Node & Vec;
634
+ const skin = lift.findChild('Skin') as unknown as Rot;
635
+ let prev = lift.position[0] ?? 0;
636
+ let dir = 0;
637
+ for (let i = 0; i < 600; i++) {
638
+ ctx.engine.step();
639
+ const x = lift.position[0] ?? 0;
640
+ const d = Math.sign(x - prev);
641
+ prev = x;
642
+ if (d !== 0 && dir !== 0 && d !== dir) {
643
+ flipAtFrame = i;
644
+ break;
645
+ }
646
+ if (d !== 0) dir = d;
647
+ }
648
+ yawBefore = skin.rotation[1] ?? 99;
649
+ for (let i = 0; i < 15; i++) ctx.engine.step();
650
+ yawQuarter = skin.rotation[1] ?? 99;
651
+ for (let i = 0; i < 105; i++) ctx.engine.step();
652
+ yawLater = skin.rotation[1] ?? 99;
653
+ },
654
+ },
655
+ ],
656
+ });
657
+ const turned = (a: number, b: number): number => Math.abs(((b - a + 540) % 360) - 180);
658
+ ok('runScript reported no failures (LIFT path)', result.ok);
659
+ ok(
660
+ `the Lift reversed at frame ${flipAtFrame} (8 m at 2 m/s ≈ 4 s = 240)`,
661
+ flipAtFrame > 200 && flipAtFrame < 280,
662
+ );
663
+ ok(
664
+ `turnSpeed 2 rad/s: 0.25 s after the reversal the deck has turned ${turned(yawBefore, yawQuarter).toFixed(1)}° of 180 (≈29 expected)`,
665
+ turned(yawBefore, yawQuarter) > 20 && turned(yawBefore, yawQuarter) < 40,
666
+ );
667
+ ok(
668
+ `…and 2 s after it has turned the full ${turned(yawBefore, yawLater).toFixed(1)}°`,
669
+ turned(yawBefore, yawLater) > 170,
670
+ );
671
+ }
672
+
673
+ // ---- MINE: a fuse that starts when stepped on (Lifetime.startOnSignal) -------------
674
+ // A 1.5 s `Lifetime` with `startOnSignal` sits at the far corner with a Trigger
675
+ // area wired `triggerEnter → startTimer` under `filter: { group: "player" }` —
676
+ // the 1 m sphere overlaps the floor, and without the filter the FLOOR lit the
677
+ // fuse on frame 1. Asserted by the node still being there after 3 s, then going
678
+ // 1.5 s after the player steps on it.
679
+ {
680
+ type Vec = { position: number[] };
681
+ let expired = 0;
682
+ let aliveAfter3s = false;
683
+ let framesToExpire = -1;
684
+ const result = await runScript(sceneJson, {
685
+ resolveScene: resolveScene as never,
686
+ behaviors,
687
+ durationMs: 8000,
688
+ steps: [
689
+ {
690
+ atMs: 100,
691
+ do: (ctx) => {
692
+ const mine = ctx.getNode('/root/Arena/Mine');
693
+ mine.on('expired', () => expired++);
694
+ for (let i = 0; i < 180; i++) ctx.engine.step();
695
+ aliveAfter3s = mine.parent !== null && expired === 0;
696
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
697
+ player.position = [20, 1.4, 20];
698
+ for (let i = 0; i < 240; i++) {
699
+ ctx.engine.step();
700
+ if (expired > 0) {
701
+ framesToExpire = i + 1;
702
+ break;
703
+ }
704
+ }
705
+ player.position = [0, 1.4, 18];
706
+ },
707
+ },
708
+ ],
709
+ });
710
+ ok('runScript reported no failures (MINE path)', result.ok);
711
+ ok(
712
+ `startOnSignal: a 1.5 s Lifetime is still in the tree after 3 s (${aliveAfter3s})`,
713
+ aliveAfter3s,
714
+ );
715
+ ok(
716
+ `…and stepping on its trigger starts the fuse: expired ${framesToExpire} frames after (90 = 1.5 s, plus the contact frame or two)`,
717
+ framesToExpire >= 89 && framesToExpire <= 100,
718
+ );
719
+ }
720
+
721
+ // ---- BAIT: a pickup only the enemies take (Pickup.collectorGroup) ---------------------
722
+ // `collectorGroup` was set by no example — every pickup in the repo is the
723
+ // player's. A Bait area near Wall4 says `collectorGroup: "enemy"`. Asserted by who
724
+ // collects it: the player standing on it must not, an enemy walked onto it must.
725
+ {
726
+ type Vec = { position: number[] };
727
+ let collected = 0;
728
+ let playerTook = -1;
729
+ let enemyTook = -1;
730
+ let baitGone = false;
731
+ const result = await runScript(sceneJson, {
732
+ resolveScene: resolveScene as never,
733
+ behaviors,
734
+ durationMs: 6000,
735
+ steps: [
736
+ {
737
+ atMs: 3500,
738
+ do: (ctx) => {
739
+ const bait = ctx.getNode('/root/Arena/Bait');
740
+ bait.on('collected', () => collected++);
741
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
742
+ player.position = [21, 1.4, 12];
743
+ for (let i = 0; i < 30; i++) ctx.engine.step();
744
+ playerTook = collected;
745
+ player.position = [0, 1.4, 18];
746
+ const enemy = liveEnemies(ctx)[0] as unknown as Vec | undefined;
747
+ if (!enemy) return;
748
+ enemy.position = [21, 0.95, 12];
749
+ for (let i = 0; i < 30; i++) ctx.engine.step();
750
+ enemyTook = collected;
751
+ baitGone = bait.parent === null;
752
+ },
753
+ },
754
+ ],
755
+ });
756
+ ok('runScript reported no failures (BAIT path)', result.ok);
757
+ ok(
758
+ `collectorGroup enemy: the player standing on the bait collected ${playerTook} of it`,
759
+ playerTook === 0,
760
+ );
761
+ ok(
762
+ `…and an enemy walked onto it collected ${enemyTook} (bait freed: ${baitGone})`,
763
+ enemyTook === 1 && baitGone,
764
+ );
765
+ }
766
+
767
+ // ---- WAVES ON A SIGNAL: `WaveSpawner.autoStart` (measured, not composed) ----------
768
+ // Set by no example — every wave in the repo starts at ready. This starter's
769
+ // waves ARE the game, so the prop is measured on a copy of the JSON with
770
+ // `autoStart: false` rather than composed into it: five seconds must produce no
771
+ // enemy, and `start()` must then produce the first wave on its own schedule.
772
+ {
773
+ const text = JSON.stringify(sceneJson);
774
+ const key = '"name":"WaveSpawner","props":{';
775
+ ok('the scene declares its waves (the comparison needs them)', text.includes(key));
776
+ const held = JSON.parse(text.replace(key, `${key}"autoStart":false,`));
777
+ let idle = -1;
778
+ let after = -1;
779
+ const result = await runScript(held, {
780
+ resolveScene: resolveScene as never,
781
+ behaviors,
782
+ durationMs: 12000,
783
+ steps: [
784
+ {
785
+ atMs: 5000,
786
+ do: (ctx) => {
787
+ idle = liveEnemies(ctx).length;
788
+ (ctx.getNode('/root/Spawner').behavior as unknown as { start(): void }).start();
789
+ for (let i = 0; i < 330; i++) ctx.engine.step(); // delayBefore 2 + 4 × 1.1 = 6.4 s
790
+ after = liveEnemies(ctx).length;
791
+ },
792
+ },
793
+ ],
794
+ });
795
+ ok('runScript reported no failures (WAVES path)', result.ok);
796
+ ok(`autoStart false: five seconds in, ${idle} enemies`, idle === 0);
797
+ ok(`…and start() brings the first wave: ${after} enemies 5.5 s later (4 declared)`, after === 4);
798
+ }
799
+
221
800
  console.log(process.exitCode ? '\nVERIFY FAILED' : '\nVERIFY OK — Vanguard plays end to end');
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.72.0",
16
+ "incanto": "^0.74.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {