@axi-engine/states 0.1.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/README.md +228 -0
- package/dist/index.d.mts +117 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +144 -0
- package/dist/index.mjs +117 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# StateMachine
|
|
2
|
+
|
|
3
|
+
A lightweight, type-safe, and easy-to-use state machine
|
|
4
|
+
designed for managing game logic and component states
|
|
5
|
+
within the Axi Engine.
|
|
6
|
+
|
|
7
|
+
This implementation prioritizes simplicity,
|
|
8
|
+
a clean API, and robust error handling for common use cases.
|
|
9
|
+
It is perfect for managing character states,
|
|
10
|
+
UI flows, game session lifecycles, and more.
|
|
11
|
+
It has minimal dependencies, relying only on the `Emitter`
|
|
12
|
+
utility from `@axi-engine/utils` for its event system.
|
|
13
|
+
|
|
14
|
+
For highly complex scenarios involving hierarchical (nested) states,
|
|
15
|
+
parallel states, or state history,
|
|
16
|
+
I recommend considering more powerful,
|
|
17
|
+
dedicated libraries like [XState](https://xstate.js.org/).
|
|
18
|
+
|
|
19
|
+
My goal is to provide a solid, built-in tool that covers 80% of typical game development needs
|
|
20
|
+
without adding external dependencies or unnecessary complexity.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **Simple and Clean API:** Registering states and transitioning between them is straightforward.
|
|
25
|
+
- **Type-Safe:** Leverages TypeScript generics to provide type safety for state names and event payloads.
|
|
26
|
+
- **Lifecycle Hooks:** States can have `onEnter` and `onExit` handlers for setup and cleanup logic.
|
|
27
|
+
- **Transition Guards:** You can define which states are allowed to transition to a new state, preventing logical errors.
|
|
28
|
+
- **Observable State:** A public `onChange` emitter allows you to subscribe to all state transitions for debugging, logging, or reacting to changes.
|
|
29
|
+
- **Minimal Dependencies:** Relies only on a tiny, self-contained `Emitter` utility.
|
|
30
|
+
|
|
31
|
+
## Getting Started
|
|
32
|
+
|
|
33
|
+
First, define the possible states, typically using an `enum` for type safety.
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
enum GameState {
|
|
37
|
+
MainMenu,
|
|
38
|
+
Loading,
|
|
39
|
+
Playing,
|
|
40
|
+
Paused,
|
|
41
|
+
GameOver,
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then, create an instance of the `StateMachine`. The machine starts in an `undefined` state.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { StateMachine } from './@axi-engine/states';
|
|
49
|
+
|
|
50
|
+
const gameState = new StateMachine<GameState>();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API and Usage
|
|
54
|
+
|
|
55
|
+
### 1. Registering States
|
|
56
|
+
|
|
57
|
+
You can register a state with a simple handler function. This function will be treated as the `onEnter` hook.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// Simple registration
|
|
61
|
+
gameState.register(GameState.MainMenu, () => {
|
|
62
|
+
console.log('Welcome to the Main Menu!');
|
|
63
|
+
showMainMenu();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
gameState.register(GameState.GameOver, () => {
|
|
67
|
+
console.log('Game Over!');
|
|
68
|
+
showGameOverScreen();
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 2. Changing States
|
|
73
|
+
|
|
74
|
+
Use the `call` method to transition to a new state. The method is asynchronous to handle any `async` logic within state handlers. The first call will formally start the machine.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// Start the machine by calling the initial state
|
|
78
|
+
await gameState.call(GameState.MainMenu);
|
|
79
|
+
|
|
80
|
+
// ... later in the game
|
|
81
|
+
await gameState.call(GameState.GameOver);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Using Payloads
|
|
85
|
+
|
|
86
|
+
You can pass data during a state transition. The payload type can be defined in the `StateMachine` generic.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// State machine that accepts a string payload for the 'Loading' state
|
|
90
|
+
const sm = new StateMachine<GameState, string>();
|
|
91
|
+
|
|
92
|
+
sm.register(GameState.Loading, async (levelId: string) => {
|
|
93
|
+
console.log(`Loading level: ${levelId}...`);
|
|
94
|
+
await loadLevelAssets(levelId);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await sm.call(GameState.Loading, 'level-2');
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 4. Advanced Registration
|
|
101
|
+
|
|
102
|
+
For more control, you can register a state with a configuration object. This allows you to define `onEnter`, `onExit` hooks, and transition guards.
|
|
103
|
+
|
|
104
|
+
#### `onEnter` and `onExit`
|
|
105
|
+
|
|
106
|
+
- `onEnter`: Called when the machine enters the state.
|
|
107
|
+
- `onExit`: Called when the machine leaves the state. This is perfect for cleanup.
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
gameState.register(GameState.Playing, {
|
|
111
|
+
onEnter: () => {
|
|
112
|
+
console.log('Starting game...');
|
|
113
|
+
gameMusic.play();
|
|
114
|
+
player.enableControls();
|
|
115
|
+
},
|
|
116
|
+
onExit: () => {
|
|
117
|
+
console.log('Exiting gameplay...');
|
|
118
|
+
gameMusic.stop();
|
|
119
|
+
player.disableControls();
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### `allowedFrom` (Transition Guards)
|
|
125
|
+
|
|
126
|
+
Specify an array of states from which a transition to this state is permitted. An attempt to transition from any other state will throw an error.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
gameState.register(GameState.Paused, {
|
|
130
|
+
// You can only pause the game if you are currently playing.
|
|
131
|
+
allowedFrom: [GameState.Playing],
|
|
132
|
+
onEnter: () => {
|
|
133
|
+
console.log('Game paused.');
|
|
134
|
+
showPauseMenu();
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// This will work:
|
|
139
|
+
await gameState.call(GameState.Playing);
|
|
140
|
+
await gameState.call(GameState.Paused);
|
|
141
|
+
|
|
142
|
+
// This will throw an error:
|
|
143
|
+
await gameState.call(GameState.MainMenu);
|
|
144
|
+
await gameState.call(GameState.Paused); // Error: Transition from MainMenu to Paused is not allowed.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 5. Subscribing to Changes
|
|
148
|
+
|
|
149
|
+
The public `onChange` property is an `Emitter`. You can use its `subscribe` method to be notified of any state change. The method returns a function to unsubscribe.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
const unsubscribe = gameState.onChange.subscribe((from, to, payload) => {
|
|
153
|
+
const fromState = from !== undefined ? GameState[from] : 'Start';
|
|
154
|
+
console.log(`State changed from ${fromState} to ${GameState[to]}`, { payload });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await gameState.call(GameState.MainMenu);
|
|
158
|
+
// Console output: State changed from Start to MainMenu
|
|
159
|
+
|
|
160
|
+
// To stop listening later:
|
|
161
|
+
unsubscribe();
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Full Example
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import { StateMachine } from '@axi-engine/states';
|
|
168
|
+
|
|
169
|
+
enum GameState {
|
|
170
|
+
MainMenu,
|
|
171
|
+
Playing,
|
|
172
|
+
Paused,
|
|
173
|
+
GameOver,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Setup ---
|
|
177
|
+
const game = new StateMachine<GameState>();
|
|
178
|
+
|
|
179
|
+
game.onChange.subscribe((from, to) => {
|
|
180
|
+
const fromState = from !== undefined ? GameState[from] : 'Start';
|
|
181
|
+
console.log(`[SYSTEM] Transition: ${fromState} -> ${GameState[to]}`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
game.register(GameState.MainMenu, {
|
|
185
|
+
onEnter: () => console.log('Showing Main Menu.'),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
game.register(GameState.Playing, {
|
|
189
|
+
allowedFrom: [GameState.MainMenu, GameState.Paused],
|
|
190
|
+
onEnter: () => console.log('Game has started! Player controls enabled.'),
|
|
191
|
+
onExit: () => console.log('Player controls disabled.'),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
game.register(GameState.Paused, {
|
|
195
|
+
allowedFrom: [GameState.Playing],
|
|
196
|
+
onEnter: () => console.log('Game is paused.'),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
game.register(GameState.GameOver, {
|
|
200
|
+
allowedFrom: [GameState.Playing],
|
|
201
|
+
onEnter: () => console.log('You lose!'),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// --- Simulation ---
|
|
205
|
+
async function runGame() {
|
|
206
|
+
await game.call(GameState.MainMenu);
|
|
207
|
+
// [SYSTEM] Transition: Start -> MainMenu
|
|
208
|
+
// Showing Main Menu.
|
|
209
|
+
|
|
210
|
+
await game.call(GameState.Playing);
|
|
211
|
+
// [SYSTEM] Transition: MainMenu -> Playing
|
|
212
|
+
// Game has started! Player controls enabled.
|
|
213
|
+
|
|
214
|
+
await game.call(GameState.Paused);
|
|
215
|
+
// [SYSTEM] Transition: Playing -> Paused
|
|
216
|
+
// Player controls disabled.
|
|
217
|
+
// Game is paused.
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// This transition will fail because of the 'allowedFrom' guard
|
|
221
|
+
await game.call(GameState.MainMenu);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
console.error(e.message); // Error: Transition from Paused to MainMenu is not allowed.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
runGame();
|
|
228
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Emitter } from '@axi-engine/utils';
|
|
2
|
+
|
|
3
|
+
type StateHandler<P = void> = P extends void ? () => void | Promise<void> : (payload: P) => void | Promise<void>;
|
|
4
|
+
interface StateHandlerConfig<T, P = void> {
|
|
5
|
+
onEnter?: StateHandler<P>;
|
|
6
|
+
onExit?: () => void | Promise<void>;
|
|
7
|
+
allowedFrom?: T[];
|
|
8
|
+
}
|
|
9
|
+
type StateHandlerRegistration<T, P = void> = StateHandler<P> | StateHandlerConfig<T, P>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A minimal, type-safe finite state machine.
|
|
13
|
+
* It manages states, transitions, and associated lifecycle hooks (`onEnter`, `onExit`).
|
|
14
|
+
*
|
|
15
|
+
* @template T The type used for state identifiers (e.g., a string or an enum).
|
|
16
|
+
* @template P The default payload type for state handlers. Can be overridden per state.
|
|
17
|
+
* @example
|
|
18
|
+
* enum PlayerState { Idle, Walk, Run }
|
|
19
|
+
*
|
|
20
|
+
* const playerFsm = new StateMachine<PlayerState>();
|
|
21
|
+
*
|
|
22
|
+
* playerFsm.register(PlayerState.Idle, () => console.log('Player is now idle.'));
|
|
23
|
+
* playerFsm.register(PlayerState.Walk, () => console.log('Player is walking.'));
|
|
24
|
+
*
|
|
25
|
+
* async function start() {
|
|
26
|
+
* await playerFsm.call(PlayerState.Idle);
|
|
27
|
+
* await playerFsm.call(PlayerState.Walk);
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
declare class StateMachine<T, P = void> {
|
|
31
|
+
/**
|
|
32
|
+
* @protected
|
|
33
|
+
* The internal representation of the current state.
|
|
34
|
+
*/
|
|
35
|
+
protected _state?: T;
|
|
36
|
+
/**
|
|
37
|
+
* @protected
|
|
38
|
+
* A map storing all registered state configurations.
|
|
39
|
+
*/
|
|
40
|
+
protected states: Map<T, StateHandlerConfig<T, P> | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Public emitter that fires an event whenever the state changes.
|
|
43
|
+
* The event provides the old state, the new state, and the payload.
|
|
44
|
+
* @see Emitter
|
|
45
|
+
* @example
|
|
46
|
+
* fsm.onChange.subscribe((from, to, payload) => {
|
|
47
|
+
* console.log(`State transitioned from ${from} to ${to}`);
|
|
48
|
+
* });
|
|
49
|
+
*/
|
|
50
|
+
readonly onChange: Emitter<[from?: T | undefined, to?: T | undefined, payload?: P | undefined]>;
|
|
51
|
+
/**
|
|
52
|
+
* Gets the current state of the machine.
|
|
53
|
+
* @returns The current state identifier, or `undefined` if the machine has not been started.
|
|
54
|
+
*/
|
|
55
|
+
get state(): T | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Registers a state and its associated handler or configuration.
|
|
58
|
+
* If a handler is already registered for the given state, it will be overwritten.
|
|
59
|
+
*
|
|
60
|
+
* @param state The identifier for the state to register.
|
|
61
|
+
* @param handler A handler function (`onEnter`) or a full configuration object.
|
|
62
|
+
* @example
|
|
63
|
+
* // Simple registration
|
|
64
|
+
* fsm.register(MyState.Idle, () => console.log('Entering Idle'));
|
|
65
|
+
*
|
|
66
|
+
* // Advanced registration
|
|
67
|
+
* fsm.register(MyState.Walking, {
|
|
68
|
+
* onEnter: () => console.log('Start walking animation'),
|
|
69
|
+
* onExit: () => console.log('Stop walking animation'),
|
|
70
|
+
* allowedFrom: [MyState.Idle]
|
|
71
|
+
* });
|
|
72
|
+
*/
|
|
73
|
+
register(state: T, handler?: StateHandlerRegistration<T, P>): void;
|
|
74
|
+
/**
|
|
75
|
+
* Transitions the machine to a new state.
|
|
76
|
+
* This method is asynchronous to accommodate async `onEnter` and `onExit` handlers.
|
|
77
|
+
* It will execute the `onExit` handler of the old state, then the `onEnter` handler of the new state.
|
|
78
|
+
*
|
|
79
|
+
* @param newState The identifier of the state to transition to.
|
|
80
|
+
* @param payload An optional payload to pass to the new state's `onEnter` handler.
|
|
81
|
+
* @returns A promise that resolves when the transition is complete.
|
|
82
|
+
* @throws {Error} if the `newState` has not been registered.
|
|
83
|
+
* @throws {Error} if the transition from the current state to the `newState` is not allowed by the `allowedFrom` rule.
|
|
84
|
+
* @example
|
|
85
|
+
* try {
|
|
86
|
+
* await fsm.call(PlayerState.Run, { speed: 10 });
|
|
87
|
+
* } catch (e) {
|
|
88
|
+
* console.error('State transition failed:', e.message);
|
|
89
|
+
* }
|
|
90
|
+
*/
|
|
91
|
+
call(newState: T, payload?: P): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Removes a single state configuration from the machine.
|
|
94
|
+
* If the removed state is the currently active one, the machine's state will be reset to `undefined`.
|
|
95
|
+
*
|
|
96
|
+
* @param state The identifier of the state to remove.
|
|
97
|
+
* @returns `true` if the state was found and removed, otherwise `false`.
|
|
98
|
+
* @example
|
|
99
|
+
* fsm.register(MyState.Temp, () => {});
|
|
100
|
+
* // ...
|
|
101
|
+
* const wasRemoved = fsm.unregister(MyState.Temp);
|
|
102
|
+
* console.log('Temporary state removed:', wasRemoved);
|
|
103
|
+
*/
|
|
104
|
+
unregister(state: T): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Removes all registered states and resets the machine to its initial, undefined state.
|
|
107
|
+
* This does not clear `onChange` subscribers.
|
|
108
|
+
* @example
|
|
109
|
+
* fsm.register(MyState.One);
|
|
110
|
+
* fsm.register(MyState.Two);
|
|
111
|
+
* // ...
|
|
112
|
+
* fsm.clear(); // The machine is now empty.
|
|
113
|
+
*/
|
|
114
|
+
clear(): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { type StateHandler, type StateHandlerConfig, type StateHandlerRegistration, StateMachine };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Emitter } from '@axi-engine/utils';
|
|
2
|
+
|
|
3
|
+
type StateHandler<P = void> = P extends void ? () => void | Promise<void> : (payload: P) => void | Promise<void>;
|
|
4
|
+
interface StateHandlerConfig<T, P = void> {
|
|
5
|
+
onEnter?: StateHandler<P>;
|
|
6
|
+
onExit?: () => void | Promise<void>;
|
|
7
|
+
allowedFrom?: T[];
|
|
8
|
+
}
|
|
9
|
+
type StateHandlerRegistration<T, P = void> = StateHandler<P> | StateHandlerConfig<T, P>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A minimal, type-safe finite state machine.
|
|
13
|
+
* It manages states, transitions, and associated lifecycle hooks (`onEnter`, `onExit`).
|
|
14
|
+
*
|
|
15
|
+
* @template T The type used for state identifiers (e.g., a string or an enum).
|
|
16
|
+
* @template P The default payload type for state handlers. Can be overridden per state.
|
|
17
|
+
* @example
|
|
18
|
+
* enum PlayerState { Idle, Walk, Run }
|
|
19
|
+
*
|
|
20
|
+
* const playerFsm = new StateMachine<PlayerState>();
|
|
21
|
+
*
|
|
22
|
+
* playerFsm.register(PlayerState.Idle, () => console.log('Player is now idle.'));
|
|
23
|
+
* playerFsm.register(PlayerState.Walk, () => console.log('Player is walking.'));
|
|
24
|
+
*
|
|
25
|
+
* async function start() {
|
|
26
|
+
* await playerFsm.call(PlayerState.Idle);
|
|
27
|
+
* await playerFsm.call(PlayerState.Walk);
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
declare class StateMachine<T, P = void> {
|
|
31
|
+
/**
|
|
32
|
+
* @protected
|
|
33
|
+
* The internal representation of the current state.
|
|
34
|
+
*/
|
|
35
|
+
protected _state?: T;
|
|
36
|
+
/**
|
|
37
|
+
* @protected
|
|
38
|
+
* A map storing all registered state configurations.
|
|
39
|
+
*/
|
|
40
|
+
protected states: Map<T, StateHandlerConfig<T, P> | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Public emitter that fires an event whenever the state changes.
|
|
43
|
+
* The event provides the old state, the new state, and the payload.
|
|
44
|
+
* @see Emitter
|
|
45
|
+
* @example
|
|
46
|
+
* fsm.onChange.subscribe((from, to, payload) => {
|
|
47
|
+
* console.log(`State transitioned from ${from} to ${to}`);
|
|
48
|
+
* });
|
|
49
|
+
*/
|
|
50
|
+
readonly onChange: Emitter<[from?: T | undefined, to?: T | undefined, payload?: P | undefined]>;
|
|
51
|
+
/**
|
|
52
|
+
* Gets the current state of the machine.
|
|
53
|
+
* @returns The current state identifier, or `undefined` if the machine has not been started.
|
|
54
|
+
*/
|
|
55
|
+
get state(): T | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Registers a state and its associated handler or configuration.
|
|
58
|
+
* If a handler is already registered for the given state, it will be overwritten.
|
|
59
|
+
*
|
|
60
|
+
* @param state The identifier for the state to register.
|
|
61
|
+
* @param handler A handler function (`onEnter`) or a full configuration object.
|
|
62
|
+
* @example
|
|
63
|
+
* // Simple registration
|
|
64
|
+
* fsm.register(MyState.Idle, () => console.log('Entering Idle'));
|
|
65
|
+
*
|
|
66
|
+
* // Advanced registration
|
|
67
|
+
* fsm.register(MyState.Walking, {
|
|
68
|
+
* onEnter: () => console.log('Start walking animation'),
|
|
69
|
+
* onExit: () => console.log('Stop walking animation'),
|
|
70
|
+
* allowedFrom: [MyState.Idle]
|
|
71
|
+
* });
|
|
72
|
+
*/
|
|
73
|
+
register(state: T, handler?: StateHandlerRegistration<T, P>): void;
|
|
74
|
+
/**
|
|
75
|
+
* Transitions the machine to a new state.
|
|
76
|
+
* This method is asynchronous to accommodate async `onEnter` and `onExit` handlers.
|
|
77
|
+
* It will execute the `onExit` handler of the old state, then the `onEnter` handler of the new state.
|
|
78
|
+
*
|
|
79
|
+
* @param newState The identifier of the state to transition to.
|
|
80
|
+
* @param payload An optional payload to pass to the new state's `onEnter` handler.
|
|
81
|
+
* @returns A promise that resolves when the transition is complete.
|
|
82
|
+
* @throws {Error} if the `newState` has not been registered.
|
|
83
|
+
* @throws {Error} if the transition from the current state to the `newState` is not allowed by the `allowedFrom` rule.
|
|
84
|
+
* @example
|
|
85
|
+
* try {
|
|
86
|
+
* await fsm.call(PlayerState.Run, { speed: 10 });
|
|
87
|
+
* } catch (e) {
|
|
88
|
+
* console.error('State transition failed:', e.message);
|
|
89
|
+
* }
|
|
90
|
+
*/
|
|
91
|
+
call(newState: T, payload?: P): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Removes a single state configuration from the machine.
|
|
94
|
+
* If the removed state is the currently active one, the machine's state will be reset to `undefined`.
|
|
95
|
+
*
|
|
96
|
+
* @param state The identifier of the state to remove.
|
|
97
|
+
* @returns `true` if the state was found and removed, otherwise `false`.
|
|
98
|
+
* @example
|
|
99
|
+
* fsm.register(MyState.Temp, () => {});
|
|
100
|
+
* // ...
|
|
101
|
+
* const wasRemoved = fsm.unregister(MyState.Temp);
|
|
102
|
+
* console.log('Temporary state removed:', wasRemoved);
|
|
103
|
+
*/
|
|
104
|
+
unregister(state: T): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Removes all registered states and resets the machine to its initial, undefined state.
|
|
107
|
+
* This does not clear `onChange` subscribers.
|
|
108
|
+
* @example
|
|
109
|
+
* fsm.register(MyState.One);
|
|
110
|
+
* fsm.register(MyState.Two);
|
|
111
|
+
* // ...
|
|
112
|
+
* fsm.clear(); // The machine is now empty.
|
|
113
|
+
*/
|
|
114
|
+
clear(): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { type StateHandler, type StateHandlerConfig, type StateHandlerRegistration, StateMachine };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
StateMachine: () => StateMachine
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/state-machine.ts
|
|
28
|
+
var import_utils = require("@axi-engine/utils");
|
|
29
|
+
var StateMachine = class {
|
|
30
|
+
constructor() {
|
|
31
|
+
/**
|
|
32
|
+
* @protected
|
|
33
|
+
* A map storing all registered state configurations.
|
|
34
|
+
*/
|
|
35
|
+
this.states = /* @__PURE__ */ new Map();
|
|
36
|
+
/**
|
|
37
|
+
* Public emitter that fires an event whenever the state changes.
|
|
38
|
+
* The event provides the old state, the new state, and the payload.
|
|
39
|
+
* @see Emitter
|
|
40
|
+
* @example
|
|
41
|
+
* fsm.onChange.subscribe((from, to, payload) => {
|
|
42
|
+
* console.log(`State transitioned from ${from} to ${to}`);
|
|
43
|
+
* });
|
|
44
|
+
*/
|
|
45
|
+
this.onChange = new import_utils.Emitter();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Gets the current state of the machine.
|
|
49
|
+
* @returns The current state identifier, or `undefined` if the machine has not been started.
|
|
50
|
+
*/
|
|
51
|
+
get state() {
|
|
52
|
+
return this._state;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Registers a state and its associated handler or configuration.
|
|
56
|
+
* If a handler is already registered for the given state, it will be overwritten.
|
|
57
|
+
*
|
|
58
|
+
* @param state The identifier for the state to register.
|
|
59
|
+
* @param handler A handler function (`onEnter`) or a full configuration object.
|
|
60
|
+
* @example
|
|
61
|
+
* // Simple registration
|
|
62
|
+
* fsm.register(MyState.Idle, () => console.log('Entering Idle'));
|
|
63
|
+
*
|
|
64
|
+
* // Advanced registration
|
|
65
|
+
* fsm.register(MyState.Walking, {
|
|
66
|
+
* onEnter: () => console.log('Start walking animation'),
|
|
67
|
+
* onExit: () => console.log('Stop walking animation'),
|
|
68
|
+
* allowedFrom: [MyState.Idle]
|
|
69
|
+
* });
|
|
70
|
+
*/
|
|
71
|
+
register(state, handler) {
|
|
72
|
+
if ((0, import_utils.isUndefined)(handler) || typeof handler === "function") {
|
|
73
|
+
this.states.set(state, { onEnter: handler });
|
|
74
|
+
} else {
|
|
75
|
+
this.states.set(state, handler);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Transitions the machine to a new state.
|
|
80
|
+
* This method is asynchronous to accommodate async `onEnter` and `onExit` handlers.
|
|
81
|
+
* It will execute the `onExit` handler of the old state, then the `onEnter` handler of the new state.
|
|
82
|
+
*
|
|
83
|
+
* @param newState The identifier of the state to transition to.
|
|
84
|
+
* @param payload An optional payload to pass to the new state's `onEnter` handler.
|
|
85
|
+
* @returns A promise that resolves when the transition is complete.
|
|
86
|
+
* @throws {Error} if the `newState` has not been registered.
|
|
87
|
+
* @throws {Error} if the transition from the current state to the `newState` is not allowed by the `allowedFrom` rule.
|
|
88
|
+
* @example
|
|
89
|
+
* try {
|
|
90
|
+
* await fsm.call(PlayerState.Run, { speed: 10 });
|
|
91
|
+
* } catch (e) {
|
|
92
|
+
* console.error('State transition failed:', e.message);
|
|
93
|
+
* }
|
|
94
|
+
*/
|
|
95
|
+
async call(newState, payload) {
|
|
96
|
+
const oldState = this._state;
|
|
97
|
+
const oldStateConfig = this._state ? this.states.get(this._state) : void 0;
|
|
98
|
+
const newStateConfig = this.states.get(newState);
|
|
99
|
+
(0, import_utils.throwIfEmpty)(newStateConfig, `State ${String(newState)} is not registered.`);
|
|
100
|
+
(0, import_utils.throwIf)(
|
|
101
|
+
!(0, import_utils.isNullOrUndefined)(newStateConfig.allowedFrom) && !(0, import_utils.isNullOrUndefined)(oldState) && !newStateConfig.allowedFrom.includes(oldState),
|
|
102
|
+
`Transition from ${String(oldState)} to ${String(newState)} is not allowed.`
|
|
103
|
+
);
|
|
104
|
+
await oldStateConfig?.onExit?.();
|
|
105
|
+
this._state = newState;
|
|
106
|
+
await newStateConfig.onEnter?.(payload);
|
|
107
|
+
this.onChange.emit(oldState, newState, payload);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Removes a single state configuration from the machine.
|
|
111
|
+
* If the removed state is the currently active one, the machine's state will be reset to `undefined`.
|
|
112
|
+
*
|
|
113
|
+
* @param state The identifier of the state to remove.
|
|
114
|
+
* @returns `true` if the state was found and removed, otherwise `false`.
|
|
115
|
+
* @example
|
|
116
|
+
* fsm.register(MyState.Temp, () => {});
|
|
117
|
+
* // ...
|
|
118
|
+
* const wasRemoved = fsm.unregister(MyState.Temp);
|
|
119
|
+
* console.log('Temporary state removed:', wasRemoved);
|
|
120
|
+
*/
|
|
121
|
+
unregister(state) {
|
|
122
|
+
if (this._state === state) {
|
|
123
|
+
this._state = void 0;
|
|
124
|
+
}
|
|
125
|
+
return this.states.delete(state);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Removes all registered states and resets the machine to its initial, undefined state.
|
|
129
|
+
* This does not clear `onChange` subscribers.
|
|
130
|
+
* @example
|
|
131
|
+
* fsm.register(MyState.One);
|
|
132
|
+
* fsm.register(MyState.Two);
|
|
133
|
+
* // ...
|
|
134
|
+
* fsm.clear(); // The machine is now empty.
|
|
135
|
+
*/
|
|
136
|
+
clear() {
|
|
137
|
+
this.states.clear();
|
|
138
|
+
this._state = void 0;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
142
|
+
0 && (module.exports = {
|
|
143
|
+
StateMachine
|
|
144
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// src/state-machine.ts
|
|
2
|
+
import { Emitter, isNullOrUndefined, isUndefined, throwIf, throwIfEmpty } from "@axi-engine/utils";
|
|
3
|
+
var StateMachine = class {
|
|
4
|
+
constructor() {
|
|
5
|
+
/**
|
|
6
|
+
* @protected
|
|
7
|
+
* A map storing all registered state configurations.
|
|
8
|
+
*/
|
|
9
|
+
this.states = /* @__PURE__ */ new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Public emitter that fires an event whenever the state changes.
|
|
12
|
+
* The event provides the old state, the new state, and the payload.
|
|
13
|
+
* @see Emitter
|
|
14
|
+
* @example
|
|
15
|
+
* fsm.onChange.subscribe((from, to, payload) => {
|
|
16
|
+
* console.log(`State transitioned from ${from} to ${to}`);
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
this.onChange = new Emitter();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Gets the current state of the machine.
|
|
23
|
+
* @returns The current state identifier, or `undefined` if the machine has not been started.
|
|
24
|
+
*/
|
|
25
|
+
get state() {
|
|
26
|
+
return this._state;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Registers a state and its associated handler or configuration.
|
|
30
|
+
* If a handler is already registered for the given state, it will be overwritten.
|
|
31
|
+
*
|
|
32
|
+
* @param state The identifier for the state to register.
|
|
33
|
+
* @param handler A handler function (`onEnter`) or a full configuration object.
|
|
34
|
+
* @example
|
|
35
|
+
* // Simple registration
|
|
36
|
+
* fsm.register(MyState.Idle, () => console.log('Entering Idle'));
|
|
37
|
+
*
|
|
38
|
+
* // Advanced registration
|
|
39
|
+
* fsm.register(MyState.Walking, {
|
|
40
|
+
* onEnter: () => console.log('Start walking animation'),
|
|
41
|
+
* onExit: () => console.log('Stop walking animation'),
|
|
42
|
+
* allowedFrom: [MyState.Idle]
|
|
43
|
+
* });
|
|
44
|
+
*/
|
|
45
|
+
register(state, handler) {
|
|
46
|
+
if (isUndefined(handler) || typeof handler === "function") {
|
|
47
|
+
this.states.set(state, { onEnter: handler });
|
|
48
|
+
} else {
|
|
49
|
+
this.states.set(state, handler);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Transitions the machine to a new state.
|
|
54
|
+
* This method is asynchronous to accommodate async `onEnter` and `onExit` handlers.
|
|
55
|
+
* It will execute the `onExit` handler of the old state, then the `onEnter` handler of the new state.
|
|
56
|
+
*
|
|
57
|
+
* @param newState The identifier of the state to transition to.
|
|
58
|
+
* @param payload An optional payload to pass to the new state's `onEnter` handler.
|
|
59
|
+
* @returns A promise that resolves when the transition is complete.
|
|
60
|
+
* @throws {Error} if the `newState` has not been registered.
|
|
61
|
+
* @throws {Error} if the transition from the current state to the `newState` is not allowed by the `allowedFrom` rule.
|
|
62
|
+
* @example
|
|
63
|
+
* try {
|
|
64
|
+
* await fsm.call(PlayerState.Run, { speed: 10 });
|
|
65
|
+
* } catch (e) {
|
|
66
|
+
* console.error('State transition failed:', e.message);
|
|
67
|
+
* }
|
|
68
|
+
*/
|
|
69
|
+
async call(newState, payload) {
|
|
70
|
+
const oldState = this._state;
|
|
71
|
+
const oldStateConfig = this._state ? this.states.get(this._state) : void 0;
|
|
72
|
+
const newStateConfig = this.states.get(newState);
|
|
73
|
+
throwIfEmpty(newStateConfig, `State ${String(newState)} is not registered.`);
|
|
74
|
+
throwIf(
|
|
75
|
+
!isNullOrUndefined(newStateConfig.allowedFrom) && !isNullOrUndefined(oldState) && !newStateConfig.allowedFrom.includes(oldState),
|
|
76
|
+
`Transition from ${String(oldState)} to ${String(newState)} is not allowed.`
|
|
77
|
+
);
|
|
78
|
+
await oldStateConfig?.onExit?.();
|
|
79
|
+
this._state = newState;
|
|
80
|
+
await newStateConfig.onEnter?.(payload);
|
|
81
|
+
this.onChange.emit(oldState, newState, payload);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Removes a single state configuration from the machine.
|
|
85
|
+
* If the removed state is the currently active one, the machine's state will be reset to `undefined`.
|
|
86
|
+
*
|
|
87
|
+
* @param state The identifier of the state to remove.
|
|
88
|
+
* @returns `true` if the state was found and removed, otherwise `false`.
|
|
89
|
+
* @example
|
|
90
|
+
* fsm.register(MyState.Temp, () => {});
|
|
91
|
+
* // ...
|
|
92
|
+
* const wasRemoved = fsm.unregister(MyState.Temp);
|
|
93
|
+
* console.log('Temporary state removed:', wasRemoved);
|
|
94
|
+
*/
|
|
95
|
+
unregister(state) {
|
|
96
|
+
if (this._state === state) {
|
|
97
|
+
this._state = void 0;
|
|
98
|
+
}
|
|
99
|
+
return this.states.delete(state);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Removes all registered states and resets the machine to its initial, undefined state.
|
|
103
|
+
* This does not clear `onChange` subscribers.
|
|
104
|
+
* @example
|
|
105
|
+
* fsm.register(MyState.One);
|
|
106
|
+
* fsm.register(MyState.Two);
|
|
107
|
+
* // ...
|
|
108
|
+
* fsm.clear(); // The machine is now empty.
|
|
109
|
+
*/
|
|
110
|
+
clear() {
|
|
111
|
+
this.states.clear();
|
|
112
|
+
this._state = void 0;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
export {
|
|
116
|
+
StateMachine
|
|
117
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@axi-engine/states",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A minimal, type-safe state machine for the Axi Engine, designed for managing game logic and component states with a simple API.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"axi-engine",
|
|
8
|
+
"typescript",
|
|
9
|
+
"gamedev",
|
|
10
|
+
"game-development",
|
|
11
|
+
"game-engine",
|
|
12
|
+
"state-machine",
|
|
13
|
+
"finite-state-machine",
|
|
14
|
+
"fsm",
|
|
15
|
+
"state-management",
|
|
16
|
+
"events"
|
|
17
|
+
],
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.mjs",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.mjs",
|
|
25
|
+
"require": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"docs": "typedoc src/index.ts --out docs/api --options ../../typedoc.json",
|
|
31
|
+
"test": "echo 'No tests yet for @axi-engine/states'"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@axi-engine/utils": "^0.1.5"
|
|
38
|
+
}
|
|
39
|
+
}
|