diff --git a/client/public/sounds/teleport_start.ogg b/client/public/sounds/teleport_start.ogg new file mode 100644 index 0000000..cb95014 Binary files /dev/null and b/client/public/sounds/teleport_start.ogg differ diff --git a/client/public/version.js b/client/public/version.js index 7ce6339..141ead1 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -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.22 R182"; +window.CHGRID_WEB_VERSION = "2026.02.22 R183"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index c761a0a..69b38b4 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -394,6 +394,34 @@ export class AudioEngine { } } + /** Starts a looping sample and returns a stop callback for explicit teardown. */ + async startLoopingSample(url: string, gain = 1): Promise<(() => void) | null> { + await this.ensureContext(); + const { audioCtx, sfxGainNode } = this; + if (!audioCtx || !sfxGainNode || gain <= 0) return null; + try { + const buffer = await this.getSampleBuffer(url); + const source = audioCtx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + const gainNode = audioCtx.createGain(); + gainNode.gain.value = gain; + source.connect(gainNode).connect(sfxGainNode); + source.start(); + return () => { + try { + source.stop(); + } catch { + // Ignore already-stopped source. + } + source.disconnect(); + gainNode.disconnect(); + }; + } catch { + return null; + } + } + cleanupPeerAudio(peer: SpatialPeerRuntime): void { if (peer.audioElement) { peer.audioElement.pause(); diff --git a/client/src/main.ts b/client/src/main.ts index 27b0621..f991c17 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -177,6 +177,7 @@ const SYSTEM_SOUND_URLS = { } as const; const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`)); const FOOTSTEP_GAIN = 0.7; +const TELEPORT_START_SOUND_URL = withBase('sounds/teleport_start.ogg'); const TELEPORT_SOUND_URL = withBase('sounds/teleport.ogg'); const WALL_SOUND_URL = withBase('sounds/wall.ogg'); @@ -219,6 +220,8 @@ let lastSubscriptionRefreshTileX = Math.round(state.player.x); let lastSubscriptionRefreshTileY = Math.round(state.player.y); let subscriptionRefreshInFlight = false; let subscriptionRefreshPending = false; +let activeTeleportLoopStop: (() => void) | null = null; +let activeTeleportLoopToken = 0; let activeTeleport: | { startX: number; @@ -1096,6 +1099,13 @@ function randomFootstepUrl(): string { return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)]; } +/** Stops active teleport loop audio, if one is running. */ +function stopTeleportLoopAudio(): void { + if (!activeTeleportLoopStop) return; + activeTeleportLoopStop(); + activeTeleportLoopStop = null; +} + /** Starts animated teleport movement toward a target tile at fixed squares-per-second pace. */ function startTeleportTo(targetX: number, targetY: number, completionStatus: string): void { const startX = state.player.x; @@ -1115,6 +1125,17 @@ function startTeleportTo(targetX: number, targetY: number, completionStatus: str lastSentY: Math.round(startY), completionStatus, }; + stopTeleportLoopAudio(); + activeTeleportLoopToken += 1; + const loopToken = activeTeleportLoopToken; + void audio.startLoopingSample(TELEPORT_START_SOUND_URL, FOOTSTEP_GAIN).then((stopLoop) => { + if (!stopLoop) return; + if (activeTeleport && loopToken === activeTeleportLoopToken) { + activeTeleportLoopStop = stopLoop; + return; + } + stopLoop(); + }); void refreshAudioSubscriptionsAt({ x: targetX, y: targetY }, true); state.keysPressed.ArrowUp = false; state.keysPressed.ArrowDown = false; @@ -1151,6 +1172,7 @@ function updateTeleport(): void { state.player.y = activeTeleport.targetY; signaling.send({ type: 'update_position', x: activeTeleport.targetX, y: activeTeleport.targetY }); activeTeleport = null; + stopTeleportLoopAudio(); persistPlayerPosition(); void refreshAudioSubscriptions(true); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); @@ -1392,6 +1414,7 @@ function disconnect(): void { lastSubscriptionRefreshAt = 0; lastSubscriptionRefreshTileX = Math.round(state.player.x); lastSubscriptionRefreshTileY = Math.round(state.player.y); + stopTeleportLoopAudio(); activeTeleport = null; } @@ -1427,6 +1450,7 @@ const onAppMessage = createOnMessageHandler({ ); }, TELEPORT_SOUND_URL, + TELEPORT_START_SOUND_URL, audioLayers, pushChatMessage, classifySystemMessageSound, diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index c561fd7..b746a55 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -47,6 +47,7 @@ type MessageHandlerDeps = { randomFootstepUrl: () => string; playRemoteSpatialStepOrTeleport: (url: string, peerX: number, peerY: number) => void; TELEPORT_SOUND_URL: string; + TELEPORT_START_SOUND_URL: string; audioLayers: { world: boolean }; pushChatMessage: (message: string) => void; classifySystemMessageSound: (message: string) => 'logon' | 'logout' | 'notify' | null; @@ -135,7 +136,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco deps.peerManager.setPeerPosition(message.id, message.x, message.y); if (peer) { const movementDelta = Math.hypot(message.x - prevX, message.y - prevY); - const soundUrl = movementDelta > 1.5 ? deps.TELEPORT_SOUND_URL : deps.randomFootstepUrl(); + const soundUrl = movementDelta > 1.5 ? deps.TELEPORT_START_SOUND_URL : deps.randomFootstepUrl(); if (deps.audioLayers.world) { deps.playRemoteSpatialStepOrTeleport(soundUrl, peer.x, peer.y); }