@waica/cli 0.7.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 (114) hide show
  1. package/README.md +1 -1
  2. package/dist/editor/assets/{cssMode-eS4pAOyh.js → cssMode-DM5x3eXD.js} +1 -1
  3. package/dist/editor/assets/{freemarker2-FMQjZKQk.js → freemarker2-CmlzUOOI.js} +1 -1
  4. package/dist/editor/assets/{handlebars-dDYOnqat.js → handlebars-Dlz-T3Pu.js} +1 -1
  5. package/dist/editor/assets/{html-Ba6xZh4T.js → html-B-NfgvUd.js} +1 -1
  6. package/dist/editor/assets/{htmlMode-DC9qJr2p.js → htmlMode-K4hF3BAu.js} +1 -1
  7. package/dist/editor/assets/{index-CoyeInOT.js → index-Ca2oPxGt.js} +4626 -1104
  8. package/dist/editor/assets/{index-Cy5pmBhN.css → index-CzarIYLW.css} +1 -1
  9. package/dist/editor/assets/{javascript-DOwFvmvM.js → javascript-COj32tw-.js} +1 -1
  10. package/dist/editor/assets/{jsonMode-CcG7zlSf.js → jsonMode-DyL12Tvg.js} +1 -1
  11. package/dist/editor/assets/{liquid-Dfp71r7J.js → liquid-CGaRZ9Iu.js} +1 -1
  12. package/dist/editor/assets/{mdx-Ci7vVeht.js → mdx-L4fRd4xO.js} +1 -1
  13. package/dist/editor/assets/{python-Bluv3Y9Q.js → python-Z6FCrHUG.js} +1 -1
  14. package/dist/editor/assets/{razor-BtwTXMaK.js → razor-6XSfF-a3.js} +1 -1
  15. package/dist/editor/assets/{tsMode-D3_D-H85.js → tsMode-C24rNZgD.js} +1 -1
  16. package/dist/editor/assets/{typescript-Ch_PS5f7.js → typescript-BcFln1pD.js} +1 -1
  17. package/dist/editor/assets/waica-iso-hero-BsEdW0V7.png +0 -0
  18. package/dist/editor/assets/waica-iso-orc-DRK-SaXm.png +0 -0
  19. package/dist/editor/assets/waica-iso-villager-szDkD_Ag.png +0 -0
  20. package/dist/editor/assets/{xml-CI_bH_MX.js → xml-DJCue8rm.js} +1 -1
  21. package/dist/editor/assets/{yaml-LD9GTZyy.js → yaml-DiJf3V99.js} +1 -1
  22. package/dist/editor/index.html +2 -2
  23. package/dist/mcp/known-archetypes.js +5 -0
  24. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/ATTRIBUTION.md +93 -0
  25. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-crate.png +0 -0
  26. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-ground.png +0 -0
  27. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-hero.png +0 -0
  28. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-orc.png +0 -0
  29. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-rock.png +0 -0
  30. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-tree.png +0 -0
  31. package/dist/mcp/node_modules/@waica/archetype-isometric/assets/waica-iso-villager.png +0 -0
  32. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/animation.d.ts +7 -0
  33. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/animation.js +17 -0
  34. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/art.d.ts +3 -0
  35. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/art.js +10 -0
  36. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/bundle.d.ts +2 -0
  37. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/bundle.js +9 -0
  38. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/controls.d.ts +3 -0
  39. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/controls.js +16 -0
  40. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/index.d.ts +24 -0
  41. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/index.js +15 -0
  42. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/manifest.d.ts +16 -0
  43. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/manifest.js +30 -0
  44. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/prefabs.d.ts +22 -0
  45. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/prefabs.js +215 -0
  46. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/registry-data.d.ts +3 -0
  47. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/registry-data.js +47 -0
  48. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/registry.d.ts +4 -0
  49. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/registry.js +17 -0
  50. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/scene-default.d.ts +5 -0
  51. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/scene-default.js +59 -0
  52. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/ui.d.ts +1 -0
  53. package/dist/mcp/node_modules/@waica/archetype-isometric/dist/ui.js +30 -0
  54. package/dist/mcp/node_modules/@waica/archetype-isometric/package.json +30 -0
  55. package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
  56. package/dist/mcp/node_modules/@waica/archetype-topdown/dist/animation.d.ts +2 -1
  57. package/dist/mcp/node_modules/@waica/archetype-topdown/dist/animation.js +6 -2
  58. package/dist/mcp/node_modules/@waica/archetype-topdown/dist/registry-data.js +2 -1
  59. package/dist/mcp/node_modules/@waica/archetype-topdown/dist/ui.js +2 -19
  60. package/dist/mcp/node_modules/@waica/archetype-topdown/package.json +3 -3
  61. package/dist/mcp/node_modules/@waica/behaviors/dist/facing.d.ts +18 -0
  62. package/dist/mcp/node_modules/@waica/behaviors/dist/facing.js +56 -0
  63. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-motor.d.ts +64 -0
  64. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-motor.js +77 -0
  65. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-player-role.d.ts +20 -0
  66. package/dist/mcp/node_modules/@waica/behaviors/dist/grid-player-role.js +133 -0
  67. package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +29 -0
  68. package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +79 -3
  69. package/dist/mcp/node_modules/@waica/behaviors/dist/index.d.ts +4 -1
  70. package/dist/mcp/node_modules/@waica/behaviors/dist/index.js +4 -1
  71. package/dist/mcp/node_modules/@waica/behaviors/dist/interactable.d.ts +3 -0
  72. package/dist/mcp/node_modules/@waica/behaviors/dist/interactable.js +25 -2
  73. package/dist/mcp/node_modules/@waica/behaviors/dist/iso-motor.d.ts +21 -0
  74. package/dist/mcp/node_modules/@waica/behaviors/dist/iso-motor.js +24 -0
  75. package/dist/mcp/node_modules/@waica/behaviors/dist/iso-player-states.d.ts +5 -0
  76. package/dist/mcp/node_modules/@waica/behaviors/dist/iso-player-states.js +9 -0
  77. package/dist/mcp/node_modules/@waica/behaviors/dist/melee-attack.d.ts +48 -0
  78. package/dist/mcp/node_modules/@waica/behaviors/dist/melee-attack.js +83 -0
  79. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.d.ts +11 -3
  80. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.js +49 -5
  81. package/dist/mcp/node_modules/@waica/behaviors/dist/topdown-motor.d.ts +7 -61
  82. package/dist/mcp/node_modules/@waica/behaviors/dist/topdown-motor.js +8 -79
  83. package/dist/mcp/node_modules/@waica/behaviors/dist/topdown-player-states.d.ts +4 -17
  84. package/dist/mcp/node_modules/@waica/behaviors/dist/topdown-player-states.js +10 -82
  85. package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
  86. package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +2 -0
  87. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +19 -1
  88. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +35 -7
  89. package/dist/mcp/node_modules/@waica/engine/dist/components/dynamic-body.js +2 -4
  90. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.d.ts +19 -0
  91. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.js +44 -8
  92. package/dist/mcp/node_modules/@waica/engine/dist/components/tilemap.d.ts +113 -0
  93. package/dist/mcp/node_modules/@waica/engine/dist/components/tilemap.js +316 -0
  94. package/dist/mcp/node_modules/@waica/engine/dist/entity.d.ts +3 -0
  95. package/dist/mcp/node_modules/@waica/engine/dist/entity.js +13 -1
  96. package/dist/mcp/node_modules/@waica/engine/dist/game.d.ts +4 -0
  97. package/dist/mcp/node_modules/@waica/engine/dist/game.js +41 -9
  98. package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +9 -0
  99. package/dist/mcp/node_modules/@waica/engine/dist/index.js +5 -0
  100. package/dist/mcp/node_modules/@waica/engine/dist/projection.d.ts +14 -0
  101. package/dist/mcp/node_modules/@waica/engine/dist/projection.js +30 -0
  102. package/dist/mcp/node_modules/@waica/engine/dist/scene-solids.d.ts +16 -0
  103. package/dist/mcp/node_modules/@waica/engine/dist/scene-solids.js +28 -0
  104. package/dist/mcp/node_modules/@waica/engine/dist/scene.d.ts +3 -1
  105. package/dist/mcp/node_modules/@waica/engine/dist/solid-axis.js +2 -4
  106. package/dist/mcp/node_modules/@waica/engine/dist/sprite-placement.d.ts +22 -0
  107. package/dist/mcp/node_modules/@waica/engine/dist/sprite-placement.js +14 -0
  108. package/dist/mcp/node_modules/@waica/engine/dist/tilemap-grid.d.ts +26 -0
  109. package/dist/mcp/node_modules/@waica/engine/dist/tilemap-grid.js +41 -0
  110. package/dist/mcp/node_modules/@waica/engine/package.json +1 -1
  111. package/dist/mcp/package-resolver.js +4 -0
  112. package/dist/mcp/project-component-runner.js +1 -0
  113. package/dist/mcp/template/src/stats.json +2 -1
  114. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ import { Component, type AnimationFacingProvider, type CameraVelocity, type CameraVelocityProvider } from '@waica/engine';
2
+ /** Shared velocity, collision and provider behavior for square-grid motors. */
3
+ export declare abstract class GridMotor<Facing extends string> extends Component implements CameraVelocityProvider, AnimationFacingProvider {
4
+ static displayName: string;
5
+ static params: {
6
+ moveSpeed: {
7
+ label: string;
8
+ min: number;
9
+ max: number;
10
+ step: number;
11
+ };
12
+ acceleration: {
13
+ label: string;
14
+ min: number;
15
+ max: number;
16
+ step: number;
17
+ };
18
+ deceleration: {
19
+ label: string;
20
+ min: number;
21
+ max: number;
22
+ step: number;
23
+ };
24
+ walkThreshold: {
25
+ label: string;
26
+ min: number;
27
+ max: number;
28
+ step: number;
29
+ };
30
+ knockbackSpeed: {
31
+ label: string;
32
+ min: number;
33
+ max: number;
34
+ step: number;
35
+ };
36
+ };
37
+ static transient: string[];
38
+ moveSpeed: number;
39
+ acceleration: number;
40
+ deceleration: number;
41
+ /** Speed above this reads as walking (the idle ↔ walk edge). */
42
+ walkThreshold: number;
43
+ /** How hard a hit shoves the body away from its source (the hurt state). */
44
+ knockbackSpeed: number;
45
+ /** The character's AABB hitbox — a footprint, shorter than the sprite. */
46
+ hitboxWidth: number;
47
+ hitboxHeight: number;
48
+ vx: number;
49
+ vy: number;
50
+ abstract facing: Facing;
51
+ /** The scene camera reads follow velocity through this explicit seam. */
52
+ getCameraVelocity(): CameraVelocity;
53
+ /** The StateMachine resolves directional clips through this explicit seam. */
54
+ getAnimationFacing(): string;
55
+ /** Accelerates each axis toward an already-normalized logical direction. */
56
+ protected accelerateToward(x: number, y: number, dt: number): void;
57
+ /** Live speed, for the idle ↔ walk edge. */
58
+ speed(): number;
59
+ /** Integrates velocity and resolves each logical axis against Solids. */
60
+ step(dt: number): void;
61
+ halt(): void;
62
+ private resolveAxis;
63
+ private collisionBody;
64
+ }
@@ -0,0 +1,77 @@
1
+ import { Component, resolveSolidAxis, THREE, } from '@waica/engine';
2
+ /** Shared velocity, collision and provider behavior for square-grid motors. */
3
+ export class GridMotor extends Component {
4
+ static displayName = 'Motor';
5
+ static params = {
6
+ moveSpeed: { label: 'Speed', min: 1, max: 30, step: 0.5 },
7
+ acceleration: { label: 'Acceleration', min: 5, max: 200, step: 5 },
8
+ deceleration: { label: 'Deceleration', min: 5, max: 200, step: 5 },
9
+ walkThreshold: { label: 'Walk threshold', min: 0, max: 5, step: 0.1 },
10
+ knockbackSpeed: { label: 'Knockback speed', min: 0, max: 30, step: 0.5 },
11
+ };
12
+ static transient = ['vx', 'vy', 'facing'];
13
+ moveSpeed = 6;
14
+ acceleration = 60;
15
+ deceleration = 80;
16
+ /** Speed above this reads as walking (the idle ↔ walk edge). */
17
+ walkThreshold = 0.5;
18
+ /** How hard a hit shoves the body away from its source (the hurt state). */
19
+ knockbackSpeed = 8;
20
+ /** The character's AABB hitbox — a footprint, shorter than the sprite. */
21
+ hitboxWidth = 0.9;
22
+ hitboxHeight = 0.6;
23
+ vx = 0;
24
+ vy = 0;
25
+ /** The scene camera reads follow velocity through this explicit seam. */
26
+ getCameraVelocity() {
27
+ return { vx: this.vx, vy: this.vy };
28
+ }
29
+ /** The StateMachine resolves directional clips through this explicit seam. */
30
+ getAnimationFacing() {
31
+ return this.facing;
32
+ }
33
+ /** Accelerates each axis toward an already-normalized logical direction. */
34
+ accelerateToward(x, y, dt) {
35
+ const rateX = x !== 0 ? this.acceleration : this.deceleration;
36
+ const rateY = y !== 0 ? this.acceleration : this.deceleration;
37
+ this.vx = THREE.MathUtils.damp(this.vx, x * this.moveSpeed, rateX / this.moveSpeed, dt);
38
+ this.vy = THREE.MathUtils.damp(this.vy, y * this.moveSpeed, rateY / this.moveSpeed, dt);
39
+ }
40
+ /** Live speed, for the idle ↔ walk edge. */
41
+ speed() {
42
+ return Math.hypot(this.vx, this.vy);
43
+ }
44
+ /** Integrates velocity and resolves each logical axis against Solids. */
45
+ step(dt) {
46
+ const position = this.entity.position;
47
+ const previousX = position.x;
48
+ position.x += this.vx * dt;
49
+ if (this.resolveAxis('x', previousX))
50
+ this.vx = 0;
51
+ const previousY = position.y;
52
+ position.y += this.vy * dt;
53
+ if (this.resolveAxis('y', previousY))
54
+ this.vy = 0;
55
+ }
56
+ halt() {
57
+ this.vx = 0;
58
+ this.vy = 0;
59
+ }
60
+ resolveAxis(axis, previous) {
61
+ return resolveSolidAxis({
62
+ entity: this.entity,
63
+ axis,
64
+ previous,
65
+ body: () => this.collisionBody(),
66
+ });
67
+ }
68
+ collisionBody() {
69
+ return {
70
+ x: this.entity.position.x,
71
+ y: this.entity.position.y,
72
+ width: this.hitboxWidth,
73
+ height: this.hitboxHeight,
74
+ shape: 'rectangle',
75
+ };
76
+ }
77
+ }
@@ -0,0 +1,20 @@
1
+ import { type Component, type ComponentClass, type RoleDefinition, type RoleGraph, type StateContext } from '@waica/engine';
2
+ interface PlayerMotor extends Component {
3
+ walkThreshold: number;
4
+ knockbackSpeed: number;
5
+ facing: string;
6
+ vx: number;
7
+ vy: number;
8
+ run(inputX: number, inputY: number, dt: number): void;
9
+ step(dt: number): void;
10
+ speed(): number;
11
+ halt(): void;
12
+ }
13
+ interface GridPlayerRoleParts {
14
+ graph: RoleGraph;
15
+ update(ctx: StateContext, dt: number): void;
16
+ role: RoleDefinition;
17
+ }
18
+ /** Builds the common player graph and role around one genre-specific grid motor. */
19
+ export declare function createGridPlayerRole<T extends PlayerMotor>(Motor: ComponentClass<T>, description: string): GridPlayerRoleParts;
20
+ export {};
@@ -0,0 +1,133 @@
1
+ import {} from '@waica/engine';
2
+ import { logicalDirection } from './facing.js';
3
+ import { Health } from './health.js';
4
+ import { interactUpdate } from './interactable.js';
5
+ import { MeleeAttack } from './melee-attack.js';
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;
10
+ /** Builds the common player graph and role around one genre-specific grid motor. */
11
+ export function createGridPlayerRole(Motor, description) {
12
+ const graph = {
13
+ initial: 'idle',
14
+ states: {
15
+ idle: {
16
+ transitions: [
17
+ { on: 'input:attack', to: 'attack' },
18
+ { on: 'signal:move', to: 'walk' },
19
+ ],
20
+ },
21
+ walk: {
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' }],
34
+ },
35
+ dead: {
36
+ // The death pose, resolved directionally like every other clip.
37
+ clip: 'death',
38
+ transitions: [{ on: 'timer:0.8', to: 'idle' }],
39
+ },
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
+ },
50
+ },
51
+ };
52
+ const update = ({ entity, game, fsm }, dt) => {
53
+ const motor = entity.get(Motor);
54
+ if (!motor)
55
+ return;
56
+ motor.run(game.input.axis('left', 'right'), game.input.axis('down', 'up'), dt);
57
+ motor.step(dt);
58
+ fsm.signal(motor.speed() > motor.walkThreshold ? 'move' : 'stop');
59
+ };
60
+ const role = {
61
+ description,
62
+ driver: Motor.componentName,
63
+ graph,
64
+ signals: {
65
+ move: 'moving',
66
+ stop: 'standing still',
67
+ },
68
+ states: {
69
+ // Always-hook: the interact lookup must survive custom states like an
70
+ // attack, so it runs in every state.
71
+ '*': {
72
+ onUpdate(ctx) {
73
+ interactUpdate(ctx);
74
+ },
75
+ },
76
+ // Fallback: a custom state without its own onUpdate keeps the full body
77
+ // update — its file only has to say what makes it special.
78
+ default: { onUpdate: update },
79
+ idle: { onUpdate: update },
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
+ },
119
+ // Coming back is what leaving death means, so it hangs off onExit: any
120
+ // other way out of this state (a project's own edge) revives too.
121
+ dead: {
122
+ // A state without its own onUpdate falls back to the role's default
123
+ // body update, so this no-op keeps the player still for the death beat.
124
+ onUpdate() { },
125
+ onExit({ entity }) {
126
+ entity.get(Respawnable)?.respawn();
127
+ entity.get(Health)?.heal(Infinity);
128
+ },
129
+ },
130
+ },
131
+ };
132
+ return { graph, update, role };
133
+ }
@@ -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
@@ -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) : [];
@@ -1,13 +1,16 @@
1
1
  export { PlatformerMotor } from './platformer-motor.js';
2
2
  export { PLAYER_ROLE, PLAYER_STATE_GRAPH, playerUpdate } from './player-states.js';
3
3
  export { TopDownMotor, type TopDownFacing } from './topdown-motor.js';
4
+ export { IsoMotor, type IsoFacing } from './iso-motor.js';
4
5
  export { TOPDOWN_PLAYER_ROLE, TOPDOWN_PLAYER_STATE_GRAPH, topdownPlayerUpdate, } from './topdown-player-states.js';
5
- export { Interactable, interactUpdate } from './interactable.js';
6
+ export { ISO_PLAYER_ROLE, ISO_PLAYER_STATE_GRAPH, isoPlayerUpdate, } from './iso-player-states.js';
7
+ export { INTERACTABLE_UI, INTERACTABLE_UI_PIECE, Interactable, interactUpdate, } from './interactable.js';
6
8
  export { Collectible } from './collectible.js';
7
9
  export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH, type PatrolAxis } from './patrol.js';
8
10
  export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH, type ChaserMode } from './chaser.js';
9
11
  export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
10
12
  export { Hazard, resolveHazardTouch, type HazardTouch } from './hazard.js';
13
+ export { MeleeAttack } from './melee-attack.js';
11
14
  export { Health, declaresDeathHandling, deathTargets } from './health.js';
12
15
  export { Respawnable } from './respawnable.js';
13
16
  export { OutOfBounds } from './out-of-bounds.js';
@@ -1,13 +1,16 @@
1
1
  export { PlatformerMotor } from './platformer-motor.js';
2
2
  export { PLAYER_ROLE, PLAYER_STATE_GRAPH, playerUpdate } from './player-states.js';
3
3
  export { TopDownMotor } from './topdown-motor.js';
4
+ export { IsoMotor } from './iso-motor.js';
4
5
  export { TOPDOWN_PLAYER_ROLE, TOPDOWN_PLAYER_STATE_GRAPH, topdownPlayerUpdate, } from './topdown-player-states.js';
5
- export { Interactable, interactUpdate } from './interactable.js';
6
+ export { ISO_PLAYER_ROLE, ISO_PLAYER_STATE_GRAPH, isoPlayerUpdate, } from './iso-player-states.js';
7
+ export { INTERACTABLE_UI, INTERACTABLE_UI_PIECE, Interactable, interactUpdate, } from './interactable.js';
6
8
  export { Collectible } from './collectible.js';
7
9
  export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH } from './patrol.js';
8
10
  export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH } from './chaser.js';
9
11
  export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
10
12
  export { Hazard, resolveHazardTouch } from './hazard.js';
13
+ export { MeleeAttack } from './melee-attack.js';
11
14
  export { Health, declaresDeathHandling, deathTargets } from './health.js';
12
15
  export { Respawnable } from './respawnable.js';
13
16
  export { OutOfBounds } from './out-of-bounds.js';
@@ -22,6 +22,9 @@ export declare class Interactable extends Component {
22
22
  line: string;
23
23
  radius: number;
24
24
  }
25
+ export declare const INTERACTABLE_UI_PIECE = "npc-line";
26
+ /** The UI fragment every archetype using Interactable must register. */
27
+ export declare const INTERACTABLE_UI: Readonly<Record<string, string>>;
25
28
  /**
26
29
  * The player role's interact lookup, run by its '*' hook in every state:
27
30
  * pressing interact near an Interactable publishes its line through the
@@ -15,6 +15,29 @@ export class Interactable extends Component {
15
15
  line = 'Hello, traveler!';
16
16
  radius = 1.5;
17
17
  }
18
+ export const INTERACTABLE_UI_PIECE = 'npc-line';
19
+ /** The UI fragment every archetype using Interactable must register. */
20
+ export const INTERACTABLE_UI = {
21
+ [INTERACTABLE_UI_PIECE]: `<style>
22
+ .npc-line {
23
+ position: absolute;
24
+ left: 50%;
25
+ bottom: 24px;
26
+ transform: translateX(-50%);
27
+ max-width: 70%;
28
+ padding: 10px 18px;
29
+ border-radius: 8px;
30
+ background: #1a1a2ecc;
31
+ border: 1px solid #ffffff2e;
32
+ font: 500 18px system-ui, sans-serif;
33
+ color: #f5f5f5;
34
+ text-shadow: 0 1px 2px #000a;
35
+ user-select: none;
36
+ }
37
+ </style>
38
+ <div class="npc-line">{{npcLine}}</div>
39
+ `,
40
+ };
18
41
  /**
19
42
  * The player role's interact lookup, run by its '*' hook in every state:
20
43
  * pressing interact near an Interactable publishes its line through the
@@ -37,13 +60,13 @@ export function interactUpdate({ entity, game }) {
37
60
  }
38
61
  }
39
62
  if (!nearest) {
40
- game.ui.hide('npc-line');
63
+ game.ui.hide(INTERACTABLE_UI_PIECE);
41
64
  return;
42
65
  }
43
66
  if (game.input.justPressed('interact') && !game.input.consumed('interact')) {
44
67
  // The press is spent: an input:interact edge needs a NEW press.
45
68
  game.input.consume('interact');
46
69
  game.stats.set('npcLine', nearest.line);
47
- game.ui.show('npc-line');
70
+ game.ui.show(INTERACTABLE_UI_PIECE);
48
71
  }
49
72
  }