@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.
- package/dist/click-to-move.js +9 -4
- package/dist/grid-motor.d.ts +3 -2
- package/dist/grid-player-role.js +31 -20
- package/dist/iso-motor.d.ts +1 -1
- package/dist/pathfinding.js +11 -6
- package/dist/scene-transition.d.ts +22 -1
- package/dist/scene-transition.js +81 -1
- package/dist/topdown-motor.d.ts +1 -1
- package/package.json +2 -2
package/dist/click-to-move.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/grid-motor.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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. */
|
package/dist/grid-player-role.js
CHANGED
|
@@ -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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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(
|
|
116
|
-
const motor = entity.get(Motor);
|
|
117
|
-
if (
|
|
118
|
-
|
|
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) {
|
package/dist/iso-motor.d.ts
CHANGED
|
@@ -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
|
|
10
|
+
export declare class IsoMotor extends GridMotor {
|
|
11
11
|
static componentName: string;
|
|
12
12
|
facing: IsoFacing;
|
|
13
13
|
/**
|
package/dist/pathfinding.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/scene-transition.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/topdown-motor.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
22
|
+
"@waica/engine": "^0.19.1"
|
|
23
23
|
}
|
|
24
24
|
}
|