@waica/cli 0.4.2 → 0.6.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 (85) hide show
  1. package/README.md +42 -37
  2. package/dist/editor/assets/{cssMode-BO8FrG9g.js → cssMode-DfLN2qUF.js} +1 -1
  3. package/dist/editor/assets/{freemarker2-C6rCz24J.js → freemarker2-BM-wWcXR.js} +1 -1
  4. package/dist/editor/assets/{handlebars-MZdu9Qw_.js → handlebars-C68SV5M9.js} +1 -1
  5. package/dist/editor/assets/{html-BrjX1TJ1.js → html-dQgLbIki.js} +1 -1
  6. package/dist/editor/assets/{htmlMode-cEvpMwiW.js → htmlMode-Dx91fUdd.js} +1 -1
  7. package/dist/editor/assets/{index-eTEBznqW.js → index-592ABIaz.js} +7154 -3186
  8. package/dist/editor/assets/{index-Btzc6zxr.css → index-Cy5pmBhN.css} +1 -1
  9. package/dist/editor/assets/{javascript-DHUgp9_L.js → javascript-BmsSq5ka.js} +1 -1
  10. package/dist/editor/assets/{jsonMode-D0PmIuaM.js → jsonMode-CWoPtX2M.js} +1 -1
  11. package/dist/editor/assets/{liquid-UM67NCct.js → liquid-DwnJlNJZ.js} +1 -1
  12. package/dist/editor/assets/{mdx-D3xXLqrP.js → mdx-DaLkvlR1.js} +1 -1
  13. package/dist/editor/assets/{python-BhLrcAva.js → python-Cs5X5zFp.js} +1 -1
  14. package/dist/editor/assets/{razor-DcG373mQ.js → razor-PTwWoRk8.js} +1 -1
  15. package/dist/editor/assets/{tsMode-ByTYaf45.js → tsMode-BqUQdgsR.js} +1 -1
  16. package/dist/editor/assets/{typescript-DfJlvcTN.js → typescript-BsJ7r2RJ.js} +1 -1
  17. package/dist/editor/assets/{xml-22dL0oCq.js → xml-DyyoPey4.js} +1 -1
  18. package/dist/editor/assets/{yaml-mgCu6yV6.js → yaml-Cjqtlp29.js} +1 -1
  19. package/dist/editor/index.html +2 -2
  20. package/dist/mcp/component-metadata.js +23 -0
  21. package/dist/mcp/introspection.js +4 -14
  22. package/dist/mcp/node_modules/@waica/archetype-platformer/dist/prefabs.js +7 -1
  23. package/dist/mcp/node_modules/@waica/archetype-platformer/dist/registry-data.js +3 -1
  24. package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
  25. package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.d.ts +1 -0
  26. package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.js +1 -0
  27. package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.d.ts +1 -0
  28. package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.js +1 -1
  29. package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.d.ts +42 -1
  30. package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.js +49 -3
  31. package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +82 -0
  32. package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +147 -0
  33. package/dist/mcp/node_modules/@waica/behaviors/dist/index.d.ts +2 -0
  34. package/dist/mcp/node_modules/@waica/behaviors/dist/index.js +2 -0
  35. package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.d.ts +1 -0
  36. package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.js +1 -0
  37. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.d.ts +30 -0
  38. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.js +38 -0
  39. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.d.ts +1 -0
  40. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.js +1 -0
  41. package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.d.ts +1 -0
  42. package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.js +10 -0
  43. package/dist/mcp/node_modules/@waica/behaviors/dist/player-states.js +26 -0
  44. package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.d.ts +5 -13
  45. package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.js +5 -11
  46. package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
  47. package/dist/mcp/node_modules/@waica/engine/README.md +85 -0
  48. package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.d.ts +11 -0
  49. package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.js +51 -0
  50. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.d.ts +39 -0
  51. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.js +157 -0
  52. package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +15 -1
  53. package/dist/mcp/node_modules/@waica/engine/dist/component.js +2 -0
  54. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +2 -0
  55. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +11 -0
  56. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.d.ts +1 -0
  57. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.js +1 -0
  58. package/dist/mcp/node_modules/@waica/engine/dist/game.d.ts +7 -0
  59. package/dist/mcp/node_modules/@waica/engine/dist/game.js +79 -1
  60. package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +8 -1
  61. package/dist/mcp/node_modules/@waica/engine/dist/index.js +4 -0
  62. package/dist/mcp/node_modules/@waica/engine/dist/input.d.ts +10 -0
  63. package/dist/mcp/node_modules/@waica/engine/dist/input.js +40 -2
  64. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.d.ts +69 -0
  65. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.js +105 -0
  66. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.d.ts +71 -0
  67. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.js +279 -0
  68. package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.d.ts +2 -1
  69. package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.js +7 -1
  70. package/dist/mcp/node_modules/@waica/engine/package.json +3 -2
  71. package/dist/mcp/package-resolver.js +10 -2
  72. package/dist/mcp/project-component-loader.js +553 -0
  73. package/dist/mcp/project-component-runner.js +237 -0
  74. package/dist/mcp/runtime-browser.js +336 -0
  75. package/dist/mcp/runtime-dev-server.js +273 -0
  76. package/dist/mcp/runtime-preflight.js +249 -0
  77. package/dist/mcp/runtime-service.js +8 -0
  78. package/dist/mcp/runtime-session-manager.js +265 -0
  79. package/dist/mcp/scaffolds.js +126 -6
  80. package/dist/mcp/server.js +330 -9
  81. package/dist/mcp/template/src/characters/player.character.json +29 -2
  82. package/dist/mcp/template/src/characters/slime.character.json +6 -0
  83. package/dist/mcp/template/src/main.ts +6 -1
  84. package/dist/mcp/validation.js +240 -24
  85. package/package.json +3 -2
@@ -8,7 +8,13 @@ export type HazardTouch = 'stomp' | 'hurt';
8
8
  export declare function resolveHazardTouch(playerVy: number, playerBottom: number, hazardY: number, stompable: boolean): HazardTouch;
9
9
  /**
10
10
  * Hurts the player on contact. If stompable (Mario-style), stomping it
11
- * destroys it and bounces the player. Requires Hitbox on both entities.
11
+ * bounces the player. Requires Hitbox on both entities.
12
+ *
13
+ * "Hurts on touch" is all this means — being able to take a hit is Health's
14
+ * job, so a spike is a Hazard alone and an enemy is Hazard + Health. Either
15
+ * side of the interaction falls back to its pre-Health behavior (destroy
16
+ * itself on a stomp, respawn the player on contact) when there is no Health
17
+ * to route the damage through.
12
18
  */
13
19
  export declare class Hazard extends Component {
14
20
  static componentName: string;
@@ -22,8 +28,43 @@ export declare class Hazard extends Component {
22
28
  max: number;
23
29
  step: number;
24
30
  };
31
+ stompDamage: {
32
+ label: string;
33
+ min: number;
34
+ max: number;
35
+ step: number;
36
+ };
37
+ contactDamage: {
38
+ label: string;
39
+ min: number;
40
+ max: number;
41
+ step: number;
42
+ };
25
43
  };
44
+ static transient: string[];
26
45
  stompable: boolean;
27
46
  bounce: number;
47
+ /**
48
+ * Dealt to this entity when the player stomps it. Only read on the
49
+ * Health path — with no Health on this entity, a stomp always destroys
50
+ * it outright (the legacy behavior), whatever this is set to. Setting it
51
+ * to 0 does not make a Health-less hazard survive a stomp.
52
+ */
53
+ stompDamage: number;
54
+ /**
55
+ * Dealt to whoever touches it any other way. Only read on the Health
56
+ * path — with no Health on the other entity, contact always respawns it
57
+ * (the legacy behavior), whatever this is set to. Setting it to 0 does
58
+ * not make contact harmless for a Health-less target.
59
+ */
60
+ contactDamage: number;
61
+ /**
62
+ * Set once a stomp bounces the player, until it stops rising. The bounce
63
+ * (motor.vy = this.bounce) can leave the hitboxes still overlapping on
64
+ * the very next check, and with vy no longer < 0 resolveHazardTouch reads
65
+ * that as 'hurt' — a survivor would otherwise hurt the player it is still
66
+ * bouncing away from.
67
+ */
68
+ private bouncing;
28
69
  onCollide(other: Entity): void;
29
70
  }
@@ -1,4 +1,5 @@
1
1
  import { Component } from '@waica/engine';
2
+ import { Health } from './health.js';
2
3
  import { PlatformerMotor } from './platformer-motor.js';
3
4
  import { isPlayer } from './player-identity.js';
4
5
  import { Respawnable } from './respawnable.js';
@@ -14,16 +15,47 @@ export function resolveHazardTouch(playerVy, playerBottom, hazardY, stompable) {
14
15
  }
15
16
  /**
16
17
  * Hurts the player on contact. If stompable (Mario-style), stomping it
17
- * destroys it and bounces the player. Requires Hitbox on both entities.
18
+ * bounces the player. Requires Hitbox on both entities.
19
+ *
20
+ * "Hurts on touch" is all this means — being able to take a hit is Health's
21
+ * job, so a spike is a Hazard alone and an enemy is Hazard + Health. Either
22
+ * side of the interaction falls back to its pre-Health behavior (destroy
23
+ * itself on a stomp, respawn the player on contact) when there is no Health
24
+ * to route the damage through.
18
25
  */
19
26
  export class Hazard extends Component {
20
27
  static componentName = 'Hazard';
21
28
  static params = {
22
29
  stompable: { label: 'Stompable' },
23
30
  bounce: { label: 'Stomp bounce', min: 0, max: 30, step: 0.5 },
31
+ stompDamage: { label: 'Stomp damage', min: 0, max: 20, step: 1 },
32
+ contactDamage: { label: 'Contact damage', min: 0, max: 20, step: 1 },
24
33
  };
34
+ static transient = ['bouncing'];
25
35
  stompable = true;
26
36
  bounce = 10;
37
+ /**
38
+ * Dealt to this entity when the player stomps it. Only read on the
39
+ * Health path — with no Health on this entity, a stomp always destroys
40
+ * it outright (the legacy behavior), whatever this is set to. Setting it
41
+ * to 0 does not make a Health-less hazard survive a stomp.
42
+ */
43
+ stompDamage = 1;
44
+ /**
45
+ * Dealt to whoever touches it any other way. Only read on the Health
46
+ * path — with no Health on the other entity, contact always respawns it
47
+ * (the legacy behavior), whatever this is set to. Setting it to 0 does
48
+ * not make contact harmless for a Health-less target.
49
+ */
50
+ contactDamage = 1;
51
+ /**
52
+ * Set once a stomp bounces the player, until it stops rising. The bounce
53
+ * (motor.vy = this.bounce) can leave the hitboxes still overlapping on
54
+ * the very next check, and with vy no longer < 0 resolveHazardTouch reads
55
+ * that as 'hurt' — a survivor would otherwise hurt the player it is still
56
+ * bouncing away from.
57
+ */
58
+ bouncing = false;
27
59
  onCollide(other) {
28
60
  if (!isPlayer(other))
29
61
  return;
@@ -32,11 +64,25 @@ export class Hazard extends Component {
32
64
  const playerBottom = other.position.y - motor.hitboxHeight / 2;
33
65
  const touch = resolveHazardTouch(motor.vy, playerBottom, this.entity.position.y, this.stompable);
34
66
  if (touch === 'stomp') {
35
- this.entity.destroy();
67
+ const health = this.entity.get(Health);
68
+ if (health)
69
+ health.damage(this.stompDamage, other);
70
+ else
71
+ this.entity.destroy();
36
72
  motor.vy = this.bounce;
73
+ this.bouncing = true;
37
74
  return;
38
75
  }
76
+ if (this.bouncing) {
77
+ if (motor.vy > 0)
78
+ return;
79
+ this.bouncing = false;
80
+ }
39
81
  }
40
- other.get(Respawnable)?.respawn();
82
+ const health = other.get(Health);
83
+ if (health)
84
+ health.damage(this.contactDamage, this.entity);
85
+ else
86
+ other.get(Respawnable)?.respawn();
41
87
  }
42
88
  }
@@ -0,0 +1,82 @@
1
+ import { Component, type Entity, type StateJson } from '@waica/engine';
2
+ /**
3
+ * Where a 'signal:death' from `current` could land: the targets of every
4
+ * death edge on the current state or on '*'. Mirrors the merge
5
+ * nextTransition performs.
6
+ */
7
+ export declare function deathTargets(states: Record<string, StateJson>, current: string): string[];
8
+ /**
9
+ * Whether a state graph reacts to death on its own: a 'signal:death' edge
10
+ * on the current state or on '*'. Mirrors the merge nextTransition performs,
11
+ * because StateMachine.signal is fire-and-forget — it cannot report whether
12
+ * anybody handled the signal, so the graph is asked statically instead.
13
+ *
14
+ * Declaring an edge is not the same as taking it: nextTransition returns the
15
+ * FIRST firing edge, so a death edge listed after another edge that also
16
+ * fires loses the race and the signal is dropped. That is why signalling is
17
+ * followed by a check on Health's first eligible update after StateMachine.
18
+ */
19
+ export declare function declaresDeathHandling(states: Record<string, StateJson>, current: string): boolean;
20
+ /**
21
+ * How much punishment an entity takes before it dies. Deliberately separate
22
+ * from Hazard's "hurts on touch": an enemy is both, a spike is only the
23
+ * hazard, and a projectile targets only what can be hurt.
24
+ *
25
+ * There is no kill(): lethality is arithmetic, so damage(Infinity) is the
26
+ * only death path and every death goes through the same policy.
27
+ */
28
+ export declare class Health extends Component {
29
+ static componentName: string;
30
+ static updateAfter: readonly string[];
31
+ static params: {
32
+ max: {
33
+ label: string;
34
+ min: number;
35
+ max: number;
36
+ step: number;
37
+ };
38
+ invulnerability: {
39
+ label: string;
40
+ min: number;
41
+ max: number;
42
+ step: number;
43
+ };
44
+ };
45
+ static transient: string[];
46
+ max: number;
47
+ /** Seconds of immunity granted by taking a hit. 0 disables i-frames. */
48
+ invulnerability: number;
49
+ /** Health left; 0 is dead. Filled in from max on ready. */
50
+ current: number;
51
+ /** Seconds left in the invulnerability window. */
52
+ private invulnerable;
53
+ /** Whether the signalled death must be checked on this component's next update. */
54
+ private deathPending;
55
+ /** States the death signal was supposed to reach. */
56
+ private expectedStates;
57
+ onReady(): void;
58
+ onUpdate(dt: number): void;
59
+ /**
60
+ * Makes good on a death the graph said it would handle. Signalling is
61
+ * fire-and-forget, so "it declared an edge" is a promise, not a receipt:
62
+ * if the machine is not in one of the states that edge led to and the
63
+ * entity is still dead, nobody took it and the destroy fallback applies
64
+ * after all. Being healed back up counts as handled — a project that
65
+ * revives on death did resolve it, just not by changing state.
66
+ */
67
+ private settleDeath;
68
+ /**
69
+ * Takes `amount` off unless already dead or still invulnerable, then opens
70
+ * the invulnerability window. `source` is whatever dealt the damage, for
71
+ * listeners that care who hit them.
72
+ */
73
+ damage(amount: number, source?: Entity): void;
74
+ /** Gives health back, capped at max. heal(Infinity) is a full restore. */
75
+ heal(amount: number): void;
76
+ /**
77
+ * Announces the death, then lets the role decide — but only if it says it
78
+ * can: a graph with no death edge would swallow the signal silently, so
79
+ * destroying is the fallback rather than the exception.
80
+ */
81
+ private die;
82
+ }
@@ -0,0 +1,147 @@
1
+ import { Component, StateMachine } from '@waica/engine';
2
+ /**
3
+ * Below this, current is treated as exactly zero. Repeated fractional
4
+ * damage (e.g. ten hits of 0.1 against max: 1) can leave a floating-point
5
+ * residual — Math.max(0, current - amount) only clamps values that land
6
+ * at or below zero, not tiny positive ones — that current === 0 would
7
+ * otherwise miss forever, leaving the entity undead at 0 HP.
8
+ */
9
+ const DEATH_EPSILON = 1e-9;
10
+ /**
11
+ * Where a 'signal:death' from `current` could land: the targets of every
12
+ * death edge on the current state or on '*'. Mirrors the merge
13
+ * nextTransition performs.
14
+ */
15
+ export function deathTargets(states, current) {
16
+ const edges = [...(states[current]?.transitions ?? []), ...(states['*']?.transitions ?? [])];
17
+ return edges.filter((transition) => transition.on === 'signal:death').map((t) => t.to);
18
+ }
19
+ /**
20
+ * Whether a state graph reacts to death on its own: a 'signal:death' edge
21
+ * on the current state or on '*'. Mirrors the merge nextTransition performs,
22
+ * because StateMachine.signal is fire-and-forget — it cannot report whether
23
+ * anybody handled the signal, so the graph is asked statically instead.
24
+ *
25
+ * Declaring an edge is not the same as taking it: nextTransition returns the
26
+ * FIRST firing edge, so a death edge listed after another edge that also
27
+ * fires loses the race and the signal is dropped. That is why signalling is
28
+ * followed by a check on Health's first eligible update after StateMachine.
29
+ */
30
+ export function declaresDeathHandling(states, current) {
31
+ return deathTargets(states, current).length > 0;
32
+ }
33
+ /**
34
+ * How much punishment an entity takes before it dies. Deliberately separate
35
+ * from Hazard's "hurts on touch": an enemy is both, a spike is only the
36
+ * hazard, and a projectile targets only what can be hurt.
37
+ *
38
+ * There is no kill(): lethality is arithmetic, so damage(Infinity) is the
39
+ * only death path and every death goes through the same policy.
40
+ */
41
+ export class Health extends Component {
42
+ static componentName = 'Health';
43
+ static updateAfter = ['StateMachine'];
44
+ static params = {
45
+ max: { label: 'Max health', min: 1, max: 20, step: 1 },
46
+ invulnerability: { label: 'Invulnerability', min: 0, max: 5, step: 0.1 },
47
+ };
48
+ static transient = ['current', 'invulnerable', 'deathPending', 'expectedStates'];
49
+ max = 3;
50
+ /** Seconds of immunity granted by taking a hit. 0 disables i-frames. */
51
+ invulnerability = 0;
52
+ /** Health left; 0 is dead. Filled in from max on ready. */
53
+ current = 0;
54
+ /** Seconds left in the invulnerability window. */
55
+ invulnerable = 0;
56
+ /** Whether the signalled death must be checked on this component's next update. */
57
+ deathPending = false;
58
+ /** States the death signal was supposed to reach. */
59
+ expectedStates = [];
60
+ onReady() {
61
+ // The inspector has no min clamp on max, so an authored 0 (or a
62
+ // negative value) is reachable. Without this, current lands at <= 0
63
+ // and stays there forever: damage() early-returns while current <= 0,
64
+ // so die() is never reached — permanently invulnerable instead of
65
+ // already dead.
66
+ this.current = Math.max(0, this.max);
67
+ if (this.current === 0)
68
+ this.die();
69
+ }
70
+ onUpdate(dt) {
71
+ if (this.invulnerable > 0)
72
+ this.invulnerable = Math.max(0, this.invulnerable - dt);
73
+ if (this.deathPending) {
74
+ this.deathPending = false;
75
+ this.settleDeath();
76
+ }
77
+ }
78
+ /**
79
+ * Makes good on a death the graph said it would handle. Signalling is
80
+ * fire-and-forget, so "it declared an edge" is a promise, not a receipt:
81
+ * if the machine is not in one of the states that edge led to and the
82
+ * entity is still dead, nobody took it and the destroy fallback applies
83
+ * after all. Being healed back up counts as handled — a project that
84
+ * revives on death did resolve it, just not by changing state.
85
+ */
86
+ settleDeath() {
87
+ const expected = this.expectedStates;
88
+ this.expectedStates = [];
89
+ if (!this.entity.alive || this.current > 0)
90
+ return;
91
+ const machine = this.entity.get(StateMachine);
92
+ if (machine && expected.includes(machine.current))
93
+ return;
94
+ this.entity.destroy();
95
+ }
96
+ /**
97
+ * Takes `amount` off unless already dead or still invulnerable, then opens
98
+ * the invulnerability window. `source` is whatever dealt the damage, for
99
+ * listeners that care who hit them.
100
+ */
101
+ damage(amount, source) {
102
+ // Written as !(amount > 0) rather than amount <= 0 so NaN is rejected
103
+ // too — every NaN comparison is false, so amount <= 0 lets it through
104
+ // and poisons current (NaN - anything is NaN, and every guard against
105
+ // it is false forever after).
106
+ if (!(amount > 0) || this.current <= 0 || this.invulnerable > 0)
107
+ return;
108
+ this.current = Math.max(0, this.current - amount);
109
+ if (this.current < DEATH_EPSILON)
110
+ this.current = 0;
111
+ this.game.events.emit('damage', {
112
+ entity: this.entity,
113
+ amount,
114
+ current: this.current,
115
+ source,
116
+ });
117
+ this.invulnerable = this.invulnerability;
118
+ if (this.current === 0)
119
+ this.die();
120
+ }
121
+ /** Gives health back, capped at max. heal(Infinity) is a full restore. */
122
+ heal(amount) {
123
+ if (amount <= 0)
124
+ return;
125
+ this.current = Math.min(this.max, this.current + amount);
126
+ }
127
+ /**
128
+ * Announces the death, then lets the role decide — but only if it says it
129
+ * can: a graph with no death edge would swallow the signal silently, so
130
+ * destroying is the fallback rather than the exception.
131
+ */
132
+ die() {
133
+ this.game.events.emit('death', { entity: this.entity });
134
+ const machine = this.entity.get(StateMachine);
135
+ const targets = machine ? deathTargets(machine.states, machine.current) : [];
136
+ if (machine && targets.length > 0) {
137
+ machine.signal('death');
138
+ // Trust, then verify: the deterministic schedule puts Health after the
139
+ // machine, so its first eligible update can settle this without an
140
+ // authored-order grace period.
141
+ this.expectedStates = targets;
142
+ this.deathPending = true;
143
+ return;
144
+ }
145
+ this.entity.destroy();
146
+ }
147
+ }
@@ -5,5 +5,7 @@ export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH, type PatrolAxis } from '
5
5
  export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH, type ChaserMode } from './chaser.js';
6
6
  export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
7
7
  export { Hazard, resolveHazardTouch, type HazardTouch } from './hazard.js';
8
+ export { Health, declaresDeathHandling, deathTargets } from './health.js';
8
9
  export { Respawnable } from './respawnable.js';
10
+ export { OutOfBounds } from './out-of-bounds.js';
9
11
  export { Lifetime } from './lifetime.js';
@@ -5,5 +5,7 @@ export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH } from './patrol.js';
5
5
  export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH } from './chaser.js';
6
6
  export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
7
7
  export { Hazard, resolveHazardTouch } from './hazard.js';
8
+ export { Health, declaresDeathHandling, deathTargets } from './health.js';
8
9
  export { Respawnable } from './respawnable.js';
10
+ export { OutOfBounds } from './out-of-bounds.js';
9
11
  export { Lifetime } from './lifetime.js';
@@ -10,6 +10,7 @@ export declare class Lifetime extends Component {
10
10
  step: number;
11
11
  };
12
12
  };
13
+ static transient: string[];
13
14
  seconds: number;
14
15
  private elapsed;
15
16
  onUpdate(dt: number): void;
@@ -5,6 +5,7 @@ export class Lifetime extends Component {
5
5
  static params = {
6
6
  seconds: { label: 'Seconds', min: 0.05, max: 60, step: 0.05 },
7
7
  };
8
+ static transient = ['elapsed'];
8
9
  seconds = 1;
9
10
  elapsed = 0;
10
11
  onUpdate(dt) {
@@ -0,0 +1,30 @@
1
+ import { Component } from '@waica/engine';
2
+ /**
3
+ * Kills whatever falls out of the world. The damage is not configurable
4
+ * because leaving the level is lethal by nature — being hurt by a hard
5
+ * landing is a different mechanic, measured on ground contact, and project
6
+ * code can write it with Health.damage and the motor's readable velocity.
7
+ *
8
+ * "Lethal" still goes through Health.damage, which means invulnerability
9
+ * frames delay it: an entity that falls right after taking a hit keeps
10
+ * falling until its i-frames lapse. That is deliberate — damage() has no
11
+ * bypass, by design — and it resolves itself because this check re-runs
12
+ * every frame. An entity with no Health is destroyed outright instead;
13
+ * unlike Hazard, this does NOT fall back to Respawnable.
14
+ */
15
+ export declare class OutOfBounds extends Component {
16
+ static componentName: string;
17
+ static updateAfter: readonly string[];
18
+ static displayName: string;
19
+ static params: {
20
+ minY: {
21
+ label: string;
22
+ min: number;
23
+ max: number;
24
+ step: number;
25
+ };
26
+ };
27
+ /** Falling below this height is fatal. */
28
+ minY: number;
29
+ onUpdate(): void;
30
+ }
@@ -0,0 +1,38 @@
1
+ import { Component } from '@waica/engine';
2
+ import { Health } from './health.js';
3
+ /**
4
+ * Kills whatever falls out of the world. The damage is not configurable
5
+ * because leaving the level is lethal by nature — being hurt by a hard
6
+ * landing is a different mechanic, measured on ground contact, and project
7
+ * code can write it with Health.damage and the motor's readable velocity.
8
+ *
9
+ * "Lethal" still goes through Health.damage, which means invulnerability
10
+ * frames delay it: an entity that falls right after taking a hit keeps
11
+ * falling until its i-frames lapse. That is deliberate — damage() has no
12
+ * bypass, by design — and it resolves itself because this check re-runs
13
+ * every frame. An entity with no Health is destroyed outright instead;
14
+ * unlike Hazard, this does NOT fall back to Respawnable.
15
+ */
16
+ export class OutOfBounds extends Component {
17
+ static componentName = 'OutOfBounds';
18
+ static updateAfter = ['DynamicBody', 'Health', 'StateMachine'];
19
+ static displayName = 'Out of bounds';
20
+ static params = {
21
+ minY: { label: 'Floor height', min: -50, max: 0, step: 1 },
22
+ };
23
+ /** Falling below this height is fatal. */
24
+ minY = -12;
25
+ onUpdate() {
26
+ // The frame that destroys the entity still iterates a copy of its
27
+ // components: stop checking instead of destroying twice.
28
+ if (!this.entity.alive)
29
+ return;
30
+ if (this.entity.position.y >= this.minY)
31
+ return;
32
+ const health = this.entity.get(Health);
33
+ if (health)
34
+ health.damage(Infinity, this.entity);
35
+ else
36
+ this.entity.destroy();
37
+ }
38
+ }
@@ -28,6 +28,7 @@ export declare class Patrol extends Component {
28
28
  step: number;
29
29
  };
30
30
  };
31
+ static transient: string[];
31
32
  axis: PatrolAxis;
32
33
  distance: number;
33
34
  speed: number;
@@ -14,6 +14,7 @@ export class Patrol extends Component {
14
14
  distance: { label: 'Distance', min: 0.5, max: 20, step: 0.5 },
15
15
  speed: { label: 'Speed', min: 0.5, max: 15, step: 0.5 },
16
16
  };
17
+ static transient = ['originX', 'originY', 'dir'];
17
18
  axis = 'horizontal';
18
19
  distance = 3;
19
20
  speed = 2;
@@ -77,6 +77,7 @@ export declare class PlatformerMotor extends Component {
77
77
  label: string;
78
78
  };
79
79
  };
80
+ static transient: string[];
80
81
  moveSpeed: number;
81
82
  acceleration: number;
82
83
  deceleration: number;
@@ -25,6 +25,16 @@ export class PlatformerMotor extends Component {
25
25
  runThreshold: { label: 'Run threshold', min: 0, max: 5, step: 0.1 },
26
26
  squashStretch: { label: 'Squash & stretch' },
27
27
  };
28
+ static transient = [
29
+ 'vx',
30
+ 'vy',
31
+ 'grounded',
32
+ 'facing',
33
+ 'coyoteTimer',
34
+ 'bufferTimer',
35
+ 'squashX',
36
+ 'squashY',
37
+ ];
28
38
  moveSpeed = 9;
29
39
  acceleration = 60;
30
40
  deceleration = 80;
@@ -1,5 +1,7 @@
1
1
  import {} from '@waica/engine';
2
+ import { Health } from './health.js';
2
3
  import { PlatformerMotor } from './platformer-motor.js';
4
+ import { Respawnable } from './respawnable.js';
3
5
  /**
4
6
  * The 'player' role: the character you control. All four default states
5
7
  * share one body update; they differ only in the edges their prefab data
@@ -38,6 +40,17 @@ export const PLAYER_STATE_GRAPH = {
38
40
  { on: 'signal:land', to: 'idle' },
39
41
  ],
40
42
  },
43
+ dead: {
44
+ // The stock dog sheet has no death animation; freeze on idle rather
45
+ // than name a clip that does not exist and warn every frame.
46
+ clip: 'idle',
47
+ // A beat before control comes back, so death reads as an event.
48
+ transitions: [{ on: 'timer:0.8', to: 'idle' }],
49
+ },
50
+ // Dying is the same from every state, so the edge lives on '*'. Its
51
+ // presence is also the contract Health checks before signalling: a graph
52
+ // without it gets its entity destroyed instead.
53
+ '*': { transitions: [{ on: 'signal:death', to: 'dead' }] },
41
54
  },
42
55
  };
43
56
  /**
@@ -91,5 +104,18 @@ export const PLAYER_ROLE = {
91
104
  run: { onUpdate: playerUpdate },
92
105
  jump: { onUpdate: playerUpdate },
93
106
  fall: { onUpdate: playerUpdate },
107
+ // Coming back is what leaving death means, so it hangs off onExit: any
108
+ // other way out of this state (a project's own edge) revives too.
109
+ dead: {
110
+ // A state without its own onUpdate falls back to the role's default
111
+ // body update — the shape every custom state relies on — so without
112
+ // this no-op the player kept running, jumping and falling for the
113
+ // whole death beat instead of the graph taking control away.
114
+ onUpdate() { },
115
+ onExit({ entity }) {
116
+ entity.get(Respawnable)?.respawn();
117
+ entity.get(Health)?.heal(Infinity);
118
+ },
119
+ },
94
120
  },
95
121
  };
@@ -1,23 +1,15 @@
1
1
  import { Component } from '@waica/engine';
2
2
  /**
3
- * Remembers the spawn point and puts the entity back there on death
4
- * (falling off the world or touching a Hazard).
3
+ * Remembers where the entity started and puts it back there on demand.
4
+ * That is all: what counts as death, and whether death leads to a respawn
5
+ * at all, belongs to Health and to the role's state graph. The `killY`
6
+ * lower bound this used to author moved to OutOfBounds (docs/adr/0003).
5
7
  */
6
8
  export declare class Respawnable extends Component {
7
9
  static componentName: string;
8
10
  static displayName: string;
9
- static params: {
10
- killY: {
11
- label: string;
12
- min: number;
13
- max: number;
14
- step: number;
15
- };
16
- };
17
- /** Falling below this height respawns. */
18
- killY: number;
11
+ static transient: string[];
19
12
  private spawn;
20
13
  onReady(): void;
21
14
  respawn(): void;
22
- onUpdate(): void;
23
15
  }
@@ -1,17 +1,15 @@
1
1
  import { Component, THREE } from '@waica/engine';
2
2
  import { PlatformerMotor } from './platformer-motor.js';
3
3
  /**
4
- * Remembers the spawn point and puts the entity back there on death
5
- * (falling off the world or touching a Hazard).
4
+ * Remembers where the entity started and puts it back there on demand.
5
+ * That is all: what counts as death, and whether death leads to a respawn
6
+ * at all, belongs to Health and to the role's state graph. The `killY`
7
+ * lower bound this used to author moved to OutOfBounds (docs/adr/0003).
6
8
  */
7
9
  export class Respawnable extends Component {
8
10
  static componentName = 'Respawnable';
9
11
  static displayName = 'Respawn';
10
- static params = {
11
- killY: { label: 'Kill height', min: -50, max: 0, step: 1 },
12
- };
13
- /** Falling below this height respawns. */
14
- killY = -12;
12
+ static transient = ['spawn'];
15
13
  spawn = new THREE.Vector3();
16
14
  onReady() {
17
15
  this.spawn.copy(this.entity.position);
@@ -20,8 +18,4 @@ export class Respawnable extends Component {
20
18
  this.entity.position.copy(this.spawn);
21
19
  this.entity.get(PlatformerMotor)?.halt();
22
20
  }
23
- onUpdate() {
24
- if (this.entity.position.y < this.killY)
25
- this.respawn();
26
- }
27
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/behaviors",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Waica built-in behaviors — the curated game-feel library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,6 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@waica/engine": "^0.4.2"
22
+ "@waica/engine": "^0.6.0"
23
23
  }
24
24
  }