@jgengine/shell 0.31.0 → 0.33.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 +67 -0
- package/dist/GamePlayerShell.js +7 -0
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.d.ts +3 -1
- package/dist/ShellChrome.js +3 -2
- package/dist/ShellHudPresentation.js +1 -1
- package/dist/audio/AudioComponents.js +61 -51
- 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 +46 -0
- package/dist/audio/audioEngine.d.ts +25 -0
- package/dist/audio/audioEngine.js +267 -32
- 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/emitterTracking.d.ts +40 -0
- package/dist/audio/emitterTracking.js +153 -0
- package/dist/audio/eventFeedback.d.ts +137 -0
- package/dist/audio/eventFeedback.js +273 -0
- package/dist/audio/gestureResume.d.ts +10 -0
- package/dist/audio/gestureResume.js +25 -0
- package/dist/audio/hapticsRoute.d.ts +28 -0
- package/dist/audio/hapticsRoute.js +59 -0
- package/dist/audio/loopParams.js +20 -10
- package/dist/audio/playableAudio.d.ts +1 -1
- package/dist/audio/playableAudio.js +3 -1
- package/dist/devtools/FrameTelemetry.d.ts +1 -1
- package/dist/devtools/FrameTelemetry.js +4 -2
- package/dist/devtools/agentBridge.js +13 -0
- package/dist/devtools/frameSampler.js +4 -0
- package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
- package/dist/drivers/CanvasCoverFrameloop.js +22 -0
- package/dist/drivers/FrameRateLimiter.d.ts +2 -1
- package/dist/drivers/FrameRateLimiter.js +6 -3
- package/dist/postfx/PostProcessing.d.ts +78 -2
- package/dist/postfx/PostProcessing.js +149 -76
- package/dist/postfx/postfxOverlay.d.ts +14 -4
- package/dist/postfx/postfxOverlay.js +90 -10
- package/dist/postfx/shadowPrepass.d.ts +6 -2
- package/dist/postfx/shadowPrepass.js +7 -3
- package/dist/render/characterLod.js +6 -0
- package/dist/render/useFootIk.js +54 -15
- package/dist/render/useModelAnimation.js +22 -5
- package/dist/settings/appliedSettings.js +3 -0
- package/dist/settings/settingsController.d.ts +10 -1
- package/dist/settings/settingsController.js +94 -67
- package/dist/vision/RevealVision.d.ts +3 -1
- package/dist/vision/RevealVision.js +34 -15
- package/dist/world/WorldItems.js +11 -3
- 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 +94 -24
- package/skills/jgengine-ui/capabilities.md +40 -0
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +1 -1
- package/skills/jgengine-world/api.md +18 -8
- package/skills/jgengine-world/capabilities.md +4 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track one sound map's positional loops.
|
|
3
|
+
*
|
|
4
|
+
* Membership is reconciled only when `sources.ids()` returns a new array, and each frame then reads
|
|
5
|
+
* just the tracked emitters — so a game with hundreds of props and a handful of sounding ones pays
|
|
6
|
+
* for the handful, and a still frame allocates nothing. An emitter that gains a sounding key through
|
|
7
|
+
* a rename is picked up on the next membership change, not per frame; catalog ids never change, and a
|
|
8
|
+
* spawned entity already carries the name its sound map is keyed by.
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export function createEmitterTracker(options) {
|
|
12
|
+
const entries = [];
|
|
13
|
+
const byId = new Map();
|
|
14
|
+
const pose = { key: "", position: [0, 0, 0] };
|
|
15
|
+
const target = { x: 0, y: 0, z: 0 };
|
|
16
|
+
let soundsRef;
|
|
17
|
+
let idsRef = null;
|
|
18
|
+
let reconcileNext = false;
|
|
19
|
+
function stopEntry(entry) {
|
|
20
|
+
entry.handle?.stop();
|
|
21
|
+
entry.handle = null;
|
|
22
|
+
}
|
|
23
|
+
function removeAt(index) {
|
|
24
|
+
const entry = entries[index];
|
|
25
|
+
stopEntry(entry);
|
|
26
|
+
byId.delete(entry.id);
|
|
27
|
+
const last = entries.pop();
|
|
28
|
+
if (index < entries.length) {
|
|
29
|
+
entries[index] = last;
|
|
30
|
+
last.index = index;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function clear() {
|
|
34
|
+
for (let index = 0; index < entries.length; index += 1)
|
|
35
|
+
stopEntry(entries[index]);
|
|
36
|
+
entries.length = 0;
|
|
37
|
+
byId.clear();
|
|
38
|
+
}
|
|
39
|
+
function playAt(sound, x, y, z) {
|
|
40
|
+
target.x = x;
|
|
41
|
+
target.y = y;
|
|
42
|
+
target.z = z;
|
|
43
|
+
return options.play(sound, target);
|
|
44
|
+
}
|
|
45
|
+
function start(id, key, sound) {
|
|
46
|
+
const position = pose.position;
|
|
47
|
+
const entry = {
|
|
48
|
+
id,
|
|
49
|
+
key,
|
|
50
|
+
sound,
|
|
51
|
+
handle: playAt(sound, position[0], position[1], position[2]),
|
|
52
|
+
index: entries.length,
|
|
53
|
+
x: position[0],
|
|
54
|
+
y: position[1],
|
|
55
|
+
z: position[2],
|
|
56
|
+
};
|
|
57
|
+
entries.push(entry);
|
|
58
|
+
byId.set(id, entry);
|
|
59
|
+
}
|
|
60
|
+
function retune(entry, sound) {
|
|
61
|
+
if (sound === undefined) {
|
|
62
|
+
removeAt(entry.index);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
entry.key = pose.key;
|
|
66
|
+
if (sound !== entry.sound) {
|
|
67
|
+
stopEntry(entry);
|
|
68
|
+
entry.sound = sound;
|
|
69
|
+
entry.handle = playAt(sound, pose.position[0], pose.position[1], pose.position[2]);
|
|
70
|
+
}
|
|
71
|
+
applyPose(entry);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
function applyPose(entry) {
|
|
75
|
+
const position = pose.position;
|
|
76
|
+
if (position[0] === entry.x && position[1] === entry.y && position[2] === entry.z)
|
|
77
|
+
return;
|
|
78
|
+
entry.x = position[0];
|
|
79
|
+
entry.y = position[1];
|
|
80
|
+
entry.z = position[2];
|
|
81
|
+
if (entry.handle !== null) {
|
|
82
|
+
target.x = entry.x;
|
|
83
|
+
target.y = entry.y;
|
|
84
|
+
target.z = entry.z;
|
|
85
|
+
entry.handle.setPosition(target);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function reconcile(sounds) {
|
|
89
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
90
|
+
const entry = entries[index];
|
|
91
|
+
if (!options.sources.read(entry.id, pose))
|
|
92
|
+
removeAt(index);
|
|
93
|
+
}
|
|
94
|
+
for (const id of options.sources.ids()) {
|
|
95
|
+
if (byId.has(id) || !options.sources.read(id, pose))
|
|
96
|
+
continue;
|
|
97
|
+
const sound = sounds[pose.key];
|
|
98
|
+
if (sound !== undefined)
|
|
99
|
+
start(id, pose.key, sound);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
step() {
|
|
104
|
+
const sounds = options.sounds();
|
|
105
|
+
if (sounds === undefined) {
|
|
106
|
+
if (entries.length > 0)
|
|
107
|
+
clear();
|
|
108
|
+
soundsRef = undefined;
|
|
109
|
+
idsRef = null;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (sounds !== soundsRef) {
|
|
113
|
+
soundsRef = sounds;
|
|
114
|
+
reconcileNext = true;
|
|
115
|
+
}
|
|
116
|
+
const ids = options.sources.ids();
|
|
117
|
+
if (ids !== idsRef) {
|
|
118
|
+
idsRef = ids;
|
|
119
|
+
reconcileNext = true;
|
|
120
|
+
}
|
|
121
|
+
if (reconcileNext) {
|
|
122
|
+
reconcileNext = false;
|
|
123
|
+
reconcile(sounds);
|
|
124
|
+
}
|
|
125
|
+
// Backwards so a retune that drops an entry does not shift the ones still to visit.
|
|
126
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
127
|
+
const entry = entries[index];
|
|
128
|
+
if (!options.sources.read(entry.id, pose)) {
|
|
129
|
+
removeAt(index);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (pose.key !== entry.key) {
|
|
133
|
+
if (!retune(entry, sounds[pose.key]))
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
applyPose(entry);
|
|
138
|
+
}
|
|
139
|
+
// A stolen or failed voice is retried while its emitter lives, as the per-frame rebuild did.
|
|
140
|
+
if (entry.handle === null)
|
|
141
|
+
entry.handle = playAt(entry.sound, entry.x, entry.y, entry.z);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
count() {
|
|
145
|
+
return entries.length;
|
|
146
|
+
},
|
|
147
|
+
dispose() {
|
|
148
|
+
clear();
|
|
149
|
+
soundsRef = undefined;
|
|
150
|
+
idsRef = null;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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; an event that carries a `position`
|
|
46
|
+
* (damage, death) pans and attenuates its cue, and one without still plays centred. A wave start has
|
|
47
|
+
* no event of its own, so a game announces it through `notify("wave")` or `attachWaveFeedback`.
|
|
48
|
+
*/
|
|
49
|
+
export declare const DEFAULT_FEEDBACK_BINDINGS: readonly AnyFeedbackBinding[];
|
|
50
|
+
/** A resolved trigger: pooled per binding, so a busy event stream allocates nothing to decide. */
|
|
51
|
+
export interface ResolvedFeedback {
|
|
52
|
+
event: FeedbackEventName;
|
|
53
|
+
cue: FeedbackCue;
|
|
54
|
+
sound: string;
|
|
55
|
+
caption: string | undefined;
|
|
56
|
+
haptics: HapticPulse | undefined;
|
|
57
|
+
shake: number;
|
|
58
|
+
cooldownMs: number;
|
|
59
|
+
}
|
|
60
|
+
/** Everything the feedback seam reaches outside itself. Injected so it runs headless. */
|
|
61
|
+
export interface FeedbackDeps {
|
|
62
|
+
/**
|
|
63
|
+
* Play one catalog sound through the game's audio seam. `position` is the event's own position
|
|
64
|
+
* when it carries one, so the engine pans and attenuates the cue; omit it and the cue plays
|
|
65
|
+
* centred at full gain.
|
|
66
|
+
*/
|
|
67
|
+
play(sound: string, position?: readonly [number, number, number]): void;
|
|
68
|
+
/** Add camera trauma `0..1`; called with `shake x motionScale`. */
|
|
69
|
+
shake?(amount: number): void;
|
|
70
|
+
/**
|
|
71
|
+
* One-shot rumble for the local player. Wire it to `createHapticsRoute` so a gamepad takes the
|
|
72
|
+
* pulse when one is connected and a phone feels it through vibration otherwise.
|
|
73
|
+
*/
|
|
74
|
+
haptics?(pulse: HapticPulse): void;
|
|
75
|
+
/** Show a caption line for `cue`; only called when captions are on. */
|
|
76
|
+
caption?(text: string, cue: FeedbackCue): void;
|
|
77
|
+
/** The local player, for `scope: "local"`. */
|
|
78
|
+
localUserId(): string | null;
|
|
79
|
+
/** The entity the local player currently controls, for damage/death scope. */
|
|
80
|
+
localEntityId(): string | null;
|
|
81
|
+
/** Engine motion scale: `0` under reduced motion, `1` normally. Default `1`. */
|
|
82
|
+
motionScale?(): number;
|
|
83
|
+
now?(): number;
|
|
84
|
+
}
|
|
85
|
+
/** Options a game or host may override. */
|
|
86
|
+
export interface EventFeedbackOptions {
|
|
87
|
+
reactions?: Partial<Record<FeedbackCue, FeedbackReaction>>;
|
|
88
|
+
bindings?: readonly AnyFeedbackBinding[];
|
|
89
|
+
/** Whether captions reach `deps.caption` at all. Default true. */
|
|
90
|
+
captions?: boolean;
|
|
91
|
+
}
|
|
92
|
+
/** The event-to-reaction seam. One instance per running world. */
|
|
93
|
+
export interface EventFeedback {
|
|
94
|
+
/** React to one announced event. Returns the cue fired, or null when nothing mapped or it was muted by cooldown. */
|
|
95
|
+
handle<Name extends FeedbackEventName>(event: Name, payload: GameEventMap[Name]): FeedbackCue | null;
|
|
96
|
+
/** Fire a cue the game announces itself (a wave starting, a custom moment); `caption` overrides the cue's text. */
|
|
97
|
+
notify(cue: FeedbackCue, caption?: string): boolean;
|
|
98
|
+
/** Map without firing - the pooled reaction for an event, for devtools and tests. */
|
|
99
|
+
resolve<Name extends FeedbackEventName>(event: Name, payload: GameEventMap[Name]): ResolvedFeedback | null;
|
|
100
|
+
/** How many triggers a cue's cooldown folded away since the last reset. */
|
|
101
|
+
suppressed(cue: FeedbackCue): number;
|
|
102
|
+
/** Replace reactions or bindings at runtime. */
|
|
103
|
+
retune(options: Partial<EventFeedbackOptions>): void;
|
|
104
|
+
/** Clear cooldowns and suppressed counters. */
|
|
105
|
+
reset(): void;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build the event-to-sound/haptic/shake/caption seam. The mapping is data
|
|
109
|
+
* ({@link DEFAULT_FEEDBACK_BINDINGS}), reactions are data ({@link DEFAULT_FEEDBACK_REACTIONS}), and the
|
|
110
|
+
* resolved trigger for each binding is allocated once - handling an event allocates only the caption
|
|
111
|
+
* string it actually shows, never per frame.
|
|
112
|
+
*
|
|
113
|
+
* @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
|
|
114
|
+
*/
|
|
115
|
+
export declare function createEventFeedback(deps: FeedbackDeps, options?: EventFeedbackOptions): EventFeedback;
|
|
116
|
+
/**
|
|
117
|
+
* Subscribe the feedback seam to a game's event bus, including `feedback.cue` for moments the game
|
|
118
|
+
* announces itself. Returns the detach function; the mapping and reactions stay override-able
|
|
119
|
+
* through the seam's `retune`.
|
|
120
|
+
*
|
|
121
|
+
* @capability attach-event-feedback one call wires every announced combat/loot/objective/refusal event to its reaction
|
|
122
|
+
*/
|
|
123
|
+
export declare function attachEventFeedback(events: Pick<GameEvents, "on">, feedback: EventFeedback, eventsToBind?: readonly FeedbackEventName[]): () => void;
|
|
124
|
+
/** The slice of `ctx.game.feedback` a wave runner needs, so this runs headless in a test. */
|
|
125
|
+
export interface FeedbackAnnouncer {
|
|
126
|
+
cue(cue: FeedbackCue, caption?: string): void;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Announce each new wave a {@link WaveRunner} starts as the `wave` cue, once per boundary. A wave
|
|
130
|
+
* has no event of its own, so this is the engine-side hookup for tower defense and wave survival
|
|
131
|
+
* games: `attachWaveFeedback(runner, ctx.game.feedback)`. Nothing is polled, the runner's own
|
|
132
|
+
* notification drives it, and one `update` that crosses two waves still fires once - the cue has a
|
|
133
|
+
* cooldown and the next boundary is announced by the next crossing.
|
|
134
|
+
*
|
|
135
|
+
* @capability attach-wave-feedback fire the wave cue once whenever a wave runner crosses into a new wave
|
|
136
|
+
*/
|
|
137
|
+
export declare function attachWaveFeedback(runner: Pick<WaveRunner, "state" | "subscribe">, announce: FeedbackAnnouncer): () => void;
|
|
@@ -0,0 +1,273 @@
|
|
|
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; an event that carries a `position`
|
|
58
|
+
* (damage, death) pans and attenuates its cue, and one without still plays centred. A wave start has
|
|
59
|
+
* no event of its own, so a game announces it through `notify("wave")` or `attachWaveFeedback`.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_FEEDBACK_BINDINGS = [
|
|
62
|
+
{ event: "combat.damage", cue: "hurt", scope: "local", detail: (event) => `${Math.round(event.amount)}` },
|
|
63
|
+
{ event: "combat.damage", cue: "hit", scope: "remote", detail: (event) => `${Math.round(event.amount)}` },
|
|
64
|
+
{ event: "entity.died", cue: "hurt", scope: "local", caption: "You died" },
|
|
65
|
+
{ event: "entity.died", cue: "kill", scope: "remote", detail: (event) => event.displayName ?? event.catalogId },
|
|
66
|
+
{ event: "worldItem.picked_up", cue: "pickup", scope: "local", detail: (event) => (event.count > 1 ? `${event.itemId} x${event.count}` : event.itemId) },
|
|
67
|
+
{ event: "quest.completed", cue: "objective", scope: "local", detail: (event) => event.questId },
|
|
68
|
+
{ event: "stat.levelUp", cue: "objective", scope: "local", detail: (event) => `${event.stat} ${event.level}` },
|
|
69
|
+
{ event: "command.refused", cue: "deny", scope: "local", detail: (event) => event.message ?? event.reason },
|
|
70
|
+
];
|
|
71
|
+
const DEFAULT_COOLDOWN_MS = 40;
|
|
72
|
+
/**
|
|
73
|
+
* Build the event-to-sound/haptic/shake/caption seam. The mapping is data
|
|
74
|
+
* ({@link DEFAULT_FEEDBACK_BINDINGS}), reactions are data ({@link DEFAULT_FEEDBACK_REACTIONS}), and the
|
|
75
|
+
* resolved trigger for each binding is allocated once - handling an event allocates only the caption
|
|
76
|
+
* string it actually shows, never per frame.
|
|
77
|
+
*
|
|
78
|
+
* @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
|
|
79
|
+
*/
|
|
80
|
+
export function createEventFeedback(deps, options = {}) {
|
|
81
|
+
const reactions = { ...DEFAULT_FEEDBACK_REACTIONS };
|
|
82
|
+
let bindings = options.bindings ?? DEFAULT_FEEDBACK_BINDINGS;
|
|
83
|
+
let captionsOn = options.captions ?? true;
|
|
84
|
+
for (const [cue, reaction] of Object.entries(options.reactions ?? {})) {
|
|
85
|
+
if (reaction !== undefined)
|
|
86
|
+
reactions[cue] = reaction;
|
|
87
|
+
}
|
|
88
|
+
const now = deps.now ?? Date.now;
|
|
89
|
+
const lastPlayed = { hit: -Infinity, hurt: -Infinity, kill: -Infinity, pickup: -Infinity, objective: -Infinity, wave: -Infinity, deny: -Infinity };
|
|
90
|
+
const suppressed = { hit: 0, hurt: 0, kill: 0, pickup: 0, objective: 0, wave: 0, deny: 0 };
|
|
91
|
+
function triggerFor(binding) {
|
|
92
|
+
const reaction = reactions[binding.cue];
|
|
93
|
+
return {
|
|
94
|
+
event: binding.event,
|
|
95
|
+
cue: binding.cue,
|
|
96
|
+
sound: reaction.sound,
|
|
97
|
+
caption: undefined,
|
|
98
|
+
haptics: reaction.haptics,
|
|
99
|
+
shake: reaction.shake ?? 0,
|
|
100
|
+
cooldownMs: reaction.cooldownMs ?? DEFAULT_COOLDOWN_MS,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// One pooled resolution per binding row plus one per cue (for `notify`), so a busy event stream
|
|
104
|
+
// allocates nothing to decide what to play.
|
|
105
|
+
let resolved = bindings.map(triggerFor);
|
|
106
|
+
const cueTriggers = Object.fromEntries(["hit", "hurt", "kill", "pickup", "objective", "wave", "deny"].map((cue) => [cue, triggerFor({ event: "combat.damage", cue })]));
|
|
107
|
+
function rebuild() {
|
|
108
|
+
resolved = bindings.map(triggerFor);
|
|
109
|
+
for (const cue of Object.keys(cueTriggers)) {
|
|
110
|
+
const reaction = reactions[cue];
|
|
111
|
+
const trigger = cueTriggers[cue];
|
|
112
|
+
trigger.sound = reaction.sound;
|
|
113
|
+
trigger.haptics = reaction.haptics;
|
|
114
|
+
trigger.shake = reaction.shake ?? 0;
|
|
115
|
+
trigger.cooldownMs = reaction.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function isLocal(event, payload) {
|
|
119
|
+
switch (event) {
|
|
120
|
+
case "combat.damage":
|
|
121
|
+
return payload.target === deps.localEntityId();
|
|
122
|
+
case "entity.died": {
|
|
123
|
+
const died = payload;
|
|
124
|
+
const userId = deps.localUserId();
|
|
125
|
+
return (userId !== null && died.userId === userId) || died.instanceId === deps.localEntityId();
|
|
126
|
+
}
|
|
127
|
+
case "worldItem.picked_up":
|
|
128
|
+
return payload.userId === deps.localUserId();
|
|
129
|
+
case "quest.completed":
|
|
130
|
+
case "stat.levelUp": {
|
|
131
|
+
const userId = deps.localUserId();
|
|
132
|
+
return userId !== null && payload.userId === userId;
|
|
133
|
+
}
|
|
134
|
+
case "command.refused": {
|
|
135
|
+
const actor = payload.actor;
|
|
136
|
+
return actor === null || actor === deps.localUserId();
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function fire(trigger, captionText, position) {
|
|
143
|
+
if (position === undefined)
|
|
144
|
+
deps.play(trigger.sound);
|
|
145
|
+
else
|
|
146
|
+
deps.play(trigger.sound, position);
|
|
147
|
+
if (trigger.haptics !== undefined)
|
|
148
|
+
deps.haptics?.(trigger.haptics);
|
|
149
|
+
if (trigger.shake > 0) {
|
|
150
|
+
const scale = deps.motionScale?.() ?? 1;
|
|
151
|
+
if (scale > 0)
|
|
152
|
+
deps.shake?.(trigger.shake * scale);
|
|
153
|
+
}
|
|
154
|
+
if (captionsOn && captionText !== undefined)
|
|
155
|
+
deps.caption?.(captionText, trigger.cue);
|
|
156
|
+
}
|
|
157
|
+
function react(cue, captionText, position) {
|
|
158
|
+
const reaction = reactions[cue];
|
|
159
|
+
const trigger = cueTriggers[cue];
|
|
160
|
+
const time = now();
|
|
161
|
+
const cooldown = trigger.cooldownMs;
|
|
162
|
+
if (time - lastPlayed[cue] < cooldown) {
|
|
163
|
+
suppressed[cue] += 1;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
lastPlayed[cue] = time;
|
|
167
|
+
const text = captionText ?? trigger.caption ?? reaction.caption;
|
|
168
|
+
fire(trigger, text, position);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
handle(event, payload) {
|
|
173
|
+
for (let index = 0; index < bindings.length; index += 1) {
|
|
174
|
+
const binding = bindings[index];
|
|
175
|
+
if (binding.event !== event)
|
|
176
|
+
continue;
|
|
177
|
+
const scope = binding.scope ?? "any";
|
|
178
|
+
if (scope !== "any" && isLocal(event, payload) !== (scope === "local"))
|
|
179
|
+
continue;
|
|
180
|
+
const detail = binding.detail?.(payload);
|
|
181
|
+
const base = binding.caption ?? reactions[binding.cue].caption;
|
|
182
|
+
const captionText = base === undefined ? detail : detail === undefined ? base : `${base} ${detail}`;
|
|
183
|
+
const position = payload.position;
|
|
184
|
+
return react(binding.cue, captionText, position) ? binding.cue : null;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
},
|
|
188
|
+
notify(cue, caption) {
|
|
189
|
+
return react(cue, caption);
|
|
190
|
+
},
|
|
191
|
+
resolve(event, payload) {
|
|
192
|
+
for (let index = 0; index < bindings.length; index += 1) {
|
|
193
|
+
const binding = bindings[index];
|
|
194
|
+
if (binding.event !== event)
|
|
195
|
+
continue;
|
|
196
|
+
const scope = binding.scope ?? "any";
|
|
197
|
+
if (scope !== "any" && isLocal(event, payload) !== (scope === "local"))
|
|
198
|
+
continue;
|
|
199
|
+
const trigger = resolved[index];
|
|
200
|
+
const detail = binding.detail?.(payload);
|
|
201
|
+
const base = binding.caption ?? reactions[binding.cue].caption;
|
|
202
|
+
trigger.caption = base === undefined ? detail : detail === undefined ? base : `${base} ${detail}`;
|
|
203
|
+
return trigger;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
},
|
|
207
|
+
suppressed(cue) {
|
|
208
|
+
return suppressed[cue];
|
|
209
|
+
},
|
|
210
|
+
retune(next) {
|
|
211
|
+
if (next.captions !== undefined)
|
|
212
|
+
captionsOn = next.captions;
|
|
213
|
+
if (next.bindings !== undefined) {
|
|
214
|
+
bindings = next.bindings;
|
|
215
|
+
rebuild();
|
|
216
|
+
}
|
|
217
|
+
if (next.reactions !== undefined) {
|
|
218
|
+
for (const [cue, reaction] of Object.entries(next.reactions)) {
|
|
219
|
+
if (reaction !== undefined)
|
|
220
|
+
reactions[cue] = reaction;
|
|
221
|
+
}
|
|
222
|
+
rebuild();
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
reset() {
|
|
226
|
+
for (const cue of Object.keys(lastPlayed)) {
|
|
227
|
+
lastPlayed[cue] = -Infinity;
|
|
228
|
+
suppressed[cue] = 0;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Subscribe the feedback seam to a game's event bus, including `feedback.cue` for moments the game
|
|
235
|
+
* announces itself. Returns the detach function; the mapping and reactions stay override-able
|
|
236
|
+
* through the seam's `retune`.
|
|
237
|
+
*
|
|
238
|
+
* @capability attach-event-feedback one call wires every announced combat/loot/objective/refusal event to its reaction
|
|
239
|
+
*/
|
|
240
|
+
export function attachEventFeedback(events, feedback, eventsToBind = Object.keys(DEFAULT_FEEDBACK_BINDINGS.reduce((names, binding) => {
|
|
241
|
+
names[binding.event] = true;
|
|
242
|
+
return names;
|
|
243
|
+
}, {}))) {
|
|
244
|
+
const offs = eventsToBind.map((name) => events.on(name, (payload) => {
|
|
245
|
+
feedback.handle(name, payload);
|
|
246
|
+
}));
|
|
247
|
+
offs.push(events.on("feedback.cue", ({ cue, caption }) => {
|
|
248
|
+
feedback.notify(cue, caption);
|
|
249
|
+
}));
|
|
250
|
+
return () => {
|
|
251
|
+
for (const off of offs)
|
|
252
|
+
off();
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Announce each new wave a {@link WaveRunner} starts as the `wave` cue, once per boundary. A wave
|
|
257
|
+
* has no event of its own, so this is the engine-side hookup for tower defense and wave survival
|
|
258
|
+
* games: `attachWaveFeedback(runner, ctx.game.feedback)`. Nothing is polled, the runner's own
|
|
259
|
+
* notification drives it, and one `update` that crosses two waves still fires once - the cue has a
|
|
260
|
+
* cooldown and the next boundary is announced by the next crossing.
|
|
261
|
+
*
|
|
262
|
+
* @capability attach-wave-feedback fire the wave cue once whenever a wave runner crosses into a new wave
|
|
263
|
+
*/
|
|
264
|
+
export function attachWaveFeedback(runner, announce) {
|
|
265
|
+
let lastWave = runner.state().wave;
|
|
266
|
+
return runner.subscribe(() => {
|
|
267
|
+
const wave = runner.state().wave;
|
|
268
|
+
if (wave === lastWave)
|
|
269
|
+
return;
|
|
270
|
+
lastWave = wave;
|
|
271
|
+
announce.cue("wave", `Wave ${wave + 1}`);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { HapticPulse } from "@jgengine/core/input/haptics";
|
|
2
|
+
/** Where a pulse actually landed: a gamepad, the device's own motor, or nowhere. */
|
|
3
|
+
export type HapticsSink = "gamepad" | "vibration" | "none";
|
|
4
|
+
/** The browser reach of {@link createHapticsRoute}; injected so the route runs headless. */
|
|
5
|
+
export interface HapticsRouteOptions {
|
|
6
|
+
/** Connected pads. Default `navigator.getGamepads`. */
|
|
7
|
+
gamepads?(): ArrayLike<Gamepad | null>;
|
|
8
|
+
/** Device vibration, called with the pulse length in ms. Default `navigator.vibrate`; `null` for a device without one. */
|
|
9
|
+
vibrate?: ((ms: number) => void) | null;
|
|
10
|
+
/** Where `gamepadconnected`/`gamepaddisconnected` arrive. Default `window`; `null` reads pads per pulse. */
|
|
11
|
+
events?: Pick<Window, "addEventListener" | "removeEventListener"> | null;
|
|
12
|
+
}
|
|
13
|
+
/** Route haptic pulses to whatever the player can feel them on. */
|
|
14
|
+
export interface HapticsRoute {
|
|
15
|
+
/** Send one pulse: the gamepad if a pad is connected, else the phone's motor, else nothing. */
|
|
16
|
+
deliver(pulse: HapticPulse): HapticsSink;
|
|
17
|
+
/** Drop the connection listeners. Safe twice. */
|
|
18
|
+
dispose(): void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One pulse in, whichever motor the player has: a connected gamepad takes it through the haptic
|
|
22
|
+
* channels the gamepad loop already drives, and a phone with no pad falls back to
|
|
23
|
+
* `navigator.vibrate`. Pad presence is cached and refreshed on connection events, so a hit never
|
|
24
|
+
* asks the browser for its pad list. Reduced motion does not silence this: see `eventFeedback`.
|
|
25
|
+
*
|
|
26
|
+
* @capability haptics-route send a feedback pulse to the gamepad when one is connected, else to the phone's vibration motor
|
|
27
|
+
*/
|
|
28
|
+
export declare function createHapticsRoute(pad: (pulse: HapticPulse) => void, options?: HapticsRouteOptions): HapticsRoute;
|