@jgengine/shell 0.27.0 → 0.29.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 +140 -0
- package/dist/GameUiPreview.js +3 -1
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.js +3 -1
- package/dist/camera/GameCameraRig.js +2 -2
- package/dist/camera/GameFirstPersonCamera.js +39 -2
- package/dist/camera/cameraCollision.d.ts +66 -1
- package/dist/camera/cameraCollision.js +72 -1
- package/dist/camera/index.d.ts +1 -1
- package/dist/camera/index.js +1 -1
- package/dist/camera/rigResolve.d.ts +13 -0
- package/dist/camera/rigResolve.js +16 -0
- package/dist/environment/EnvironmentScene.js +69 -4
- package/dist/postfx/shadowPrepass.d.ts +8 -1
- package/dist/postfx/shadowPrepass.js +18 -2
- package/dist/postfx/stylizePass.js +2 -1
- package/dist/render/CascadedShadows.d.ts +31 -1
- package/dist/render/CascadedShadows.js +71 -5
- package/dist/render/CharacterCrowd.d.ts +20 -0
- package/dist/render/CharacterCrowd.js +176 -0
- package/dist/render/SceneLighting.js +27 -3
- package/dist/render/SceneWarmupGate.d.ts +20 -0
- package/dist/render/SceneWarmupGate.js +48 -0
- package/dist/render/characterLod.d.ts +62 -0
- package/dist/render/characterLod.js +165 -0
- package/dist/render/crowdRegistry.d.ts +48 -0
- package/dist/render/crowdRegistry.js +97 -0
- package/dist/render/materialAsset.js +2 -1
- package/dist/render/modelLoad.js +3 -0
- package/dist/render/modelMeshMerge.d.ts +49 -0
- package/dist/render/modelMeshMerge.js +269 -0
- package/dist/render/shaderWarmup.d.ts +69 -0
- package/dist/render/shaderWarmup.js +120 -0
- package/dist/render/shadowCasters.d.ts +28 -0
- package/dist/render/shadowCasters.js +95 -0
- package/dist/render/staticTransforms.d.ts +20 -0
- package/dist/render/staticTransforms.js +32 -0
- package/dist/render/useFootIk.d.ts +11 -3
- package/dist/render/useFootIk.js +35 -7
- package/dist/render/useModelAnimation.d.ts +16 -2
- package/dist/render/useModelAnimation.js +73 -4
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +23 -0
- package/dist/render/useRagdoll.d.ts +32 -0
- package/dist/render/useRagdoll.js +105 -0
- package/dist/settings/SettingsMenu.d.ts +1 -1
- package/dist/settings/SettingsMenu.js +10 -79
- package/dist/settings/settingsController.js +3 -0
- package/dist/world/StaticObjectModels.d.ts +13 -0
- package/dist/world/StaticObjectModels.js +28 -3
- package/dist/world/WorldParticles.d.ts +2 -1
- package/dist/world/WorldParticles.js +29 -10
- package/dist/world/WorldScene.js +50 -13
- package/dist/world/entityPose.d.ts +17 -0
- package/dist/world/entityPose.js +42 -0
- package/dist/world/staticObjectBatches.d.ts +33 -5
- package/dist/world/staticObjectBatches.js +40 -6
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +200 -9
- package/skills/jgengine-ui/capabilities.md +103 -1
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +8 -0
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +128 -6
- package/skills/jgengine-world/capabilities.md +41 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/reference.md +1 -1
package/dist/render/useFootIk.js
CHANGED
|
@@ -134,6 +134,8 @@ export function resolveFootIkRig(scene, config) {
|
|
|
134
134
|
legs,
|
|
135
135
|
pelvis,
|
|
136
136
|
head,
|
|
137
|
+
lookAtLimits: { ...(options.lookAt?.maxYaw === undefined ? {} : { maxYaw: options.lookAt.maxYaw }), ...(options.lookAt?.maxPitch === undefined ? {} : { maxPitch: options.lookAt.maxPitch }) },
|
|
138
|
+
lookAtWeight: Math.max(0, Math.min(1, options.lookAt?.weight ?? 1)),
|
|
137
139
|
maxAdjust: options.maxAdjust ?? DEFAULT_MAX_ADJUST,
|
|
138
140
|
alignToGround: Math.max(0, Math.min(1, options.alignToGround ?? 1)),
|
|
139
141
|
poses: [...bones].map((bone) => ({
|
|
@@ -175,10 +177,11 @@ function restoreFootIkPose(rig) {
|
|
|
175
177
|
/**
|
|
176
178
|
* Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
|
|
177
179
|
* resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
|
|
178
|
-
* toward the animated knee, and tilts planted feet to the ground.
|
|
179
|
-
*
|
|
180
|
+
* toward the animated knee, and tilts planted feet to the ground. Turns the look-at bone toward
|
|
181
|
+
* `lookAtTarget` when the rig has one. Returns whether the feet are grounded; `state` carries the
|
|
182
|
+
* faded weight between frames. Exported for tests and custom hosts.
|
|
180
183
|
*/
|
|
181
|
-
export function applyFootIk(rig, originY, probe, state, delta,
|
|
184
|
+
export function applyFootIk(rig, originY, probe, state, delta, lookAtTarget) {
|
|
182
185
|
restoreFootIkPose(rig);
|
|
183
186
|
for (const record of rig.poses) {
|
|
184
187
|
record.animated.q.copy(record.bone.quaternion);
|
|
@@ -235,11 +238,12 @@ export function applyFootIk(rig, originY, probe, state, delta, cameraTarget) {
|
|
|
235
238
|
writeWorldQuaternion(leg.tip, current.slerp(tilt.multiply(current), planted * weight * rig.alignToGround));
|
|
236
239
|
}
|
|
237
240
|
}
|
|
238
|
-
if (rig.head !== null &&
|
|
241
|
+
if (rig.head !== null && lookAtTarget !== undefined) {
|
|
239
242
|
const from = rig.head.getWorldPosition(scratch.a);
|
|
240
|
-
const q = lookAt({ from: [from.x, from.y, from.z], target:
|
|
243
|
+
const q = lookAt({ from: [from.x, from.y, from.z], target: lookAtTarget, up: [0, 1, 0], ...rig.lookAtLimits });
|
|
241
244
|
const current = rig.head.getWorldQuaternion(scratch.q2);
|
|
242
|
-
|
|
245
|
+
const reach = Math.max(weight, rig.legs.length === 0 ? 1 : 0) * rig.lookAtWeight;
|
|
246
|
+
writeWorldQuaternion(rig.head, current.slerp(scratch.q.set(q[0], q[1], q[2], q[3]), reach));
|
|
243
247
|
}
|
|
244
248
|
for (const record of rig.poses) {
|
|
245
249
|
record.written.q.copy(record.bone.quaternion);
|
|
@@ -256,8 +260,11 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
|
|
|
256
260
|
const camera = useThree((three) => three.camera);
|
|
257
261
|
const rig = useMemo(() => (config === undefined ? null : resolveFootIkRig(scene, config)), [scene, config]);
|
|
258
262
|
const state = useRef({ weight: 0, pelvis: 0 });
|
|
263
|
+
const lookAtConfig = config === undefined || config === "auto" ? undefined : config.lookAt;
|
|
264
|
+
const target = useRef(null);
|
|
259
265
|
useLayoutEffect(() => {
|
|
260
266
|
state.current = { weight: 0, pelvis: 0 };
|
|
267
|
+
target.current = null;
|
|
261
268
|
return () => {
|
|
262
269
|
if (rig !== null) {
|
|
263
270
|
restoreFootIkPose(rig);
|
|
@@ -271,11 +278,32 @@ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
|
|
|
271
278
|
const parent = scene.parent;
|
|
272
279
|
const originY = parent === null ? scene.position.y : parent.localToWorld(scratch.a.set(0, groundOffset, 0)).y;
|
|
273
280
|
const exclude = [instanceId];
|
|
281
|
+
const aim = resolveLookAtTarget(lookAtConfig?.target, ctx, target.current, lookAtConfig?.entityEyeHeight ?? DEFAULT_EYE_HEIGHT);
|
|
282
|
+
target.current = aim;
|
|
274
283
|
applyFootIk(rig, originY, (input) => ctx.scene.raycast({
|
|
275
284
|
...input,
|
|
276
285
|
excludeInstanceIds: exclude,
|
|
277
286
|
filter: { entities: false, objects: true, walls: false, terrain: true },
|
|
278
287
|
accept: (hit) => hit.blocks && hit.purpose === "physical",
|
|
279
|
-
}), state.current, delta,
|
|
288
|
+
}), state.current, delta, aim === null ? camera.getWorldPosition(scratch.b).toArray() : [aim.x, aim.y, aim.z]);
|
|
280
289
|
});
|
|
281
290
|
}
|
|
291
|
+
const DEFAULT_EYE_HEIGHT = 1.5;
|
|
292
|
+
/**
|
|
293
|
+
* Where a look-at bone should aim this frame, or `null` when it aims at the camera. An entity
|
|
294
|
+
* target that is not spawned yet reuses `previous` rather than snapping back to the camera.
|
|
295
|
+
*/
|
|
296
|
+
function resolveLookAtTarget(spec, ctx, previous, eyeHeight) {
|
|
297
|
+
if (spec === undefined || spec === "camera")
|
|
298
|
+
return null;
|
|
299
|
+
if ("point" in spec) {
|
|
300
|
+
previous?.set(spec.point[0], spec.point[1], spec.point[2]);
|
|
301
|
+
return previous ?? new THREE.Vector3(spec.point[0], spec.point[1], spec.point[2]);
|
|
302
|
+
}
|
|
303
|
+
const entity = ctx.scene.entity.get(spec.entity);
|
|
304
|
+
if (entity === null || entity === undefined)
|
|
305
|
+
return previous;
|
|
306
|
+
const position = entity.position;
|
|
307
|
+
previous?.set(position[0], position[1] + eyeHeight, position[2]);
|
|
308
|
+
return previous ?? new THREE.Vector3(position[0], position[1] + eyeHeight, position[2]);
|
|
309
|
+
}
|
|
@@ -13,14 +13,28 @@ export interface ModelAnimationDiagnostic {
|
|
|
13
13
|
* @capability model-animation-diagnostics inspect imported clip mappings, layer masks and root-track compatibility
|
|
14
14
|
*/
|
|
15
15
|
export declare function diagnoseModelAnimation(scene: THREE.Object3D, animation: ModelAnimationConfig, clips: readonly THREE.AnimationClip[]): readonly ModelAnimationDiagnostic[];
|
|
16
|
+
/**
|
|
17
|
+
* One visual sample's measured motion, used to derive the directional locomotion parameters.
|
|
18
|
+
* `dx`/`dz` are world horizontal displacement since the previous sample, `dyaw` the yaw change in
|
|
19
|
+
* radians and `seconds` the time the sample covers.
|
|
20
|
+
*/
|
|
21
|
+
export interface ModelMotionStep {
|
|
22
|
+
dx: number;
|
|
23
|
+
dz: number;
|
|
24
|
+
dyaw: number;
|
|
25
|
+
seconds: number;
|
|
26
|
+
}
|
|
16
27
|
/**
|
|
17
28
|
* Refreshes a caller-owned graph parameter dictionary from authored extras and known physical movement.
|
|
18
29
|
* Grounded, verticalSpeed and crouched use the shared motor's resolved state when available; custom movers
|
|
19
|
-
* retain their authored values. Speed always uses the supplied measured ground speed.
|
|
30
|
+
* retain their authored values. Speed always uses the supplied measured ground speed. Passing `step`
|
|
31
|
+
* derives the directional parameters from the character's own motion: `direction` is the motion's share
|
|
32
|
+
* along the character's right axis, `facing` its share along the facing axis, and `turnRate` its yaw
|
|
33
|
+
* change per second. Derived values override conflicting authored extras. Does not write game state.
|
|
20
34
|
*
|
|
21
35
|
* @capability animation-motion-params read resolved player movement into reusable animation graph parameters
|
|
22
36
|
*/
|
|
23
|
-
export declare function readModelAnimationParams(ctx: GameContext, instanceId: string, speed: number, out: Record<string, AnimParamValue
|
|
37
|
+
export declare function readModelAnimationParams(ctx: GameContext, instanceId: string, speed: number, out: Record<string, AnimParamValue>, step?: ModelMotionStep): Record<string, AnimParamValue>;
|
|
24
38
|
/**
|
|
25
39
|
* For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
|
|
26
40
|
* its bind pose, so the clip plays in place, and returns that step's root travel as a world-space
|
|
@@ -3,10 +3,20 @@ import { useEffect, useMemo, useRef } from "react";
|
|
|
3
3
|
import * as THREE from "three";
|
|
4
4
|
import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
|
|
5
5
|
import { ANIM_PARAMS_KEY, createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
|
|
6
|
-
import { animGraphFromConfig, LOCOMOTION_CROUCHED_PARAM, LOCOMOTION_GROUNDED_PARAM, LOCOMOTION_SPEED_PARAM, LOCOMOTION_VERTICAL_SPEED_PARAM } from "@jgengine/core/anim/locomotionGraph";
|
|
6
|
+
import { animGraphFromConfig, LOCOMOTION_CROUCHED_PARAM, LOCOMOTION_DIRECTION_PARAM, LOCOMOTION_FACING_PARAM, LOCOMOTION_GROUNDED_PARAM, LOCOMOTION_SPEED_PARAM, LOCOMOTION_TURN_RATE_PARAM, LOCOMOTION_VERTICAL_SPEED_PARAM, } from "@jgengine/core/anim/locomotionGraph";
|
|
7
7
|
import { playerMovementTelemetry } from "@jgengine/core/movement/playerMovement";
|
|
8
8
|
import { seededRng } from "@jgengine/core/random/rng";
|
|
9
9
|
import { useOptionalGameContext } from "@jgengine/react/provider";
|
|
10
|
+
import { characterLodInterval, characterLodTierOf, noteAnimationPhase, noteCharacterPose } from "./characterLod.js";
|
|
11
|
+
/** Wraps a yaw change into `(-pi, pi]`, so a turn across the wrap boundary is not a full spin. */
|
|
12
|
+
function shortestAngle(radians) {
|
|
13
|
+
const turn = radians % (Math.PI * 2);
|
|
14
|
+
if (turn > Math.PI)
|
|
15
|
+
return turn - Math.PI * 2;
|
|
16
|
+
if (turn < -Math.PI)
|
|
17
|
+
return turn + Math.PI * 2;
|
|
18
|
+
return turn;
|
|
19
|
+
}
|
|
10
20
|
function graphClipNames(graph) {
|
|
11
21
|
const names = new Set();
|
|
12
22
|
for (const layer of graph.layers) {
|
|
@@ -102,11 +112,14 @@ function warnAnimationDiagnostics(scene, animation, clips) {
|
|
|
102
112
|
/**
|
|
103
113
|
* Refreshes a caller-owned graph parameter dictionary from authored extras and known physical movement.
|
|
104
114
|
* Grounded, verticalSpeed and crouched use the shared motor's resolved state when available; custom movers
|
|
105
|
-
* retain their authored values. Speed always uses the supplied measured ground speed.
|
|
115
|
+
* retain their authored values. Speed always uses the supplied measured ground speed. Passing `step`
|
|
116
|
+
* derives the directional parameters from the character's own motion: `direction` is the motion's share
|
|
117
|
+
* along the character's right axis, `facing` its share along the facing axis, and `turnRate` its yaw
|
|
118
|
+
* change per second. Derived values override conflicting authored extras. Does not write game state.
|
|
106
119
|
*
|
|
107
120
|
* @capability animation-motion-params read resolved player movement into reusable animation graph parameters
|
|
108
121
|
*/
|
|
109
|
-
export function readModelAnimationParams(ctx, instanceId, speed, out) {
|
|
122
|
+
export function readModelAnimationParams(ctx, instanceId, speed, out, step) {
|
|
110
123
|
const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
|
|
111
124
|
const motion = playerMovementTelemetry(ctx, instanceId);
|
|
112
125
|
if (out === extra || out === motion)
|
|
@@ -122,6 +135,22 @@ export function readModelAnimationParams(ctx, instanceId, speed, out) {
|
|
|
122
135
|
out[LOCOMOTION_CROUCHED_PARAM] = motion.crouching;
|
|
123
136
|
}
|
|
124
137
|
out[LOCOMOTION_SPEED_PARAM] = speed;
|
|
138
|
+
if (step !== undefined) {
|
|
139
|
+
const yaw = ctx.scene.entity.get(instanceId)?.rotationY ?? 0;
|
|
140
|
+
const planar = Math.hypot(step.dx, step.dz);
|
|
141
|
+
if (planar > 1e-4) {
|
|
142
|
+
const mx = step.dx / planar;
|
|
143
|
+
const mz = step.dz / planar;
|
|
144
|
+
// The rig faces its own +Z turned by `rotationY`, so its right axis is +X turned the same way.
|
|
145
|
+
out[LOCOMOTION_FACING_PARAM] = mx * Math.sin(yaw) + mz * Math.cos(yaw);
|
|
146
|
+
out[LOCOMOTION_DIRECTION_PARAM] = mx * Math.cos(yaw) - mz * Math.sin(yaw);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
out[LOCOMOTION_FACING_PARAM] = 0;
|
|
150
|
+
out[LOCOMOTION_DIRECTION_PARAM] = 0;
|
|
151
|
+
}
|
|
152
|
+
out[LOCOMOTION_TURN_RATE_PARAM] = step.seconds > 0 ? step.dyaw / step.seconds : 0;
|
|
153
|
+
}
|
|
125
154
|
return out;
|
|
126
155
|
}
|
|
127
156
|
/** Per-layer action key: a masked or additive layer needs its own clip variant even for a clip another layer plays. */
|
|
@@ -157,6 +186,15 @@ function applyGraphClips(actions, clips) {
|
|
|
157
186
|
action.time = entry.time;
|
|
158
187
|
}
|
|
159
188
|
}
|
|
189
|
+
/** The clip with the largest weight in one graph output: the pose the rig is mostly showing. */
|
|
190
|
+
function dominantPhase(clips) {
|
|
191
|
+
let best;
|
|
192
|
+
for (const entry of clips) {
|
|
193
|
+
if (entry.weight > 0 && (best === undefined || entry.weight > best.weight))
|
|
194
|
+
best = entry;
|
|
195
|
+
}
|
|
196
|
+
return best === undefined ? undefined : { clip: best.clip, time: best.time };
|
|
197
|
+
}
|
|
160
198
|
/**
|
|
161
199
|
* Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
|
|
162
200
|
* clips, additive layers additive ones) so a host that runs its own `createAnimGraphRuntime`, such as
|
|
@@ -241,6 +279,7 @@ function buildGraphPlayback(scene, mixer, graph, clips, rng) {
|
|
|
241
279
|
rootBone,
|
|
242
280
|
rootBindPosition: rootBone?.position.clone() ?? null,
|
|
243
281
|
lastPos: null,
|
|
282
|
+
lastYaw: null,
|
|
244
283
|
smoothedSpeed: 0,
|
|
245
284
|
params: {},
|
|
246
285
|
};
|
|
@@ -274,6 +313,8 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
274
313
|
const animationPausedRef = useRef(false);
|
|
275
314
|
const graphRef = useRef(null);
|
|
276
315
|
const advancedSecondsRef = useRef(undefined);
|
|
316
|
+
const pendingRef = useRef(0);
|
|
317
|
+
const frameRef = useRef(0);
|
|
277
318
|
const states = animation?.states;
|
|
278
319
|
const oneShots = animation?.oneShots;
|
|
279
320
|
const authoredGraph = animation?.graph;
|
|
@@ -393,6 +434,17 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
393
434
|
return;
|
|
394
435
|
playbackDelta = advancedSeconds - previousSeconds;
|
|
395
436
|
}
|
|
437
|
+
// Distance tiers pose a far rig on fewer frames; the accumulated time keeps the clip at real
|
|
438
|
+
// speed, so a half-rate character walks the same distance, it just reaches its poses less often.
|
|
439
|
+
const interval = characterLodInterval(characterLodTierOf(instanceId));
|
|
440
|
+
if (interval === 0)
|
|
441
|
+
return;
|
|
442
|
+
pendingRef.current += playbackDelta;
|
|
443
|
+
frameRef.current += 1;
|
|
444
|
+
if (interval > 1 && frameRef.current % interval !== 0)
|
|
445
|
+
return;
|
|
446
|
+
playbackDelta = pendingRef.current;
|
|
447
|
+
pendingRef.current = 0;
|
|
396
448
|
const playback = graphRef.current;
|
|
397
449
|
if (playback !== null && mixerRef.current !== null) {
|
|
398
450
|
const params = playback.params;
|
|
@@ -400,6 +452,8 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
400
452
|
const entity = ctx.scene.entity.get(instanceId);
|
|
401
453
|
if (entity !== null && playbackDelta > 0) {
|
|
402
454
|
const [x, , z] = entity.position;
|
|
455
|
+
const yaw = entity.rotationY ?? 0;
|
|
456
|
+
let step;
|
|
403
457
|
if (playback.lastPos !== null) {
|
|
404
458
|
const dx = x - playback.lastPos[0];
|
|
405
459
|
const dy = entity.position[1] - playback.lastPos[1];
|
|
@@ -411,11 +465,16 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
411
465
|
else {
|
|
412
466
|
const instantSpeed = Math.hypot(dx, dz) / playbackDelta;
|
|
413
467
|
playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, playbackDelta * 12);
|
|
468
|
+
step = { dx, dz, dyaw: playback.lastYaw === null ? 0 : shortestAngle(yaw - playback.lastYaw), seconds: playbackDelta };
|
|
414
469
|
}
|
|
415
470
|
}
|
|
416
471
|
playback.lastPos = [x, entity.position[1], z];
|
|
472
|
+
playback.lastYaw = yaw;
|
|
473
|
+
readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params, step);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params);
|
|
417
477
|
}
|
|
418
|
-
readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params);
|
|
419
478
|
}
|
|
420
479
|
else {
|
|
421
480
|
for (const key in params)
|
|
@@ -426,6 +485,12 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
426
485
|
const out = playback.runtime.advance(playbackDelta * (animation?.timeScale ?? 1), params, playback.durations);
|
|
427
486
|
applyGraphClips(playback.actions, out.clips);
|
|
428
487
|
mixerRef.current.update(0);
|
|
488
|
+
if (instanceId !== undefined) {
|
|
489
|
+
noteCharacterPose(instanceId);
|
|
490
|
+
const phase = dominantPhase(out.clips);
|
|
491
|
+
if (phase !== undefined)
|
|
492
|
+
noteAnimationPhase(instanceId, phase.clip, phase.time);
|
|
493
|
+
}
|
|
429
494
|
if (out.rootMotion === true && playback.rootBone !== null && playback.rootBindPosition !== null) {
|
|
430
495
|
playback.rootBone.position.x = playback.rootBindPosition.x;
|
|
431
496
|
playback.rootBone.position.z = playback.rootBindPosition.z;
|
|
@@ -438,6 +503,10 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
438
503
|
}
|
|
439
504
|
if (mixerRef.current !== null && actionRef.current?.isRunning()) {
|
|
440
505
|
mixerRef.current.update(playbackDelta);
|
|
506
|
+
if (instanceId !== undefined) {
|
|
507
|
+
noteCharacterPose(instanceId);
|
|
508
|
+
noteAnimationPhase(instanceId, actionRef.current.getClip().name, actionRef.current.time);
|
|
509
|
+
}
|
|
441
510
|
}
|
|
442
511
|
});
|
|
443
512
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Group, type Object3D } from "three";
|
|
2
2
|
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
3
3
|
/** Model loading, placement, shadows and animation for a custom renderer. */
|
|
4
|
-
export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes" | "surfaceDetail" | "wind">;
|
|
4
|
+
export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes" | "surfaceDetail" | "wind" | "lod">;
|
|
5
5
|
/** Caller-owned styling runs on the isolated clone, never the loader cache. */
|
|
6
6
|
export interface ModelInstanceOptions {
|
|
7
7
|
instanceId?: string;
|
|
@@ -2,6 +2,7 @@ import { useLoader, useThree } from "@react-three/fiber";
|
|
|
2
2
|
import { useEffect, useMemo } from "react";
|
|
3
3
|
import { Group } from "three";
|
|
4
4
|
import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
|
|
5
|
+
import { registerCharacterRig, unregisterCharacterRig } from "./characterLod.js";
|
|
5
6
|
import { measureLocalBounds } from "./measureBounds.js";
|
|
6
7
|
import { detectKtx2Support, sharedGltfLoader } from "./modelLoad.js";
|
|
7
8
|
import { cloneModelScene, disposeModelScene, modelPlacementTransform } from "./modelRender.js";
|
|
@@ -40,6 +41,28 @@ export function useModelInstance(model, options = {}) {
|
|
|
40
41
|
}, [gltf.scene, model.shadows, model.surfaceDetail, model.wind, configure, hiddenNodesKey]);
|
|
41
42
|
const placement = useMemo(() => modelPlacementTransform(scene, model, bindBounds), [scene, bindBounds, model.scale, model.targetHeight, model.y, model.anchor, model.dims]);
|
|
42
43
|
useEffect(() => () => disposeModelScene(content), [content]);
|
|
44
|
+
const lodKey = JSON.stringify(model.lod);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (instanceId === undefined)
|
|
47
|
+
return;
|
|
48
|
+
const [parts, bones] = countRig(content);
|
|
49
|
+
if (parts === 0)
|
|
50
|
+
return;
|
|
51
|
+
registerCharacterRig(instanceId, parts, bones);
|
|
52
|
+
return () => unregisterCharacterRig(instanceId);
|
|
53
|
+
}, [content, instanceId, lodKey]);
|
|
43
54
|
useModelAnimation(content, gltf.animations, model.animation, instanceId);
|
|
44
55
|
return { content, scene, ...placement };
|
|
45
56
|
}
|
|
57
|
+
/** Skinned parts and bones a mounted character renders, for the character-LOD probe. */
|
|
58
|
+
function countRig(root) {
|
|
59
|
+
let parts = 0;
|
|
60
|
+
let bones = 0;
|
|
61
|
+
root.traverse((node) => {
|
|
62
|
+
if (node.isSkinnedMesh === true) {
|
|
63
|
+
parts += 1;
|
|
64
|
+
bones = Math.max(bones, node.skeleton.bones.length);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return [parts, bones];
|
|
68
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { SkeletonMap } from "@jgengine/core/anim/skeletonStandard";
|
|
3
|
+
import type { PhysicsWorld } from "@jgengine/core/physics/physicsWorld";
|
|
4
|
+
/** What {@link useRagdoll} needs: the rig's bone names, the physics world and what arms it. */
|
|
5
|
+
export interface RagdollOptions {
|
|
6
|
+
/** The rig's bone names by standard slot, from `mapRigToStandard` on the loaded scene's bones. */
|
|
7
|
+
map: SkeletonMap;
|
|
8
|
+
/**
|
|
9
|
+
* The physics world the bodies live in. It is stepped by this hook, so give the ragdoll its own
|
|
10
|
+
* world (a private one per dead character) rather than a world shared with live physics. Its
|
|
11
|
+
* bounds' lower Y is the floor the body lands on.
|
|
12
|
+
*/
|
|
13
|
+
world: PhysicsWorld;
|
|
14
|
+
/** Arm the ragdoll. On the false → true edge the rig's current world poses become the bodies. */
|
|
15
|
+
active: boolean;
|
|
16
|
+
/** Character height in metres, so body shapes scale. Default 1.8. */
|
|
17
|
+
height?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Turns a rigged character into a physics ragdoll when `active` goes true: the bones' world poses at
|
|
21
|
+
* that moment become box bodies joined by distance links in `world`, and every frame after, each
|
|
22
|
+
* bone's world position and aim come from its body. The physics bodies carry translation only, so
|
|
23
|
+
* each bone is oriented by rotating its death-pose direction-to-child onto the current one, which is
|
|
24
|
+
* what gives the body a skeleton instead of a pile of boxes.
|
|
25
|
+
*
|
|
26
|
+
* While the ragdoll is active it owns the mapped bones, so stop the model's IK for the same
|
|
27
|
+
* character (`ModelConfig.ik` unset) or the two write the same joints. Deactivating removes the
|
|
28
|
+
* bodies and leaves the rig where the ragdoll put it.
|
|
29
|
+
*
|
|
30
|
+
* @capability animation turn a killed rigged character into a physics ragdoll on death
|
|
31
|
+
*/
|
|
32
|
+
export declare function useRagdoll(scene: THREE.Object3D, options: RagdollOptions): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useFrame } from "@react-three/fiber";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { ragdollBoneOrientations, ragdollConfigFromPoses } from "@jgengine/core/anim/ragdollRig";
|
|
5
|
+
import { createRagdoll } from "@jgengine/core/physics/ragdoll";
|
|
6
|
+
import { resolveRigNode } from "./rigNode.js";
|
|
7
|
+
const REFERENCE_HEIGHT = 1.8;
|
|
8
|
+
/**
|
|
9
|
+
* Turns a rigged character into a physics ragdoll when `active` goes true: the bones' world poses at
|
|
10
|
+
* that moment become box bodies joined by distance links in `world`, and every frame after, each
|
|
11
|
+
* bone's world position and aim come from its body. The physics bodies carry translation only, so
|
|
12
|
+
* each bone is oriented by rotating its death-pose direction-to-child onto the current one, which is
|
|
13
|
+
* what gives the body a skeleton instead of a pile of boxes.
|
|
14
|
+
*
|
|
15
|
+
* While the ragdoll is active it owns the mapped bones, so stop the model's IK for the same
|
|
16
|
+
* character (`ModelConfig.ik` unset) or the two write the same joints. Deactivating removes the
|
|
17
|
+
* bodies and leaves the rig where the ragdoll put it.
|
|
18
|
+
*
|
|
19
|
+
* @capability animation turn a killed rigged character into a physics ragdoll on death
|
|
20
|
+
*/
|
|
21
|
+
export function useRagdoll(scene, options) {
|
|
22
|
+
const { map, world, active, height = REFERENCE_HEIGHT } = options;
|
|
23
|
+
const ragdollRef = useRef(null);
|
|
24
|
+
const posesRef = useRef([]);
|
|
25
|
+
const bonesRef = useRef({});
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const bones = {};
|
|
28
|
+
for (const slot of Object.keys(map.slots)) {
|
|
29
|
+
const name = map.slots[slot];
|
|
30
|
+
if (name === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const node = resolveRigNode(scene, name).node;
|
|
33
|
+
if (node !== undefined)
|
|
34
|
+
bones[slot] = node;
|
|
35
|
+
}
|
|
36
|
+
bonesRef.current = bones;
|
|
37
|
+
return () => {
|
|
38
|
+
bonesRef.current = {};
|
|
39
|
+
};
|
|
40
|
+
}, [scene, map]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!active)
|
|
43
|
+
return;
|
|
44
|
+
scene.updateMatrixWorld(true);
|
|
45
|
+
const positions = [];
|
|
46
|
+
const worldPosition = new THREE.Vector3();
|
|
47
|
+
const worldQuaternion = new THREE.Quaternion();
|
|
48
|
+
for (const slot of Object.keys(bonesRef.current)) {
|
|
49
|
+
const bone = bonesRef.current[slot];
|
|
50
|
+
if (bone === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
bone.getWorldPosition(worldPosition);
|
|
53
|
+
bone.getWorldQuaternion(worldQuaternion);
|
|
54
|
+
positions.push({
|
|
55
|
+
slot,
|
|
56
|
+
position: [worldPosition.x, worldPosition.y, worldPosition.z],
|
|
57
|
+
orientation: [worldQuaternion.x, worldQuaternion.y, worldQuaternion.z, worldQuaternion.w],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (positions.length < 2)
|
|
61
|
+
return;
|
|
62
|
+
posesRef.current = positions;
|
|
63
|
+
ragdollRef.current = createRagdoll(world, ragdollConfigFromPoses(map, positions, { scale: height / REFERENCE_HEIGHT }));
|
|
64
|
+
return () => {
|
|
65
|
+
ragdollRef.current?.remove();
|
|
66
|
+
for (const id of Object.values(ragdollRef.current?.bones ?? {}))
|
|
67
|
+
world.removeBody(id);
|
|
68
|
+
ragdollRef.current = null;
|
|
69
|
+
posesRef.current = [];
|
|
70
|
+
};
|
|
71
|
+
}, [active, scene, map, world, height]);
|
|
72
|
+
useFrame((_state, delta) => {
|
|
73
|
+
const ragdoll = ragdollRef.current;
|
|
74
|
+
if (ragdoll === null || delta <= 0)
|
|
75
|
+
return;
|
|
76
|
+
world.step(delta);
|
|
77
|
+
const positions = {};
|
|
78
|
+
for (const [slot, id] of Object.entries(ragdoll.bones)) {
|
|
79
|
+
positions[slot] = [world.posX[id], world.posY[id], world.posZ[id]];
|
|
80
|
+
}
|
|
81
|
+
const orientations = ragdollBoneOrientations(posesRef.current, positions);
|
|
82
|
+
const worldPosition = new THREE.Vector3();
|
|
83
|
+
const worldQuaternion = new THREE.Quaternion();
|
|
84
|
+
const parentQuaternion = new THREE.Quaternion();
|
|
85
|
+
for (const [slot, id] of Object.entries(ragdoll.bones)) {
|
|
86
|
+
const bone = bonesRef.current[slot];
|
|
87
|
+
const orientation = orientations[slot];
|
|
88
|
+
if (bone === undefined || orientation === undefined)
|
|
89
|
+
continue;
|
|
90
|
+
worldPosition.set(world.posX[id], world.posY[id], world.posZ[id]);
|
|
91
|
+
worldQuaternion.set(orientation[0], orientation[1], orientation[2], orientation[3]);
|
|
92
|
+
const parent = bone.parent;
|
|
93
|
+
if (parent === null) {
|
|
94
|
+
bone.position.copy(worldPosition);
|
|
95
|
+
bone.quaternion.copy(worldQuaternion);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
parent.updateWorldMatrix(true, false);
|
|
99
|
+
bone.position.copy(parent.worldToLocal(worldPosition));
|
|
100
|
+
bone.quaternion.copy(parent.getWorldQuaternion(parentQuaternion).invert().multiply(worldQuaternion));
|
|
101
|
+
}
|
|
102
|
+
bone.updateMatrixWorld(true);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -1,64 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { ScrollRail } from "@jgengine/react/scrollRail";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
4
3
|
import { useDialogBehavior } from "@jgengine/react/dialogBehavior";
|
|
5
|
-
import {
|
|
6
|
-
import {} from "
|
|
4
|
+
import { ScrollRail } from "@jgengine/react/scrollRail";
|
|
5
|
+
import { SettingsSection } from "@jgengine/react/settingsBlocks";
|
|
7
6
|
const surface = "var(--jg-surface, #16161d)";
|
|
8
7
|
const surfaceDeep = "var(--jg-surface-deep, #0b0b11)";
|
|
9
8
|
const edge = "var(--jg-edge, #2b2b35)";
|
|
10
9
|
const text = "var(--jg-text, #ececef)";
|
|
11
10
|
const textDim = "var(--jg-text-dim, #9494a0)";
|
|
12
11
|
const accent = "var(--jg-accent, #6ee7a8)";
|
|
13
|
-
const accentGlow = "var(--jg-accent-glow, rgba(110,231,168,0.35))";
|
|
14
12
|
const onAccent = "var(--jg-surface-deep, #0b0b0f)";
|
|
15
13
|
const danger = "var(--jg-danger, #e0574e)";
|
|
16
14
|
const fontDisplay = "var(--jg-font-display, inherit)";
|
|
17
|
-
const rowStyle = { background: "rgba(255,255,255,0.045)", border: "1px solid rgba(255,255,255,0.08)" };
|
|
18
|
-
const badgeStyle = { background: surfaceDeep, border: "1px solid rgba(255,255,255,0.12)", color: text };
|
|
19
15
|
function GlyphClose() {
|
|
20
16
|
return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", className: "h-4 w-4", "aria-hidden": true, children: _jsx("path", { d: "M6 6l12 12M18 6L6 18" }) }));
|
|
21
17
|
}
|
|
22
|
-
function GlyphReset() {
|
|
23
|
-
return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", className: "h-3.5 w-3.5", "aria-hidden": true, children: [_jsx("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }), _jsx("path", { d: "M3 3v5h5" })] }));
|
|
24
|
-
}
|
|
25
18
|
function CloseButton({ onClose }) {
|
|
26
19
|
return (_jsx("button", { type: "button", onClick: onClose, "aria-label": "Close settings", className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition hover:bg-white/10", style: { color: textDim, border: "1px solid rgba(255,255,255,0.1)" }, children: _jsx(GlyphClose, {}) }));
|
|
27
20
|
}
|
|
28
|
-
function RowShell({ label, control }) {
|
|
29
|
-
return (_jsxs("div", { className: "flex items-center justify-between gap-4 rounded-xl px-3.5 py-3", style: rowStyle, children: [_jsx("span", { className: "text-sm", style: { color: text }, children: label }), control] }));
|
|
30
|
-
}
|
|
31
|
-
function SliderRow({ row }) {
|
|
32
|
-
// Mirror the thumb + readout locally; debounce the persisting `set` so a drag doesn't storm the
|
|
33
|
-
// settings store (and any graphics rebuild it drives), flushing on release/blur (#1372).
|
|
34
|
-
const { value: local, onInput, flush } = useDebouncedCommit(Number(row.value), (next) => row.set(next));
|
|
35
|
-
const readout = row.format?.(local) ?? String(local);
|
|
36
|
-
return (_jsx(RowShell, { label: row.label, control: _jsxs("div", { className: "flex flex-1 items-center gap-3 pl-4", children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: local, "aria-label": row.label, className: "h-1.5 flex-1 cursor-pointer", style: { accentColor: accent }, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush }), _jsx("span", { className: "w-12 shrink-0 text-right text-sm tabular-nums", style: { color: text }, children: readout })] }) }));
|
|
37
|
-
}
|
|
38
|
-
function ToggleRow({ row }) {
|
|
39
|
-
const on = Boolean(row.value);
|
|
40
|
-
return (_jsx(RowShell, { label: row.label, control: _jsx("button", { type: "button", role: "switch", "aria-checked": on, "aria-label": row.label, onClick: () => row.set(!on), className: "relative h-7 w-12 shrink-0 rounded-full transition", style: { background: on ? accent : "rgba(255,255,255,0.16)", boxShadow: on ? `0 0 12px ${accentGlow}` : "none" }, children: _jsx("span", { className: `absolute top-1 h-5 w-5 rounded-full bg-white shadow transition-all ${on ? "left-6" : "left-1"}` }) }) }));
|
|
41
|
-
}
|
|
42
|
-
function SelectRow({ row }) {
|
|
43
|
-
const options = row.options ?? [];
|
|
44
|
-
const current = String(row.value);
|
|
45
|
-
return (_jsx(RowShell, { label: row.label, control: _jsx("div", { role: "group", "aria-label": row.label, className: "flex flex-wrap justify-end gap-1 rounded-lg p-1", style: { background: surfaceDeep }, children: options.map((option) => {
|
|
46
|
-
const selected = option.value === current;
|
|
47
|
-
return (_jsx("button", { type: "button", onClick: () => row.set(option.value), "aria-pressed": selected, className: "min-h-[34px] rounded-md px-3 text-sm font-medium transition", style: selected ? { background: accent, color: onAccent } : { color: textDim }, children: option.label }, option.value));
|
|
48
|
-
}) }) }));
|
|
49
|
-
}
|
|
50
|
-
function RowControl({ row }) {
|
|
51
|
-
if (row.kind === "slider")
|
|
52
|
-
return _jsx(SliderRow, { row: row });
|
|
53
|
-
if (row.kind === "toggle")
|
|
54
|
-
return _jsx(ToggleRow, { row: row });
|
|
55
|
-
return _jsx(SelectRow, { row: row });
|
|
56
|
-
}
|
|
57
|
-
function KeybindRowView({ row, capturing, onCapture, }) {
|
|
58
|
-
return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded-xl px-3.5 py-3", style: rowStyle, children: [_jsx("span", { className: "text-sm", style: { color: text }, children: row.label }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [_jsx("button", { type: "button", onClick: onCapture, disabled: capturing, title: "Click to rebind \u2014 Esc to cancel", className: "inline-flex min-h-[34px] min-w-[3.75rem] items-center justify-center rounded-lg px-3 font-mono text-sm transition hover:brightness-125", style: capturing
|
|
59
|
-
? { background: accentGlow, color: accent, border: `1px solid ${accent}` }
|
|
60
|
-
: badgeStyle, children: capturing ? "Press…" : row.bindingLabel }), _jsx("button", { type: "button", onClick: () => row.reset(), "aria-label": `Reset ${row.label}`, title: "Reset to default", className: `flex h-8 w-8 items-center justify-center rounded-lg transition hover:bg-white/10 ${row.isDefault ? "invisible" : ""}`, style: { color: textDim }, children: _jsx(GlyphReset, {}) })] })] }));
|
|
61
|
-
}
|
|
62
21
|
function ActionRow({ action, onClose }) {
|
|
63
22
|
const isDanger = action.kind === "danger";
|
|
64
23
|
return (_jsxs("button", { type: "button", onClick: () => {
|
|
@@ -72,10 +31,10 @@ function ActionRow({ action, onClose }) {
|
|
|
72
31
|
function TabButton({ tab, selected, orientation, onSelect }) {
|
|
73
32
|
return (_jsx("button", { type: "button", onClick: onSelect, className: `shrink-0 min-h-[44px] whitespace-nowrap rounded-lg px-4 text-sm font-medium transition ${orientation === "col" ? "w-full text-left" : ""} ${selected ? "" : "hover:bg-white/5"}`, style: selected ? { background: accent, color: onAccent } : { color: textDim }, children: tab.label }));
|
|
74
33
|
}
|
|
75
|
-
function Body({ tab, actions,
|
|
34
|
+
function Body({ tab, actions, onClose }) {
|
|
76
35
|
return (_jsx("div", { className: "jg-settings-scroll min-h-0 flex-1 space-y-2 overflow-y-auto p-4", children: tab.category === null
|
|
77
36
|
? actions.map((action) => _jsx(ActionRow, { action: action, onClose: onClose }, action.id))
|
|
78
|
-
: (
|
|
37
|
+
: _jsx(SettingsSection, { category: tab.category, heading: false }) }));
|
|
79
38
|
}
|
|
80
39
|
const panelStyle = {
|
|
81
40
|
background: surface,
|
|
@@ -95,41 +54,13 @@ export function SettingsMenu({ controller, onClose, initialTab, }) {
|
|
|
95
54
|
...controller.categories.map((category) => ({ id: category.id, label: category.label, category })),
|
|
96
55
|
];
|
|
97
56
|
const [activeId, setActiveId] = useState(initialTab !== undefined && tabs.some((tab) => tab.id === initialTab) ? initialTab : (tabs[0]?.id ?? ""));
|
|
98
|
-
const [capturing, setCapturing] = useState(null);
|
|
99
57
|
const active = tabs.find((tab) => tab.id === activeId) ?? tabs[0] ?? null;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return;
|
|
104
|
-
const onKey = (event) => {
|
|
105
|
-
event.preventDefault();
|
|
106
|
-
event.stopPropagation();
|
|
107
|
-
if (event.code === "Escape") {
|
|
108
|
-
setCapturing(null);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
capturing.rebind(event.code);
|
|
112
|
-
setCapturing(null);
|
|
113
|
-
};
|
|
114
|
-
const onPointer = (event) => {
|
|
115
|
-
event.preventDefault();
|
|
116
|
-
event.stopPropagation();
|
|
117
|
-
capturing.rebind(`mouse${event.button}`);
|
|
118
|
-
setCapturing(null);
|
|
119
|
-
};
|
|
120
|
-
const onContext = (event) => event.preventDefault();
|
|
121
|
-
window.addEventListener("keydown", onKey, true);
|
|
122
|
-
window.addEventListener("pointerdown", onPointer, true);
|
|
123
|
-
window.addEventListener("contextmenu", onContext, true);
|
|
124
|
-
return () => {
|
|
125
|
-
window.removeEventListener("keydown", onKey, true);
|
|
126
|
-
window.removeEventListener("pointerdown", onPointer, true);
|
|
127
|
-
window.removeEventListener("contextmenu", onContext, true);
|
|
128
|
-
};
|
|
129
|
-
}, [capturing]);
|
|
58
|
+
// A keybind capture consumes Escape at the window, before the dialog's document listener, so close
|
|
59
|
+
// stays available without waiting for the capture state to re-render.
|
|
60
|
+
const dialogRef = useDialogBehavior({ open: active !== null, onClose });
|
|
130
61
|
if (active === null)
|
|
131
62
|
return null;
|
|
132
|
-
const bodyProps = { tab: active, actions: controller.actions,
|
|
63
|
+
const bodyProps = { tab: active, actions: controller.actions, onClose };
|
|
133
64
|
const title = (_jsx("h2", { className: "text-base font-semibold tracking-wide", style: { color: text }, children: "Settings" }));
|
|
134
65
|
if (controller.variant === "fullscreen") {
|
|
135
66
|
return (_jsx("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": "Settings", tabIndex: -1, className: "pointer-events-auto absolute inset-0 z-40 flex flex-col", style: { background: surface, color: text, fontFamily: fontDisplay }, children: _jsxs("div", { className: "mx-auto flex h-full w-full max-w-2xl flex-col px-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 pb-4 pt-6", children: [_jsx("h2", { className: "text-2xl font-bold tracking-wide", style: { color: text }, children: "Settings" }), _jsx(CloseButton, { onClose: onClose })] }), _jsx(ScrollRail, { label: "settings categories", style: { paddingBottom: 16 }, children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx(Body, { ...bodyProps })] }) }));
|
|
@@ -2,6 +2,7 @@ import { actionLabel, bindingLabel } from "@jgengine/core/input/actionBindings";
|
|
|
2
2
|
import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
|
|
3
3
|
import { ANISOTROPY_OPTIONS, applyGraphicsQuality, GRAPHICS_POST_STAGE_LABELS, GRAPHICS_POST_STAGES, graphicsPostStageSettingId, readGraphicsSettings, RENDER_SCALE_MAX, RENDER_SCALE_MIN, RENDER_SCALE_STEP, } from "@jgengine/core/settings/graphicsSettings";
|
|
4
4
|
import { FRAME_RATE_LIMIT_DISPLAY, FRAME_RATE_LIMIT_OPTIONS } from "@jgengine/core/settings/frameRateLimit";
|
|
5
|
+
import { accessibilitySettingRows } from "@jgengine/core/settings/accessibilitySettings";
|
|
5
6
|
import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANT_OPTIONS, TOUCH_STYLE_OPTIONS, } from "@jgengine/core/input/touchScheme";
|
|
6
7
|
import { TOUCH_STYLE_AUTO, useSettingsRevision } from "./appliedSettings.js";
|
|
7
8
|
import { useSettingsStore, } from "@jgengine/react/settings";
|
|
@@ -11,6 +12,7 @@ const DEFAULT_CATEGORY_LABELS = {
|
|
|
11
12
|
graphics: "Graphics",
|
|
12
13
|
gameplay: "Gameplay",
|
|
13
14
|
controls: "Controls",
|
|
15
|
+
accessibility: "Accessibility",
|
|
14
16
|
};
|
|
15
17
|
const percent = (value) => `${Math.round(value * 100)}%`;
|
|
16
18
|
const TOUCH_STYLE_SELECT_OPTIONS = [
|
|
@@ -195,6 +197,7 @@ export function useSettingsCategories(config) {
|
|
|
195
197
|
graphics: graphicsRows,
|
|
196
198
|
gameplay: gameplayRows,
|
|
197
199
|
controls: controlsRows,
|
|
200
|
+
accessibility: accessibilitySettingRows(store),
|
|
198
201
|
};
|
|
199
202
|
const builtInKeybinds = { controls: keybinds };
|
|
200
203
|
const declaredLabels = new Map(config.categories.map((c) => [c.id, c.label]));
|