diff --git a/client/public/version.js b/client/public/version.js index 9fe64e7..9c94a1a 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 R171"; +window.CHGRID_WEB_VERSION = "2026.02.22 R172"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index bdf3386..7c5cd79 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -81,6 +81,7 @@ const HEARTBEAT_INTERVAL_MS = 10_000; const RECONNECT_DELAY_MS = 5_000; const RECONNECT_MAX_ATTEMPTS = 3; const AUTO_RECONNECT_AFTER_RELOAD_KEY = 'chatGridAutoReconnectAfterReload'; +const SELF_LIST_ENTRY_ID = '__self__'; declare global { interface Window { @@ -517,6 +518,31 @@ function applyConfiguredPeerListenGains(): void { } } +/** Resolves a list user entry id to either local player or a remote peer. */ +function getListUserEntry(peerId: string): + | { id: string; nickname: string; x: number; y: number; isSelf: true } + | { id: string; nickname: string; x: number; y: number; isSelf: false } + | null { + if (peerId === SELF_LIST_ENTRY_ID) { + return { + id: SELF_LIST_ENTRY_ID, + nickname: state.player.nickname, + x: state.player.x, + y: state.player.y, + isSelf: true, + }; + } + const peer = state.peers.get(peerId); + if (!peer) return null; + return { + id: peer.id, + nickname: peer.nickname, + x: peer.x, + y: peer.y, + isSelf: false, + }; +} + /** Applies current layer toggles to peer voice, media streams, and item emitters. */ async function applyAudioLayerState(): Promise { audio.setVoiceLayerEnabled(audioLayers.voice); @@ -1470,24 +1496,25 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { return; } case 'listUsersAlphabetical': - if (state.peers.size === 0) { - updateStatus('No users to list.'); - audio.sfxUiCancel(); - return; - } - state.sortedPeerIds = Array.from(state.peers.entries()) - .sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' })) - .map(([id]) => id); + state.sortedPeerIds = [ + ...Array.from(state.peers.entries()).map(([id, peer]) => ({ id, nickname: peer.nickname })), + { id: SELF_LIST_ENTRY_ID, nickname: state.player.nickname }, + ] + .sort((a, b) => a.nickname.localeCompare(b.nickname, undefined, { sensitivity: 'base' })) + .map((entry) => entry.id); state.listIndex = 0; state.mode = 'listUsers'; { - const first = state.peers.get(state.sortedPeerIds[0]); + const first = getListUserEntry(state.sortedPeerIds[0]); if (first) { const userCount = state.sortedPeerIds.length; const userLabelText = userCount === 1 ? 'user' : 'users'; - const gain = getPeerListenGainForNickname(first.nickname); + const gain = first.isSelf ? 1 : getPeerListenGainForNickname(first.nickname); + const gainPhrase = first.isSelf + ? 'self volume not adjustable' + : `volume ${formatSteppedNumber(gain, MIC_INPUT_GAIN_STEP)}`; updateStatus( - `${userCount} ${userLabelText}. ${first.nickname}, volume ${formatSteppedNumber(gain, MIC_INPUT_GAIN_STEP)}.`, + `${userCount} ${userLabelText}. ${first.nickname}, ${gainPhrase}.`, ); } } @@ -1867,15 +1894,20 @@ function handleListModeInput(code: string, key: string): void { } if (code === 'ArrowLeft' || code === 'ArrowRight') { - const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); - if (!peer) return; - const current = getPeerListenGainForNickname(peer.nickname); + const entry = getListUserEntry(state.sortedPeerIds[state.listIndex]); + if (!entry) return; + if (entry.isSelf) { + updateStatus('Your own listen volume is not adjustable.'); + audio.sfxUiCancel(); + return; + } + const current = getPeerListenGainForNickname(entry.nickname); const delta = code === 'ArrowRight' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP; const attempted = snapNumberToStep(current + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN); const next = clampMicInputGain(attempted); - setPeerListenGainForNickname(peer.nickname, next); - peerManager.setPeerListenGain(peer.id, next); - updateStatus(`${peer.nickname} volume ${formatSteppedNumber(next, MIC_INPUT_GAIN_STEP)}.`); + setPeerListenGainForNickname(entry.nickname, next); + peerManager.setPeerListenGain(entry.id, next); + updateStatus(`${entry.nickname} volume ${formatSteppedNumber(next, MIC_INPUT_GAIN_STEP)}.`); if (Math.abs(next - current) < 1e-9 || Math.abs(next - attempted) > 1e-9) { audio.sfxUiCancel(); } else { @@ -1884,14 +1916,19 @@ function handleListModeInput(code: string, key: string): void { return; } - const control = handleListControlKey(code, key, state.sortedPeerIds, state.listIndex, (peerId) => state.peers.get(peerId)?.nickname ?? ''); + const control = handleListControlKey(code, key, state.sortedPeerIds, state.listIndex, (peerId) => { + const entry = getListUserEntry(peerId); + return entry?.nickname ?? ''; + }); if (control.type === 'move') { state.listIndex = control.index; - const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); - if (!peer) return; - const gain = getPeerListenGainForNickname(peer.nickname); + const entry = getListUserEntry(state.sortedPeerIds[state.listIndex]); + if (!entry) return; + const gainPhrase = entry.isSelf + ? 'self volume not adjustable' + : `volume ${formatSteppedNumber(getPeerListenGainForNickname(entry.nickname), MIC_INPUT_GAIN_STEP)}`; updateStatus( - `${peer.nickname}, volume ${formatSteppedNumber(gain, MIC_INPUT_GAIN_STEP)}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`, + `${entry.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, entry.x, entry.y)}, ${entry.x}, ${entry.y}`, ); if (control.reason === 'initial') { audio.sfxUiBlip(); @@ -1900,19 +1937,19 @@ function handleListModeInput(code: string, key: string): void { } if (control.type === 'select') { - const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); - if (!peer) return; - if (state.player.x === peer.x && state.player.y === peer.y) { + const entry = getListUserEntry(state.sortedPeerIds[state.listIndex]); + if (!entry) return; + if (state.player.x === entry.x && state.player.y === entry.y) { updateStatus('Already here.'); return; } - state.player.x = peer.x; - state.player.y = peer.y; + state.player.x = entry.x; + state.player.y = entry.y; persistPlayerPosition(); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); - signaling.send({ type: 'update_position', x: peer.x, y: peer.y }); + signaling.send({ type: 'update_position', x: entry.x, y: entry.y }); state.mode = 'normal'; - updateStatus(`Moved to ${peer.nickname}.`); + updateStatus(`Moved to ${entry.nickname}.`); return; }