@mpgd/game-runtime 0.3.0 → 0.4.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/dist/ads/index.js CHANGED
@@ -34,6 +34,7 @@ export function createCoordinatedAdProvider(input) {
34
34
  const protocol = provider.protocol;
35
35
  const protocolVersion = provider.protocolVersion;
36
36
  const rewardSignal = provider.rewardSignal;
37
+ const presentationAudio = provider.presentationAudio;
37
38
  const capacity = input.maxRememberedInvocations ?? 1024;
38
39
  if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 10000) {
39
40
  throw new RangeError('maxRememberedInvocations must be from 1 to 10000.');
@@ -372,7 +373,7 @@ export function createCoordinatedAdProvider(input) {
372
373
  throw new PresentationExecutionError('disposed');
373
374
  }
374
375
  try {
375
- flight.lease = input.presentation.acquire({ kind: request.format, invocationId: request.invocationId });
376
+ flight.lease = input.presentation.acquire({ kind: request.format, invocationId: request.invocationId, audioStart: presentationAudio ?? 'requested' });
376
377
  }
377
378
  catch (error) {
378
379
  if (error instanceof PresentationExecutionError && error.code === 'busy') {
@@ -419,6 +420,7 @@ export function createCoordinatedAdProvider(input) {
419
420
  }
420
421
  return Object.freeze({
421
422
  id: id, protocol: protocol, protocolVersion: protocolVersion, rewardSignal: rewardSignal,
423
+ ...(presentationAudio === undefined ? {} : { presentationAudio }),
422
424
  getAvailability,
423
425
  async preload(supplied) {
424
426
  const request = placement(supplied);
@@ -15,12 +15,18 @@ export interface GameServicePorts {
15
15
  reconcile(): Promise<unknown>;
16
16
  };
17
17
  }
18
+ /** One scene's logical play state; execution blocks remain owned by the game runtime. */
19
+ export interface GameGameplayScope {
20
+ setActive(active: boolean): void;
21
+ dispose(): void;
22
+ }
18
23
  export interface GamePlatformRuntime<T extends PlatformGateway = PlatformGateway, S extends GameServicePorts = GameServicePorts> {
19
24
  readonly gateway: T;
20
25
  readonly execution: GameExecutionController;
21
26
  readonly presentation: FullScreenPresentationScope;
22
27
  readonly services: S;
23
28
  readonly actions?: GameActionCoordinator;
29
+ createGameplayScope(): GameGameplayScope;
24
30
  /** Reconcile existing journal entries only; this method never opens platform UI. */
25
31
  reconcile(): Promise<void>;
26
32
  /** Game teardown; native observers and application-owned late recovery may continue. */
@@ -1,4 +1,4 @@
1
- import { createGameExecutionController } from '../index.js';
1
+ import { createGameExecutionController, } from '../index.js';
2
2
  import { bindGameLifecycle } from '../platform/index.js';
3
3
  import { createFullScreenPresentationScope, } from '../presentation/index.js';
4
4
  import { createAdClaimEvidenceRecoveryObserver, createCoordinatedPlatformGateway, } from '../ads/index.js';
@@ -39,12 +39,81 @@ export function createGamePlatformRuntime(input) {
39
39
  let actions;
40
40
  let unsubscribeResume;
41
41
  let disposed = false;
42
+ let hostAudio;
43
+ let unsubscribeHostAudio;
44
+ const gameplayScopes = new Map();
45
+ const lifecycleReason = 'game-runtime:lifecycle';
46
+ let gameplayActive = false;
47
+ let hasPlayed = false;
48
+ let loading = true;
49
+ let unsubscribeActivity;
50
+ const updateActivity = () => {
51
+ const snapshot = execution.getSnapshot();
52
+ const activity = gateway.gameActivity;
53
+ const playable = [...gameplayScopes.values()].some(Boolean);
54
+ const blocked = snapshot.blocks.some((block) => block.channels.includes('simulation')
55
+ && !(activity?.handlesFocusChanges === true && hasPlayed && block.reason === lifecycleReason));
56
+ const next = !disposed && snapshot.status === 'active' && playable && !blocked;
57
+ if (next && loading) {
58
+ observe(() => {
59
+ activity?.setLoading(false);
60
+ loading = false;
61
+ }, input.onObserverError);
62
+ if (loading) {
63
+ return;
64
+ }
65
+ }
66
+ if (next !== gameplayActive) {
67
+ observe(() => {
68
+ activity?.setGameplayActive(next);
69
+ gameplayActive = next;
70
+ if (next) {
71
+ hasPlayed = true;
72
+ }
73
+ }, input.onObserverError);
74
+ }
75
+ };
76
+ const createGameplayScope = () => {
77
+ const owner = {};
78
+ let ended = disposed;
79
+ if (!ended) {
80
+ gameplayScopes.set(owner, false);
81
+ }
82
+ return Object.freeze({
83
+ setActive(active) {
84
+ if (!ended && !disposed) {
85
+ gameplayScopes.set(owner, active);
86
+ updateActivity();
87
+ }
88
+ },
89
+ dispose() {
90
+ if (!ended) {
91
+ ended = true;
92
+ gameplayScopes.delete(owner);
93
+ updateActivity();
94
+ }
95
+ },
96
+ });
97
+ };
42
98
  const dispose = () => {
43
99
  if (disposed) {
44
100
  return;
45
101
  }
46
102
  disposed = true;
103
+ updateActivity();
104
+ if (loading) {
105
+ observe(() => {
106
+ gateway.gameActivity?.setLoading(false);
107
+ loading = false;
108
+ }, input.onObserverError);
109
+ }
110
+ unsubscribeActivity?.();
111
+ gameplayScopes.clear();
47
112
  execution.destroy();
113
+ if (unsubscribeHostAudio !== undefined) {
114
+ observe(unsubscribeHostAudio, input.onObserverError);
115
+ }
116
+ hostAudio?.release();
48
117
  actions?.dispose();
49
118
  coordinated?.dispose();
50
119
  presentation.dispose();
@@ -83,14 +152,36 @@ export function createGamePlatformRuntime(input) {
83
152
  });
84
153
  }
85
154
  lifecycle = bindGameLifecycle({ controller: execution, source: gateway.lifecycle, initialState: input.initialLifecycleState,
155
+ reason: lifecycleReason,
86
156
  ...(input.onObserverError === undefined ? {} : { onError: input.onObserverError }),
87
157
  });
88
158
  unsubscribeResume = gateway.lifecycle.onResume(() => { observe(reconcile, input.onObserverError); });
159
+ const settings = gateway.gameSettings;
160
+ if (settings !== undefined) {
161
+ const updateMute = (muted) => {
162
+ if (disposed) {
163
+ return;
164
+ }
165
+ if (muted && hostAudio === undefined) {
166
+ hostAudio = execution.acquireBlock({ reason: 'game-runtime:host-audio', channels: ['audio'] });
167
+ }
168
+ else if (!muted && hostAudio !== undefined) {
169
+ const owned = hostAudio;
170
+ hostAudio = undefined;
171
+ owned.release();
172
+ }
173
+ };
174
+ unsubscribeHostAudio = settings.onAudioMuteChange(updateMute);
175
+ updateMute(settings.getAudioMuted());
176
+ }
177
+ observe(() => gateway.gameActivity?.setLoading(true), input.onObserverError);
178
+ unsubscribeActivity = execution.subscribe(updateActivity);
89
179
  return Object.freeze({
90
180
  gateway,
91
181
  execution,
92
182
  presentation,
93
183
  services,
184
+ createGameplayScope,
94
185
  ...(actions === undefined ? {} : { actions }),
95
186
  reconcile,
96
187
  dispose,
@@ -1,4 +1,5 @@
1
1
  import type Phaser from 'phaser';
2
+ import type { GameGameplayScope } from '../game/index.js';
2
3
  import type { GameExecutionController, GameExecutionSnapshot } from '../index.js';
3
4
  export interface GameplayAudioSink {
4
5
  getMuted(): boolean;
@@ -21,6 +22,7 @@ export interface BindPhaserGameSceneInput {
21
22
  readonly uiScope?: {
22
23
  dispose(): void;
23
24
  };
25
+ readonly gameplayScope?: GameGameplayScope;
24
26
  /** Phaser pause also suspends input: simulation-only blocking cannot preserve active input. */
25
27
  readonly onUnsupportedState: (snapshot: GameExecutionSnapshot, reason: UnsupportedPhaserExecutionState) => void | Promise<void>;
26
28
  readonly onError?: (error: unknown) => void | Promise<void>;
@@ -23,6 +23,7 @@ export function bindPhaserGameScene(input) {
23
23
  let ownedVisibility = false;
24
24
  let ownedAudio = false;
25
25
  let inputBlocked = false;
26
+ let externallyInactive = scene.sys.isPaused();
26
27
  const ownedInput = new Set();
27
28
  let unsubscribe = () => { };
28
29
  function report(error) {
@@ -99,6 +100,7 @@ export function bindPhaserGameScene(input) {
99
100
  }
100
101
  disposed = true;
101
102
  attempt(unsubscribe);
103
+ attempt(() => input.gameplayScope?.dispose());
102
104
  // Keep shutdown observation installed while restoration calls into consumer/engine code.
103
105
  if (mode !== 'terminal') {
104
106
  attempt(() => {
@@ -141,11 +143,14 @@ export function bindPhaserGameScene(input) {
141
143
  function onPause() {
142
144
  if (!disposed && !applying) {
143
145
  ownedPause = false;
146
+ externallyInactive = true;
147
+ attempt(() => input.gameplayScope?.setActive(false));
144
148
  }
145
149
  }
146
150
  function onResume() {
147
151
  if (!disposed && !applying) {
148
152
  ownedPause = false;
153
+ externallyInactive = false;
149
154
  apply();
150
155
  }
151
156
  }
@@ -153,8 +158,11 @@ export function bindPhaserGameScene(input) {
153
158
  // External sleep owns activity/visibility until an external wake. Never wake it ourselves.
154
159
  ownedPause = false;
155
160
  ownedVisibility = false;
161
+ externallyInactive = true;
162
+ attempt(() => input.gameplayScope?.setActive(false));
156
163
  }
157
164
  function onWake() {
165
+ externallyInactive = false;
158
166
  if (controller.getSnapshot().blocked['gameplay-input']) {
159
167
  inputBlocked = false;
160
168
  }
@@ -175,6 +183,7 @@ export function bindPhaserGameScene(input) {
175
183
  dispose('terminal');
176
184
  break;
177
185
  }
186
+ attempt(() => input.gameplayScope?.setActive(!externallyInactive && (scene.sys.isActive() || scene.sys.isPaused())));
178
187
  const blocked = snapshot.blocked;
179
188
  const simulationUnsupported = blocked.simulation && !blocked['gameplay-input'];
180
189
  const audioUnsupported = blocked.audio && audio === undefined && input.audioOwner !== 'game';
@@ -23,6 +23,7 @@ export interface FullScreenPresentationScope {
23
23
  acquire(input: {
24
24
  readonly kind: FullScreenPresentationKind;
25
25
  readonly invocationId: string;
26
+ readonly audioStart?: 'requested' | 'started';
26
27
  }): FullScreenPresentationLease;
27
28
  getSnapshot(): FullScreenPresentationSnapshot;
28
29
  subscribe(listener: UiListener<FullScreenPresentationSnapshot>): () => void;
@@ -49,8 +49,10 @@ export function createFullScreenPresentationScope(input) {
49
49
  }
50
50
  const kind = request.kind;
51
51
  const invocationId = request.invocationId;
52
+ const audioStart = request.audioStart ?? 'requested';
52
53
  if (!['purchase', 'rewarded', 'interstitial'].includes(kind)
53
- || typeof invocationId !== 'string' || invocationId.trim() === '' || invocationId.length > 512) {
54
+ || typeof invocationId !== 'string' || invocationId.trim() === '' || invocationId.length > 512
55
+ || (audioStart !== 'requested' && audioStart !== 'started')) {
54
56
  throw new TypeError('Invalid presentation identity.');
55
57
  }
56
58
  if (isDisposed()) {
@@ -66,7 +68,7 @@ export function createFullScreenPresentationScope(input) {
66
68
  owner = owned;
67
69
  try {
68
70
  owned.block = input.execution.acquireBlock({
69
- reason: `presentation:${kind}`, channels: ['simulation', 'gameplay-input', 'audio'],
71
+ reason: `presentation:${kind}`, channels: audioStart === 'started' ? ['simulation', 'gameplay-input'] : ['simulation', 'gameplay-input', 'audio'],
70
72
  });
71
73
  }
72
74
  catch (error) {
@@ -75,16 +77,46 @@ export function createFullScreenPresentationScope(input) {
75
77
  }
76
78
  throw error;
77
79
  }
80
+ let acquiringAudio = false;
81
+ const ensureAudio = () => {
82
+ if (audioStart !== 'started' || acquiringAudio || owned.audioBlock !== undefined || input.execution.getSnapshot().status === 'destroyed') {
83
+ return;
84
+ }
85
+ acquiringAudio = true;
86
+ try {
87
+ const block = input.execution.acquireBlock({ reason: `presentation:${kind}:audio`, channels: ['audio'] });
88
+ if (owner === owned) {
89
+ owned.audioBlock = block;
90
+ }
91
+ else {
92
+ block.release();
93
+ }
94
+ }
95
+ finally {
96
+ acquiringAudio = false;
97
+ }
98
+ };
78
99
  publish();
79
100
  return Object.freeze({
80
101
  markStarted() {
81
102
  if (owner === owned && owned.info.state !== 'open') {
103
+ const previousInfo = owned.info;
104
+ ensureAudio();
105
+ if (owner !== owned || owned.info !== previousInfo) {
106
+ return;
107
+ }
82
108
  owned.info = Object.freeze({ ...owned.info, state: 'open' });
83
109
  publish();
84
110
  }
85
111
  },
86
112
  markUnknown() {
87
113
  if (owner === owned && owned.info.state !== 'unknown') {
114
+ const previousInfo = owned.info;
115
+ // Lost native observations quarantine audio as well as execution.
116
+ ensureAudio();
117
+ if (owner !== owned || owned.info !== previousInfo) {
118
+ return;
119
+ }
88
120
  owned.info = Object.freeze({ ...owned.info, state: 'unknown' });
89
121
  publish();
90
122
  }
@@ -95,6 +127,7 @@ export function createFullScreenPresentationScope(input) {
95
127
  }
96
128
  owner = undefined;
97
129
  publish();
130
+ owned.audioBlock?.release();
98
131
  owned.block?.release();
99
132
  },
100
133
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Gameplay execution, scoped UI and action coordination with optional Phaser scene bindings.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -64,8 +64,8 @@
64
64
  "@types/node": "^24.0.0"
65
65
  },
66
66
  "dependencies": {
67
- "@mpgd/game-services": "0.18.0",
68
- "@mpgd/platform": "0.15.0"
67
+ "@mpgd/game-services": "0.18.1",
68
+ "@mpgd/platform": "0.16.0"
69
69
  },
70
70
  "repository": {
71
71
  "type": "git",