rn-gamekit 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.
Files changed (231) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -0
  4. package/lib/module/assets/defineAssets.js +48 -0
  5. package/lib/module/assets/defineAssets.js.map +1 -0
  6. package/lib/module/assets/errors.js +38 -0
  7. package/lib/module/assets/errors.js.map +1 -0
  8. package/lib/module/assets/types.js +2 -0
  9. package/lib/module/assets/types.js.map +1 -0
  10. package/lib/module/assets/validation.js +99 -0
  11. package/lib/module/assets/validation.js.map +1 -0
  12. package/lib/module/core/frameDriver.js +20 -0
  13. package/lib/module/core/frameDriver.js.map +1 -0
  14. package/lib/module/core/input/createInputBuffer.js +296 -0
  15. package/lib/module/core/input/createInputBuffer.js.map +1 -0
  16. package/lib/module/core/input/types.js +4 -0
  17. package/lib/module/core/input/types.js.map +1 -0
  18. package/lib/module/core/session/createGameSession.js +554 -0
  19. package/lib/module/core/session/createGameSession.js.map +1 -0
  20. package/lib/module/core/session/deepFreeze.js +155 -0
  21. package/lib/module/core/session/deepFreeze.js.map +1 -0
  22. package/lib/module/core/session/diagnostics.js +2 -0
  23. package/lib/module/core/session/diagnostics.js.map +1 -0
  24. package/lib/module/core/session/types.js +43 -0
  25. package/lib/module/core/session/types.js.map +1 -0
  26. package/lib/module/definition/defineGame.js +57 -0
  27. package/lib/module/definition/defineGame.js.map +1 -0
  28. package/lib/module/definition/types.js +4 -0
  29. package/lib/module/definition/types.js.map +1 -0
  30. package/lib/module/geometry/types.js +2 -0
  31. package/lib/module/geometry/types.js.map +1 -0
  32. package/lib/module/index.js +12 -0
  33. package/lib/module/index.js.map +1 -0
  34. package/lib/module/package.json +1 -0
  35. package/lib/module/react/GamePointerInput.js +385 -0
  36. package/lib/module/react/GamePointerInput.js.map +1 -0
  37. package/lib/module/react/GameView.js +223 -0
  38. package/lib/module/react/GameView.js.map +1 -0
  39. package/lib/module/react/alphaClock.js +42 -0
  40. package/lib/module/react/alphaClock.js.map +1 -0
  41. package/lib/module/react/assets/createGameAssetStore.js +358 -0
  42. package/lib/module/react/assets/createGameAssetStore.js.map +1 -0
  43. package/lib/module/react/assets/decodeSkiaImage.js +54 -0
  44. package/lib/module/react/assets/decodeSkiaImage.js.map +1 -0
  45. package/lib/module/react/assets/errors.js +10 -0
  46. package/lib/module/react/assets/errors.js.map +1 -0
  47. package/lib/module/react/assets/useGameAssets.js +160 -0
  48. package/lib/module/react/assets/useGameAssets.js.map +1 -0
  49. package/lib/module/react/bindAppLifecycle.js +58 -0
  50. package/lib/module/react/bindAppLifecycle.js.map +1 -0
  51. package/lib/module/react/bindGameSession.js +37 -0
  52. package/lib/module/react/bindGameSession.js.map +1 -0
  53. package/lib/module/react/gestureLifecycle.js +68 -0
  54. package/lib/module/react/gestureLifecycle.js.map +1 -0
  55. package/lib/module/react/instrumentation.js +4 -0
  56. package/lib/module/react/instrumentation.js.map +1 -0
  57. package/lib/module/react/pointerBinding.js +196 -0
  58. package/lib/module/react/pointerBinding.js.map +1 -0
  59. package/lib/module/react/pointerCoalescer.js +224 -0
  60. package/lib/module/react/pointerCoalescer.js.map +1 -0
  61. package/lib/module/react/pointerContainment.js +27 -0
  62. package/lib/module/react/pointerContainment.js.map +1 -0
  63. package/lib/module/react/sprites/GameSprite.js +116 -0
  64. package/lib/module/react/sprites/GameSprite.js.map +1 -0
  65. package/lib/module/react/sprites/GameWorld2D.js +44 -0
  66. package/lib/module/react/sprites/GameWorld2D.js.map +1 -0
  67. package/lib/module/react/sprites/Sprite.js +147 -0
  68. package/lib/module/react/sprites/Sprite.js.map +1 -0
  69. package/lib/module/react/sprites/SpriteBatch.js +173 -0
  70. package/lib/module/react/sprites/SpriteBatch.js.map +1 -0
  71. package/lib/module/react/sprites/spriteBatchPolicy.js +25 -0
  72. package/lib/module/react/sprites/spriteBatchPolicy.js.map +1 -0
  73. package/lib/module/react/sprites/spriteTransform.js +135 -0
  74. package/lib/module/react/sprites/spriteTransform.js.map +1 -0
  75. package/lib/module/react/viewportBinding.js +101 -0
  76. package/lib/module/react/viewportBinding.js.map +1 -0
  77. package/lib/module/react.js +13 -0
  78. package/lib/module/react.js.map +1 -0
  79. package/lib/module/scene/defineScene.js +29 -0
  80. package/lib/module/scene/defineScene.js.map +1 -0
  81. package/lib/module/scene/types.js +4 -0
  82. package/lib/module/scene/types.js.map +1 -0
  83. package/lib/module/sprites/sampleSpriteClip.js +50 -0
  84. package/lib/module/sprites/sampleSpriteClip.js.map +1 -0
  85. package/lib/module/sprites/spriteAnimationState.js +165 -0
  86. package/lib/module/sprites/spriteAnimationState.js.map +1 -0
  87. package/lib/module/testing.js +57 -0
  88. package/lib/module/testing.js.map +1 -0
  89. package/lib/module/viewport2d/index.js +4 -0
  90. package/lib/module/viewport2d/index.js.map +1 -0
  91. package/lib/module/viewport2d/math.js +129 -0
  92. package/lib/module/viewport2d/math.js.map +1 -0
  93. package/lib/module/viewport2d/types.js +2 -0
  94. package/lib/module/viewport2d/types.js.map +1 -0
  95. package/lib/typescript/package.json +1 -0
  96. package/lib/typescript/src/assets/defineAssets.d.ts +20 -0
  97. package/lib/typescript/src/assets/defineAssets.d.ts.map +1 -0
  98. package/lib/typescript/src/assets/errors.d.ts +20 -0
  99. package/lib/typescript/src/assets/errors.d.ts.map +1 -0
  100. package/lib/typescript/src/assets/types.d.ts +138 -0
  101. package/lib/typescript/src/assets/types.d.ts.map +1 -0
  102. package/lib/typescript/src/assets/validation.d.ts +15 -0
  103. package/lib/typescript/src/assets/validation.d.ts.map +1 -0
  104. package/lib/typescript/src/core/frameDriver.d.ts +12 -0
  105. package/lib/typescript/src/core/frameDriver.d.ts.map +1 -0
  106. package/lib/typescript/src/core/input/createInputBuffer.d.ts +12 -0
  107. package/lib/typescript/src/core/input/createInputBuffer.d.ts.map +1 -0
  108. package/lib/typescript/src/core/input/types.d.ts +64 -0
  109. package/lib/typescript/src/core/input/types.d.ts.map +1 -0
  110. package/lib/typescript/src/core/session/createGameSession.d.ts +18 -0
  111. package/lib/typescript/src/core/session/createGameSession.d.ts.map +1 -0
  112. package/lib/typescript/src/core/session/deepFreeze.d.ts +27 -0
  113. package/lib/typescript/src/core/session/deepFreeze.d.ts.map +1 -0
  114. package/lib/typescript/src/core/session/diagnostics.d.ts +36 -0
  115. package/lib/typescript/src/core/session/diagnostics.d.ts.map +1 -0
  116. package/lib/typescript/src/core/session/types.d.ts +104 -0
  117. package/lib/typescript/src/core/session/types.d.ts.map +1 -0
  118. package/lib/typescript/src/definition/defineGame.d.ts +66 -0
  119. package/lib/typescript/src/definition/defineGame.d.ts.map +1 -0
  120. package/lib/typescript/src/definition/types.d.ts +56 -0
  121. package/lib/typescript/src/definition/types.d.ts.map +1 -0
  122. package/lib/typescript/src/geometry/types.d.ts +8 -0
  123. package/lib/typescript/src/geometry/types.d.ts.map +1 -0
  124. package/lib/typescript/src/index.d.ts +18 -0
  125. package/lib/typescript/src/index.d.ts.map +1 -0
  126. package/lib/typescript/src/react/GamePointerInput.d.ts +37 -0
  127. package/lib/typescript/src/react/GamePointerInput.d.ts.map +1 -0
  128. package/lib/typescript/src/react/GameView.d.ts +61 -0
  129. package/lib/typescript/src/react/GameView.d.ts.map +1 -0
  130. package/lib/typescript/src/react/alphaClock.d.ts +29 -0
  131. package/lib/typescript/src/react/alphaClock.d.ts.map +1 -0
  132. package/lib/typescript/src/react/assets/createGameAssetStore.d.ts +33 -0
  133. package/lib/typescript/src/react/assets/createGameAssetStore.d.ts.map +1 -0
  134. package/lib/typescript/src/react/assets/decodeSkiaImage.d.ts +12 -0
  135. package/lib/typescript/src/react/assets/decodeSkiaImage.d.ts.map +1 -0
  136. package/lib/typescript/src/react/assets/errors.d.ts +8 -0
  137. package/lib/typescript/src/react/assets/errors.d.ts.map +1 -0
  138. package/lib/typescript/src/react/assets/useGameAssets.d.ts +35 -0
  139. package/lib/typescript/src/react/assets/useGameAssets.d.ts.map +1 -0
  140. package/lib/typescript/src/react/bindAppLifecycle.d.ts +32 -0
  141. package/lib/typescript/src/react/bindAppLifecycle.d.ts.map +1 -0
  142. package/lib/typescript/src/react/bindGameSession.d.ts +14 -0
  143. package/lib/typescript/src/react/bindGameSession.d.ts.map +1 -0
  144. package/lib/typescript/src/react/gestureLifecycle.d.ts +37 -0
  145. package/lib/typescript/src/react/gestureLifecycle.d.ts.map +1 -0
  146. package/lib/typescript/src/react/instrumentation.d.ts +47 -0
  147. package/lib/typescript/src/react/instrumentation.d.ts.map +1 -0
  148. package/lib/typescript/src/react/pointerBinding.d.ts +104 -0
  149. package/lib/typescript/src/react/pointerBinding.d.ts.map +1 -0
  150. package/lib/typescript/src/react/pointerCoalescer.d.ts +106 -0
  151. package/lib/typescript/src/react/pointerCoalescer.d.ts.map +1 -0
  152. package/lib/typescript/src/react/pointerContainment.d.ts +12 -0
  153. package/lib/typescript/src/react/pointerContainment.d.ts.map +1 -0
  154. package/lib/typescript/src/react/sprites/GameSprite.d.ts +75 -0
  155. package/lib/typescript/src/react/sprites/GameSprite.d.ts.map +1 -0
  156. package/lib/typescript/src/react/sprites/GameWorld2D.d.ts +29 -0
  157. package/lib/typescript/src/react/sprites/GameWorld2D.d.ts.map +1 -0
  158. package/lib/typescript/src/react/sprites/Sprite.d.ts +43 -0
  159. package/lib/typescript/src/react/sprites/Sprite.d.ts.map +1 -0
  160. package/lib/typescript/src/react/sprites/SpriteBatch.d.ts +41 -0
  161. package/lib/typescript/src/react/sprites/SpriteBatch.d.ts.map +1 -0
  162. package/lib/typescript/src/react/sprites/spriteBatchPolicy.d.ts +10 -0
  163. package/lib/typescript/src/react/sprites/spriteBatchPolicy.d.ts.map +1 -0
  164. package/lib/typescript/src/react/sprites/spriteTransform.d.ts +86 -0
  165. package/lib/typescript/src/react/sprites/spriteTransform.d.ts.map +1 -0
  166. package/lib/typescript/src/react/viewportBinding.d.ts +42 -0
  167. package/lib/typescript/src/react/viewportBinding.d.ts.map +1 -0
  168. package/lib/typescript/src/react.d.ts +20 -0
  169. package/lib/typescript/src/react.d.ts.map +1 -0
  170. package/lib/typescript/src/scene/defineScene.d.ts +26 -0
  171. package/lib/typescript/src/scene/defineScene.d.ts.map +1 -0
  172. package/lib/typescript/src/scene/types.d.ts +71 -0
  173. package/lib/typescript/src/scene/types.d.ts.map +1 -0
  174. package/lib/typescript/src/sprites/sampleSpriteClip.d.ts +24 -0
  175. package/lib/typescript/src/sprites/sampleSpriteClip.d.ts.map +1 -0
  176. package/lib/typescript/src/sprites/spriteAnimationState.d.ts +45 -0
  177. package/lib/typescript/src/sprites/spriteAnimationState.d.ts.map +1 -0
  178. package/lib/typescript/src/testing.d.ts +28 -0
  179. package/lib/typescript/src/testing.d.ts.map +1 -0
  180. package/lib/typescript/src/viewport2d/index.d.ts +3 -0
  181. package/lib/typescript/src/viewport2d/index.d.ts.map +1 -0
  182. package/lib/typescript/src/viewport2d/math.d.ts +25 -0
  183. package/lib/typescript/src/viewport2d/math.d.ts.map +1 -0
  184. package/lib/typescript/src/viewport2d/types.d.ts +59 -0
  185. package/lib/typescript/src/viewport2d/types.d.ts.map +1 -0
  186. package/package.json +120 -0
  187. package/src/assets/defineAssets.ts +62 -0
  188. package/src/assets/errors.ts +64 -0
  189. package/src/assets/types.ts +164 -0
  190. package/src/assets/validation.ts +158 -0
  191. package/src/core/frameDriver.ts +31 -0
  192. package/src/core/input/createInputBuffer.ts +337 -0
  193. package/src/core/input/types.ts +67 -0
  194. package/src/core/session/createGameSession.ts +652 -0
  195. package/src/core/session/deepFreeze.ts +158 -0
  196. package/src/core/session/diagnostics.ts +35 -0
  197. package/src/core/session/types.ts +122 -0
  198. package/src/definition/defineGame.ts +94 -0
  199. package/src/definition/types.ts +67 -0
  200. package/src/geometry/types.ts +7 -0
  201. package/src/index.ts +84 -0
  202. package/src/react/GamePointerInput.tsx +478 -0
  203. package/src/react/GameView.tsx +317 -0
  204. package/src/react/alphaClock.ts +59 -0
  205. package/src/react/assets/createGameAssetStore.ts +459 -0
  206. package/src/react/assets/decodeSkiaImage.ts +64 -0
  207. package/src/react/assets/errors.ts +7 -0
  208. package/src/react/assets/useGameAssets.ts +171 -0
  209. package/src/react/bindAppLifecycle.ts +79 -0
  210. package/src/react/bindGameSession.ts +41 -0
  211. package/src/react/gestureLifecycle.ts +73 -0
  212. package/src/react/instrumentation.ts +54 -0
  213. package/src/react/pointerBinding.ts +226 -0
  214. package/src/react/pointerCoalescer.ts +234 -0
  215. package/src/react/pointerContainment.ts +23 -0
  216. package/src/react/sprites/GameSprite.tsx +192 -0
  217. package/src/react/sprites/GameWorld2D.tsx +46 -0
  218. package/src/react/sprites/Sprite.tsx +194 -0
  219. package/src/react/sprites/SpriteBatch.tsx +219 -0
  220. package/src/react/sprites/spriteBatchPolicy.ts +21 -0
  221. package/src/react/sprites/spriteTransform.ts +163 -0
  222. package/src/react/viewportBinding.ts +104 -0
  223. package/src/react.ts +37 -0
  224. package/src/scene/defineScene.ts +38 -0
  225. package/src/scene/types.ts +79 -0
  226. package/src/sprites/sampleSpriteClip.ts +51 -0
  227. package/src/sprites/spriteAnimationState.ts +176 -0
  228. package/src/testing.ts +61 -0
  229. package/src/viewport2d/index.ts +7 -0
  230. package/src/viewport2d/math.ts +134 -0
  231. package/src/viewport2d/types.ts +63 -0
@@ -0,0 +1,652 @@
1
+ import type { GameDefinition, InputMap, SceneMap } from '../../definition/types';
2
+ import type { SceneTransitionController } from '../../scene/types';
3
+ import { createAnimationFrameDriver, type FrameDriver, type FrameHandle } from '../frameDriver';
4
+ import { createInputBuffer } from '../input/createInputBuffer';
5
+ import type { InputFrame } from '../input/types';
6
+ import {
7
+ GameSessionDisposedError,
8
+ GameSessionLifecycleError,
9
+ type CommitFrame,
10
+ type DeepReadonly,
11
+ type GameRenderFrame,
12
+ type GameSession,
13
+ type GameSessionStatus,
14
+ } from './types';
15
+ import type { SessionDiagnostics } from './diagnostics';
16
+ import { createDeepFreeze, type DeepFreezer } from './deepFreeze';
17
+
18
+ const DEFAULT_FIXED_STEP_MS = 1000 / 60;
19
+ const DEFAULT_MAX_CATCH_UP_STEPS = 5;
20
+
21
+ interface SessionOptions {
22
+ readonly frameDriver: FrameDriver;
23
+ readonly fixedStepMs?: number;
24
+ readonly maxCatchUpSteps?: number;
25
+ readonly maxFrameDeltaMs?: number;
26
+ /** @internal Performance instrumentation for the playground Performance Lab. */
27
+ readonly diagnostics?: SessionDiagnostics;
28
+ }
29
+
30
+ interface ErasedScene {
31
+ readonly actions: readonly string[];
32
+ readonly transitions: readonly string[];
33
+ create(): unknown;
34
+ update(frame: {
35
+ readonly state: unknown;
36
+ readonly input: InputFrame<string>;
37
+ readonly transition: SceneTransitionController<string>;
38
+ readonly tick: number;
39
+ readonly sceneTick: number;
40
+ readonly deltaSeconds: number;
41
+ readonly elapsedSeconds: number;
42
+ readonly sceneElapsedSeconds: number;
43
+ }): unknown;
44
+ snapshot(context: { readonly state: unknown }): unknown;
45
+ dispose?(state: unknown): void;
46
+ }
47
+
48
+ interface ActiveScene {
49
+ readonly name: string;
50
+ readonly definition: ErasedScene;
51
+ state: unknown;
52
+ sceneTick: number;
53
+ }
54
+
55
+ type TransitionIntent =
56
+ | { readonly kind: 'setScene'; readonly name: string }
57
+ | { readonly kind: 'restart' };
58
+
59
+ interface UpdateScope {
60
+ intent?: TransitionIntent;
61
+ }
62
+
63
+ function now(): number {
64
+ return typeof performance !== 'undefined' ? performance.now() : 0;
65
+ }
66
+
67
+ function freezeObject<T>(value: T): T {
68
+ return typeof value === 'object' && value !== null ? Object.freeze(value) : value;
69
+ }
70
+
71
+ function describeIntent(intent: TransitionIntent): string {
72
+ return intent.kind === 'restart' ? 'restartScene()' : `setScene("${intent.name}")`;
73
+ }
74
+
75
+ function sameIntent(a: TransitionIntent, b: TransitionIntent): boolean {
76
+ if (a.kind === 'restart') {
77
+ return b.kind === 'restart';
78
+ }
79
+ return b.kind === 'setScene' && a.name === b.name;
80
+ }
81
+
82
+ /** Create a live session using the platform animation-frame driver. */
83
+ export function createGameSession<
84
+ const TScenes extends SceneMap,
85
+ const TInput extends InputMap,
86
+ const TInitialScene extends keyof TScenes,
87
+ >(
88
+ definition: GameDefinition<TScenes, TInput, TInitialScene>,
89
+ ): GameSession<TScenes, TInput> {
90
+ return createGameSessionWithDriver(definition, {
91
+ frameDriver: createAnimationFrameDriver(),
92
+ });
93
+ }
94
+
95
+ /** @internal Deterministic construction seam used by the headless test suite. */
96
+ export function createGameSessionWithDriver<
97
+ const TScenes extends SceneMap,
98
+ const TInput extends InputMap,
99
+ const TInitialScene extends keyof TScenes,
100
+ >(
101
+ definition: GameDefinition<TScenes, TInput, TInitialScene>,
102
+ options: SessionOptions,
103
+ ): GameSession<TScenes, TInput> {
104
+ const fixedStepMs = options.fixedStepMs ?? DEFAULT_FIXED_STEP_MS;
105
+ const maxCatchUpSteps = options.maxCatchUpSteps ?? DEFAULT_MAX_CATCH_UP_STEPS;
106
+ const maxFrameDeltaMs = options.maxFrameDeltaMs ?? fixedStepMs * DEFAULT_MAX_CATCH_UP_STEPS;
107
+ // F4: keep diagnostics optional and guard every measurement — the disabled
108
+ // path must perform no timing reads, callbacks, or wrapper allocations.
109
+ const diagnostics = options.diagnostics;
110
+ const deepFreeze: DeepFreezer = createDeepFreeze();
111
+
112
+ if (!(fixedStepMs > 0) || !Number.isFinite(fixedStepMs)) {
113
+ throw new RangeError('fixedStepMs must be a finite positive number');
114
+ }
115
+ if (!Number.isInteger(maxCatchUpSteps) || maxCatchUpSteps < 1) {
116
+ throw new RangeError('maxCatchUpSteps must be a positive integer');
117
+ }
118
+ if (!(maxFrameDeltaMs > 0) || !Number.isFinite(maxFrameDeltaMs)) {
119
+ throw new RangeError('maxFrameDeltaMs must be a finite positive number');
120
+ }
121
+
122
+ const erasedSceneMap: Record<string, ErasedScene> = {};
123
+ for (const [name, scene] of Object.entries(definition.scenes)) {
124
+ const erased = scene as unknown as ErasedScene;
125
+ for (const action of erased.actions) {
126
+ if (!Object.hasOwn(definition.input, action)) {
127
+ throw new Error(`Scene "${name}" uses undeclared input action: ${action}`);
128
+ }
129
+ }
130
+ for (const target of erased.transitions ?? []) {
131
+ if (!Object.hasOwn(definition.scenes, target)) {
132
+ throw new Error(`Scene "${name}" declares an unknown transition target: ${target}`);
133
+ }
134
+ }
135
+ erasedSceneMap[name] = erased;
136
+ }
137
+
138
+ // Create the initial scene instance synchronously.
139
+ const initialSceneName = String(definition.initialScene);
140
+ const initialDefinition = erasedSceneMap[initialSceneName];
141
+ if (initialDefinition === undefined) {
142
+ throw new Error(`Unknown initial scene: ${initialSceneName}`);
143
+ }
144
+ const initialState = freezeObject(initialDefinition.create());
145
+ let initialSnapshot: DeepReadonly<unknown>;
146
+ try {
147
+ initialSnapshot = deepFreeze(initialDefinition.snapshot({ state: initialState }));
148
+ } catch (error) {
149
+ initialDefinition.dispose?.(initialState);
150
+ throw error;
151
+ }
152
+
153
+ let activeScene: ActiveScene = {
154
+ name: initialSceneName,
155
+ definition: initialDefinition,
156
+ state: initialState,
157
+ sceneTick: 0,
158
+ };
159
+ let sceneName: string = initialSceneName;
160
+ let currentSnapshot: DeepReadonly<unknown> = initialSnapshot;
161
+ let previousSnapshot: DeepReadonly<unknown> = initialSnapshot;
162
+
163
+ let status: GameSessionStatus = 'idle';
164
+ let generation = 0;
165
+ let frameHandle: FrameHandle | undefined;
166
+ let previousTimestampMs: number | undefined;
167
+ let accumulatorMs = 0;
168
+ let tick = 0;
169
+ let pendingTransition: TransitionIntent | undefined;
170
+ let hardCutPending = false;
171
+ let publishedThisCallback = false;
172
+ let updateInProgress = false;
173
+ let revision = 0;
174
+ let commitFrame: CommitFrame<TScenes> = Object.freeze({
175
+ scene: sceneName,
176
+ previous: previousSnapshot,
177
+ current: currentSnapshot,
178
+ tick: 0,
179
+ elapsedSeconds: 0,
180
+ revision: 0,
181
+ hardCut: false,
182
+ stepMs: fixedStepMs,
183
+ }) as unknown as CommitFrame<TScenes>;
184
+ const listeners = new Set<(frame: CommitFrame<TScenes>) => void>();
185
+
186
+ const assertLive = () => {
187
+ if (status === 'disposed') {
188
+ throw new GameSessionDisposedError();
189
+ }
190
+ };
191
+ const isDisposed = () => status === 'disposed';
192
+ const inputBuffer = createInputBuffer(definition.input, assertLive);
193
+
194
+ // Simulation commits are the only presentation updates (T5): a commit
195
+ // happens on the initial baseline, after every presentation callback that
196
+ // ran at least one fixed step (one commit per callback, carrying the final
197
+ // adjacent snapshot pair), and on every transition/restart. Zero-step
198
+ // callbacks allocate nothing beyond scheduling their successor.
199
+ const commit = () => {
200
+ const hardCut = hardCutPending;
201
+ hardCutPending = false;
202
+ publishedThisCallback = true;
203
+ const publishStart = diagnostics === undefined ? 0 : now();
204
+ const frame = Object.freeze({
205
+ scene: sceneName,
206
+ previous: previousSnapshot,
207
+ current: currentSnapshot,
208
+ tick,
209
+ elapsedSeconds: (tick * fixedStepMs) / 1000,
210
+ revision: ++revision,
211
+ hardCut,
212
+ stepMs: fixedStepMs,
213
+ }) as unknown as CommitFrame<TScenes>;
214
+ commitFrame = frame;
215
+ for (const listener of [...listeners]) {
216
+ listener(frame);
217
+ }
218
+ if (diagnostics !== undefined) {
219
+ diagnostics.onPublish(now() - publishStart);
220
+ diagnostics.onCommitNotification();
221
+ diagnostics.onListenerCount(listeners.size);
222
+ }
223
+ };
224
+
225
+ const pauseInternal = () => {
226
+ if (status !== 'running') {
227
+ return;
228
+ }
229
+ status = 'paused';
230
+ generation += 1;
231
+ if (frameHandle !== undefined) {
232
+ options.frameDriver.cancelFrame(frameHandle);
233
+ frameHandle = undefined;
234
+ }
235
+ previousTimestampMs = undefined;
236
+ accumulatorMs = 0;
237
+ inputBuffer.reset();
238
+ };
239
+
240
+ const commitOrPause = () => {
241
+ try {
242
+ commit();
243
+ } catch (error) {
244
+ pauseInternal();
245
+ throw error;
246
+ }
247
+ };
248
+
249
+ /**
250
+ * Transition to `targetName` following the deterministic ordering:
251
+ * prepare the target -> dispose the outgoing scene exactly once -> advance
252
+ * session time when the request came from a successful update -> install
253
+ * the new scene with scene-local time reset -> clear scene-local input edges
254
+ * and interpolation debt -> install hard-cut snapshots. An update-scoped
255
+ * transition may retain a physically active pointer when the target scene
256
+ * consumes the same action; external transitions still perform a full reset.
257
+ *
258
+ * On any preparation or disposal failure the old scene and its state are
259
+ * retained, session tick/time are left unchanged, and the original error is
260
+ * rethrown after cleaning up any partially created target.
261
+ */
262
+ const commitTransition = (
263
+ intent: TransitionIntent,
264
+ outgoingFinalState: unknown,
265
+ advanceTimeTick: number | undefined,
266
+ preserveActivePointers = false,
267
+ ) => {
268
+ const targetName = intent.kind === 'restart' ? activeScene.name : intent.name;
269
+ const targetDefinition = erasedSceneMap[targetName];
270
+ if (targetDefinition === undefined) {
271
+ throw new GameSessionLifecycleError(`Unknown scene: ${targetName}`);
272
+ }
273
+
274
+ let targetState: unknown;
275
+ let targetCreated = false;
276
+ let targetSnapshot: DeepReadonly<unknown>;
277
+ try {
278
+ targetState = freezeObject(targetDefinition.create());
279
+ targetCreated = true;
280
+ targetSnapshot = deepFreeze(targetDefinition.snapshot({ state: targetState }));
281
+ } catch (error) {
282
+ if (targetCreated) {
283
+ try {
284
+ targetDefinition.dispose?.(targetState);
285
+ } catch {
286
+ // Cleanup failures during failure handling are best effort.
287
+ }
288
+ }
289
+ throw error;
290
+ }
291
+
292
+ try {
293
+ activeScene.definition.dispose?.(outgoingFinalState);
294
+ } catch (error) {
295
+ // The outgoing scene's dispose already ran (possibly partially) and is
296
+ // not retried; the old scene remains active with that honest caveat.
297
+ if (targetCreated) {
298
+ try {
299
+ targetDefinition.dispose?.(targetState);
300
+ } catch {
301
+ // Best effort.
302
+ }
303
+ }
304
+ throw error;
305
+ }
306
+
307
+ if (advanceTimeTick !== undefined) {
308
+ tick = advanceTimeTick;
309
+ }
310
+ activeScene = {
311
+ name: targetName,
312
+ definition: targetDefinition,
313
+ state: targetState,
314
+ sceneTick: 0,
315
+ };
316
+ sceneName = targetName;
317
+ currentSnapshot = targetSnapshot;
318
+ previousSnapshot = targetSnapshot;
319
+ if (preserveActivePointers) {
320
+ inputBuffer.resetForTransition(targetDefinition.actions);
321
+ } else {
322
+ inputBuffer.reset();
323
+ }
324
+ // Keep the accumulated timing fraction: discarding it would make the tick
325
+ // count depend on presentation rate (acceptance criterion 11). The hard cut
326
+ // is still published with alpha 0 via `hardCutPending`.
327
+ hardCutPending = true;
328
+ };
329
+
330
+ const requestPendingTransition = (intent: TransitionIntent) => {
331
+ if (pendingTransition !== undefined && !sameIntent(pendingTransition, intent)) {
332
+ throw new GameSessionLifecycleError(
333
+ `Conflicting pending scene transition (${describeIntent(pendingTransition)} then ${describeIntent(intent)})`,
334
+ );
335
+ }
336
+ pendingTransition = intent;
337
+ };
338
+
339
+ let activeUpdateScope: UpdateScope | undefined;
340
+
341
+ const makeTransitionController = (scope: UpdateScope): SceneTransitionController<string> => {
342
+ const assertActive = () => {
343
+ if (activeUpdateScope !== scope) {
344
+ throw new GameSessionLifecycleError(
345
+ 'Scene transition controller is only valid during its owning scene update',
346
+ );
347
+ }
348
+ };
349
+ return Object.freeze({
350
+ setScene(name: string) {
351
+ assertActive();
352
+ if (name === activeScene.name) {
353
+ return; // Idempotent no-op.
354
+ }
355
+ if (!Object.hasOwn(definition.scenes, name)) {
356
+ throw new GameSessionLifecycleError(`Unknown scene: ${name}`);
357
+ }
358
+ const declared = activeScene.definition.transitions ?? [];
359
+ if (!declared.includes(name)) {
360
+ throw new GameSessionLifecycleError(
361
+ `Scene "${activeScene.name}" can only transition to its declared targets (missing "${name}")`,
362
+ );
363
+ }
364
+ if (
365
+ scope.intent !== undefined &&
366
+ !(scope.intent.kind === 'setScene' && scope.intent.name === name)
367
+ ) {
368
+ throw new GameSessionLifecycleError(
369
+ `Conflicting scene transition requests in one update (${describeIntent(scope.intent)} then setScene("${name}"))`,
370
+ );
371
+ }
372
+ scope.intent = { kind: 'setScene', name };
373
+ },
374
+ restartScene() {
375
+ assertActive();
376
+ if (scope.intent !== undefined && scope.intent.kind !== 'restart') {
377
+ throw new GameSessionLifecycleError(
378
+ `Conflicting scene transition requests in one update (${describeIntent(scope.intent)} then restartScene())`,
379
+ );
380
+ }
381
+ scope.intent = { kind: 'restart' };
382
+ },
383
+ });
384
+ };
385
+
386
+ const schedule = (activeGeneration: number) => {
387
+ frameHandle = options.frameDriver.requestFrame((timestampMs) => {
388
+ if (status !== 'running' || generation !== activeGeneration) {
389
+ return;
390
+ }
391
+ frameHandle = undefined;
392
+ publishedThisCallback = false;
393
+ if (diagnostics !== undefined) {
394
+ diagnostics.onDisplayCallback();
395
+ }
396
+
397
+ // A pending external transition commits at the next fixed-step boundary
398
+ // without advancing simulation tick/time.
399
+ if (pendingTransition !== undefined) {
400
+ const pending = pendingTransition;
401
+ pendingTransition = undefined;
402
+ try {
403
+ commitTransition(pending, activeScene.state, undefined);
404
+ } catch (error) {
405
+ pauseInternal();
406
+ throw error;
407
+ }
408
+ commitOrPause();
409
+ if (status !== 'running' || generation !== activeGeneration) {
410
+ return;
411
+ }
412
+ }
413
+
414
+ if (previousTimestampMs === undefined) {
415
+ previousTimestampMs = timestampMs;
416
+ if (!publishedThisCallback) {
417
+ commitOrPause();
418
+ }
419
+ if (status === 'running' && generation === activeGeneration) {
420
+ schedule(activeGeneration);
421
+ }
422
+ return;
423
+ }
424
+
425
+ const wallDeltaMs = Math.max(0, timestampMs - previousTimestampMs);
426
+ previousTimestampMs = timestampMs;
427
+ accumulatorMs += Math.min(wallDeltaMs, maxFrameDeltaMs);
428
+ const tolerance = fixedStepMs * 1e-9;
429
+ let catchUpSteps = 0;
430
+ let stepsRan = false;
431
+
432
+ try {
433
+ while (
434
+ accumulatorMs + tolerance >= fixedStepMs &&
435
+ catchUpSteps < maxCatchUpSteps &&
436
+ status === 'running' &&
437
+ generation === activeGeneration
438
+ ) {
439
+ stepsRan = true;
440
+ const nextTick = tick + 1;
441
+ const nextSceneTick = activeScene.sceneTick + 1;
442
+ const sampleStart = diagnostics === undefined ? 0 : now();
443
+ const input = inputBuffer.sample();
444
+ if (diagnostics !== undefined) {
445
+ diagnostics.onInputSample(now() - sampleStart);
446
+ }
447
+ const scope: UpdateScope = {};
448
+ activeUpdateScope = scope;
449
+ updateInProgress = true;
450
+ const updateStart = diagnostics === undefined ? 0 : now();
451
+ const nextState = freezeObject(
452
+ activeScene.definition.update({
453
+ state: activeScene.state,
454
+ input,
455
+ transition: makeTransitionController(scope),
456
+ tick: nextTick,
457
+ sceneTick: nextSceneTick,
458
+ deltaSeconds: fixedStepMs / 1000,
459
+ elapsedSeconds: (nextTick * fixedStepMs) / 1000,
460
+ sceneElapsedSeconds: (nextSceneTick * fixedStepMs) / 1000,
461
+ }),
462
+ );
463
+ activeUpdateScope = undefined;
464
+ updateInProgress = false;
465
+ if (diagnostics !== undefined) {
466
+ diagnostics.onUpdate(now() - updateStart);
467
+ }
468
+ const intent = scope.intent;
469
+ if (isDisposed()) {
470
+ return;
471
+ }
472
+ if (intent !== undefined) {
473
+ // A transition requested during update commits after this update
474
+ // completes successfully. The update consumed one fixed step; the
475
+ // hard-cut frame publishes at the end of this presentation callback.
476
+ commitTransition(intent, nextState, nextTick, true);
477
+ accumulatorMs = Math.max(0, accumulatorMs - fixedStepMs);
478
+ // F4: the transition consumed one fixed step; report it as such
479
+ // and never as a zero-step callback (zero-step fires only when
480
+ // no step ran at all).
481
+ if (diagnostics !== undefined) {
482
+ diagnostics.onFixedStep();
483
+ }
484
+ break;
485
+ }
486
+ const snapshotStart = diagnostics === undefined ? 0 : now();
487
+ const rawSnapshot = activeScene.definition.snapshot({ state: nextState });
488
+ if (diagnostics !== undefined) {
489
+ diagnostics.onSnapshot(now() - snapshotStart);
490
+ }
491
+ const freezeStart = diagnostics === undefined ? 0 : now();
492
+ const nextSnapshot = deepFreeze(rawSnapshot);
493
+ if (diagnostics !== undefined) {
494
+ diagnostics.onDeepFreeze(now() - freezeStart);
495
+ }
496
+ if (isDisposed()) {
497
+ return;
498
+ }
499
+ activeScene.state = nextState;
500
+ activeScene.sceneTick = nextSceneTick;
501
+ previousSnapshot = currentSnapshot;
502
+ currentSnapshot = nextSnapshot;
503
+ tick = nextTick;
504
+ accumulatorMs = Math.max(0, accumulatorMs - fixedStepMs);
505
+ catchUpSteps += 1;
506
+ if (diagnostics !== undefined) {
507
+ diagnostics.onFixedStep();
508
+ if (catchUpSteps > 1) {
509
+ diagnostics.onCatchUpStep();
510
+ }
511
+ }
512
+ }
513
+ } catch (error) {
514
+ activeUpdateScope = undefined;
515
+ updateInProgress = false;
516
+ pauseInternal();
517
+ throw error;
518
+ }
519
+
520
+ if (catchUpSteps === maxCatchUpSteps && accumulatorMs >= fixedStepMs) {
521
+ const droppedSteps = Math.floor(accumulatorMs / fixedStepMs);
522
+ accumulatorMs %= fixedStepMs;
523
+ if (diagnostics !== undefined) {
524
+ diagnostics.onDroppedDebt(droppedSteps);
525
+ }
526
+ }
527
+
528
+ if (catchUpSteps === 0 && !stepsRan) {
529
+ if (diagnostics !== undefined) {
530
+ diagnostics.onZeroStepCallback();
531
+ }
532
+ }
533
+
534
+ if (stepsRan && !publishedThisCallback) {
535
+ // One commit per presentation callback, with the final adjacent pair:
536
+ // `previous`/`current` already hold the last two committed snapshots.
537
+ // A transition inside the loop also counts as a run step: its hard
538
+ // cut commits here (zero-step callbacks never reach this branch).
539
+ commitOrPause();
540
+ }
541
+ if (status === 'running' && generation === activeGeneration) {
542
+ schedule(activeGeneration);
543
+ }
544
+ });
545
+ };
546
+
547
+ const session: GameSession<TScenes, TInput> = {
548
+ get status() {
549
+ return status;
550
+ },
551
+ get scene() {
552
+ return sceneName as keyof TScenes;
553
+ },
554
+ viewport: definition.viewport,
555
+ input: inputBuffer.controller,
556
+ start() {
557
+ assertLive();
558
+ if (status === 'running') {
559
+ return;
560
+ }
561
+ status = 'running';
562
+ previousTimestampMs = undefined;
563
+ accumulatorMs = 0;
564
+ const activeGeneration = ++generation;
565
+ schedule(activeGeneration);
566
+ },
567
+ pause() {
568
+ assertLive();
569
+ pauseInternal();
570
+ if (pendingTransition !== undefined) {
571
+ const pending = pendingTransition;
572
+ pendingTransition = undefined;
573
+ commitTransition(pending, activeScene.state, undefined);
574
+ commitOrPause();
575
+ }
576
+ },
577
+ setScene(name: keyof TScenes) {
578
+ assertLive();
579
+ if (updateInProgress) {
580
+ throw new GameSessionLifecycleError(
581
+ 'External scene transitions cannot be requested during a scene update',
582
+ );
583
+ }
584
+ const nameString = String(name);
585
+ if (nameString === sceneName) {
586
+ return; // Idempotent no-op.
587
+ }
588
+ if (!Object.hasOwn(definition.scenes, nameString)) {
589
+ throw new GameSessionLifecycleError(`Unknown scene: ${nameString}`);
590
+ }
591
+ if (status === 'running') {
592
+ requestPendingTransition({ kind: 'setScene', name: nameString });
593
+ return;
594
+ }
595
+ commitTransition({ kind: 'setScene', name: nameString }, activeScene.state, undefined);
596
+ commitOrPause();
597
+ },
598
+ restartScene() {
599
+ assertLive();
600
+ if (updateInProgress) {
601
+ throw new GameSessionLifecycleError(
602
+ 'External scene transitions cannot be requested during a scene update',
603
+ );
604
+ }
605
+ if (status === 'running') {
606
+ requestPendingTransition({ kind: 'restart' });
607
+ return;
608
+ }
609
+ commitTransition({ kind: 'restart' }, activeScene.state, undefined);
610
+ commitOrPause();
611
+ },
612
+ dispose() {
613
+ if (status === 'disposed') {
614
+ return;
615
+ }
616
+ if (frameHandle !== undefined) {
617
+ options.frameDriver.cancelFrame(frameHandle);
618
+ frameHandle = undefined;
619
+ }
620
+ status = 'disposed';
621
+ generation += 1;
622
+ previousTimestampMs = undefined;
623
+ accumulatorMs = 0;
624
+ pendingTransition = undefined;
625
+ inputBuffer.reset();
626
+ listeners.clear();
627
+ activeScene.definition.dispose?.(activeScene.state);
628
+ },
629
+ getRenderFrame() {
630
+ const alpha = commitFrame.hardCut
631
+ ? 0
632
+ : Math.max(0, Math.min(accumulatorMs / fixedStepMs, 1 - Number.EPSILON));
633
+ return Object.freeze({ ...commitFrame, alpha }) as unknown as GameRenderFrame<TScenes>;
634
+ },
635
+ addCommitListener(listener) {
636
+ assertLive();
637
+ listeners.add(listener);
638
+ let removed = false;
639
+ return Object.freeze({
640
+ remove() {
641
+ if (removed) {
642
+ return;
643
+ }
644
+ removed = true;
645
+ listeners.delete(listener);
646
+ },
647
+ });
648
+ },
649
+ };
650
+
651
+ return Object.freeze(session);
652
+ }