@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.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/dist/actions/index.d.ts +75 -0
- package/dist/actions/index.js +321 -0
- package/dist/channels.d.ts +2 -0
- package/dist/channels.js +3 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +122 -0
- package/dist/observers.d.ts +3 -0
- package/dist/observers.js +22 -0
- package/dist/phaser/index.d.ts +26 -0
- package/dist/phaser/index.js +286 -0
- package/dist/platform/index.d.ts +25 -0
- package/dist/platform/index.js +120 -0
- package/dist/ui/index.d.ts +33 -0
- package/dist/ui/index.js +197 -0
- package/docs/phaser.md +106 -0
- package/package.json +81 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { observe } from '../observers.js';
|
|
2
|
+
import { createGameUiBridge, } from '../ui/index.js';
|
|
3
|
+
/** Scheduling/preflight rejection, distinct from a service result or external exception. */
|
|
4
|
+
export class GameActionExecutionError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code) {
|
|
7
|
+
super(`Game action cannot start: ${code}`);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = 'GameActionExecutionError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const purchaseSources = {
|
|
13
|
+
shop: true,
|
|
14
|
+
stage_fail: true,
|
|
15
|
+
result: true,
|
|
16
|
+
event: true,
|
|
17
|
+
};
|
|
18
|
+
/** Bind one coordinator to one runtime/client (including its player identity), above all screens. */
|
|
19
|
+
export function createGameActionCoordinator(options) {
|
|
20
|
+
const { execution, client, onObserverError } = options;
|
|
21
|
+
const capacity = options.maxRememberedKeys ?? 1024;
|
|
22
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 10000) {
|
|
23
|
+
throw new RangeError('maxRememberedKeys must be an integer from 1 to 10000.');
|
|
24
|
+
}
|
|
25
|
+
const history = new Map();
|
|
26
|
+
let current;
|
|
27
|
+
let last;
|
|
28
|
+
let nextId = 0;
|
|
29
|
+
let disposed = false;
|
|
30
|
+
let needsReconciliation = false;
|
|
31
|
+
function isDisposed() {
|
|
32
|
+
return disposed || execution.getSnapshot().status === 'destroyed';
|
|
33
|
+
}
|
|
34
|
+
function reserve(kind, supplied, canStart) {
|
|
35
|
+
if (isDisposed()) {
|
|
36
|
+
throw new GameActionExecutionError('disposed');
|
|
37
|
+
}
|
|
38
|
+
const key = supplied.idempotencyKey;
|
|
39
|
+
const productId = kind === 'purchase' ? supplied.productId : undefined;
|
|
40
|
+
const source = kind === 'purchase' ? supplied.source : undefined;
|
|
41
|
+
const placementId = kind === 'rewarded-ad'
|
|
42
|
+
? supplied.placementId
|
|
43
|
+
: undefined;
|
|
44
|
+
if (typeof key !== 'string' || key.trim() === ''
|
|
45
|
+
|| (kind === 'purchase' && (typeof productId !== 'string' || productId.trim() === ''
|
|
46
|
+
|| !Object.hasOwn(purchaseSources, source ?? '')))
|
|
47
|
+
|| (kind === 'rewarded-ad' && (typeof placementId !== 'string' || placementId.trim() === ''))) {
|
|
48
|
+
throw new GameActionExecutionError('invalid-input');
|
|
49
|
+
}
|
|
50
|
+
// Copy only the flat public input; later caller mutation cannot change this invocation.
|
|
51
|
+
const input = Object.freeze(kind === 'purchase'
|
|
52
|
+
? { productId, source, idempotencyKey: key }
|
|
53
|
+
: { placementId, idempotencyKey: key });
|
|
54
|
+
const fingerprint = JSON.stringify([kind, productId, source, placementId]);
|
|
55
|
+
const prior = history.get(key);
|
|
56
|
+
if (prior !== undefined && prior !== fingerprint) {
|
|
57
|
+
throw new GameActionExecutionError('key-conflict');
|
|
58
|
+
}
|
|
59
|
+
if (current !== undefined && current.key === key) {
|
|
60
|
+
return current;
|
|
61
|
+
}
|
|
62
|
+
if (last !== undefined && last.key === key) {
|
|
63
|
+
return last;
|
|
64
|
+
}
|
|
65
|
+
if (prior !== undefined) {
|
|
66
|
+
throw new GameActionExecutionError('already-completed');
|
|
67
|
+
}
|
|
68
|
+
if (needsReconciliation) {
|
|
69
|
+
throw new GameActionExecutionError('reconciliation-required');
|
|
70
|
+
}
|
|
71
|
+
if (current !== undefined) {
|
|
72
|
+
throw new GameActionExecutionError('busy');
|
|
73
|
+
}
|
|
74
|
+
if (history.size >= capacity) {
|
|
75
|
+
throw new GameActionExecutionError('history-full');
|
|
76
|
+
}
|
|
77
|
+
const id = ++nextId;
|
|
78
|
+
const bridge = createGameUiBridge({
|
|
79
|
+
initialSnapshot: Object.freeze({ kind, status: 'running', operationId: id }),
|
|
80
|
+
...(onObserverError === undefined ? {} : { onListenerError: onObserverError }),
|
|
81
|
+
});
|
|
82
|
+
let resolve;
|
|
83
|
+
let reject;
|
|
84
|
+
const promise = new Promise((done, fail) => {
|
|
85
|
+
resolve = done;
|
|
86
|
+
reject = fail;
|
|
87
|
+
});
|
|
88
|
+
// A nested observer can join without attaching a rejection handler of its own.
|
|
89
|
+
void promise.catch(() => { });
|
|
90
|
+
let started = false;
|
|
91
|
+
let block;
|
|
92
|
+
let invoked = false;
|
|
93
|
+
function finish(status) {
|
|
94
|
+
flight.settled = true;
|
|
95
|
+
if (invoked && (status === 'pending' || status === 'exception')) {
|
|
96
|
+
needsReconciliation = true;
|
|
97
|
+
}
|
|
98
|
+
current = undefined;
|
|
99
|
+
if (invoked) {
|
|
100
|
+
last = flight;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
history.delete(key);
|
|
104
|
+
}
|
|
105
|
+
// Reentrant release observers can start another operation; only this flight is touched below.
|
|
106
|
+
bridge.setSnapshot(invoked
|
|
107
|
+
? Object.freeze({ kind, status, operationId: id })
|
|
108
|
+
: Object.freeze({ kind, status: 'idle' }));
|
|
109
|
+
observe(() => block?.release(), onObserverError);
|
|
110
|
+
bridge.destroy();
|
|
111
|
+
}
|
|
112
|
+
function progress(value) {
|
|
113
|
+
if (flight.settled || value.kind !== kind) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Whitelist fields: ports cannot leak raw receipts/responses through additional properties.
|
|
117
|
+
const base = { kind, sequence: value.sequence };
|
|
118
|
+
let safe;
|
|
119
|
+
switch (value.phase) {
|
|
120
|
+
case 'platform-result':
|
|
121
|
+
case 'completed':
|
|
122
|
+
safe = Object.freeze({
|
|
123
|
+
...base,
|
|
124
|
+
phase: value.phase,
|
|
125
|
+
status: value.status,
|
|
126
|
+
});
|
|
127
|
+
break;
|
|
128
|
+
case 'server-result':
|
|
129
|
+
safe = Object.freeze({
|
|
130
|
+
...base,
|
|
131
|
+
phase: value.phase,
|
|
132
|
+
accepted: value.accepted,
|
|
133
|
+
});
|
|
134
|
+
break;
|
|
135
|
+
case 'exception':
|
|
136
|
+
safe = Object.freeze({
|
|
137
|
+
...base,
|
|
138
|
+
phase: value.phase,
|
|
139
|
+
at: value.at,
|
|
140
|
+
});
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
safe = Object.freeze({ ...base, phase: value.phase });
|
|
144
|
+
}
|
|
145
|
+
bridge.setSnapshot(Object.freeze({ kind, status: 'running', operationId: id, progress: safe }));
|
|
146
|
+
}
|
|
147
|
+
const flight = {
|
|
148
|
+
id, key, promise, bridge, settled: false,
|
|
149
|
+
start() {
|
|
150
|
+
if (started) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
started = true;
|
|
154
|
+
void (async () => {
|
|
155
|
+
try {
|
|
156
|
+
if (isDisposed() || !canStart()) {
|
|
157
|
+
throw new GameActionExecutionError('disposed');
|
|
158
|
+
}
|
|
159
|
+
block = execution.acquireBlock({ reason: `action:${kind}`, channels: ['simulation', 'gameplay-input'] });
|
|
160
|
+
if (isDisposed() || !canStart()) {
|
|
161
|
+
throw new GameActionExecutionError('disposed');
|
|
162
|
+
}
|
|
163
|
+
invoked = true;
|
|
164
|
+
const result = await (kind === 'purchase'
|
|
165
|
+
? client.purchase(input, { onProgress: (value) => observe(() => progress(value), onObserverError) })
|
|
166
|
+
: client.claimRewardedAd(input, { onProgress: (value) => observe(() => progress(value), onObserverError) }));
|
|
167
|
+
finish(result.status);
|
|
168
|
+
resolve(result);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
finish('exception');
|
|
172
|
+
reject(error);
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
history.set(key, fingerprint);
|
|
178
|
+
current = flight;
|
|
179
|
+
return flight;
|
|
180
|
+
}
|
|
181
|
+
function makeController(kind) {
|
|
182
|
+
if (isDisposed()) {
|
|
183
|
+
throw new GameActionExecutionError('disposed');
|
|
184
|
+
}
|
|
185
|
+
let ownerDisposed = false;
|
|
186
|
+
let snapshot = Object.freeze({ kind, status: 'idle' });
|
|
187
|
+
const bridge = createGameUiBridge({
|
|
188
|
+
initialSnapshot: snapshot,
|
|
189
|
+
...(onObserverError === undefined ? {} : { onListenerError: onObserverError }),
|
|
190
|
+
});
|
|
191
|
+
let trackedId;
|
|
192
|
+
let detach = () => { };
|
|
193
|
+
function execute(input, attach) {
|
|
194
|
+
try {
|
|
195
|
+
if (ownerDisposed) {
|
|
196
|
+
throw new GameActionExecutionError('disposed');
|
|
197
|
+
}
|
|
198
|
+
const flight = reserve(kind, input, () => !ownerDisposed);
|
|
199
|
+
if (trackedId !== flight.id) {
|
|
200
|
+
detach();
|
|
201
|
+
trackedId = flight.id;
|
|
202
|
+
const update = (value) => {
|
|
203
|
+
if (trackedId !== flight.id) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
snapshot = value;
|
|
207
|
+
if (flight.settled) {
|
|
208
|
+
detach();
|
|
209
|
+
detach = () => { };
|
|
210
|
+
}
|
|
211
|
+
if (!ownerDisposed) {
|
|
212
|
+
bridge.setSnapshot(value);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
if (!flight.settled) {
|
|
216
|
+
detach = flight.bridge.subscribeSnapshot(update);
|
|
217
|
+
}
|
|
218
|
+
update(flight.bridge.getSnapshot());
|
|
219
|
+
}
|
|
220
|
+
observe(() => attach?.(flight), onObserverError);
|
|
221
|
+
flight.start();
|
|
222
|
+
return flight.promise;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return Promise.reject(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return Object.freeze({
|
|
229
|
+
execute,
|
|
230
|
+
getSnapshot: () => snapshot,
|
|
231
|
+
subscribe(listener) {
|
|
232
|
+
if (ownerDisposed) {
|
|
233
|
+
throw new GameActionExecutionError('disposed');
|
|
234
|
+
}
|
|
235
|
+
return bridge.subscribeSnapshot(listener);
|
|
236
|
+
},
|
|
237
|
+
isDisposed: () => ownerDisposed,
|
|
238
|
+
dispose() {
|
|
239
|
+
ownerDisposed = true;
|
|
240
|
+
bridge.destroy();
|
|
241
|
+
// Keep the current flight observer until settlement so its authoritative result remains readable.
|
|
242
|
+
},
|
|
243
|
+
bindScope(scope, projection) {
|
|
244
|
+
if (ownerDisposed || scope.isDisposed()) {
|
|
245
|
+
throw new GameActionExecutionError('disposed');
|
|
246
|
+
}
|
|
247
|
+
let viewDisposed = false;
|
|
248
|
+
let viewId;
|
|
249
|
+
let detachView = () => { };
|
|
250
|
+
const dispose = () => {
|
|
251
|
+
viewDisposed = true;
|
|
252
|
+
detachView();
|
|
253
|
+
};
|
|
254
|
+
const release = scope.own(dispose);
|
|
255
|
+
return Object.freeze({
|
|
256
|
+
dispose: release,
|
|
257
|
+
execute(input) {
|
|
258
|
+
if (viewDisposed || scope.isDisposed()) {
|
|
259
|
+
return Promise.reject(new GameActionExecutionError('disposed'));
|
|
260
|
+
}
|
|
261
|
+
return execute(input, (flight) => {
|
|
262
|
+
if (viewId === flight.id || viewDisposed || scope.isDisposed()) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
detachView();
|
|
266
|
+
viewId = flight.id;
|
|
267
|
+
const update = (value) => {
|
|
268
|
+
if (viewDisposed || scope.isDisposed() || viewId !== flight.id) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (flight.settled) {
|
|
272
|
+
detachView();
|
|
273
|
+
detachView = () => { };
|
|
274
|
+
}
|
|
275
|
+
observe(() => {
|
|
276
|
+
scope.setSnapshot(projection.snapshot(value));
|
|
277
|
+
}, onObserverError);
|
|
278
|
+
const projectEvent = projection.event;
|
|
279
|
+
if (value.status !== 'running' && value.status !== 'idle'
|
|
280
|
+
&& !viewDisposed && !scope.isDisposed() && viewId === flight.id && projectEvent !== undefined) {
|
|
281
|
+
observe(() => {
|
|
282
|
+
scope.emit(projectEvent(value));
|
|
283
|
+
}, onObserverError);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
if (!flight.settled) {
|
|
287
|
+
detachView = flight.bridge.subscribeSnapshot(update);
|
|
288
|
+
}
|
|
289
|
+
update(flight.bridge.getSnapshot());
|
|
290
|
+
});
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return Object.freeze({
|
|
297
|
+
createPurchaseController: () => makeController('purchase'),
|
|
298
|
+
createRewardedAdController: () => makeController('rewarded-ad'),
|
|
299
|
+
getAvailability() {
|
|
300
|
+
if (isDisposed()) {
|
|
301
|
+
return 'disposed';
|
|
302
|
+
}
|
|
303
|
+
if (current !== undefined) {
|
|
304
|
+
return 'busy';
|
|
305
|
+
}
|
|
306
|
+
if (needsReconciliation) {
|
|
307
|
+
return 'reconciliation-required';
|
|
308
|
+
}
|
|
309
|
+
return history.size >= capacity ? 'history-full' : 'ready';
|
|
310
|
+
},
|
|
311
|
+
dispose() {
|
|
312
|
+
disposed = true;
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
export function createPurchaseActionController(input) {
|
|
317
|
+
return input.coordinator.createPurchaseController();
|
|
318
|
+
}
|
|
319
|
+
export function createRewardedAdActionController(input) {
|
|
320
|
+
return input.coordinator.createRewardedAdController();
|
|
321
|
+
}
|
package/dist/channels.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ExecutionChannel } from './channels.js';
|
|
2
|
+
import { type ObserverErrorHandler } from './observers.js';
|
|
3
|
+
export type { ExecutionChannel } from './channels.js';
|
|
4
|
+
export type { ObserverErrorHandler } from './observers.js';
|
|
5
|
+
export interface ExecutionBlockInput {
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
readonly channels: readonly ExecutionChannel[];
|
|
8
|
+
}
|
|
9
|
+
export interface ExecutionBlockInfo extends ExecutionBlockInput {
|
|
10
|
+
/** Diagnostic identifier, local to this controller. It cannot release a block. */
|
|
11
|
+
readonly id: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ExecutionBlock {
|
|
14
|
+
readonly info: ExecutionBlockInfo;
|
|
15
|
+
release(): void;
|
|
16
|
+
}
|
|
17
|
+
export interface GameExecutionSnapshot {
|
|
18
|
+
readonly status: 'active' | 'destroyed';
|
|
19
|
+
/** Increments once per acquisition, first release, or first destruction, including diagnostic-only changes. */
|
|
20
|
+
readonly version: number;
|
|
21
|
+
/** True means blocked. Destroyed snapshots fail closed on every channel. */
|
|
22
|
+
readonly blocked: Readonly<Record<ExecutionChannel, boolean>>;
|
|
23
|
+
readonly blocks: readonly ExecutionBlockInfo[];
|
|
24
|
+
}
|
|
25
|
+
export type ExecutionListener = (snapshot: GameExecutionSnapshot) => void | Promise<void>;
|
|
26
|
+
export interface GameExecutionController {
|
|
27
|
+
acquireBlock(input: ExecutionBlockInput): ExecutionBlock;
|
|
28
|
+
getSnapshot(): GameExecutionSnapshot;
|
|
29
|
+
/** Does not deliver the initial snapshot. Read it explicitly after subscribing. */
|
|
30
|
+
subscribe(listener: ExecutionListener): () => void;
|
|
31
|
+
destroy(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function createGameExecutionController(options?: {
|
|
34
|
+
readonly onListenerError?: ObserverErrorHandler;
|
|
35
|
+
}): GameExecutionController;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { executionChannels } from './channels.js';
|
|
2
|
+
import { observe } from './observers.js';
|
|
3
|
+
export function createGameExecutionController(options = {}) {
|
|
4
|
+
const onError = options.onListenerError;
|
|
5
|
+
const blocks = new Map();
|
|
6
|
+
const subscriptions = new Set();
|
|
7
|
+
const queue = [];
|
|
8
|
+
let nextId = 0;
|
|
9
|
+
let destroyed = false;
|
|
10
|
+
let delivering = false;
|
|
11
|
+
let snapshot = makeSnapshot(0);
|
|
12
|
+
function assertActive() {
|
|
13
|
+
if (destroyed) {
|
|
14
|
+
throw new Error('Game execution controller is destroyed.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function makeSnapshot(version) {
|
|
18
|
+
const blocked = {
|
|
19
|
+
simulation: destroyed,
|
|
20
|
+
'gameplay-input': destroyed,
|
|
21
|
+
rendering: destroyed,
|
|
22
|
+
audio: destroyed,
|
|
23
|
+
};
|
|
24
|
+
for (const block of blocks.values()) {
|
|
25
|
+
for (const channel of block.channels) {
|
|
26
|
+
blocked[channel] = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
status: destroyed ? 'destroyed' : 'active',
|
|
31
|
+
version,
|
|
32
|
+
blocked: Object.freeze(blocked),
|
|
33
|
+
blocks: Object.freeze([...blocks.values()]),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function publish() {
|
|
37
|
+
snapshot = makeSnapshot(snapshot.version + 1);
|
|
38
|
+
queue.push({ snapshot, recipients: [...subscriptions] });
|
|
39
|
+
if (delivering) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
delivering = true;
|
|
43
|
+
try {
|
|
44
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
45
|
+
const delivery = queue[cursor];
|
|
46
|
+
if (delivery === undefined) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const subscription of delivery.recipients) {
|
|
50
|
+
// Destruction supersedes all queued active deliveries, including this round.
|
|
51
|
+
if (subscription.active && (!destroyed || delivery.snapshot.status === 'destroyed')) {
|
|
52
|
+
observe(() => subscription.listener(delivery.snapshot), onError);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
queue.length = 0;
|
|
59
|
+
delivering = false;
|
|
60
|
+
if (destroyed) {
|
|
61
|
+
subscriptions.clear();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
acquireBlock(input) {
|
|
67
|
+
assertActive();
|
|
68
|
+
// Copy and validate all caller-controlled data before mutating state.
|
|
69
|
+
const reason = input.reason;
|
|
70
|
+
const requested = input.channels;
|
|
71
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
|
72
|
+
throw new TypeError('Block reason must be a non-empty string.');
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(requested) || requested.length === 0) {
|
|
75
|
+
throw new TypeError('At least one execution channel is required.');
|
|
76
|
+
}
|
|
77
|
+
const selected = [...new Set(requested)];
|
|
78
|
+
if (selected.some((channel) => !executionChannels.includes(channel))) {
|
|
79
|
+
throw new TypeError('Unknown execution channel.');
|
|
80
|
+
}
|
|
81
|
+
// Input getters/iterators may have reentered the controller.
|
|
82
|
+
assertActive();
|
|
83
|
+
const info = Object.freeze({
|
|
84
|
+
id: ++nextId,
|
|
85
|
+
reason,
|
|
86
|
+
channels: Object.freeze(selected),
|
|
87
|
+
});
|
|
88
|
+
blocks.set(info.id, info);
|
|
89
|
+
const token = Object.freeze({
|
|
90
|
+
info,
|
|
91
|
+
release() {
|
|
92
|
+
if (!destroyed && blocks.delete(info.id)) {
|
|
93
|
+
publish();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
publish();
|
|
98
|
+
return token;
|
|
99
|
+
},
|
|
100
|
+
getSnapshot: () => snapshot,
|
|
101
|
+
subscribe(listener) {
|
|
102
|
+
assertActive();
|
|
103
|
+
if (typeof listener !== 'function') {
|
|
104
|
+
throw new TypeError('Execution listener must be a function.');
|
|
105
|
+
}
|
|
106
|
+
const subscription = { listener, active: true };
|
|
107
|
+
subscriptions.add(subscription);
|
|
108
|
+
return () => {
|
|
109
|
+
subscription.active = false;
|
|
110
|
+
subscriptions.delete(subscription);
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
destroy() {
|
|
114
|
+
if (destroyed) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
destroyed = true;
|
|
118
|
+
blocks.clear();
|
|
119
|
+
publish();
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function observe(callback, onError) {
|
|
2
|
+
const report = (error) => {
|
|
3
|
+
try {
|
|
4
|
+
const result = onError?.(error);
|
|
5
|
+
if (result !== undefined) {
|
|
6
|
+
void Promise.resolve(result).catch(() => { });
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// An error observer has no authority to fail a business operation.
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
try {
|
|
14
|
+
const result = callback();
|
|
15
|
+
if (result !== undefined) {
|
|
16
|
+
void Promise.resolve(result).catch(report);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
report(error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type Phaser from 'phaser';
|
|
2
|
+
import type { GameExecutionController, GameExecutionSnapshot } from '../index.js';
|
|
3
|
+
export interface GameplayAudioSink {
|
|
4
|
+
getMuted(): boolean;
|
|
5
|
+
setMuted(muted: boolean): void;
|
|
6
|
+
}
|
|
7
|
+
/** Only this scene's built-in pointer, keyboard, and gamepad plugins are affected. */
|
|
8
|
+
export type GameplayScene = Pick<Phaser.Scene, 'sys' | 'input'>;
|
|
9
|
+
export interface PhaserGameRuntimeBinding {
|
|
10
|
+
dispose(): void;
|
|
11
|
+
}
|
|
12
|
+
export type UnsupportedPhaserExecutionState = 'simulation-requires-input-block' | 'audio-sink-missing';
|
|
13
|
+
export interface BindPhaserGameSceneInput {
|
|
14
|
+
readonly controller: GameExecutionController;
|
|
15
|
+
readonly scene: GameplayScene;
|
|
16
|
+
readonly resetInput: () => void;
|
|
17
|
+
readonly renderingPolicy: 'visibility';
|
|
18
|
+
readonly audio?: GameplayAudioSink;
|
|
19
|
+
readonly uiScope?: {
|
|
20
|
+
dispose(): void;
|
|
21
|
+
};
|
|
22
|
+
/** Phaser pause also suspends input: simulation-only blocking cannot preserve active input. */
|
|
23
|
+
readonly onUnsupportedState: (snapshot: GameExecutionSnapshot, reason: UnsupportedPhaserExecutionState) => void | Promise<void>;
|
|
24
|
+
readonly onError?: (error: unknown) => void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function bindPhaserGameScene(input: BindPhaserGameSceneInput): PhaserGameRuntimeBinding;
|