ugly-game 0.5.19 → 0.5.20
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 +2 -0
- package/dist/index.js +2 -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/package.json +9 -10
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ugly-game",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -15,14 +15,6 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
|
-
"scripts": {
|
|
19
|
-
"preinstall": "npx only-allow pnpm",
|
|
20
|
-
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"lint": "ugly-app lint",
|
|
22
|
-
"lint:fix": "ugly-app lint --fix",
|
|
23
|
-
"pretty": "prettier --write .",
|
|
24
|
-
"prepublishOnly": "pnpm run pretty && ugly-app lint && tsc -p tsconfig.json"
|
|
25
|
-
},
|
|
26
18
|
"dependencies": {
|
|
27
19
|
"meshoptimizer": "^1.2.0"
|
|
28
20
|
},
|
|
@@ -35,5 +27,12 @@
|
|
|
35
27
|
"@webgpu/types": "^0.1.40",
|
|
36
28
|
"prettier": "^3.9.5",
|
|
37
29
|
"ugly-app": "^0.1.841"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"preinstall": "npx only-allow pnpm",
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"lint": "ugly-app lint",
|
|
35
|
+
"lint:fix": "ugly-app lint --fix",
|
|
36
|
+
"pretty": "prettier --write ."
|
|
38
37
|
}
|
|
39
|
-
}
|
|
38
|
+
}
|