@jgengine/shell 0.30.0 → 0.32.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,57 @@ 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.32.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.32.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.29.0`; `@jgengine/github` is `0.19.0`.
34
+
35
+ ### Added
36
+
37
+ - Audio mixing grew a mute that composes with gain: `AudioEngine.setBusMute` / `setMasterMute` keep the player's volume while silencing a bus, `masterLevel()` / `busLevel()` report the composed value, and Settings carries a mute row per bus plus "Mute all" (`sound.muted`, `sound.bus.<id>.muted`). `AudioEngine.play` and `playOneShot` hold cues in a bounded 16-slot queue while the browser keeps the context suspended and play them on the first gesture instead of dropping them; `installGestureResume` wires that gesture for both presentation paths.
38
+ - `AudioEngine.measure()` reads master RMS and peak from a lazily built analyser, and `installAudioProbe` publishes `window.__jgengineAudio` so a headless capture can prove sound with a measured level rather than a screenshot.
39
+ - `@jgengine/core/audio/feedbackCues` ships seven procedural reactions - `hit`, `hurt`, `kill`, `pickup`, `objective`, `wave`, `deny` - on an `sfx` bus, merged into every game's catalog as `feel.<cue>`. A game sound with that id replaces one; `audio.feedbackCues` replaces or silences them.
40
+ - `@jgengine/shell/audio/eventFeedback` maps the events the engine already announces to those reactions: `combat.damage` (local player reads as hurt, anything else as a hit), `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp` and `command.refused`. Each row carries a sound, a gamepad pulse, camera trauma and a caption; the mapping and reactions are data (`DEFAULT_FEEDBACK_BINDINGS`, `DEFAULT_FEEDBACK_REACTIONS`) a game can replace, cooldowns fold a burst into one impact, reduced motion keeps sound and rumble but drops the camera kick, and the resolved reaction is pooled so handling an event allocates nothing per frame. `attachWaveFeedback(runner, ctx.game.feedback)` announces each new wave.
41
+ - `ctx.game.feedback.cue(cue, caption?)` announces a moment no engine event covers and emits `feedback.cue`.
42
+ - `@jgengine/shell/audio/captions` and `CaptionOverlay` render a bounded, deduping, self-expiring caption line under the accessibility captions setting; `FeedbackHost` mounts the whole layer - feedback mapping, gesture resume, audio probe and captions - for both the 3D and HUD presentation paths.
43
+
44
+ ### Changed
45
+
46
+ - The CI gate runs nine test shards instead of ten, and a `types` job whose stages overlap, so a docs-only pull request fits the account's 20-job runner cap with room for another run beside it. `scripts/test-shard.ts` owns which files each shard runs and what it prepares first; `bun scripts/type-shard.ts` runs the `check-types` plan two stages at a time and then the tsc fan-out alone.
47
+
48
+ ## 0.31.0
49
+
50
+ ### Migrate
51
+
52
+ - **Bump lockstep SDK packages to `^0.31.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.
53
+
54
+ ### Added
55
+
56
+ - `createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.
57
+ - `createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.
58
+ - `createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.
59
+ - `createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.
60
+ - `GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.
61
+ - `HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.
62
+ - `hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.
63
+ - `rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.
64
+ - `createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.
65
+ - `salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.
66
+ - `stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.
67
+ - `markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `"horizontal"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `"spatial"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.
68
+
69
+ ### Changed
70
+
71
+ - `evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.
72
+ - Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.
73
+
74
+ ### Fixed
75
+
76
+ - `HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.
77
+ - The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.
78
+ - `reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.
79
+
29
80
  ## 0.30.0
30
81
 
31
82
  ### Migrate
@@ -417,5 +417,5 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
417
417
  touchScheme !== null &&
418
418
  (touchScheme.gestures !== null || touchScheme.look) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: playable.camera?.firstPerson?.maxPitch ?? 1.45, onPrimaryTap: () => {
419
419
  primaryClickRef.current = true;
420
- } })) : null, _jsx(ShellGameUiChrome, { ctx: ctx, playable: playable, GameUI: GameUI, uiScale: graphics.uiScale, orientationGate: orientationGate, onRuntimeError: reportRuntimeError }), !poster && !orientationGate && showReticle ? _jsx(Reticle, {}) : null, dockMounted && touchScheme !== null ? (_jsx(TouchControlsDock, { scheme: touchScheme, sink: touchSink, style: touchStyle, scale: touchScale, joystickVariant: touchJoystickVariant })) : null, orientationGateEl, marquee !== null ? _jsx(MarqueeBox, { rect: marquee }) : null, contextMenu !== null ? (_jsx(ContextMenuView, { menu: contextMenu.menu, x: contextMenu.x, y: contextMenu.y, onPick: handleVerbPick, onClose: () => setContextMenu(null) })) : null, _jsx(ShellDebugOverlays, { ctx: ctx, playable: playable, multiplayer: multiplayer, diagnostics: diagnostics, devtoolsEnabled: devtoolsEnabled, devtoolsOpen: devtoolsOpen, hideDiagnostics: poster }), poster || orthographic || settingsHostsFov ? null : _jsx(PlayerFovSlider, {}), poster ? null : _jsx(SettingsChrome, {})] }) }) })] }) }));
420
+ } })) : null, _jsx(ShellGameUiChrome, { ctx: ctx, playable: playable, GameUI: GameUI, uiScale: graphics.uiScale, orientationGate: orientationGate, onRuntimeError: reportRuntimeError, audioEngine: audioEngine }), !poster && !orientationGate && showReticle ? _jsx(Reticle, {}) : null, dockMounted && touchScheme !== null ? (_jsx(TouchControlsDock, { scheme: touchScheme, sink: touchSink, style: touchStyle, scale: touchScale, joystickVariant: touchJoystickVariant })) : null, orientationGateEl, marquee !== null ? _jsx(MarqueeBox, { rect: marquee }) : null, contextMenu !== null ? (_jsx(ContextMenuView, { menu: contextMenu.menu, x: contextMenu.x, y: contextMenu.y, onPick: handleVerbPick, onClose: () => setContextMenu(null) })) : null, _jsx(ShellDebugOverlays, { ctx: ctx, playable: playable, multiplayer: multiplayer, diagnostics: diagnostics, devtoolsEnabled: devtoolsEnabled, devtoolsOpen: devtoolsOpen, hideDiagnostics: poster }), poster || orthographic || settingsHostsFov ? null : _jsx(PlayerFovSlider, {}), poster ? null : _jsx(SettingsChrome, {})] }) }) })] }) }));
421
421
  }
@@ -1,16 +1,18 @@
1
1
  import { type ComponentType } from "react";
2
2
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
3
3
  import { type RuntimeDiagnostic } from "./diagnostics/RuntimeDiagnostics.js";
4
+ import type { AudioEngine } from "./audio/audioEngine.js";
4
5
  import type { ShellMultiplayer } from "./multiplayer.js";
5
6
  import type { PlayableGame } from "./registry.js";
6
7
  /** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport → HUD theme root. @internal */
7
- export declare function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, }: {
8
+ export declare function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, audioEngine, }: {
8
9
  ctx: GameContext;
9
10
  playable: PlayableGame;
10
11
  GameUI: ComponentType;
11
12
  uiScale: number;
12
13
  orientationGate: boolean;
13
14
  onRuntimeError: (error: unknown, phase: string, componentStack?: string) => void;
15
+ audioEngine: AudioEngine;
14
16
  }): import("react").JSX.Element;
15
17
  /** Devtools + diagnostic overlays shared by HUD and 3D presentation paths. @internal */
16
18
  export declare function ShellDebugOverlays({ ctx, playable, multiplayer, diagnostics, devtoolsEnabled, devtoolsOpen, hideDiagnostics, }: {
@@ -6,14 +6,15 @@ import { HudViewportProvider } from "@jgengine/react/hudViewport";
6
6
  import { GameProvider } from "@jgengine/react/provider";
7
7
  import { DiagnosticOverlay, GameUiErrorBoundary, } from "./diagnostics/RuntimeDiagnostics.js";
8
8
  import { DevtoolsRuntime } from "./devtools/DevtoolsRuntime.js";
9
+ import { FeedbackHost } from "./audio/FeedbackHost.js";
9
10
  import { GamePhaseStamp } from "./GamePhaseStamp.js";
10
11
  import { ShellNotices, ShellUiCues } from "./ShellNotices.js";
11
12
  import { SettingsPlayControlGate } from "./settings/SettingsRuntime.js";
12
13
  const DevtoolsOverlay = lazy(() => import("./devtools/DevtoolsOverlay.js").then((module) => ({ default: module.DevtoolsOverlay })));
13
14
  /** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport → HUD theme root. @internal */
14
- export function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, }) {
15
+ export function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, audioEngine, }) {
15
16
  const accessibility = useAccessibilitySettings();
16
- 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, accessibility: accessibility, children: [_jsx(GameUI, {}), _jsx(ShellNotices, { ctx: ctx, config: playable.notices, scale: uiScale })] }) })) })] }) }));
17
+ 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, accessibility: accessibility, children: [_jsx(GameUI, {}), _jsx(ShellNotices, { ctx: ctx, config: playable.notices, scale: uiScale }), _jsx(FeedbackHost, { ctx: ctx, engine: audioEngine })] }) })) })] }) }));
17
18
  }
18
19
  /** Devtools + diagnostic overlays shared by HUD and 3D presentation paths. @internal */
19
20
  export function ShellDebugOverlays({ ctx, playable, multiplayer, diagnostics, devtoolsEnabled, devtoolsOpen, hideDiagnostics = false, }) {
@@ -54,5 +54,5 @@ export function ShellHudPresentation({ playable, ctx, multiplayer, serverIdRef,
54
54
  coarsePointer &&
55
55
  touchScheme !== null &&
56
56
  touchScheme.gestures !== null &&
57
- playControlsActive(ctx) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: 0, onPrimaryTap: () => undefined })) : null, _jsx(ShellGameUiChrome, { ctx: ctx, playable: playable, GameUI: GameUI, uiScale: uiScale, orientationGate: orientationGate, onRuntimeError: reportRuntimeError }), orientationGateEl, _jsx(ShellDebugOverlays, { ctx: ctx, playable: playable, multiplayer: multiplayer, diagnostics: diagnostics, devtoolsEnabled: devtoolsEnabled, devtoolsOpen: devtoolsOpen }), poster ? null : _jsx(SettingsChrome, {})] }) }) })] }));
57
+ playControlsActive(ctx) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: 0, onPrimaryTap: () => undefined })) : null, _jsx(ShellGameUiChrome, { ctx: ctx, playable: playable, GameUI: GameUI, uiScale: uiScale, orientationGate: orientationGate, onRuntimeError: reportRuntimeError, audioEngine: audioEngine }), orientationGateEl, _jsx(ShellDebugOverlays, { ctx: ctx, playable: playable, multiplayer: multiplayer, diagnostics: diagnostics, devtoolsEnabled: devtoolsEnabled, devtoolsOpen: devtoolsOpen }), poster ? null : _jsx(SettingsChrome, {})] }) }) })] }));
58
58
  }
@@ -0,0 +1,15 @@
1
+ import { type ReactNode } from "react";
2
+ import type { CaptionFeed } from "./captions.js";
3
+ /**
4
+ * The captions line: a bounded, bottom-centred stack that renders only while the accessibility
5
+ * captions setting is on. Reads HudTheme tokens, so a game's theme reskins it, and lives in the same
6
+ * overlay layer as the notice stack.
7
+ *
8
+ * @capability caption-overlay render the caption feed as a themed, screen-reader-announced line stack gated by the captions accessibility setting
9
+ */
10
+ export declare function CaptionOverlay({ feed, max, zIndex, }: {
11
+ feed: CaptionFeed;
12
+ /** How many lines to show at once. Default 2. */
13
+ max?: number;
14
+ zIndex?: number;
15
+ }): ReactNode;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { useAccessibilitySettings } from "@jgengine/react/accessibilitySettings";
4
+ const PRUNE_MS = 250;
5
+ /**
6
+ * The captions line: a bounded, bottom-centred stack that renders only while the accessibility
7
+ * captions setting is on. Reads HudTheme tokens, so a game's theme reskins it, and lives in the same
8
+ * overlay layer as the notice stack.
9
+ *
10
+ * @capability caption-overlay render the caption feed as a themed, screen-reader-announced line stack gated by the captions accessibility setting
11
+ */
12
+ export function CaptionOverlay({ feed, max = 2, zIndex = 84, }) {
13
+ const accessibility = useAccessibilitySettings();
14
+ const [changed, setChanged] = useState(0);
15
+ const lines = accessibility.captions ? feed.list().slice(-max) : [];
16
+ useEffect(() => feed.subscribe(() => setChanged((current) => current + 1)), [feed]);
17
+ useEffect(() => {
18
+ if (lines.length === 0)
19
+ return;
20
+ const timer = setInterval(() => feed.prune(), PRUNE_MS);
21
+ return () => clearInterval(timer);
22
+ }, [feed, lines.length, changed]);
23
+ if (!accessibility.captions || lines.length === 0)
24
+ return null;
25
+ return (_jsx("div", { "data-jg-captions": "", "aria-live": "polite", "aria-atomic": "false", style: {
26
+ position: "absolute",
27
+ left: "50%",
28
+ bottom: "clamp(64px, 12vh, 132px)",
29
+ transform: "translateX(-50%)",
30
+ display: "flex",
31
+ flexDirection: "column",
32
+ alignItems: "center",
33
+ gap: 4,
34
+ maxWidth: "min(720px, 82vw)",
35
+ pointerEvents: "none",
36
+ zIndex,
37
+ }, children: lines.map((line) => (_jsxs("div", { "data-jg-caption": line.kind, style: {
38
+ background: "var(--jg-frame-bg, rgba(8, 11, 16, 0.82))",
39
+ border: "var(--jg-frame-border, 1px solid rgba(255, 255, 255, 0.12))",
40
+ borderRadius: "var(--jg-frame-radius, 10px)",
41
+ color: "var(--jg-text, #f8fafc)",
42
+ fontFamily: "var(--jg-font-body, ui-sans-serif, system-ui, sans-serif)",
43
+ fontSize: "clamp(0.95rem, 1.5vw, 1.15rem)",
44
+ fontWeight: 600,
45
+ letterSpacing: "0.01em",
46
+ padding: "6px 12px",
47
+ textAlign: "center",
48
+ textShadow: "0 1px 2px rgba(0, 0, 0, 0.6)",
49
+ }, children: [line.text, line.count > 1 ? _jsxs("span", { style: { opacity: 0.7 }, children: [" x", line.count] }) : null] }, line.id))) }));
50
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from "react";
2
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
3
+ import type { AudioEngine } from "./audioEngine.js";
4
+ /**
5
+ * Mounts the engine's feel layer for one game: announced events become a sound, a gamepad pulse, a
6
+ * camera kick and a caption line; the audio context unlocks on the first gesture; and the caption
7
+ * stack follows the accessibility setting. Games supply sounds, never the rules.
8
+ *
9
+ * @capability feedback-host mount event feedback, gesture-gated audio and captions for a running game in one component
10
+ */
11
+ export declare function FeedbackHost({ ctx, engine }: {
12
+ ctx: GameContext;
13
+ engine: AudioEngine;
14
+ }): ReactNode;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { useAccessibilitySettings } from "@jgengine/react/accessibilitySettings";
4
+ import { cameraShake } from "../camera/shakeChannel.js";
5
+ import { installAudioProbe } from "./audioProbe.js";
6
+ import { CaptionOverlay } from "./CaptionOverlay.js";
7
+ import { createCaptionFeed } from "./captions.js";
8
+ import { attachEventFeedback, createEventFeedback } from "./eventFeedback.js";
9
+ import { installGestureResume } from "./gestureResume.js";
10
+ /**
11
+ * Mounts the engine's feel layer for one game: announced events become a sound, a gamepad pulse, a
12
+ * camera kick and a caption line; the audio context unlocks on the first gesture; and the caption
13
+ * stack follows the accessibility setting. Games supply sounds, never the rules.
14
+ *
15
+ * @capability feedback-host mount event feedback, gesture-gated audio and captions for a running game in one component
16
+ */
17
+ export function FeedbackHost({ ctx, engine }) {
18
+ const accessibility = useAccessibilitySettings();
19
+ const feed = useMemo(() => createCaptionFeed(), []);
20
+ const motionScale = useRef(accessibility.reducedMotion ? 0 : 1);
21
+ motionScale.current = accessibility.reducedMotion ? 0 : 1;
22
+ const feedback = useMemo(() => {
23
+ const haptics = () => ctx.input.haptics(ctx.player.userId);
24
+ return createEventFeedback({
25
+ play: (sound) => ctx.game.audio.play(sound),
26
+ shake: (amount) => cameraShake(amount),
27
+ haptics: (pulse) => haptics().pulse("feedback", pulse, 2),
28
+ caption: (text, cue) => feed.push(text, cue),
29
+ localUserId: () => ctx.player.userId,
30
+ localEntityId: () => ctx.player.possession.active(ctx.player.userId),
31
+ motionScale: () => motionScale.current,
32
+ }, { captions: accessibility.captions });
33
+ }, [ctx, feed, accessibility.captions]);
34
+ useEffect(() => {
35
+ feedback.retune({ captions: accessibility.captions });
36
+ }, [feedback, accessibility.captions]);
37
+ useEffect(() => attachEventFeedback(ctx.game.events, feedback), [ctx, feedback]);
38
+ useEffect(() => installGestureResume(engine), [engine]);
39
+ useEffect(() => installAudioProbe(engine), [engine]);
40
+ useEffect(() => () => feed.clear(), [feed]);
41
+ return accessibility.captions ? _jsx(CaptionOverlay, { feed: feed }) : null;
42
+ }
@@ -50,15 +50,40 @@ export interface AudioEngine {
50
50
  setListenerPose(pose: ListenerPose | Vec3): void;
51
51
  /** Retune the SFX budget and optional admission policy, stopping the lowest-priority oldest voices first. */
52
52
  setVoiceLimit(maxTotal: number, overflow?: VoiceAllocatorConfig["overflow"]): void;
53
+ /**
54
+ * One-call playback. Before a user gesture the browser keeps the context suspended, so the cue is
55
+ * held in a small bounded queue and played when {@link resume} succeeds instead of being dropped
56
+ * silently. Allocation happens per played cue, never per frame.
57
+ */
58
+ play(soundId: string, position?: Vec3): void;
53
59
  playOneShot(soundId: string, position?: Vec3): void;
54
60
  playLoop(soundId: string, position?: Vec3): AudioEmitterHandle | null;
55
61
  /** Crossfade the procedural soundtrack to `themeId` (null fades out). No-op when no `music` catalog is configured. */
56
62
  playMusic(themeId: string | null, options?: CrossfadeOptions): void;
57
63
  setBusGain(busId: string, gain: number): void;
64
+ /** Mute one bus without losing its gain: unmuting restores exactly the volume the player set. */
65
+ setBusMute(busId: string, muted: boolean): void;
58
66
  setMasterGain(gain: number): void;
67
+ setMasterMute(muted: boolean): void;
68
+ /** Master gain after volume and mute compose (`gain × (muted ? 0 : 1)`). */
69
+ masterLevel(): number;
70
+ /** Effective gain of `busId` after its authored gain, the player's volume and its mute compose. */
71
+ busLevel(busId: string): number;
72
+ /**
73
+ * Peak and RMS of the master output sampled on the Web Audio render clock. Pools its buffers, so
74
+ * calling it every frame allocates nothing; a silent (or unsupported) engine reports `0`.
75
+ */
76
+ measure(): AudioLevel;
59
77
  resume(): void;
60
78
  dispose(): void;
61
79
  }
80
+ /** Master-output level, `0..1`. Pooled by {@link AudioEngine.measure} - read it, don't retain it. */
81
+ export interface AudioLevel {
82
+ rms: number;
83
+ peak: number;
84
+ }
85
+ /** Cues held while the browser keeps the audio context suspended, before any user gesture. */
86
+ export declare const MAX_PENDING_CUES = 16;
62
87
  /**
63
88
  * Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
64
89
  * @capability bounded-spatial-audio play positional or flat cues with listener orientation, voice budgets, and per-source cleanup
@@ -9,15 +9,24 @@ import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
9
9
  /** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
10
10
  const LOOP_PARAM_SMOOTH_TC = 0.02;
11
11
  const ZERO_VELOCITY = { x: 0, y: 0, z: 0 };
12
+ /** Cues held while the browser keeps the audio context suspended, before any user gesture. */
13
+ export const MAX_PENDING_CUES = 16;
12
14
  function createNoopEngine() {
15
+ const level = { rms: 0, peak: 0 };
13
16
  return {
14
17
  setListenerPose: () => undefined,
15
18
  setVoiceLimit: () => undefined,
19
+ play: () => undefined,
16
20
  playOneShot: () => undefined,
17
21
  playLoop: () => null,
18
22
  playMusic: () => undefined,
19
23
  setBusGain: () => undefined,
24
+ setBusMute: () => undefined,
20
25
  setMasterGain: () => undefined,
26
+ setMasterMute: () => undefined,
27
+ masterLevel: () => 0,
28
+ busLevel: () => 0,
29
+ measure: () => level,
21
30
  resume: () => undefined,
22
31
  dispose: () => undefined,
23
32
  };
@@ -93,12 +102,36 @@ export function createAudioEngine(config = {}) {
93
102
  return createNoopEngine();
94
103
  }
95
104
  const busGains = new Map();
105
+ // Authored gain survives a mute, so unmuting restores the player's volume exactly.
106
+ const busMix = new Map();
107
+ let masterVolume = 1;
108
+ let masterMuted = false;
109
+ function applyMaster() {
110
+ masterGain.gain.value = masterLevel();
111
+ }
112
+ function masterLevel() {
113
+ return masterMuted ? 0 : masterVolume;
114
+ }
115
+ function busMixOf(busId) {
116
+ let mix = busMix.get(busId);
117
+ if (mix === undefined) {
118
+ mix = { gain: busDefs[busId]?.gain ?? 1, muted: false };
119
+ busMix.set(busId, mix);
120
+ }
121
+ return mix;
122
+ }
123
+ function busLevel(busId) {
124
+ const mix = busMix.get(busId);
125
+ if (mix === undefined)
126
+ return busDefs[busId]?.gain ?? 1;
127
+ return mix.muted ? 0 : mix.gain;
128
+ }
96
129
  function busGainNode(busId) {
97
130
  let node = busGains.get(busId);
98
131
  if (node === undefined) {
99
132
  node = context.createGain();
100
133
  try {
101
- node.gain.value = busDefs[busId]?.gain ?? 1;
134
+ node.gain.value = busLevel(busId);
102
135
  node.connect(masterGain);
103
136
  busGains.set(busId, node);
104
137
  }
@@ -109,6 +142,11 @@ export function createAudioEngine(config = {}) {
109
142
  }
110
143
  return node;
111
144
  }
145
+ function applyBus(busId) {
146
+ const node = busGains.get(busId);
147
+ if (node !== undefined)
148
+ node.gain.value = busLevel(busId);
149
+ }
112
150
  let noiseBuffer = null;
113
151
  function sharedNoiseBuffer() {
114
152
  if (noiseBuffer === null)
@@ -166,6 +204,106 @@ export function createAudioEngine(config = {}) {
166
204
  let listenerVelocity = { x: 0, y: 0, z: 0 };
167
205
  const nyquist = context.sampleRate / 2;
168
206
  const activeSpatialUpdaters = new Set();
207
+ // Browser autoplay: until the first user gesture the context stays suspended and a played cue
208
+ // would vanish. Hold a bounded FIFO of ids (preallocated slots) and flush it on resume.
209
+ const pendingCues = Array.from({ length: MAX_PENDING_CUES }, () => ({
210
+ soundId: null,
211
+ hasPosition: false,
212
+ x: 0,
213
+ y: 0,
214
+ z: 0,
215
+ }));
216
+ let pendingHead = 0;
217
+ let pendingCount = 0;
218
+ function contextRunning() {
219
+ const state = context.state;
220
+ return state === undefined || state === "running";
221
+ }
222
+ function queueCue(soundId, position) {
223
+ let slot;
224
+ if (pendingCount < MAX_PENDING_CUES) {
225
+ slot = pendingCues[(pendingHead + pendingCount) % MAX_PENDING_CUES];
226
+ pendingCount += 1;
227
+ }
228
+ else {
229
+ // Full: drop the oldest cue, which the player has already stopped hearing.
230
+ slot = pendingCues[pendingHead];
231
+ pendingHead = (pendingHead + 1) % MAX_PENDING_CUES;
232
+ }
233
+ slot.soundId = soundId;
234
+ slot.hasPosition = position !== undefined;
235
+ slot.x = position?.x ?? 0;
236
+ slot.y = position?.y ?? 0;
237
+ slot.z = position?.z ?? 0;
238
+ }
239
+ /** Play what was held pre-gesture, oldest first, once the context can actually render it. */
240
+ function flushPending() {
241
+ while (pendingCount > 0) {
242
+ const slot = pendingCues[pendingHead];
243
+ pendingHead = (pendingHead + 1) % MAX_PENDING_CUES;
244
+ pendingCount -= 1;
245
+ const soundId = slot.soundId;
246
+ slot.soundId = null;
247
+ if (soundId === null)
248
+ continue;
249
+ playInternal(soundId, slot.hasPosition ? { x: slot.x, y: slot.y, z: slot.z } : undefined, false);
250
+ }
251
+ }
252
+ function playCue(soundId, position) {
253
+ if (disposed)
254
+ return;
255
+ if (!contextRunning()) {
256
+ queueCue(soundId, position);
257
+ void context.resume().catch(() => undefined);
258
+ return;
259
+ }
260
+ playInternal(soundId, position, false);
261
+ }
262
+ let analyser = null;
263
+ let analyserSamples = null;
264
+ const level = { rms: 0, peak: 0 };
265
+ function measure() {
266
+ if (disposed) {
267
+ level.rms = 0;
268
+ level.peak = 0;
269
+ return level;
270
+ }
271
+ if (analyser === null) {
272
+ try {
273
+ analyser = context.createAnalyser();
274
+ analyser.fftSize = 1024;
275
+ analyser.smoothingTimeConstant = 0;
276
+ masterGain.disconnect();
277
+ masterGain.connect(analyser);
278
+ analyser.connect(context.destination);
279
+ analyserSamples = new Float32Array(analyser.fftSize);
280
+ }
281
+ catch {
282
+ analyser = null;
283
+ level.rms = 0;
284
+ level.peak = 0;
285
+ return level;
286
+ }
287
+ }
288
+ if (analyserSamples === null) {
289
+ level.rms = 0;
290
+ level.peak = 0;
291
+ return level;
292
+ }
293
+ analyser.getFloatTimeDomainData(analyserSamples);
294
+ let sum = 0;
295
+ let peak = 0;
296
+ for (let index = 0; index < analyserSamples.length; index += 1) {
297
+ const sample = analyserSamples[index];
298
+ sum += sample * sample;
299
+ const magnitude = Math.abs(sample);
300
+ if (magnitude > peak)
301
+ peak = magnitude;
302
+ }
303
+ level.rms = Math.sqrt(sum / analyserSamples.length);
304
+ level.peak = Math.min(1, peak);
305
+ return level;
306
+ }
169
307
  function playInternal(soundId, position, loop) {
170
308
  if (disposed)
171
309
  return null;
@@ -416,11 +554,11 @@ export function createAudioEngine(config = {}) {
416
554
  for (const updateGain of activeSpatialUpdaters)
417
555
  updateGain();
418
556
  },
557
+ play(soundId, position) {
558
+ playCue(soundId, position);
559
+ },
419
560
  playOneShot(soundId, position) {
420
- if (disposed)
421
- return;
422
- void context.resume().catch(() => undefined);
423
- playInternal(soundId, position, false);
561
+ playCue(soundId, position);
424
562
  },
425
563
  playLoop(soundId, position) {
426
564
  return playInternal(soundId, position, true);
@@ -434,17 +572,37 @@ export function createAudioEngine(config = {}) {
434
572
  setBusGain(busId, gain) {
435
573
  if (disposed)
436
574
  return;
437
- busGainNode(busId).gain.value = gain;
575
+ busMixOf(busId).gain = gain;
576
+ applyBus(busId);
577
+ },
578
+ setBusMute(busId, muted) {
579
+ if (disposed)
580
+ return;
581
+ busMixOf(busId).muted = muted;
582
+ applyBus(busId);
438
583
  },
439
584
  setMasterGain(gain) {
440
585
  if (disposed)
441
586
  return;
442
- masterGain.gain.value = gain;
587
+ masterVolume = gain;
588
+ applyMaster();
443
589
  },
590
+ setMasterMute(muted) {
591
+ if (disposed)
592
+ return;
593
+ masterMuted = muted;
594
+ applyMaster();
595
+ },
596
+ masterLevel,
597
+ busLevel,
598
+ measure,
444
599
  resume() {
445
600
  if (disposed)
446
601
  return;
447
- void context.resume().catch(() => undefined);
602
+ void context.resume().then(() => {
603
+ if (!disposed && contextRunning())
604
+ flushPending();
605
+ }).catch(() => undefined);
448
606
  },
449
607
  dispose() {
450
608
  if (disposed)
@@ -0,0 +1,22 @@
1
+ import type { AudioEngine, AudioLevel } from "./audioEngine.js";
2
+ /** The read-only handle a capture script or devtools uses to prove the mix is producing sound. */
3
+ export interface AudioProbe {
4
+ /** Master RMS and peak since the last read, `0..1`. Non-zero RMS means audio reached the output. */
5
+ measure(): AudioLevel;
6
+ /** Master gain after volume and mute. */
7
+ masterLevel(): number;
8
+ /** One bus's effective gain after its volume and mute. */
9
+ busLevel(busId: string): number;
10
+ }
11
+ declare global {
12
+ interface Window {
13
+ __jgengineAudio?: AudioProbe;
14
+ }
15
+ }
16
+ /**
17
+ * Publish the audio engine's own level readout on `window.__jgengineAudio`, so a headless capture can
18
+ * state a measured RMS instead of guessing from a screenshot. Reads only; it never plays or changes a bus.
19
+ *
20
+ * @capability audio-probe read the live master RMS/peak and bus gains from a running page for capture and diagnostics
21
+ */
22
+ export declare function installAudioProbe(engine: AudioEngine): () => void;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Publish the audio engine's own level readout on `window.__jgengineAudio`, so a headless capture can
3
+ * state a measured RMS instead of guessing from a screenshot. Reads only; it never plays or changes a bus.
4
+ *
5
+ * @capability audio-probe read the live master RMS/peak and bus gains from a running page for capture and diagnostics
6
+ */
7
+ export function installAudioProbe(engine) {
8
+ if (typeof window === "undefined")
9
+ return () => undefined;
10
+ const probe = {
11
+ measure: () => engine.measure(),
12
+ masterLevel: () => engine.masterLevel(),
13
+ busLevel: (busId) => engine.busLevel(busId),
14
+ };
15
+ window.__jgengineAudio = probe;
16
+ return () => {
17
+ if (window.__jgengineAudio === probe)
18
+ delete window.__jgengineAudio;
19
+ };
20
+ }
@@ -0,0 +1,4 @@
1
+ /** Persisted mute for the whole mix, next to `sound.master`. */
2
+ export declare const MASTER_MUTE_SETTING_ID = "sound.muted";
3
+ /** Persisted mute for one bus, next to its `busVolumeSettingId(busId)` volume. */
4
+ export declare function busMuteSettingId(busId: string): string;
@@ -0,0 +1,7 @@
1
+ import { busVolumeSettingId } from "@jgengine/core/settings/settingsModel";
2
+ /** Persisted mute for the whole mix, next to `sound.master`. */
3
+ export const MASTER_MUTE_SETTING_ID = "sound.muted";
4
+ /** Persisted mute for one bus, next to its `busVolumeSettingId(busId)` volume. */
5
+ export function busMuteSettingId(busId) {
6
+ return `${busVolumeSettingId(busId)}.muted`;
7
+ }
@@ -0,0 +1,48 @@
1
+ /** One caption line as the overlay renders it. */
2
+ export interface Caption {
3
+ id: number;
4
+ text: string;
5
+ /** Free-string kind the game styles ("hit", "pickup", "wave"). */
6
+ kind: string;
7
+ /** How many identical lines stacked into this one. */
8
+ count: number;
9
+ at: number;
10
+ expiresAt: number;
11
+ }
12
+ /** Tunables for {@link createCaptionFeed}; all retunable at runtime. */
13
+ export interface CaptionFeedConfig {
14
+ /** Maximum live lines. Default 3. */
15
+ cap?: number;
16
+ /** Line lifetime in ms. Default 2400. */
17
+ ttlMs?: number;
18
+ /** Injected clock; default `Date.now`. */
19
+ now?: () => number;
20
+ }
21
+ /** A capped, self-expiring list of caption lines that the accessibility captions setting turns on. */
22
+ export interface CaptionFeed {
23
+ /** Show `text`; an identical line still live stacks into one with a count. */
24
+ push(text: string, kind?: string): Caption;
25
+ /** Live lines, oldest first. Stable reference until the next change. */
26
+ list(): readonly Caption[];
27
+ /** Drop expired lines as of the feed clock. */
28
+ prune(nowMs?: number): void;
29
+ clear(): void;
30
+ subscribe(listener: () => void): () => void;
31
+ retune(config: Omit<CaptionFeedConfig, "now">): void;
32
+ snapshot(): {
33
+ captions: Caption[];
34
+ counter: number;
35
+ };
36
+ restore(state: {
37
+ captions: Caption[];
38
+ counter: number;
39
+ }): void;
40
+ }
41
+ /**
42
+ * The caption list the feedback seam writes to and the HUD overlay reads: bounded, self-expiring, and
43
+ * deduped so three hits read as "Hit x3" rather than three copies. Pure state with an injected clock,
44
+ * so it runs headless.
45
+ *
46
+ * @capability caption-feed bounded, self-expiring, deduped caption lines for the accessibility captions overlay
47
+ */
48
+ export declare function createCaptionFeed(config?: CaptionFeedConfig): CaptionFeed;