@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/ramp.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Easing, type EasingName } from './juice.js';
|
|
2
|
+
/**
|
|
3
|
+
* Difficulty over time, as a clamped curve.
|
|
4
|
+
*
|
|
5
|
+
* The hand-rolled version is `speed += 0.001` in update — linear, uncapped,
|
|
6
|
+
* and unplayable by the sixty-second mark, which is exactly when a good run is
|
|
7
|
+
* getting interesting. A ramp is a *shape*: where it starts, where it tops
|
|
8
|
+
* out, how long it takes to get there, and how the difficulty is distributed
|
|
9
|
+
* along the way. The cap is built in because every endless game needs one and
|
|
10
|
+
* the hand-rolled version never has one.
|
|
11
|
+
*/
|
|
12
|
+
export type RampOptions = {
|
|
13
|
+
/** The value at 0 — the first ten seconds, which should be easy. */
|
|
14
|
+
from: number;
|
|
15
|
+
/** The cap. Reached at `over` and held forever after. */
|
|
16
|
+
to: number;
|
|
17
|
+
/** Seconds (or metres, or points — whatever drives it) to reach the cap. */
|
|
18
|
+
over: number;
|
|
19
|
+
/**
|
|
20
|
+
* How difficulty is distributed. 'quadIn' (the default) rises gently early
|
|
21
|
+
* and steepens late: a forgiving opening while the player finds the verb,
|
|
22
|
+
* with the pressure arriving once they have it. 'quadOut' front-loads it.
|
|
23
|
+
*/
|
|
24
|
+
curve?: EasingName | Easing;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A pure function of progress: `speed(game.time.elapsed)`.
|
|
28
|
+
*
|
|
29
|
+
* const speed = createRamp({ from: 8, to: 22, over: 75 })
|
|
30
|
+
* update(game, step) {
|
|
31
|
+
* player.position.z -= speed(game.time.elapsed) * step
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Drive it by elapsed time, distance, or score — anything monotonic. Several
|
|
35
|
+
* ramps can share one input: speed on 75s, spawn density on 90s, gap width
|
|
36
|
+
* shrinking on 60s, so the pressure arrives in layers instead of all at once.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createRamp(options: RampOptions): (progress: number) => number;
|
|
39
|
+
//# sourceMappingURL=ramp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ramp.d.ts","sourceRoot":"","sources":["../src/ramp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAE,KAAK,UAAU,EAAW,MAAM,YAAY,CAAA;AAElE;;;;;;;;;GASG;AAEH,MAAM,MAAM,WAAW,GAAG;IACxB,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,yDAAyD;IACzD,EAAE,EAAE,MAAM,CAAA;IACV,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,KAAK,CAAC,EAAE,UAAU,GAAG,MAAM,CAAA;CAC5B,CAAA;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,WAAW,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAU7E"}
|
package/dist/ramp.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { easings } from './juice.js';
|
|
2
|
+
/**
|
|
3
|
+
* A pure function of progress: `speed(game.time.elapsed)`.
|
|
4
|
+
*
|
|
5
|
+
* const speed = createRamp({ from: 8, to: 22, over: 75 })
|
|
6
|
+
* update(game, step) {
|
|
7
|
+
* player.position.z -= speed(game.time.elapsed) * step
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* Drive it by elapsed time, distance, or score — anything monotonic. Several
|
|
11
|
+
* ramps can share one input: speed on 75s, spawn density on 90s, gap width
|
|
12
|
+
* shrinking on 60s, so the pressure arrives in layers instead of all at once.
|
|
13
|
+
*/
|
|
14
|
+
export function createRamp(options) {
|
|
15
|
+
if (!(options.over > 0))
|
|
16
|
+
throw new Error(`createRamp: over must be > 0, got ${options.over}`);
|
|
17
|
+
const curve = typeof options.curve === 'function' ? options.curve : easings[options.curve ?? 'quadIn'];
|
|
18
|
+
if (!curve)
|
|
19
|
+
throw new Error(`createRamp: unknown easing '${String(options.curve)}'`);
|
|
20
|
+
return (progress) => {
|
|
21
|
+
const t = Math.min(Math.max(progress / options.over, 0), 1);
|
|
22
|
+
return options.from + (options.to - options.from) * curve(t);
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/random.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Randomness that feels fair.
|
|
3
|
+
*
|
|
4
|
+
* `Math.random()` is stateless, and stateless rolls streak: a 25% crit chance
|
|
5
|
+
* misses eight times in a row for one player in ten thousand attacks, and that
|
|
6
|
+
* player experiences a broken game, not an unlucky one. Players expect random
|
|
7
|
+
* events to self-correct over a handful of trials — real independence does not —
|
|
8
|
+
* so a repeated player-facing roll wants a distribution that is *guaranteed*
|
|
9
|
+
* over a short cycle and merely *shuffled* within it.
|
|
10
|
+
*
|
|
11
|
+
* That is the shuffle bag (the "marble bag"): fill a bag with the outcomes in
|
|
12
|
+
* their intended ratio, draw without replacement, refill and reshuffle when it
|
|
13
|
+
* empties. A 1-in-4 crit is then exactly one crit in every four attacks, the
|
|
14
|
+
* worst possible drought is bounded by construction (2 × (cycle − 1) misses,
|
|
15
|
+
* when a crit opens one cycle and closes the next), and the ratio is still
|
|
16
|
+
* honest over any long run.
|
|
17
|
+
*
|
|
18
|
+
* This lives in the SDK rather than in prompt prose because every game that
|
|
19
|
+
* hand-rolls it gets a different one — usually with the `sort(() => r - 0.5)`
|
|
20
|
+
* shuffle, which is biased on every engine — and because an injectable RNG is
|
|
21
|
+
* what makes game logic deterministic under test and daily-challenge seeds
|
|
22
|
+
* reproducible across players.
|
|
23
|
+
*/
|
|
24
|
+
export type ShuffleBagEntry<T> = {
|
|
25
|
+
/** What `draw()` returns when this marble comes up. */
|
|
26
|
+
value: T;
|
|
27
|
+
/** How many of this marble go into each cycle. A positive integer. */
|
|
28
|
+
count: number;
|
|
29
|
+
};
|
|
30
|
+
export type ShuffleBagOptions = {
|
|
31
|
+
/**
|
|
32
|
+
* The random source, returning [0, 1) like `Math.random` — which is the
|
|
33
|
+
* default. Pass `seededRandom(seed)` when the sequence has to reproduce:
|
|
34
|
+
* unit tests, replays, a daily challenge every player should experience
|
|
35
|
+
* identically.
|
|
36
|
+
*/
|
|
37
|
+
rng?: () => number;
|
|
38
|
+
};
|
|
39
|
+
export type ShuffleBag<T> = {
|
|
40
|
+
/** The next outcome. The bag refills and reshuffles itself when it empties. */
|
|
41
|
+
draw(): T;
|
|
42
|
+
/** Draws left before the next refill. */
|
|
43
|
+
readonly remaining: number;
|
|
44
|
+
/** Draws per full cycle: the sum of every entry's count. */
|
|
45
|
+
readonly size: number;
|
|
46
|
+
/**
|
|
47
|
+
* Discard the part-drawn cycle so the next draw starts a fresh, full bag.
|
|
48
|
+
* Call it from the run reset — a run that inherits the tail of the last
|
|
49
|
+
* run's bag starts with odds the player never saw the front of.
|
|
50
|
+
*/
|
|
51
|
+
reset(): void;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* A pool of outcomes drawn without replacement, refilled when empty.
|
|
55
|
+
*
|
|
56
|
+
* Values are generic, so bags nest: a bag whose values are themselves bags
|
|
57
|
+
* first decides the category, then the category's own bag decides the item —
|
|
58
|
+
* which is how "rare letters are not only rare but spaced out" is built.
|
|
59
|
+
*
|
|
60
|
+
* Use it for every repeated player-facing roll: crits, drops, obstacle types.
|
|
61
|
+
* One-off cosmetic jitter keeps using the plain RNG; nobody counts particles.
|
|
62
|
+
*/
|
|
63
|
+
export declare function createShuffleBag<T>(entries: readonly ShuffleBagEntry<T>[], options?: ShuffleBagOptions): ShuffleBag<T>;
|
|
64
|
+
export type PityTimerOptions = {
|
|
65
|
+
/** The per-roll probability, 0..1. */
|
|
66
|
+
chance: number;
|
|
67
|
+
/** The ceiling: roll number `guaranteedBy` hits if none before it did. */
|
|
68
|
+
guaranteedBy: number;
|
|
69
|
+
/** The random source, returning [0, 1). Defaults to `Math.random`. */
|
|
70
|
+
rng?: () => number;
|
|
71
|
+
};
|
|
72
|
+
export type PityTimer = {
|
|
73
|
+
/** One attempt. A hit — rolled or guaranteed — resets the counter. */
|
|
74
|
+
roll(): boolean;
|
|
75
|
+
/** Misses since the last hit. */
|
|
76
|
+
readonly misses: number;
|
|
77
|
+
/** Forget the streak. Call from the run reset if pity is per-run. */
|
|
78
|
+
reset(): void;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* A rare event with a guaranteed ceiling: independent rolls, but the Nth
|
|
82
|
+
* attempt after a hit always hits.
|
|
83
|
+
*
|
|
84
|
+
* The shuffle bag is the wrong tool for the genuinely rare — a 1-in-50 drop
|
|
85
|
+
* means a 50-item bag, and the guarantee it offers ("exactly one per 50") is
|
|
86
|
+
* more rigid than a drop wants to feel. The pity timer keeps every roll
|
|
87
|
+
* independent and honest, and only intervenes at the tail: the player who
|
|
88
|
+
* would have gone 80 drops dry is the one player it touches, and that player
|
|
89
|
+
* was about to quit.
|
|
90
|
+
*/
|
|
91
|
+
export declare function createPityTimer(options: PityTimerOptions): PityTimer;
|
|
92
|
+
export type EscalatingChanceOptions = {
|
|
93
|
+
/** The probability of the first attempt after a hit, 0..1. */
|
|
94
|
+
start: number;
|
|
95
|
+
/** Added to the probability after each miss. Clamped at 1. */
|
|
96
|
+
step: number;
|
|
97
|
+
/** The random source, returning [0, 1). Defaults to `Math.random`. */
|
|
98
|
+
rng?: () => number;
|
|
99
|
+
};
|
|
100
|
+
export type EscalatingChance = {
|
|
101
|
+
/** One attempt. A hit resets the probability to `start`. */
|
|
102
|
+
roll(): boolean;
|
|
103
|
+
/** The probability the NEXT roll will be made at. */
|
|
104
|
+
readonly chance: number;
|
|
105
|
+
/** Back to `start`. Call from the run reset. */
|
|
106
|
+
reset(): void;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* A probability that climbs with every miss and resets on a hit — the
|
|
110
|
+
* pseudo-random distribution behind "25%" crits in Dota and Warcraft 3.
|
|
111
|
+
*
|
|
112
|
+
* Softer than the pity timer: there is no hard ceiling, just pressure toward
|
|
113
|
+
* the mean from both sides. Streaks of misses get rapidly less likely, and
|
|
114
|
+
* back-to-back hits do too, because a hit drops the odds back to `start`.
|
|
115
|
+
* `start: 0.05, step: 0.1` feels like roughly a fifth and never insults the
|
|
116
|
+
* player with an eight-miss run.
|
|
117
|
+
*/
|
|
118
|
+
export declare function createEscalatingChance(options: EscalatingChanceOptions): EscalatingChance;
|
|
119
|
+
/**
|
|
120
|
+
* mulberry32: a seeded drop-in for `Math.random`, returning [0, 1).
|
|
121
|
+
*
|
|
122
|
+
* Not cryptographic and not meant to be — it exists so a sequence can be
|
|
123
|
+
* replayed. Same seed, same sequence, on every engine, which is what makes a
|
|
124
|
+
* shuffle bag assertable in a unit test and a daily challenge identical for
|
|
125
|
+
* every player who types the same date.
|
|
126
|
+
*/
|
|
127
|
+
export declare function seededRandom(seed: number): () => number;
|
|
128
|
+
//# sourceMappingURL=random.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"random.d.ts","sourceRoot":"","sources":["../src/random.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,MAAM,MAAM,eAAe,CAAC,CAAC,IAAI;IAC/B,uDAAuD;IACvD,KAAK,EAAE,CAAC,CAAA;IACR,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI;IAC1B,+EAA+E;IAC/E,IAAI,IAAI,CAAC,CAAA;IACT,yCAAyC;IACzC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,KAAK,IAAI,IAAI,CAAA;CACd,CAAA;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,OAAO,EAAE,SAAS,eAAe,CAAC,CAAC,CAAC,EAAE,EACtC,OAAO,GAAE,iBAAsB,GAC9B,UAAU,CAAC,CAAC,CAAC,CAwCf;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAA;IACd,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,sEAAsE;IACtE,IAAI,IAAI,OAAO,CAAA;IACf,iCAAiC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,KAAK,IAAI,IAAI,CAAA;CACd,CAAA;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,SAAS,CAuBpE;AAED,MAAM,MAAM,uBAAuB,GAAG;IACpC,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,4DAA4D;IAC5D,IAAI,IAAI,OAAO,CAAA;IACf,qDAAqD;IACrD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,gDAAgD;IAChD,KAAK,IAAI,IAAI,CAAA;CACd,CAAA;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,uBAAuB,GAAG,gBAAgB,CAqBzF;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,MAAM,CASvD"}
|
package/dist/random.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Randomness that feels fair.
|
|
3
|
+
*
|
|
4
|
+
* `Math.random()` is stateless, and stateless rolls streak: a 25% crit chance
|
|
5
|
+
* misses eight times in a row for one player in ten thousand attacks, and that
|
|
6
|
+
* player experiences a broken game, not an unlucky one. Players expect random
|
|
7
|
+
* events to self-correct over a handful of trials — real independence does not —
|
|
8
|
+
* so a repeated player-facing roll wants a distribution that is *guaranteed*
|
|
9
|
+
* over a short cycle and merely *shuffled* within it.
|
|
10
|
+
*
|
|
11
|
+
* That is the shuffle bag (the "marble bag"): fill a bag with the outcomes in
|
|
12
|
+
* their intended ratio, draw without replacement, refill and reshuffle when it
|
|
13
|
+
* empties. A 1-in-4 crit is then exactly one crit in every four attacks, the
|
|
14
|
+
* worst possible drought is bounded by construction (2 × (cycle − 1) misses,
|
|
15
|
+
* when a crit opens one cycle and closes the next), and the ratio is still
|
|
16
|
+
* honest over any long run.
|
|
17
|
+
*
|
|
18
|
+
* This lives in the SDK rather than in prompt prose because every game that
|
|
19
|
+
* hand-rolls it gets a different one — usually with the `sort(() => r - 0.5)`
|
|
20
|
+
* shuffle, which is biased on every engine — and because an injectable RNG is
|
|
21
|
+
* what makes game logic deterministic under test and daily-challenge seeds
|
|
22
|
+
* reproducible across players.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* A pool of outcomes drawn without replacement, refilled when empty.
|
|
26
|
+
*
|
|
27
|
+
* Values are generic, so bags nest: a bag whose values are themselves bags
|
|
28
|
+
* first decides the category, then the category's own bag decides the item —
|
|
29
|
+
* which is how "rare letters are not only rare but spaced out" is built.
|
|
30
|
+
*
|
|
31
|
+
* Use it for every repeated player-facing roll: crits, drops, obstacle types.
|
|
32
|
+
* One-off cosmetic jitter keeps using the plain RNG; nobody counts particles.
|
|
33
|
+
*/
|
|
34
|
+
export function createShuffleBag(entries, options = {}) {
|
|
35
|
+
const rng = options.rng ?? Math.random;
|
|
36
|
+
const cycle = [];
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (!Number.isInteger(entry.count) || entry.count <= 0) {
|
|
39
|
+
throw new Error(`createShuffleBag: count must be a positive integer, got ${entry.count}`);
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < entry.count; i++)
|
|
42
|
+
cycle.push(entry.value);
|
|
43
|
+
}
|
|
44
|
+
if (cycle.length === 0)
|
|
45
|
+
throw new Error('createShuffleBag: entries must not be empty');
|
|
46
|
+
const bag = [];
|
|
47
|
+
const refill = () => {
|
|
48
|
+
bag.length = 0;
|
|
49
|
+
bag.push(...cycle);
|
|
50
|
+
// Fisher–Yates. `sort(() => rng() - 0.5)` — the shuffle an LLM reaches for
|
|
51
|
+
// first — is measurably biased under every engine's sort, which quietly
|
|
52
|
+
// un-randomises exactly the distribution the bag exists to guarantee.
|
|
53
|
+
for (let i = bag.length - 1; i > 0; i--) {
|
|
54
|
+
const j = Math.floor(rng() * (i + 1));
|
|
55
|
+
const swap = bag[i];
|
|
56
|
+
bag[i] = bag[j];
|
|
57
|
+
bag[j] = swap;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
draw() {
|
|
62
|
+
if (bag.length === 0)
|
|
63
|
+
refill();
|
|
64
|
+
return bag.pop();
|
|
65
|
+
},
|
|
66
|
+
get remaining() {
|
|
67
|
+
return bag.length;
|
|
68
|
+
},
|
|
69
|
+
size: cycle.length,
|
|
70
|
+
reset() {
|
|
71
|
+
bag.length = 0;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A rare event with a guaranteed ceiling: independent rolls, but the Nth
|
|
77
|
+
* attempt after a hit always hits.
|
|
78
|
+
*
|
|
79
|
+
* The shuffle bag is the wrong tool for the genuinely rare — a 1-in-50 drop
|
|
80
|
+
* means a 50-item bag, and the guarantee it offers ("exactly one per 50") is
|
|
81
|
+
* more rigid than a drop wants to feel. The pity timer keeps every roll
|
|
82
|
+
* independent and honest, and only intervenes at the tail: the player who
|
|
83
|
+
* would have gone 80 drops dry is the one player it touches, and that player
|
|
84
|
+
* was about to quit.
|
|
85
|
+
*/
|
|
86
|
+
export function createPityTimer(options) {
|
|
87
|
+
const { chance, guaranteedBy } = options;
|
|
88
|
+
const rng = options.rng ?? Math.random;
|
|
89
|
+
if (!(chance >= 0 && chance <= 1)) {
|
|
90
|
+
throw new Error(`createPityTimer: chance must be in 0..1, got ${chance}`);
|
|
91
|
+
}
|
|
92
|
+
if (!Number.isInteger(guaranteedBy) || guaranteedBy <= 0) {
|
|
93
|
+
throw new Error(`createPityTimer: guaranteedBy must be a positive integer, got ${guaranteedBy}`);
|
|
94
|
+
}
|
|
95
|
+
let misses = 0;
|
|
96
|
+
return {
|
|
97
|
+
roll() {
|
|
98
|
+
const hit = misses + 1 >= guaranteedBy || rng() < chance;
|
|
99
|
+
misses = hit ? 0 : misses + 1;
|
|
100
|
+
return hit;
|
|
101
|
+
},
|
|
102
|
+
get misses() {
|
|
103
|
+
return misses;
|
|
104
|
+
},
|
|
105
|
+
reset() {
|
|
106
|
+
misses = 0;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A probability that climbs with every miss and resets on a hit — the
|
|
112
|
+
* pseudo-random distribution behind "25%" crits in Dota and Warcraft 3.
|
|
113
|
+
*
|
|
114
|
+
* Softer than the pity timer: there is no hard ceiling, just pressure toward
|
|
115
|
+
* the mean from both sides. Streaks of misses get rapidly less likely, and
|
|
116
|
+
* back-to-back hits do too, because a hit drops the odds back to `start`.
|
|
117
|
+
* `start: 0.05, step: 0.1` feels like roughly a fifth and never insults the
|
|
118
|
+
* player with an eight-miss run.
|
|
119
|
+
*/
|
|
120
|
+
export function createEscalatingChance(options) {
|
|
121
|
+
const { start, step } = options;
|
|
122
|
+
const rng = options.rng ?? Math.random;
|
|
123
|
+
if (!(start >= 0 && start <= 1)) {
|
|
124
|
+
throw new Error(`createEscalatingChance: start must be in 0..1, got ${start}`);
|
|
125
|
+
}
|
|
126
|
+
if (!(step > 0))
|
|
127
|
+
throw new Error(`createEscalatingChance: step must be > 0, got ${step}`);
|
|
128
|
+
let chance = start;
|
|
129
|
+
return {
|
|
130
|
+
roll() {
|
|
131
|
+
const hit = rng() < chance;
|
|
132
|
+
chance = hit ? start : Math.min(chance + step, 1);
|
|
133
|
+
return hit;
|
|
134
|
+
},
|
|
135
|
+
get chance() {
|
|
136
|
+
return chance;
|
|
137
|
+
},
|
|
138
|
+
reset() {
|
|
139
|
+
chance = start;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* mulberry32: a seeded drop-in for `Math.random`, returning [0, 1).
|
|
145
|
+
*
|
|
146
|
+
* Not cryptographic and not meant to be — it exists so a sequence can be
|
|
147
|
+
* replayed. Same seed, same sequence, on every engine, which is what makes a
|
|
148
|
+
* shuffle bag assertable in a unit test and a daily challenge identical for
|
|
149
|
+
* every player who types the same date.
|
|
150
|
+
*/
|
|
151
|
+
export function seededRandom(seed) {
|
|
152
|
+
let state = seed >>> 0;
|
|
153
|
+
return () => {
|
|
154
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
155
|
+
let t = state;
|
|
156
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
157
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
158
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Object3D } from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* The Three.js leak, solved once.
|
|
4
|
+
*
|
|
5
|
+
* `scene.remove(mesh)` frees nothing: geometries, materials and textures are
|
|
6
|
+
* GPU resources that only `renderer.info.memory` ever sees, and they survive
|
|
7
|
+
* until someone calls `.dispose()`. A game that rebuilds its level on every
|
|
8
|
+
* restart and never disposes will climb until the WebView is killed — and
|
|
9
|
+
* because it climbs on the GPU, JS heap profiling shows nothing.
|
|
10
|
+
*/
|
|
11
|
+
export type Disposable = {
|
|
12
|
+
dispose(): void;
|
|
13
|
+
};
|
|
14
|
+
/** Anything that must outlive a restart: cached assets, shared materials. */
|
|
15
|
+
export type DisposeOptions = {
|
|
16
|
+
skip?: ReadonlySet<unknown>;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Recursively disposes every geometry, material and texture under `object`.
|
|
20
|
+
*
|
|
21
|
+
* Shared resources are visited once, and anything in `skip` (pass
|
|
22
|
+
* `assets.owned`) is left alone — cached assets live for the whole session and
|
|
23
|
+
* disposing one would blank every other object that clones it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function disposeObject(object: Object3D, options?: DisposeOptions): void;
|
|
26
|
+
/** Detach from the scene graph and dispose. The normal way to remove a thing. */
|
|
27
|
+
export declare function destroy(object: Object3D, options?: DisposeOptions): void;
|
|
28
|
+
/**
|
|
29
|
+
* A bag of things to free together.
|
|
30
|
+
*
|
|
31
|
+
* `game.transient` is a scope cleared on every `restart()`, which is why a game
|
|
32
|
+
* only has to remember rule 8 — register what you create after setup — instead
|
|
33
|
+
* of tracking individual resources.
|
|
34
|
+
*/
|
|
35
|
+
export declare class DisposalScope {
|
|
36
|
+
private readonly items;
|
|
37
|
+
private skip;
|
|
38
|
+
/** @param skip resources this scope must never dispose (`assets.owned`). */
|
|
39
|
+
constructor(skip?: ReadonlySet<unknown>);
|
|
40
|
+
add<T extends Object3D | Disposable>(item: T): T;
|
|
41
|
+
/** Removes without disposing — for an object that was handed off elsewhere. */
|
|
42
|
+
forget(item: unknown): void;
|
|
43
|
+
get size(): number;
|
|
44
|
+
dispose(): void;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=dispose.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dispose.d.ts","sourceRoot":"","sources":["../../src/scene/dispose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA4B,QAAQ,EAAW,MAAM,OAAO,CAAA;AAExE;;;;;;;;GAQG;AAEH,MAAM,MAAM,UAAU,GAAG;IAAE,OAAO,IAAI,IAAI,CAAA;CAAE,CAAA;AAE5C,6EAA6E;AAC7E,MAAM,MAAM,cAAc,GAAG;IAAE,IAAI,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC,CAAA;CAAE,CAAA;AA4C5D;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAE,cAAmB,GAAG,IAAI,CAelF;AAED,iFAAiF;AACjF,wBAAgB,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAE,cAAmB,GAAG,IAAI,CAG5E;AAED;;;;;;GAMG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IACtC,OAAO,CAAC,IAAI,CAA8B;IAE1C,4EAA4E;gBAChE,IAAI,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC;IAIvC,GAAG,CAAC,CAAC,SAAS,QAAQ,GAAG,UAAU,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC;IAKhD,+EAA+E;IAC/E,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI;IAK3B,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,OAAO,IAAI,IAAI;CAUhB"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const EMPTY = new Set();
|
|
2
|
+
function isTexture(value) {
|
|
3
|
+
return (typeof value === 'object' &&
|
|
4
|
+
value !== null &&
|
|
5
|
+
value.isTexture === true);
|
|
6
|
+
}
|
|
7
|
+
function disposeMaterial(material, skip, seen) {
|
|
8
|
+
if (skip.has(material) || seen.has(material))
|
|
9
|
+
return;
|
|
10
|
+
seen.add(material);
|
|
11
|
+
// Enumerate rather than list the slots: three has ~20 map properties and
|
|
12
|
+
// MeshPhysicalMaterial keeps adding them, so a hardcoded list is a slow leak
|
|
13
|
+
// waiting for the next release.
|
|
14
|
+
for (const value of Object.values(material)) {
|
|
15
|
+
if (!isTexture(value) || skip.has(value) || seen.has(value))
|
|
16
|
+
continue;
|
|
17
|
+
seen.add(value);
|
|
18
|
+
value.dispose();
|
|
19
|
+
}
|
|
20
|
+
material.dispose();
|
|
21
|
+
}
|
|
22
|
+
function disposeGeometry(geometry, skip, seen) {
|
|
23
|
+
if (skip.has(geometry) || seen.has(geometry))
|
|
24
|
+
return;
|
|
25
|
+
seen.add(geometry);
|
|
26
|
+
geometry.dispose();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Recursively disposes every geometry, material and texture under `object`.
|
|
30
|
+
*
|
|
31
|
+
* Shared resources are visited once, and anything in `skip` (pass
|
|
32
|
+
* `assets.owned`) is left alone — cached assets live for the whole session and
|
|
33
|
+
* disposing one would blank every other object that clones it.
|
|
34
|
+
*/
|
|
35
|
+
export function disposeObject(object, options = {}) {
|
|
36
|
+
const skip = options.skip ?? EMPTY;
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
object.traverse((node) => {
|
|
39
|
+
const renderable = node;
|
|
40
|
+
if (renderable.geometry)
|
|
41
|
+
disposeGeometry(renderable.geometry, skip, seen);
|
|
42
|
+
const material = renderable.material;
|
|
43
|
+
if (!material)
|
|
44
|
+
return;
|
|
45
|
+
if (Array.isArray(material)) {
|
|
46
|
+
for (const entry of material)
|
|
47
|
+
disposeMaterial(entry, skip, seen);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
disposeMaterial(material, skip, seen);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Detach from the scene graph and dispose. The normal way to remove a thing. */
|
|
55
|
+
export function destroy(object, options = {}) {
|
|
56
|
+
object.removeFromParent();
|
|
57
|
+
disposeObject(object, options);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A bag of things to free together.
|
|
61
|
+
*
|
|
62
|
+
* `game.transient` is a scope cleared on every `restart()`, which is why a game
|
|
63
|
+
* only has to remember rule 8 — register what you create after setup — instead
|
|
64
|
+
* of tracking individual resources.
|
|
65
|
+
*/
|
|
66
|
+
export class DisposalScope {
|
|
67
|
+
items = [];
|
|
68
|
+
skip = EMPTY;
|
|
69
|
+
/** @param skip resources this scope must never dispose (`assets.owned`). */
|
|
70
|
+
constructor(skip) {
|
|
71
|
+
if (skip)
|
|
72
|
+
this.skip = skip;
|
|
73
|
+
}
|
|
74
|
+
add(item) {
|
|
75
|
+
this.items.push(item);
|
|
76
|
+
return item;
|
|
77
|
+
}
|
|
78
|
+
/** Removes without disposing — for an object that was handed off elsewhere. */
|
|
79
|
+
forget(item) {
|
|
80
|
+
const index = this.items.indexOf(item);
|
|
81
|
+
if (index >= 0)
|
|
82
|
+
this.items.splice(index, 1);
|
|
83
|
+
}
|
|
84
|
+
get size() {
|
|
85
|
+
return this.items.length;
|
|
86
|
+
}
|
|
87
|
+
dispose() {
|
|
88
|
+
// Reverse order so a child registered after its parent is freed first.
|
|
89
|
+
for (let i = this.items.length - 1; i >= 0; i--) {
|
|
90
|
+
const item = this.items[i];
|
|
91
|
+
if (this.skip.has(item))
|
|
92
|
+
continue;
|
|
93
|
+
if (isObject3D(item))
|
|
94
|
+
destroy(item, { skip: this.skip });
|
|
95
|
+
else if (isDisposable(item))
|
|
96
|
+
item.dispose();
|
|
97
|
+
}
|
|
98
|
+
this.items.length = 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function isObject3D(value) {
|
|
102
|
+
return (typeof value === 'object' &&
|
|
103
|
+
value !== null &&
|
|
104
|
+
value.isObject3D === true);
|
|
105
|
+
}
|
|
106
|
+
function isDisposable(value) {
|
|
107
|
+
return typeof value?.dispose === 'function';
|
|
108
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Box3, DirectionalLight, HemisphereLight, Mesh, type Object3D, type Scene, type WebGLRenderer } from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Three phone-tuned presets. There is deliberately no EffectComposer here: at
|
|
4
|
+
* 720x1080 with DPR capped at 2 the real framebuffer is 1440x2160, and a bloom
|
|
5
|
+
* chain costs 4-6 ms of a 16.6 ms frame on a mid-range Android WebView. ACES
|
|
6
|
+
* tone mapping (set by `createGame`) plus emissive materials gets most of the
|
|
7
|
+
* look for free.
|
|
8
|
+
*/
|
|
9
|
+
export type LightingPreset = 'studio' | 'sunset' | 'night';
|
|
10
|
+
export type LightingOptions = {
|
|
11
|
+
/** Real shadow maps are opt-in — they cost a second scene pass. */
|
|
12
|
+
shadows?: boolean;
|
|
13
|
+
/** Ortho frustum for the shadow camera. Without it shadows look chunky. */
|
|
14
|
+
shadowBounds?: Box3;
|
|
15
|
+
renderer?: WebGLRenderer;
|
|
16
|
+
};
|
|
17
|
+
export type Lighting = {
|
|
18
|
+
key: DirectionalLight;
|
|
19
|
+
fill: HemisphereLight;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
};
|
|
22
|
+
export declare function applyLighting(scene: Scene, preset: LightingPreset, options?: LightingOptions): Lighting;
|
|
23
|
+
export type BlobShadowOptions = {
|
|
24
|
+
radius?: number;
|
|
25
|
+
opacity?: number;
|
|
26
|
+
/** World Y the blob sits on. */
|
|
27
|
+
groundY?: number;
|
|
28
|
+
};
|
|
29
|
+
export type BlobShadow = {
|
|
30
|
+
mesh: Mesh;
|
|
31
|
+
update(): void;
|
|
32
|
+
dispose(): void;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A flat translucent disc that tracks an object's ground position.
|
|
36
|
+
*
|
|
37
|
+
* On a small screen this reads better than a real shadow map and costs one
|
|
38
|
+
* draw call instead of a depth pass — the same trick the Remix avatar already
|
|
39
|
+
* uses in `packages/ui/src/lib/avatar-three.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function blobShadow(target: Object3D, options?: BlobShadowOptions): BlobShadow;
|
|
42
|
+
//# sourceMappingURL=lighting.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lighting.d.ts","sourceRoot":"","sources":["../../src/scene/lighting.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,IAAI,EAGT,gBAAgB,EAEhB,eAAe,EACf,IAAI,EAEJ,KAAK,QAAQ,EAEb,KAAK,KAAK,EAEV,KAAK,aAAa,EACnB,MAAM,OAAO,CAAA;AAEd;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAA;AA8C1D,MAAM,MAAM,eAAe,GAAG;IAC5B,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,IAAI,CAAA;IACnB,QAAQ,CAAC,EAAE,aAAa,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,gBAAgB,CAAA;IACrB,IAAI,EAAE,eAAe,CAAA;IACrB,OAAO,IAAI,IAAI,CAAA;CAChB,CAAA;AAED,wBAAgB,aAAa,CAC3B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,cAAc,EACtB,OAAO,GAAE,eAAoB,GAC5B,QAAQ,CAiDV;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,gCAAgC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,IAAI,CAAA;IACV,MAAM,IAAI,IAAI,CAAA;IACd,OAAO,IAAI,IAAI,CAAA;CAChB,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAiCxF"}
|