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
@@ -34,7 +34,9 @@
34
34
  "jump": { "type": "button", "keys": ["Space"], "touch": "button" },
35
35
  "sprint": { "type": "button", "keys": ["ShiftLeft", "ShiftRight"] },
36
36
  "interact": { "type": "button", "keys": ["KeyE"], "touch": "button" },
37
- "attack": { "type": "button", "keys": ["Mouse0", "KeyF"], "touch": "button" }
37
+ "attack": { "type": "button", "keys": ["Mouse0", "KeyF"], "touch": "button" },
38
+ "pause": { "type": "button", "keys": ["Escape"] },
39
+ "restart": { "type": "button", "keys": ["KeyR"] }
38
40
  },
39
41
  "assets": {
40
42
  "characters/base": {
@@ -64,6 +66,10 @@
64
66
  "anims/attack": {
65
67
  "type": "animation",
66
68
  "url": "https://agent8-games.verse8.io/assets/3d/animations/mixamorig/melee-attack.glb"
69
+ },
70
+ "anims/swim": {
71
+ "type": "animation",
72
+ "url": "https://agent8-games.verse8.io/assets/3d/animations/mixamorig/swim.glb"
67
73
  }
68
74
  },
69
75
  "root": {
@@ -2458,7 +2464,8 @@
2458
2464
  "walk": "$anims/walk",
2459
2465
  "run": "$anims/run",
2460
2466
  "fastRun": "$anims/runFast",
2461
- "airborne": "$anims/jump"
2467
+ "airborne": "$anims/jump",
2468
+ "swim": "$anims/swim"
2462
2469
  }
2463
2470
  },
2464
2471
  "script": { "name": "SwordStrike" },
@@ -2756,6 +2763,110 @@
2756
2763
  "anchor": "bottomRight",
2757
2764
  "shadow": true
2758
2765
  }
2766
+ },
2767
+ {
2768
+ "name": "PauseMenu",
2769
+ "type": "UiPanel",
2770
+ "uid": "n_36ox1wee9cdz2cgj",
2771
+ "props": {
2772
+ "anchor": "center",
2773
+ "layout": "column",
2774
+ "gap": 12,
2775
+ "padding": 22,
2776
+ "visible": false,
2777
+ "background": "rgba(10,14,22,0.92)",
2778
+ "radius": 12
2779
+ },
2780
+ "children": [
2781
+ {
2782
+ "name": "PausedTitle",
2783
+ "type": "UiText",
2784
+ "uid": "n_kdecsevdetjk0q1i",
2785
+ "props": { "text": "PAUSED", "size": 26 }
2786
+ },
2787
+ {
2788
+ "name": "Resume",
2789
+ "type": "UiButton",
2790
+ "uid": "n_vaxmkxrqot30vd7u",
2791
+ "props": { "text": "RESUME", "size": 18 }
2792
+ },
2793
+ {
2794
+ "name": "PauseOptions",
2795
+ "type": "UiButton",
2796
+ "uid": "n_3nt1ztixpsvjtb86",
2797
+ "props": { "text": "OPTIONS", "size": 16 }
2798
+ },
2799
+ {
2800
+ "name": "Save",
2801
+ "type": "UiButton",
2802
+ "uid": "n_enx1clunsomchjx2",
2803
+ "props": { "text": "SAVE", "size": 16 }
2804
+ },
2805
+ {
2806
+ "name": "Continue",
2807
+ "type": "UiButton",
2808
+ "uid": "n_3hpo1n3ho12l7po2",
2809
+ "props": { "text": "CONTINUE", "size": 16 }
2810
+ },
2811
+ {
2812
+ "name": "Restart",
2813
+ "type": "UiButton",
2814
+ "uid": "n_0ytaknljuqmthwpl",
2815
+ "props": { "text": "START OVER", "size": 16 }
2816
+ }
2817
+ ]
2818
+ },
2819
+ {
2820
+ "name": "Options",
2821
+ "type": "UiPanel",
2822
+ "uid": "n_drf0bze03zae5h4l",
2823
+ "props": {
2824
+ "anchor": "center",
2825
+ "layout": "column",
2826
+ "gap": 10,
2827
+ "padding": 22,
2828
+ "visible": false,
2829
+ "background": "rgba(10,14,22,0.92)",
2830
+ "radius": 12
2831
+ },
2832
+ "children": [
2833
+ {
2834
+ "name": "OptionsTitle",
2835
+ "type": "UiText",
2836
+ "uid": "n_wn6vz16vfsnz4guj",
2837
+ "props": { "text": "OPTIONS", "size": 24 }
2838
+ },
2839
+ {
2840
+ "name": "Master",
2841
+ "type": "UiVolumeSlider",
2842
+ "uid": "n_aguq7s3aeapln07y",
2843
+ "props": { "label": "master", "bus": "master", "width": 260 }
2844
+ },
2845
+ {
2846
+ "name": "Sfx",
2847
+ "type": "UiVolumeSlider",
2848
+ "uid": "n_yyaguhkmv9z4u9a8",
2849
+ "props": { "label": "effects", "bus": "sfx", "width": 260 }
2850
+ },
2851
+ {
2852
+ "name": "Mute",
2853
+ "type": "UiMuteToggle",
2854
+ "uid": "n_3yj77bemd2sjm4yi",
2855
+ "props": { "label": "mute everything" }
2856
+ },
2857
+ {
2858
+ "name": "Quality",
2859
+ "type": "UiQualitySelect",
2860
+ "uid": "n_e3ehj0ln9p28ib59",
2861
+ "props": { "label": "quality" }
2862
+ },
2863
+ {
2864
+ "name": "Back",
2865
+ "type": "UiButton",
2866
+ "uid": "n_y95ob0mpwrvvhr78",
2867
+ "props": { "text": "BACK", "size": 16 }
2868
+ }
2869
+ ]
2759
2870
  }
2760
2871
  ],
2761
2872
  "uid": "n_0q1r3a6rijr3hc1w"
@@ -2895,12 +3006,55 @@
2895
3006
  }
2896
3007
  }
2897
3008
  ]
3009
+ },
3010
+ {
3011
+ "name": "Flow",
3012
+ "type": "Node",
3013
+ "uid": "n_ibf6agaxzcxjhcmg",
3014
+ "script": { "name": "GameFlow", "props": { "saveSlots": "beacon-isle" } }
2898
3015
  }
2899
3016
  ],
2900
3017
  "uid": "n_nxftk1ep8wbi1bn9"
2901
3018
  },
2902
3019
  "connections": [
2903
3020
  { "signal": "interacted", "from": "Keeper", "to": "/root", "handler": "onTalk" },
2904
- { "signal": "died", "from": "Player", "to": "/root", "handler": "onPlayerDown" }
3021
+ { "signal": "died", "from": "Player", "to": "/root", "handler": "onPlayerDown" },
3022
+ {
3023
+ "from": "/root/HUD/PauseMenu/Resume",
3024
+ "signal": "pressed",
3025
+ "to": "/root/Flow",
3026
+ "handler": "resume"
3027
+ },
3028
+ {
3029
+ "from": "/root/HUD/PauseMenu/PauseOptions",
3030
+ "signal": "pressed",
3031
+ "to": "/root/Flow",
3032
+ "handler": "screen",
3033
+ "args": ["/root/HUD/Options"]
3034
+ },
3035
+ {
3036
+ "from": "/root/HUD/PauseMenu/Restart",
3037
+ "signal": "pressed",
3038
+ "to": "/root/Flow",
3039
+ "handler": "restart"
3040
+ },
3041
+ {
3042
+ "from": "/root/HUD/Options/Back",
3043
+ "signal": "pressed",
3044
+ "to": "/root/Flow",
3045
+ "handler": "back"
3046
+ },
3047
+ {
3048
+ "from": "/root/HUD/PauseMenu/Save",
3049
+ "signal": "pressed",
3050
+ "to": "/root/Flow",
3051
+ "handler": "save"
3052
+ },
3053
+ {
3054
+ "from": "/root/HUD/PauseMenu/Continue",
3055
+ "signal": "pressed",
3056
+ "to": "/root/Flow",
3057
+ "handler": "continueFrom"
3058
+ }
2905
3059
  ]
2906
3060
  }
@@ -25,3 +25,10 @@
25
25
  `tags` entry on the node and a branch in `onMoleClicked`.
26
26
  - Turn the field into a GRID of clickable cells and you have a match-3, a
27
27
  minesweeper or a tower-defense build menu; the input path is identical.
28
+
29
+ ## Pausing
30
+
31
+ ESC pauses and shows the pause menu; OPTIONS opens the settings over it and
32
+ BACK returns to the menu that asked. All of it is `GameFlow` (`pausePanelPath`,
33
+ `screen(path)`, `back()`) wired with `connections` — there is no TypeScript for
34
+ any screen in this game.
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
- "incanto": "^0.74.0",
14
+ "incanto": "^0.76.0",
15
15
  "three": "^0.184.0"
16
16
  },
17
17
  "devDependencies": {
@@ -1175,8 +1175,106 @@
1175
1175
  "type": "UiButton",
1176
1176
  "uid": "n_u20dbq90sumk4806",
1177
1177
  "props": { "anchor": "bottom", "text": "PLAY AGAIN", "visible": false }
1178
+ },
1179
+ {
1180
+ "name": "PauseMenu",
1181
+ "type": "UiPanel",
1182
+ "uid": "n_z9nyxykbc17ausrw",
1183
+ "props": {
1184
+ "anchor": "center",
1185
+ "layout": "column",
1186
+ "gap": 12,
1187
+ "padding": 22,
1188
+ "visible": false,
1189
+ "background": "rgba(10,14,22,0.92)",
1190
+ "radius": 12
1191
+ },
1192
+ "children": [
1193
+ {
1194
+ "name": "PausedTitle",
1195
+ "type": "UiText",
1196
+ "uid": "n_f7eckdwb24bwqpo8",
1197
+ "props": { "text": "PAUSED", "size": 26 }
1198
+ },
1199
+ {
1200
+ "name": "Resume",
1201
+ "type": "UiButton",
1202
+ "uid": "n_oz53g65a66krulb0",
1203
+ "props": { "text": "RESUME", "size": 18 }
1204
+ },
1205
+ {
1206
+ "name": "PauseOptions",
1207
+ "type": "UiButton",
1208
+ "uid": "n_cn8ix0y56khi3i90",
1209
+ "props": { "text": "OPTIONS", "size": 16 }
1210
+ },
1211
+ {
1212
+ "name": "Restart",
1213
+ "type": "UiButton",
1214
+ "uid": "n_cy1svsikf8hhtz3m",
1215
+ "props": { "text": "START OVER", "size": 16 }
1216
+ }
1217
+ ]
1218
+ },
1219
+ {
1220
+ "name": "Options",
1221
+ "type": "UiPanel",
1222
+ "uid": "n_2t2waf1lsux76rw9",
1223
+ "props": {
1224
+ "anchor": "center",
1225
+ "layout": "column",
1226
+ "gap": 10,
1227
+ "padding": 22,
1228
+ "visible": false,
1229
+ "background": "rgba(10,14,22,0.92)",
1230
+ "radius": 12
1231
+ },
1232
+ "children": [
1233
+ {
1234
+ "name": "OptionsTitle",
1235
+ "type": "UiText",
1236
+ "uid": "n_rp0oo781nder7a6m",
1237
+ "props": { "text": "OPTIONS", "size": 24 }
1238
+ },
1239
+ {
1240
+ "name": "Master",
1241
+ "type": "UiVolumeSlider",
1242
+ "uid": "n_8sufldfevj26zdb2",
1243
+ "props": { "label": "master", "bus": "master", "width": 260 }
1244
+ },
1245
+ {
1246
+ "name": "Sfx",
1247
+ "type": "UiVolumeSlider",
1248
+ "uid": "n_ahyngscv3eatp9mi",
1249
+ "props": { "label": "effects", "bus": "sfx", "width": 260 }
1250
+ },
1251
+ {
1252
+ "name": "Mute",
1253
+ "type": "UiMuteToggle",
1254
+ "uid": "n_478neek47ba9eaa4",
1255
+ "props": { "label": "mute everything" }
1256
+ },
1257
+ {
1258
+ "name": "Quality",
1259
+ "type": "UiQualitySelect",
1260
+ "uid": "n_f35z57tq3jsxrdz1",
1261
+ "props": { "label": "quality" }
1262
+ },
1263
+ {
1264
+ "name": "Back",
1265
+ "type": "UiButton",
1266
+ "uid": "n_m5d8dwpml28xj9jz",
1267
+ "props": { "text": "BACK", "size": 16 }
1268
+ }
1269
+ ]
1178
1270
  }
1179
1271
  ]
1272
+ },
1273
+ {
1274
+ "name": "Flow",
1275
+ "type": "Node",
1276
+ "uid": "n_08m0apva5yx370w6",
1277
+ "script": { "name": "GameFlow" }
1180
1278
  }
1181
1279
  ]
1182
1280
  },
@@ -1200,6 +1298,35 @@
1200
1298
  { "signal": "lost", "from": ".", "to": "Hud", "handler": "onLost" },
1201
1299
  { "signal": "won", "from": ".", "to": "Field", "handler": "stop" },
1202
1300
  { "signal": "lost", "from": ".", "to": "Field", "handler": "stop" },
1203
- { "signal": "pressed", "from": "Hud/Again", "to": "Hud", "handler": "onAgain" }
1204
- ]
1301
+ { "signal": "pressed", "from": "Hud/Again", "to": "Hud", "handler": "onAgain" },
1302
+ {
1303
+ "from": "/root/Hud/PauseMenu/Resume",
1304
+ "signal": "pressed",
1305
+ "to": "/root/Flow",
1306
+ "handler": "resume"
1307
+ },
1308
+ {
1309
+ "from": "/root/Hud/PauseMenu/PauseOptions",
1310
+ "signal": "pressed",
1311
+ "to": "/root/Flow",
1312
+ "handler": "screen",
1313
+ "args": ["/root/Hud/Options"]
1314
+ },
1315
+ {
1316
+ "from": "/root/Hud/PauseMenu/Restart",
1317
+ "signal": "pressed",
1318
+ "to": "/root/Flow",
1319
+ "handler": "restart"
1320
+ },
1321
+ {
1322
+ "from": "/root/Hud/Options/Back",
1323
+ "signal": "pressed",
1324
+ "to": "/root/Flow",
1325
+ "handler": "back"
1326
+ }
1327
+ ],
1328
+ "input": {
1329
+ "pause": { "type": "button", "keys": ["Escape"] },
1330
+ "restart": { "type": "button", "keys": ["KeyR"] }
1331
+ }
1205
1332
  }
@@ -65,3 +65,10 @@ A course past GroundD (x 3000+): `Trampoline` (`restitution 1`) with a dead `Bou
65
65
  (`friction 0`) with a `Sled` at 260 px/s, and a `Hound` (`CharacterBody2D` + `Chase`
66
66
  `ground: true`) under a `Perch`. `verify.ts` measures the rebound (287 of 300 px), the slide
67
67
  (400 px of ice) and that the hound stays on the floor under a perched player.
68
+
69
+ ## Pausing
70
+
71
+ ESC pauses and shows the pause menu; OPTIONS opens the settings over it and
72
+ BACK returns to the menu that asked. All of it is `GameFlow` (`pausePanelPath`,
73
+ `screen(path)`, `back()`) wired with `connections` — there is no TypeScript for
74
+ any screen in this game.
@@ -17,8 +17,9 @@ YOU MUST follow these rules EXACTLY when vibe-coding on this Incanto template
17
17
  `createGame2D({ behaviors })` and attached with `"script": { "name": "Name" }`.
18
18
  This game's custom behaviors are the platformer GAME FEEL the library leaves
19
19
  open: `PlayerController` (coyote-time, jump-buffer, double-jump, variable jump
20
- height, stomp, knockback, hearts+lives, checkpoint respawn, moving-platform
21
- carry), `GoblinSkin`, `FollowCam` (follow + screen-shake), `ParallaxLayer`,
20
+ height, stomp, checkpoint respawn, moving-platform carry — hearts, i-frames,
21
+ the knockback and the control lock are the Player's `Vitals` **Health**, which
22
+ the controller reads: `staggered`, `kickVelocity`, `takeLift()`), `GoblinSkin`, `FollowCam` (follow + screen-shake), `ParallaxLayer`,
22
23
  `HudUpdater`.
23
24
  4. 🔑 UIDS ARE GENERATED: omit `uid` and the loader assigns one (via the engine's
24
25
  `newUid`). NEVER hand-invent readable uid strings.
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
- "incanto": "^0.74.0",
14
+ "incanto": "^0.76.0",
15
15
  "three": "^0.184.0"
16
16
  },
17
17
  "devDependencies": {
@@ -23,6 +23,7 @@ import type { Node, UiBanner, UiText } from 'incanto';
23
23
  import { Behavior } from 'incanto';
24
24
  import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Node2D } from 'incanto/2d';
25
25
  import { Particles2D } from 'incanto/2d';
26
+ import type { Health } from 'incanto/gameplay';
26
27
 
27
28
  // ---- tuning (px, y-down; scene gravity is [0, 1800]) --------------------------
28
29
  const RUN_SPEED = 250;
@@ -35,12 +36,9 @@ const BUFFER = 0.12; // press jump slightly early and it still fires on landing
35
36
  const JUMP_CUT = 0.45; // release jump while rising → cut the climb (variable height)
36
37
  const MAX_JUMPS = 2; // ground jump + one air (double) jump
37
38
  const STOMP_BOUNCE = 560; // upward pop after squashing a goblin
38
- const KNOCK_X = 300;
39
- const KNOCK_UP = 360;
40
- const CONTROL_LOCK = 0.22; // seconds the player can't steer after a knockback
41
- const INVULN = 1.1; // i-frames after a hit
39
+ // hearts, i-frames, the knockback and the control lock after a hit are the
40
+ // Player's `Vitals` Health (game.scene.json) — this controller reads them
42
41
  const RESPAWN_INVULN = 1.4;
43
- const MAX_HEARTS = 3;
44
42
  const PLAYER_HW = 18;
45
43
  const PLAYER_HH = 33;
46
44
  const ENEMY_HW = 26;
@@ -71,14 +69,15 @@ interface Audio {
71
69
  }
72
70
 
73
71
  export class PlayerController extends Behavior {
74
- hearts = MAX_HEARTS;
72
+ /** Hearts = the Vitals' hit points; the HUD reads this. */
73
+ get hearts(): number {
74
+ return this.health.current;
75
+ }
75
76
 
76
77
  private gravity = 1800;
77
78
  private coyote = 0;
78
79
  private buffer = 0;
79
80
  private jumps = 0;
80
- private invuln = 0;
81
- private lock = 0;
82
81
  private wasGrounded = true;
83
82
  private respawn: [number, number] = [0, 0];
84
83
  private platform: Node2D | null = null;
@@ -89,6 +88,9 @@ export class PlayerController extends Behavior {
89
88
  private get body(): CharacterBody2D {
90
89
  return this.node as unknown as CharacterBody2D;
91
90
  }
91
+ private get health(): Health {
92
+ return this.getNode('Vitals').behavior as unknown as Health;
93
+ }
92
94
  private get skin(): AnimatedSprite2D {
93
95
  return this.getNode('Skin') as unknown as AnimatedSprite2D;
94
96
  }
@@ -108,6 +110,9 @@ export class PlayerController extends Behavior {
108
110
  this.getNode('/root').on('lost', () => {
109
111
  this.over = true;
110
112
  });
113
+ // a death is the Vitals' signal, whoever dealt it — a hit, a pit, or a
114
+ // probe calling `kill()` — so every route spends a life the same way
115
+ this.getNode('Vitals').on('died', () => this.die());
111
116
  // moving-platform CARRY: a Feet sensor remembers the platform we stand on so we
112
117
  // ride its motion (the kinematic controller doesn't inherit platform velocity).
113
118
  const feet = this.getNode('Feet');
@@ -137,15 +142,19 @@ export class PlayerController extends Behavior {
137
142
 
138
143
  this.coyote = Math.max(0, this.coyote - dt);
139
144
  this.buffer = Math.max(0, this.buffer - dt);
140
- this.invuln = Math.max(0, this.invuln - dt);
141
- this.lock = Math.max(0, this.lock - dt);
142
145
 
143
146
  const grounded = body.isOnFloor();
144
147
  const dir = this.input.getVector('move');
145
148
  let vx = body.velocity[0] ?? 0;
146
149
  let vy = body.velocity[1] ?? 0;
147
150
 
148
- if (this.lock <= 0) vx = dir.x * RUN_SPEED;
151
+ // a hit's stagger takes the stick, and its kick IS the velocity while it
152
+ // lasts: this controller drives the body, so it reads the Health's kick
153
+ // (`applyKnockback: false` in the scene) instead of Health moving the body
154
+ const health = this.health;
155
+ if (!health.staggered) vx = dir.x * RUN_SPEED;
156
+ const kick = health.kickVelocity;
157
+ if (kick.length > 0) vx = kick[0] ?? vx;
149
158
 
150
159
  if (grounded) {
151
160
  this.jumps = 0;
@@ -177,6 +186,13 @@ export class PlayerController extends Behavior {
177
186
  }
178
187
  if (this.input.justReleased('jump') && vy < 0) vy *= JUMP_CUT;
179
188
 
189
+ // the hit's hop: the kick's vertical part comes out ONCE, then gravity owns it
190
+ const lift = health.takeLift();
191
+ if (lift !== 0) {
192
+ vy = lift;
193
+ this.platform = null;
194
+ }
195
+
180
196
  body.velocity = [vx, vy];
181
197
 
182
198
  // ride a moving platform: add its per-frame delta before resolving collisions
@@ -224,12 +240,12 @@ export class PlayerController extends Behavior {
224
240
  body.velocity = [body.velocity[0] ?? 0, vy];
225
241
  this.jumps = 1; // a stomp refreshes the air jump — chain bounces
226
242
  this.shake(8);
227
- } else if (this.invuln <= 0) {
228
- this.takeHit(px < ex ? -1 : 1);
243
+ } else if (this.health.invulnerableRemaining <= 0) {
244
+ this.takeHit([ex, ey]);
229
245
  }
230
246
  }
231
247
 
232
- if (this.invuln <= 0) {
248
+ if (this.health.invulnerableRemaining <= 0) {
233
249
  for (const h of tree.getNodesInGroup('hazard')) {
234
250
  const hn = h as unknown as Node2D;
235
251
  const size = ((hn as unknown as { collider?: { size?: number[] } }).collider?.size ?? [
@@ -247,7 +263,7 @@ export class PlayerController extends Behavior {
247
263
  (size[1] ?? 40) / 2,
248
264
  )
249
265
  ) {
250
- this.takeHit(this.facing * -1);
266
+ this.takeHit([px + this.facing * 10, py]); // a hazard hits from the front
251
267
  break;
252
268
  }
253
269
  }
@@ -270,7 +286,7 @@ export class PlayerController extends Behavior {
270
286
  (size[1] ?? 80) / 2,
271
287
  )
272
288
  ) {
273
- this.die();
289
+ this.health.kill(); // → `died` → `die()`
274
290
  return;
275
291
  }
276
292
  }
@@ -300,23 +316,20 @@ export class PlayerController extends Behavior {
300
316
  }
301
317
  }
302
318
 
303
- private takeHit(dirX: number): void {
304
- this.hearts -= 1;
305
- this.invuln = INVULN;
306
- this.lock = CONTROL_LOCK;
307
- this.body.velocity = [dirX * KNOCK_X, -KNOCK_UP];
319
+ /** Hurt from a world point: the Vitals take the heart, the i-frames, the kick and the stagger. */
320
+ private takeHit(from: [number, number]): void {
321
+ const health = this.health;
322
+ health.damage(1, from); // the killing blow reaches `die()` through `died`
308
323
  this.sfx('SfxHurt');
309
324
  this.shake(10);
310
- if (this.hearts <= 0) this.die();
311
325
  }
312
326
 
313
327
  private die(): void {
314
328
  const sk = this.scoreKeeper;
315
329
  sk.loseLife();
316
330
  if (sk.lives > 0) {
317
- this.hearts = MAX_HEARTS;
318
- this.invuln = RESPAWN_INVULN;
319
- this.lock = 0;
331
+ this.health.revive();
332
+ this.health.protect(RESPAWN_INVULN);
320
333
  this.platform = null;
321
334
  this.body.velocity = [0, 0];
322
335
  this.body.position = [this.respawn[0], this.respawn[1]];
@@ -381,7 +394,8 @@ export class PlayerController extends Behavior {
381
394
  this.playOnce(skin, clip);
382
395
 
383
396
  // blink while invulnerable
384
- skin.opacity = this.invuln > 0 && Math.floor(this.invuln * 14) % 2 === 0 ? 0.35 : 1;
397
+ const inv = this.health.invulnerableRemaining;
398
+ skin.opacity = inv > 0 && Math.floor(inv * 14) % 2 === 0 ? 0.35 : 1;
385
399
  }
386
400
 
387
401
  private lastClip = '';