@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.
- package/dist/click-to-move.d.ts +92 -0
- package/dist/click-to-move.js +276 -0
- package/dist/grid-player-role.js +29 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/melee-attack.d.ts +7 -2
- package/dist/melee-attack.js +10 -4
- package/dist/navigation-grid.d.ts +34 -0
- package/dist/navigation-grid.js +106 -0
- package/dist/pathfinding.d.ts +35 -0
- package/dist/pathfinding.js +181 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/grid-player-role.js
CHANGED
|
@@ -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 = (
|
|
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
|
-
|
|
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';
|
package/dist/melee-attack.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/melee-attack.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
22
|
+
"@waica/engine": "^0.11.0"
|
|
23
23
|
}
|
|
24
24
|
}
|