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,459 @@
1
+ /**
2
+ * Asset store core (T7.4).
3
+ *
4
+ * The explicit owner of the decoded-image cache. Source resolution and
5
+ * image decoding are injected so the ownership logic is unit-testable with
6
+ * fakes and never imports native modules itself.
7
+ *
8
+ * Ownership rules:
9
+ * - An explicitly created store owns cache/native entries.
10
+ * - `acquire` resolves only with a complete usable lease.
11
+ * - Reference counts keep a shared source alive across leases; the native
12
+ * handle is disposed exactly once when the final lease releases it.
13
+ * - A failed/abandoned attempt releases every handle it acquired; entries
14
+ * still leased elsewhere are preserved.
15
+ * - Attempts carry an epoch token: stale completion after retry/unmount is
16
+ * ignored and can never double-dispose.
17
+ * - `AbortSignal` detaches an imperative caller immediately; late results
18
+ * from the underlying work are ignored.
19
+ * - Progress is monotonic and counts requested logical resources (a
20
+ * deduplicated source still counts once per logical descriptor).
21
+ * - The store rejects acquisitions after disposal; disposal is idempotent.
22
+ */
23
+ import { GameAssetError } from '../../assets/errors';
24
+ import type {
25
+ AssetGroupMap,
26
+ BrandedAssetDescriptor,
27
+ GameAssetLease,
28
+ ImageDescriptor,
29
+ LoadedImage,
30
+ LoadedAssets,
31
+ LoadedSpriteSheet,
32
+ SpriteFrameRect,
33
+ SpriteSheetDescriptor,
34
+ } from '../../assets/types';
35
+ import { validateFrameRect } from '../../assets/validation';
36
+ import { GameAssetError as AssetStoreError } from '../../assets/errors';
37
+
38
+ /** Opaque decoded-image handle (Skia's SkImage satisfies this structurally). */
39
+ export interface NativeImageHandle {
40
+ /** Decoded width in pixels. */
41
+ width(): number;
42
+ /** Decoded height in pixels. */
43
+ height(): number;
44
+ /** Release the native handle; exactly once per resource. */
45
+ dispose(): void;
46
+ }
47
+
48
+ /** Injected source resolution and decode pipelines. */
49
+ export interface AssetPipelines {
50
+ /** Resolve a static module handle to a canonical local URI. */
51
+ readonly resolve: (source: number) => Promise<string>;
52
+ /** Decode a canonical local URI into an image handle. */
53
+ readonly decode: (uri: string) => Promise<NativeImageHandle>;
54
+ }
55
+
56
+ /** Acquisition options for `acquire`. */
57
+ export interface AcquireOptions {
58
+ /** Logical groups to load; an empty set resolves immediately. */
59
+ readonly groups: readonly string[];
60
+ /** Detach the caller; late results are ignored, never resurrected. */
61
+ readonly signal?: AbortSignal;
62
+ /** Monotonic progress in [0, 1], one update per completed logical asset. */
63
+ readonly onProgress?: (progress: number) => void;
64
+ }
65
+
66
+ interface ResourceEntry {
67
+ readonly uri: string;
68
+ handle: NativeImageHandle | undefined;
69
+ inFlight: Promise<NativeImageHandle> | undefined;
70
+ /** Logical descriptor keys sharing this resolved source. */
71
+ readonly logicalKeys: Set<string>;
72
+ refCount: number;
73
+ }
74
+
75
+ interface Attempt {
76
+ readonly token: number;
77
+ /** Idempotent release closures for every reference this attempt owns. */
78
+ readonly acquired: ResourceRef[];
79
+ }
80
+
81
+ /** One idempotent ownership token: release() is safe to call repeatedly and
82
+ * disposes the native handle when it is the final reference (RF5). */
83
+ interface ResourceRef {
84
+ readonly release: () => void;
85
+ /** Resolve to the decoded handle (the shared in-flight promise or cache). */
86
+ readonly ready: () => Promise<NativeImageHandle>;
87
+ }
88
+
89
+ /** One logical (group, asset) identity inside a manifest. */
90
+ interface LogicalAsset {
91
+ readonly key: string;
92
+ readonly group: string;
93
+ readonly name: string;
94
+ readonly descriptor: ImageDescriptor | SpriteSheetDescriptor;
95
+ }
96
+
97
+ function logicalAssetsOf(manifest: AssetGroupMap): LogicalAsset[] {
98
+ const result: LogicalAsset[] = [];
99
+ for (const [group, assets] of Object.entries(manifest)) {
100
+ for (const [name, descriptor] of Object.entries(assets)) {
101
+ result.push({
102
+ key: `${group}/${name}`,
103
+ group,
104
+ name,
105
+ descriptor: descriptor as ImageDescriptor | SpriteSheetDescriptor,
106
+ });
107
+ }
108
+ }
109
+ return result;
110
+ }
111
+
112
+ /** Validate a sprite sheet's frames against decoded dimensions. */
113
+ function validateFrames(
114
+ path: readonly string[],
115
+ frames: Readonly<Record<string, SpriteFrameRect>>,
116
+ width: number,
117
+ height: number,
118
+ ): void {
119
+ for (const [name, rect] of Object.entries(frames)) {
120
+ validateFrameRect(path, name, rect);
121
+ if (rect.x + rect.width > width || rect.y + rect.height > height) {
122
+ throw new GameAssetError(
123
+ 'ASSET_FRAME_OUT_OF_BOUNDS',
124
+ [...path, name],
125
+ `frame ${JSON.stringify(name)} (${rect.x},${rect.y} ${rect.width}x${rect.height}) exceeds the decoded image ${width}x${height}`,
126
+ );
127
+ }
128
+ }
129
+ }
130
+
131
+ export function createGameAssetStoreCore<TManifest extends AssetGroupMap>(
132
+ manifest: TManifest,
133
+ pipelines: AssetPipelines,
134
+ ): {
135
+ readonly acquire: (options: AcquireOptions) => Promise<GameAssetLease<TManifest>>;
136
+ readonly dispose: () => void;
137
+ readonly isDisposed: () => boolean;
138
+ } {
139
+ const groups = new Set<string>(Object.keys(manifest));
140
+ const logical = logicalAssetsOf(manifest);
141
+ const byKey = new Map<string, LogicalAsset>(logical.map((asset) => [asset.key, asset]));
142
+ const resources = new Map<string, ResourceEntry>();
143
+ let disposed = false;
144
+ let nextAttemptToken = 1;
145
+ /** Per-attempt ownership is explicit: the attempt object is passed through
146
+ * every resolve/decode/validate operation; no shared singleton (R4). */
147
+
148
+ function assertLive(): void {
149
+ if (disposed) {
150
+ throw new AssetStoreError('ASSET_STORE_DISPOSED', [], 'asset store is disposed');
151
+ }
152
+ }
153
+
154
+ /** Race a promise with the abort signal; the listener is removed on
155
+ * resolve, reject, and abort (RF5). */
156
+ function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
157
+ if (signal === undefined) {
158
+ return promise;
159
+ }
160
+ return new Promise<T>((resolve, reject) => {
161
+ const onAbort = (): void => {
162
+ reject(new AssetStoreError('ASSET_ABORTED', [], 'asset acquisition aborted'));
163
+ };
164
+ signal.addEventListener('abort', onAbort, { once: true });
165
+ promise.then(
166
+ (value) => {
167
+ signal.removeEventListener('abort', onAbort);
168
+ resolve(value);
169
+ },
170
+ (error) => {
171
+ signal.removeEventListener('abort', onAbort);
172
+ reject(error);
173
+ },
174
+ );
175
+ });
176
+ }
177
+
178
+ /** Drop one reference held by a caller that never recorded a logical key. */
179
+ function dropResourceRef(uri: string): void {
180
+ const entry = resources.get(uri);
181
+ if (entry === undefined) {
182
+ return;
183
+ }
184
+ entry.refCount -= 1;
185
+ if (entry.refCount <= 0) {
186
+ if (entry.handle !== undefined) {
187
+ entry.handle.dispose();
188
+ }
189
+ resources.delete(uri);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Begin one owned reference to a resource. Every waiter — cache miss,
195
+ * in-flight share, or completed cache hit — goes through this single
196
+ * accounting path and receives an idempotent release closure (RF5). The
197
+ * caller must either commit the ref to the attempt or call release()
198
+ * exactly once; the final release disposes the native handle.
199
+ */
200
+ function beginResourceRef(uri: string): ResourceRef {
201
+ const existing = resources.get(uri);
202
+ let entry: ResourceEntry;
203
+ if (existing !== undefined && existing.handle !== undefined) {
204
+ // Completed cache hit.
205
+ entry = existing;
206
+ entry.refCount += 1;
207
+ const handle = existing.handle;
208
+ return {
209
+ release: () => dropResourceRef(uri),
210
+ ready: async () => handle,
211
+ };
212
+ }
213
+ if (existing !== undefined && existing.inFlight !== undefined) {
214
+ // Shared in-flight decode.
215
+ entry = existing;
216
+ entry.refCount += 1;
217
+ const shared = existing.inFlight;
218
+ return {
219
+ release: () => dropResourceRef(uri),
220
+ ready: async () => shared,
221
+ };
222
+ }
223
+ // Cache miss: this waiter starts the decode.
224
+ entry = {
225
+ uri,
226
+ handle: undefined,
227
+ inFlight: undefined,
228
+ logicalKeys: new Set(),
229
+ refCount: 1,
230
+ };
231
+ resources.set(uri, entry);
232
+ const promise = (async () => {
233
+ const handle = await pipelines.decode(uri);
234
+ // A late completion must never resurrect a disposed store or an entry
235
+ // whose last waiter aborted while the decode was in flight.
236
+ if (disposed || entry.refCount <= 0) {
237
+ handle.dispose();
238
+ if (entry.refCount <= 0) {
239
+ resources.delete(uri);
240
+ }
241
+ throw new AssetStoreError('ASSET_ABORTED', [], 'asset acquisition aborted');
242
+ }
243
+ entry.handle = handle;
244
+ return handle;
245
+ })();
246
+ entry.inFlight = promise;
247
+ void promise.then(
248
+ () => {
249
+ entry.inFlight = undefined;
250
+ },
251
+ () => {
252
+ entry.inFlight = undefined;
253
+ },
254
+ );
255
+ return {
256
+ release: () => dropResourceRef(uri),
257
+ ready: async () => promise,
258
+ };
259
+ }
260
+
261
+ async function acquireOne(
262
+ asset: LogicalAsset,
263
+ attempt: Attempt,
264
+ signal: AbortSignal | undefined,
265
+ ): Promise<void> {
266
+ const uri = await raceWithAbort(pipelines.resolve(asset.descriptor.source), signal);
267
+ // RF5: the reference token exists before any cancellable await; abort
268
+ // can never leave a positive reference behind.
269
+ const ref = beginResourceRef(uri);
270
+ try {
271
+ const handle = await raceWithAbort(ref.ready(), signal);
272
+ attempt.acquired.push(ref);
273
+ const entry = resources.get(uri);
274
+ if (entry !== undefined) {
275
+ entry.logicalKeys.add(asset.key);
276
+ }
277
+ if (asset.descriptor.kind === 'sprite-sheet') {
278
+ validateFrames(
279
+ [asset.group, asset.name, 'frames'],
280
+ asset.descriptor.frames,
281
+ handle.width(),
282
+ handle.height(),
283
+ );
284
+ }
285
+ } catch (error) {
286
+ // Every failure, abort, and stale completion releases this waiter's
287
+ // reference exactly once; surviving owners keep theirs.
288
+ ref.release();
289
+ throw error;
290
+ }
291
+ }
292
+
293
+
294
+ const acquire = async (options: AcquireOptions): Promise<GameAssetLease<TManifest>> => {
295
+ assertLive();
296
+ const { signal } = options;
297
+ const throwIfAborted = (): void => {
298
+ if (signal?.aborted === true) {
299
+ throw new AssetStoreError('ASSET_ABORTED', [], 'asset acquisition aborted');
300
+ }
301
+ };
302
+ throwIfAborted();
303
+
304
+ // RF5: normalize groups at the public boundary (dedupe) and use the
305
+ // same list for the progress total and acquisition.
306
+ const groupsNormalized = [...new Set(options.groups)];
307
+ const requested: LogicalAsset[] = [];
308
+ for (const group of groupsNormalized) {
309
+ if (!groups.has(group)) {
310
+ throw new GameAssetError('ASSET_UNKNOWN_GROUP', [group], `unknown asset group ${JSON.stringify(group)}`);
311
+ }
312
+ for (const asset of logical) {
313
+ if (asset.group === group) {
314
+ requested.push(asset);
315
+ }
316
+ }
317
+ }
318
+ if (requested.length === 0) {
319
+ // Empty group set resolves immediately with progress 1 and no resources.
320
+ options.onProgress?.(1);
321
+ return createLease(new Map(), () => undefined);
322
+ }
323
+
324
+ const token = nextAttemptToken;
325
+ nextAttemptToken += 1;
326
+ const attempt: Attempt = { token, acquired: [] };
327
+
328
+ const loaded = new Map<string, string>();
329
+ try {
330
+ let completed = 0;
331
+ const total = requested.length;
332
+ for (const asset of requested) {
333
+ throwIfAborted();
334
+ await acquireOne(asset, attempt, signal);
335
+ loaded.set(asset.key, asset.key);
336
+ completed += 1;
337
+ options.onProgress?.(completed / total);
338
+ }
339
+ return createLease(loaded, () => {
340
+ for (const ref of attempt.acquired) {
341
+ ref.release();
342
+ }
343
+ });
344
+ } catch (error) {
345
+ // Release every reference this attempt acquired exactly once; entries
346
+ // still leased by a previous lease keep their references.
347
+ for (const ref of attempt.acquired) {
348
+ ref.release();
349
+ }
350
+ throw error;
351
+ }
352
+ };
353
+
354
+ function createLease(
355
+ loadedKeys: ReadonlyMap<string, string>,
356
+ onDispose: () => void,
357
+ ): GameAssetLease<TManifest> {
358
+ let leaseDisposed = false;
359
+ const loaded = new Map<string, LogicalAsset>();
360
+ for (const key of loadedKeys.keys()) {
361
+ const asset = byKey.get(key);
362
+ if (asset !== undefined) {
363
+ loaded.set(key, asset);
364
+ }
365
+ }
366
+ const assets: LoadedAssets<TManifest> = {
367
+ manifest,
368
+ get: <TDescriptor extends ImageDescriptor | SpriteSheetDescriptor>(
369
+ descriptor: BrandedAssetDescriptor<TManifest, TDescriptor>,
370
+ ) => {
371
+ assertLive();
372
+ if (leaseDisposed) {
373
+ throw new AssetStoreError('ASSET_STORE_DISPOSED', [], 'lease is disposed');
374
+ }
375
+ const entry = findLoadedFor(descriptor, loaded);
376
+ if (entry === undefined) {
377
+ // R9: v1 lookup is descriptor-reference membership — the exact
378
+ // descriptor object the manifest declared — not a nominal manifest
379
+ // identity. Identically shaped manifests share the structural type,
380
+ // so the type layer cannot distinguish them; the runtime reference
381
+ // check is the guarantee.
382
+ throw new GameAssetError(
383
+ 'ASSET_UNKNOWN_ASSET',
384
+ [],
385
+ 'descriptor is not a reference declared by this manifest and group selection',
386
+ );
387
+ }
388
+ return entry as TDescriptor extends { readonly kind: 'sprite-sheet' }
389
+ ? LoadedSpriteSheet
390
+ : LoadedImage;
391
+ },
392
+ };
393
+ return {
394
+ assets,
395
+ dispose: () => {
396
+ if (leaseDisposed) {
397
+ return;
398
+ }
399
+ leaseDisposed = true;
400
+ onDispose();
401
+ },
402
+ };
403
+ }
404
+
405
+ function findLoadedFor(
406
+ descriptor: ImageDescriptor | SpriteSheetDescriptor,
407
+ loaded: ReadonlyMap<string, LogicalAsset>,
408
+ ): unknown {
409
+ for (const asset of loaded.values()) {
410
+ if (asset.descriptor === descriptor) {
411
+ const entry = entryFor(asset.key);
412
+ const handle = entry?.handle;
413
+ const width = handle?.width() ?? 0;
414
+ const height = handle?.height() ?? 0;
415
+ if (asset.descriptor.kind === 'image') {
416
+ return { descriptor: asset.descriptor, width, height, image: handle };
417
+ }
418
+ return {
419
+ descriptor: asset.descriptor,
420
+ frames: asset.descriptor.frames,
421
+ width,
422
+ height,
423
+ image: handle,
424
+ };
425
+ }
426
+ }
427
+ return undefined;
428
+ }
429
+
430
+ function entryFor(logicalKey: string): ResourceEntry | undefined {
431
+ for (const entry of resources.values()) {
432
+ if (entry.logicalKeys.has(logicalKey)) {
433
+ return entry;
434
+ }
435
+ }
436
+ return undefined;
437
+ }
438
+
439
+ return {
440
+ acquire,
441
+ dispose: () => {
442
+ if (disposed) {
443
+ return;
444
+ }
445
+ disposed = true;
446
+ // Drop entries with no live lease; leased entries stay until their
447
+ // lease releases them (their refCount keeps them alive).
448
+ for (const [uri, entry] of resources) {
449
+ if (entry.refCount <= 0 && entry.handle !== undefined) {
450
+ entry.handle.dispose();
451
+ resources.delete(uri);
452
+ }
453
+ }
454
+ },
455
+ isDisposed: () => disposed,
456
+ };
457
+ }
458
+
459
+ export type GameAssetStore = ReturnType<typeof createGameAssetStoreCore>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Default Expo/Skia asset pipelines (T7.4).
3
+ *
4
+ * Exact primitives used (verified against the installed types, Skia 2.11.0
5
+ * and expo-asset SDK 57):
6
+ * - `Asset.fromModule(source)` -> `await asset.downloadAsync()` ->
7
+ * `asset.localUri ?? asset.uri` (canonical local URI; static module
8
+ * handles only);
9
+ * - `Skia.Data.fromURI(uri)` -> `SkData` (disposed as soon as the image is
10
+ * created — `SkImage.MakeImageFromEncoded` copies the encoded bytes);
11
+ * - `Skia.Image.MakeImageFromEncoded(data)` -> `SkImage | null` (null is a
12
+ * structured load failure, never a ready resource).
13
+ */
14
+ import { Asset } from 'expo-asset';
15
+ import { Skia } from '@shopify/react-native-skia';
16
+
17
+ import { GameAssetError } from '../../assets/errors';
18
+ import type { AssetGroupMap } from '../../assets/types';
19
+ import {
20
+ createGameAssetStoreCore,
21
+ type NativeImageHandle,
22
+ } from './createGameAssetStore';
23
+
24
+ const resolvePipeline = async (source: number): Promise<string> => {
25
+ const asset = Asset.fromModule(source);
26
+ try {
27
+ await asset.downloadAsync();
28
+ } catch (error) {
29
+ throw new GameAssetError(
30
+ 'ASSET_RESOLVE_FAILED',
31
+ [],
32
+ `failed to resolve static asset module ${source}: ${error instanceof Error ? error.message : String(error)}`,
33
+ );
34
+ }
35
+ return asset.localUri ?? asset.uri;
36
+ };
37
+
38
+ const decodePipeline = async (uri: string): Promise<NativeImageHandle> => {
39
+ const data = await Skia.Data.fromURI(uri);
40
+ try {
41
+ const image = Skia.Image.MakeImageFromEncoded(data);
42
+ if (image === null) {
43
+ throw new GameAssetError('ASSET_DECODE_FAILED', [], `decoding ${uri} produced no image`);
44
+ }
45
+ return image as unknown as NativeImageHandle;
46
+ } finally {
47
+ data.dispose();
48
+ }
49
+ };
50
+
51
+ /**
52
+ * Create the explicit asset-store owner for a manifest using the default
53
+ * Expo/Skia pipelines. Callers dispose the lease then the store in
54
+ * `finally`.
55
+ */
56
+ export function createGameAssetStore<TManifest extends AssetGroupMap>(
57
+ manifest: TManifest,
58
+ ) {
59
+ // The return type keeps the manifest generic so leases stay typed.
60
+ return createGameAssetStoreCore(manifest, {
61
+ resolve: resolvePipeline,
62
+ decode: decodePipeline,
63
+ });
64
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Asset store errors (T7.4).
3
+ *
4
+ * The store adds no error codes of its own: every failure is a structured
5
+ * `GameAssetError` with a stable code and field path.
6
+ */
7
+ export { GameAssetError } from '../../assets/errors';
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `useGameAssets` — the React loading adapter (T7.5).
3
+ *
4
+ * The hook creates and owns one asset store and lease for the requested
5
+ * groups; the caller must not dispose the returned ready value manually.
6
+ *
7
+ * Contract:
8
+ * - `{ status: 'loading'; progress }` — progress in [0, 1], monotonic;
9
+ * - `{ status: 'error'; error; retry }` — structured error + stable retry;
10
+ * - `{ status: 'ready'; assets }` — the complete lease, typed to the
11
+ * manifest.
12
+ *
13
+ * Lifecycle:
14
+ * - The requested group list is normalized (sorted) so a recreated
15
+ * equivalent array does not reload.
16
+ * - Retry starts a new attempt; late completion from an older attempt can
17
+ * never replace the new state.
18
+ * - Unmount invalidates the attempt, releases the lease, and disposes the
19
+ * store — hook-owned resources are released exactly once.
20
+ */
21
+ import { useCallback, useEffect, useRef, useState } from 'react';
22
+
23
+ import { GameAssetError } from '../../assets/errors';
24
+ import type { AssetGroupMap, GameAssetLease, LoadedAssets } from '../../assets/types';
25
+ import type { AcquireOptions } from './createGameAssetStore';
26
+
27
+ /** The store surface the hook needs, kept generic over the manifest. */
28
+ export interface HookStore<TManifest extends AssetGroupMap> {
29
+ readonly acquire: (options: AcquireOptions) => Promise<GameAssetLease<TManifest>>;
30
+ readonly dispose: () => void;
31
+ }
32
+
33
+ /**
34
+ * Default store factory using the Expo/Skia pipelines. The wiring module is
35
+ * required lazily so the hook's import graph stays headless (tests inject
36
+ * their own factory and never touch the native side).
37
+ */
38
+ function defaultStoreFactory<TManifest extends AssetGroupMap>(
39
+ manifest: TManifest,
40
+ ): HookStore<TManifest> {
41
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const { createGameAssetStore } = require('./decodeSkiaImage') as {
43
+ createGameAssetStore: <T extends AssetGroupMap>(value: T) => HookStore<T>;
44
+ };
45
+ return createGameAssetStore(manifest);
46
+ }
47
+
48
+ /** The discriminated loading state machine. Every state carries the
49
+ * normalized request key (RF7) so a rendered request that differs from the
50
+ * state's request can never expose a stale lease. */
51
+ export type GameAssetsState<TManifest extends AssetGroupMap> =
52
+ | { readonly status: 'loading'; readonly progress: number; readonly retry: () => void; readonly requestKey: string }
53
+ | { readonly status: 'error'; readonly error: GameAssetError; readonly retry: () => void; readonly requestKey: string }
54
+ | { readonly status: 'ready'; readonly assets: LoadedAssets<TManifest>; readonly requestKey: string };
55
+
56
+ /** Deduplicate and sort groups so equivalent reordered/duplicated arrays
57
+ * map to one key and one acquisition (R6). */
58
+ export function dedupeGroups(groups: readonly string[]): readonly string[] {
59
+ return [...new Set(groups)].sort();
60
+ }
61
+
62
+ /** Order-independent group key: equivalent arrays map to one key. */
63
+ export function stableGroupsKey(groups: readonly string[]): string {
64
+ return dedupeGroups(groups).join('\u0000');
65
+ }
66
+
67
+ export function useGameAssets<TManifest extends AssetGroupMap>(
68
+ manifest: TManifest,
69
+ options: { readonly groups: readonly (Extract<keyof TManifest, string>)[] },
70
+ storeFactory?: (manifest: TManifest) => HookStore<TManifest>,
71
+ ): GameAssetsState<TManifest> {
72
+ const groupsKey = stableGroupsKey(options.groups);
73
+ const [attempt, setAttempt] = useState(0);
74
+ const [state, setState] = useState<GameAssetsState<TManifest>>({
75
+ status: 'loading',
76
+ progress: 0,
77
+ retry: () => undefined,
78
+ requestKey: groupsKey,
79
+ });
80
+ const storeRef = useRef<HookStore<TManifest> | undefined>(undefined);
81
+ const leaseRef = useRef<GameAssetLease<TManifest> | undefined>(undefined);
82
+ const retry = useCallback(() => {
83
+ // R6: one user action starts exactly one new attempt; the same function
84
+ // identity is exposed in loading and error states.
85
+ setAttempt((current) => current + 1);
86
+ }, []);
87
+
88
+ // Ref mirror of the attempt so stale-effect completions are rejected
89
+ // after a retry re-renders with a higher attempt.
90
+ const attemptRef = useRef(attempt);
91
+ attemptRef.current = attempt;
92
+
93
+ // RF7: if the rendered request differs from the state's request, expose
94
+ // loading synchronously instead of the previous ready lease — the old
95
+ // lease is disposed by the effect cleanup, so no consumer can observe it
96
+ // under the new request.
97
+ if (state.requestKey !== groupsKey && state.status === 'ready') {
98
+ return { status: 'loading', progress: 0, retry, requestKey: groupsKey };
99
+ }
100
+
101
+ useEffect(() => {
102
+ const store = (storeFactory ?? defaultStoreFactory)(manifest);
103
+ storeRef.current = store;
104
+ let disposed = false;
105
+ const attemptAtStart = attempt;
106
+ const isCurrent = (): boolean => attemptRef.current === attemptAtStart;
107
+ const setIfCurrent = (updater: (previous: GameAssetsState<TManifest>) => GameAssetsState<TManifest>): void => {
108
+ if (!disposed && isCurrent()) {
109
+ setState(updater);
110
+ }
111
+ };
112
+
113
+ // R6: every attempt owns an AbortController; aborting detaches the
114
+ // caller and rejects late progress/ready/error from older configs.
115
+ const controller = new AbortController();
116
+
117
+ setState({
118
+ status: 'loading',
119
+ progress: 0,
120
+ retry: retry,
121
+ requestKey: groupsKey,
122
+ });
123
+
124
+ store
125
+ .acquire({
126
+ groups: dedupeGroups(options.groups),
127
+ signal: controller.signal,
128
+ onProgress: (progress) => {
129
+ setIfCurrent((previous) =>
130
+ previous.status === 'loading' ? { ...previous, progress } : previous,
131
+ );
132
+ },
133
+ })
134
+ .then((lease) => {
135
+ if (disposed || !isCurrent()) {
136
+ // Stale completion (retry/unmount): release immediately.
137
+ lease.dispose();
138
+ return;
139
+ }
140
+ leaseRef.current?.dispose();
141
+ leaseRef.current = lease;
142
+ setState({ status: 'ready', assets: lease.assets, requestKey: groupsKey });
143
+ })
144
+ .catch((error: unknown) => {
145
+ if (disposed || !isCurrent()) {
146
+ return;
147
+ }
148
+ const structured =
149
+ error instanceof GameAssetError
150
+ ? error
151
+ : new GameAssetError('ASSET_DECODE_FAILED', [], error instanceof Error ? error.message : String(error));
152
+ setState({ status: 'error', error: structured, retry, requestKey: groupsKey });
153
+ });
154
+
155
+ return () => {
156
+ disposed = true;
157
+ // R6: abort before releasing so no late completion can touch state or
158
+ // resources, then release the lease and store exactly once.
159
+ controller.abort();
160
+ leaseRef.current?.dispose();
161
+ leaseRef.current = undefined;
162
+ storeRef.current = undefined;
163
+ store.dispose();
164
+ };
165
+ // The groups key is order-independent, so a recreated equivalent array
166
+ // does not reload; the attempt state drives retries. The store factory
167
+ // is the internal test seam and must be stable across renders.
168
+ }, [attempt, groupsKey, manifest, storeFactory]);
169
+
170
+ return state;
171
+ }