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
package/dist/dmath.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Deterministic sine. Quadrant reduction (round + subtract) → polynomial. Bit-identical
|
|
2
|
+
* across engines because it uses only +,-,*,/,Math.round (all IEEE/spec-exact). */
|
|
3
|
+
export declare function dsin(x: number): number;
|
|
4
|
+
/** Deterministic cosine. */
|
|
5
|
+
export declare function dcos(x: number): number;
|
|
6
|
+
/** Deterministic tangent. */
|
|
7
|
+
export declare function dtan(x: number): number;
|
|
8
|
+
/** Deterministic 2D length. Math.sqrt IS IEEE-correctly-rounded (identical everywhere),
|
|
9
|
+
* unlike Math.hypot whose overflow-avoidance algorithm varies by engine. For engine
|
|
10
|
+
* magnitudes (no overflow risk) sqrt(a²+b²) is exact and portable. */
|
|
11
|
+
export declare function dhypot(a: number, b: number): number;
|
|
12
|
+
export declare function dhypot3(a: number, b: number, c: number): number;
|
|
13
|
+
/** Deterministic atan2. Reduces to |ratio| ≤ 1 then a polynomial + quadrant fixup. */
|
|
14
|
+
export declare function datan2(y: number, x: number): number;
|
package/dist/dmath.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Deterministic math — the ONLY trig/hypot the engine sim may use.
|
|
3
|
+
//
|
|
4
|
+
// WHY: JS `Math.sin/cos/tan/atan2/hypot/exp/log/pow` are NOT bit-identical across JS
|
|
5
|
+
// engines (V8 vs SpiderMonkey vs JavaScriptCore) — they are not required by IEEE-754 to
|
|
6
|
+
// be correctly rounded, so each engine's approximation differs in the low bits. Proven
|
|
7
|
+
// live on /determinism: the pure-JS ECS diverged on Firefox-Android and Safari-iOS.
|
|
8
|
+
// That silently breaks cross-device replay + lockstep — the moat.
|
|
9
|
+
//
|
|
10
|
+
// FIX: implement the transcendentals from ONLY the operations IEEE-754 *does* mandate to
|
|
11
|
+
// be correctly rounded and therefore identical everywhere — `+ - * /`, `Math.sqrt`,
|
|
12
|
+
// `Math.round/floor/abs` (exact, spec-defined). The result is bit-identical on every
|
|
13
|
+
// engine. Enforced by the lint rule + `mathguard.test.mts` (no raw Math.* trig in engine
|
|
14
|
+
// source). See design/engine-build-plan.md, GAME.md §5.1.
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
const PI = Math.PI, HALF_PI = PI / 2, INV_HALF_PI = 2 / PI;
|
|
17
|
+
// sin/cos on the reduced range [-π/4, π/4] via Taylor series (error < ~2e-9 there).
|
|
18
|
+
function sinPoly(r) { const r2 = r * r; return r * (1 + r2 * (-1 / 6 + r2 * (1 / 120 + r2 * (-1 / 5040 + r2 * (1 / 362880))))); }
|
|
19
|
+
function cosPoly(r) { const r2 = r * r; return 1 + r2 * (-1 / 2 + r2 * (1 / 24 + r2 * (-1 / 720 + r2 * (1 / 40320)))); }
|
|
20
|
+
/** Deterministic sine. Quadrant reduction (round + subtract) → polynomial. Bit-identical
|
|
21
|
+
* across engines because it uses only +,-,*,/,Math.round (all IEEE/spec-exact). */
|
|
22
|
+
export function dsin(x) {
|
|
23
|
+
const k = Math.round(x * INV_HALF_PI); // nearest π/2 quadrant (Math.round is spec-exact)
|
|
24
|
+
const r = x - k * HALF_PI; // r ∈ [-π/4, π/4]
|
|
25
|
+
const q = k & 3; // quadrant 0..3 (correct for negative k too)
|
|
26
|
+
return q === 0 ? sinPoly(r) : q === 1 ? cosPoly(r) : q === 2 ? -sinPoly(r) : -cosPoly(r);
|
|
27
|
+
}
|
|
28
|
+
/** Deterministic cosine. */
|
|
29
|
+
export function dcos(x) {
|
|
30
|
+
const k = Math.round(x * INV_HALF_PI);
|
|
31
|
+
const r = x - k * HALF_PI;
|
|
32
|
+
const q = k & 3;
|
|
33
|
+
return q === 0 ? cosPoly(r) : q === 1 ? -sinPoly(r) : q === 2 ? -cosPoly(r) : sinPoly(r);
|
|
34
|
+
}
|
|
35
|
+
/** Deterministic tangent. */
|
|
36
|
+
export function dtan(x) { return dsin(x) / dcos(x); }
|
|
37
|
+
/** Deterministic 2D length. Math.sqrt IS IEEE-correctly-rounded (identical everywhere),
|
|
38
|
+
* unlike Math.hypot whose overflow-avoidance algorithm varies by engine. For engine
|
|
39
|
+
* magnitudes (no overflow risk) sqrt(a²+b²) is exact and portable. */
|
|
40
|
+
export function dhypot(a, b) { return Math.sqrt(a * a + b * b); }
|
|
41
|
+
export function dhypot3(a, b, c) { return Math.sqrt(a * a + b * b + c * c); }
|
|
42
|
+
// atan on [-1,1] via a minimax polynomial (error < ~2e-6) — only +,-,*,/.
|
|
43
|
+
function atanUnit(z) { const z2 = z * z; return z * (0.9998660 + z2 * (-0.3302995 + z2 * (0.1801410 + z2 * (-0.0851330 + z2 * 0.0208351)))); }
|
|
44
|
+
/** Deterministic atan2. Reduces to |ratio| ≤ 1 then a polynomial + quadrant fixup. */
|
|
45
|
+
export function datan2(y, x) {
|
|
46
|
+
if (x === 0)
|
|
47
|
+
return y > 0 ? HALF_PI : y < 0 ? -HALF_PI : 0;
|
|
48
|
+
const ax = x < 0 ? -x : x, ay = y < 0 ? -y : y;
|
|
49
|
+
let a; // atan(min/max) ∈ [0, π/4]
|
|
50
|
+
if (ay <= ax)
|
|
51
|
+
a = atanUnit(ay / ax);
|
|
52
|
+
else
|
|
53
|
+
a = HALF_PI - atanUnit(ax / ay);
|
|
54
|
+
if (x < 0)
|
|
55
|
+
a = PI - a;
|
|
56
|
+
return y < 0 ? -a : a;
|
|
57
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** One cube instance: world position, per-axis scale, a hue (< 0 ⇒ neutral ground), and an optional
|
|
2
|
+
* emissive flag (> 0 ⇒ the cube self-glows at full brightness, bypassing sun/shadow/AO — used for the
|
|
3
|
+
* place ghost, destroy marker, projectiles, and sparkles so effect cubes read as distinct from terrain). */
|
|
4
|
+
export interface CubeInstance {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
z: number;
|
|
8
|
+
sx: number;
|
|
9
|
+
sy: number;
|
|
10
|
+
sz: number;
|
|
11
|
+
hue: number;
|
|
12
|
+
emissive?: number;
|
|
13
|
+
rot?: [number, number, number, number];
|
|
14
|
+
}
|
|
15
|
+
/** The 36-vertex cube as WGSL array-literal bodies (pos + per-face normal). Shared by shadow modules. */
|
|
16
|
+
export declare const CUBE: {
|
|
17
|
+
pos: string;
|
|
18
|
+
nrm: string;
|
|
19
|
+
};
|
|
20
|
+
export declare class GpuShadowRenderer {
|
|
21
|
+
private device;
|
|
22
|
+
private instBuf;
|
|
23
|
+
private camBuf;
|
|
24
|
+
private shadowTex;
|
|
25
|
+
private shadowView;
|
|
26
|
+
private shadowPipe;
|
|
27
|
+
private mainPipe;
|
|
28
|
+
private shadowBG;
|
|
29
|
+
private mainBG;
|
|
30
|
+
private shadowBGL;
|
|
31
|
+
private mainBGL;
|
|
32
|
+
private shadowSampler;
|
|
33
|
+
private count;
|
|
34
|
+
private cam;
|
|
35
|
+
private alphaPipe;
|
|
36
|
+
private overlayBuf;
|
|
37
|
+
private overlayBG;
|
|
38
|
+
private overlayScratch;
|
|
39
|
+
private overlayCount;
|
|
40
|
+
constructor(device: GPUDevice, format: GPUTextureFormat);
|
|
41
|
+
private scratch;
|
|
42
|
+
private fill;
|
|
43
|
+
private makeBindGroups;
|
|
44
|
+
/** Static scene: sizes the buffer to exactly these instances. */
|
|
45
|
+
setScene(instances: CubeInstance[]): void;
|
|
46
|
+
/** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
|
|
47
|
+
setCapacity(cap: number): void;
|
|
48
|
+
updateInstances(instances: CubeInstance[]): void;
|
|
49
|
+
/** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
|
|
50
|
+
updateOverlay(instances: CubeInstance[]): void;
|
|
51
|
+
/** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
|
|
52
|
+
frame(enc: GPUCommandEncoder, colorView: GPUTextureView, sceneDepth: GPUTextureView, camVP: Float32Array, lightVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], ambient: number, load?: boolean): void;
|
|
53
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Render IR increment 5 — directional (sun) SHADOW MAPPING. The next rung of the
|
|
2
|
+
// shading program: real cast shadows, the single biggest look upgrade after lighting.
|
|
3
|
+
// Shadows need real depth from the light's POV, so geometry is instanced CUBES (impostors
|
|
4
|
+
// are view-dependent and can't cast a correct shadow from a different view) on a ground
|
|
5
|
+
// plane. Two passes: (1) render occluder depth from the sun's ortho view into a shadow
|
|
6
|
+
// map; (2) main camera pass samples that map (PCF 3×3) to decide lit vs. shadowed.
|
|
7
|
+
//
|
|
8
|
+
// Everything is a cube instance — including the ground (a big flat cube) — so one geometry
|
|
9
|
+
// and one vertex path serve both passes. Per-instance non-uniform scale (vec3) lets the
|
|
10
|
+
// ground be flat while props are boxy.
|
|
11
|
+
const SHADOW_SIZE = 2048;
|
|
12
|
+
const OVERLAY_CAP = 512; // max translucent overlay cubes per frame (the place ghost + headroom)
|
|
13
|
+
// Build the 36-vertex cube (6 faces × 2 tris) in JS and inject as WGSL const arrays —
|
|
14
|
+
// bulletproof + readable vs. bit-twiddling from vertex_index. cullMode is 'none', so winding
|
|
15
|
+
// doesn't matter. Positions in [-0.5, 0.5]; flat per-face normals.
|
|
16
|
+
function buildCube() {
|
|
17
|
+
const faces = [
|
|
18
|
+
{ n: [1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] },
|
|
19
|
+
{ n: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] },
|
|
20
|
+
{ n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, 1] },
|
|
21
|
+
{ n: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] },
|
|
22
|
+
{ n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
|
|
23
|
+
{ n: [0, 0, -1], u: [1, 0, 0], v: [0, 1, 0] },
|
|
24
|
+
];
|
|
25
|
+
const corners = [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]];
|
|
26
|
+
const P = [], Nn = [];
|
|
27
|
+
for (const f of faces)
|
|
28
|
+
for (const [cu, cv] of corners) {
|
|
29
|
+
const x = f.n[0] * 0.5 + f.u[0] * cu + f.v[0] * cv;
|
|
30
|
+
const y = f.n[1] * 0.5 + f.u[1] * cu + f.v[1] * cv;
|
|
31
|
+
const z = f.n[2] * 0.5 + f.u[2] * cu + f.v[2] * cv;
|
|
32
|
+
P.push(`vec3<f32>(${x},${y},${z})`);
|
|
33
|
+
Nn.push(`vec3<f32>(${f.n[0]},${f.n[1]},${f.n[2]})`);
|
|
34
|
+
}
|
|
35
|
+
return { pos: P.join(', '), nrm: Nn.join(', ') };
|
|
36
|
+
}
|
|
37
|
+
/** The 36-vertex cube as WGSL array-literal bodies (pos + per-face normal). Shared by shadow modules. */
|
|
38
|
+
export const CUBE = buildCube();
|
|
39
|
+
// Shared vertex helpers + cube data. cam.sunCol.w carries the shadow-map texel size.
|
|
40
|
+
const COMMON_WGSL = /* wgsl */ `
|
|
41
|
+
struct Cam {
|
|
42
|
+
camVP: mat4x4<f32>,
|
|
43
|
+
lightVP: mat4x4<f32>,
|
|
44
|
+
sun: vec4<f32>, // xyz = direction light travels, w = ambient
|
|
45
|
+
sunCol: vec4<f32>, // xyz = sun colour, w = shadow map size (texels)
|
|
46
|
+
};
|
|
47
|
+
struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32> }; // pos.w = hue (<0 ⇒ ground) · scl.w = emissive/alpha · rot = quaternion
|
|
48
|
+
@group(0) @binding(0) var<uniform> cam: Cam;
|
|
49
|
+
@group(0) @binding(1) var<storage, read> insts: array<Inst>;
|
|
50
|
+
|
|
51
|
+
const CUBE_POS = array<vec3<f32>, 36>(${CUBE.pos});
|
|
52
|
+
const CUBE_NRM = array<vec3<f32>, 36>(${CUBE.nrm});
|
|
53
|
+
|
|
54
|
+
fn qrot(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> { let t = 2.0 * cross(q.xyz, v); return v + q.w * t + cross(q.xyz, t); }
|
|
55
|
+
fn worldOf(inst: Inst, vi: u32) -> vec3<f32> { return inst.pos.xyz + qrot(inst.rot, CUBE_POS[vi] * inst.scl.xyz); }
|
|
56
|
+
fn hsv(hh: f32) -> vec3<f32> {
|
|
57
|
+
let h = fract(hh) * 6.0;
|
|
58
|
+
let x = 1.0 - abs((h % 2.0) - 1.0);
|
|
59
|
+
if (h < 1.0) { return vec3(1.0, x, 0.0); } if (h < 2.0) { return vec3(x, 1.0, 0.0); }
|
|
60
|
+
if (h < 3.0) { return vec3(0.0, 1.0, x); } if (h < 4.0) { return vec3(0.0, x, 1.0); }
|
|
61
|
+
if (h < 5.0) { return vec3(x, 0.0, 1.0); } return vec3(1.0, 0.0, x);
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
const SHADOW_WGSL = COMMON_WGSL + /* wgsl */ `
|
|
65
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
|
|
66
|
+
return cam.lightVP * vec4(worldOf(insts[ii], vi), 1.0);
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
const MAIN_WGSL = COMMON_WGSL + /* wgsl */ `
|
|
70
|
+
@group(0) @binding(2) var shadowMap: texture_depth_2d;
|
|
71
|
+
@group(0) @binding(3) var shadowSamp: sampler_comparison;
|
|
72
|
+
|
|
73
|
+
struct VOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) hue: f32, @location(3) emis: f32 };
|
|
74
|
+
|
|
75
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VOut {
|
|
76
|
+
let inst = insts[ii];
|
|
77
|
+
let world = worldOf(inst, vi);
|
|
78
|
+
var o: VOut;
|
|
79
|
+
o.clip = cam.camVP * vec4(world, 1.0);
|
|
80
|
+
o.world = world;
|
|
81
|
+
o.nrm = normalize(qrot(inst.rot, CUBE_NRM[vi] / inst.scl.xyz)); // normal under non-uniform scale + rotation
|
|
82
|
+
o.hue = inst.pos.w;
|
|
83
|
+
o.emis = inst.scl.w;
|
|
84
|
+
return o;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// PCF 3×3 sun shadow. Returns 1 = fully lit, 0 = fully shadowed.
|
|
88
|
+
fn shadowFactor(world: vec3<f32>, ndl: f32) -> f32 {
|
|
89
|
+
let lp = cam.lightVP * vec4(world, 1.0);
|
|
90
|
+
let proj = lp.xyz / lp.w;
|
|
91
|
+
let uv = proj.xy * vec2(0.5, -0.5) + vec2(0.5, 0.5);
|
|
92
|
+
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || proj.z > 1.0 || proj.z < 0.0) { return 1.0; }
|
|
93
|
+
let bias = max(0.0006 * (1.0 - ndl), 0.0004); // slope-scaled, kills acne on grazing faces
|
|
94
|
+
let texel = 1.0 / cam.sunCol.w;
|
|
95
|
+
var s = 0.0;
|
|
96
|
+
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
|
97
|
+
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
|
98
|
+
s = s + textureSampleCompareLevel(shadowMap, shadowSamp, uv + vec2(f32(dx), f32(dy)) * texel, proj.z - bias);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return s / 9.0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Per-block edge darkening (baked AO) — mirrors shared/voxelShade.ts blockShade so a flat face shows its
|
|
105
|
+
// individual voxels instead of reading as one uniform slab. fx,fz ∈ [0,1) within the block face.
|
|
106
|
+
fn blockShade(fx: f32, fz: f32) -> f32 {
|
|
107
|
+
let edge = min(min(fx, 1.0 - fx), min(fz, 1.0 - fz));
|
|
108
|
+
let t = clamp(edge / 0.17, 0.0, 1.0);
|
|
109
|
+
return 0.6 + 0.4 * (t * t * (3.0 - 2.0 * t));
|
|
110
|
+
}
|
|
111
|
+
@fragment fn fs(i: VOut) -> @location(0) vec4<f32> {
|
|
112
|
+
if (i.emis > 0.5) { // self-glowing effect cube: full-bright, unlit
|
|
113
|
+
let g = mix(hsv(i.hue), vec3(1.0), 0.4);
|
|
114
|
+
return vec4(pow(clamp(g, vec3(0.0), vec3(1.0)), vec3(0.4545)), 1.0);
|
|
115
|
+
}
|
|
116
|
+
let N = normalize(i.nrm);
|
|
117
|
+
let ndl = max(dot(N, -normalize(cam.sun.xyz)), 0.0);
|
|
118
|
+
let base = select(mix(vec3(0.34), hsv(i.hue), 0.62), vec3(0.6, 0.62, 0.66), i.hue < 0.0);
|
|
119
|
+
let sh = shadowFactor(i.world, ndl);
|
|
120
|
+
// in-block coords = the two world axes not along the (axis-aligned) face normal
|
|
121
|
+
let an = abs(N);
|
|
122
|
+
var fx: f32; var fz: f32;
|
|
123
|
+
if (an.y >= an.x && an.y >= an.z) { fx = fract(i.world.x); fz = fract(i.world.z); }
|
|
124
|
+
else if (an.x >= an.z) { fx = fract(i.world.y); fz = fract(i.world.z); }
|
|
125
|
+
else { fx = fract(i.world.x); fz = fract(i.world.y); }
|
|
126
|
+
let ao = blockShade(fx, fz);
|
|
127
|
+
let lit = base * (cam.sun.w + cam.sunCol.xyz * ndl * sh) * ao;
|
|
128
|
+
return vec4(pow(clamp(lit, vec3(0.0), vec3(1.0)), vec3(0.4545)), 1.0); // gamma
|
|
129
|
+
}
|
|
130
|
+
`;
|
|
131
|
+
// Translucent OVERLAY pass — full-size, alpha-blended cubes drawn over the opaque scene with depth test but
|
|
132
|
+
// no depth write (so they don't occlude each other or the world behind). scl.w carries the ALPHA (0..1).
|
|
133
|
+
// Used for the place ghost: a full-size, low-opacity preview of the block that will materialize.
|
|
134
|
+
const ALPHA_WGSL = COMMON_WGSL + /* wgsl */ `
|
|
135
|
+
struct AOut { @builtin(position) clip: vec4<f32>, @location(0) hue: f32, @location(1) a: f32 };
|
|
136
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> AOut {
|
|
137
|
+
let inst = insts[ii];
|
|
138
|
+
var o: AOut;
|
|
139
|
+
o.clip = cam.camVP * vec4(worldOf(inst, vi), 1.0);
|
|
140
|
+
o.hue = inst.pos.w; o.a = inst.scl.w;
|
|
141
|
+
return o;
|
|
142
|
+
}
|
|
143
|
+
@fragment fn fs(i: AOut) -> @location(0) vec4<f32> {
|
|
144
|
+
let g = mix(hsv(i.hue), vec3(1.0), 0.28);
|
|
145
|
+
return vec4(pow(clamp(g, vec3(0.0), vec3(1.0)), vec3(0.4545)), i.a);
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
export class GpuShadowRenderer {
|
|
149
|
+
device;
|
|
150
|
+
instBuf;
|
|
151
|
+
camBuf;
|
|
152
|
+
shadowTex;
|
|
153
|
+
shadowView;
|
|
154
|
+
shadowPipe;
|
|
155
|
+
mainPipe;
|
|
156
|
+
shadowBG;
|
|
157
|
+
mainBG;
|
|
158
|
+
shadowBGL;
|
|
159
|
+
mainBGL;
|
|
160
|
+
shadowSampler;
|
|
161
|
+
count = 0;
|
|
162
|
+
cam = new Float32Array(40); // 2×mat4 + 2×vec4
|
|
163
|
+
alphaPipe;
|
|
164
|
+
overlayBuf;
|
|
165
|
+
overlayBG;
|
|
166
|
+
overlayScratch = new Float32Array(OVERLAY_CAP * 12);
|
|
167
|
+
overlayCount = 0;
|
|
168
|
+
constructor(device, format) {
|
|
169
|
+
this.device = device;
|
|
170
|
+
this.camBuf = device.createBuffer({ size: this.cam.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
171
|
+
this.shadowTex = device.createTexture({ size: [SHADOW_SIZE, SHADOW_SIZE], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
|
|
172
|
+
this.shadowView = this.shadowTex.createView();
|
|
173
|
+
const shadowMod = device.createShaderModule({ code: SHADOW_WGSL });
|
|
174
|
+
const mainMod = device.createShaderModule({ code: MAIN_WGSL });
|
|
175
|
+
const shadowBGL = device.createBindGroupLayout({ entries: [
|
|
176
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
|
|
177
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
|
178
|
+
] });
|
|
179
|
+
this.shadowPipe = device.createRenderPipeline({
|
|
180
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [shadowBGL] }),
|
|
181
|
+
vertex: { module: shadowMod, entryPoint: 'vs' },
|
|
182
|
+
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
|
183
|
+
depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' },
|
|
184
|
+
});
|
|
185
|
+
const mainBGL = device.createBindGroupLayout({ entries: [
|
|
186
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
|
187
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
|
188
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'depth' } },
|
|
189
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'comparison' } },
|
|
190
|
+
] });
|
|
191
|
+
this.mainPipe = device.createRenderPipeline({
|
|
192
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [mainBGL] }),
|
|
193
|
+
vertex: { module: mainMod, entryPoint: 'vs' },
|
|
194
|
+
fragment: { module: mainMod, entryPoint: 'fs', targets: [{ format }] },
|
|
195
|
+
primitive: { topology: 'triangle-list', cullMode: 'none' }, // cube winding isn't per-face outward; depth test handles overdraw
|
|
196
|
+
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
|
|
197
|
+
});
|
|
198
|
+
this.shadowBGL = shadowBGL;
|
|
199
|
+
this.mainBGL = mainBGL;
|
|
200
|
+
this.shadowSampler = device.createSampler({ compare: 'less', magFilter: 'linear', minFilter: 'linear' });
|
|
201
|
+
// translucent overlay: alpha-blended, depth-tested but not depth-writing (drawn after opaque)
|
|
202
|
+
const alphaMod = device.createShaderModule({ code: ALPHA_WGSL });
|
|
203
|
+
const alphaBGL = device.createBindGroupLayout({ entries: [
|
|
204
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
|
205
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
|
206
|
+
] });
|
|
207
|
+
this.alphaPipe = device.createRenderPipeline({
|
|
208
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [alphaBGL] }),
|
|
209
|
+
vertex: { module: alphaMod, entryPoint: 'vs' },
|
|
210
|
+
fragment: { module: alphaMod, entryPoint: 'fs', targets: [{ format, blend: {
|
|
211
|
+
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
212
|
+
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
|
|
213
|
+
} }] },
|
|
214
|
+
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
|
215
|
+
depthStencil: { format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'less' },
|
|
216
|
+
});
|
|
217
|
+
this.overlayBuf = device.createBuffer({ size: this.overlayScratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
218
|
+
this.overlayBG = device.createBindGroup({ layout: alphaBGL, entries: [
|
|
219
|
+
{ binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.overlayBuf } }
|
|
220
|
+
] });
|
|
221
|
+
}
|
|
222
|
+
scratch = new Float32Array(0);
|
|
223
|
+
fill(scratch, instances, n) {
|
|
224
|
+
for (let i = 0; i < n; i++) {
|
|
225
|
+
const c = instances[i], o = i * 12, r = c.rot;
|
|
226
|
+
scratch[o] = c.x;
|
|
227
|
+
scratch[o + 1] = c.y;
|
|
228
|
+
scratch[o + 2] = c.z;
|
|
229
|
+
scratch[o + 3] = c.hue;
|
|
230
|
+
scratch[o + 4] = c.sx;
|
|
231
|
+
scratch[o + 5] = c.sy;
|
|
232
|
+
scratch[o + 6] = c.sz;
|
|
233
|
+
scratch[o + 7] = c.emissive ?? 0;
|
|
234
|
+
scratch[o + 8] = r ? r[0] : 0;
|
|
235
|
+
scratch[o + 9] = r ? r[1] : 0;
|
|
236
|
+
scratch[o + 10] = r ? r[2] : 0;
|
|
237
|
+
scratch[o + 11] = r ? r[3] : 1; // quaternion, default identity
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
makeBindGroups() {
|
|
241
|
+
this.shadowBG = this.device.createBindGroup({ layout: this.shadowBGL, entries: [
|
|
242
|
+
{ binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.instBuf } },
|
|
243
|
+
] });
|
|
244
|
+
this.mainBG = this.device.createBindGroup({ layout: this.mainBGL, entries: [
|
|
245
|
+
{ binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.instBuf } },
|
|
246
|
+
{ binding: 2, resource: this.shadowView }, { binding: 3, resource: this.shadowSampler },
|
|
247
|
+
] });
|
|
248
|
+
}
|
|
249
|
+
/** Static scene: sizes the buffer to exactly these instances. */
|
|
250
|
+
setScene(instances) {
|
|
251
|
+
this.count = instances.length;
|
|
252
|
+
this.scratch = new Float32Array(this.count * 12);
|
|
253
|
+
this.fill(this.scratch, instances, this.count);
|
|
254
|
+
this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
255
|
+
this.device.queue.writeBuffer(this.instBuf, 0, this.scratch);
|
|
256
|
+
this.makeBindGroups();
|
|
257
|
+
}
|
|
258
|
+
/** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
|
|
259
|
+
setCapacity(cap) {
|
|
260
|
+
this.scratch = new Float32Array(cap * 12);
|
|
261
|
+
this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
262
|
+
this.count = 0;
|
|
263
|
+
this.makeBindGroups();
|
|
264
|
+
}
|
|
265
|
+
updateInstances(instances) {
|
|
266
|
+
const n = Math.min(instances.length, this.scratch.length / 12);
|
|
267
|
+
this.fill(this.scratch, instances, n);
|
|
268
|
+
this.device.queue.writeBuffer(this.instBuf, 0, this.scratch, 0, n * 12);
|
|
269
|
+
this.count = n;
|
|
270
|
+
}
|
|
271
|
+
/** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
|
|
272
|
+
updateOverlay(instances) {
|
|
273
|
+
const n = Math.min(instances.length, OVERLAY_CAP);
|
|
274
|
+
this.fill(this.overlayScratch, instances, n);
|
|
275
|
+
if (n > 0)
|
|
276
|
+
this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * 12);
|
|
277
|
+
this.overlayCount = n;
|
|
278
|
+
}
|
|
279
|
+
/** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
|
|
280
|
+
frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false) {
|
|
281
|
+
this.cam.set(camVP, 0);
|
|
282
|
+
this.cam.set(lightVP, 16);
|
|
283
|
+
this.cam[32] = sunDir[0];
|
|
284
|
+
this.cam[33] = sunDir[1];
|
|
285
|
+
this.cam[34] = sunDir[2];
|
|
286
|
+
this.cam[35] = ambient;
|
|
287
|
+
this.cam[36] = sunCol[0];
|
|
288
|
+
this.cam[37] = sunCol[1];
|
|
289
|
+
this.cam[38] = sunCol[2];
|
|
290
|
+
this.cam[39] = SHADOW_SIZE;
|
|
291
|
+
this.device.queue.writeBuffer(this.camBuf, 0, this.cam);
|
|
292
|
+
// Pass 1: occluder depth from the sun.
|
|
293
|
+
const sp = enc.beginRenderPass({ colorAttachments: [], depthStencilAttachment: {
|
|
294
|
+
view: this.shadowView, depthClearValue: 1.0, depthLoadOp: 'clear', depthStoreOp: 'store'
|
|
295
|
+
} });
|
|
296
|
+
sp.setPipeline(this.shadowPipe);
|
|
297
|
+
sp.setBindGroup(0, this.shadowBG);
|
|
298
|
+
sp.draw(36, this.count);
|
|
299
|
+
sp.end();
|
|
300
|
+
// Pass 2: camera, shadow-sampled.
|
|
301
|
+
const mp = enc.beginRenderPass({
|
|
302
|
+
colorAttachments: [{ view: colorView, ...(load ? { loadOp: 'load' } : { clearValue: { r: 0.04, g: 0.05, b: 0.07, a: 1 }, loadOp: 'clear' }), storeOp: 'store' }],
|
|
303
|
+
depthStencilAttachment: { view: sceneDepth, ...(load ? { depthLoadOp: 'load' } : { depthClearValue: 1.0, depthLoadOp: 'clear' }), depthStoreOp: 'store' }
|
|
304
|
+
});
|
|
305
|
+
mp.setPipeline(this.mainPipe);
|
|
306
|
+
mp.setBindGroup(0, this.mainBG);
|
|
307
|
+
mp.draw(36, this.count);
|
|
308
|
+
if (this.overlayCount > 0) {
|
|
309
|
+
mp.setPipeline(this.alphaPipe);
|
|
310
|
+
mp.setBindGroup(0, this.overlayBG);
|
|
311
|
+
mp.draw(36, this.overlayCount);
|
|
312
|
+
} // translucent overlay last
|
|
313
|
+
mp.end();
|
|
314
|
+
}
|
|
315
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './dmath';
|
|
2
|
+
export * from './mat4';
|
|
3
|
+
export * from './noise';
|
|
4
|
+
export * from './moat';
|
|
5
|
+
export * from './gpuShadow';
|
|
6
|
+
export * from './input/resolve';
|
|
7
|
+
export * from './input/browserBackend';
|
|
8
|
+
export * from './input/ControlHints';
|
|
9
|
+
export * from './input/TouchControls';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// ugly-game — the reusable game platform (renderer, input system, math, noise, deterministic moat).
|
|
2
|
+
// Games (all-mine-sky, the demo) depend on this package instead of copy-lifting these modules.
|
|
3
|
+
export * from './dmath';
|
|
4
|
+
export * from './mat4';
|
|
5
|
+
export * from './noise';
|
|
6
|
+
export * from './moat';
|
|
7
|
+
export * from './gpuShadow';
|
|
8
|
+
export * from './input/resolve';
|
|
9
|
+
export * from './input/browserBackend';
|
|
10
|
+
export * from './input/ControlHints';
|
|
11
|
+
export * from './input/TouchControls';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type Device, type ControlDesc, type InputMode } from './resolve';
|
|
3
|
+
export interface ControlHint {
|
|
4
|
+
keys: string[];
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
export type HintCorner = 'bottom' | 'bottom-left' | 'top-left';
|
|
8
|
+
/** Pretty label for a raw KeyboardEvent.code (KeyW→W, ArrowUp→↑, Space→Space, ShiftLeft→Shift, …). */
|
|
9
|
+
export declare function prettyKey(code: string): string;
|
|
10
|
+
/** Turn control descriptors into display hints for one device (drops fields with no binding on that device).
|
|
11
|
+
* Fields that share a label are MERGED into one hint (e.g. mv+strafe both "move" → one "W A S D move"). */
|
|
12
|
+
export declare function deviceHints(descs: ControlDesc[], device: Device): ControlHint[];
|
|
13
|
+
/** The device the player is currently using — updates live on keydown/mouse (desktop), touchstart (touch),
|
|
14
|
+
* or gamepad activity (gamepad). Last deliberate input wins, so the hints always match what's in hand. */
|
|
15
|
+
export declare function useActiveDevice(): Device;
|
|
16
|
+
type Props = {
|
|
17
|
+
corner?: HintCorner;
|
|
18
|
+
compact?: boolean;
|
|
19
|
+
showDevice?: boolean;
|
|
20
|
+
} & ({
|
|
21
|
+
hints: ControlHint[];
|
|
22
|
+
} | {
|
|
23
|
+
mode: InputMode<unknown>;
|
|
24
|
+
labels?: Record<string, string> | undefined;
|
|
25
|
+
order?: string[] | undefined;
|
|
26
|
+
});
|
|
27
|
+
/** The standardized overlay. Give it a `mode` (+labels) to derive device-aware hints, or an explicit list. */
|
|
28
|
+
export declare function ControlHints(props: Props): React.JSX.Element;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { describeControls } from './resolve';
|
|
4
|
+
/** Pretty label for a raw KeyboardEvent.code (KeyW→W, ArrowUp→↑, Space→Space, ShiftLeft→Shift, …). */
|
|
5
|
+
export function prettyKey(code) {
|
|
6
|
+
if (code.startsWith('Key'))
|
|
7
|
+
return code.slice(3);
|
|
8
|
+
if (code.startsWith('Digit'))
|
|
9
|
+
return code.slice(5);
|
|
10
|
+
const map = {
|
|
11
|
+
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
|
|
12
|
+
Space: 'Space', ShiftLeft: 'Shift', ShiftRight: 'Shift', ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
|
13
|
+
Enter: 'Enter', Escape: 'Esc', Tab: 'Tab',
|
|
14
|
+
};
|
|
15
|
+
return map[code] ?? code;
|
|
16
|
+
}
|
|
17
|
+
// W3C Standard Gamepad button/axis names.
|
|
18
|
+
const GP_BTN = { 0: 'A', 1: 'B', 2: 'X', 3: 'Y', 4: 'LB', 5: 'RB', 6: 'LT', 7: 'RT', 8: 'Back', 9: 'Start', 10: 'L3', 11: 'R3', 12: '↑', 13: '↓', 14: '←', 15: '→' };
|
|
19
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
20
|
+
/** The label to show for one binding on a given device, or null if that binding isn't for this device. */
|
|
21
|
+
function token(ref, device) {
|
|
22
|
+
if (device === 'desktop') {
|
|
23
|
+
if (ref.dev === 'key')
|
|
24
|
+
return prettyKey(ref.code);
|
|
25
|
+
if (ref.dev === 'pointer' || ref.dev === 'pointerButton')
|
|
26
|
+
return 'Mouse';
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (device === 'gamepad') {
|
|
30
|
+
if (ref.dev === 'gpButton')
|
|
31
|
+
return GP_BTN[ref.button] ?? `B${ref.button}`;
|
|
32
|
+
if (ref.dev === 'gpAxis')
|
|
33
|
+
return ref.axis < 2 ? 'L-Stick' : 'R-Stick';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (ref.dev === 'touchButton')
|
|
37
|
+
return ref.id.toUpperCase();
|
|
38
|
+
if (ref.dev === 'touchAxis')
|
|
39
|
+
return cap(ref.id) + ' pad';
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const uniq = (a) => [...new Set(a)];
|
|
43
|
+
/** Turn control descriptors into display hints for one device (drops fields with no binding on that device).
|
|
44
|
+
* Fields that share a label are MERGED into one hint (e.g. mv+strafe both "move" → one "W A S D move"). */
|
|
45
|
+
export function deviceHints(descs, device) {
|
|
46
|
+
const byLabel = new Map();
|
|
47
|
+
const order = [];
|
|
48
|
+
for (const d of descs) {
|
|
49
|
+
if (!d.label)
|
|
50
|
+
continue;
|
|
51
|
+
const keys = uniq(d.bindings.map((b) => token(b, device)).filter((x) => x !== null));
|
|
52
|
+
if (keys.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
if (!byLabel.has(d.label)) {
|
|
55
|
+
byLabel.set(d.label, []);
|
|
56
|
+
order.push(d.label);
|
|
57
|
+
}
|
|
58
|
+
const arr = byLabel.get(d.label);
|
|
59
|
+
for (const k of keys)
|
|
60
|
+
if (!arr.includes(k))
|
|
61
|
+
arr.push(k);
|
|
62
|
+
}
|
|
63
|
+
return order.map((label) => ({ keys: byLabel.get(label), label }));
|
|
64
|
+
}
|
|
65
|
+
/** The device the player is currently using — updates live on keydown/mouse (desktop), touchstart (touch),
|
|
66
|
+
* or gamepad activity (gamepad). Last deliberate input wins, so the hints always match what's in hand. */
|
|
67
|
+
export function useActiveDevice() {
|
|
68
|
+
const [dev, setDev] = React.useState(() => {
|
|
69
|
+
if (typeof navigator !== 'undefined' && navigator.getGamepads().some((p) => p))
|
|
70
|
+
return 'gamepad'; // a pad is already plugged in → show its controls
|
|
71
|
+
if (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0 && !(typeof matchMedia !== 'undefined' && matchMedia('(pointer:fine)').matches))
|
|
72
|
+
return 'touch';
|
|
73
|
+
return 'desktop';
|
|
74
|
+
});
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
const desktop = () => { setDev('desktop'); }, touch = () => { setDev('touch'); }, gamepad = () => { setDev('gamepad'); };
|
|
77
|
+
window.addEventListener('keydown', desktop);
|
|
78
|
+
window.addEventListener('mousedown', desktop);
|
|
79
|
+
window.addEventListener('touchstart', touch);
|
|
80
|
+
window.addEventListener('gamepadconnected', gamepad);
|
|
81
|
+
let raf = 0;
|
|
82
|
+
const poll = () => {
|
|
83
|
+
const pads = navigator.getGamepads();
|
|
84
|
+
for (const p of pads) {
|
|
85
|
+
if (p && (p.buttons.some((b) => b.pressed) || p.axes.some((a) => Math.abs(a) > 0.5))) {
|
|
86
|
+
setDev('gamepad');
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
raf = requestAnimationFrame(poll);
|
|
91
|
+
};
|
|
92
|
+
raf = requestAnimationFrame(poll);
|
|
93
|
+
return () => { window.removeEventListener('keydown', desktop); window.removeEventListener('mousedown', desktop); window.removeEventListener('touchstart', touch); window.removeEventListener('gamepadconnected', gamepad); cancelAnimationFrame(raf); };
|
|
94
|
+
}, []);
|
|
95
|
+
return dev;
|
|
96
|
+
}
|
|
97
|
+
const POS = {
|
|
98
|
+
'bottom': { bottom: 14, left: '50%', transform: 'translateX(-50%)' },
|
|
99
|
+
'bottom-left': { bottom: 14, left: 14 },
|
|
100
|
+
'top-left': { top: 14, left: 14 },
|
|
101
|
+
};
|
|
102
|
+
const DEVICE_LABEL = { desktop: 'keyboard', gamepad: 'gamepad', touch: 'touch' };
|
|
103
|
+
/** The standardized overlay. Give it a `mode` (+labels) to derive device-aware hints, or an explicit list. */
|
|
104
|
+
export function ControlHints(props) {
|
|
105
|
+
const device = useActiveDevice();
|
|
106
|
+
const hints = 'hints' in props ? props.hints : deviceHints(describeControls(props.mode, props.labels ?? {}, props.order), device);
|
|
107
|
+
const { corner = 'bottom', compact = false, showDevice = false } = props;
|
|
108
|
+
return (_jsxs("div", { style: { position: 'absolute', zIndex: 5, ...POS[corner], display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: compact ? 8 : 12, justifyContent: 'center',
|
|
109
|
+
maxWidth: '92%', padding: '7px 12px', borderRadius: 10, background: 'rgba(6,12,20,0.55)', backdropFilter: 'blur(3px)',
|
|
110
|
+
fontFamily: 'ui-monospace, Menlo, monospace', fontSize: compact ? 11 : 12.5, color: '#e6edf5', pointerEvents: 'none', userSelect: 'none' }, children: [showDevice && !('hints' in props) && _jsx("span", { style: { fontSize: 10, opacity: 0.55, textTransform: 'uppercase', letterSpacing: 0.5 }, children: DEVICE_LABEL[device] }), hints.map((h) => (_jsxs("span", { style: { display: 'inline-flex', alignItems: 'center', gap: 5, whiteSpace: 'nowrap' }, children: [h.keys.map((k) => _jsx("kbd", { style: { display: 'inline-block', minWidth: k.length > 1 ? undefined : 18, padding: '1px 6px', borderRadius: 5,
|
|
111
|
+
background: 'rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.28)', boxShadow: '0 1px 0 rgba(0,0,0,0.4)', fontWeight: 700, textAlign: 'center' }, children: k }, k)), _jsx("span", { style: { opacity: 0.85 }, children: h.label })] }, h.label)))] }));
|
|
112
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { BrowserRawBackend } from './browserBackend';
|
|
3
|
+
export interface StickCfg {
|
|
4
|
+
id: string;
|
|
5
|
+
side: 'left' | 'right';
|
|
6
|
+
label?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface BtnCfg {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function TouchControls({ backendRef, sticks, buttons }: {
|
|
13
|
+
backendRef: React.RefObject<BrowserRawBackend | null>;
|
|
14
|
+
sticks?: StickCfg[];
|
|
15
|
+
buttons?: BtnCfg[];
|
|
16
|
+
}): React.JSX.Element;
|