@jgengine/shell 0.12.0 → 0.13.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/GameHost.d.ts +2 -0
  3. package/dist/GameHost.js +25 -2
  4. package/dist/GamePlayerShell.js +15 -1
  5. package/dist/Shell3dPresentation.js +2 -2
  6. package/dist/camera/GameOrbitCamera.js +7 -1
  7. package/dist/camera/cameraCollision.d.ts +28 -0
  8. package/dist/camera/cameraCollision.js +34 -0
  9. package/dist/camera/cameraRigs.js +4 -8
  10. package/dist/camera/index.d.ts +1 -0
  11. package/dist/camera/index.js +1 -0
  12. package/dist/camera/rigMath.d.ts +7 -0
  13. package/dist/camera/rigMath.js +16 -0
  14. package/dist/environment/EnvironmentScene.js +6 -1
  15. package/dist/environment/RoadJunctions.d.ts +12 -0
  16. package/dist/environment/RoadJunctions.js +69 -0
  17. package/dist/environment/RoadRibbons.d.ts +8 -2
  18. package/dist/environment/RoadRibbons.js +26 -8
  19. package/dist/presentationResolve.d.ts +5 -1
  20. package/dist/presentationResolve.js +3 -1
  21. package/dist/render/PartMotion.d.ts +18 -0
  22. package/dist/render/PartMotion.js +91 -0
  23. package/dist/render/SceneModels.d.ts +9 -2
  24. package/dist/render/SceneModels.js +31 -180
  25. package/dist/render/measureBounds.d.ts +34 -0
  26. package/dist/render/measureBounds.js +127 -0
  27. package/dist/render/resolveModel.js +16 -10
  28. package/dist/render/useModelAnimation.d.ts +10 -0
  29. package/dist/render/useModelAnimation.js +198 -0
  30. package/dist/structures/GeneratedBuilding.d.ts +4 -0
  31. package/dist/structures/GeneratedBuilding.js +17 -7
  32. package/dist/world/WorldHud.d.ts +3 -1
  33. package/dist/world/WorldHud.js +2 -2
  34. package/dist/world/WorldScene.js +3 -2
  35. package/package.json +4 -4
  36. package/skills/jgengine/SKILL.md +1 -0
  37. package/skills/jgengine/api.md +13 -5
  38. package/skills/jgengine-ui/SKILL.md +6 -4
  39. package/skills/jgengine-ui/api.md +116 -6
  40. package/skills/jgengine-ui/capabilities.md +64 -0
  41. package/skills/jgengine-ui/reference.md +40 -3
  42. package/skills/jgengine-world/api.md +52 -24
  43. package/skills/jgengine-world/capabilities.md +8 -4
  44. package/skills/jgengine-world/reference.md +1 -1
package/CHANGELOG.md CHANGED
@@ -12,6 +12,32 @@ Agents building on the published SDK can also read this programmatically:
12
12
  same data as typed values, so an updater can diff its installed version against
13
13
  the latest and surface the migration steps.
14
14
 
15
+ ## 0.13.0
16
+
17
+ ### Migrate
18
+
19
+ - **Nothing required** — this release is additive fixes, defaults, and new drop-in primitives. Bump `@jgengine/*` pins to `^0.13.0` (CLI `jgengine` is 0.10.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
20
+ - Two defaults changed in the player's favor, both overridable: **offline games now persist to localStorage automatically** (#1207) and **rigged catalog assets animate by default** via clip metadata + semantic clip roles (#1209). If a game relied on rigged models staying frozen or on saves being memory-only, opt out explicitly; otherwise you get better behavior for free.
21
+
22
+ ### Added
23
+
24
+ - **Drop-in inventory grid, window manager, and character sheet** (#1220) — `@jgengine/react` ships `InventoryGrid` (real drag/stack/split), a toggleable-window system (`panels`: bag, character sheet, spellbook-style windows with `@jgengine/core/ui/panelModel` state), and a `CharacterSheet` paperdoll, all HudTheme-skinnable — wire in a line or two instead of re-deriving from raw divs.
25
+ - **Serializable runtime snapshots** (#1179) — capture/restore of live runtime state, including magazine state (`@jgengine/core/combat/magazine`) and stat modifiers, for saves and replication.
26
+ - **Portable weapon plumbing** (#1211) — `@jgengine/core/combat/weaponFire` composes trigger → magazine → spread → projectile/hitscan as data, no game-local glue.
27
+ - **Portable leveling** (#1206) and **portable damage-pool access** (#1208) — progression and damage pools exposed as genre-agnostic seams on the game context.
28
+ - **Named combat VFX presets** (#1204) — `@jgengine/core/combat/vfxPresets`: a visible attack is one line.
29
+ - **Runtime-measured render-bounds hitboxes** (#1227) — colliders wrap what the shell actually mounts, not the raw asset bounds.
30
+ - **Character motion feel** — procedural part motion for rig-less part-composed characters (#1222) plus squash & stretch and death splat (#1229).
31
+ - **City fabric** (#1191) — `streetGenerator` + `cityGenerator`, editor bake verb, street-aware lots, road junctions, grounding, and a web playground.
32
+ - **`jgengine recipe` CLI** (#1214, #1231) — vetted wired compositions, compile-pinned against the SDK, listed and copied from the CLI.
33
+ - **Editor/debug modes mirror to the URL** with a proper editor exit (#1203); game template ships a dependency-free WebGL screenshot script (#1202).
34
+
35
+ ### Changed
36
+
37
+ - Offline games persist to localStorage by default (#1207); rigged assets animate by default (#1209).
38
+ - RTS camera pan inversion fixed (#1228); sprite raycast warning silenced and duplicate nameplate health bars removed (#1194); scaffolded F2+E editor Tailwind `@source` renders its theme (#1195).
39
+ - Outside-game DX: stat-pool no-op stability, camera-transparent decor, cleaner install/version output, headless UI-intent seam (#1223); new-game briefs direct engine bugs/gaps upstream (#1200).
40
+
15
41
  ## 0.12.0
16
42
 
17
43
  ### Migrate
@@ -8,6 +8,8 @@ export interface EditorSummonModule {
8
8
  gameId: string;
9
9
  playable: PlayableGame;
10
10
  catalogs?: readonly EditorCatalogDefinition[];
11
+ /** Passed by the host when the editor was summoned over a game — powers the "Exit to game" control. */
12
+ onExitEditor?: () => void;
11
13
  }>;
12
14
  }
13
15
  /** Props for {@link GameHost}: the playable, optional multiplayer overrides, and the optional editor loader that enables the engine-owned F2+E summon. */
package/dist/GameHost.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { lazy, Suspense, useEffect, useMemo, useState } from "react";
3
3
  import { installSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
4
+ import { readUrlParam, subscribeUrlChange, writeUrlParam } from "@jgengine/core/devtools/urlFlags";
4
5
  import { multiplayerAdapterKind } from "@jgengine/core/runtime/adapter";
5
6
  import { GamePlayerShell } from "./GamePlayerShell.js";
6
7
  import { resolveShellMultiplayer } from "./multiplayer.js";
@@ -12,10 +13,13 @@ function warnUndrivenAdapter(kind) {
12
13
  console.warn(`GameHost: game declares multiplayer adapter "${kind}", which the built-in shell resolver cannot drive. ` +
13
14
  "Running offline. Pass a prebuilt session via the multiplayer prop, or a resolveMultiplayer prop, to GameHost.");
14
15
  }
16
+ /** The query param that mirrors editor mode into the URL — strip it (and reload) to drop back to the game. */
17
+ const EDITOR_MODE_PARAM = "mode";
18
+ const EDITOR_MODE_VALUE = "editor";
15
19
  function initialEditorMode(enabled) {
16
20
  if (!enabled || typeof window === "undefined")
17
21
  return false;
18
- return new URLSearchParams(window.location.search).get("mode") === "editor";
22
+ return readUrlParam(EDITOR_MODE_PARAM) === EDITOR_MODE_VALUE;
19
23
  }
20
24
  /**
21
25
  * The one documented mount: resolves multiplayer for the playable and renders the shell. With the
@@ -32,6 +36,25 @@ export function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultipla
32
36
  // Self-guards against production builds; the endpoint just marks where dev saves land.
33
37
  return installSaveEndpoint("/__jgengine/save", resolvedGameId);
34
38
  }, [editor, resolvedGameId]);
39
+ // Mirror editor mode into the URL so the address bar is the honest record of what's open: sharing
40
+ // the URL reopens the editor, and stripping `?mode=editor` (then reloading) drops back to the game.
41
+ // Only ever clear a `mode` we set to editor — never a stray value the app put there.
42
+ useEffect(() => {
43
+ if (editor === undefined)
44
+ return;
45
+ if (editorOpen)
46
+ writeUrlParam(EDITOR_MODE_PARAM, EDITOR_MODE_VALUE);
47
+ else if (readUrlParam(EDITOR_MODE_PARAM) === EDITOR_MODE_VALUE)
48
+ writeUrlParam(EDITOR_MODE_PARAM, null);
49
+ }, [editor, editorOpen]);
50
+ // Keep in step with browser back/forward: navigating away from `?mode=editor` closes the editor.
51
+ useEffect(() => {
52
+ if (editor === undefined)
53
+ return;
54
+ return subscribeUrlChange(() => {
55
+ setEditorOpen(readUrlParam(EDITOR_MODE_PARAM) === EDITOR_MODE_VALUE);
56
+ });
57
+ }, [editor]);
35
58
  useEffect(() => {
36
59
  if (editor === undefined || editorOpen)
37
60
  return;
@@ -83,7 +106,7 @@ export function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultipla
83
106
  return session;
84
107
  }, [playable, resolvedGameId, wsUrl, multiplayer, resolveMultiplayer]);
85
108
  if (editorOpen && EditorLazy !== null) {
86
- return (_jsx(Suspense, { fallback: null, children: _jsx(EditorLazy, { gameId: resolvedGameId, playable: playable, catalogs: playable.editorCatalogs }) }));
109
+ return (_jsx(Suspense, { fallback: null, children: _jsx(EditorLazy, { gameId: resolvedGameId, playable: playable, catalogs: playable.editorCatalogs, onExitEditor: () => setEditorOpen(false) }) }));
87
110
  }
88
111
  return _jsx(GamePlayerShell, { playable: playable, multiplayer: resolved });
89
112
  }
@@ -11,6 +11,7 @@ import { createSettingsStore } from "@jgengine/core/settings/settingsModel";
11
11
  import { applyBindingOverrides, clearBindingOverride, loadBindingOverrides, saveBindingOverride, } from "@jgengine/core/input/bindingOverrides";
12
12
  import { orientationGateActive, orientationHintActive, resolveOrientationRequirement, } from "@jgengine/core/ui/orientation";
13
13
  import { armFallbackSeams } from "@jgengine/core/devtools/fallbackSeams";
14
+ import { readUrlFlag, subscribeUrlChange, writeUrlParam } from "@jgengine/core/devtools/urlFlags";
14
15
  import { createAudioEngine } from "./audio/audioEngine.js";
15
16
  import { attachAudioEventWire } from "./audio/audioWire.js";
16
17
  import { installAgentBridge } from "./devtools/agentBridge.js";
@@ -25,6 +26,8 @@ import { useShellMultiplayerSync } from "./useShellMultiplayerSync.js";
25
26
  import { ShellHudPresentation } from "./ShellHudPresentation.js";
26
27
  import { Shell3dPresentation } from "./Shell3dPresentation.js";
27
28
  const DEV_USER_ID = "dev-player";
29
+ /** The query param that mirrors the devtools overlay into the URL — present = open. */
30
+ const DEBUG_PARAM = "debug";
28
31
  export { applyMotionImpulses } from "@jgengine/core/runtime/motionIntents";
29
32
  export { nearbyObstacles } from "@jgengine/core/movement/movementModel";
30
33
  export { resolvePhysicsTuning } from "@jgengine/core/movement/playerMovement";
@@ -38,9 +41,20 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
38
41
  const multiplayer = useMemo(() => (rawMultiplayer === null ? null : withDevtoolsLatency(rawMultiplayer)), [rawMultiplayer]);
39
42
  const devtoolsEnabled = playable.devtools !== false && !poster;
40
43
  armFallbackSeams(devtoolsEnabled);
41
- const [devtoolsOpen, setDevtoolsOpen] = useState(false);
44
+ // `?debug` mirrors the devtools overlay into the URL: open with a link, strip the param to close.
45
+ const [devtoolsOpen, setDevtoolsOpen] = useState(() => devtoolsEnabled && readUrlFlag(DEBUG_PARAM));
42
46
  const devtoolsOpenRef = useRef(false);
43
47
  devtoolsOpenRef.current = devtoolsOpen;
48
+ useEffect(() => {
49
+ if (!devtoolsEnabled)
50
+ return;
51
+ writeUrlParam(DEBUG_PARAM, devtoolsOpen ? "1" : null);
52
+ }, [devtoolsEnabled, devtoolsOpen]);
53
+ useEffect(() => {
54
+ if (!devtoolsEnabled)
55
+ return;
56
+ return subscribeUrlChange(() => setDevtoolsOpen(readUrlFlag(DEBUG_PARAM)));
57
+ }, [devtoolsEnabled]);
44
58
  useEffect(() => installAgentBridge({
45
59
  playable,
46
60
  devtoolsEnabled,
@@ -19,7 +19,7 @@ import { PostProcessing } from "./postfx/PostProcessing.js";
19
19
  import { EnvironmentLighting } from "./render/EnvironmentLighting.js";
20
20
  import { CollisionDebugWorld } from "./devtools/CollisionDebugWorld.js";
21
21
  import { DevtoolsRendererProbe } from "./devtools/DevtoolsOverlay.js";
22
- import { GameCameraRig, PlayerFovProvider, PlayerFovSlider, resolveRigKind, rtsPanKeysConflict, } from "./camera/index.js";
22
+ import { CAMERA_TRANSPARENT_USERDATA, GameCameraRig, PlayerFovProvider, PlayerFovSlider, resolveRigKind, rtsPanKeysConflict, } from "./camera/index.js";
23
23
  import { CullingProvider } from "./visibility/CullingProvider.js";
24
24
  import { SkyDaylight, TimeOfDayDaylight } from "./environment.js";
25
25
  import { resolveSkyLightOwnership, skyEmitsLights } from "./environment/skyLightingPolicy.js";
@@ -289,7 +289,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
289
289
  fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
290
290
  near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
291
291
  far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
292
- }, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? _jsx(EnvironmentLighting, {}) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? _jsx(WorldOverlay, { ctx: ctx }) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
292
+ }, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? _jsx(EnvironmentLighting, {}) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
293
293
  cameraDraggingRef.current = dragging;
294
294
  } }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled }), _jsx(DevtoolsRendererProbe, {}), resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: resolvedLook.postProcessing, quality: graphics.quality })) : null] }), !poster &&
295
295
  !orientationGate &&
@@ -3,6 +3,7 @@ import { OrbitControls } from "@react-three/drei";
3
3
  import { useFrame, useThree } from "@react-three/fiber";
4
4
  import { useEffect, useRef } from "react";
5
5
  import { MOUSE, PerspectiveCamera, Raycaster, Vector3 } from "three";
6
+ import { isCameraOccluderTransparent } from "./cameraCollision.js";
6
7
  import { useGameContext } from "@jgengine/react/provider";
7
8
  import { usePlayer } from "@jgengine/react/hooks";
8
9
  import { cameraLookPitch, compensatedFov, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, seedOrbitFollowState, } from "./orbitCameraMath.js";
@@ -81,6 +82,10 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
81
82
  ray.set(collisionOriginRef.current.set(t.x, t.y, t.z), dir);
82
83
  ray.near = config.collision.minTargetDistance;
83
84
  ray.far = dist;
85
+ // Sprites (VFX, billboards) live in the scene; three's Sprite.raycast
86
+ // errors every frame unless the raycaster has a camera, even though we
87
+ // discard sprite hits below. Setting it silences that per-frame log.
88
+ ray.camera = camera;
84
89
  let blocked = 0;
85
90
  for (const hit of ray.intersectObjects(scene.children, true)) {
86
91
  const obj = hit.object;
@@ -88,7 +93,8 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
88
93
  continue;
89
94
  if (obj.isSprite === true)
90
95
  continue;
91
- if (obj.userData.jgCameraTransparent === true)
96
+ // Inheritable: decor flags a group jgCameraTransparent and all children pass through.
97
+ if (isCameraOccluderTransparent(obj))
92
98
  continue;
93
99
  blocked = hit.distance;
94
100
  break;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Camera spring-arm occlusion filtering.
3
+ *
4
+ * The orbit rig raycasts target→camera each frame and pulls the boom in past
5
+ * occluders. Author decor mounted through the `WorldOverlay` presentation seam
6
+ * is dressing, not collision geometry, so it should never yank the camera. A
7
+ * decor group flags itself camera-transparent once (`jgCameraTransparent`) and
8
+ * every descendant inherits it — a child can opt back into blocking the camera
9
+ * with `jgCameraCollide`.
10
+ */
11
+ /** Minimal structural view of a raycast hit object: its own tags plus a parent link to walk. Keeps this filter free of a three.js import so it stays unit-testable. */
12
+ export interface CameraOccluder {
13
+ readonly userData?: Record<string, unknown>;
14
+ readonly parent?: CameraOccluder | null;
15
+ }
16
+ /** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
17
+ export declare const CAMERA_TRANSPARENT_USERDATA: {
18
+ readonly jgCameraTransparent: true;
19
+ };
20
+ /**
21
+ * Should the camera spring-arm ignore this raycast hit? Walks the object up its
22
+ * `.parent` chain and honors the nearest camera tag: `jgCameraCollide === true`
23
+ * blocks (opt back in), `jgCameraTransparent === true` passes through. Untagged
24
+ * geometry blocks as before, so engine-owned ground/entities are unaffected.
25
+ *
26
+ * @capability camera-transparent-decor let author decor pass through the orbit spring-arm; a child opts back in with jgCameraCollide
27
+ */
28
+ export declare function isCameraOccluderTransparent(object: CameraOccluder | null | undefined): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Camera spring-arm occlusion filtering.
3
+ *
4
+ * The orbit rig raycasts target→camera each frame and pulls the boom in past
5
+ * occluders. Author decor mounted through the `WorldOverlay` presentation seam
6
+ * is dressing, not collision geometry, so it should never yank the camera. A
7
+ * decor group flags itself camera-transparent once (`jgCameraTransparent`) and
8
+ * every descendant inherits it — a child can opt back into blocking the camera
9
+ * with `jgCameraCollide`.
10
+ */
11
+ /** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
12
+ export const CAMERA_TRANSPARENT_USERDATA = {
13
+ jgCameraTransparent: true,
14
+ };
15
+ /**
16
+ * Should the camera spring-arm ignore this raycast hit? Walks the object up its
17
+ * `.parent` chain and honors the nearest camera tag: `jgCameraCollide === true`
18
+ * blocks (opt back in), `jgCameraTransparent === true` passes through. Untagged
19
+ * geometry blocks as before, so engine-owned ground/entities are unaffected.
20
+ *
21
+ * @capability camera-transparent-decor let author decor pass through the orbit spring-arm; a child opts back in with jgCameraCollide
22
+ */
23
+ export function isCameraOccluderTransparent(object) {
24
+ for (let node = object ?? null; node != null; node = node.parent ?? null) {
25
+ const data = node.userData;
26
+ if (data === undefined)
27
+ continue;
28
+ if (data.jgCameraCollide === true)
29
+ return false;
30
+ if (data.jgCameraTransparent === true)
31
+ return true;
32
+ }
33
+ return false;
34
+ }
@@ -4,7 +4,7 @@ import { PerspectiveCamera, Quaternion, Vector3 } from "three";
4
4
  import { usePlayer } from "@jgengine/react/hooks";
5
5
  import { useGameContext } from "@jgengine/react/provider";
6
6
  import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
7
- import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, velocityYawTarget, } from "./rigMath.js";
7
+ import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rtsPanWorldDir, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, velocityYawTarget, } from "./rigMath.js";
8
8
  import { usePlayerFov } from "./PlayerFov.js";
9
9
  import { useCameraShake } from "./shakeChannel.js";
10
10
  import { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
@@ -262,13 +262,9 @@ export function RtsRig(props) {
262
262
  }
263
263
  const center = centerRef.current;
264
264
  if (panX !== 0 || panZ !== 0) {
265
- const cos = Math.cos(yawRef.current);
266
- const sin = Math.sin(yawRef.current);
267
- const worldX = panX * cos + panZ * sin;
268
- const worldZ = panZ * cos - panX * sin;
269
- const len = Math.hypot(worldX, worldZ) || 1;
270
- center.x += (worldX / len) * panSpeed * dt;
271
- center.z += (worldZ / len) * panSpeed * dt;
265
+ const dir = rtsPanWorldDir(panX, panZ, yawRef.current);
266
+ center.x += dir.x * panSpeed * dt;
267
+ center.z += dir.z * panSpeed * dt;
272
268
  }
273
269
  else {
274
270
  const follow = readFollow(ctx, followId);
@@ -7,3 +7,4 @@ export { PlayerFovProvider, PlayerFovSlider, usePlayerFov, type PlayerFovState,
7
7
  export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, type CameraShakeChannel, } from "./shakeChannel.js";
8
8
  export { rtsPanKeysConflict } from "./rigMath.js";
9
9
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
10
+ export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, type CameraOccluder, } from "./cameraCollision.js";
@@ -7,3 +7,4 @@ export { PlayerFovProvider, PlayerFovSlider, usePlayerFov, } from "./PlayerFov.j
7
7
  export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, } from "./shakeChannel.js";
8
8
  export { rtsPanKeysConflict } from "./rigMath.js";
9
9
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
10
+ export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, } from "./cameraCollision.js";
@@ -37,6 +37,13 @@ export declare function resolveTopDown(config: TopDownCameraConfig | undefined):
37
37
  * @internal
38
38
  */
39
39
  export declare function topDownPose(follow: Vec3, resolved: ResolvedTopDown, fov: number): CameraPose;
40
+ /**
41
+ * World-space direction for RTS pan input: `panX` slides along screen-right,
42
+ * `panZ` along the camera's ground-plane forward, for a camera posed by
43
+ * `topDownPose` at the same yaw. Normalized; zero input returns zero.
44
+ * @internal
45
+ */
46
+ export declare function rtsPanWorldDir(panX: number, panZ: number, yaw: number): Vec3;
40
47
  /** Exponential spring-arm approach toward a desired point (frame-rate independent).
41
48
  * @internal
42
49
  */
@@ -74,6 +74,22 @@ export function topDownPose(follow, resolved, fov) {
74
74
  fov,
75
75
  };
76
76
  }
77
+ /**
78
+ * World-space direction for RTS pan input: `panX` slides along screen-right,
79
+ * `panZ` along the camera's ground-plane forward, for a camera posed by
80
+ * `topDownPose` at the same yaw. Normalized; zero input returns zero.
81
+ * @internal
82
+ */
83
+ export function rtsPanWorldDir(panX, panZ, yaw) {
84
+ const forward = forwardVector(yaw);
85
+ const right = rightVector(yaw);
86
+ const x = panX * right.x + panZ * forward.x;
87
+ const z = panX * right.z + panZ * forward.z;
88
+ const len = Math.hypot(x, z);
89
+ if (len === 0)
90
+ return { x: 0, y: 0, z: 0 };
91
+ return { x: x / len, y: 0, z: z / len };
92
+ }
77
93
  /** Exponential spring-arm approach toward a desired point (frame-rate independent).
78
94
  * @internal
79
95
  */
@@ -6,8 +6,10 @@ import * as THREE from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { resolveBuildingPalette } from "@jgengine/core/world/buildings";
8
8
  import { resolveStructureBuildings } from "@jgengine/core/world/environmentSummary";
9
+ import { findRoadJunctions, junctionExclusions } from "@jgengine/core/world/streets";
9
10
  import { createTerrainPaletteSampler, resolveEnvironmentField, resolveTerrainDetail, resolveTerrainPalette, } from "@jgengine/core/world/terrain";
10
11
  import { GroundPad } from "./GroundPad.js";
12
+ import { RoadJunctions } from "./RoadJunctions.js";
11
13
  import { RoadRibbons } from "./RoadRibbons.js";
12
14
  import { terrainGroundColorSampler } from "./terrainGroundColor.js";
13
15
  import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
@@ -142,6 +144,7 @@ function Structures({ structures, field }) {
142
144
  const placements = useMemo(() => resolveStructureBuildings(structures).map((building) => ({
143
145
  building,
144
146
  position: [0, field.sampleHeight(building.center[0], building.center[1]), 0],
147
+ ...(building.rotationY === undefined ? {} : { rotationY: building.rotationY, pivot: building.center }),
145
148
  })), [structures, field]);
146
149
  const palette = useMemo(() => resolveBuildingPalette(structures.style, structures.palette), [structures.style, structures.palette]);
147
150
  return _jsx(InstancedBuildings, { buildings: placements, palette: palette });
@@ -154,5 +157,7 @@ export function EnvironmentScene({ feature }) {
154
157
  const pads = feature.pads ?? [];
155
158
  const islands = feature.islands ?? [];
156
159
  const roads = feature.roads ?? [];
157
- return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field }, `road-${index}`))), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field, groundColor: vegetationGroundColor(feature.terrain, field, grass) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
160
+ const junctions = useMemo(() => findRoadJunctions(roads), [roads]);
161
+ const exclusions = useMemo(() => junctionExclusions(junctions), [junctions]);
162
+ return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field, exclusions: exclusions }, `road-${index}`))), _jsx(RoadJunctions, { junctions: junctions, field: field }), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field, groundColor: vegetationGroundColor(feature.terrain, field, grass) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
158
163
  }
@@ -0,0 +1,12 @@
1
+ import { type RoadJunction } from "@jgengine/core/world/streets";
2
+ import type { TerrainField } from "@jgengine/core/world/terrain";
3
+ /**
4
+ * Renders welded road intersections: one asphalt disc patch per junction (covering the crossing
5
+ * ribbons so they read as one intersection instead of stacking/z-fighting), plus white stop lines
6
+ * and crosswalk bars at each approach. Fed the junctions the scene already computed from its roads.
7
+ * @internal
8
+ */
9
+ export declare function RoadJunctions({ junctions, field, }: {
10
+ junctions: readonly RoadJunction[];
11
+ field: TerrainField;
12
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
4
+ import { buildJunctionPatch, buildRoadRibbon } from "@jgengine/core/world/roads";
5
+ import { junctionMarkings } from "@jgengine/core/world/streets";
6
+ function toGeometry(positions, indices) {
7
+ if (positions.length === 0)
8
+ return null;
9
+ const geometry = new BufferGeometry();
10
+ geometry.setAttribute("position", new BufferAttribute(positions, 3));
11
+ geometry.setIndex(new BufferAttribute(indices, 1));
12
+ geometry.computeVertexNormals();
13
+ return geometry;
14
+ }
15
+ /** Merge many thin marking ribbons (stop lines + crosswalk bars) into one buffer geometry. */
16
+ function mergedBars(bars, width, field, elevation) {
17
+ const ribbons = bars.map((bar) => buildRoadRibbon(bar, width, (x, z) => field.sampleHeight(x, z), { elevation }));
18
+ const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
19
+ const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
20
+ if (vertexCount === 0)
21
+ return null;
22
+ const positions = new Float32Array(vertexCount);
23
+ const indices = new Uint32Array(indexCount);
24
+ let vertexOffset = 0;
25
+ let indexOffset = 0;
26
+ for (const ribbon of ribbons) {
27
+ positions.set(ribbon.positions, vertexOffset);
28
+ const base = vertexOffset / 3;
29
+ for (let i = 0; i < ribbon.indices.length; i += 1)
30
+ indices[indexOffset + i] = ribbon.indices[i] + base;
31
+ vertexOffset += ribbon.positions.length;
32
+ indexOffset += ribbon.indices.length;
33
+ }
34
+ return toGeometry(positions, indices);
35
+ }
36
+ /**
37
+ * Renders welded road intersections: one asphalt disc patch per junction (covering the crossing
38
+ * ribbons so they read as one intersection instead of stacking/z-fighting), plus white stop lines
39
+ * and crosswalk bars at each approach. Fed the junctions the scene already computed from its roads.
40
+ * @internal
41
+ */
42
+ export function RoadJunctions({ junctions, field, }) {
43
+ const patches = useMemo(() => junctions
44
+ .map((junction) => {
45
+ const patch = buildJunctionPatch(junction.center, junction.radius, (x, z) => field.sampleHeight(x, z), { elevation: junction.elevation + 0.02 });
46
+ return { color: junction.color, geometry: toGeometry(patch.positions, patch.indices) };
47
+ })
48
+ .filter((entry) => entry.geometry !== null), [junctions, field]);
49
+ const markings = useMemo(() => {
50
+ const stopBars = [];
51
+ const crossBars = [];
52
+ let elevation = 0;
53
+ for (const junction of junctions) {
54
+ elevation = Math.max(elevation, junction.elevation);
55
+ const marks = junctionMarkings(junction);
56
+ for (const bar of marks.stopLines)
57
+ stopBars.push(bar);
58
+ for (const bar of marks.crosswalkBars)
59
+ crossBars.push(bar);
60
+ }
61
+ return {
62
+ stops: mergedBars(stopBars, 0.5, field, elevation + 0.05),
63
+ crosswalks: mergedBars(crossBars, 0.55, field, elevation + 0.05),
64
+ };
65
+ }, [junctions, field]);
66
+ if (patches.length === 0)
67
+ return null;
68
+ return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx("mesh", { geometry: patch.geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: patch.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null] }));
69
+ }
@@ -1,7 +1,13 @@
1
1
  import type { RoadEnvironmentDescriptor } from "@jgengine/core/world/features";
2
+ import { type DashExclusion } from "@jgengine/core/world/roads";
2
3
  import type { TerrainField } from "@jgengine/core/world/terrain";
3
- /** Renders one `road()` descriptor: an asphalt ribbon plus optional dashed centerline, draped on the terrain. */
4
- export declare function RoadRibbons({ road, field }: {
4
+ /**
5
+ * Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
6
+ * optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
7
+ * — the welded junction patches — so the center line does not paint across intersections).
8
+ */
9
+ export declare function RoadRibbons({ road, field, exclusions, }: {
5
10
  road: RoadEnvironmentDescriptor;
6
11
  field: TerrainField;
12
+ exclusions?: readonly DashExclusion[];
7
13
  }): import("react").JSX.Element | null;
@@ -1,8 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
- import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
3
+ import { BufferAttribute, BufferGeometry, Color, DoubleSide } from "three";
4
4
  import { buildRoadRibbon, dashSegments } from "@jgengine/core/world/roads";
5
- import { offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
5
+ import { curbPaths, offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
6
+ /** Darken an asphalt color toward black for a curb/edge strip (deterministic, no alloc churn). */
7
+ function darken(hex, amount) {
8
+ const c = new Color(hex);
9
+ c.multiplyScalar(1 - amount);
10
+ return `#${c.getHexString()}`;
11
+ }
6
12
  function toGeometry(positions, indices) {
7
13
  if (positions.length === 0)
8
14
  return null;
@@ -16,9 +22,9 @@ function ribbonGeometry(path, width, field, elevation) {
16
22
  const ribbon = buildRoadRibbon(path, width, (x, z) => field.sampleHeight(x, z), { elevation });
17
23
  return toGeometry(ribbon.positions, ribbon.indices);
18
24
  }
19
- function mergedDashGeometry(road, field) {
25
+ function mergedDashGeometry(road, field, exclusions) {
20
26
  const width = Math.max(0.25, road.width * 0.035);
21
- const ribbons = dashSegments(road.path, 3.2, 4).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
27
+ const ribbons = dashSegments(road.path, 3.2, 4, exclusions).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
22
28
  const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
23
29
  const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
24
30
  if (vertexCount === 0)
@@ -38,10 +44,22 @@ function mergedDashGeometry(road, field) {
38
44
  }
39
45
  return toGeometry(positions, indices);
40
46
  }
41
- /** Renders one `road()` descriptor: an asphalt ribbon plus optional dashed centerline, draped on the terrain. */
42
- export function RoadRibbons({ road, field }) {
47
+ /**
48
+ * Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
49
+ * optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
50
+ * — the welded junction patches — so the center line does not paint across intersections).
51
+ */
52
+ export function RoadRibbons({ road, field, exclusions = [], }) {
43
53
  const asphalt = useMemo(() => ribbonGeometry(road.path, road.width, field, road.elevation), [road, field]);
44
- const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field) : null), [road, field]);
54
+ const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field, exclusions) : null), [road, field, exclusions]);
55
+ const curbColor = useMemo(() => darken(road.color, 0.45), [road.color]);
56
+ const curbs = useMemo(() => {
57
+ // A thin strip straddling each asphalt edge, lifted just above the ribbon.
58
+ const strip = Math.max(0.4, road.width * 0.09);
59
+ return curbPaths(road, strip / 2)
60
+ .map((path) => ribbonGeometry(path, strip, field, road.elevation + 0.02))
61
+ .filter((geometry) => geometry !== null);
62
+ }, [road, field]);
45
63
  const sidewalks = useMemo(() => {
46
64
  const width = sidewalkWidthOf(road);
47
65
  if (width <= 0)
@@ -53,5 +71,5 @@ export function RoadRibbons({ road, field }) {
53
71
  }, [road, field]);
54
72
  if (asphalt === null)
55
73
  return null;
56
- return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
74
+ return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), curbs.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `curb-${index}`))), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
57
75
  }
@@ -5,17 +5,21 @@ export interface ResolvedWorldOverlayBars {
5
5
  statId: string;
6
6
  roles?: readonly CatalogEntityRole[];
7
7
  maxDistance?: number;
8
+ /** Nameplates only: draw the built-in HP bar. `false` yields a name-only plate. Defaults to `true`. */
9
+ showHealth?: boolean;
8
10
  }
9
11
  /**
10
12
  * Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
11
13
  * single resolved shape. `undefined` / `false` → `null` (off); `true` → default stat `"health"`;
12
- * object → explicit fields with `statId` defaulting to `"health"`.
14
+ * object → explicit fields with `statId` defaulting to `"health"`. `showHealth` is nameplate-only
15
+ * (ignored by `worldHealthBars`, which is itself a bar).
13
16
  * @internal
14
17
  */
15
18
  export declare function resolveWorldOverlayBars(config: boolean | {
16
19
  statId?: string;
17
20
  roles?: readonly CatalogEntityRole[];
18
21
  maxDistance?: number;
22
+ showHealth?: boolean;
19
23
  } | undefined): ResolvedWorldOverlayBars | null;
20
24
  /** Resolved combat presentation channels (each boolean is whether that stack piece mounts). @internal */
21
25
  export interface ResolvedPresentationEffects {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
3
3
  * single resolved shape. `undefined` / `false` → `null` (off); `true` → default stat `"health"`;
4
- * object → explicit fields with `statId` defaulting to `"health"`.
4
+ * object → explicit fields with `statId` defaulting to `"health"`. `showHealth` is nameplate-only
5
+ * (ignored by `worldHealthBars`, which is itself a bar).
5
6
  * @internal
6
7
  */
7
8
  export function resolveWorldOverlayBars(config) {
@@ -13,6 +14,7 @@ export function resolveWorldOverlayBars(config) {
13
14
  statId: config.statId ?? "health",
14
15
  ...(config.roles === undefined ? {} : { roles: config.roles }),
15
16
  ...(config.maxDistance === undefined ? {} : { maxDistance: config.maxDistance }),
17
+ ...(config.showHealth === undefined ? {} : { showHealth: config.showHealth }),
16
18
  };
17
19
  }
18
20
  const ALL_ON = {
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ModelConfig, ModelPart } from "@jgengine/core/game/playableGame";
3
+ /**
4
+ * Procedural motion rig for a rig-less part-composed character (`ModelPart.role` — see
5
+ * `@jgengine/core/game/partAnimation`). Wraps the whole composition in a root group that bobs,
6
+ * breathes, flinches on `combat.hitReaction`, and topples on `entity.died`, while each
7
+ * role-tagged part swings around its authored transform — legs/arms counter-phase from the
8
+ * entity's live movement speed, head counter-sway, tail wag, wing flap. Untagged parts render
9
+ * as static kit pieces. Children are the base model content (primitive, attachments).
10
+ */
11
+ export declare function PartMotionRig({ parts, model, instanceId, renderPart, children, }: {
12
+ parts: readonly ModelPart[];
13
+ model: ModelConfig;
14
+ instanceId?: string;
15
+ /** Renders one part's model content (the shell passes its part renderer to avoid an import cycle). */
16
+ renderPart: (part: ModelPart, index: number) => ReactNode;
17
+ children: ReactNode;
18
+ }): ReactNode;