@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
@@ -0,0 +1,321 @@
1
+ import { Vector3 } from 'three';
2
+ const EPSILON = 1e-8;
3
+ /**
4
+ * How close counts as touching.
5
+ *
6
+ * Contact has to be a band, not float equality. A game places its player at
7
+ * `y = platformTop` — the obvious authoring choice, and the one an LLM makes
8
+ * every time — so "resting on" and "exactly coincident with" are the same
9
+ * state, and every frame after that the body sits within a rounding error of
10
+ * the surface. Treating that as an overlap is what froze bodies in place: the
11
+ * resolver saw a t=0 contact, had no normal to slide along, and cancelled
12
+ * nothing.
13
+ *
14
+ * 1e-3 world units is far below anything visible at the scale portrait games
15
+ * work at (a character is ~1 unit) and far above the float error a few frames
16
+ * of accumulated motion can produce.
17
+ */
18
+ const CONTACT_EPSILON = 1e-3;
19
+ /**
20
+ * Swept AABB against a set of static boxes: how far `box` can move along
21
+ * `velocity` before it touches something, and the face it touched.
22
+ *
23
+ * This is the slab method. Discrete overlap tests are what let a fast-moving
24
+ * player tunnel straight through the floor on a slow frame; sweeping cannot.
25
+ *
26
+ * Contact is measured against `contactEpsilon`, and the three states a real
27
+ * game spends its time in all resolve here rather than in the caller:
28
+ *
29
+ * - **resting** (`box.min.y === obstacle.max.y`) reports a hit at `t = 0` with
30
+ * an upward normal, so the caller can cancel gravity and stay grounded;
31
+ * - **sliding along** that same surface — no motion on the contact axis —
32
+ * reports no hit, because touching is not overlapping;
33
+ * - **leaving** it (a jump) reports no hit, because the overlap window closed
34
+ * before this step began.
35
+ */
36
+ export function sweepAabb(box, velocity, obstacles, contactEpsilon = CONTACT_EPSILON) {
37
+ let nearest = 1;
38
+ const normal = new Vector3();
39
+ let hit = false;
40
+ for (const obstacle of obstacles) {
41
+ // -Infinity, not 0. Clamping the entry time to 0 up front makes "enters
42
+ // exactly now" and "entered some time ago" the same number, and a body that
43
+ // starts in contact is always the second one. That conflation is what
44
+ // produced a hit with a (0,0,0) normal, which slides along nothing.
45
+ let entry = Number.NEGATIVE_INFINITY;
46
+ let exit = Number.POSITIVE_INFINITY;
47
+ const axisNormal = new Vector3();
48
+ let separated = false;
49
+ for (const axis of AXES) {
50
+ const delta = velocity[axis];
51
+ const minDistance = obstacle.min[axis] - box.max[axis];
52
+ const maxDistance = obstacle.max[axis] - box.min[axis];
53
+ if (Math.abs(delta) < EPSILON) {
54
+ // No motion on this axis: if they are apart here they can never touch,
55
+ // whatever the other axes do — and merely touching counts as apart.
56
+ // That clause is what lets a body walk along the floor it is standing
57
+ // on instead of colliding with it once per frame.
58
+ if (minDistance >= -contactEpsilon || maxDistance <= contactEpsilon) {
59
+ separated = true;
60
+ break;
61
+ }
62
+ continue;
63
+ }
64
+ // The face being approached sets the entry time; the face being left sets
65
+ // the exit. The exit face is pulled in by the contact band so a body in
66
+ // contact with a surface and moving *off* it reads as already separated
67
+ // rather than as colliding with the thing it is leaving. Without that, a
68
+ // jump is cancelled on its first frame by the floor underneath.
69
+ let axisEntry;
70
+ let axisExit;
71
+ let sign;
72
+ if (delta > 0) {
73
+ axisEntry = minDistance / delta;
74
+ axisExit = (maxDistance - contactEpsilon) / delta;
75
+ sign = -1;
76
+ }
77
+ else {
78
+ axisEntry = maxDistance / delta;
79
+ axisExit = (minDistance + contactEpsilon) / delta;
80
+ sign = 1;
81
+ }
82
+ if (axisEntry > entry) {
83
+ entry = axisEntry;
84
+ axisNormal.set(0, 0, 0);
85
+ axisNormal[axis] = sign;
86
+ }
87
+ if (axisExit < exit)
88
+ exit = axisExit;
89
+ if (entry > exit) {
90
+ separated = true;
91
+ break;
92
+ }
93
+ }
94
+ if (separated)
95
+ continue;
96
+ // The overlap window closed before this step began: the body is moving away
97
+ // from something it touches, not into it.
98
+ if (exit <= 0)
99
+ continue;
100
+ // A contact already in progress resolves now, at t=0. The normal is the one
101
+ // recorded for the axis entered last, which is the axis to slide along.
102
+ const time = entry > 0 ? entry : 0;
103
+ if (time > 1 || time >= nearest)
104
+ continue;
105
+ nearest = time;
106
+ normal.copy(axisNormal);
107
+ hit = true;
108
+ }
109
+ return { hit, time: nearest, normal };
110
+ }
111
+ const AXES = ['x', 'y', 'z'];
112
+ /**
113
+ * The displacement that pushes `box` out of everything it is currently inside.
114
+ *
115
+ * A sweep cannot help a body that starts overlapping: there is no direction of
116
+ * travel that leaves the obstacle, so every step reports a contact at t=0 and
117
+ * the body never moves. Depenetration is the only way out, and games produce
118
+ * the state constantly — a player authored at `y = platformTop` straddles the
119
+ * surface by half its height, and anything spawned into a scene it did not
120
+ * measure lands inside something.
121
+ *
122
+ * Per obstacle: the smallest of the six axis-aligned exits (the minimum
123
+ * translation vector), applied immediately so the next obstacle is measured
124
+ * against the already-displaced box. Summing every obstacle's MTV instead — the
125
+ * obvious implementation — double-counts a body resting in a floor built from
126
+ * two adjacent tiles, because both tiles ask for the same upward push and the
127
+ * body pops out twice as far as it should.
128
+ *
129
+ * Bodies are pushed to exactly touching, not to touching-plus-a-nudge: contact
130
+ * is a band, and `sweepAabb` already treats a body flush against a surface as
131
+ * resting on it rather than stuck in it.
132
+ *
133
+ * Mutates nothing.
134
+ */
135
+ export function depenetrate(box, obstacles, contactEpsilon = CONTACT_EPSILON) {
136
+ const total = new Vector3();
137
+ const min = box.min.clone();
138
+ const max = box.max.clone();
139
+ for (const obstacle of obstacles) {
140
+ let bestAxis = null;
141
+ let bestPush = 0;
142
+ let bestDepth = Number.POSITIVE_INFINITY;
143
+ for (const axis of AXES) {
144
+ const forward = obstacle.max[axis] - min[axis];
145
+ const backward = max[axis] - obstacle.min[axis];
146
+ if (forward <= contactEpsilon || backward <= contactEpsilon) {
147
+ bestAxis = null;
148
+ break;
149
+ }
150
+ const depth = Math.min(forward, backward);
151
+ if (depth < bestDepth) {
152
+ bestDepth = depth;
153
+ bestAxis = axis;
154
+ bestPush = forward < backward ? forward : -backward;
155
+ }
156
+ }
157
+ if (!bestAxis)
158
+ continue;
159
+ total[bestAxis] += bestPush;
160
+ min[bestAxis] += bestPush;
161
+ max[bestAxis] += bestPush;
162
+ }
163
+ return total;
164
+ }
165
+ /**
166
+ * Is there a surface directly under the body, within the contact band?
167
+ *
168
+ * `grounded` has to be a state, not an event. Derived only from a contact found
169
+ * during this step's sweep it flickers off on every frame where the body is
170
+ * already resting and has no vertical motion left to collide with — which is
171
+ * every frame in a game that zeroes vertical velocity while grounded — and a
172
+ * jump gated on it becomes unreliable in exactly the way players call "the
173
+ * controls are broken".
174
+ */
175
+ function restsOnSurface(box, obstacles, contactEpsilon) {
176
+ for (const obstacle of obstacles) {
177
+ // Standing with one edge exactly on the lip of a platform is not standing
178
+ // on it, so the horizontal overlap has to be real rather than a touch.
179
+ if (box.min.x >= obstacle.max.x - contactEpsilon)
180
+ continue;
181
+ if (box.max.x <= obstacle.min.x + contactEpsilon)
182
+ continue;
183
+ if (box.min.z >= obstacle.max.z - contactEpsilon)
184
+ continue;
185
+ if (box.max.z <= obstacle.min.z + contactEpsilon)
186
+ continue;
187
+ const gap = box.min.y - obstacle.max.y;
188
+ if (gap <= contactEpsilon && gap >= -contactEpsilon)
189
+ return true;
190
+ }
191
+ return false;
192
+ }
193
+ /**
194
+ * Moves a kinematic AABB through static geometry, sliding along contacts.
195
+ *
196
+ * The model, in the order it runs:
197
+ *
198
+ * 1. **Depenetrate.** Anything the body starts inside is escaped first, so the
199
+ * sweep is always asked a question it can answer.
200
+ * 2. **Sweep and slide**, up to `maxIterations` times. A contact cancels the
201
+ * component of the motion pointing into the surface and keeps the rest, so a
202
+ * body resting on the ground still walks along it and a body against a wall
203
+ * still slides.
204
+ * 3. **Probe for ground.** Grounded is a state — a surface within
205
+ * `contactEpsilon` under the body — not just a normal seen this step.
206
+ *
207
+ * Mutates nothing: `box` is read, and the caller applies the returned position.
208
+ */
209
+ export function resolveKinematic(box, velocity, obstacles, options = {}) {
210
+ const maxIterations = options.maxIterations ?? 3;
211
+ const slop = options.slop ?? 1e-4;
212
+ const contactEpsilon = options.contactEpsilon ?? CONTACT_EPSILON;
213
+ const current = box.clone();
214
+ const pushOut = depenetrate(current, obstacles, contactEpsilon);
215
+ current.min.add(pushOut);
216
+ current.max.add(pushOut);
217
+ const remaining = velocity.clone();
218
+ const result = velocity.clone();
219
+ let grounded = false;
220
+ for (let i = 0; i < maxIterations; i++) {
221
+ if (remaining.lengthSq() < EPSILON)
222
+ break;
223
+ const sweep = sweepAabb(current, remaining, obstacles, contactEpsilon);
224
+ if (!sweep.hit) {
225
+ current.min.add(remaining);
226
+ current.max.add(remaining);
227
+ break;
228
+ }
229
+ // `slop` is a distance, not a fraction of the motion. Stopping the same
230
+ // fraction short of contact leaves a 3 mm gap after a fast fall and a
231
+ // 10-micron gap after a slow one, and the fast case then reads as airborne.
232
+ const advance = Math.max(sweep.time - slop / remaining.length(), 0);
233
+ const step = remaining.clone().multiplyScalar(advance);
234
+ current.min.add(step);
235
+ current.max.add(step);
236
+ if (sweep.normal.y > 0.5)
237
+ grounded = true;
238
+ // Slide: drop the component of the motion pointing into the surface, keep
239
+ // the rest. Without this a player walking into a wall stops dead instead of
240
+ // sliding along it.
241
+ remaining.sub(step);
242
+ const into = remaining.dot(sweep.normal);
243
+ remaining.addScaledVector(sweep.normal, -into);
244
+ const resultInto = result.dot(sweep.normal);
245
+ if (resultInto < 0)
246
+ result.addScaledVector(sweep.normal, -resultInto);
247
+ }
248
+ if (!grounded)
249
+ grounded = restsOnSurface(current, obstacles, contactEpsilon);
250
+ return { position: current.getCenter(new Vector3()), velocity: result, grounded };
251
+ }
252
+ /**
253
+ * Uniform-grid broadphase.
254
+ *
255
+ * Testing every candidate against every other is O(n^2) and a phone notices at
256
+ * a couple of hundred. Size cells around your typical object.
257
+ */
258
+ export class Grid {
259
+ cellSize;
260
+ cells = new Map();
261
+ constructor(cellSize) {
262
+ this.cellSize = cellSize;
263
+ if (!(cellSize > 0))
264
+ throw new Error(`Grid: cellSize must be > 0, got ${cellSize}`);
265
+ }
266
+ insert(item, bounds) {
267
+ for (const key of this.keysFor(bounds)) {
268
+ const cell = this.cells.get(key);
269
+ if (cell)
270
+ cell.push(item);
271
+ else
272
+ this.cells.set(key, [item]);
273
+ }
274
+ }
275
+ /** Candidates overlapping `bounds`, deduplicated. Still needs a narrow test. */
276
+ query(bounds, out = []) {
277
+ out.length = 0;
278
+ const seen = new Set();
279
+ for (const key of this.keysFor(bounds)) {
280
+ for (const item of this.cells.get(key) ?? []) {
281
+ if (seen.has(item))
282
+ continue;
283
+ seen.add(item);
284
+ out.push(item);
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+ clear() {
290
+ this.cells.clear();
291
+ }
292
+ *keysFor(bounds) {
293
+ const minX = Math.floor(bounds.min.x / this.cellSize);
294
+ const maxX = Math.floor(bounds.max.x / this.cellSize);
295
+ const minY = Math.floor(bounds.min.y / this.cellSize);
296
+ const maxY = Math.floor(bounds.max.y / this.cellSize);
297
+ const minZ = Math.floor(bounds.min.z / this.cellSize);
298
+ const maxZ = Math.floor(bounds.max.z / this.cellSize);
299
+ for (let x = minX; x <= maxX; x++) {
300
+ for (let y = minY; y <= maxY; y++) {
301
+ for (let z = minZ; z <= maxZ; z++)
302
+ yield `${x}|${y}|${z}`;
303
+ }
304
+ }
305
+ }
306
+ }
307
+ /** Indices of every box whose closest point is within `radius` of `center`. */
308
+ export function overlapSphere(center, radius, boxes) {
309
+ const hits = [];
310
+ const closest = new Vector3();
311
+ const radiusSq = radius * radius;
312
+ for (let i = 0; i < boxes.length; i++) {
313
+ const box = boxes[i];
314
+ if (!box)
315
+ continue;
316
+ box.clampPoint(center, closest);
317
+ if (closest.distanceToSquared(center) <= radiusSq)
318
+ hits.push(i);
319
+ }
320
+ return hits;
321
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Input forgiveness: act on the player's intent, not their timing.
3
+ *
4
+ * A finger on glass is a noisy signal. Reaction time, display latency and the
5
+ * physical travel of a tap all blur *when* the player meant to act, and a game
6
+ * that demands the exact frame punishes the hardware more than the player.
7
+ * The fix is two tiny windows, invisible when they work:
8
+ *
9
+ * - a GRACE WINDOW forgives acting slightly late — the jump pressed a few
10
+ * frames after running off the ledge still counts ("coyote time");
11
+ * - an INPUT BUFFER forgives acting slightly early — the jump pressed just
12
+ * before landing fires on the exact frame it becomes legal.
13
+ *
14
+ * Both are countdowns stepped by the fixed timestep, so the forgiveness is the
15
+ * same number of milliseconds on every display. 0.08–0.15s is the useful
16
+ * range; below it nobody is helped, above it inputs visibly fire "on their
17
+ * own". Players never notice these windows — they notice their absence, as
18
+ * "the controls ate my tap".
19
+ */
20
+ export type GraceWindow = {
21
+ /** The condition is true now (grounded, in range, alive). Call every step it holds. */
22
+ refresh(): void;
23
+ /** Advance the countdown. Once per fixed step. */
24
+ update(step: number): void;
25
+ /** Still within the window — the condition held less than `seconds` ago. */
26
+ readonly active: boolean;
27
+ /**
28
+ * Take the grace: returns whether it was active, then closes the window so
29
+ * one ledge cannot grant two jumps.
30
+ */
31
+ consume(): boolean;
32
+ };
33
+ /**
34
+ * Coyote time, generalised: "was the condition true within the last N
35
+ * seconds" as a first-class object.
36
+ *
37
+ * const coyote = createGraceWindow(0.1)
38
+ * update(game, step) {
39
+ * coyote.update(step)
40
+ * if (grounded) coyote.refresh()
41
+ * }
42
+ * game.input.on('tap', () => {
43
+ * if (coyote.consume()) jump()
44
+ * })
45
+ */
46
+ export declare function createGraceWindow(seconds: number): GraceWindow;
47
+ export type InputBuffer = {
48
+ /** The player acted. Call from the gesture handler, legal or not. */
49
+ press(): void;
50
+ /** Advance the countdown. Once per fixed step. */
51
+ update(step: number): void;
52
+ /**
53
+ * Was there a press within the window? True consumes it, so one tap fires
54
+ * one action. Poll this at the moment the action becomes legal.
55
+ */
56
+ consume(): boolean;
57
+ readonly pending: boolean;
58
+ };
59
+ /**
60
+ * The other half: remember a too-early input and fire it the frame it becomes
61
+ * legal, instead of throwing it away.
62
+ *
63
+ * const jumpBuffer = createInputBuffer(0.12)
64
+ * game.input.on('tap', () => jumpBuffer.press())
65
+ * update(game, step) {
66
+ * jumpBuffer.update(step)
67
+ * if (grounded && jumpBuffer.consume()) jump()
68
+ * }
69
+ */
70
+ export declare function createInputBuffer(seconds: number): InputBuffer;
71
+ //# sourceMappingURL=forgiveness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forgiveness.d.ts","sourceRoot":"","sources":["../src/forgiveness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,MAAM,WAAW,GAAG;IACxB,uFAAuF;IACvF,OAAO,IAAI,IAAI,CAAA;IACf,kDAAkD;IAClD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB;;;OAGG;IACH,OAAO,IAAI,OAAO,CAAA;CACnB,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,CAmB9D;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,qEAAqE;IACrE,KAAK,IAAI,IAAI,CAAA;IACb,kDAAkD;IAClD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B;;;OAGG;IACH,OAAO,IAAI,OAAO,CAAA;IAClB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B,CAAA;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,CAmB9D"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Input forgiveness: act on the player's intent, not their timing.
3
+ *
4
+ * A finger on glass is a noisy signal. Reaction time, display latency and the
5
+ * physical travel of a tap all blur *when* the player meant to act, and a game
6
+ * that demands the exact frame punishes the hardware more than the player.
7
+ * The fix is two tiny windows, invisible when they work:
8
+ *
9
+ * - a GRACE WINDOW forgives acting slightly late — the jump pressed a few
10
+ * frames after running off the ledge still counts ("coyote time");
11
+ * - an INPUT BUFFER forgives acting slightly early — the jump pressed just
12
+ * before landing fires on the exact frame it becomes legal.
13
+ *
14
+ * Both are countdowns stepped by the fixed timestep, so the forgiveness is the
15
+ * same number of milliseconds on every display. 0.08–0.15s is the useful
16
+ * range; below it nobody is helped, above it inputs visibly fire "on their
17
+ * own". Players never notice these windows — they notice their absence, as
18
+ * "the controls ate my tap".
19
+ */
20
+ /**
21
+ * Coyote time, generalised: "was the condition true within the last N
22
+ * seconds" as a first-class object.
23
+ *
24
+ * const coyote = createGraceWindow(0.1)
25
+ * update(game, step) {
26
+ * coyote.update(step)
27
+ * if (grounded) coyote.refresh()
28
+ * }
29
+ * game.input.on('tap', () => {
30
+ * if (coyote.consume()) jump()
31
+ * })
32
+ */
33
+ export function createGraceWindow(seconds) {
34
+ if (!(seconds > 0))
35
+ throw new Error(`createGraceWindow: seconds must be > 0, got ${seconds}`);
36
+ let remaining = 0;
37
+ return {
38
+ refresh() {
39
+ remaining = seconds;
40
+ },
41
+ update(step) {
42
+ remaining = Math.max(remaining - step, 0);
43
+ },
44
+ get active() {
45
+ return remaining > 0;
46
+ },
47
+ consume() {
48
+ const wasActive = remaining > 0;
49
+ remaining = 0;
50
+ return wasActive;
51
+ },
52
+ };
53
+ }
54
+ /**
55
+ * The other half: remember a too-early input and fire it the frame it becomes
56
+ * legal, instead of throwing it away.
57
+ *
58
+ * const jumpBuffer = createInputBuffer(0.12)
59
+ * game.input.on('tap', () => jumpBuffer.press())
60
+ * update(game, step) {
61
+ * jumpBuffer.update(step)
62
+ * if (grounded && jumpBuffer.consume()) jump()
63
+ * }
64
+ */
65
+ export function createInputBuffer(seconds) {
66
+ if (!(seconds > 0))
67
+ throw new Error(`createInputBuffer: seconds must be > 0, got ${seconds}`);
68
+ let remaining = 0;
69
+ return {
70
+ press() {
71
+ remaining = seconds;
72
+ },
73
+ update(step) {
74
+ remaining = Math.max(remaining - step, 0);
75
+ },
76
+ consume() {
77
+ const wasPending = remaining > 0;
78
+ remaining = 0;
79
+ return wasPending;
80
+ },
81
+ get pending() {
82
+ return remaining > 0;
83
+ },
84
+ };
85
+ }
package/dist/game.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { type ColorRepresentation, Scene, type Texture, WebGLRenderer } from 'three';
2
+ import { type AssetManifest, type Assets } from './assets.js';
3
+ import { type AudioBus } from './audio.js';
4
+ import { type CameraRig } from './camera.js';
5
+ import { type Hud } from './hud/index.js';
6
+ import { type Input } from './input/index.js';
7
+ import { type Platform } from './platform/index.js';
8
+ import { DisposalScope } from './scene/dispose.js';
9
+ import { type FitMode, type Viewport } from './viewport.js';
10
+ export type RemixGameOptions = {
11
+ /** The guaranteed-visible design box. Defaults to 720x1080. */
12
+ design?: {
13
+ width: number;
14
+ height: number;
15
+ };
16
+ fit?: FitMode;
17
+ camera?: CameraRig;
18
+ background?: ColorRepresentation | Texture | null;
19
+ /** DPR ceiling. 2 is already past what anyone can see at arm's length. */
20
+ maxPixelRatio?: number;
21
+ fixedStep?: number;
22
+ maxStepsPerFrame?: number;
23
+ assets?: AssetManifest;
24
+ /** Where the canvas and HUD are mounted. Defaults to `document.body`. */
25
+ container?: HTMLElement;
26
+ onProgress?: (loaded: number, total: number) => void;
27
+ setup: (game: RemixGame) => void | Promise<void>;
28
+ /**
29
+ * Rebuilds a run. Wired to the platform's `play_again` automatically.
30
+ *
31
+ * A run, not the game: everything `setup` built survives, scene contents and
32
+ * HUD elements alike. Reset values here, build once in `setup`.
33
+ */
34
+ restart?: (game: RemixGame) => void;
35
+ update?: (game: RemixGame, step: number) => void;
36
+ render?: (game: RemixGame, dt: number, alpha: number) => void;
37
+ };
38
+ export type RemixGame = {
39
+ readonly renderer: WebGLRenderer;
40
+ readonly scene: Scene;
41
+ readonly rig: CameraRig;
42
+ readonly camera: CameraRig['camera'];
43
+ readonly viewport: Viewport;
44
+ readonly input: Input;
45
+ readonly assets: Assets;
46
+ readonly audio: AudioBus;
47
+ readonly hud: Hud;
48
+ readonly platform: Platform;
49
+ /** Cleared on every `restart()`. Register anything created after setup. */
50
+ readonly transient: DisposalScope;
51
+ readonly time: {
52
+ readonly elapsed: number;
53
+ readonly delta: number;
54
+ readonly frame: number;
55
+ };
56
+ readonly running: boolean;
57
+ gameOver(score: number): void;
58
+ restart(): void;
59
+ pause(): void;
60
+ resume(): void;
61
+ dispose(): void;
62
+ };
63
+ /**
64
+ * The one bootstrap call. A game never constructs a `WebGLRenderer` itself,
65
+ * because every one of the settings below is a decision that only looks
66
+ * cosmetic until it costs 30 fps on a phone.
67
+ */
68
+ export declare function createGame(options: RemixGameOptions): Promise<RemixGame>;
69
+ //# sourceMappingURL=game.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"game.d.ts","sourceRoot":"","sources":["../src/game.ts"],"names":[],"mappings":"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"}