@jgengine/shell 0.13.0 → 0.15.0-next.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 +529 -1
- package/dist/CombatPresentation.js +2 -1
- package/dist/GamePlayerShell.js +25 -5
- package/dist/Shell3dPresentation.d.ts +6 -6
- package/dist/Shell3dPresentation.js +4 -4
- package/dist/camera/ControllerCameraShake.d.ts +31 -0
- package/dist/camera/ControllerCameraShake.js +34 -0
- package/dist/camera/GameCameraRig.js +10 -5
- package/dist/camera/GameOrbitCamera.js +1 -10
- package/dist/camera/cameraRigs.js +16 -4
- package/dist/camera/index.d.ts +1 -0
- package/dist/camera/index.js +1 -0
- package/dist/camera/rigMath.d.ts +2 -0
- package/dist/camera/rigMath.js +7 -0
- package/dist/devtools/CollisionDebugWorld.js +15 -1
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/collisionDebugMath.d.ts +8 -0
- package/dist/devtools/collisionDebugMath.js +14 -5
- package/dist/drivers/FrameDriver.d.ts +4 -1
- package/dist/drivers/FrameDriver.js +5 -3
- package/dist/environment/DayNightSky.d.ts +33 -0
- package/dist/environment/DayNightSky.js +51 -0
- package/dist/environment/Daylight.d.ts +3 -1
- package/dist/environment/Daylight.js +7 -3
- package/dist/environment/EnvironmentScene.js +6 -6
- package/dist/environment/RoadJunctions.d.ts +8 -4
- package/dist/environment/RoadJunctions.js +16 -15
- package/dist/environment/RoadRibbons.d.ts +42 -6
- package/dist/environment/RoadRibbons.js +150 -27
- package/dist/environment/VolumetricClouds.js +3 -3
- package/dist/environment/index.d.ts +1 -0
- package/dist/environment/index.js +1 -0
- package/dist/gameKit.d.ts +7 -4
- package/dist/gameKit.js +8 -5
- package/dist/inputSink.js +14 -0
- package/dist/materialOverride.js +30 -0
- package/dist/postfx/PostProcessing.d.ts +3 -2
- package/dist/postfx/PostProcessing.js +38 -5
- package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
- package/dist/postfx/ScreenEffectsOverlay.js +70 -0
- package/dist/postfx/postfxOverlay.d.ts +24 -0
- package/dist/postfx/postfxOverlay.js +35 -0
- package/dist/presentationResolve.d.ts +3 -2
- package/dist/presentationResolve.js +11 -6
- package/dist/render/CascadedShadows.d.ts +13 -0
- package/dist/render/CascadedShadows.js +96 -0
- package/dist/render/EntityPreview.d.ts +53 -0
- package/dist/render/EntityPreview.js +23 -0
- package/dist/render/EnvironmentLighting.d.ts +23 -7
- package/dist/render/EnvironmentLighting.js +69 -10
- package/dist/render/PartMotion.js +4 -4
- package/dist/render/SceneLighting.js +7 -0
- package/dist/render/SceneModels.d.ts +37 -1
- package/dist/render/SceneModels.js +52 -10
- package/dist/render/measureBounds.js +4 -0
- package/dist/render/measureCollisionMesh.d.ts +26 -0
- package/dist/render/measureCollisionMesh.js +108 -0
- package/dist/render/modelLoad.d.ts +41 -5
- package/dist/render/modelLoad.js +85 -18
- package/dist/render/sceneCapture.d.ts +27 -0
- package/dist/render/sceneCapture.js +43 -0
- package/dist/render/useDisposable.d.ts +16 -0
- package/dist/render/useDisposable.js +24 -0
- package/dist/render/useModelAnimation.js +47 -4
- package/dist/scene/AuthoredScene.js +14 -24
- package/dist/scene/CityRenderer.js +103 -36
- package/dist/scene/GrassFieldRenderer.js +4 -1
- package/dist/scene/PoleLineRenderer.js +7 -16
- package/dist/settings/QuickControls.js +5 -3
- package/dist/settings/SettingsMenu.js +6 -3
- package/dist/settings/appliedSettings.d.ts +3 -1
- package/dist/settings/appliedSettings.js +17 -5
- package/dist/settings/settingsController.js +11 -5
- package/dist/shellMovement.d.ts +15 -1
- package/dist/shellMovement.js +15 -1
- package/dist/structures/GeneratedBuilding.js +4 -5
- package/dist/structures/TransformGizmo.js +6 -5
- package/dist/terrain/CarvedTerrain.js +3 -7
- package/dist/terrain/GrassField.js +2 -2
- package/dist/terrain/ProceduralGround.js +3 -5
- package/dist/terrain/grassMaterial.d.ts +8 -2
- package/dist/terrain/grassMaterial.js +24 -5
- package/dist/terrain/index.d.ts +1 -1
- package/dist/terrain/terrainDetailMaterial.js +11 -2
- package/dist/terrain/terrainMath.d.ts +38 -1
- package/dist/terrain/terrainMath.js +86 -64
- package/dist/touch/TouchControlsOverlay.d.ts +6 -2
- package/dist/touch/TouchControlsOverlay.js +136 -17
- package/dist/vfx/ParticleField.d.ts +32 -0
- package/dist/vfx/ParticleField.js +91 -0
- package/dist/vfx/WorldFloatingText.d.ts +32 -0
- package/dist/vfx/WorldFloatingText.js +32 -0
- package/dist/water/Ocean.js +3 -6
- package/dist/weather/LightningStrike.js +3 -9
- package/dist/weather/RainField.js +3 -7
- package/dist/weather/SnowField.js +3 -7
- package/dist/world/DataObjects.js +3 -6
- package/dist/world/GridWorldScene.js +3 -6
- package/dist/world/InstancedBodies.js +3 -6
- package/dist/world/InstancedJoints.js +2 -2
- package/dist/world/SpriteBatch.js +3 -6
- package/dist/world/WorldEntityFrames.d.ts +47 -0
- package/dist/world/WorldEntityFrames.js +75 -0
- package/dist/world/WorldHud.js +7 -5
- package/dist/world/WorldPings.d.ts +26 -0
- package/dist/world/WorldPings.js +68 -0
- package/dist/world/WorldScene.js +9 -4
- package/dist/world/pingPulse.d.ts +21 -0
- package/dist/world/pingPulse.js +17 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +7 -3
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-ui/SKILL.md +8 -3
- package/skills/jgengine-ui/api.md +732 -23
- package/skills/jgengine-ui/capabilities.md +371 -2
- package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
- package/skills/jgengine-ui/reference.md +7 -4
- package/skills/jgengine-world/api.md +408 -12
- package/skills/jgengine-world/capabilities.md +127 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { useThree } from "@react-three/fiber";
|
|
2
2
|
import { useEffect } from "react";
|
|
3
3
|
import * as THREE from "three";
|
|
4
|
-
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Image-based lighting from a procedural sky/ground probe (not three's stock
|
|
6
|
+
* RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
|
|
7
|
+
* an optional sun glint; re-render when sky/sun props change so a daylight cycle
|
|
8
|
+
* regenerates the environment map with the authored world.
|
|
9
|
+
*
|
|
10
|
+
* Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
|
|
10
11
|
* @internal shell-internal default lighting; games never import it.
|
|
11
12
|
*/
|
|
12
|
-
export function EnvironmentLighting({ intensity = 0.
|
|
13
|
+
export function EnvironmentLighting({ intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
|
|
13
14
|
const gl = useThree((state) => state.gl);
|
|
14
15
|
const scene = useThree((state) => state.scene);
|
|
15
16
|
useEffect(() => {
|
|
16
17
|
const pmrem = new THREE.PMREMGenerator(gl);
|
|
17
|
-
const
|
|
18
|
-
const target = pmrem.fromScene(
|
|
18
|
+
const probe = buildSkyProbeScene({ skyColor, groundColor, sunDirection, sunColor });
|
|
19
|
+
const target = pmrem.fromScene(probe, 0.04);
|
|
19
20
|
const previous = scene.environment;
|
|
20
21
|
scene.environment = target.texture;
|
|
21
22
|
const sceneWithIntensity = scene;
|
|
22
23
|
const previousIntensity = sceneWithIntensity.environmentIntensity;
|
|
23
24
|
sceneWithIntensity.environmentIntensity = intensity;
|
|
24
|
-
|
|
25
|
+
disposeScene(probe);
|
|
25
26
|
pmrem.dispose();
|
|
26
27
|
return () => {
|
|
27
28
|
target.dispose();
|
|
@@ -29,6 +30,64 @@ export function EnvironmentLighting({ intensity = 0.28 }) {
|
|
|
29
30
|
if (previousIntensity !== undefined)
|
|
30
31
|
sceneWithIntensity.environmentIntensity = previousIntensity;
|
|
31
32
|
};
|
|
32
|
-
}, [gl, scene, intensity]);
|
|
33
|
+
}, [gl, scene, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
|
|
33
34
|
return null;
|
|
34
35
|
}
|
|
36
|
+
function buildSkyProbeScene(options) {
|
|
37
|
+
const probe = new THREE.Scene();
|
|
38
|
+
// Inward-facing sphere: sky above, ground below, so PMREM samples a sky dome not a studio box.
|
|
39
|
+
const geo = new THREE.SphereGeometry(50, 32, 16);
|
|
40
|
+
const mat = new THREE.ShaderMaterial({
|
|
41
|
+
side: THREE.BackSide,
|
|
42
|
+
depthWrite: false,
|
|
43
|
+
uniforms: {
|
|
44
|
+
sky: { value: new THREE.Color(options.skyColor) },
|
|
45
|
+
ground: { value: new THREE.Color(options.groundColor) },
|
|
46
|
+
},
|
|
47
|
+
vertexShader: /* glsl */ `
|
|
48
|
+
varying vec3 vDir;
|
|
49
|
+
void main() {
|
|
50
|
+
vDir = normalize(position);
|
|
51
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
52
|
+
}
|
|
53
|
+
`,
|
|
54
|
+
fragmentShader: /* glsl */ `
|
|
55
|
+
uniform vec3 sky;
|
|
56
|
+
uniform vec3 ground;
|
|
57
|
+
varying vec3 vDir;
|
|
58
|
+
void main() {
|
|
59
|
+
float t = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0);
|
|
60
|
+
// Soft horizon blend so the equator is not a hard band.
|
|
61
|
+
float w = smoothstep(0.0, 1.0, t);
|
|
62
|
+
gl_FragColor = vec4(mix(ground, sky, w), 1.0);
|
|
63
|
+
}
|
|
64
|
+
`,
|
|
65
|
+
});
|
|
66
|
+
probe.add(new THREE.Mesh(geo, mat));
|
|
67
|
+
if (options.sunDirection !== undefined) {
|
|
68
|
+
const dir = new THREE.Vector3(options.sunDirection[0], options.sunDirection[1], options.sunDirection[2]);
|
|
69
|
+
if (dir.lengthSq() > 1e-8) {
|
|
70
|
+
dir.normalize();
|
|
71
|
+
const sunGeo = new THREE.SphereGeometry(3.5, 16, 12);
|
|
72
|
+
const sunMat = new THREE.MeshBasicMaterial({ color: options.sunColor });
|
|
73
|
+
const sun = new THREE.Mesh(sunGeo, sunMat);
|
|
74
|
+
// Place the disc outside the sky sphere sample so it stays a compact highlight.
|
|
75
|
+
sun.position.copy(dir.multiplyScalar(42));
|
|
76
|
+
probe.add(sun);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return probe;
|
|
80
|
+
}
|
|
81
|
+
function disposeScene(scene) {
|
|
82
|
+
scene.traverse((obj) => {
|
|
83
|
+
const mesh = obj;
|
|
84
|
+
if (mesh.isMesh) {
|
|
85
|
+
mesh.geometry?.dispose();
|
|
86
|
+
const material = mesh.material;
|
|
87
|
+
if (Array.isArray(material))
|
|
88
|
+
material.forEach((m) => m.dispose());
|
|
89
|
+
else
|
|
90
|
+
material?.dispose();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -3,7 +3,7 @@ import { useFrame } from "@react-three/fiber";
|
|
|
3
3
|
import { useEffect, useMemo, useRef } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
import { createPartPose, partMotionPhase, sampleBodyPose, samplePartPose, } from "@jgengine/core/game/partAnimation";
|
|
6
|
-
import {
|
|
6
|
+
import { useOptionalGameContext } from "@jgengine/react/provider";
|
|
7
7
|
const FLINCH_RELEASE_SEC = 0.35;
|
|
8
8
|
const DEATH_RAMP_PER_SEC = 2.5;
|
|
9
9
|
const SPEED_SMOOTHING = 12;
|
|
@@ -16,7 +16,7 @@ const SPEED_SMOOTHING = 12;
|
|
|
16
16
|
* as static kit pieces. Children are the base model content (primitive, attachments).
|
|
17
17
|
*/
|
|
18
18
|
export function PartMotionRig({ parts, model, instanceId, renderPart, children, }) {
|
|
19
|
-
const ctx =
|
|
19
|
+
const ctx = useOptionalGameContext();
|
|
20
20
|
const rootRef = useRef(null);
|
|
21
21
|
const partRefs = useRef([]);
|
|
22
22
|
const motionRef = useRef({
|
|
@@ -27,7 +27,7 @@ export function PartMotionRig({ parts, model, instanceId, renderPart, children,
|
|
|
27
27
|
});
|
|
28
28
|
const phase = useMemo(() => partMotionPhase(instanceId ?? model.url), [instanceId, model.url]);
|
|
29
29
|
useEffect(() => {
|
|
30
|
-
if (instanceId === undefined)
|
|
30
|
+
if (ctx === null || instanceId === undefined)
|
|
31
31
|
return;
|
|
32
32
|
const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
|
|
33
33
|
if (event.instanceId === instanceId)
|
|
@@ -46,7 +46,7 @@ export function PartMotionRig({ parts, model, instanceId, renderPart, children,
|
|
|
46
46
|
const pose = useMemo(() => createPartPose(), []);
|
|
47
47
|
useFrame((frameState, delta) => {
|
|
48
48
|
const motion = motionRef.current;
|
|
49
|
-
if (delta > 0 && instanceId !== undefined) {
|
|
49
|
+
if (delta > 0 && instanceId !== undefined && ctx !== null) {
|
|
50
50
|
const entity = ctx.scene.entity.get(instanceId);
|
|
51
51
|
if (entity !== null) {
|
|
52
52
|
const [x, , z] = entity.position;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CascadedShadows } from "./CascadedShadows.js";
|
|
2
3
|
const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
|
|
4
|
+
function usesCascades(entry) {
|
|
5
|
+
return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
|
|
6
|
+
}
|
|
3
7
|
function DirectionalShadowLight({ entry }) {
|
|
8
|
+
if (usesCascades(entry)) {
|
|
9
|
+
return _jsx(CascadedShadows, { entry: entry });
|
|
10
|
+
}
|
|
4
11
|
const size = entry.shadowCameraSize ?? 40;
|
|
5
12
|
return (_jsx("directionalLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": entry.shadowMapSize ?? 1024, "shadow-mapSize-height": entry.shadowMapSize ?? 1024, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": Math.max(200, size * 6), "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02 }));
|
|
6
13
|
}
|
|
@@ -1,8 +1,44 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { Component, type ReactNode } from "react";
|
|
2
2
|
import type { EntitySpriteConfig, ModelConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
import { type FallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
|
|
3
4
|
export declare function EntitySprite({ sprite }: {
|
|
4
5
|
sprite: EntitySpriteConfig;
|
|
5
6
|
}): import("react").JSX.Element;
|
|
7
|
+
/**
|
|
8
|
+
* Catches a render throw from a model subtree and swaps in `fallback` — but never silently: the
|
|
9
|
+
* failed state re-reports to the fallback-seam probe on every render (matching the per-seam frame
|
|
10
|
+
* boundary in `WorldScene`) and the catch warns once with the model url and the error. Without
|
|
11
|
+
* that, a throw anywhere in a composition erases the actor with an empty `probes.fallbacks`.
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export declare class ModelFallbackBoundary extends Component<{
|
|
15
|
+
fallback: ReactNode;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
url: string;
|
|
18
|
+
seam?: FallbackSeam;
|
|
19
|
+
}, {
|
|
20
|
+
failed: boolean;
|
|
21
|
+
}> {
|
|
22
|
+
state: {
|
|
23
|
+
failed: boolean;
|
|
24
|
+
};
|
|
25
|
+
static getDerivedStateFromError(): {
|
|
26
|
+
failed: boolean;
|
|
27
|
+
};
|
|
28
|
+
componentDidCatch(error: unknown): void;
|
|
29
|
+
render(): ReactNode;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One model subtree behind its own error boundary and Suspense boundary, so a throwing or
|
|
33
|
+
* still-loading piece cannot erase its siblings. Every nested piece of a composition — parts,
|
|
34
|
+
* attachments, material maps — mounts through this.
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export declare function IsolatedModelPart({ model, seam, children, }: {
|
|
38
|
+
model: ModelConfig;
|
|
39
|
+
seam?: FallbackSeam;
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}): import("react").JSX.Element;
|
|
6
42
|
export declare function IsolatedEntityModel({ model, instanceId, measure, fallback, }: {
|
|
7
43
|
model: ModelConfig;
|
|
8
44
|
instanceId?: string;
|
|
@@ -3,9 +3,11 @@ import { useFrame, useLoader } from "@react-three/fiber";
|
|
|
3
3
|
import { useTexture } from "@react-three/drei";
|
|
4
4
|
import { Component, Suspense, useEffect, useMemo, useRef } from "react";
|
|
5
5
|
import * as THREE from "three";
|
|
6
|
-
import {
|
|
6
|
+
import { reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
|
|
7
|
+
import { useOptionalGameContext } from "@jgengine/react/provider";
|
|
7
8
|
import { sharedGltfLoader } from "./modelLoad.js";
|
|
8
9
|
import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
|
|
10
|
+
import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
|
|
9
11
|
import { useModelAnimation } from "./useModelAnimation.js";
|
|
10
12
|
import { PartMotionRig } from "./PartMotion.js";
|
|
11
13
|
import { applyMaterialOverride } from "../materialOverride.js";
|
|
@@ -19,17 +21,42 @@ export function EntitySprite({ sprite }) {
|
|
|
19
21
|
}, [texture]);
|
|
20
22
|
return (_jsx("sprite", { "position-y": sprite.y, scale: [sprite.width, sprite.height, 1], children: _jsx("spriteMaterial", { map: texture, transparent: true, alphaTest: 0.08, depthWrite: false }) }));
|
|
21
23
|
}
|
|
22
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Catches a render throw from a model subtree and swaps in `fallback` — but never silently: the
|
|
26
|
+
* failed state re-reports to the fallback-seam probe on every render (matching the per-seam frame
|
|
27
|
+
* boundary in `WorldScene`) and the catch warns once with the model url and the error. Without
|
|
28
|
+
* that, a throw anywhere in a composition erases the actor with an empty `probes.fallbacks`.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export class ModelFallbackBoundary extends Component {
|
|
23
32
|
state = { failed: false };
|
|
24
33
|
static getDerivedStateFromError() {
|
|
25
34
|
return { failed: true };
|
|
26
35
|
}
|
|
36
|
+
componentDidCatch(error) {
|
|
37
|
+
if (typeof console === "undefined")
|
|
38
|
+
return;
|
|
39
|
+
console.warn(`[jgengine] model "${this.props.url}" threw while rendering and was replaced by its fallback ` +
|
|
40
|
+
`(nothing, unless one was supplied). Fix the throw below — the actor is invisible until then.`, error);
|
|
41
|
+
}
|
|
27
42
|
render() {
|
|
28
|
-
|
|
43
|
+
if (!this.state.failed)
|
|
44
|
+
return this.props.children;
|
|
45
|
+
reportFallbackSeam(this.props.seam ?? "entity", "renderError");
|
|
46
|
+
return this.props.fallback;
|
|
29
47
|
}
|
|
30
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* One model subtree behind its own error boundary and Suspense boundary, so a throwing or
|
|
51
|
+
* still-loading piece cannot erase its siblings. Every nested piece of a composition — parts,
|
|
52
|
+
* attachments, material maps — mounts through this.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export function IsolatedModelPart({ model, seam, children, }) {
|
|
56
|
+
return (_jsx(ModelFallbackBoundary, { fallback: null, url: model.url, seam: seam, children: _jsx(Suspense, { fallback: null, children: children }) }));
|
|
57
|
+
}
|
|
31
58
|
export function IsolatedEntityModel({ model, instanceId, measure, fallback, }) {
|
|
32
|
-
return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure }) }) }));
|
|
59
|
+
return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, url: model.url, seam: measure?.target, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure }) }) }));
|
|
33
60
|
}
|
|
34
61
|
function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
|
|
35
62
|
const gltf = useLoader(sharedGltfLoader, model.url);
|
|
@@ -62,7 +89,7 @@ function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
|
|
|
62
89
|
}
|
|
63
90
|
/** Renders a static kit-of-parts child at a fixed local offset under the parent model — no bone/rig lookup, unlike `BoneAttachment`. The nested `EntityModel` still applies its own `dims`/anchor centering, so scale/pivot sanity carries over per part. */
|
|
64
91
|
function ModelPartGroup({ model, position, rotation, scale, }) {
|
|
65
|
-
return (_jsx("group", { position: position ?? [0, 0, 0], rotation: rotation ?? [0, 0, 0], scale: scale ?? 1, children: _jsx(EntityModel, { model: model }) }));
|
|
92
|
+
return (_jsx("group", { position: position ?? [0, 0, 0], rotation: rotation ?? [0, 0, 0], scale: scale ?? 1, children: _jsx(IsolatedModelPart, { model: model, children: _jsx(EntityModel, { model: model }) }) }));
|
|
66
93
|
}
|
|
67
94
|
function ModelMaterialMapsApplier({ scene, maps }) {
|
|
68
95
|
const entries = useMemo(() => {
|
|
@@ -87,7 +114,10 @@ function ModelMaterialMapsApplier({ scene, maps }) {
|
|
|
87
114
|
}
|
|
88
115
|
export function EntityModel({ model, instanceId, measure, }) {
|
|
89
116
|
const gltf = useLoader(sharedGltfLoader, model.url);
|
|
90
|
-
|
|
117
|
+
// Optional, not required: measured bounds and paint strokes are live-world extras, and a model
|
|
118
|
+
// that threw without a running game could not be inspected outside one — which is how a broken
|
|
119
|
+
// composition stayed undiagnosable in `EntityPreview` (#1588).
|
|
120
|
+
const ctx = useOptionalGameContext();
|
|
91
121
|
const material = model.material;
|
|
92
122
|
const baseY = model.y ?? 0;
|
|
93
123
|
const dims = model.dims;
|
|
@@ -129,7 +159,7 @@ export function EntityModel({ model, instanceId, measure, }) {
|
|
|
129
159
|
const dimsMaxY = dims?.maxY;
|
|
130
160
|
const [positionX, positionY, positionZ] = position;
|
|
131
161
|
useEffect(() => {
|
|
132
|
-
if (measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
|
|
162
|
+
if (ctx === null || measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
|
|
133
163
|
return;
|
|
134
164
|
const raw = measureLocalBounds(scene);
|
|
135
165
|
if (raw === null)
|
|
@@ -140,6 +170,18 @@ export function EntityModel({ model, instanceId, measure, }) {
|
|
|
140
170
|
meshCount: raw.meshCount,
|
|
141
171
|
});
|
|
142
172
|
}, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey, dimsMaxY]);
|
|
173
|
+
// Runs with or without index dims: the loaded geometry upgrades whatever box the kind resolved
|
|
174
|
+
// (fitted or measured) to a hitbox that raycasts the model's own triangles.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (ctx === null || measureTarget === undefined || measureKey === undefined)
|
|
177
|
+
return;
|
|
178
|
+
const triangles = measureLocalCollisionTriangles(scene, {
|
|
179
|
+
scale,
|
|
180
|
+
offset: [positionX, positionY, positionZ],
|
|
181
|
+
});
|
|
182
|
+
if (triangles !== null)
|
|
183
|
+
reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
|
|
184
|
+
}, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
|
|
143
185
|
useModelAnimation(scene, gltf.animations, model.animation, instanceId);
|
|
144
186
|
const paintCanvasRef = useRef(null);
|
|
145
187
|
const paintDrawnCountRef = useRef(0);
|
|
@@ -155,7 +197,7 @@ export function EntityModel({ model, instanceId, measure, }) {
|
|
|
155
197
|
materialCacheRef.current = null;
|
|
156
198
|
}, [scene]);
|
|
157
199
|
useFrame(() => {
|
|
158
|
-
if (instanceId === undefined)
|
|
200
|
+
if (ctx === null || instanceId === undefined)
|
|
159
201
|
return;
|
|
160
202
|
const paint = ctx.scene.entity.paint;
|
|
161
203
|
const version = paint.version(instanceId);
|
|
@@ -177,12 +219,12 @@ export function EntityModel({ model, instanceId, measure, }) {
|
|
|
177
219
|
}
|
|
178
220
|
paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, cache.seedColor, strokes, paintDrawnCountRef.current);
|
|
179
221
|
});
|
|
180
|
-
const base = (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), material?.maps !== undefined ? _jsx(ModelMaterialMapsApplier, { scene: scene, maps: material.maps }) : null, (model.attachments ?? []).map((attachment, index) => typeof attachment.model === "string" ? null : (_jsx(BoneAttachment, { rig: scene, model: attachment.model, slot: attachment.slot, position: attachment.position, rotation: attachment.rotation, scale: attachment.scale }, `${attachment.slot}-${index}`)))] }));
|
|
222
|
+
const base = (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), material?.maps !== undefined ? (_jsx(IsolatedModelPart, { model: model, children: _jsx(ModelMaterialMapsApplier, { scene: scene, maps: material.maps }) })) : null, (model.attachments ?? []).map((attachment, index) => typeof attachment.model === "string" ? null : (_jsx(IsolatedModelPart, { model: attachment.model, children: _jsx(BoneAttachment, { rig: scene, model: attachment.model, slot: attachment.slot, position: attachment.position, rotation: attachment.rotation, scale: attachment.scale }) }, `${attachment.slot}-${index}`)))] }));
|
|
181
223
|
const parts = model.parts ?? [];
|
|
182
224
|
// Any role-tagged part switches the composition onto the procedural part-motion rig:
|
|
183
225
|
// root bob/flinch/topple plus per-role limb swing, driven from the entity's live state.
|
|
184
226
|
if (parts.some((part) => part.role !== undefined)) {
|
|
185
|
-
return (_jsx(PartMotionRig, { parts: parts, model: model, instanceId: instanceId, renderPart: (part) =>
|
|
227
|
+
return (_jsx(PartMotionRig, { parts: parts, model: model, instanceId: instanceId, renderPart: (part) => typeof part.model === "string" ? null : (_jsx(IsolatedModelPart, { model: part.model, children: _jsx(EntityModel, { model: part.model }) })), children: base }));
|
|
186
228
|
}
|
|
187
229
|
return (_jsxs(_Fragment, { children: [base, parts.map((part, index) => typeof part.model === "string" ? null : (_jsx(ModelPartGroup, { model: part.model, position: part.position, rotation: part.rotation, scale: part.scale }, index)))] }));
|
|
188
230
|
}
|
|
@@ -3,6 +3,7 @@ import { useFrame } from "@react-three/fiber";
|
|
|
3
3
|
import { useRef } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
import { useGameContext } from "@jgengine/react/provider";
|
|
6
|
+
import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
|
|
6
7
|
/** Subtrees flagged with this userData key are excluded from bounds measurement — debug gizmos,
|
|
7
8
|
* selection quads, effect billboards that should not inflate the hitbox. Sprites and invisible
|
|
8
9
|
* subtrees are always excluded. */
|
|
@@ -122,6 +123,9 @@ export function MeasuredBoundsGroup({ target, measureKey, children, }) {
|
|
|
122
123
|
return;
|
|
123
124
|
lastMeshCountRef.current = measured.meshCount;
|
|
124
125
|
reportMeasuredBounds(ctx, target, measureKey, measured);
|
|
126
|
+
const triangles = measureLocalCollisionTriangles(group);
|
|
127
|
+
if (triangles !== null)
|
|
128
|
+
reportMeasuredCollisionMesh(ctx, target, measureKey, triangles);
|
|
125
129
|
});
|
|
126
130
|
return _jsx("group", { ref: groupRef, children: children });
|
|
127
131
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { CollisionMeshSource } from "@jgengine/core/scene/collisionMesh";
|
|
3
|
+
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
4
|
+
/** Entity-local triangle soup of the meshes under a measured root, plus the contributing mesh count
|
|
5
|
+
* (same async fill-in detection as measured bounds). Satisfies {@link CollisionMeshSource}. */
|
|
6
|
+
export interface MeasuredCollisionTriangles extends CollisionMeshSource {
|
|
7
|
+
positions: Float32Array;
|
|
8
|
+
indices: Uint32Array;
|
|
9
|
+
triangleCount: number;
|
|
10
|
+
meshCount: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate
|
|
14
|
+
* counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY}
|
|
15
|
+
* exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset`
|
|
16
|
+
* maps the result into entity-local space when the caller mounts the root scaled/translated (the
|
|
17
|
+
* model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the
|
|
18
|
+
* triangle budget — callers keep their box fallback.
|
|
19
|
+
*/
|
|
20
|
+
export declare function measureLocalCollisionTriangles(root: THREE.Object3D, transform?: {
|
|
21
|
+
scale: number;
|
|
22
|
+
offset: readonly [number, number, number];
|
|
23
|
+
}): MeasuredCollisionTriangles | null;
|
|
24
|
+
/** Forward measured triangles into the context (entity kind or object catalog id), deduping repeat
|
|
25
|
+
* reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH. */
|
|
26
|
+
export declare function reportMeasuredCollisionMesh(ctx: GameContext, target: "entity" | "object", key: string, triangles: MeasuredCollisionTriangles): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { MEASURE_EXCLUDE_KEY } from "./measureBounds.js";
|
|
3
|
+
/** Above this the model is too dense to double as its own hitbox — keep the fitted box instead of
|
|
4
|
+
* paying BVH build + per-ray traversal on a hero-detail mesh. Game-ready low-poly assets sit far under it. */
|
|
5
|
+
const MAX_COLLISION_TRIANGLES = 60_000;
|
|
6
|
+
const IDENTITY = new THREE.Matrix4();
|
|
7
|
+
const tmpVec = new THREE.Vector3();
|
|
8
|
+
function collectTriangles(object, parentMatrix, sink) {
|
|
9
|
+
if (sink.overflow)
|
|
10
|
+
return;
|
|
11
|
+
if (parentMatrix !== null) {
|
|
12
|
+
if (!object.visible)
|
|
13
|
+
return;
|
|
14
|
+
if (object.isSprite === true)
|
|
15
|
+
return;
|
|
16
|
+
if (object.userData[MEASURE_EXCLUDE_KEY] === true)
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let matrix = null;
|
|
20
|
+
if (parentMatrix !== null) {
|
|
21
|
+
if (object.matrixAutoUpdate)
|
|
22
|
+
object.updateMatrix();
|
|
23
|
+
matrix = new THREE.Matrix4().multiplyMatrices(parentMatrix, object.matrix);
|
|
24
|
+
}
|
|
25
|
+
const mesh = object;
|
|
26
|
+
if (mesh.isMesh === true && mesh.geometry !== undefined) {
|
|
27
|
+
const geometry = mesh.geometry;
|
|
28
|
+
const positionAttribute = geometry.getAttribute("position");
|
|
29
|
+
if (positionAttribute !== undefined && positionAttribute.itemSize === 3 && positionAttribute.count >= 3) {
|
|
30
|
+
const base = sink.positions.length / 3;
|
|
31
|
+
const applied = matrix ?? IDENTITY;
|
|
32
|
+
for (let v = 0; v < positionAttribute.count; v += 1) {
|
|
33
|
+
tmpVec.fromBufferAttribute(positionAttribute, v).applyMatrix4(applied);
|
|
34
|
+
sink.positions.push(tmpVec.x, tmpVec.y, tmpVec.z);
|
|
35
|
+
}
|
|
36
|
+
const index = geometry.getIndex();
|
|
37
|
+
const triangleCount = index !== null ? Math.floor(index.count / 3) : Math.floor(positionAttribute.count / 3);
|
|
38
|
+
if (sink.triangleCount + triangleCount > MAX_COLLISION_TRIANGLES) {
|
|
39
|
+
sink.overflow = true;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (index !== null) {
|
|
43
|
+
for (let i = 0; i + 2 < index.count; i += 3) {
|
|
44
|
+
sink.indices.push(base + index.getX(i), base + index.getX(i + 1), base + index.getX(i + 2));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
for (let i = 0; i + 2 < positionAttribute.count; i += 3) {
|
|
49
|
+
sink.indices.push(base + i, base + i + 1, base + i + 2);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
sink.triangleCount += triangleCount;
|
|
53
|
+
sink.meshCount += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const childMatrix = matrix ?? IDENTITY;
|
|
57
|
+
for (const child of object.children)
|
|
58
|
+
collectTriangles(child, childMatrix, sink);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate
|
|
62
|
+
* counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY}
|
|
63
|
+
* exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset`
|
|
64
|
+
* maps the result into entity-local space when the caller mounts the root scaled/translated (the
|
|
65
|
+
* model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the
|
|
66
|
+
* triangle budget — callers keep their box fallback.
|
|
67
|
+
*/
|
|
68
|
+
export function measureLocalCollisionTriangles(root, transform) {
|
|
69
|
+
const sink = { positions: [], indices: [], meshCount: 0, triangleCount: 0, overflow: false };
|
|
70
|
+
collectTriangles(root, null, sink);
|
|
71
|
+
if (sink.overflow || sink.meshCount === 0 || sink.triangleCount === 0)
|
|
72
|
+
return null;
|
|
73
|
+
const positions = new Float32Array(sink.positions);
|
|
74
|
+
if (transform !== undefined) {
|
|
75
|
+
const { scale, offset } = transform;
|
|
76
|
+
for (let v = 0; v < positions.length; v += 3) {
|
|
77
|
+
positions[v] = positions[v] * scale + offset[0];
|
|
78
|
+
positions[v + 1] = positions[v + 1] * scale + offset[1];
|
|
79
|
+
positions[v + 2] = positions[v + 2] * scale + offset[2];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
positions,
|
|
84
|
+
indices: new Uint32Array(sink.indices),
|
|
85
|
+
triangleCount: sink.triangleCount,
|
|
86
|
+
meshCount: sink.meshCount,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const reportedByContext = new WeakMap();
|
|
90
|
+
/** Forward measured triangles into the context (entity kind or object catalog id), deduping repeat
|
|
91
|
+
* reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH. */
|
|
92
|
+
export function reportMeasuredCollisionMesh(ctx, target, key, triangles) {
|
|
93
|
+
let map = reportedByContext.get(ctx);
|
|
94
|
+
if (map === undefined) {
|
|
95
|
+
map = new Map();
|
|
96
|
+
reportedByContext.set(ctx, map);
|
|
97
|
+
}
|
|
98
|
+
const registryKey = `${target}:${key}`;
|
|
99
|
+
const prev = map.get(registryKey);
|
|
100
|
+
if (prev !== undefined && prev.meshCount === triangles.meshCount && prev.triangleCount === triangles.triangleCount) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const accepted = target === "entity"
|
|
104
|
+
? ctx.scene.entity.reportCollisionMesh(key, triangles)
|
|
105
|
+
: ctx.scene.object.reportCollisionMesh(key, triangles);
|
|
106
|
+
if (accepted)
|
|
107
|
+
map.set(registryKey, { meshCount: triangles.meshCount, triangleCount: triangles.triangleCount });
|
|
108
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
2
2
|
import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
|
|
3
|
+
/**
|
|
4
|
+
* The shared GLB {@link THREE.LoadingManager}'s `onError` sink. It fires for EVERY item the manager
|
|
5
|
+
* loads — the GLB container AND each texture/image a `GLTFLoader` fetches for it. The container's own
|
|
6
|
+
* failure is already surfaced by the model-fallback probe (it resolves to a placeholder), so this records
|
|
7
|
+
* only the sub-resource (texture) failures the fallback probe cannot see — a model that resolves but whose
|
|
8
|
+
* textures 404. Split out and exported so the wiring is unit-testable without a GL context.
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export declare function recordManagerLoadError(url: string): void;
|
|
3
12
|
/**
|
|
4
13
|
* Re-fetch a model URL that just failed to load and classify what actually came
|
|
5
14
|
* back — a 404, a dev-server HTML fallback, a corrupt or non-model body — so the
|
|
@@ -10,11 +19,38 @@ import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
|
|
|
10
19
|
*/
|
|
11
20
|
export declare function probeModelUrl(url: string, logicalId?: string, fetchImpl?: typeof fetch): Promise<AssetLoadDiagnosis>;
|
|
12
21
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
22
|
+
* Build a stand-in {@link GLTF} whose scene is a single magenta placeholder box,
|
|
23
|
+
* returned in place of a model that could not be loaded. Consumers clone and
|
|
24
|
+
* mount `gltf.scene` exactly as they would a real model, so one broken asset
|
|
25
|
+
* degrades to a visible primitive instead of taking down the scene. The optional
|
|
26
|
+
* `diagnosis` is stashed on `userData.jgengineDiagnosis` for debugging.
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
export declare function createFallbackModel(diagnosis?: AssetLoadDiagnosis): GLTF;
|
|
30
|
+
/**
|
|
31
|
+
* The failure seam for {@link DiagnosticGLTFLoader}: re-probe the URL a load just
|
|
32
|
+
* failed on and decide what the loader should hand back. A diagnosed broken asset
|
|
33
|
+
* (missing / HTML dev-server fallback / corrupt / unsupported) resolves via
|
|
34
|
+
* `onLoad` to a {@link createFallbackModel} primitive so the failure is contained
|
|
35
|
+
* at the load seam for **every** consumer — no reliance on a React error boundary
|
|
36
|
+
* catching a rejected Suspense promise, which does not fire reliably for async
|
|
37
|
+
* loader rejections inside the react-three-fiber reconciler. Only a genuine parse
|
|
38
|
+
* error over bytes that still look like a valid model (`diagnosis.ok`) is surfaced
|
|
39
|
+
* through `onError`. Split out from the loader so it is unit-testable without a GL
|
|
40
|
+
* context; `overrides` inject the probe and warning sink in tests.
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export declare function handleModelLoadFailure(resolvedUrl: string, originalEvent: unknown, onLoad: (gltf: GLTF) => void, onError: (event: unknown) => void, overrides?: {
|
|
44
|
+
probe?: typeof probeModelUrl;
|
|
45
|
+
warn?: (message: string) => void;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
|
|
49
|
+
* gracefully: when a load errors, it probes the URL and — for a diagnosed broken
|
|
50
|
+
* asset (missing / HTML fallback / corrupt / unsupported) — resolves to a
|
|
51
|
+
* {@link createFallbackModel} placeholder primitive instead of rejecting, so the
|
|
52
|
+
* one broken model never bubbles a throw past its mount. Drop-in for the shared
|
|
53
|
+
* loader, so `useLoader(sharedGltfLoader, url)` benefits with no call-site change.
|
|
18
54
|
* @internal
|
|
19
55
|
*/
|
|
20
56
|
export declare class DiagnosticGLTFLoader extends GLTFLoader {
|