@waica/engine 0.3.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/LICENSE +21 -0
- package/dist/aabb.d.ts +2 -0
- package/dist/aabb.js +4 -0
- package/dist/animation/clip-player.d.ts +20 -0
- package/dist/animation/clip-player.js +24 -0
- package/dist/animation/contract.d.ts +21 -0
- package/dist/animation/contract.js +23 -0
- package/dist/animation/sheet.d.ts +65 -0
- package/dist/animation/sheet.js +44 -0
- package/dist/archetype.d.ts +37 -0
- package/dist/archetype.js +1 -0
- package/dist/camera.d.ts +71 -0
- package/dist/camera.js +59 -0
- package/dist/collision-shape.d.ts +26 -0
- package/dist/collision-shape.js +136 -0
- package/dist/component-registry.d.ts +16 -0
- package/dist/component-registry.js +44 -0
- package/dist/component.d.ts +58 -0
- package/dist/component.js +11 -0
- package/dist/components/animated-sprite.d.ts +80 -0
- package/dist/components/animated-sprite.js +210 -0
- package/dist/components/dynamic-body.d.ts +68 -0
- package/dist/components/dynamic-body.js +189 -0
- package/dist/components/hitbox.d.ts +25 -0
- package/dist/components/hitbox.js +21 -0
- package/dist/components/solid.d.ts +32 -0
- package/dist/components/solid.js +45 -0
- package/dist/components/sprite.d.ts +51 -0
- package/dist/components/sprite.js +112 -0
- package/dist/entity.d.ts +22 -0
- package/dist/entity.js +52 -0
- package/dist/events.d.ts +8 -0
- package/dist/events.js +20 -0
- package/dist/game.d.ts +95 -0
- package/dist/game.js +263 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +26 -0
- package/dist/input.d.ts +40 -0
- package/dist/input.js +84 -0
- package/dist/scene.d.ts +80 -0
- package/dist/scene.js +93 -0
- package/dist/solid-axis.d.ts +21 -0
- package/dist/solid-axis.js +77 -0
- package/dist/state/hooks.d.ts +90 -0
- package/dist/state/hooks.js +81 -0
- package/dist/state/state-machine.d.ts +83 -0
- package/dist/state/state-machine.js +173 -0
- package/dist/stats.d.ts +27 -0
- package/dist/stats.js +54 -0
- package/dist/ui.d.ts +47 -0
- package/dist/ui.js +175 -0
- package/package.json +32 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { collisionBounds, collisionOverlap } from './collision-shape.js';
|
|
2
|
+
import { Solid } from './components/solid.js';
|
|
3
|
+
const CONTACT_TOLERANCE = 1e-7;
|
|
4
|
+
function bodyFor(solid) {
|
|
5
|
+
return {
|
|
6
|
+
x: solid.entity.position.x + solid.offsetX,
|
|
7
|
+
y: solid.entity.position.y + solid.offsetY,
|
|
8
|
+
width: solid.width,
|
|
9
|
+
height: solid.height,
|
|
10
|
+
shape: solid.shape,
|
|
11
|
+
points: solid.points,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves one axis against scene Solids. Large displacements are split into
|
|
16
|
+
* body-sized steps so a thin wall cannot sit entirely between two samples.
|
|
17
|
+
* Returns whether a new contact blocked the move.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveSolidAxis({ entity, axis, previous, body, onContact, }) {
|
|
20
|
+
const position = entity.position;
|
|
21
|
+
const target = position[axis];
|
|
22
|
+
const solids = entity.game.entities
|
|
23
|
+
.filter((other) => other !== entity)
|
|
24
|
+
.map((other) => other.get(Solid))
|
|
25
|
+
.filter((solid) => solid !== undefined);
|
|
26
|
+
position[axis] = previous;
|
|
27
|
+
// Preserve the established spawn-inside-wall bail: pre-existing overlaps
|
|
28
|
+
// are ignored for this move rather than becoming arbitrary push-out rules.
|
|
29
|
+
const ignored = new Set(solids.filter((solid) => collisionOverlap(body(), bodyFor(solid))));
|
|
30
|
+
const bounds = collisionBounds(body());
|
|
31
|
+
const extent = axis === 'x' ? bounds.right - bounds.left : bounds.top - bounds.bottom;
|
|
32
|
+
const maxStep = Math.max(extent / 2, 0.05);
|
|
33
|
+
const displacement = target - previous;
|
|
34
|
+
const steps = Math.max(1, Math.ceil(Math.abs(displacement) / maxStep));
|
|
35
|
+
let free = previous;
|
|
36
|
+
for (let step = 1; step <= steps; step++) {
|
|
37
|
+
const candidate = previous + displacement * (step / steps);
|
|
38
|
+
position[axis] = candidate;
|
|
39
|
+
const blockers = solids.filter((solid) => !ignored.has(solid) && collisionOverlap(body(), bodyFor(solid)));
|
|
40
|
+
if (blockers.length === 0) {
|
|
41
|
+
free = candidate;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// More than one Solid can occupy a sample. Resolve against each from the
|
|
45
|
+
// same known-free point and keep the nearest contact along this move.
|
|
46
|
+
let contact = candidate;
|
|
47
|
+
let contactDistance = Infinity;
|
|
48
|
+
let contacts = [];
|
|
49
|
+
for (const solid of blockers) {
|
|
50
|
+
let open = free;
|
|
51
|
+
let blocked = candidate;
|
|
52
|
+
for (let iteration = 0; iteration < 14; iteration++) {
|
|
53
|
+
const middle = (open + blocked) / 2;
|
|
54
|
+
position[axis] = middle;
|
|
55
|
+
if (collisionOverlap(body(), bodyFor(solid)))
|
|
56
|
+
blocked = middle;
|
|
57
|
+
else
|
|
58
|
+
open = middle;
|
|
59
|
+
}
|
|
60
|
+
const distance = Math.abs(open - free);
|
|
61
|
+
if (distance < contactDistance - CONTACT_TOLERANCE) {
|
|
62
|
+
contact = open;
|
|
63
|
+
contactDistance = distance;
|
|
64
|
+
contacts = [solid];
|
|
65
|
+
}
|
|
66
|
+
else if (Math.abs(distance - contactDistance) <= CONTACT_TOLERANCE) {
|
|
67
|
+
contacts.push(solid);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
position[axis] = contact;
|
|
71
|
+
for (const solid of contacts)
|
|
72
|
+
onContact?.(solid);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
position[axis] = target;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Entity } from '../entity.js';
|
|
2
|
+
import type { Game } from '../game.js';
|
|
3
|
+
import type { StateJson, StateMachine } from './state-machine.js';
|
|
4
|
+
/** What state code receives: the entity, the game, and the machine itself. */
|
|
5
|
+
export interface StateContext {
|
|
6
|
+
entity: Entity;
|
|
7
|
+
game: Game;
|
|
8
|
+
fsm: StateMachine;
|
|
9
|
+
}
|
|
10
|
+
/** Code for one state. Every hook is optional — a state can be pure data. */
|
|
11
|
+
export interface StateHooks {
|
|
12
|
+
onEnter?(ctx: StateContext): void;
|
|
13
|
+
onUpdate?(ctx: StateContext, dt: number): void;
|
|
14
|
+
onCollide?(ctx: StateContext, other: Entity): void;
|
|
15
|
+
onExit?(ctx: StateContext): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A named bundle of state code. Two entries are special: '*' is the
|
|
19
|
+
* always-hook — it runs every frame no matter which state is active
|
|
20
|
+
* (per-frame bookkeeping like motor timers lives there) — and 'default'
|
|
21
|
+
* is the fallback — a state that doesn't define a phase inherits it, so
|
|
22
|
+
* a custom state keeps the stock body update unless it overrides it.
|
|
23
|
+
*/
|
|
24
|
+
export type StateLogic = Record<string, StateHooks>;
|
|
25
|
+
/**
|
|
26
|
+
* Registers state code under a logic-set name. A role's name is also its
|
|
27
|
+
* logic-set name, so this is how a role's code is extended: registering
|
|
28
|
+
* again with the same name merges per state.
|
|
29
|
+
*/
|
|
30
|
+
export declare function defineStates(name: string, states: StateLogic): void;
|
|
31
|
+
/** A role's starter state graph — what new characters of the role begin with. */
|
|
32
|
+
export interface RoleGraph {
|
|
33
|
+
initial: string;
|
|
34
|
+
states: Record<string, StateJson>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A character role: the whole behavior package behind one answer to
|
|
38
|
+
* "what is this character?". The editor uses the description to let the
|
|
39
|
+
* role explain itself, and the driver + graph to install the package.
|
|
40
|
+
*/
|
|
41
|
+
export interface RoleDefinition {
|
|
42
|
+
/** One plain-language line: what this role is and does. */
|
|
43
|
+
description: string;
|
|
44
|
+
/**
|
|
45
|
+
* Component the role's states move (they early-return without it).
|
|
46
|
+
* Several roles may share one driver — the contract is N roles : 1 driver.
|
|
47
|
+
*/
|
|
48
|
+
driver?: string;
|
|
49
|
+
/** Starter graph installed when a character adopts this role. */
|
|
50
|
+
graph?: RoleGraph;
|
|
51
|
+
/** The role's state code, registered as the role's logic set. */
|
|
52
|
+
states?: StateLogic;
|
|
53
|
+
/**
|
|
54
|
+
* Signals the role's code emits (name → plain-language description).
|
|
55
|
+
* The editor lists them wherever a transition asks for a signal, so
|
|
56
|
+
* users pick from a vocabulary instead of typing names blind.
|
|
57
|
+
*/
|
|
58
|
+
signals?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
/** All state-registry data an archetype installs before project extensions. */
|
|
61
|
+
export interface ArchetypeBundle {
|
|
62
|
+
roles: Readonly<Record<string, RoleDefinition>>;
|
|
63
|
+
/** Extra named logic sets that are not themselves roles. */
|
|
64
|
+
logicSets?: Readonly<Record<string, StateLogic>>;
|
|
65
|
+
}
|
|
66
|
+
/** Clears role definitions and every named logic set. */
|
|
67
|
+
export declare function resetRegistries(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Replaces the active registry contents with one archetype's complete bundle.
|
|
70
|
+
* Project defineRole/defineStates calls may extend this clean baseline after it
|
|
71
|
+
* is installed.
|
|
72
|
+
*/
|
|
73
|
+
export declare function installArchetype(bundle: ArchetypeBundle): void;
|
|
74
|
+
/**
|
|
75
|
+
* Registers a character role. Prefabs adopt it via the StateMachine's
|
|
76
|
+
* `role` prop; the role's name doubles as its logic-set name, so
|
|
77
|
+
* defineStates(name, {...}) extends its code. Registering an existing
|
|
78
|
+
* role again merges its definition (and its states).
|
|
79
|
+
*/
|
|
80
|
+
export declare function defineRole(name: string, def: RoleDefinition): void;
|
|
81
|
+
/** The registered role, if any — driver, graph and description included. */
|
|
82
|
+
export declare function roleDefinition(name: string): RoleDefinition | undefined;
|
|
83
|
+
/** Every registered role name — for the editor's Role pickers. */
|
|
84
|
+
export declare function registeredRoles(): string[];
|
|
85
|
+
/** The registered code for a set, if any. */
|
|
86
|
+
export declare function logicSet(name: string): StateLogic | undefined;
|
|
87
|
+
/** Every registered set name — for error messages and editor pickers. */
|
|
88
|
+
export declare function registeredLogicSets(): string[];
|
|
89
|
+
/** Closest registered set to a (likely typo'd) name, for the not-found error. */
|
|
90
|
+
export declare function closestLogicSet(name: string): string | undefined;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const sets = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* Registers state code under a logic-set name. A role's name is also its
|
|
4
|
+
* logic-set name, so this is how a role's code is extended: registering
|
|
5
|
+
* again with the same name merges per state.
|
|
6
|
+
*/
|
|
7
|
+
export function defineStates(name, states) {
|
|
8
|
+
sets.set(name, { ...sets.get(name), ...states });
|
|
9
|
+
}
|
|
10
|
+
const roles = new Map();
|
|
11
|
+
/** Clears role definitions and every named logic set. */
|
|
12
|
+
export function resetRegistries() {
|
|
13
|
+
roles.clear();
|
|
14
|
+
sets.clear();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Replaces the active registry contents with one archetype's complete bundle.
|
|
18
|
+
* Project defineRole/defineStates calls may extend this clean baseline after it
|
|
19
|
+
* is installed.
|
|
20
|
+
*/
|
|
21
|
+
export function installArchetype(bundle) {
|
|
22
|
+
resetRegistries();
|
|
23
|
+
for (const [name, def] of Object.entries(bundle.roles))
|
|
24
|
+
defineRole(name, def);
|
|
25
|
+
for (const [name, states] of Object.entries(bundle.logicSets ?? {})) {
|
|
26
|
+
defineStates(name, states);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Registers a character role. Prefabs adopt it via the StateMachine's
|
|
31
|
+
* `role` prop; the role's name doubles as its logic-set name, so
|
|
32
|
+
* defineStates(name, {...}) extends its code. Registering an existing
|
|
33
|
+
* role again merges its definition (and its states).
|
|
34
|
+
*/
|
|
35
|
+
export function defineRole(name, def) {
|
|
36
|
+
roles.set(name, { ...roles.get(name), ...def });
|
|
37
|
+
if (def.states)
|
|
38
|
+
defineStates(name, def.states);
|
|
39
|
+
}
|
|
40
|
+
/** The registered role, if any — driver, graph and description included. */
|
|
41
|
+
export function roleDefinition(name) {
|
|
42
|
+
return roles.get(name);
|
|
43
|
+
}
|
|
44
|
+
/** Every registered role name — for the editor's Role pickers. */
|
|
45
|
+
export function registeredRoles() {
|
|
46
|
+
return [...roles.keys()];
|
|
47
|
+
}
|
|
48
|
+
/** The registered code for a set, if any. */
|
|
49
|
+
export function logicSet(name) {
|
|
50
|
+
return sets.get(name);
|
|
51
|
+
}
|
|
52
|
+
/** Every registered set name — for error messages and editor pickers. */
|
|
53
|
+
export function registeredLogicSets() {
|
|
54
|
+
return [...sets.keys()];
|
|
55
|
+
}
|
|
56
|
+
/** Closest registered set to a (likely typo'd) name, for the not-found error. */
|
|
57
|
+
export function closestLogicSet(name) {
|
|
58
|
+
let best;
|
|
59
|
+
let bestDistance = Infinity;
|
|
60
|
+
for (const candidate of sets.keys()) {
|
|
61
|
+
const distance = editDistance(name, candidate);
|
|
62
|
+
if (distance < bestDistance) {
|
|
63
|
+
bestDistance = distance;
|
|
64
|
+
best = candidate;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return bestDistance <= Math.max(2, Math.floor(name.length / 3)) ? best : undefined;
|
|
68
|
+
}
|
|
69
|
+
function editDistance(a, b) {
|
|
70
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
71
|
+
for (let i = 1; i <= a.length; i++) {
|
|
72
|
+
let diagonal = row[0];
|
|
73
|
+
row[0] = i;
|
|
74
|
+
for (let j = 1; j <= b.length; j++) {
|
|
75
|
+
const previous = row[j];
|
|
76
|
+
row[j] = Math.min(previous + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
77
|
+
diagonal = previous;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return row[b.length];
|
|
81
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Component } from '../component.js';
|
|
2
|
+
import type { Entity } from '../entity.js';
|
|
3
|
+
import { type StateHooks } from './hooks.js';
|
|
4
|
+
/** One outgoing edge: when `on` fires, the machine moves to `to`. */
|
|
5
|
+
export interface StateTransitionJson {
|
|
6
|
+
/** Trigger: 'input:<action>' | 'timer:<seconds>' | 'signal:<name>'. */
|
|
7
|
+
on: string;
|
|
8
|
+
to: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* One state, as prefab data. Its code (if any) is looked up by state name
|
|
12
|
+
* in the machine's logic set. The '*' state key holds from-any-state
|
|
13
|
+
* transitions instead of a real state.
|
|
14
|
+
*/
|
|
15
|
+
export interface StateJson {
|
|
16
|
+
/** Clip to play on enter; defaults to the state's own name. */
|
|
17
|
+
clip?: string;
|
|
18
|
+
transitions?: StateTransitionJson[];
|
|
19
|
+
}
|
|
20
|
+
/** What a trigger string is checked against. */
|
|
21
|
+
export interface TriggerEnv {
|
|
22
|
+
/** An unconsumed press of the action this frame (see Input.consume). */
|
|
23
|
+
justPressed(action: string): boolean;
|
|
24
|
+
/** Seconds spent in the current state. */
|
|
25
|
+
elapsed: number;
|
|
26
|
+
signals: ReadonlySet<string>;
|
|
27
|
+
}
|
|
28
|
+
/** Whether one trigger fires. Unknown or malformed triggers never fire. */
|
|
29
|
+
export declare function evaluateTrigger(on: string, env: TriggerEnv): boolean;
|
|
30
|
+
/** The first firing transition: the state's own edges, then '*''s. */
|
|
31
|
+
export declare function nextTransition(states: Record<string, StateJson>, current: string, env: TriggerEnv): StateTransitionJson | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The hooks that actually run for one phase of a state: the state's own
|
|
34
|
+
* (logic set + instance), or — when none of them define the phase — the
|
|
35
|
+
* set's 'default' entry. A custom state keeps the role's stock body
|
|
36
|
+
* update unless it brings its own.
|
|
37
|
+
*/
|
|
38
|
+
export declare function phaseHooks(hooks: StateHooks[], fallback: StateHooks | undefined, phase: keyof StateHooks): StateHooks[];
|
|
39
|
+
/**
|
|
40
|
+
* Finite state machine: the single owner of a character's per-frame
|
|
41
|
+
* logic — only the active state's code moves the body, so behaviors
|
|
42
|
+
* never fight over velocity. States and transitions are prefab data;
|
|
43
|
+
* state code lives in the role's logic set (defineRole / defineStates)
|
|
44
|
+
* picked by `role`. Entering a state plays the sibling AnimatedSprite
|
|
45
|
+
* clip of the same name (or the state's `clip` override); a missing
|
|
46
|
+
* clip warns and keeps the previous one.
|
|
47
|
+
*/
|
|
48
|
+
export declare class StateMachine extends Component {
|
|
49
|
+
static componentName: string;
|
|
50
|
+
static displayName: string;
|
|
51
|
+
static params: {
|
|
52
|
+
role: {
|
|
53
|
+
label: string;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/** The character's role — names the logic set providing its state code. */
|
|
57
|
+
role: string;
|
|
58
|
+
/** Starting state; defaults to the first declared state. */
|
|
59
|
+
initial: string;
|
|
60
|
+
states: Record<string, StateJson>;
|
|
61
|
+
/** Active state name. */
|
|
62
|
+
current: string;
|
|
63
|
+
/** Seconds spent in the active state. */
|
|
64
|
+
elapsed: number;
|
|
65
|
+
private readonly instanceHooks;
|
|
66
|
+
private readonly signals;
|
|
67
|
+
private readonly warnedClips;
|
|
68
|
+
onReady(): void;
|
|
69
|
+
/** Adds instance-level hooks on top of the logic set — the escape hatch. */
|
|
70
|
+
on(state: string, hooks: StateHooks): void;
|
|
71
|
+
/** Queues a signal for 'signal:<name>' transitions, consumed this frame or the next. */
|
|
72
|
+
signal(name: string): void;
|
|
73
|
+
/** Jumps straight to a state, ignoring transitions. */
|
|
74
|
+
goto(state: string): void;
|
|
75
|
+
onUpdate(dt: number): void;
|
|
76
|
+
onCollide(other: Entity): void;
|
|
77
|
+
private env;
|
|
78
|
+
private hooksFor;
|
|
79
|
+
private run;
|
|
80
|
+
private runCollision;
|
|
81
|
+
private enter;
|
|
82
|
+
private playClip;
|
|
83
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Component } from '../component.js';
|
|
2
|
+
import { AnimatedSprite } from '../components/animated-sprite.js';
|
|
3
|
+
import { closestLogicSet, logicSet, registeredLogicSets, } from './hooks.js';
|
|
4
|
+
/** Whether one trigger fires. Unknown or malformed triggers never fire. */
|
|
5
|
+
export function evaluateTrigger(on, env) {
|
|
6
|
+
const sep = on.indexOf(':');
|
|
7
|
+
if (sep <= 0)
|
|
8
|
+
return false;
|
|
9
|
+
const kind = on.slice(0, sep);
|
|
10
|
+
const arg = on.slice(sep + 1);
|
|
11
|
+
if (kind === 'input')
|
|
12
|
+
return env.justPressed(arg);
|
|
13
|
+
if (kind === 'timer')
|
|
14
|
+
return env.elapsed >= Number(arg);
|
|
15
|
+
if (kind === 'signal')
|
|
16
|
+
return env.signals.has(arg);
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
/** The first firing transition: the state's own edges, then '*''s. */
|
|
20
|
+
export function nextTransition(states, current, env) {
|
|
21
|
+
const edges = [...(states[current]?.transitions ?? []), ...(states['*']?.transitions ?? [])];
|
|
22
|
+
return edges.find((t) => evaluateTrigger(t.on, env));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The hooks that actually run for one phase of a state: the state's own
|
|
26
|
+
* (logic set + instance), or — when none of them define the phase — the
|
|
27
|
+
* set's 'default' entry. A custom state keeps the role's stock body
|
|
28
|
+
* update unless it brings its own.
|
|
29
|
+
*/
|
|
30
|
+
export function phaseHooks(hooks, fallback, phase) {
|
|
31
|
+
if (hooks.some((h) => h[phase]))
|
|
32
|
+
return hooks;
|
|
33
|
+
return fallback?.[phase] ? [fallback] : hooks;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Finite state machine: the single owner of a character's per-frame
|
|
37
|
+
* logic — only the active state's code moves the body, so behaviors
|
|
38
|
+
* never fight over velocity. States and transitions are prefab data;
|
|
39
|
+
* state code lives in the role's logic set (defineRole / defineStates)
|
|
40
|
+
* picked by `role`. Entering a state plays the sibling AnimatedSprite
|
|
41
|
+
* clip of the same name (or the state's `clip` override); a missing
|
|
42
|
+
* clip warns and keeps the previous one.
|
|
43
|
+
*/
|
|
44
|
+
export class StateMachine extends Component {
|
|
45
|
+
static componentName = 'StateMachine';
|
|
46
|
+
static displayName = 'State Machine';
|
|
47
|
+
static params = {
|
|
48
|
+
role: { label: 'Role' },
|
|
49
|
+
};
|
|
50
|
+
/** The character's role — names the logic set providing its state code. */
|
|
51
|
+
role = '';
|
|
52
|
+
/** Starting state; defaults to the first declared state. */
|
|
53
|
+
initial = '';
|
|
54
|
+
states = {};
|
|
55
|
+
/** Active state name. */
|
|
56
|
+
current = '';
|
|
57
|
+
/** Seconds spent in the active state. */
|
|
58
|
+
elapsed = 0;
|
|
59
|
+
instanceHooks = new Map();
|
|
60
|
+
signals = new Set();
|
|
61
|
+
warnedClips = new Set();
|
|
62
|
+
onReady() {
|
|
63
|
+
if (this.role && !logicSet(this.role)) {
|
|
64
|
+
const sets = registeredLogicSets();
|
|
65
|
+
const hint = closestLogicSet(this.role);
|
|
66
|
+
console.error(`[waica] "${this.entity.name}": role "${this.role}" has no registered state code. ` +
|
|
67
|
+
`Known: ${sets.length ? sets.join(', ') : '(none)'}.` +
|
|
68
|
+
(hint ? ` Did you mean "${hint}"?` : ''));
|
|
69
|
+
}
|
|
70
|
+
const start = this.initial || Object.keys(this.states).find((name) => name !== '*');
|
|
71
|
+
if (start)
|
|
72
|
+
this.enter(start);
|
|
73
|
+
}
|
|
74
|
+
/** Adds instance-level hooks on top of the logic set — the escape hatch. */
|
|
75
|
+
on(state, hooks) {
|
|
76
|
+
const list = this.instanceHooks.get(state) ?? [];
|
|
77
|
+
list.push(hooks);
|
|
78
|
+
this.instanceHooks.set(state, list);
|
|
79
|
+
}
|
|
80
|
+
/** Queues a signal for 'signal:<name>' transitions, consumed this frame or the next. */
|
|
81
|
+
signal(name) {
|
|
82
|
+
this.signals.add(name);
|
|
83
|
+
}
|
|
84
|
+
/** Jumps straight to a state, ignoring transitions. */
|
|
85
|
+
goto(state) {
|
|
86
|
+
if (state !== this.current)
|
|
87
|
+
this.enter(state);
|
|
88
|
+
}
|
|
89
|
+
onUpdate(dt) {
|
|
90
|
+
if (!this.current)
|
|
91
|
+
return;
|
|
92
|
+
this.run('*', 'onUpdate', dt);
|
|
93
|
+
this.run(this.current, 'onUpdate', dt);
|
|
94
|
+
this.elapsed += dt;
|
|
95
|
+
// Chained transitions settle within the frame (e.g. land → idle → run),
|
|
96
|
+
// capped so a degenerate cyclic graph can't hang the loop.
|
|
97
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
98
|
+
const edge = nextTransition(this.states, this.current, this.env());
|
|
99
|
+
if (!edge)
|
|
100
|
+
break;
|
|
101
|
+
// A transition fired by a key press spends it: one press, one
|
|
102
|
+
// transition — it can't fire again from the state just entered.
|
|
103
|
+
if (edge.on.startsWith('input:'))
|
|
104
|
+
this.game.input.consume(edge.on.slice('input:'.length));
|
|
105
|
+
this.enter(edge.to);
|
|
106
|
+
}
|
|
107
|
+
this.signals.clear();
|
|
108
|
+
}
|
|
109
|
+
onCollide(other) {
|
|
110
|
+
// The state that was active when the contact happened owns it: a
|
|
111
|
+
// transition fired inside the always-hook must not hand the collision
|
|
112
|
+
// to the state it just entered.
|
|
113
|
+
const state = this.current;
|
|
114
|
+
if (!state)
|
|
115
|
+
return;
|
|
116
|
+
this.runCollision('*', other);
|
|
117
|
+
// Either side can be destroyed by a hook. Game.dispatchCollisions guards
|
|
118
|
+
// the same way between the two sides of one contact.
|
|
119
|
+
if (!this.entity.alive || !other.alive)
|
|
120
|
+
return;
|
|
121
|
+
this.runCollision(state, other);
|
|
122
|
+
}
|
|
123
|
+
env() {
|
|
124
|
+
return {
|
|
125
|
+
justPressed: (action) => this.game.input.justPressed(action) && !this.game.input.consumed(action),
|
|
126
|
+
elapsed: this.elapsed,
|
|
127
|
+
signals: this.signals,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
hooksFor(state) {
|
|
131
|
+
const fromSet = this.role ? logicSet(this.role)?.[state] : undefined;
|
|
132
|
+
return [...(fromSet ? [fromSet] : []), ...(this.instanceHooks.get(state) ?? [])];
|
|
133
|
+
}
|
|
134
|
+
run(state, phase, dt = 0) {
|
|
135
|
+
const ctx = { entity: this.entity, game: this.game, fsm: this };
|
|
136
|
+
const fallback = state !== '*' && this.role ? logicSet(this.role)?.['default'] : undefined;
|
|
137
|
+
for (const hooks of phaseHooks(this.hooksFor(state), fallback, phase)) {
|
|
138
|
+
if (phase === 'onUpdate')
|
|
139
|
+
hooks.onUpdate?.(ctx, dt);
|
|
140
|
+
else
|
|
141
|
+
hooks[phase]?.(ctx);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
runCollision(state, other) {
|
|
145
|
+
const ctx = { entity: this.entity, game: this.game, fsm: this };
|
|
146
|
+
const fallback = state !== '*' && this.role ? logicSet(this.role)?.['default'] : undefined;
|
|
147
|
+
for (const hooks of phaseHooks(this.hooksFor(state), fallback, 'onCollide')) {
|
|
148
|
+
hooks.onCollide?.(ctx, other);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
enter(state) {
|
|
152
|
+
if (this.current)
|
|
153
|
+
this.run(this.current, 'onExit');
|
|
154
|
+
this.current = state;
|
|
155
|
+
this.elapsed = 0;
|
|
156
|
+
this.playClip(state);
|
|
157
|
+
this.run(state, 'onEnter');
|
|
158
|
+
}
|
|
159
|
+
playClip(state) {
|
|
160
|
+
const sprite = this.entity.get(AnimatedSprite);
|
|
161
|
+
if (!sprite)
|
|
162
|
+
return;
|
|
163
|
+
const clip = this.states[state]?.clip ?? state;
|
|
164
|
+
if (sprite.clips[clip]) {
|
|
165
|
+
sprite.play(clip);
|
|
166
|
+
}
|
|
167
|
+
else if (!this.warnedClips.has(state)) {
|
|
168
|
+
this.warnedClips.add(state);
|
|
169
|
+
console.warn(`[waica] "${this.entity.name}": no clip "${clip}" for state "${state}" — ` +
|
|
170
|
+
`keeping "${sprite.current ?? 'none'}"`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A stat's value: points/lives are numbers, flags are booleans, labels strings. */
|
|
2
|
+
export type StatValue = number | boolean | string;
|
|
3
|
+
/**
|
|
4
|
+
* Named game state (points, lives, door-open flags…) declared in the
|
|
5
|
+
* project's src/stats.json. Every new Game starts from the declared initial
|
|
6
|
+
* values; behaviours read and change them and can subscribe to changes.
|
|
7
|
+
*/
|
|
8
|
+
export declare class Stats {
|
|
9
|
+
private readonly initial;
|
|
10
|
+
private values;
|
|
11
|
+
private readonly emitter;
|
|
12
|
+
constructor(initial?: Record<string, StatValue>);
|
|
13
|
+
/** Current value; undeclared stats read as undefined. */
|
|
14
|
+
get(name: string): StatValue | undefined;
|
|
15
|
+
/** Numeric read: non-number and undeclared stats count as 0. */
|
|
16
|
+
count(name: string): number;
|
|
17
|
+
/** Sets a stat (declared or not) and notifies subscribers on real changes. */
|
|
18
|
+
set(name: string, value: StatValue): void;
|
|
19
|
+
/** Adds delta (default 1) to a numeric stat; undeclared stats start at 0. */
|
|
20
|
+
add(name: string, delta?: number): number;
|
|
21
|
+
/** Back to the declared initial values, notifying every stat that changed. */
|
|
22
|
+
reset(): void;
|
|
23
|
+
/** Fires when the named stat changes. Returns the unsubscribe. */
|
|
24
|
+
onChange(name: string, handler: (value: StatValue | undefined) => void): () => void;
|
|
25
|
+
/** Every stat with its current value (declared and runtime-created). */
|
|
26
|
+
entries(): Array<[string, StatValue]>;
|
|
27
|
+
}
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Emitter } from './events.js';
|
|
2
|
+
/**
|
|
3
|
+
* Named game state (points, lives, door-open flags…) declared in the
|
|
4
|
+
* project's src/stats.json. Every new Game starts from the declared initial
|
|
5
|
+
* values; behaviours read and change them and can subscribe to changes.
|
|
6
|
+
*/
|
|
7
|
+
export class Stats {
|
|
8
|
+
initial;
|
|
9
|
+
values;
|
|
10
|
+
emitter = new Emitter();
|
|
11
|
+
constructor(initial = {}) {
|
|
12
|
+
this.initial = { ...initial };
|
|
13
|
+
this.values = { ...initial };
|
|
14
|
+
}
|
|
15
|
+
/** Current value; undeclared stats read as undefined. */
|
|
16
|
+
get(name) {
|
|
17
|
+
return this.values[name];
|
|
18
|
+
}
|
|
19
|
+
/** Numeric read: non-number and undeclared stats count as 0. */
|
|
20
|
+
count(name) {
|
|
21
|
+
const value = this.values[name];
|
|
22
|
+
return typeof value === 'number' ? value : 0;
|
|
23
|
+
}
|
|
24
|
+
/** Sets a stat (declared or not) and notifies subscribers on real changes. */
|
|
25
|
+
set(name, value) {
|
|
26
|
+
if (this.values[name] === value)
|
|
27
|
+
return;
|
|
28
|
+
this.values[name] = value;
|
|
29
|
+
this.emitter.emit(name, value);
|
|
30
|
+
}
|
|
31
|
+
/** Adds delta (default 1) to a numeric stat; undeclared stats start at 0. */
|
|
32
|
+
add(name, delta = 1) {
|
|
33
|
+
const next = this.count(name) + delta;
|
|
34
|
+
this.set(name, next);
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
/** Back to the declared initial values, notifying every stat that changed. */
|
|
38
|
+
reset() {
|
|
39
|
+
const previous = this.values;
|
|
40
|
+
this.values = { ...this.initial };
|
|
41
|
+
for (const name of new Set([...Object.keys(previous), ...Object.keys(this.values)])) {
|
|
42
|
+
if (previous[name] !== this.values[name])
|
|
43
|
+
this.emitter.emit(name, this.values[name]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Fires when the named stat changes. Returns the unsubscribe. */
|
|
47
|
+
onChange(name, handler) {
|
|
48
|
+
return this.emitter.on(name, handler);
|
|
49
|
+
}
|
|
50
|
+
/** Every stat with its current value (declared and runtime-created). */
|
|
51
|
+
entries() {
|
|
52
|
+
return Object.entries(this.values);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Stats } from './stats.js';
|
|
2
|
+
/**
|
|
3
|
+
* The HTML UI layer. Each piece is a self-contained HTML fragment
|
|
4
|
+
* (markup + <style>) that only DRAWS: it declares which stats it shows
|
|
5
|
+
* with {{stat}} placeholders and positions itself with its own CSS.
|
|
6
|
+
* Behaviour always comes from outside — code toggles pieces with
|
|
7
|
+
* show/hide and wires interactivity through element().
|
|
8
|
+
*
|
|
9
|
+
* Pieces mount inside a transparent overlay that covers the game canvas
|
|
10
|
+
* (each in its own shadow root, so styles never leak between pieces or
|
|
11
|
+
* into the hosting page). The whole overlay hides while the game is not
|
|
12
|
+
* simulating (pause / editor edit mode).
|
|
13
|
+
*/
|
|
14
|
+
export declare class GameUi {
|
|
15
|
+
private readonly stats;
|
|
16
|
+
/** Resolved lazily: the canvas may not be in the DOM at construction. */
|
|
17
|
+
private readonly host;
|
|
18
|
+
private readonly sources;
|
|
19
|
+
private readonly pieces;
|
|
20
|
+
private overlay?;
|
|
21
|
+
private active;
|
|
22
|
+
constructor(stats: Stats,
|
|
23
|
+
/** Resolved lazily: the canvas may not be in the DOM at construction. */
|
|
24
|
+
host: () => HTMLElement);
|
|
25
|
+
/** Registers a piece's HTML source. Re-defining an unmounted name wins. */
|
|
26
|
+
define(name: string, html: string): void;
|
|
27
|
+
defineAll(pieces: Record<string, string>): void;
|
|
28
|
+
/** Piece names available to show (defined via the registry or define()). */
|
|
29
|
+
names(): string[];
|
|
30
|
+
show(name: string): void;
|
|
31
|
+
hide(name: string): void;
|
|
32
|
+
toggle(name: string): void;
|
|
33
|
+
isVisible(name: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* The piece's DOM root — the escape hatch that keeps pieces logic-free:
|
|
36
|
+
* behaviour is wired from code (element(...).querySelector + listeners).
|
|
37
|
+
* Mounts the piece hidden if it wasn't mounted yet.
|
|
38
|
+
*/
|
|
39
|
+
element(name: string): HTMLElement | null;
|
|
40
|
+
/** Called by the game loop: the overlay only draws while simulating. */
|
|
41
|
+
setActive(active: boolean): void;
|
|
42
|
+
/** Unmounts every piece and removes the overlay (Game.dispose). */
|
|
43
|
+
dispose(): void;
|
|
44
|
+
private mount;
|
|
45
|
+
private mountOverlay;
|
|
46
|
+
private sync;
|
|
47
|
+
}
|