@jgengine/shell 0.23.0 → 0.25.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 CHANGED
@@ -26,6 +26,47 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.25.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.25.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.22.0`; `@jgengine/github` is `0.12.0`.
34
+ - The frame panel from `@jgengine/react/hudPanel` is now `HudSurface` in `@jgengine/react/hudSurface` (`HudSurfaceProps`, `HudSurfaceElevation`). `HudPanel`, `HudPanelProps` and `HudPanelElevation` from `@jgengine/react/hudPanel` stay as deprecated aliases for this release. The `HudCanvas` layout slot keeps the name `HudPanel`.
35
+ - Engine HUD control sounds (button click/hover, panel and window open/close) no longer turn off with `notices: false` or `notices.cues: false`. Set `audio.hudCues: false` to silence them; `notices.cues` now covers notice sounds only.
36
+ - The notice stack now defaults to z-index 85 (was 45), above `ModalHost`. Pass `notices.zIndex` to put it back under a game overlay.
37
+
38
+ ### Added
39
+
40
+ - `HudSurface` takes `as` (`section`, `aside`, `nav`, ...) to render as a landmark.
41
+ - `notices.offset`, `notices.avoidHud` and `notices.zIndex`, with matching `NoticeStack` props. The offset adds to the device safe-area inset.
42
+ - `ctx.game.notices.push({ text, silent: true })` shows a notice without its sound.
43
+
44
+ ### Changed
45
+
46
+ - The engine stylesheet (`@jgengine/react/styles.css` and the injected copy) sits in the `components.jgengine` cascade layer, after a `theme, base, components, utilities` layer order, so plain game CSS and Tailwind utilities override engine buttons and panels without `!important`.
47
+ - `HudSurface` padding comes from `--jg-panel-padding` (default 12px); the `padding` prop sets that variable instead of an inline `padding`, so a game class or media query can override it.
48
+ - `HudButton` labels wrap (`white-space: normal`, line height 1.2, 4px vertical padding) instead of overflowing on one line.
49
+ - The shell renders the notice stack inside the HUD theme root, so notices use the game's `hudTheme` colours and font.
50
+ - A top or bottom notice stack moves clear of `HudCanvas` panels and touch controls registered in its column (Harbor Heat's courier card, action bars).
51
+
52
+ ### Fixed
53
+
54
+ - Flowing `HudCanvas` panels now register their layout region once their anchor region mounts; before, most never registered, so layout collision checks and region consumers missed them.
55
+
56
+ ## 0.24.0
57
+
58
+ ### Migrate
59
+
60
+ - **Bump lockstep SDK packages to `^0.24.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.21.0`; `@jgengine/github` is `0.11.0`.
61
+
62
+ ### Added
63
+
64
+ - `buildGroundCoverageMask`, `createGroundEdgeFade` and `applyGroundEdgeFade` (`@jgengine/shell/terrain`) fade any ground material out at the outer edge of a merged mesh footprint, leaving seams between layers inside it crisp.
65
+
66
+ ### Changed
67
+
68
+ - City studio ground (roads, junctions, block land, sidewalks, medians, drives, parks, crops, curbs, markings) now fades into the terrain where the town's ground ends instead of stopping at a hard edge. `meta.edgeFade: false` on the city volume keeps the old hard edge.
69
+
29
70
  ## 0.23.0
30
71
 
31
72
  ### Migrate
@@ -11,7 +11,7 @@ import { SettingsPlayControlGate } from "./settings/SettingsRuntime.js";
11
11
  const DevtoolsOverlay = lazy(() => import("./devtools/DevtoolsOverlay.js").then((module) => ({ default: module.DevtoolsOverlay })));
12
12
  /** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport → HUD theme root. @internal */
13
13
  export function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, }) {
14
- return (_jsx(GameUiErrorBoundary, { onRuntimeError: onRuntimeError, children: _jsxs(GameProvider, { context: ctx, children: [_jsx(SettingsPlayControlGate, { ctx: ctx }), _jsx(GamePhaseStamp, {}), _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: uiScale, children: orientationGate ? null : (_jsx(ShellUiCues, { ctx: ctx, enabled: playable.notices !== false && playable.notices?.cues !== false, children: _jsx(HudThemeRoot, { theme: playable.hudTheme, children: _jsx(GameUI, {}) }) })) }), orientationGate ? null : _jsx(ShellNotices, { ctx: ctx, config: playable.notices, scale: uiScale })] }) }));
14
+ return (_jsx(GameUiErrorBoundary, { onRuntimeError: onRuntimeError, children: _jsxs(GameProvider, { context: ctx, children: [_jsx(SettingsPlayControlGate, { ctx: ctx }), _jsx(GamePhaseStamp, {}), _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: uiScale, children: orientationGate ? null : (_jsx(ShellUiCues, { ctx: ctx, enabled: playable.audio?.hudCues !== false, children: _jsxs(HudThemeRoot, { theme: playable.hudTheme, children: [_jsx(GameUI, {}), _jsx(ShellNotices, { ctx: ctx, config: playable.notices, scale: uiScale })] }) })) })] }) }));
15
15
  }
16
16
  /** Devtools + diagnostic overlays shared by HUD and 3D presentation paths. @internal */
17
17
  export function ShellDebugOverlays({ ctx, playable, multiplayer, diagnostics, devtoolsEnabled, devtoolsOpen, hideDiagnostics = false, }) {
@@ -3,11 +3,11 @@ import type { NoticePresentationConfig } from "@jgengine/core/game/playableGame"
3
3
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
4
4
  /**
5
5
  * Wire `ctx.game.notices` for the player: refused commands with a `message` become deny notices, each new
6
- * notice plays its tone's UI cue, and the default stack renders it. Returns the detach function.
6
+ * notice plays its tone's UI cue unless pushed `silent`, and the default stack renders it. Returns the detach function.
7
7
  * @internal
8
8
  */
9
9
  export declare function attachShellNotices(ctx: GameContext, config?: NoticePresentationConfig): () => void;
10
- /** The shell-mounted notice feed; `playable.notices: false` removes it. @internal */
10
+ /** The shell-mounted notice feed, rendered inside the HUD theme root; `playable.notices: false` removes it. @internal */
11
11
  export declare function ShellNotices({ ctx, config, scale, }: {
12
12
  ctx: GameContext;
13
13
  config: NoticePresentationConfig | false | undefined;
@@ -15,8 +15,8 @@ export declare function ShellNotices({ ctx, config, scale, }: {
15
15
  }): import("react").JSX.Element | null;
16
16
  /**
17
17
  * Plays the UI cue for every engine HUD primitive interaction below it (button activate/hover, panel
18
- * open/close), so `HudButton`, `HudPanel`, windows and action slots sound without per-button wiring.
19
- * Off with `playable.notices: false` or `notices.cues: false`; `audio.uiCues` silences individual cues.
18
+ * open/close), so `HudButton`, `HudSurface`, windows and action slots sound without per-button wiring.
19
+ * Off with `audio.hudCues: false` (notice sounds are separate: `notices.cues`); `audio.uiCues` silences individual cues.
20
20
  * @internal
21
21
  */
22
22
  export declare function ShellUiCues({ ctx, enabled, children }: {
@@ -7,7 +7,7 @@ import { NoticeStack } from "@jgengine/react/notices";
7
7
  import { playUiCue, uiCueForInteraction } from "@jgengine/react/uiCues";
8
8
  /**
9
9
  * Wire `ctx.game.notices` for the player: refused commands with a `message` become deny notices, each new
10
- * notice plays its tone's UI cue, and the default stack renders it. Returns the detach function.
10
+ * notice plays its tone's UI cue unless pushed `silent`, and the default stack renders it. Returns the detach function.
11
11
  * @internal
12
12
  */
13
13
  export function attachShellNotices(ctx, config = {}) {
@@ -18,25 +18,28 @@ export function attachShellNotices(ctx, config = {}) {
18
18
  : attachRefusalNotices(ctx.game.events, notices, { localUserId: () => ctx.player.userId });
19
19
  const detachCues = config.cues === false
20
20
  ? () => undefined
21
- : notices.onPush((notice) => ctx.game.audio.play(uiCueSoundId(NOTICE_TONE_CUES[notice.tone])));
21
+ : notices.onPush((notice) => {
22
+ if (notice.silent !== true)
23
+ ctx.game.audio.play(uiCueSoundId(NOTICE_TONE_CUES[notice.tone]));
24
+ });
22
25
  return () => {
23
26
  detachRefusals();
24
27
  detachCues();
25
28
  };
26
29
  }
27
- /** The shell-mounted notice feed; `playable.notices: false` removes it. @internal */
30
+ /** The shell-mounted notice feed, rendered inside the HUD theme root; `playable.notices: false` removes it. @internal */
28
31
  export function ShellNotices({ ctx, config, scale, }) {
29
32
  const enabled = config !== false;
30
33
  const settings = config === false ? undefined : config;
31
34
  useEffect(() => (enabled ? attachShellNotices(ctx, settings) : undefined), [ctx, enabled, settings]);
32
35
  if (!enabled || settings?.hidden === true)
33
36
  return null;
34
- return _jsx(NoticeStack, { feed: ctx.game.notices, placement: settings?.placement ?? "top", scale: scale });
37
+ return (_jsx(NoticeStack, { feed: ctx.game.notices, placement: settings?.placement ?? "top", offset: settings?.offset, avoidHud: settings?.avoidHud ?? true, zIndex: settings?.zIndex ?? 85, scale: scale }));
35
38
  }
36
39
  /**
37
40
  * Plays the UI cue for every engine HUD primitive interaction below it (button activate/hover, panel
38
- * open/close), so `HudButton`, `HudPanel`, windows and action slots sound without per-button wiring.
39
- * Off with `playable.notices: false` or `notices.cues: false`; `audio.uiCues` silences individual cues.
41
+ * open/close), so `HudButton`, `HudSurface`, windows and action slots sound without per-button wiring.
42
+ * Off with `audio.hudCues: false` (notice sounds are separate: `notices.cues`); `audio.uiCues` silences individual cues.
40
43
  * @internal
41
44
  */
42
45
  export function ShellUiCues({ ctx, enabled, children }) {
@@ -14,7 +14,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
14
14
  * All geometry derives from the document volume, so editor sliders re-render the district live and
15
15
  * games consume the same authored volume unchanged.
16
16
  */
17
- import { useEffect, useMemo, useRef, useState } from "react";
17
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
18
18
  import { useFrame } from "@react-three/fiber";
19
19
  import * as THREE from "three";
20
20
  import { roundPathCorners, buildIntersectionMarkings, buildRoadRibbon, buildTrimmedIntersections, dashSegments, GROUND_DECAL_LAYERS, } from "@jgengine/core/world/roads";
@@ -30,6 +30,7 @@ import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
30
30
  import { getBuildingKit } from "../structures/buildingKitRegistry.js";
31
31
  import { createCityWindowMaterial } from "./cityWindowMaterial.js";
32
32
  import { createSurfaceRibbonMaterial } from "../terrain/surfaceRibbonMaterial.js";
33
+ import { applyGroundEdgeFade, buildGroundCoverageMask, createGroundEdgeFade } from "../terrain/groundEdgeFade.js";
33
34
  import { useTerrainQuality } from "../terrain/terrainQuality.js";
34
35
  import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
35
36
  import { junctionSurface } from "./cityJunctionSurface.js";
@@ -91,6 +92,22 @@ const CROP_LAYER = 0.42;
91
92
  const RACE_SURFACE_LAYER = 0.28;
92
93
  const RACE_PAINT_LAYER = 0.3;
93
94
  const BRIDGE_DECK_LIFT = 0.12;
95
+ /** Ground meshes bottom layer first; the index is each mesh's `renderOrder`, so blended layers draw under the ones above. */
96
+ const GROUND_LAYERS = [
97
+ "urban",
98
+ "asphalt",
99
+ "gravel",
100
+ "patches",
101
+ "curbs",
102
+ "markings",
103
+ "land",
104
+ "medians",
105
+ "sidewalks",
106
+ "parks",
107
+ "pavementDrives",
108
+ "gravelDrives",
109
+ "crops",
110
+ ];
94
111
  /** Compact carriageway-union — same seam as StreetGeometryPreview and playground `cityScene`. */
95
112
  const JUNCTION_OPTS = { curbReturnRadius: 2, apronMargin: 0.25, filletSegments: 6, elevation: ROAD_LAYER, maxSegmentLength: 2 };
96
113
  const SPECIES_PROXY = { broadleaf: "oak", conifer: "pine", palm: "palm", cypress: "cypress" };
@@ -1506,34 +1523,72 @@ function OneCity({ object, context }) {
1506
1523
  }, [race]);
1507
1524
  const quality = useTerrainQuality();
1508
1525
  const surfaceDetail = object.meta?.["surfaceDetail"] !== false;
1509
- const groundMaterials = useMemo(() => {
1510
- if (!surfaceDetail)
1526
+ // Every opaque ground layer fades out where the district's merged ground footprint meets bare
1527
+ // terrain; seams between layers inside it stay crisp. The urban sheet is left out of the
1528
+ // footprint because it already fades over its own street-distance reach.
1529
+ const edgeFadeOn = object.meta?.["edgeFade"] !== false;
1530
+ const edgeFade = useMemo(() => (edgeFadeOn ? createGroundEdgeFade() : null), [edgeFadeOn]);
1531
+ useEffect(() => () => edgeFade?.dispose(), [edgeFade]);
1532
+ const fadeWidth = Math.min(4, Math.max(1.5, (resolved?.rules.streetWidth ?? 3) * 0.6));
1533
+ const footprint = useMemo(() => {
1534
+ if (edgeFade === null || ground === null)
1511
1535
  return null;
1512
- const make = (surface, color, vertexColors, roughness, scale = 1) => createSurfaceRibbonMaterial({ surface, color, vertexColors, roughness, scale, halfWidth: 1, quality });
1536
+ const layers = GROUND_LAYERS.filter((layer) => layer !== "urban" && layer !== "curbs" && layer !== "markings").map((layer) => ground[layer]);
1537
+ return buildGroundCoverageMask(layers, { width: fadeWidth });
1538
+ }, [edgeFade, ground, fadeWidth]);
1539
+ useLayoutEffect(() => {
1540
+ edgeFade?.setMask(footprint, fadeWidth);
1541
+ }, [edgeFade, footprint, fadeWidth]);
1542
+ const groundMaterials = useMemo(() => {
1543
+ const make = (surface, color, vertexColors, roughness, scale = 1) => surfaceDetail
1544
+ ? createSurfaceRibbonMaterial({ surface, color, vertexColors, roughness, scale, halfWidth: 1, quality })
1545
+ : new THREE.MeshStandardMaterial({ color, vertexColors, roughness, metalness: 0, side: THREE.DoubleSide });
1513
1546
  const urban = make("dirt", "#ffffff", true, 1);
1514
1547
  urban.transparent = true;
1515
1548
  urban.depthWrite = false;
1516
- return {
1549
+ const materials = {
1517
1550
  urban,
1518
- land: make("dirt", "#ffffff", true, 1),
1519
1551
  asphalt: make("road", ASPHALT_COLOR, true, 0.96),
1520
1552
  gravel: make("gravel", GRAVEL_COLOR, false, 1),
1521
- sidewalks: make("pavement", SIDEWALK_COLOR, false, 1, 1.6),
1522
1553
  patches: make("road", ASPHALT_COLOR, true, 0.96),
1554
+ curbs: new THREE.MeshStandardMaterial({ color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }),
1555
+ markings: new THREE.MeshStandardMaterial({
1556
+ color: MARKING_COLOR,
1557
+ vertexColors: true,
1558
+ roughness: 0.8,
1559
+ metalness: 0,
1560
+ side: THREE.DoubleSide,
1561
+ polygonOffset: true,
1562
+ polygonOffsetFactor: -1,
1563
+ polygonOffsetUnits: -1,
1564
+ }),
1565
+ land: make("dirt", "#ffffff", true, 1),
1523
1566
  medians: make("grass", MEDIAN_COLOR, false, 1),
1567
+ sidewalks: make("pavement", SIDEWALK_COLOR, false, 1, 1.6),
1568
+ parks: make("ground", "#ffffff", true, 1),
1524
1569
  pavementDrives: make("concrete", PAVEMENT_COLOR, false, 1, 1.6),
1525
1570
  gravelDrives: make("gravel", GRAVEL_COLOR, false, 1),
1526
- parks: make("ground", "#ffffff", true, 1),
1527
1571
  crops: make("dirt", "#ffffff", true, 1),
1528
1572
  };
1529
- }, [surfaceDetail, quality]);
1573
+ if (edgeFade !== null) {
1574
+ for (const layer of GROUND_LAYERS)
1575
+ if (layer !== "urban")
1576
+ applyGroundEdgeFade(materials[layer], edgeFade);
1577
+ }
1578
+ return materials;
1579
+ }, [surfaceDetail, quality, edgeFade]);
1530
1580
  useEffect(() => () => {
1531
- for (const material of Object.values(groundMaterials ?? {}))
1581
+ for (const material of Object.values(groundMaterials))
1532
1582
  material.dispose();
1533
1583
  }, [groundMaterials]);
1534
1584
  if (resolved === null)
1535
1585
  return null;
1536
- return (_jsxs("group", { children: [ground?.urban != null ? (_jsx("mesh", { geometry: ground.urban, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.urban }), children: groundMaterials === null ? (_jsx("meshStandardMaterial", { vertexColors: true, transparent: true, depthWrite: false, roughness: 1, metalness: 0, side: THREE.DoubleSide })) : null })) : null, ground?.land != null ? (_jsx("mesh", { geometry: ground.land, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.land }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.asphalt }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.gravel }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.sidewalks }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.patches }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, vertexColors: true, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.medians }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.pavementDrives }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.gravelDrives }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.parks }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.crops }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, race !== null ? race.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `race:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
1586
+ return (_jsxs("group", { children: [GROUND_LAYERS.map((layer, order) => {
1587
+ const geometry = ground?.[layer];
1588
+ if (geometry == null)
1589
+ return null;
1590
+ return (_jsx("mesh", { geometry: geometry, material: groundMaterials[layer], renderOrder: order, receiveShadow: layer !== "markings", castShadow: layer === "curbs" }, layer));
1591
+ }), bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, race !== null ? race.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `race:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
1537
1592
  }
1538
1593
  /** One instanced unit-box mesh from precomposed matrices (bridge parts). */
1539
1594
  function InstancedBoxes({ matrices, color }) {
@@ -0,0 +1,67 @@
1
+ import * as THREE from "three";
2
+ /** Options for {@link buildGroundCoverageMask}. */
3
+ export interface GroundCoverageOptions {
4
+ /** Metres inside the outer edge over which ground goes from clear to opaque. */
5
+ width: number;
6
+ /** Gaps between layers narrower than twice this (metres) count as inside, not as an outer edge. Default 1.5. */
7
+ closeRadius?: number;
8
+ /** Finest cell size in metres. Default 0.5. */
9
+ cell?: number;
10
+ /** Upper bound on cells per axis; large districts get coarser cells. Default 640. */
11
+ maxCells?: number;
12
+ }
13
+ /**
14
+ * How far each point of a merged ground footprint lies inside its outer edge, as a grid over world
15
+ * XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more
16
+ * inside. Cell centres sit at `origin + (index + 0.5) * cell`.
17
+ */
18
+ export interface GroundCoverageMask {
19
+ data: Uint8Array;
20
+ columns: number;
21
+ rows: number;
22
+ originX: number;
23
+ originZ: number;
24
+ cell: number;
25
+ }
26
+ /**
27
+ * Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a
28
+ * {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged
29
+ * footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical
30
+ * faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
31
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
32
+ */
33
+ export declare function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null;
34
+ /** Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile. */
35
+ export interface GroundEdgeFade {
36
+ readonly uniforms: {
37
+ uJgFadeMask: {
38
+ value: THREE.Texture;
39
+ };
40
+ /** `(originX, originZ, columns * cell, rows * cell)`. */
41
+ uJgFadeBounds: {
42
+ value: THREE.Vector4;
43
+ };
44
+ /** Mask width in metres; scales the noise that breaks up the edge. */
45
+ uJgFadeWidth: {
46
+ value: number;
47
+ };
48
+ };
49
+ /** Swap in a new mask (`null` = fully opaque everywhere). The previous texture is disposed. */
50
+ setMask(mask: GroundCoverageMask | null, width: number): void;
51
+ dispose(): void;
52
+ }
53
+ /**
54
+ * Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
55
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
56
+ */
57
+ export declare function createGroundEdgeFade(): GroundEdgeFade;
58
+ /**
59
+ * Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge,
60
+ * opaque `width` metres inside, with a noise-broken line. Seams between layers inside the
61
+ * footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary
62
+ * melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending
63
+ * `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes
64
+ * with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
65
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
66
+ */
67
+ export declare function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M;
@@ -0,0 +1,234 @@
1
+ import * as THREE from "three";
2
+ const DIAGONAL = Math.SQRT2;
3
+ /** Two-pass chamfer distance (in cells) from every cell to the nearest seed cell. */
4
+ function chamfer(seed, columns, rows) {
5
+ const d = new Float32Array(columns * rows);
6
+ for (let i = 0; i < d.length; i += 1)
7
+ d[i] = seed[i] === 1 ? 0 : Infinity;
8
+ for (let j = 0; j < rows; j += 1) {
9
+ for (let i = 0; i < columns; i += 1) {
10
+ const k = j * columns + i;
11
+ let best = d[k];
12
+ if (i > 0)
13
+ best = Math.min(best, d[k - 1] + 1);
14
+ if (j > 0) {
15
+ best = Math.min(best, d[k - columns] + 1);
16
+ if (i > 0)
17
+ best = Math.min(best, d[k - columns - 1] + DIAGONAL);
18
+ if (i + 1 < columns)
19
+ best = Math.min(best, d[k - columns + 1] + DIAGONAL);
20
+ }
21
+ d[k] = best;
22
+ }
23
+ }
24
+ for (let j = rows - 1; j >= 0; j -= 1) {
25
+ for (let i = columns - 1; i >= 0; i -= 1) {
26
+ const k = j * columns + i;
27
+ let best = d[k];
28
+ if (i + 1 < columns)
29
+ best = Math.min(best, d[k + 1] + 1);
30
+ if (j + 1 < rows) {
31
+ best = Math.min(best, d[k + columns] + 1);
32
+ if (i + 1 < columns)
33
+ best = Math.min(best, d[k + columns + 1] + DIAGONAL);
34
+ if (i > 0)
35
+ best = Math.min(best, d[k + columns - 1] + DIAGONAL);
36
+ }
37
+ d[k] = best;
38
+ }
39
+ }
40
+ return d;
41
+ }
42
+ /**
43
+ * Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a
44
+ * {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged
45
+ * footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical
46
+ * faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
47
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
48
+ */
49
+ export function buildGroundCoverageMask(geometries, options) {
50
+ const width = Math.max(1e-3, options.width);
51
+ const closeRadius = Math.max(0, options.closeRadius ?? 1.5);
52
+ let minX = Infinity;
53
+ let minZ = Infinity;
54
+ let maxX = -Infinity;
55
+ let maxZ = -Infinity;
56
+ for (const geometry of geometries) {
57
+ const position = geometry?.getAttribute("position");
58
+ if (position === undefined)
59
+ continue;
60
+ for (let v = 0; v < position.count; v += 1) {
61
+ const x = position.getX(v);
62
+ const z = position.getZ(v);
63
+ if (x < minX)
64
+ minX = x;
65
+ if (x > maxX)
66
+ maxX = x;
67
+ if (z < minZ)
68
+ minZ = z;
69
+ if (z > maxZ)
70
+ maxZ = z;
71
+ }
72
+ }
73
+ if (!Number.isFinite(minX))
74
+ return null;
75
+ const maxCells = Math.max(8, Math.floor(options.maxCells ?? 640));
76
+ const pad = closeRadius + 2;
77
+ const spanX = maxX - minX + pad * 2;
78
+ const spanZ = maxZ - minZ + pad * 2;
79
+ const cell = Math.max(options.cell ?? 0.5, spanX / maxCells, spanZ / maxCells);
80
+ const columns = Math.max(2, Math.ceil(spanX / cell));
81
+ const rows = Math.max(2, Math.ceil(spanZ / cell));
82
+ const originX = minX - pad;
83
+ const originZ = minZ - pad;
84
+ const covered = new Uint8Array(columns * rows);
85
+ let any = false;
86
+ for (const geometry of geometries) {
87
+ const position = geometry?.getAttribute("position");
88
+ if (geometry == null || position === undefined)
89
+ continue;
90
+ const index = geometry.getIndex();
91
+ const count = index === null ? position.count : index.count;
92
+ for (let t = 0; t + 2 < count; t += 3) {
93
+ const a = index === null ? t : index.getX(t);
94
+ const b = index === null ? t + 1 : index.getX(t + 1);
95
+ const c = index === null ? t + 2 : index.getX(t + 2);
96
+ const ax = position.getX(a);
97
+ const az = position.getZ(a);
98
+ const bx = position.getX(b);
99
+ const bz = position.getZ(b);
100
+ const cx = position.getX(c);
101
+ const cz = position.getZ(c);
102
+ const area = (bx - ax) * (cz - az) - (bz - az) * (cx - ax);
103
+ if (Math.abs(area) < 1e-6)
104
+ continue;
105
+ const sign = area > 0 ? 1 : -1;
106
+ const i0 = Math.max(0, Math.floor((Math.min(ax, bx, cx) - originX) / cell - 0.5));
107
+ const i1 = Math.min(columns - 1, Math.ceil((Math.max(ax, bx, cx) - originX) / cell - 0.5));
108
+ const j0 = Math.max(0, Math.floor((Math.min(az, bz, cz) - originZ) / cell - 0.5));
109
+ const j1 = Math.min(rows - 1, Math.ceil((Math.max(az, bz, cz) - originZ) / cell - 0.5));
110
+ for (let j = j0; j <= j1; j += 1) {
111
+ const pz = originZ + (j + 0.5) * cell;
112
+ for (let i = i0; i <= i1; i += 1) {
113
+ const px = originX + (i + 0.5) * cell;
114
+ const e0 = ((bx - ax) * (pz - az) - (bz - az) * (px - ax)) * sign;
115
+ const e1 = ((cx - bx) * (pz - bz) - (cz - bz) * (px - bx)) * sign;
116
+ const e2 = ((ax - cx) * (pz - cz) - (az - cz) * (px - cx)) * sign;
117
+ if (e0 >= 0 && e1 >= 0 && e2 >= 0) {
118
+ covered[j * columns + i] = 1;
119
+ any = true;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ if (!any)
126
+ return null;
127
+ // Morphological closing: grow the footprint by `closeRadius`, then measure inward from the grown
128
+ // edge less that radius, so a narrow gap between two layers never reads as an outer edge.
129
+ const toCovered = chamfer(covered, columns, rows);
130
+ const outsideGrown = new Uint8Array(columns * rows);
131
+ for (let k = 0; k < outsideGrown.length; k += 1)
132
+ outsideGrown[k] = toCovered[k] * cell > closeRadius ? 1 : 0;
133
+ const toOutside = chamfer(outsideGrown, columns, rows);
134
+ const data = new Uint8Array(columns * rows);
135
+ for (let k = 0; k < data.length; k += 1) {
136
+ // Centre-to-centre distance overshoots the edge between two cells by half a cell.
137
+ const inside = (toOutside[k] - 0.5) * cell - closeRadius;
138
+ data[k] = Math.round(Math.max(0, Math.min(1, inside / width)) * 255);
139
+ }
140
+ return { data, columns, rows, originX, originZ, cell };
141
+ }
142
+ function opaqueTexture() {
143
+ const texture = new THREE.DataTexture(new Uint8Array([255]), 1, 1, THREE.RedFormat);
144
+ texture.needsUpdate = true;
145
+ return texture;
146
+ }
147
+ /**
148
+ * Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
149
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
150
+ */
151
+ export function createGroundEdgeFade() {
152
+ const uniforms = {
153
+ uJgFadeMask: { value: opaqueTexture() },
154
+ uJgFadeBounds: { value: new THREE.Vector4(-1e6, -1e6, 2e6, 2e6) },
155
+ uJgFadeWidth: { value: 1 },
156
+ };
157
+ return {
158
+ uniforms,
159
+ setMask(mask, width) {
160
+ uniforms.uJgFadeMask.value.dispose();
161
+ if (mask === null) {
162
+ uniforms.uJgFadeMask.value = opaqueTexture();
163
+ uniforms.uJgFadeBounds.value.set(-1e6, -1e6, 2e6, 2e6);
164
+ }
165
+ else {
166
+ const texture = new THREE.DataTexture(mask.data, mask.columns, mask.rows, THREE.RedFormat);
167
+ texture.magFilter = THREE.LinearFilter;
168
+ texture.minFilter = THREE.LinearFilter;
169
+ texture.wrapS = THREE.ClampToEdgeWrapping;
170
+ texture.wrapT = THREE.ClampToEdgeWrapping;
171
+ // One byte per texel: rows are not 4-byte aligned unless `columns` happens to be.
172
+ texture.unpackAlignment = 1;
173
+ texture.needsUpdate = true;
174
+ uniforms.uJgFadeMask.value = texture;
175
+ uniforms.uJgFadeBounds.value.set(mask.originX, mask.originZ, mask.columns * mask.cell, mask.rows * mask.cell);
176
+ }
177
+ uniforms.uJgFadeWidth.value = Math.max(1e-3, width);
178
+ },
179
+ dispose() {
180
+ uniforms.uJgFadeMask.value.dispose();
181
+ },
182
+ };
183
+ }
184
+ const FADE_FRAGMENT_GLSL = /* glsl */ `
185
+ {
186
+ float jgFadeIn = texture2D(uJgFadeMask, (vJgFadePos - uJgFadeBounds.xy) / uJgFadeBounds.zw).r;
187
+ // Two octaves of value noise keep the edge from reading as a traced outline.
188
+ float jgFadeN = jgFadeNoise(vJgFadePos / max(0.6, uJgFadeWidth * 1.2)) * 0.7 + jgFadeNoise(vJgFadePos / max(0.25, uJgFadeWidth * 0.3) + 17.3) * 0.3 - 0.5;
189
+ diffuseColor.a *= smoothstep(0.0, 1.0, (jgFadeIn - 0.15 + jgFadeN * 0.35) / 0.7);
190
+ }
191
+ `;
192
+ const FADE_NOISE_GLSL = /* glsl */ `
193
+ float jgFadeHash(vec2 p) {
194
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
195
+ }
196
+ float jgFadeNoise(vec2 p) {
197
+ vec2 i = floor(p);
198
+ vec2 f = fract(p);
199
+ vec2 u = f * f * (3.0 - 2.0 * f);
200
+ return mix(mix(jgFadeHash(i), jgFadeHash(i + vec2(1.0, 0.0)), u.x), mix(jgFadeHash(i + vec2(0.0, 1.0)), jgFadeHash(i + vec2(1.0, 1.0)), u.x), u.y);
201
+ }
202
+ `;
203
+ /**
204
+ * Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge,
205
+ * opaque `width` metres inside, with a noise-broken line. Seams between layers inside the
206
+ * footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary
207
+ * melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending
208
+ * `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes
209
+ * with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
210
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
211
+ */
212
+ export function applyGroundEdgeFade(material, fade) {
213
+ const previousCompile = material.onBeforeCompile;
214
+ const previousKey = material.customProgramCacheKey;
215
+ material.transparent = true;
216
+ material.onBeforeCompile = (shader, renderer) => {
217
+ previousCompile.call(material, shader, renderer);
218
+ Object.assign(shader.uniforms, fade.uniforms);
219
+ shader.vertexShader = shader.vertexShader
220
+ .replace("#include <common>", "#include <common>\nvarying vec2 vJgFadePos;")
221
+ .replace("#include <begin_vertex>", "#include <begin_vertex>\nvJgFadePos = (modelMatrix * vec4(transformed, 1.0)).xz;");
222
+ shader.fragmentShader = shader.fragmentShader
223
+ .replace("#include <common>", `#include <common>
224
+ varying vec2 vJgFadePos;
225
+ uniform sampler2D uJgFadeMask;
226
+ uniform vec4 uJgFadeBounds;
227
+ uniform float uJgFadeWidth;
228
+ ${FADE_NOISE_GLSL}`)
229
+ .replace("#include <alphamap_fragment>", `#include <alphamap_fragment>\n${FADE_FRAGMENT_GLSL}`);
230
+ };
231
+ material.customProgramCacheKey = () => `${previousKey.call(material)}-jgedgefade`;
232
+ material.needsUpdate = true;
233
+ return material;
234
+ }
@@ -14,3 +14,4 @@ export { ALL_TERRAIN_SURFACE_CLASSES, createTerrainDetailMaterial, type TerrainD
14
14
  export { createTerrainPaintTextures, MAX_TERRAIN_PAINT_LAYERS, packTerrainPaintWeights, terrainSurfaceClass, terrainSurfaceClassIndex, terrainSurfaceRoughness, type TerrainPaintSlot, type TerrainPaintTextures, type TerrainPaintWeights, type TerrainSurfaceClass, } from "./terrainSurfaces.js";
15
15
  export { TerrainQualityContext, useTerrainQuality } from "./terrainQuality.js";
16
16
  export { createSurfaceRibbonMaterial, resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonEdgeMargin, ribbonSurfaceFrame, useSurfaceRibbonMaterial, type RibbonSurfaceStyle, type SurfaceRibbonMaterialOptions, } from "./surfaceRibbonMaterial.js";
17
+ export { applyGroundEdgeFade, buildGroundCoverageMask, createGroundEdgeFade, type GroundCoverageMask, type GroundCoverageOptions, type GroundEdgeFade, } from "./groundEdgeFade.js";
@@ -14,3 +14,4 @@ export { ALL_TERRAIN_SURFACE_CLASSES, createTerrainDetailMaterial, } from "./ter
14
14
  export { createTerrainPaintTextures, MAX_TERRAIN_PAINT_LAYERS, packTerrainPaintWeights, terrainSurfaceClass, terrainSurfaceClassIndex, terrainSurfaceRoughness, } from "./terrainSurfaces.js";
15
15
  export { TerrainQualityContext, useTerrainQuality } from "./terrainQuality.js";
16
16
  export { createSurfaceRibbonMaterial, resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonEdgeMargin, ribbonSurfaceFrame, useSurfaceRibbonMaterial, } from "./surfaceRibbonMaterial.js";
17
+ export { applyGroundEdgeFade, buildGroundCoverageMask, createGroundEdgeFade, } from "./groundEdgeFade.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,9 +35,9 @@
35
35
  "test": "bun test src"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.23.0",
39
- "@jgengine/react": "^0.23.0",
40
- "@jgengine/ws": "^0.23.0"
38
+ "@jgengine/core": "^0.25.0",
39
+ "@jgengine/react": "^0.25.0",
40
+ "@jgengine/ws": "^0.25.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@react-three/drei": "^10.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.23.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.25.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -232,7 +232,7 @@
232
232
 
233
233
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
234
234
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
235
- - `VERSION` (const): const VERSION: "0.23.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
235
+ - `VERSION` (const): const VERSION: "0.25.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
236
236
 
237
237
  ## @jgengine/core/runtime/adapter
238
238
 
@@ -32,8 +32,8 @@ Existing React games keep their entity store and use the focused
32
32
  - **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
33
33
  - **Route the front-end with `useMenuRouter`, and generate the credits.** `useMenuRouter(root)` is the screen stack behind title → save select → settings → credits: `open`/`back`/`replace`/`reset`, `path` for depth, and Escape-to-back bound by default (`escapeToBack: false` when the game owns Escape). `menuStackReducer` is the same transition as a pure function. For credits, `creditsForSourceIds(ids)` (`@jgengine/assets/credits`) turns the pack ids a game declares to `buildCatalog` into licensed, attributed sections, `mergeCredits` folds them into hand-authored lines, and `CreditsScreen` renders the result HudTheme-skinned.
34
34
  - **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the whole `--jg-*` vocabulary — bar fills and troughs, `HudFrame variation="themed"`, painted slots, the minimap ring, plus surfaces, edges, text, status colors, fonts, and rarity — so one theme spread on any HUD ancestor reskins the `@jgengine/react` primitives and the `registry/jgengine` blocks together. Author your own with `deriveHudTheme({ accent, surface })` or `hudTheme({ ... })`; `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. A component that needs a token no theme emits fails `check-hud-tokens` — add it to `HudTheme` rather than inventing a second vocabulary. Caller `style`/`renderItem` overrides still win.
35
- - **The shell applies `defaultHudTheme` at the GameUI root.** `HudThemeRoot` wraps every `GameUI` in a `display: contents` `[data-jg-hud]` element carrying the theme vars and injects the engine stylesheet (`jg-*` keyframes, `[data-jg-*]` interaction states, reduced motion). Set `defineGame({ hudTheme })` to swap the root theme, `hudTheme: false` to opt out; a theme spread inside `GameUI` still wins. Games outside the shell add `@import "@jgengine/react/styles.css";` to `src/index.css` (the `jgengine create` template does). The theme also carries a type scale with a 12px floor, spacing, motion, elevation, and hover/press/focus/disabled tokens; read them in inline styles through `hudType`, `hudSpace`, `hudTransition`, `hudElevation`, and `HUD_FOCUS_RING` (`@jgengine/react/hudTokens`) — never hardcode text under 12px.
36
- - **Build interactive chrome from `HudButton`, `HudPanel`, and `TweenNumber`.** `HudButton` gives hover lift, press scale, focus ring, and disabled from tokens; `HudPanel` gives elevation, header slots, and an `open` enter/exit transition; `TweenNumber` / `useTweenedValue` roll counters with an up/down delta flash. Each takes `cue` (`data-jg-cue`) and `onInteract`; `HudInteractProvider` routes every primitive's hover/press/activate/open/close to one listener; the shell mounts one that plays the matching UI cue, so engine primitives sound with no wiring (`cue="confirm"` picks the activate sound).
35
+ - **The shell applies `defaultHudTheme` at the GameUI root.** `HudThemeRoot` wraps every `GameUI` in a `display: contents` `[data-jg-hud]` element carrying the theme vars and injects the engine stylesheet (`jg-*` keyframes, `[data-jg-*]` interaction states, reduced motion). Set `defineGame({ hudTheme })` to swap the root theme, `hudTheme: false` to opt out; a theme spread inside `GameUI` still wins. Games outside the shell add `@import "@jgengine/react/styles.css";` to `src/index.css` (the `jgengine create` template does). Every engine rule sits in the `components.jgengine` cascade layer, so plain game CSS and Tailwind utilities override engine buttons and panels without `!important`. The theme also carries a type scale with a 12px floor, spacing, motion, elevation, and hover/press/focus/disabled tokens; read them in inline styles through `hudType`, `hudSpace`, `hudTransition`, `hudElevation`, and `HUD_FOCUS_RING` (`@jgengine/react/hudTokens`) — never hardcode text under 12px.
36
+ - **Build interactive chrome from `HudButton`, `HudSurface`, and `TweenNumber`.** `HudButton` gives hover lift, press scale, focus ring, and disabled from tokens, and wraps long labels; `HudSurface` (`@jgengine/react/hudSurface`; `HudPanel` from `@jgengine/react/hudPanel` is its deprecated old name, not the `HudCanvas` slot) gives elevation, header slots, an `as` landmark element, padding from `--jg-panel-padding`, and an `open` enter/exit transition; `TweenNumber` / `useTweenedValue` roll counters with an up/down delta flash. Each takes `cue` (`data-jg-cue`) and `onInteract`; `HudInteractProvider` routes every primitive's hover/press/activate/open/close to one listener; the shell mounts one that plays the matching UI cue, so engine primitives sound with no wiring (`cue="confirm"` picks the activate sound). `audio.hudCues: false` silences those control sounds; notice sounds are separate (`notices.cues`).
37
37
  - **Paint slot icons with `IconTreatment`, never render raw `itemId` strings.** It draws a `GameIcon` glyph over a school/element-keyed gradient with gloss, vignette, and optional count/keycap badges, reading the `--jg-slot-*` / `--jg-accent` tokens. `treatedItemIcon(itemId)` / `treatedActionIcon(action)` are ready adapters for hotbars and ability bars.
38
38
  - **Player death gets a visible screen and respawn feedback — never a silent teleport.** Build the moment `game-design` requires: an authored death/downed overlay that reads the lethal event, communicates the consequence, and surfaces the way back (respawn/revive/restart). Drive it from `createDownedState` (`@jgengine/core/combat`) rather than a hand-rolled flag, and mount it inside `HudCanvas`.
39
39
  - **Browser interruption listeners are shared.** Use `useBrowserSuspension` from `@jgengine/react/browserLifecycle` for blur, hidden-page and pointer-lock loss. Keep the decision to pause, release controls or preserve multiplayer simulation in the callback; use options to disable unwanted triggers.
@@ -43,7 +43,7 @@ Existing React games keep their entity store and use the focused
43
43
 
44
44
  ## Shared-world interaction rules
45
45
 
46
- - **Refusals and notices are engine feedback, not per-game toasts.** Return `{ reason: "insufficient-gold", message: "Needs 40 more gold" }` from a command's `validate`: the shell shows `message` in the shared notice feed with the deny cue and stacks repeats. Push other player messages with `ctx.game.notices.push({ text, tone })` (info/success/warn/deny) from game logic instead of a hand-built toast list. Tune or hide the stock feed with `notices: { placement, hidden }` (`hidden` keeps cues and lets the game render `useNoticeList(ctx.game.notices)` itself).
46
+ - **Refusals and notices are engine feedback, not per-game toasts.** Return `{ reason: "insufficient-gold", message: "Needs 40 more gold" }` from a command's `validate`: the shell shows `message` in the shared notice feed with the deny cue and stacks repeats. Push other player messages with `ctx.game.notices.push({ text, tone })` (info/success/warn/deny) from game logic instead of a hand-built toast list. The stack renders inside the HUD theme root, so it takes the game's `hudTheme` colours and font; a top or bottom stack moves clear of `HudCanvas` panels and touch controls in its column and sits above `ModalHost` (z 85). Tune or hide it with `notices: { placement, offset, avoidHud, zIndex, cues, hidden }` (`hidden` keeps cues and lets the game render `useNoticeList(ctx.game.notices)` itself); `push({ text, silent: true })` skips one notice's sound.
47
47
  - **UI sounds come from `useUiCue()`.** Engine primitives already sound through the shell; for game-owned markup call `cue("click" | "hover" | "open" | "close" | "confirm" | "deny" | "notify")` from buttons and panels; replace a default by declaring `audio.sounds["ui.click"]`, or silence with `audio.uiCues: { hover: false }` / `false`.
48
48
  - Every rejected verb displays its reason on the triggering surface. Use `useServerSession(transport, gameId)` for join lifecycle. Join failure blocks play with `JoinGate` and retry; returning `{ ok: false }` without feedback is incomplete.
49
49
  - Placement previews display claim cost and affordability inline with `TerritoryOverlay`.
@@ -601,7 +601,7 @@
601
601
  - `ComboMeterTheme` (interface): interface ComboMeterTheme — Reskin tokens for {@link ComboMeterHud}.
602
602
  - `Compass` (function): function Compass({ facingYaw, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }: CompassProps): ReactNode — Horizontal compass strip centered on the player's facing direction, with the eight cardinals and optional marker pips from static views, an external source, or a native `MarkerSet`.
603
603
  - `CompassProps` (interface): interface CompassProps { facingYaw: number; center?: WorldXZ; markers?: MarkerCollection; width?: number; fov?: number; kindStyles?: Record<string, MarkerKindStyle>; className?: string } — ⚠ undocumented · used by `Compass`: Horizontal compass strip centered on the player's facing direction, with the eight cardinals and optional marker pips from static views, an …
604
- - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudPanel}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
604
+ - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudSurface}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
605
605
  - `ConfirmDialogProps` (interface): interface ConfirmDialogProps — Props for {@link ConfirmDialog}.
606
606
  - `ControlHint` (interface): interface ControlHint — One row of a control legend. Name the game action(s) whose bound key(s) to show (`action`) so the glyphs come straight from the keybind map — never re-typed — or give literal `keys` for controls that live outside the map (`"Mouse"`, `"LMB"`). `label` says what the control does.
607
607
  - `ControlsList` (function): function ControlsList({ bindings, controls, hideOnCoarsePointer = true, separator = " / ", className, rowClassName, keysClassName, keyClassName, separatorClassName, labelClassName, renderKey, renderRow, }: { bindings?: ActionCodesMap; controls: readonly ControlHint[]; hideOnCoarsePointer?: boolean; … — Renders a controls legend whose key glyphs come from the game's keybind map, so bindings live in one place and never drift from a hand-typed table. Hides itself on coarse-pointer devices (a touchscreen has no keyboard) via the same `data-jg-kbd-hint` marker as `KeyHint`. Headless: every part carries a `className` slot and `data-*` hook; pass `renderKey`/`renderRow` to fully own the markup.
@@ -772,7 +772,7 @@
772
772
  - `PartyFrame` (function): function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderMember?: (member: PartyMemberEntry) => ReactNode; }): React.JSX.Element — ⚠ undocumented
773
773
  - `PartyInviteToast` (function): function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; renderInvite?: (invite: PartyInviteEntry) => ReactNode; }): React.JSX.Element | null — ⚠ undocumented
774
774
  - `PartyMemberRow` (function): function PartyMemberRow({ member, className, dotClassName, children, }: { member: PartyMemberEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented
775
- - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudPanel}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
775
+ - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudSurface}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
776
776
  - `PauseMenuItem` (interface): interface PauseMenuItem — One selectable row in a {@link PauseMenu}'s slot list (Settings, Save, Quit, …).
777
777
  - `PauseMenuProps` (interface): interface PauseMenuProps — Props for {@link PauseMenu}.
778
778
  - `PhotoModeControls` (function): function PhotoModeControls({ store, onCapture, className, style }: PhotoModeControlsProps): ReactNode — Photo-mode toolbar — a hide/show-HUD toggle, a capture button, and an exit button, bound to a `createPhotoModeStore`. Presentation-only: the game wires `onCapture` to shell `captureCanvas`/`downloadImage` and reads `hideHud` to drop its gameplay HUD.
@@ -1241,7 +1241,7 @@
1241
1241
 
1242
1242
  ## @jgengine/react/engineStyles
1243
1243
 
1244
- - `ENGINE_STYLESHEET` (const): const ENGINE_STYLESHEET: string — The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudPanel}, the quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`). Generic HUD rules sit in `:where()` so any game selector wins. The shell injects it; games that mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
1244
+ - `ENGINE_STYLESHEET` (const): const ENGINE_STYLESHEET: string — The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudSurface}, the quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`). Every rule sits in the `components.jgengine` cascade layer, so unlayered game CSS, a game's own `components` rules and Tailwind utilities win without `!important`. It first declares `theme, base, components, utilities` so Tailwind v4's preflight stays below engine controls even when the engine sheet loads first. The shell injects it; games that mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
1245
1245
  - `ensureEngineStyles` (function): function ensureEngineStyles(doc: Document | undefined = typeof document === "undefined" ? undefined : document): void — Injects {@link ENGINE_STYLESHEET} into `doc` once. Safe to call repeatedly and during SSR (no-op).
1246
1246
  - `useEngineStyles` (function): function useEngineStyles(): void — Ensures the engine stylesheet is in the document before the calling component paints. Engine primitives call it, so they animate and show interaction states even outside the shell.
1247
1247
 
@@ -1407,9 +1407,9 @@
1407
1407
 
1408
1408
  ## @jgengine/react/hudPanel
1409
1409
 
1410
- - `HudPanel` (const): const HudPanel: React.ForwardRefExoticComponent<HudPanelProps & React.RefAttributes<HTMLDivElement>> — Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with a short rise-and-fade and stays mounted through its exit when `open` turns false (`data-state="open" | "closed"`); reduced motion skips both. `className` and `style` restyle it.
1411
- - `HudPanelElevation` (type): type HudPanelElevation = "raised" | "overlay" | "modal" — Shadow height of a {@link HudPanel}.
1412
- - `HudPanelProps` (interface): interface HudPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> — Props for {@link HudPanel}.
1410
+ - `HudPanel` (const): const HudPanel: React.ForwardRefExoticComponent<HudSurfaceProps & React.RefAttributes<HTMLElement>> — Old name of the themed frame {@link HudSurface} (`@jgengine/react/hudSurface`), renamed so it no longer shares a name with the `HudCanvas` layout slot `HudPanel`.
1411
+ - `HudPanelElevation` (type): type HudPanelElevation = HudSurfaceElevation — Old name of {@link HudSurfaceElevation} (`@jgengine/react/hudSurface`).
1412
+ - `HudPanelProps` (type): type HudPanelProps = HudSurfaceProps — Old name of {@link HudSurfaceProps} (`@jgengine/react/hudSurface`).
1413
1413
 
1414
1414
  ## @jgengine/react/hudSkin
1415
1415
 
@@ -1422,6 +1422,13 @@
1422
1422
 
1423
1423
  - `HudSkinPreview` (function): function HudSkinPreview({ className }: { className?: string }): React.JSX.Element — Procedural two-skin fixture for visual regression captures.
1424
1424
 
1425
+ ## @jgengine/react/hudSurface
1426
+
1427
+ - `HudSurface` (const): const HudSurface: React.ForwardRefExoticComponent<HudSurfaceProps & React.RefAttributes<HTMLElement>> — Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with a short rise-and-fade and stays mounted through its exit when `open` turns false (`data-state="open" | "closed"`); reduced motion skips both. Padding comes from `--jg-panel-padding`, and the engine rules sit in a cascade layer, so a game's `className` restyles it without `!important`. Not the `HudCanvas` layout slot (`HudPanel` in `@jgengine/react/hudLayout`).
1428
+ - `HudSurfaceElement` (type): type HudSurfaceElement = "div" | "section" | "aside" | "nav" | "header" | "footer" | "article" | "main" | "form" | "dialog" — Block elements a {@link HudSurface} can render as, so it can be a landmark.
1429
+ - `HudSurfaceElevation` (type): type HudSurfaceElevation = "raised" | "overlay" | "modal" — Shadow height of a {@link HudSurface}.
1430
+ - `HudSurfaceProps` (interface): interface HudSurfaceProps extends Omit<HTMLAttributes<HTMLElement>, "title"> — Props for {@link HudSurface}.
1431
+
1425
1432
  ## @jgengine/react/hudTheme
1426
1433
 
1427
1434
  - `HUD_THEME_PRESETS` (const): const HUD_THEME_PRESETS: { "arcane-stone": HudTheme; "survival-wood": HudTheme; "military-flat": HudTheme; "sleek-hex": HudTheme; "elemental-circle": HudTheme; "sandbox-slot": HudTheme; ember: HudTheme; synthwave: HudTheme; fieldkit: HudTheme; } — Built-in theme presets for demos/scaffold only — not product identity; games author their own theme.
@@ -1600,13 +1607,13 @@
1600
1607
 
1601
1608
  ## @jgengine/react/modals
1602
1609
 
1603
- - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudPanel}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
1610
+ - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudSurface}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy.
1604
1611
  - `ConfirmDialogProps` (interface): interface ConfirmDialogProps — Props for {@link ConfirmDialog}.
1605
1612
  - `ModalControls` (interface): interface ModalControls — Stack-bound controls handed to a {@link ModalHost} render callback.
1606
1613
  - `ModalHost` (function): function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, onInteract, className, style, }: ModalHostProps): ReactNode — Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into it on open and is trapped with Tab/Shift+Tab cycling, Escape and (optionally) a backdrop click cancel the top modal, and focus returns to the previously focused element on close. It renders nothing when the stack is empty. Skin the panel and backdrop via `HudTheme` tokens.
1607
1614
  - `ModalHostProps` (interface): interface ModalHostProps — Props for {@link ModalHost}.
1608
1615
  - `ModalStackView` (interface): interface ModalStackView — React chrome over the headless modal stack (`@jgengine/core/ui/modalStack`) — the blocking-overlay layer a game raises over its scene: a pause menu, a "quit to menu?" confirm, a reward popup. {@link useModalStack} is the DATA/HOOK layer (subscribe + the active record), {@link ModalHost} renders the top modal with a backdrop, focus trap, and Esc-to-cancel, and {@link ConfirmDialog} / {@link PauseMenu} are ready-to-wire, HudTheme-token-driven building blocks. All unskinned and game-owned: the model never interprets a modal's `kind`, so a game switches on it to pick which of these (or its own) content to show, and reskins everything via `HudTheme` tokens and the frame.
1609
- - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudPanel}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
1616
+ - `PauseMenu` (function): function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }: PauseMenuProps): ReactNode — A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudSurface}. It ships the common shape every game's pause screen needs and nothing genre-specific — the game supplies the title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
1610
1617
  - `PauseMenuItem` (interface): interface PauseMenuItem — One selectable row in a {@link PauseMenu}'s slot list (Settings, Save, Quit, …).
1611
1618
  - `PauseMenuProps` (interface): interface PauseMenuProps — Props for {@link PauseMenu}.
1612
1619
  - `useModalStack` (function): function useModalStack(stack: ModalStack): ModalStackView — Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
@@ -1615,7 +1622,7 @@
1615
1622
 
1616
1623
  - `Notice` (interface): interface Notice — A live notice as the HUD renders it.
1617
1624
  - `NoticePlacement` (type): type NoticePlacement = "top" | "center" | "bottom" — Where {@link NoticeStack} sits: below top-edge HUD bars, over the play area, or above bottom action bars.
1618
- - `NoticeStack` (function): function NoticeStack({ feed, placement = "top", scale = 1, renderNotice, }: { /** Defaults to the surrounding game's `ctx.game.notices`. */ feed?: NoticeFeed; placement?: NoticePlacement; /** Multiplies text and spacing (the shell passes the player's UI scale). */ scale?: number; renderNotice?: (not… — Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for `ctx.game.notices`. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input.
1625
+ - `NoticeStack` (function): function NoticeStack({ feed, placement = "top", offset, avoidHud = true, zIndex = 85, scale = 1, renderNotice, }: { /** Defaults to the surrounding game's `ctx.game.notices`. */ feed?: NoticeFeed; placement?: NoticePlacement; /** Distance from the band's screen edge (px or CSS length), added to the … — Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for `ctx.game.notices` inside the HUD theme root. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input. A top or bottom stack keeps clear of HUD panels registered in its column and sits above modal overlays.
1619
1626
  - `NoticeTone` (type): type NoticeTone = "info" | "success" | "warn" | "deny" — How a notice reads: neutral info, a win, a caution, or a refused action.
1620
1627
  - `useNoticeList` (function): function useNoticeList(feed: NoticeFeed | null): readonly Notice[] — Live notices of `feed`, re-rendering on change and pruning each one when it expires.
1621
1628
 
@@ -2828,6 +2835,9 @@
2828
2835
  - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented
2829
2836
  - `GrassShaderUniforms` (interface): interface GrassShaderUniforms { uTime: THREE.IUniform<number>; uWindDirection: THREE.IUniform<THREE.Vector2>; uWindStrength: THREE.IUniform<number>; uWindSpeed: THREE.IUniform<number>; uWindGustScale: THREE.IUniform<number>; uWindFlutter: THREE.IUniform<number>; uColorB… — ⚠ undocumented
2830
2837
  - `GrassWindOptions` (interface): interface GrassWindOptions { direction?: readonly [x: number, z: number]; strength?: number; speed?: number; gustScale?: number; flutter?: number; layered?: boolean } — ⚠ undocumented
2838
+ - `GroundCoverageMask` (interface): interface GroundCoverageMask — How far each point of a merged ground footprint lies inside its outer edge, as a grid over world XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more inside. Cell centres sit at `origin + (index + 0.5) * cell`.
2839
+ - `GroundCoverageOptions` (interface): interface GroundCoverageOptions — Options for {@link buildGroundCoverageMask}.
2840
+ - `GroundEdgeFade` (interface): interface GroundEdgeFade — Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile.
2831
2841
  - `HeightfieldColorFn` (type): type HeightfieldColorFn = (x: number, z: number, height: number, out: THREE.Color) => void — Per-vertex color hook for {@link displaceHeightfieldGeometry}; write the tone into `out`.
2832
2842
  - `HeightfieldDisplaceOptions` (interface): interface HeightfieldDisplaceOptions — Options for {@link displaceHeightfieldGeometry}.
2833
2843
  - `HeightfieldRect` (interface): interface HeightfieldRect — XZ rectangle in world units — matches the shape of a terraform snapshot's `bounds`.
@@ -2861,6 +2871,9 @@
2861
2871
  - `TerrainSurfaceClass` (type): type TerrainSurfaceClass = "ground" | "grass" | "dirt" | "gravel" | "rock" | "sand" | "mud" | "snow" | "road" — Procedural ground response the terrain shader renders for a painted surface id.
2862
2872
  - `TerrainSurfaceColorOptions` (interface): interface TerrainSurfaceColorOptions — Slope/noise surface shading laid over the height lerp so untextured ground reads like terrain instead of a smooth two-tone gradient. All knobs are optional with sane defaults; every field is a pure function of vertex position (+ `seed`), so the coloring is stable across rebuilds and tests.
2863
2873
  - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions { low?: THREE.ColorRepresentation; high?: THREE.ColorRepresentation; waterline?: THREE.ColorRepresentation; waterlineHeight?: number } — ⚠ undocumented
2874
+ - `applyGroundEdgeFade` (function): function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M — Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge, opaque `width` metres inside, with a noise-broken line. Seams between layers inside the footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
2875
+ - `buildGroundCoverageMask` (function): function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null — Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
2876
+ - `createGroundEdgeFade` (function): function createGroundEdgeFade(): GroundEdgeFade — Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
2864
2877
  - `createSurfaceRibbonMaterial` (function): function createSurfaceRibbonMaterial(options: SurfaceRibbonMaterialOptions): THREE.MeshStandardMaterial — A `MeshStandardMaterial` that shades a ground ribbon (path, road, sidewalk) with the terrain's own procedural surface functions, so a dirt track or asphalt strip matches painted ground: the authored colour varied by the surface class's grain, roughness and micro-normal, wheel ruts or tyre lanes, and (with `feather > 0`) a noise-warped edge that fades into the terrain. The geometry needs a {@link RIBBON_FRAME_ATTRIBUTE} attribute from {@link ribbonSurfaceFrame}; build it wider by {@link ribbonEdgeMargin} so the fade straddles the authored edge. The caller disposes the material.
2865
2878
  - `createTerrainPaintColorSampler` (function): function createTerrainPaintColorSampler(terrain: TerrainPaintSource): TerrainPaintColorSampler — Shares layer tint, opacity and linear weighted-color blending between editor and runtime ground. Paint lives on a coarse cell grid; when the source exposes that grid, each sample smoothly blends the four surrounding cell centres (unpainted cells contribute the caller's base color in `out`), so painted paths and clearings get soft edges instead of grid-aligned stair steps.
2866
2879
  - `createTerrainPaintTextures` (function): function createTerrainPaintTextures(weights: TerrainPaintWeights): TerrainPaintTextures — Uploads packed paint coverage ({@link packTerrainPaintWeights}) as two linear-filtered RGBA textures plus per-slot hue, surface class, roughness and pattern scale for the terrain surface material.
@@ -2893,6 +2906,9 @@
2893
2906
  - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented
2894
2907
  - `GrassShaderUniforms` (interface): interface GrassShaderUniforms { uTime: THREE.IUniform<number>; uWindDirection: THREE.IUniform<THREE.Vector2>; uWindStrength: THREE.IUniform<number>; uWindSpeed: THREE.IUniform<number>; uWindGustScale: THREE.IUniform<number>; uWindFlutter: THREE.IUniform<number>; uColorB… — ⚠ undocumented
2895
2908
  - `GrassWindOptions` (interface): interface GrassWindOptions { direction?: readonly [x: number, z: number]; strength?: number; speed?: number; gustScale?: number; flutter?: number; layered?: boolean } — ⚠ undocumented
2909
+ - `GroundCoverageMask` (interface): interface GroundCoverageMask — How far each point of a merged ground footprint lies inside its outer edge, as a grid over world XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more inside. Cell centres sit at `origin + (index + 0.5) * cell`.
2910
+ - `GroundCoverageOptions` (interface): interface GroundCoverageOptions — Options for {@link buildGroundCoverageMask}.
2911
+ - `GroundEdgeFade` (interface): interface GroundEdgeFade — Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile.
2896
2912
  - `HeightfieldColorFn` (type): type HeightfieldColorFn = (x: number, z: number, height: number, out: THREE.Color) => void — Per-vertex color hook for {@link displaceHeightfieldGeometry}; write the tone into `out`.
2897
2913
  - `HeightfieldDisplaceOptions` (interface): interface HeightfieldDisplaceOptions — Options for {@link displaceHeightfieldGeometry}.
2898
2914
  - `HeightfieldRect` (interface): interface HeightfieldRect — XZ rectangle in world units — matches the shape of a terraform snapshot's `bounds`.
@@ -2926,6 +2942,9 @@
2926
2942
  - `TerrainSurfaceClass` (type): type TerrainSurfaceClass = "ground" | "grass" | "dirt" | "gravel" | "rock" | "sand" | "mud" | "snow" | "road" — Procedural ground response the terrain shader renders for a painted surface id.
2927
2943
  - `TerrainSurfaceColorOptions` (interface): interface TerrainSurfaceColorOptions — Slope/noise surface shading laid over the height lerp so untextured ground reads like terrain instead of a smooth two-tone gradient. All knobs are optional with sane defaults; every field is a pure function of vertex position (+ `seed`), so the coloring is stable across rebuilds and tests.
2928
2944
  - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions { low?: THREE.ColorRepresentation; high?: THREE.ColorRepresentation; waterline?: THREE.ColorRepresentation; waterlineHeight?: number } — ⚠ undocumented
2945
+ - `applyGroundEdgeFade` (function): function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M — Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge, opaque `width` metres inside, with a noise-broken line. Seams between layers inside the footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
2946
+ - `buildGroundCoverageMask` (function): function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null — Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
2947
+ - `createGroundEdgeFade` (function): function createGroundEdgeFade(): GroundEdgeFade — Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
2929
2948
  - `createSurfaceRibbonMaterial` (function): function createSurfaceRibbonMaterial(options: SurfaceRibbonMaterialOptions): THREE.MeshStandardMaterial — A `MeshStandardMaterial` that shades a ground ribbon (path, road, sidewalk) with the terrain's own procedural surface functions, so a dirt track or asphalt strip matches painted ground: the authored colour varied by the surface class's grain, roughness and micro-normal, wheel ruts or tyre lanes, and (with `feather > 0`) a noise-warped edge that fades into the terrain. The geometry needs a {@link RIBBON_FRAME_ATTRIBUTE} attribute from {@link ribbonSurfaceFrame}; build it wider by {@link ribbonEdgeMargin} so the fade straddles the authored edge. The caller disposes the material.
2930
2949
  - `createTerrainPaintColorSampler` (function): function createTerrainPaintColorSampler(terrain: TerrainPaintSource): TerrainPaintColorSampler — Shares layer tint, opacity and linear weighted-color blending between editor and runtime ground. Paint lives on a coarse cell grid; when the source exposes that grid, each sample smoothly blends the four surrounding cell centres (unpainted cells contribute the caller's base color in `out`), so painted paths and clearings get soft edges instead of grid-aligned stair steps.
2931
2950
  - `createTerrainPaintTextures` (function): function createTerrainPaintTextures(weights: TerrainPaintWeights): TerrainPaintTextures — Uploads packed paint coverage ({@link packTerrainPaintWeights}) as two linear-filtered RGBA textures plus per-slot hue, surface class, roughness and pattern scale for the terrain surface material.
@@ -2991,6 +3010,15 @@
2991
3010
  - `GrassShaderUniforms` (interface): interface GrassShaderUniforms { uTime: THREE.IUniform<number>; uWindDirection: THREE.IUniform<THREE.Vector2>; uWindStrength: THREE.IUniform<number>; uWindSpeed: THREE.IUniform<number>; uWindGustScale: THREE.IUniform<number>; uWindFlutter: THREE.IUniform<number>; uColorB… — ⚠ undocumented
2992
3011
  - `GrassWindOptions` (interface): interface GrassWindOptions { direction?: readonly [x: number, z: number]; strength?: number; speed?: number; gustScale?: number; flutter?: number; layered?: boolean } — ⚠ undocumented
2993
3012
 
3013
+ ## @jgengine/shell/terrain/groundEdgeFade
3014
+
3015
+ - `GroundCoverageMask` (interface): interface GroundCoverageMask — How far each point of a merged ground footprint lies inside its outer edge, as a grid over world XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more inside. Cell centres sit at `origin + (index + 0.5) * cell`.
3016
+ - `GroundCoverageOptions` (interface): interface GroundCoverageOptions — Options for {@link buildGroundCoverageMask}.
3017
+ - `GroundEdgeFade` (interface): interface GroundEdgeFade — Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile.
3018
+ - `applyGroundEdgeFade` (function): function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M — Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge, opaque `width` metres inside, with a noise-broken line. Seams between layers inside the footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
3019
+ - `buildGroundCoverageMask` (function): function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null — Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
3020
+ - `createGroundEdgeFade` (function): function createGroundEdgeFade(): GroundEdgeFade — Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
3021
+
2994
3022
  ## @jgengine/shell/terrain/heightfieldGeometry
2995
3023
 
2996
3024
  - `HeightfieldColorFn` (type): type HeightfieldColorFn = (x: number, z: number, height: number, out: THREE.Color) => void — Per-vertex color hook for {@link displaceHeightfieldGeometry}; write the tone into `out`.
@@ -266,6 +266,12 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
266
266
 
267
267
  - `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"`
268
268
 
269
+ ## ground-edge-fade — fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
270
+
271
+ - `applyGroundEdgeFade` (function) · `import { applyGroundEdgeFade } from "@jgengine/shell/terrain"`
272
+ - `buildGroundCoverageMask` (function) · `import { buildGroundCoverageMask } from "@jgengine/shell/terrain"`
273
+ - `createGroundEdgeFade` (function) · `import { createGroundEdgeFade } from "@jgengine/shell/terrain"`
274
+
269
275
  ## heavy-models — models over the per-model triangle budget, for perf probes and smoke assertions
270
276
 
271
277
  - `heavyModels` (function) · `import { heavyModels } from "@jgengine/shell/render/modelLoad"`
@@ -330,6 +336,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
330
336
 
331
337
  - `StatBar` (function) · `import { StatBar } from "@jgengine/react"`
332
338
 
339
+ ## hud-surface — themed panel frame with elevation, header slots, landmark element, and open/close transitions
340
+
341
+ - `HudSurface` (const) · `import { HudSurface } from "@jgengine/react/hudSurface"`
342
+
333
343
  ## hud-theme — game-authored token object covering bars, frames, slots, chrome, text, status colors, fonts, and rarity
334
344
 
335
345
  - `HudThemePalette` (interface) · `import { HudThemePalette } from "@jgengine/react"`
@@ -496,7 +506,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
496
506
 
497
507
  - `WorldNameplates` (function) · `import { WorldNameplates } from "@jgengine/shell/world/WorldHud"`
498
508
 
499
- ## notice-feed — render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking)
509
+ ## notice-feed — render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking, HUD avoidance)
500
510
 
501
511
  - `NoticeStack` (function) · `import { NoticeStack } from "@jgengine/react/notices"`
502
512
 
@@ -70,7 +70,7 @@ instead of mirroring external entities into `createMarkerSet`.
70
70
 
71
71
  **Toggleable game windows** are already shipped, not hand-rolled open/close state: `usePanels(defs)` + `<PanelHost>` + `<Window>` (`@jgengine/react`) over the headless `@jgengine/core/ui/panelModel` are THE way to build openable windows — bag (`B`), character (`C`), spellbook, quest log — with keybind toggles, drag-to-move, close buttons, ESC-to-close, and z-stack ordering all handled. Declare each window in `defs` (id, keybind, title), mount one `<PanelHost>`, and render each panel's body inside a `<Window>`; the game owns the body content and skin, the model owns focus/stacking/keybind wiring. Never re-derive window toggling from local `useState` booleans and hand-rolled keydown listeners.
72
72
 
73
- Window title-bar movement belongs to one primary pointer. Release, cancellation, capture loss, blur, and window unmount retire that gesture's listeners; another pointer cannot move it. Controlled positions and panel z-order remain caller-owned. Windows without a `variation` render on `HudPanel` (theme frame plus modal elevation) and rise in on open; `PanelHost` keeps a closed window mounted for its short exit transition, and closing retires any drag in progress. `ModalHost` fades its backdrop and lifts the dialog in, and plays the same exit when the stack empties; `ConfirmDialog` and `PauseMenu` are built from `HudPanel` and `HudButton`.
73
+ Window title-bar movement belongs to one primary pointer. Release, cancellation, capture loss, blur, and window unmount retire that gesture's listeners; another pointer cannot move it. Controlled positions and panel z-order remain caller-owned. Windows without a `variation` render on `HudSurface` (theme frame plus modal elevation) and rise in on open; `PanelHost` keeps a closed window mounted for its short exit transition, and closing retires any drag in progress. `ModalHost` fades its backdrop and lifts the dialog in, and plays the same exit when the stack empties; `ConfirmDialog` and `PauseMenu` are built from `HudSurface` and `HudButton`.
74
74
 
75
75
  **Character sheet / paperdoll** is already shipped, not hand-rolled: `<CharacterSheet>` / `<Paperdoll>` / `<StatList>` (`@jgengine/react`) compose the paperdoll equip layout (worn `EquipmentSlots` arranged around a portrait) and the derived-stat readout into one window body. `Paperdoll` is the equip-slot arrangement, `StatList` the derived-stat rows, `CharacterSheet` the batteries-included default that pairs them — the game composes and reskins them (slot layout, stat labels/terminology, skin) rather than re-deriving a paperdoll from raw divs and manual stat math.
76
76
 
@@ -48,8 +48,12 @@ noise-warped edge that fades into the terrain around the authored width; pavemen
48
48
  edge with tyre lanes and cracks. Tune with `meta.edgeSoftness` (0 = hard) and `meta.wear` (0..1);
49
49
  `meta.surfaceDetail: false` or `<AuthoredScene pathSurfaceDetail={false}>` keeps flat ribbons.
50
50
  `road({ surface })` picks the carriageway surface (default `"road"`, `false` for flat colour), and
51
- city studio ground takes `meta.surfaceDetail: false` on its volume. Custom ribbons use
52
- `createSurfaceRibbonMaterial` with `ribbonSurfaceFrame` from `@jgengine/shell/terrain`.
51
+ city studio ground takes `meta.surfaceDetail: false` on its volume. City ground layers fade out
52
+ where the district's merged footprint meets bare terrain, keeping seams between layers crisp;
53
+ `meta.edgeFade: false` on the volume keeps hard outer edges. Custom ribbons use
54
+ `createSurfaceRibbonMaterial` with `ribbonSurfaceFrame` from `@jgengine/shell/terrain`; custom
55
+ ground meshes get the same outer-edge fade from `buildGroundCoverageMask`, `createGroundEdgeFade`
56
+ and `applyGroundEdgeFade`.
53
57
 
54
58
  Custom ground consumers can use `createTerrainPaintColorSampler` from `@jgengine/shell/terrain`
55
59
  with a `TerrainPaintSource` (an editable terrain's layer/surface/weight channels). The returned