@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 +51 -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 +4 -3
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +79 -3
- package/skills/jgengine-ui/capabilities.md +42 -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
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
39
|
-
"@jgengine/react": "^0.
|
|
40
|
-
"@jgengine/ws": "^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",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
@@ -728,7 +729,7 @@
|
|
|
728
729
|
- `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
|
|
729
730
|
- `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
|
|
730
731
|
- `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
|
|
731
|
-
- `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /**
|
|
732
|
+
- `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
|
|
732
733
|
- `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the en… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
|
|
733
734
|
- `StatBar` (function): function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }… — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
|
|
734
735
|
- `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
|