@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.
- package/dist/assets.d.ts +36 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +100 -0
- package/dist/audio.d.ts +42 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +150 -0
- package/dist/camera.d.ts +72 -0
- package/dist/camera.d.ts.map +1 -0
- package/dist/camera.js +120 -0
- package/dist/collide.d.ts +111 -0
- package/dist/collide.d.ts.map +1 -0
- package/dist/collide.js +321 -0
- package/dist/forgiveness.d.ts +71 -0
- package/dist/forgiveness.d.ts.map +1 -0
- package/dist/forgiveness.js +85 -0
- package/dist/game.d.ts +69 -0
- package/dist/game.d.ts.map +1 -0
- package/dist/game.js +209 -0
- package/dist/hud/index.d.ts +72 -0
- package/dist/hud/index.d.ts.map +1 -0
- package/dist/hud/index.js +142 -0
- package/dist/hud/styles.d.ts +14 -0
- package/dist/hud/styles.d.ts.map +1 -0
- package/dist/hud/styles.js +142 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/input/gestures.d.ts +124 -0
- package/dist/input/gestures.d.ts.map +1 -0
- package/dist/input/gestures.js +171 -0
- package/dist/input/index.d.ts +30 -0
- package/dist/input/index.d.ts.map +1 -0
- package/dist/input/index.js +121 -0
- package/dist/juice.d.ts +151 -0
- package/dist/juice.d.ts.map +1 -0
- package/dist/juice.js +237 -0
- package/dist/loop.d.ts +27 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/loop.js +30 -0
- package/dist/platform/index.d.ts +50 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +177 -0
- package/dist/platform/sdk-contract.d.ts +113 -0
- package/dist/platform/sdk-contract.d.ts.map +1 -0
- package/dist/platform/sdk-contract.js +18 -0
- package/dist/ramp.d.ts +39 -0
- package/dist/ramp.d.ts.map +1 -0
- package/dist/ramp.js +24 -0
- package/dist/random.d.ts +128 -0
- package/dist/random.d.ts.map +1 -0
- package/dist/random.js +160 -0
- package/dist/scene/dispose.d.ts +46 -0
- package/dist/scene/dispose.d.ts.map +1 -0
- package/dist/scene/dispose.js +108 -0
- package/dist/scene/lighting.d.ts +42 -0
- package/dist/scene/lighting.d.ts.map +1 -0
- package/dist/scene/lighting.js +118 -0
- package/dist/scene/pool.d.ts +36 -0
- package/dist/scene/pool.d.ts.map +1 -0
- package/dist/scene/pool.js +70 -0
- package/dist/three.d.ts +2 -0
- package/dist/three.d.ts.map +1 -0
- package/dist/three.js +11 -0
- package/dist/viewport.d.ts +86 -0
- package/dist/viewport.d.ts.map +1 -0
- package/dist/viewport.js +174 -0
- package/package.json +43 -0
package/dist/collide.js
ADDED
|
@@ -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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"}
|