@waica/behaviors 0.19.0 → 0.19.2

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))
@@ -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.19.0",
3
+ "version": "0.19.2",
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.19.0"
22
+ "@waica/engine": "^0.19.2"
23
23
  }
24
24
  }