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,624 @@
1
+ /**
2
+ * Hollow Horde — the custom behaviors for a top-down SURVIVORS-LIKE (Vampire
3
+ * Survivors genre). Spawning + chasing + contact damage are BUILT-INS wired in
4
+ * `game.scene.json` (two `Spawner`s clone goblin/ghost templates; each enemy
5
+ * `Chase`s the player and a `DamageOnContact` hitbox drains the player's `Health`).
6
+ * What a renderer-agnostic library can't give you — the SURVIVOR loop — is here:
7
+ *
8
+ * Survivor — the player: top-down move + facing/anim, AUTO-ATTACK (fire bolts at
9
+ * the nearest enemy on a cooldown, managed in one list), XP pickup + MAGNET,
10
+ * LEVEL-UP that pauses the game and offers 3 random UPGRADES, survive-timer win,
11
+ * and the DOM HUD (HP/XP/level/kills/timer + banner + level-up cards).
12
+ * Director — escalates the horde: scatters each freshly-spawned enemy onto a ring
13
+ * AROUND the player (built-in Spawners spawn at the origin) and ramps spawn rate
14
+ * + enemy HP/speed with elapsed time.
15
+ * EnemyDrop — on an enemy's death, drop an XP shard and tally the kill.
16
+ * EnemyFace — flip the enemy sprite toward its travel direction + play 'move'.
17
+ *
18
+ * One script per node, so the player's `Health` lives on a child `Vitals` node
19
+ * (DamageOnContact finds it by searching the contacted body's descendants).
20
+ */
21
+
22
+ import type { Node } from 'incanto';
23
+ import { Behavior } from 'incanto';
24
+ import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Node2D } from 'incanto/2d';
25
+ import { ColorRect2D, Particles2D } from 'incanto/2d';
26
+
27
+ const RAD2DEG = 180 / Math.PI;
28
+ const hasDom = typeof document !== 'undefined';
29
+ const dist2 = (ax: number, ay: number, bx: number, by: number): number => {
30
+ const dx = ax - bx;
31
+ const dy = ay - by;
32
+ return dx * dx + dy * dy;
33
+ };
34
+
35
+ interface HealthLike {
36
+ current: number;
37
+ max: number;
38
+ isDead: boolean;
39
+ damage(n: number): void;
40
+ heal(n: number): void;
41
+ }
42
+ interface ScoreLike {
43
+ scoreToWin: number;
44
+ setScore(n: number): void;
45
+ }
46
+
47
+ const MOVE_SPEED = 205;
48
+ const ATTACK_RANGE = 360; // only auto-fire at enemies within this radius
49
+ const BOLT_SPEED = 520;
50
+ const BOLT_LIFE = 0.9;
51
+ const PICKUP_RADIUS = 30;
52
+ const SURVIVE_SECONDS = 90;
53
+ const HIT_KNOCK = 18; // px an enemy is shoved back when a bolt connects (hit feedback)
54
+
55
+ interface Bolt {
56
+ node: Node2D;
57
+ vx: number;
58
+ vy: number;
59
+ life: number;
60
+ pierce: number;
61
+ hit: Set<Node>;
62
+ }
63
+ interface Upgrade {
64
+ key: string;
65
+ label: string;
66
+ apply: (s: Survivor) => void;
67
+ }
68
+
69
+ const UPGRADES: Upgrade[] = [
70
+ { key: 'dmg', label: '⚔ +1 Damage', apply: (s) => (s.damage += 1) },
71
+ { key: 'rate', label: '⏱ +20% Fire rate', apply: (s) => (s.fireCd *= 0.82) },
72
+ { key: 'multi', label: '✦ +1 Bolt', apply: (s) => (s.bolts += 1) },
73
+ { key: 'pierce', label: '➹ +1 Pierce', apply: (s) => (s.pierce += 1) },
74
+ { key: 'speed', label: '👟 +12% Move speed', apply: (s) => (s.moveSpeed *= 1.12) },
75
+ { key: 'magnet', label: '🧲 +50% Magnet', apply: (s) => (s.magnet *= 1.5) },
76
+ {
77
+ key: 'hp',
78
+ label: '❤ +20 Max HP & heal',
79
+ apply: (s) => {
80
+ const h = s.health;
81
+ if (h) {
82
+ h.max += 20;
83
+ h.heal(20);
84
+ }
85
+ },
86
+ },
87
+ ];
88
+
89
+ export class Survivor extends Behavior {
90
+ moveSpeed = MOVE_SPEED;
91
+ damage = 1;
92
+ fireCd = 0.55;
93
+ bolts = 1;
94
+ pierce = 0;
95
+ magnet = 95;
96
+
97
+ level = 1;
98
+ xp = 0;
99
+ xpToLevel = 5;
100
+ kills = 0;
101
+ elapsed = 0;
102
+ private fireTimer = 0;
103
+ private facing = 1;
104
+ private over = false;
105
+ private paused = false;
106
+ private boltList: Bolt[] = [];
107
+ private lastClip = '';
108
+
109
+ get health(): HealthLike | null {
110
+ return (this.getNode('Vitals').behavior as unknown as HealthLike) ?? null;
111
+ }
112
+ private get body(): CharacterBody2D {
113
+ return this.node as unknown as CharacterBody2D;
114
+ }
115
+ private get skin(): AnimatedSprite2D {
116
+ return this.getNode('Skin') as unknown as AnimatedSprite2D;
117
+ }
118
+
119
+ override onReady(): void {
120
+ this.getNode('/root').on('lost', () => {
121
+ this.over = true;
122
+ this.banner('YOU FELL', '#ef476f');
123
+ });
124
+ }
125
+
126
+ addKill(): void {
127
+ this.kills += 1;
128
+ }
129
+ addXp(n: number): void {
130
+ this.xp += n;
131
+ while (this.xp >= this.xpToLevel) {
132
+ this.xp -= this.xpToLevel;
133
+ this.level += 1;
134
+ this.xpToLevel = Math.round(this.xpToLevel * 1.35 + 2);
135
+ this.onLevelUp();
136
+ }
137
+ }
138
+
139
+ override fixedUpdate(dt: number): void {
140
+ if (this.over || this.paused) {
141
+ this.body.velocity = [0, 0];
142
+ return;
143
+ }
144
+ this.elapsed += dt;
145
+
146
+ const dir = this.input.getVector('move');
147
+ this.body.velocity = [dir.x * this.moveSpeed, dir.y * this.moveSpeed];
148
+ this.body.moveAndSlide();
149
+ if (dir.x > 0.1) this.facing = 1;
150
+ else if (dir.x < -0.1) this.facing = -1;
151
+
152
+ this.fireTimer -= dt;
153
+ if (this.fireTimer <= 0) {
154
+ const target = this.nearestEnemy();
155
+ if (target) {
156
+ this.fireTimer = this.fireCd;
157
+ this.fireAt(target);
158
+ }
159
+ }
160
+
161
+ this.stepBolts(dt);
162
+ this.collectXp(dt);
163
+
164
+ if (this.elapsed >= SURVIVE_SECONDS) {
165
+ this.over = true;
166
+ const sk = this.getNode('/root').behavior as unknown as ScoreLike;
167
+ sk.setScore(sk.scoreToWin); // → ScoreKeeper emits 'won'
168
+ this.banner('YOU SURVIVED!', '#ffd166');
169
+ }
170
+ }
171
+
172
+ private px(): number {
173
+ return this.body.position[0] ?? 0;
174
+ }
175
+ private py(): number {
176
+ return this.body.position[1] ?? 0;
177
+ }
178
+
179
+ private nearestEnemy(): Node2D | null {
180
+ const tree = this.node.tree;
181
+ if (!tree) return null;
182
+ const px = this.px();
183
+ const py = this.py();
184
+ let best: Node2D | null = null;
185
+ let bestD = ATTACK_RANGE * ATTACK_RANGE;
186
+ for (const e of tree.getNodesInGroup('enemy')) {
187
+ const en = e as unknown as Node2D;
188
+ const d = dist2(px, py, en.position[0] ?? 0, en.position[1] ?? 0);
189
+ if (d < bestD) {
190
+ bestD = d;
191
+ best = en;
192
+ }
193
+ }
194
+ return best;
195
+ }
196
+
197
+ private fireAt(target: Node2D): void {
198
+ const px = this.px();
199
+ const py = this.py();
200
+ const baseAng = Math.atan2((target.position[1] ?? 0) - py, (target.position[0] ?? 0) - px);
201
+ const spread = 0.18;
202
+ const n = this.bolts;
203
+ for (let i = 0; i < n; i++) {
204
+ const ang = baseAng + (i - (n - 1) / 2) * spread;
205
+ const b = new ColorRect2D('Bolt');
206
+ b.size = [13, 13];
207
+ b.color = '#ffe9a8';
208
+ b.position = [px, py];
209
+ b.rotation = ang * RAD2DEG + 45;
210
+ this.node.getRoot().addChild(b as unknown as Node);
211
+ this.boltList.push({
212
+ node: b as unknown as Node2D,
213
+ vx: Math.cos(ang) * BOLT_SPEED,
214
+ vy: Math.sin(ang) * BOLT_SPEED,
215
+ life: BOLT_LIFE,
216
+ pierce: this.pierce,
217
+ hit: new Set<Node>(),
218
+ });
219
+ }
220
+ (this.getNode('/root/SfxShoot') as unknown as { play?: () => void } | null)?.play?.();
221
+ }
222
+
223
+ private stepBolts(dt: number): void {
224
+ if (this.boltList.length === 0) return;
225
+ const tree = this.node.tree;
226
+ const enemies = tree ? tree.getNodesInGroup('enemy') : [];
227
+ const keep: Bolt[] = [];
228
+ for (const b of this.boltList) {
229
+ b.life -= dt;
230
+ const nx = (b.node.position[0] ?? 0) + b.vx * dt;
231
+ const ny = (b.node.position[1] ?? 0) + b.vy * dt;
232
+ b.node.position = [nx, ny];
233
+ let dead = b.life <= 0;
234
+ if (!dead) {
235
+ for (const e of enemies) {
236
+ if (b.hit.has(e)) continue;
237
+ const en = e as unknown as Node2D;
238
+ if (dist2(nx, ny, en.position[0] ?? 0, en.position[1] ?? 0) < 26 * 26) {
239
+ b.hit.add(e);
240
+ // HIT FEEDBACK: damage (→ the enemy's EnemyFace flashes on 'damaged'),
241
+ // shove it back along the bolt, and pop an impact spark at the contact.
242
+ (e as unknown as { behavior?: HealthLike }).behavior?.damage?.(this.damage);
243
+ const il = Math.hypot(b.vx, b.vy) || 1;
244
+ en.position = [
245
+ (en.position[0] ?? 0) + (b.vx / il) * HIT_KNOCK,
246
+ (en.position[1] ?? 0) + (b.vy / il) * HIT_KNOCK,
247
+ ];
248
+ this.spark(nx, ny);
249
+ if (b.pierce <= 0) {
250
+ dead = true;
251
+ break;
252
+ }
253
+ b.pierce -= 1;
254
+ }
255
+ }
256
+ }
257
+ if (dead) b.node.parent?.removeChild(b.node as unknown as Node);
258
+ else keep.push(b);
259
+ }
260
+ this.boltList = keep;
261
+ }
262
+
263
+ /** A tiny additive impact spark at a bolt hit (pops on the dark arena). */
264
+ private spark(x: number, y: number): void {
265
+ const p = new Particles2D('Spark');
266
+ p.position = [x, y];
267
+ p.preset = 'custom';
268
+ p.emitting = false;
269
+ p.rate = 0;
270
+ p.burst = 10;
271
+ p.spreadDeg = 360;
272
+ p.lifetime = [0.12, 0.3];
273
+ p.speed = [150, 360];
274
+ p.drag = 2.6;
275
+ p.sizeStart = 20;
276
+ p.sizeEnd = 2;
277
+ p.colorStart = '#ffffff';
278
+ p.colorEnd = '#ffcf5a';
279
+ p.blend = 'add';
280
+ p.maxParticles = 8;
281
+ this.node.getRoot().addChild(p as unknown as Node);
282
+ p.on('finished', () => p.parent?.removeChild(p as unknown as Node));
283
+ }
284
+
285
+ private collectXp(dt: number): void {
286
+ const tree = this.node.tree;
287
+ if (!tree) return;
288
+ const px = this.px();
289
+ const py = this.py();
290
+ const mag2 = this.magnet * this.magnet;
291
+ const pick2 = PICKUP_RADIUS * PICKUP_RADIUS;
292
+ for (const g of tree.getNodesInGroup('xp')) {
293
+ const gn = g as unknown as Node2D & { _xp?: number };
294
+ const gx = gn.position[0] ?? 0;
295
+ const gy = gn.position[1] ?? 0;
296
+ const d = dist2(px, py, gx, gy);
297
+ if (d < pick2) {
298
+ this.addXp(gn._xp ?? 1);
299
+ (this.getNode('/root/SfxXp') as unknown as { play?: () => void } | null)?.play?.();
300
+ g.queueFree();
301
+ } else if (d < mag2) {
302
+ const ang = Math.atan2(py - gy, px - gx);
303
+ gn.position = [gx + Math.cos(ang) * 360 * dt, gy + Math.sin(ang) * 360 * dt];
304
+ }
305
+ }
306
+ }
307
+
308
+ private onLevelUp(): void {
309
+ this.health?.heal(8);
310
+ if (!hasDom) {
311
+ UPGRADES[0]?.apply(this); // headless: auto-pick so the loop is testable
312
+ return;
313
+ }
314
+ const pick = this.pickThree();
315
+ const overlay = document.getElementById('levelup');
316
+ const cards = document.getElementById('levelup-cards');
317
+ if (!overlay || !cards) {
318
+ pick[0]?.apply(this);
319
+ return;
320
+ }
321
+ const lv = document.getElementById('levelup-level');
322
+ if (lv) lv.textContent = `Level ${this.level}`;
323
+ cards.replaceChildren();
324
+ this.paused = true;
325
+ this.engine.stop(); // freeze the whole world while choosing
326
+ for (const up of pick) {
327
+ const btn = document.createElement('button');
328
+ btn.className = 'lvl-card';
329
+ btn.textContent = up.label;
330
+ btn.onclick = () => {
331
+ up.apply(this);
332
+ overlay.style.display = 'none';
333
+ this.paused = false;
334
+ this.engine.start();
335
+ };
336
+ cards.appendChild(btn);
337
+ }
338
+ overlay.style.display = 'flex';
339
+ }
340
+
341
+ private pickThree(): Upgrade[] {
342
+ const pool = [...UPGRADES];
343
+ const out: Upgrade[] = [];
344
+ for (let i = 0; i < 3 && pool.length; i++) {
345
+ const idx = Math.floor(this.rng.next() * pool.length);
346
+ out.push(pool.splice(idx, 1)[0] as Upgrade);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ override update(): void {
352
+ const skin = this.skin;
353
+ if (skin) {
354
+ skin.flipX = this.facing < 0;
355
+ const v = this.body.velocity;
356
+ const moving = Math.abs(v[0] ?? 0) + Math.abs(v[1] ?? 0) > 8;
357
+ this.playOnce(skin, moving ? 'move' : 'idle');
358
+ }
359
+ this.refreshHud();
360
+ }
361
+
362
+ private playOnce(skin: AnimatedSprite2D, name: string): void {
363
+ if (this.lastClip === name) return;
364
+ this.lastClip = name;
365
+ skin.play(name);
366
+ }
367
+
368
+ private set(id: string, text: string): void {
369
+ const el = document.getElementById(id);
370
+ if (el) el.textContent = text;
371
+ }
372
+ private bar(id: string, frac: number, color?: string): void {
373
+ const el = document.getElementById(id);
374
+ if (el) {
375
+ el.style.width = `${Math.max(0, Math.min(1, frac)) * 100}%`;
376
+ if (color) el.style.background = color;
377
+ }
378
+ }
379
+ private refreshHud(): void {
380
+ if (!hasDom) return;
381
+ const h = this.health;
382
+ const hp = Math.max(0, Math.round(h?.current ?? 0));
383
+ this.set('hp-text', `${hp}`);
384
+ this.bar('hp-fill', hp / (h?.max || 100), hp > 40 ? '#6ee7dc' : '#ef476f');
385
+ this.bar('xp-fill', this.xp / this.xpToLevel);
386
+ this.set('level-text', `LV ${this.level}`);
387
+ this.set('kills-text', `☠ ${this.kills}`);
388
+ const left = Math.max(0, Math.ceil(SURVIVE_SECONDS - this.elapsed));
389
+ this.set('timer-text', `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`);
390
+ }
391
+
392
+ private banner(text: string, color: string): void {
393
+ if (!hasDom) return;
394
+ const b = document.getElementById('banner');
395
+ if (b) {
396
+ b.textContent = text;
397
+ b.style.color = color;
398
+ b.style.opacity = '1';
399
+ }
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Director — turn the two built-in `Spawner`s into an escalating horde: each
405
+ * freshly-spawned enemy is dropped onto a ring AROUND the player (the spawners sit
406
+ * at the origin), and spawn rate + enemy HP/speed ramp with elapsed time.
407
+ */
408
+ export class Director extends Behavior {
409
+ static readonly props = {
410
+ spawners: { default: [] as string[] },
411
+ ringMin: { default: 360 },
412
+ ringMax: { default: 540 },
413
+ };
414
+ spawners: string[] = [];
415
+ ringMin = 360;
416
+ ringMax = 540;
417
+
418
+ private elapsed = 0;
419
+ private player: Node2D | null = null;
420
+
421
+ override onReady(): void {
422
+ this.player = this.getNode('/root/Player') as unknown as Node2D;
423
+ for (const path of this.spawners) {
424
+ this.getNode(path).on('spawned', (clone) => this.onSpawn(clone as Node));
425
+ }
426
+ }
427
+
428
+ override update(dt: number): void {
429
+ this.elapsed += dt;
430
+ const factor = Math.max(0.28, 1 - this.elapsed / 150);
431
+ for (const path of this.spawners) {
432
+ const sp = this.getNode(path).behavior as unknown as {
433
+ interval: number;
434
+ _baseInterval?: number;
435
+ };
436
+ if (sp) {
437
+ sp._baseInterval ??= sp.interval;
438
+ sp.interval = sp._baseInterval * factor;
439
+ }
440
+ }
441
+ }
442
+
443
+ private onSpawn(clone: Node): void {
444
+ const c = clone as unknown as Node2D;
445
+ const px = this.player?.position[0] ?? 0;
446
+ const py = this.player?.position[1] ?? 0;
447
+ const ang = this.rng.next() * Math.PI * 2;
448
+ const r = this.ringMin + this.rng.next() * (this.ringMax - this.ringMin);
449
+ c.position = [px + Math.cos(ang) * r, py + Math.sin(ang) * r];
450
+ const diff = 1 + this.elapsed / 50;
451
+ const hp = (clone as unknown as { behavior?: { max: number; current: number } }).behavior;
452
+ if (hp && typeof hp.max === 'number') {
453
+ hp.max = Math.round(hp.max * diff);
454
+ hp.current = hp.max;
455
+ }
456
+ const ai = (clone.getNodeOrNull('AI') as unknown as { behavior?: { speed: number } } | null)
457
+ ?.behavior;
458
+ if (ai && typeof ai.speed === 'number') ai.speed *= 1 + this.elapsed / 200;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * EnemyDrop — on the parent enemy's death, drop an XP shard at its spot and tally
464
+ * the kill on the player's Survivor. Lives on a child node (one script per node);
465
+ * the parent's `Health` emits `died` on the parent. Cloned with the template, so
466
+ * every spawned enemy carries it (scene `connections` are NOT cloned).
467
+ */
468
+ export class EnemyDrop extends Behavior {
469
+ static readonly props = { xp: { default: 1 }, color: { default: '#ff8a5b' } };
470
+ xp = 1;
471
+ color = '#ff8a5b';
472
+
473
+ override onReady(): void {
474
+ const parent = this.node.parent as unknown as { on(s: string, f: () => void): void } | null;
475
+ parent?.on('died', () => this.drop());
476
+ }
477
+
478
+ private drop(): void {
479
+ const pos = (this.node.parent as unknown as Node2D).position;
480
+ const x = pos[0] ?? 0;
481
+ const y = pos[1] ?? 0;
482
+ // death POOF — a burst in the enemy's colour so a kill reads clearly
483
+ const poof = new Particles2D('Poof');
484
+ poof.position = [x, y];
485
+ poof.preset = 'custom';
486
+ poof.emitting = false;
487
+ poof.rate = 0;
488
+ poof.burst = 16;
489
+ poof.spreadDeg = 360;
490
+ poof.lifetime = [0.22, 0.5];
491
+ poof.speed = [120, 340];
492
+ poof.drag = 2.4;
493
+ poof.sizeStart = 18;
494
+ poof.sizeEnd = 2;
495
+ poof.colorStart = '#ffffff';
496
+ poof.colorEnd = this.color;
497
+ poof.blend = 'add';
498
+ poof.maxParticles = 18;
499
+ this.node.getRoot().addChild(poof as unknown as Node);
500
+ poof.on('finished', () => poof.parent?.removeChild(poof as unknown as Node));
501
+ (this.getNode('/root/SfxKill') as unknown as { play?: () => void } | null)?.play?.();
502
+
503
+ const shard = new ColorRect2D('Xp');
504
+ shard.size = [14, 14];
505
+ shard.color = '#7ad7ff';
506
+ shard.rotation = 45;
507
+ shard.position = [x, y];
508
+ (shard as unknown as { _xp: number })._xp = this.xp;
509
+ (shard as unknown as Node).addToGroup('xp');
510
+ this.node.getRoot().addChild(shard as unknown as Node);
511
+ (this.getNode('/root/Player').behavior as unknown as { addKill?: () => void })?.addKill?.();
512
+ }
513
+ }
514
+
515
+ /**
516
+ * EnemyFace — flip the enemy sprite toward travel, keep the walk clip playing, and
517
+ * PUNCH the sprite scale up briefly each time the parent takes damage (hit feedback;
518
+ * paired with the Survivor's knockback + impact spark).
519
+ */
520
+ const FLASH_TIME = 0.16;
521
+ export class EnemyFace extends Behavior {
522
+ private lastX = 0;
523
+ private flash = 0;
524
+ private wasFlashing = false;
525
+ private baseScale: [number, number] = [1, 1];
526
+ private baseOpacity = 1;
527
+ private get root(): Node2D {
528
+ return this.node.parent as unknown as Node2D;
529
+ }
530
+ private get skin(): AnimatedSprite2D {
531
+ return this.getNode('../Skin') as unknown as AnimatedSprite2D;
532
+ }
533
+ override onReady(): void {
534
+ this.lastX = this.root.position[0] ?? 0;
535
+ const s = this.skin;
536
+ if (s) {
537
+ this.baseScale = [s.scale[0] ?? 1, s.scale[1] ?? 1];
538
+ this.baseOpacity = (s as unknown as { opacity?: number }).opacity ?? 1;
539
+ s.play('move');
540
+ }
541
+ (this.root as unknown as { on(s: string, f: () => void): void }).on('damaged', () => {
542
+ this.flash = FLASH_TIME;
543
+ });
544
+ }
545
+ override update(dt: number): void {
546
+ const skin = this.skin as AnimatedSprite2D & { opacity: number };
547
+ if (!skin) return;
548
+ const x = this.root.position[0] ?? 0;
549
+ const dx = x - this.lastX;
550
+ this.lastX = x;
551
+ if (Math.abs(dx) > 0.05) skin.flipX = dx < 0;
552
+ if (this.flash > 0) {
553
+ this.flash = Math.max(0, this.flash - dt);
554
+ const t = this.flash / FLASH_TIME;
555
+ skin.scale = [this.baseScale[0] * (1 + 0.55 * t), this.baseScale[1] * (1 + 0.55 * t)];
556
+ skin.opacity = this.baseOpacity * (0.4 + 0.6 * (1 - t)); // dip to ~0.4 then back up
557
+ this.wasFlashing = true;
558
+ } else if (this.wasFlashing) {
559
+ skin.scale = [this.baseScale[0], this.baseScale[1]]; // restore once
560
+ skin.opacity = this.baseOpacity;
561
+ this.wasFlashing = false;
562
+ }
563
+ }
564
+ }
565
+
566
+ /**
567
+ * GridFloor — an INFINITE arena floor. A camera-following solid backdrop (so there's
568
+ * never a visible edge however far you roam) plus a grid that follows the camera but
569
+ * snaps its lines to world multiples of `cell` (modulo scroll), so the lines look
570
+ * world-fixed and tile forever. Replaces a fixed-size floor whose border showed once
571
+ * the player wandered past it. Builds its rects in code; all sit behind the action.
572
+ */
573
+ export class GridFloor extends Behavior {
574
+ static readonly props = {
575
+ color: { default: '#12152a' },
576
+ line: { default: '#1d244a' },
577
+ cell: { default: 200 },
578
+ lines: { default: 26 },
579
+ };
580
+ color = '#12152a';
581
+ line = '#1d244a';
582
+ cell = 200;
583
+ lines = 26;
584
+
585
+ private cam: Node2D | null = null;
586
+ private bg: Node2D | null = null;
587
+ private vlines: Node2D[] = [];
588
+ private hlines: Node2D[] = [];
589
+
590
+ override onReady(): void {
591
+ this.cam = this.getNode('/root/Camera') as unknown as Camera2D as unknown as Node2D;
592
+ const span = this.lines * this.cell + this.cell;
593
+ const bg = new ColorRect2D('Bg');
594
+ bg.size = [span + 1600, span + 1600];
595
+ bg.color = this.color;
596
+ bg.renderOrder = -60;
597
+ this.node.addChild(bg as unknown as Node);
598
+ this.bg = bg as unknown as Node2D;
599
+ const mk = (name: string, w: number, h: number): Node2D => {
600
+ const r = new ColorRect2D(name);
601
+ r.size = [w, h];
602
+ r.color = this.line;
603
+ r.renderOrder = -59;
604
+ this.node.addChild(r as unknown as Node);
605
+ return r as unknown as Node2D;
606
+ };
607
+ for (let i = 0; i < this.lines; i++) this.vlines.push(mk(`V${i}`, 3, span));
608
+ for (let i = 0; i < this.lines; i++) this.hlines.push(mk(`H${i}`, span, 3));
609
+ }
610
+
611
+ override update(): void {
612
+ const cam = this.cam;
613
+ if (!cam) return;
614
+ const cx = cam.position[0] ?? 0;
615
+ const cy = cam.position[1] ?? 0;
616
+ if (this.bg) this.bg.position = [cx, cy];
617
+ const cell = this.cell;
618
+ const half = (this.lines * cell) / 2;
619
+ const startX = Math.floor((cx - half) / cell) * cell;
620
+ const startY = Math.floor((cy - half) / cell) * cell;
621
+ for (let i = 0; i < this.vlines.length; i++) this.vlines[i].position = [startX + i * cell, cy];
622
+ for (let i = 0; i < this.hlines.length; i++) this.hlines[i].position = [cx, startY + i * cell];
623
+ }
624
+ }