@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.
@@ -0,0 +1,77 @@
1
+ const DEFAULT_CAP = 3;
2
+ const DEFAULT_TTL_MS = 2400;
3
+ /**
4
+ * The caption list the feedback seam writes to and the HUD overlay reads: bounded, self-expiring, and
5
+ * deduped so three hits read as "Hit x3" rather than three copies. Pure state with an injected clock,
6
+ * so it runs headless.
7
+ *
8
+ * @capability caption-feed bounded, self-expiring, deduped caption lines for the accessibility captions overlay
9
+ */
10
+ export function createCaptionFeed(config = {}) {
11
+ const now = config.now ?? Date.now;
12
+ let cap = Math.max(1, config.cap ?? DEFAULT_CAP);
13
+ let ttlMs = Math.max(0, config.ttlMs ?? DEFAULT_TTL_MS);
14
+ let captions = [];
15
+ let counter = 0;
16
+ const listeners = new Set();
17
+ function set(next) {
18
+ if (next === captions)
19
+ return;
20
+ captions = next;
21
+ for (const listener of [...listeners])
22
+ listener();
23
+ }
24
+ function live(time) {
25
+ return captions.every((caption) => caption.expiresAt > time)
26
+ ? captions
27
+ : captions.filter((caption) => caption.expiresAt > time);
28
+ }
29
+ return {
30
+ push(text, kind = "default") {
31
+ const time = now();
32
+ const current = live(time);
33
+ const index = current.findIndex((caption) => caption.text === text);
34
+ if (index >= 0) {
35
+ const previous = current[index];
36
+ const next = [...current];
37
+ next[index] = { ...previous, kind, count: previous.count + 1, at: time, expiresAt: time + ttlMs };
38
+ set(next);
39
+ return next[index];
40
+ }
41
+ const caption = { id: counter, text, kind, count: 1, at: time, expiresAt: time + ttlMs };
42
+ counter += 1;
43
+ const appended = [...current, caption];
44
+ set(appended.length > cap ? appended.slice(appended.length - cap) : appended);
45
+ return caption;
46
+ },
47
+ list() {
48
+ return live(now());
49
+ },
50
+ prune(nowMs) {
51
+ set(live(nowMs ?? now()));
52
+ },
53
+ clear() {
54
+ if (captions.length > 0)
55
+ set([]);
56
+ },
57
+ subscribe(listener) {
58
+ listeners.add(listener);
59
+ return () => listeners.delete(listener);
60
+ },
61
+ retune(next) {
62
+ if (next.cap !== undefined)
63
+ cap = Math.max(1, next.cap);
64
+ if (next.ttlMs !== undefined)
65
+ ttlMs = Math.max(0, next.ttlMs);
66
+ set(live(now()));
67
+ },
68
+ snapshot() {
69
+ return { captions: [...captions], counter };
70
+ },
71
+ restore(state) {
72
+ captions = [...state.captions];
73
+ counter = state.counter;
74
+ set(live(now()));
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,127 @@
1
+ import type { FeedbackCue } from "@jgengine/core/audio/feedbackCues";
2
+ import type { GameEventMap, GameEvents } from "@jgengine/core/game/events";
3
+ import type { HapticPulse } from "@jgengine/core/input/haptics";
4
+ import type { WaveRunner } from "@jgengine/core/ai/waveRunner";
5
+ /** One cue's reaction: what plays, what the hand feels, what the screen does, and what a caption says. */
6
+ export interface FeedbackReaction {
7
+ /** Catalog id played for the cue. Defaults to `feel.<cue>`; a game sound with that id replaces it. */
8
+ sound: string;
9
+ /** Gamepad pulse, `0..1` per motor. Omitted cues do not rumble. */
10
+ haptics?: HapticPulse;
11
+ /** Camera trauma `0..1`, scaled by the player's motion setting (`0` under reduced motion). */
12
+ shake?: number;
13
+ /** Caption text when the accessibility captions setting is on. */
14
+ caption?: string;
15
+ /** Minimum ms between two plays of this cue, so a burst of hits reads as one impact. Default 40. */
16
+ cooldownMs?: number;
17
+ }
18
+ /**
19
+ * The default reactions. They are data: a game overrides one through `audio.feedbackCues` (sound) or
20
+ * `createEventFeedback({ reactions })` (haptics, shake, caption), and replaces the mapping through
21
+ * `bindings` without touching the shell.
22
+ */
23
+ export declare const DEFAULT_FEEDBACK_REACTIONS: Readonly<Record<FeedbackCue, FeedbackReaction>>;
24
+ /** Which side of an event a binding reacts to: the local player's, someone else's, or either. */
25
+ export type FeedbackScope = "local" | "remote" | "any";
26
+ /** Event names the default mapping listens to; a game may add its own through `bindings`. */
27
+ export type FeedbackEventName = "combat.damage" | "entity.died" | "worldItem.picked_up" | "quest.completed" | "stat.levelUp" | "command.refused";
28
+ /** One row of the event mapping: `event` announces, `cue` reacts. */
29
+ export type FeedbackBinding<Name extends FeedbackEventName = FeedbackEventName> = {
30
+ event: Name;
31
+ cue: FeedbackCue;
32
+ /** Default `"any"`. `"local"` only fires for the local player's own target/user/refusal. */
33
+ scope?: FeedbackScope;
34
+ /** Overrides the cue's caption for this event, e.g. "You died". */
35
+ caption?: string;
36
+ /** Reads extra caption detail out of the payload, e.g. the damage amount. */
37
+ detail?: (payload: GameEventMap[Name]) => string | undefined;
38
+ };
39
+ /** Any row of the event mapping, whichever event it binds. */
40
+ export type AnyFeedbackBinding = {
41
+ [Name in FeedbackEventName]: FeedbackBinding<Name>;
42
+ }[FeedbackEventName];
43
+ /**
44
+ * The default mapping: each engine-announced gameplay event to the cue it raises. Damage to the
45
+ * local player reads as hurt, damage anywhere else as a hit; a wave start has no event of its own,
46
+ * so a game announces it through `notify("wave")` or `attachWaveFeedback`.
47
+ */
48
+ export declare const DEFAULT_FEEDBACK_BINDINGS: readonly AnyFeedbackBinding[];
49
+ /** A resolved trigger: pooled per binding, so a busy event stream allocates nothing to decide. */
50
+ export interface ResolvedFeedback {
51
+ event: FeedbackEventName;
52
+ cue: FeedbackCue;
53
+ sound: string;
54
+ caption: string | undefined;
55
+ haptics: HapticPulse | undefined;
56
+ shake: number;
57
+ cooldownMs: number;
58
+ }
59
+ /** Everything the feedback seam reaches outside itself. Injected so it runs headless. */
60
+ export interface FeedbackDeps {
61
+ /** Play one catalog sound through the game's audio seam. */
62
+ play(sound: string): void;
63
+ /** Add camera trauma `0..1`; called with `shake x motionScale`. */
64
+ shake?(amount: number): void;
65
+ haptics?(pulse: HapticPulse): void; /** Show a caption line for `cue`; only called when captions are on. */
66
+ caption?(text: string, cue: FeedbackCue): void;
67
+ /** The local player, for `scope: "local"`. */
68
+ localUserId(): string | null;
69
+ /** The entity the local player currently controls, for damage/death scope. */
70
+ localEntityId(): string | null;
71
+ /** Engine motion scale: `0` under reduced motion, `1` normally. Default `1`. */
72
+ motionScale?(): number;
73
+ now?(): number;
74
+ }
75
+ /** Options a game or host may override. */
76
+ export interface EventFeedbackOptions {
77
+ reactions?: Partial<Record<FeedbackCue, FeedbackReaction>>;
78
+ bindings?: readonly AnyFeedbackBinding[];
79
+ /** Whether captions reach `deps.caption` at all. Default true. */
80
+ captions?: boolean;
81
+ }
82
+ /** The event-to-reaction seam. One instance per running world. */
83
+ export interface EventFeedback {
84
+ /** React to one announced event. Returns the cue fired, or null when nothing mapped or it was muted by cooldown. */
85
+ handle<Name extends FeedbackEventName>(event: Name, payload: GameEventMap[Name]): FeedbackCue | null;
86
+ /** Fire a cue the game announces itself (a wave starting, a custom moment); `caption` overrides the cue's text. */
87
+ notify(cue: FeedbackCue, caption?: string): boolean;
88
+ /** Map without firing - the pooled reaction for an event, for devtools and tests. */
89
+ resolve<Name extends FeedbackEventName>(event: Name, payload: GameEventMap[Name]): ResolvedFeedback | null;
90
+ /** How many triggers a cue's cooldown folded away since the last reset. */
91
+ suppressed(cue: FeedbackCue): number;
92
+ /** Replace reactions or bindings at runtime. */
93
+ retune(options: Partial<EventFeedbackOptions>): void;
94
+ /** Clear cooldowns and suppressed counters. */
95
+ reset(): void;
96
+ }
97
+ /**
98
+ * Build the event-to-sound/haptic/shake/caption seam. The mapping is data
99
+ * ({@link DEFAULT_FEEDBACK_BINDINGS}), reactions are data ({@link DEFAULT_FEEDBACK_REACTIONS}), and the
100
+ * resolved trigger for each binding is allocated once - handling an event allocates only the caption
101
+ * string it actually shows, never per frame.
102
+ *
103
+ * @capability event-feedback map announced gameplay events (damage, kill, pickup, objective, wave, refusal) to a sound, gamepad pulse, camera shake and caption through one data mapping
104
+ */
105
+ export declare function createEventFeedback(deps: FeedbackDeps, options?: EventFeedbackOptions): EventFeedback;
106
+ /**
107
+ * Subscribe the feedback seam to a game's event bus, including `feedback.cue` for moments the game
108
+ * announces itself. Returns the detach function; the mapping and reactions stay override-able
109
+ * through the seam's `retune`.
110
+ *
111
+ * @capability attach-event-feedback one call wires every announced combat/loot/objective/refusal event to its reaction
112
+ */
113
+ export declare function attachEventFeedback(events: Pick<GameEvents, "on">, feedback: EventFeedback, eventsToBind?: readonly FeedbackEventName[]): () => void;
114
+ /** The slice of `ctx.game.feedback` a wave runner needs, so this runs headless in a test. */
115
+ export interface FeedbackAnnouncer {
116
+ cue(cue: FeedbackCue, caption?: string): void;
117
+ }
118
+ /**
119
+ * Announce each new wave a {@link WaveRunner} starts as the `wave` cue, once per boundary. A wave
120
+ * has no event of its own, so this is the engine-side hookup for tower defense and wave survival
121
+ * games: `attachWaveFeedback(runner, ctx.game.feedback)`. Nothing is polled, the runner's own
122
+ * notification drives it, and one `update` that crosses two waves still fires once - the cue has a
123
+ * cooldown and the next boundary is announced by the next crossing.
124
+ *
125
+ * @capability attach-wave-feedback fire the wave cue once whenever a wave runner crosses into a new wave
126
+ */
127
+ export declare function attachWaveFeedback(runner: Pick<WaveRunner, "state" | "subscribe">, announce: FeedbackAnnouncer): () => void;
@@ -0,0 +1,268 @@
1
+ import { feedbackCueSoundId } from "@jgengine/core/audio/feedbackCues";
2
+ /**
3
+ * The default reactions. They are data: a game overrides one through `audio.feedbackCues` (sound) or
4
+ * `createEventFeedback({ reactions })` (haptics, shake, caption), and replaces the mapping through
5
+ * `bindings` without touching the shell.
6
+ */
7
+ export const DEFAULT_FEEDBACK_REACTIONS = {
8
+ hit: {
9
+ sound: feedbackCueSoundId("hit"),
10
+ haptics: { strong: 0.18, weak: 0.3, ms: 70 },
11
+ shake: 0.14,
12
+ caption: "Hit",
13
+ cooldownMs: 55,
14
+ },
15
+ hurt: {
16
+ sound: feedbackCueSoundId("hurt"),
17
+ haptics: { strong: 0.45, weak: 0.35, ms: 140 },
18
+ shake: 0.4,
19
+ caption: "Hurt",
20
+ cooldownMs: 140,
21
+ },
22
+ kill: {
23
+ sound: feedbackCueSoundId("kill"),
24
+ haptics: { strong: 0.3, weak: 0.45, ms: 110 },
25
+ shake: 0.22,
26
+ caption: "Enemy down",
27
+ cooldownMs: 90,
28
+ },
29
+ pickup: {
30
+ sound: feedbackCueSoundId("pickup"),
31
+ haptics: { strong: 0.06, weak: 0.14, ms: 45 },
32
+ caption: "Picked up",
33
+ cooldownMs: 80,
34
+ },
35
+ objective: {
36
+ sound: feedbackCueSoundId("objective"),
37
+ haptics: { strong: 0.12, weak: 0.2, ms: 120 },
38
+ caption: "Objective complete",
39
+ cooldownMs: 500,
40
+ },
41
+ wave: {
42
+ sound: feedbackCueSoundId("wave"),
43
+ haptics: { strong: 0.2, weak: 0.1, ms: 180 },
44
+ shake: 0.1,
45
+ caption: "Wave incoming",
46
+ cooldownMs: 1500,
47
+ },
48
+ deny: {
49
+ sound: feedbackCueSoundId("deny"),
50
+ haptics: { strong: 0.1, weak: 0.25, ms: 60 },
51
+ caption: "Not possible",
52
+ cooldownMs: 350,
53
+ },
54
+ };
55
+ /**
56
+ * The default mapping: each engine-announced gameplay event to the cue it raises. Damage to the
57
+ * local player reads as hurt, damage anywhere else as a hit; a wave start has no event of its own,
58
+ * so a game announces it through `notify("wave")` or `attachWaveFeedback`.
59
+ */
60
+ export const DEFAULT_FEEDBACK_BINDINGS = [
61
+ { event: "combat.damage", cue: "hurt", scope: "local", detail: (event) => `${Math.round(event.amount)}` },
62
+ { event: "combat.damage", cue: "hit", scope: "remote", detail: (event) => `${Math.round(event.amount)}` },
63
+ { event: "entity.died", cue: "hurt", scope: "local", caption: "You died" },
64
+ { event: "entity.died", cue: "kill", scope: "remote", detail: (event) => event.displayName ?? event.catalogId },
65
+ { event: "worldItem.picked_up", cue: "pickup", scope: "local", detail: (event) => (event.count > 1 ? `${event.itemId} x${event.count}` : event.itemId) },
66
+ { event: "quest.completed", cue: "objective", scope: "local", detail: (event) => event.questId },
67
+ { event: "stat.levelUp", cue: "objective", scope: "local", detail: (event) => `${event.stat} ${event.level}` },
68
+ { event: "command.refused", cue: "deny", scope: "local", detail: (event) => event.message ?? event.reason },
69
+ ];
70
+ const DEFAULT_COOLDOWN_MS = 40;
71
+ /**
72
+ * Build the event-to-sound/haptic/shake/caption seam. The mapping is data
73
+ * ({@link DEFAULT_FEEDBACK_BINDINGS}), reactions are data ({@link DEFAULT_FEEDBACK_REACTIONS}), and the
74
+ * resolved trigger for each binding is allocated once - handling an event allocates only the caption
75
+ * string it actually shows, never per frame.
76
+ *
77
+ * @capability event-feedback map announced gameplay events (damage, kill, pickup, objective, wave, refusal) to a sound, gamepad pulse, camera shake and caption through one data mapping
78
+ */
79
+ export function createEventFeedback(deps, options = {}) {
80
+ const reactions = { ...DEFAULT_FEEDBACK_REACTIONS };
81
+ let bindings = options.bindings ?? DEFAULT_FEEDBACK_BINDINGS;
82
+ let captionsOn = options.captions ?? true;
83
+ for (const [cue, reaction] of Object.entries(options.reactions ?? {})) {
84
+ if (reaction !== undefined)
85
+ reactions[cue] = reaction;
86
+ }
87
+ const now = deps.now ?? Date.now;
88
+ const lastPlayed = { hit: -Infinity, hurt: -Infinity, kill: -Infinity, pickup: -Infinity, objective: -Infinity, wave: -Infinity, deny: -Infinity };
89
+ const suppressed = { hit: 0, hurt: 0, kill: 0, pickup: 0, objective: 0, wave: 0, deny: 0 };
90
+ function triggerFor(binding) {
91
+ const reaction = reactions[binding.cue];
92
+ return {
93
+ event: binding.event,
94
+ cue: binding.cue,
95
+ sound: reaction.sound,
96
+ caption: undefined,
97
+ haptics: reaction.haptics,
98
+ shake: reaction.shake ?? 0,
99
+ cooldownMs: reaction.cooldownMs ?? DEFAULT_COOLDOWN_MS,
100
+ };
101
+ }
102
+ // One pooled resolution per binding row plus one per cue (for `notify`), so a busy event stream
103
+ // allocates nothing to decide what to play.
104
+ let resolved = bindings.map(triggerFor);
105
+ const cueTriggers = Object.fromEntries(["hit", "hurt", "kill", "pickup", "objective", "wave", "deny"].map((cue) => [cue, triggerFor({ event: "combat.damage", cue })]));
106
+ function rebuild() {
107
+ resolved = bindings.map(triggerFor);
108
+ for (const cue of Object.keys(cueTriggers)) {
109
+ const reaction = reactions[cue];
110
+ const trigger = cueTriggers[cue];
111
+ trigger.sound = reaction.sound;
112
+ trigger.haptics = reaction.haptics;
113
+ trigger.shake = reaction.shake ?? 0;
114
+ trigger.cooldownMs = reaction.cooldownMs ?? DEFAULT_COOLDOWN_MS;
115
+ }
116
+ }
117
+ function isLocal(event, payload) {
118
+ switch (event) {
119
+ case "combat.damage":
120
+ return payload.target === deps.localEntityId();
121
+ case "entity.died": {
122
+ const died = payload;
123
+ const userId = deps.localUserId();
124
+ return (userId !== null && died.userId === userId) || died.instanceId === deps.localEntityId();
125
+ }
126
+ case "worldItem.picked_up":
127
+ return payload.userId === deps.localUserId();
128
+ case "quest.completed":
129
+ case "stat.levelUp": {
130
+ const userId = deps.localUserId();
131
+ return userId !== null && payload.userId === userId;
132
+ }
133
+ case "command.refused": {
134
+ const actor = payload.actor;
135
+ return actor === null || actor === deps.localUserId();
136
+ }
137
+ default:
138
+ return true;
139
+ }
140
+ }
141
+ function fire(trigger, captionText) {
142
+ deps.play(trigger.sound);
143
+ if (trigger.haptics !== undefined)
144
+ deps.haptics?.(trigger.haptics);
145
+ if (trigger.shake > 0) {
146
+ const scale = deps.motionScale?.() ?? 1;
147
+ if (scale > 0)
148
+ deps.shake?.(trigger.shake * scale);
149
+ }
150
+ if (captionsOn && captionText !== undefined)
151
+ deps.caption?.(captionText, trigger.cue);
152
+ }
153
+ function react(cue, captionText) {
154
+ const reaction = reactions[cue];
155
+ const trigger = cueTriggers[cue];
156
+ const time = now();
157
+ const cooldown = trigger.cooldownMs;
158
+ if (time - lastPlayed[cue] < cooldown) {
159
+ suppressed[cue] += 1;
160
+ return false;
161
+ }
162
+ lastPlayed[cue] = time;
163
+ const text = captionText ?? trigger.caption ?? reaction.caption;
164
+ fire(trigger, text);
165
+ return true;
166
+ }
167
+ return {
168
+ handle(event, payload) {
169
+ for (let index = 0; index < bindings.length; index += 1) {
170
+ const binding = bindings[index];
171
+ if (binding.event !== event)
172
+ continue;
173
+ const scope = binding.scope ?? "any";
174
+ if (scope !== "any" && isLocal(event, payload) !== (scope === "local"))
175
+ continue;
176
+ const detail = binding.detail?.(payload);
177
+ const base = binding.caption ?? reactions[binding.cue].caption;
178
+ const captionText = base === undefined ? detail : detail === undefined ? base : `${base} ${detail}`;
179
+ return react(binding.cue, captionText) ? binding.cue : null;
180
+ }
181
+ return null;
182
+ },
183
+ notify(cue, caption) {
184
+ return react(cue, caption);
185
+ },
186
+ resolve(event, payload) {
187
+ for (let index = 0; index < bindings.length; index += 1) {
188
+ const binding = bindings[index];
189
+ if (binding.event !== event)
190
+ continue;
191
+ const scope = binding.scope ?? "any";
192
+ if (scope !== "any" && isLocal(event, payload) !== (scope === "local"))
193
+ continue;
194
+ const trigger = resolved[index];
195
+ const detail = binding.detail?.(payload);
196
+ const base = binding.caption ?? reactions[binding.cue].caption;
197
+ trigger.caption = base === undefined ? detail : detail === undefined ? base : `${base} ${detail}`;
198
+ return trigger;
199
+ }
200
+ return null;
201
+ },
202
+ suppressed(cue) {
203
+ return suppressed[cue];
204
+ },
205
+ retune(next) {
206
+ if (next.captions !== undefined)
207
+ captionsOn = next.captions;
208
+ if (next.bindings !== undefined) {
209
+ bindings = next.bindings;
210
+ rebuild();
211
+ }
212
+ if (next.reactions !== undefined) {
213
+ for (const [cue, reaction] of Object.entries(next.reactions)) {
214
+ if (reaction !== undefined)
215
+ reactions[cue] = reaction;
216
+ }
217
+ rebuild();
218
+ }
219
+ },
220
+ reset() {
221
+ for (const cue of Object.keys(lastPlayed)) {
222
+ lastPlayed[cue] = -Infinity;
223
+ suppressed[cue] = 0;
224
+ }
225
+ },
226
+ };
227
+ }
228
+ /**
229
+ * Subscribe the feedback seam to a game's event bus, including `feedback.cue` for moments the game
230
+ * announces itself. Returns the detach function; the mapping and reactions stay override-able
231
+ * through the seam's `retune`.
232
+ *
233
+ * @capability attach-event-feedback one call wires every announced combat/loot/objective/refusal event to its reaction
234
+ */
235
+ export function attachEventFeedback(events, feedback, eventsToBind = Object.keys(DEFAULT_FEEDBACK_BINDINGS.reduce((names, binding) => {
236
+ names[binding.event] = true;
237
+ return names;
238
+ }, {}))) {
239
+ const offs = eventsToBind.map((name) => events.on(name, (payload) => {
240
+ feedback.handle(name, payload);
241
+ }));
242
+ offs.push(events.on("feedback.cue", ({ cue, caption }) => {
243
+ feedback.notify(cue, caption);
244
+ }));
245
+ return () => {
246
+ for (const off of offs)
247
+ off();
248
+ };
249
+ }
250
+ /**
251
+ * Announce each new wave a {@link WaveRunner} starts as the `wave` cue, once per boundary. A wave
252
+ * has no event of its own, so this is the engine-side hookup for tower defense and wave survival
253
+ * games: `attachWaveFeedback(runner, ctx.game.feedback)`. Nothing is polled, the runner's own
254
+ * notification drives it, and one `update` that crosses two waves still fires once - the cue has a
255
+ * cooldown and the next boundary is announced by the next crossing.
256
+ *
257
+ * @capability attach-wave-feedback fire the wave cue once whenever a wave runner crosses into a new wave
258
+ */
259
+ export function attachWaveFeedback(runner, announce) {
260
+ let lastWave = runner.state().wave;
261
+ return runner.subscribe(() => {
262
+ const wave = runner.state().wave;
263
+ if (wave === lastWave)
264
+ return;
265
+ lastWave = wave;
266
+ announce.cue("wave", `Wave ${wave + 1}`);
267
+ });
268
+ }
@@ -0,0 +1,10 @@
1
+ import type { AudioEngine } from "./audioEngine.js";
2
+ /**
3
+ * Resume the audio context on the first real user gesture. Chrome keeps a freshly created
4
+ * `AudioContext` suspended until the page sees a trusted input, so pressing a key, tapping or clicking
5
+ * is the only moment sound may start; the engine then flushes any cue it held. One listener set per
6
+ * game, removed on unmount.
7
+ *
8
+ * @capability gesture-resume unlock the audio context on the first pointer/key/touch gesture, so pre-gesture cues play instead of vanishing
9
+ */
10
+ export declare function installGestureResume(engine: Pick<AudioEngine, "resume">, target?: Pick<Document, "addEventListener" | "removeEventListener"> | null): () => void;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Resume the audio context on the first real user gesture. Chrome keeps a freshly created
3
+ * `AudioContext` suspended until the page sees a trusted input, so pressing a key, tapping or clicking
4
+ * is the only moment sound may start; the engine then flushes any cue it held. One listener set per
5
+ * game, removed on unmount.
6
+ *
7
+ * @capability gesture-resume unlock the audio context on the first pointer/key/touch gesture, so pre-gesture cues play instead of vanishing
8
+ */
9
+ export function installGestureResume(engine, target = typeof document === "undefined" ? null : document) {
10
+ if (target === null)
11
+ return () => undefined;
12
+ const resume = () => {
13
+ engine.resume();
14
+ detach();
15
+ };
16
+ const detach = () => {
17
+ target.removeEventListener("pointerdown", resume, true);
18
+ target.removeEventListener("keydown", resume, true);
19
+ target.removeEventListener("touchstart", resume, true);
20
+ };
21
+ target.addEventListener("pointerdown", resume, true);
22
+ target.addEventListener("keydown", resume, true);
23
+ target.addEventListener("touchstart", resume, true);
24
+ return detach;
25
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AudioBusDef, SoundDef } from "@jgengine/core/audio/audioFalloff";
2
2
  import type { PlayableGame } from "../registry.js";
3
- /** A playable's sound catalog and buses with the default UI cues merged in. @internal */
3
+ /** A playable's sound catalog and buses with the default UI and gameplay feedback cues merged in. @internal */
4
4
  export interface ResolvedPlayableAudio {
5
5
  sounds: Record<string, SoundDef>;
6
6
  buses: Record<string, AudioBusDef> | undefined;
@@ -1,10 +1,12 @@
1
+ import { withFeedbackCueSounds } from "@jgengine/core/audio/feedbackCues";
1
2
  import { withUiCueSounds } from "@jgengine/core/audio/uiCues";
2
3
  const cache = new WeakMap();
3
4
  /** Resolve once per playable so the engine, the volume bridge and the settings rows share one bus list. @internal */
4
5
  export function resolvePlayableAudio(playable) {
5
6
  let resolved = cache.get(playable);
6
7
  if (resolved === undefined) {
7
- resolved = withUiCueSounds(playable.audio?.sounds, playable.audio?.buses, playable.audio?.uiCues);
8
+ const ui = withUiCueSounds(playable.audio?.sounds, playable.audio?.buses, playable.audio?.uiCues);
9
+ resolved = withFeedbackCueSounds(ui.sounds, ui.buses, playable.audio?.feedbackCues);
8
10
  cache.set(playable, resolved);
9
11
  }
10
12
  return resolved;
@@ -3,6 +3,7 @@ import { busVolumeSettingId, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, SETTING_ID
3
3
  import { readGraphicsSettings } from "@jgengine/core/settings/graphicsSettings";
4
4
  import { readFrameRateLimit } from "@jgengine/core/settings/frameRateLimit";
5
5
  import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANTS, TOUCH_STYLES, } from "@jgengine/core/input/touchScheme";
6
+ import { busMuteSettingId, MASTER_MUTE_SETTING_ID } from "../audio/busMix.js";
6
7
  /** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
7
8
  export const TOUCH_STYLE_AUTO = "auto";
8
9
  /** Shell-internal: binds the SettingsStore touch-style choice to the touch renderer. @internal */
@@ -49,8 +50,10 @@ export function AudioSettingsBridge({ store, engine, buses, }) {
49
50
  const rev = useSettingsRevision(store);
50
51
  useEffect(() => {
51
52
  engine.setMasterGain(store.get(SETTING_IDS.masterVolume, DEFAULT_MASTER_VOLUME));
53
+ engine.setMasterMute(Boolean(store.get(MASTER_MUTE_SETTING_ID, false)));
52
54
  for (const bus of Object.values(buses ?? {})) {
53
55
  engine.setBusGain(bus.id, store.get(busVolumeSettingId(bus.id), bus.gain ?? 1));
56
+ engine.setBusMute(bus.id, Boolean(store.get(busMuteSettingId(bus.id), false)));
54
57
  }
55
58
  }, [store, engine, buses, rev]);
56
59
  return null;
@@ -3,6 +3,7 @@ import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId, DEFAULT_GRAPHICS_SHADO
3
3
  import { ANISOTROPY_OPTIONS, applyGraphicsQuality, GRAPHICS_POST_STAGE_LABELS, GRAPHICS_POST_STAGES, graphicsPostStageSettingId, readGraphicsSettings, RENDER_SCALE_MAX, RENDER_SCALE_MIN, RENDER_SCALE_STEP, } from "@jgengine/core/settings/graphicsSettings";
4
4
  import { FRAME_RATE_LIMIT_DISPLAY, FRAME_RATE_LIMIT_OPTIONS } from "@jgengine/core/settings/frameRateLimit";
5
5
  import { accessibilitySettingRows } from "@jgengine/core/settings/accessibilitySettings";
6
+ import { busMuteSettingId, MASTER_MUTE_SETTING_ID } from "../audio/busMix.js";
6
7
  import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANT_OPTIONS, TOUCH_STYLE_OPTIONS, } from "@jgengine/core/input/touchScheme";
7
8
  import { TOUCH_STYLE_AUTO, useSettingsRevision } from "./appliedSettings.js";
8
9
  import { useSettingsStore, } from "@jgengine/react/settings";
@@ -63,6 +64,20 @@ export function useSettingsCategories(config) {
63
64
  set: (value) => store.set(id, value),
64
65
  };
65
66
  }),
67
+ ...Object.values(config.buses ?? {}).map((bus) => ({
68
+ id: busMuteSettingId(bus.id),
69
+ label: bus.id === "ui" ? "Mute UI" : `Mute ${bus.id}`,
70
+ kind: "toggle",
71
+ value: Boolean(store.get(busMuteSettingId(bus.id), false)),
72
+ set: (value) => store.set(busMuteSettingId(bus.id), Boolean(value)),
73
+ })),
74
+ {
75
+ id: MASTER_MUTE_SETTING_ID,
76
+ label: "Mute all",
77
+ kind: "toggle",
78
+ value: Boolean(store.get(MASTER_MUTE_SETTING_ID, false)),
79
+ set: (value) => store.set(MASTER_MUTE_SETTING_ID, Boolean(value)),
80
+ },
66
81
  ...extrasFor("sound"),
67
82
  ];
68
83
  const graphics = readGraphicsSettings(store, config.graphics);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.31.0",
3
+ "version": "0.32.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.31.0",
39
- "@jgengine/react": "^0.31.0",
40
- "@jgengine/ws": "^0.31.0"
38
+ "@jgengine/core": "^0.32.0",
39
+ "@jgengine/react": "^0.32.0",
40
+ "@jgengine/ws": "^0.32.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.31.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.31.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -410,6 +410,7 @@
410
410
  - `GameContextRace` (interface): interface GameContextRace { state(id: string, config?: RaceStateConfig): RaceState } — ⚠ undocumented
411
411
  - `GameContextTurn` (interface): interface GameContextTurn { loop(id: string, config?: TurnLoopConfig): TurnLoop } — ⚠ undocumented
412
412
  - `GameContextWorld` (interface): interface GameContextWorld { ground: TerrainField; groundHeightAt(x: number, z: number): number; solids: WorldSolids } — ⚠ undocumented
413
+ - `GameFeedback` (interface): interface GameFeedback — The game-announced feel seam on `ctx.game`: name a moment the engine has no event for and the shell plays its cue's sound, rumble, camera kick and caption. The reaction table lives in the shell, so a game never encodes what a hit or a wave should sound like.
413
414
  - `HitReactionInput` (interface): interface HitReactionInput { from: string; to: string; config: HitReactionConfig | ImpactPresetName; power?: number } — ⚠ undocumented · used by `resolveHitReaction` (@jgengine/core/combat): Resolves hit feel (hitstop, knockback impulse, camera shake) from either a named `impactPresets` event (`resolveHitReaction("explosion", inp…
414
415
  - `MoveTowardCommitOptions` (interface): interface MoveTowardCommitOptions extends MoveTowardOptions — Options for {@link SceneEntityContext.moveTowardCommit}: {@link MoveTowardOptions} plus an optional facing turn.
415
416
  - `SceneEntityContext` (interface): interface SceneEntityContext { spawn(name: string, options?: SpawnOptions): string; despawn(instanceId: string): boolean; update: EntityStore["update"]; setPose(instanceId: string, pose: EntityPose): boolean; setVelocity: EntityStore["setVelocity"]; setPoseConstraint: … — ⚠ undocumented
@@ -46,6 +46,7 @@ Existing React games keep their entity store and use the focused
46
46
 
47
47
  - **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.
48
48
  - **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`.
49
+ - **A hit, kill, pickup, objective, wave and refusal react without any sound authored.** The shell maps the events the engine already announces (`combat.damage`, `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp`, `command.refused`) to a `feel.<cue>` reaction - sound, gamepad pulse, camera kick and caption - and merges procedural defaults on an `sfx` bus. Replace a sound by declaring `audio.sounds["feel.hit"]`, tune or silence with `audio.feedbackCues`, and announce a wave through `attachWaveFeedback(runner, ctx.game.feedback)` or any custom moment with `ctx.game.feedback.cue("wave", "WAVE 3")`. Captions follow the accessibility setting; sound follows the `sfx` bus volume and mute rows in Settings.
49
50
  - 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.
50
51
  - Placement previews display claim cost and affordability inline with `TerritoryOverlay`.
51
52
  - `ChatPanel` owns collapsed/expanded state, own-message styling, Enter focus and Escape blur; standalone hosts pass `messages`, `userId`, and `onSend` (typed `{ ok, reason }`), while context-backed games use channel props. Use native inputs so shell typing-target suspension applies.