@mpgd/game-runtime 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.
@@ -0,0 +1,286 @@
1
+ export function bindPhaserGameScene(input) {
2
+ const { scene, controller, resetInput, audio, uiScope, onUnsupportedState, onError } = input;
3
+ if (input.renderingPolicy !== 'visibility' || typeof onUnsupportedState !== 'function') {
4
+ throw new TypeError('Choose visibility rendering and provide an unsupported-state observer.');
5
+ }
6
+ if (typeof resetInput !== 'function') {
7
+ throw new TypeError('Provide a gameplay input reset callback.');
8
+ }
9
+ if (controller.getSnapshot().status === 'destroyed') {
10
+ throw new Error('Cannot bind a destroyed game execution controller.');
11
+ }
12
+ const events = scene.sys.events;
13
+ let disposed = false;
14
+ let sceneEnded = false;
15
+ let reportedSimulation = false;
16
+ let reportedAudio = false;
17
+ let applying = false;
18
+ let dirty = false;
19
+ let ownedPause = false;
20
+ let ownedVisibility = false;
21
+ let ownedAudio = false;
22
+ let inputBlocked = false;
23
+ const ownedInput = new Set();
24
+ let unsubscribe = () => { };
25
+ function report(error) {
26
+ try {
27
+ const result = onError?.(error);
28
+ if (result !== undefined) {
29
+ void Promise.resolve(result).catch(() => { });
30
+ }
31
+ }
32
+ catch {
33
+ // Diagnostics cannot interrupt teardown.
34
+ }
35
+ }
36
+ function attempt(callback) {
37
+ try {
38
+ const result = callback();
39
+ if (result !== undefined) {
40
+ void Promise.resolve(result).catch(report);
41
+ }
42
+ }
43
+ catch (error) {
44
+ report(error);
45
+ }
46
+ }
47
+ let disposing = false;
48
+ function restoreAudio() {
49
+ if (ownedAudio && audio !== undefined) {
50
+ // A throwing setter leaves cleanup ownership pending for an explicit retry.
51
+ audio.setMuted(false);
52
+ ownedAudio = false;
53
+ }
54
+ }
55
+ function restore(resumeScene) {
56
+ const shouldResume = resumeScene && ownedPause;
57
+ ownedPause = false;
58
+ for (const plugin of ownedInput) {
59
+ attempt(() => {
60
+ plugin.enabled = true;
61
+ });
62
+ }
63
+ ownedInput.clear();
64
+ if (resumeScene && !sceneEnded && ownedVisibility && (scene.sys.isActive() || scene.sys.isPaused())) {
65
+ attempt(() => {
66
+ scene.sys.setVisible(true);
67
+ });
68
+ }
69
+ ownedVisibility = false;
70
+ attempt(restoreAudio);
71
+ // Resume last: its listeners may synchronously restart the scene and install a new binding.
72
+ if (shouldResume && !sceneEnded) {
73
+ attempt(() => {
74
+ if (controller.getSnapshot().status === 'active' && scene.sys.isPaused()) {
75
+ scene.sys.resume();
76
+ }
77
+ });
78
+ }
79
+ }
80
+ function dispose(mode = 'restore') {
81
+ if (disposing) {
82
+ return;
83
+ }
84
+ disposing = true;
85
+ try {
86
+ if (disposed) {
87
+ // Retry only unresolved sink cleanup, never scene controls or terminal teardown.
88
+ if (mode === 'restore' && ownedAudio) {
89
+ attempt(() => {
90
+ if (controller.getSnapshot().status === 'active') {
91
+ restoreAudio();
92
+ }
93
+ });
94
+ }
95
+ return;
96
+ }
97
+ disposed = true;
98
+ attempt(unsubscribe);
99
+ // Keep shutdown observation installed while restoration calls into consumer/engine code.
100
+ if (mode !== 'terminal') {
101
+ attempt(() => {
102
+ if (controller.getSnapshot().status === 'active') {
103
+ restore(mode === 'restore');
104
+ }
105
+ });
106
+ }
107
+ for (const event of ['shutdown', 'destroy']) {
108
+ attempt(() => {
109
+ events.off(event, onShutdown);
110
+ });
111
+ }
112
+ attempt(() => {
113
+ events.off('sleep', onSleep);
114
+ });
115
+ attempt(() => {
116
+ events.off('wake', onWake);
117
+ });
118
+ attempt(() => {
119
+ events.off('pause', onPause);
120
+ });
121
+ attempt(() => {
122
+ events.off('resume', onResume);
123
+ });
124
+ attempt(() => {
125
+ events.off('create', apply);
126
+ });
127
+ attempt(() => uiScope?.dispose());
128
+ ownedInput.clear();
129
+ }
130
+ finally {
131
+ disposing = false;
132
+ }
133
+ }
134
+ function onShutdown() {
135
+ sceneEnded = true;
136
+ dispose('shutdown');
137
+ }
138
+ function onPause() {
139
+ if (!disposed && !applying) {
140
+ ownedPause = false;
141
+ }
142
+ }
143
+ function onResume() {
144
+ if (!disposed && !applying) {
145
+ ownedPause = false;
146
+ apply();
147
+ }
148
+ }
149
+ function onSleep() {
150
+ // External sleep owns activity/visibility until an external wake. Never wake it ourselves.
151
+ ownedPause = false;
152
+ ownedVisibility = false;
153
+ }
154
+ function onWake() {
155
+ if (controller.getSnapshot().blocked['gameplay-input']) {
156
+ inputBlocked = false;
157
+ }
158
+ apply();
159
+ }
160
+ function apply() {
161
+ dirty = true;
162
+ if (disposed || applying) {
163
+ return;
164
+ }
165
+ applying = true;
166
+ try {
167
+ while (dirty && !disposed) {
168
+ dirty = false;
169
+ const snapshot = controller.getSnapshot();
170
+ if (snapshot.status === 'destroyed') {
171
+ // Terminal runtime teardown must not request gameplay resume or unmute.
172
+ dispose('terminal');
173
+ break;
174
+ }
175
+ const blocked = snapshot.blocked;
176
+ const simulationUnsupported = blocked.simulation && !blocked['gameplay-input'];
177
+ const audioUnsupported = blocked.audio && audio === undefined;
178
+ const notifySimulation = simulationUnsupported && !reportedSimulation;
179
+ const notifyAudio = audioUnsupported && !reportedAudio;
180
+ reportedSimulation = simulationUnsupported;
181
+ reportedAudio = audioUnsupported;
182
+ if (notifySimulation) {
183
+ attempt(() => onUnsupportedState(snapshot, 'simulation-requires-input-block'));
184
+ }
185
+ if (notifyAudio) {
186
+ attempt(() => onUnsupportedState(snapshot, 'audio-sink-missing'));
187
+ }
188
+ if (simulationUnsupported) {
189
+ continue;
190
+ }
191
+ if (audio !== undefined) {
192
+ attempt(() => {
193
+ if (blocked.audio && !audio.getMuted()) {
194
+ ownedAudio = true;
195
+ audio.setMuted(true);
196
+ }
197
+ else if (!blocked.audio && ownedAudio) {
198
+ restoreAudio();
199
+ }
200
+ });
201
+ }
202
+ if (disposed) {
203
+ break;
204
+ }
205
+ if (controller.getSnapshot().version !== snapshot.version) {
206
+ dirty = true;
207
+ continue;
208
+ }
209
+ // Stopped, destroyed, and sleeping scenes belong to their external lifecycle owner.
210
+ if (!scene.sys.isActive() && !scene.sys.isPaused()) {
211
+ continue;
212
+ }
213
+ if (blocked['gameplay-input'] && !inputBlocked) {
214
+ inputBlocked = true;
215
+ for (const plugin of [scene.input, scene.input.keyboard, scene.input.gamepad]) {
216
+ if (plugin !== null && plugin !== undefined && plugin.enabled) {
217
+ ownedInput.add(plugin);
218
+ attempt(() => {
219
+ plugin.enabled = false;
220
+ });
221
+ }
222
+ }
223
+ attempt(resetInput);
224
+ if (disposed) {
225
+ break;
226
+ }
227
+ if (controller.getSnapshot().version !== snapshot.version) {
228
+ dirty = true;
229
+ continue;
230
+ }
231
+ }
232
+ else if (!blocked['gameplay-input'] && inputBlocked) {
233
+ inputBlocked = false;
234
+ for (const plugin of ownedInput) {
235
+ attempt(() => {
236
+ plugin.enabled = true;
237
+ });
238
+ }
239
+ ownedInput.clear();
240
+ }
241
+ if (blocked.rendering && scene.sys.isVisible()) {
242
+ ownedVisibility = true;
243
+ scene.sys.setVisible(false);
244
+ }
245
+ else if (!blocked.rendering && ownedVisibility) {
246
+ ownedVisibility = false;
247
+ scene.sys.setVisible(true);
248
+ }
249
+ if (blocked.simulation && scene.sys.isActive()) {
250
+ ownedPause = true;
251
+ scene.sys.pause();
252
+ }
253
+ else if (!blocked.simulation && ownedPause) {
254
+ ownedPause = false;
255
+ if (scene.sys.isPaused()) {
256
+ scene.sys.resume();
257
+ }
258
+ }
259
+ }
260
+ }
261
+ catch (error) {
262
+ report(error);
263
+ }
264
+ finally {
265
+ applying = false;
266
+ }
267
+ }
268
+ try {
269
+ for (const event of ['shutdown', 'destroy']) {
270
+ events.on(event, onShutdown);
271
+ }
272
+ events.on('sleep', onSleep);
273
+ events.on('wake', onWake);
274
+ events.on('pause', onPause);
275
+ events.on('resume', onResume);
276
+ // SceneManager sets RUNNING after scene.create() returns; apply before its first update.
277
+ events.on('create', apply);
278
+ unsubscribe = controller.subscribe(apply);
279
+ apply();
280
+ }
281
+ catch (error) {
282
+ dispose('shutdown');
283
+ throw error;
284
+ }
285
+ return Object.freeze({ dispose: () => dispose() });
286
+ }
@@ -0,0 +1,25 @@
1
+ import type { ExecutionChannel, GameExecutionController } from '../index.js';
2
+ import { type ObserverErrorHandler } from '../observers.js';
3
+ /** Compatible with PlatformGateway.lifecycle without importing a platform SDK. */
4
+ export interface GameLifecycleSource {
5
+ onPause(callback: () => void): () => void;
6
+ onResume(callback: () => void): () => void;
7
+ }
8
+ export type GameLifecycleState = 'active' | 'inactive' | 'unknown';
9
+ export type LifecycleInitialization = {
10
+ readonly initialState: GameLifecycleState;
11
+ readonly readState?: never;
12
+ } | {
13
+ readonly readState: () => GameLifecycleState;
14
+ readonly initialState?: never;
15
+ };
16
+ export interface GameLifecycleBinding {
17
+ dispose(): void;
18
+ }
19
+ export declare function bindGameLifecycle(input: LifecycleInitialization & {
20
+ readonly controller: GameExecutionController;
21
+ readonly source: GameLifecycleSource;
22
+ readonly reason?: string;
23
+ readonly channels?: readonly ExecutionChannel[];
24
+ readonly onError?: ObserverErrorHandler;
25
+ }): GameLifecycleBinding;
@@ -0,0 +1,120 @@
1
+ import { executionChannels } from '../channels.js';
2
+ import { observe } from '../observers.js';
3
+ export function bindGameLifecycle(input) {
4
+ const { controller, source, onError } = input;
5
+ const reason = input.reason ?? 'lifecycle';
6
+ const channels = [...(input.channels ?? executionChannels)];
7
+ if (typeof reason !== 'string' || reason.trim().length === 0 || channels.length === 0) {
8
+ throw new TypeError('Lifecycle reason and channels must be non-empty.');
9
+ }
10
+ if (channels.some((channel) => !executionChannels.includes(channel))) {
11
+ throw new TypeError('Unknown lifecycle execution channel.');
12
+ }
13
+ const readState = input.readState;
14
+ let state = 'unknown';
15
+ if (readState === undefined) {
16
+ state = validateState(input.initialState);
17
+ }
18
+ else if (typeof readState !== 'function') {
19
+ throw new TypeError('Lifecycle readState must be a function.');
20
+ }
21
+ let disposed = false;
22
+ let initializing = true;
23
+ let applying = false;
24
+ let dirty = false;
25
+ let eventsSeen = 0;
26
+ let block;
27
+ const unsubscribes = [];
28
+ function dispose() {
29
+ if (disposed) {
30
+ return;
31
+ }
32
+ disposed = true;
33
+ for (const unsubscribe of unsubscribes.splice(0)) {
34
+ observe(unsubscribe, onError);
35
+ }
36
+ const owned = block;
37
+ block = undefined;
38
+ owned?.release();
39
+ }
40
+ function reconcile() {
41
+ dirty = true;
42
+ if (disposed || initializing || applying) {
43
+ return;
44
+ }
45
+ applying = true;
46
+ try {
47
+ while (dirty && !disposed) {
48
+ dirty = false;
49
+ if (controller.getSnapshot().status === 'destroyed') {
50
+ dispose();
51
+ break;
52
+ }
53
+ if (state !== 'active' && block === undefined) {
54
+ const acquired = controller.acquireBlock({ reason, channels });
55
+ if (disposed) {
56
+ acquired.release();
57
+ }
58
+ else {
59
+ block = acquired;
60
+ }
61
+ }
62
+ else if (state === 'active' && block !== undefined) {
63
+ const owned = block;
64
+ block = undefined;
65
+ owned.release();
66
+ }
67
+ }
68
+ }
69
+ finally {
70
+ applying = false;
71
+ }
72
+ }
73
+ function receive(next) {
74
+ if (!disposed) {
75
+ eventsSeen += 1;
76
+ state = next;
77
+ reconcile();
78
+ }
79
+ }
80
+ function own(unsubscribe) {
81
+ if (disposed) {
82
+ observe(unsubscribe, onError);
83
+ }
84
+ else {
85
+ unsubscribes.push(unsubscribe);
86
+ }
87
+ }
88
+ try {
89
+ own(controller.subscribe((snapshot) => {
90
+ if (snapshot.status === 'destroyed') {
91
+ dispose();
92
+ }
93
+ }));
94
+ own(source.onPause(() => receive('inactive')));
95
+ if (!disposed) {
96
+ own(source.onResume(() => receive('active')));
97
+ }
98
+ if (!disposed && readState !== undefined) {
99
+ const beforeRead = eventsSeen;
100
+ const initial = validateState(readState());
101
+ // A synchronously observed event during the read is newer than its return value.
102
+ if (eventsSeen === beforeRead) {
103
+ state = initial;
104
+ }
105
+ }
106
+ initializing = false;
107
+ reconcile();
108
+ }
109
+ catch (error) {
110
+ dispose();
111
+ throw error;
112
+ }
113
+ return Object.freeze({ dispose });
114
+ }
115
+ function validateState(state) {
116
+ if (state !== 'active' && state !== 'inactive' && state !== 'unknown') {
117
+ throw new TypeError('Provide an explicit active, inactive, or unknown lifecycle state.');
118
+ }
119
+ return state;
120
+ }
@@ -0,0 +1,33 @@
1
+ import { type ObserverErrorHandler } from '../observers.js';
2
+ export type UiListener<T> = (value: T) => void | Promise<void>;
3
+ export type UiCleanup = () => void | Promise<void>;
4
+ export interface GameUiScope<TSnapshot, TCommand, TEvent> {
5
+ isDisposed(): boolean;
6
+ /** Register owned cleanup; the returned function releases it once, early if needed. */
7
+ own(cleanup: UiCleanup): () => void;
8
+ subscribeSnapshot(listener: UiListener<TSnapshot>): () => void;
9
+ subscribeSelector<TSelected>(selector: (snapshot: TSnapshot) => TSelected, listener: UiListener<TSelected>, equality?: (left: TSelected, right: TSelected) => boolean): () => void;
10
+ onCommand(listener: UiListener<TCommand>): () => void;
11
+ onEvent(listener: UiListener<TEvent>): () => void;
12
+ /** False after scope disposal, including disposal through bridge destruction. */
13
+ setSnapshot(snapshot: TSnapshot): boolean;
14
+ emit(event: TEvent): boolean;
15
+ dispatch(command: TCommand): boolean;
16
+ dispose(): void;
17
+ }
18
+ export interface GameUiBridge<TSnapshot, TCommand, TEvent> {
19
+ getSnapshot(): TSnapshot;
20
+ setSnapshot(snapshot: TSnapshot): void;
21
+ subscribeSnapshot(listener: UiListener<TSnapshot>): () => void;
22
+ subscribeSelector<TSelected>(selector: (snapshot: TSnapshot) => TSelected, listener: UiListener<TSelected>, equality?: (left: TSelected, right: TSelected) => boolean): () => void;
23
+ dispatch(command: TCommand): void;
24
+ onCommand(listener: UiListener<TCommand>): () => void;
25
+ emit(event: TEvent): void;
26
+ onEvent(listener: UiListener<TEvent>): () => void;
27
+ createScope(): GameUiScope<TSnapshot, TCommand, TEvent>;
28
+ destroy(): void;
29
+ }
30
+ export declare function createGameUiBridge<TSnapshot, TCommand, TEvent = never>(input: {
31
+ readonly initialSnapshot: TSnapshot;
32
+ readonly onListenerError?: ObserverErrorHandler;
33
+ }): GameUiBridge<TSnapshot, TCommand, TEvent>;
@@ -0,0 +1,197 @@
1
+ import { observe } from '../observers.js';
2
+ export function createGameUiBridge(input) {
3
+ let snapshot = input.initialSnapshot;
4
+ const onError = input.onListenerError;
5
+ const snapshots = new Set();
6
+ const commands = new Set();
7
+ const events = new Set();
8
+ const scopes = new Set();
9
+ const queue = [];
10
+ let destroyed = false;
11
+ let delivering = false;
12
+ function assertActive() {
13
+ if (destroyed) {
14
+ throw new Error('Game UI bridge is destroyed.');
15
+ }
16
+ }
17
+ function subscribe(subscriptions, listener) {
18
+ assertActive();
19
+ if (typeof listener !== 'function') {
20
+ throw new TypeError('UI listener must be a function.');
21
+ }
22
+ const subscription = { listener, active: true };
23
+ subscriptions.add(subscription);
24
+ return () => {
25
+ subscription.active = false;
26
+ subscriptions.delete(subscription);
27
+ };
28
+ }
29
+ function publish(subscriptions, value) {
30
+ const recipients = [...subscriptions];
31
+ queue.push(() => {
32
+ for (const subscription of recipients) {
33
+ if (!destroyed && subscription.active) {
34
+ observe(() => subscription.listener(value), onError);
35
+ }
36
+ }
37
+ });
38
+ if (delivering) {
39
+ return;
40
+ }
41
+ delivering = true;
42
+ try {
43
+ for (let cursor = 0; !destroyed && cursor < queue.length; cursor += 1) {
44
+ queue[cursor]?.();
45
+ }
46
+ }
47
+ finally {
48
+ queue.length = 0;
49
+ delivering = false;
50
+ }
51
+ }
52
+ const bridge = {
53
+ getSnapshot: () => snapshot,
54
+ setSnapshot(next) {
55
+ assertActive();
56
+ if (!Object.is(snapshot, next)) {
57
+ snapshot = next;
58
+ publish(snapshots, next);
59
+ }
60
+ },
61
+ subscribeSnapshot: (listener) => subscribe(snapshots, listener),
62
+ subscribeSelector(selector, listener, equality = Object.is) {
63
+ assertActive();
64
+ if (typeof selector !== 'function'
65
+ || typeof listener !== 'function'
66
+ || typeof equality !== 'function') {
67
+ throw new TypeError('Selector, listener and equality must be functions.');
68
+ }
69
+ // Selectors and equality functions must be pure. An initial failure registers nothing.
70
+ let selected = selector(snapshot);
71
+ return subscribe(snapshots, (next) => {
72
+ const nextSelected = selector(next);
73
+ if (!equality(selected, nextSelected)) {
74
+ selected = nextSelected;
75
+ return listener(nextSelected);
76
+ }
77
+ });
78
+ },
79
+ dispatch(command) {
80
+ assertActive();
81
+ publish(commands, command);
82
+ },
83
+ onCommand: (listener) => subscribe(commands, listener),
84
+ emit(event) {
85
+ assertActive();
86
+ publish(events, event);
87
+ },
88
+ onEvent: (listener) => subscribe(events, listener),
89
+ createScope() {
90
+ assertActive();
91
+ const cleanups = new Set();
92
+ let disposed = false;
93
+ function assertScopeActive() {
94
+ assertActive();
95
+ if (disposed) {
96
+ throw new Error('Game UI scope is disposed.');
97
+ }
98
+ }
99
+ function register(registerListener) {
100
+ assertScopeActive();
101
+ const unsubscribe = registerListener();
102
+ // Selector callbacks can dispose the scope during registration.
103
+ if (disposed || destroyed) {
104
+ unsubscribe();
105
+ return () => { };
106
+ }
107
+ return scope.own(unsubscribe);
108
+ }
109
+ function guarded(listener) {
110
+ if (typeof listener !== 'function') {
111
+ throw new TypeError('UI listener must be a function.');
112
+ }
113
+ return (value) => {
114
+ if (!disposed && !destroyed) {
115
+ return listener(value);
116
+ }
117
+ };
118
+ }
119
+ const scope = {
120
+ isDisposed: () => disposed,
121
+ own(cleanup) {
122
+ if (typeof cleanup !== 'function') {
123
+ throw new TypeError('Scope cleanup must be a function.');
124
+ }
125
+ let active = true;
126
+ const release = () => {
127
+ if (active) {
128
+ active = false;
129
+ cleanups.delete(release);
130
+ observe(cleanup, onError);
131
+ }
132
+ };
133
+ if (disposed || destroyed) {
134
+ release();
135
+ }
136
+ else {
137
+ cleanups.add(release);
138
+ }
139
+ return release;
140
+ },
141
+ subscribeSnapshot: (listener) => register(() => bridge.subscribeSnapshot(guarded(listener))),
142
+ subscribeSelector: (selector, listener, equality) => register(() => bridge.subscribeSelector(selector, guarded(listener), equality)),
143
+ onCommand: (listener) => register(() => bridge.onCommand(guarded(listener))),
144
+ onEvent: (listener) => register(() => bridge.onEvent(guarded(listener))),
145
+ setSnapshot(next) {
146
+ if (disposed || destroyed) {
147
+ return false;
148
+ }
149
+ bridge.setSnapshot(next);
150
+ return true;
151
+ },
152
+ emit(event) {
153
+ if (disposed || destroyed) {
154
+ return false;
155
+ }
156
+ bridge.emit(event);
157
+ return true;
158
+ },
159
+ dispatch(command) {
160
+ if (disposed || destroyed) {
161
+ return false;
162
+ }
163
+ bridge.dispatch(command);
164
+ return true;
165
+ },
166
+ dispose() {
167
+ if (disposed) {
168
+ return;
169
+ }
170
+ disposed = true;
171
+ scopes.delete(scope);
172
+ // Revoke all scope callbacks before cleanup can reenter the bridge.
173
+ const owned = [...cleanups];
174
+ for (const cleanup of owned) {
175
+ cleanup();
176
+ }
177
+ },
178
+ };
179
+ scopes.add(scope);
180
+ return Object.freeze(scope);
181
+ },
182
+ destroy() {
183
+ if (destroyed) {
184
+ return;
185
+ }
186
+ destroyed = true;
187
+ for (const scope of [...scopes]) {
188
+ scope.dispose();
189
+ }
190
+ snapshots.clear();
191
+ commands.clear();
192
+ events.clear();
193
+ queue.length = 0;
194
+ },
195
+ };
196
+ return Object.freeze(bridge);
197
+ }