2026-02-21 16:13:48 -05:00
|
|
|
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
|
|
|
|
import { AudioEngine } from './audioEngine';
|
2026-02-21 19:25:26 -05:00
|
|
|
import { resolveSpatialMix } from './spatial';
|
2026-02-21 16:13:48 -05:00
|
|
|
|
|
|
|
|
type EmitOutput = {
|
|
|
|
|
soundUrl: string;
|
|
|
|
|
element: HTMLAudioElement;
|
|
|
|
|
source: MediaElementAudioSourceNode;
|
|
|
|
|
gain: GainNode;
|
|
|
|
|
panner: StereoPannerNode | null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 19:37:08 -05:00
|
|
|
type EmitSpatialConfig = {
|
|
|
|
|
range: number;
|
|
|
|
|
directional: boolean;
|
|
|
|
|
facingDeg: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 16:30:31 -05:00
|
|
|
const ITEM_EMIT_BASE_GAIN = 0.3;
|
|
|
|
|
|
2026-02-21 16:13:48 -05:00
|
|
|
export class ItemEmitRuntime {
|
|
|
|
|
private readonly outputs = new Map<string, EmitOutput>();
|
2026-02-21 16:30:31 -05:00
|
|
|
private layerEnabled = true;
|
2026-02-21 16:13:48 -05:00
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly audio: AudioEngine,
|
|
|
|
|
private readonly resolveSoundUrl: (soundPath: string) => string,
|
2026-02-21 19:37:08 -05:00
|
|
|
private readonly getSpatialConfig: (item: WorldItem) => EmitSpatialConfig,
|
2026-02-21 16:13:48 -05:00
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
cleanup(itemId: string): void {
|
|
|
|
|
const output = this.outputs.get(itemId);
|
|
|
|
|
if (!output) return;
|
|
|
|
|
output.element.pause();
|
|
|
|
|
output.element.src = '';
|
|
|
|
|
output.source.disconnect();
|
|
|
|
|
output.gain.disconnect();
|
|
|
|
|
output.panner?.disconnect();
|
|
|
|
|
this.outputs.delete(itemId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cleanupAll(): void {
|
|
|
|
|
for (const itemId of Array.from(this.outputs.keys())) {
|
|
|
|
|
this.cleanup(itemId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:30:31 -05:00
|
|
|
async setLayerEnabled(enabled: boolean, items: Iterable<WorldItem>): Promise<void> {
|
|
|
|
|
this.layerEnabled = enabled;
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
this.cleanupAll();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await this.sync(items);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:13:48 -05:00
|
|
|
async sync(items: Iterable<WorldItem>): Promise<void> {
|
2026-02-21 16:30:31 -05:00
|
|
|
if (!this.layerEnabled) {
|
|
|
|
|
this.cleanupAll();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 16:13:48 -05:00
|
|
|
const validIds = new Set<string>();
|
|
|
|
|
await this.audio.ensureContext();
|
|
|
|
|
const audioCtx = this.audio.context;
|
|
|
|
|
if (!audioCtx) return;
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const soundUrl = this.resolveSoundUrl(String(item.emitSound ?? '').trim());
|
|
|
|
|
if (!soundUrl || item.carrierId) {
|
|
|
|
|
this.cleanup(item.id);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
validIds.add(item.id);
|
|
|
|
|
const existing = this.outputs.get(item.id);
|
|
|
|
|
if (existing && existing.soundUrl === soundUrl) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (existing) {
|
|
|
|
|
this.cleanup(item.id);
|
|
|
|
|
}
|
|
|
|
|
const element = new Audio(soundUrl);
|
|
|
|
|
element.loop = true;
|
|
|
|
|
element.preload = 'none';
|
|
|
|
|
element.crossOrigin = 'anonymous';
|
|
|
|
|
const source = audioCtx.createMediaElementSource(element);
|
|
|
|
|
const gain = audioCtx.createGain();
|
|
|
|
|
gain.gain.value = 0;
|
|
|
|
|
let panner: StereoPannerNode | null = null;
|
|
|
|
|
source.connect(gain);
|
|
|
|
|
if (this.audio.supportsStereoPanner()) {
|
|
|
|
|
panner = audioCtx.createStereoPanner();
|
|
|
|
|
gain.connect(panner).connect(audioCtx.destination);
|
|
|
|
|
} else {
|
|
|
|
|
gain.connect(audioCtx.destination);
|
|
|
|
|
}
|
|
|
|
|
this.outputs.set(item.id, { soundUrl, element, source, gain, panner });
|
|
|
|
|
void element.play().catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const itemId of Array.from(this.outputs.keys())) {
|
|
|
|
|
if (!validIds.has(itemId)) {
|
|
|
|
|
this.cleanup(itemId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateSpatialAudio(items: Map<string, WorldItem>, playerPosition: { x: number; y: number }): void {
|
2026-02-21 16:30:31 -05:00
|
|
|
if (!this.layerEnabled) return;
|
2026-02-21 16:13:48 -05:00
|
|
|
const audioCtx = this.audio.context;
|
|
|
|
|
if (!audioCtx) return;
|
|
|
|
|
|
|
|
|
|
for (const [itemId, output] of this.outputs.entries()) {
|
|
|
|
|
const item = items.get(itemId);
|
|
|
|
|
if (!item || item.carrierId) {
|
|
|
|
|
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-02-21 19:37:08 -05:00
|
|
|
const spatialConfig = this.getSpatialConfig(item);
|
2026-02-21 19:25:26 -05:00
|
|
|
const mix = resolveSpatialMix({
|
|
|
|
|
dx: item.x - playerPosition.x,
|
|
|
|
|
dy: item.y - playerPosition.y,
|
2026-02-21 19:37:08 -05:00
|
|
|
range: Math.max(1, spatialConfig.range || HEARING_RADIUS),
|
2026-02-21 19:25:26 -05:00
|
|
|
baseGain: ITEM_EMIT_BASE_GAIN,
|
|
|
|
|
nearFieldDistance: 1,
|
|
|
|
|
nearFieldGain: 1,
|
|
|
|
|
nearFieldCenterPan: true,
|
2026-02-21 19:37:08 -05:00
|
|
|
directional: {
|
|
|
|
|
enabled: spatialConfig.directional,
|
|
|
|
|
facingDeg: spatialConfig.facingDeg,
|
|
|
|
|
coneDeg: 120,
|
2026-02-21 19:53:25 -05:00
|
|
|
rearGain: 0.35,
|
2026-02-21 19:37:08 -05:00
|
|
|
},
|
2026-02-21 19:25:26 -05:00
|
|
|
});
|
|
|
|
|
const gainValue = mix?.gain ?? 0;
|
|
|
|
|
const panValue = mix?.pan ?? 0;
|
|
|
|
|
output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1);
|
2026-02-21 16:13:48 -05:00
|
|
|
if (output.panner) {
|
|
|
|
|
const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
|
|
|
|
output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|