ugly-game 0.1.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.
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
4
+ function Stick({ cfg, backendRef }) {
5
+ const ref = React.useRef(null);
6
+ const [knob, setKnob] = React.useState({ x: 0, y: 0 });
7
+ const active = React.useRef(false);
8
+ const R = 52;
9
+ const move = (e) => {
10
+ if (!active.current)
11
+ return;
12
+ const el = ref.current;
13
+ if (!el)
14
+ return;
15
+ const r = el.getBoundingClientRect();
16
+ const dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
17
+ const nx = clamp(dx / R, -1, 1), ny = clamp(dy / R, -1, 1);
18
+ backendRef.current?.setTouchAxis(cfg.id, nx, ny);
19
+ setKnob({ x: nx * R, y: ny * R });
20
+ };
21
+ const end = (e) => { active.current = false; backendRef.current?.setTouchAxis(cfg.id, 0, 0); setKnob({ x: 0, y: 0 }); e.currentTarget.releasePointerCapture(e.pointerId); };
22
+ const sideStyle = cfg.side === 'left' ? { left: 22 } : { right: 22 };
23
+ return (_jsxs("div", { ref: ref, onPointerDown: (e) => { active.current = true; e.currentTarget.setPointerCapture(e.pointerId); move(e); }, onPointerMove: move, onPointerUp: end, onPointerCancel: end, style: { position: 'absolute', bottom: 22, ...sideStyle, width: R * 2, height: R * 2, borderRadius: '50%', background: 'rgba(120,150,190,.12)', border: '1px solid rgba(150,180,220,.4)', touchAction: 'none', userSelect: 'none' }, children: [_jsx("div", { style: { position: 'absolute', left: '50%', top: '50%', width: 46, height: 46, marginLeft: -23, marginTop: -23, transform: `translate(${knob.x}px,${knob.y}px)`, borderRadius: '50%', background: 'rgba(180,205,235,.5)' } }), cfg.label && _jsx("div", { style: { position: 'absolute', bottom: -18, width: '100%', textAlign: 'center', fontSize: 10, color: '#8fa2b5' }, children: cfg.label })] }));
24
+ }
25
+ function Btn({ cfg, backendRef, offset }) {
26
+ const [down, setDown] = React.useState(false);
27
+ const set = (v) => (e) => { setDown(v); backendRef.current?.setTouchButton(cfg.id, v); if (v)
28
+ e.currentTarget.setPointerCapture(e.pointerId); };
29
+ return (_jsx("div", { onPointerDown: set(true), onPointerUp: set(false), onPointerCancel: set(false), style: { position: 'absolute', bottom: 30 + offset * 62, right: 150, width: 56, height: 56, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, color: down ? '#04160c' : '#dbe5ef', background: down ? 'rgba(120,220,160,.85)' : 'rgba(120,150,190,.16)', border: '1px solid rgba(150,180,220,.4)', touchAction: 'none', userSelect: 'none' }, children: cfg.label }));
30
+ }
31
+ export function TouchControls({ backendRef, sticks = [], buttons = [] }) {
32
+ return (_jsx("div", { style: { position: 'absolute', inset: 0, pointerEvents: 'none' }, children: _jsxs("div", { style: { position: 'absolute', inset: 0, pointerEvents: 'auto' }, children: [sticks.map((s) => _jsx(Stick, { cfg: s, backendRef: backendRef }, s.id)), buttons.map((b, i) => _jsx(Btn, { cfg: b, backendRef: backendRef, offset: i }, b.id))] }) }));
33
+ }
@@ -0,0 +1,40 @@
1
+ import { type RawInputFrame } from './resolve';
2
+ export interface BrowserBackendOpts {
3
+ target: HTMLElement;
4
+ pointerLock?: boolean;
5
+ }
6
+ export declare class BrowserRawBackend {
7
+ private keysDown;
8
+ private edges;
9
+ private pdx;
10
+ private pdy;
11
+ private pwheel;
12
+ private ppx;
13
+ private ppy;
14
+ private pdragx;
15
+ private pdragy;
16
+ private ppinch;
17
+ private ptwist;
18
+ private pointerButtons;
19
+ private touches;
20
+ private gPrev;
21
+ private touchAxes;
22
+ private touchButtons;
23
+ private prevPadButtons;
24
+ private disposers;
25
+ private target;
26
+ private pointerLock;
27
+ private paused;
28
+ constructor(opts: BrowserBackendOpts);
29
+ setPaused(p: boolean): void;
30
+ private on;
31
+ private cursor;
32
+ private gesture;
33
+ private attach;
34
+ setTouchAxis(id: string, x: number, y: number): void;
35
+ clearTouchAxis(id: string): void;
36
+ setTouchButton(id: string, down: boolean): void;
37
+ private pollGamepads;
38
+ beginFrame(): RawInputFrame;
39
+ dispose(): void;
40
+ }
@@ -0,0 +1,205 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // BrowserRawBackend — the web-fallback of the `input.*` channel (design §9.1, §11).
3
+ // Samples keyboard + pointer (pointer-lock deltas, absolute cursor, drag/pan) + wheel +
4
+ // MULTI-TOUCH GESTURES (two-finger pan / pinch-zoom / twist-rotate) + Gamepad API +
5
+ // on-screen touch widgets into one RawInputFrame per render frame. CLIENT-ONLY (touches
6
+ // DOM/Gamepad) — deliberately OUTSIDE the determinism-linted shared/ set; its sole output
7
+ // is the plain RawInputFrame the pure resolver consumes.
8
+ //
9
+ // Hybrid sampling: event-driven ACCUMULATORS for sub-frame sources (keydown/up, pointer
10
+ // motion, wheel, gestures, edges), POLLED gamepads (read late in beginFrame()).
11
+ // • mouse: left/right/middle buttons; middle-drag pans; wheel zooms; click = edge.
12
+ // • touch: one finger = cursor (tap = place edge); two fingers = pan + pinch-zoom + twist.
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ import { emptyFrame } from './resolve';
15
+ export class BrowserRawBackend {
16
+ keysDown = new Set();
17
+ edges = new Set();
18
+ pdx = 0;
19
+ pdy = 0;
20
+ pwheel = 0; // lock look delta + wheel
21
+ ppx = 0.5;
22
+ ppy = 0.5; // absolute cursor 0..1
23
+ pdragx = 0;
24
+ pdragy = 0;
25
+ ppinch = 0;
26
+ ptwist = 0; // pan / pinch / twist
27
+ pointerButtons = new Set();
28
+ touches = new Map();
29
+ gPrev = null;
30
+ touchAxes = {};
31
+ touchButtons = new Set();
32
+ prevPadButtons = [];
33
+ disposers = [];
34
+ target;
35
+ pointerLock;
36
+ paused = false; // while a menu is open: suspend gameplay input + don't grab pointer lock
37
+ constructor(opts) { this.target = opts.target; this.pointerLock = opts.pointerLock ?? false; this.attach(); }
38
+ setPaused(p) { this.paused = p; }
39
+ on(el, type, fn, o) {
40
+ const handler = fn;
41
+ el.addEventListener(type, handler, o);
42
+ this.disposers.push(() => { el.removeEventListener(type, handler, o); });
43
+ }
44
+ cursor(clientX, clientY) {
45
+ const r = this.target.getBoundingClientRect();
46
+ this.ppx = Math.max(0, Math.min(1, (clientX - r.left) / Math.max(1, r.width)));
47
+ this.ppy = Math.max(0, Math.min(1, (clientY - r.top) / Math.max(1, r.height)));
48
+ }
49
+ gesture() {
50
+ const [a, b] = [...this.touches.values()];
51
+ return { cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, dist: Math.hypot(b.x - a.x, b.y - a.y), ang: Math.atan2(b.y - a.y, b.x - a.x) };
52
+ }
53
+ attach() {
54
+ this.on(window, 'keydown', (e) => { if (!this.keysDown.has(e.code))
55
+ this.edges.add('key:' + e.code); this.keysDown.add(e.code); });
56
+ this.on(window, 'keyup', (e) => { this.keysDown.delete(e.code); });
57
+ this.on(window, 'blur', () => { this.keysDown.clear(); this.touches.clear(); this.pointerButtons.clear(); });
58
+ this.on(this.target, 'wheel', (e) => { this.pwheel += e.deltaY; e.preventDefault(); }, { passive: false });
59
+ this.on(this.target, 'contextmenu', (e) => { e.preventDefault(); });
60
+ this.on(this.target, 'pointerdown', (e) => {
61
+ if (this.paused)
62
+ return; // menu open → let clicks reach the UI, don't grab pointer lock
63
+ this.cursor(e.clientX, e.clientY);
64
+ const el = this.target;
65
+ if (e.pointerType === 'touch') {
66
+ this.touches.set(e.pointerId, { x: e.clientX, y: e.clientY, moved: 0 });
67
+ this.gPrev = this.touches.size === 2 ? this.gesture() : null;
68
+ if (!document.pointerLockElement)
69
+ el.setPointerCapture(e.pointerId); // track gestures that leave the canvas
70
+ }
71
+ else {
72
+ if (!this.pointerButtons.has(e.button))
73
+ this.edges.add(`ptr:${e.button}`); // mouse: immediate click edge
74
+ this.pointerButtons.add(e.button);
75
+ // Pointer LOCK and pointer CAPTURE are mutually-exclusive ways to route pointer input. Calling capture
76
+ // while lock is engaged/pending throws InvalidStateError (Firefox then never locks → no mouselook;
77
+ // Chrome logs it on every click while locked). So when the game wants mouselook use ONLY pointer lock;
78
+ // otherwise capture so a drag keeps tracking off-canvas. `document.pointerLockElement` is the guard that
79
+ // makes capture valid by construction — no try/catch needed.
80
+ if (this.pointerLock) {
81
+ if (!document.pointerLockElement)
82
+ void Promise.resolve(el.requestPointerLock()).catch(() => { });
83
+ }
84
+ else if (!document.pointerLockElement) {
85
+ el.setPointerCapture(e.pointerId);
86
+ }
87
+ }
88
+ });
89
+ this.on(window, 'pointermove', (e) => {
90
+ if (document.pointerLockElement) {
91
+ this.pdx += e.movementX;
92
+ this.pdy += e.movementY;
93
+ } // mouselook whenever locked (any mode)
94
+ if (e.pointerType === 'touch') {
95
+ const t = this.touches.get(e.pointerId);
96
+ if (!t)
97
+ return;
98
+ t.moved += Math.abs(e.movementX) + Math.abs(e.movementY);
99
+ t.x = e.clientX;
100
+ t.y = e.clientY;
101
+ if (this.touches.size === 2) {
102
+ const g = this.gesture();
103
+ this.cursor(g.cx, g.cy);
104
+ if (this.gPrev) {
105
+ const r = this.target.getBoundingClientRect();
106
+ this.pdragx += g.cx - this.gPrev.cx;
107
+ this.pdragy += g.cy - this.gPrev.cy;
108
+ this.ppinch += (g.dist - this.gPrev.dist) / Math.max(1, r.height);
109
+ let d = g.ang - this.gPrev.ang;
110
+ if (d > Math.PI)
111
+ d -= 2 * Math.PI;
112
+ if (d < -Math.PI)
113
+ d += 2 * Math.PI;
114
+ this.ptwist += d;
115
+ }
116
+ this.gPrev = g;
117
+ }
118
+ else {
119
+ this.cursor(e.clientX, e.clientY);
120
+ } // one finger = cursor only
121
+ }
122
+ else {
123
+ this.cursor(e.clientX, e.clientY);
124
+ if (this.pointerButtons.has(1)) {
125
+ this.pdragx += e.movementX;
126
+ this.pdragy += e.movementY;
127
+ } // middle-drag pans
128
+ }
129
+ });
130
+ this.on(window, 'pointerup', (e) => {
131
+ if (e.pointerType === 'touch') {
132
+ const t = this.touches.get(e.pointerId);
133
+ if (t && this.touches.size === 1 && t.moved < 10)
134
+ this.edges.add('ptr:0'); // single-finger tap = place
135
+ this.touches.delete(e.pointerId);
136
+ this.gPrev = this.touches.size === 2 ? this.gesture() : null;
137
+ }
138
+ else
139
+ this.pointerButtons.delete(e.button);
140
+ });
141
+ }
142
+ setTouchAxis(id, x, y) { this.touchAxes[id] = { x, y }; }
143
+ clearTouchAxis(id) { this.touchAxes[id] = { x: 0, y: 0 }; }
144
+ setTouchButton(id, down) {
145
+ if (down) {
146
+ if (!this.touchButtons.has(id))
147
+ this.edges.add('touch:' + id);
148
+ this.touchButtons.add(id);
149
+ }
150
+ else
151
+ this.touchButtons.delete(id);
152
+ }
153
+ pollGamepads() {
154
+ const pads = [];
155
+ const edges = [];
156
+ const src = navigator.getGamepads();
157
+ for (let i = 0; i < src.length; i++) {
158
+ const gp = src[i];
159
+ if (!gp)
160
+ continue;
161
+ pads[i] = { axes: gp.axes.slice(), buttons: gp.buttons.map((b) => b.value) };
162
+ const prev = this.prevPadButtons[i] ?? [];
163
+ for (let b = 0; b < gp.buttons.length; b++)
164
+ if (gp.buttons[b].pressed && !prev[b])
165
+ edges.push(`gp${i}:b${b}`);
166
+ this.prevPadButtons[i] = gp.buttons.map((b) => b.pressed);
167
+ }
168
+ return { pads, edges };
169
+ }
170
+ beginFrame() {
171
+ if (this.paused) {
172
+ this.edges.clear();
173
+ this.pdx = 0;
174
+ this.pdy = 0;
175
+ this.pwheel = 0;
176
+ this.pdragx = 0;
177
+ this.pdragy = 0;
178
+ this.ppinch = 0;
179
+ this.ptwist = 0;
180
+ return emptyFrame();
181
+ }
182
+ const { pads, edges: padEdges } = this.pollGamepads();
183
+ const frame = emptyFrame();
184
+ frame.keysDown = new Set(this.keysDown);
185
+ frame.pressedEdges = new Set(this.edges);
186
+ for (const e of padEdges)
187
+ frame.pressedEdges.add(e);
188
+ frame.pointer = { dx: this.pdx, dy: this.pdy, wheel: this.pwheel, px: this.ppx, py: this.ppy, dragx: this.pdragx, dragy: this.pdragy, pinch: this.ppinch, twist: this.ptwist };
189
+ frame.pointerButtons = new Set(this.pointerButtons);
190
+ frame.touchAxes = { ...this.touchAxes };
191
+ frame.touchButtons = new Set(this.touchButtons);
192
+ frame.gamepads = pads;
193
+ this.edges.clear();
194
+ this.pdx = 0;
195
+ this.pdy = 0;
196
+ this.pwheel = 0;
197
+ this.pdragx = 0;
198
+ this.pdragy = 0;
199
+ this.ppinch = 0;
200
+ this.ptwist = 0;
201
+ return frame;
202
+ }
203
+ dispose() { for (const d of this.disposers)
204
+ d(); this.disposers = []; }
205
+ }
@@ -0,0 +1,98 @@
1
+ export interface RawGamepad {
2
+ axes: number[];
3
+ buttons: number[];
4
+ }
5
+ export interface RawInputFrame {
6
+ keysDown: Set<string>;
7
+ pressedEdges: Set<string>;
8
+ pointer: {
9
+ dx: number;
10
+ dy: number;
11
+ wheel: number;
12
+ px: number;
13
+ py: number;
14
+ dragx: number;
15
+ dragy: number;
16
+ pinch: number;
17
+ twist: number;
18
+ };
19
+ pointerButtons: Set<number>;
20
+ touchAxes: Record<string, {
21
+ x: number;
22
+ y: number;
23
+ }>;
24
+ touchButtons: Set<string>;
25
+ gamepads: RawGamepad[];
26
+ }
27
+ export declare function emptyFrame(): RawInputFrame;
28
+ export type RawControlRef = {
29
+ dev: 'key';
30
+ code: string;
31
+ } | {
32
+ dev: 'gpAxis';
33
+ pad?: number;
34
+ axis: number;
35
+ } | {
36
+ dev: 'gpButton';
37
+ pad?: number;
38
+ button: number;
39
+ } | {
40
+ dev: 'pointer';
41
+ axis: 'dx' | 'dy' | 'wheel' | 'px' | 'py' | 'dragx' | 'dragy' | 'pinch' | 'twist';
42
+ } | {
43
+ dev: 'pointerButton';
44
+ button: number;
45
+ } | {
46
+ dev: 'touchAxis';
47
+ id: string;
48
+ comp: 'x' | 'y';
49
+ } | {
50
+ dev: 'touchButton';
51
+ id: string;
52
+ };
53
+ export interface Binding {
54
+ field: string;
55
+ src: RawControlRef;
56
+ sign?: 1 | -1;
57
+ scale?: number;
58
+ deadzone?: number;
59
+ }
60
+ export type Carry = 'level' | 'edge' | 'accumulate';
61
+ export interface FieldSpec {
62
+ carry: Carry;
63
+ range?: [number, number];
64
+ }
65
+ export interface InputMode<I> {
66
+ id: string;
67
+ base: I;
68
+ fields: Record<string, FieldSpec>;
69
+ bindings: Binding[];
70
+ }
71
+ export declare class InputDriver<I extends object> {
72
+ private mode;
73
+ private byField;
74
+ private level;
75
+ private pendEdge;
76
+ private pendAcc;
77
+ constructor(mode: InputMode<I>);
78
+ beginFrame(raw: RawInputFrame): void;
79
+ inputForSubstep(sub: number): I;
80
+ endFrame(consumedSubsteps: number): void;
81
+ }
82
+ export declare function edgeControlId(ref: RawControlRef): string;
83
+ export declare function extendMode<I>(base: InputMode<I>, ext: {
84
+ fields?: Record<string, FieldSpec>;
85
+ bindings?: Binding[];
86
+ }): InputMode<I>;
87
+ export type Device = 'desktop' | 'gamepad' | 'touch';
88
+ export interface ControlDesc {
89
+ field: string;
90
+ carry: Carry;
91
+ range?: [number, number] | undefined;
92
+ label: string;
93
+ bindings: RawControlRef[];
94
+ }
95
+ /** Machine-readable control descriptor for a mode: each semantic field, how it's driven on every device, its
96
+ * carry/range, and a human label. Feeds both the on-screen ControlHints and window.__game.controls (so the
97
+ * IR/AI harness knows every action the game exposes and how to drive it). */
98
+ export declare function describeControls(mode: InputMode<unknown>, labels?: Record<string, string>, order?: string[]): ControlDesc[];
@@ -0,0 +1,141 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // ugly-game control system — the device-agnostic resolver (Tier 2/3).
3
+ //
4
+ // Turns a per-frame RawInputFrame (every device this frame) into the game's
5
+ // serializable per-tick `Input` via a declared InputMode (bindings + carry policy).
6
+ // PURE and determinism-safe: uses only abs/min/max (never sin/cos/random/Date), so
7
+ // it may live in shared/. Its ONLY output is the plain `Input` the sim/IR record
8
+ // verbatim — the determinism firewall (see design/input-system.md §2). Device I/O
9
+ // (DOM/Gamepad sampling) lives in client/input/browserBackend.ts, NOT here.
10
+ //
11
+ // The recorded artifact is the RESOLVED semantic value, never raw events — so a
12
+ // replay is device-agnostic: `mv` produced from a W key and from a gamepad stick
13
+ // record and replay identically. That is what makes clean capture + re-test work.
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ export function emptyFrame() {
16
+ return { keysDown: new Set(), pressedEdges: new Set(), pointer: { dx: 0, dy: 0, wheel: 0, px: 0.5, py: 0.5, dragx: 0, dragy: 0, pinch: 0, twist: 0 }, pointerButtons: new Set(), touchAxes: {}, touchButtons: new Set(), gamepads: [] };
17
+ }
18
+ // ── Raw reads ────────────────────────────────────────────────────────────────
19
+ function controlId(ref) {
20
+ switch (ref.dev) {
21
+ case 'key': return 'key:' + ref.code;
22
+ case 'gpButton': return `gp${ref.pad ?? 0}:b${ref.button}`;
23
+ case 'pointerButton': return `ptr:${ref.button}`;
24
+ case 'touchButton': return 'touch:' + ref.id;
25
+ default: return ''; // axes/pointer-motion have no edge id
26
+ }
27
+ }
28
+ function readLevel(raw, ref) {
29
+ switch (ref.dev) {
30
+ case 'key': return raw.keysDown.has(ref.code) ? 1 : 0;
31
+ case 'gpAxis': return raw.gamepads[ref.pad ?? 0]?.axes[ref.axis] ?? 0;
32
+ case 'gpButton': return raw.gamepads[ref.pad ?? 0]?.buttons[ref.button] ?? 0;
33
+ case 'pointer': return raw.pointer[ref.axis];
34
+ case 'pointerButton': return raw.pointerButtons.has(ref.button) ? 1 : 0;
35
+ case 'touchAxis': return raw.touchAxes[ref.id]?.[ref.comp] ?? 0;
36
+ case 'touchButton': return raw.touchButtons.has(ref.id) ? 1 : 0;
37
+ }
38
+ }
39
+ function proc(v, b) {
40
+ if (b.deadzone && Math.abs(v) < b.deadzone)
41
+ v = 0;
42
+ return v * (b.scale ?? 1) * (b.sign ?? 1);
43
+ }
44
+ function clamp(v, r) {
45
+ if (!r)
46
+ return v;
47
+ return v < r[0] ? r[0] : v > r[1] ? r[1] : v;
48
+ }
49
+ function bindingsByField(bindings) {
50
+ const m = new Map();
51
+ for (const b of bindings) {
52
+ const list = m.get(b.field) ?? [];
53
+ list.push(b);
54
+ m.set(b.field, list);
55
+ }
56
+ return m;
57
+ }
58
+ // ── The driver: RawInputFrame → per-tick Input, carry-policy correct ─────────
59
+ //
60
+ // beginFrame(raw) once per RENDER frame; inputForSubstep(sub) per FIXED sub-step
61
+ // (sub 0 carries edges + accumulated deltas, sub>0 gets level-only); endFrame(n)
62
+ // clears consumed edges/deltas. Edges/deltas PERSIST across a 0-sub-step frame
63
+ // (a fast click/flick between ticks is never dropped) and never double-apply
64
+ // across ≥2 sub-steps — the two rules PlayPage hand-rolls (design §9.3).
65
+ export class InputDriver {
66
+ mode;
67
+ byField;
68
+ level = {};
69
+ pendEdge = {};
70
+ pendAcc = {};
71
+ constructor(mode) {
72
+ this.mode = mode;
73
+ this.byField = bindingsByField(mode.bindings);
74
+ }
75
+ beginFrame(raw) {
76
+ this.level = {};
77
+ for (const field in this.mode.fields) {
78
+ const spec = this.mode.fields[field];
79
+ const binds = this.byField.get(field) ?? [];
80
+ if (spec.carry === 'level') {
81
+ let v = 0;
82
+ for (const b of binds)
83
+ v += proc(readLevel(raw, b.src), b);
84
+ this.level[field] = clamp(v, spec.range);
85
+ }
86
+ else if (spec.carry === 'edge') {
87
+ for (const b of binds)
88
+ if (raw.pressedEdges.has(controlId(b.src)))
89
+ this.pendEdge[field] = 1;
90
+ }
91
+ else { // accumulate
92
+ let v = 0;
93
+ for (const b of binds)
94
+ v += proc(readLevel(raw, b.src), b);
95
+ if (v !== 0)
96
+ this.pendAcc[field] = (this.pendAcc[field] ?? 0) + v;
97
+ }
98
+ }
99
+ }
100
+ inputForSubstep(sub) {
101
+ const inp = { ...this.mode.base };
102
+ for (const field in this.mode.fields) {
103
+ const spec = this.mode.fields[field];
104
+ let val;
105
+ if (spec.carry === 'level')
106
+ val = this.level[field] ?? 0;
107
+ else if (spec.carry === 'edge')
108
+ val = sub === 0 ? (this.pendEdge[field] ? 1 : 0) : 0;
109
+ else
110
+ val = sub === 0 ? (this.pendAcc[field] ?? 0) : 0;
111
+ inp[field] = val;
112
+ }
113
+ return inp;
114
+ }
115
+ endFrame(consumedSubsteps) {
116
+ if (consumedSubsteps > 0) {
117
+ this.pendEdge = {};
118
+ this.pendAcc = {};
119
+ }
120
+ }
121
+ }
122
+ // Canonical control ids a backend must emit into `pressedEdges` for edge fields.
123
+ export function edgeControlId(ref) { return controlId(ref); }
124
+ // ── Control registry (controls are DERIVED from bindings) ────────────────────
125
+ // A game "registers" custom actions by extending a base mode with extra fields + bindings. The on-screen
126
+ // control hints AND the IR/AI descriptor both read the resulting bindings, so a new binding (e.g. G → flip)
127
+ // shows up in the UI on every device AND tells the harness a new semantic field exists — one source of
128
+ // truth, no hand-maintained control lists.
129
+ export function extendMode(base, ext) {
130
+ return { ...base, fields: { ...base.fields, ...(ext.fields ?? {}) }, bindings: [...base.bindings, ...(ext.bindings ?? [])] };
131
+ }
132
+ /** Machine-readable control descriptor for a mode: each semantic field, how it's driven on every device, its
133
+ * carry/range, and a human label. Feeds both the on-screen ControlHints and window.__game.controls (so the
134
+ * IR/AI harness knows every action the game exposes and how to drive it). */
135
+ export function describeControls(mode, labels = {}, order) {
136
+ const fields = order ?? Object.keys(mode.fields);
137
+ return fields.filter((f) => mode.fields[f]).map((f) => ({
138
+ field: f, carry: mode.fields[f].carry, range: mode.fields[f].range, label: labels[f] ?? f,
139
+ bindings: mode.bindings.filter((b) => b.field === f).map((b) => b.src),
140
+ }));
141
+ }
package/dist/mat4.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export type Mat4 = Float64Array;
2
+ export type Vec3 = readonly [number, number, number];
3
+ export declare function perspective(fovY: number, aspect: number, near: number, far: number): Mat4;
4
+ export declare function lookAt(eye: Vec3, target: Vec3, up: Vec3): Mat4;
5
+ export declare function mul(a: Mat4, b: Mat4): Mat4;
6
+ /** camera view-projection = perspective · lookAt (world → clip). */
7
+ export declare function viewProjection(eye: Vec3, target: Vec3, fovY: number, aspect: number, near?: number, far?: number): Mat4;
8
+ /** Orthographic projection, WebGPU clip z ∈ [0,1] (for shadow-map light views). */
9
+ export declare function ortho(l: number, r: number, b: number, t: number, n: number, f: number): Mat4;
10
+ /** A directional light's view-projection: an ortho box `extent` wide, `depth` deep,
11
+ * looking along `dir` at the scene `center`. World → light clip (for shadow mapping). */
12
+ export declare function lightViewProjection(dir: Vec3, center: Vec3, extent: number, depth: number): Mat4;
13
+ export type Plane = readonly [number, number, number, number];
14
+ /** Extract + normalize the 6 frustum planes from a column-major view-proj (Gribb-Hartmann). */
15
+ export declare function frustumPlanes(m: Mat4): Plane[];
16
+ /** Sphere vs frustum: false only if the sphere is fully outside any plane. */
17
+ export declare function sphereVisible(planes: Plane[], x: number, y: number, z: number, radius: number): boolean;
package/dist/mat4.js ADDED
@@ -0,0 +1,99 @@
1
+ // Minimal column-major mat4 + frustum extraction for the render IR's culling. Uses
2
+ // dmath (deterministic) for trig so the CPU cull reference is bit-stable and can be a
3
+ // test oracle for the GPU compute-cull backend. Only what the render IR needs.
4
+ import { dtan } from './dmath';
5
+ export function perspective(fovY, aspect, near, far) {
6
+ const f = 1 / dtan(fovY / 2), nf = 1 / (near - far);
7
+ const m = new Float64Array(16);
8
+ m[0] = f / aspect;
9
+ m[5] = f;
10
+ m[10] = (far + near) * nf;
11
+ m[11] = -1;
12
+ m[14] = 2 * far * near * nf;
13
+ return m;
14
+ }
15
+ export function lookAt(eye, target, up) {
16
+ let zx = eye[0] - target[0], zy = eye[1] - target[1], zz = eye[2] - target[2];
17
+ let l = Math.sqrt(zx * zx + zy * zy + zz * zz) || 1;
18
+ zx /= l;
19
+ zy /= l;
20
+ zz /= l;
21
+ let xx = up[1] * zz - up[2] * zy, xy = up[2] * zx - up[0] * zz, xz = up[0] * zy - up[1] * zx;
22
+ l = Math.sqrt(xx * xx + xy * xy + xz * xz) || 1;
23
+ xx /= l;
24
+ xy /= l;
25
+ xz /= l;
26
+ const yx = zy * xz - zz * xy, yy = zz * xx - zx * xz, yz = zx * xy - zy * xx;
27
+ const m = new Float64Array(16);
28
+ m[0] = xx;
29
+ m[1] = yx;
30
+ m[2] = zx;
31
+ m[4] = xy;
32
+ m[5] = yy;
33
+ m[6] = zy;
34
+ m[8] = xz;
35
+ m[9] = yz;
36
+ m[10] = zz;
37
+ m[12] = -(xx * eye[0] + xy * eye[1] + xz * eye[2]);
38
+ m[13] = -(yx * eye[0] + yy * eye[1] + yz * eye[2]);
39
+ m[14] = -(zx * eye[0] + zy * eye[1] + zz * eye[2]);
40
+ m[15] = 1;
41
+ return m;
42
+ }
43
+ export function mul(a, b) {
44
+ const o = new Float64Array(16);
45
+ for (let c = 0; c < 4; c++)
46
+ for (let r = 0; r < 4; r++) {
47
+ let s = 0;
48
+ for (let k = 0; k < 4; k++)
49
+ s += a[k * 4 + r] * b[c * 4 + k];
50
+ o[c * 4 + r] = s;
51
+ }
52
+ return o;
53
+ }
54
+ /** camera view-projection = perspective · lookAt (world → clip). */
55
+ export function viewProjection(eye, target, fovY, aspect, near = 0.1, far = 1000) {
56
+ return mul(perspective(fovY, aspect, near, far), lookAt(eye, target, [0, 1, 0]));
57
+ }
58
+ /** Orthographic projection, WebGPU clip z ∈ [0,1] (for shadow-map light views). */
59
+ export function ortho(l, r, b, t, n, f) {
60
+ const m = new Float64Array(16);
61
+ m[0] = 2 / (r - l);
62
+ m[12] = -(r + l) / (r - l);
63
+ m[5] = 2 / (t - b);
64
+ m[13] = -(t + b) / (t - b);
65
+ m[10] = 1 / (n - f);
66
+ m[14] = n / (n - f);
67
+ m[15] = 1;
68
+ return m;
69
+ }
70
+ /** A directional light's view-projection: an ortho box `extent` wide, `depth` deep,
71
+ * looking along `dir` at the scene `center`. World → light clip (for shadow mapping). */
72
+ export function lightViewProjection(dir, center, extent, depth) {
73
+ let dx = dir[0], dy = dir[1], dz = dir[2];
74
+ const l = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
75
+ dx /= l;
76
+ dy /= l;
77
+ dz /= l;
78
+ const eye = [center[0] - dx * depth * 0.5, center[1] - dy * depth * 0.5, center[2] - dz * depth * 0.5];
79
+ const up = Math.abs(dy) > 0.99 ? [0, 0, 1] : [0, 1, 0];
80
+ return mul(ortho(-extent, extent, -extent, extent, 0.1, depth), lookAt(eye, center, up));
81
+ }
82
+ /** Extract + normalize the 6 frustum planes from a column-major view-proj (Gribb-Hartmann). */
83
+ export function frustumPlanes(m) {
84
+ const row = (r) => [m[r], m[4 + r], m[8 + r], m[12 + r]];
85
+ const r0 = row(0), r1 = row(1), r2 = row(2), r3 = row(3);
86
+ const combine = (a, b, sign) => {
87
+ const p = [a[0] + sign * b[0], a[1] + sign * b[1], a[2] + sign * b[2], a[3] + sign * b[3]];
88
+ const l = Math.sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]) || 1;
89
+ return [p[0] / l, p[1] / l, p[2] / l, p[3] / l];
90
+ };
91
+ return [combine(r3, r0, 1), combine(r3, r0, -1), combine(r3, r1, 1), combine(r3, r1, -1), combine(r3, r2, 1), combine(r3, r2, -1)];
92
+ }
93
+ /** Sphere vs frustum: false only if the sphere is fully outside any plane. */
94
+ export function sphereVisible(planes, x, y, z, radius) {
95
+ for (const p of planes)
96
+ if (p[0] * x + p[1] * y + p[2] * z + p[3] < -radius)
97
+ return false;
98
+ return true;
99
+ }