@waica/behaviors 0.10.0 → 0.11.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.
@@ -0,0 +1,92 @@
1
+ import { Component, type Entity, type StateContext } from '@waica/engine';
2
+ import { type GridPoint } from './navigation-grid.js';
3
+ interface GroundOrder {
4
+ kind: 'ground';
5
+ waypoints: GridPoint[];
6
+ /** The resolved cell center, for an exact snap on arrival (CA-4). */
7
+ destination: GridPoint;
8
+ }
9
+ interface NpcOrder {
10
+ kind: 'npc';
11
+ target: Entity;
12
+ waypoints: GridPoint[];
13
+ lastPlannedTarget: GridPoint | null;
14
+ }
15
+ interface AttackOrder {
16
+ kind: 'attack';
17
+ target: Entity;
18
+ waypoints: GridPoint[];
19
+ lastPlannedTarget: GridPoint | null;
20
+ }
21
+ type MoveOrder = GroundOrder | NpcOrder | AttackOrder;
22
+ /** The minimal motor contract driveAttackOrder/driveGroundOrder need. */
23
+ interface ClickToMoveMotor {
24
+ facing: string;
25
+ run(inputX: number, inputY: number, dt: number): void;
26
+ halt(): void;
27
+ }
28
+ /**
29
+ * Pointer-issued objective for a grid player role (CA-4/CA-5/CA-6): a click
30
+ * on open ground walks there, on an Interactable walks up and triggers its
31
+ * line, on a foreign Health walks into melee range and re-engages until it
32
+ * dies or disappears. Passive like a motor — driveClickToMove, called from
33
+ * the grid player role's update, does the driving; state code owns the
34
+ * frame.
35
+ */
36
+ export declare class ClickToMove extends Component {
37
+ static componentName: string;
38
+ static displayName: string;
39
+ static params: {
40
+ arrivalTolerance: {
41
+ label: string;
42
+ min: number;
43
+ max: number;
44
+ step: number;
45
+ };
46
+ markerWidth: {
47
+ label: string;
48
+ min: number;
49
+ max: number;
50
+ step: number;
51
+ };
52
+ markerHeight: {
53
+ label: string;
54
+ min: number;
55
+ max: number;
56
+ step: number;
57
+ };
58
+ markerColor: {
59
+ label: string;
60
+ };
61
+ markerTexture: {
62
+ label: string;
63
+ };
64
+ };
65
+ static transient: string[];
66
+ /** Distance (logical units) counted as "arrived" at a waypoint or destination. */
67
+ arrivalTolerance: number;
68
+ /** Destination marker size (CA-8); a diamond/circle sized to taste per archetype. */
69
+ markerWidth: number;
70
+ markerHeight: number;
71
+ markerColor: number;
72
+ /** Empty draws a flat-color circle (the generic default); set to texture a diamond etc. */
73
+ markerTexture: string;
74
+ /** The live order, if any — exposed for tests and inspection, not authored. */
75
+ order: MoveOrder | null;
76
+ /** The ground-order destination marker entity, if one is currently shown. */
77
+ marker: Entity | null;
78
+ /** Cancels the active order (if any) and removes its marker (CA-4/CA-7). */
79
+ cancel(): void;
80
+ onDestroy(): void;
81
+ private despawnMarker;
82
+ }
83
+ /**
84
+ * Drives the active Move Order for one frame: drains a newly arrived click
85
+ * (replacing whatever order was active), then advances the live order and
86
+ * returns the logical-space direction the caller should move the motor
87
+ * along, or null to stand still. Called only while the role's shared body
88
+ * update runs (idle/walk) — never during attack/hurt/dead, which is exactly
89
+ * how a hurt stun pauses the order and a swing can't be interrupted (CA-7).
90
+ */
91
+ export declare function driveClickToMove({ entity, game }: StateContext, motor: ClickToMoveMotor): GridPoint | null;
92
+ export {};
@@ -0,0 +1,276 @@
1
+ import { Component, Sprite, screenInputToLogical, } from '@waica/engine';
2
+ import { INTERACTABLE_UI_PIECE, Interactable } from './interactable.js';
3
+ import { Health } from './health.js';
4
+ import { MeleeAttack } from './melee-attack.js';
5
+ import { buildNavigationGrid } from './navigation-grid.js';
6
+ import { planPath } from './pathfinding.js';
7
+ /** How close (logical units) counts as "arrived" at a waypoint or the final cell. */
8
+ const DEFAULT_ARRIVAL_TOLERANCE = 0.2;
9
+ /** How far (logical units) a chased target has to move before its path re-plans. */
10
+ const ATTACK_REPLAN_DISTANCE = 0.5;
11
+ /** Same threshold, for an NPC Move Order re-planning toward its target. */
12
+ const NPC_REPLAN_DISTANCE = ATTACK_REPLAN_DISTANCE;
13
+ /**
14
+ * Pointer-issued objective for a grid player role (CA-4/CA-5/CA-6): a click
15
+ * on open ground walks there, on an Interactable walks up and triggers its
16
+ * line, on a foreign Health walks into melee range and re-engages until it
17
+ * dies or disappears. Passive like a motor — driveClickToMove, called from
18
+ * the grid player role's update, does the driving; state code owns the
19
+ * frame.
20
+ */
21
+ export class ClickToMove extends Component {
22
+ static componentName = 'ClickToMove';
23
+ static displayName = 'Click to move';
24
+ static params = {
25
+ arrivalTolerance: { label: 'Arrival tolerance', min: 0.05, max: 1, step: 0.05 },
26
+ markerWidth: { label: 'Marker width', min: 0.1, max: 2, step: 0.05 },
27
+ markerHeight: { label: 'Marker height', min: 0.1, max: 2, step: 0.05 },
28
+ markerColor: { label: 'Marker color' },
29
+ markerTexture: { label: 'Marker texture' },
30
+ };
31
+ static transient = ['order', 'marker'];
32
+ /** Distance (logical units) counted as "arrived" at a waypoint or destination. */
33
+ arrivalTolerance = DEFAULT_ARRIVAL_TOLERANCE;
34
+ /** Destination marker size (CA-8); a diamond/circle sized to taste per archetype. */
35
+ markerWidth = 0.5;
36
+ markerHeight = 0.25;
37
+ markerColor = 0xffffff;
38
+ /** Empty draws a flat-color circle (the generic default); set to texture a diamond etc. */
39
+ markerTexture = '';
40
+ /** The live order, if any — exposed for tests and inspection, not authored. */
41
+ order = null;
42
+ /** The ground-order destination marker entity, if one is currently shown. */
43
+ marker = null;
44
+ /** Cancels the active order (if any) and removes its marker (CA-4/CA-7). */
45
+ cancel() {
46
+ this.order = null;
47
+ this.despawnMarker();
48
+ }
49
+ onDestroy() {
50
+ this.despawnMarker();
51
+ }
52
+ despawnMarker() {
53
+ if (!this.marker)
54
+ return;
55
+ if (this.marker.alive)
56
+ this.marker.destroy();
57
+ this.marker = null;
58
+ }
59
+ }
60
+ function pointOf(entity) {
61
+ return { x: entity.position.x, y: entity.position.y };
62
+ }
63
+ function distance(a, b) {
64
+ return Math.hypot(a.x - b.x, a.y - b.y);
65
+ }
66
+ function unitToward(from, to) {
67
+ const gap = distance(from, to);
68
+ if (gap <= 1e-9)
69
+ return null;
70
+ return { x: (to.x - from.x) / gap, y: (to.y - from.y) / gap };
71
+ }
72
+ /** A* waypoints (cell centers) from the mover toward a logical point (CA-2/CA-3). */
73
+ function waypointsToward(game, mover, target) {
74
+ const grid = buildNavigationGrid(game, [pointOf(mover), target], mover);
75
+ const plan = planPath(grid, pointOf(mover), target);
76
+ if (!plan)
77
+ return [];
78
+ return plan.cells.map((cell) => grid.cellCenter(cell));
79
+ }
80
+ function spawnMarker(clickToMove, game, mover, at) {
81
+ const marker = game.spawn(`${mover.name}:click-marker`);
82
+ marker.position.set(at.x, at.y, 0);
83
+ if (clickToMove.markerTexture) {
84
+ marker.add(Sprite, {
85
+ texture: clickToMove.markerTexture,
86
+ pixelArt: true,
87
+ width: clickToMove.markerWidth,
88
+ height: clickToMove.markerHeight,
89
+ color: clickToMove.markerColor,
90
+ });
91
+ }
92
+ else {
93
+ marker.add(Sprite, {
94
+ shape: 'circle',
95
+ width: clickToMove.markerWidth,
96
+ height: clickToMove.markerHeight,
97
+ color: clickToMove.markerColor,
98
+ });
99
+ }
100
+ clickToMove.marker = marker;
101
+ }
102
+ /** Consumes the leading waypoints already reached; returns the direction to what's left. */
103
+ function followWaypoints(clickToMove, mover, waypoints) {
104
+ while (waypoints.length > 0) {
105
+ const next = waypoints[0];
106
+ const gap = distance(pointOf(mover), next);
107
+ if (gap <= clickToMove.arrivalTolerance) {
108
+ waypoints.shift();
109
+ continue;
110
+ }
111
+ return { x: (next.x - mover.position.x) / gap, y: (next.y - mover.position.y) / gap };
112
+ }
113
+ return null;
114
+ }
115
+ /**
116
+ * Closes the last stretch toward an entity target directly, once the planned
117
+ * path (nearest-reachable-cell granularity) is exhausted but the live
118
+ * distance is still outside range/radius. A cell-quantized plan can resolve
119
+ * one cell short of a target whose own Solid straddles cell boundaries
120
+ * (CA-2's rasterization blocks at cell resolution); the direct approach
121
+ * closes that gap the same way normal collision does — GridMotor still
122
+ * resolves it per axis against every Solid, including the target's own, so
123
+ * this never walks through anything.
124
+ */
125
+ function beelineToward(entity, target) {
126
+ return unitToward(pointOf(entity), pointOf(target));
127
+ }
128
+ /**
129
+ * Orients the motor's facing toward a point with zero velocity/position
130
+ * side effects: motor.run's own damped acceleration is a no-op at dt=0
131
+ * (THREE.MathUtils.damp returns its start value unchanged), so this reuses
132
+ * each motor's own facing algorithm (iso's eight-way, topdown's dominant
133
+ * axis) without duplicating it or nudging the mover even a fraction of a
134
+ * unit. Needed because an attack order that starts (or resumes) already
135
+ * inside range never otherwise calls motor.run before MeleeAttack.strike
136
+ * reads motor.facing — a target behind the player's stale facing would be
137
+ * missed forever (CA-6).
138
+ */
139
+ function faceToward(motor, projection, from, to) {
140
+ const direction = unitToward(from, to);
141
+ if (!direction)
142
+ return;
143
+ const screenInput = projection === 'isometric' ? screenInputToLogical(direction.x, direction.y) : direction;
144
+ motor.run(screenInput.x, screenInput.y, 0);
145
+ }
146
+ function startOrder(clickToMove, entity, game, pick) {
147
+ clickToMove.cancel();
148
+ const picked = pick.entity;
149
+ if (picked && picked !== entity && picked.alive) {
150
+ if (picked.get(Health)) {
151
+ clickToMove.order = {
152
+ kind: 'attack',
153
+ target: picked,
154
+ waypoints: waypointsToward(game, entity, pointOf(picked)),
155
+ lastPlannedTarget: pointOf(picked),
156
+ };
157
+ return;
158
+ }
159
+ if (picked.get(Interactable)) {
160
+ clickToMove.order = {
161
+ kind: 'npc',
162
+ target: picked,
163
+ waypoints: waypointsToward(game, entity, pointOf(picked)),
164
+ lastPlannedTarget: pointOf(picked),
165
+ };
166
+ return;
167
+ }
168
+ // An entity without Health/Interactable: fall through to a ground order.
169
+ }
170
+ const waypoints = waypointsToward(game, entity, pick.point);
171
+ const destination = waypoints.length > 0 ? waypoints[waypoints.length - 1] : pick.point;
172
+ clickToMove.order = { kind: 'ground', waypoints, destination };
173
+ spawnMarker(clickToMove, game, entity, destination);
174
+ }
175
+ function driveGroundOrder(clickToMove, entity, order, motor) {
176
+ const direction = followWaypoints(clickToMove, entity, order.waypoints);
177
+ if (direction)
178
+ return direction;
179
+ // Arrived: snap to the exact destination and kill residual velocity, so
180
+ // handing control back doesn't coast a few more frames past the tolerance
181
+ // this same check just confirmed (CA-4's ~0.2-cell arrival is the
182
+ // resting position, not just the moment the order let go).
183
+ entity.position.x = order.destination.x;
184
+ entity.position.y = order.destination.y;
185
+ motor.halt();
186
+ clickToMove.cancel(); // arrived (CA-4/CA-8)
187
+ return null;
188
+ }
189
+ function driveNpcOrder(clickToMove, entity, game, order) {
190
+ const target = order.target;
191
+ if (!target.alive) {
192
+ clickToMove.cancel();
193
+ return null;
194
+ }
195
+ const interactable = target.get(Interactable);
196
+ if (!interactable) {
197
+ clickToMove.cancel();
198
+ return null;
199
+ }
200
+ if (distance(pointOf(entity), pointOf(target)) <= interactable.radius) {
201
+ game.stats.set('npcLine', interactable.line);
202
+ game.ui.show(INTERACTABLE_UI_PIECE);
203
+ clickToMove.cancel(); // CA-5: one trigger per arrival, no key simulated
204
+ return null;
205
+ }
206
+ // Follows the NPC and re-plans toward its current position while it
207
+ // moves, exactly like an attack order (grill decision 8, CA-5) — most
208
+ // Interactables are stationary so this rarely fires in practice, but a
209
+ // moving one (a wandering NPC role added later) must still be chased.
210
+ const targetNow = pointOf(target);
211
+ const moved = !order.lastPlannedTarget || distance(order.lastPlannedTarget, targetNow) >= NPC_REPLAN_DISTANCE;
212
+ if (moved || order.waypoints.length === 0) {
213
+ order.waypoints = waypointsToward(game, entity, targetNow);
214
+ order.lastPlannedTarget = targetNow;
215
+ }
216
+ const direction = followWaypoints(clickToMove, entity, order.waypoints);
217
+ if (direction)
218
+ return direction;
219
+ // Path exhausted (reached the nearest reachable cell) but still outside the
220
+ // radius: close the rest of the way directly (see beelineToward).
221
+ return beelineToward(entity, target);
222
+ }
223
+ function driveAttackOrder(clickToMove, entity, game, order, motor) {
224
+ const target = order.target;
225
+ // alive alone isn't "not dead": a graph that handles signal:death (the
226
+ // orc's, the shared player's) keeps the entity alive — possibly
227
+ // respawning it — while current sits at 0. Health missing entirely is
228
+ // just as much a reason to stop as a lethal current.
229
+ const health = target.get(Health);
230
+ if (!target.alive || !health || health.current <= 0) {
231
+ clickToMove.cancel(); // CA-6: target died (however its graph handles that) or disappeared
232
+ return null;
233
+ }
234
+ const range = entity.get(MeleeAttack)?.range ?? 1;
235
+ if (distance(pointOf(entity), pointOf(target)) <= range) {
236
+ // An order that starts (or resumes) already in range never otherwise
237
+ // calls motor.run before the swing lands — face the target first.
238
+ faceToward(motor, game.projection, pointOf(entity), pointOf(target));
239
+ game.input.injectAction('attack', 'press'); // re-engages via the existing input:attack edge
240
+ return null;
241
+ }
242
+ const targetNow = pointOf(target);
243
+ const moved = !order.lastPlannedTarget || distance(order.lastPlannedTarget, targetNow) >= ATTACK_REPLAN_DISTANCE;
244
+ if (moved || order.waypoints.length === 0) {
245
+ order.waypoints = waypointsToward(game, entity, targetNow);
246
+ order.lastPlannedTarget = targetNow;
247
+ }
248
+ const direction = followWaypoints(clickToMove, entity, order.waypoints);
249
+ if (direction)
250
+ return direction;
251
+ return beelineToward(entity, target);
252
+ }
253
+ /**
254
+ * Drives the active Move Order for one frame: drains a newly arrived click
255
+ * (replacing whatever order was active), then advances the live order and
256
+ * returns the logical-space direction the caller should move the motor
257
+ * along, or null to stand still. Called only while the role's shared body
258
+ * update runs (idle/walk) — never during attack/hurt/dead, which is exactly
259
+ * how a hurt stun pauses the order and a swing can't be interrupted (CA-7).
260
+ */
261
+ export function driveClickToMove({ entity, game }, motor) {
262
+ const clickToMove = entity.get(ClickToMove);
263
+ if (!clickToMove)
264
+ return null;
265
+ const picked = game.pointer?.takePending();
266
+ if (picked)
267
+ startOrder(clickToMove, entity, game, picked);
268
+ const order = clickToMove.order;
269
+ if (!order)
270
+ return null;
271
+ if (order.kind === 'ground')
272
+ return driveGroundOrder(clickToMove, entity, order, motor);
273
+ if (order.kind === 'npc')
274
+ return driveNpcOrder(clickToMove, entity, game, order);
275
+ return driveAttackOrder(clickToMove, entity, game, order, motor);
276
+ }
@@ -1,4 +1,5 @@
1
- import {} from '@waica/engine';
1
+ import { screenInputToLogical, } from '@waica/engine';
2
+ import { ClickToMove, driveClickToMove } from './click-to-move.js';
2
3
  import { logicalDirection } from './facing.js';
3
4
  import { Health } from './health.js';
4
5
  import { interactUpdate } from './interactable.js';
@@ -49,11 +50,31 @@ export function createGridPlayerRole(Motor, description) {
49
50
  },
50
51
  },
51
52
  };
52
- const update = ({ entity, game, fsm }, dt) => {
53
+ const update = (ctx, dt) => {
54
+ const { entity, game, fsm } = ctx;
53
55
  const motor = entity.get(Motor);
54
56
  if (!motor)
55
57
  return;
56
- motor.run(game.input.axis('left', 'right'), game.input.axis('down', 'up'), dt);
58
+ const keyboardX = game.input.axis('left', 'right');
59
+ const keyboardY = game.input.axis('down', 'up');
60
+ if (keyboardX !== 0 || keyboardY !== 0) {
61
+ // Keyboard movement always wins and cancels a live Move Order (CA-4).
62
+ entity.get(ClickToMove)?.cancel();
63
+ motor.run(keyboardX, keyboardY, dt);
64
+ }
65
+ else {
66
+ const driven = driveClickToMove(ctx, motor);
67
+ if (driven) {
68
+ // driveClickToMove answers in logical space; motor.run expects the
69
+ // same screen-relative input keyboard axes already are (IsoMotor
70
+ // converts internally, TopDownMotor's screen space is logical).
71
+ const screenInput = game.projection === 'isometric' ? screenInputToLogical(driven.x, driven.y) : driven;
72
+ motor.run(screenInput.x, screenInput.y, dt);
73
+ }
74
+ else {
75
+ motor.run(0, 0, dt);
76
+ }
77
+ }
57
78
  motor.step(dt);
58
79
  fsm.signal(motor.speed() > motor.walkThreshold ? 'move' : 'stop');
59
80
  };
@@ -119,6 +140,11 @@ export function createGridPlayerRole(Motor, description) {
119
140
  // Coming back is what leaving death means, so it hangs off onExit: any
120
141
  // other way out of this state (a project's own edge) revives too.
121
142
  dead: {
143
+ // Dying cancels any live Move Order outright (CA-7) — hurt only
144
+ // pauses one (nothing to do there: its onUpdate never runs update()).
145
+ onEnter({ entity }) {
146
+ entity.get(ClickToMove)?.cancel();
147
+ },
122
148
  // A state without its own onUpdate falls back to the role's default
123
149
  // body update, so this no-op keeps the player still for the death beat.
124
150
  onUpdate() { },
package/dist/index.d.ts CHANGED
@@ -15,3 +15,8 @@ export { Health, declaresDeathHandling, deathTargets } from './health.js';
15
15
  export { Respawnable } from './respawnable.js';
16
16
  export { OutOfBounds } from './out-of-bounds.js';
17
17
  export { Lifetime } from './lifetime.js';
18
+ export { ClickToMove, driveClickToMove } from './click-to-move.js';
19
+ export { buildNavigationGrid } from './navigation-grid.js';
20
+ export type { GridCell, GridPoint, NavigationGrid } from './navigation-grid.js';
21
+ export { findPath, nearestReachableCell, planPath, reachableCells } from './pathfinding.js';
22
+ export type { PlannedPath } from './pathfinding.js';
package/dist/index.js CHANGED
@@ -15,3 +15,6 @@ export { Health, declaresDeathHandling, deathTargets } from './health.js';
15
15
  export { Respawnable } from './respawnable.js';
16
16
  export { OutOfBounds } from './out-of-bounds.js';
17
17
  export { Lifetime } from './lifetime.js';
18
+ export { ClickToMove, driveClickToMove } from './click-to-move.js';
19
+ export { buildNavigationGrid } from './navigation-grid.js';
20
+ export { findPath, nearestReachableCell, planPath, reachableCells } from './pathfinding.js';
@@ -4,7 +4,7 @@ import { Component, type Entity } from '@waica/engine';
4
4
  * happens (the player's `attack` state) and calls strike(facing); this only
5
5
  * knows where the blow lands and who can be hurt by it. The hit area is a
6
6
  * `range` × `width` rectangle in logical space, laid along the facing from
7
- * the attacker's position — under the isometric projection that is the
7
+ * the attacker's hitbox — under the isometric projection that is the
8
8
  * diamond diagonal screen-east maps to, so "in front" matches the screen.
9
9
  */
10
10
  export declare class MeleeAttack extends Component {
@@ -43,6 +43,11 @@ export declare class MeleeAttack extends Component {
43
43
  * counted. An unknown facing strikes nothing.
44
44
  */
45
45
  strike(facing: string): Entity[];
46
- /** The oriented rectangle in front of the attacker, as a unit-scaled polygon. */
46
+ /**
47
+ * The oriented rectangle in front of the attacker, as a unit-scaled polygon.
48
+ * Anchored on the attacker's own Hitbox — the same offset every target is
49
+ * read through — so an offset body swings from where it stands, not from
50
+ * its transform. An attacker without one keeps swinging from its position.
51
+ */
47
52
  private strikeArea;
48
53
  }
@@ -6,7 +6,7 @@ import { Health } from './health.js';
6
6
  * happens (the player's `attack` state) and calls strike(facing); this only
7
7
  * knows where the blow lands and who can be hurt by it. The hit area is a
8
8
  * `range` × `width` rectangle in logical space, laid along the facing from
9
- * the attacker's position — under the isometric projection that is the
9
+ * the attacker's hitbox — under the isometric projection that is the
10
10
  * diamond diagonal screen-east maps to, so "in front" matches the screen.
11
11
  */
12
12
  export class MeleeAttack extends Component {
@@ -61,8 +61,14 @@ export class MeleeAttack extends Component {
61
61
  }
62
62
  return struck;
63
63
  }
64
- /** The oriented rectangle in front of the attacker, as a unit-scaled polygon. */
64
+ /**
65
+ * The oriented rectangle in front of the attacker, as a unit-scaled polygon.
66
+ * Anchored on the attacker's own Hitbox — the same offset every target is
67
+ * read through — so an offset body swings from where it stands, not from
68
+ * its transform. An attacker without one keeps swinging from its position.
69
+ */
65
70
  strikeArea(dx, dy) {
71
+ const box = this.entity.get(Hitbox);
66
72
  const along = { x: (dx * this.range) / 2, y: (dy * this.range) / 2 };
67
73
  const across = { x: (-dy * this.width) / 2, y: (dx * this.width) / 2 };
68
74
  const points = [
@@ -72,8 +78,8 @@ export class MeleeAttack extends Component {
72
78
  [-along.x + across.x, -along.y + across.y],
73
79
  ];
74
80
  return {
75
- x: this.entity.position.x + along.x,
76
- y: this.entity.position.y + along.y,
81
+ x: this.entity.position.x + (box?.offsetX ?? 0) + along.x,
82
+ y: this.entity.position.y + (box?.offsetY ?? 0) + along.y,
77
83
  width: 1,
78
84
  height: 1,
79
85
  shape: 'polygon',
@@ -0,0 +1,34 @@
1
+ import { type Entity, type Game, type TilemapGridSpec } from '@waica/engine';
2
+ export interface GridCell {
3
+ column: number;
4
+ row: number;
5
+ }
6
+ export interface GridPoint {
7
+ x: number;
8
+ y: number;
9
+ }
10
+ /**
11
+ * Transient lattice of walkable 1×1 logical cells, rasterized from a scene's
12
+ * Solids — Tilemap-derived and entity-authored alike (CA-2). Aligned to the
13
+ * first Tilemap found when one exists; otherwise covers the AABB of the
14
+ * supplied points (player + destination, typically) and every Solid, plus a
15
+ * margin. Always derived fresh — never authored, never cached across plans.
16
+ */
17
+ export interface NavigationGrid {
18
+ readonly spec: TilemapGridSpec;
19
+ readonly columns: number;
20
+ readonly rows: number;
21
+ isWalkable(cell: GridCell): boolean;
22
+ /** The cell containing a logical point, or null outside the grid. */
23
+ cellAt(point: GridPoint): GridCell | null;
24
+ /** The logical center of a cell; null for a cell outside the grid. */
25
+ cellCenter(cell: GridCell): GridPoint | null;
26
+ }
27
+ /**
28
+ * Rasterizes the live scene into a Navigation Grid (CA-2). `points` seeds
29
+ * the AABB fallback when no Tilemap is present (topdown) — pass at least
30
+ * the mover's position and the click/target point. `except` (typically the
31
+ * mover's own entity) is excluded from the blocking Solids, mirroring
32
+ * sceneSolids' own contract.
33
+ */
34
+ export declare function buildNavigationGrid(game: Game, points: readonly GridPoint[], except?: Entity): NavigationGrid;
@@ -0,0 +1,106 @@
1
+ import { aabbOverlap, cellAt as gridCellAt, cellBounds as gridCellBounds, sceneSolids, Tilemap, } from '@waica/engine';
2
+ /** How far past the covered points/Solids an authorless (Tilemap-less) grid extends. */
3
+ const AABB_MARGIN_CELLS = 1;
4
+ function findTilemap(game) {
5
+ for (const entity of game.entities) {
6
+ const tilemap = entity.get(Tilemap);
7
+ if (tilemap)
8
+ return tilemap;
9
+ }
10
+ return undefined;
11
+ }
12
+ function tilemapSpec(tilemap) {
13
+ return {
14
+ mapWidth: tilemap.mapWidth,
15
+ mapHeight: tilemap.mapHeight,
16
+ cellSize: tilemap.cellSize,
17
+ originX: tilemap.entity.position.x,
18
+ originY: tilemap.entity.position.y,
19
+ };
20
+ }
21
+ /** AABB of every supplied point plus every Solid's bounds, +1 cell margin. */
22
+ function aabbSpec(game, points, except) {
23
+ let minX = Infinity;
24
+ let minY = Infinity;
25
+ let maxX = -Infinity;
26
+ let maxY = -Infinity;
27
+ const consider = (x, y) => {
28
+ if (x < minX)
29
+ minX = x;
30
+ if (y < minY)
31
+ minY = y;
32
+ if (x > maxX)
33
+ maxX = x;
34
+ if (y > maxY)
35
+ maxY = y;
36
+ };
37
+ for (const point of points)
38
+ consider(point.x, point.y);
39
+ for (const solid of sceneSolids(game, except)) {
40
+ consider(solid.left, solid.top);
41
+ consider(solid.right, solid.bottom);
42
+ }
43
+ if (!Number.isFinite(minX)) {
44
+ minX = 0;
45
+ minY = 0;
46
+ maxX = 0;
47
+ maxY = 0;
48
+ }
49
+ const originX = Math.floor(minX) - AABB_MARGIN_CELLS;
50
+ const originY = Math.floor(minY) - AABB_MARGIN_CELLS;
51
+ const mapWidth = Math.max(1, Math.ceil(maxX) + AABB_MARGIN_CELLS - originX);
52
+ const mapHeight = Math.max(1, Math.ceil(maxY) + AABB_MARGIN_CELLS - originY);
53
+ return { mapWidth, mapHeight, cellSize: 1, originX, originY };
54
+ }
55
+ /**
56
+ * Rasterizes the live scene into a Navigation Grid (CA-2). `points` seeds
57
+ * the AABB fallback when no Tilemap is present (topdown) — pass at least
58
+ * the mover's position and the click/target point. `except` (typically the
59
+ * mover's own entity) is excluded from the blocking Solids, mirroring
60
+ * sceneSolids' own contract.
61
+ */
62
+ export function buildNavigationGrid(game, points, except) {
63
+ const tilemap = findTilemap(game);
64
+ const spec = tilemap ? tilemapSpec(tilemap) : aabbSpec(game, points, except);
65
+ const columns = Math.max(0, Math.floor(spec.mapWidth));
66
+ const rows = Math.max(0, Math.floor(spec.mapHeight));
67
+ const solids = sceneSolids(game, except);
68
+ const blocked = new Uint8Array(columns * rows);
69
+ for (let row = 0; row < rows; row += 1) {
70
+ for (let column = 0; column < columns; column += 1) {
71
+ const bounds = gridCellBounds(spec, column, row);
72
+ if (!bounds)
73
+ continue;
74
+ const cellCx = bounds.centerX;
75
+ const cellCy = bounds.centerY;
76
+ for (const solid of solids) {
77
+ const solidCx = (solid.left + solid.right) / 2;
78
+ const solidCy = (solid.top + solid.bottom) / 2;
79
+ const solidW = solid.right - solid.left;
80
+ const solidH = solid.top - solid.bottom;
81
+ if (aabbOverlap(cellCx, cellCy, spec.cellSize, spec.cellSize, solidCx, solidCy, solidW, solidH)) {
82
+ blocked[row * columns + column] = 1;
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return {
89
+ spec,
90
+ columns,
91
+ rows,
92
+ isWalkable(cell) {
93
+ if (cell.column < 0 || cell.row < 0 || cell.column >= columns || cell.row >= rows)
94
+ return false;
95
+ return blocked[cell.row * columns + cell.column] === 0;
96
+ },
97
+ cellAt(point) {
98
+ const cell = gridCellAt(spec, point.x, point.y);
99
+ return cell ? { column: cell.column, row: cell.row } : null;
100
+ },
101
+ cellCenter(cell) {
102
+ const bounds = gridCellBounds(spec, cell.column, cell.row);
103
+ return bounds ? { x: bounds.centerX, y: bounds.centerY } : null;
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,35 @@
1
+ import type { GridCell, GridPoint, NavigationGrid } from './navigation-grid.js';
2
+ /**
3
+ * Every cell reachable from `start` under the same 8-way, corner-cut-forbidden
4
+ * adjacency A* uses — a flood fill, cheapest way to answer "is X reachable"
5
+ * and to enumerate candidates for the nearest-reachable fallback (CA-3).
6
+ */
7
+ export declare function reachableCells(grid: NavigationGrid, start: GridCell): GridCell[];
8
+ /**
9
+ * A* over the Navigation Grid, 8-way with corner-cutting forbidden (CA-3).
10
+ * Returns the cell path from (excluding) `start` to (including) `goal`, or
11
+ * null when `goal` isn't reachable from `start` under this adjacency.
12
+ */
13
+ export declare function findPath(grid: NavigationGrid, start: GridCell, goal: GridCell): GridCell[] | null;
14
+ /**
15
+ * The reachable-from-`start` cell whose center is nearest the logical point
16
+ * `to` — the CA-3 fallback for an unreachable or out-of-grid destination.
17
+ * Ties keep the lowest row then column, for determinism.
18
+ */
19
+ export declare function nearestReachableCell(grid: NavigationGrid, start: GridCell, to: GridPoint): GridCell | null;
20
+ export interface PlannedPath {
21
+ /** Cells from (excluding) the start to (including) the resolved target. */
22
+ cells: GridCell[];
23
+ /** The resolved target cell — the clicked cell if reachable, else nearest reachable. */
24
+ targetCell: GridCell;
25
+ /** Logical center of the resolved target cell. */
26
+ target: GridPoint;
27
+ }
28
+ /**
29
+ * Plans a route from a logical point to a logical destination over a fresh
30
+ * Navigation Grid (CA-2/CA-3): an unreachable or out-of-grid destination
31
+ * resolves to the nearest reachable cell to the clicked point, and the path
32
+ * always ends there. Null only when the mover's own position isn't walkable
33
+ * (should not happen for an entity already standing in the scene).
34
+ */
35
+ export declare function planPath(grid: NavigationGrid, from: GridPoint, to: GridPoint): PlannedPath | null;
@@ -0,0 +1,181 @@
1
+ /** 8-way neighborhood; the first four are cardinal, the last four diagonal. */
2
+ const NEIGHBORS = [
3
+ [1, 0],
4
+ [-1, 0],
5
+ [0, 1],
6
+ [0, -1],
7
+ [1, 1],
8
+ [1, -1],
9
+ [-1, 1],
10
+ [-1, -1],
11
+ ];
12
+ function key(cell) {
13
+ return `${cell.column},${cell.row}`;
14
+ }
15
+ /**
16
+ * Walkable neighbors of a cell, corner-cutting forbidden: a diagonal step is
17
+ * only offered when both adjacent cardinal cells are walkable too (CA-3).
18
+ */
19
+ function neighbors(grid, cell) {
20
+ const result = [];
21
+ for (const [dc, dr] of NEIGHBORS) {
22
+ const next = { column: cell.column + dc, row: cell.row + dr };
23
+ if (!grid.isWalkable(next))
24
+ continue;
25
+ if (dc !== 0 && dr !== 0) {
26
+ const cardinalA = { column: cell.column + dc, row: cell.row };
27
+ const cardinalB = { column: cell.column, row: cell.row + dr };
28
+ if (!grid.isWalkable(cardinalA) || !grid.isWalkable(cardinalB))
29
+ continue;
30
+ }
31
+ result.push(next);
32
+ }
33
+ return result;
34
+ }
35
+ function stepCost(dc, dr) {
36
+ return dc !== 0 && dr !== 0 ? Math.SQRT2 : 1;
37
+ }
38
+ /** Octile distance: admissible heuristic for 8-way movement with unit/√2 costs. */
39
+ function octile(a, b) {
40
+ const dx = Math.abs(a.column - b.column);
41
+ const dy = Math.abs(a.row - b.row);
42
+ return Math.max(dx, dy) + (Math.SQRT2 - 1) * Math.min(dx, dy);
43
+ }
44
+ /**
45
+ * Every cell reachable from `start` under the same 8-way, corner-cut-forbidden
46
+ * adjacency A* uses — a flood fill, cheapest way to answer "is X reachable"
47
+ * and to enumerate candidates for the nearest-reachable fallback (CA-3).
48
+ */
49
+ export function reachableCells(grid, start) {
50
+ if (!grid.isWalkable(start))
51
+ return [];
52
+ const visited = new Map([[key(start), start]]);
53
+ const queue = [start];
54
+ while (queue.length > 0) {
55
+ const current = queue.shift();
56
+ for (const next of neighbors(grid, current)) {
57
+ const k = key(next);
58
+ if (visited.has(k))
59
+ continue;
60
+ visited.set(k, next);
61
+ queue.push(next);
62
+ }
63
+ }
64
+ return [...visited.values()];
65
+ }
66
+ /**
67
+ * A* over the Navigation Grid, 8-way with corner-cutting forbidden (CA-3).
68
+ * Returns the cell path from (excluding) `start` to (including) `goal`, or
69
+ * null when `goal` isn't reachable from `start` under this adjacency.
70
+ */
71
+ export function findPath(grid, start, goal) {
72
+ if (!grid.isWalkable(start) || !grid.isWalkable(goal))
73
+ return null;
74
+ if (start.column === goal.column && start.row === goal.row)
75
+ return [];
76
+ const startKey = key(start);
77
+ const goalKey = key(goal);
78
+ const gScore = new Map([[startKey, 0]]);
79
+ const cameFrom = new Map();
80
+ const open = new Map([[startKey, start]]);
81
+ const closed = new Set();
82
+ while (open.size > 0) {
83
+ let currentKey = '';
84
+ let current;
85
+ let bestF = Infinity;
86
+ for (const [k, cell] of open) {
87
+ const f = gScore.get(k) + octile(cell, goal);
88
+ if (f < bestF) {
89
+ bestF = f;
90
+ currentKey = k;
91
+ current = cell;
92
+ }
93
+ }
94
+ if (!current)
95
+ break;
96
+ if (currentKey === goalKey) {
97
+ const path = [current];
98
+ let trace = currentKey;
99
+ while (cameFrom.has(trace)) {
100
+ const previous = cameFrom.get(trace);
101
+ path.unshift(previous);
102
+ trace = key(previous);
103
+ }
104
+ path.shift(); // drop the start cell — the mover is already there.
105
+ return path;
106
+ }
107
+ open.delete(currentKey);
108
+ closed.add(currentKey);
109
+ const currentG = gScore.get(currentKey);
110
+ for (const next of neighbors(grid, current)) {
111
+ const nextKey = key(next);
112
+ if (closed.has(nextKey))
113
+ continue;
114
+ const tentativeG = currentG + stepCost(next.column - current.column, next.row - current.row);
115
+ if (tentativeG < (gScore.get(nextKey) ?? Infinity)) {
116
+ cameFrom.set(nextKey, current);
117
+ gScore.set(nextKey, tentativeG);
118
+ open.set(nextKey, next);
119
+ }
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ function squaredDistance(a, b) {
125
+ const dx = a.x - b.x;
126
+ const dy = a.y - b.y;
127
+ return dx * dx + dy * dy;
128
+ }
129
+ /**
130
+ * The reachable-from-`start` cell whose center is nearest the logical point
131
+ * `to` — the CA-3 fallback for an unreachable or out-of-grid destination.
132
+ * Ties keep the lowest row then column, for determinism.
133
+ */
134
+ export function nearestReachableCell(grid, start, to) {
135
+ let best = null;
136
+ let bestDistance = Infinity;
137
+ for (const cell of reachableCells(grid, start)) {
138
+ const center = grid.cellCenter(cell);
139
+ if (!center)
140
+ continue;
141
+ const distance = squaredDistance(center, to);
142
+ if (distance < bestDistance ||
143
+ (distance === bestDistance &&
144
+ best &&
145
+ (cell.row < best.row || (cell.row === best.row && cell.column < best.column)))) {
146
+ best = cell;
147
+ bestDistance = distance;
148
+ }
149
+ }
150
+ return best;
151
+ }
152
+ /**
153
+ * Plans a route from a logical point to a logical destination over a fresh
154
+ * Navigation Grid (CA-2/CA-3): an unreachable or out-of-grid destination
155
+ * resolves to the nearest reachable cell to the clicked point, and the path
156
+ * always ends there. Null only when the mover's own position isn't walkable
157
+ * (should not happen for an entity already standing in the scene).
158
+ */
159
+ export function planPath(grid, from, to) {
160
+ const startCell = grid.cellAt(from);
161
+ if (!startCell || !grid.isWalkable(startCell))
162
+ return null;
163
+ const rawTargetCell = grid.cellAt(to);
164
+ const directPath = rawTargetCell ? findPath(grid, startCell, rawTargetCell) : null;
165
+ let targetCell;
166
+ let cells;
167
+ if (rawTargetCell && directPath !== null) {
168
+ targetCell = rawTargetCell;
169
+ cells = directPath;
170
+ }
171
+ else {
172
+ targetCell = nearestReachableCell(grid, startCell, to);
173
+ if (!targetCell)
174
+ return null;
175
+ cells = findPath(grid, startCell, targetCell) ?? [];
176
+ }
177
+ const target = grid.cellCenter(targetCell);
178
+ if (!target)
179
+ return null;
180
+ return { cells, targetCell, target };
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/behaviors",
3
- "version": "0.10.0",
3
+ "version": "0.11.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.10.0"
22
+ "@waica/engine": "^0.11.0"
23
23
  }
24
24
  }