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.
- package/dist/dmath.d.ts +14 -0
- package/dist/dmath.js +57 -0
- package/dist/gpuShadow.d.ts +53 -0
- package/dist/gpuShadow.js +315 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +11 -0
- package/dist/input/ControlHints.d.ts +29 -0
- package/dist/input/ControlHints.js +112 -0
- package/dist/input/TouchControls.d.ts +16 -0
- package/dist/input/TouchControls.js +33 -0
- package/dist/input/browserBackend.d.ts +40 -0
- package/dist/input/browserBackend.js +205 -0
- package/dist/input/resolve.d.ts +98 -0
- package/dist/input/resolve.js +141 -0
- package/dist/mat4.d.ts +17 -0
- package/dist/mat4.js +99 -0
- package/dist/moat.d.ts +64 -0
- package/dist/moat.js +76 -0
- package/dist/noise.d.ts +35 -0
- package/dist/noise.js +91 -0
- package/package.json +10 -0
|
@@ -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
|
+
}
|