@forgeax/engine-state 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +87 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli-state.d.ts +10 -0
- package/dist/cli-state.d.ts.map +1 -0
- package/dist/cli-state.mjs +275 -0
- package/dist/cli-state.mjs.map +1 -0
- package/dist/conditions.d.ts +31 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/define-state.d.ts +71 -0
- package/dist/define-state.d.ts.map +1 -0
- package/dist/errors.d.ts +70 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +406 -0
- package/dist/index.mjs.map +1 -0
- package/dist/on-enter-on-exit.d.ts +65 -0
- package/dist/on-enter-on-exit.d.ts.map +1 -0
- package/dist/plugin-factory.d.ts +9 -0
- package/dist/plugin-factory.d.ts.map +1 -0
- package/dist/register-plugin.d.ts +27 -0
- package/dist/register-plugin.d.ts.map +1 -0
- package/dist/resources.d.ts +23 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/scoped-component.d.ts +38 -0
- package/dist/scoped-component.d.ts.map +1 -0
- package/dist/set-next-state.d.ts +48 -0
- package/dist/set-next-state.d.ts.map +1 -0
- package/dist/transition-system.d.ts +3 -0
- package/dist/transition-system.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/cli-state.ts +232 -0
- package/src/conditions.ts +60 -0
- package/src/define-state.ts +158 -0
- package/src/errors.ts +147 -0
- package/src/index.ts +42 -0
- package/src/on-enter-on-exit.ts +156 -0
- package/src/plugin-factory.ts +19 -0
- package/src/register-plugin.ts +114 -0
- package/src/resources.ts +45 -0
- package/src/scoped-component.ts +159 -0
- package/src/set-next-state.ts +105 -0
- package/src/transition-system.ts +153 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @forgeax/engine-state -- setNextState / getState / getPreviousState (M2 / m2w4)
|
|
2
|
+
//
|
|
3
|
+
// Free functions that read and write per-token Resource slots. All return
|
|
4
|
+
// Result<T, StateError> (never throw for AI-user call sites per AGENTS.md
|
|
5
|
+
// Error model).
|
|
6
|
+
//
|
|
7
|
+
// Decision anchors:
|
|
8
|
+
// - requirements C-3: setNextState returns Result.err, not throw
|
|
9
|
+
// - requirements C-4: free functions, not world.x methods
|
|
10
|
+
// - requirements F-5/F-6: State / NextState / PreviousState as Resources
|
|
11
|
+
// - plan-strategy D-4: State stores variant index (u32), decoded via token.variants
|
|
12
|
+
|
|
13
|
+
import type { World } from '@forgeax/engine-ecs';
|
|
14
|
+
import type { StateToken, StateTokenVariant } from './define-state';
|
|
15
|
+
import type { StateError } from './errors';
|
|
16
|
+
import { invalidVariant, stateNotRegistered } from './errors';
|
|
17
|
+
import { nextStateResourceKey, previousStateResourceKey, stateResourceKey } from './resources';
|
|
18
|
+
|
|
19
|
+
interface NextStatePayload {
|
|
20
|
+
value: number;
|
|
21
|
+
force: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function errWrap(err: StateError): { ok: false; error: StateError } {
|
|
25
|
+
return { ok: false, error: err };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Request a state transition for `token` to `variant` at the next frame.
|
|
30
|
+
*
|
|
31
|
+
* `variant` is narrowed to the token's variant union: a misspelled variant is
|
|
32
|
+
* a compile-time error (`StateTokenVariant<T>`), not just a runtime
|
|
33
|
+
* `invalid-variant` Result.
|
|
34
|
+
*/
|
|
35
|
+
export function setNextState<T extends StateToken>(
|
|
36
|
+
world: World,
|
|
37
|
+
token: T,
|
|
38
|
+
variant: StateTokenVariant<T>,
|
|
39
|
+
): { ok: true; value: undefined } | { ok: false; error: StateError } {
|
|
40
|
+
return _runCheckAndWrite(world, token, variant, false);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Like {@link setNextState} but with `force=true`.
|
|
45
|
+
*/
|
|
46
|
+
export function setNextStateForce<T extends StateToken>(
|
|
47
|
+
world: World,
|
|
48
|
+
token: T,
|
|
49
|
+
variant: StateTokenVariant<T>,
|
|
50
|
+
): { ok: true; value: undefined } | { ok: false; error: StateError } {
|
|
51
|
+
return _runCheckAndWrite(world, token, variant, true);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _runCheckAndWrite(world: World, token: StateToken, variant: string, force: boolean) {
|
|
55
|
+
const nsKey = nextStateResourceKey(token);
|
|
56
|
+
if (!world.hasResource(nsKey)) {
|
|
57
|
+
return errWrap(stateNotRegistered(token.name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const idx = token.nameToIdx.get(variant as never);
|
|
61
|
+
if (idx === undefined) {
|
|
62
|
+
return errWrap(invalidVariant(token.name, variant, token.variants));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
world.insertResource<NextStatePayload>(nsKey, { value: idx, force });
|
|
66
|
+
return { ok: true as const, value: undefined };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the current state value for `token`.
|
|
71
|
+
*/
|
|
72
|
+
export function getState(
|
|
73
|
+
world: World,
|
|
74
|
+
token: StateToken,
|
|
75
|
+
): { ok: true; value: string } | { ok: false; error: StateError } {
|
|
76
|
+
const key = stateResourceKey(token);
|
|
77
|
+
if (!world.hasResource(key)) {
|
|
78
|
+
return errWrap(stateNotRegistered(token.name));
|
|
79
|
+
}
|
|
80
|
+
const idx = world.getResource<number>(key);
|
|
81
|
+
const variant = token.variants[idx];
|
|
82
|
+
if (variant === undefined) {
|
|
83
|
+
return errWrap(invalidVariant(token.name, String(idx), token.variants));
|
|
84
|
+
}
|
|
85
|
+
return { ok: true as const, value: variant };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the previous-frame state value for `token`.
|
|
90
|
+
*/
|
|
91
|
+
export function getPreviousState(
|
|
92
|
+
world: World,
|
|
93
|
+
token: StateToken,
|
|
94
|
+
): { ok: true; value: string } | { ok: false; error: StateError } {
|
|
95
|
+
const key = previousStateResourceKey(token);
|
|
96
|
+
if (!world.hasResource(key)) {
|
|
97
|
+
return errWrap(stateNotRegistered(token.name));
|
|
98
|
+
}
|
|
99
|
+
const idx = world.getResource<number>(key);
|
|
100
|
+
const variant = token.variants[idx];
|
|
101
|
+
if (variant === undefined) {
|
|
102
|
+
return errWrap(invalidVariant(token.name, String(idx), token.variants));
|
|
103
|
+
}
|
|
104
|
+
return { ok: true as const, value: variant };
|
|
105
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @forgeax/engine-state -- transitionStatesSystem (M3 / m3w4)
|
|
2
|
+
//
|
|
3
|
+
// 8-step per-token transition logic executed every frame by the
|
|
4
|
+
// 'transitionStates' system registered in registerStatesPlugin.
|
|
5
|
+
//
|
|
6
|
+
// Per token:
|
|
7
|
+
// 1. Read NextState Resource; if undefined -> continue (zero-cost skip)
|
|
8
|
+
// 2. Read State Resource; if prev===next && !force -> clear NextState, continue (same-state no-op)
|
|
9
|
+
// 3. Write PreviousState = prev, flip State = next
|
|
10
|
+
// 4. Collect exit-scoped entities -> world.despawn each
|
|
11
|
+
// 5. OnExit placeholder (M4)
|
|
12
|
+
// 6. Collect enter-scoped entities -> world.despawn each
|
|
13
|
+
// 7. OnEnter placeholder (M4)
|
|
14
|
+
// 8. Clear NextState = undefined
|
|
15
|
+
//
|
|
16
|
+
// Decision anchors:
|
|
17
|
+
// - plan-strategy sec 3.2: 8-step flowchart + OnEnter/OnExit dispatch between flip and despawn
|
|
18
|
+
// - plan-strategy D-2: unified world.despawn via linkedSpawn cascade
|
|
19
|
+
// - plan-strategy D-5: fn[] registry + transition body dispatch, zero ECS change
|
|
20
|
+
// - research F-6: row iteration + world.despawn is sufficient
|
|
21
|
+
// - requirements sec 7: despawn tolerance (entity already dead = no error)
|
|
22
|
+
|
|
23
|
+
import type { Component, EntityHandle, World } from '@forgeax/engine-ecs';
|
|
24
|
+
import { worldDespawnScene } from '@forgeax/engine-scene';
|
|
25
|
+
import { getRegisteredTokens } from './define-state';
|
|
26
|
+
import { getCallbacks, OnEnter, OnExit } from './on-enter-on-exit';
|
|
27
|
+
import { nextStateResourceKey, previousStateResourceKey, stateResourceKey } from './resources';
|
|
28
|
+
import { SCOPED_MODE_VALUE } from './scoped-component';
|
|
29
|
+
|
|
30
|
+
interface NextStatePayload {
|
|
31
|
+
value: number;
|
|
32
|
+
force: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Collect entities whose ScopedTo component matches a given mode and value,
|
|
37
|
+
* then despawn all of them. Single despawn fault (already-dead entity) does
|
|
38
|
+
* not abort the batch — despawn tolerance per requirements sec 7.
|
|
39
|
+
*
|
|
40
|
+
* A scoped entity that is a SceneInstance root is torn down with `despawnScene`
|
|
41
|
+
* (cascade over its instantiated members), NOT plain `world.despawn`. Plain
|
|
42
|
+
* despawn does not cascade through `ChildOf` (which ships `linkedSpawn=false`),
|
|
43
|
+
* so a scoped scene root would orphan every member entity it instantiated. On a
|
|
44
|
+
* state replay (e.g. Title->Play->Title->Play) those orphans linger, their index
|
|
45
|
+
* slots are reused at a new generation, and a surviving member's stale
|
|
46
|
+
* `ChildOf -> (oldRoot, oldGen)` makes `propagateTransforms` throw
|
|
47
|
+
* `hierarchy-broken` every frame. Cascading via `despawnScene` removes the whole
|
|
48
|
+
* instantiated subtree so nothing is left pointing at the dead root.
|
|
49
|
+
*/
|
|
50
|
+
function scopeDespawn(world: World, scopedComponent: Component, mode: number, value: number): void {
|
|
51
|
+
const query = world.query({ read: [scopedComponent] }).unwrap();
|
|
52
|
+
const despawns: EntityHandle[] = [];
|
|
53
|
+
for (const row of query) {
|
|
54
|
+
const scoped = row.get(scopedComponent);
|
|
55
|
+
if (scoped.mode === mode && scoped.value === value) despawns.push(row.entity);
|
|
56
|
+
}
|
|
57
|
+
// SceneInstance is resolved by name through the global registry so the state
|
|
58
|
+
// package stays free of a runtime dependency (layering: state -> ecs only).
|
|
59
|
+
const sceneInstance = world.components.resolve('SceneInstance');
|
|
60
|
+
// Tear down SceneInstance roots FIRST, via despawnScene (cascade over their
|
|
61
|
+
// instantiated members). This must precede the plain despawns: a scoped scene
|
|
62
|
+
// root is often ChildOf a scoped non-scene entity (e.g. a character rig parented
|
|
63
|
+
// under a KCC body), and despawning that parent first invalidates the root
|
|
64
|
+
// handle before we can cascade it -- leaving the scene's members orphaned with a
|
|
65
|
+
// stale ChildOf -> dead-root ref. Doing the cascades up front guarantees each
|
|
66
|
+
// SceneInstance subtree is fully removed while its root is still live.
|
|
67
|
+
if (sceneInstance !== undefined) {
|
|
68
|
+
for (const e of despawns) {
|
|
69
|
+
if (world.get(e, sceneInstance).ok) worldDespawnScene(world, e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const e of despawns) {
|
|
73
|
+
// Scene roots already torn down above are now dead -> world.despawn is a
|
|
74
|
+
// tolerated no-op (requirements sec 7); every other scoped entity despawns here.
|
|
75
|
+
world.despawn(e);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function transitionStatesSystem(world: World): void {
|
|
80
|
+
for (const token of getRegisteredTokens().values()) {
|
|
81
|
+
const nsKey = nextStateResourceKey(token);
|
|
82
|
+
|
|
83
|
+
// (0) Skip tokens with no Resources yet. getRegisteredTokens() returns every
|
|
84
|
+
// token ever defined, but registerStatesPlugin only inserts Resources for
|
|
85
|
+
// tokens known at plugin time. A token defined after the plugin ran has no
|
|
86
|
+
// NextState Resource; world.getResource would throw ResourceNotFoundError.
|
|
87
|
+
// hasResource guard mirrors setNextState / getState in this package.
|
|
88
|
+
if (!world.hasResource(nsKey)) continue;
|
|
89
|
+
const ns = world.getResource<NextStatePayload | undefined>(nsKey);
|
|
90
|
+
|
|
91
|
+
// (1) No pending transition — zero-cost continue
|
|
92
|
+
if (ns === undefined) continue;
|
|
93
|
+
|
|
94
|
+
const sKey = stateResourceKey(token);
|
|
95
|
+
const prevIdx = world.getResource<number>(sKey);
|
|
96
|
+
const nextIdx = ns.value;
|
|
97
|
+
const force = ns.force;
|
|
98
|
+
|
|
99
|
+
// (2) Same-state no-op (unless force flag overrides)
|
|
100
|
+
if (prevIdx === nextIdx && !force) {
|
|
101
|
+
world.insertResource<NextStatePayload | undefined>(nsKey, undefined);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// (3) Write PreviousState = prev, flip State = next
|
|
106
|
+
const psKey = previousStateResourceKey(token);
|
|
107
|
+
world.insertResource(psKey, prevIdx);
|
|
108
|
+
world.insertResource(sKey, nextIdx);
|
|
109
|
+
|
|
110
|
+
// Resolve the per-token ScopedTo component from the global ECS registry
|
|
111
|
+
const scopedComponent = world.components.resolve(`__scopedTo__${token.name}`);
|
|
112
|
+
if (scopedComponent) {
|
|
113
|
+
// (4) Despawn exit-scoped entities (value=prev)
|
|
114
|
+
scopeDespawn(world, scopedComponent, SCOPED_MODE_VALUE.exit, prevIdx);
|
|
115
|
+
|
|
116
|
+
// (5) OnExit dispatch: fire registered callbacks for prev variant.
|
|
117
|
+
// Errors bubble to the transitionStatesSystem call stack per req §7.
|
|
118
|
+
const prevVariant = token.variants[prevIdx];
|
|
119
|
+
if (prevVariant !== undefined) {
|
|
120
|
+
const exitLabel = OnExit(token, prevVariant);
|
|
121
|
+
for (const fn of getCallbacks(exitLabel)) {
|
|
122
|
+
fn(world);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// (6) Despawn enter-scoped entities (value=next)
|
|
127
|
+
scopeDespawn(world, scopedComponent, SCOPED_MODE_VALUE.enter, nextIdx);
|
|
128
|
+
|
|
129
|
+
// (7) OnEnter dispatch: fire registered callbacks for next variant.
|
|
130
|
+
// Errors bubble to the transitionStatesSystem call stack per req §7.
|
|
131
|
+
const nextVariant = token.variants[nextIdx];
|
|
132
|
+
if (nextVariant !== undefined) {
|
|
133
|
+
const enterLabel = OnEnter(token, nextVariant);
|
|
134
|
+
for (const fn of getCallbacks(enterLabel)) {
|
|
135
|
+
fn(world);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// (8) Clear NextState — but only if OnEnter callbacks did not already
|
|
141
|
+
// write a new NextState payload (e.g. nested setNextState). If the
|
|
142
|
+
// payload differs from the original `ns`, leave it for the next frame.
|
|
143
|
+
const nsAfterCallbacks = world.getResource<NextStatePayload | undefined>(nsKey);
|
|
144
|
+
if (
|
|
145
|
+
nsAfterCallbacks !== undefined &&
|
|
146
|
+
nsAfterCallbacks.value === ns.value &&
|
|
147
|
+
nsAfterCallbacks.force === ns.force
|
|
148
|
+
) {
|
|
149
|
+
world.insertResource<NextStatePayload | undefined>(nsKey, undefined);
|
|
150
|
+
}
|
|
151
|
+
// else: callbacks wrote a new NextState — survive for next frame
|
|
152
|
+
}
|
|
153
|
+
}
|