@remix-gg/three 0.1.1

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 (67) hide show
  1. package/dist/assets.d.ts +36 -0
  2. package/dist/assets.d.ts.map +1 -0
  3. package/dist/assets.js +100 -0
  4. package/dist/audio.d.ts +42 -0
  5. package/dist/audio.d.ts.map +1 -0
  6. package/dist/audio.js +150 -0
  7. package/dist/camera.d.ts +72 -0
  8. package/dist/camera.d.ts.map +1 -0
  9. package/dist/camera.js +120 -0
  10. package/dist/collide.d.ts +111 -0
  11. package/dist/collide.d.ts.map +1 -0
  12. package/dist/collide.js +321 -0
  13. package/dist/forgiveness.d.ts +71 -0
  14. package/dist/forgiveness.d.ts.map +1 -0
  15. package/dist/forgiveness.js +85 -0
  16. package/dist/game.d.ts +69 -0
  17. package/dist/game.d.ts.map +1 -0
  18. package/dist/game.js +209 -0
  19. package/dist/hud/index.d.ts +72 -0
  20. package/dist/hud/index.d.ts.map +1 -0
  21. package/dist/hud/index.js +142 -0
  22. package/dist/hud/styles.d.ts +14 -0
  23. package/dist/hud/styles.d.ts.map +1 -0
  24. package/dist/hud/styles.js +142 -0
  25. package/dist/index.d.ts +30 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +27 -0
  28. package/dist/input/gestures.d.ts +124 -0
  29. package/dist/input/gestures.d.ts.map +1 -0
  30. package/dist/input/gestures.js +171 -0
  31. package/dist/input/index.d.ts +30 -0
  32. package/dist/input/index.d.ts.map +1 -0
  33. package/dist/input/index.js +121 -0
  34. package/dist/juice.d.ts +151 -0
  35. package/dist/juice.d.ts.map +1 -0
  36. package/dist/juice.js +237 -0
  37. package/dist/loop.d.ts +27 -0
  38. package/dist/loop.d.ts.map +1 -0
  39. package/dist/loop.js +30 -0
  40. package/dist/platform/index.d.ts +50 -0
  41. package/dist/platform/index.d.ts.map +1 -0
  42. package/dist/platform/index.js +177 -0
  43. package/dist/platform/sdk-contract.d.ts +113 -0
  44. package/dist/platform/sdk-contract.d.ts.map +1 -0
  45. package/dist/platform/sdk-contract.js +18 -0
  46. package/dist/ramp.d.ts +39 -0
  47. package/dist/ramp.d.ts.map +1 -0
  48. package/dist/ramp.js +24 -0
  49. package/dist/random.d.ts +128 -0
  50. package/dist/random.d.ts.map +1 -0
  51. package/dist/random.js +160 -0
  52. package/dist/scene/dispose.d.ts +46 -0
  53. package/dist/scene/dispose.d.ts.map +1 -0
  54. package/dist/scene/dispose.js +108 -0
  55. package/dist/scene/lighting.d.ts +42 -0
  56. package/dist/scene/lighting.d.ts.map +1 -0
  57. package/dist/scene/lighting.js +118 -0
  58. package/dist/scene/pool.d.ts +36 -0
  59. package/dist/scene/pool.d.ts.map +1 -0
  60. package/dist/scene/pool.js +70 -0
  61. package/dist/three.d.ts +2 -0
  62. package/dist/three.d.ts.map +1 -0
  63. package/dist/three.js +11 -0
  64. package/dist/viewport.d.ts +86 -0
  65. package/dist/viewport.d.ts.map +1 -0
  66. package/dist/viewport.js +174 -0
  67. package/package.json +43 -0
package/dist/juice.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The curated set. Linear motion everywhere is the tell of an untweened game —
3
+ * real objects accelerate — but a menagerie of forty easings is choice with no
4
+ * information. These seven cover the moves portrait games make: `quadOut` for
5
+ * almost everything that moves on screen, `backOut` for pops that overshoot,
6
+ * `elasticOut` for the rare springy emphasis, the rest for symmetry and ramps.
7
+ */
8
+ export const easings = {
9
+ linear: (t) => t,
10
+ quadIn: (t) => t * t,
11
+ quadOut: (t) => t * (2 - t),
12
+ quadInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t)),
13
+ cubicOut: (t) => 1 + (t - 1) ** 3,
14
+ backOut: (t) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2,
15
+ elasticOut: (t) => t === 0 || t === 1 ? t : 2 ** (-10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,
16
+ };
17
+ /** Absorbs accumulated step error in "has this timer elapsed" comparisons. */
18
+ const EPSILON = 1e-9;
19
+ const resolveEasing = (easing) => {
20
+ if (easing === undefined)
21
+ return easings.quadOut;
22
+ if (typeof easing === 'function')
23
+ return easing;
24
+ const found = easings[easing];
25
+ if (!found)
26
+ throw new Error(`unknown easing '${String(easing)}'`);
27
+ return found;
28
+ };
29
+ /**
30
+ * A tween runner stepped by the game's own fixed timestep.
31
+ *
32
+ * Stepped, not clocked: it advances only when `update(step)` is called, so
33
+ * tweens pause with the simulation (including during hit-stop, which is the
34
+ * behaviour that reads as impact), run identically on 60 Hz and 120 Hz
35
+ * displays, and are deterministic under test.
36
+ *
37
+ * Create it once at module scope, call `tweens.update(step)` first thing in
38
+ * the game's update, and `tweens.cancelAll()` from the run reset — a tween
39
+ * from the previous run landing on a freshly reset object is a classic ghost.
40
+ */
41
+ export function createTweens() {
42
+ let live = [];
43
+ return {
44
+ to(target, to, options) {
45
+ if (!(options.duration >= 0)) {
46
+ throw new Error(`tween duration must be >= 0, got ${options.duration}`);
47
+ }
48
+ const record = target;
49
+ const keys = Object.keys(to);
50
+ const tween = {
51
+ target: record,
52
+ keys,
53
+ from: keys.map((key) => {
54
+ const value = record[key];
55
+ if (typeof value !== 'number') {
56
+ throw new Error(`tween target property '${key}' is not a number`);
57
+ }
58
+ return value;
59
+ }),
60
+ to: keys.map((key) => to[key]),
61
+ easing: resolveEasing(options.easing),
62
+ duration: options.duration,
63
+ yoyo: options.yoyo ?? false,
64
+ elapsed: 0,
65
+ cancelled: false,
66
+ onUpdate: options.onUpdate,
67
+ onComplete: options.onComplete,
68
+ };
69
+ live.push(tween);
70
+ return {
71
+ cancel() {
72
+ tween.cancelled = true;
73
+ },
74
+ get done() {
75
+ return tween.cancelled || tween.elapsed + EPSILON >= tween.duration * (tween.yoyo ? 2 : 1);
76
+ },
77
+ };
78
+ },
79
+ update(step) {
80
+ if (live.length === 0)
81
+ return;
82
+ const finished = [];
83
+ for (const tween of live) {
84
+ if (tween.cancelled)
85
+ continue;
86
+ tween.elapsed += step;
87
+ const total = tween.duration * (tween.yoyo ? 2 : 1);
88
+ // The epsilon absorbs accumulated float error: thirty 1/60 steps sum
89
+ // to a hair under 0.5, and without it every tween runs one extra frame.
90
+ const landed = tween.elapsed + EPSILON >= total;
91
+ if (landed) {
92
+ // Snap to the exact destination rather than trusting easing(1) —
93
+ // curves like backOut miss their endpoint by float noise, and a
94
+ // scale meant to return to 1 must be 1, not 1 ± 2e-16, forever.
95
+ const finals = tween.yoyo ? tween.from : tween.to;
96
+ for (let i = 0; i < tween.keys.length; i++) {
97
+ tween.target[tween.keys[i]] = finals[i];
98
+ }
99
+ }
100
+ else {
101
+ const t = tween.elapsed / total;
102
+ // The yoyo leg mirrors progress; easing applies per leg so the
103
+ // return trip decelerates the same way the outbound one did.
104
+ const leg = tween.yoyo ? 1 - Math.abs(1 - 2 * t) : t;
105
+ const eased = tween.easing(leg);
106
+ for (let i = 0; i < tween.keys.length; i++) {
107
+ const from = tween.from[i];
108
+ tween.target[tween.keys[i]] = from + (tween.to[i] - from) * eased;
109
+ }
110
+ }
111
+ tween.onUpdate?.();
112
+ if (landed)
113
+ finished.push(tween);
114
+ }
115
+ if (finished.length > 0 || live.some((tween) => tween.cancelled)) {
116
+ live = live.filter((tween) => !tween.cancelled && !finished.includes(tween));
117
+ // Callbacks run after the list is settled, so an onComplete that starts
118
+ // the next tween (a squash chaining into a stretch) is not swept away.
119
+ for (const tween of finished)
120
+ tween.onComplete?.();
121
+ }
122
+ },
123
+ cancelAll() {
124
+ for (const tween of live)
125
+ tween.cancelled = true;
126
+ live = [];
127
+ },
128
+ get active() {
129
+ return live.filter((tween) => !tween.cancelled).length;
130
+ },
131
+ };
132
+ }
133
+ /**
134
+ * Wraps any camera rig with trauma-based screen shake.
135
+ *
136
+ * A decorator, because ownership is the entire problem: rigs own the camera
137
+ * transform, and `followRig` rewrites it every frame while `portraitRig` never
138
+ * touches it after boot. Adding an offset directly therefore either vanishes
139
+ * next frame or accumulates forever, depending on which rig the game picked.
140
+ * The wrapper removes last frame's offset, lets the inner rig do its work,
141
+ * then applies a fresh one — correct against both behaviours.
142
+ *
143
+ * Trauma-squared with linear decay (Squirrel Eiserloh's model): shake feels
144
+ * proportional to events this way, and it composes — two quick hits raise
145
+ * trauma additively instead of restarting a fixed animation.
146
+ *
147
+ * Shake runs on render time (`rig.update(dt)`), so it keeps moving during
148
+ * hit-stop and after `gameOver` — a dead-still camera over a game-over sheet
149
+ * reads as a hang, and shake-through-freeze is the classic impact combo.
150
+ *
151
+ * const rig = shakeRig(portraitRig())
152
+ * await createGame({ camera: rig, ... })
153
+ * rig.shake(0.4)
154
+ */
155
+ export function shakeRig(rig, options = {}) {
156
+ const maxOffset = options.maxOffset ?? 0.35;
157
+ const maxRoll = options.maxRoll ?? 0.04;
158
+ const decay = options.decay ?? 1.5;
159
+ const rng = options.rng ?? Math.random;
160
+ let trauma = 0;
161
+ let appliedX = 0;
162
+ let appliedY = 0;
163
+ let appliedRoll = 0;
164
+ // Inverse in reverse order: the roll changed the local axes the offsets
165
+ // were applied along, so it unwinds first or the translation comes back
166
+ // along the wrong basis and the camera drifts a little every frame.
167
+ const remove = () => {
168
+ rig.camera.rotateZ(-appliedRoll);
169
+ rig.camera.translateY(-appliedY);
170
+ rig.camera.translateX(-appliedX);
171
+ appliedX = 0;
172
+ appliedY = 0;
173
+ appliedRoll = 0;
174
+ };
175
+ return {
176
+ get camera() {
177
+ return rig.camera;
178
+ },
179
+ resize(viewport) {
180
+ rig.resize(viewport);
181
+ },
182
+ update(dt) {
183
+ remove();
184
+ rig.update(dt);
185
+ trauma = Math.max(trauma - decay * dt, 0);
186
+ const shake = trauma * trauma;
187
+ if (shake > 0) {
188
+ appliedX = maxOffset * shake * (rng() * 2 - 1);
189
+ appliedY = maxOffset * shake * (rng() * 2 - 1);
190
+ appliedRoll = maxRoll * shake * (rng() * 2 - 1);
191
+ rig.camera.translateX(appliedX);
192
+ rig.camera.translateY(appliedY);
193
+ rig.camera.rotateZ(appliedRoll);
194
+ }
195
+ },
196
+ shake(amount) {
197
+ trauma = Math.min(trauma + Math.max(amount, 0), 1);
198
+ },
199
+ get trauma() {
200
+ return trauma;
201
+ },
202
+ };
203
+ }
204
+ /**
205
+ * Hit-stop: the few-frame freeze that sells an impact.
206
+ *
207
+ * It cannot be a sleep and cannot skip rendering — the loop keeps drawing the
208
+ * frozen state, which is exactly the effect. What stops is simulation, and the
209
+ * game owns its update, so this is a countdown the game gates on rather than
210
+ * something the SDK can impose.
211
+ *
212
+ * Keep it short and rare: 0.05–0.12s, on the few biggest moments (a death, a
213
+ * heavy hit). On every minor event it stops reading as impact and starts
214
+ * reading as jank. Screen shake keeps moving through it (see `shakeRig`) —
215
+ * freeze-plus-shake is the combination that reads as force.
216
+ */
217
+ export function createHitStop() {
218
+ let remaining = 0;
219
+ return {
220
+ trigger(seconds) {
221
+ remaining = Math.max(remaining, seconds);
222
+ },
223
+ frozen(step) {
224
+ // The epsilon keeps three 1/60 steps from leaving 5e-18s of freeze that
225
+ // buys a whole extra frozen frame.
226
+ if (remaining <= EPSILON) {
227
+ remaining = 0;
228
+ return false;
229
+ }
230
+ remaining -= step;
231
+ return true;
232
+ },
233
+ get active() {
234
+ return remaining > EPSILON;
235
+ },
236
+ };
237
+ }
package/dist/loop.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Pure fixed-timestep accumulator. Extracted from the render loop so the timing
3
+ * rules are unit-testable with zero DOM and zero WebGL.
4
+ */
5
+ /**
6
+ * A frame gap longer than this is a stall, not gameplay: a backgrounded tab, a
7
+ * GC pause, or a paused debugger. Clamping it is what stops the game from
8
+ * burst-simulating minutes of physics in a single frame when the player comes
9
+ * back.
10
+ */
11
+ export declare const MAX_FRAME_DELTA = 0.25;
12
+ export type StepResult = {
13
+ /** How many fixed `update(step)` calls this frame owes. */
14
+ steps: number;
15
+ /** Sub-step remainder in 0..1, for interpolating the render between states. */
16
+ alpha: number;
17
+ /** Carry-over to feed into the next call. */
18
+ accumulator: number;
19
+ };
20
+ /**
21
+ * @param accumulator seconds carried over from the previous frame
22
+ * @param dt seconds of wall clock since the previous frame
23
+ * @param step fixed simulation step in seconds (1/60)
24
+ * @param maxSteps hard ceiling on steps per frame — the spiral-of-death guard
25
+ */
26
+ export declare function stepClock(accumulator: number, dt: number, step: number, maxSteps: number): StepResult;
27
+ //# sourceMappingURL=loop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../src/loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,OAAO,CAAA;AAEnC,MAAM,MAAM,UAAU,GAAG;IACvB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;IACb,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAA;IACb,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CACvB,WAAW,EAAE,MAAM,EACnB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GACf,UAAU,CAcZ"}
package/dist/loop.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure fixed-timestep accumulator. Extracted from the render loop so the timing
3
+ * rules are unit-testable with zero DOM and zero WebGL.
4
+ */
5
+ /**
6
+ * A frame gap longer than this is a stall, not gameplay: a backgrounded tab, a
7
+ * GC pause, or a paused debugger. Clamping it is what stops the game from
8
+ * burst-simulating minutes of physics in a single frame when the player comes
9
+ * back.
10
+ */
11
+ export const MAX_FRAME_DELTA = 0.25;
12
+ /**
13
+ * @param accumulator seconds carried over from the previous frame
14
+ * @param dt seconds of wall clock since the previous frame
15
+ * @param step fixed simulation step in seconds (1/60)
16
+ * @param maxSteps hard ceiling on steps per frame — the spiral-of-death guard
17
+ */
18
+ export function stepClock(accumulator, dt, step, maxSteps) {
19
+ if (!(step > 0))
20
+ throw new Error(`stepClock: step must be > 0, got ${step}`);
21
+ const total = accumulator + Math.min(Math.max(dt, 0), MAX_FRAME_DELTA);
22
+ const steps = Math.min(Math.floor(total / step), Math.max(0, Math.floor(maxSteps)));
23
+ // `total % step` serves both cases. In the normal case it equals
24
+ // `total - steps * step`. When we hit `maxSteps` it additionally DROPS the
25
+ // backlog we refused to simulate — keeping it would guarantee we hit the cap
26
+ // again next frame and never catch up (the spiral of death), while the
27
+ // remainder preserves sub-step phase so motion stays smooth.
28
+ const remainder = total % step;
29
+ return { steps, alpha: remainder / step, accumulator: remainder };
30
+ }
@@ -0,0 +1,50 @@
1
+ import { type FarcadeSDKGlobal, type GameInfo, type GameState, type HapticFeedbackType, type Player, type SafeAreaInset, type ShopItem, type ViewContext } from './sdk-contract.js';
2
+ export type Platform = {
3
+ readonly info: GameInfo | null;
4
+ readonly player: Player | null;
5
+ readonly players: readonly Player[];
6
+ readonly viewContext: ViewContext;
7
+ readonly safeArea: SafeAreaInset;
8
+ readonly state: GameState | null;
9
+ readonly shopItems: readonly ShopItem[];
10
+ /** True once `gameOver` has been sent for the current run. */
11
+ readonly isOver: boolean;
12
+ ready(): Promise<GameInfo>;
13
+ gameOver(score: number): void;
14
+ /** Clears the one-`game_over`-per-run latch. Called by `game.restart()`. */
15
+ rearm(): void;
16
+ saveState(state: GameState): void;
17
+ onPlayAgain(cb: () => void): () => void;
18
+ onToggleMute(cb: (muted: boolean) => void): () => void;
19
+ onGameInfo(cb: (info: GameInfo) => void): () => void;
20
+ haptic(type?: HapticFeedbackType): void;
21
+ hasItem(slug: string): boolean;
22
+ itemCount(slug: string): number;
23
+ purchase(slug: string): Promise<{
24
+ success: boolean;
25
+ item?: string;
26
+ }>;
27
+ reportError(error: unknown): void;
28
+ };
29
+ /**
30
+ * Resolves `window.FarcadeSDK`, rAF-polling until it appears.
31
+ *
32
+ * The poll is not paranoia: remix-dev's dev-init installs its SDK mock
33
+ * asynchronously, so the global is genuinely absent at module-eval time in the
34
+ * dev dashboard. Resolves `null` past the deadline rather than rejecting — a
35
+ * game opened as a bare file has no host and must still boot.
36
+ */
37
+ export declare function awaitSdk(timeoutMs?: number): Promise<FarcadeSDKGlobal | null>;
38
+ /**
39
+ * Typed wrapper over `window.FarcadeSDK`.
40
+ *
41
+ * It owns the two things every game gets wrong on its own: exactly one
42
+ * `game_over` per run, and the fact that `game_info` (and therefore
43
+ * `contentSafeAreaInset`) arrives *after* the first frames.
44
+ *
45
+ * The wrapper registers exactly one callback per SDK event and fans out to its
46
+ * own listener sets, because the SDK's `onPlayAgain`/`onToggleMute` helpers wrap
47
+ * the callback in a closure and so cannot be unsubscribed.
48
+ */
49
+ export declare function createPlatform(sdk: FarcadeSDKGlobal | null): Platform;
50
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/platform/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,QAAQ,EACb,KAAK,SAAS,EAEd,KAAK,kBAAkB,EACvB,KAAK,MAAM,EACX,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,WAAW,EAEjB,MAAM,mBAAmB,CAAA;AAE1B,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CAAA;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,SAAS,EAAE,SAAS,QAAQ,EAAE,CAAA;IACvC,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,KAAK,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,4EAA4E;IAC5E,KAAK,IAAI,IAAI,CAAA;IACb,SAAS,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,CAAA;IACjC,WAAW,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;IACvC,YAAY,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;IACtD,UAAU,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;IACpD,MAAM,CAAC,IAAI,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAA;IACvC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;IAC9B,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACpE,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAA;CAClC,CAAA;AAaD;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,SAAS,SAAO,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAc3E;AAeD;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,IAAI,GAAG,QAAQ,CAsHrE"}
@@ -0,0 +1,177 @@
1
+ import { ZERO_SAFE_AREA_INSET, } from './sdk-contract.js';
2
+ const STANDALONE_PLAYER = { id: 'local', name: 'Player', purchasedItems: [] };
3
+ const STANDALONE_INFO = {
4
+ players: [STANDALONE_PLAYER],
5
+ player: STANDALONE_PLAYER,
6
+ shopItems: [],
7
+ viewContext: 'full_screen',
8
+ contentSafeAreaInset: ZERO_SAFE_AREA_INSET,
9
+ initialGameState: null,
10
+ };
11
+ /**
12
+ * Resolves `window.FarcadeSDK`, rAF-polling until it appears.
13
+ *
14
+ * The poll is not paranoia: remix-dev's dev-init installs its SDK mock
15
+ * asynchronously, so the global is genuinely absent at module-eval time in the
16
+ * dev dashboard. Resolves `null` past the deadline rather than rejecting — a
17
+ * game opened as a bare file has no host and must still boot.
18
+ */
19
+ export function awaitSdk(timeoutMs = 2000) {
20
+ const global = globalThis;
21
+ if (global.FarcadeSDK)
22
+ return Promise.resolve(global.FarcadeSDK);
23
+ if (typeof requestAnimationFrame !== 'function')
24
+ return Promise.resolve(null);
25
+ return new Promise((resolve) => {
26
+ const deadline = performance.now() + timeoutMs;
27
+ const poll = () => {
28
+ if (global.FarcadeSDK)
29
+ return resolve(global.FarcadeSDK);
30
+ if (performance.now() >= deadline)
31
+ return resolve(null);
32
+ requestAnimationFrame(poll);
33
+ };
34
+ requestAnimationFrame(poll);
35
+ });
36
+ }
37
+ function subscribe(set, cb) {
38
+ set.add(cb);
39
+ return () => {
40
+ set.delete(cb);
41
+ };
42
+ }
43
+ /**
44
+ * Typed wrapper over `window.FarcadeSDK`.
45
+ *
46
+ * It owns the two things every game gets wrong on its own: exactly one
47
+ * `game_over` per run, and the fact that `game_info` (and therefore
48
+ * `contentSafeAreaInset`) arrives *after* the first frames.
49
+ *
50
+ * The wrapper registers exactly one callback per SDK event and fans out to its
51
+ * own listener sets, because the SDK's `onPlayAgain`/`onToggleMute` helpers wrap
52
+ * the callback in a closure and so cannot be unsubscribed.
53
+ */
54
+ export function createPlatform(sdk) {
55
+ const listeners = { playAgain: new Set(), toggleMute: new Set(), gameInfo: new Set() };
56
+ let info = null;
57
+ let state = null;
58
+ let over = false;
59
+ if (sdk) {
60
+ const handlePlayAgain = () => {
61
+ for (const cb of [...listeners.playAgain])
62
+ cb();
63
+ };
64
+ const handleToggleMute = (data) => {
65
+ for (const cb of [...listeners.toggleMute])
66
+ cb(data.isMuted);
67
+ };
68
+ const handleGameInfo = (next) => {
69
+ info = next;
70
+ if (next.initialGameState)
71
+ state = next.initialGameState.gameState;
72
+ for (const cb of [...listeners.gameInfo])
73
+ cb(next);
74
+ };
75
+ const handleGameStateUpdated = (data) => {
76
+ state = data ? data.gameState : null;
77
+ };
78
+ // remix-dev 1.8.1 implements the generic event API but predates these
79
+ // named helpers. Prefer the production helpers and fall back without making
80
+ // a freshly scaffolded game crash before its first frame.
81
+ if (typeof sdk.onPlayAgain === 'function')
82
+ sdk.onPlayAgain(handlePlayAgain);
83
+ else
84
+ sdk.on?.('play_again', handlePlayAgain);
85
+ if (typeof sdk.onToggleMute === 'function')
86
+ sdk.onToggleMute(handleToggleMute);
87
+ else
88
+ sdk.on?.('toggle_mute', handleToggleMute);
89
+ if (typeof sdk.onGameInfo === 'function')
90
+ sdk.onGameInfo(handleGameInfo);
91
+ else
92
+ sdk.on?.('game_info', handleGameInfo);
93
+ if (typeof sdk.onGameStateUpdated === 'function')
94
+ sdk.onGameStateUpdated(handleGameStateUpdated);
95
+ else
96
+ sdk.on?.('game_state_updated', handleGameStateUpdated);
97
+ }
98
+ const purchasedItems = () => info?.player.purchasedItems ?? [];
99
+ return {
100
+ get info() {
101
+ return info;
102
+ },
103
+ get player() {
104
+ return info?.player ?? null;
105
+ },
106
+ get players() {
107
+ return info?.players ?? [];
108
+ },
109
+ get viewContext() {
110
+ return info?.viewContext ?? 'full_screen';
111
+ },
112
+ get safeArea() {
113
+ return info?.contentSafeAreaInset ?? ZERO_SAFE_AREA_INSET;
114
+ },
115
+ get state() {
116
+ return state;
117
+ },
118
+ get shopItems() {
119
+ return info?.shopItems ?? [];
120
+ },
121
+ get isOver() {
122
+ return over;
123
+ },
124
+ async ready() {
125
+ if (!sdk) {
126
+ info = STANDALONE_INFO;
127
+ return STANDALONE_INFO;
128
+ }
129
+ const resolved = await sdk.ready();
130
+ info = resolved;
131
+ if (!state && resolved.initialGameState)
132
+ state = resolved.initialGameState.gameState;
133
+ return resolved;
134
+ },
135
+ gameOver(score) {
136
+ // Idempotent per run. The host scores the FIRST game_over it sees, so a
137
+ // second one from a death animation's callback would silently overwrite a
138
+ // real score with a stale one.
139
+ if (over)
140
+ return;
141
+ over = true;
142
+ sdk?.singlePlayer.actions.gameOver({ score });
143
+ },
144
+ rearm() {
145
+ over = false;
146
+ },
147
+ saveState(next) {
148
+ state = next;
149
+ sdk?.singlePlayer.actions.saveGameState({ gameState: next });
150
+ },
151
+ onPlayAgain: (cb) => subscribe(listeners.playAgain, cb),
152
+ onToggleMute: (cb) => subscribe(listeners.toggleMute, cb),
153
+ onGameInfo: (cb) => subscribe(listeners.gameInfo, cb),
154
+ haptic(type) {
155
+ const actions = sdk?.singlePlayer.actions;
156
+ if (actions?.hapticFeedback)
157
+ actions.hapticFeedback(type);
158
+ else
159
+ sdk?.hapticFeedback?.(type);
160
+ },
161
+ hasItem: (slug) => purchasedItems().includes(slug),
162
+ itemCount: (slug) => purchasedItems().filter((item) => item === slug).length,
163
+ async purchase(slug) {
164
+ if (!sdk)
165
+ return { success: false };
166
+ return sdk.purchase({ item: slug });
167
+ },
168
+ reportError(error) {
169
+ const err = error instanceof Error ? error : new Error(String(error));
170
+ const actions = sdk?.singlePlayer.actions;
171
+ if (actions?.reportError)
172
+ actions.reportError({ message: err.message, error: err });
173
+ else
174
+ sdk?.reportError?.({ message: err.message, error: err });
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * VENDORED copy of the public type surface of `@remix-gg/sdk`
3
+ * (`packages/game-sdk/src/index.ts`). Types only — no logic, no runtime import.
4
+ *
5
+ * Two reasons this is a copy rather than a dependency:
6
+ *
7
+ * 1. `apps/web/lib/format-game.ts` unconditionally injects a
8
+ * `<script src=".../@remix-gg/sdk@x/dist/index.min.js">` into every published
9
+ * game. A bundled second copy would run `RemixSDK`'s constructor twice — two
10
+ * `ready` postMessages and two listener sets — and the host handshake and the
11
+ * launch gate both key off exactly one.
12
+ * 2. Repo rule: public packages must not publish `workspace:` dependencies.
13
+ *
14
+ * Keep in sync by hand when `packages/game-sdk/src/index.ts` changes. The
15
+ * runtime we talk to is `window.FarcadeSDK`, so only the shapes we actually
16
+ * consume live here.
17
+ */
18
+ export type ViewContext = 'feed' | 'full_screen' | 'challenge' | 'tournament';
19
+ export type SafeAreaInset = {
20
+ top: number;
21
+ right: number;
22
+ bottom: number;
23
+ left: number;
24
+ };
25
+ export declare const ZERO_SAFE_AREA_INSET: SafeAreaInset;
26
+ export type GameState = Record<string, unknown>;
27
+ export type Player = {
28
+ id: string;
29
+ name: string;
30
+ purchasedItems: string[];
31
+ imageUrl?: string;
32
+ };
33
+ export type InventoryItem = {
34
+ slug: string;
35
+ quantity: number;
36
+ };
37
+ export type ShopItem = {
38
+ slug: string;
39
+ name: string;
40
+ itemType?: string;
41
+ bitsCost?: number | null;
42
+ description?: string | null;
43
+ iconUrl?: string | null;
44
+ tier?: number | null;
45
+ };
46
+ export type GameInfo = {
47
+ players: Player[];
48
+ player: Player;
49
+ shopItems?: ShopItem[];
50
+ viewContext: ViewContext;
51
+ contentSafeAreaInset: SafeAreaInset;
52
+ initialGameState: {
53
+ id: string;
54
+ gameState: GameState;
55
+ } | null;
56
+ };
57
+ export type HapticFeedbackType = 'light' | 'medium' | 'hard' | 'success' | 'error';
58
+ export type ToggleMuteData = {
59
+ isMuted: boolean;
60
+ };
61
+ export type GameStateUpdatedData = {
62
+ id: string;
63
+ gameState: GameState;
64
+ } | null;
65
+ export type PurchaseCompleteData = {
66
+ success: boolean;
67
+ item?: string;
68
+ };
69
+ export type GameErrorData = {
70
+ message: string;
71
+ source?: string;
72
+ lineno?: number;
73
+ colno?: number;
74
+ error?: Error;
75
+ };
76
+ export type FarcadeSDKEventMap = {
77
+ play_again: Record<string, never>;
78
+ toggle_mute: ToggleMuteData;
79
+ game_info: GameInfo;
80
+ game_state_updated: GameStateUpdatedData;
81
+ };
82
+ /** The subset of `window.FarcadeSDK` this SDK actually calls. */
83
+ export type FarcadeSDKGlobal = {
84
+ ready: () => Promise<GameInfo>;
85
+ purchase: (data: {
86
+ item: string;
87
+ }) => Promise<PurchaseCompleteData>;
88
+ reportError?: (data: GameErrorData) => void;
89
+ hapticFeedback?: (type?: HapticFeedbackType) => void;
90
+ on?: <Event extends keyof FarcadeSDKEventMap>(event: Event, cb: (data: FarcadeSDKEventMap[Event]) => void) => void;
91
+ onPlayAgain?: (cb: () => void) => void;
92
+ onToggleMute?: (cb: (data: ToggleMuteData) => void) => void;
93
+ onGameInfo?: (cb: (data: GameInfo) => void) => void;
94
+ onGameStateUpdated?: (cb: (data: GameStateUpdatedData) => void) => void;
95
+ readonly gameInfo?: GameInfo;
96
+ readonly gameState?: GameState | null;
97
+ readonly shopItems: ShopItem[];
98
+ readonly players?: Player[];
99
+ readonly player?: Player;
100
+ singlePlayer: {
101
+ actions: {
102
+ gameOver: (data: {
103
+ score: number;
104
+ }) => void;
105
+ saveGameState: (data: {
106
+ gameState: GameState;
107
+ }) => void;
108
+ reportError?: (data: GameErrorData) => void;
109
+ hapticFeedback?: (type?: HapticFeedbackType) => void;
110
+ };
111
+ };
112
+ };
113
+ //# sourceMappingURL=sdk-contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sdk-contract.d.ts","sourceRoot":"","sources":["../../src/platform/sdk-contract.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * VENDORED copy of the public type surface of `@remix-gg/sdk`
3
+ * (`packages/game-sdk/src/index.ts`). Types only — no logic, no runtime import.
4
+ *
5
+ * Two reasons this is a copy rather than a dependency:
6
+ *
7
+ * 1. `apps/web/lib/format-game.ts` unconditionally injects a
8
+ * `<script src=".../@remix-gg/sdk@x/dist/index.min.js">` into every published
9
+ * game. A bundled second copy would run `RemixSDK`'s constructor twice — two
10
+ * `ready` postMessages and two listener sets — and the host handshake and the
11
+ * launch gate both key off exactly one.
12
+ * 2. Repo rule: public packages must not publish `workspace:` dependencies.
13
+ *
14
+ * Keep in sync by hand when `packages/game-sdk/src/index.ts` changes. The
15
+ * runtime we talk to is `window.FarcadeSDK`, so only the shapes we actually
16
+ * consume live here.
17
+ */
18
+ export const ZERO_SAFE_AREA_INSET = { top: 0, right: 0, bottom: 0, left: 0 };