@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
|
@@ -3,9 +3,12 @@ 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 { resolveOneShotClip } from "@jgengine/core/game/modelAnimation";
|
|
7
6
|
import { useGameContext } from "@jgengine/react/provider";
|
|
8
7
|
import { sharedGltfLoader } from "./modelLoad.js";
|
|
8
|
+
import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
|
|
9
|
+
import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
|
|
10
|
+
import { useModelAnimation } from "./useModelAnimation.js";
|
|
11
|
+
import { PartMotionRig } from "./PartMotion.js";
|
|
9
12
|
import { applyMaterialOverride } from "../materialOverride.js";
|
|
10
13
|
import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeClonedMaterials, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
|
|
11
14
|
export function EntitySprite({ sprite }) {
|
|
@@ -26,8 +29,8 @@ class ModelFallbackBoundary extends Component {
|
|
|
26
29
|
return this.state.failed ? this.props.fallback : this.props.children;
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
|
-
export function IsolatedEntityModel({ model, instanceId, fallback, }) {
|
|
30
|
-
return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId }) }) }));
|
|
32
|
+
export function IsolatedEntityModel({ model, instanceId, measure, fallback, }) {
|
|
33
|
+
return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure }) }) }));
|
|
31
34
|
}
|
|
32
35
|
function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
|
|
33
36
|
const gltf = useLoader(sharedGltfLoader, model.url);
|
|
@@ -83,7 +86,7 @@ function ModelMaterialMapsApplier({ scene, maps }) {
|
|
|
83
86
|
}, [scene, textures]);
|
|
84
87
|
return null;
|
|
85
88
|
}
|
|
86
|
-
export function EntityModel({ model, instanceId }) {
|
|
89
|
+
export function EntityModel({ model, instanceId, measure, }) {
|
|
87
90
|
const gltf = useLoader(sharedGltfLoader, model.url);
|
|
88
91
|
const ctx = useGameContext();
|
|
89
92
|
const material = model.material;
|
|
@@ -119,150 +122,38 @@ export function EntityModel({ model, instanceId }) {
|
|
|
119
122
|
useEffect(() => () => {
|
|
120
123
|
disposeClonedMaterials(scene);
|
|
121
124
|
}, [scene]);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const activeOneShotRef = useRef(null);
|
|
125
|
+
// A model without index-measured dims can't drive the fitted-collider path, so report the live
|
|
126
|
+
// measurement (the primitive mounts with only this uniform scale + position) instead of letting
|
|
127
|
+
// the kind fall back to a fixed-size box. Index dims keep priority — skip when they exist.
|
|
128
|
+
const measureTarget = measure?.target;
|
|
129
|
+
const measureKey = measure?.key;
|
|
130
|
+
const dimsMaxY = dims?.maxY;
|
|
131
|
+
const [positionX, positionY, positionZ] = position;
|
|
130
132
|
useEffect(() => {
|
|
131
|
-
if (
|
|
132
|
-
mixerRef.current = null;
|
|
133
|
-
stateActionsRef.current = null;
|
|
133
|
+
if (measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
|
|
134
134
|
return;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (states !== undefined) {
|
|
138
|
-
const clipFor = (name) => THREE.AnimationClip.findByName(gltf.animations, name) ?? gltf.animations[0];
|
|
139
|
-
const actions = {
|
|
140
|
-
idle: mixer.clipAction(clipFor(states.idle)),
|
|
141
|
-
walk: mixer.clipAction(clipFor(states.walk)),
|
|
142
|
-
...(states.run === undefined ? {} : { run: mixer.clipAction(clipFor(states.run)) }),
|
|
143
|
-
};
|
|
144
|
-
for (const action of Object.values(actions)) {
|
|
145
|
-
action.setLoop(THREE.LoopRepeat, Infinity);
|
|
146
|
-
action.timeScale = animation.timeScale ?? 1;
|
|
147
|
-
action.enabled = true;
|
|
148
|
-
}
|
|
149
|
-
actions.idle.play();
|
|
150
|
-
mixer.update(0);
|
|
151
|
-
mixerRef.current = mixer;
|
|
152
|
-
stateActionsRef.current = { actions, active: "idle", lastPos: null, smoothedSpeed: 0 };
|
|
153
|
-
animationPausedRef.current = false;
|
|
154
|
-
let onOneShotFinished = null;
|
|
155
|
-
if (oneShots !== undefined) {
|
|
156
|
-
const clipNames = new Set();
|
|
157
|
-
for (const spec of Object.values(oneShots)) {
|
|
158
|
-
if (typeof spec === "string")
|
|
159
|
-
clipNames.add(spec);
|
|
160
|
-
else
|
|
161
|
-
for (const name of spec)
|
|
162
|
-
clipNames.add(name);
|
|
163
|
-
}
|
|
164
|
-
const oneShotActions = new Map();
|
|
165
|
-
for (const name of clipNames) {
|
|
166
|
-
const found = THREE.AnimationClip.findByName(gltf.animations, name);
|
|
167
|
-
if (found === null)
|
|
168
|
-
continue;
|
|
169
|
-
const oneShotAction = mixer.clipAction(found);
|
|
170
|
-
oneShotAction.setLoop(THREE.LoopOnce, 1);
|
|
171
|
-
oneShotAction.enabled = true;
|
|
172
|
-
oneShotActions.set(name, oneShotAction);
|
|
173
|
-
}
|
|
174
|
-
onOneShotFinished = ({ action }) => {
|
|
175
|
-
const active = activeOneShotRef.current;
|
|
176
|
-
if (active === null || action !== active.action || active.isDeath)
|
|
177
|
-
return;
|
|
178
|
-
const machine = stateActionsRef.current;
|
|
179
|
-
const back = machine?.actions[machine.active];
|
|
180
|
-
action.fadeOut(0.15);
|
|
181
|
-
if (back !== undefined)
|
|
182
|
-
back.reset().fadeIn(0.15).play();
|
|
183
|
-
activeOneShotRef.current = null;
|
|
184
|
-
};
|
|
185
|
-
mixer.addEventListener("finished", onOneShotFinished);
|
|
186
|
-
oneShotPlayRef.current = (event) => {
|
|
187
|
-
const active = activeOneShotRef.current;
|
|
188
|
-
if (active !== null && active.isDeath)
|
|
189
|
-
return;
|
|
190
|
-
const clipName = resolveOneShotClip(oneShots, event, Math.random());
|
|
191
|
-
if (clipName === null)
|
|
192
|
-
return;
|
|
193
|
-
const oneShotAction = oneShotActions.get(clipName);
|
|
194
|
-
if (oneShotAction === undefined)
|
|
195
|
-
return;
|
|
196
|
-
const machine = stateActionsRef.current;
|
|
197
|
-
machine?.actions[machine.active]?.fadeOut(0.1);
|
|
198
|
-
if (active !== null && active.action !== oneShotAction)
|
|
199
|
-
active.action.stop();
|
|
200
|
-
oneShotAction.clampWhenFinished = event === "death";
|
|
201
|
-
oneShotAction.reset().fadeIn(0.1).play();
|
|
202
|
-
activeOneShotRef.current = { action: oneShotAction, isDeath: event === "death" };
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
return () => {
|
|
206
|
-
if (onOneShotFinished !== null)
|
|
207
|
-
mixer.removeEventListener("finished", onOneShotFinished);
|
|
208
|
-
oneShotPlayRef.current = null;
|
|
209
|
-
activeOneShotRef.current = null;
|
|
210
|
-
mixer.stopAllAction();
|
|
211
|
-
mixerRef.current = null;
|
|
212
|
-
stateActionsRef.current = null;
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(gltf.animations, animation.clip) : undefined) ??
|
|
216
|
-
gltf.animations[0];
|
|
217
|
-
const action = mixer.clipAction(clip);
|
|
218
|
-
action.setLoop(animation.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
|
|
219
|
-
action.clampWhenFinished = animation.loop === false;
|
|
220
|
-
action.timeScale = animation.timeScale ?? 1;
|
|
221
|
-
action.enabled = true;
|
|
222
|
-
action.paused = animation.paused === true;
|
|
223
|
-
action.play();
|
|
224
|
-
if (animation.time !== undefined)
|
|
225
|
-
action.time = animation.time;
|
|
226
|
-
mixer.update(0);
|
|
227
|
-
mixerRef.current = mixer;
|
|
228
|
-
animationPausedRef.current = animation.paused === true;
|
|
229
|
-
return () => {
|
|
230
|
-
mixer.stopAllAction();
|
|
231
|
-
mixerRef.current = null;
|
|
232
|
-
};
|
|
233
|
-
}, [
|
|
234
|
-
scene,
|
|
235
|
-
gltf,
|
|
236
|
-
animation?.clip,
|
|
237
|
-
animation?.loop,
|
|
238
|
-
animation?.timeScale,
|
|
239
|
-
animation?.paused,
|
|
240
|
-
animation?.time,
|
|
241
|
-
states,
|
|
242
|
-
oneShots,
|
|
243
|
-
]);
|
|
244
|
-
useEffect(() => {
|
|
245
|
-
if (instanceId === undefined || oneShots === undefined)
|
|
135
|
+
const raw = measureLocalBounds(scene);
|
|
136
|
+
if (raw === null)
|
|
246
137
|
return;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
});
|
|
252
|
-
const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
|
|
253
|
-
if (event.instanceId === instanceId)
|
|
254
|
-
fire("hit");
|
|
138
|
+
reportMeasuredBounds(ctx, measureTarget, measureKey, {
|
|
139
|
+
min: [raw.min[0] * scale + positionX, raw.min[1] * scale + positionY, raw.min[2] * scale + positionZ],
|
|
140
|
+
max: [raw.max[0] * scale + positionX, raw.max[1] * scale + positionY, raw.max[2] * scale + positionZ],
|
|
141
|
+
meshCount: raw.meshCount,
|
|
255
142
|
});
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
143
|
+
}, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey, dimsMaxY]);
|
|
144
|
+
// Runs with or without index dims: the loaded geometry upgrades whatever box the kind resolved
|
|
145
|
+
// (fitted or measured) to a hitbox that raycasts the model's own triangles.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (measureTarget === undefined || measureKey === undefined)
|
|
148
|
+
return;
|
|
149
|
+
const triangles = measureLocalCollisionTriangles(scene, {
|
|
150
|
+
scale,
|
|
151
|
+
offset: [positionX, positionY, positionZ],
|
|
259
152
|
});
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
};
|
|
265
|
-
}, [ctx, instanceId, oneShots]);
|
|
153
|
+
if (triangles !== null)
|
|
154
|
+
reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
|
|
155
|
+
}, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
|
|
156
|
+
useModelAnimation(scene, gltf.animations, model.animation, instanceId);
|
|
266
157
|
const paintCanvasRef = useRef(null);
|
|
267
158
|
const paintDrawnCountRef = useRef(0);
|
|
268
159
|
const paintVersionRef = useRef(-1);
|
|
@@ -276,41 +167,7 @@ export function EntityModel({ model, instanceId }) {
|
|
|
276
167
|
paintVersionRef.current = -1;
|
|
277
168
|
materialCacheRef.current = null;
|
|
278
169
|
}, [scene]);
|
|
279
|
-
useFrame((
|
|
280
|
-
const stateMachine = stateActionsRef.current;
|
|
281
|
-
if (stateMachine !== null && states !== undefined && instanceId !== undefined && delta > 0) {
|
|
282
|
-
const entity = ctx.scene.entity.get(instanceId);
|
|
283
|
-
if (entity !== null) {
|
|
284
|
-
const [x, , z] = entity.position;
|
|
285
|
-
if (stateMachine.lastPos !== null) {
|
|
286
|
-
const instantSpeed = Math.hypot(x - stateMachine.lastPos[0], z - stateMachine.lastPos[2]) / delta;
|
|
287
|
-
stateMachine.smoothedSpeed +=
|
|
288
|
-
(instantSpeed - stateMachine.smoothedSpeed) * Math.min(1, delta * 12);
|
|
289
|
-
}
|
|
290
|
-
stateMachine.lastPos = [x, entity.position[1], z];
|
|
291
|
-
const walkSpeed = states.walkSpeed ?? 0.5;
|
|
292
|
-
const runSpeed = states.runSpeed ?? 6;
|
|
293
|
-
const next = stateMachine.smoothedSpeed < walkSpeed
|
|
294
|
-
? "idle"
|
|
295
|
-
: stateMachine.actions.run !== undefined && stateMachine.smoothedSpeed >= runSpeed
|
|
296
|
-
? "run"
|
|
297
|
-
: "walk";
|
|
298
|
-
if (next !== stateMachine.active) {
|
|
299
|
-
if (activeOneShotRef.current === null) {
|
|
300
|
-
const fade = states.fadeSec ?? 0.2;
|
|
301
|
-
const from = stateMachine.actions[stateMachine.active];
|
|
302
|
-
const to = stateMachine.actions[next];
|
|
303
|
-
if (from !== undefined && to !== undefined) {
|
|
304
|
-
to.reset().fadeIn(fade).play();
|
|
305
|
-
from.fadeOut(fade);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
stateMachine.active = next;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
if (mixerRef.current !== null && !animationPausedRef.current)
|
|
313
|
-
mixerRef.current.update(delta);
|
|
170
|
+
useFrame(() => {
|
|
314
171
|
if (instanceId === undefined)
|
|
315
172
|
return;
|
|
316
173
|
const paint = ctx.scene.entity.paint;
|
|
@@ -333,5 +190,12 @@ export function EntityModel({ model, instanceId }) {
|
|
|
333
190
|
}
|
|
334
191
|
paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, cache.seedColor, strokes, paintDrawnCountRef.current);
|
|
335
192
|
});
|
|
336
|
-
|
|
193
|
+
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}`)))] }));
|
|
194
|
+
const parts = model.parts ?? [];
|
|
195
|
+
// Any role-tagged part switches the composition onto the procedural part-motion rig:
|
|
196
|
+
// root bob/flinch/topple plus per-role limb swing, driven from the entity's live state.
|
|
197
|
+
if (parts.some((part) => part.role !== undefined)) {
|
|
198
|
+
return (_jsx(PartMotionRig, { parts: parts, model: model, instanceId: instanceId, renderPart: (part) => (typeof part.model === "string" ? null : _jsx(EntityModel, { model: part.model })), children: base }));
|
|
199
|
+
}
|
|
200
|
+
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)))] }));
|
|
337
201
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
4
|
+
/** Subtrees flagged with this userData key are excluded from bounds measurement — debug gizmos,
|
|
5
|
+
* selection quads, effect billboards that should not inflate the hitbox. Sprites and invisible
|
|
6
|
+
* subtrees are always excluded. */
|
|
7
|
+
export declare const MEASURE_EXCLUDE_KEY = "jgMeasureExclude";
|
|
8
|
+
/** Entity-local AABB of the meshes under a measured root, plus how many meshes contributed — the
|
|
9
|
+
* count lets callers detect async subtree fill-in without diffing bounds. */
|
|
10
|
+
export interface MeasuredLocalBounds {
|
|
11
|
+
min: [number, number, number];
|
|
12
|
+
max: [number, number, number];
|
|
13
|
+
meshCount: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Measure the meshes under `root` in `root`'s own frame (root's transform is the measuring frame,
|
|
17
|
+
* children compose their local matrices below it). Returns `null` when nothing measurable is
|
|
18
|
+
* mounted. Skinned meshes measure at bind pose — the standard engine approximation.
|
|
19
|
+
*/
|
|
20
|
+
export declare function measureLocalBounds(root: THREE.Object3D): MeasuredLocalBounds | null;
|
|
21
|
+
/** Forward a measured result into the context (entity kind or object catalog id), deduping repeat
|
|
22
|
+
* reports of the same shape so remounts and multi-instance kinds don't churn collider resolution. */
|
|
23
|
+
export declare function reportMeasuredBounds(ctx: GameContext, target: "entity" | "object", key: string, bounds: MeasuredLocalBounds): void;
|
|
24
|
+
/**
|
|
25
|
+
* Wraps custom-rendered actor content (`renderEntity` / `renderObject`) and reports its measured
|
|
26
|
+
* entity-local bounds into collider resolution, so the hitbox wraps what is actually on screen
|
|
27
|
+
* instead of the fixed-size fallback box. A successful measure re-reports only when the subtree
|
|
28
|
+
* gained or lost meshes (async fill-in), not when idle animation nudges the same meshes.
|
|
29
|
+
*/
|
|
30
|
+
export declare function MeasuredBoundsGroup({ target, measureKey, children, }: {
|
|
31
|
+
target: "entity" | "object";
|
|
32
|
+
measureKey: string;
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { useGameContext } from "@jgengine/react/provider";
|
|
6
|
+
import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
|
|
7
|
+
/** Subtrees flagged with this userData key are excluded from bounds measurement — debug gizmos,
|
|
8
|
+
* selection quads, effect billboards that should not inflate the hitbox. Sprites and invisible
|
|
9
|
+
* subtrees are always excluded. */
|
|
10
|
+
export const MEASURE_EXCLUDE_KEY = "jgMeasureExclude";
|
|
11
|
+
const IDENTITY = new THREE.Matrix4();
|
|
12
|
+
const tmpBox = new THREE.Box3();
|
|
13
|
+
/**
|
|
14
|
+
* Measure the meshes under `root` in `root`'s own frame (root's transform is the measuring frame,
|
|
15
|
+
* children compose their local matrices below it). Returns `null` when nothing measurable is
|
|
16
|
+
* mounted. Skinned meshes measure at bind pose — the standard engine approximation.
|
|
17
|
+
*/
|
|
18
|
+
export function measureLocalBounds(root) {
|
|
19
|
+
const box = new THREE.Box3();
|
|
20
|
+
box.makeEmpty();
|
|
21
|
+
const meshCount = collectBounds(root, null, box);
|
|
22
|
+
if (meshCount === 0 || box.isEmpty())
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
min: [box.min.x, box.min.y, box.min.z],
|
|
26
|
+
max: [box.max.x, box.max.y, box.max.z],
|
|
27
|
+
meshCount,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function collectBounds(object, parentMatrix, into) {
|
|
31
|
+
if (parentMatrix !== null) {
|
|
32
|
+
if (!object.visible)
|
|
33
|
+
return 0;
|
|
34
|
+
if (object.isSprite === true)
|
|
35
|
+
return 0;
|
|
36
|
+
if (object.userData[MEASURE_EXCLUDE_KEY] === true)
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
let matrix = null;
|
|
40
|
+
if (parentMatrix !== null) {
|
|
41
|
+
if (object.matrixAutoUpdate)
|
|
42
|
+
object.updateMatrix();
|
|
43
|
+
matrix = new THREE.Matrix4().multiplyMatrices(parentMatrix, object.matrix);
|
|
44
|
+
}
|
|
45
|
+
let count = 0;
|
|
46
|
+
const mesh = object;
|
|
47
|
+
if (mesh.isMesh === true && mesh.geometry !== undefined) {
|
|
48
|
+
const geometry = mesh.geometry;
|
|
49
|
+
if (geometry.boundingBox === null)
|
|
50
|
+
geometry.computeBoundingBox();
|
|
51
|
+
const bounds = geometry.boundingBox;
|
|
52
|
+
if (bounds !== null && !bounds.isEmpty()) {
|
|
53
|
+
tmpBox.copy(bounds);
|
|
54
|
+
if (matrix !== null)
|
|
55
|
+
tmpBox.applyMatrix4(matrix);
|
|
56
|
+
into.union(tmpBox);
|
|
57
|
+
count += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const childMatrix = matrix ?? IDENTITY;
|
|
61
|
+
for (const child of object.children)
|
|
62
|
+
count += collectBounds(child, childMatrix, into);
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
const reportedByContext = new WeakMap();
|
|
66
|
+
const EPSILON = 0.01;
|
|
67
|
+
function boundsClose(a, b) {
|
|
68
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
69
|
+
if (Math.abs(a.min[axis] - b.min[axis]) > EPSILON)
|
|
70
|
+
return false;
|
|
71
|
+
if (Math.abs(a.max[axis] - b.max[axis]) > EPSILON)
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/** Forward a measured result into the context (entity kind or object catalog id), deduping repeat
|
|
77
|
+
* reports of the same shape so remounts and multi-instance kinds don't churn collider resolution. */
|
|
78
|
+
export function reportMeasuredBounds(ctx, target, key, bounds) {
|
|
79
|
+
let map = reportedByContext.get(ctx);
|
|
80
|
+
if (map === undefined) {
|
|
81
|
+
map = new Map();
|
|
82
|
+
reportedByContext.set(ctx, map);
|
|
83
|
+
}
|
|
84
|
+
const registryKey = `${target}:${key}`;
|
|
85
|
+
const prev = map.get(registryKey);
|
|
86
|
+
if (prev !== undefined && prev.meshCount === bounds.meshCount && boundsClose(prev, bounds))
|
|
87
|
+
return;
|
|
88
|
+
const accepted = target === "entity"
|
|
89
|
+
? ctx.scene.entity.reportBounds(key, bounds)
|
|
90
|
+
: ctx.scene.object.reportBounds(key, bounds);
|
|
91
|
+
if (accepted)
|
|
92
|
+
map.set(registryKey, bounds);
|
|
93
|
+
}
|
|
94
|
+
/** Frames-since-mount at which a marker re-measures — bounded retries catch content that fills in
|
|
95
|
+
* asynchronously after first mount without paying a traversal every frame forever. */
|
|
96
|
+
const MEASURE_FRAMES = [1, 15, 30, 60];
|
|
97
|
+
/**
|
|
98
|
+
* Wraps custom-rendered actor content (`renderEntity` / `renderObject`) and reports its measured
|
|
99
|
+
* entity-local bounds into collider resolution, so the hitbox wraps what is actually on screen
|
|
100
|
+
* instead of the fixed-size fallback box. A successful measure re-reports only when the subtree
|
|
101
|
+
* gained or lost meshes (async fill-in), not when idle animation nudges the same meshes.
|
|
102
|
+
*/
|
|
103
|
+
export function MeasuredBoundsGroup({ target, measureKey, children, }) {
|
|
104
|
+
const ctx = useGameContext();
|
|
105
|
+
const groupRef = useRef(null);
|
|
106
|
+
const frameRef = useRef(0);
|
|
107
|
+
const attemptRef = useRef(0);
|
|
108
|
+
const lastMeshCountRef = useRef(null);
|
|
109
|
+
useFrame(() => {
|
|
110
|
+
if (attemptRef.current >= MEASURE_FRAMES.length)
|
|
111
|
+
return;
|
|
112
|
+
frameRef.current += 1;
|
|
113
|
+
if (frameRef.current < MEASURE_FRAMES[attemptRef.current])
|
|
114
|
+
return;
|
|
115
|
+
attemptRef.current += 1;
|
|
116
|
+
const group = groupRef.current;
|
|
117
|
+
if (group === null)
|
|
118
|
+
return;
|
|
119
|
+
const measured = measureLocalBounds(group);
|
|
120
|
+
if (measured === null)
|
|
121
|
+
return;
|
|
122
|
+
if (lastMeshCountRef.current === measured.meshCount)
|
|
123
|
+
return;
|
|
124
|
+
lastMeshCountRef.current = measured.meshCount;
|
|
125
|
+
reportMeasuredBounds(ctx, target, measureKey, measured);
|
|
126
|
+
const triangles = measureLocalCollisionTriangles(group);
|
|
127
|
+
if (triangles !== null)
|
|
128
|
+
reportMeasuredCollisionMesh(ctx, target, measureKey, triangles);
|
|
129
|
+
});
|
|
130
|
+
return _jsx("group", { ref: groupRef, children: children });
|
|
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,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ModelConfig` a resolved catalog ref renders as. Stamps `animation: "auto"` so any rigged
|
|
3
|
+
* catalog asset animates by default — the shell derives idle/walk/run states and one-shots from
|
|
4
|
+
* the loaded GLB's clip names; clipless models are unaffected. An explicit `style.animation`
|
|
5
|
+
* (merged after resolve) or `"none"` overrides.
|
|
6
|
+
*/
|
|
7
|
+
function catalogModelConfig(ref) {
|
|
8
|
+
return {
|
|
9
|
+
url: ref.url,
|
|
10
|
+
...(ref.dims === undefined ? {} : { dims: ref.dims }),
|
|
11
|
+
...(ref.collisionMesh === undefined ? {} : { collisionMesh: ref.collisionMesh }),
|
|
12
|
+
animation: "auto",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* Resolve a string asset id or a direct ModelConfig. Missing/misspelled catalog
|
|
3
17
|
* ids throw — silent generic-primitive fallback only happens when the mapping
|
|
@@ -14,11 +28,7 @@ export function resolveModel(value, assets, context) {
|
|
|
14
28
|
const where = context === undefined ? `model asset "${value}"` : `${context.seam}["${context.key}"] → "${value}"`;
|
|
15
29
|
throw new Error(`[jgengine] missing ${where} — not in the asset catalog. Fix the id, register it, or omit the mapping to keep the deliberate primitive/sprite fallback.`);
|
|
16
30
|
}
|
|
17
|
-
return
|
|
18
|
-
url: ref.url,
|
|
19
|
-
...(ref.dims === undefined ? {} : { dims: ref.dims }),
|
|
20
|
-
...(ref.collisionMesh === undefined ? {} : { collisionMesh: ref.collisionMesh }),
|
|
21
|
-
};
|
|
31
|
+
return catalogModelConfig(ref);
|
|
22
32
|
}
|
|
23
33
|
/** Soft lookup used when an object catalog id may double as a model asset id.
|
|
24
34
|
* @internal
|
|
@@ -27,11 +37,7 @@ export function tryResolveCatalogModel(id, assets) {
|
|
|
27
37
|
const ref = assets.resolve(id);
|
|
28
38
|
if (ref === null)
|
|
29
39
|
return undefined;
|
|
30
|
-
return
|
|
31
|
-
url: ref.url,
|
|
32
|
-
...(ref.dims === undefined ? {} : { dims: ref.dims }),
|
|
33
|
-
...(ref.collisionMesh === undefined ? {} : { collisionMesh: ref.collisionMesh }),
|
|
34
|
-
};
|
|
40
|
+
return catalogModelConfig(ref);
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
37
43
|
* Resolve an entity model plus bone-attachment / kit-of-parts models through the catalog
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
/**
|
|
4
|
+
* The engine's model animation driver as a standalone hook — the same mixer `EntityModel` runs,
|
|
5
|
+
* for games that render a cloned scene themselves (custom materials, procedural composition).
|
|
6
|
+
* Handles `"auto"` derivation from the GLB's clip names, speed-driven idle/walk/run crossfades
|
|
7
|
+
* read from the entity's live position when `instanceId` is set, one-shots fired from
|
|
8
|
+
* `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useModelAnimation(scene: THREE.Object3D, clips: THREE.AnimationClip[], animationInput: ModelAnimationConfig | "auto" | "none" | undefined, instanceId?: string): void;
|