From 3e321df56ca41a552b8afd52cd3b4d556f200914 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 22 Feb 2026 17:05:36 -0500 Subject: [PATCH] Extract message, item editor, and UI binding modules --- client/src/items/itemPropertyEditor.ts | 346 +++++++++++++ client/src/main.ts | 686 ++++--------------------- client/src/network/messageHandlers.ts | 247 +++++++++ client/src/ui/domBindings.ts | 111 ++++ 4 files changed, 812 insertions(+), 578 deletions(-) create mode 100644 client/src/items/itemPropertyEditor.ts create mode 100644 client/src/network/messageHandlers.ts create mode 100644 client/src/ui/domBindings.ts diff --git a/client/src/items/itemPropertyEditor.ts b/client/src/items/itemPropertyEditor.ts new file mode 100644 index 0000000..46eb8dd --- /dev/null +++ b/client/src/items/itemPropertyEditor.ts @@ -0,0 +1,346 @@ +import { handleListControlKey } from '../input/listController'; +import { getEditSessionAction } from '../input/editSession'; +import { formatSteppedNumber, snapNumberToStep } from '../input/numeric'; +import { type WorldItem } from '../state/gameState'; + +type EditorDeps = { + state: { + mode: string; + selectedItemId: string | null; + editingPropertyKey: string | null; + itemPropertyOptionValues: string[]; + itemPropertyOptionIndex: number; + itemPropertyKeys: string[]; + itemPropertyIndex: number; + nicknameInput: string; + cursorPos: number; + items: Map; + }; + signalingSend: (message: unknown) => void; + getItemPropertyValue: (item: WorldItem, key: string) => string; + itemPropertyLabel: (key: string) => string; + isItemPropertyEditable: (item: WorldItem, key: string) => boolean; + getItemPropertyOptionValues: (key: string) => string[] | undefined; + openItemPropertyOptionSelect: (item: WorldItem, key: string) => void; + describeItemPropertyHelp: (item: WorldItem, key: string) => string; + getItemPropertyMetadata: (itemType: WorldItem['type'], key: string) => { valueType?: string; range?: { min: number; max: number; step?: number } } | undefined; + validateNumericItemPropertyInput: ( + item: WorldItem, + key: string, + rawValue: string, + requireInteger: boolean, + ) => { ok: true; value: number } | { ok: false; message: string }; + clampEffectLevel: (value: number) => number; + effectIds: Set; + effectSequenceIdsCsv: string; + applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean, allowReplaceOnNextType?: boolean) => void; + setReplaceTextOnNextType: (value: boolean) => void; + updateStatus: (message: string) => void; + sfxUiBlip: () => void; + sfxUiCancel: () => void; +}; + +export function createItemPropertyEditor(deps: EditorDeps): { + handleItemPropertiesModeInput: (code: string, key: string) => void; + handleItemPropertyEditModeInput: (code: string, key: string, ctrlKey: boolean) => void; + handleItemPropertyOptionSelectModeInput: (code: string, key: string) => void; +} { + function handleItemPropertiesModeInput(code: string, key: string): void { + const itemId = deps.state.selectedItemId; + if (!itemId) { + deps.state.mode = 'normal'; + deps.state.editingPropertyKey = null; + deps.state.itemPropertyOptionValues = []; + deps.state.itemPropertyOptionIndex = 0; + return; + } + const item = deps.state.items.get(itemId); + if (!item) { + deps.state.mode = 'normal'; + deps.state.editingPropertyKey = null; + deps.state.itemPropertyOptionValues = []; + deps.state.itemPropertyOptionIndex = 0; + deps.updateStatus('Item no longer exists.'); + deps.sfxUiCancel(); + return; + } + const control = handleListControlKey(code, key, deps.state.itemPropertyKeys, deps.state.itemPropertyIndex, (propertyKey) => propertyKey); + if (control.type === 'move') { + deps.state.itemPropertyIndex = control.index; + const selectedKey = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex]; + const value = deps.getItemPropertyValue(item, selectedKey); + deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)}: ${value}`); + deps.sfxUiBlip(); + return; + } + if (code === 'Space') { + const selectedKey = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex]; + deps.updateStatus(deps.describeItemPropertyHelp(item, selectedKey)); + deps.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const selectedKey = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex]; + if (!deps.isItemPropertyEditable(item, selectedKey)) { + deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)} is not editable.`); + deps.sfxUiCancel(); + return; + } + if (selectedKey === 'enabled') { + const nextEnabled = item.params.enabled === false; + deps.signalingSend({ type: 'item_update', itemId, params: { enabled: nextEnabled } }); + deps.updateStatus(`enabled: ${nextEnabled ? 'on' : 'off'}`); + deps.sfxUiBlip(); + return; + } + if (selectedKey === 'directional') { + const nextDirectional = item.params.directional !== true; + deps.signalingSend({ type: 'item_update', itemId, params: { directional: nextDirectional } }); + deps.updateStatus(`directional: ${nextDirectional ? 'on' : 'off'}`); + deps.sfxUiBlip(); + return; + } + if (selectedKey === 'use24Hour') { + const nextUse24Hour = item.params.use24Hour !== true; + deps.signalingSend({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } }); + deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)}: ${nextUse24Hour ? 'on' : 'off'}`); + deps.sfxUiBlip(); + return; + } + if (deps.getItemPropertyOptionValues(selectedKey)) { + deps.openItemPropertyOptionSelect(item, selectedKey); + return; + } + deps.state.mode = 'itemPropertyEdit'; + deps.state.editingPropertyKey = selectedKey; + deps.state.nicknameInput = + selectedKey === 'title' + ? item.title + : selectedKey === 'enabled' + ? item.params.enabled === false + ? 'off' + : 'on' + : String(item.params[selectedKey] ?? ''); + deps.state.cursorPos = deps.state.nicknameInput.length; + deps.setReplaceTextOnNextType(true); + deps.updateStatus(`Edit ${deps.itemPropertyLabel(selectedKey)}: ${deps.state.nicknameInput}`); + deps.sfxUiBlip(); + return; + } + if (control.type === 'cancel') { + deps.state.mode = 'normal'; + deps.state.selectedItemId = null; + deps.state.itemPropertyKeys = []; + deps.state.itemPropertyIndex = 0; + deps.state.editingPropertyKey = null; + deps.state.itemPropertyOptionValues = []; + deps.state.itemPropertyOptionIndex = 0; + deps.updateStatus('Closed item properties.'); + deps.sfxUiCancel(); + } + } + + function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boolean): void { + const itemId = deps.state.selectedItemId; + const propertyKey = deps.state.editingPropertyKey; + if (!itemId || !propertyKey) { + deps.state.mode = 'normal'; + return; + } + const item = deps.state.items.get(itemId); + if (!item) { + deps.state.mode = 'normal'; + deps.state.editingPropertyKey = null; + deps.updateStatus('Item no longer exists.'); + deps.sfxUiCancel(); + return; + } + if (code === 'ArrowUp' || code === 'ArrowDown') { + const metadata = deps.getItemPropertyMetadata(item.type, propertyKey); + if (metadata?.valueType === 'number') { + const range = metadata.range; + const step = range?.step && range.step > 0 ? range.step : 1; + const min = range?.min; + const max = range?.max; + const rawCurrent = Number(deps.state.nicknameInput.trim()); + const paramCurrent = Number(item.params[propertyKey]); + const currentValue = Number.isFinite(rawCurrent) + ? rawCurrent + : Number.isFinite(paramCurrent) + ? paramCurrent + : Number.isFinite(min) + ? min + : 0; + const delta = code === 'ArrowUp' ? step : -step; + const anchor = Number.isFinite(min) ? min : 0; + const attempted = snapNumberToStep(currentValue + delta, step, anchor); + let nextValue = attempted; + if (Number.isFinite(min)) nextValue = Math.max(min, nextValue); + if (Number.isFinite(max)) nextValue = Math.min(max, nextValue); + deps.state.nicknameInput = formatSteppedNumber(nextValue, step); + deps.state.cursorPos = deps.state.nicknameInput.length; + deps.setReplaceTextOnNextType(false); + deps.updateStatus(deps.state.nicknameInput); + if (Math.abs(nextValue - currentValue) < 1e-9 || Math.abs(nextValue - attempted) > 1e-9) { + deps.sfxUiCancel(); + } else { + deps.sfxUiBlip(); + } + return; + } + } + const editAction = getEditSessionAction(code); + if (editAction === 'submit') { + const value = deps.state.nicknameInput.trim(); + const sendItemParams = (params: Record): void => { + deps.signalingSend({ type: 'item_update', itemId, params }); + }; + const parseToggleValue = (raw: string, field: string): { ok: true; value: boolean } | { ok: false } => { + const normalized = raw.toLowerCase(); + if (!['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'].includes(normalized)) { + deps.updateStatus(`${field} must be on or off.`); + deps.sfxUiCancel(); + return { ok: false }; + } + return { ok: true, value: ['on', 'true', '1', 'yes'].includes(normalized) }; + }; + const submitNumericParam = ( + targetKey: string, + requireInteger: boolean, + transform?: (num: number) => number, + ): boolean => { + const parsed = deps.validateNumericItemPropertyInput(item, targetKey, value, requireInteger); + if (!parsed.ok) { + deps.updateStatus(parsed.message); + deps.sfxUiCancel(); + return false; + } + sendItemParams({ [targetKey]: transform ? transform(parsed.value) : parsed.value }); + return true; + }; + if (propertyKey === 'title') { + if (!value) { + deps.updateStatus('Value is required.'); + deps.sfxUiCancel(); + return; + } + deps.signalingSend({ type: 'item_update', itemId, title: value }); + } else if (propertyKey === 'streamUrl') { + sendItemParams({ streamUrl: value }); + } else if (propertyKey === 'enabled' || propertyKey === 'directional') { + const toggle = parseToggleValue(value, propertyKey); + if (!toggle.ok) return; + sendItemParams({ [propertyKey]: toggle.value }); + } else if ( + propertyKey === 'mediaVolume' || + propertyKey === 'emitVolume' || + propertyKey === 'emitSoundSpeed' || + propertyKey === 'emitSoundTempo' || + propertyKey === 'emitRange' || + propertyKey === 'sides' || + propertyKey === 'number' + ) { + if (!submitNumericParam(propertyKey, true)) return; + } else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') { + const normalized = value.trim().toLowerCase(); + if (!deps.effectIds.has(normalized)) { + deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be one of: ${deps.effectSequenceIdsCsv}.`); + deps.sfxUiCancel(); + return; + } + sendItemParams({ [propertyKey]: normalized }); + } else if (propertyKey === 'mediaEffectValue' || propertyKey === 'emitEffectValue') { + if (!submitNumericParam(propertyKey, false, (num) => deps.clampEffectLevel(num))) return; + } else if (propertyKey === 'facing') { + if (!submitNumericParam(propertyKey, false)) return; + } else if (propertyKey === 'useSound' || propertyKey === 'emitSound') { + sendItemParams({ [propertyKey]: value }); + } else if (propertyKey === 'spaces') { + const spaces = value + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0); + if (spaces.length === 0) { + deps.updateStatus('spaces must include at least one comma-delimited value.'); + deps.sfxUiCancel(); + return; + } + if (spaces.length > 100) { + deps.updateStatus('spaces supports up to 100 values.'); + deps.sfxUiCancel(); + return; + } + if (spaces.some((token) => token.length > 80)) { + deps.updateStatus('each space must be 80 chars or less.'); + deps.sfxUiCancel(); + return; + } + sendItemParams({ spaces: spaces.join(', ') }); + } + deps.state.mode = 'itemProperties'; + deps.state.editingPropertyKey = null; + deps.setReplaceTextOnNextType(false); + return; + } + if (editAction === 'cancel') { + deps.state.mode = 'itemProperties'; + deps.state.editingPropertyKey = null; + deps.setReplaceTextOnNextType(false); + deps.updateStatus('Cancelled.'); + deps.sfxUiCancel(); + return; + } + deps.applyTextInputEdit(code, key, 500, ctrlKey, true); + } + + function handleItemPropertyOptionSelectModeInput(code: string, key: string): void { + const itemId = deps.state.selectedItemId; + const propertyKey = deps.state.editingPropertyKey; + if (!itemId || !propertyKey || deps.state.itemPropertyOptionValues.length === 0) { + deps.state.mode = 'itemProperties'; + deps.state.editingPropertyKey = null; + deps.state.itemPropertyOptionValues = []; + deps.state.itemPropertyOptionIndex = 0; + return; + } + + const control = handleListControlKey( + code, + key, + deps.state.itemPropertyOptionValues, + deps.state.itemPropertyOptionIndex, + (value) => value, + ); + if (control.type === 'move') { + deps.state.itemPropertyOptionIndex = control.index; + deps.updateStatus(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]); + deps.sfxUiBlip(); + return; + } + + if (control.type === 'select') { + const selectedValue = deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]; + deps.signalingSend({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } }); + deps.state.mode = 'itemProperties'; + deps.state.editingPropertyKey = null; + deps.state.itemPropertyOptionValues = []; + deps.state.itemPropertyOptionIndex = 0; + return; + } + + if (control.type === 'cancel') { + deps.state.mode = 'itemProperties'; + deps.state.editingPropertyKey = null; + deps.state.itemPropertyOptionValues = []; + deps.state.itemPropertyOptionIndex = 0; + deps.updateStatus('Cancelled.'); + deps.sfxUiCancel(); + } + } + + return { + handleItemPropertiesModeInput, + handleItemPropertyEditModeInput, + handleItemPropertyOptionSelectModeInput, + }; +} diff --git a/client/src/main.ts b/client/src/main.ts index 0664a61..1955e34 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -33,6 +33,7 @@ import { handleListControlKey } from './input/listController'; import { getEditSessionAction } from './input/editSession'; import { formatSteppedNumber, snapNumberToStep } from './input/numeric'; import { type IncomingMessage, type OutgoingMessage } from './network/protocol'; +import { createOnMessageHandler } from './network/messageHandlers'; import { SignalingClient } from './network/signalingClient'; import { CanvasRenderer } from './render/canvasRenderer'; import { @@ -57,6 +58,8 @@ import { getItemTypeTooltip, itemTypeLabel, } from './items/itemRegistry'; +import { createItemPropertyEditor } from './items/itemPropertyEditor'; +import { setupUiHandlers as setupDomUiHandlers } from './ui/domBindings'; import { PeerManager } from './webrtc/peerManager'; const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels'; @@ -1236,195 +1239,56 @@ function disconnect(): void { } } -async function onMessage(message: IncomingMessage): Promise { - switch (message.type) { - case 'welcome': - if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) { - worldGridSize = message.worldConfig.gridSize; - } - renderer.setGridSize(worldGridSize); - applyServerItemUiDefinitions(message.uiDefinitions); - state.addItemTypeIndex = 0; - state.player.id = message.id; - state.running = true; - connecting = false; - state.player.x = Math.max(0, Math.min(worldGridSize - 1, state.player.x)); - state.player.y = Math.max(0, Math.min(worldGridSize - 1, state.player.y)); - dom.nicknameContainer.classList.add('hidden'); - dom.connectButton.classList.add('hidden'); - dom.disconnectButton.classList.remove('hidden'); - dom.focusGridButton.classList.remove('hidden'); - dom.canvas.classList.remove('hidden'); - dom.instructions.classList.remove('hidden'); - dom.canvas.focus(); - - signaling.send({ type: 'update_position', x: state.player.x, y: state.player.y }); - signaling.send({ type: 'update_nickname', nickname: state.player.nickname }); - - for (const user of message.users) { - state.peers.set(user.id, { ...user }); - await peerManager.createOrGetPeer(user.id, true, user); - } - state.items.clear(); - for (const item of message.items || []) { - state.items.set(item.id, { - ...item, - carrierId: item.carrierId ?? null, - }); - } - await radioRuntime.sync(state.items.values()); - await itemEmitRuntime.sync(state.items.values()); - await applyAudioLayerState(); - - gameLoop(); - break; - - case 'signal': { - const peer = await peerManager.handleSignal(message); - if (!state.peers.has(peer.id)) { - state.peers.set(peer.id, { - id: peer.id, - nickname: sanitizeName(peer.nickname) || 'user...', - x: peer.x, - y: peer.y, - }); - } - break; - } - - case 'update_position': { - const peer = state.peers.get(message.id); - const prevX = peer?.x ?? message.x; - const prevY = peer?.y ?? message.y; - if (peer) { - peer.x = message.x; - peer.y = message.y; - } - 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 ? TELEPORT_SOUND_URL : randomFootstepUrl(); - if (audioLayers.world) { - void audio.playSpatialSample( - soundUrl, - { x: peer.x - state.player.x, y: peer.y - state.player.y }, - FOOTSTEP_GAIN, - ); - } - } - break; - } - - case 'update_nickname': { - const peer = state.peers.get(message.id); - if (peer) { - peer.nickname = sanitizeName(message.nickname) || 'user...'; - } - peerManager.setPeerNickname(message.id, sanitizeName(message.nickname) || 'user...'); - break; - } - - case 'user_left': { - const peer = state.peers.get(message.id); - if (peer) { - updateStatus(`${peer.nickname} has left.`); - } - state.peers.delete(message.id); - peerManager.removePeer(message.id); - break; - } - - case 'chat_message': { - if (message.system) { - pushChatMessage(message.message); - const sound = classifySystemMessageSound(message.message); - if (sound) { - void audio.playSample(SYSTEM_SOUND_URLS[sound], 1); - } - } else { - const sender = message.senderNickname || 'Unknown'; - pushChatMessage(`${sender}: ${message.message}`); - } - break; - } - - case 'pong': { - const elapsed = Math.max(0, Date.now() - message.clientSentAt); - updateStatus(`Ping ${elapsed} ms`); - audio.sfxUiBlip(); - break; - } - - case 'nickname_result': { - state.player.nickname = sanitizeName(message.effectiveNickname) || 'user...'; - if (message.accepted) { - dom.preconnectNickname.value = state.player.nickname; - localStorage.setItem(NICKNAME_STORAGE_KEY, state.player.nickname); - } else { - pushChatMessage(message.reason || 'Nickname unavailable.'); - audio.sfxUiCancel(); - } - break; - } - - case 'item_upsert': { - state.items.set(message.item.id, { - ...message.item, - carrierId: message.item.carrierId ?? null, - }); - state.carriedItemId = getCarriedItem()?.id ?? null; - if (state.mode === 'itemProperties' && state.selectedItemId === message.item.id) { - const key = state.itemPropertyKeys[state.itemPropertyIndex]; - if (key) { - updateStatus(`${itemPropertyLabel(key)}: ${getItemPropertyValue(message.item, key)}`); - } - } - await radioRuntime.sync(state.items.values()); - await itemEmitRuntime.sync(state.items.values()); - break; - } - - case 'item_remove': { - state.items.delete(message.itemId); - state.carriedItemId = getCarriedItem()?.id ?? null; - radioRuntime.cleanup(message.itemId); - itemEmitRuntime.cleanup(message.itemId); - break; - } - - case 'item_action_result': { - if (message.ok) { - if (message.action === 'use') { - pushChatMessage(message.message); - const item = message.itemId ? state.items.get(message.itemId) : null; - if (!item?.useSound && item) { - audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); - } - } else if (message.action !== 'update') { - pushChatMessage(message.message); - audio.sfxUiConfirm(); - } - } else { - pushChatMessage(message.message); - audio.sfxUiCancel(); - } - break; - } - - case 'item_use_sound': { - const soundUrl = resolveIncomingSoundUrl(message.sound); - if (!soundUrl) break; - if (audioLayers.world) { - void audio.playSpatialSample( - soundUrl, - { x: message.x - state.player.x, y: message.y - state.player.y }, - 1, - ); - } - break; - } - } -} +const onMessage = createOnMessageHandler({ + getWorldGridSize: () => worldGridSize, + setWorldGridSize: (size) => { + worldGridSize = size; + }, + setConnecting: (value) => { + connecting = value; + }, + rendererSetGridSize: (size) => renderer.setGridSize(size), + applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters[0]), + state, + dom, + signalingSend: (message) => signaling.send(message as OutgoingMessage), + peerManager, + radioRuntime, + itemEmitRuntime, + applyAudioLayerState, + gameLoop, + sanitizeName, + randomFootstepUrl, + playRemoteSpatialStepOrTeleport: (url, peerX, peerY) => { + void audio.playSpatialSample( + url, + { x: peerX - state.player.x, y: peerY - state.player.y }, + FOOTSTEP_GAIN, + ); + }, + TELEPORT_SOUND_URL, + audioLayers, + pushChatMessage, + classifySystemMessageSound, + SYSTEM_SOUND_URLS, + playSample: (url, gain = 1) => { + void audio.playSample(url, gain); + }, + updateStatus, + audioUiBlip: () => audio.sfxUiBlip(), + audioUiConfirm: () => audio.sfxUiConfirm(), + audioUiCancel: () => audio.sfxUiCancel(), + NICKNAME_STORAGE_KEY, + getCarriedItemId: () => getCarriedItem()?.id ?? null, + itemPropertyLabel, + getItemPropertyValue, + getItemById: (itemId) => state.items.get(itemId), + playLocateToneAt: (x, y) => audio.sfxLocate({ x: x - state.player.x, y: y - state.player.y }), + resolveIncomingSoundUrl, + playIncomingItemUseSound: (url, x, y) => { + void audio.playSpatialSample(url, { x: x - state.player.x, y: y - state.player.y }, 1); + }, +}); function toggleMute(): void { state.isMuted = !state.isMuted; @@ -2076,306 +1940,28 @@ function handleSelectItemModeInput(code: string, key: string): void { } } -function handleItemPropertiesModeInput(code: string, key: string): void { - const itemId = state.selectedItemId; - if (!itemId) { - state.mode = 'normal'; - state.editingPropertyKey = null; - state.itemPropertyOptionValues = []; - state.itemPropertyOptionIndex = 0; - return; - } - const item = state.items.get(itemId); - if (!item) { - state.mode = 'normal'; - state.editingPropertyKey = null; - state.itemPropertyOptionValues = []; - state.itemPropertyOptionIndex = 0; - updateStatus('Item no longer exists.'); - audio.sfxUiCancel(); - return; - } - const control = handleListControlKey(code, key, state.itemPropertyKeys, state.itemPropertyIndex, (propertyKey) => propertyKey); - if (control.type === 'move') { - state.itemPropertyIndex = control.index; - const selectedKey = state.itemPropertyKeys[state.itemPropertyIndex]; - const value = getItemPropertyValue(item, selectedKey); - updateStatus(`${itemPropertyLabel(selectedKey)}: ${value}`); - audio.sfxUiBlip(); - return; - } - if (code === 'Space') { - const selectedKey = state.itemPropertyKeys[state.itemPropertyIndex]; - updateStatus(describeItemPropertyHelp(item, selectedKey)); - audio.sfxUiBlip(); - return; - } - if (control.type === 'select') { - const key = state.itemPropertyKeys[state.itemPropertyIndex]; - if (!isItemPropertyEditable(item, key)) { - updateStatus(`${itemPropertyLabel(key)} is not editable.`); - audio.sfxUiCancel(); - return; - } - if (key === 'enabled') { - const nextEnabled = item.params.enabled === false; - signaling.send({ type: 'item_update', itemId, params: { enabled: nextEnabled } }); - updateStatus(`enabled: ${nextEnabled ? 'on' : 'off'}`); - audio.sfxUiBlip(); - return; - } - if (key === 'directional') { - const nextDirectional = item.params.directional !== true; - signaling.send({ type: 'item_update', itemId, params: { directional: nextDirectional } }); - updateStatus(`directional: ${nextDirectional ? 'on' : 'off'}`); - audio.sfxUiBlip(); - return; - } - if (key === 'use24Hour') { - const nextUse24Hour = item.params.use24Hour !== true; - signaling.send({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } }); - updateStatus(`${itemPropertyLabel(key)}: ${nextUse24Hour ? 'on' : 'off'}`); - audio.sfxUiBlip(); - return; - } - if (getItemPropertyOptionValues(key)) { - openItemPropertyOptionSelect(item, key); - return; - } - state.mode = 'itemPropertyEdit'; - state.editingPropertyKey = key; - state.nicknameInput = - key === 'title' - ? item.title - : key === 'enabled' - ? item.params.enabled === false - ? 'off' - : 'on' - : String(item.params[key] ?? ''); - state.cursorPos = state.nicknameInput.length; - replaceTextOnNextType = true; - updateStatus(`Edit ${itemPropertyLabel(key)}: ${state.nicknameInput}`); - audio.sfxUiBlip(); - return; - } - if (control.type === 'cancel') { - state.mode = 'normal'; - state.selectedItemId = null; - state.itemPropertyKeys = []; - state.itemPropertyIndex = 0; - state.editingPropertyKey = null; - state.itemPropertyOptionValues = []; - state.itemPropertyOptionIndex = 0; - updateStatus('Closed item properties.'); - audio.sfxUiCancel(); - } -} - -function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boolean): void { - const itemId = state.selectedItemId; - const propertyKey = state.editingPropertyKey; - if (!itemId || !propertyKey) { - state.mode = 'normal'; - return; - } - const item = state.items.get(itemId); - if (!item) { - state.mode = 'normal'; - state.editingPropertyKey = null; - updateStatus('Item no longer exists.'); - audio.sfxUiCancel(); - return; - } - if (code === 'ArrowUp' || code === 'ArrowDown') { - const metadata = getItemPropertyMetadata(item.type, propertyKey); - if (metadata?.valueType === 'number') { - const range = metadata.range; - const step = range?.step && range.step > 0 ? range.step : 1; - const min = range?.min; - const max = range?.max; - const rawCurrent = Number(state.nicknameInput.trim()); - const paramCurrent = Number(item.params[propertyKey]); - const currentValue = Number.isFinite(rawCurrent) - ? rawCurrent - : Number.isFinite(paramCurrent) - ? paramCurrent - : Number.isFinite(min) - ? min - : 0; - const delta = code === 'ArrowUp' ? step : -step; - const anchor = Number.isFinite(min) ? min : 0; - const attempted = snapNumberToStep(currentValue + delta, step, anchor); - let nextValue = attempted; - if (Number.isFinite(min)) nextValue = Math.max(min, nextValue); - if (Number.isFinite(max)) nextValue = Math.min(max, nextValue); - state.nicknameInput = formatSteppedNumber(nextValue, step); - state.cursorPos = state.nicknameInput.length; - replaceTextOnNextType = false; - updateStatus(state.nicknameInput); - if (Math.abs(nextValue - currentValue) < 1e-9 || Math.abs(nextValue - attempted) > 1e-9) { - audio.sfxUiCancel(); - } else { - audio.sfxUiBlip(); - } - return; - } - } - const editAction = getEditSessionAction(code); - if (editAction === 'submit') { - const value = state.nicknameInput.trim(); - const sendItemParams = (params: Record): void => { - signaling.send({ type: 'item_update', itemId, params }); - }; - const parseToggleValue = (raw: string, field: string): { ok: true; value: boolean } | { ok: false } => { - const normalized = raw.toLowerCase(); - if (!['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'].includes(normalized)) { - updateStatus(`${field} must be on or off.`); - audio.sfxUiCancel(); - return { ok: false }; - } - return { ok: true, value: ['on', 'true', '1', 'yes'].includes(normalized) }; - }; - const submitNumericParam = ( - targetKey: string, - requireInteger: boolean, - transform?: (num: number) => number, - ): boolean => { - const parsed = validateNumericItemPropertyInput(item, targetKey, value, requireInteger); - if (!parsed.ok) { - updateStatus(parsed.message); - audio.sfxUiCancel(); - return false; - } - sendItemParams({ [targetKey]: transform ? transform(parsed.value) : parsed.value }); - return true; - }; - if (propertyKey === 'title') { - if (!value) { - updateStatus('Value is required.'); - audio.sfxUiCancel(); - return; - } - signaling.send({ type: 'item_update', itemId, title: value }); - } else if (propertyKey === 'streamUrl') { - sendItemParams({ streamUrl: value }); - } else if (propertyKey === 'enabled' || propertyKey === 'directional') { - const toggle = parseToggleValue(value, propertyKey); - if (!toggle.ok) { - return; - } - sendItemParams({ [propertyKey]: toggle.value }); - } else if ( - propertyKey === 'mediaVolume' || - propertyKey === 'emitVolume' || - propertyKey === 'emitSoundSpeed' || - propertyKey === 'emitSoundTempo' || - propertyKey === 'emitRange' || - propertyKey === 'sides' || - propertyKey === 'number' - ) { - if (!submitNumericParam(propertyKey, true)) { - return; - } - } else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') { - const normalized = value.trim().toLowerCase() as EffectId; - if (!EFFECT_IDS.has(normalized)) { - updateStatus(`${itemPropertyLabel(propertyKey)} must be one of: ${EFFECT_SEQUENCE.map((effect) => effect.id).join(', ')}.`); - audio.sfxUiCancel(); - return; - } - sendItemParams({ [propertyKey]: normalized }); - } else if (propertyKey === 'mediaEffectValue' || propertyKey === 'emitEffectValue') { - if (!submitNumericParam(propertyKey, false, (num) => clampEffectLevel(num))) { - return; - } - } else if (propertyKey === 'facing') { - if (!submitNumericParam(propertyKey, false)) { - return; - } - } else if (propertyKey === 'useSound' || propertyKey === 'emitSound') { - sendItemParams({ [propertyKey]: value }); - } else if (propertyKey === 'spaces') { - const spaces = value - .split(',') - .map((token) => token.trim()) - .filter((token) => token.length > 0); - if (spaces.length === 0) { - updateStatus('spaces must include at least one comma-delimited value.'); - audio.sfxUiCancel(); - return; - } - if (spaces.length > 100) { - updateStatus('spaces supports up to 100 values.'); - audio.sfxUiCancel(); - return; - } - if (spaces.some((token) => token.length > 80)) { - updateStatus('each space must be 80 chars or less.'); - audio.sfxUiCancel(); - return; - } - sendItemParams({ spaces: spaces.join(', ') }); - } - state.mode = 'itemProperties'; - state.editingPropertyKey = null; - replaceTextOnNextType = false; - return; - } - if (editAction === 'cancel') { - state.mode = 'itemProperties'; - state.editingPropertyKey = null; - replaceTextOnNextType = false; - updateStatus('Cancelled.'); - audio.sfxUiCancel(); - return; - } - applyTextInputEdit(code, key, 500, ctrlKey, true); -} - -function handleItemPropertyOptionSelectModeInput(code: string, key: string): void { - const itemId = state.selectedItemId; - const propertyKey = state.editingPropertyKey; - if (!itemId || !propertyKey || state.itemPropertyOptionValues.length === 0) { - state.mode = 'itemProperties'; - state.editingPropertyKey = null; - state.itemPropertyOptionValues = []; - state.itemPropertyOptionIndex = 0; - return; - } - - const control = handleListControlKey( - code, - key, - state.itemPropertyOptionValues, - state.itemPropertyOptionIndex, - (value) => value, - ); - if (control.type === 'move') { - state.itemPropertyOptionIndex = control.index; - updateStatus(state.itemPropertyOptionValues[state.itemPropertyOptionIndex]); - audio.sfxUiBlip(); - return; - } - - if (control.type === 'select') { - const selectedValue = state.itemPropertyOptionValues[state.itemPropertyOptionIndex]; - signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } }); - state.mode = 'itemProperties'; - state.editingPropertyKey = null; - state.itemPropertyOptionValues = []; - state.itemPropertyOptionIndex = 0; - return; - } - - if (control.type === 'cancel') { - state.mode = 'itemProperties'; - state.editingPropertyKey = null; - state.itemPropertyOptionValues = []; - state.itemPropertyOptionIndex = 0; - updateStatus('Cancelled.'); - audio.sfxUiCancel(); - } -} +const itemPropertyEditor = createItemPropertyEditor({ + state, + signalingSend: (message) => signaling.send(message as OutgoingMessage), + getItemPropertyValue, + itemPropertyLabel, + isItemPropertyEditable, + getItemPropertyOptionValues, + openItemPropertyOptionSelect, + describeItemPropertyHelp, + getItemPropertyMetadata, + validateNumericItemPropertyInput, + clampEffectLevel, + effectIds: EFFECT_IDS as Set, + effectSequenceIdsCsv: EFFECT_SEQUENCE.map((effect) => effect.id).join(', '), + applyTextInputEdit, + setReplaceTextOnNextType: (value) => { + replaceTextOnNextType = value; + }, + updateStatus, + sfxUiBlip: () => audio.sfxUiBlip(), + sfxUiCancel: () => audio.sfxUiCancel(), +}); function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void { const editAction = getEditSessionAction(code); @@ -2472,11 +2058,11 @@ function setupInputHandlers(): void { } else if (state.mode === 'selectItem') { handleSelectItemModeInput(code, event.key); } else if (state.mode === 'itemProperties') { - handleItemPropertiesModeInput(code, event.key); + itemPropertyEditor.handleItemPropertiesModeInput(code, event.key); } else if (state.mode === 'itemPropertyEdit') { - handleItemPropertyEditModeInput(code, event.key, event.ctrlKey); + itemPropertyEditor.handleItemPropertyEditModeInput(code, event.key, event.ctrlKey); } else if (state.mode === 'itemPropertyOptionSelect') { - handleItemPropertyOptionSelectModeInput(code, event.key); + itemPropertyEditor.handleItemPropertyOptionSelectModeInput(code, event.key); } else { handleNormalModeInput(code, event.shiftKey); } @@ -2564,92 +2150,36 @@ function closeSettings(): void { } function setupUiHandlers(): void { - const persistOnUnload = (): void => { - if (!state.running) return; - persistPlayerPosition(); - }; - window.addEventListener('pagehide', persistOnUnload); - window.addEventListener('beforeunload', persistOnUnload); - - dom.connectButton.addEventListener('click', () => { - void connect(); - }); - dom.preconnectNickname.addEventListener('input', () => { - updateConnectAvailability(); - }); - dom.preconnectNickname.addEventListener('change', () => { - const clean = sanitizeName(dom.preconnectNickname.value); - dom.preconnectNickname.value = clean; - if (clean) { - localStorage.setItem(NICKNAME_STORAGE_KEY, clean); - } else { - localStorage.removeItem(NICKNAME_STORAGE_KEY); - } - updateConnectAvailability(); - }); - dom.preconnectNickname.addEventListener('keydown', (event) => { - if (event.key === 'Enter' && !dom.connectButton.disabled) { - event.preventDefault(); - void connect(); - } - }); - - dom.disconnectButton.addEventListener('click', () => { - disconnect(); - }); - - dom.focusGridButton.addEventListener('click', () => { - dom.canvas.focus(); - updateStatus('Chat Grid focused.'); - audio.sfxUiBlip(); - }); - - dom.settingsButton.addEventListener('click', () => { - openSettings(); - }); - - dom.closeSettingsButton.addEventListener('click', () => { - closeSettings(); - }); - - dom.audioInputSelect.addEventListener('change', (event) => { - const target = event.target as HTMLSelectElement; - if (!target.value) return; - preferredInputDeviceId = target.value; - preferredInputDeviceName = target.selectedOptions[0]?.text || preferredInputDeviceName; - localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId); - localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName); - updateDeviceSummary(); - void setupLocalMedia(target.value); - }); - - dom.audioOutputSelect.addEventListener('change', (event) => { - const target = event.target as HTMLSelectElement; - preferredOutputDeviceId = target.value; - preferredOutputDeviceName = target.selectedOptions[0]?.text || preferredOutputDeviceName; - localStorage.setItem(AUDIO_OUTPUT_STORAGE_KEY, preferredOutputDeviceId); - localStorage.setItem(AUDIO_OUTPUT_NAME_STORAGE_KEY, preferredOutputDeviceName); - updateDeviceSummary(); - void peerManager.setOutputDevice(preferredOutputDeviceId); - }); - - dom.settingsModal.addEventListener('keydown', (event) => { - if (event.key !== 'Tab') return; - const focusable = Array.from(dom.settingsModal.querySelectorAll('select, button')); - if (focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (event.shiftKey && document.activeElement === first) { - last.focus(); - event.preventDefault(); - return; - } - - if (!event.shiftKey && document.activeElement === last) { - first.focus(); - event.preventDefault(); - } + setupDomUiHandlers({ + dom, + sanitizeName, + nicknameStorageKey: NICKNAME_STORAGE_KEY, + updateConnectAvailability, + connect, + disconnect, + openSettings, + closeSettings, + updateStatus, + sfxUiBlip: () => audio.sfxUiBlip(), + setupLocalMedia, + setPreferredInput: (id, name) => { + preferredInputDeviceId = id; + preferredInputDeviceName = name || preferredInputDeviceName; + localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId); + localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName); + }, + setPreferredOutput: (id, name) => { + preferredOutputDeviceId = id; + preferredOutputDeviceName = name || preferredOutputDeviceName; + localStorage.setItem(AUDIO_OUTPUT_STORAGE_KEY, preferredOutputDeviceId); + localStorage.setItem(AUDIO_OUTPUT_NAME_STORAGE_KEY, preferredOutputDeviceName); + }, + updateDeviceSummary, + setOutputDevice: (id) => peerManager.setOutputDevice(id), + persistOnUnload: () => { + if (!state.running) return; + persistPlayerPosition(); + }, }); } diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts new file mode 100644 index 0000000..6e2d4f5 --- /dev/null +++ b/client/src/network/messageHandlers.ts @@ -0,0 +1,247 @@ +import { type IncomingMessage } from './protocol'; +import { type WorldItem } from '../state/gameState'; + +type MessageHandlerDeps = { + getWorldGridSize: () => number; + setWorldGridSize: (size: number) => void; + setConnecting: (value: boolean) => void; + rendererSetGridSize: (size: number) => void; + applyServerItemUiDefinitions: (defs: unknown) => void; + state: { + addItemTypeIndex: number; + player: { id: string | null; nickname: string; x: number; y: number }; + running: boolean; + peers: Map; + items: Map; + mode: string; + selectedItemId: string | null; + itemPropertyKeys: string[]; + itemPropertyIndex: number; + carriedItemId: string | null; + }; + dom: { + nicknameContainer: HTMLElement; + connectButton: HTMLElement; + disconnectButton: HTMLElement; + focusGridButton: HTMLElement; + canvas: HTMLCanvasElement; + instructions: HTMLElement; + preconnectNickname: HTMLInputElement; + }; + signalingSend: (message: unknown) => void; + peerManager: { + createOrGetPeer: (id: string, initiator: boolean, user: { id: string; nickname: string; x: number; y: number }) => Promise; + handleSignal: (message: IncomingMessage) => Promise<{ id: string; nickname: string; x: number; y: number }>; + setPeerPosition: (id: string, x: number, y: number) => void; + setPeerNickname: (id: string, nickname: string) => void; + removePeer: (id: string) => void; + }; + radioRuntime: { sync: (items: Iterable) => Promise; cleanup: (itemId: string) => void }; + itemEmitRuntime: { sync: (items: Iterable) => Promise; cleanup: (itemId: string) => void }; + applyAudioLayerState: () => Promise; + gameLoop: () => void; + sanitizeName: (value: string) => string; + randomFootstepUrl: () => string; + playRemoteSpatialStepOrTeleport: (url: string, peerX: number, peerY: number) => void; + TELEPORT_SOUND_URL: string; + audioLayers: { world: boolean }; + pushChatMessage: (message: string) => void; + classifySystemMessageSound: (message: string) => 'logon' | 'logout' | 'notify' | null; + SYSTEM_SOUND_URLS: { logon: string; logout: string; notify: string }; + playSample: (url: string, gain?: number) => void; + updateStatus: (message: string) => void; + audioUiBlip: () => void; + audioUiConfirm: () => void; + audioUiCancel: () => void; + NICKNAME_STORAGE_KEY: string; + getCarriedItemId: () => string | null; + itemPropertyLabel: (key: string) => string; + getItemPropertyValue: (item: WorldItem, key: string) => string; + getItemById: (itemId: string) => WorldItem | undefined; + playLocateToneAt: (x: number, y: number) => void; + resolveIncomingSoundUrl: (url: string) => string; + playIncomingItemUseSound: (url: string, x: number, y: number) => void; +}; + +export function createOnMessageHandler(deps: MessageHandlerDeps): (message: IncomingMessage) => Promise { + return async function onMessage(message: IncomingMessage): Promise { + switch (message.type) { + case 'welcome': + if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) { + deps.setWorldGridSize(message.worldConfig.gridSize); + } + deps.rendererSetGridSize(deps.getWorldGridSize()); + deps.applyServerItemUiDefinitions(message.uiDefinitions); + deps.state.addItemTypeIndex = 0; + deps.state.player.id = message.id; + deps.state.running = true; + deps.setConnecting(false); + deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.x)); + deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.y)); + deps.dom.nicknameContainer.classList.add('hidden'); + deps.dom.connectButton.classList.add('hidden'); + deps.dom.disconnectButton.classList.remove('hidden'); + deps.dom.focusGridButton.classList.remove('hidden'); + deps.dom.canvas.classList.remove('hidden'); + deps.dom.instructions.classList.remove('hidden'); + deps.dom.canvas.focus(); + + deps.signalingSend({ type: 'update_position', x: deps.state.player.x, y: deps.state.player.y }); + deps.signalingSend({ type: 'update_nickname', nickname: deps.state.player.nickname }); + + for (const user of message.users) { + deps.state.peers.set(user.id, { ...user }); + await deps.peerManager.createOrGetPeer(user.id, true, user); + } + deps.state.items.clear(); + for (const item of message.items || []) { + deps.state.items.set(item.id, { + ...item, + carrierId: item.carrierId ?? null, + }); + } + await deps.radioRuntime.sync(deps.state.items.values()); + await deps.itemEmitRuntime.sync(deps.state.items.values()); + await deps.applyAudioLayerState(); + deps.gameLoop(); + break; + + case 'signal': { + const peer = await deps.peerManager.handleSignal(message); + if (!deps.state.peers.has(peer.id)) { + deps.state.peers.set(peer.id, { + id: peer.id, + nickname: deps.sanitizeName(peer.nickname) || 'user...', + x: peer.x, + y: peer.y, + }); + } + break; + } + + case 'update_position': { + const peer = deps.state.peers.get(message.id); + const prevX = peer?.x ?? message.x; + const prevY = peer?.y ?? message.y; + if (peer) { + peer.x = message.x; + peer.y = message.y; + } + 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(); + if (deps.audioLayers.world) { + deps.playRemoteSpatialStepOrTeleport(soundUrl, peer.x, peer.y); + } + } + break; + } + + case 'update_nickname': { + const peer = deps.state.peers.get(message.id); + if (peer) { + peer.nickname = deps.sanitizeName(message.nickname) || 'user...'; + } + deps.peerManager.setPeerNickname(message.id, deps.sanitizeName(message.nickname) || 'user...'); + break; + } + + case 'user_left': { + const peer = deps.state.peers.get(message.id); + if (peer) { + deps.updateStatus(`${peer.nickname} has left.`); + } + deps.state.peers.delete(message.id); + deps.peerManager.removePeer(message.id); + break; + } + + case 'chat_message': { + if (message.system) { + deps.pushChatMessage(message.message); + const sound = deps.classifySystemMessageSound(message.message); + if (sound) { + deps.playSample(deps.SYSTEM_SOUND_URLS[sound], 1); + } + } else { + const sender = message.senderNickname || 'Unknown'; + deps.pushChatMessage(`${sender}: ${message.message}`); + } + break; + } + + case 'pong': { + const elapsed = Math.max(0, Date.now() - message.clientSentAt); + deps.updateStatus(`Ping ${elapsed} ms`); + deps.audioUiBlip(); + break; + } + + case 'nickname_result': { + deps.state.player.nickname = deps.sanitizeName(message.effectiveNickname) || 'user...'; + if (message.accepted) { + deps.dom.preconnectNickname.value = deps.state.player.nickname; + localStorage.setItem(deps.NICKNAME_STORAGE_KEY, deps.state.player.nickname); + } else { + deps.pushChatMessage(message.reason || 'Nickname unavailable.'); + deps.audioUiCancel(); + } + break; + } + + case 'item_upsert': { + deps.state.items.set(message.item.id, { + ...message.item, + carrierId: message.item.carrierId ?? null, + }); + deps.state.carriedItemId = deps.getCarriedItemId(); + if (deps.state.mode === 'itemProperties' && deps.state.selectedItemId === message.item.id) { + const key = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex]; + if (key) { + deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`); + } + } + await deps.radioRuntime.sync(deps.state.items.values()); + await deps.itemEmitRuntime.sync(deps.state.items.values()); + break; + } + + case 'item_remove': { + deps.state.items.delete(message.itemId); + deps.state.carriedItemId = deps.getCarriedItemId(); + deps.radioRuntime.cleanup(message.itemId); + deps.itemEmitRuntime.cleanup(message.itemId); + break; + } + + case 'item_action_result': { + if (message.ok) { + if (message.action === 'use') { + deps.pushChatMessage(message.message); + const item = message.itemId ? deps.getItemById(message.itemId) : null; + if (!item?.useSound && item) { + deps.playLocateToneAt(item.x, item.y); + } + } else if (message.action !== 'update') { + deps.pushChatMessage(message.message); + deps.audioUiConfirm(); + } + } else { + deps.pushChatMessage(message.message); + deps.audioUiCancel(); + } + break; + } + + case 'item_use_sound': { + const soundUrl = deps.resolveIncomingSoundUrl(message.sound); + if (!soundUrl) break; + if (deps.audioLayers.world) { + deps.playIncomingItemUseSound(soundUrl, message.x, message.y); + } + break; + } + } + }; +} diff --git a/client/src/ui/domBindings.ts b/client/src/ui/domBindings.ts new file mode 100644 index 0000000..8514f70 --- /dev/null +++ b/client/src/ui/domBindings.ts @@ -0,0 +1,111 @@ +type UiDom = { + connectButton: HTMLButtonElement; + preconnectNickname: HTMLInputElement; + disconnectButton: HTMLButtonElement; + focusGridButton: HTMLButtonElement; + settingsButton: HTMLButtonElement; + closeSettingsButton: HTMLButtonElement; + audioInputSelect: HTMLSelectElement; + audioOutputSelect: HTMLSelectElement; + settingsModal: HTMLDivElement; + canvas: HTMLCanvasElement; +}; + +type UiBindingsDeps = { + dom: UiDom; + sanitizeName: (value: string) => string; + nicknameStorageKey: string; + updateConnectAvailability: () => void; + connect: () => Promise; + disconnect: () => void; + openSettings: () => void; + closeSettings: () => void; + updateStatus: (message: string) => void; + sfxUiBlip: () => void; + setupLocalMedia: (audioDeviceId: string) => Promise; + setPreferredInput: (id: string, name: string) => void; + setPreferredOutput: (id: string, name: string) => void; + updateDeviceSummary: () => void; + setOutputDevice: (id: string) => Promise; + persistOnUnload: () => void; +}; + +export function setupUiHandlers(deps: UiBindingsDeps): void { + window.addEventListener('pagehide', deps.persistOnUnload); + window.addEventListener('beforeunload', deps.persistOnUnload); + + deps.dom.connectButton.addEventListener('click', () => { + void deps.connect(); + }); + deps.dom.preconnectNickname.addEventListener('input', () => { + deps.updateConnectAvailability(); + }); + deps.dom.preconnectNickname.addEventListener('change', () => { + const clean = deps.sanitizeName(deps.dom.preconnectNickname.value); + deps.dom.preconnectNickname.value = clean; + if (clean) { + localStorage.setItem(deps.nicknameStorageKey, clean); + } else { + localStorage.removeItem(deps.nicknameStorageKey); + } + deps.updateConnectAvailability(); + }); + deps.dom.preconnectNickname.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !deps.dom.connectButton.disabled) { + event.preventDefault(); + void deps.connect(); + } + }); + + deps.dom.disconnectButton.addEventListener('click', () => { + deps.disconnect(); + }); + + deps.dom.focusGridButton.addEventListener('click', () => { + deps.dom.canvas.focus(); + deps.updateStatus('Chat Grid focused.'); + deps.sfxUiBlip(); + }); + + deps.dom.settingsButton.addEventListener('click', () => { + deps.openSettings(); + }); + + deps.dom.closeSettingsButton.addEventListener('click', () => { + deps.closeSettings(); + }); + + deps.dom.audioInputSelect.addEventListener('change', (event) => { + const target = event.target as HTMLSelectElement; + if (!target.value) return; + deps.setPreferredInput(target.value, target.selectedOptions[0]?.text || ''); + deps.updateDeviceSummary(); + void deps.setupLocalMedia(target.value); + }); + + deps.dom.audioOutputSelect.addEventListener('change', (event) => { + const target = event.target as HTMLSelectElement; + deps.setPreferredOutput(target.value, target.selectedOptions[0]?.text || ''); + deps.updateDeviceSummary(); + void deps.setOutputDevice(target.value); + }); + + deps.dom.settingsModal.addEventListener('keydown', (event) => { + if (event.key !== 'Tab') return; + const focusable = Array.from(deps.dom.settingsModal.querySelectorAll('select, button')); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + last.focus(); + event.preventDefault(); + return; + } + + if (!event.shiftKey && document.activeElement === last) { + first.focus(); + event.preventDefault(); + } + }); +}