@jgengine/shell 0.36.0 → 0.38.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/CHANGELOG.md +147 -0
- package/dist/GamePlayerShell.js +9 -0
- package/dist/devtools/DevtoolsOverlay.js +34 -0
- package/dist/render/CascadedShadows.d.ts +28 -9
- package/dist/render/CascadedShadows.js +67 -20
- package/dist/render/SceneModels.d.ts +13 -0
- package/dist/render/SceneModels.js +38 -2
- package/dist/render/shadowTiers.d.ts +0 -10
- package/dist/render/shadowTiers.js +0 -17
- package/dist/render/singleVariantLod.d.ts +24 -0
- package/dist/render/singleVariantLod.js +33 -0
- package/dist/render/triangleAttribution.d.ts +40 -0
- package/dist/render/triangleAttribution.js +88 -0
- package/dist/scatter/InstancedScatter.d.ts +12 -0
- package/dist/scatter/InstancedScatter.js +83 -11
- package/dist/scatter/scatterVariants.d.ts +30 -0
- package/dist/scatter/scatterVariants.js +36 -0
- package/dist/world/StaticObjectModels.d.ts +18 -0
- package/dist/world/StaticObjectModels.js +101 -17
- package/dist/world/staticObjectBatches.d.ts +2 -2
- package/dist/world/staticObjectBatches.js +3 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -1
- package/skills/jgengine-ui/api.md +83 -0
- package/skills/jgengine-ui/capabilities.md +44 -0
- package/skills/jgengine-ui/reference.md +1 -1
- package/skills/jgengine-world/api.md +17 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/** One line of the triangle table: the draws sharing one geometry inside one named model. */
|
|
3
|
+
export interface TriangleLine {
|
|
4
|
+
/** Model ancestor names plus the mesh name, or the material name when nothing is named. */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Triangles one instance submits. */
|
|
7
|
+
perInstance: number;
|
|
8
|
+
/** Instances drawn from this geometry. */
|
|
9
|
+
instances: number;
|
|
10
|
+
/** Draw calls this line costs. */
|
|
11
|
+
draws: number;
|
|
12
|
+
/** `perInstance * instances` - the triangles this line puts in the frame. */
|
|
13
|
+
triangles: number;
|
|
14
|
+
}
|
|
15
|
+
/** Where one scene's triangles are, biggest line first. */
|
|
16
|
+
export interface TriangleAttribution {
|
|
17
|
+
/** Every triangle the scene graph would submit for one camera pass, instances included. */
|
|
18
|
+
triangles: number;
|
|
19
|
+
/** How many of those belong to meshes that cast shadows, so the shadow pass pays them again. */
|
|
20
|
+
casters: number;
|
|
21
|
+
lines: readonly TriangleLine[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Sum a scene's triangles by model and geometry, which is the attribution a triangle budget needs:
|
|
25
|
+
* a total says a frame is heavy, a table says which authored object made it heavy. Walks the graph
|
|
26
|
+
* once; call it on demand, never per frame.
|
|
27
|
+
* @capability triangle-attribution per-model triangle table with instance counts, for perf probes
|
|
28
|
+
*/
|
|
29
|
+
export declare function attributeTriangles(root: THREE.Object3D, limit?: number): TriangleAttribution;
|
|
30
|
+
/**
|
|
31
|
+
* The `render/triangles` probe for one scene: the table, its two headline numbers, and the one-line
|
|
32
|
+
* summary a report prints. Reads the graph on every call, so wrap it in a short cache when a
|
|
33
|
+
* snapshot reads counters and state together.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export declare function triangleProbe(root: THREE.Object3D): {
|
|
37
|
+
table(): TriangleAttribution;
|
|
38
|
+
counters(): Record<string, number>;
|
|
39
|
+
state(): string;
|
|
40
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
function triangleCount(mesh) {
|
|
3
|
+
if (mesh.isPoints === true)
|
|
4
|
+
return 0;
|
|
5
|
+
if (mesh.isLine === true)
|
|
6
|
+
return 0;
|
|
7
|
+
const geometry = mesh.geometry;
|
|
8
|
+
if (geometry === undefined || geometry === null)
|
|
9
|
+
return 0;
|
|
10
|
+
const index = geometry.getIndex();
|
|
11
|
+
const position = geometry.getAttribute("position");
|
|
12
|
+
const vertices = index !== null ? index.count : position?.count ?? 0;
|
|
13
|
+
return Math.round(vertices / 3);
|
|
14
|
+
}
|
|
15
|
+
const MAX_LABEL_DEPTH = 3;
|
|
16
|
+
function labelOf(mesh) {
|
|
17
|
+
const names = [];
|
|
18
|
+
let node = mesh;
|
|
19
|
+
for (let depth = 0; node !== null && node !== undefined && depth < MAX_LABEL_DEPTH + 1; depth += 1) {
|
|
20
|
+
if (typeof node.name === "string" && node.name.length > 0)
|
|
21
|
+
names.push(node.name);
|
|
22
|
+
node = node.parent;
|
|
23
|
+
}
|
|
24
|
+
if (names.length > 0)
|
|
25
|
+
return names.reverse().join("/");
|
|
26
|
+
const material = mesh.material;
|
|
27
|
+
const first = Array.isArray(material) ? material[0] : material;
|
|
28
|
+
return first?.name || first?.type || mesh.type;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Sum a scene's triangles by model and geometry, which is the attribution a triangle budget needs:
|
|
32
|
+
* a total says a frame is heavy, a table says which authored object made it heavy. Walks the graph
|
|
33
|
+
* once; call it on demand, never per frame.
|
|
34
|
+
* @capability triangle-attribution per-model triangle table with instance counts, for perf probes
|
|
35
|
+
*/
|
|
36
|
+
export function attributeTriangles(root, limit = 12) {
|
|
37
|
+
const lines = new Map();
|
|
38
|
+
let triangles = 0;
|
|
39
|
+
let casters = 0;
|
|
40
|
+
root.traverse((node) => {
|
|
41
|
+
const mesh = node;
|
|
42
|
+
const per = triangleCount(mesh);
|
|
43
|
+
if (per === 0)
|
|
44
|
+
return;
|
|
45
|
+
const instances = mesh.isInstancedMesh === true
|
|
46
|
+
? Math.max(0, mesh.count)
|
|
47
|
+
: 1;
|
|
48
|
+
const total = per * instances;
|
|
49
|
+
triangles += total;
|
|
50
|
+
if (mesh.castShadow === true)
|
|
51
|
+
casters += total;
|
|
52
|
+
const label = labelOf(mesh);
|
|
53
|
+
const key = `${label}#${mesh.geometry.uuid}`;
|
|
54
|
+
const line = lines.get(key);
|
|
55
|
+
if (line === undefined)
|
|
56
|
+
lines.set(key, { label, perInstance: per, instances, draws: 1, triangles: total });
|
|
57
|
+
else {
|
|
58
|
+
line.instances += instances;
|
|
59
|
+
line.draws += 1;
|
|
60
|
+
line.triangles += total;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const sorted = [...lines.values()].sort((a, b) => b.triangles - a.triangles);
|
|
64
|
+
return { triangles, casters, lines: limit > 0 ? sorted.slice(0, limit) : sorted };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The `render/triangles` probe for one scene: the table, its two headline numbers, and the one-line
|
|
68
|
+
* summary a report prints. Reads the graph on every call, so wrap it in a short cache when a
|
|
69
|
+
* snapshot reads counters and state together.
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
export function triangleProbe(root) {
|
|
73
|
+
const table = () => attributeTriangles(root);
|
|
74
|
+
return {
|
|
75
|
+
table,
|
|
76
|
+
counters: () => {
|
|
77
|
+
const { triangles, casters, lines } = table();
|
|
78
|
+
return { triangles, casters, lines: lines.length };
|
|
79
|
+
},
|
|
80
|
+
state: () => {
|
|
81
|
+
const { triangles, casters, lines } = table();
|
|
82
|
+
const top = lines[0];
|
|
83
|
+
return top === undefined
|
|
84
|
+
? `${triangles} triangles`
|
|
85
|
+
: `${triangles} triangles · casters ${casters} · top ${top.label} ${top.triangles}`;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
1
2
|
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
2
3
|
import { type ScatterInstance } from "@jgengine/core/world/scatterRegion";
|
|
4
|
+
/** One instanced draw unit - a single species within a single spatial chunk. @internal */
|
|
5
|
+
export interface ScatterBatch {
|
|
6
|
+
key: string;
|
|
7
|
+
item: string;
|
|
8
|
+
/** Per-instance transforms, parallel to `instances`. */
|
|
9
|
+
matrices: THREE.Matrix4[];
|
|
10
|
+
/** The placements themselves, so the level pass can read a stable id and a world position. */
|
|
11
|
+
instances: ScatterInstance[];
|
|
12
|
+
}
|
|
13
|
+
/** Groups resolved instances by spatial chunk then species so each batch is one cullable draw. @internal */
|
|
14
|
+
export declare function scatterBatchInstances(instances: readonly ScatterInstance[], chunkSize: number): ScatterBatch[];
|
|
3
15
|
/** Props for {@link InstancedScatter}: the resolved instances plus chunking/instance-cap knobs. */
|
|
4
16
|
export interface InstancedScatterProps {
|
|
5
17
|
instances: readonly ScatterInstance[];
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Suspense, useEffect, useMemo, useRef } from "react";
|
|
3
|
-
import { useLoader } from "@react-three/fiber";
|
|
2
|
+
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { useFrame, useLoader, useThree } from "@react-three/fiber";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
6
6
|
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
|
7
7
|
import { chunkScatterInstances } from "@jgengine/core/world/scatterRegion";
|
|
8
8
|
import { beginFallbackSeam, reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
|
|
9
|
+
import { createModelVariantSelector } from "@jgengine/core/world/modelLod";
|
|
10
|
+
import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
|
|
9
11
|
import { buildScatterProxy } from "./scatterProxies.js";
|
|
10
12
|
import { buildScatterModelSources, disposeScatterModelSources } from "./scatterModels.js";
|
|
13
|
+
import { writeScatterVariantLevel } from "./scatterVariants.js";
|
|
11
14
|
/** Chunk edge (m): dense fields split into this-sized buckets, each frustum-culled independently. */
|
|
12
15
|
const DEFAULT_CHUNK_SIZE = 24;
|
|
13
16
|
const DEFAULT_MAX_INSTANCES = 20000;
|
|
14
|
-
/** Groups resolved instances by spatial chunk then species so each batch is one cullable draw. */
|
|
15
|
-
function
|
|
17
|
+
/** Groups resolved instances by spatial chunk then species so each batch is one cullable draw. @internal */
|
|
18
|
+
export function scatterBatchInstances(instances, chunkSize) {
|
|
16
19
|
const batches = [];
|
|
17
20
|
const matrix = new THREE.Matrix4();
|
|
18
21
|
const position = new THREE.Vector3();
|
|
@@ -29,12 +32,15 @@ function batchInstances(instances, chunkSize) {
|
|
|
29
32
|
matrix.compose(position, quaternion, scale);
|
|
30
33
|
const bucket = byItem.get(instance.item);
|
|
31
34
|
if (bucket === undefined)
|
|
32
|
-
byItem.set(instance.item, [matrix.clone()]);
|
|
33
|
-
else
|
|
34
|
-
bucket.push(matrix.clone());
|
|
35
|
+
byItem.set(instance.item, { matrices: [matrix.clone()], instances: [instance] });
|
|
36
|
+
else {
|
|
37
|
+
bucket.matrices.push(matrix.clone());
|
|
38
|
+
bucket.instances.push(instance);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const [item, bucket] of byItem) {
|
|
42
|
+
batches.push({ key: `${chunk.key}:${item}`, item, matrices: bucket.matrices, instances: bucket.instances });
|
|
35
43
|
}
|
|
36
|
-
for (const [item, matrices] of byItem)
|
|
37
|
-
batches.push({ key: `${chunk.key}:${item}`, item, matrices });
|
|
38
44
|
}
|
|
39
45
|
return batches;
|
|
40
46
|
}
|
|
@@ -71,8 +77,68 @@ function ScatterModelSourceInstances({ source, matrices }) {
|
|
|
71
77
|
}, [matrices, source]);
|
|
72
78
|
return _jsx("instancedMesh", { ref: meshRef, args: [source.geometry, source.material, matrices.length], castShadow: true, receiveShadow: true });
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* One detail level's draw source, instanced across every chunk of one species. Each frame it asks the
|
|
82
|
+
* shared variant selector for each placement's level and draws only the ones it owns, composing into
|
|
83
|
+
* a reserved matrix slot so the far level's instances cost a second instanced draw, not per-frame
|
|
84
|
+
* allocation. A cell that holds near and far placements feeds both levels from the same batch.
|
|
85
|
+
*/
|
|
86
|
+
function ScatterVariantSourceInstances({ source, batch, level, selector }) {
|
|
87
|
+
const meshRef = useRef(null);
|
|
88
|
+
const camera = useThree((state) => state.camera);
|
|
89
|
+
const slots = useMemo(() => batch.matrices.map(() => new THREE.Matrix4()), [batch]);
|
|
90
|
+
const cameraPosition = useMemo(() => new THREE.Vector3(), []);
|
|
91
|
+
const lastCamera = useRef(new THREE.Vector3(Number.POSITIVE_INFINITY, 0, 0));
|
|
92
|
+
const writeLevel = useCallback((from) => {
|
|
93
|
+
const mesh = meshRef.current;
|
|
94
|
+
if (mesh === null)
|
|
95
|
+
return;
|
|
96
|
+
const count = writeScatterVariantLevel(level, selector, from, batch.instances, batch.matrices, source.localMatrix, slots);
|
|
97
|
+
mesh.count = count;
|
|
98
|
+
mesh.visible = count > 0;
|
|
99
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
100
|
+
if (count > 0)
|
|
101
|
+
mesh.computeBoundingSphere();
|
|
102
|
+
}, [level, selector, batch, source, slots]);
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
camera.getWorldPosition(cameraPosition);
|
|
105
|
+
lastCamera.current.copy(cameraPosition);
|
|
106
|
+
writeLevel(cameraPosition);
|
|
107
|
+
}, [camera, writeLevel, cameraPosition]);
|
|
108
|
+
useFrame((state) => {
|
|
109
|
+
state.camera.getWorldPosition(cameraPosition);
|
|
110
|
+
if (cameraPosition.distanceToSquared(lastCamera.current) < 0.25)
|
|
111
|
+
return;
|
|
112
|
+
lastCamera.current.copy(cameraPosition);
|
|
113
|
+
writeLevel(cameraPosition);
|
|
114
|
+
}, CAMERA_POST_FRAME_PRIORITY + 0.1);
|
|
115
|
+
return (_jsx("instancedMesh", { ref: meshRef, args: [source.geometry, source.material, batch.matrices.length], castShadow: source.castShadow ?? true, receiveShadow: source.receiveShadow ?? true }));
|
|
116
|
+
}
|
|
117
|
+
/** Renders one level of a variant model: the level's own GLB harvested into instanced draws. */
|
|
118
|
+
function ScatterVariantLevel({ model, url, level, selector, batches }) {
|
|
119
|
+
const gltf = useLoader(GLTFLoader, url, (loader) => {
|
|
120
|
+
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
121
|
+
});
|
|
122
|
+
const { sources, root } = useMemo(() => {
|
|
123
|
+
const levelModel = { ...model, url, variants: undefined };
|
|
124
|
+
return buildScatterModelSources(gltf.scene, levelModel);
|
|
125
|
+
}, [gltf, model, url]);
|
|
126
|
+
useEffect(() => () => disposeScatterModelSources(root), [root]);
|
|
127
|
+
return (_jsx(_Fragment, { children: sources.map((source, sourceIndex) => batches.map((batch) => (_jsx(ScatterVariantSourceInstances, { source: source, batch: batch, level: level, selector: selector }, `${batch.key}:${sourceIndex}`)))) }));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A species whose model declares detail levels: one GLB per level, each level's instanced draws
|
|
131
|
+
* claiming only the placements the one shared selector put on it. The selector lives here so a
|
|
132
|
+
* placement keeps its hysteresis across every level component instead of each level deciding alone.
|
|
133
|
+
*/
|
|
134
|
+
function ScatterVariantSpecies({ model, batches }) {
|
|
135
|
+
const variants = model.variants;
|
|
136
|
+
const selector = useMemo(() => createModelVariantSelector(variants), [variants]);
|
|
137
|
+
useEffect(() => () => selector.clear(), [selector]);
|
|
138
|
+
return (_jsx(_Fragment, { children: variants.levels.map((level, index) => (_jsx(Suspense, { fallback: null, children: _jsx(ScatterVariantLevel, { model: model, url: level.url, level: index, selector: selector, batches: batches }) }, `${level.url}:${index}`))) }));
|
|
139
|
+
}
|
|
74
140
|
/** Loads one species' real catalog GLB once, harvests its instanceable draw sources, then GPU-instances them across every chunk that species appears in. */
|
|
75
|
-
function
|
|
141
|
+
function ScatterSingleSpecies({ model, batches }) {
|
|
76
142
|
const gltf = useLoader(GLTFLoader, model.url, (loader) => {
|
|
77
143
|
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
78
144
|
});
|
|
@@ -80,6 +146,12 @@ function ScatterModelSpecies({ model, batches }) {
|
|
|
80
146
|
useEffect(() => () => disposeScatterModelSources(root), [root]);
|
|
81
147
|
return (_jsx(_Fragment, { children: sources.map((source, sourceIndex) => batches.map((batch) => (_jsx(ScatterModelSourceInstances, { source: source, matrices: batch.matrices }, `${batch.key}:${sourceIndex}`)))) }));
|
|
82
148
|
}
|
|
149
|
+
/** One species draw: the level switch for a model with `variants`, the single GLB otherwise. */
|
|
150
|
+
function ScatterModelSpecies({ model, batches }) {
|
|
151
|
+
return model.variants === undefined
|
|
152
|
+
? _jsx(ScatterSingleSpecies, { model: model, batches: batches })
|
|
153
|
+
: _jsx(ScatterVariantSpecies, { model: model, batches: batches });
|
|
154
|
+
}
|
|
83
155
|
/**
|
|
84
156
|
* Renders resolved {@link ScatterInstance}s grouped into per-chunk, per-species GPU-instanced draws
|
|
85
157
|
* that frustum-cull independently. Species with no `resolveItem` mapping (or a `null` result) render
|
|
@@ -89,7 +161,7 @@ function ScatterModelSpecies({ model, batches }) {
|
|
|
89
161
|
* `resolveScatter` — never one node per placement.
|
|
90
162
|
*/
|
|
91
163
|
export function InstancedScatter({ instances, chunkSize = DEFAULT_CHUNK_SIZE, maxInstances = DEFAULT_MAX_INSTANCES, resolveItem, }) {
|
|
92
|
-
const batches = useMemo(() =>
|
|
164
|
+
const batches = useMemo(() => scatterBatchInstances(instances.length > maxInstances ? instances.slice(0, maxInstances) : instances, chunkSize), [instances, chunkSize, maxInstances]);
|
|
93
165
|
const { proxyBatches, modelSpecies } = useMemo(() => {
|
|
94
166
|
// Dev diagnostic (no-op unless armed): re-zero the scatter seam before re-tallying which species
|
|
95
167
|
// fall to a stylized proxy (no resolveItem mapping / a null result). Reported once per species.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type * as THREE from "three";
|
|
2
|
+
import type { ModelVariantSelector } from "@jgengine/core/world/modelLod";
|
|
3
|
+
/** A scatter placement as the level pass reads it: a stable id and a world position. */
|
|
4
|
+
export interface ScatterVariantMember {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly x: number;
|
|
7
|
+
readonly y: number;
|
|
8
|
+
readonly z: number;
|
|
9
|
+
}
|
|
10
|
+
/** A camera position the level pass measures from. */
|
|
11
|
+
export interface ScatterVariantCamera {
|
|
12
|
+
readonly x: number;
|
|
13
|
+
readonly y: number;
|
|
14
|
+
readonly z: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Writes the matrices of the members the shared variant selector put on `level` into `out`, in source
|
|
18
|
+
* order, and returns how many were written. `out` is the level mesh's reserved capacity (one matrix
|
|
19
|
+
* per member), so a frame writes matrices and `count` without allocating.
|
|
20
|
+
*
|
|
21
|
+
* Every member is asked for its level through the same {@link ModelVariantSelector} the static prop
|
|
22
|
+
* path uses, so hysteresis and the `lodBandIndex` boundary are identical, and a chunk cell holding
|
|
23
|
+
* near and far members of one species splits between this level's draw and the other levels'.
|
|
24
|
+
*
|
|
25
|
+
* `keep` lets an orthogonal instance cull (tier distance, occlusion) drop a member without changing
|
|
26
|
+
* the level answer, so culling and level selection compose in one pass over the batch.
|
|
27
|
+
*/
|
|
28
|
+
export declare function writeScatterVariantLevel(level: number, selector: ModelVariantSelector, camera: ScatterVariantCamera, members: readonly ScatterVariantMember[], transforms: readonly THREE.Matrix4[], localMatrix: THREE.Matrix4, out: THREE.Matrix4[], keep?: (index: number) => boolean): number;
|
|
29
|
+
/** Triangle total for per-level instance counts, so a fixture reports real geometry, not instance counts. */
|
|
30
|
+
export declare function scatterVariantTriangles(counts: readonly number[], triangles: readonly number[]): number;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the matrices of the members the shared variant selector put on `level` into `out`, in source
|
|
3
|
+
* order, and returns how many were written. `out` is the level mesh's reserved capacity (one matrix
|
|
4
|
+
* per member), so a frame writes matrices and `count` without allocating.
|
|
5
|
+
*
|
|
6
|
+
* Every member is asked for its level through the same {@link ModelVariantSelector} the static prop
|
|
7
|
+
* path uses, so hysteresis and the `lodBandIndex` boundary are identical, and a chunk cell holding
|
|
8
|
+
* near and far members of one species splits between this level's draw and the other levels'.
|
|
9
|
+
*
|
|
10
|
+
* `keep` lets an orthogonal instance cull (tier distance, occlusion) drop a member without changing
|
|
11
|
+
* the level answer, so culling and level selection compose in one pass over the batch.
|
|
12
|
+
*/
|
|
13
|
+
export function writeScatterVariantLevel(level, selector, camera, members, transforms, localMatrix, out, keep) {
|
|
14
|
+
let count = 0;
|
|
15
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
16
|
+
const member = members[index];
|
|
17
|
+
const dx = member.x - camera.x;
|
|
18
|
+
const dy = member.y - camera.y;
|
|
19
|
+
const dz = member.z - camera.z;
|
|
20
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
21
|
+
if (selector.level(member.id, distance) !== level)
|
|
22
|
+
continue;
|
|
23
|
+
if (keep !== undefined && !keep(index))
|
|
24
|
+
continue;
|
|
25
|
+
out[count].multiplyMatrices(transforms[index], localMatrix);
|
|
26
|
+
count += 1;
|
|
27
|
+
}
|
|
28
|
+
return count;
|
|
29
|
+
}
|
|
30
|
+
/** Triangle total for per-level instance counts, so a fixture reports real geometry, not instance counts. */
|
|
31
|
+
export function scatterVariantTriangles(counts, triangles) {
|
|
32
|
+
let total = 0;
|
|
33
|
+
for (let index = 0; index < counts.length; index += 1)
|
|
34
|
+
total += (counts[index] ?? 0) * (triangles[index] ?? 0);
|
|
35
|
+
return total;
|
|
36
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import * as THREE from "three";
|
|
3
3
|
import type { SceneObject } from "@jgengine/core/scene/objectStore";
|
|
4
|
+
import { type ModelVariantSelector } from "@jgengine/core/world/modelLod";
|
|
4
5
|
import { type StaticObjectBatch } from "./staticObjectBatches.js";
|
|
5
6
|
/**
|
|
6
7
|
* Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
|
|
@@ -14,6 +15,23 @@ export declare function useStaticObjectBatchProbe(scene: THREE.Object3D, stats:
|
|
|
14
15
|
instances: number;
|
|
15
16
|
singles: number;
|
|
16
17
|
}): void;
|
|
18
|
+
/** Members of one level drawn through the individual renderer, for a level whose asset cannot be instanced. @internal */
|
|
19
|
+
export declare function LevelSingles({ objects, level, selector, single }: {
|
|
20
|
+
objects: readonly SceneObject[];
|
|
21
|
+
level: number;
|
|
22
|
+
selector: ModelVariantSelector;
|
|
23
|
+
single: (object: SceneObject) => ReactNode;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
/**
|
|
26
|
+
* A batch whose model declares detail levels: every level draws its own instanced meshes and claims
|
|
27
|
+
* only the instances the shared distance switch put on it, so near and far trees of one species keep
|
|
28
|
+
* their own mesh inside one batch cell.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export declare function VariantStaticObjects({ batch, single }: {
|
|
32
|
+
batch: StaticObjectBatch;
|
|
33
|
+
single: (object: SceneObject) => ReactNode;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
17
35
|
/** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
|
|
18
36
|
export declare function StaticObjectModels({ batch, single }: {
|
|
19
37
|
batch: StaticObjectBatch;
|
|
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useFrame, useLoader } from "@react-three/fiber";
|
|
3
3
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
|
+
import { createModelVariantSelector } from "@jgengine/core/world/modelLod";
|
|
5
6
|
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
6
7
|
import { useGameContext } from "@jgengine/react/provider";
|
|
7
8
|
import { sharedGltfLoader } from "../render/modelLoad.js";
|
|
@@ -31,11 +32,26 @@ export function useStaticObjectBatchProbe(scene, stats) {
|
|
|
31
32
|
},
|
|
32
33
|
}), [scene]);
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
/** Live members of one batch after the per-frame compatibility gate removed any that changed at runtime. */
|
|
36
|
+
function useBatchMembers(batch) {
|
|
37
|
+
const ctx = useGameContext();
|
|
38
|
+
const [excluded, setExcluded] = useState([]);
|
|
39
|
+
const fallbackObjects = useRef(new Map());
|
|
40
|
+
useFrame(() => {
|
|
41
|
+
const next = incompatibleStaticObjectIds(batch, ctx.scene.object.get, ctx.scene.object.catalog, excluded, fallbackObjects.current);
|
|
42
|
+
if (next !== excluded)
|
|
43
|
+
setExcluded(next);
|
|
44
|
+
});
|
|
45
|
+
const objects = useMemo(() => batch.objects.filter((object) => !excluded.includes(object.instanceId)), [batch, excluded]);
|
|
46
|
+
return { objects, excluded };
|
|
47
|
+
}
|
|
48
|
+
function ObjectSource({ source, objects, batch, selector, level }) {
|
|
35
49
|
const ctx = useGameContext();
|
|
36
50
|
const visibility = useRenderVisibility();
|
|
37
51
|
const ref = useRef(null);
|
|
38
52
|
const scratch = useMemo(() => new THREE.Object3D(), []);
|
|
53
|
+
const cameraPosition = useRef(new THREE.Vector3());
|
|
54
|
+
const probe = useMemo(() => new THREE.Vector3(), []);
|
|
39
55
|
const attach = useCallback((mesh) => {
|
|
40
56
|
ref.current = mesh;
|
|
41
57
|
if (mesh !== null)
|
|
@@ -45,26 +61,26 @@ function ObjectSource({ source, objects, batch }) {
|
|
|
45
61
|
const mesh = ref.current;
|
|
46
62
|
return () => { mesh?.dispose(); };
|
|
47
63
|
}, [objects.length, source]);
|
|
48
|
-
useFrame(() => {
|
|
49
|
-
if (ref.current
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
useFrame((state) => {
|
|
65
|
+
if (ref.current === null)
|
|
66
|
+
return;
|
|
67
|
+
let accept;
|
|
68
|
+
if (selector !== undefined && level !== undefined) {
|
|
69
|
+
const camera = cameraPosition.current;
|
|
70
|
+
state.camera.getWorldPosition(camera);
|
|
71
|
+
accept = (live) => selector.level(live.instanceId, camera.distanceTo(probe.set(live.position[0], live.position[1], live.position[2]))) === level;
|
|
72
|
+
}
|
|
73
|
+
syncStaticObjectSource(ref.current, source, objects, (id) => {
|
|
74
|
+
const object = ctx.scene.object.get(id);
|
|
75
|
+
return object !== null && object.catalogId === batch.objects[0].catalogId &&
|
|
76
|
+
isCompatibleStaticObject(object, batch.model, ctx.scene.object.catalog(id)) ? object : null;
|
|
77
|
+
}, visibility.current, scratch, accept);
|
|
55
78
|
}, CAMERA_POST_FRAME_PRIORITY + 0.1);
|
|
56
79
|
return _jsx("instancedMesh", { ref: attach, args: [source.geometry, source.material, objects.length], userData: { [STATIC_BATCH_MESH_KEY]: true }, castShadow: source.castShadow, receiveShadow: source.receiveShadow, dispose: null });
|
|
57
80
|
}
|
|
58
81
|
function LoadedStaticObjects({ batch, single }) {
|
|
59
82
|
const ctx = useGameContext();
|
|
60
|
-
const
|
|
61
|
-
const fallbackObjects = useRef(new Map());
|
|
62
|
-
useFrame(() => {
|
|
63
|
-
const next = incompatibleStaticObjectIds(batch, ctx.scene.object.get, ctx.scene.object.catalog, excluded, fallbackObjects.current);
|
|
64
|
-
if (next !== excluded)
|
|
65
|
-
setExcluded(next);
|
|
66
|
-
});
|
|
67
|
-
const objects = useMemo(() => batch.objects.filter((object) => !excluded.includes(object.instanceId)), [batch, excluded]);
|
|
83
|
+
const { objects, excluded } = useBatchMembers(batch);
|
|
68
84
|
const gltf = useLoader(sharedGltfLoader, batch.model.url);
|
|
69
85
|
const supported = useMemo(() => canInstanceStaticScene(gltf.scene, gltf.animations), [gltf]);
|
|
70
86
|
const harvested = useMemo(() => supported ? buildScatterModelSources(gltf.scene, batch.model) : null, [supported, gltf, batch.model]);
|
|
@@ -94,7 +110,75 @@ function LoadedStaticObjects({ batch, single }) {
|
|
|
94
110
|
return _jsx(_Fragment, { children: batch.objects.map(single) });
|
|
95
111
|
return _jsxs(_Fragment, { children: [harvested.sources.map((source, index) => objects.length === 0 ? null : _jsx(ObjectSource, { source: source, objects: objects, batch: batch }, index)), excluded.map((id) => { const live = ctx.scene.object.get(id); return live === null ? null : single(live); })] });
|
|
96
112
|
}
|
|
113
|
+
/** One detail level's GLB, harvested into instanced draws that only claim the instances the selector put on this level. */
|
|
114
|
+
function VariantLevel({ url, model, batch, objects, level, selector, measure, single }) {
|
|
115
|
+
const ctx = useGameContext();
|
|
116
|
+
const gltf = useLoader(sharedGltfLoader, url);
|
|
117
|
+
const supported = useMemo(() => canInstanceStaticScene(gltf.scene, gltf.animations), [gltf]);
|
|
118
|
+
const harvested = useMemo(() => supported ? buildScatterModelSources(gltf.scene, model) : null, [supported, gltf, model]);
|
|
119
|
+
useEffect(() => () => { if (harvested !== null)
|
|
120
|
+
disposeScatterModelSources(harvested.root); }, [harvested]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (harvested === null || !measure)
|
|
123
|
+
return;
|
|
124
|
+
const root = new THREE.Group();
|
|
125
|
+
for (const source of harvested.sources) {
|
|
126
|
+
const mesh = new THREE.Mesh(source.geometry, source.material);
|
|
127
|
+
mesh.matrixAutoUpdate = false;
|
|
128
|
+
mesh.matrix.copy(source.localMatrix);
|
|
129
|
+
root.add(mesh);
|
|
130
|
+
}
|
|
131
|
+
const key = batch.objects[0].catalogId;
|
|
132
|
+
if (batch.model.dims?.maxY === undefined) {
|
|
133
|
+
const bounds = measureLocalBounds(root);
|
|
134
|
+
if (bounds !== null)
|
|
135
|
+
reportMeasuredBounds(ctx, "object", key, bounds);
|
|
136
|
+
}
|
|
137
|
+
const triangles = measureLocalCollisionTriangles(root);
|
|
138
|
+
if (triangles !== null)
|
|
139
|
+
reportMeasuredCollisionMesh(ctx, "object", key, triangles);
|
|
140
|
+
}, [ctx, harvested, batch, measure]);
|
|
141
|
+
if (harvested === null)
|
|
142
|
+
return _jsx(LevelSingles, { objects: objects, level: level, selector: selector, single: single });
|
|
143
|
+
return _jsx(_Fragment, { children: harvested.sources.map((source, index) => _jsx(ObjectSource, { source: source, objects: objects, batch: batch, selector: selector, level: level }, index)) });
|
|
144
|
+
}
|
|
145
|
+
/** Members of one level drawn through the individual renderer, for a level whose asset cannot be instanced. @internal */
|
|
146
|
+
export function LevelSingles({ objects, level, selector, single }) {
|
|
147
|
+
const ctx = useGameContext();
|
|
148
|
+
const visibility = useRenderVisibility();
|
|
149
|
+
const camera = useMemo(() => new THREE.Vector3(), []);
|
|
150
|
+
const probe = useMemo(() => new THREE.Vector3(), []);
|
|
151
|
+
const [members, setMembers] = useState(objects);
|
|
152
|
+
useFrame((state) => {
|
|
153
|
+
state.camera.getWorldPosition(camera);
|
|
154
|
+
const next = objects.flatMap((object) => {
|
|
155
|
+
const live = ctx.scene.object.get(object.instanceId);
|
|
156
|
+
if (live === null || !visibility.current(live.instanceId))
|
|
157
|
+
return [];
|
|
158
|
+
probe.set(live.position[0], live.position[1], live.position[2]);
|
|
159
|
+
return selector.level(live.instanceId, camera.distanceTo(probe)) === level ? [live] : [];
|
|
160
|
+
});
|
|
161
|
+
if (next.length !== members.length || next.some((member, index) => member !== members[index]))
|
|
162
|
+
setMembers(next);
|
|
163
|
+
});
|
|
164
|
+
return _jsx(_Fragment, { children: members.map(single) });
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A batch whose model declares detail levels: every level draws its own instanced meshes and claims
|
|
168
|
+
* only the instances the shared distance switch put on it, so near and far trees of one species keep
|
|
169
|
+
* their own mesh inside one batch cell.
|
|
170
|
+
* @internal
|
|
171
|
+
*/
|
|
172
|
+
export function VariantStaticObjects({ batch, single }) {
|
|
173
|
+
const ctx = useGameContext();
|
|
174
|
+
const { objects, excluded } = useBatchMembers(batch);
|
|
175
|
+
const levels = batch.model.variants.levels;
|
|
176
|
+
const selector = useMemo(() => createModelVariantSelector(batch.model.variants), [batch.model]);
|
|
177
|
+
return _jsxs(_Fragment, { children: [levels.map((level, index) => (_jsx(VariantLevel, { url: level.url, model: { ...batch.model, url: level.url, variants: undefined }, batch: batch, objects: objects, level: index, selector: selector, measure: index === 0, single: single }, `${level.url}:${index}`))), excluded.map((id) => { const live = ctx.scene.object.get(id); return live === null ? null : single(live); })] });
|
|
178
|
+
}
|
|
97
179
|
/** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
|
|
98
180
|
export function StaticObjectModels({ batch, single }) {
|
|
99
|
-
return _jsx(IsolatedModelPart, { model: batch.model, seam: "object", children:
|
|
181
|
+
return (_jsx(IsolatedModelPart, { model: batch.model, seam: "object", children: batch.model.variants === undefined
|
|
182
|
+
? _jsx(LoadedStaticObjects, { batch: batch, single: single })
|
|
183
|
+
: _jsx(VariantStaticObjects, { batch: batch, single: single }) }));
|
|
100
184
|
}
|
|
@@ -63,5 +63,5 @@ export interface StaticBatchCounts {
|
|
|
63
63
|
export declare function countStaticBatchDraws(scene: THREE.Object3D): StaticBatchCounts;
|
|
64
64
|
/** Rejects loaded scenes whose behavior cannot be represented by static instanced draws. @internal */
|
|
65
65
|
export declare function canInstanceStaticScene(scene: THREE.Object3D, clips: readonly THREE.AnimationClip[]): boolean;
|
|
66
|
-
/** Compact visible placements and update picking identities without allocating per instance. @internal */
|
|
67
|
-
export declare function syncStaticObjectSource(mesh: THREE.InstancedMesh, source: ScatterModelSource, objects: readonly SceneObject[], get: (id: string) => SceneObject | null, visible: (id: string) => boolean, scratch: THREE.Object3D): void;
|
|
66
|
+
/** Compact visible placements and update picking identities without allocating per instance. `accept` narrows the range to one detail level of a variant model. @internal */
|
|
67
|
+
export declare function syncStaticObjectSource(mesh: THREE.InstancedMesh, source: ScatterModelSource, objects: readonly SceneObject[], get: (id: string) => SceneObject | null, visible: (id: string) => boolean, scratch: THREE.Object3D, accept?: (live: SceneObject) => boolean): void;
|
|
@@ -155,8 +155,8 @@ export function canInstanceStaticScene(scene, clips) {
|
|
|
155
155
|
});
|
|
156
156
|
return supported;
|
|
157
157
|
}
|
|
158
|
-
/** Compact visible placements and update picking identities without allocating per instance. @internal */
|
|
159
|
-
export function syncStaticObjectSource(mesh, source, objects, get, visible, scratch) {
|
|
158
|
+
/** Compact visible placements and update picking identities without allocating per instance. `accept` narrows the range to one detail level of a variant model. @internal */
|
|
159
|
+
export function syncStaticObjectSource(mesh, source, objects, get, visible, scratch, accept) {
|
|
160
160
|
const ids = (mesh.userData[POINTER_OBJECT_INSTANCES_KEY] ??= []);
|
|
161
161
|
const poses = (mesh.userData["jgStaticObjectPoses"] ??= []);
|
|
162
162
|
if (mesh.userData["jgStaticObjectSource"] !== source) {
|
|
@@ -168,7 +168,7 @@ export function syncStaticObjectSource(mesh, source, objects, get, visible, scra
|
|
|
168
168
|
const composed = scratch.matrixWorld;
|
|
169
169
|
for (const object of objects) {
|
|
170
170
|
const live = get(object.instanceId);
|
|
171
|
-
if (live === null || !visible(live.instanceId))
|
|
171
|
+
if (live === null || !visible(live.instanceId) || (accept !== undefined && !accept(live)))
|
|
172
172
|
continue;
|
|
173
173
|
if (poses[count] === live) {
|
|
174
174
|
ids[count++] = live.instanceId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"test": "bun test src"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@jgengine/core": "^0.
|
|
43
|
-
"@jgengine/react": "^0.
|
|
44
|
-
"@jgengine/ws": "^0.
|
|
42
|
+
"@jgengine/core": "^0.38.0",
|
|
43
|
+
"@jgengine/react": "^0.38.0",
|
|
44
|
+
"@jgengine/ws": "^0.38.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@react-three/drei": "^10.0.0",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
8
8
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
9
|
-
- `VERSION` (const): const VERSION: "0.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.38.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
10
10
|
|
|
11
11
|
## @jgengine/core/authoring
|
|
12
12
|
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
|
|
286
286
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
287
287
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
288
|
-
- `VERSION` (const): const VERSION: "0.
|
|
288
|
+
- `VERSION` (const): const VERSION: "0.38.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -29,7 +29,7 @@ Existing React games keep their entity store and use the focused
|
|
|
29
29
|
|
|
30
30
|
- **Every game owns its UI end-to-end.** Custom composition, skin, placement, terminology, motion, and one main menu. The website/runner is a bare loader. Composing and reskinning the shipped blocks is correct use; a genre HUD kit, theme preset, or unarranged default stack dropped in as the game's identity is not.
|
|
31
31
|
- **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md#main-menu).
|
|
32
|
-
- **Menus come from the shipped blocks, navigated by `useMenuNav`.** `TitleMenu`/`MenuScreen` (`@jgengine/react`)
|
|
32
|
+
- **Menus come from the shipped blocks, navigated by `useMenuNav`.** `GameMenu` (`@jgengine/react/gameMenu`) is the front-end surface: full-bleed over a live scene or key art, a logo sized by `--jg-menu-logo-size`, animated primary verbs with a generated selection tick, and a detail pane that follows the highlight — with `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (cards with thumbnails), `CreditsPane` and the game's own settings surface as its panes, and `GamePauseMenu` the same system sized down. It locks the document scroll, so the surface is exactly the viewport at any size. `TitleMenu`/`MenuScreen` (`@jgengine/react`) remain for a plainer animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the small pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` a bare slot list, `CreditsPanel` the credits screen, `LoadingScreen` the boot screen with rotating tips. `useMenuNav` (`@jgengine/react/menuNav`) is the one keyboard + gamepad focus model for any menu: roving focus, confirm/back, disabled rows skipped, no focus trap. Games keep wording, layout, art and terminology.
|
|
33
33
|
- **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
|
|
34
34
|
- **Route the front-end with `useMenuRouter`, and generate the credits.** `useMenuRouter(root)` is the screen stack behind title → save select → settings → credits: `open`/`back`/`replace`/`reset`, `path` for depth, and Escape-to-back bound by default (`escapeToBack: false` when the game owns Escape). `menuStackReducer` is the same transition as a pure function. For credits, `creditsForSourceIds(ids)` (`@jgengine/assets/credits`) turns the pack ids a game declares to `buildCatalog` into licensed, attributed sections, `mergeCredits` folds them into hand-authored lines, and `CreditsScreen` renders the result HudTheme-skinned.
|
|
35
35
|
- **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the whole `--jg-*` vocabulary — bar fills and troughs, `HudFrame variation="themed"`, painted slots, the minimap ring, plus surfaces, edges, text, status colors, fonts, and rarity — so one theme spread on any HUD ancestor reskins the `@jgengine/react` primitives and the `registry/jgengine` blocks together. Author your own with `deriveHudTheme({ accent, surface })` or `hudTheme({ ... })`; `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. A component that needs a token no theme emits fails `check-hud-tokens` — add it to `HudTheme` rather than inventing a second vocabulary. Caller `style`/`renderItem` overrides still win.
|