@waica/behaviors 0.18.0 → 0.19.1

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.
@@ -75,7 +75,13 @@ function waypointsToward(game, mover, target) {
75
75
  const plan = planPath(grid, pointOf(mover), target);
76
76
  if (!plan)
77
77
  return [];
78
- return plan.cells.map((cell) => grid.cellCenter(cell));
78
+ return plan.cells.map((cell) => {
79
+ const center = grid.cellCenter(cell);
80
+ // Invariant: planPath only returns cells of the grid it planned on.
81
+ if (!center)
82
+ throw new Error(`Planned cell (${cell.column}, ${cell.row}) is outside its navigation grid`);
83
+ return center;
84
+ });
79
85
  }
80
86
  function spawnMarker(clickToMove, game, mover, at) {
81
87
  const marker = game.spawn(`${mover.name}:click-marker`);
@@ -101,8 +107,7 @@ function spawnMarker(clickToMove, game, mover, at) {
101
107
  }
102
108
  /** Consumes the leading waypoints already reached; returns the direction to what's left. */
103
109
  function followWaypoints(clickToMove, mover, waypoints) {
104
- while (waypoints.length > 0) {
105
- const next = waypoints[0];
110
+ for (let next = waypoints[0]; next !== undefined; next = waypoints[0]) {
106
111
  const gap = distance(pointOf(mover), next);
107
112
  if (gap <= clickToMove.arrivalTolerance) {
108
113
  waypoints.shift();
@@ -168,7 +173,7 @@ function startOrder(clickToMove, entity, game, pick) {
168
173
  // An entity without Health/Interactable: fall through to a ground order.
169
174
  }
170
175
  const waypoints = waypointsToward(game, entity, pick.point);
171
- const destination = waypoints.length > 0 ? waypoints[waypoints.length - 1] : pick.point;
176
+ const destination = waypoints.at(-1) ?? pick.point;
172
177
  clickToMove.order = { kind: 'ground', waypoints, destination };
173
178
  spawnMarker(clickToMove, game, entity, destination);
174
179
  }
@@ -1,6 +1,6 @@
1
1
  import { Component, type AnimationFacingProvider, type CameraVelocity, type CameraVelocityProvider } from '@waica/engine';
2
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 {
3
+ export declare abstract class GridMotor extends Component implements CameraVelocityProvider, AnimationFacingProvider {
4
4
  static displayName: string;
5
5
  static params: {
6
6
  moveSpeed: {
@@ -47,7 +47,8 @@ export declare abstract class GridMotor<Facing extends string> extends Component
47
47
  hitboxHeight: number;
48
48
  vx: number;
49
49
  vy: number;
50
- abstract facing: Facing;
50
+ /** Each motor narrows this to its own facing vocabulary. */
51
+ abstract facing: string;
51
52
  /** The scene camera reads follow velocity through this explicit seam. */
52
53
  getCameraVelocity(): CameraVelocity;
53
54
  /** The StateMachine resolves directional clips through this explicit seam. */
@@ -8,9 +8,9 @@ import { Respawnable } from './respawnable.js';
8
8
  /** Seconds the attack swing and the hurt stun each keep the body from the player. */
9
9
  const ATTACK_SECONDS = 0.3;
10
10
  const HURT_SECONDS = 0.3;
11
- /** Builds the common player graph and role around one genre-specific grid motor. */
12
- export function createGridPlayerRole(Motor, description) {
13
- const graph = {
11
+ /** The common player graph; fresh per role so no two roles share a mutable graph. */
12
+ function gridPlayerGraph() {
13
+ return {
14
14
  initial: 'idle',
15
15
  states: {
16
16
  idle: {
@@ -50,7 +50,10 @@ export function createGridPlayerRole(Motor, description) {
50
50
  },
51
51
  },
52
52
  };
53
- const update = (ctx, dt) => {
53
+ }
54
+ /** The body update: keyboard input, else a live Move Order, then the motor step and move/stop signal. */
55
+ function gridPlayerUpdate(Motor) {
56
+ return (ctx, dt) => {
54
57
  const { entity, game, fsm } = ctx;
55
58
  const motor = entity.get(Motor);
56
59
  if (!motor)
@@ -78,6 +81,26 @@ export function createGridPlayerRole(Motor, description) {
78
81
  motor.step(dt);
79
82
  fsm.signal(motor.speed() > motor.walkThreshold ? 'move' : 'stop');
80
83
  };
84
+ }
85
+ /** Sets the motor's knockback velocity away from the last damage source, else back from its facing. */
86
+ function knockBack({ entity, game }, motor) {
87
+ const source = entity.get(Health)?.lastDamageSource;
88
+ const dx = source ? entity.position.x - source.position.x : 0;
89
+ const dy = source ? entity.position.y - source.position.y : 0;
90
+ const distance = Math.hypot(dx, dy);
91
+ let away = distance > 0 ? { x: dx / distance, y: dy / distance } : undefined;
92
+ if (!away) {
93
+ // No source (or one standing on top of us): recoil from the facing.
94
+ const forward = logicalDirection(motor.facing, game.projection);
95
+ away = forward ? { x: -forward.x, y: -forward.y } : { x: 0, y: 0 };
96
+ }
97
+ motor.vx = away.x * motor.knockbackSpeed;
98
+ motor.vy = away.y * motor.knockbackSpeed;
99
+ }
100
+ /** Builds the common player graph and role around one genre-specific grid motor. */
101
+ export function createGridPlayerRole(Motor, description) {
102
+ const graph = gridPlayerGraph();
103
+ const update = gridPlayerUpdate(Motor);
81
104
  const role = {
82
105
  description,
83
106
  driver: Motor.componentName,
@@ -112,22 +135,10 @@ export function createGridPlayerRole(Motor, description) {
112
135
  onUpdate() { },
113
136
  },
114
137
  hurt: {
115
- onEnter({ entity, game }) {
116
- const motor = entity.get(Motor);
117
- if (!motor)
118
- return;
119
- const source = entity.get(Health)?.lastDamageSource;
120
- const dx = source ? entity.position.x - source.position.x : 0;
121
- const dy = source ? entity.position.y - source.position.y : 0;
122
- const distance = Math.hypot(dx, dy);
123
- let away = distance > 0 ? { x: dx / distance, y: dy / distance } : undefined;
124
- if (!away) {
125
- // No source (or one standing on top of us): recoil from the facing.
126
- const forward = logicalDirection(motor.facing, game.projection);
127
- away = forward ? { x: -forward.x, y: -forward.y } : { x: 0, y: 0 };
128
- }
129
- motor.vx = away.x * motor.knockbackSpeed;
130
- motor.vy = away.y * motor.knockbackSpeed;
138
+ onEnter(ctx) {
139
+ const motor = ctx.entity.get(Motor);
140
+ if (motor)
141
+ knockBack(ctx, motor);
131
142
  },
132
143
  // The shove resolves against Solids like any movement; no input.
133
144
  onUpdate({ entity }, dt) {
@@ -7,7 +7,7 @@ export type IsoFacing = ScreenFacing;
7
7
  * shared GridMotor integrates and collides in logical square-grid space.
8
8
  * State code owns the frame; this component has no onUpdate of its own.
9
9
  */
10
- export declare class IsoMotor extends GridMotor<IsoFacing> {
10
+ export declare class IsoMotor extends GridMotor {
11
11
  static componentName: string;
12
12
  facing: IsoFacing;
13
13
  /**
@@ -35,6 +35,13 @@ function neighbors(grid, cell) {
35
35
  function stepCost(dc, dr) {
36
36
  return dc !== 0 && dr !== 0 ? Math.SQRT2 : 1;
37
37
  }
38
+ /** The best known cost of a cell A* has already scored (every open or current cell has one). */
39
+ function scoreOf(gScore, cellKey) {
40
+ const score = gScore.get(cellKey);
41
+ if (score === undefined)
42
+ throw new Error(`A* has no score for cell ${cellKey}`);
43
+ return score;
44
+ }
38
45
  /** Octile distance: admissible heuristic for 8-way movement with unit/√2 costs. */
39
46
  function octile(a, b) {
40
47
  const dx = Math.abs(a.column - b.column);
@@ -51,8 +58,7 @@ export function reachableCells(grid, start) {
51
58
  return [];
52
59
  const visited = new Map([[key(start), start]]);
53
60
  const queue = [start];
54
- while (queue.length > 0) {
55
- const current = queue.shift();
61
+ for (let current = queue.shift(); current !== undefined; current = queue.shift()) {
56
62
  for (const next of neighbors(grid, current)) {
57
63
  const k = key(next);
58
64
  if (visited.has(k))
@@ -84,7 +90,7 @@ export function findPath(grid, start, goal) {
84
90
  let current;
85
91
  let bestF = Infinity;
86
92
  for (const [k, cell] of open) {
87
- const f = gScore.get(k) + octile(cell, goal);
93
+ const f = scoreOf(gScore, k) + octile(cell, goal);
88
94
  if (f < bestF) {
89
95
  bestF = f;
90
96
  currentKey = k;
@@ -96,8 +102,7 @@ export function findPath(grid, start, goal) {
96
102
  if (currentKey === goalKey) {
97
103
  const path = [current];
98
104
  let trace = currentKey;
99
- while (cameFrom.has(trace)) {
100
- const previous = cameFrom.get(trace);
105
+ for (let previous = cameFrom.get(trace); previous; previous = cameFrom.get(trace)) {
101
106
  path.unshift(previous);
102
107
  trace = key(previous);
103
108
  }
@@ -106,7 +111,7 @@ export function findPath(grid, start, goal) {
106
111
  }
107
112
  open.delete(currentKey);
108
113
  closed.add(currentKey);
109
- const currentG = gScore.get(currentKey);
114
+ const currentG = scoreOf(gScore, currentKey);
110
115
  for (const next of neighbors(grid, current)) {
111
116
  const nextKey = key(next);
112
117
  if (closed.has(nextKey))
@@ -1,4 +1,4 @@
1
- import { Component, type Entity } from '@waica/engine';
1
+ import { Component, type CameraEffectColor, type Entity } from '@waica/engine';
2
2
  /**
3
3
  * Replaces the live scene with `scene` (its file's stem, e.g.
4
4
  * "cave.scene.json" -> "cave") when fired — a door, or any entity a player
@@ -13,6 +13,12 @@ import { Component, type Entity } from '@waica/engine';
13
13
  * it needs a sibling Interactable and fires from the shared nearest-wins
14
14
  * interact scan (interactable.ts's fireInteract), so a door and an NPC in
15
15
  * range arbitrate themselves with no new rule.
16
+ *
17
+ * With fadeSeconds > 0 it hides the swap behind a Fade (issue #74): the
18
+ * view fades to fadeColor over fadeSeconds, the destination loads, and the
19
+ * Fade clears over the same fadeSeconds in the incoming scene. Simulation
20
+ * and input keep running; while its outgoing fade runs, this component
21
+ * ignores further triggers. fadeSeconds 0 (the default) is a hard cut.
16
22
  */
17
23
  export declare class SceneTransition extends Component {
18
24
  static componentName: string;
@@ -24,10 +30,25 @@ export declare class SceneTransition extends Component {
24
30
  label: string;
25
31
  options: string[];
26
32
  };
33
+ fadeSeconds: {
34
+ label: string;
35
+ min: number;
36
+ max: number;
37
+ step: number;
38
+ };
39
+ fadeColor: {
40
+ label: string;
41
+ };
27
42
  };
28
43
  /** Destination scene name. */
29
44
  scene: string;
30
45
  trigger: 'overlap' | 'interact';
46
+ /** Seconds of each half of the fade (out, then clear); 0 = hard cut. */
47
+ fadeSeconds: number;
48
+ /** `'black'`, `'white'` or `'#rrggbb'`. */
49
+ fadeColor: CameraEffectColor;
50
+ /** True while the outgoing fade runs: further triggers do nothing. */
51
+ private _fadingOut;
31
52
  onReady(): void;
32
53
  onCollide(_other: Entity): void;
33
54
  onInteract(_initiator: Entity): void;
@@ -14,16 +14,30 @@ import { Interactable } from './interactable.js';
14
14
  * it needs a sibling Interactable and fires from the shared nearest-wins
15
15
  * interact scan (interactable.ts's fireInteract), so a door and an NPC in
16
16
  * range arbitrate themselves with no new rule.
17
+ *
18
+ * With fadeSeconds > 0 it hides the swap behind a Fade (issue #74): the
19
+ * view fades to fadeColor over fadeSeconds, the destination loads, and the
20
+ * Fade clears over the same fadeSeconds in the incoming scene. Simulation
21
+ * and input keep running; while its outgoing fade runs, this component
22
+ * ignores further triggers. fadeSeconds 0 (the default) is a hard cut.
17
23
  */
18
24
  export class SceneTransition extends Component {
19
25
  static componentName = 'SceneTransition';
20
26
  static params = {
21
27
  scene: { label: 'Scene' },
22
28
  trigger: { label: 'Trigger', options: ['overlap', 'interact'] },
29
+ fadeSeconds: { label: 'Fade seconds', min: 0, max: 5, step: 0.05 },
30
+ fadeColor: { label: 'Fade color' },
23
31
  };
24
32
  /** Destination scene name. */
25
33
  scene = '';
26
34
  trigger = 'overlap';
35
+ /** Seconds of each half of the fade (out, then clear); 0 = hard cut. */
36
+ fadeSeconds = 0;
37
+ /** `'black'`, `'white'` or `'#rrggbb'`. */
38
+ fadeColor = 'black';
39
+ /** True while the outgoing fade runs: further triggers do nothing. */
40
+ _fadingOut = false;
27
41
  onReady() {
28
42
  if (this.trigger === 'interact' && !this.entity.has(Interactable)) {
29
43
  console.warn(`[waica] SceneTransition on "${this.entity.name}" has trigger:"interact" but no ` +
@@ -41,6 +55,72 @@ export class SceneTransition extends Component {
41
55
  this.fire();
42
56
  }
43
57
  fire() {
44
- this.game.loadSceneByName(this.scene);
58
+ if (!(this.fadeSeconds > 0)) {
59
+ this.game.loadSceneByName(this.scene);
60
+ return;
61
+ }
62
+ if (this._fadingOut)
63
+ return;
64
+ const { game, fadeSeconds } = this;
65
+ const sceneAtFadeStart = game.sceneName;
66
+ const fadeHandle = game.cameraEffects.fade({ to: this.fadeColor, seconds: fadeSeconds });
67
+ this._fadingOut = true;
68
+ // Scheduled on Game Time, not on `fade.done`: a promise settles in a
69
+ // microtask, after a whole multi-step frame or Runtime Bridge `step`,
70
+ // while this timer fires on the exact step the fade has covered the view.
71
+ //
72
+ // Session-scoped and un-owned (issue #74 review): a mid-fade scene
73
+ // change (another transition, a Runtime Bridge `scene` op, a host
74
+ // reset) cancels scene-scoped work, and Entity.destroy() cancels an
75
+ // owned timer whatever its scope. Either would otherwise strand the
76
+ // Fade at opacity 1 with nothing left to clear it, since the Fade
77
+ // itself is session-scoped and keeps advancing on its own. This timer
78
+ // always runs; it swaps only if the door is still alive and the scene
79
+ // it started the fade in is still live, and it always queues the
80
+ // 'clear'.
81
+ game.time.after(fadeSeconds, () => {
82
+ // Superseded (PR #98 review): the Fade is one shared, session-scoped
83
+ // layer — a second door, or any other `cameraEffects.fade(...)`
84
+ // call, replaces this fade outright instead of queueing behind it
85
+ // (CameraEffects.fade() cancels whatever is running). `fadeHandle
86
+ // .superseded` is read synchronously here, never `.done`: that
87
+ // Promise only notifies its `.then()` in a microtask, and nothing
88
+ // flushes the microtask queue between this Game Time-scheduled
89
+ // callback and the many Simulation Steps that led up to it (same
90
+ // reasoning as scheduling the swap itself on Game Time instead of
91
+ // on `fade.done`, above). It is set once, synchronously, only when
92
+ // a *later* fade call actually replaces this one while it is still
93
+ // running — never for this door's own fade completing on its own,
94
+ // whatever step that lands on (`fire()` runs mid-`dispatchCollisions`
95
+ // for a real overlap, one Simulation Step "behind" where `game.time`
96
+ // sees this same call start, which would otherwise look identical
97
+ // to a supersession). If it is true, somebody else now owns the
98
+ // shared layer and its eventual 'clear': this door must not swap
99
+ // under their partial cover, and must not queue a 'clear' of its
100
+ // own that would cancel their fade. Nothing is in flight to
101
+ // protect, so the latch resets right away — the door can be
102
+ // triggered again if the player is still on it.
103
+ if (fadeHandle.superseded) {
104
+ this._fadingOut = false;
105
+ return;
106
+ }
107
+ if (this.entity.alive && game.sceneName === sceneAtFadeStart) {
108
+ game.loadSceneByName(this.scene);
109
+ }
110
+ // `_fadingOut` stays true here (PR #98 review): loadSceneByName
111
+ // above only *queues* the swap mid-frame, it doesn't apply it until
112
+ // the next frame. Dropping the guard now would let the rest of
113
+ // this same step's dispatchCollisions re-fire on the door, still
114
+ // alive and still overlapped, in the outgoing scene — a second
115
+ // fire() that schedules an orphan timer whose 'clear' lands in the
116
+ // new scene and cancels whatever legitimate fade is running there.
117
+ // Reset it only in the zero-delay follow-up below, once the queued
118
+ // swap has applied and the door has died with its scene (or, if
119
+ // the swap was skipped, once there is nothing left to re-arm).
120
+ game.time.after(0, () => {
121
+ this._fadingOut = false;
122
+ game.cameraEffects.fade({ to: 'clear', seconds: fadeSeconds });
123
+ }, { scope: 'session' });
124
+ }, { scope: 'session' });
45
125
  }
46
126
  }
@@ -6,7 +6,7 @@ export type TopDownFacing = 'n' | 's' | 'e' | 'w';
6
6
  * step(). Eight-direction input is normalized so diagonals match cardinal
7
7
  * speed; movement has no gravity and resolves each axis against Solids.
8
8
  */
9
- export declare class TopDownMotor extends GridMotor<TopDownFacing> {
9
+ export declare class TopDownMotor extends GridMotor {
10
10
  static componentName: string;
11
11
  /** Four-direction facing; starts looking at the camera. */
12
12
  facing: TopDownFacing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/behaviors",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
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.18.0"
22
+ "@waica/engine": "^0.19.1"
23
23
  }
24
24
  }