ugly-game 0.5.22 → 0.6.0
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 +5 -0
- package/dist/index.js +7 -0
- package/dist/loop/controlBlock.d.ts +33 -0
- package/dist/loop/controlBlock.js +121 -0
- package/dist/loop/deltaRing.d.ts +19 -0
- package/dist/loop/deltaRing.js +48 -0
- package/dist/loop/layout.d.ts +37 -0
- package/dist/loop/layout.js +97 -0
- package/dist/loop/simCore.d.ts +19 -0
- package/dist/loop/simCore.js +28 -0
- package/dist/loop/simHost.d.ts +57 -0
- package/dist/loop/simHost.js +104 -0
- package/dist/loop/simHost.worker.d.ts +8 -0
- package/dist/loop/simHost.worker.js +55 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -13,3 +13,8 @@ export * from './input/ControlHints';
|
|
|
13
13
|
export * from './input/TouchControls';
|
|
14
14
|
export * from './loop/pageState';
|
|
15
15
|
export * from './loop/gameLoop';
|
|
16
|
+
export * from './loop/layout';
|
|
17
|
+
export * from './loop/controlBlock';
|
|
18
|
+
export * from './loop/deltaRing';
|
|
19
|
+
export * from './loop/simCore';
|
|
20
|
+
export * from './loop/simHost';
|
package/dist/index.js
CHANGED
|
@@ -15,3 +15,10 @@ export * from './input/ControlHints';
|
|
|
15
15
|
export * from './input/TouchControls';
|
|
16
16
|
export * from './loop/pageState';
|
|
17
17
|
export * from './loop/gameLoop';
|
|
18
|
+
export * from './loop/layout';
|
|
19
|
+
export * from './loop/controlBlock';
|
|
20
|
+
export * from './loop/deltaRing';
|
|
21
|
+
export * from './loop/simCore';
|
|
22
|
+
export * from './loop/simHost';
|
|
23
|
+
// NOTE: './loop/simHost.worker' is intentionally NOT in the barrel — it is a worker entry, imported directly as
|
|
24
|
+
// 'ugly-game/loop/simHost.worker' by a game's own worker file, never into a main-thread bundle.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare const CTL_HEADER: {
|
|
2
|
+
readonly RUNNING: 0;
|
|
3
|
+
readonly PLAYING: 1;
|
|
4
|
+
readonly PAUSE_REQ: 2;
|
|
5
|
+
readonly PAUSED: 3;
|
|
6
|
+
readonly DIRTY: 4;
|
|
7
|
+
readonly UPS: 5;
|
|
8
|
+
readonly TICK: 6;
|
|
9
|
+
readonly TARGET_TICK: 7;
|
|
10
|
+
readonly COOP: 8;
|
|
11
|
+
readonly LATE: 9;
|
|
12
|
+
};
|
|
13
|
+
export declare function controlByteLength(scalarNames: readonly string[]): number;
|
|
14
|
+
export type { ScalarCell } from './layout';
|
|
15
|
+
import type { ScalarCell } from './layout';
|
|
16
|
+
export interface ControlView<S extends string> {
|
|
17
|
+
running: boolean;
|
|
18
|
+
playing: boolean;
|
|
19
|
+
pauseReq: boolean;
|
|
20
|
+
paused: boolean;
|
|
21
|
+
dirty: boolean;
|
|
22
|
+
ups: number;
|
|
23
|
+
tick: number;
|
|
24
|
+
targetTick: number;
|
|
25
|
+
coop: boolean;
|
|
26
|
+
late: number;
|
|
27
|
+
scalar(name: S): ScalarCell;
|
|
28
|
+
raw(): Int32Array;
|
|
29
|
+
/** Spin-wait (bounded) until PAUSED reaches `target`; returns true if it did. Main-thread safe (no Atomics.wait). */
|
|
30
|
+
waitPaused(target: boolean, spinMax?: number): boolean;
|
|
31
|
+
notify(slot: number): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function createControlView<S extends string>(buf: ArrayBufferLike, scalarNames: readonly S[]): ControlView<S>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// CONTROL BLOCK — the sim/render coordination channel: a small Int32Array (over the same SharedArrayBuffer family)
|
|
3
|
+
// with a FIXED header (lifecycle + diagnostics + co-op lockstep) and a game-declared SCALAR-mirror region (worker-
|
|
4
|
+
// owned JS-number scalars it publishes for the main thread to read). Everything is accessed through typed named
|
|
5
|
+
// getters/setters backed by Atomics — consumers never touch a raw slot index, so a wrong field is a compile error.
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
export const CTL_HEADER = {
|
|
8
|
+
RUNNING: 0,
|
|
9
|
+
PLAYING: 1,
|
|
10
|
+
PAUSE_REQ: 2,
|
|
11
|
+
PAUSED: 3,
|
|
12
|
+
DIRTY: 4,
|
|
13
|
+
UPS: 5,
|
|
14
|
+
TICK: 6,
|
|
15
|
+
TARGET_TICK: 7,
|
|
16
|
+
COOP: 8,
|
|
17
|
+
LATE: 9,
|
|
18
|
+
};
|
|
19
|
+
const HEADER_LEN = 10;
|
|
20
|
+
export function controlByteLength(scalarNames) {
|
|
21
|
+
return (HEADER_LEN + scalarNames.length) * 4;
|
|
22
|
+
}
|
|
23
|
+
export function createControlView(buf, scalarNames) {
|
|
24
|
+
const a = new Int32Array(buf);
|
|
25
|
+
const idx = new Map();
|
|
26
|
+
scalarNames.forEach((n, i) => idx.set(n, HEADER_LEN + i));
|
|
27
|
+
const b = (slot) => Atomics.load(a, slot) === 1;
|
|
28
|
+
const setb = (slot, v) => {
|
|
29
|
+
Atomics.store(a, slot, v ? 1 : 0);
|
|
30
|
+
};
|
|
31
|
+
const n = (slot) => Atomics.load(a, slot);
|
|
32
|
+
const setn = (slot, v) => {
|
|
33
|
+
Atomics.store(a, slot, v | 0);
|
|
34
|
+
};
|
|
35
|
+
const H = CTL_HEADER;
|
|
36
|
+
return {
|
|
37
|
+
get running() {
|
|
38
|
+
return b(H.RUNNING);
|
|
39
|
+
},
|
|
40
|
+
set running(v) {
|
|
41
|
+
setb(H.RUNNING, v);
|
|
42
|
+
},
|
|
43
|
+
get playing() {
|
|
44
|
+
return b(H.PLAYING);
|
|
45
|
+
},
|
|
46
|
+
set playing(v) {
|
|
47
|
+
setb(H.PLAYING, v);
|
|
48
|
+
},
|
|
49
|
+
get pauseReq() {
|
|
50
|
+
return b(H.PAUSE_REQ);
|
|
51
|
+
},
|
|
52
|
+
set pauseReq(v) {
|
|
53
|
+
setb(H.PAUSE_REQ, v);
|
|
54
|
+
},
|
|
55
|
+
get paused() {
|
|
56
|
+
return b(H.PAUSED);
|
|
57
|
+
},
|
|
58
|
+
set paused(v) {
|
|
59
|
+
setb(H.PAUSED, v);
|
|
60
|
+
},
|
|
61
|
+
get dirty() {
|
|
62
|
+
return b(H.DIRTY);
|
|
63
|
+
},
|
|
64
|
+
set dirty(v) {
|
|
65
|
+
setb(H.DIRTY, v);
|
|
66
|
+
},
|
|
67
|
+
get ups() {
|
|
68
|
+
return n(H.UPS);
|
|
69
|
+
},
|
|
70
|
+
set ups(v) {
|
|
71
|
+
setn(H.UPS, v);
|
|
72
|
+
},
|
|
73
|
+
get tick() {
|
|
74
|
+
return n(H.TICK);
|
|
75
|
+
},
|
|
76
|
+
set tick(v) {
|
|
77
|
+
setn(H.TICK, v);
|
|
78
|
+
},
|
|
79
|
+
get targetTick() {
|
|
80
|
+
return n(H.TARGET_TICK);
|
|
81
|
+
},
|
|
82
|
+
set targetTick(v) {
|
|
83
|
+
setn(H.TARGET_TICK, v);
|
|
84
|
+
},
|
|
85
|
+
get coop() {
|
|
86
|
+
return b(H.COOP);
|
|
87
|
+
},
|
|
88
|
+
set coop(v) {
|
|
89
|
+
setb(H.COOP, v);
|
|
90
|
+
},
|
|
91
|
+
get late() {
|
|
92
|
+
return n(H.LATE);
|
|
93
|
+
},
|
|
94
|
+
set late(v) {
|
|
95
|
+
setn(H.LATE, v);
|
|
96
|
+
},
|
|
97
|
+
scalar(name) {
|
|
98
|
+
const slot = idx.get(name);
|
|
99
|
+
if (slot === undefined) {
|
|
100
|
+
throw new Error(`[controlBlock] unknown scalar "${name}"`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
get: () => Atomics.load(a, slot),
|
|
104
|
+
set: (v) => {
|
|
105
|
+
Atomics.store(a, slot, v | 0);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
raw: () => a,
|
|
110
|
+
waitPaused(target, spinMax = 8_000_000) {
|
|
111
|
+
let s = 0;
|
|
112
|
+
while (b(H.PAUSED) !== target && s++ < spinMax) {
|
|
113
|
+
/* spin — the worker flips PAUSED only between ticks */
|
|
114
|
+
}
|
|
115
|
+
return b(H.PAUSED) === target;
|
|
116
|
+
},
|
|
117
|
+
notify(slot) {
|
|
118
|
+
Atomics.notify(a, slot);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface SideCodec<Entry> {
|
|
2
|
+
entryBytes: number;
|
|
3
|
+
encode(view: DataView, off: number, e: Entry): void;
|
|
4
|
+
decode(view: DataView, off: number): Entry;
|
|
5
|
+
}
|
|
6
|
+
export declare function ringByteLength(capacityEntries: number, entryBytes: number): number;
|
|
7
|
+
export interface RingWriter<E> {
|
|
8
|
+
push(e: E): void;
|
|
9
|
+
seq(): number;
|
|
10
|
+
}
|
|
11
|
+
export declare function createRingWriter<E>(buf: ArrayBufferLike, codec: SideCodec<E>, capacityEntries: number): RingWriter<E>;
|
|
12
|
+
export interface DrainResult {
|
|
13
|
+
drained: number;
|
|
14
|
+
overflowed: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface RingReader<E> {
|
|
17
|
+
drain(onEntry: (e: E) => void): DrainResult;
|
|
18
|
+
}
|
|
19
|
+
export declare function createRingReader<E>(buf: ArrayBufferLike, codec: SideCodec<E>, capacityEntries: number): RingReader<E>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// DELTA RING — a single-writer / single-reader versioned ring of FIXED-size entries over a shared buffer, for state
|
|
3
|
+
// that isn't a fixed SAB column (chiefly a game's edit overlay). The sim thread `push`es a typed Entry (encoded via
|
|
4
|
+
// a game-supplied SideCodec); the render thread `drain`s every entry appended since its last read. If the reader
|
|
5
|
+
// fell more than `capacity` entries behind, that window is unrecoverable — `drain` reports `overflowed:true`, resets
|
|
6
|
+
// itself to the writer head, and the caller requests a full resync. Header: [writeSeq:i32, _pad:i32] then entries.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
const HEADER_BYTES = 8; // writeSeq(i32) + pad(i32, keeps entries 8-aligned)
|
|
9
|
+
export function ringByteLength(capacityEntries, entryBytes) {
|
|
10
|
+
return HEADER_BYTES + capacityEntries * entryBytes;
|
|
11
|
+
}
|
|
12
|
+
export function createRingWriter(buf, codec, capacityEntries) {
|
|
13
|
+
const head = new Int32Array(buf, 0, 2);
|
|
14
|
+
const view = new DataView(buf);
|
|
15
|
+
return {
|
|
16
|
+
push(e) {
|
|
17
|
+
const seq = Atomics.load(head, 0);
|
|
18
|
+
const slot = seq % capacityEntries;
|
|
19
|
+
codec.encode(view, HEADER_BYTES + slot * codec.entryBytes, e);
|
|
20
|
+
Atomics.store(head, 0, seq + 1); // publish AFTER the bytes are written
|
|
21
|
+
},
|
|
22
|
+
seq: () => Atomics.load(head, 0),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function createRingReader(buf, codec, capacityEntries) {
|
|
26
|
+
const head = new Int32Array(buf, 0, 2);
|
|
27
|
+
const view = new DataView(buf);
|
|
28
|
+
let readSeq = Atomics.load(head, 0); // start at current head — pre-existing entries aren't "new"
|
|
29
|
+
return {
|
|
30
|
+
drain(onEntry) {
|
|
31
|
+
const writeSeq = Atomics.load(head, 0);
|
|
32
|
+
let overflowed = false;
|
|
33
|
+
let from = readSeq;
|
|
34
|
+
if (writeSeq - from > capacityEntries) {
|
|
35
|
+
overflowed = true;
|
|
36
|
+
from = writeSeq - capacityEntries; // only the newest `capacity` entries survive
|
|
37
|
+
}
|
|
38
|
+
let drained = 0;
|
|
39
|
+
for (let s = from; s < writeSeq; s++) {
|
|
40
|
+
const slot = s % capacityEntries;
|
|
41
|
+
onEntry(codec.decode(view, HEADER_BYTES + slot * codec.entryBytes));
|
|
42
|
+
drained++;
|
|
43
|
+
}
|
|
44
|
+
readSeq = writeSeq;
|
|
45
|
+
return { drained, overflowed };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type ArrayKind = 'f32' | 'f64' | 'i32' | 'u32' | 'u16' | 'u8' | 'i8';
|
|
2
|
+
export interface ColumnSpec {
|
|
3
|
+
readonly _tag: 'field';
|
|
4
|
+
readonly kind: ArrayKind;
|
|
5
|
+
readonly len: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ScalarSpec {
|
|
8
|
+
readonly _tag: 'scalar';
|
|
9
|
+
readonly kind: 'i32' | 'f64';
|
|
10
|
+
}
|
|
11
|
+
export interface LayoutSpec {
|
|
12
|
+
readonly [k: string]: ColumnSpec | ScalarSpec | LayoutSpec;
|
|
13
|
+
}
|
|
14
|
+
export declare const f32: (len: number) => ColumnSpec;
|
|
15
|
+
export declare const f64: (len: number) => ColumnSpec;
|
|
16
|
+
export declare const i32: (len: number) => ColumnSpec;
|
|
17
|
+
export declare const u32: (len: number) => ColumnSpec;
|
|
18
|
+
export declare const u16: (len: number) => ColumnSpec;
|
|
19
|
+
export declare const u8: (len: number) => ColumnSpec;
|
|
20
|
+
export declare const i8: (len: number) => ColumnSpec;
|
|
21
|
+
export declare const scalarI32: () => ScalarSpec;
|
|
22
|
+
export declare const scalarF64: () => ScalarSpec;
|
|
23
|
+
export interface ScalarCell {
|
|
24
|
+
get(): number;
|
|
25
|
+
set(v: number): void;
|
|
26
|
+
}
|
|
27
|
+
type ArrayOf<K extends ArrayKind> = K extends 'f32' ? Float32Array : K extends 'f64' ? Float64Array : K extends 'i32' ? Int32Array : K extends 'u32' ? Uint32Array : K extends 'u16' ? Uint16Array : K extends 'u8' ? Uint8Array : Int8Array;
|
|
28
|
+
type NodeOf<N> = N extends ColumnSpec ? ArrayOf<N['kind']> : N extends ScalarSpec ? ScalarCell : N extends LayoutSpec ? StateOf<N> : never;
|
|
29
|
+
export type StateOf<Spec extends LayoutSpec> = {
|
|
30
|
+
[K in keyof Spec]: NodeOf<Spec[K]>;
|
|
31
|
+
};
|
|
32
|
+
export interface Layout<State> {
|
|
33
|
+
byteLength: number;
|
|
34
|
+
attach(buf: ArrayBufferLike): State;
|
|
35
|
+
}
|
|
36
|
+
export declare function defineLayout<Spec extends LayoutSpec>(spec: Spec): Layout<StateOf<Spec>>;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// LAYOUT — a typed Structure-of-Arrays descriptor over ONE ArrayBuffer/SharedArrayBuffer. One `defineLayout(spec)`
|
|
3
|
+
// call yields (a) a computed, 4-aligned `byteLength` and (b) `attach(buf)` that overlays typed-array columns + scalar
|
|
4
|
+
// cells at those offsets, returning a view whose TYPE is inferred from the spec. Both the sim (worker) and render
|
|
5
|
+
// (main) threads call `attach` on the same shared buffer, so they read the identical typed view — a field rename or
|
|
6
|
+
// retype is a compile error on both sides. The byte→field binding itself is runtime (inherent to SAB); it is the one
|
|
7
|
+
// tested seam (see layout.test.mts) rather than a compiler-proven fact.
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
const field = (kind) => (len) => ({ _tag: 'field', kind, len });
|
|
10
|
+
export const f32 = field('f32');
|
|
11
|
+
export const f64 = field('f64');
|
|
12
|
+
export const i32 = field('i32');
|
|
13
|
+
export const u32 = field('u32');
|
|
14
|
+
export const u16 = field('u16');
|
|
15
|
+
export const u8 = field('u8');
|
|
16
|
+
export const i8 = field('i8');
|
|
17
|
+
export const scalarI32 = () => ({ _tag: 'scalar', kind: 'i32' });
|
|
18
|
+
export const scalarF64 = () => ({ _tag: 'scalar', kind: 'f64' });
|
|
19
|
+
const BYTES = {
|
|
20
|
+
f32: 4,
|
|
21
|
+
f64: 8,
|
|
22
|
+
i32: 4,
|
|
23
|
+
u32: 4,
|
|
24
|
+
u16: 2,
|
|
25
|
+
u8: 1,
|
|
26
|
+
i8: 1,
|
|
27
|
+
};
|
|
28
|
+
const CTOR = {
|
|
29
|
+
f32: Float32Array,
|
|
30
|
+
f64: Float64Array,
|
|
31
|
+
i32: Int32Array,
|
|
32
|
+
u32: Uint32Array,
|
|
33
|
+
u16: Uint16Array,
|
|
34
|
+
u8: Uint8Array,
|
|
35
|
+
i8: Int8Array,
|
|
36
|
+
};
|
|
37
|
+
const align = (o, a) => (o + (a - 1)) & ~(a - 1);
|
|
38
|
+
function place(spec, start, out, path) {
|
|
39
|
+
let o = start;
|
|
40
|
+
for (const key of Object.keys(spec)) {
|
|
41
|
+
const node = spec[key];
|
|
42
|
+
const p = [...path, key];
|
|
43
|
+
if ('_tag' in node && node._tag === 'field') {
|
|
44
|
+
const b = BYTES[node.kind];
|
|
45
|
+
o = align(o, Math.max(4, b)); // 4-align every column so mixed widths stay aligned
|
|
46
|
+
out.set(p, { offset: o, spec: node });
|
|
47
|
+
o += node.len * b;
|
|
48
|
+
}
|
|
49
|
+
else if ('_tag' in node && node._tag === 'scalar') {
|
|
50
|
+
const b = node.kind === 'f64' ? 8 : 4;
|
|
51
|
+
o = align(o, b);
|
|
52
|
+
out.set(p, { offset: o, spec: node });
|
|
53
|
+
o += b;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
o = place(node, o, out, p); // nested
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return o;
|
|
60
|
+
}
|
|
61
|
+
export function defineLayout(spec) {
|
|
62
|
+
const placed = new Map();
|
|
63
|
+
const byteLength = align(place(spec, 0, placed, []), 4);
|
|
64
|
+
return {
|
|
65
|
+
byteLength,
|
|
66
|
+
attach(buf) {
|
|
67
|
+
const root = {};
|
|
68
|
+
for (const [path, { offset, spec: s }] of placed) {
|
|
69
|
+
let cur = root;
|
|
70
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
71
|
+
cur[path[i]] ??= {};
|
|
72
|
+
cur = cur[path[i]];
|
|
73
|
+
}
|
|
74
|
+
const leaf = path[path.length - 1];
|
|
75
|
+
// Cast to ArrayBuffer for the typed-array ctors: TS 5.7 brands SharedArrayBuffer distinctly, but both are
|
|
76
|
+
// valid backing stores for a view at runtime (the whole point of a shared layout).
|
|
77
|
+
const ab = buf;
|
|
78
|
+
if (s._tag === 'field') {
|
|
79
|
+
cur[leaf] = new CTOR[s.kind](ab, offset, s.len);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const arr = s.kind === 'f64'
|
|
83
|
+
? new Float64Array(ab, offset, 1)
|
|
84
|
+
: new Int32Array(ab, offset, 1);
|
|
85
|
+
const cell = {
|
|
86
|
+
get: () => arr[0],
|
|
87
|
+
set: (v) => {
|
|
88
|
+
arr[0] = v;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
cur[leaf] = cell;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return root;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DeterministicSim } from '../moat';
|
|
2
|
+
import type { ControlView } from './controlBlock';
|
|
3
|
+
export interface SimClock {
|
|
4
|
+
now(): number;
|
|
5
|
+
}
|
|
6
|
+
export interface FixedStepOpts<S, I> {
|
|
7
|
+
sim: DeterministicSim<S, I>;
|
|
8
|
+
state: S;
|
|
9
|
+
ctl: ControlView<string>;
|
|
10
|
+
nextInput(): I | undefined;
|
|
11
|
+
clock: SimClock;
|
|
12
|
+
lastRef: {
|
|
13
|
+
t: number;
|
|
14
|
+
};
|
|
15
|
+
fps?: number;
|
|
16
|
+
maxCatchUpFrames?: number;
|
|
17
|
+
onScalars?(): void;
|
|
18
|
+
}
|
|
19
|
+
export declare function fixedStepBatch<S, I>(opts: FixedStepOpts<S, I>, acc: number): number;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function fixedStepBatch(opts, acc) {
|
|
2
|
+
const fps = opts.fps ?? 60;
|
|
3
|
+
const maxCatch = (opts.maxCatchUpFrames ?? 6) / fps; // seconds
|
|
4
|
+
const T = 1 / fps; // seconds/tick
|
|
5
|
+
const now = opts.clock.now();
|
|
6
|
+
const elapsed = Math.max(0, (now - opts.lastRef.t) / 1000);
|
|
7
|
+
opts.lastRef.t = now;
|
|
8
|
+
if (opts.ctl.pauseReq) {
|
|
9
|
+
opts.ctl.paused = true;
|
|
10
|
+
return 0; // drop the accumulator so we don't over-step on resume
|
|
11
|
+
}
|
|
12
|
+
opts.ctl.paused = false;
|
|
13
|
+
let a = Math.min(acc + elapsed, maxCatch); // clamp catch-up
|
|
14
|
+
let stepped = 0;
|
|
15
|
+
while (a >= T) {
|
|
16
|
+
const input = opts.nextInput();
|
|
17
|
+
opts.sim.step(opts.state, input); // step accepts the game's Input (undefined ⇒ the game's no-op input)
|
|
18
|
+
opts.ctl.tick = opts.ctl.tick + 1;
|
|
19
|
+
a -= T;
|
|
20
|
+
stepped++;
|
|
21
|
+
}
|
|
22
|
+
if (stepped > 0) {
|
|
23
|
+
const secs = Math.max(elapsed, T);
|
|
24
|
+
opts.ctl.ups = Math.round(stepped / secs) || opts.ctl.ups;
|
|
25
|
+
opts.onScalars?.();
|
|
26
|
+
}
|
|
27
|
+
return a;
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Layout } from './layout';
|
|
2
|
+
import { type ControlView } from './controlBlock';
|
|
3
|
+
import { type SimClock } from './simCore';
|
|
4
|
+
import type { DeterministicSim } from '../moat';
|
|
5
|
+
import type { NetAdapter } from './gameLoop';
|
|
6
|
+
export interface WorkerLike {
|
|
7
|
+
postMessage(m: unknown): void;
|
|
8
|
+
onmessage: ((e: {
|
|
9
|
+
data: unknown;
|
|
10
|
+
}) => void) | null;
|
|
11
|
+
terminate(): void;
|
|
12
|
+
}
|
|
13
|
+
export type HostToWorker<I> = {
|
|
14
|
+
t: 'init';
|
|
15
|
+
ctlBuf: ArrayBufferLike;
|
|
16
|
+
dataBuf: ArrayBufferLike;
|
|
17
|
+
scalars: readonly string[];
|
|
18
|
+
simFps: number;
|
|
19
|
+
} | {
|
|
20
|
+
t: 'input';
|
|
21
|
+
v: I;
|
|
22
|
+
} | {
|
|
23
|
+
t: 'resync-req';
|
|
24
|
+
};
|
|
25
|
+
export type WorkerToHost = {
|
|
26
|
+
t: 'ready';
|
|
27
|
+
} | {
|
|
28
|
+
t: 'hash';
|
|
29
|
+
hash: string;
|
|
30
|
+
} | {
|
|
31
|
+
t: 'cold';
|
|
32
|
+
payload: unknown;
|
|
33
|
+
};
|
|
34
|
+
export interface SimHostOpts<State, Input> {
|
|
35
|
+
layout: Layout<State>;
|
|
36
|
+
sim: DeterministicSim<State, Input>;
|
|
37
|
+
seed?: (s: State) => void;
|
|
38
|
+
scalars?: readonly string[];
|
|
39
|
+
worker?: () => WorkerLike;
|
|
40
|
+
clock?: SimClock;
|
|
41
|
+
simFps?: number;
|
|
42
|
+
maxCatchUpFrames?: number;
|
|
43
|
+
net?: NetAdapter;
|
|
44
|
+
}
|
|
45
|
+
export interface SimHost<State, Input> {
|
|
46
|
+
readonly state: State;
|
|
47
|
+
readonly available: boolean;
|
|
48
|
+
send(input: Input): void;
|
|
49
|
+
edit(fn: (s: State) => void): void;
|
|
50
|
+
ctl: ControlView<string>;
|
|
51
|
+
hash(): string;
|
|
52
|
+
netAdapter(): NetAdapter;
|
|
53
|
+
stop(): void;
|
|
54
|
+
/** Fallback/test only: advance the main-thread sim to wall time `nowMs`. Undefined when a worker owns stepping. */
|
|
55
|
+
pump?(nowMs: number): void;
|
|
56
|
+
}
|
|
57
|
+
export declare function createSimHost<State, Input>(opts: SimHostOpts<State, Input>): SimHost<State, Input>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createControlView, controlByteLength, } from './controlBlock';
|
|
2
|
+
import { fixedStepBatch } from './simCore';
|
|
3
|
+
const canSAB = () => typeof SharedArrayBuffer !== 'undefined' &&
|
|
4
|
+
(globalThis
|
|
5
|
+
.crossOriginIsolated ??
|
|
6
|
+
false);
|
|
7
|
+
export function createSimHost(opts) {
|
|
8
|
+
const scalars = opts.scalars ?? [];
|
|
9
|
+
const useWorker = !!opts.worker && canSAB();
|
|
10
|
+
const dataBuf = useWorker
|
|
11
|
+
? new SharedArrayBuffer(opts.layout.byteLength)
|
|
12
|
+
: new ArrayBuffer(opts.layout.byteLength);
|
|
13
|
+
const ctlBuf = useWorker
|
|
14
|
+
? new SharedArrayBuffer(controlByteLength(scalars))
|
|
15
|
+
: new ArrayBuffer(controlByteLength(scalars));
|
|
16
|
+
const state = opts.layout.attach(dataBuf);
|
|
17
|
+
const ctl = createControlView(ctlBuf, scalars);
|
|
18
|
+
opts.seed?.(state);
|
|
19
|
+
const inputQ = [];
|
|
20
|
+
const clock = opts.clock ?? { now: () => Date.now() };
|
|
21
|
+
let lastHash = opts.sim.hash(state);
|
|
22
|
+
// ── Worker path ──────────────────────────────────────────────
|
|
23
|
+
let worker = null;
|
|
24
|
+
if (useWorker && opts.worker) {
|
|
25
|
+
worker = opts.worker();
|
|
26
|
+
worker.onmessage = (e) => {
|
|
27
|
+
const m = e.data;
|
|
28
|
+
if (m.t === 'hash')
|
|
29
|
+
lastHash = m.hash;
|
|
30
|
+
};
|
|
31
|
+
const init = {
|
|
32
|
+
t: 'init',
|
|
33
|
+
ctlBuf,
|
|
34
|
+
dataBuf,
|
|
35
|
+
scalars,
|
|
36
|
+
simFps: opts.simFps ?? 60,
|
|
37
|
+
};
|
|
38
|
+
worker.postMessage(init);
|
|
39
|
+
ctl.playing = true;
|
|
40
|
+
}
|
|
41
|
+
// ── Fallback path (main-thread stepping) ─────────────────────
|
|
42
|
+
const lastRef = { t: clock.now() };
|
|
43
|
+
let acc = 0;
|
|
44
|
+
const pump = (nowMs) => {
|
|
45
|
+
// The fallback clock is driven by the caller's timestamp for determinism in tests + frame-locked stepping.
|
|
46
|
+
const drivenClock = { now: () => nowMs };
|
|
47
|
+
acc = fixedStepBatch({
|
|
48
|
+
sim: opts.sim,
|
|
49
|
+
state,
|
|
50
|
+
ctl,
|
|
51
|
+
nextInput: () => inputQ.shift(),
|
|
52
|
+
clock: drivenClock,
|
|
53
|
+
lastRef,
|
|
54
|
+
fps: opts.simFps,
|
|
55
|
+
maxCatchUpFrames: opts.maxCatchUpFrames,
|
|
56
|
+
}, acc);
|
|
57
|
+
lastHash = opts.sim.hash(state);
|
|
58
|
+
};
|
|
59
|
+
// Solo default: nothing to catch up or resync (single-player). A co-op game passes its own NetAdapter.
|
|
60
|
+
const noop = () => undefined;
|
|
61
|
+
const net = opts.net ?? {
|
|
62
|
+
window: opts.maxCatchUpFrames ?? 6,
|
|
63
|
+
catchUp: noop,
|
|
64
|
+
resync: noop,
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
state,
|
|
68
|
+
available: useWorker,
|
|
69
|
+
send(input) {
|
|
70
|
+
if (worker) {
|
|
71
|
+
const msg = { t: 'input', v: input };
|
|
72
|
+
worker.postMessage(msg);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
inputQ.push(input);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
edit(fn) {
|
|
79
|
+
if (worker) {
|
|
80
|
+
ctl.pauseReq = true;
|
|
81
|
+
ctl.waitPaused(true);
|
|
82
|
+
try {
|
|
83
|
+
fn(state);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
ctl.dirty = true;
|
|
87
|
+
ctl.pauseReq = false;
|
|
88
|
+
ctl.notify(2 /* PAUSE_REQ slot */);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
fn(state); // fallback: no concurrent stepping, apply directly
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
ctl,
|
|
96
|
+
hash: () => lastHash,
|
|
97
|
+
netAdapter: () => net,
|
|
98
|
+
stop() {
|
|
99
|
+
ctl.running = false;
|
|
100
|
+
worker?.terminate();
|
|
101
|
+
},
|
|
102
|
+
pump: useWorker ? undefined : pump,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DeterministicSim } from '../moat';
|
|
2
|
+
import type { Layout } from './layout';
|
|
3
|
+
export interface SimWorkerConfig<S, I> {
|
|
4
|
+
sim: DeterministicSim<S, I>;
|
|
5
|
+
layout: Layout<S>;
|
|
6
|
+
makeInput?: (raw: unknown) => I;
|
|
7
|
+
}
|
|
8
|
+
export declare function runSimWorker<S, I>(cfg: SimWorkerConfig<S, I>): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createControlView } from './controlBlock';
|
|
2
|
+
import { fixedStepBatch } from './simCore';
|
|
3
|
+
export function runSimWorker(cfg) {
|
|
4
|
+
const ctx = globalThis;
|
|
5
|
+
const inputQ = [];
|
|
6
|
+
let state = null;
|
|
7
|
+
let ctl = null;
|
|
8
|
+
let acc = 0;
|
|
9
|
+
const clock = { now: () => performance.now() };
|
|
10
|
+
const lastRef = { t: 0 };
|
|
11
|
+
let fps = 60;
|
|
12
|
+
let upsCount = 0;
|
|
13
|
+
let upsWinStart = 0;
|
|
14
|
+
const loop = () => {
|
|
15
|
+
if (state && ctl?.running) {
|
|
16
|
+
const before = ctl.tick;
|
|
17
|
+
acc = fixedStepBatch({
|
|
18
|
+
sim: cfg.sim,
|
|
19
|
+
state,
|
|
20
|
+
ctl,
|
|
21
|
+
nextInput: () => inputQ.shift(),
|
|
22
|
+
clock,
|
|
23
|
+
lastRef,
|
|
24
|
+
fps,
|
|
25
|
+
}, acc);
|
|
26
|
+
const stepped = ctl.tick - before;
|
|
27
|
+
upsCount += stepped;
|
|
28
|
+
const now = clock.now();
|
|
29
|
+
if (upsWinStart === 0)
|
|
30
|
+
upsWinStart = now;
|
|
31
|
+
else if (now - upsWinStart >= 500) {
|
|
32
|
+
ctl.ups = Math.round((upsCount * 1000) / (now - upsWinStart));
|
|
33
|
+
upsCount = 0;
|
|
34
|
+
upsWinStart = now;
|
|
35
|
+
}
|
|
36
|
+
if (stepped > 0)
|
|
37
|
+
ctx.postMessage({ t: 'hash', hash: cfg.sim.hash(state) });
|
|
38
|
+
}
|
|
39
|
+
setTimeout(loop, 1);
|
|
40
|
+
};
|
|
41
|
+
ctx.onmessage = (e) => {
|
|
42
|
+
const m = e.data;
|
|
43
|
+
if (m.t === 'init') {
|
|
44
|
+
state = cfg.layout.attach(m.dataBuf);
|
|
45
|
+
ctl = createControlView(m.ctlBuf, m.scalars ?? []);
|
|
46
|
+
fps = m.simFps ?? 60;
|
|
47
|
+
ctl.running = true;
|
|
48
|
+
lastRef.t = clock.now();
|
|
49
|
+
loop();
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
inputQ.push(cfg.makeInput ? cfg.makeInput(m.v) : m.v);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|