Restore useSound and add looping spatial emitSound

This commit is contained in:
Jage9
2026-02-21 16:13:48 -05:00
parent 6698639260
commit 61551eaac5
13 changed files with 167 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.21 R99";
window.CHGRID_WEB_VERSION = "2026.02.21 R100";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -169,6 +169,10 @@ export class AudioEngine {
return this.outputMode;
}
getOutputMode(): OutputMode {
return this.outputMode;
}
toggleLoopback(): boolean {
this.loopbackEnabled = !this.loopbackEnabled;
this.rebuildOutboundEffectGraph();

View File

@@ -0,0 +1,111 @@
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
import { AudioEngine } from './audioEngine';
type EmitOutput = {
soundUrl: string;
element: HTMLAudioElement;
source: MediaElementAudioSourceNode;
gain: GainNode;
panner: StereoPannerNode | null;
};
export class ItemEmitRuntime {
private readonly outputs = new Map<string, EmitOutput>();
constructor(
private readonly audio: AudioEngine,
private readonly resolveSoundUrl: (soundPath: string) => string,
) {}
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);
}
}
async sync(items: Iterable<WorldItem>): Promise<void> {
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 {
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;
}
const dist = Math.hypot(item.x - playerPosition.x, item.y - playerPosition.y);
let gainValue = 0;
let panValue = 0;
if (dist < HEARING_RADIUS) {
gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2);
panValue = Math.sin(((item.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2));
}
if (dist <= 1) {
gainValue = 1;
panValue = 0;
}
output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1);
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);
}
}
}
}

View File

@@ -7,6 +7,7 @@ import {
type EffectId,
} from './audio/effects';
import { RADIO_CHANNEL_OPTIONS, RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
import {
applyPastedText,
applyTextInput,
@@ -152,10 +153,10 @@ dom.appVersion.textContent = APP_VERSION
: 'Another AI experiment with Jage. Version unknown';
const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
const ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
radio_station: { emitSound: 'none', useCooldownMs: 1000 },
dice: { emitSound: 'sounds/roll.ogg', useCooldownMs: 1000 },
wheel: { emitSound: 'sounds/spin.ogg', useCooldownMs: 4000 },
clock: { emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 },
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 },
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 },
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
};
const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
'title',
@@ -210,6 +211,7 @@ let connecting = false;
const messageBuffer: string[] = [];
let messageCursor = -1;
const radioRuntime = new RadioStationRuntime(audio);
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl);
let internalClipboardText = '';
let replaceTextOnNextType = false;
let pendingEscapeDisconnect = false;
@@ -510,7 +512,19 @@ function getInspectItemPropertyKeys(item: WorldItem): string[] {
const seen = new Set(editableKeys);
const allKeys: string[] = [...editableKeys];
const baseKeys = ['type', 'x', 'y', 'carrierId', 'version', 'createdBy', 'createdAt', 'updatedAt', 'capabilities', 'emitSound'];
const baseKeys = [
'type',
'x',
'y',
'carrierId',
'version',
'createdBy',
'createdAt',
'updatedAt',
'capabilities',
'useSound',
'emitSound',
];
for (const key of baseKeys) {
if (seen.has(key)) continue;
seen.add(key);
@@ -666,6 +680,7 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
if (key === 'createdAt') return formatTimestampMs(item.createdAt);
if (key === 'updatedAt') return formatTimestampMs(item.updatedAt);
if (key === 'capabilities') return item.capabilities.join(', ') || 'none';
if (key === 'useSound') return item.useSound ?? 'none';
if (key === 'emitSound') return item.emitSound ?? 'none';
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
if (key === 'timeZone') return String(item.params.timeZone ?? CLOCK_TIME_ZONE_OPTIONS[0]);
@@ -709,6 +724,7 @@ function gameLoop(): void {
handleMovement();
audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y });
radioRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y });
itemEmitRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y });
state.cursorVisible = Math.floor(Date.now() / 500) % 2 === 0;
renderer.draw(state);
requestAnimationFrame(gameLoop);
@@ -897,6 +913,7 @@ function disconnect(): void {
peerManager.cleanupAll();
radioRuntime.cleanupAll();
itemEmitRuntime.cleanupAll();
state.running = false;
state.keysPressed = {};
state.peers.clear();
@@ -961,6 +978,7 @@ async function onMessage(message: IncomingMessage): Promise<void> {
});
}
await radioRuntime.sync(state.items.values());
await itemEmitRuntime.sync(state.items.values());
gameLoop();
break;
@@ -1064,6 +1082,7 @@ async function onMessage(message: IncomingMessage): Promise<void> {
}
}
await radioRuntime.sync(state.items.values());
await itemEmitRuntime.sync(state.items.values());
break;
}
@@ -1071,6 +1090,7 @@ async function onMessage(message: IncomingMessage): Promise<void> {
state.items.delete(message.itemId);
state.carriedItemId = getCarriedItem()?.id ?? null;
radioRuntime.cleanup(message.itemId);
itemEmitRuntime.cleanup(message.itemId);
break;
}
@@ -1079,7 +1099,7 @@ async function onMessage(message: IncomingMessage): Promise<void> {
if (message.action === 'use') {
pushChatMessage(message.message);
const item = message.itemId ? state.items.get(message.itemId) : null;
if (!item?.emitSound && item) {
if (!item?.useSound && item) {
audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y });
}
} else if (message.action !== 'update') {

View File

@@ -11,6 +11,7 @@ export const itemSchema = z.object({
updatedAt: z.number().int(),
version: z.number().int(),
capabilities: z.array(z.string()),
useSound: z.string().optional(),
emitSound: z.string().optional(),
params: z.record(z.string(), z.unknown()),
carrierId: z.string().nullable().optional(),

View File

@@ -15,6 +15,7 @@ export type WorldItem = {
updatedAt: number;
version: number;
capabilities: string[];
useSound?: string;
emitSound?: string;
params: Record<string, unknown>;
carrierId?: string | null;