ugly-game 0.5.19 → 0.5.21
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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/loop/gameLoop.d.ts +47 -0
- package/dist/loop/gameLoop.js +94 -0
- package/dist/loop/pageState.d.ts +17 -0
- package/dist/loop/pageState.js +62 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +95 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './mat4';
|
|
|
3
3
|
export * from './noise';
|
|
4
4
|
export * from './moat';
|
|
5
5
|
export * from './stateIR';
|
|
6
|
+
export * from './telemetry';
|
|
6
7
|
export * from './playtestIR';
|
|
7
8
|
export * from './visualIR';
|
|
8
9
|
export * from './gpuShadow';
|
|
@@ -10,3 +11,5 @@ export * from './input/resolve';
|
|
|
10
11
|
export * from './input/browserBackend';
|
|
11
12
|
export * from './input/ControlHints';
|
|
12
13
|
export * from './input/TouchControls';
|
|
14
|
+
export * from './loop/pageState';
|
|
15
|
+
export * from './loop/gameLoop';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export * from './mat4';
|
|
|
5
5
|
export * from './noise';
|
|
6
6
|
export * from './moat';
|
|
7
7
|
export * from './stateIR';
|
|
8
|
+
export * from './telemetry';
|
|
8
9
|
export * from './playtestIR';
|
|
9
10
|
export * from './visualIR';
|
|
10
11
|
export * from './gpuShadow';
|
|
@@ -12,3 +13,5 @@ export * from './input/resolve';
|
|
|
12
13
|
export * from './input/browserBackend';
|
|
13
14
|
export * from './input/ControlHints';
|
|
14
15
|
export * from './input/TouchControls';
|
|
16
|
+
export * from './loop/pageState';
|
|
17
|
+
export * from './loop/gameLoop';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type PageState, type PageTier } from './pageState';
|
|
2
|
+
/** The three methods the loop needs to restore a networked sim after a pause. */
|
|
3
|
+
export interface NetAdapter {
|
|
4
|
+
window: number;
|
|
5
|
+
catchUp(gapTicks: number): void;
|
|
6
|
+
resync(): void;
|
|
7
|
+
}
|
|
8
|
+
export interface ResumeInfo {
|
|
9
|
+
hiddenMs: number;
|
|
10
|
+
gapTicks: number;
|
|
11
|
+
tier: PageTier;
|
|
12
|
+
}
|
|
13
|
+
export interface FrameInfo {
|
|
14
|
+
tier: PageTier;
|
|
15
|
+
frame: number;
|
|
16
|
+
dt: number;
|
|
17
|
+
t: number;
|
|
18
|
+
}
|
|
19
|
+
export interface GameLoopOpts {
|
|
20
|
+
render: (info: FrameInfo) => void;
|
|
21
|
+
/** Render FPS per tier. Default { active: 60, blurred: 20, hidden: 0 }. */
|
|
22
|
+
gameFps?: {
|
|
23
|
+
active: number;
|
|
24
|
+
blurred: number;
|
|
25
|
+
hidden: number;
|
|
26
|
+
};
|
|
27
|
+
/** Fixed sim timestep + catch-up clamp. Default { fps: 60, maxCatchUpFrames: 6 }. */
|
|
28
|
+
sim?: {
|
|
29
|
+
fps: number;
|
|
30
|
+
maxCatchUpFrames: number;
|
|
31
|
+
};
|
|
32
|
+
/** Fixed-step sim callback. Omit if the sim lives elsewhere (e.g. a worker). */
|
|
33
|
+
tick?: (dt: number) => void;
|
|
34
|
+
/** Enables the default resume policy (catchUp vs resync). */
|
|
35
|
+
net?: NetAdapter;
|
|
36
|
+
/** Fully overrides the default resume policy. */
|
|
37
|
+
onResume?: (info: ResumeInfo) => void;
|
|
38
|
+
pageState?: PageState;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
raf?: (cb: (t: number) => void) => number;
|
|
41
|
+
caf?: (id: number) => void;
|
|
42
|
+
}
|
|
43
|
+
export interface GameLoop {
|
|
44
|
+
stop(): void;
|
|
45
|
+
state(): PageTier;
|
|
46
|
+
}
|
|
47
|
+
export declare function runGameLoop(opts: GameLoopOpts): GameLoop;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// GAME LOOP — the one rAF loop a game hands its render (and optionally its sim) to.
|
|
3
|
+
// It owns three things a hand-rolled loop keeps getting wrong:
|
|
4
|
+
// 1. RENDER PACING by page tier — active 60 / blurred 20 / hidden 0 (overridable).
|
|
5
|
+
// 2. A FIXED-STEP SIM accumulator (opt-in via `tick`) that advances on wall time
|
|
6
|
+
// while visible and FREEZES while hidden, with a catch-up clamp.
|
|
7
|
+
// 3. SIM RESTORE on resume — the part games forget. Hand it a NetAdapter and it
|
|
8
|
+
// does the correct thing automatically: small gap → deterministic catch-up,
|
|
9
|
+
// large gap → snapshot resync. Override with onResume for anything bespoke.
|
|
10
|
+
// A single-player game passes neither net nor onResume and still gets correct
|
|
11
|
+
// catch-up clamping for free. Clock/rAF/pageState are injectable for tests.
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
import { createPageState } from './pageState';
|
|
14
|
+
const DEFAULT_GAME_FPS = { active: 60, blurred: 20, hidden: 0 };
|
|
15
|
+
const DEFAULT_SIM = { fps: 60, maxCatchUpFrames: 6 };
|
|
16
|
+
export function runGameLoop(opts) {
|
|
17
|
+
const gameFps = opts.gameFps ?? DEFAULT_GAME_FPS;
|
|
18
|
+
const sim = opts.sim ?? DEFAULT_SIM;
|
|
19
|
+
const now = opts.now ?? (() => performance.now());
|
|
20
|
+
const raf = opts.raf ?? ((cb) => requestAnimationFrame(cb));
|
|
21
|
+
const caf = opts.caf ??
|
|
22
|
+
((id) => {
|
|
23
|
+
cancelAnimationFrame(id);
|
|
24
|
+
});
|
|
25
|
+
const page = opts.pageState ?? createPageState({ now });
|
|
26
|
+
const fixedDt = 1 / sim.fps; // seconds per sim tick
|
|
27
|
+
const maxCatchUp = sim.maxCatchUpFrames * fixedDt; // seconds
|
|
28
|
+
let handle = 0;
|
|
29
|
+
let last = -1; // ms of the previous rAF callback
|
|
30
|
+
let acc = 0; // sim accumulator (seconds)
|
|
31
|
+
let renderAcc = 0; // render accumulator (seconds)
|
|
32
|
+
let frame = 0;
|
|
33
|
+
let stopped = false;
|
|
34
|
+
page.onResumeFromHidden((hiddenMs) => {
|
|
35
|
+
// Reset frame timing so the first frame after resume carries no dt/step spike.
|
|
36
|
+
last = -1;
|
|
37
|
+
acc = 0;
|
|
38
|
+
renderAcc = 0;
|
|
39
|
+
const gapTicks = Math.round(hiddenMs / (1000 * fixedDt));
|
|
40
|
+
const info = { hiddenMs, gapTicks, tier: page.tier() };
|
|
41
|
+
if (opts.onResume)
|
|
42
|
+
opts.onResume(info);
|
|
43
|
+
else if (opts.net) {
|
|
44
|
+
if (gapTicks <= opts.net.window)
|
|
45
|
+
opts.net.catchUp(gapTicks);
|
|
46
|
+
else
|
|
47
|
+
opts.net.resync();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const loop = (t) => {
|
|
51
|
+
if (stopped)
|
|
52
|
+
return;
|
|
53
|
+
if (last < 0)
|
|
54
|
+
last = t;
|
|
55
|
+
const wallDt = (t - last) / 1000;
|
|
56
|
+
last = t;
|
|
57
|
+
const tier = page.tier();
|
|
58
|
+
// SIM: advance whenever not hidden; freeze while hidden.
|
|
59
|
+
if (opts.tick && tier !== 'hidden') {
|
|
60
|
+
acc += Math.min(wallDt, maxCatchUp);
|
|
61
|
+
while (acc >= fixedDt) {
|
|
62
|
+
opts.tick(fixedDt);
|
|
63
|
+
acc -= fixedDt;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// RENDER: gate to the tier's FPS. 0 ⇒ never render (also covers a hidden tab
|
|
67
|
+
// whose browser still fires the occasional rAF).
|
|
68
|
+
const fps = gameFps[tier];
|
|
69
|
+
if (fps > 0) {
|
|
70
|
+
renderAcc += wallDt;
|
|
71
|
+
const period = 1 / fps;
|
|
72
|
+
// Carry the remainder (subtract the period, don't reset) so exact frame
|
|
73
|
+
// boundaries fire deterministically instead of being lost to fp jitter. The
|
|
74
|
+
// epsilon absorbs the last ulp when wallDt lands right on the period.
|
|
75
|
+
if (renderAcc + 1e-9 >= period) {
|
|
76
|
+
renderAcc -= period;
|
|
77
|
+
if (renderAcc > period)
|
|
78
|
+
renderAcc = 0; // shed backlog; never render twice per frame
|
|
79
|
+
opts.render({ tier, frame, dt: wallDt, t });
|
|
80
|
+
frame++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
handle = raf(loop);
|
|
84
|
+
};
|
|
85
|
+
handle = raf(loop);
|
|
86
|
+
return {
|
|
87
|
+
stop: () => {
|
|
88
|
+
stopped = true;
|
|
89
|
+
caf(handle);
|
|
90
|
+
page.stop();
|
|
91
|
+
},
|
|
92
|
+
state: () => page.tier(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type PageTier = 'active' | 'blurred' | 'hidden';
|
|
2
|
+
export interface PageStateTarget {
|
|
3
|
+
hidden(): boolean;
|
|
4
|
+
hasFocus(): boolean;
|
|
5
|
+
onVisibilityChange(cb: () => void): void;
|
|
6
|
+
offVisibilityChange(cb: () => void): void;
|
|
7
|
+
}
|
|
8
|
+
export interface PageStateDeps {
|
|
9
|
+
now?: () => number;
|
|
10
|
+
target?: PageStateTarget | null;
|
|
11
|
+
}
|
|
12
|
+
export interface PageState {
|
|
13
|
+
tier(): PageTier;
|
|
14
|
+
onResumeFromHidden(cb: (hiddenMs: number) => void): void;
|
|
15
|
+
stop(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function createPageState(deps?: PageStateDeps): PageState;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// PAGE STATE — "how present is this tab right now?" resolves the coarse tier that
|
|
3
|
+
// drives frame pacing (active / blurred / hidden), and timestamps hidden spans so a
|
|
4
|
+
// game can restore its sim on resume. requestAnimationFrame STOPS firing while a tab
|
|
5
|
+
// is hidden, so a running loop cannot detect its own return — this module owns the
|
|
6
|
+
// one visibilitychange listener that can. tier() polls hidden()/hasFocus() live, so
|
|
7
|
+
// blur/focus need no listeners. The DOM is injected (defaults to the real document/
|
|
8
|
+
// window) to keep it pure/testable, matching SharedClock's injected-clock style.
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
function realTarget() {
|
|
11
|
+
if (typeof document === 'undefined' || typeof window === 'undefined')
|
|
12
|
+
return null;
|
|
13
|
+
return {
|
|
14
|
+
hidden: () => document.hidden,
|
|
15
|
+
hasFocus: () => document.hasFocus(),
|
|
16
|
+
onVisibilityChange: (cb) => {
|
|
17
|
+
document.addEventListener('visibilitychange', cb);
|
|
18
|
+
},
|
|
19
|
+
offVisibilityChange: (cb) => {
|
|
20
|
+
document.removeEventListener('visibilitychange', cb);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function createPageState(deps = {}) {
|
|
25
|
+
const now = deps.now ?? (() => performance.now());
|
|
26
|
+
const target = deps.target === undefined ? realTarget() : deps.target;
|
|
27
|
+
const resumeCbs = [];
|
|
28
|
+
let hiddenAt = -1;
|
|
29
|
+
const tier = () => {
|
|
30
|
+
if (!target)
|
|
31
|
+
return 'active';
|
|
32
|
+
if (target.hidden())
|
|
33
|
+
return 'hidden';
|
|
34
|
+
return target.hasFocus() ? 'active' : 'blurred';
|
|
35
|
+
};
|
|
36
|
+
const onVisibility = () => {
|
|
37
|
+
if (!target)
|
|
38
|
+
return;
|
|
39
|
+
if (target.hidden()) {
|
|
40
|
+
if (hiddenAt < 0)
|
|
41
|
+
hiddenAt = now();
|
|
42
|
+
}
|
|
43
|
+
else if (hiddenAt >= 0) {
|
|
44
|
+
const hiddenMs = now() - hiddenAt;
|
|
45
|
+
hiddenAt = -1;
|
|
46
|
+
for (const cb of resumeCbs)
|
|
47
|
+
cb(hiddenMs);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
if (target)
|
|
51
|
+
target.onVisibilityChange(onVisibility);
|
|
52
|
+
return {
|
|
53
|
+
tier,
|
|
54
|
+
onResumeFromHidden: (cb) => {
|
|
55
|
+
resumeCbs.push(cb);
|
|
56
|
+
},
|
|
57
|
+
stop: () => {
|
|
58
|
+
if (target)
|
|
59
|
+
target.offVisibilityChange(onVisibility);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A registered provider: returns a JSON-able snapshot of some subsystem's state, evaluated lazily at report time. */
|
|
2
|
+
export type SnapshotProvider = () => unknown;
|
|
3
|
+
/** Register a lazy snapshot provider under `key`. Returns an unregister fn (useEffect-friendly). The provider is
|
|
4
|
+
* only invoked when a report is actually assembled, so an expensive full-state serialize costs nothing otherwise. */
|
|
5
|
+
export declare function registerSnapshotProvider(key: string, fn: SnapshotProvider): () => void;
|
|
6
|
+
/** Run every registered provider (best-effort — a throwing provider is recorded as `{_error}`, not fatal). */
|
|
7
|
+
export declare function collectSnapshots(): Record<string, unknown>;
|
|
8
|
+
/** A compact, storable reference to a packed snapshot — goes into the report's `context` JSON. Either the gzip is
|
|
9
|
+
* inlined (`gzB64`) or it was uploaded and only the `url` is kept. `bytes`/`gzBytes` let a viewer show the size. */
|
|
10
|
+
export interface PackedSnapshot {
|
|
11
|
+
key: string;
|
|
12
|
+
gzip: true;
|
|
13
|
+
bytes: number;
|
|
14
|
+
gzBytes: number;
|
|
15
|
+
gzB64?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
truncated?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface PackOpts {
|
|
20
|
+
/** gzip size (bytes) at/below which we INLINE as base64; above it we upload. Default 48000 (headroom under the
|
|
21
|
+
* 60KB keepalive-body cap once base64's ~1.37× expansion is applied). */
|
|
22
|
+
maxInlineBytes?: number;
|
|
23
|
+
/** Uploads the gzip blob to object storage and resolves to its URL. If absent, oversize payloads inline anyway. */
|
|
24
|
+
upload?: (gz: Uint8Array, meta: {
|
|
25
|
+
key: string;
|
|
26
|
+
bytes: number;
|
|
27
|
+
gzBytes: number;
|
|
28
|
+
}) => Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
/** Serialize → gzip → inline if small (base64) else upload. Returns the compact reference to store in the report. */
|
|
31
|
+
export declare function packSnapshot(key: string, snap: unknown, opts?: PackOpts): Promise<PackedSnapshot>;
|
|
32
|
+
/** Inverse of packSnapshot for tooling: recover the original object from a PackedSnapshot. Inline payloads decode
|
|
33
|
+
* locally; uploaded ones are fetched (pass a `fetchUrl` for non-browser contexts). Enables local-sim repro. */
|
|
34
|
+
export declare function unpackSnapshot(ref: PackedSnapshot, fetchUrl?: (url: string) => Promise<Uint8Array>): Promise<unknown>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// ugly-game TELEMETRY ENRICH — attach a game's structured IR snapshot to a feedback / error report so a bug can be
|
|
3
|
+
// LOCALLY REPRODUCED, not just described. Generic + engine-side: the app supplies the snapshot (typically an
|
|
4
|
+
// AgentDebuggable.snapshot() carrying the full deterministic sim state) and, for big payloads, an uploader; this
|
|
5
|
+
// module handles JSON serialization, gzip compression (CompressionStream — browser + Node ≥18), the
|
|
6
|
+
// inline-vs-upload SIZE decision, and the compact reference you store in the report's `context`.
|
|
7
|
+
//
|
|
8
|
+
// Why gzip + upload: a full-state snapshot (edit overlays, entity lists, CA fields) routinely blows past the 60KB
|
|
9
|
+
// keepalive-body line and bloats the single D1 report row. Gzip alone usually gets it back under the line (inline);
|
|
10
|
+
// genuinely huge snapshots upload to object storage (R2) and the report keeps only the URL.
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
const providers = new Map();
|
|
13
|
+
/** Register a lazy snapshot provider under `key`. Returns an unregister fn (useEffect-friendly). The provider is
|
|
14
|
+
* only invoked when a report is actually assembled, so an expensive full-state serialize costs nothing otherwise. */
|
|
15
|
+
export function registerSnapshotProvider(key, fn) {
|
|
16
|
+
providers.set(key, fn);
|
|
17
|
+
return () => {
|
|
18
|
+
if (providers.get(key) === fn)
|
|
19
|
+
providers.delete(key);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Run every registered provider (best-effort — a throwing provider is recorded as `{_error}`, not fatal). */
|
|
23
|
+
export function collectSnapshots() {
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const [k, fn] of providers) {
|
|
26
|
+
try {
|
|
27
|
+
out[k] = fn();
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
out[k] = { _error: String(e) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
async function gzipBytes(bytes) {
|
|
36
|
+
const cs = new CompressionStream('gzip');
|
|
37
|
+
const w = cs.writable.getWriter();
|
|
38
|
+
void w.write(bytes);
|
|
39
|
+
void w.close();
|
|
40
|
+
return new Uint8Array(await new Response(cs.readable).arrayBuffer());
|
|
41
|
+
}
|
|
42
|
+
async function gunzipBytes(bytes) {
|
|
43
|
+
const ds = new DecompressionStream('gzip');
|
|
44
|
+
const w = ds.writable.getWriter();
|
|
45
|
+
void w.write(bytes);
|
|
46
|
+
void w.close();
|
|
47
|
+
return new Uint8Array(await new Response(ds.readable).arrayBuffer());
|
|
48
|
+
}
|
|
49
|
+
function toB64(bytes) {
|
|
50
|
+
let s = '';
|
|
51
|
+
for (const b of bytes)
|
|
52
|
+
s += String.fromCharCode(b);
|
|
53
|
+
return btoa(s);
|
|
54
|
+
}
|
|
55
|
+
function fromB64(b64) {
|
|
56
|
+
const bin = atob(b64);
|
|
57
|
+
const out = new Uint8Array(bin.length);
|
|
58
|
+
for (let i = 0; i < bin.length; i++)
|
|
59
|
+
out[i] = bin.charCodeAt(i);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Serialize → gzip → inline if small (base64) else upload. Returns the compact reference to store in the report. */
|
|
63
|
+
export async function packSnapshot(key, snap, opts = {}) {
|
|
64
|
+
const maxInline = opts.maxInlineBytes ?? 48_000;
|
|
65
|
+
const json = JSON.stringify(snap);
|
|
66
|
+
const raw = new TextEncoder().encode(json);
|
|
67
|
+
const gz = await gzipBytes(raw);
|
|
68
|
+
const truncated = !!snap && typeof snap === 'object' && '_truncated' in snap
|
|
69
|
+
? true
|
|
70
|
+
: undefined;
|
|
71
|
+
const ref = {
|
|
72
|
+
key,
|
|
73
|
+
gzip: true,
|
|
74
|
+
bytes: raw.length,
|
|
75
|
+
gzBytes: gz.length,
|
|
76
|
+
truncated,
|
|
77
|
+
};
|
|
78
|
+
if (gz.length <= maxInline || !opts.upload)
|
|
79
|
+
return { ...ref, gzB64: toB64(gz) };
|
|
80
|
+
return {
|
|
81
|
+
...ref,
|
|
82
|
+
url: await opts.upload(gz, { key, bytes: raw.length, gzBytes: gz.length }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Inverse of packSnapshot for tooling: recover the original object from a PackedSnapshot. Inline payloads decode
|
|
86
|
+
* locally; uploaded ones are fetched (pass a `fetchUrl` for non-browser contexts). Enables local-sim repro. */
|
|
87
|
+
export async function unpackSnapshot(ref, fetchUrl = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer())) {
|
|
88
|
+
const gz = ref.gzB64
|
|
89
|
+
? fromB64(ref.gzB64)
|
|
90
|
+
: ref.url
|
|
91
|
+
? await fetchUrl(ref.url)
|
|
92
|
+
: new Uint8Array();
|
|
93
|
+
const raw = await gunzipBytes(gz);
|
|
94
|
+
return JSON.parse(new TextDecoder().decode(raw));
|
|
95
|
+
}
|