incanto 0.74.0 → 0.76.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 (92) hide show
  1. package/bin/incanto-multiplay.mjs +52 -4
  2. package/dist/2d.d.ts +83 -7
  3. package/dist/2d.js +4 -3
  4. package/dist/3d.d.ts +861 -167
  5. package/dist/3d.js +6 -6
  6. package/dist/{agent8-CHTT4unP.js → agent8-B-bh3n0X.js} +1 -1
  7. package/dist/{audio-player-BOrk4eQx.d.ts → audio-player-BRo2uvG6.d.ts} +23 -2
  8. package/dist/{behavior-J1cmVah0.d.ts → behavior-B_245qRy.d.ts} +15 -1
  9. package/dist/{create-game-ssvGvP7W.js → create-game-BMIPKrQW.js} +84 -8
  10. package/dist/{create-game-BAiA-FjP.js → create-game-Dai5vfVu.js} +80 -11
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/{editor-switch-CFU9mCec.d.ts → editor-switch-Bzt0GzVp.d.ts} +13 -3
  13. package/dist/editor.js +1600 -1529
  14. package/dist/env.d.ts +1 -1
  15. package/dist/{environment-presets-BkqlWewf.js → environment-presets-e_9YCnGu.js} +2361 -104
  16. package/dist/{gameplay-Bxe1sMVT.js → gameplay-Bfff_beb.js} +10919 -6798
  17. package/dist/gameplay.d.ts +573 -31
  18. package/dist/gameplay.js +3 -2
  19. package/dist/index.d.ts +150 -6
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-BAeWMYfF.d.ts → loader-CbkVdXL8.d.ts} +2 -2
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +2 -2
  24. package/dist/{physics-2d-C-SfvjzH.js → physics-2d-Tuf5LAyq.js} +250 -31
  25. package/dist/{physics-3d-rOzeg890.js → physics-3d-C5MOujfQ.js} +673 -51
  26. package/dist/{picking-BMV34Pjl.js → picking-DIwE48sU.js} +10 -7
  27. package/dist/{save-slots-CEUGeLuB.js → pose-ByFB_J3O.js} +217 -11
  28. package/dist/react.d.ts +2 -2
  29. package/dist/react.js +1 -1
  30. package/dist/{register-DRQyZKGU.js → register-7E3P1gz6.js} +191 -430
  31. package/dist/{register-CjociOtt.js → register-B768Qedj.js} +596 -2
  32. package/dist/{replay-B6BbgGM_.js → replay-DrqQ6dZj.js} +133 -18
  33. package/dist/{replay-wZK5Ok5c.d.ts → replay-W5nCw_cU.d.ts} +21 -2
  34. package/dist/{rng-Bb-IutXB.d.ts → rng-BsXZg3D6.d.ts} +7 -0
  35. package/dist/{split-screen-BxZ5rBe7.js → split-screen-B-dIcQsQ.js} +10 -5
  36. package/dist/{split-screen-D6sx8z3Z.d.ts → split-screen-CiLJf2-C.d.ts} +3 -3
  37. package/dist/{sprite-animation-Bz3DHD_4.js → sprite-animation-SQa5gIu2.js} +8 -6
  38. package/dist/{src-CqtvYtSN.js → src-D823V07k.js} +1 -1
  39. package/dist/{test-CEl0y2Sw.js → test-CVbxnXlv.js} +487 -82
  40. package/dist/test.d.ts +90 -12
  41. package/dist/test.js +2 -2
  42. package/dist/vite.js +34 -5
  43. package/editor/assets/{agent8-BlzturGi.js → agent8-DA-I5qhv.js} +1 -1
  44. package/editor/assets/{debug-BMUi8usj.js → debug-99tEPoGm.js} +1 -1
  45. package/editor/assets/index-Db74LZY-.js +11579 -0
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +1639 -280
  49. package/skills/incanto-3d-character.md +383 -20
  50. package/skills/incanto-audio.md +24 -2
  51. package/skills/incanto-building-2d-games.md +21 -2
  52. package/skills/incanto-building-3d-games.md +38 -3
  53. package/skills/incanto-editor.md +29 -8
  54. package/skills/incanto-environment.md +257 -14
  55. package/skills/incanto-game-feel.md +60 -1
  56. package/skills/incanto-gameplay-behaviors.md +668 -20
  57. package/skills/incanto-hud.md +116 -0
  58. package/skills/incanto-multiplayer.md +141 -2
  59. package/skills/incanto-node-reference.md +398 -16
  60. package/skills/incanto-performance.md +32 -0
  61. package/skills/incanto-physics-and-input.md +241 -13
  62. package/skills/incanto-playtesting.md +2 -2
  63. package/skills/incanto-save-slots.md +61 -2
  64. package/skills/incanto-scene-json-authoring.md +13 -0
  65. package/skills/incanto-verifying-your-game.md +86 -3
  66. package/templates-app/beacon-isle-3d/PROJECT/Status.md +12 -0
  67. package/templates-app/beacon-isle-3d/package.json +1 -1
  68. package/templates-app/beacon-isle-3d/src/game.scene.json +157 -3
  69. package/templates-app/molehill-2d/PROJECT/Status.md +7 -0
  70. package/templates-app/molehill-2d/package.json +1 -1
  71. package/templates-app/molehill-2d/src/game.scene.json +129 -2
  72. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  73. package/templates-app/platformer-2d/docs/project-2d-rules.md +3 -2
  74. package/templates-app/platformer-2d/package.json +1 -1
  75. package/templates-app/platformer-2d/src/behaviors.ts +40 -26
  76. package/templates-app/platformer-2d/src/game.scene.json +144 -2
  77. package/templates-app/platformer-2d/verify.ts +18 -1
  78. package/templates-app/star-survivor/PROJECT/Status.md +7 -0
  79. package/templates-app/star-survivor/package.json +1 -1
  80. package/templates-app/star-survivor/src/behaviors.ts +9 -8
  81. package/templates-app/star-survivor/src/game.scene.json +142 -4
  82. package/templates-app/star-survivor/verify.ts +13 -0
  83. package/templates-app/tps-3d/PROJECT/Status.md +6 -0
  84. package/templates-app/tps-3d/package.json +1 -1
  85. package/templates-app/tps-3d/src/game.scene.json +71 -10
  86. package/templates-app/village-quest-3d/PROJECT/Status.md +7 -0
  87. package/templates-app/village-quest-3d/package.json +1 -1
  88. package/templates-app/village-quest-3d/src/behaviors.ts +8 -16
  89. package/templates-app/village-quest-3d/src/grove.scene.json +121 -5
  90. package/templates-app/village-quest-3d/src/village.scene.json +126 -2
  91. package/templates-app/village-quest-3d/verify.ts +52 -2
  92. package/editor/assets/index-CrUCQoaB.js +0 -11046
@@ -27,6 +27,7 @@ import { Behavior, findPath, gridFromRows, Timer } from 'incanto';
27
27
  import { mergeSolidRects, parseCells } from 'incanto/2d';
28
28
  import type { Camera3D, CharacterController3D, Node3D, Trail3D } from 'incanto/3d';
29
29
  import { MeshInstance3D, Particles3D, StaticBody3D } from 'incanto/3d';
30
+ import type { Health } from 'incanto/gameplay';
30
31
  import { goToScene } from 'incanto/gameplay';
31
32
  import groveJsonRaw from './grove.scene.json';
32
33
  import { quest, resetQuest } from './quest';
@@ -558,19 +559,13 @@ export class SwordStrike extends Behavior {
558
559
  if (!wolf.tree) return; // already gone
559
560
  const at = (wolf as unknown as Positioned).position;
560
561
  (this.player.getNode('HitSfx') as AudioPlayer).play();
561
- // two hits per wolf: the first FLINCHES it (knockback along the strike)
562
- const hits = (wolfHits.get(wolf) ?? 0) + 1;
563
- wolfHits.set(wolf, hits);
564
- if (hits < 2) {
565
- const [px, , pz] = this.player.position;
566
- const dx = (at[0] ?? 0) - (px ?? 0);
567
- const dz = (at[2] ?? 0) - (pz ?? 0);
568
- const len = Math.hypot(dx, dz) || 1;
569
- (wolf as unknown as Positioned).position = [
570
- (at[0] ?? 0) + (dx / len) * 1.2,
571
- at[1] ?? 0.45,
572
- (at[2] ?? 0) + (dz / len) * 1.2,
573
- ];
562
+ // two hits per wolf (`Vitals` is a `Health` of 2): the first FLINCHES it
563
+ // `knockback` shoves it along the strike and `staggerSeconds` holds its
564
+ // PathFollow — and the second fells it. The blow's direction is the hit's
565
+ // source: the player's position.
566
+ const vitals = wolf.getNode('Vitals').behavior as Health;
567
+ vitals.damage(1, this.player.position);
568
+ if (!vitals.isDead) {
574
569
  (wolf.getNodeOrNull('GrowlSfx') as AudioPlayer | null)?.play();
575
570
  flashWolf(wolf);
576
571
  return;
@@ -591,9 +586,6 @@ export class SwordStrike extends Behavior {
591
586
  }
592
587
  }
593
588
 
594
- /** Strike bookkeeping: hits per wolf (module-scope — survives behavior instances). */
595
- const wolfHits = new WeakMap<Node, number>();
596
-
597
589
  /**
598
590
  * Red hurt-flash on every mesh of the wolf, reverted a beat later.
599
591
  *
@@ -228,6 +228,15 @@
228
228
  },
229
229
  "uid": "n_7pidlrhnsw3irdcq"
230
230
  },
231
+ {
232
+ "name": "Vitals",
233
+ "type": "Node",
234
+ "script": {
235
+ "name": "Health",
236
+ "props": { "max": 2, "knockback": 3, "staggerSeconds": 0.35 }
237
+ },
238
+ "uid": "n_3a3vhg3kjbt8z3gy"
239
+ },
231
240
  {
232
241
  "name": "Fang",
233
242
  "type": "Area3D",
@@ -327,6 +336,15 @@
327
336
  },
328
337
  "uid": "n_b41lz424hn6e1jh1"
329
338
  },
339
+ {
340
+ "name": "Vitals",
341
+ "type": "Node",
342
+ "script": {
343
+ "name": "Health",
344
+ "props": { "max": 2, "knockback": 3, "staggerSeconds": 0.35 }
345
+ },
346
+ "uid": "n_8btt3s1ekwpzz16r"
347
+ },
330
348
  {
331
349
  "name": "Fang",
332
350
  "type": "Area3D",
@@ -426,6 +444,15 @@
426
444
  },
427
445
  "uid": "n_2ph84bwixda1kbuf"
428
446
  },
447
+ {
448
+ "name": "Vitals",
449
+ "type": "Node",
450
+ "script": {
451
+ "name": "Health",
452
+ "props": { "max": 2, "knockback": 3, "staggerSeconds": 0.35 }
453
+ },
454
+ "uid": "n_astg45lvndezejsw"
455
+ },
429
456
  {
430
457
  "name": "Fang",
431
458
  "type": "Area3D",
@@ -664,13 +691,77 @@
664
691
  "uid": "n_ucyuyojea32g6zm4"
665
692
  },
666
693
  {
667
- "name": "ResumeHint",
668
- "type": "UiText",
669
- "props": { "text": "Esc to resume · R to restart", "size": 13, "color": "#c9d2dd" },
670
- "uid": "n_rs5w00p3m62rmoy6"
694
+ "name": "Resume",
695
+ "type": "UiButton",
696
+ "uid": "n_ibf6agaxzcxjhcmg",
697
+ "props": { "text": "RESUME", "size": 18 }
698
+ },
699
+ {
700
+ "name": "PauseOptions",
701
+ "type": "UiButton",
702
+ "uid": "n_kdecsevdetjk0q1i",
703
+ "props": { "text": "OPTIONS", "size": 16 }
704
+ },
705
+ {
706
+ "name": "Restart",
707
+ "type": "UiButton",
708
+ "uid": "n_vaxmkxrqot30vd7u",
709
+ "props": { "text": "START OVER", "size": 16 }
671
710
  }
672
711
  ],
673
712
  "uid": "n_5wfhk1dbgyv25gxl"
713
+ },
714
+ {
715
+ "name": "Options",
716
+ "type": "UiPanel",
717
+ "uid": "n_p9poexefeps6a5ti",
718
+ "props": {
719
+ "anchor": "center",
720
+ "layout": "column",
721
+ "gap": 10,
722
+ "padding": 22,
723
+ "visible": false,
724
+ "background": "rgba(10,14,22,0.92)",
725
+ "radius": 12
726
+ },
727
+ "children": [
728
+ {
729
+ "name": "OptionsTitle",
730
+ "type": "UiText",
731
+ "uid": "n_iewdvemq5y7qda2b",
732
+ "props": { "text": "OPTIONS", "size": 24 }
733
+ },
734
+ {
735
+ "name": "Master",
736
+ "type": "UiVolumeSlider",
737
+ "uid": "n_ycigwyt5cj3bjz3a",
738
+ "props": { "label": "master", "bus": "master", "width": 260 }
739
+ },
740
+ {
741
+ "name": "Sfx",
742
+ "type": "UiVolumeSlider",
743
+ "uid": "n_6mnynp8ycxxalwbi",
744
+ "props": { "label": "effects", "bus": "sfx", "width": 260 }
745
+ },
746
+ {
747
+ "name": "Mute",
748
+ "type": "UiMuteToggle",
749
+ "uid": "n_g2ckhuighfpqsz03",
750
+ "props": { "label": "mute everything" }
751
+ },
752
+ {
753
+ "name": "Quality",
754
+ "type": "UiQualitySelect",
755
+ "uid": "n_owxh3r8ktaohote2",
756
+ "props": { "label": "quality" }
757
+ },
758
+ {
759
+ "name": "Back",
760
+ "type": "UiButton",
761
+ "uid": "n_g7ugvwf2z03wjf2r",
762
+ "props": { "text": "BACK", "size": 16 }
763
+ }
764
+ ]
674
765
  }
675
766
  ],
676
767
  "uid": "n_wwhwnwye7olskfer"
@@ -680,6 +771,31 @@
680
771
  },
681
772
  "connections": [
682
773
  { "signal": "triggerEnter", "from": "ReturnPortal", "to": "/root", "handler": "onPortalEnter" },
683
- { "signal": "died", "from": "Player", "to": "/root", "handler": "onPlayerDown" }
774
+ { "signal": "died", "from": "Player", "to": "/root", "handler": "onPlayerDown" },
775
+ {
776
+ "from": "/root/HUD/PauseMenu/Resume",
777
+ "signal": "pressed",
778
+ "to": "/root/Flow",
779
+ "handler": "resume"
780
+ },
781
+ {
782
+ "from": "/root/HUD/PauseMenu/PauseOptions",
783
+ "signal": "pressed",
784
+ "to": "/root/Flow",
785
+ "handler": "screen",
786
+ "args": ["/root/HUD/Options"]
787
+ },
788
+ {
789
+ "from": "/root/HUD/PauseMenu/Restart",
790
+ "signal": "pressed",
791
+ "to": "/root/Flow",
792
+ "handler": "restart"
793
+ },
794
+ {
795
+ "from": "/root/HUD/Options/Back",
796
+ "signal": "pressed",
797
+ "to": "/root/Flow",
798
+ "handler": "back"
799
+ }
684
800
  ]
685
801
  }
@@ -47,7 +47,8 @@
47
47
  "sprint": { "type": "button", "keys": ["ShiftLeft", "ShiftRight"] },
48
48
  "interact": { "type": "button", "keys": ["KeyE"] },
49
49
  "attack": { "type": "button", "keys": ["Mouse0", "KeyF"] },
50
- "restart": { "type": "button", "keys": ["KeyR"] }
50
+ "restart": { "type": "button", "keys": ["KeyR"] },
51
+ "pause": { "type": "button", "keys": ["Escape"] }
51
52
  },
52
53
  "assets": {
53
54
  "characters/base": {
@@ -3092,9 +3093,107 @@
3092
3093
  "type": "UiLanguageSelect",
3093
3094
  "uid": "n_4m6tsfl0og3ekii3",
3094
3095
  "props": { "label": "@t:settings.language", "anchor": "topRight" }
3096
+ },
3097
+ {
3098
+ "name": "PauseMenu",
3099
+ "type": "UiPanel",
3100
+ "uid": "n_2qwqjqh4quth7e2n",
3101
+ "props": {
3102
+ "anchor": "center",
3103
+ "layout": "column",
3104
+ "gap": 12,
3105
+ "padding": 22,
3106
+ "visible": false,
3107
+ "background": "rgba(10,14,22,0.92)",
3108
+ "radius": 12
3109
+ },
3110
+ "children": [
3111
+ {
3112
+ "name": "PausedTitle",
3113
+ "type": "UiText",
3114
+ "uid": "n_txt30ax88omwicjs",
3115
+ "props": { "text": "PAUSED", "size": 26 }
3116
+ },
3117
+ {
3118
+ "name": "Resume",
3119
+ "type": "UiButton",
3120
+ "uid": "n_jo0ovhcin79mtfel",
3121
+ "props": { "text": "RESUME", "size": 18 }
3122
+ },
3123
+ {
3124
+ "name": "PauseOptions",
3125
+ "type": "UiButton",
3126
+ "uid": "n_gu9f3scxx3ey6zoc",
3127
+ "props": { "text": "OPTIONS", "size": 16 }
3128
+ },
3129
+ {
3130
+ "name": "Restart",
3131
+ "type": "UiButton",
3132
+ "uid": "n_v1jdh9w3njlqtc4u",
3133
+ "props": { "text": "START OVER", "size": 16 }
3134
+ }
3135
+ ]
3136
+ },
3137
+ {
3138
+ "name": "Options",
3139
+ "type": "UiPanel",
3140
+ "uid": "n_kq3hlan3nrz5keog",
3141
+ "props": {
3142
+ "anchor": "center",
3143
+ "layout": "column",
3144
+ "gap": 10,
3145
+ "padding": 22,
3146
+ "visible": false,
3147
+ "background": "rgba(10,14,22,0.92)",
3148
+ "radius": 12
3149
+ },
3150
+ "children": [
3151
+ {
3152
+ "name": "OptionsTitle",
3153
+ "type": "UiText",
3154
+ "uid": "n_i0a6qykgauqump71",
3155
+ "props": { "text": "OPTIONS", "size": 24 }
3156
+ },
3157
+ {
3158
+ "name": "Master",
3159
+ "type": "UiVolumeSlider",
3160
+ "uid": "n_n5h7cfsjsych63ix",
3161
+ "props": { "label": "master", "bus": "master", "width": 260 }
3162
+ },
3163
+ {
3164
+ "name": "Sfx",
3165
+ "type": "UiVolumeSlider",
3166
+ "uid": "n_a37vbt17sodktb1h",
3167
+ "props": { "label": "effects", "bus": "sfx", "width": 260 }
3168
+ },
3169
+ {
3170
+ "name": "Mute",
3171
+ "type": "UiMuteToggle",
3172
+ "uid": "n_wxmwd9gbf16mia7v",
3173
+ "props": { "label": "mute everything" }
3174
+ },
3175
+ {
3176
+ "name": "Quality",
3177
+ "type": "UiQualitySelect",
3178
+ "uid": "n_g9f4iazzwcqxlykd",
3179
+ "props": { "label": "quality" }
3180
+ },
3181
+ {
3182
+ "name": "Back",
3183
+ "type": "UiButton",
3184
+ "uid": "n_6vkdb2onbyjrwakr",
3185
+ "props": { "text": "BACK", "size": 16 }
3186
+ }
3187
+ ]
3095
3188
  }
3096
3189
  ],
3097
3190
  "uid": "n_6u92lb0drej635s9"
3191
+ },
3192
+ {
3193
+ "name": "Flow",
3194
+ "type": "Node",
3195
+ "uid": "n_y2pc0uwbjnsx2ppe",
3196
+ "script": { "name": "GameFlow" }
3098
3197
  }
3099
3198
  ],
3100
3199
  "uid": "n_2a2pghqmq92ko29c"
@@ -3102,6 +3201,31 @@
3102
3201
  "connections": [
3103
3202
  { "signal": "interacted", "from": "Elder", "to": "/root", "handler": "onTalk" },
3104
3203
  { "signal": "triggerEnter", "from": "GroveGate", "to": "/root", "handler": "onGateEnter" },
3105
- { "signal": "pressed", "from": "HUD/RestartBtn", "to": "/root", "handler": "onRestart" }
3204
+ { "signal": "pressed", "from": "HUD/RestartBtn", "to": "/root", "handler": "onRestart" },
3205
+ {
3206
+ "from": "/root/HUD/PauseMenu/Resume",
3207
+ "signal": "pressed",
3208
+ "to": "/root/Flow",
3209
+ "handler": "resume"
3210
+ },
3211
+ {
3212
+ "from": "/root/HUD/PauseMenu/PauseOptions",
3213
+ "signal": "pressed",
3214
+ "to": "/root/Flow",
3215
+ "handler": "screen",
3216
+ "args": ["/root/HUD/Options"]
3217
+ },
3218
+ {
3219
+ "from": "/root/HUD/PauseMenu/Restart",
3220
+ "signal": "pressed",
3221
+ "to": "/root/Flow",
3222
+ "handler": "restart"
3223
+ },
3224
+ {
3225
+ "from": "/root/HUD/Options/Back",
3226
+ "signal": "pressed",
3227
+ "to": "/root/Flow",
3228
+ "handler": "back"
3229
+ }
3106
3230
  ]
3107
3231
  }
@@ -117,6 +117,8 @@ const ok = (label: string, cond: boolean): void => {
117
117
  let sceneName = (): string => 'unset';
118
118
  let wolvesWalked = false;
119
119
 
120
+ let wolfKnock = 0; // metres the first strike shoved the wolf along the blow (Health.knockback)
121
+ let wolfHeld = -1; // metres it walked in the 8 frames AFTER that — its PathFollow is held
120
122
  const result = await runScript(villageJson, {
121
123
  durationMs: 60000,
122
124
  seed: 7,
@@ -222,7 +224,8 @@ const ok = (label: string, cond: boolean): void => {
222
224
  const root = engine.scene?.tree.root;
223
225
  if (!root) throw new Error('no scene');
224
226
  const player = root.getNode('Player') as unknown as Vec;
225
- // wolves take TWO strikes now (first = flinch knockback)
227
+ // wolves take TWO strikes now (first = flinch: `Health.knockback` shoves
228
+ // it along the blow and `staggerSeconds` holds its PathFollow)
226
229
  for (let round = 0; round < 6; round++) {
227
230
  const wolves = engine.scene?.tree.getNodesInGroup('wolf') ?? [];
228
231
  const target = wolves[0] as unknown as Vec | undefined;
@@ -233,10 +236,49 @@ const ok = (label: string, cond: boolean): void => {
233
236
  (target.position[2] ?? 0) + 0.8,
234
237
  ];
235
238
  engine.step();
239
+ let hitAt = -1;
240
+ let hitPos: number[] = [];
241
+ let cur = 0;
242
+ if (round === 0) {
243
+ const vitals = (
244
+ target as unknown as { getNode(p: string): { behavior: unknown } }
245
+ ).getNode('Vitals').behavior as {
246
+ on(s: string, f: () => void): void;
247
+ };
248
+ vitals.on('damaged', () => {
249
+ hitAt = cur;
250
+ hitPos = [...target.position];
251
+ });
252
+ }
236
253
  engine.input.pressAction('attack');
237
254
  engine.step();
238
255
  engine.input.releaseAction('attack');
239
- for (let i = 0; i < 40; i++) engine.step();
256
+ let held: number[] = [];
257
+ for (let i = 0; i < (round === 0 ? 60 : 40); i++) {
258
+ cur = i;
259
+ engine.step();
260
+ if (round === 0 && hitAt >= 0) {
261
+ const away = [
262
+ (target.position[0] ?? 0) - (player.position[0] ?? 0),
263
+ (target.position[2] ?? 0) - (player.position[2] ?? 0),
264
+ ];
265
+ const len = Math.hypot(away[0] ?? 0, away[1] ?? 0) || 1;
266
+ const moved = [
267
+ (target.position[0] ?? 0) - (hitPos[0] ?? 0),
268
+ (target.position[2] ?? 0) - (hitPos[2] ?? 0),
269
+ ];
270
+ if (i === hitAt + 9) {
271
+ wolfKnock =
272
+ ((moved[0] ?? 0) * (away[0] ?? 0) + (moved[1] ?? 0) * (away[1] ?? 0)) / len;
273
+ held = [...target.position];
274
+ }
275
+ if (i === hitAt + 17)
276
+ wolfHeld = Math.hypot(
277
+ (target.position[0] ?? 0) - (held[0] ?? 0),
278
+ (target.position[2] ?? 0) - (held[2] ?? 0),
279
+ );
280
+ }
281
+ }
240
282
  }
241
283
  const left = engine.scene?.tree.getNodesInGroup('wolf').length ?? -1;
242
284
  if (left !== 0) throw new Error(`${left} wolves survived`);
@@ -276,6 +318,14 @@ const ok = (label: string, cond: boolean): void => {
276
318
  });
277
319
 
278
320
  ok('QUEST: full loop (dialogue → grove → wolves → report back)', result.ok);
321
+ ok(
322
+ `QUEST: the first strike MOVED the wolf ${wolfKnock.toFixed(2)} m along the blow (Health.knockback)`,
323
+ wolfKnock > 0.12,
324
+ );
325
+ ok(
326
+ `QUEST: …and HELD it: ${wolfHeld.toFixed(2)} m walked in the 8 frames after (PathFollow staggered; free = 0.32 m)`,
327
+ wolfHeld >= 0 && wolfHeld < 0.05,
328
+ );
279
329
  for (const f of result.failures) console.error(` ${f.label}: ${f.message}`);
280
330
  }
281
331