Extract message, item editor, and UI binding modules
This commit is contained in:
346
client/src/items/itemPropertyEditor.ts
Normal file
346
client/src/items/itemPropertyEditor.ts
Normal file
@@ -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<string, WorldItem>;
|
||||||
|
};
|
||||||
|
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<string>;
|
||||||
|
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<string, unknown>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { handleListControlKey } from './input/listController';
|
|||||||
import { getEditSessionAction } from './input/editSession';
|
import { getEditSessionAction } from './input/editSession';
|
||||||
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
||||||
import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
|
import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
|
||||||
|
import { createOnMessageHandler } from './network/messageHandlers';
|
||||||
import { SignalingClient } from './network/signalingClient';
|
import { SignalingClient } from './network/signalingClient';
|
||||||
import { CanvasRenderer } from './render/canvasRenderer';
|
import { CanvasRenderer } from './render/canvasRenderer';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +58,8 @@ import {
|
|||||||
getItemTypeTooltip,
|
getItemTypeTooltip,
|
||||||
itemTypeLabel,
|
itemTypeLabel,
|
||||||
} from './items/itemRegistry';
|
} from './items/itemRegistry';
|
||||||
|
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
||||||
|
import { setupUiHandlers as setupDomUiHandlers } from './ui/domBindings';
|
||||||
import { PeerManager } from './webrtc/peerManager';
|
import { PeerManager } from './webrtc/peerManager';
|
||||||
|
|
||||||
const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels';
|
const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels';
|
||||||
@@ -1236,195 +1239,56 @@ function disconnect(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onMessage(message: IncomingMessage): Promise<void> {
|
const onMessage = createOnMessageHandler({
|
||||||
switch (message.type) {
|
getWorldGridSize: () => worldGridSize,
|
||||||
case 'welcome':
|
setWorldGridSize: (size) => {
|
||||||
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {
|
worldGridSize = size;
|
||||||
worldGridSize = message.worldConfig.gridSize;
|
},
|
||||||
}
|
setConnecting: (value) => {
|
||||||
renderer.setGridSize(worldGridSize);
|
connecting = value;
|
||||||
applyServerItemUiDefinitions(message.uiDefinitions);
|
},
|
||||||
state.addItemTypeIndex = 0;
|
rendererSetGridSize: (size) => renderer.setGridSize(size),
|
||||||
state.player.id = message.id;
|
applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters<typeof applyServerItemUiDefinitions>[0]),
|
||||||
state.running = true;
|
state,
|
||||||
connecting = false;
|
dom,
|
||||||
state.player.x = Math.max(0, Math.min(worldGridSize - 1, state.player.x));
|
signalingSend: (message) => signaling.send(message as OutgoingMessage),
|
||||||
state.player.y = Math.max(0, Math.min(worldGridSize - 1, state.player.y));
|
peerManager,
|
||||||
dom.nicknameContainer.classList.add('hidden');
|
radioRuntime,
|
||||||
dom.connectButton.classList.add('hidden');
|
itemEmitRuntime,
|
||||||
dom.disconnectButton.classList.remove('hidden');
|
applyAudioLayerState,
|
||||||
dom.focusGridButton.classList.remove('hidden');
|
gameLoop,
|
||||||
dom.canvas.classList.remove('hidden');
|
sanitizeName,
|
||||||
dom.instructions.classList.remove('hidden');
|
randomFootstepUrl,
|
||||||
dom.canvas.focus();
|
playRemoteSpatialStepOrTeleport: (url, peerX, peerY) => {
|
||||||
|
void audio.playSpatialSample(
|
||||||
signaling.send({ type: 'update_position', x: state.player.x, y: state.player.y });
|
url,
|
||||||
signaling.send({ type: 'update_nickname', nickname: state.player.nickname });
|
{ x: peerX - state.player.x, y: peerY - state.player.y },
|
||||||
|
FOOTSTEP_GAIN,
|
||||||
for (const user of message.users) {
|
);
|
||||||
state.peers.set(user.id, { ...user });
|
},
|
||||||
await peerManager.createOrGetPeer(user.id, true, user);
|
TELEPORT_SOUND_URL,
|
||||||
}
|
audioLayers,
|
||||||
state.items.clear();
|
pushChatMessage,
|
||||||
for (const item of message.items || []) {
|
classifySystemMessageSound,
|
||||||
state.items.set(item.id, {
|
SYSTEM_SOUND_URLS,
|
||||||
...item,
|
playSample: (url, gain = 1) => {
|
||||||
carrierId: item.carrierId ?? null,
|
void audio.playSample(url, gain);
|
||||||
});
|
},
|
||||||
}
|
updateStatus,
|
||||||
await radioRuntime.sync(state.items.values());
|
audioUiBlip: () => audio.sfxUiBlip(),
|
||||||
await itemEmitRuntime.sync(state.items.values());
|
audioUiConfirm: () => audio.sfxUiConfirm(),
|
||||||
await applyAudioLayerState();
|
audioUiCancel: () => audio.sfxUiCancel(),
|
||||||
|
NICKNAME_STORAGE_KEY,
|
||||||
gameLoop();
|
getCarriedItemId: () => getCarriedItem()?.id ?? null,
|
||||||
break;
|
itemPropertyLabel,
|
||||||
|
getItemPropertyValue,
|
||||||
case 'signal': {
|
getItemById: (itemId) => state.items.get(itemId),
|
||||||
const peer = await peerManager.handleSignal(message);
|
playLocateToneAt: (x, y) => audio.sfxLocate({ x: x - state.player.x, y: y - state.player.y }),
|
||||||
if (!state.peers.has(peer.id)) {
|
resolveIncomingSoundUrl,
|
||||||
state.peers.set(peer.id, {
|
playIncomingItemUseSound: (url, x, y) => {
|
||||||
id: peer.id,
|
void audio.playSpatialSample(url, { x: x - state.player.x, y: y - state.player.y }, 1);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMute(): void {
|
function toggleMute(): void {
|
||||||
state.isMuted = !state.isMuted;
|
state.isMuted = !state.isMuted;
|
||||||
@@ -2076,306 +1940,28 @@ function handleSelectItemModeInput(code: string, key: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleItemPropertiesModeInput(code: string, key: string): void {
|
const itemPropertyEditor = createItemPropertyEditor({
|
||||||
const itemId = state.selectedItemId;
|
state,
|
||||||
if (!itemId) {
|
signalingSend: (message) => signaling.send(message as OutgoingMessage),
|
||||||
state.mode = 'normal';
|
getItemPropertyValue,
|
||||||
state.editingPropertyKey = null;
|
itemPropertyLabel,
|
||||||
state.itemPropertyOptionValues = [];
|
isItemPropertyEditable,
|
||||||
state.itemPropertyOptionIndex = 0;
|
getItemPropertyOptionValues,
|
||||||
return;
|
openItemPropertyOptionSelect,
|
||||||
}
|
describeItemPropertyHelp,
|
||||||
const item = state.items.get(itemId);
|
getItemPropertyMetadata,
|
||||||
if (!item) {
|
validateNumericItemPropertyInput,
|
||||||
state.mode = 'normal';
|
clampEffectLevel,
|
||||||
state.editingPropertyKey = null;
|
effectIds: EFFECT_IDS as Set<string>,
|
||||||
state.itemPropertyOptionValues = [];
|
effectSequenceIdsCsv: EFFECT_SEQUENCE.map((effect) => effect.id).join(', '),
|
||||||
state.itemPropertyOptionIndex = 0;
|
applyTextInputEdit,
|
||||||
updateStatus('Item no longer exists.');
|
setReplaceTextOnNextType: (value) => {
|
||||||
audio.sfxUiCancel();
|
replaceTextOnNextType = value;
|
||||||
return;
|
},
|
||||||
}
|
updateStatus,
|
||||||
const control = handleListControlKey(code, key, state.itemPropertyKeys, state.itemPropertyIndex, (propertyKey) => propertyKey);
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
if (control.type === 'move') {
|
sfxUiCancel: () => audio.sfxUiCancel(),
|
||||||
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<string, unknown>): 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void {
|
function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void {
|
||||||
const editAction = getEditSessionAction(code);
|
const editAction = getEditSessionAction(code);
|
||||||
@@ -2472,11 +2058,11 @@ function setupInputHandlers(): void {
|
|||||||
} else if (state.mode === 'selectItem') {
|
} else if (state.mode === 'selectItem') {
|
||||||
handleSelectItemModeInput(code, event.key);
|
handleSelectItemModeInput(code, event.key);
|
||||||
} else if (state.mode === 'itemProperties') {
|
} else if (state.mode === 'itemProperties') {
|
||||||
handleItemPropertiesModeInput(code, event.key);
|
itemPropertyEditor.handleItemPropertiesModeInput(code, event.key);
|
||||||
} else if (state.mode === 'itemPropertyEdit') {
|
} else if (state.mode === 'itemPropertyEdit') {
|
||||||
handleItemPropertyEditModeInput(code, event.key, event.ctrlKey);
|
itemPropertyEditor.handleItemPropertyEditModeInput(code, event.key, event.ctrlKey);
|
||||||
} else if (state.mode === 'itemPropertyOptionSelect') {
|
} else if (state.mode === 'itemPropertyOptionSelect') {
|
||||||
handleItemPropertyOptionSelectModeInput(code, event.key);
|
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(code, event.key);
|
||||||
} else {
|
} else {
|
||||||
handleNormalModeInput(code, event.shiftKey);
|
handleNormalModeInput(code, event.shiftKey);
|
||||||
}
|
}
|
||||||
@@ -2564,92 +2150,36 @@ function closeSettings(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupUiHandlers(): void {
|
function setupUiHandlers(): void {
|
||||||
const persistOnUnload = (): void => {
|
setupDomUiHandlers({
|
||||||
if (!state.running) return;
|
dom,
|
||||||
persistPlayerPosition();
|
sanitizeName,
|
||||||
};
|
nicknameStorageKey: NICKNAME_STORAGE_KEY,
|
||||||
window.addEventListener('pagehide', persistOnUnload);
|
updateConnectAvailability,
|
||||||
window.addEventListener('beforeunload', persistOnUnload);
|
connect,
|
||||||
|
disconnect,
|
||||||
dom.connectButton.addEventListener('click', () => {
|
openSettings,
|
||||||
void connect();
|
closeSettings,
|
||||||
});
|
updateStatus,
|
||||||
dom.preconnectNickname.addEventListener('input', () => {
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
updateConnectAvailability();
|
setupLocalMedia,
|
||||||
});
|
setPreferredInput: (id, name) => {
|
||||||
dom.preconnectNickname.addEventListener('change', () => {
|
preferredInputDeviceId = id;
|
||||||
const clean = sanitizeName(dom.preconnectNickname.value);
|
preferredInputDeviceName = name || preferredInputDeviceName;
|
||||||
dom.preconnectNickname.value = clean;
|
localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId);
|
||||||
if (clean) {
|
localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName);
|
||||||
localStorage.setItem(NICKNAME_STORAGE_KEY, clean);
|
},
|
||||||
} else {
|
setPreferredOutput: (id, name) => {
|
||||||
localStorage.removeItem(NICKNAME_STORAGE_KEY);
|
preferredOutputDeviceId = id;
|
||||||
}
|
preferredOutputDeviceName = name || preferredOutputDeviceName;
|
||||||
updateConnectAvailability();
|
localStorage.setItem(AUDIO_OUTPUT_STORAGE_KEY, preferredOutputDeviceId);
|
||||||
});
|
localStorage.setItem(AUDIO_OUTPUT_NAME_STORAGE_KEY, preferredOutputDeviceName);
|
||||||
dom.preconnectNickname.addEventListener('keydown', (event) => {
|
},
|
||||||
if (event.key === 'Enter' && !dom.connectButton.disabled) {
|
updateDeviceSummary,
|
||||||
event.preventDefault();
|
setOutputDevice: (id) => peerManager.setOutputDevice(id),
|
||||||
void connect();
|
persistOnUnload: () => {
|
||||||
}
|
if (!state.running) return;
|
||||||
});
|
persistPlayerPosition();
|
||||||
|
},
|
||||||
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<HTMLElement>('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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
247
client/src/network/messageHandlers.ts
Normal file
247
client/src/network/messageHandlers.ts
Normal file
@@ -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<string, { id: string; nickname: string; x: number; y: number }>;
|
||||||
|
items: Map<string, WorldItem>;
|
||||||
|
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<unknown>;
|
||||||
|
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<WorldItem>) => Promise<void>; cleanup: (itemId: string) => void };
|
||||||
|
itemEmitRuntime: { sync: (items: Iterable<WorldItem>) => Promise<void>; cleanup: (itemId: string) => void };
|
||||||
|
applyAudioLayerState: () => Promise<void>;
|
||||||
|
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<void> {
|
||||||
|
return async function onMessage(message: IncomingMessage): Promise<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
111
client/src/ui/domBindings.ts
Normal file
111
client/src/ui/domBindings.ts
Normal file
@@ -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<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
openSettings: () => void;
|
||||||
|
closeSettings: () => void;
|
||||||
|
updateStatus: (message: string) => void;
|
||||||
|
sfxUiBlip: () => void;
|
||||||
|
setupLocalMedia: (audioDeviceId: string) => Promise<void>;
|
||||||
|
setPreferredInput: (id: string, name: string) => void;
|
||||||
|
setPreferredOutput: (id: string, name: string) => void;
|
||||||
|
updateDeviceSummary: () => void;
|
||||||
|
setOutputDevice: (id: string) => Promise<void>;
|
||||||
|
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<HTMLElement>('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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user