@jgengine/shell 0.12.0 → 0.14.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 +73 -1
- package/dist/CombatPresentation.js +2 -1
- package/dist/GameHost.d.ts +2 -0
- package/dist/GameHost.js +25 -2
- package/dist/GamePlayerShell.js +15 -1
- package/dist/Shell3dPresentation.js +2 -2
- package/dist/camera/GameOrbitCamera.js +7 -1
- package/dist/camera/cameraCollision.d.ts +28 -0
- package/dist/camera/cameraCollision.js +34 -0
- package/dist/camera/cameraRigs.js +8 -8
- package/dist/camera/index.d.ts +1 -0
- package/dist/camera/index.js +1 -0
- package/dist/camera/rigMath.d.ts +7 -0
- package/dist/camera/rigMath.js +16 -0
- package/dist/devtools/CollisionDebugWorld.js +15 -1
- package/dist/devtools/collisionDebugMath.d.ts +8 -0
- package/dist/devtools/collisionDebugMath.js +14 -5
- package/dist/environment/EnvironmentScene.js +6 -1
- package/dist/environment/RoadJunctions.d.ts +12 -0
- package/dist/environment/RoadJunctions.js +69 -0
- package/dist/environment/RoadRibbons.d.ts +8 -2
- package/dist/environment/RoadRibbons.js +26 -8
- package/dist/postfx/PostProcessing.js +27 -2
- package/dist/postfx/postfxOverlay.d.ts +24 -0
- package/dist/postfx/postfxOverlay.js +35 -0
- package/dist/presentationResolve.d.ts +5 -1
- package/dist/presentationResolve.js +3 -1
- package/dist/render/EntityPreview.d.ts +53 -0
- package/dist/render/EntityPreview.js +23 -0
- package/dist/render/PartMotion.d.ts +18 -0
- package/dist/render/PartMotion.js +91 -0
- package/dist/render/SceneModels.d.ts +9 -2
- package/dist/render/SceneModels.js +43 -179
- package/dist/render/measureBounds.d.ts +34 -0
- package/dist/render/measureBounds.js +131 -0
- package/dist/render/measureCollisionMesh.d.ts +26 -0
- package/dist/render/measureCollisionMesh.js +108 -0
- package/dist/render/resolveModel.js +16 -10
- package/dist/render/useModelAnimation.d.ts +10 -0
- package/dist/render/useModelAnimation.js +198 -0
- package/dist/structures/GeneratedBuilding.d.ts +4 -0
- package/dist/structures/GeneratedBuilding.js +17 -7
- package/dist/world/WorldHud.d.ts +3 -1
- package/dist/world/WorldHud.js +2 -2
- package/dist/world/WorldScene.js +3 -2
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +5 -2
- package/skills/jgengine/api.md +13 -5
- package/skills/jgengine-ui/SKILL.md +9 -4
- package/skills/jgengine-ui/api.md +138 -8
- package/skills/jgengine-ui/capabilities.md +72 -0
- package/skills/jgengine-ui/reference.md +40 -3
- package/skills/jgengine-world/api.md +82 -25
- package/skills/jgengine-world/capabilities.md +12 -4
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
|
|
4
|
+
import { buildJunctionPatch, buildRoadRibbon } from "@jgengine/core/world/roads";
|
|
5
|
+
import { junctionMarkings } from "@jgengine/core/world/streets";
|
|
6
|
+
function toGeometry(positions, indices) {
|
|
7
|
+
if (positions.length === 0)
|
|
8
|
+
return null;
|
|
9
|
+
const geometry = new BufferGeometry();
|
|
10
|
+
geometry.setAttribute("position", new BufferAttribute(positions, 3));
|
|
11
|
+
geometry.setIndex(new BufferAttribute(indices, 1));
|
|
12
|
+
geometry.computeVertexNormals();
|
|
13
|
+
return geometry;
|
|
14
|
+
}
|
|
15
|
+
/** Merge many thin marking ribbons (stop lines + crosswalk bars) into one buffer geometry. */
|
|
16
|
+
function mergedBars(bars, width, field, elevation) {
|
|
17
|
+
const ribbons = bars.map((bar) => buildRoadRibbon(bar, width, (x, z) => field.sampleHeight(x, z), { elevation }));
|
|
18
|
+
const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
|
|
19
|
+
const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
|
|
20
|
+
if (vertexCount === 0)
|
|
21
|
+
return null;
|
|
22
|
+
const positions = new Float32Array(vertexCount);
|
|
23
|
+
const indices = new Uint32Array(indexCount);
|
|
24
|
+
let vertexOffset = 0;
|
|
25
|
+
let indexOffset = 0;
|
|
26
|
+
for (const ribbon of ribbons) {
|
|
27
|
+
positions.set(ribbon.positions, vertexOffset);
|
|
28
|
+
const base = vertexOffset / 3;
|
|
29
|
+
for (let i = 0; i < ribbon.indices.length; i += 1)
|
|
30
|
+
indices[indexOffset + i] = ribbon.indices[i] + base;
|
|
31
|
+
vertexOffset += ribbon.positions.length;
|
|
32
|
+
indexOffset += ribbon.indices.length;
|
|
33
|
+
}
|
|
34
|
+
return toGeometry(positions, indices);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Renders welded road intersections: one asphalt disc patch per junction (covering the crossing
|
|
38
|
+
* ribbons so they read as one intersection instead of stacking/z-fighting), plus white stop lines
|
|
39
|
+
* and crosswalk bars at each approach. Fed the junctions the scene already computed from its roads.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export function RoadJunctions({ junctions, field, }) {
|
|
43
|
+
const patches = useMemo(() => junctions
|
|
44
|
+
.map((junction) => {
|
|
45
|
+
const patch = buildJunctionPatch(junction.center, junction.radius, (x, z) => field.sampleHeight(x, z), { elevation: junction.elevation + 0.02 });
|
|
46
|
+
return { color: junction.color, geometry: toGeometry(patch.positions, patch.indices) };
|
|
47
|
+
})
|
|
48
|
+
.filter((entry) => entry.geometry !== null), [junctions, field]);
|
|
49
|
+
const markings = useMemo(() => {
|
|
50
|
+
const stopBars = [];
|
|
51
|
+
const crossBars = [];
|
|
52
|
+
let elevation = 0;
|
|
53
|
+
for (const junction of junctions) {
|
|
54
|
+
elevation = Math.max(elevation, junction.elevation);
|
|
55
|
+
const marks = junctionMarkings(junction);
|
|
56
|
+
for (const bar of marks.stopLines)
|
|
57
|
+
stopBars.push(bar);
|
|
58
|
+
for (const bar of marks.crosswalkBars)
|
|
59
|
+
crossBars.push(bar);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
stops: mergedBars(stopBars, 0.5, field, elevation + 0.05),
|
|
63
|
+
crosswalks: mergedBars(crossBars, 0.55, field, elevation + 0.05),
|
|
64
|
+
};
|
|
65
|
+
}, [junctions, field]);
|
|
66
|
+
if (patches.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx("mesh", { geometry: patch.geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: patch.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null] }));
|
|
69
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { RoadEnvironmentDescriptor } from "@jgengine/core/world/features";
|
|
2
|
+
import { type DashExclusion } from "@jgengine/core/world/roads";
|
|
2
3
|
import type { TerrainField } from "@jgengine/core/world/terrain";
|
|
3
|
-
/**
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
|
|
6
|
+
* optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
|
|
7
|
+
* — the welded junction patches — so the center line does not paint across intersections).
|
|
8
|
+
*/
|
|
9
|
+
export declare function RoadRibbons({ road, field, exclusions, }: {
|
|
5
10
|
road: RoadEnvironmentDescriptor;
|
|
6
11
|
field: TerrainField;
|
|
12
|
+
exclusions?: readonly DashExclusion[];
|
|
7
13
|
}): import("react").JSX.Element | null;
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
|
-
import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
|
|
3
|
+
import { BufferAttribute, BufferGeometry, Color, DoubleSide } from "three";
|
|
4
4
|
import { buildRoadRibbon, dashSegments } from "@jgengine/core/world/roads";
|
|
5
|
-
import { offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
|
|
5
|
+
import { curbPaths, offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
|
|
6
|
+
/** Darken an asphalt color toward black for a curb/edge strip (deterministic, no alloc churn). */
|
|
7
|
+
function darken(hex, amount) {
|
|
8
|
+
const c = new Color(hex);
|
|
9
|
+
c.multiplyScalar(1 - amount);
|
|
10
|
+
return `#${c.getHexString()}`;
|
|
11
|
+
}
|
|
6
12
|
function toGeometry(positions, indices) {
|
|
7
13
|
if (positions.length === 0)
|
|
8
14
|
return null;
|
|
@@ -16,9 +22,9 @@ function ribbonGeometry(path, width, field, elevation) {
|
|
|
16
22
|
const ribbon = buildRoadRibbon(path, width, (x, z) => field.sampleHeight(x, z), { elevation });
|
|
17
23
|
return toGeometry(ribbon.positions, ribbon.indices);
|
|
18
24
|
}
|
|
19
|
-
function mergedDashGeometry(road, field) {
|
|
25
|
+
function mergedDashGeometry(road, field, exclusions) {
|
|
20
26
|
const width = Math.max(0.25, road.width * 0.035);
|
|
21
|
-
const ribbons = dashSegments(road.path, 3.2, 4).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
|
|
27
|
+
const ribbons = dashSegments(road.path, 3.2, 4, exclusions).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
|
|
22
28
|
const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
|
|
23
29
|
const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
|
|
24
30
|
if (vertexCount === 0)
|
|
@@ -38,10 +44,22 @@ function mergedDashGeometry(road, field) {
|
|
|
38
44
|
}
|
|
39
45
|
return toGeometry(positions, indices);
|
|
40
46
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
|
|
49
|
+
* optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
|
|
50
|
+
* — the welded junction patches — so the center line does not paint across intersections).
|
|
51
|
+
*/
|
|
52
|
+
export function RoadRibbons({ road, field, exclusions = [], }) {
|
|
43
53
|
const asphalt = useMemo(() => ribbonGeometry(road.path, road.width, field, road.elevation), [road, field]);
|
|
44
|
-
const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field) : null), [road, field]);
|
|
54
|
+
const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field, exclusions) : null), [road, field, exclusions]);
|
|
55
|
+
const curbColor = useMemo(() => darken(road.color, 0.45), [road.color]);
|
|
56
|
+
const curbs = useMemo(() => {
|
|
57
|
+
// A thin strip straddling each asphalt edge, lifted just above the ribbon.
|
|
58
|
+
const strip = Math.max(0.4, road.width * 0.09);
|
|
59
|
+
return curbPaths(road, strip / 2)
|
|
60
|
+
.map((path) => ribbonGeometry(path, strip, field, road.elevation + 0.02))
|
|
61
|
+
.filter((geometry) => geometry !== null);
|
|
62
|
+
}, [road, field]);
|
|
45
63
|
const sidewalks = useMemo(() => {
|
|
46
64
|
const width = sidewalkWidthOf(road);
|
|
47
65
|
if (width <= 0)
|
|
@@ -53,5 +71,5 @@ export function RoadRibbons({ road, field }) {
|
|
|
53
71
|
}, [road, field]);
|
|
54
72
|
if (asphalt === null)
|
|
55
73
|
return null;
|
|
56
|
-
return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
|
|
74
|
+
return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), curbs.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `curb-${index}`))), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
|
|
57
75
|
}
|
|
@@ -6,6 +6,31 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
|
|
|
6
6
|
import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
|
|
7
7
|
import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
|
|
8
8
|
import { createGradePass } from "./gradeShader.js";
|
|
9
|
+
import { hidePostfxOverlays, restorePostfxOverlays } from "./postfxOverlay.js";
|
|
10
|
+
/**
|
|
11
|
+
* GTAO renders the scene with an `overrideMaterial` normal/depth prepass, which stamps
|
|
12
|
+
* transparent overlay quads (additive VFX sprites, telegraphs, debug gizmos) into the AO
|
|
13
|
+
* buffers as opaque squares — the AO composite then multiplies them toward black. Hide
|
|
14
|
+
* marked overlay subtrees for the whole pass: the beauty buffer it composites over was
|
|
15
|
+
* already rendered with them in.
|
|
16
|
+
*/
|
|
17
|
+
class OverlayAwareGTAOPass extends GTAOPass {
|
|
18
|
+
hiddenOverlays = [];
|
|
19
|
+
render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
|
|
20
|
+
hidePostfxOverlays(this.scene, this.hiddenOverlays);
|
|
21
|
+
super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
|
|
22
|
+
restorePostfxOverlays(this.hiddenOverlays);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** DOF's depth prepass has the same `overrideMaterial` overlay-stamping problem as GTAO. */
|
|
26
|
+
class OverlayAwareBokehPass extends BokehPass {
|
|
27
|
+
hiddenOverlays = [];
|
|
28
|
+
render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
|
|
29
|
+
hidePostfxOverlays(this.scene, this.hiddenOverlays);
|
|
30
|
+
super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
|
|
31
|
+
restorePostfxOverlays(this.hiddenOverlays);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
9
34
|
const TONE_MAPPING = {
|
|
10
35
|
aces: THREE.ACESFilmicToneMapping,
|
|
11
36
|
agx: THREE.AgXToneMapping,
|
|
@@ -60,7 +85,7 @@ export function PostProcessing({ config, quality = "high" }) {
|
|
|
60
85
|
composer.addPass(new RenderPass(scene, camera));
|
|
61
86
|
const heavyPasses = quality === "high";
|
|
62
87
|
if (heavyPasses && config.ao !== undefined && config.ao !== false) {
|
|
63
|
-
const ao = new
|
|
88
|
+
const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
|
|
64
89
|
ao.blendIntensity = config.ao.blend ?? 1;
|
|
65
90
|
ao.updateGtaoMaterial({
|
|
66
91
|
radius: config.ao.radius ?? 1.8,
|
|
@@ -78,7 +103,7 @@ export function PostProcessing({ config, quality = "high" }) {
|
|
|
78
103
|
let dof = null;
|
|
79
104
|
if (heavyPasses && config.dof !== undefined && config.dof !== false) {
|
|
80
105
|
const d = config.dof;
|
|
81
|
-
dof = new
|
|
106
|
+
dof = new OverlayAwareBokehPass(scene, camera, {
|
|
82
107
|
focus: d.focus ?? 18,
|
|
83
108
|
aperture: d.aperture ?? 0.00025,
|
|
84
109
|
maxblur: d.maxBlur ?? 0.01,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect —
|
|
4
|
+
* additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses
|
|
5
|
+
* (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the
|
|
6
|
+
* scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps
|
|
7
|
+
* in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own
|
|
8
|
+
* combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
|
|
9
|
+
*/
|
|
10
|
+
export declare const POSTFX_OVERLAY_USERDATA: {
|
|
11
|
+
readonly jgPostfxOverlay: true;
|
|
12
|
+
};
|
|
13
|
+
/** True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}. */
|
|
14
|
+
export declare function isPostfxOverlay(object: {
|
|
15
|
+
userData?: Record<string, unknown>;
|
|
16
|
+
}): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Hide every currently visible marked object under `scene`, recording what was hidden into
|
|
19
|
+
* `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
|
|
20
|
+
* undo exactly that set.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void;
|
|
23
|
+
/** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
|
|
24
|
+
export declare function restorePostfxOverlays(hidden: THREE.Object3D[]): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect —
|
|
3
|
+
* additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses
|
|
4
|
+
* (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the
|
|
5
|
+
* scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps
|
|
6
|
+
* in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own
|
|
7
|
+
* combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
|
|
8
|
+
*/
|
|
9
|
+
export const POSTFX_OVERLAY_USERDATA = {
|
|
10
|
+
jgPostfxOverlay: true,
|
|
11
|
+
};
|
|
12
|
+
/** True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}. */
|
|
13
|
+
export function isPostfxOverlay(object) {
|
|
14
|
+
return object.userData?.jgPostfxOverlay === true;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Hide every currently visible marked object under `scene`, recording what was hidden into
|
|
18
|
+
* `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
|
|
19
|
+
* undo exactly that set.
|
|
20
|
+
*/
|
|
21
|
+
export function hidePostfxOverlays(scene, out) {
|
|
22
|
+
out.length = 0;
|
|
23
|
+
scene.traverse((object) => {
|
|
24
|
+
if (object.visible && isPostfxOverlay(object)) {
|
|
25
|
+
object.visible = false;
|
|
26
|
+
out.push(object);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
|
|
31
|
+
export function restorePostfxOverlays(hidden) {
|
|
32
|
+
for (const object of hidden)
|
|
33
|
+
object.visible = true;
|
|
34
|
+
hidden.length = 0;
|
|
35
|
+
}
|
|
@@ -5,17 +5,21 @@ export interface ResolvedWorldOverlayBars {
|
|
|
5
5
|
statId: string;
|
|
6
6
|
roles?: readonly CatalogEntityRole[];
|
|
7
7
|
maxDistance?: number;
|
|
8
|
+
/** Nameplates only: draw the built-in HP bar. `false` yields a name-only plate. Defaults to `true`. */
|
|
9
|
+
showHealth?: boolean;
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
|
|
11
13
|
* single resolved shape. `undefined` / `false` → `null` (off); `true` → default stat `"health"`;
|
|
12
|
-
* object → explicit fields with `statId` defaulting to `"health"`.
|
|
14
|
+
* object → explicit fields with `statId` defaulting to `"health"`. `showHealth` is nameplate-only
|
|
15
|
+
* (ignored by `worldHealthBars`, which is itself a bar).
|
|
13
16
|
* @internal
|
|
14
17
|
*/
|
|
15
18
|
export declare function resolveWorldOverlayBars(config: boolean | {
|
|
16
19
|
statId?: string;
|
|
17
20
|
roles?: readonly CatalogEntityRole[];
|
|
18
21
|
maxDistance?: number;
|
|
22
|
+
showHealth?: boolean;
|
|
19
23
|
} | undefined): ResolvedWorldOverlayBars | null;
|
|
20
24
|
/** Resolved combat presentation channels (each boolean is whether that stack piece mounts). @internal */
|
|
21
25
|
export interface ResolvedPresentationEffects {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
|
|
3
3
|
* single resolved shape. `undefined` / `false` → `null` (off); `true` → default stat `"health"`;
|
|
4
|
-
* object → explicit fields with `statId` defaulting to `"health"`.
|
|
4
|
+
* object → explicit fields with `statId` defaulting to `"health"`. `showHealth` is nameplate-only
|
|
5
|
+
* (ignored by `worldHealthBars`, which is itself a bar).
|
|
5
6
|
* @internal
|
|
6
7
|
*/
|
|
7
8
|
export function resolveWorldOverlayBars(config) {
|
|
@@ -13,6 +14,7 @@ export function resolveWorldOverlayBars(config) {
|
|
|
13
14
|
statId: config.statId ?? "health",
|
|
14
15
|
...(config.roles === undefined ? {} : { roles: config.roles }),
|
|
15
16
|
...(config.maxDistance === undefined ? {} : { maxDistance: config.maxDistance }),
|
|
17
|
+
...(config.showHealth === undefined ? {} : { showHealth: config.showHealth }),
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
const ALL_ON = {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
import { type StudioMood } from "../scene/StudioStage.js";
|
|
4
|
+
/** Props for {@link EntityPreview}. */
|
|
5
|
+
export interface EntityPreviewProps {
|
|
6
|
+
/**
|
|
7
|
+
* The rendered entity — the game's *own* `renderEntity` content (e.g. its procedural body component),
|
|
8
|
+
* which may call `useEntityRenderCues(id)` / `useGameContext()` internally to mirror the live game.
|
|
9
|
+
* Takes precedence over `model`. Omit both to render an empty lit stage.
|
|
10
|
+
*/
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* Convenience for engine-native models: render this {@link ModelConfig} via `IsolatedEntityModel`.
|
|
14
|
+
* Pair with `instanceId` to bind it to a live entity so the preview walks / flinches / topples in
|
|
15
|
+
* sync with the world. Ignored when `children` is supplied.
|
|
16
|
+
*/
|
|
17
|
+
model?: ModelConfig;
|
|
18
|
+
/** Live entity id driving motion cues for `model` (also lets a custom child resolve the same entity). */
|
|
19
|
+
instanceId?: string;
|
|
20
|
+
/** Lighting mood for the stage. Default `"studio"`. */
|
|
21
|
+
mood?: StudioMood;
|
|
22
|
+
/** Override the backdrop color (hex). Default: the mood's backdrop. */
|
|
23
|
+
backdrop?: string;
|
|
24
|
+
/** Turntable spin (rad/s). Default 0 — a still portrait that mirrors the live entity's own motion. */
|
|
25
|
+
turntable?: number;
|
|
26
|
+
/** Yaw the subject to face the camera each frame instead of spinning. Overrides `turntable`. */
|
|
27
|
+
faceCamera?: boolean;
|
|
28
|
+
/** The subject's declared forward axis for `faceCamera`. Default `[0, 0, 1]`. */
|
|
29
|
+
forward?: readonly [number, number, number];
|
|
30
|
+
/** Draw the backdrop + floor (true) or keep a transparent stage as a pure lighting rig (false). Default true. */
|
|
31
|
+
environment?: boolean;
|
|
32
|
+
/** Camera position framing the subject. Default `[0, 1, 3]`. */
|
|
33
|
+
cameraPosition?: [number, number, number];
|
|
34
|
+
/** Camera field of view. Default 35. */
|
|
35
|
+
fov?: number;
|
|
36
|
+
/** Device pixel ratio clamp. Default `[1, 2]`. */
|
|
37
|
+
dpr?: number | [number, number];
|
|
38
|
+
className?: string;
|
|
39
|
+
style?: CSSProperties;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout
|
|
43
|
+
* viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the
|
|
44
|
+
* {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so
|
|
45
|
+
* the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a
|
|
46
|
+
* {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly
|
|
47
|
+
* GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native
|
|
48
|
+
* `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks,
|
|
49
|
+
* flinches, and topples in lockstep with the world instead of a canned spin.
|
|
50
|
+
*
|
|
51
|
+
* @capability entity-preview live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
|
|
52
|
+
*/
|
|
53
|
+
export declare function EntityPreview({ children, model, instanceId, mood, backdrop, turntable, faceCamera, forward, environment, cameraPosition, fov, dpr, className, style, }: EntityPreviewProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Canvas } from "@react-three/fiber";
|
|
3
|
+
import { GameContextBridge, useOptionalGameContext } from "@jgengine/react/provider";
|
|
4
|
+
import { StudioStage } from "../scene/StudioStage.js";
|
|
5
|
+
import { IsolatedEntityModel } from "./SceneModels.js";
|
|
6
|
+
/**
|
|
7
|
+
* Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout
|
|
8
|
+
* viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the
|
|
9
|
+
* {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so
|
|
10
|
+
* the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a
|
|
11
|
+
* {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly
|
|
12
|
+
* GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native
|
|
13
|
+
* `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks,
|
|
14
|
+
* flinches, and topples in lockstep with the world instead of a canned spin.
|
|
15
|
+
*
|
|
16
|
+
* @capability entity-preview live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
|
|
17
|
+
*/
|
|
18
|
+
export function EntityPreview({ children, model, instanceId, mood = "studio", backdrop, turntable = 0, faceCamera = false, forward, environment = true, cameraPosition = [0, 1, 3], fov = 35, dpr = [1, 2], className, style, }) {
|
|
19
|
+
const ctx = useOptionalGameContext();
|
|
20
|
+
const subject = children ??
|
|
21
|
+
(model !== undefined ? _jsx(IsolatedEntityModel, { model: model, instanceId: instanceId }) : null);
|
|
22
|
+
return (_jsx("div", { "data-jg-entity-preview": "", className: className, style: { position: "relative", width: "100%", height: "100%", ...style }, children: _jsx(Canvas, { shadows: true, dpr: dpr, camera: { position: cameraPosition, fov }, gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: _jsx(GameContextBridge, { context: ctx, children: _jsx(StudioStage, { mood: mood, ...(backdrop === undefined ? {} : { backdrop }), turntable: turntable, faceCamera: faceCamera, ...(forward === undefined ? {} : { forward }), environment: environment, children: subject }) }) }) }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ModelConfig, ModelPart } from "@jgengine/core/game/playableGame";
|
|
3
|
+
/**
|
|
4
|
+
* Procedural motion rig for a rig-less part-composed character (`ModelPart.role` — see
|
|
5
|
+
* `@jgengine/core/game/partAnimation`). Wraps the whole composition in a root group that bobs,
|
|
6
|
+
* breathes, flinches on `combat.hitReaction`, and topples on `entity.died`, while each
|
|
7
|
+
* role-tagged part swings around its authored transform — legs/arms counter-phase from the
|
|
8
|
+
* entity's live movement speed, head counter-sway, tail wag, wing flap. Untagged parts render
|
|
9
|
+
* as static kit pieces. Children are the base model content (primitive, attachments).
|
|
10
|
+
*/
|
|
11
|
+
export declare function PartMotionRig({ parts, model, instanceId, renderPart, children, }: {
|
|
12
|
+
parts: readonly ModelPart[];
|
|
13
|
+
model: ModelConfig;
|
|
14
|
+
instanceId?: string;
|
|
15
|
+
/** Renders one part's model content (the shell passes its part renderer to avoid an import cycle). */
|
|
16
|
+
renderPart: (part: ModelPart, index: number) => ReactNode;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): ReactNode;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { createPartPose, partMotionPhase, sampleBodyPose, samplePartPose, } from "@jgengine/core/game/partAnimation";
|
|
6
|
+
import { useGameContext } from "@jgengine/react/provider";
|
|
7
|
+
const FLINCH_RELEASE_SEC = 0.35;
|
|
8
|
+
const DEATH_RAMP_PER_SEC = 2.5;
|
|
9
|
+
const SPEED_SMOOTHING = 12;
|
|
10
|
+
/**
|
|
11
|
+
* Procedural motion rig for a rig-less part-composed character (`ModelPart.role` — see
|
|
12
|
+
* `@jgengine/core/game/partAnimation`). Wraps the whole composition in a root group that bobs,
|
|
13
|
+
* breathes, flinches on `combat.hitReaction`, and topples on `entity.died`, while each
|
|
14
|
+
* role-tagged part swings around its authored transform — legs/arms counter-phase from the
|
|
15
|
+
* entity's live movement speed, head counter-sway, tail wag, wing flap. Untagged parts render
|
|
16
|
+
* as static kit pieces. Children are the base model content (primitive, attachments).
|
|
17
|
+
*/
|
|
18
|
+
export function PartMotionRig({ parts, model, instanceId, renderPart, children, }) {
|
|
19
|
+
const ctx = useGameContext();
|
|
20
|
+
const rootRef = useRef(null);
|
|
21
|
+
const partRefs = useRef([]);
|
|
22
|
+
const motionRef = useRef({
|
|
23
|
+
lastPos: null,
|
|
24
|
+
speed: 0,
|
|
25
|
+
flinch: 0,
|
|
26
|
+
death: 0,
|
|
27
|
+
});
|
|
28
|
+
const phase = useMemo(() => partMotionPhase(instanceId ?? model.url), [instanceId, model.url]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (instanceId === undefined)
|
|
31
|
+
return;
|
|
32
|
+
const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
|
|
33
|
+
if (event.instanceId === instanceId)
|
|
34
|
+
motionRef.current.flinch = 1;
|
|
35
|
+
});
|
|
36
|
+
const offDied = ctx.game.events.on("entity.died", (event) => {
|
|
37
|
+
if (event.instanceId === instanceId)
|
|
38
|
+
motionRef.current.death = Math.max(motionRef.current.death, 1e-6);
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
offHit();
|
|
42
|
+
offDied();
|
|
43
|
+
};
|
|
44
|
+
}, [ctx, instanceId]);
|
|
45
|
+
const input = useMemo(() => ({ timeSec: 0, speed: 0, phase, flinch: 0, death: 0 }), [phase]);
|
|
46
|
+
const pose = useMemo(() => createPartPose(), []);
|
|
47
|
+
useFrame((frameState, delta) => {
|
|
48
|
+
const motion = motionRef.current;
|
|
49
|
+
if (delta > 0 && instanceId !== undefined) {
|
|
50
|
+
const entity = ctx.scene.entity.get(instanceId);
|
|
51
|
+
if (entity !== null) {
|
|
52
|
+
const [x, , z] = entity.position;
|
|
53
|
+
if (motion.lastPos !== null) {
|
|
54
|
+
const instantSpeed = Math.hypot(x - motion.lastPos[0], z - motion.lastPos[2]) / delta;
|
|
55
|
+
motion.speed += (instantSpeed - motion.speed) * Math.min(1, delta * SPEED_SMOOTHING);
|
|
56
|
+
}
|
|
57
|
+
motion.lastPos = [x, entity.position[1], z];
|
|
58
|
+
}
|
|
59
|
+
motion.flinch = Math.max(0, motion.flinch - delta / FLINCH_RELEASE_SEC);
|
|
60
|
+
if (motion.death > 0)
|
|
61
|
+
motion.death = Math.min(1, motion.death + delta * DEATH_RAMP_PER_SEC);
|
|
62
|
+
}
|
|
63
|
+
input.timeSec = frameState.clock.elapsedTime;
|
|
64
|
+
input.speed = motion.speed;
|
|
65
|
+
input.flinch = motion.flinch;
|
|
66
|
+
input.death = motion.death;
|
|
67
|
+
const root = rootRef.current;
|
|
68
|
+
if (root !== null) {
|
|
69
|
+
sampleBodyPose(input, model.partMotion, pose);
|
|
70
|
+
root.position.set(pose.position[0], pose.position[1], pose.position[2]);
|
|
71
|
+
root.rotation.set(pose.rotation[0], pose.rotation[1], pose.rotation[2]);
|
|
72
|
+
root.scale.set(pose.scale[0], pose.scale[1], pose.scale[2]);
|
|
73
|
+
}
|
|
74
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
75
|
+
const part = parts[index];
|
|
76
|
+
const group = partRefs.current[index];
|
|
77
|
+
if (group === null || group === undefined || part.role === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
samplePartPose(part.role, input, model.partMotion, pose);
|
|
80
|
+
const base = part.position ?? [0, 0, 0];
|
|
81
|
+
const baseRotation = part.rotation ?? [0, 0, 0];
|
|
82
|
+
group.position.set(base[0] + pose.position[0], base[1] + pose.position[1], base[2] + pose.position[2]);
|
|
83
|
+
group.rotation.set(baseRotation[0] + pose.rotation[0], baseRotation[1] + pose.rotation[1], baseRotation[2] + pose.rotation[2]);
|
|
84
|
+
const baseScale = part.scale ?? 1;
|
|
85
|
+
group.scale.set(baseScale * pose.scale[0], baseScale * pose.scale[1], baseScale * pose.scale[2]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return (_jsxs("group", { ref: rootRef, children: [children, parts.map((part, index) => (_jsx("group", { ref: (group) => {
|
|
89
|
+
partRefs.current[index] = group;
|
|
90
|
+
}, position: part.position ?? [0, 0, 0], rotation: part.rotation ?? [0, 0, 0], scale: part.scale ?? 1, children: renderPart(part, index) }, index)))] }));
|
|
91
|
+
}
|
|
@@ -3,12 +3,19 @@ import type { EntitySpriteConfig, ModelConfig } from "@jgengine/core/game/playab
|
|
|
3
3
|
export declare function EntitySprite({ sprite }: {
|
|
4
4
|
sprite: EntitySpriteConfig;
|
|
5
5
|
}): import("react").JSX.Element;
|
|
6
|
-
export declare function IsolatedEntityModel({ model, instanceId, fallback, }: {
|
|
6
|
+
export declare function IsolatedEntityModel({ model, instanceId, measure, fallback, }: {
|
|
7
7
|
model: ModelConfig;
|
|
8
8
|
instanceId?: string;
|
|
9
|
+
measure?: MeasureTarget;
|
|
9
10
|
fallback?: ReactNode;
|
|
10
11
|
}): import("react").JSX.Element;
|
|
11
|
-
|
|
12
|
+
/** Where a measured model reports its rendered bounds: an entity kind or an object catalog id. */
|
|
13
|
+
export interface MeasureTarget {
|
|
14
|
+
target: "entity" | "object";
|
|
15
|
+
key: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function EntityModel({ model, instanceId, measure, }: {
|
|
12
18
|
model: ModelConfig;
|
|
13
19
|
instanceId?: string;
|
|
20
|
+
measure?: MeasureTarget;
|
|
14
21
|
}): import("react").JSX.Element;
|