@jgengine/shell 0.27.0 → 0.28.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 +130 -0
- package/dist/GameUiPreview.js +3 -1
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.js +3 -1
- package/dist/camera/GameCameraRig.js +2 -2
- package/dist/camera/GameFirstPersonCamera.js +39 -2
- package/dist/camera/cameraCollision.d.ts +66 -1
- package/dist/camera/cameraCollision.js +72 -1
- package/dist/camera/index.d.ts +1 -1
- package/dist/camera/index.js +1 -1
- package/dist/camera/rigResolve.d.ts +13 -0
- package/dist/camera/rigResolve.js +16 -0
- package/dist/environment/EnvironmentScene.js +69 -4
- package/dist/postfx/shadowPrepass.d.ts +8 -1
- package/dist/postfx/shadowPrepass.js +18 -2
- package/dist/postfx/stylizePass.js +2 -1
- package/dist/render/CascadedShadows.d.ts +31 -1
- package/dist/render/CascadedShadows.js +71 -5
- package/dist/render/CharacterCrowd.d.ts +20 -0
- package/dist/render/CharacterCrowd.js +176 -0
- package/dist/render/SceneLighting.js +27 -3
- package/dist/render/SceneWarmupGate.d.ts +20 -0
- package/dist/render/SceneWarmupGate.js +48 -0
- package/dist/render/characterLod.d.ts +62 -0
- package/dist/render/characterLod.js +165 -0
- package/dist/render/crowdRegistry.d.ts +48 -0
- package/dist/render/crowdRegistry.js +97 -0
- package/dist/render/materialAsset.js +2 -1
- package/dist/render/modelLoad.js +3 -0
- package/dist/render/modelMeshMerge.d.ts +49 -0
- package/dist/render/modelMeshMerge.js +269 -0
- package/dist/render/shaderWarmup.d.ts +69 -0
- package/dist/render/shaderWarmup.js +120 -0
- package/dist/render/shadowCasters.d.ts +28 -0
- package/dist/render/shadowCasters.js +95 -0
- package/dist/render/staticTransforms.d.ts +20 -0
- package/dist/render/staticTransforms.js +32 -0
- package/dist/render/useFootIk.d.ts +11 -3
- package/dist/render/useFootIk.js +35 -7
- package/dist/render/useModelAnimation.d.ts +16 -2
- package/dist/render/useModelAnimation.js +73 -4
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +23 -0
- package/dist/render/useRagdoll.d.ts +32 -0
- package/dist/render/useRagdoll.js +105 -0
- package/dist/settings/SettingsMenu.d.ts +1 -1
- package/dist/settings/SettingsMenu.js +10 -79
- package/dist/settings/settingsController.js +3 -0
- package/dist/world/StaticObjectModels.d.ts +13 -0
- package/dist/world/StaticObjectModels.js +28 -3
- package/dist/world/WorldParticles.d.ts +2 -1
- package/dist/world/WorldParticles.js +29 -10
- package/dist/world/WorldScene.js +50 -13
- package/dist/world/entityPose.d.ts +17 -0
- package/dist/world/entityPose.js +42 -0
- package/dist/world/staticObjectBatches.d.ts +33 -5
- package/dist/world/staticObjectBatches.js +40 -6
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +200 -9
- package/skills/jgengine-ui/capabilities.md +103 -1
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +8 -0
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +128 -6
- package/skills/jgengine-world/capabilities.md +41 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
+
import * as THREE from "three";
|
|
2
3
|
import type { SceneObject } from "@jgengine/core/scene/objectStore";
|
|
3
4
|
import { type StaticObjectBatch } from "./staticObjectBatches.js";
|
|
5
|
+
/**
|
|
6
|
+
* Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
|
|
7
|
+
* total instances behind them, and how many instances survived per-instance culling this frame. A
|
|
8
|
+
* batch whose instances are all outside the view contributes a draw of zero, so `drawn` is the culling
|
|
9
|
+
* evidence, not a claim.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function useStaticObjectBatchProbe(scene: THREE.Object3D, stats: {
|
|
13
|
+
batches: number;
|
|
14
|
+
instances: number;
|
|
15
|
+
singles: number;
|
|
16
|
+
}): void;
|
|
4
17
|
/** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
|
|
5
18
|
export declare function StaticObjectModels({ batch, single }: {
|
|
6
19
|
batch: StaticObjectBatch;
|
|
@@ -1,21 +1,46 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useFrame, useLoader } from "@react-three/fiber";
|
|
3
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
|
+
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
5
6
|
import { useGameContext } from "@jgengine/react/provider";
|
|
6
7
|
import { sharedGltfLoader } from "../render/modelLoad.js";
|
|
7
8
|
import { IsolatedModelPart } from "../render/SceneModels.js";
|
|
9
|
+
import { freezeStaticTransforms } from "../render/staticTransforms.js";
|
|
8
10
|
import { measureLocalBounds, reportMeasuredBounds } from "../render/measureBounds.js";
|
|
9
11
|
import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "../render/measureCollisionMesh.js";
|
|
10
12
|
import { buildScatterModelSources, disposeScatterModelSources } from "../scatter/scatterModels.js";
|
|
11
13
|
import { useRenderVisibility } from "../visibility/CullingProvider.js";
|
|
12
14
|
import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
|
|
13
|
-
import { canInstanceStaticScene, incompatibleStaticObjectIds, isCompatibleStaticObject, syncStaticObjectSource } from "./staticObjectBatches.js";
|
|
15
|
+
import { canInstanceStaticScene, countStaticBatchDraws, incompatibleStaticObjectIds, isCompatibleStaticObject, STATIC_BATCH_MESH_KEY, syncStaticObjectSource } from "./staticObjectBatches.js";
|
|
16
|
+
/**
|
|
17
|
+
* Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
|
|
18
|
+
* total instances behind them, and how many instances survived per-instance culling this frame. A
|
|
19
|
+
* batch whose instances are all outside the view contributes a draw of zero, so `drawn` is the culling
|
|
20
|
+
* evidence, not a claim.
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export function useStaticObjectBatchProbe(scene, stats) {
|
|
24
|
+
const ref = useRef(stats);
|
|
25
|
+
ref.current = stats;
|
|
26
|
+
useEffect(() => devtools.probes.registerSystem("render/staticBatches", {
|
|
27
|
+
counters: () => ({ ...ref.current, ...countStaticBatchDraws(scene) }),
|
|
28
|
+
state: () => {
|
|
29
|
+
const { draws, drawn } = countStaticBatchDraws(scene);
|
|
30
|
+
return `${ref.current.batches} batches · ${draws} draws · ${drawn}/${ref.current.instances} instances drawn`;
|
|
31
|
+
},
|
|
32
|
+
}), [scene]);
|
|
33
|
+
}
|
|
14
34
|
function ObjectSource({ source, objects, batch }) {
|
|
15
35
|
const ctx = useGameContext();
|
|
16
36
|
const visibility = useRenderVisibility();
|
|
17
37
|
const ref = useRef(null);
|
|
18
38
|
const scratch = useMemo(() => new THREE.Object3D(), []);
|
|
39
|
+
const attach = useCallback((mesh) => {
|
|
40
|
+
ref.current = mesh;
|
|
41
|
+
if (mesh !== null)
|
|
42
|
+
freezeStaticTransforms(mesh);
|
|
43
|
+
}, []);
|
|
19
44
|
useEffect(() => {
|
|
20
45
|
const mesh = ref.current;
|
|
21
46
|
return () => { mesh?.dispose(); };
|
|
@@ -28,7 +53,7 @@ function ObjectSource({ source, objects, batch }) {
|
|
|
28
53
|
isCompatibleStaticObject(object, batch.model, ctx.scene.object.catalog(id)) ? object : null;
|
|
29
54
|
}, visibility.current, scratch);
|
|
30
55
|
}, CAMERA_POST_FRAME_PRIORITY + 0.1);
|
|
31
|
-
return _jsx("instancedMesh", { ref:
|
|
56
|
+
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 });
|
|
32
57
|
}
|
|
33
58
|
function LoadedStaticObjects({ batch, single }) {
|
|
34
59
|
const ctx = useGameContext();
|
|
@@ -11,8 +11,9 @@ export declare function transformParticleVector(point: Vec3, origin: Vec3, yaw:
|
|
|
11
11
|
/** @internal Cosmetic catch-up follows game time, with bounded work after suspension. */
|
|
12
12
|
export declare function resolveParticleDelta(previousTime: number, gameTime: number): number;
|
|
13
13
|
/** Renders the existing director and pooled simulator; named bursts reuse the standing pool. @internal */
|
|
14
|
-
export declare function WorldParticles({ quality, particleCap, diagnostics }: {
|
|
14
|
+
export declare function WorldParticles({ quality, particleCap, particleBudget, diagnostics }: {
|
|
15
15
|
quality: GraphicsQuality;
|
|
16
16
|
particleCap?: number;
|
|
17
|
+
particleBudget?: number;
|
|
17
18
|
diagnostics?: boolean;
|
|
18
19
|
}): ReactElement;
|
|
@@ -2,14 +2,16 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useFrame } from "@react-three/fiber";
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
5
|
+
import { DEFAULT_GRAPHICS_PROFILES } from "@jgengine/core/settings/graphicsProfile";
|
|
5
6
|
import { createParticleSystem } from "@jgengine/core/vfx/particles";
|
|
7
|
+
import { planParticleBudget } from "@jgengine/core/vfx/particleBudget";
|
|
6
8
|
import { useGameContext } from "@jgengine/react/provider";
|
|
7
9
|
import { ParticleField } from "../vfx/ParticleField.js";
|
|
8
|
-
const QUALITY_POOL_CAP = { low: 128, medium: 256, high: 512, ultra: 1024 };
|
|
9
10
|
/** The requested visual pool clamped to the graphics tier. */
|
|
10
11
|
export function resolveParticleBudget(quality, requested, particleCap) {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
12
|
+
const tier = DEFAULT_GRAPHICS_PROFILES[quality].particleCap;
|
|
13
|
+
const rawCap = particleCap ?? tier;
|
|
14
|
+
const cap = Number.isFinite(rawCap) ? Math.max(1, Math.floor(rawCap)) : tier;
|
|
13
15
|
return Number.isFinite(requested) ? Math.max(1, Math.floor(Math.min(requested, cap))) : cap;
|
|
14
16
|
}
|
|
15
17
|
const MAX_CONCURRENT_BURSTS = 32;
|
|
@@ -43,11 +45,12 @@ export function resolveParticleDelta(previousTime, gameTime) {
|
|
|
43
45
|
return Number.isFinite(previousTime) && Number.isFinite(gameTime) ? Math.max(0, Math.min(0.1, gameTime - previousTime)) : 0;
|
|
44
46
|
}
|
|
45
47
|
/** Renders the existing director and pooled simulator; named bursts reuse the standing pool. @internal */
|
|
46
|
-
export function WorldParticles({ quality, particleCap, diagnostics = false }) {
|
|
48
|
+
export function WorldParticles({ quality, particleCap, particleBudget, diagnostics = false }) {
|
|
47
49
|
const ctx = useGameContext();
|
|
48
50
|
const emittersRef = useRef(new Map());
|
|
49
51
|
const burstsRef = useRef([]);
|
|
50
52
|
const previousTime = useRef(ctx.time.now());
|
|
53
|
+
const budget = useRef({ cap: 0, particles: 0, dropped: [] });
|
|
51
54
|
const timing = useRef({ poolsCreated: 0, poolsRetired: 0, updateSamples: 0, lastUpdateMs: 0, totalUpdateMs: 0, maxUpdateMs: 0, droppedCatchupSeconds: 0 });
|
|
52
55
|
const resources = useRef({ fieldsMounted: 0, fieldsUnmounted: 0, liveFields: 0, attributeBytes: 0, totalUploadMs: 0, maxUploadMs: 0, uploadSamples: 0 });
|
|
53
56
|
const [, setGeneration] = useState(0);
|
|
@@ -121,10 +124,23 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
|
|
|
121
124
|
const emitters = emittersRef.current;
|
|
122
125
|
function sync() {
|
|
123
126
|
let changed = false;
|
|
127
|
+
const cap = resolveParticleBudget(quality, undefined, particleBudget ?? DEFAULT_GRAPHICS_PROFILES[quality].particleBudget);
|
|
128
|
+
const specs = ctx.particles.emitters();
|
|
129
|
+
const queued = ctx.particles.drainBursts();
|
|
130
|
+
const plan = planParticleBudget([
|
|
131
|
+
...specs.map((spec) => ({ id: spec.id, priority: spec.priority ?? 0, requested: resolveParticleBudget(quality, spec.config.max, particleCap) })),
|
|
132
|
+
...queued.map((burst) => ({ id: `burst:${burst.seq}`, priority: burst.priority ?? 0, requested: resolveParticleBudget(quality, Math.min(burst.count, burst.config.max ?? burst.count), particleCap) })),
|
|
133
|
+
], cap);
|
|
134
|
+
const granted = new Map(plan.grants.map((grant) => [grant.id, grant.particles]));
|
|
135
|
+
budget.current = { cap, particles: plan.particles, dropped: plan.dropped };
|
|
124
136
|
const liveIds = new Set();
|
|
125
|
-
for (const spec of
|
|
137
|
+
for (const spec of specs) {
|
|
138
|
+
const poolMax = granted.get(spec.id) ?? 0;
|
|
139
|
+
if (poolMax <= 0) {
|
|
140
|
+
emitters.delete(spec.id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
126
143
|
liveIds.add(spec.id);
|
|
127
|
-
const poolMax = resolveParticleBudget(quality, spec.config.max, particleCap);
|
|
128
144
|
const current = emitters.get(spec.id);
|
|
129
145
|
if (current === undefined || current.poolMax !== poolMax) {
|
|
130
146
|
const next = { spec, poolMax, group: current?.group ?? null, pose: pose(spec),
|
|
@@ -155,14 +171,16 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
|
|
|
155
171
|
timing.current.poolsRetired++;
|
|
156
172
|
changed = true;
|
|
157
173
|
}
|
|
158
|
-
for (const burst of
|
|
174
|
+
for (const burst of queued) {
|
|
159
175
|
const named = burst.emitterId === undefined ? undefined : emitters.get(burst.emitterId);
|
|
160
176
|
if (named !== undefined) {
|
|
161
177
|
syncPose(named);
|
|
162
178
|
named.system.emit(burst.count);
|
|
163
179
|
continue;
|
|
164
180
|
}
|
|
165
|
-
const poolMax =
|
|
181
|
+
const poolMax = granted.get(`burst:${burst.seq}`) ?? 0;
|
|
182
|
+
if (poolMax <= 0)
|
|
183
|
+
continue;
|
|
166
184
|
const spec = { id: `burst:${burst.seq}`, config: burst.config, ...burst.options };
|
|
167
185
|
const resolved = pose(spec);
|
|
168
186
|
const system = createParticleSystem({ ...burst.config, rate: 0, max: poolMax, seed: burst.config.seed ?? burst.seq }, { collision: collisionFor(() => spec, resolved), acceleration: accelerationFor(() => spec, resolved) });
|
|
@@ -170,7 +188,7 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
|
|
|
170
188
|
const base = spec.follow === undefined ? burst.config.position ?? [0, 0, 0] : [0, 0, 0];
|
|
171
189
|
system.configure({ position: [resolved.position[0] + base[0], resolved.position[1] + base[1], resolved.position[2] + base[2]] });
|
|
172
190
|
}
|
|
173
|
-
system.emit(burst.count);
|
|
191
|
+
system.emit(Math.min(burst.count, poolMax));
|
|
174
192
|
timing.current.poolsCreated++;
|
|
175
193
|
burstsRef.current.push({ seq: burst.seq, system, blending: burst.blending ?? "additive", render: spec.render, collisionEffect: spec.collisionEffect,
|
|
176
194
|
position: spec.space === "local" ? resolved.position : [0, 0, 0], rotationY: spec.space === "local" ? resolved.yaw : 0 });
|
|
@@ -186,7 +204,7 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
|
|
|
186
204
|
sync();
|
|
187
205
|
const off = ctx.particles.subscribe(sync);
|
|
188
206
|
return off;
|
|
189
|
-
}, [ctx, quality, particleCap]);
|
|
207
|
+
}, [ctx, quality, particleCap, particleBudget]);
|
|
190
208
|
useEffect(() => {
|
|
191
209
|
if (!diagnostics)
|
|
192
210
|
return;
|
|
@@ -204,6 +222,7 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
|
|
|
204
222
|
read(burst.system);
|
|
205
223
|
const sample = timing.current, fields = resources.current;
|
|
206
224
|
return { scope: "WorldParticles", emitters: emittersRef.current.size, bursts: burstsRef.current.length, count, capacity, renderBufferBytes,
|
|
225
|
+
cap: budget.current.cap, budgetParticles: budget.current.particles, dropped: budget.current.dropped.length, droppedIds: budget.current.dropped,
|
|
207
226
|
poolsCreated: sample.poolsCreated, poolsRetired: sample.poolsRetired,
|
|
208
227
|
updateSamples: sample.updateSamples, lastUpdateMs: sample.lastUpdateMs, maxUpdateMs: sample.maxUpdateMs,
|
|
209
228
|
avgUpdateMs: sample.updateSamples === 0 ? 0 : sample.totalUpdateMs / sample.updateSamples,
|
package/dist/world/WorldScene.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { modelWithAuthoredMaterials } from "@jgengine/core/editor/materialAuthoring";
|
|
3
3
|
import { modelWithAuthoredNodeVisibility } from "@jgengine/core/editor/modelNodeVisibility";
|
|
4
|
-
import { useFrame } from "@react-three/fiber";
|
|
4
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
5
5
|
import { useEffect, useMemo, useRef } from "react";
|
|
6
6
|
import * as THREE from "three";
|
|
7
7
|
import { createEmptyEditorDocument } from "@jgengine/core/editor/index";
|
|
8
8
|
import { resolveAuthoredObjects } from "@jgengine/core/world/authoredObjects";
|
|
9
9
|
import { useLiveEditorDocument } from "../scene/AuthoredScene.js";
|
|
10
|
-
import { StaticObjectModels } from "./StaticObjectModels.js";
|
|
10
|
+
import { StaticObjectModels, useStaticObjectBatchProbe } from "./StaticObjectModels.js";
|
|
11
11
|
import { groupStaticObjects } from "./staticObjectBatches.js";
|
|
12
|
+
import { groundSpeed } from "@jgengine/core/scene/entityStore";
|
|
12
13
|
import { objectVisualScale } from "@jgengine/core/scene/objectStore";
|
|
13
14
|
import { WORLD_ITEM_ENTITY_NAME } from "@jgengine/core/game/worldItem";
|
|
14
15
|
import { beginFallbackSeam, endFallbackPass, reportFallbackSeam, } from "@jgengine/core/devtools/fallbackSeams";
|
|
@@ -20,15 +21,18 @@ import { useDisposable } from "../render/useDisposable.js";
|
|
|
20
21
|
import { WindClock } from "../render/modelWind.js";
|
|
21
22
|
import { MeasuredBoundsGroup } from "../render/measureBounds.js";
|
|
22
23
|
import { EntitySprite, IsolatedEntityModel } from "../render/SceneModels.js";
|
|
24
|
+
import { CharacterCrowd } from "../render/CharacterCrowd.js";
|
|
25
|
+
import { CharacterCrowdRegistry, crowdEligible, crowdKindOf, crowdKindReady, } from "../render/crowdRegistry.js";
|
|
26
|
+
import { characterLodTier, clearCharacterLodTier, setCharacterLodTier, } from "../render/characterLod.js";
|
|
23
27
|
import { resolveEntityModel, resolveObjectModel } from "../render/resolveModel.js";
|
|
24
28
|
import { useRenderVisibility } from "../visibility/CullingProvider.js";
|
|
25
|
-
import {
|
|
29
|
+
import { writePoseIfChanged, writeRenderPoseIfChanged, emptyPoseMemo } from "./entityPose.js";
|
|
26
30
|
import { createSnapshotBuffer } from "@jgengine/core/runtime/snapshotBuffer";
|
|
27
31
|
import { POINTER_ENTITY_KEY, POINTER_OBJECT_KEY } from "../pointer/pointerService.js";
|
|
28
32
|
const EMPTY_DOCUMENT = createEmptyEditorDocument();
|
|
29
33
|
const GROUND_SIZE = 160;
|
|
30
34
|
const GROUND_SEGMENTS = 80;
|
|
31
|
-
function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, selected, onSelect, }) {
|
|
35
|
+
function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, selected, crowd, onSelect, }) {
|
|
32
36
|
const groupRef = useRef(null);
|
|
33
37
|
const ctx = useGameContext();
|
|
34
38
|
const visibleRef = useRenderVisibility();
|
|
@@ -36,18 +40,40 @@ function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, select
|
|
|
36
40
|
const role = entity.role;
|
|
37
41
|
const name = entity.name;
|
|
38
42
|
const color = isLocal ? "#4ade80" : role === "npc" ? colorFromId(name) : "#9ca3af";
|
|
43
|
+
const tierRef = useRef(undefined);
|
|
44
|
+
const crowdModel = custom === undefined && !isLocal && crowdEligible(model) && crowd !== undefined ? model : undefined;
|
|
45
|
+
const crowdKind = crowdModel === undefined ? undefined : crowdKindOf(crowdModel);
|
|
39
46
|
const scratchRef = useRef([0, 0, 0, 0]);
|
|
40
|
-
|
|
47
|
+
const memoRef = useRef(emptyPoseMemo());
|
|
48
|
+
useFrame((state) => {
|
|
41
49
|
const group = groupRef.current;
|
|
42
50
|
if (group === null)
|
|
43
51
|
return;
|
|
44
52
|
const live = ctx.scene.entity.get(entityId);
|
|
45
53
|
if (live === null)
|
|
46
54
|
return;
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
writeRenderPoseIfChanged(group, ctx, entityId, live, scratchRef.current, memoRef.current);
|
|
56
|
+
let tier;
|
|
57
|
+
if (crowdModel !== undefined && crowd !== undefined && crowdKind !== undefined && crowdKindReady(crowdKind)) {
|
|
58
|
+
const [x, y, z] = live.position;
|
|
59
|
+
const distance = Math.sqrt((state.camera.position.x - x) ** 2 + (state.camera.position.y - y) ** 2 + (state.camera.position.z - z) ** 2);
|
|
60
|
+
tier = characterLodTier(distance, crowdModel.lod === false ? undefined : crowdModel.lod, tierRef.current);
|
|
61
|
+
setCharacterLodTier(entityId, tier);
|
|
62
|
+
if (tier === "crowd") {
|
|
63
|
+
crowd.set(entityId, crowdKind, { x, y, z, rotationY: live.rotationY ?? 0, speed: groundSpeed(live) });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
crowd.delete(entityId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
tierRef.current = tier;
|
|
70
|
+
group.visible = live.hidden !== true && visibleRef.current(entityId) && tier !== "crowd";
|
|
49
71
|
});
|
|
50
|
-
|
|
72
|
+
useEffect(() => () => {
|
|
73
|
+
crowd?.delete(entityId);
|
|
74
|
+
clearCharacterLodTier(entityId);
|
|
75
|
+
}, [crowd, entityId]);
|
|
76
|
+
return (_jsxs("group", { ref: groupRef, matrixAutoUpdate: false, userData: { [POINTER_ENTITY_KEY]: entityId }, onPointerDown: (event) => {
|
|
51
77
|
event.stopPropagation();
|
|
52
78
|
if (isLocal)
|
|
53
79
|
return;
|
|
@@ -61,6 +87,7 @@ function ObjectMarker({ object, custom, model, style, }) {
|
|
|
61
87
|
const ctx = useGameContext();
|
|
62
88
|
const visibleRef = useRenderVisibility();
|
|
63
89
|
const instanceId = object.instanceId;
|
|
90
|
+
const memoRef = useRef(emptyPoseMemo());
|
|
64
91
|
const [scaleX, scaleY, scaleZ] = objectVisualScale(object.visual);
|
|
65
92
|
const color = object.visual?.color ?? style?.color ?? colorFromId(object.catalogId);
|
|
66
93
|
const opacity = object.visual?.opacity ?? style?.opacity ?? 1;
|
|
@@ -71,10 +98,10 @@ function ObjectMarker({ object, custom, model, style, }) {
|
|
|
71
98
|
const live = ctx.scene.object.get(instanceId);
|
|
72
99
|
if (live === null)
|
|
73
100
|
return;
|
|
74
|
-
|
|
101
|
+
writePoseIfChanged(group, live, memoRef.current);
|
|
75
102
|
group.visible = visibleRef.current(instanceId);
|
|
76
103
|
});
|
|
77
|
-
return (_jsx("group", { ref: groupRef, userData: { [POINTER_OBJECT_KEY]: instanceId }, children: custom !== undefined && custom !== null ? (_jsx(MeasuredBoundsGroup, { target: "object", measureKey: object.catalogId, children: custom })) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: instanceId, measure: { target: "object", key: object.catalogId } })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], castShadow: true, receiveShadow: true, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx(DefaultSurface, { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
|
|
104
|
+
return (_jsx("group", { ref: groupRef, matrixAutoUpdate: false, userData: { [POINTER_OBJECT_KEY]: instanceId }, children: custom !== undefined && custom !== null ? (_jsx(MeasuredBoundsGroup, { target: "object", measureKey: object.catalogId, children: custom })) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: instanceId, measure: { target: "object", key: object.catalogId } })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], castShadow: true, receiveShadow: true, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx(DefaultSurface, { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
|
|
78
105
|
}
|
|
79
106
|
function GroundPlane() {
|
|
80
107
|
const geometry = useMemo(() => {
|
|
@@ -141,6 +168,7 @@ function WorldEnvironment({ environment: Environment }) {
|
|
|
141
168
|
}
|
|
142
169
|
function WorldActors({ entitySprites, entityModels, objectModels, objectStyles, assets, renderEntity, renderObject, selectedIds, hideLocalActor, editorLayers, }) {
|
|
143
170
|
const ctx = useGameContext();
|
|
171
|
+
const scene = useThree((state) => state.scene);
|
|
144
172
|
const entityIds = useSceneEntityIds();
|
|
145
173
|
const objectIds = useSceneObjectIds();
|
|
146
174
|
const document = useLiveEditorDocument(editorLayers ?? EMPTY_DOCUMENT, editorLayers !== undefined);
|
|
@@ -151,6 +179,7 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
|
|
|
151
179
|
const player = usePlayer();
|
|
152
180
|
const targetId = useTarget(player.userId);
|
|
153
181
|
const controlledId = useGameStore((c) => c.player.possession.active(player.userId));
|
|
182
|
+
const crowdRegistry = useMemo(() => new CharacterCrowdRegistry(), []);
|
|
154
183
|
const handleSelect = (entity) => {
|
|
155
184
|
const relation = ctx.scene.entity.canReceive(entity.id, "damage") === null ? "hostile" : "friendly";
|
|
156
185
|
ctx.scene.entity.setTarget(controlledId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
|
|
@@ -176,9 +205,15 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
|
|
|
176
205
|
}
|
|
177
206
|
return [{ object, model, custom, style, authored: authoredIds.has(instanceId), catalog: ctx.scene.object.catalog(instanceId) }];
|
|
178
207
|
});
|
|
179
|
-
const grouped = groupStaticObjects(candidates.map((candidate) => ({ ...candidate, custom: candidate.custom !== undefined && candidate.custom !== null })),
|
|
208
|
+
const grouped = groupStaticObjects(candidates.map((candidate) => ({ ...candidate, custom: candidate.custom !== undefined && candidate.custom !== null })), batchCache.current);
|
|
180
209
|
batchCache.current = new Map(grouped.batches.map((batch) => [batch.key, batch]));
|
|
210
|
+
useStaticObjectBatchProbe(scene, {
|
|
211
|
+
batches: grouped.batches.length,
|
|
212
|
+
instances: grouped.batches.reduce((total, batch) => total + batch.objects.length, 0),
|
|
213
|
+
singles: grouped.singles.length,
|
|
214
|
+
});
|
|
181
215
|
const candidatesById = new Map(candidates.map((candidate) => [candidate.object.instanceId, candidate]));
|
|
216
|
+
const crowdModels = new Map();
|
|
182
217
|
const renderSingle = (object) => {
|
|
183
218
|
object = ctx.scene.object.get(object.instanceId) ?? object;
|
|
184
219
|
const candidate = candidatesById.get(object.instanceId);
|
|
@@ -199,8 +234,10 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
|
|
|
199
234
|
if ((custom === undefined || custom === null) && model === undefined && sprite === undefined) {
|
|
200
235
|
reportFallbackSeam("entity", "omittedMapping");
|
|
201
236
|
}
|
|
202
|
-
|
|
203
|
-
|
|
237
|
+
if (crowdEligible(model))
|
|
238
|
+
crowdModels.set(crowdKindOf(model), model);
|
|
239
|
+
return (_jsx(EntityMarker, { entity: entity, custom: custom, model: model, sprite: sprite, isLocal: entityId === controlledId, targeted: entityId === targetId, selected: selectedIds.has(entityId), crowd: crowdRegistry, onSelect: handleSelect }, entityId));
|
|
240
|
+
}), crowdModels.size > 0 ? _jsx(CharacterCrowd, { models: [...crowdModels.values()], registry: crowdRegistry }) : null, grouped.singles.map((candidate) => renderSingle(candidate.object)), grouped.batches.map((batch) => _jsx(StaticObjectModels, { batch: batch, single: renderSingle }, batch.key))] }));
|
|
204
241
|
}
|
|
205
242
|
export function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment, assets, renderEntity, renderObject, selectedIds, hideLocalActor, editorLayers, }) {
|
|
206
243
|
return (_jsxs(_Fragment, { children: [_jsx(WindClock, {}), _jsx(WorldEnvironment, { environment: environment }), _jsx(WorldActors, { entitySprites: entitySprites, entityModels: entityModels, objectModels: objectModels, objectStyles: objectStyles, assets: assets, renderEntity: renderEntity, renderObject: renderObject, selectedIds: selectedIds, hideLocalActor: hideLocalActor, editorLayers: editorLayers })] }));
|
|
@@ -14,6 +14,23 @@ export interface PoseSource {
|
|
|
14
14
|
}
|
|
15
15
|
/** @internal */
|
|
16
16
|
export declare function writeEntityPose(target: PoseWritable, source: PoseSource): void;
|
|
17
|
+
/** The pose last written to one marker group, reused across frames. @internal */
|
|
18
|
+
export type PoseMemo = [number, number, number, number];
|
|
19
|
+
/** A marker group that owns its own matrix instead of three recomposing it every frame. @internal */
|
|
20
|
+
export interface MatrixPoseTarget extends PoseWritable {
|
|
21
|
+
updateMatrix(): void;
|
|
22
|
+
}
|
|
23
|
+
/** A memo that never matches, so the first write to a marker group always lands. @internal */
|
|
24
|
+
export declare function emptyPoseMemo(): PoseMemo;
|
|
25
|
+
/**
|
|
26
|
+
* Write a marker pose and recompose only when it changed. The marker group runs with
|
|
27
|
+
* `matrixAutoUpdate=false`, so an idle actor or a parked prop leaves its whole subtree out of the
|
|
28
|
+
* per-frame world-matrix pass instead of forcing every child to recompose (#2038).
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export declare function writePoseIfChanged(target: MatrixPoseTarget, source: PoseSource, memo: PoseMemo): boolean;
|
|
32
|
+
/** {@link writePoseIfChanged} for the interpolated pose in `ctx.sim`, falling back to `live`. @internal */
|
|
33
|
+
export declare function writeRenderPoseIfChanged(target: MatrixPoseTarget, ctx: Pick<GameContext, "sim">, entityId: string, live: PoseSource, scratch: RenderPose, memo: PoseMemo): boolean;
|
|
17
34
|
/** Write the interpolated render pose from `ctx.sim` (falls back to `live` when no history exists). @internal */
|
|
18
35
|
export declare function writeRenderPose(target: PoseWritable, ctx: Pick<GameContext, "sim">, entityId: string, live: PoseSource, scratch: RenderPose): void;
|
|
19
36
|
/** @internal */
|
package/dist/world/entityPose.js
CHANGED
|
@@ -3,6 +3,48 @@ export function writeEntityPose(target, source) {
|
|
|
3
3
|
target.position.set(source.position[0], source.position[1], source.position[2]);
|
|
4
4
|
target.rotation.y = source.rotationY;
|
|
5
5
|
}
|
|
6
|
+
/** A memo that never matches, so the first write to a marker group always lands. @internal */
|
|
7
|
+
export function emptyPoseMemo() {
|
|
8
|
+
return [Number.NaN, Number.NaN, Number.NaN, Number.NaN];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Write a marker pose and recompose only when it changed. The marker group runs with
|
|
12
|
+
* `matrixAutoUpdate=false`, so an idle actor or a parked prop leaves its whole subtree out of the
|
|
13
|
+
* per-frame world-matrix pass instead of forcing every child to recompose (#2038).
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export function writePoseIfChanged(target, source, memo) {
|
|
17
|
+
const [x, y, z] = source.position;
|
|
18
|
+
if (memo[0] === x && memo[1] === y && memo[2] === z && memo[3] === source.rotationY)
|
|
19
|
+
return false;
|
|
20
|
+
memo[0] = x;
|
|
21
|
+
memo[1] = y;
|
|
22
|
+
memo[2] = z;
|
|
23
|
+
memo[3] = source.rotationY;
|
|
24
|
+
target.position.set(x, y, z);
|
|
25
|
+
target.rotation.y = source.rotationY;
|
|
26
|
+
target.updateMatrix();
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/** {@link writePoseIfChanged} for the interpolated pose in `ctx.sim`, falling back to `live`. @internal */
|
|
30
|
+
export function writeRenderPoseIfChanged(target, ctx, entityId, live, scratch, memo) {
|
|
31
|
+
if (!ctx.sim.renderPose(entityId, scratch)) {
|
|
32
|
+
scratch[0] = live.position[0];
|
|
33
|
+
scratch[1] = live.position[1];
|
|
34
|
+
scratch[2] = live.position[2];
|
|
35
|
+
scratch[3] = live.rotationY;
|
|
36
|
+
}
|
|
37
|
+
if (memo[0] === scratch[0] && memo[1] === scratch[1] && memo[2] === scratch[2] && memo[3] === scratch[3])
|
|
38
|
+
return false;
|
|
39
|
+
memo[0] = scratch[0];
|
|
40
|
+
memo[1] = scratch[1];
|
|
41
|
+
memo[2] = scratch[2];
|
|
42
|
+
memo[3] = scratch[3];
|
|
43
|
+
target.position.set(scratch[0], scratch[1], scratch[2]);
|
|
44
|
+
target.rotation.y = scratch[3];
|
|
45
|
+
target.updateMatrix();
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
6
48
|
/** Write the interpolated render pose from `ctx.sim` (falls back to `live` when no history exists). @internal */
|
|
7
49
|
export function writeRenderPose(target, ctx, entityId, live, scratch) {
|
|
8
50
|
if (ctx.sim.renderPose(entityId, scratch)) {
|
|
@@ -24,15 +24,43 @@ export declare function canBatchStaticObject(candidate: StaticObjectCandidate):
|
|
|
24
24
|
export declare function isCompatibleStaticObject(object: SceneObject, model: ModelConfig | undefined, catalog: GameContextObjectEntry | null): boolean;
|
|
25
25
|
/** Stable exclusion snapshot; only compatibility transitions allocate. @internal */
|
|
26
26
|
export declare function incompatibleStaticObjectIds(batch: StaticObjectBatch, get: (id: string) => SceneObject | null, catalog: (id: string) => GameContextObjectEntry | null, previous: readonly string[], fallbackObjects?: Map<string, SceneObject>): readonly string[];
|
|
27
|
-
/** Batch cell edge in metres: one instanced draw per model per cell, culled as a unit. @internal */
|
|
28
|
-
export declare const STATIC_BATCH_CHUNK = 64;
|
|
29
27
|
/** Smallest same-model group worth an instanced draw; below this each prop draws on its own. @internal */
|
|
30
|
-
export declare const STATIC_BATCH_MIN =
|
|
31
|
-
/**
|
|
32
|
-
|
|
28
|
+
export declare const STATIC_BATCH_MIN = 2;
|
|
29
|
+
/**
|
|
30
|
+
* Batch cell edge in metres. One InstancedMesh has one bounding sphere, and three frustum-tests it in
|
|
31
|
+
* the shadow pass too, so a batch that spans the whole city submits every camera-visible instance to
|
|
32
|
+
* every shadow cascade. A cell keeps that test useful without fragmenting one model per prop.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export declare const STATIC_BATCH_CELL = 128;
|
|
36
|
+
/**
|
|
37
|
+
* Groups compatible authored or `scenery` props by model inside {@link STATIC_BATCH_CELL} metres, at
|
|
38
|
+
* least {@link STATIC_BATCH_MIN} of a model. Culling stays per instance: `syncStaticObjectSource`
|
|
39
|
+
* compacts each batch's drawn range against the visibility predicate every frame, and the cell keeps
|
|
40
|
+
* the batch's one bounding sphere small enough that three's own frustum test still rejects it in the
|
|
41
|
+
* shadow pass. Props spread thinner than the minimum in a cell fall back to individual draws.
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export declare function groupStaticObjects(candidates: readonly StaticObjectCandidate[], previous?: ReadonlyMap<string, StaticObjectBatch>, cell?: number): {
|
|
33
45
|
batches: StaticObjectBatch[];
|
|
34
46
|
singles: StaticObjectCandidate[];
|
|
35
47
|
};
|
|
48
|
+
/** Marks the instanced draws a static object batch owns so the batching probe can count them. @internal */
|
|
49
|
+
export declare const STATIC_BATCH_MESH_KEY = "jgStaticObjectBatch";
|
|
50
|
+
/** What one frame's static object batching produced. @internal */
|
|
51
|
+
export interface StaticBatchCounts {
|
|
52
|
+
/** Instanced draws the loaded models actually submitted. */
|
|
53
|
+
draws: number;
|
|
54
|
+
/** Distinct instances inside those draws after the visibility predicate compacted them. */
|
|
55
|
+
drawn: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sum the tagged draws in the live scene. `drawn` counts each instance once even though one instance
|
|
59
|
+
* appears in every material source's draw, so it is the number of instances the renderer was asked to
|
|
60
|
+
* draw this frame — a world-wide batch whose instances leave the view drops it without unmounting.
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
export declare function countStaticBatchDraws(scene: THREE.Object3D): StaticBatchCounts;
|
|
36
64
|
/** Rejects loaded scenes whose behavior cannot be represented by static instanced draws. @internal */
|
|
37
65
|
export declare function canInstanceStaticScene(scene: THREE.Object3D, clips: readonly THREE.AnimationClip[]): boolean;
|
|
38
66
|
/** Compact visible placements and update picking identities without allocating per instance. @internal */
|
|
@@ -49,12 +49,24 @@ export function incompatibleStaticObjectIds(batch, get, catalog, previous, fallb
|
|
|
49
49
|
return live !== null && !compatible(live) ? [live.instanceId] : [];
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
-
/** Batch cell edge in metres: one instanced draw per model per cell, culled as a unit. @internal */
|
|
53
|
-
export const STATIC_BATCH_CHUNK = 64;
|
|
54
52
|
/** Smallest same-model group worth an instanced draw; below this each prop draws on its own. @internal */
|
|
55
|
-
export const STATIC_BATCH_MIN =
|
|
56
|
-
/**
|
|
57
|
-
|
|
53
|
+
export const STATIC_BATCH_MIN = 2;
|
|
54
|
+
/**
|
|
55
|
+
* Batch cell edge in metres. One InstancedMesh has one bounding sphere, and three frustum-tests it in
|
|
56
|
+
* the shadow pass too, so a batch that spans the whole city submits every camera-visible instance to
|
|
57
|
+
* every shadow cascade. A cell keeps that test useful without fragmenting one model per prop.
|
|
58
|
+
* @internal
|
|
59
|
+
*/
|
|
60
|
+
export const STATIC_BATCH_CELL = 128;
|
|
61
|
+
/**
|
|
62
|
+
* Groups compatible authored or `scenery` props by model inside {@link STATIC_BATCH_CELL} metres, at
|
|
63
|
+
* least {@link STATIC_BATCH_MIN} of a model. Culling stays per instance: `syncStaticObjectSource`
|
|
64
|
+
* compacts each batch's drawn range against the visibility predicate every frame, and the cell keeps
|
|
65
|
+
* the batch's one bounding sphere small enough that three's own frustum test still rejects it in the
|
|
66
|
+
* shadow pass. Props spread thinner than the minimum in a cell fall back to individual draws.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export function groupStaticObjects(candidates, previous, cell = STATIC_BATCH_CELL) {
|
|
58
70
|
const groups = new Map();
|
|
59
71
|
const singles = [];
|
|
60
72
|
for (const candidate of candidates) {
|
|
@@ -63,7 +75,7 @@ export function groupStaticObjects(candidates, chunkSize = STATIC_BATCH_CHUNK, p
|
|
|
63
75
|
singles.push(candidate);
|
|
64
76
|
continue;
|
|
65
77
|
}
|
|
66
|
-
const key = JSON.stringify([object.catalogId, Math.floor(object.position[0] /
|
|
78
|
+
const key = JSON.stringify([object.catalogId, Math.floor(object.position[0] / cell), Math.floor(object.position[2] / cell), model]);
|
|
67
79
|
let group = groups.get(key);
|
|
68
80
|
if (group === undefined) {
|
|
69
81
|
group = { batch: { key, model, objects: [] }, candidates: [] };
|
|
@@ -84,6 +96,28 @@ export function groupStaticObjects(candidates, chunkSize = STATIC_BATCH_CHUNK, p
|
|
|
84
96
|
}
|
|
85
97
|
return { batches, singles };
|
|
86
98
|
}
|
|
99
|
+
/** Marks the instanced draws a static object batch owns so the batching probe can count them. @internal */
|
|
100
|
+
export const STATIC_BATCH_MESH_KEY = "jgStaticObjectBatch";
|
|
101
|
+
/**
|
|
102
|
+
* Sum the tagged draws in the live scene. `drawn` counts each instance once even though one instance
|
|
103
|
+
* appears in every material source's draw, so it is the number of instances the renderer was asked to
|
|
104
|
+
* draw this frame — a world-wide batch whose instances leave the view drops it without unmounting.
|
|
105
|
+
* @internal
|
|
106
|
+
*/
|
|
107
|
+
export function countStaticBatchDraws(scene) {
|
|
108
|
+
let draws = 0;
|
|
109
|
+
const drawn = new Set();
|
|
110
|
+
scene.traverse((node) => {
|
|
111
|
+
if (node.userData[STATIC_BATCH_MESH_KEY] !== true)
|
|
112
|
+
return;
|
|
113
|
+
draws += 1;
|
|
114
|
+
const ids = node.userData[POINTER_OBJECT_INSTANCES_KEY];
|
|
115
|
+
if (Array.isArray(ids))
|
|
116
|
+
for (const id of ids)
|
|
117
|
+
drawn.add(id);
|
|
118
|
+
});
|
|
119
|
+
return { draws, drawn: drawn.size };
|
|
120
|
+
}
|
|
87
121
|
/** Rejects loaded scenes whose behavior cannot be represented by static instanced draws. @internal */
|
|
88
122
|
export function canInstanceStaticScene(scene, clips) {
|
|
89
123
|
if (clips.length > 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"test": "bun test src"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@jgengine/core": "^0.
|
|
39
|
-
"@jgengine/react": "^0.
|
|
40
|
-
"@jgengine/ws": "^0.
|
|
38
|
+
"@jgengine/core": "^0.28.0",
|
|
39
|
+
"@jgengine/react": "^0.28.0",
|
|
40
|
+
"@jgengine/ws": "^0.28.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@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.28.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.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -29,6 +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`) give the animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` the 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.
|
|
32
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.
|
|
33
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.
|
|
34
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.
|