@waica/cli 0.8.0 → 0.9.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 (58) hide show
  1. package/dist/editor/assets/{cssMode-DwPoqXFO.js → cssMode-DM5x3eXD.js} +1 -1
  2. package/dist/editor/assets/{freemarker2-gl5y25bH.js → freemarker2-CmlzUOOI.js} +1 -1
  3. package/dist/editor/assets/{handlebars-DHUb8mb3.js → handlebars-Dlz-T3Pu.js} +1 -1
  4. package/dist/editor/assets/{html-C8W-psr4.js → html-B-NfgvUd.js} +1 -1
  5. package/dist/editor/assets/{htmlMode-BiF5TH2_.js → htmlMode-K4hF3BAu.js} +1 -1
  6. package/dist/editor/assets/{index-DtMwWp8R.js → index-Ca2oPxGt.js} +1705 -307
  7. package/dist/editor/assets/{javascript-D_t2jyWi.js → javascript-COj32tw-.js} +1 -1
  8. package/dist/editor/assets/{jsonMode-CZ0SltDj.js → jsonMode-DyL12Tvg.js} +1 -1
  9. package/dist/editor/assets/{liquid-DUxURZui.js → liquid-CGaRZ9Iu.js} +1 -1
  10. package/dist/editor/assets/{mdx-CDIQ7hmo.js → mdx-L4fRd4xO.js} +1 -1
  11. package/dist/editor/assets/{python-Cra2N916.js → python-Z6FCrHUG.js} +1 -1
  12. package/dist/editor/assets/{razor-B3qV-mNp.js → razor-6XSfF-a3.js} +1 -1
  13. package/dist/editor/assets/{tsMode-Ci457z4v.js → tsMode-C24rNZgD.js} +1 -1
  14. package/dist/editor/assets/{typescript-BXjh3GLT.js → typescript-BcFln1pD.js} +1 -1
  15. package/dist/editor/assets/waica-iso-hero-BsEdW0V7.png +0 -0
  16. package/dist/editor/assets/waica-iso-orc-DRK-SaXm.png +0 -0
  17. package/dist/editor/assets/waica-iso-villager-szDkD_Ag.png +0 -0
  18. package/dist/editor/assets/{xml-DQuEpqk5.js → xml-DJCue8rm.js} +1 -1
  19. package/dist/editor/assets/{yaml-CQA7WIT-.js → yaml-DiJf3V99.js} +1 -1
  20. package/dist/editor/index.html +1 -1
  21. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/ATTRIBUTION.md +40 -19
  22. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-hero.png +0 -0
  23. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-orc.png +0 -0
  24. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-villager.png +0 -0
  25. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/animation.d.ts +5 -1
  26. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/animation.js +9 -2
  27. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/controls.js +2 -0
  28. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/manifest.js +1 -0
  29. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/prefabs.d.ts +8 -4
  30. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/prefabs.js +33 -14
  31. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/registry-data.js +2 -1
  32. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/scene-default.js +12 -2
  33. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/ui.js +13 -0
  34. package/dist/mcp/node_modules/@waica/archetype-isometric/package.json +3 -3
  35. package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
  36. package/dist/mcp/node_modules/@waica/archetype-topdown/dist/animation.d.ts +2 -1
  37. package/dist/mcp/node_modules/@waica/archetype-topdown/dist/animation.js +6 -2
  38. package/dist/mcp/node_modules/@waica/archetype-topdown/package.json +3 -3
  39. package/dist/mcp/node_modules/@waica/behaviors/dist/facing.d.ts +18 -0
  40. package/dist/mcp/node_modules/@waica/behaviors/dist/facing.js +56 -0
  41. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-motor.d.ts +8 -0
  42. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-motor.js +3 -0
  43. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-player-role.d.ts +5 -0
  44. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-player-role.js +71 -9
  45. package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +29 -0
  46. package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +79 -3
  47. package/dist/mcp/node_modules/@waica/behaviors/dist/index.d.ts +1 -0
  48. package/dist/mcp/node_modules/@waica/behaviors/dist/index.js +1 -0
  49. package/dist/mcp/node_modules/@waica/behaviors/dist/iso-motor.d.ts +8 -1
  50. package/dist/mcp/node_modules/@waica/behaviors/dist/iso-motor.js +7 -19
  51. package/dist/mcp/node_modules/@waica/behaviors/dist/melee-attack.d.ts +48 -0
  52. package/dist/mcp/node_modules/@waica/behaviors/dist/melee-attack.js +83 -0
  53. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.d.ts +11 -3
  54. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.js +49 -5
  55. package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
  56. package/dist/mcp/node_modules/@waica/engine/package.json +1 -1
  57. package/dist/mcp/template/src/stats.json +2 -1
  58. package/package.json +1 -1
@@ -7,6 +7,8 @@ import { Component, StateMachine } from '@waica/engine';
7
7
  * otherwise miss forever, leaving the entity undead at 0 HP.
8
8
  */
9
9
  const DEATH_EPSILON = 1e-9;
10
+ /** Seconds each blink phase lasts while invulnerable: 10 Hz on/off. */
11
+ const BLINK_PERIOD = 0.1;
10
12
  /**
11
13
  * Where a 'signal:death' from `current` could land: the targets of every
12
14
  * death edge on the current state or on '*'. Mirrors the merge
@@ -44,19 +46,56 @@ export class Health extends Component {
44
46
  static params = {
45
47
  max: { label: 'Max health', min: 1, max: 20, step: 1 },
46
48
  invulnerability: { label: 'Invulnerability', min: 0, max: 5, step: 0.1 },
49
+ stat: { label: 'Stat', ref: 'stat' },
47
50
  };
48
- static transient = ['current', 'invulnerable', 'deathPending', 'expectedStates'];
51
+ static transient = [
52
+ 'current',
53
+ 'invulnerable',
54
+ 'deathPending',
55
+ 'expectedStates',
56
+ 'lastDamageSource',
57
+ 'blinkClock',
58
+ ];
49
59
  max = 3;
50
60
  /** Seconds of immunity granted by taking a hit. 0 disables i-frames. */
51
61
  invulnerability = 0;
62
+ /**
63
+ * Stat that mirrors `current` (a HUD binds to it with `{{stat}}`). Empty
64
+ * publishes nothing: the stat is the project's to declare.
65
+ */
66
+ stat = '';
52
67
  /** Health left; 0 is dead. Filled in from max on ready. */
53
68
  current = 0;
69
+ /** Whoever dealt the last accepted hit, for the state that reacts to it. */
70
+ lastDamageSource;
54
71
  /** Seconds left in the invulnerability window. */
55
72
  invulnerable = 0;
73
+ /** Seconds into the current blink, for the 10 Hz toggle. */
74
+ blinkClock = 0;
75
+ /** Whether the node is being flashed: exactly while a window is open. */
76
+ get blinking() {
77
+ return this.invulnerable > 0;
78
+ }
56
79
  /** Whether the signalled death must be checked on this component's next update. */
57
80
  deathPending = false;
58
81
  /** States the death signal was supposed to reach. */
59
82
  expectedStates = [];
83
+ /**
84
+ * What a Runtime Snapshot shows: the authored values, the live ones, and
85
+ * the last source by name — projecting the whole Entity would drag its
86
+ * scene node along and bury the numbers that matter.
87
+ */
88
+ inspectState() {
89
+ return {
90
+ max: this.max,
91
+ invulnerability: this.invulnerability,
92
+ stat: this.stat,
93
+ current: this.current,
94
+ invulnerable: this.invulnerable,
95
+ blinking: this.blinking,
96
+ lastDamageSource: this.lastDamageSource?.name ?? null,
97
+ };
98
+ }
60
99
  onReady() {
61
100
  // The inspector has no min clamp on max, so an authored 0 (or a
62
101
  // negative value) is reachable. Without this, current lands at <= 0
@@ -64,12 +103,15 @@ export class Health extends Component {
64
103
  // so die() is never reached — permanently invulnerable instead of
65
104
  // already dead.
66
105
  this.current = Math.max(0, this.max);
106
+ this.publish();
67
107
  if (this.current === 0)
68
108
  this.die();
69
109
  }
70
110
  onUpdate(dt) {
71
- if (this.invulnerable > 0)
111
+ if (this.invulnerable > 0) {
72
112
  this.invulnerable = Math.max(0, this.invulnerable - dt);
113
+ this.blink(dt);
114
+ }
73
115
  if (this.deathPending) {
74
116
  this.deathPending = false;
75
117
  this.settleDeath();
@@ -108,6 +150,8 @@ export class Health extends Component {
108
150
  this.current = Math.max(0, this.current - amount);
109
151
  if (this.current < DEATH_EPSILON)
110
152
  this.current = 0;
153
+ this.lastDamageSource = source;
154
+ this.publish();
111
155
  this.game.events.emit('damage', {
112
156
  entity: this.entity,
113
157
  amount,
@@ -115,14 +159,39 @@ export class Health extends Component {
115
159
  source,
116
160
  });
117
161
  this.invulnerable = this.invulnerability;
118
- if (this.current === 0)
162
+ this.blinkClock = 0;
163
+ if (this.current === 0) {
119
164
  this.die();
165
+ return;
166
+ }
167
+ // Fire-and-forget, like death: a graph with no 'signal:hurt' edge simply
168
+ // ignores it, so a stunned/knocked-back reaction is the role's to add.
169
+ this.entity.get(StateMachine)?.signal('hurt');
120
170
  }
121
171
  /** Gives health back, capped at max. heal(Infinity) is a full restore. */
122
172
  heal(amount) {
123
173
  if (amount <= 0)
124
174
  return;
125
175
  this.current = Math.min(this.max, this.current + amount);
176
+ this.publish();
177
+ }
178
+ /** Mirrors current into the named stat, when one is named. */
179
+ publish() {
180
+ if (this.stat)
181
+ this.game.stats.set(this.stat, this.current);
182
+ }
183
+ /**
184
+ * Flashes the node at 10 Hz for as long as the window stays open, and
185
+ * leaves it visible the frame the window closes — the flash is feedback,
186
+ * never a state a hit can leave the entity stuck in.
187
+ */
188
+ blink(dt) {
189
+ if (this.invulnerable > 0) {
190
+ this.blinkClock += dt;
191
+ this.entity.node.visible = Math.floor(this.blinkClock / BLINK_PERIOD) % 2 === 0;
192
+ return;
193
+ }
194
+ this.entity.node.visible = true;
126
195
  }
127
196
  /**
128
197
  * Announces the death, then lets the role decide — but only if it says it
@@ -130,6 +199,13 @@ export class Health extends Component {
130
199
  * destroying is the fallback rather than the exception.
131
200
  */
132
201
  die() {
202
+ // The killing blow opened a window like any other hit; close it. The
203
+ // death pose must hold steady, and a revived entity starts without
204
+ // leftover immunity. The node is visible here by construction: blink()
205
+ // restores it the frame a window closes, and a lethal hit only lands
206
+ // while no window is open.
207
+ this.invulnerable = 0;
208
+ this.blinkClock = 0;
133
209
  this.game.events.emit('death', { entity: this.entity });
134
210
  const machine = this.entity.get(StateMachine);
135
211
  const targets = machine ? deathTargets(machine.states, machine.current) : [];
@@ -10,6 +10,7 @@ export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH, type PatrolAxis } from '
10
10
  export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH, type ChaserMode } from './chaser.js';
11
11
  export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
12
12
  export { Hazard, resolveHazardTouch, type HazardTouch } from './hazard.js';
13
+ export { MeleeAttack } from './melee-attack.js';
13
14
  export { Health, declaresDeathHandling, deathTargets } from './health.js';
14
15
  export { Respawnable } from './respawnable.js';
15
16
  export { OutOfBounds } from './out-of-bounds.js';
@@ -10,6 +10,7 @@ export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH } from './patrol.js';
10
10
  export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH } from './chaser.js';
11
11
  export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
12
12
  export { Hazard, resolveHazardTouch } from './hazard.js';
13
+ export { MeleeAttack } from './melee-attack.js';
13
14
  export { Health, declaresDeathHandling, deathTargets } from './health.js';
14
15
  export { Respawnable } from './respawnable.js';
15
16
  export { OutOfBounds } from './out-of-bounds.js';
@@ -1,6 +1,7 @@
1
+ import { type ScreenFacing } from './facing.js';
1
2
  import { GridMotor } from './grid-motor.js';
2
3
  /** The eight screen-relative facings declared by the isometric archetype. */
3
- export type IsoFacing = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
4
+ export type IsoFacing = ScreenFacing;
4
5
  /**
5
6
  * Passive isometric motor. Input and facing are screen-relative while the
6
7
  * shared GridMotor integrates and collides in logical square-grid space.
@@ -9,6 +10,12 @@ export type IsoFacing = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
9
10
  export declare class IsoMotor extends GridMotor<IsoFacing> {
10
11
  static componentName: string;
11
12
  facing: IsoFacing;
13
+ /**
14
+ * Under the diamond projection neither logical axis is "up the screen", so
15
+ * the footprint is square: as deep as it is wide, one cell-ish, instead of
16
+ * the short body top-down uses to let the player overlap a fence's top.
17
+ */
18
+ hitboxHeight: number;
12
19
  /** Accelerates screen-relative input toward a logical-space velocity. */
13
20
  run(inputX: number, inputY: number, dt: number): void;
14
21
  }
@@ -1,24 +1,6 @@
1
1
  import { screenInputToLogical } from '@waica/engine';
2
+ import { facingForInput } from './facing.js';
2
3
  import { GridMotor } from './grid-motor.js';
3
- function facingForInput(inputX, inputY) {
4
- if (inputX === 0 && inputY === 0)
5
- return undefined;
6
- if (inputY > 0) {
7
- if (inputX > 0)
8
- return 'ne';
9
- if (inputX < 0)
10
- return 'nw';
11
- return 'n';
12
- }
13
- if (inputY < 0) {
14
- if (inputX > 0)
15
- return 'se';
16
- if (inputX < 0)
17
- return 'sw';
18
- return 's';
19
- }
20
- return inputX > 0 ? 'e' : 'w';
21
- }
22
4
  /**
23
5
  * Passive isometric motor. Input and facing are screen-relative while the
24
6
  * shared GridMotor integrates and collides in logical square-grid space.
@@ -27,6 +9,12 @@ function facingForInput(inputX, inputY) {
27
9
  export class IsoMotor extends GridMotor {
28
10
  static componentName = 'IsoMotor';
29
11
  facing = 's';
12
+ /**
13
+ * Under the diamond projection neither logical axis is "up the screen", so
14
+ * the footprint is square: as deep as it is wide, one cell-ish, instead of
15
+ * the short body top-down uses to let the player overlap a fence's top.
16
+ */
17
+ hitboxHeight = 0.9;
30
18
  /** Accelerates screen-relative input toward a logical-space velocity. */
31
19
  run(inputX, inputY, dt) {
32
20
  const direction = screenInputToLogical(inputX, inputY);
@@ -0,0 +1,48 @@
1
+ import { Component, type Entity } from '@waica/engine';
2
+ /**
3
+ * A short frontal strike. Passive like a motor: state code decides when it
4
+ * happens (the player's `attack` state) and calls strike(facing); this only
5
+ * knows where the blow lands and who can be hurt by it. The hit area is a
6
+ * `range` × `width` rectangle in logical space, laid along the facing from
7
+ * the attacker's position — under the isometric projection that is the
8
+ * diamond diagonal screen-east maps to, so "in front" matches the screen.
9
+ */
10
+ export declare class MeleeAttack extends Component {
11
+ static componentName: string;
12
+ static displayName: string;
13
+ static params: {
14
+ damage: {
15
+ label: string;
16
+ min: number;
17
+ max: number;
18
+ step: number;
19
+ };
20
+ range: {
21
+ label: string;
22
+ min: number;
23
+ max: number;
24
+ step: number;
25
+ };
26
+ width: {
27
+ label: string;
28
+ min: number;
29
+ max: number;
30
+ step: number;
31
+ };
32
+ };
33
+ damage: number;
34
+ /** How far in front of the attacker the blow reaches, in world units. */
35
+ range: number;
36
+ /** How wide the blow is, across the facing. */
37
+ width: number;
38
+ /**
39
+ * Lands one blow along `facing`: every other living entity with a Hitbox
40
+ * and a Health inside the strike area takes `damage` once, with this
41
+ * entity as the source. Returns the entities whose health actually
42
+ * dropped — a target still inside its invulnerability window is not
43
+ * counted. An unknown facing strikes nothing.
44
+ */
45
+ strike(facing: string): Entity[];
46
+ /** The oriented rectangle in front of the attacker, as a unit-scaled polygon. */
47
+ private strikeArea;
48
+ }
@@ -0,0 +1,83 @@
1
+ import { Component, Hitbox, collisionOverlap, } from '@waica/engine';
2
+ import { logicalDirection } from './facing.js';
3
+ import { Health } from './health.js';
4
+ /**
5
+ * A short frontal strike. Passive like a motor: state code decides when it
6
+ * happens (the player's `attack` state) and calls strike(facing); this only
7
+ * knows where the blow lands and who can be hurt by it. The hit area is a
8
+ * `range` × `width` rectangle in logical space, laid along the facing from
9
+ * the attacker's position — under the isometric projection that is the
10
+ * diamond diagonal screen-east maps to, so "in front" matches the screen.
11
+ */
12
+ export class MeleeAttack extends Component {
13
+ static componentName = 'MeleeAttack';
14
+ static displayName = 'Melee attack';
15
+ static params = {
16
+ damage: { label: 'Damage', min: 0, max: 20, step: 1 },
17
+ range: { label: 'Range', min: 0.5, max: 5, step: 0.5 },
18
+ width: { label: 'Width', min: 0.5, max: 5, step: 0.5 },
19
+ };
20
+ damage = 1;
21
+ /** How far in front of the attacker the blow reaches, in world units. */
22
+ range = 1;
23
+ /** How wide the blow is, across the facing. */
24
+ width = 1;
25
+ /**
26
+ * Lands one blow along `facing`: every other living entity with a Hitbox
27
+ * and a Health inside the strike area takes `damage` once, with this
28
+ * entity as the source. Returns the entities whose health actually
29
+ * dropped — a target still inside its invulnerability window is not
30
+ * counted. An unknown facing strikes nothing.
31
+ */
32
+ strike(facing) {
33
+ const direction = logicalDirection(facing, this.game.projection);
34
+ if (!direction)
35
+ return [];
36
+ const area = this.strikeArea(direction.x, direction.y);
37
+ const struck = [];
38
+ // A copy: a target with no death-handling graph is destroyed on the spot,
39
+ // which splices it out of the live array mid-loop (Game does the same).
40
+ for (const other of [...this.game.entities]) {
41
+ if (other === this.entity || !other.alive)
42
+ continue;
43
+ const hitbox = other.get(Hitbox);
44
+ const health = other.get(Health);
45
+ if (!hitbox || !health)
46
+ continue;
47
+ const body = {
48
+ x: other.position.x + hitbox.offsetX,
49
+ y: other.position.y + hitbox.offsetY,
50
+ width: hitbox.width,
51
+ height: hitbox.height,
52
+ shape: hitbox.shape,
53
+ points: hitbox.points,
54
+ };
55
+ if (!collisionOverlap(area, body))
56
+ continue;
57
+ const before = health.current;
58
+ health.damage(this.damage, this.entity);
59
+ if (health.current < before)
60
+ struck.push(other);
61
+ }
62
+ return struck;
63
+ }
64
+ /** The oriented rectangle in front of the attacker, as a unit-scaled polygon. */
65
+ strikeArea(dx, dy) {
66
+ const along = { x: (dx * this.range) / 2, y: (dy * this.range) / 2 };
67
+ const across = { x: (-dy * this.width) / 2, y: (dx * this.width) / 2 };
68
+ const points = [
69
+ [-along.x - across.x, -along.y - across.y],
70
+ [along.x - across.x, along.y - across.y],
71
+ [along.x + across.x, along.y + across.y],
72
+ [-along.x + across.x, -along.y + across.y],
73
+ ];
74
+ return {
75
+ x: this.entity.position.x + along.x,
76
+ y: this.entity.position.y + along.y,
77
+ width: 1,
78
+ height: 1,
79
+ shape: 'polygon',
80
+ points,
81
+ };
82
+ }
83
+ }
@@ -1,14 +1,16 @@
1
- import { Component, type RoleDefinition, type RoleGraph } from '@waica/engine';
1
+ import { Component, type AnimationFacingProvider, type RoleDefinition, type RoleGraph } from '@waica/engine';
2
2
  export type PatrolAxis = 'horizontal' | 'vertical';
3
3
  /**
4
4
  * Rail patrol: back and forth `distance` units from the starting
5
5
  * position along one axis — sideways or up and down — turning around
6
- * at the ends (with a sprite flip when walking sideways).
6
+ * at the ends. It reports which way it walks as seen on screen, so a
7
+ * directional contract picks the matching clip; without one, walking
8
+ * sideways flips the sprite by scale instead.
7
9
  * Passive like a motor: no onUpdate of its own — the 'patroller' logic
8
10
  * set's walk state calls step(dt), so the StateMachine stays the single
9
11
  * owner of the frame.
10
12
  */
11
- export declare class Patrol extends Component {
13
+ export declare class Patrol extends Component implements AnimationFacingProvider {
12
14
  static componentName: string;
13
15
  static params: {
14
16
  axis: {
@@ -36,6 +38,12 @@ export declare class Patrol extends Component {
36
38
  private originY;
37
39
  private dir;
38
40
  onReady(): void;
41
+ /**
42
+ * The rail direction as rendered: the logical axis it walks along,
43
+ * projected when the scene is isometric — a horizontal rail reads as
44
+ * south-east/north-west there, a vertical one as south-west/north-east.
45
+ */
46
+ getAnimationFacing(): string;
39
47
  /** One patrol step: advance, turn at the rail's ends, flip the sprite. */
40
48
  step(dt: number): void;
41
49
  }
@@ -1,8 +1,15 @@
1
- import { Component } from '@waica/engine';
1
+ import { Component, installedDirectionalAnimation, projectIsometric, } from '@waica/engine';
2
+ import { facingForInput } from './facing.js';
3
+ /** Seconds a hit stops the rail. */
4
+ const HURT_SECONDS = 0.25;
5
+ /** Seconds the death pose stays on screen before the entity goes away. */
6
+ const DEATH_SECONDS = 0.5;
2
7
  /**
3
8
  * Rail patrol: back and forth `distance` units from the starting
4
9
  * position along one axis — sideways or up and down — turning around
5
- * at the ends (with a sprite flip when walking sideways).
10
+ * at the ends. It reports which way it walks as seen on screen, so a
11
+ * directional contract picks the matching clip; without one, walking
12
+ * sideways flips the sprite by scale instead.
6
13
  * Passive like a motor: no onUpdate of its own — the 'patroller' logic
7
14
  * set's walk state calls step(dt), so the StateMachine stays the single
8
15
  * owner of the frame.
@@ -26,6 +33,18 @@ export class Patrol extends Component {
26
33
  this.originX = this.entity.position.x;
27
34
  this.originY = this.entity.position.y;
28
35
  }
36
+ /**
37
+ * The rail direction as rendered: the logical axis it walks along,
38
+ * projected when the scene is isometric — a horizontal rail reads as
39
+ * south-east/north-west there, a vertical one as south-west/north-east.
40
+ */
41
+ getAnimationFacing() {
42
+ const vertical = this.axis === 'vertical';
43
+ const lx = vertical ? 0 : this.dir;
44
+ const ly = vertical ? this.dir : 0;
45
+ const screen = this.game.projection === 'isometric' ? projectIsometric(lx, ly) : { x: lx, y: ly };
46
+ return facingForInput(Math.sign(screen.x), Math.sign(screen.y)) ?? 's';
47
+ }
29
48
  /** One patrol step: advance, turn at the rail's ends, flip the sprite. */
30
49
  step(dt) {
31
50
  const pos = this.entity.position;
@@ -42,16 +61,32 @@ export class Patrol extends Component {
42
61
  this.dir = 1;
43
62
  }
44
63
  pos[key] = next;
45
- if (!vertical)
64
+ // With a directional contract installed the clip carries the mirroring
65
+ // (flipX); a scale flip on top would undo it.
66
+ if (!vertical && !installedDirectionalAnimation())
46
67
  this.entity.scale.x = this.dir;
47
68
  }
48
69
  }
49
70
  /** The state graph new patrolling characters start with, as prefab data. */
50
71
  export const PATROLLER_STATE_GRAPH = {
51
72
  initial: 'walk',
52
- states: { walk: {} },
73
+ states: {
74
+ walk: {},
75
+ hurt: { transitions: [{ on: `timer:${HURT_SECONDS}`, to: 'walk' }] },
76
+ // The death pose, resolved directionally like every other clip.
77
+ dead: { clip: 'death' },
78
+ // Death first: a lethal hit must never land in hurt. The death edge is
79
+ // also what makes Health hand the death to this graph instead of
80
+ // destroying the entity on the spot.
81
+ '*': {
82
+ transitions: [
83
+ { on: 'signal:death', to: 'dead' },
84
+ { on: 'signal:hurt', to: 'hurt' },
85
+ ],
86
+ },
87
+ },
53
88
  };
54
- // The walking-critter role. One state out of the box; give patrolling
89
+ // The walking-critter role. Walks, flinches, dies; give patrolling
55
90
  // characters more states by registering on top (defineStates('patroller',
56
91
  // { chasing: {...} })) plus prefab data.
57
92
  export const PATROLLER_ROLE = {
@@ -65,5 +100,14 @@ export const PATROLLER_ROLE = {
65
100
  entity.get(Patrol)?.step(dt);
66
101
  },
67
102
  },
103
+ // The rail stops for the flinch: no step, nothing else to do.
104
+ hurt: { onUpdate() { } },
105
+ // Holds the death pose for a beat, then the body goes away for good.
106
+ dead: {
107
+ onUpdate({ entity, fsm }) {
108
+ if (fsm.elapsed >= DEATH_SECONDS)
109
+ entity.destroy();
110
+ },
111
+ },
68
112
  },
69
113
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/behaviors",
3
- "version": "0.8.0",
3
+ "version": "0.9.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.8.0"
22
+ "@waica/engine": "^0.9.0"
23
23
  }
24
24
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,7 @@
2
2
  "waicaStats": 1,
3
3
  "stats": {
4
4
  "points": 0,
5
- "lives": 3
5
+ "lives": 3,
6
+ "health": 3
6
7
  }
7
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Waica in one command — `npx @waica/cli` opens the editor, `waica mcp` serves the MCP server",
5
5
  "license": "MIT",
6
6
  "type": "module",