@jgengine/shell 0.31.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 +19 -0
- package/dist/Shell3dPresentation.js +1 -1
- package/dist/ShellChrome.d.ts +3 -1
- package/dist/ShellChrome.js +3 -2
- package/dist/ShellHudPresentation.js +1 -1
- package/dist/audio/CaptionOverlay.d.ts +15 -0
- package/dist/audio/CaptionOverlay.js +50 -0
- package/dist/audio/FeedbackHost.d.ts +14 -0
- package/dist/audio/FeedbackHost.js +42 -0
- package/dist/audio/audioEngine.d.ts +25 -0
- package/dist/audio/audioEngine.js +166 -8
- package/dist/audio/audioProbe.d.ts +22 -0
- package/dist/audio/audioProbe.js +20 -0
- package/dist/audio/busMix.d.ts +4 -0
- package/dist/audio/busMix.js +7 -0
- package/dist/audio/captions.d.ts +48 -0
- package/dist/audio/captions.js +77 -0
- package/dist/audio/eventFeedback.d.ts +127 -0
- package/dist/audio/eventFeedback.js +268 -0
- package/dist/audio/gestureResume.d.ts +10 -0
- package/dist/audio/gestureResume.js +25 -0
- package/dist/audio/playableAudio.d.ts +1 -1
- package/dist/audio/playableAudio.js +3 -1
- package/dist/settings/appliedSettings.js +3 -0
- package/dist/settings/settingsController.js +15 -0
- package/package.json +4 -4
- package/skills/jgengine/api.md +3 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +50 -1
- package/skills/jgengine-ui/capabilities.md +32 -0
- package/skills/jgengine-ui/reference.md +1 -1
- package/skills/jgengine-world/api.md +14 -4
- package/skills/jgengine-world/capabilities.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,25 @@ 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
|
+
|
|
29
48
|
## 0.31.0
|
|
30
49
|
|
|
31
50
|
### 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
|
}
|
package/dist/ShellChrome.d.ts
CHANGED
|
@@ -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, }: {
|
package/dist/ShellChrome.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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().
|
|
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;
|