@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
@@ -1,25 +1,35 @@
1
1
  import { ISO_PLAYER_STATE_GRAPH, NPC_STATE_GRAPH, PATROLLER_STATE_GRAPH, } from '@waica/behaviors';
2
2
  const SHIPPED_DIRECTIONS = ['n', 'ne', 'e', 'se', 's'];
3
- function directionalClips(includePlain) {
3
+ /** One row per shipped facing: idle · walk×3 · attack×3 · hurt×2 · death×2. */
4
+ const SHEET_COLUMNS = 11;
5
+ const SOUTH_ROW = SHIPPED_DIRECTIONS.indexOf('s') * SHEET_COLUMNS;
6
+ /** Transparent texels under the boots in every Puny cell, in render units (9 / 16). */
7
+ const BOOTS_OFFSET = -9 / 16;
8
+ function directionalClips({ plain = false, attacks = false }) {
4
9
  const clips = {};
5
10
  SHIPPED_DIRECTIONS.forEach((direction, row) => {
6
- const first = row * 4;
11
+ const first = row * SHEET_COLUMNS;
7
12
  clips[`idle-${direction}`] = { frames: [first], fps: 2 };
8
13
  clips[`walk-${direction}`] = {
9
14
  frames: [first + 1, first + 2, first + 3, first + 2],
10
15
  fps: 8,
11
16
  };
17
+ if (attacks) {
18
+ clips[`attack-${direction}`] = { frames: [first + 4, first + 5, first + 6], fps: 12, loop: false };
19
+ }
20
+ clips[`hurt-${direction}`] = { frames: [first + 7, first + 8], fps: 8, loop: false };
21
+ clips[`death-${direction}`] = { frames: [first + 9, first + 10], fps: 4, loop: false };
12
22
  });
13
- if (includePlain) {
14
- clips['idle'] = { frames: [16], fps: 2 };
15
- clips['walk'] = { frames: [17, 18, 19, 18], fps: 8 };
23
+ if (plain) {
24
+ clips['idle'] = { frames: [SOUTH_ROW], fps: 2 };
25
+ clips['walk'] = { frames: [SOUTH_ROW + 1, SOUTH_ROW + 2, SOUTH_ROW + 3, SOUTH_ROW + 2], fps: 8 };
16
26
  }
17
27
  return clips;
18
28
  }
19
- function characterSprite(texture, includePlain = false) {
29
+ function characterSprite(texture, options = {}) {
20
30
  return {
21
31
  texture,
22
- cols: 4,
32
+ cols: SHEET_COLUMNS,
23
33
  rows: 5,
24
34
  spacingX: 1,
25
35
  spacingY: 1,
@@ -27,11 +37,16 @@ function characterSprite(texture, includePlain = false) {
27
37
  width: 2,
28
38
  height: 2,
29
39
  anchorY: 0,
30
- clips: directionalClips(includePlain),
31
- initialClip: includePlain ? 'idle' : 'idle-s',
40
+ // The Puny bodies stand 9 texels above their cell's bottom edge; shift the
41
+ // quad down so the boots sit on the entity's position (its footprint),
42
+ // instead of floating half a unit up the screen — over a water diamond
43
+ // the body is actually stopped short of.
44
+ offsetY: BOOTS_OFFSET,
45
+ clips: directionalClips(options),
46
+ initialClip: options.plain ? 'idle' : 'idle-s',
32
47
  };
33
48
  }
34
- export const ISOMETRIC_HERO_SPRITE = characterSprite('waica:iso-hero');
49
+ export const ISOMETRIC_HERO_SPRITE = characterSprite('waica:iso-hero', { attacks: true });
35
50
  const MAP_WIDTH = 16;
36
51
  const MAP_HEIGHT = 16;
37
52
  const GRASS = 0;
@@ -68,8 +83,9 @@ export const ISOMETRIC_PREFABS = {
68
83
  },
69
84
  },
70
85
  { type: 'Hitbox', props: { width: 0.8, height: 0.8 } },
86
+ { type: 'MeleeAttack' },
71
87
  { type: 'Respawnable' },
72
- { type: 'Health', props: { max: 3, invulnerability: 1 } },
88
+ { type: 'Health', props: { max: 3, invulnerability: 1, stat: 'health' } },
73
89
  ],
74
90
  },
75
91
  'characters/villager': {
@@ -78,7 +94,7 @@ export const ISOMETRIC_PREFABS = {
78
94
  components: [
79
95
  {
80
96
  type: 'AnimatedSprite',
81
- props: characterSprite('waica:iso-villager', true),
97
+ props: characterSprite('waica:iso-villager', { plain: true }),
82
98
  },
83
99
  {
84
100
  type: 'StateMachine',
@@ -99,8 +115,10 @@ export const ISOMETRIC_PREFABS = {
99
115
  {
100
116
  type: 'AnimatedSprite',
101
117
  props: {
102
- ...characterSprite('waica:iso-orc', true),
103
- initialClip: 'walk',
118
+ ...characterSprite('waica:iso-orc', { attacks: true }),
119
+ // Patrol reports facing, so the orc resolves directional clips
120
+ // from the first frame: it starts down its rail, screen south-east.
121
+ initialClip: 'walk-se',
104
122
  },
105
123
  },
106
124
  { type: 'Patrol', props: { axis: 'horizontal', distance: 2, speed: 2 } },
@@ -114,6 +132,7 @@ export const ISOMETRIC_PREFABS = {
114
132
  },
115
133
  { type: 'Hitbox', props: { width: 0.8, height: 0.7 } },
116
134
  { type: 'Hazard', props: { stompable: false, contactDamage: 1 } },
135
+ { type: 'Health', props: { max: 2, invulnerability: 0.3 } },
117
136
  ],
118
137
  },
119
138
  'objects/crate': {
@@ -1,5 +1,5 @@
1
1
  import { AnimatedSprite, Hitbox, Solid, Sprite, StateMachine, Tilemap, } from '@waica/engine';
2
- import { Chaser, Collectible, Hazard, Health, Interactable, IsoMotor, Lifetime, Patrol, Respawnable, } from '@waica/behaviors';
2
+ import { Chaser, Collectible, Hazard, Health, Interactable, IsoMotor, Lifetime, MeleeAttack, Patrol, Respawnable, } from '@waica/behaviors';
3
3
  import { ISOMETRIC_ART } from './art.js';
4
4
  import { ISOMETRIC_PREFABS } from './prefabs.js';
5
5
  import { ISOMETRIC_UI } from './ui.js';
@@ -13,6 +13,7 @@ export const ISOMETRIC_REGISTRY_DATA = {
13
13
  Hitbox,
14
14
  StateMachine,
15
15
  IsoMotor,
16
+ MeleeAttack,
16
17
  Interactable,
17
18
  Collectible,
18
19
  Patrol,
@@ -1,8 +1,19 @@
1
+ /**
2
+ * Full-map framing plus a follow feel tuned for the diamond: a tight dead
3
+ * zone and no horizontal lookahead (the engine's step kicks in every time
4
+ * the player crosses walking speed, which lurches on short runs), a little
5
+ * vertical lead, and softer smoothing than the platformer default.
6
+ */
1
7
  function isometricCamera() {
2
8
  return {
3
9
  position: [0, -8],
4
10
  zoom: 12,
5
11
  limits: { minX: -16, maxX: 16, minY: -16, maxY: 0 },
12
+ deadzoneWidth: 0.5,
13
+ deadzoneHeight: 0.5,
14
+ lookahead: 0,
15
+ lookaheadY: 0.5,
16
+ smoothing: 4,
6
17
  };
7
18
  }
8
19
  /** A logical 16x16 meadow projected into a 2:1 diamond at render time. */
@@ -12,7 +23,6 @@ export const ISOMETRIC_SCENE = {
12
23
  camera: {
13
24
  ...isometricCamera(),
14
25
  follow: 'Player',
15
- lookaheadY: 1,
16
26
  },
17
27
  entities: [
18
28
  { name: 'Ground', prefab: 'tiles/ground', position: [0, 0] },
@@ -38,7 +48,7 @@ export const ISOMETRIC_SCENE = {
38
48
  { name: 'Rock-1', prefab: 'objects/rock', position: [5, 11] },
39
49
  { name: 'Rock-2', prefab: 'objects/rock', position: [9, 4] },
40
50
  ],
41
- ui: ['crate-counter'],
51
+ ui: ['crate-counter', 'health'],
42
52
  };
43
53
  /** Blank projects retain the diamond grid and full-map framing. */
44
54
  export const ISOMETRIC_BLANK_SCENE = {
@@ -12,6 +12,19 @@ export const ISOMETRIC_UI = {
12
12
  }
13
13
  </style>
14
14
  <div class="crate-counter">📦 {{points}}</div>
15
+ `,
16
+ health: `<style>
17
+ .health {
18
+ position: absolute;
19
+ top: 12px;
20
+ right: 12px;
21
+ font: 600 20px system-ui, sans-serif;
22
+ color: #ef476f;
23
+ text-shadow: 0 1px 3px #000a;
24
+ user-select: none;
25
+ }
26
+ </style>
27
+ <div class="health">♥ {{health}}</div>
15
28
  `,
16
29
  ...INTERACTABLE_UI,
17
30
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/archetype-isometric",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Waica isometric archetype — logical-grid action adventures with screen-relative movement",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@waica/behaviors": "^0.8.0",
28
- "@waica/engine": "^0.8.0"
27
+ "@waica/behaviors": "^0.9.0",
28
+ "@waica/engine": "^0.9.0"
29
29
  }
30
30
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/archetype-platformer",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Waica platformer archetype — opinionated setup for side-scrolling platformers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@waica/behaviors": "^0.8.0",
28
- "@waica/engine": "^0.8.0"
27
+ "@waica/behaviors": "^0.9.0",
28
+ "@waica/engine": "^0.9.0"
29
29
  }
30
30
  }
@@ -2,6 +2,7 @@ import type { DirectionalAnimation } from '@waica/engine';
2
2
  /**
3
3
  * The archetype's directional animation contract: four facings with west
4
4
  * mirrored from east art (the stock sheets only draw s/n/e), clips named
5
- * `<state>-<dir>`, and walk falling back to idle when a sheet lacks it.
5
+ * `<state>-<dir>`, and every pose the shared player graph can enter (walk,
6
+ * attack, hurt, death) falling back to idle when a sheet lacks it.
6
7
  */
7
8
  export declare const TOPDOWN_ANIMATION: DirectionalAnimation;
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * The archetype's directional animation contract: four facings with west
3
3
  * mirrored from east art (the stock sheets only draw s/n/e), clips named
4
- * `<state>-<dir>`, and walk falling back to idle when a sheet lacks it.
4
+ * `<state>-<dir>`, and every pose the shared player graph can enter (walk,
5
+ * attack, hurt, death) falling back to idle when a sheet lacks it.
5
6
  */
6
7
  export const TOPDOWN_ANIMATION = {
7
8
  directions: ['n', 's', 'e', 'w'],
8
9
  fallbacks: { w: { dir: 'e', flip: true } },
9
- contract: { required: ['idle'], fallbacks: { walk: 'idle' } },
10
+ contract: {
11
+ required: ['idle'],
12
+ fallbacks: { walk: 'idle', attack: 'idle', hurt: 'idle', death: 'idle' },
13
+ },
10
14
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/archetype-topdown",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Waica top-down archetype — opinionated setup for Zelda-like overhead adventures",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@waica/behaviors": "^0.8.0",
28
- "@waica/engine": "^0.8.0"
27
+ "@waica/behaviors": "^0.9.0",
28
+ "@waica/engine": "^0.9.0"
29
29
  }
30
30
  }
@@ -0,0 +1,18 @@
1
+ import { type ProjectedPoint } from '@waica/engine';
2
+ /** The eight screen-relative facings a directional contract can declare. */
3
+ export type ScreenFacing = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
4
+ export declare const SCREEN_FACINGS: readonly ScreenFacing[];
5
+ /**
6
+ * The eight-way facing a screen-relative input reads as — signs only, so a
7
+ * tiny diagonal component still counts. Undefined for no input, which lets
8
+ * the caller keep whatever it was facing.
9
+ */
10
+ export declare function facingForInput(inputX: number, inputY: number): ScreenFacing | undefined;
11
+ /** The screen sign pair behind a facing; undefined for a name the table lacks. */
12
+ export declare function facingVector(facing: string): ProjectedPoint | undefined;
13
+ /**
14
+ * The unit vector a facing points along in the scene's logical space: the
15
+ * screen vector itself without a projection, or its image on the logical
16
+ * diamond under the isometric one — the same mapping the motors move along.
17
+ */
18
+ export declare function logicalDirection(facing: string, projection: 'isometric' | null): ProjectedPoint | undefined;
@@ -0,0 +1,56 @@
1
+ import { screenInputToLogical } from '@waica/engine';
2
+ export const SCREEN_FACINGS = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
3
+ /** Facing → screen sign pair (x right, y up), the inverse of facingForInput. */
4
+ const FACING_VECTORS = {
5
+ n: { x: 0, y: 1 },
6
+ ne: { x: 1, y: 1 },
7
+ e: { x: 1, y: 0 },
8
+ se: { x: 1, y: -1 },
9
+ s: { x: 0, y: -1 },
10
+ sw: { x: -1, y: -1 },
11
+ w: { x: -1, y: 0 },
12
+ nw: { x: -1, y: 1 },
13
+ };
14
+ /**
15
+ * The eight-way facing a screen-relative input reads as — signs only, so a
16
+ * tiny diagonal component still counts. Undefined for no input, which lets
17
+ * the caller keep whatever it was facing.
18
+ */
19
+ export function facingForInput(inputX, inputY) {
20
+ if (inputX === 0 && inputY === 0)
21
+ return undefined;
22
+ if (inputY > 0) {
23
+ if (inputX > 0)
24
+ return 'ne';
25
+ if (inputX < 0)
26
+ return 'nw';
27
+ return 'n';
28
+ }
29
+ if (inputY < 0) {
30
+ if (inputX > 0)
31
+ return 'se';
32
+ if (inputX < 0)
33
+ return 'sw';
34
+ return 's';
35
+ }
36
+ return inputX > 0 ? 'e' : 'w';
37
+ }
38
+ /** The screen sign pair behind a facing; undefined for a name the table lacks. */
39
+ export function facingVector(facing) {
40
+ const vector = FACING_VECTORS[facing];
41
+ return vector ? { ...vector } : undefined;
42
+ }
43
+ /**
44
+ * The unit vector a facing points along in the scene's logical space: the
45
+ * screen vector itself without a projection, or its image on the logical
46
+ * diamond under the isometric one — the same mapping the motors move along.
47
+ */
48
+ export function logicalDirection(facing, projection) {
49
+ const vector = facingVector(facing);
50
+ if (!vector)
51
+ return undefined;
52
+ if (projection === 'isometric')
53
+ return screenInputToLogical(vector.x, vector.y);
54
+ const length = Math.hypot(vector.x, vector.y);
55
+ return { x: vector.x / length, y: vector.y / length };
56
+ }
@@ -27,6 +27,12 @@ export declare abstract class GridMotor<Facing extends string> extends Component
27
27
  max: number;
28
28
  step: number;
29
29
  };
30
+ knockbackSpeed: {
31
+ label: string;
32
+ min: number;
33
+ max: number;
34
+ step: number;
35
+ };
30
36
  };
31
37
  static transient: string[];
32
38
  moveSpeed: number;
@@ -34,6 +40,8 @@ export declare abstract class GridMotor<Facing extends string> extends Component
34
40
  deceleration: number;
35
41
  /** Speed above this reads as walking (the idle ↔ walk edge). */
36
42
  walkThreshold: number;
43
+ /** How hard a hit shoves the body away from its source (the hurt state). */
44
+ knockbackSpeed: number;
37
45
  /** The character's AABB hitbox — a footprint, shorter than the sprite. */
38
46
  hitboxWidth: number;
39
47
  hitboxHeight: number;
@@ -7,6 +7,7 @@ export class GridMotor extends Component {
7
7
  acceleration: { label: 'Acceleration', min: 5, max: 200, step: 5 },
8
8
  deceleration: { label: 'Deceleration', min: 5, max: 200, step: 5 },
9
9
  walkThreshold: { label: 'Walk threshold', min: 0, max: 5, step: 0.1 },
10
+ knockbackSpeed: { label: 'Knockback speed', min: 0, max: 30, step: 0.5 },
10
11
  };
11
12
  static transient = ['vx', 'vy', 'facing'];
12
13
  moveSpeed = 6;
@@ -14,6 +15,8 @@ export class GridMotor extends Component {
14
15
  deceleration = 80;
15
16
  /** Speed above this reads as walking (the idle ↔ walk edge). */
16
17
  walkThreshold = 0.5;
18
+ /** How hard a hit shoves the body away from its source (the hurt state). */
19
+ knockbackSpeed = 8;
17
20
  /** The character's AABB hitbox — a footprint, shorter than the sprite. */
18
21
  hitboxWidth = 0.9;
19
22
  hitboxHeight = 0.6;
@@ -1,9 +1,14 @@
1
1
  import { type Component, type ComponentClass, type RoleDefinition, type RoleGraph, type StateContext } from '@waica/engine';
2
2
  interface PlayerMotor extends Component {
3
3
  walkThreshold: number;
4
+ knockbackSpeed: number;
5
+ facing: string;
6
+ vx: number;
7
+ vy: number;
4
8
  run(inputX: number, inputY: number, dt: number): void;
5
9
  step(dt: number): void;
6
10
  speed(): number;
11
+ halt(): void;
7
12
  }
8
13
  interface GridPlayerRoleParts {
9
14
  graph: RoleGraph;
@@ -1,28 +1,52 @@
1
1
  import {} from '@waica/engine';
2
+ import { logicalDirection } from './facing.js';
2
3
  import { Health } from './health.js';
3
4
  import { interactUpdate } from './interactable.js';
5
+ import { MeleeAttack } from './melee-attack.js';
4
6
  import { Respawnable } from './respawnable.js';
7
+ /** Seconds the attack swing and the hurt stun each keep the body from the player. */
8
+ const ATTACK_SECONDS = 0.3;
9
+ const HURT_SECONDS = 0.3;
5
10
  /** Builds the common player graph and role around one genre-specific grid motor. */
6
11
  export function createGridPlayerRole(Motor, description) {
7
12
  const graph = {
8
13
  initial: 'idle',
9
14
  states: {
10
15
  idle: {
11
- transitions: [{ on: 'signal:move', to: 'walk' }],
16
+ transitions: [
17
+ { on: 'input:attack', to: 'attack' },
18
+ { on: 'signal:move', to: 'walk' },
19
+ ],
12
20
  },
13
21
  walk: {
14
- transitions: [{ on: 'signal:stop', to: 'idle' }],
22
+ transitions: [
23
+ { on: 'input:attack', to: 'attack' },
24
+ { on: 'signal:stop', to: 'idle' },
25
+ ],
26
+ },
27
+ // One swing, body frozen; the strike itself lands on entry.
28
+ attack: {
29
+ transitions: [{ on: `timer:${ATTACK_SECONDS}`, to: 'idle' }],
30
+ },
31
+ // Knocked back and stunned for a beat; the '*' edge below gets it here.
32
+ hurt: {
33
+ transitions: [{ on: `timer:${HURT_SECONDS}`, to: 'idle' }],
15
34
  },
16
35
  dead: {
17
- // Freeze on idle (resolved directionally) rather than name a death
18
- // clip the stock sheets do not have and warn every frame.
19
- clip: 'idle',
36
+ // The death pose, resolved directionally like every other clip.
37
+ clip: 'death',
20
38
  transitions: [{ on: 'timer:0.8', to: 'idle' }],
21
39
  },
22
- // Dying is the same from every state, so the edge lives on '*'. Its
23
- // presence is also the contract Health checks before signalling: a graph
24
- // without it gets its entity destroyed instead.
25
- '*': { transitions: [{ on: 'signal:death', to: 'dead' }] },
40
+ // Dying and getting hurt are the same from every state, so both edges
41
+ // live on '*' death first, so a lethal hit never lands in hurt. The
42
+ // death edge is also the contract Health checks before signalling: a
43
+ // graph without it gets its entity destroyed instead.
44
+ '*': {
45
+ transitions: [
46
+ { on: 'signal:death', to: 'dead' },
47
+ { on: 'signal:hurt', to: 'hurt' },
48
+ ],
49
+ },
26
50
  },
27
51
  };
28
52
  const update = ({ entity, game, fsm }, dt) => {
@@ -54,6 +78,44 @@ export function createGridPlayerRole(Motor, description) {
54
78
  default: { onUpdate: update },
55
79
  idle: { onUpdate: update },
56
80
  walk: { onUpdate: update },
81
+ attack: {
82
+ onEnter({ entity }) {
83
+ const motor = entity.get(Motor);
84
+ if (!motor)
85
+ return;
86
+ motor.halt();
87
+ entity.get(MeleeAttack)?.strike(motor.facing);
88
+ },
89
+ // Explicit no-op: without it the state would inherit the default
90
+ // body update and keep walking through the swing.
91
+ onUpdate() { },
92
+ },
93
+ hurt: {
94
+ onEnter({ entity, game }) {
95
+ const motor = entity.get(Motor);
96
+ if (!motor)
97
+ return;
98
+ const source = entity.get(Health)?.lastDamageSource;
99
+ const dx = source ? entity.position.x - source.position.x : 0;
100
+ const dy = source ? entity.position.y - source.position.y : 0;
101
+ const distance = Math.hypot(dx, dy);
102
+ let away = distance > 0 ? { x: dx / distance, y: dy / distance } : undefined;
103
+ if (!away) {
104
+ // No source (or one standing on top of us): recoil from the facing.
105
+ const forward = logicalDirection(motor.facing, game.projection);
106
+ away = forward ? { x: -forward.x, y: -forward.y } : { x: 0, y: 0 };
107
+ }
108
+ motor.vx = away.x * motor.knockbackSpeed;
109
+ motor.vy = away.y * motor.knockbackSpeed;
110
+ },
111
+ // The shove resolves against Solids like any movement; no input.
112
+ onUpdate({ entity }, dt) {
113
+ entity.get(Motor)?.step(dt);
114
+ },
115
+ onExit({ entity }) {
116
+ entity.get(Motor)?.halt();
117
+ },
118
+ },
57
119
  // Coming back is what leaving death means, so it hangs off onExit: any
58
120
  // other way out of this state (a project's own edge) revives too.
59
121
  dead: {
@@ -41,19 +41,40 @@ export declare class Health extends Component {
41
41
  max: number;
42
42
  step: number;
43
43
  };
44
+ stat: {
45
+ label: string;
46
+ ref: 'stat';
47
+ };
44
48
  };
45
49
  static transient: string[];
46
50
  max: number;
47
51
  /** Seconds of immunity granted by taking a hit. 0 disables i-frames. */
48
52
  invulnerability: number;
53
+ /**
54
+ * Stat that mirrors `current` (a HUD binds to it with `{{stat}}`). Empty
55
+ * publishes nothing: the stat is the project's to declare.
56
+ */
57
+ stat: string;
49
58
  /** Health left; 0 is dead. Filled in from max on ready. */
50
59
  current: number;
60
+ /** Whoever dealt the last accepted hit, for the state that reacts to it. */
61
+ lastDamageSource: Entity | undefined;
51
62
  /** Seconds left in the invulnerability window. */
52
63
  private invulnerable;
64
+ /** Seconds into the current blink, for the 10 Hz toggle. */
65
+ private blinkClock;
66
+ /** Whether the node is being flashed: exactly while a window is open. */
67
+ get blinking(): boolean;
53
68
  /** Whether the signalled death must be checked on this component's next update. */
54
69
  private deathPending;
55
70
  /** States the death signal was supposed to reach. */
56
71
  private expectedStates;
72
+ /**
73
+ * What a Runtime Snapshot shows: the authored values, the live ones, and
74
+ * the last source by name — projecting the whole Entity would drag its
75
+ * scene node along and bury the numbers that matter.
76
+ */
77
+ inspectState(): Record<string, unknown>;
57
78
  onReady(): void;
58
79
  onUpdate(dt: number): void;
59
80
  /**
@@ -73,6 +94,14 @@ export declare class Health extends Component {
73
94
  damage(amount: number, source?: Entity): void;
74
95
  /** Gives health back, capped at max. heal(Infinity) is a full restore. */
75
96
  heal(amount: number): void;
97
+ /** Mirrors current into the named stat, when one is named. */
98
+ private publish;
99
+ /**
100
+ * Flashes the node at 10 Hz for as long as the window stays open, and
101
+ * leaves it visible the frame the window closes — the flash is feedback,
102
+ * never a state a hit can leave the entity stuck in.
103
+ */
104
+ private blink;
76
105
  /**
77
106
  * Announces the death, then lets the role decide — but only if it says it
78
107
  * can: a graph with no death edge would swallow the signal silently, so