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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/lib/module/assets/defineAssets.js +48 -0
- package/lib/module/assets/defineAssets.js.map +1 -0
- package/lib/module/assets/errors.js +38 -0
- package/lib/module/assets/errors.js.map +1 -0
- package/lib/module/assets/types.js +2 -0
- package/lib/module/assets/types.js.map +1 -0
- package/lib/module/assets/validation.js +99 -0
- package/lib/module/assets/validation.js.map +1 -0
- package/lib/module/core/frameDriver.js +20 -0
- package/lib/module/core/frameDriver.js.map +1 -0
- package/lib/module/core/input/createInputBuffer.js +296 -0
- package/lib/module/core/input/createInputBuffer.js.map +1 -0
- package/lib/module/core/input/types.js +4 -0
- package/lib/module/core/input/types.js.map +1 -0
- package/lib/module/core/session/createGameSession.js +554 -0
- package/lib/module/core/session/createGameSession.js.map +1 -0
- package/lib/module/core/session/deepFreeze.js +155 -0
- package/lib/module/core/session/deepFreeze.js.map +1 -0
- package/lib/module/core/session/diagnostics.js +2 -0
- package/lib/module/core/session/diagnostics.js.map +1 -0
- package/lib/module/core/session/types.js +43 -0
- package/lib/module/core/session/types.js.map +1 -0
- package/lib/module/definition/defineGame.js +57 -0
- package/lib/module/definition/defineGame.js.map +1 -0
- package/lib/module/definition/types.js +4 -0
- package/lib/module/definition/types.js.map +1 -0
- package/lib/module/geometry/types.js +2 -0
- package/lib/module/geometry/types.js.map +1 -0
- package/lib/module/index.js +12 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/react/GamePointerInput.js +385 -0
- package/lib/module/react/GamePointerInput.js.map +1 -0
- package/lib/module/react/GameView.js +223 -0
- package/lib/module/react/GameView.js.map +1 -0
- package/lib/module/react/alphaClock.js +42 -0
- package/lib/module/react/alphaClock.js.map +1 -0
- package/lib/module/react/assets/createGameAssetStore.js +358 -0
- package/lib/module/react/assets/createGameAssetStore.js.map +1 -0
- package/lib/module/react/assets/decodeSkiaImage.js +54 -0
- package/lib/module/react/assets/decodeSkiaImage.js.map +1 -0
- package/lib/module/react/assets/errors.js +10 -0
- package/lib/module/react/assets/errors.js.map +1 -0
- package/lib/module/react/assets/useGameAssets.js +160 -0
- package/lib/module/react/assets/useGameAssets.js.map +1 -0
- package/lib/module/react/bindAppLifecycle.js +58 -0
- package/lib/module/react/bindAppLifecycle.js.map +1 -0
- package/lib/module/react/bindGameSession.js +37 -0
- package/lib/module/react/bindGameSession.js.map +1 -0
- package/lib/module/react/gestureLifecycle.js +68 -0
- package/lib/module/react/gestureLifecycle.js.map +1 -0
- package/lib/module/react/instrumentation.js +4 -0
- package/lib/module/react/instrumentation.js.map +1 -0
- package/lib/module/react/pointerBinding.js +196 -0
- package/lib/module/react/pointerBinding.js.map +1 -0
- package/lib/module/react/pointerCoalescer.js +224 -0
- package/lib/module/react/pointerCoalescer.js.map +1 -0
- package/lib/module/react/pointerContainment.js +27 -0
- package/lib/module/react/pointerContainment.js.map +1 -0
- package/lib/module/react/sprites/GameSprite.js +116 -0
- package/lib/module/react/sprites/GameSprite.js.map +1 -0
- package/lib/module/react/sprites/GameWorld2D.js +44 -0
- package/lib/module/react/sprites/GameWorld2D.js.map +1 -0
- package/lib/module/react/sprites/Sprite.js +147 -0
- package/lib/module/react/sprites/Sprite.js.map +1 -0
- package/lib/module/react/sprites/SpriteBatch.js +173 -0
- package/lib/module/react/sprites/SpriteBatch.js.map +1 -0
- package/lib/module/react/sprites/spriteBatchPolicy.js +25 -0
- package/lib/module/react/sprites/spriteBatchPolicy.js.map +1 -0
- package/lib/module/react/sprites/spriteTransform.js +135 -0
- package/lib/module/react/sprites/spriteTransform.js.map +1 -0
- package/lib/module/react/viewportBinding.js +101 -0
- package/lib/module/react/viewportBinding.js.map +1 -0
- package/lib/module/react.js +13 -0
- package/lib/module/react.js.map +1 -0
- package/lib/module/scene/defineScene.js +29 -0
- package/lib/module/scene/defineScene.js.map +1 -0
- package/lib/module/scene/types.js +4 -0
- package/lib/module/scene/types.js.map +1 -0
- package/lib/module/sprites/sampleSpriteClip.js +50 -0
- package/lib/module/sprites/sampleSpriteClip.js.map +1 -0
- package/lib/module/sprites/spriteAnimationState.js +165 -0
- package/lib/module/sprites/spriteAnimationState.js.map +1 -0
- package/lib/module/testing.js +57 -0
- package/lib/module/testing.js.map +1 -0
- package/lib/module/viewport2d/index.js +4 -0
- package/lib/module/viewport2d/index.js.map +1 -0
- package/lib/module/viewport2d/math.js +129 -0
- package/lib/module/viewport2d/math.js.map +1 -0
- package/lib/module/viewport2d/types.js +2 -0
- package/lib/module/viewport2d/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/assets/defineAssets.d.ts +20 -0
- package/lib/typescript/src/assets/defineAssets.d.ts.map +1 -0
- package/lib/typescript/src/assets/errors.d.ts +20 -0
- package/lib/typescript/src/assets/errors.d.ts.map +1 -0
- package/lib/typescript/src/assets/types.d.ts +138 -0
- package/lib/typescript/src/assets/types.d.ts.map +1 -0
- package/lib/typescript/src/assets/validation.d.ts +15 -0
- package/lib/typescript/src/assets/validation.d.ts.map +1 -0
- package/lib/typescript/src/core/frameDriver.d.ts +12 -0
- package/lib/typescript/src/core/frameDriver.d.ts.map +1 -0
- package/lib/typescript/src/core/input/createInputBuffer.d.ts +12 -0
- package/lib/typescript/src/core/input/createInputBuffer.d.ts.map +1 -0
- package/lib/typescript/src/core/input/types.d.ts +64 -0
- package/lib/typescript/src/core/input/types.d.ts.map +1 -0
- package/lib/typescript/src/core/session/createGameSession.d.ts +18 -0
- package/lib/typescript/src/core/session/createGameSession.d.ts.map +1 -0
- package/lib/typescript/src/core/session/deepFreeze.d.ts +27 -0
- package/lib/typescript/src/core/session/deepFreeze.d.ts.map +1 -0
- package/lib/typescript/src/core/session/diagnostics.d.ts +36 -0
- package/lib/typescript/src/core/session/diagnostics.d.ts.map +1 -0
- package/lib/typescript/src/core/session/types.d.ts +104 -0
- package/lib/typescript/src/core/session/types.d.ts.map +1 -0
- package/lib/typescript/src/definition/defineGame.d.ts +66 -0
- package/lib/typescript/src/definition/defineGame.d.ts.map +1 -0
- package/lib/typescript/src/definition/types.d.ts +56 -0
- package/lib/typescript/src/definition/types.d.ts.map +1 -0
- package/lib/typescript/src/geometry/types.d.ts +8 -0
- package/lib/typescript/src/geometry/types.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +18 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/react/GamePointerInput.d.ts +37 -0
- package/lib/typescript/src/react/GamePointerInput.d.ts.map +1 -0
- package/lib/typescript/src/react/GameView.d.ts +61 -0
- package/lib/typescript/src/react/GameView.d.ts.map +1 -0
- package/lib/typescript/src/react/alphaClock.d.ts +29 -0
- package/lib/typescript/src/react/alphaClock.d.ts.map +1 -0
- package/lib/typescript/src/react/assets/createGameAssetStore.d.ts +33 -0
- package/lib/typescript/src/react/assets/createGameAssetStore.d.ts.map +1 -0
- package/lib/typescript/src/react/assets/decodeSkiaImage.d.ts +12 -0
- package/lib/typescript/src/react/assets/decodeSkiaImage.d.ts.map +1 -0
- package/lib/typescript/src/react/assets/errors.d.ts +8 -0
- package/lib/typescript/src/react/assets/errors.d.ts.map +1 -0
- package/lib/typescript/src/react/assets/useGameAssets.d.ts +35 -0
- package/lib/typescript/src/react/assets/useGameAssets.d.ts.map +1 -0
- package/lib/typescript/src/react/bindAppLifecycle.d.ts +32 -0
- package/lib/typescript/src/react/bindAppLifecycle.d.ts.map +1 -0
- package/lib/typescript/src/react/bindGameSession.d.ts +14 -0
- package/lib/typescript/src/react/bindGameSession.d.ts.map +1 -0
- package/lib/typescript/src/react/gestureLifecycle.d.ts +37 -0
- package/lib/typescript/src/react/gestureLifecycle.d.ts.map +1 -0
- package/lib/typescript/src/react/instrumentation.d.ts +47 -0
- package/lib/typescript/src/react/instrumentation.d.ts.map +1 -0
- package/lib/typescript/src/react/pointerBinding.d.ts +104 -0
- package/lib/typescript/src/react/pointerBinding.d.ts.map +1 -0
- package/lib/typescript/src/react/pointerCoalescer.d.ts +106 -0
- package/lib/typescript/src/react/pointerCoalescer.d.ts.map +1 -0
- package/lib/typescript/src/react/pointerContainment.d.ts +12 -0
- package/lib/typescript/src/react/pointerContainment.d.ts.map +1 -0
- package/lib/typescript/src/react/sprites/GameSprite.d.ts +75 -0
- package/lib/typescript/src/react/sprites/GameSprite.d.ts.map +1 -0
- package/lib/typescript/src/react/sprites/GameWorld2D.d.ts +29 -0
- package/lib/typescript/src/react/sprites/GameWorld2D.d.ts.map +1 -0
- package/lib/typescript/src/react/sprites/Sprite.d.ts +43 -0
- package/lib/typescript/src/react/sprites/Sprite.d.ts.map +1 -0
- package/lib/typescript/src/react/sprites/SpriteBatch.d.ts +41 -0
- package/lib/typescript/src/react/sprites/SpriteBatch.d.ts.map +1 -0
- package/lib/typescript/src/react/sprites/spriteBatchPolicy.d.ts +10 -0
- package/lib/typescript/src/react/sprites/spriteBatchPolicy.d.ts.map +1 -0
- package/lib/typescript/src/react/sprites/spriteTransform.d.ts +86 -0
- package/lib/typescript/src/react/sprites/spriteTransform.d.ts.map +1 -0
- package/lib/typescript/src/react/viewportBinding.d.ts +42 -0
- package/lib/typescript/src/react/viewportBinding.d.ts.map +1 -0
- package/lib/typescript/src/react.d.ts +20 -0
- package/lib/typescript/src/react.d.ts.map +1 -0
- package/lib/typescript/src/scene/defineScene.d.ts +26 -0
- package/lib/typescript/src/scene/defineScene.d.ts.map +1 -0
- package/lib/typescript/src/scene/types.d.ts +71 -0
- package/lib/typescript/src/scene/types.d.ts.map +1 -0
- package/lib/typescript/src/sprites/sampleSpriteClip.d.ts +24 -0
- package/lib/typescript/src/sprites/sampleSpriteClip.d.ts.map +1 -0
- package/lib/typescript/src/sprites/spriteAnimationState.d.ts +45 -0
- package/lib/typescript/src/sprites/spriteAnimationState.d.ts.map +1 -0
- package/lib/typescript/src/testing.d.ts +28 -0
- package/lib/typescript/src/testing.d.ts.map +1 -0
- package/lib/typescript/src/viewport2d/index.d.ts +3 -0
- package/lib/typescript/src/viewport2d/index.d.ts.map +1 -0
- package/lib/typescript/src/viewport2d/math.d.ts +25 -0
- package/lib/typescript/src/viewport2d/math.d.ts.map +1 -0
- package/lib/typescript/src/viewport2d/types.d.ts +59 -0
- package/lib/typescript/src/viewport2d/types.d.ts.map +1 -0
- package/package.json +120 -0
- package/src/assets/defineAssets.ts +62 -0
- package/src/assets/errors.ts +64 -0
- package/src/assets/types.ts +164 -0
- package/src/assets/validation.ts +158 -0
- package/src/core/frameDriver.ts +31 -0
- package/src/core/input/createInputBuffer.ts +337 -0
- package/src/core/input/types.ts +67 -0
- package/src/core/session/createGameSession.ts +652 -0
- package/src/core/session/deepFreeze.ts +158 -0
- package/src/core/session/diagnostics.ts +35 -0
- package/src/core/session/types.ts +122 -0
- package/src/definition/defineGame.ts +94 -0
- package/src/definition/types.ts +67 -0
- package/src/geometry/types.ts +7 -0
- package/src/index.ts +84 -0
- package/src/react/GamePointerInput.tsx +478 -0
- package/src/react/GameView.tsx +317 -0
- package/src/react/alphaClock.ts +59 -0
- package/src/react/assets/createGameAssetStore.ts +459 -0
- package/src/react/assets/decodeSkiaImage.ts +64 -0
- package/src/react/assets/errors.ts +7 -0
- package/src/react/assets/useGameAssets.ts +171 -0
- package/src/react/bindAppLifecycle.ts +79 -0
- package/src/react/bindGameSession.ts +41 -0
- package/src/react/gestureLifecycle.ts +73 -0
- package/src/react/instrumentation.ts +54 -0
- package/src/react/pointerBinding.ts +226 -0
- package/src/react/pointerCoalescer.ts +234 -0
- package/src/react/pointerContainment.ts +23 -0
- package/src/react/sprites/GameSprite.tsx +192 -0
- package/src/react/sprites/GameWorld2D.tsx +46 -0
- package/src/react/sprites/Sprite.tsx +194 -0
- package/src/react/sprites/SpriteBatch.tsx +219 -0
- package/src/react/sprites/spriteBatchPolicy.ts +21 -0
- package/src/react/sprites/spriteTransform.ts +163 -0
- package/src/react/viewportBinding.ts +104 -0
- package/src/react.ts +37 -0
- package/src/scene/defineScene.ts +38 -0
- package/src/scene/types.ts +79 -0
- package/src/sprites/sampleSpriteClip.ts +51 -0
- package/src/sprites/spriteAnimationState.ts +176 -0
- package/src/testing.ts +61 -0
- package/src/viewport2d/index.ts +7 -0
- package/src/viewport2d/math.ts +134 -0
- package/src/viewport2d/types.ts +63 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointer coalescer (T7).
|
|
3
|
+
*
|
|
4
|
+
* Pure, worklet-safe state machine that throttles move forwarding from the
|
|
5
|
+
* UI runtime: at most one move crosses runtimes per configured interval
|
|
6
|
+
* (default: one fixed step), always carrying the latest dirty position.
|
|
7
|
+
* Edges are never throttled or dropped:
|
|
8
|
+
*
|
|
9
|
+
* - `down` forwards the begin immediately (latency-critical) and establishes
|
|
10
|
+
* the single active pointer; a secondary pointer is ignored.
|
|
11
|
+
* - `move` updates the latest dirty position and forwards it at most once
|
|
12
|
+
* per interval.
|
|
13
|
+
* - `flush` forwards the latest deferred move even if native touch movement
|
|
14
|
+
* has paused.
|
|
15
|
+
* - `up` forwards the final position in the terminal edge and releases the
|
|
16
|
+
* active pointer.
|
|
17
|
+
* - `cancel` neutralizes exactly once.
|
|
18
|
+
* - `reset` drops all queued state (layout revision, unmount).
|
|
19
|
+
*
|
|
20
|
+
* The coalescer is not the authoritative owner: ownership, edge sampling,
|
|
21
|
+
* and containment re-validation stay on the JS side (input buffer).
|
|
22
|
+
* Time is injected so ordering and frequency are testable without a device.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type CoalescedPointerEvent =
|
|
26
|
+
| { readonly kind: 'begin'; readonly pointerId: number; readonly x: number; readonly y: number }
|
|
27
|
+
| { readonly kind: 'move'; readonly pointerId: number; readonly x: number; readonly y: number }
|
|
28
|
+
| { readonly kind: 'end'; readonly pointerId: number; readonly x: number; readonly y: number }
|
|
29
|
+
| { readonly kind: 'cancel' };
|
|
30
|
+
|
|
31
|
+
export interface PointerCoalescer {
|
|
32
|
+
/** A pointer went down at a surface point. Returns the events to forward. */
|
|
33
|
+
down(pointerId: number, x: number, y: number, nowMs: number): readonly CoalescedPointerEvent[];
|
|
34
|
+
/** A pointer moved. Returns at most one (coalesced) move per interval. */
|
|
35
|
+
move(pointerId: number, x: number, y: number, nowMs: number): readonly CoalescedPointerEvent[];
|
|
36
|
+
/** Forward a deferred move when the frame clock reaches the interval. */
|
|
37
|
+
flush(nowMs: number): readonly CoalescedPointerEvent[];
|
|
38
|
+
/** A pointer lifted. Returns the final point and the terminal edge together. */
|
|
39
|
+
up(pointerId: number, x: number, y: number, nowMs: number): readonly CoalescedPointerEvent[];
|
|
40
|
+
/** Neutralize the active pointer. Returns the cancel event exactly once. */
|
|
41
|
+
cancel(nowMs: number): readonly CoalescedPointerEvent[];
|
|
42
|
+
/** Drop all queued state without emitting anything (layout/unmount). */
|
|
43
|
+
reset(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ActivePointer {
|
|
47
|
+
readonly pointerId: number;
|
|
48
|
+
readonly lastForwardMs: number;
|
|
49
|
+
readonly pendingMove:
|
|
50
|
+
| { readonly x: number; readonly y: number }
|
|
51
|
+
| undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Explicit coalescer state stored in one UI-runtime SharedValue. */
|
|
55
|
+
export interface PointerCoalescerState {
|
|
56
|
+
readonly maxMoveIntervalMs: number;
|
|
57
|
+
readonly active: ActivePointer | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One native touch input consumed by the pure coalescer reducer. */
|
|
61
|
+
export type PointerCoalescerInput =
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: 'down' | 'move' | 'up';
|
|
64
|
+
readonly pointerId: number;
|
|
65
|
+
readonly x: number;
|
|
66
|
+
readonly y: number;
|
|
67
|
+
readonly nowMs: number;
|
|
68
|
+
}
|
|
69
|
+
| { readonly kind: 'cancel'; readonly nowMs: number }
|
|
70
|
+
| { readonly kind: 'flush'; readonly nowMs: number }
|
|
71
|
+
| { readonly kind: 'reset' };
|
|
72
|
+
|
|
73
|
+
/** Immutable state plus the ordered events produced by one coalescer input. */
|
|
74
|
+
export interface PointerCoalescerTransition {
|
|
75
|
+
readonly state: PointerCoalescerState;
|
|
76
|
+
readonly events: readonly CoalescedPointerEvent[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Create fresh state for a UI-runtime coalescer. */
|
|
80
|
+
export function createPointerCoalescerState(
|
|
81
|
+
maxMoveIntervalMs: number,
|
|
82
|
+
): PointerCoalescerState {
|
|
83
|
+
'worklet';
|
|
84
|
+
return { maxMoveIntervalMs, active: undefined };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Advance pointer coalescing without relying on mutable worklet closures.
|
|
89
|
+
*
|
|
90
|
+
* RNGH registers each touch callback as a separate worklet. Closure-captured
|
|
91
|
+
* objects may therefore be serialized into independent copies; callers must
|
|
92
|
+
* keep the returned state in one SharedValue and pass it into every handler.
|
|
93
|
+
*/
|
|
94
|
+
export function reducePointerCoalescer(
|
|
95
|
+
state: PointerCoalescerState,
|
|
96
|
+
input: PointerCoalescerInput,
|
|
97
|
+
): PointerCoalescerTransition {
|
|
98
|
+
'worklet';
|
|
99
|
+
if (input.kind === 'reset') {
|
|
100
|
+
return { state: createPointerCoalescerState(state.maxMoveIntervalMs), events: [] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (input.kind === 'down') {
|
|
104
|
+
if (state.active !== undefined) {
|
|
105
|
+
return { state, events: [] };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
state: {
|
|
109
|
+
...state,
|
|
110
|
+
active: {
|
|
111
|
+
pointerId: input.pointerId,
|
|
112
|
+
lastForwardMs: input.nowMs,
|
|
113
|
+
pendingMove: undefined,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
events: [
|
|
117
|
+
{
|
|
118
|
+
kind: 'begin',
|
|
119
|
+
pointerId: input.pointerId,
|
|
120
|
+
x: input.x,
|
|
121
|
+
y: input.y,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (input.kind === 'cancel') {
|
|
128
|
+
if (state.active === undefined) {
|
|
129
|
+
return { state, events: [] };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
state: { ...state, active: undefined },
|
|
133
|
+
events: [{ kind: 'cancel' }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const active = state.active;
|
|
138
|
+
|
|
139
|
+
if (input.kind === 'flush') {
|
|
140
|
+
if (active === undefined || active.pendingMove === undefined) {
|
|
141
|
+
return { state, events: [] };
|
|
142
|
+
}
|
|
143
|
+
if (input.nowMs - active.lastForwardMs < state.maxMoveIntervalMs) {
|
|
144
|
+
return { state, events: [] };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
state: {
|
|
148
|
+
...state,
|
|
149
|
+
active: {
|
|
150
|
+
...active,
|
|
151
|
+
lastForwardMs: input.nowMs,
|
|
152
|
+
pendingMove: undefined,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
events: [
|
|
156
|
+
{
|
|
157
|
+
kind: 'move',
|
|
158
|
+
pointerId: active.pointerId,
|
|
159
|
+
x: active.pendingMove.x,
|
|
160
|
+
y: active.pendingMove.y,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (active === undefined || active.pointerId !== input.pointerId) {
|
|
167
|
+
return { state, events: [] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (input.kind === 'up') {
|
|
171
|
+
return {
|
|
172
|
+
state: { ...state, active: undefined },
|
|
173
|
+
// The terminal event carries the newest point, subsuming a deferred move.
|
|
174
|
+
events: [
|
|
175
|
+
{
|
|
176
|
+
kind: 'end',
|
|
177
|
+
pointerId: input.pointerId,
|
|
178
|
+
x: input.x,
|
|
179
|
+
y: input.y,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const shouldForward =
|
|
186
|
+
input.nowMs - active.lastForwardMs >= state.maxMoveIntervalMs;
|
|
187
|
+
const nextActive: ActivePointer = {
|
|
188
|
+
pointerId: input.pointerId,
|
|
189
|
+
lastForwardMs: shouldForward ? input.nowMs : active.lastForwardMs,
|
|
190
|
+
pendingMove: shouldForward ? undefined : { x: input.x, y: input.y },
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
state: { ...state, active: nextActive },
|
|
194
|
+
events: shouldForward
|
|
195
|
+
? [
|
|
196
|
+
{
|
|
197
|
+
kind: 'move',
|
|
198
|
+
pointerId: input.pointerId,
|
|
199
|
+
x: input.x,
|
|
200
|
+
y: input.y,
|
|
201
|
+
},
|
|
202
|
+
]
|
|
203
|
+
: [],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Stateful RN-runtime adapter retained for pure tests and non-worklet callers.
|
|
209
|
+
* UI gesture handlers use `reducePointerCoalescer` with a SharedValue instead.
|
|
210
|
+
*/
|
|
211
|
+
export function createPointerCoalescer(maxMoveIntervalMs: number): PointerCoalescer {
|
|
212
|
+
let state = createPointerCoalescerState(maxMoveIntervalMs);
|
|
213
|
+
|
|
214
|
+
const dispatch = (input: PointerCoalescerInput): readonly CoalescedPointerEvent[] => {
|
|
215
|
+
const transition = reducePointerCoalescer(state, input);
|
|
216
|
+
state = transition.state;
|
|
217
|
+
return transition.events;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const coalescer: PointerCoalescer = {
|
|
221
|
+
down: (pointerId: number, x: number, y: number, nowMs: number) =>
|
|
222
|
+
dispatch({ kind: 'down', pointerId, x, y, nowMs }),
|
|
223
|
+
move: (pointerId: number, x: number, y: number, nowMs: number) =>
|
|
224
|
+
dispatch({ kind: 'move', pointerId, x, y, nowMs }),
|
|
225
|
+
flush: (nowMs: number) => dispatch({ kind: 'flush', nowMs }),
|
|
226
|
+
up: (pointerId: number, x: number, y: number, nowMs: number) =>
|
|
227
|
+
dispatch({ kind: 'up', pointerId, x, y, nowMs }),
|
|
228
|
+
cancel: (nowMs: number) => dispatch({ kind: 'cancel', nowMs }),
|
|
229
|
+
reset: () => {
|
|
230
|
+
dispatch({ kind: 'reset' });
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
return Object.freeze(coalescer);
|
|
234
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI-side containment mirror (T7).
|
|
3
|
+
*
|
|
4
|
+
* The exact same viewport formula the JS binding uses to reject begins in
|
|
5
|
+
* `fit` letterbox space, workletized so the Manual gesture can fail before
|
|
6
|
+
* activation. JS re-validates on receipt; this mirror only prevents an
|
|
7
|
+
* obviously invalid begin from activating the gesture at all.
|
|
8
|
+
*/
|
|
9
|
+
import type { ResolvedViewport2D } from '../viewport2d';
|
|
10
|
+
|
|
11
|
+
/** Whether a surface point may begin a gesture in the interactive content. */
|
|
12
|
+
export function isBeginAllowed(
|
|
13
|
+
viewport: ResolvedViewport2D | undefined,
|
|
14
|
+
x: number,
|
|
15
|
+
y: number,
|
|
16
|
+
): boolean {
|
|
17
|
+
'worklet';
|
|
18
|
+
if (viewport === undefined) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const { x: boundsX, y: boundsY, width, height } = viewport.contentBounds;
|
|
22
|
+
return x >= boundsX && y >= boundsY && x <= boundsX + width && y <= boundsY + height;
|
|
23
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GameSprite` — the common GameKit sprite integration (T7.6).
|
|
3
|
+
*
|
|
4
|
+
* Accepts the renderer's commit/alpha shared values, narrows the snapshot
|
|
5
|
+
* by `scene`, invokes one worklet `select` mapper, and drives the
|
|
6
|
+
* underlying `Sprite` through coherent derived values. The selector's scene
|
|
7
|
+
* typing is exact — a `scene="play"` selector receives only the play
|
|
8
|
+
* snapshot without casts.
|
|
9
|
+
*/
|
|
10
|
+
import { useDerivedValue, type SharedValue } from 'react-native-reanimated';
|
|
11
|
+
|
|
12
|
+
import type { CommitFrame } from '../../core/session/types';
|
|
13
|
+
import type { SceneSnapshot } from '../../scene/types';
|
|
14
|
+
import type { SceneMap } from '../../definition/types';
|
|
15
|
+
import type { LoadedImage, LoadedSpriteSheet } from '../../assets/types';
|
|
16
|
+
import { Sprite, type SpriteAnimatable, type SpriteAnimatableBoolean, type SpriteProps } from './Sprite';
|
|
17
|
+
|
|
18
|
+
/** Interpolation context for the select mapper. */
|
|
19
|
+
export interface GameSpriteSelectContext<
|
|
20
|
+
TScenes extends SceneMap,
|
|
21
|
+
TSceneName extends Extract<keyof TScenes, string>,
|
|
22
|
+
> {
|
|
23
|
+
/** The previous commit's snapshot for the selected scene. */
|
|
24
|
+
readonly previous: SceneSnapshot<TScenes[TSceneName]>;
|
|
25
|
+
/** The current commit's snapshot for the selected scene. */
|
|
26
|
+
readonly current: SceneSnapshot<TScenes[TSceneName]>;
|
|
27
|
+
/** Presentation interpolation alpha in [0, 1]. */
|
|
28
|
+
readonly alpha: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The discrete + interpolated presentation selection. */
|
|
32
|
+
export interface GameSpriteSelection {
|
|
33
|
+
/** World position (interpolated when the selector requests it). */
|
|
34
|
+
readonly x: number;
|
|
35
|
+
readonly y: number;
|
|
36
|
+
/** Rotation in radians around the anchor. */
|
|
37
|
+
readonly rotation?: number;
|
|
38
|
+
/** Uniform scale around the anchor. */
|
|
39
|
+
readonly scale?: number;
|
|
40
|
+
/** The clip to select from the sprite sheet (discrete). */
|
|
41
|
+
readonly clip?: string;
|
|
42
|
+
/** Elapsed time within the clip in milliseconds (discrete). */
|
|
43
|
+
readonly elapsedMs?: number;
|
|
44
|
+
/** The exact frame name (discrete; overrides clip + elapsedMs). */
|
|
45
|
+
readonly frame?: string;
|
|
46
|
+
/** Explicit flips (discrete). */
|
|
47
|
+
readonly flipX?: boolean;
|
|
48
|
+
readonly flipY?: boolean;
|
|
49
|
+
/** Opacity in [0, 1]. */
|
|
50
|
+
readonly opacity?: number;
|
|
51
|
+
/** Hide the sprite. */
|
|
52
|
+
readonly visible?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GameSpriteProps<
|
|
56
|
+
TScenes extends SceneMap,
|
|
57
|
+
TSceneName extends Extract<keyof TScenes, string>,
|
|
58
|
+
> {
|
|
59
|
+
/** The scene whose snapshot the selector reads. */
|
|
60
|
+
readonly scene: TSceneName;
|
|
61
|
+
/** The renderer's latest commit. */
|
|
62
|
+
readonly commit: SharedValue<CommitFrame<TScenes>>;
|
|
63
|
+
/** The renderer's presentation alpha. */
|
|
64
|
+
readonly alpha: SharedValue<number>;
|
|
65
|
+
/** The loaded asset; the renderer borrows and never disposes it. */
|
|
66
|
+
readonly source: LoadedImage | LoadedSpriteSheet;
|
|
67
|
+
/** Normalized anchor in [0, 1] relative to the selected frame. */
|
|
68
|
+
readonly anchor?: { readonly x: number; readonly y: number };
|
|
69
|
+
/** Worklet mapper from the scene snapshots to presentation values. */
|
|
70
|
+
readonly select: (context: GameSpriteSelectContext<TScenes, TSceneName>) => GameSpriteSelection;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Select the frame name for a clip + elapsed time (loop/once semantics).
|
|
75
|
+
* Reads the descriptor's animation table; the result is resolved against
|
|
76
|
+
* the frame rectangles by the caller. Reuses the pure sampler's semantics.
|
|
77
|
+
*/
|
|
78
|
+
export function spriteFrameNameForClip(
|
|
79
|
+
animations: Readonly<
|
|
80
|
+
Record<
|
|
81
|
+
string,
|
|
82
|
+
{
|
|
83
|
+
readonly frames: readonly string[];
|
|
84
|
+
readonly frameDurationMs: number;
|
|
85
|
+
readonly mode: 'loop' | 'once';
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
>,
|
|
89
|
+
clip: string,
|
|
90
|
+
elapsedMs: number,
|
|
91
|
+
): string {
|
|
92
|
+
'worklet';
|
|
93
|
+
const animation = animations[clip];
|
|
94
|
+
if (animation === undefined || animation.frames.length === 0) {
|
|
95
|
+
return clip;
|
|
96
|
+
}
|
|
97
|
+
const duration = animation.frameDurationMs;
|
|
98
|
+
const count = animation.frames.length;
|
|
99
|
+
const index =
|
|
100
|
+
animation.mode === 'once'
|
|
101
|
+
? Math.min(Math.floor(elapsedMs / duration), count - 1)
|
|
102
|
+
: Math.floor(elapsedMs / duration) % count;
|
|
103
|
+
return animation.frames[index] ?? animation.frames[0] ?? clip;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function GameSprite<
|
|
107
|
+
TScenes extends SceneMap,
|
|
108
|
+
TSceneName extends Extract<keyof TScenes, string>,
|
|
109
|
+
>({
|
|
110
|
+
scene,
|
|
111
|
+
commit,
|
|
112
|
+
alpha,
|
|
113
|
+
source,
|
|
114
|
+
anchor,
|
|
115
|
+
select,
|
|
116
|
+
}: GameSpriteProps<TScenes, TSceneName>) {
|
|
117
|
+
const selection = useDerivedValue(() => {
|
|
118
|
+
'worklet';
|
|
119
|
+
const envelope = commit.value;
|
|
120
|
+
if (envelope.scene !== scene) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const current = envelope.current as never as SceneSnapshot<TScenes[TSceneName]>;
|
|
124
|
+
if (current === undefined) {
|
|
125
|
+
// The frame has not been seeded for this session yet (session swap on
|
|
126
|
+
// the persistent surface): present nothing until the first commit.
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const context = {
|
|
130
|
+
// The first commit has no previous snapshot; the current one stands in
|
|
131
|
+
// so the initial presentation interpolates from itself (alpha 0).
|
|
132
|
+
previous:
|
|
133
|
+
(envelope.previous ?? envelope.current) as never as SceneSnapshot<TScenes[TSceneName]>,
|
|
134
|
+
current,
|
|
135
|
+
alpha: alpha.value,
|
|
136
|
+
} as GameSpriteSelectContext<TScenes, TSceneName>;
|
|
137
|
+
return select(context);
|
|
138
|
+
}, [commit, alpha, scene, select]);
|
|
139
|
+
|
|
140
|
+
const x = useDerivedValue(() => selection.value?.x ?? 0);
|
|
141
|
+
const y = useDerivedValue(() => selection.value?.y ?? 0);
|
|
142
|
+
const rotation = useDerivedValue(() => selection.value?.rotation ?? 0);
|
|
143
|
+
const scale = useDerivedValue(() => selection.value?.scale ?? 1);
|
|
144
|
+
const opacity = useDerivedValue(() => selection.value?.opacity ?? 1);
|
|
145
|
+
const flipX = useDerivedValue(() => selection.value?.flipX ?? false);
|
|
146
|
+
const flipY = useDerivedValue(() => selection.value?.flipY ?? false);
|
|
147
|
+
const visible = useDerivedValue(() => selection.value?.visible ?? true);
|
|
148
|
+
const frameName = useDerivedValue(() => {
|
|
149
|
+
'worklet';
|
|
150
|
+
const selectionValue = selection.value;
|
|
151
|
+
if (selectionValue === undefined) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
if (selectionValue.frame !== undefined) {
|
|
155
|
+
return selectionValue.frame;
|
|
156
|
+
}
|
|
157
|
+
if (selectionValue.clip === undefined) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
if (source.descriptor.kind !== 'sprite-sheet') {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
// R3: the sampler reads the descriptor's animation table (clips), never
|
|
164
|
+
// the frame-rectangle map; the returned frame name is resolved against
|
|
165
|
+
// the frame rectangles in the Sprite's rect modifier.
|
|
166
|
+
const sheet = source as LoadedSpriteSheet;
|
|
167
|
+
return spriteFrameNameForClip(
|
|
168
|
+
sheet.descriptor.animations,
|
|
169
|
+
selectionValue.clip,
|
|
170
|
+
selectionValue.elapsedMs ?? 0,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const animated = {
|
|
175
|
+
x: x as SpriteAnimatable,
|
|
176
|
+
y: y as SpriteAnimatable,
|
|
177
|
+
rotation: rotation as SpriteAnimatable,
|
|
178
|
+
scale: scale as SpriteAnimatable,
|
|
179
|
+
opacity: opacity as SpriteAnimatable,
|
|
180
|
+
flipX: flipX as SpriteAnimatableBoolean,
|
|
181
|
+
flipY: flipY as SpriteAnimatableBoolean,
|
|
182
|
+
visible: visible as SpriteAnimatableBoolean,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const props = {
|
|
186
|
+
source,
|
|
187
|
+
frame: source.descriptor.kind === 'sprite-sheet' ? (frameName as never) : undefined,
|
|
188
|
+
anchor,
|
|
189
|
+
...animated,
|
|
190
|
+
} as unknown as SpriteProps;
|
|
191
|
+
return <Sprite {...props} />;
|
|
192
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GameWorld2D` — the viewport transform group (T7.6).
|
|
3
|
+
*
|
|
4
|
+
* Applies the resolved viewport offset/scale exactly once to all child
|
|
5
|
+
* sprites. It adds no camera state, subscribes no React per frame, and is
|
|
6
|
+
* not a camera API. Raw Skia nodes may be nested inside.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { Group } from '@shopify/react-native-skia';
|
|
10
|
+
import { useDerivedValue, type SharedValue } from 'react-native-reanimated';
|
|
11
|
+
|
|
12
|
+
import type { ResolvedViewport2D } from '../../viewport2d/types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The viewport transform as a Skia element list:
|
|
16
|
+
* `T(offset) · S(scale)` — logical units map to surface points.
|
|
17
|
+
*/
|
|
18
|
+
export function viewportTransform(viewport: ResolvedViewport2D | undefined): readonly (
|
|
19
|
+
| { readonly translateX: number }
|
|
20
|
+
| { readonly translateY: number }
|
|
21
|
+
| { readonly scaleX: number }
|
|
22
|
+
| { readonly scaleY: number }
|
|
23
|
+
)[] {
|
|
24
|
+
'worklet';
|
|
25
|
+
if (viewport === undefined) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return [
|
|
29
|
+
{ translateX: viewport.offsetX },
|
|
30
|
+
{ translateY: viewport.offsetY },
|
|
31
|
+
{ scaleX: viewport.scale },
|
|
32
|
+
{ scaleY: viewport.scale },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function GameWorld2D({
|
|
37
|
+
viewport,
|
|
38
|
+
children,
|
|
39
|
+
}: {
|
|
40
|
+
/** The resolved viewport shared value supplied to the renderer. */
|
|
41
|
+
readonly viewport: SharedValue<ResolvedViewport2D | undefined>;
|
|
42
|
+
readonly children: ReactNode;
|
|
43
|
+
}) {
|
|
44
|
+
const transform = useDerivedValue(() => viewportTransform(viewport.value));
|
|
45
|
+
return <Group transform={transform as never}>{children}</Group>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Sprite` — the retained sprite primitive (T7.6).
|
|
3
|
+
*
|
|
4
|
+
* Renders one full-image sprite or one sprite-sheet frame through a
|
|
5
|
+
* source-verified Skia path: full images use `<Image>`; sheet frames use a
|
|
6
|
+
* single-entry Atlas (the RSXform carries rotation + position, a wrapping
|
|
7
|
+
* Group applies the scale/flip part around the anchor). All animatable
|
|
8
|
+
* properties accept plain numbers or Reanimated shared/derived values
|
|
9
|
+
* without mirroring them through React state. Drawing order is React child
|
|
10
|
+
* order. The sprite never subscribes React to the game frame.
|
|
11
|
+
*/
|
|
12
|
+
import { useMemo } from 'react';
|
|
13
|
+
import { Atlas, Group, type SkImage, type SamplingOptions } from '@shopify/react-native-skia';
|
|
14
|
+
import { useDerivedValue, type SharedValue } from 'react-native-reanimated';
|
|
15
|
+
import { useRectBuffer, useRSXformBuffer } from '@shopify/react-native-skia';
|
|
16
|
+
|
|
17
|
+
import type { LoadedImage, LoadedSpriteSheet } from '../../assets/types';
|
|
18
|
+
import {
|
|
19
|
+
computeSpriteRsxform,
|
|
20
|
+
resolveSpriteFrameRect,
|
|
21
|
+
spriteGroupCorrection,
|
|
22
|
+
type SpriteTransformInput,
|
|
23
|
+
} from './spriteTransform';
|
|
24
|
+
|
|
25
|
+
export { resolveSpriteFrameRect, type SpriteFrameRect } from './spriteTransform';
|
|
26
|
+
|
|
27
|
+
export type SpriteAnimatable = number | SharedValue<number>;
|
|
28
|
+
export type SpriteAnimatableBoolean = boolean | SharedValue<boolean>;
|
|
29
|
+
export type SpriteAnimatableString = string | SharedValue<string>;
|
|
30
|
+
|
|
31
|
+
export interface SpriteProps {
|
|
32
|
+
/** The loaded asset; the renderer borrows and never disposes it. */
|
|
33
|
+
readonly source: LoadedImage | LoadedSpriteSheet;
|
|
34
|
+
/** Frame name for sprite sheets; ignored for full images. A shared value
|
|
35
|
+
* may carry undefined while the selection has not been published. */
|
|
36
|
+
readonly frame?: SpriteAnimatableString | SharedValue<string | undefined>;
|
|
37
|
+
/** Clip name for sprite sheets; the frame is selected from it. */
|
|
38
|
+
readonly clip?: SpriteAnimatableString;
|
|
39
|
+
/** Elapsed milliseconds within the clip (frame selection). */
|
|
40
|
+
readonly elapsedMs?: SpriteAnimatable;
|
|
41
|
+
/** World position in logical units. */
|
|
42
|
+
readonly x?: SpriteAnimatable;
|
|
43
|
+
readonly y?: SpriteAnimatable;
|
|
44
|
+
/** Rotation in radians around the anchor. */
|
|
45
|
+
readonly rotation?: SpriteAnimatable;
|
|
46
|
+
/** Uniform scale around the anchor. */
|
|
47
|
+
readonly scale?: SpriteAnimatable;
|
|
48
|
+
/** Normalized anchor in [0, 1] relative to the selected frame. */
|
|
49
|
+
readonly anchor?: { readonly x: number; readonly y: number };
|
|
50
|
+
/** Explicit flips preserve the anchor. */
|
|
51
|
+
readonly flipX?: SpriteAnimatableBoolean;
|
|
52
|
+
readonly flipY?: SpriteAnimatableBoolean;
|
|
53
|
+
/** Opacity in [0, 1]. */
|
|
54
|
+
readonly opacity?: SpriteAnimatable;
|
|
55
|
+
/** Optional tint color applied to the draw. */
|
|
56
|
+
readonly tint?: string;
|
|
57
|
+
/** Sampling for the texture; pixel-art defaults to nearest. */
|
|
58
|
+
readonly sampling?: SamplingOptions;
|
|
59
|
+
/** Hide the sprite without unmounting it. */
|
|
60
|
+
readonly visible?: SpriteAnimatableBoolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function Sprite({
|
|
64
|
+
source,
|
|
65
|
+
frame,
|
|
66
|
+
clip,
|
|
67
|
+
elapsedMs = 0,
|
|
68
|
+
x = 0,
|
|
69
|
+
y = 0,
|
|
70
|
+
rotation = 0,
|
|
71
|
+
scale = 1,
|
|
72
|
+
anchor = { x: 0, y: 0 },
|
|
73
|
+
flipX = false,
|
|
74
|
+
flipY = false,
|
|
75
|
+
opacity = 1,
|
|
76
|
+
tint,
|
|
77
|
+
sampling,
|
|
78
|
+
visible = true,
|
|
79
|
+
}: SpriteProps) {
|
|
80
|
+
const image = source.image as SkImage;
|
|
81
|
+
|
|
82
|
+
// Worklet-safe frame resolution: an explicit `frame` wins; otherwise the
|
|
83
|
+
// clip + elapsed time select the frame (the clip/elapsed may be animated
|
|
84
|
+
// shared values, so the resolution happens inside the rect modifier).
|
|
85
|
+
const resolveFrameRectWorklet = (
|
|
86
|
+
rect: { setXYWH(x: number, y: number, width: number, height: number): void },
|
|
87
|
+
): void => {
|
|
88
|
+
'worklet';
|
|
89
|
+
const clipValue = typeof clip === 'string' ? clip : clip?.value;
|
|
90
|
+
const elapsedValue = typeof elapsedMs === 'number' ? elapsedMs : elapsedMs?.value;
|
|
91
|
+
let name: string | undefined = typeof frame === 'string' ? frame : frame?.value;
|
|
92
|
+
if (name === undefined && source.descriptor.kind === 'sprite-sheet' && clipValue !== undefined) {
|
|
93
|
+
const sheet = source as LoadedSpriteSheet;
|
|
94
|
+
// R3: resolve the clip through the descriptor's animation table, then
|
|
95
|
+
// the returned frame name through the frame rectangles below.
|
|
96
|
+
const animation = sheet.descriptor.animations[clipValue];
|
|
97
|
+
if (animation !== undefined && animation.frames.length > 0) {
|
|
98
|
+
const duration = animation.frameDurationMs;
|
|
99
|
+
const count = animation.frames.length;
|
|
100
|
+
const index =
|
|
101
|
+
animation.mode === 'once'
|
|
102
|
+
? Math.min(Math.floor((elapsedValue ?? 0) / duration), count - 1)
|
|
103
|
+
: Math.floor((elapsedValue ?? 0) / duration) % count;
|
|
104
|
+
name = animation.frames[index] ?? animation.frames[0];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const frameRect =
|
|
108
|
+
source.descriptor.kind === 'image'
|
|
109
|
+
? { x: 0, y: 0, width: (source as LoadedImage).width, height: (source as LoadedImage).height }
|
|
110
|
+
: name === undefined
|
|
111
|
+
? undefined
|
|
112
|
+
: (source as LoadedSpriteSheet).frames[name];
|
|
113
|
+
if (frameRect === undefined) {
|
|
114
|
+
if (name === undefined) {
|
|
115
|
+
// RF4: the selection has not been published yet (scene mismatch or a
|
|
116
|
+
// shared frame before its first value): present nothing, never throw.
|
|
117
|
+
rect.setXYWH(0, 0, 0, 0);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`frame ${JSON.stringify(name)} does not belong to this sprite sheet (loaded frames: ${Object.keys((source as LoadedSpriteSheet).frames).join(', ')})`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
rect.setXYWH(frameRect.x, frameRect.y, frameRect.width, frameRect.height);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const staticFrame = typeof frame === 'string' ? frame : undefined;
|
|
128
|
+
const frameSize = useMemo(() => resolveSpriteFrameRect(source, staticFrame), [source, staticFrame]);
|
|
129
|
+
const input: SpriteTransformInput = {
|
|
130
|
+
x,
|
|
131
|
+
y,
|
|
132
|
+
rotation,
|
|
133
|
+
scale,
|
|
134
|
+
flipX,
|
|
135
|
+
flipY,
|
|
136
|
+
anchorX: anchor.x,
|
|
137
|
+
anchorY: anchor.y,
|
|
138
|
+
frameWidth: frameSize.width,
|
|
139
|
+
frameHeight: frameSize.height,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const rects = useRectBuffer(1, resolveFrameRectWorklet);
|
|
143
|
+
const xforms = useRSXformBuffer(1, (xform) => {
|
|
144
|
+
'worklet';
|
|
145
|
+
// RF4: resolve the selected frame's rectangle and dimensions in the same
|
|
146
|
+
// worklet update so the anchor math always uses the current frame size.
|
|
147
|
+
const name = typeof frame === 'string' ? frame : frame?.value;
|
|
148
|
+
let width = frameSize.width;
|
|
149
|
+
let height = frameSize.height;
|
|
150
|
+
if (name !== undefined && source.descriptor.kind === 'sprite-sheet') {
|
|
151
|
+
const rect = (source as LoadedSpriteSheet).frames[name];
|
|
152
|
+
if (rect !== undefined) {
|
|
153
|
+
width = rect.width;
|
|
154
|
+
height = rect.height;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const result = computeSpriteRsxform({
|
|
158
|
+
...input,
|
|
159
|
+
frameWidth: width,
|
|
160
|
+
frameHeight: height,
|
|
161
|
+
});
|
|
162
|
+
xform.set(result.scos, result.ssin, result.tx, result.ty);
|
|
163
|
+
});
|
|
164
|
+
const groupTransform = useMemo(
|
|
165
|
+
() => spriteGroupCorrection(input) as unknown as Parameters<typeof Group>[0]['transform'],
|
|
166
|
+
// The correction reads animated values at draw time; the element list
|
|
167
|
+
// identity is stable (values are shared-value reads inside the group).
|
|
168
|
+
[anchor.x, anchor.y, frameSize.width, frameSize.height],
|
|
169
|
+
);
|
|
170
|
+
const colors = useMemo(() => (tint === undefined ? undefined : [tint]), [tint]);
|
|
171
|
+
|
|
172
|
+
// The Skia Group has no `visible` prop: hiding is expressed as a combined
|
|
173
|
+
// opacity so the component stays mounted (topology is never remounted per
|
|
174
|
+
// frame) while the draw is fully transparent. RF4: derived on the UI
|
|
175
|
+
// runtime for every static/shared combination of opacity and visible.
|
|
176
|
+
const effectiveOpacity = useDerivedValue(() => {
|
|
177
|
+
'worklet';
|
|
178
|
+
const o = typeof opacity === 'number' ? opacity : opacity.value;
|
|
179
|
+
const v = typeof visible === 'boolean' ? visible : visible.value;
|
|
180
|
+
return v ? o : 0;
|
|
181
|
+
}, [opacity, visible]);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<Group transform={groupTransform as never} opacity={effectiveOpacity}>
|
|
185
|
+
<Atlas
|
|
186
|
+
image={image}
|
|
187
|
+
sprites={rects}
|
|
188
|
+
transforms={xforms}
|
|
189
|
+
colors={colors as never}
|
|
190
|
+
sampling={(sampling ?? 'nearest') as never}
|
|
191
|
+
/>
|
|
192
|
+
</Group>
|
|
193
|
+
);
|
|
194
|
+
}
|