incanto 0.57.0 → 0.58.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 (63) hide show
  1. package/README.md +6 -4
  2. package/bin/incanto-check.mjs +27 -0
  3. package/bin/incanto-new.mjs +29 -7
  4. package/bin/incanto-playtest.mjs +28 -2
  5. package/bin/incanto-verify.mjs +87 -15
  6. package/bin/incanto.mjs +106 -0
  7. package/dist/2d.js +2 -2
  8. package/dist/3d.js +2 -2
  9. package/dist/{create-game-DpbUrMOQ.js → create-game-BZwWJIns.js} +1 -1
  10. package/dist/{create-game-C5jQYPah.js → create-game-D10bU5_J.js} +56 -8
  11. package/dist/index.js +1 -1
  12. package/dist/{physics-2d-BLcvEFDR.js → physics-2d-EqA-jddf.js} +2 -1
  13. package/dist/{physics-3d-QBrfIT2Y.js → physics-3d-Dnz4fsXX.js} +2 -1
  14. package/dist/quiet-rapier-BAJ4K94N.js +46 -0
  15. package/dist/react.js +1 -1
  16. package/dist/{src-CGjmPw65.js → src-C1J09Op6.js} +1 -1
  17. package/dist/{test-it1VekWs.js → test-ZBga8kQ9.js} +36 -10
  18. package/dist/test.d.ts +1 -1
  19. package/dist/test.js +1 -1
  20. package/dist/vite.js +2 -2
  21. package/editor/assets/{agent8-CGT7r3Mb.js → agent8-BQQjE9UQ.js} +1 -1
  22. package/editor/assets/{debug-BxWSIHG3.js → debug-C9UCsXBS.js} +1 -1
  23. package/editor/assets/{index-CV1m-aX5.js → index-CBgfM3WD.js} +51 -51
  24. package/editor/index.html +1 -1
  25. package/package.json +2 -1
  26. package/skills/incanto-building-2d-games.md +13 -0
  27. package/skills/incanto-building-3d-games.md +3 -3
  28. package/skills/incanto-playtesting.md +19 -5
  29. package/skills/incanto-verifying-your-game.md +11 -4
  30. package/templates-app/beacon-isle-3d/package.json +1 -1
  31. package/templates-app/beacon-isle-3d/src/game.scene.json +7 -6
  32. package/templates-app/platformer-2d/PROJECT/Context.md +70 -0
  33. package/templates-app/platformer-2d/PROJECT/Requirements.md +63 -0
  34. package/templates-app/platformer-2d/PROJECT/Status.md +60 -0
  35. package/templates-app/platformer-2d/PROJECT/Structure.md +77 -0
  36. package/templates-app/platformer-2d/docs/project-2d-rules.md +61 -0
  37. package/templates-app/platformer-2d/index.html +99 -0
  38. package/templates-app/platformer-2d/package.json +23 -0
  39. package/templates-app/platformer-2d/src/behaviors.ts +541 -0
  40. package/templates-app/platformer-2d/src/game.scene.json +2061 -0
  41. package/templates-app/platformer-2d/src/main.ts +68 -0
  42. package/templates-app/platformer-2d/tsconfig.json +13 -0
  43. package/templates-app/platformer-2d/verify.ts +275 -0
  44. package/templates-app/platformer-2d/vite.config.ts +12 -0
  45. package/templates-app/star-survivor/PROJECT/Context.md +55 -0
  46. package/templates-app/star-survivor/PROJECT/Requirements.md +47 -0
  47. package/templates-app/star-survivor/PROJECT/Status.md +44 -0
  48. package/templates-app/star-survivor/PROJECT/Structure.md +63 -0
  49. package/templates-app/star-survivor/docs/project-2d-rules.md +53 -0
  50. package/templates-app/star-survivor/index.html +232 -0
  51. package/templates-app/star-survivor/package.json +23 -0
  52. package/templates-app/star-survivor/src/behaviors.ts +624 -0
  53. package/templates-app/star-survivor/src/game.scene.json +464 -0
  54. package/templates-app/star-survivor/src/main.ts +49 -0
  55. package/templates-app/star-survivor/tsconfig.json +13 -0
  56. package/templates-app/star-survivor/verify.ts +193 -0
  57. package/templates-app/star-survivor/vite.config.ts +12 -0
  58. package/templates-app/tps-3d/package.json +1 -1
  59. package/templates-app/tps-3d/src/game.scene.json +6 -3
  60. package/templates-app/tps-3d/verify.ts +17 -1
  61. package/templates-app/village-quest-3d/package.json +1 -1
  62. package/templates-app/village-quest-3d/src/grove.scene.json +14 -13
  63. package/templates-app/village-quest-3d/src/village.scene.json +5 -5
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "platformer-2d",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "typecheck": "tsc --noEmit",
9
+ "check": "incanto-check src",
10
+ "verify": "bun verify.ts"
11
+ },
12
+ "dependencies": {
13
+ "@dimforge/rapier2d-compat": "0.19.3",
14
+ "incanto": "^0.58.0",
15
+ "three": "^0.184.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^24.0.0",
19
+ "@types/three": "^0.184.1",
20
+ "typescript": "^6.0.3",
21
+ "vite": "^8.0.16"
22
+ }
23
+ }
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Castle Run — the custom behaviors. Almost everything that CAN be a built-in IS
3
+ * one (Pickup coins, Patrol enemies/lifts, Oscillate bobbing pickups, ScoreKeeper
4
+ * score/lives/win-lose, AudioPlayer SFX, all wired in `game.scene.json`). These
5
+ * exist only for the parts a renderer-agnostic library has no opinion about — the
6
+ * platformer GAME FEEL and the presentation:
7
+ *
8
+ * PlayerController — the heart. A hand-tuned platformer controller on the
9
+ * CharacterBody2D: run + gravity + COYOTE TIME + JUMP BUFFER + DOUBLE JUMP +
10
+ * VARIABLE jump height, plus STOMP-to-kill (Mario-style), side-hit knockback,
11
+ * hearts + lives, checkpoint RESPAWN, moving-platform CARRY, and the
12
+ * idle/run/jump sprite + facing. The built-in CharacterController2D is
13
+ * frame-perfect and stiff; a great platformer needs the forgiveness above.
14
+ * GoblinSkin — face the patrol direction + play the goblin walk clip.
15
+ * FollowCam — follow the knight (look-ahead + smoothing + world clamp) AND
16
+ * screen-SHAKE on stomps/landings/hits (juice the built-in camera
17
+ * can't do; one script per node, so follow + shake live together).
18
+ * ParallaxLayer— scroll a castle backdrop slower than the camera for depth.
19
+ * HudUpdater — paint ScoreKeeper/hearts/lives into the HUD + win/lose banner.
20
+ */
21
+
22
+ import type { Node } from 'incanto';
23
+ import { Behavior } from 'incanto';
24
+ import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Label, Node2D } from 'incanto/2d';
25
+ import { Particles2D } from 'incanto/2d';
26
+
27
+ // ---- tuning (px, y-down; scene gravity is [0, 1800]) --------------------------
28
+ const RUN_SPEED = 250;
29
+ const JUMP_V = 735; // ≈ √(2·1800·150) → ~150px apex
30
+ const DOUBLE_V = 625; // the air jump is a touch weaker
31
+ const MAX_FALL = 1250;
32
+ const GROUND_SNAP = 20; // tiny downward bias keeps the controller glued to the floor
33
+ const COYOTE = 0.1; // grace window to still jump just after leaving a ledge
34
+ const BUFFER = 0.12; // press jump slightly early and it still fires on landing
35
+ const JUMP_CUT = 0.45; // release jump while rising → cut the climb (variable height)
36
+ const MAX_JUMPS = 2; // ground jump + one air (double) jump
37
+ 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
42
+ const RESPAWN_INVULN = 1.4;
43
+ const MAX_HEARTS = 3;
44
+ const PLAYER_HW = 18;
45
+ const PLAYER_HH = 33;
46
+ const ENEMY_HW = 26;
47
+ const ENEMY_HH = 30;
48
+ const STOMP_SCORE = 50;
49
+
50
+ const aabb = (
51
+ ax: number,
52
+ ay: number,
53
+ ahw: number,
54
+ ahh: number,
55
+ bx: number,
56
+ by: number,
57
+ bhw: number,
58
+ bhh: number,
59
+ ): boolean => Math.abs(ax - bx) < ahw + bhw && Math.abs(ay - by) < ahh + bhh;
60
+
61
+ interface ScoreLike {
62
+ score: number;
63
+ lives: number;
64
+ scoreToWin: number;
65
+ addScore(n: number): void;
66
+ setScore(n: number): void;
67
+ loseLife(): void;
68
+ }
69
+ interface Audio {
70
+ play(): void;
71
+ }
72
+
73
+ export class PlayerController extends Behavior {
74
+ hearts = MAX_HEARTS;
75
+
76
+ private gravity = 1800;
77
+ private coyote = 0;
78
+ private buffer = 0;
79
+ private jumps = 0;
80
+ private invuln = 0;
81
+ private lock = 0;
82
+ private wasGrounded = true;
83
+ private respawn: [number, number] = [0, 0];
84
+ private platform: Node2D | null = null;
85
+ private platPrev: [number, number] = [0, 0];
86
+ private over = false;
87
+ private facing = 1;
88
+
89
+ private get body(): CharacterBody2D {
90
+ return this.node as unknown as CharacterBody2D;
91
+ }
92
+ private get skin(): AnimatedSprite2D {
93
+ return this.getNode('Skin') as unknown as AnimatedSprite2D;
94
+ }
95
+ private get scoreKeeper(): ScoreLike {
96
+ return this.getNode('/root').behavior as unknown as ScoreLike;
97
+ }
98
+
99
+ private sfx(name: string): void {
100
+ (this.getNode(`/root/${name}`) as unknown as Audio | null)?.play?.();
101
+ }
102
+
103
+ private gravityRead = false;
104
+
105
+ override onReady(): void {
106
+ const p = this.body.position;
107
+ this.respawn = [p[0] ?? 0, p[1] ?? 0];
108
+ this.getNode('/root').on('lost', () => {
109
+ this.over = true;
110
+ });
111
+ // moving-platform CARRY: a Feet sensor remembers the platform we stand on so we
112
+ // ride its motion (the kinematic controller doesn't inherit platform velocity).
113
+ const feet = this.getNode('Feet');
114
+ feet.on('triggerEnter', (o) => {
115
+ const n = o as Node2D;
116
+ if ((n as unknown as Node).isInGroup?.('platform')) {
117
+ this.platform = n;
118
+ this.platPrev = [n.position[0] ?? 0, n.position[1] ?? 0];
119
+ }
120
+ });
121
+ feet.on('triggerExit', (o) => {
122
+ if (o === (this.platform as unknown)) this.platform = null;
123
+ });
124
+ }
125
+
126
+ override fixedUpdate(dt: number): void {
127
+ const body = this.body;
128
+ if (!this.gravityRead) {
129
+ this.gravity = (this.engine.scene?.physics?.gravity as number[] | undefined)?.[1] ?? 1800;
130
+ this.gravityRead = true;
131
+ }
132
+ if (this.over) {
133
+ body.velocity = [0, 0];
134
+ body.moveAndSlide();
135
+ return;
136
+ }
137
+
138
+ this.coyote = Math.max(0, this.coyote - dt);
139
+ 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
+
143
+ const grounded = body.isOnFloor();
144
+ const dir = this.input.getVector('move');
145
+ let vx = body.velocity[0] ?? 0;
146
+ let vy = body.velocity[1] ?? 0;
147
+
148
+ if (this.lock <= 0) vx = dir.x * RUN_SPEED;
149
+
150
+ if (grounded) {
151
+ this.jumps = 0;
152
+ this.coyote = COYOTE;
153
+ if (vy >= 0) vy = GROUND_SNAP;
154
+ if (!this.wasGrounded && vy >= 0) this.onLand();
155
+ } else {
156
+ vy += this.gravity * dt;
157
+ if (vy > MAX_FALL) vy = MAX_FALL;
158
+ }
159
+
160
+ if (this.input.justPressed('jump')) this.buffer = BUFFER;
161
+ if (this.buffer > 0) {
162
+ if (this.jumps === 0 && (grounded || this.coyote > 0)) {
163
+ vy = -JUMP_V;
164
+ this.jumps = 1;
165
+ this.buffer = 0;
166
+ this.coyote = 0;
167
+ this.platform = null;
168
+ this.sfx('SfxJump');
169
+ } else if (this.jumps < MAX_JUMPS) {
170
+ vy = -DOUBLE_V;
171
+ this.jumps = Math.max(this.jumps, 1) + 1;
172
+ this.buffer = 0;
173
+ this.platform = null;
174
+ this.sfx('SfxJump');
175
+ this.puff(body.position[0] ?? 0, (body.position[1] ?? 0) + PLAYER_HH, '#cfe8ff');
176
+ }
177
+ }
178
+ if (this.input.justReleased('jump') && vy < 0) vy *= JUMP_CUT;
179
+
180
+ body.velocity = [vx, vy];
181
+
182
+ // ride a moving platform: add its per-frame delta before resolving collisions
183
+ if (this.platform && grounded) {
184
+ const cx = this.platform.position[0] ?? 0;
185
+ const cy = this.platform.position[1] ?? 0;
186
+ body.position = [
187
+ (body.position[0] ?? 0) + (cx - this.platPrev[0]),
188
+ (body.position[1] ?? 0) + (cy - this.platPrev[1]),
189
+ ];
190
+ }
191
+ if (this.platform) {
192
+ this.platPrev = [this.platform.position[0] ?? 0, this.platform.position[1] ?? 0];
193
+ }
194
+
195
+ const preBottom = (body.position[1] ?? 0) + PLAYER_HH;
196
+ body.moveAndSlide();
197
+ this.wasGrounded = grounded;
198
+
199
+ this.resolveContacts(preBottom);
200
+ }
201
+
202
+ /** Stomps, side-hits, spikes, pits, checkpoints, goal — all by AABB so there is
203
+ * one authority and no double-trigger (stomp vs damage on the same frame). */
204
+ private resolveContacts(preBottom: number): void {
205
+ const body = this.body;
206
+ const px = body.position[0] ?? 0;
207
+ const py = body.position[1] ?? 0;
208
+ const tree = this.node.tree;
209
+ if (!tree) return;
210
+ let vy = body.velocity[1] ?? 0;
211
+
212
+ for (const e of tree.getNodesInGroup('enemy')) {
213
+ const en = e as unknown as Node2D;
214
+ const ex = en.position[0] ?? 0;
215
+ const ey = en.position[1] ?? 0;
216
+ if (!aabb(px, py, PLAYER_HW, PLAYER_HH, ex, ey, ENEMY_HW, ENEMY_HH)) continue;
217
+ const cameFromAbove = preBottom <= ey - ENEMY_HH + 18 && vy > 0;
218
+ if (cameFromAbove) {
219
+ e.queueFree();
220
+ this.scoreKeeper.addScore(STOMP_SCORE);
221
+ this.sfx('SfxStomp');
222
+ this.puff(ex, ey - ENEMY_HH, '#9be36b');
223
+ vy = -STOMP_BOUNCE;
224
+ body.velocity = [body.velocity[0] ?? 0, vy];
225
+ this.jumps = 1; // a stomp refreshes the air jump — chain bounces
226
+ this.shake(8);
227
+ } else if (this.invuln <= 0) {
228
+ this.takeHit(px < ex ? -1 : 1);
229
+ }
230
+ }
231
+
232
+ if (this.invuln <= 0) {
233
+ for (const h of tree.getNodesInGroup('hazard')) {
234
+ const hn = h as unknown as Node2D;
235
+ const size = ((hn as unknown as { collider?: { size?: number[] } }).collider?.size ?? [
236
+ 40, 40,
237
+ ]) as number[];
238
+ if (
239
+ aabb(
240
+ px,
241
+ py,
242
+ PLAYER_HW,
243
+ PLAYER_HH,
244
+ hn.position[0] ?? 0,
245
+ hn.position[1] ?? 0,
246
+ (size[0] ?? 40) / 2,
247
+ (size[1] ?? 40) / 2,
248
+ )
249
+ ) {
250
+ this.takeHit(this.facing * -1);
251
+ break;
252
+ }
253
+ }
254
+ }
255
+
256
+ for (const pit of tree.getNodesInGroup('pit')) {
257
+ const pn = pit as unknown as Node2D;
258
+ const size = ((pn as unknown as { collider?: { size?: number[] } }).collider?.size ?? [
259
+ 4000, 80,
260
+ ]) as number[];
261
+ if (
262
+ aabb(
263
+ px,
264
+ py,
265
+ PLAYER_HW,
266
+ PLAYER_HH,
267
+ pn.position[0] ?? 0,
268
+ pn.position[1] ?? 0,
269
+ (size[0] ?? 4000) / 2,
270
+ (size[1] ?? 80) / 2,
271
+ )
272
+ ) {
273
+ this.die();
274
+ return;
275
+ }
276
+ }
277
+
278
+ for (const c of tree.getNodesInGroup('checkpoint')) {
279
+ const cn = c as unknown as Node2D;
280
+ if (aabb(px, py, PLAYER_HW, PLAYER_HH, cn.position[0] ?? 0, cn.position[1] ?? 0, 36, 80)) {
281
+ const cx = cn.position[0] ?? 0;
282
+ const cy = cn.position[1] ?? 0;
283
+ if (this.respawn[0] !== cx || this.respawn[1] !== cy) {
284
+ this.respawn = [cx, cy];
285
+ this.sfx('SfxCheckpoint');
286
+ this.puff(cx, cy - 40, '#ffd166');
287
+ }
288
+ }
289
+ }
290
+
291
+ for (const g of tree.getNodesInGroup('goal')) {
292
+ const gn = g as unknown as Node2D;
293
+ if (aabb(px, py, PLAYER_HW, PLAYER_HH, gn.position[0] ?? 0, gn.position[1] ?? 0, 40, 80)) {
294
+ const sk = this.scoreKeeper;
295
+ if (sk.score < sk.scoreToWin) {
296
+ sk.setScore(sk.scoreToWin);
297
+ this.over = true;
298
+ }
299
+ }
300
+ }
301
+ }
302
+
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];
308
+ this.sfx('SfxHurt');
309
+ this.shake(10);
310
+ if (this.hearts <= 0) this.die();
311
+ }
312
+
313
+ private die(): void {
314
+ const sk = this.scoreKeeper;
315
+ sk.loseLife();
316
+ if (sk.lives > 0) {
317
+ this.hearts = MAX_HEARTS;
318
+ this.invuln = RESPAWN_INVULN;
319
+ this.lock = 0;
320
+ this.platform = null;
321
+ this.body.velocity = [0, 0];
322
+ this.body.position = [this.respawn[0], this.respawn[1]];
323
+ this.shake(6);
324
+ } else {
325
+ this.over = true;
326
+ }
327
+ }
328
+
329
+ private onLand(): void {
330
+ const vyFall = this.body.velocity[1] ?? 0;
331
+ if (vyFall > 200) {
332
+ this.puff(this.body.position[0] ?? 0, (this.body.position[1] ?? 0) + PLAYER_HH, '#d9c9a3');
333
+ this.shake(3);
334
+ }
335
+ }
336
+
337
+ private shake(mag: number): void {
338
+ const cam = this.getNode('/root/Camera').behavior as unknown as { shake?: (m: number) => void };
339
+ cam?.shake?.(mag);
340
+ }
341
+
342
+ private puff(x: number, y: number, color: string): void {
343
+ const p = new Particles2D('Puff');
344
+ p.position = [x, y];
345
+ p.preset = 'custom';
346
+ p.emitting = false;
347
+ p.rate = 0;
348
+ p.burst = 12;
349
+ p.spreadDeg = 360;
350
+ p.directionDeg = -90;
351
+ p.lifetime = [0.18, 0.4];
352
+ p.speed = [60, 200];
353
+ p.gravity = [0, 300];
354
+ p.drag = 2;
355
+ p.sizeStart = 16;
356
+ p.sizeEnd = 2;
357
+ p.colorStart = color;
358
+ p.colorEnd = color;
359
+ p.alphaStart = 0.9;
360
+ p.alphaEnd = 0;
361
+ p.blend = 'normal';
362
+ p.maxParticles = 14;
363
+ this.node.getRoot().addChild(p as unknown as Node);
364
+ p.on('finished', () => p.parent?.removeChild(p as unknown as Node));
365
+ }
366
+
367
+ // ---- presentation (animation + facing + i-frame blink) ----------------------
368
+ override update(): void {
369
+ const body = this.body;
370
+ const skin = this.skin;
371
+ if (!skin) return;
372
+ const vx = body.velocity[0] ?? 0;
373
+ const vy = body.velocity[1] ?? 0;
374
+ if (vx > 8) this.facing = 1;
375
+ else if (vx < -8) this.facing = -1;
376
+ skin.flipX = this.facing < 0;
377
+
378
+ let clip = 'idle';
379
+ if (!body.isOnFloor() && Math.abs(vy) > 40) clip = 'jump';
380
+ else if (Math.abs(vx) > 12) clip = 'run';
381
+ this.playOnce(skin, clip);
382
+
383
+ // blink while invulnerable
384
+ skin.opacity = this.invuln > 0 && Math.floor(this.invuln * 14) % 2 === 0 ? 0.35 : 1;
385
+ }
386
+
387
+ private lastClip = '';
388
+ private playOnce(skin: AnimatedSprite2D, name: string): void {
389
+ if (this.lastClip === name) return;
390
+ this.lastClip = name;
391
+ skin.play(name);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * GoblinSkin — face the patrol heading + play the walk clip. The built-in Patrol
397
+ * moves the node; this only flips the sprite to look where it walks.
398
+ */
399
+ export class GoblinSkin extends Behavior {
400
+ private lastX = 0;
401
+ /** The goblin's moving root (Patrol drives the parent; this lives on a child). */
402
+ private get root(): Node2D {
403
+ return this.node.parent as unknown as Node2D;
404
+ }
405
+ private get skin(): AnimatedSprite2D {
406
+ return this.getNode('../Skin') as unknown as AnimatedSprite2D;
407
+ }
408
+ override onReady(): void {
409
+ this.lastX = this.root.position[0] ?? 0;
410
+ this.skin?.play('move');
411
+ }
412
+ override update(): void {
413
+ const x = this.root.position[0] ?? 0;
414
+ const dx = x - this.lastX;
415
+ this.lastX = x;
416
+ if (Math.abs(dx) > 0.05) this.skin.flipX = dx < 0; // sheet faces right by default
417
+ }
418
+ }
419
+
420
+ /**
421
+ * FollowCam — chase the knight (look-ahead + smoothing + world clamp) and add a
422
+ * decaying screen SHAKE on demand. One script per node, so the camera owns both.
423
+ */
424
+ export class FollowCam extends Behavior {
425
+ static readonly props = {
426
+ target: { default: '/root/Player' },
427
+ offset: { default: [70, -40] },
428
+ lookahead: { default: 90 },
429
+ limits: { default: [] as number[] },
430
+ };
431
+ target = '/root/Player';
432
+ offset: number[] = [70, -40];
433
+ lookahead = 90;
434
+ limits: number[] = [];
435
+
436
+ private shakeT = 0;
437
+ private shakeMag = 0;
438
+ private lookX = 0;
439
+
440
+ shake(mag: number): void {
441
+ this.shakeMag = Math.max(this.shakeMag, mag);
442
+ this.shakeT = 0.28;
443
+ }
444
+
445
+ override update(dt: number): void {
446
+ const cam = this.node as unknown as Camera2D;
447
+ const t = this.getNode(this.target) as unknown as Node2D | null;
448
+ if (!t) return;
449
+ const vx = ((t.behavior as unknown as { body?: { velocity: number[] } })?.body?.velocity?.[0] ??
450
+ 0) as number;
451
+ const wantLook = vx > 8 ? this.lookahead : vx < -8 ? -this.lookahead : this.lookX;
452
+ this.lookX += (wantLook - this.lookX) * Math.min(1, dt * 4);
453
+
454
+ const dx = (t.position[0] ?? 0) + (this.offset[0] ?? 0) + this.lookX;
455
+ const dy = (t.position[1] ?? 0) + (this.offset[1] ?? 0);
456
+ const a = 1 - Math.exp(-dt * 7);
457
+ let cx = (cam.position[0] ?? 0) + (dx - (cam.position[0] ?? 0)) * a;
458
+ let cy = (cam.position[1] ?? 0) + (dy - (cam.position[1] ?? 0)) * a;
459
+
460
+ if (this.limits.length === 4) {
461
+ cx = Math.min(Math.max(cx, this.limits[0] ?? cx), this.limits[2] ?? cx);
462
+ cy = Math.min(Math.max(cy, this.limits[1] ?? cy), this.limits[3] ?? cy);
463
+ }
464
+ if (this.shakeT > 0) {
465
+ this.shakeT -= dt;
466
+ const k = (this.shakeMag * Math.max(0, this.shakeT)) / 0.28;
467
+ cx += (this.rng.next() * 2 - 1) * k;
468
+ cy += (this.rng.next() * 2 - 1) * k;
469
+ if (this.shakeT <= 0) this.shakeMag = 0;
470
+ }
471
+ cam.position = [cx, cy];
472
+ }
473
+ }
474
+
475
+ /**
476
+ * ParallaxLayer — scroll a backdrop slower than the camera for depth. factor 0 =
477
+ * locked to the world, 1 = locked to the screen (infinitely far).
478
+ */
479
+ export class ParallaxLayer extends Behavior {
480
+ static readonly props = { factor: { default: 0.5 } };
481
+ factor = 0.5;
482
+ private baseX = 0;
483
+
484
+ override onReady(): void {
485
+ this.baseX = (this.node as unknown as Node2D).position[0] ?? 0;
486
+ }
487
+ override update(): void {
488
+ const cam = this.getNode('/root/Camera') as unknown as Node2D;
489
+ const layer = this.node as unknown as Node2D;
490
+ layer.position = [this.baseX + (cam.position[0] ?? 0) * this.factor, layer.position[1] ?? 0];
491
+ }
492
+ }
493
+
494
+ /**
495
+ * HudUpdater — paint ScoreKeeper.score, the player's hearts, and lives into the
496
+ * HUD Labels; flip the won/lost signals into the centered banner. No rules here.
497
+ */
498
+ interface HeartState {
499
+ hearts: number;
500
+ }
501
+ export class HudUpdater extends Behavior {
502
+ private get coinLabel(): Label {
503
+ return this.getNode('CoinLabel') as unknown as Label;
504
+ }
505
+ private get heartLabel(): Label {
506
+ return this.getNode('HeartLabel') as unknown as Label;
507
+ }
508
+ private get livesLabel(): Label {
509
+ return this.getNode('LivesLabel') as unknown as Label;
510
+ }
511
+ private get banner(): Label {
512
+ return this.getNode('/root/BannerLayer/Banner') as unknown as Label;
513
+ }
514
+ private get score(): ScoreLike {
515
+ return this.getNode('/root').behavior as unknown as ScoreLike;
516
+ }
517
+ private get player(): HeartState {
518
+ return this.getNode('/root/Player').behavior as unknown as HeartState;
519
+ }
520
+
521
+ override onReady(): void {
522
+ const root = this.getNode('/root');
523
+ root.on('won', () => this.show('CASTLE CLEARED!', '#ffd166'));
524
+ root.on('lost', () => this.show('GAME OVER', '#ef476f'));
525
+ this.refresh();
526
+ }
527
+ override update(): void {
528
+ this.refresh();
529
+ }
530
+ private refresh(): void {
531
+ const hearts = Math.max(0, this.player.hearts ?? 0);
532
+ this.coinLabel.text = `◆ ${this.score.score}`;
533
+ this.heartLabel.text = `${'♥'.repeat(hearts)}${'·'.repeat(Math.max(0, 3 - hearts))}`;
534
+ this.livesLabel.text = `x ${Math.max(0, this.score.lives)}`;
535
+ }
536
+ private show(text: string, color: string): void {
537
+ const b = this.banner;
538
+ b.text = text;
539
+ b.color = color;
540
+ }
541
+ }