2026-02-20 08:16:43 -05:00
|
|
|
import './styles.css';
|
|
|
|
|
import { AudioEngine } from './audio/audioEngine';
|
2026-02-20 16:39:44 -05:00
|
|
|
import {
|
|
|
|
|
EFFECT_IDS,
|
|
|
|
|
EFFECT_SEQUENCE,
|
|
|
|
|
clampEffectLevel,
|
|
|
|
|
type EffectId,
|
|
|
|
|
} from './audio/effects';
|
2026-02-21 02:37:29 -05:00
|
|
|
import { RADIO_CHANNEL_OPTIONS, RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
|
2026-02-21 16:13:48 -05:00
|
|
|
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
2026-02-21 03:57:49 -05:00
|
|
|
import {
|
|
|
|
|
applyPastedText,
|
|
|
|
|
applyTextInput,
|
|
|
|
|
describeBackspaceDeletedCharacter,
|
|
|
|
|
describeCursorCharacter,
|
|
|
|
|
describeCursorWordOrCharacter,
|
|
|
|
|
mapTextInputKey,
|
|
|
|
|
moveCursorWordLeft,
|
|
|
|
|
moveCursorWordRight,
|
|
|
|
|
shouldReplaceCurrentText,
|
|
|
|
|
} from './input/textInput';
|
2026-02-20 08:16:43 -05:00
|
|
|
import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
|
|
|
|
|
import { SignalingClient } from './network/signalingClient';
|
|
|
|
|
import { CanvasRenderer } from './render/canvasRenderer';
|
|
|
|
|
import {
|
|
|
|
|
GRID_SIZE,
|
|
|
|
|
MOVE_COOLDOWN_MS,
|
|
|
|
|
createInitialState,
|
|
|
|
|
getDirection,
|
|
|
|
|
getNearestItem,
|
|
|
|
|
getNearestPeer,
|
|
|
|
|
type ItemType,
|
|
|
|
|
type WorldItem,
|
|
|
|
|
} from './state/gameState';
|
|
|
|
|
import { PeerManager } from './webrtc/peerManager';
|
|
|
|
|
|
|
|
|
|
const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels';
|
|
|
|
|
const AUDIO_INPUT_STORAGE_KEY = 'chatGridAudioInputDeviceId';
|
|
|
|
|
const AUDIO_OUTPUT_STORAGE_KEY = 'chatGridAudioOutputDeviceId';
|
|
|
|
|
const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName';
|
|
|
|
|
const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName';
|
|
|
|
|
const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode';
|
2026-02-21 16:30:31 -05:00
|
|
|
const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers';
|
2026-02-21 02:52:01 -05:00
|
|
|
const DEFAULT_DISPLAY_TIME_ZONE = 'America/Detroit';
|
2026-02-20 08:16:43 -05:00
|
|
|
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
|
|
|
|
const NICKNAME_MAX_LENGTH = 32;
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface Window {
|
|
|
|
|
CHGRID_WEB_VERSION?: string;
|
2026-02-21 02:52:01 -05:00
|
|
|
CHGRID_TIME_ZONE?: string;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Dom = {
|
|
|
|
|
appVersion: HTMLElement;
|
2026-02-21 02:19:33 -05:00
|
|
|
updatesSection: HTMLElement;
|
|
|
|
|
updatesToggle: HTMLButtonElement;
|
|
|
|
|
updatesPanel: HTMLDivElement;
|
2026-02-20 08:16:43 -05:00
|
|
|
nicknameContainer: HTMLDivElement;
|
|
|
|
|
preconnectNickname: HTMLInputElement;
|
|
|
|
|
connectButton: HTMLButtonElement;
|
|
|
|
|
disconnectButton: HTMLButtonElement;
|
|
|
|
|
focusGridButton: HTMLButtonElement;
|
|
|
|
|
settingsButton: HTMLButtonElement;
|
|
|
|
|
closeSettingsButton: HTMLButtonElement;
|
|
|
|
|
settingsModal: HTMLDivElement;
|
|
|
|
|
audioInputSelect: HTMLSelectElement;
|
|
|
|
|
audioOutputSelect: HTMLSelectElement;
|
|
|
|
|
audioInputCurrent: HTMLParagraphElement;
|
|
|
|
|
audioOutputCurrent: HTMLParagraphElement;
|
|
|
|
|
canvas: HTMLCanvasElement;
|
|
|
|
|
status: HTMLDivElement;
|
|
|
|
|
instructions: HTMLDivElement;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const dom: Dom = {
|
|
|
|
|
appVersion: requiredById('appVersion'),
|
2026-02-21 02:19:33 -05:00
|
|
|
updatesSection: requiredById('updatesSection'),
|
|
|
|
|
updatesToggle: requiredById('updatesToggle'),
|
|
|
|
|
updatesPanel: requiredById('updatesPanel'),
|
2026-02-20 08:16:43 -05:00
|
|
|
nicknameContainer: requiredById('nicknameContainer'),
|
|
|
|
|
preconnectNickname: requiredById('preconnectNickname'),
|
|
|
|
|
connectButton: requiredById('connectButton'),
|
|
|
|
|
disconnectButton: requiredById('disconnectButton'),
|
|
|
|
|
focusGridButton: requiredById('focusGridButton'),
|
|
|
|
|
settingsButton: requiredById('settingsButton'),
|
|
|
|
|
closeSettingsButton: requiredById('closeSettingsButton'),
|
|
|
|
|
settingsModal: requiredById('settingsModal'),
|
|
|
|
|
audioInputSelect: requiredById('audioInputSelect'),
|
|
|
|
|
audioOutputSelect: requiredById('audioOutputSelect'),
|
|
|
|
|
audioInputCurrent: requiredById('audioInputCurrent'),
|
|
|
|
|
audioOutputCurrent: requiredById('audioOutputCurrent'),
|
|
|
|
|
canvas: requiredById('gameCanvas'),
|
|
|
|
|
status: requiredById('status'),
|
|
|
|
|
instructions: requiredById('instructions'),
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 02:19:33 -05:00
|
|
|
type ChangelogSection = {
|
|
|
|
|
date: string;
|
|
|
|
|
items: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ChangelogData = {
|
|
|
|
|
sections: ChangelogSection[];
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 16:30:31 -05:00
|
|
|
type AudioLayerState = {
|
|
|
|
|
voice: boolean;
|
|
|
|
|
item: boolean;
|
|
|
|
|
media: boolean;
|
|
|
|
|
world: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim();
|
2026-02-21 02:52:01 -05:00
|
|
|
const DISPLAY_TIME_ZONE = resolveDisplayTimeZone();
|
2026-02-21 16:01:40 -05:00
|
|
|
const CLOCK_TIME_ZONE_OPTIONS = [
|
2026-02-21 16:04:55 -05:00
|
|
|
'America/Anchorage',
|
|
|
|
|
'America/Argentina/Buenos_Aires',
|
|
|
|
|
'America/Chicago',
|
2026-02-21 16:01:40 -05:00
|
|
|
'America/Detroit',
|
2026-02-21 16:04:55 -05:00
|
|
|
'America/Halifax',
|
2026-02-21 16:15:41 -05:00
|
|
|
'America/Indiana/Indianapolis',
|
|
|
|
|
'America/Kentucky/Louisville',
|
2026-02-21 16:04:55 -05:00
|
|
|
'America/Los_Angeles',
|
|
|
|
|
'America/St_Johns',
|
|
|
|
|
'Asia/Bangkok',
|
|
|
|
|
'Asia/Dhaka',
|
|
|
|
|
'Asia/Dubai',
|
|
|
|
|
'Asia/Hong_Kong',
|
|
|
|
|
'Asia/Kabul',
|
|
|
|
|
'Asia/Karachi',
|
|
|
|
|
'Asia/Kathmandu',
|
|
|
|
|
'Asia/Kolkata',
|
|
|
|
|
'Asia/Seoul',
|
|
|
|
|
'Asia/Singapore',
|
|
|
|
|
'Asia/Tehran',
|
|
|
|
|
'Asia/Tokyo',
|
|
|
|
|
'Asia/Yangon',
|
|
|
|
|
'Atlantic/Azores',
|
|
|
|
|
'Atlantic/South_Georgia',
|
|
|
|
|
'Australia/Brisbane',
|
|
|
|
|
'Australia/Darwin',
|
|
|
|
|
'Australia/Eucla',
|
|
|
|
|
'Australia/Lord_Howe',
|
|
|
|
|
'Europe/Berlin',
|
|
|
|
|
'Europe/Helsinki',
|
|
|
|
|
'Europe/London',
|
|
|
|
|
'Europe/Moscow',
|
2026-02-21 16:32:28 -05:00
|
|
|
'Pacific/Apia',
|
2026-02-21 16:04:55 -05:00
|
|
|
'Pacific/Auckland',
|
|
|
|
|
'Pacific/Chatham',
|
|
|
|
|
'Pacific/Honolulu',
|
|
|
|
|
'Pacific/Kiritimati',
|
|
|
|
|
'Pacific/Noumea',
|
|
|
|
|
'Pacific/Pago_Pago',
|
|
|
|
|
'UTC',
|
2026-02-21 16:01:40 -05:00
|
|
|
] as const;
|
2026-02-20 08:16:43 -05:00
|
|
|
dom.appVersion.textContent = APP_VERSION
|
|
|
|
|
? `Another AI experiment with Jage. Version ${APP_VERSION}`
|
|
|
|
|
: 'Another AI experiment with Jage. Version unknown';
|
2026-02-21 16:04:55 -05:00
|
|
|
const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
|
2026-02-20 16:50:12 -05:00
|
|
|
const ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
2026-02-21 16:13:48 -05:00
|
|
|
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 },
|
|
|
|
|
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 },
|
|
|
|
|
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 },
|
|
|
|
|
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
|
2026-02-20 16:50:12 -05:00
|
|
|
};
|
2026-02-20 17:43:05 -05:00
|
|
|
const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
|
|
|
|
|
'title',
|
|
|
|
|
'streamUrl',
|
|
|
|
|
'enabled',
|
2026-02-21 01:48:20 -05:00
|
|
|
'channel',
|
2026-02-20 17:43:05 -05:00
|
|
|
'volume',
|
|
|
|
|
'effect',
|
|
|
|
|
'effectValue',
|
2026-02-21 00:55:19 -05:00
|
|
|
'spaces',
|
2026-02-20 17:43:05 -05:00
|
|
|
'sides',
|
|
|
|
|
'number',
|
2026-02-21 16:01:40 -05:00
|
|
|
'timeZone',
|
|
|
|
|
'use24Hour',
|
2026-02-20 17:43:05 -05:00
|
|
|
]);
|
2026-02-20 17:46:43 -05:00
|
|
|
const OPTION_ITEM_PROPERTY_VALUES: Partial<Record<string, string[]>> = {
|
|
|
|
|
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
2026-02-21 01:48:20 -05:00
|
|
|
channel: [...RADIO_CHANNEL_OPTIONS],
|
2026-02-21 16:01:40 -05:00
|
|
|
timeZone: [...CLOCK_TIME_ZONE_OPTIONS],
|
2026-02-20 17:46:43 -05:00
|
|
|
};
|
2026-02-20 08:16:43 -05:00
|
|
|
const APP_BASE_URL = import.meta.env.BASE_URL || '/';
|
|
|
|
|
function withBase(path: string): string {
|
|
|
|
|
const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`;
|
|
|
|
|
return `${normalizedBase}${path.replace(/^\/+/, '')}`;
|
|
|
|
|
}
|
|
|
|
|
const SYSTEM_SOUND_URLS = {
|
|
|
|
|
logon: withBase('sounds/logon.ogg'),
|
|
|
|
|
logout: withBase('sounds/logout.ogg'),
|
|
|
|
|
notify: withBase('sounds/notify.ogg'),
|
|
|
|
|
} as const;
|
2026-02-21 00:55:19 -05:00
|
|
|
const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`));
|
2026-02-21 01:05:18 -05:00
|
|
|
const FOOTSTEP_GAIN = 0.7;
|
2026-02-21 01:19:46 -05:00
|
|
|
const TELEPORT_SOUND_URL = withBase('sounds/teleport.ogg');
|
2026-02-21 00:55:19 -05:00
|
|
|
const WALL_SOUND_URL = withBase('sounds/wall.ogg');
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
const state = createInitialState();
|
|
|
|
|
const renderer = new CanvasRenderer(dom.canvas);
|
|
|
|
|
const audio = new AudioEngine();
|
|
|
|
|
let localStream: MediaStream | null = null;
|
|
|
|
|
let outboundStream: MediaStream | null = null;
|
|
|
|
|
let statusTimeout: number | null = null;
|
|
|
|
|
let lastFocusedElement: Element | null = null;
|
|
|
|
|
let lastAnnouncementText = '';
|
|
|
|
|
let lastAnnouncementAt = 0;
|
|
|
|
|
let preferredInputDeviceId = localStorage.getItem(AUDIO_INPUT_STORAGE_KEY) || '';
|
|
|
|
|
let preferredOutputDeviceId = localStorage.getItem(AUDIO_OUTPUT_STORAGE_KEY) || '';
|
|
|
|
|
let preferredInputDeviceName = localStorage.getItem(AUDIO_INPUT_NAME_STORAGE_KEY) || '';
|
|
|
|
|
let preferredOutputDeviceName = localStorage.getItem(AUDIO_OUTPUT_NAME_STORAGE_KEY) || '';
|
|
|
|
|
let outputMode = localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' ? 'mono' : 'stereo';
|
|
|
|
|
let connecting = false;
|
|
|
|
|
const messageBuffer: string[] = [];
|
|
|
|
|
let messageCursor = -1;
|
2026-02-21 02:37:29 -05:00
|
|
|
const radioRuntime = new RadioStationRuntime(audio);
|
2026-02-21 16:13:48 -05:00
|
|
|
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl);
|
2026-02-21 03:43:11 -05:00
|
|
|
let internalClipboardText = '';
|
2026-02-20 08:16:43 -05:00
|
|
|
let replaceTextOnNextType = false;
|
2026-02-20 17:46:43 -05:00
|
|
|
let pendingEscapeDisconnect = false;
|
2026-02-21 16:30:31 -05:00
|
|
|
let audioLayers: AudioLayerState = {
|
|
|
|
|
voice: true,
|
|
|
|
|
item: true,
|
|
|
|
|
media: true,
|
|
|
|
|
world: true,
|
|
|
|
|
};
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
const signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
|
|
|
const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`;
|
|
|
|
|
const signaling = new SignalingClient(signalingUrl, updateStatus);
|
|
|
|
|
|
|
|
|
|
const peerManager = new PeerManager(
|
|
|
|
|
audio,
|
|
|
|
|
(targetId, payload) => {
|
|
|
|
|
signaling.send({ type: 'signal', targetId, ...payload });
|
|
|
|
|
},
|
|
|
|
|
() => outboundStream,
|
|
|
|
|
updateStatus,
|
|
|
|
|
);
|
|
|
|
|
audio.setOutputMode(outputMode);
|
|
|
|
|
|
|
|
|
|
loadEffectLevels();
|
2026-02-21 16:30:31 -05:00
|
|
|
loadAudioLayerState();
|
2026-02-21 02:19:33 -05:00
|
|
|
void loadChangelog();
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
function requiredById<T extends HTMLElement>(id: string): T {
|
|
|
|
|
const found = document.getElementById(id);
|
|
|
|
|
if (!found) {
|
|
|
|
|
throw new Error(`Missing element: ${id}`);
|
|
|
|
|
}
|
|
|
|
|
return found as T;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 02:52:01 -05:00
|
|
|
function resolveDisplayTimeZone(): string {
|
|
|
|
|
const configured = String(window.CHGRID_TIME_ZONE ?? '').trim();
|
|
|
|
|
if (configured) {
|
|
|
|
|
try {
|
|
|
|
|
new Intl.DateTimeFormat('en-US', { timeZone: configured }).format(new Date());
|
|
|
|
|
return configured;
|
|
|
|
|
} catch {
|
|
|
|
|
// Fall back when configured timezone is invalid.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return DEFAULT_DISPLAY_TIME_ZONE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTimestampMs(value: unknown): string {
|
|
|
|
|
const raw = Number(value);
|
|
|
|
|
if (!Number.isFinite(raw)) {
|
|
|
|
|
return String(value ?? '');
|
|
|
|
|
}
|
|
|
|
|
const date = new Date(raw);
|
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
|
|
|
return String(value ?? '');
|
|
|
|
|
}
|
|
|
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
|
|
|
timeZone: DISPLAY_TIME_ZONE,
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
hour12: false,
|
|
|
|
|
}).formatToParts(date);
|
|
|
|
|
const pick = (type: Intl.DateTimeFormatPartTypes): string => parts.find((part) => part.type === type)?.value ?? '00';
|
|
|
|
|
return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 02:19:33 -05:00
|
|
|
function setUpdatesExpanded(expanded: boolean): void {
|
|
|
|
|
dom.updatesToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
|
|
|
dom.updatesToggle.textContent = expanded ? 'Hide updates' : 'Show updates';
|
|
|
|
|
dom.updatesPanel.hidden = !expanded;
|
|
|
|
|
dom.updatesPanel.classList.toggle('hidden', !expanded);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderChangelog(changelog: ChangelogData): void {
|
|
|
|
|
dom.updatesPanel.innerHTML = '';
|
|
|
|
|
for (const section of changelog.sections) {
|
|
|
|
|
const heading = document.createElement('h3');
|
|
|
|
|
heading.textContent = section.date;
|
|
|
|
|
dom.updatesPanel.appendChild(heading);
|
|
|
|
|
|
|
|
|
|
const list = document.createElement('ul');
|
|
|
|
|
for (const item of section.items) {
|
|
|
|
|
const li = document.createElement('li');
|
|
|
|
|
li.textContent = item;
|
|
|
|
|
list.appendChild(li);
|
|
|
|
|
}
|
|
|
|
|
dom.updatesPanel.appendChild(list);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadChangelog(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(withBase('changelog.json'), { cache: 'no-store' });
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
dom.updatesSection.classList.add('hidden');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const changelog = (await response.json()) as ChangelogData;
|
|
|
|
|
if (!Array.isArray(changelog.sections) || changelog.sections.length === 0) {
|
|
|
|
|
dom.updatesSection.classList.add('hidden');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
renderChangelog(changelog);
|
|
|
|
|
setUpdatesExpanded(false);
|
|
|
|
|
dom.updatesToggle.addEventListener('click', () => {
|
|
|
|
|
const expanded = dom.updatesToggle.getAttribute('aria-expanded') === 'true';
|
|
|
|
|
setUpdatesExpanded(!expanded);
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
dom.updatesSection.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
function updateStatus(message: string): void {
|
|
|
|
|
const normalized = String(message)
|
|
|
|
|
.replace(/\s*\n+\s*/g, ' ')
|
|
|
|
|
.replace(/\s{2,}/g, ' ')
|
|
|
|
|
.trim();
|
|
|
|
|
const now = performance.now();
|
|
|
|
|
if (normalized && normalized === lastAnnouncementText && now - lastAnnouncementAt < 300) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
lastAnnouncementText = normalized;
|
|
|
|
|
lastAnnouncementAt = now;
|
|
|
|
|
|
|
|
|
|
if (statusTimeout !== null) {
|
|
|
|
|
window.clearTimeout(statusTimeout);
|
|
|
|
|
}
|
|
|
|
|
dom.status.textContent = '';
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
dom.status.textContent = normalized;
|
|
|
|
|
});
|
|
|
|
|
statusTimeout = window.setTimeout(() => {
|
|
|
|
|
if (dom.status.textContent === normalized) {
|
|
|
|
|
dom.status.textContent = '';
|
|
|
|
|
}
|
|
|
|
|
}, 4000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sanitizeName(value: string): string {
|
|
|
|
|
return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateConnectAvailability(): void {
|
|
|
|
|
if (state.running) {
|
|
|
|
|
dom.connectButton.disabled = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const hasNickname = sanitizeName(dom.preconnectNickname.value).length > 0;
|
|
|
|
|
dom.connectButton.disabled = connecting || !hasNickname;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadEffectLevels(): void {
|
|
|
|
|
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
|
|
|
|
|
if (!raw) return;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw) as Partial<
|
|
|
|
|
Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>
|
|
|
|
|
>;
|
|
|
|
|
audio.setEffectLevels(parsed);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore malformed persisted values.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persistEffectLevels(): void {
|
|
|
|
|
localStorage.setItem(EFFECT_LEVELS_STORAGE_KEY, JSON.stringify(audio.getEffectLevels()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:30:31 -05:00
|
|
|
function loadAudioLayerState(): void {
|
|
|
|
|
const raw = localStorage.getItem(AUDIO_LAYER_STATE_STORAGE_KEY);
|
|
|
|
|
if (raw) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw) as Partial<AudioLayerState>;
|
|
|
|
|
audioLayers = {
|
|
|
|
|
voice: parsed.voice !== false,
|
|
|
|
|
item: parsed.item !== false,
|
|
|
|
|
media: parsed.media !== false,
|
|
|
|
|
world: parsed.world !== false,
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore malformed persisted values.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
audio.setVoiceLayerEnabled(audioLayers.voice);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persistAudioLayerState(): void {
|
|
|
|
|
localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(audioLayers));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyAudioLayerState(): Promise<void> {
|
|
|
|
|
audio.setVoiceLayerEnabled(audioLayers.voice);
|
|
|
|
|
if (audioLayers.voice) {
|
|
|
|
|
await peerManager.resumeRemoteAudio();
|
|
|
|
|
} else {
|
|
|
|
|
peerManager.suspendRemoteAudio();
|
|
|
|
|
}
|
|
|
|
|
await radioRuntime.setLayerEnabled(audioLayers.media, state.items.values());
|
|
|
|
|
await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleAudioLayer(layer: keyof AudioLayerState): void {
|
|
|
|
|
audioLayers = { ...audioLayers, [layer]: !audioLayers[layer] };
|
|
|
|
|
persistAudioLayerState();
|
|
|
|
|
void applyAudioLayerState();
|
|
|
|
|
updateStatus(`${layer} layer ${audioLayers[layer] ? 'on' : 'off'}.`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
function pushChatMessage(message: string): void {
|
|
|
|
|
messageBuffer.push(message);
|
|
|
|
|
if (messageBuffer.length > 300) {
|
|
|
|
|
messageBuffer.shift();
|
|
|
|
|
}
|
|
|
|
|
messageCursor = messageBuffer.length - 1;
|
|
|
|
|
updateStatus(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function classifySystemMessageSound(message: string): keyof typeof SYSTEM_SOUND_URLS | null {
|
|
|
|
|
const normalized = message.trim().toLowerCase();
|
|
|
|
|
if (!normalized) return null;
|
|
|
|
|
if (normalized.startsWith('welcome. logged in as ') || normalized.endsWith(' has logged in.')) {
|
|
|
|
|
return 'logon';
|
|
|
|
|
}
|
|
|
|
|
if (normalized.endsWith(' has logged out.')) {
|
|
|
|
|
return 'logout';
|
|
|
|
|
}
|
|
|
|
|
if (normalized.includes(' is now known as ') || normalized.startsWith('you are now known as ')) {
|
|
|
|
|
return 'notify';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveIncomingSoundUrl(url: string): string {
|
|
|
|
|
const raw = String(url || '').trim();
|
|
|
|
|
if (!raw) return '';
|
|
|
|
|
if (/^(https?:|data:|blob:)/i.test(raw)) return raw;
|
|
|
|
|
if (raw.startsWith('/sounds/')) {
|
|
|
|
|
return withBase(raw.slice(1));
|
|
|
|
|
}
|
|
|
|
|
if (raw.startsWith('sounds/')) {
|
|
|
|
|
return withBase(raw);
|
|
|
|
|
}
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void {
|
|
|
|
|
if (messageBuffer.length === 0) {
|
|
|
|
|
updateStatus('No chat messages.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (target === 'first') {
|
|
|
|
|
messageCursor = 0;
|
|
|
|
|
} else if (target === 'last') {
|
|
|
|
|
messageCursor = messageBuffer.length - 1;
|
|
|
|
|
} else if (target === 'prev') {
|
|
|
|
|
messageCursor = Math.max(0, messageCursor - 1);
|
|
|
|
|
} else if (target === 'next') {
|
|
|
|
|
messageCursor = Math.min(messageBuffer.length - 1, messageCursor + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateStatus(messageBuffer[messageCursor]);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateDeviceSummary(): void {
|
|
|
|
|
if (preferredInputDeviceId) {
|
|
|
|
|
const text = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName || 'Saved microphone';
|
|
|
|
|
dom.audioInputCurrent.textContent = `Input: ${text}`;
|
|
|
|
|
dom.audioInputCurrent.classList.remove('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
dom.audioInputCurrent.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preferredOutputDeviceId) {
|
|
|
|
|
const text = dom.audioOutputSelect.selectedOptions[0]?.text || preferredOutputDeviceName || 'Saved speakers';
|
|
|
|
|
dom.audioOutputCurrent.textContent = `Output: ${text}`;
|
|
|
|
|
dom.audioOutputCurrent.classList.remove('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
dom.audioOutputCurrent.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPeerNamesAtPosition(x: number, y: number): string[] {
|
|
|
|
|
return Array.from(state.peers.values())
|
|
|
|
|
.filter((peer) => peer.x === x && peer.y === y)
|
|
|
|
|
.map((peer) => peer.nickname);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function itemTypeLabel(type: ItemType): string {
|
2026-02-21 00:55:19 -05:00
|
|
|
if (type === 'radio_station') return 'radio';
|
|
|
|
|
return type;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function itemLabel(item: WorldItem): string {
|
|
|
|
|
return `${item.title} (${itemTypeLabel(item.type)})`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:15:41 -05:00
|
|
|
function itemPropertyLabel(key: string): string {
|
|
|
|
|
if (key === 'use24Hour') return 'use 24 hour format';
|
|
|
|
|
return key;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
function getItemsAtPosition(x: number, y: number): WorldItem[] {
|
|
|
|
|
return Array.from(state.items.values()).filter((item) => !item.carrierId && item.x === x && item.y === y);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCarriedItem(): WorldItem | null {
|
|
|
|
|
if (!state.player.id) return null;
|
|
|
|
|
return Array.from(state.items.values()).find((item) => item.carrierId === state.player.id) || null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:50:12 -05:00
|
|
|
function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'inspect', items: WorldItem[]): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
if (items.length === 0) {
|
|
|
|
|
updateStatus('No items available.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.mode = 'selectItem';
|
|
|
|
|
state.selectionContext = context;
|
|
|
|
|
state.selectedItemIds = items.map((item) => item.id);
|
|
|
|
|
state.selectedItemIndex = 0;
|
|
|
|
|
updateStatus(`Select item: ${itemLabel(items[0])}.`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:01:27 -05:00
|
|
|
function getEditableItemPropertyKeys(item: WorldItem): string[] {
|
|
|
|
|
const keys = ['title'];
|
|
|
|
|
if (item.type === 'radio_station') {
|
2026-02-21 01:48:20 -05:00
|
|
|
keys.push('streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue');
|
2026-02-20 18:01:27 -05:00
|
|
|
} else if (item.type === 'dice') {
|
|
|
|
|
keys.push('sides', 'number');
|
2026-02-21 00:55:19 -05:00
|
|
|
} else if (item.type === 'wheel') {
|
|
|
|
|
keys.push('spaces');
|
2026-02-21 16:01:40 -05:00
|
|
|
} else if (item.type === 'clock') {
|
|
|
|
|
keys.push('timeZone', 'use24Hour');
|
2026-02-20 18:01:27 -05:00
|
|
|
}
|
|
|
|
|
return keys;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 17:43:05 -05:00
|
|
|
function getInspectItemPropertyKeys(item: WorldItem): string[] {
|
2026-02-20 18:01:27 -05:00
|
|
|
const editableKeys = getEditableItemPropertyKeys(item);
|
|
|
|
|
const seen = new Set(editableKeys);
|
|
|
|
|
const allKeys: string[] = [...editableKeys];
|
|
|
|
|
|
2026-02-21 16:13:48 -05:00
|
|
|
const baseKeys = [
|
|
|
|
|
'type',
|
|
|
|
|
'x',
|
|
|
|
|
'y',
|
|
|
|
|
'carrierId',
|
|
|
|
|
'version',
|
|
|
|
|
'createdBy',
|
|
|
|
|
'createdAt',
|
|
|
|
|
'updatedAt',
|
|
|
|
|
'capabilities',
|
|
|
|
|
'useSound',
|
|
|
|
|
'emitSound',
|
|
|
|
|
];
|
2026-02-20 18:01:27 -05:00
|
|
|
for (const key of baseKeys) {
|
|
|
|
|
if (seen.has(key)) continue;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
allKeys.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 17:43:05 -05:00
|
|
|
const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b));
|
2026-02-20 18:01:27 -05:00
|
|
|
for (const key of paramKeys) {
|
|
|
|
|
if (seen.has(key)) continue;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
allKeys.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 17:43:05 -05:00
|
|
|
const globalKeys = Object.keys(ITEM_TYPE_GLOBAL_PROPERTIES[item.type] ?? {}).sort((a, b) => a.localeCompare(b));
|
2026-02-20 18:01:27 -05:00
|
|
|
for (const key of globalKeys) {
|
|
|
|
|
if (seen.has(key)) continue;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
allKeys.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return allKeys;
|
2026-02-20 17:43:05 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:01:27 -05:00
|
|
|
function beginItemProperties(item: WorldItem, showAll = false): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
state.selectedItemId = item.id;
|
|
|
|
|
state.mode = 'itemProperties';
|
2026-02-20 17:46:43 -05:00
|
|
|
state.editingPropertyKey = null;
|
|
|
|
|
state.itemPropertyOptionValues = [];
|
|
|
|
|
state.itemPropertyOptionIndex = 0;
|
2026-02-20 18:01:27 -05:00
|
|
|
if (showAll) {
|
2026-02-20 17:43:05 -05:00
|
|
|
state.itemPropertyKeys = getInspectItemPropertyKeys(item);
|
|
|
|
|
} else {
|
2026-02-20 18:01:27 -05:00
|
|
|
state.itemPropertyKeys = getEditableItemPropertyKeys(item);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
state.itemPropertyIndex = 0;
|
|
|
|
|
const key = state.itemPropertyKeys[0];
|
|
|
|
|
const value = getItemPropertyValue(item, key);
|
2026-02-21 16:15:41 -05:00
|
|
|
updateStatus(`${itemPropertyLabel(key)}: ${value}`);
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useItem(item: WorldItem): void {
|
|
|
|
|
signaling.send({ type: 'item_use', itemId: item.id });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 17:46:43 -05:00
|
|
|
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
|
|
|
|
const options = OPTION_ITEM_PROPERTY_VALUES[key];
|
|
|
|
|
if (!options || options.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.mode = 'itemPropertyOptionSelect';
|
|
|
|
|
state.editingPropertyKey = key;
|
|
|
|
|
state.itemPropertyOptionValues = options;
|
|
|
|
|
const currentValue = getItemPropertyValue(item, key);
|
|
|
|
|
const currentIndex = options.indexOf(currentValue);
|
|
|
|
|
state.itemPropertyOptionIndex = currentIndex >= 0 ? currentIndex : 0;
|
2026-02-21 16:15:41 -05:00
|
|
|
updateStatus(`Select ${itemPropertyLabel(key)}: ${state.itemPropertyOptionValues[state.itemPropertyOptionIndex]}`);
|
2026-02-20 17:46:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:02:42 -05:00
|
|
|
function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
|
|
|
|
|
if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
|
|
|
|
|
if (mode === 'chat') return 500;
|
|
|
|
|
if (mode === 'itemPropertyEdit') return 500;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pasteIntoActiveTextInput(raw: string): boolean {
|
|
|
|
|
const maxLength = textInputMaxLengthForMode(state.mode);
|
|
|
|
|
if (maxLength === null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-21 03:57:49 -05:00
|
|
|
const result = applyPastedText(raw, state.nicknameInput, state.cursorPos, maxLength, replaceTextOnNextType);
|
|
|
|
|
if (!result.handled) return false;
|
|
|
|
|
state.nicknameInput = result.newString;
|
|
|
|
|
state.cursorPos = result.newCursorPos;
|
|
|
|
|
replaceTextOnNextType = result.replaceTextOnNextType;
|
2026-02-20 18:02:42 -05:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:47:43 -05:00
|
|
|
async function handlePasteShortcut(): Promise<void> {
|
|
|
|
|
let pasted = internalClipboardText;
|
|
|
|
|
try {
|
|
|
|
|
const clipboardText = await navigator.clipboard?.readText();
|
|
|
|
|
if (typeof clipboardText === 'string') {
|
|
|
|
|
pasted = clipboardText;
|
|
|
|
|
internalClipboardText = clipboardText;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Clipboard read can fail without user gesture/permissions; fallback to internal clipboard.
|
|
|
|
|
}
|
|
|
|
|
if (!pasteIntoActiveTextInput(pasted)) return;
|
|
|
|
|
updateStatus('pasted');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:43:11 -05:00
|
|
|
function isTextEditingMode(mode: typeof state.mode): boolean {
|
|
|
|
|
return mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:36:16 -05:00
|
|
|
function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKey = false, allowReplaceOnNextType = false): void {
|
|
|
|
|
if (ctrlKey && code === 'KeyA') {
|
|
|
|
|
replaceTextOnNextType = true;
|
|
|
|
|
state.cursorPos = state.nicknameInput.length;
|
|
|
|
|
updateStatus(`${state.nicknameInput} selected`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (ctrlKey && code === 'ArrowLeft') {
|
|
|
|
|
state.cursorPos = moveCursorWordLeft(state.nicknameInput, state.cursorPos);
|
2026-02-21 03:57:49 -05:00
|
|
|
const spoken = describeCursorWordOrCharacter(state.nicknameInput, state.cursorPos);
|
|
|
|
|
if (spoken) updateStatus(spoken);
|
2026-02-21 03:36:16 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (ctrlKey && code === 'ArrowRight') {
|
|
|
|
|
state.cursorPos = moveCursorWordRight(state.nicknameInput, state.cursorPos);
|
2026-02-21 03:57:49 -05:00
|
|
|
const spoken = describeCursorWordOrCharacter(state.nicknameInput, state.cursorPos);
|
|
|
|
|
if (spoken) updateStatus(spoken);
|
2026-02-21 03:36:16 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:14:26 -05:00
|
|
|
const beforeText = state.nicknameInput;
|
|
|
|
|
const beforeCursor = state.cursorPos;
|
|
|
|
|
const mappedKey = mapTextInputKey(code, key);
|
|
|
|
|
|
2026-02-21 03:57:49 -05:00
|
|
|
const replaceDecision = shouldReplaceCurrentText(code, key, replaceTextOnNextType);
|
|
|
|
|
replaceTextOnNextType = replaceDecision.replaceTextOnNextType;
|
|
|
|
|
if (allowReplaceOnNextType && replaceDecision.shouldReplace) {
|
2026-02-20 18:14:26 -05:00
|
|
|
state.nicknameInput = key;
|
|
|
|
|
state.cursorPos = key.length;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = applyTextInput(mappedKey, state.nicknameInput, state.cursorPos, maxLength);
|
|
|
|
|
state.nicknameInput = result.newString;
|
|
|
|
|
state.cursorPos = result.newCursorPos;
|
|
|
|
|
if (code === 'Backspace') {
|
2026-02-21 03:57:49 -05:00
|
|
|
const spoken = describeBackspaceDeletedCharacter(beforeText, beforeCursor);
|
|
|
|
|
if (spoken) updateStatus(spoken);
|
2026-02-20 18:14:26 -05:00
|
|
|
}
|
|
|
|
|
if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') {
|
2026-02-21 03:57:49 -05:00
|
|
|
const spoken = describeCursorCharacter(state.nicknameInput, state.cursorPos);
|
|
|
|
|
if (spoken) updateStatus(spoken);
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getItemPropertyValue(item: WorldItem, key: string): string {
|
|
|
|
|
if (key === 'title') return item.title;
|
2026-02-20 17:43:05 -05:00
|
|
|
if (key === 'type') return item.type;
|
|
|
|
|
if (key === 'x') return String(item.x);
|
|
|
|
|
if (key === 'y') return String(item.y);
|
|
|
|
|
if (key === 'carrierId') return item.carrierId ?? 'none';
|
|
|
|
|
if (key === 'version') return String(item.version);
|
|
|
|
|
if (key === 'createdBy') return item.createdBy;
|
2026-02-21 02:52:01 -05:00
|
|
|
if (key === 'createdAt') return formatTimestampMs(item.createdAt);
|
|
|
|
|
if (key === 'updatedAt') return formatTimestampMs(item.updatedAt);
|
2026-02-20 17:43:05 -05:00
|
|
|
if (key === 'capabilities') return item.capabilities.join(', ') || 'none';
|
2026-02-21 16:13:48 -05:00
|
|
|
if (key === 'useSound') return item.useSound ?? 'none';
|
2026-02-21 16:01:40 -05:00
|
|
|
if (key === 'emitSound') return item.emitSound ?? 'none';
|
2026-02-20 08:16:43 -05:00
|
|
|
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
|
2026-02-21 16:01:40 -05:00
|
|
|
if (key === 'timeZone') return String(item.params.timeZone ?? CLOCK_TIME_ZONE_OPTIONS[0]);
|
|
|
|
|
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
|
2026-02-21 01:48:20 -05:00
|
|
|
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
|
2026-02-20 16:39:44 -05:00
|
|
|
if (key === 'effect') return normalizeRadioEffect(item.params.effect);
|
|
|
|
|
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
|
2026-02-20 17:43:05 -05:00
|
|
|
const globalValue = ITEM_TYPE_GLOBAL_PROPERTIES[item.type]?.[key];
|
2026-02-21 01:13:29 -05:00
|
|
|
if (globalValue !== undefined) return String(globalValue);
|
2026-02-20 08:16:43 -05:00
|
|
|
return String(item.params[key] ?? '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function squareWord(distance: number): string {
|
|
|
|
|
return distance === 1 ? 'square' : 'squares';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:28:51 -05:00
|
|
|
function distanceDirectionPhrase(px: number, py: number, tx: number, ty: number): string {
|
|
|
|
|
const distance = Math.round(Math.hypot(tx - px, ty - py));
|
|
|
|
|
const direction = getDirection(px, py, tx, ty);
|
|
|
|
|
if (direction === 'here') return 'here';
|
|
|
|
|
return `${distance} ${squareWord(distance)} ${direction}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:19:42 -05:00
|
|
|
function persistPlayerPosition(): void {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
'spatialChatPosition',
|
|
|
|
|
JSON.stringify({ x: state.player.x, y: state.player.y }),
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore storage failures (private mode/quota/blocked storage).
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 00:55:19 -05:00
|
|
|
function randomFootstepUrl(): string {
|
|
|
|
|
return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
function gameLoop(): void {
|
|
|
|
|
if (!state.running) return;
|
|
|
|
|
handleMovement();
|
|
|
|
|
audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y });
|
2026-02-21 02:37:29 -05:00
|
|
|
radioRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y });
|
2026-02-21 16:13:48 -05:00
|
|
|
itemEmitRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y });
|
2026-02-20 08:16:43 -05:00
|
|
|
state.cursorVisible = Math.floor(Date.now() / 500) % 2 === 0;
|
|
|
|
|
renderer.draw(state);
|
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleMovement(): void {
|
|
|
|
|
if (state.mode !== 'normal') return;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - state.player.lastMoveTime < MOVE_COOLDOWN_MS) return;
|
|
|
|
|
|
|
|
|
|
let dx = 0;
|
|
|
|
|
let dy = 0;
|
|
|
|
|
if (state.keysPressed.ArrowUp) dy = 1;
|
|
|
|
|
if (state.keysPressed.ArrowDown) dy = -1;
|
|
|
|
|
if (state.keysPressed.ArrowLeft) dx = -1;
|
|
|
|
|
if (state.keysPressed.ArrowRight) dx = 1;
|
|
|
|
|
|
|
|
|
|
if (dx === 0 && dy === 0) return;
|
|
|
|
|
|
|
|
|
|
const nextX = state.player.x + dx;
|
|
|
|
|
const nextY = state.player.y + dy;
|
2026-02-21 00:55:19 -05:00
|
|
|
if (nextX < 0 || nextY < 0 || nextX >= GRID_SIZE || nextY >= GRID_SIZE) {
|
|
|
|
|
state.player.lastMoveTime = now;
|
|
|
|
|
void audio.playSample(WALL_SOUND_URL, 1);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
state.player.x = nextX;
|
|
|
|
|
state.player.y = nextY;
|
2026-02-20 18:19:42 -05:00
|
|
|
persistPlayerPosition();
|
2026-02-20 08:16:43 -05:00
|
|
|
state.player.lastMoveTime = now;
|
2026-02-21 01:05:18 -05:00
|
|
|
void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN);
|
2026-02-20 08:16:43 -05:00
|
|
|
signaling.send({ type: 'update_position', x: nextX, y: nextY });
|
|
|
|
|
|
|
|
|
|
const namesOnTile = getPeerNamesAtPosition(nextX, nextY);
|
|
|
|
|
const itemsOnTile = getItemsAtPosition(nextX, nextY);
|
|
|
|
|
const tileAnnouncements: string[] = [];
|
|
|
|
|
if (namesOnTile.length > 0) {
|
|
|
|
|
tileAnnouncements.push(namesOnTile.join(', '));
|
2026-02-21 03:47:43 -05:00
|
|
|
audio.sfxTileUserPing();
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
if (itemsOnTile.length > 0) {
|
|
|
|
|
tileAnnouncements.push(itemsOnTile.map((item) => itemLabel(item)).join(', '));
|
2026-02-21 03:47:43 -05:00
|
|
|
audio.sfxTileItemPing();
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
if (tileAnnouncements.length > 0) {
|
|
|
|
|
updateStatus(tileAnnouncements.join('. '));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkMicPermission(): Promise<boolean> {
|
|
|
|
|
const permissionApi = navigator.permissions;
|
|
|
|
|
if (!permissionApi?.query) return true;
|
|
|
|
|
try {
|
|
|
|
|
const result = await permissionApi.query({ name: 'microphone' as PermissionName });
|
|
|
|
|
return result.state !== 'denied';
|
|
|
|
|
} catch {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
|
2026-02-20 16:30:54 -05:00
|
|
|
stopLocalMedia();
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
await audio.ensureContext();
|
|
|
|
|
|
|
|
|
|
const constraints: MediaStreamConstraints = {
|
|
|
|
|
audio: {
|
|
|
|
|
deviceId: audioDeviceId ? { exact: audioDeviceId } : undefined,
|
|
|
|
|
sampleRate: 48000,
|
|
|
|
|
channelCount: 2,
|
|
|
|
|
echoCancellation: false,
|
|
|
|
|
noiseSuppression: false,
|
|
|
|
|
autoGainControl: false,
|
|
|
|
|
},
|
|
|
|
|
video: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
|
|
|
const audioTrack = localStream.getAudioTracks()[0];
|
|
|
|
|
if (audioTrack) {
|
|
|
|
|
audioTrack.enabled = !state.isMuted;
|
|
|
|
|
}
|
|
|
|
|
outboundStream = await audio.configureOutboundStream(localStream);
|
|
|
|
|
await peerManager.replaceOutgoingTrack(outboundStream);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:30:54 -05:00
|
|
|
function stopLocalMedia(): void {
|
|
|
|
|
if (localStream) {
|
|
|
|
|
localStream.getTracks().forEach((track) => track.stop());
|
|
|
|
|
localStream = null;
|
|
|
|
|
}
|
|
|
|
|
outboundStream = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
function describeMediaError(error: unknown): string {
|
|
|
|
|
if (error instanceof DOMException) {
|
|
|
|
|
if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.';
|
|
|
|
|
if (error.name === 'NotFoundError') return 'No microphone found. Check that an input device is connected and enabled.';
|
|
|
|
|
if (error.name === 'NotReadableError') return 'Microphone is busy or unavailable. Close other apps using the mic and retry.';
|
|
|
|
|
if (error.name === 'OverconstrainedError') return 'Selected audio device is unavailable. Choose another input device.';
|
|
|
|
|
if (error.name === 'SecurityError') return 'Microphone access requires a secure context (HTTPS) in production.';
|
|
|
|
|
}
|
|
|
|
|
return 'Audio setup failed. Check browser permissions and selected input device.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function connect(): Promise<void> {
|
|
|
|
|
if (connecting || state.running) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const nickname = sanitizeName(dom.preconnectNickname.value);
|
|
|
|
|
if (!nickname) {
|
|
|
|
|
updateStatus('Nickname is required.');
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.player.nickname = nickname;
|
|
|
|
|
dom.preconnectNickname.value = nickname;
|
|
|
|
|
localStorage.setItem(NICKNAME_STORAGE_KEY, nickname);
|
|
|
|
|
connecting = true;
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
const canProceed = await checkMicPermission();
|
|
|
|
|
if (!canProceed) {
|
|
|
|
|
updateStatus('Microphone access is required.');
|
|
|
|
|
connecting = false;
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:30:54 -05:00
|
|
|
state.player.x = Math.floor(Math.random() * GRID_SIZE);
|
|
|
|
|
state.player.y = Math.floor(Math.random() * GRID_SIZE);
|
2026-02-20 08:16:43 -05:00
|
|
|
const storedPosition = localStorage.getItem('spatialChatPosition');
|
|
|
|
|
if (storedPosition) {
|
2026-02-20 16:30:54 -05:00
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(storedPosition) as { x?: number; y?: number };
|
|
|
|
|
if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) {
|
|
|
|
|
const x = Math.floor(parsed.x as number);
|
|
|
|
|
const y = Math.floor(parsed.y as number);
|
|
|
|
|
if (x >= 0 && x < GRID_SIZE && y >= 0 && y < GRID_SIZE) {
|
|
|
|
|
state.player.x = x;
|
|
|
|
|
state.player.y = y;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore malformed saved positions and keep randomized defaults.
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await populateAudioDevices();
|
|
|
|
|
if (dom.audioInputSelect.options.length === 0) {
|
|
|
|
|
updateStatus('No audio input device found. Open Settings or connect a microphone.');
|
|
|
|
|
connecting = false;
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const inputDeviceId = dom.audioInputSelect.value || preferredInputDeviceId;
|
|
|
|
|
await setupLocalMedia(inputDeviceId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
updateStatus(describeMediaError(error));
|
|
|
|
|
connecting = false;
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await signaling.connect(onMessage);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
2026-02-20 16:30:54 -05:00
|
|
|
stopLocalMedia();
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus('Connect failed. Signaling server may be offline or unreachable.');
|
|
|
|
|
connecting = false;
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function disconnect(): void {
|
|
|
|
|
const wasRunning = state.running;
|
|
|
|
|
if (state.running) {
|
2026-02-20 18:19:42 -05:00
|
|
|
persistPlayerPosition();
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
signaling.disconnect();
|
2026-02-20 16:30:54 -05:00
|
|
|
stopLocalMedia();
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
peerManager.cleanupAll();
|
2026-02-21 02:37:29 -05:00
|
|
|
radioRuntime.cleanupAll();
|
2026-02-21 16:13:48 -05:00
|
|
|
itemEmitRuntime.cleanupAll();
|
2026-02-20 08:16:43 -05:00
|
|
|
state.running = false;
|
|
|
|
|
state.keysPressed = {};
|
|
|
|
|
state.peers.clear();
|
|
|
|
|
state.items.clear();
|
|
|
|
|
state.carriedItemId = null;
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.sortedItemIds = [];
|
|
|
|
|
state.itemListIndex = 0;
|
|
|
|
|
state.selectedItemIds = [];
|
|
|
|
|
state.selectionContext = null;
|
|
|
|
|
state.selectedItemIndex = 0;
|
|
|
|
|
state.selectedItemId = null;
|
|
|
|
|
state.itemPropertyKeys = [];
|
|
|
|
|
state.itemPropertyIndex = 0;
|
|
|
|
|
state.editingPropertyKey = null;
|
2026-02-20 17:46:43 -05:00
|
|
|
state.itemPropertyOptionValues = [];
|
|
|
|
|
state.itemPropertyOptionIndex = 0;
|
2026-02-21 02:06:32 -05:00
|
|
|
state.effectSelectIndex = 0;
|
2026-02-20 17:46:43 -05:00
|
|
|
pendingEscapeDisconnect = false;
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
connecting = false;
|
|
|
|
|
dom.nicknameContainer.classList.remove('hidden');
|
|
|
|
|
dom.connectButton.classList.remove('hidden');
|
|
|
|
|
dom.disconnectButton.classList.add('hidden');
|
|
|
|
|
dom.focusGridButton.classList.add('hidden');
|
|
|
|
|
dom.canvas.classList.add('hidden');
|
|
|
|
|
dom.instructions.classList.add('hidden');
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
|
|
|
|
|
updateStatus('Disconnected.');
|
|
|
|
|
if (wasRunning) {
|
|
|
|
|
void audio.playSample(SYSTEM_SOUND_URLS.logout, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onMessage(message: IncomingMessage): Promise<void> {
|
|
|
|
|
switch (message.type) {
|
|
|
|
|
case 'welcome':
|
|
|
|
|
state.player.id = message.id;
|
|
|
|
|
state.running = true;
|
|
|
|
|
connecting = false;
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-21 02:37:29 -05:00
|
|
|
await radioRuntime.sync(state.items.values());
|
2026-02-21 16:13:48 -05:00
|
|
|
await itemEmitRuntime.sync(state.items.values());
|
2026-02-21 16:30:31 -05:00
|
|
|
await applyAudioLayerState();
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
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);
|
2026-02-21 01:13:29 -05:00
|
|
|
const prevX = peer?.x ?? message.x;
|
|
|
|
|
const prevY = peer?.y ?? message.y;
|
2026-02-20 08:16:43 -05:00
|
|
|
if (peer) {
|
|
|
|
|
peer.x = message.x;
|
|
|
|
|
peer.y = message.y;
|
|
|
|
|
}
|
|
|
|
|
peerManager.setPeerPosition(message.id, message.x, message.y);
|
|
|
|
|
if (peer) {
|
2026-02-21 01:13:29 -05:00
|
|
|
const movementDelta = Math.hypot(message.x - prevX, message.y - prevY);
|
|
|
|
|
const soundUrl = movementDelta > 1.5 ? TELEPORT_SOUND_URL : randomFootstepUrl();
|
2026-02-21 16:30:31 -05:00
|
|
|
if (audioLayers.world) {
|
|
|
|
|
void audio.playSpatialSample(
|
|
|
|
|
soundUrl,
|
|
|
|
|
{ x: peer.x - state.player.x, y: peer.y - state.player.y },
|
|
|
|
|
FOOTSTEP_GAIN,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-02-21 16:15:41 -05:00
|
|
|
updateStatus(`${itemPropertyLabel(key)}: ${getItemPropertyValue(message.item, key)}`);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 02:37:29 -05:00
|
|
|
await radioRuntime.sync(state.items.values());
|
2026-02-21 16:13:48 -05:00
|
|
|
await itemEmitRuntime.sync(state.items.values());
|
2026-02-20 08:16:43 -05:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'item_remove': {
|
|
|
|
|
state.items.delete(message.itemId);
|
|
|
|
|
state.carriedItemId = getCarriedItem()?.id ?? null;
|
2026-02-21 02:37:29 -05:00
|
|
|
radioRuntime.cleanup(message.itemId);
|
2026-02-21 16:13:48 -05:00
|
|
|
itemEmitRuntime.cleanup(message.itemId);
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
2026-02-21 16:13:48 -05:00
|
|
|
if (!item?.useSound && item) {
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
2026-02-21 16:30:31 -05:00
|
|
|
if (audioLayers.world) {
|
|
|
|
|
void audio.playSpatialSample(
|
|
|
|
|
soundUrl,
|
|
|
|
|
{ x: message.x - state.player.x, y: message.y - state.player.y },
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleMute(): void {
|
|
|
|
|
state.isMuted = !state.isMuted;
|
|
|
|
|
if (localStream) {
|
|
|
|
|
const track = localStream.getAudioTracks()[0];
|
|
|
|
|
if (track) track.enabled = !state.isMuted;
|
|
|
|
|
}
|
|
|
|
|
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
2026-02-20 17:46:43 -05:00
|
|
|
if (code !== 'Escape' && pendingEscapeDisconnect) {
|
|
|
|
|
pendingEscapeDisconnect = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'KeyN') {
|
|
|
|
|
state.mode = 'nickname';
|
|
|
|
|
state.nicknameInput = state.player.nickname;
|
|
|
|
|
state.cursorPos = state.player.nickname.length;
|
|
|
|
|
replaceTextOnNextType = true;
|
|
|
|
|
updateStatus(`Nickname edit: ${state.nicknameInput}`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyM') {
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
outputMode = audio.toggleOutputMode();
|
|
|
|
|
localStorage.setItem(AUDIO_OUTPUT_MODE_STORAGE_KEY, outputMode);
|
|
|
|
|
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
toggleMute();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:43:14 -05:00
|
|
|
if (code === 'Digit1' && shiftKey) {
|
|
|
|
|
const enabled = audio.toggleLoopback();
|
|
|
|
|
updateStatus(enabled ? 'Loopback on.' : 'Loopback off.');
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:30:31 -05:00
|
|
|
if (code === 'Digit1') {
|
|
|
|
|
toggleAudioLayer('voice');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Digit2') {
|
|
|
|
|
toggleAudioLayer('item');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Digit3') {
|
|
|
|
|
toggleAudioLayer('media');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Digit4') {
|
|
|
|
|
toggleAudioLayer('world');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'KeyE') {
|
2026-02-21 02:06:32 -05:00
|
|
|
const currentEffect = audio.getCurrentEffect();
|
|
|
|
|
const currentIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === currentEffect.id);
|
|
|
|
|
state.effectSelectIndex = currentIndex >= 0 ? currentIndex : 0;
|
|
|
|
|
state.mode = 'effectSelect';
|
|
|
|
|
updateStatus(`Select effect: ${EFFECT_SEQUENCE[state.effectSelectIndex].label}`);
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Equal' || code === 'NumpadAdd' || code === 'Minus' || code === 'NumpadSubtract') {
|
|
|
|
|
const step = code === 'Equal' || code === 'NumpadAdd' ? 5 : -5;
|
|
|
|
|
const adjusted = audio.adjustCurrentEffectLevel(step);
|
|
|
|
|
if (!adjusted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
persistEffectLevels();
|
|
|
|
|
audio.sfxEffectLevel(adjusted.value === adjusted.defaultValue);
|
|
|
|
|
updateStatus(`${adjusted.label} ${adjusted.value}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyC') {
|
|
|
|
|
updateStatus(`${state.player.x}, ${state.player.y}`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyU') {
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((p) => p.nickname)];
|
|
|
|
|
const label = allUsers.length === 1 ? 'user' : 'users';
|
|
|
|
|
updateStatus(`${allUsers.length} ${label}: ${allUsers.join(', ')}`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const carried = getCarriedItem();
|
|
|
|
|
if (carried) {
|
|
|
|
|
useItem(carried);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
|
|
|
|
const usable = squareItems.filter((item) => item.capabilities.includes('usable'));
|
|
|
|
|
if (usable.length === 0) {
|
|
|
|
|
updateStatus('No usable items here.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (usable.length === 1) {
|
|
|
|
|
useItem(usable[0]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemSelection('use', usable);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyA') {
|
|
|
|
|
state.mode = 'addItem';
|
|
|
|
|
updateStatus(`Add item: ${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyI') {
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
if (state.items.size === 0) {
|
|
|
|
|
updateStatus('No items to list.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.sortedItemIds = Array.from(state.items.entries())
|
|
|
|
|
.filter(([, item]) => !item.carrierId)
|
|
|
|
|
.sort(
|
|
|
|
|
(a, b) =>
|
|
|
|
|
Math.hypot(a[1].x - state.player.x, a[1].y - state.player.y) -
|
|
|
|
|
Math.hypot(b[1].x - state.player.x, b[1].y - state.player.y),
|
|
|
|
|
)
|
|
|
|
|
.map(([id]) => id);
|
|
|
|
|
if (state.sortedItemIds.length === 0) {
|
|
|
|
|
updateStatus('No items to list.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.itemListIndex = 0;
|
|
|
|
|
state.mode = 'listItems';
|
|
|
|
|
const first = state.items.get(state.sortedItemIds[0]);
|
|
|
|
|
if (first) {
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`List: ${itemLabel(first)}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
|
2026-02-20 08:16:43 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const nearest = getNearestItem(state);
|
|
|
|
|
if (!nearest.itemId) {
|
|
|
|
|
updateStatus('No items to locate.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const item = state.items.get(nearest.itemId);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y });
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`,
|
2026-02-20 08:16:43 -05:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyD') {
|
|
|
|
|
const carried = getCarriedItem();
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
|
|
|
|
if (squareItems.length === 0) {
|
|
|
|
|
updateStatus('No items to delete.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (squareItems.length === 1) {
|
|
|
|
|
signaling.send({ type: 'item_delete', itemId: squareItems[0].id });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemSelection('delete', squareItems);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (carried) {
|
|
|
|
|
signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
|
|
|
|
if (squareItems.length === 0) {
|
|
|
|
|
updateStatus('No items to pick up.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (squareItems.length === 1) {
|
|
|
|
|
signaling.send({ type: 'item_pickup', itemId: squareItems[0].id });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemSelection('pickup', squareItems);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyO') {
|
|
|
|
|
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
2026-02-20 16:50:12 -05:00
|
|
|
const carried = getCarriedItem();
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
if (squareItems.length === 0) {
|
|
|
|
|
if (!carried) {
|
|
|
|
|
updateStatus('No item to inspect.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 17:43:05 -05:00
|
|
|
beginItemProperties(carried, true);
|
2026-02-20 16:50:12 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (squareItems.length === 1) {
|
2026-02-20 17:43:05 -05:00
|
|
|
beginItemProperties(squareItems[0], true);
|
2026-02-20 16:50:12 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemSelection('inspect', squareItems);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
if (squareItems.length === 0) {
|
|
|
|
|
if (!carried) {
|
|
|
|
|
updateStatus('No editable item here.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemProperties(carried);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (squareItems.length === 1) {
|
|
|
|
|
beginItemProperties(squareItems[0]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemSelection('edit', squareItems);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyP') {
|
|
|
|
|
signaling.send({ type: 'ping', clientSentAt: Date.now() });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'KeyL') {
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
if (state.peers.size === 0) {
|
|
|
|
|
updateStatus('No users to list.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.sortedPeerIds = Array.from(state.peers.entries())
|
|
|
|
|
.sort(
|
|
|
|
|
(a, b) =>
|
|
|
|
|
Math.hypot(a[1].x - state.player.x, a[1].y - state.player.y) -
|
|
|
|
|
Math.hypot(b[1].x - state.player.x, b[1].y - state.player.y),
|
|
|
|
|
)
|
|
|
|
|
.map(([id]) => id);
|
|
|
|
|
state.listIndex = 0;
|
|
|
|
|
state.mode = 'listUsers';
|
|
|
|
|
const first = state.peers.get(state.sortedPeerIds[0]);
|
|
|
|
|
if (first) {
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`List: ${first.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
|
2026-02-20 08:16:43 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nearest = getNearestPeer(state);
|
|
|
|
|
if (!nearest.peerId) {
|
|
|
|
|
updateStatus('No users to locate.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const peer = state.peers.get(nearest.peerId);
|
|
|
|
|
if (!peer) return;
|
|
|
|
|
audio.sfxLocate({ x: peer.x - state.player.x, y: peer.y - state.player.y });
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`,
|
2026-02-20 08:16:43 -05:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:05:18 -05:00
|
|
|
if (code === 'Slash' && !shiftKey) {
|
2026-02-20 08:16:43 -05:00
|
|
|
state.mode = 'chat';
|
|
|
|
|
state.nicknameInput = '';
|
|
|
|
|
state.cursorPos = 0;
|
|
|
|
|
replaceTextOnNextType = false;
|
|
|
|
|
updateStatus('Chat.');
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Comma') {
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
navigateChatBuffer('first');
|
|
|
|
|
} else {
|
|
|
|
|
navigateChatBuffer('prev');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Period') {
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
navigateChatBuffer('last');
|
|
|
|
|
} else {
|
|
|
|
|
navigateChatBuffer('next');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Escape') {
|
2026-02-20 17:46:43 -05:00
|
|
|
if (pendingEscapeDisconnect) {
|
|
|
|
|
pendingEscapeDisconnect = false;
|
|
|
|
|
disconnect();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pendingEscapeDisconnect = true;
|
|
|
|
|
updateStatus('Press Escape again to disconnect.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:36:16 -05:00
|
|
|
function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const message = state.nicknameInput.trim();
|
|
|
|
|
if (message.length > 0) {
|
|
|
|
|
signaling.send({ type: 'chat_message', message });
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.nicknameInput = '';
|
|
|
|
|
state.cursorPos = 0;
|
|
|
|
|
audio.sfxUiConfirm();
|
|
|
|
|
} else {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.nicknameInput = '';
|
|
|
|
|
state.cursorPos = 0;
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:36:16 -05:00
|
|
|
applyTextInputEdit(code, key, 500, ctrlKey);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 02:06:32 -05:00
|
|
|
function handleEffectSelectModeInput(code: string, key: string): void {
|
|
|
|
|
if (code === 'ArrowDown' || code === 'ArrowUp') {
|
|
|
|
|
state.effectSelectIndex =
|
|
|
|
|
code === 'ArrowDown'
|
|
|
|
|
? (state.effectSelectIndex + 1) % EFFECT_SEQUENCE.length
|
|
|
|
|
: (state.effectSelectIndex - 1 + EFFECT_SEQUENCE.length) % EFFECT_SEQUENCE.length;
|
2026-02-21 02:09:21 -05:00
|
|
|
updateStatus(EFFECT_SEQUENCE[state.effectSelectIndex].label);
|
2026-02-21 02:06:32 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextByInitial = findNextIndexByInitial(
|
|
|
|
|
EFFECT_SEQUENCE,
|
|
|
|
|
state.effectSelectIndex,
|
|
|
|
|
key,
|
|
|
|
|
(effect) => effect.label,
|
|
|
|
|
);
|
|
|
|
|
if (nextByInitial >= 0) {
|
|
|
|
|
state.effectSelectIndex = nextByInitial;
|
2026-02-21 02:09:21 -05:00
|
|
|
updateStatus(EFFECT_SEQUENCE[state.effectSelectIndex].label);
|
2026-02-21 02:06:32 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const selected = EFFECT_SEQUENCE[state.effectSelectIndex];
|
|
|
|
|
const effect = audio.setOutboundEffect(selected.id);
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus(effect.label);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:41:47 -05:00
|
|
|
function handleListModeInput(code: string, key: string): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
if (state.sortedPeerIds.length === 0) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'ArrowDown' || code === 'ArrowUp') {
|
|
|
|
|
state.listIndex =
|
|
|
|
|
code === 'ArrowDown'
|
|
|
|
|
? (state.listIndex + 1) % state.sortedPeerIds.length
|
|
|
|
|
: (state.listIndex - 1 + state.sortedPeerIds.length) % state.sortedPeerIds.length;
|
|
|
|
|
const peer = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
|
|
|
|
if (!peer) return;
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`,
|
2026-02-20 08:16:43 -05:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
const nextByInitial = findNextIndexByInitial(
|
|
|
|
|
state.sortedPeerIds,
|
|
|
|
|
state.listIndex,
|
|
|
|
|
key,
|
|
|
|
|
(peerId) => state.peers.get(peerId)?.nickname ?? '',
|
|
|
|
|
);
|
|
|
|
|
if (nextByInitial >= 0) {
|
|
|
|
|
state.listIndex = nextByInitial;
|
|
|
|
|
const peer = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
|
|
|
|
if (!peer) return;
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`,
|
2026-02-21 01:41:47 -05:00
|
|
|
);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const peer = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
|
|
|
|
if (!peer) return;
|
2026-02-21 04:03:49 -05:00
|
|
|
if (state.player.x === peer.x && state.player.y === peer.y) {
|
|
|
|
|
updateStatus('Already here.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
state.player.x = peer.x;
|
|
|
|
|
state.player.y = peer.y;
|
2026-02-20 18:19:42 -05:00
|
|
|
persistPlayerPosition();
|
2026-02-21 01:13:29 -05:00
|
|
|
void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN);
|
2026-02-20 08:16:43 -05:00
|
|
|
signaling.send({ type: 'update_position', x: peer.x, y: peer.y });
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus(`Moved to ${peer.nickname}.`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Exit list mode.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:41:47 -05:00
|
|
|
function handleListItemsModeInput(code: string, key: string): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
if (state.sortedItemIds.length === 0) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'ArrowDown' || code === 'ArrowUp') {
|
|
|
|
|
state.itemListIndex =
|
|
|
|
|
code === 'ArrowDown'
|
|
|
|
|
? (state.itemListIndex + 1) % state.sortedItemIds.length
|
|
|
|
|
: (state.itemListIndex - 1 + state.sortedItemIds.length) % state.sortedItemIds.length;
|
|
|
|
|
const item = state.items.get(state.sortedItemIds[state.itemListIndex]);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`,
|
2026-02-20 08:16:43 -05:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
const nextByInitial = findNextIndexByInitial(
|
|
|
|
|
state.sortedItemIds,
|
|
|
|
|
state.itemListIndex,
|
|
|
|
|
key,
|
|
|
|
|
(itemId) => {
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
return item ? itemLabel(item) : '';
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
if (nextByInitial >= 0) {
|
|
|
|
|
state.itemListIndex = nextByInitial;
|
|
|
|
|
const item = state.items.get(state.sortedItemIds[state.itemListIndex]);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
updateStatus(
|
2026-02-21 03:28:51 -05:00
|
|
|
`${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`,
|
2026-02-21 01:41:47 -05:00
|
|
|
);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const item = state.items.get(state.sortedItemIds[state.itemListIndex]);
|
|
|
|
|
if (!item) return;
|
2026-02-21 04:03:49 -05:00
|
|
|
if (state.player.x === item.x && state.player.y === item.y) {
|
|
|
|
|
updateStatus('Already here.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
state.player.x = item.x;
|
|
|
|
|
state.player.y = item.y;
|
2026-02-20 18:19:42 -05:00
|
|
|
persistPlayerPosition();
|
2026-02-21 01:13:29 -05:00
|
|
|
void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN);
|
2026-02-20 08:16:43 -05:00
|
|
|
signaling.send({ type: 'update_position', x: item.x, y: item.y });
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus(`Moved to ${itemLabel(item)}.`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Exit item list mode.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:41:47 -05:00
|
|
|
function handleAddItemModeInput(code: string, key: string): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'ArrowDown' || code === 'ArrowUp') {
|
|
|
|
|
state.addItemTypeIndex =
|
|
|
|
|
code === 'ArrowDown'
|
|
|
|
|
? (state.addItemTypeIndex + 1) % ITEM_TYPE_SEQUENCE.length
|
|
|
|
|
: (state.addItemTypeIndex - 1 + ITEM_TYPE_SEQUENCE.length) % ITEM_TYPE_SEQUENCE.length;
|
|
|
|
|
updateStatus(`${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
const nextByInitial = findNextIndexByInitial(
|
|
|
|
|
ITEM_TYPE_SEQUENCE,
|
|
|
|
|
state.addItemTypeIndex,
|
|
|
|
|
key,
|
|
|
|
|
(itemType) => itemTypeLabel(itemType),
|
|
|
|
|
);
|
|
|
|
|
if (nextByInitial >= 0) {
|
|
|
|
|
state.addItemTypeIndex = nextByInitial;
|
|
|
|
|
updateStatus(`${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'Enter') {
|
|
|
|
|
signaling.send({ type: 'item_add', itemType: ITEM_TYPE_SEQUENCE[state.addItemTypeIndex] });
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:41:47 -05:00
|
|
|
function handleSelectItemModeInput(code: string, key: string): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
if (state.selectedItemIds.length === 0) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.selectionContext = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'ArrowDown' || code === 'ArrowUp') {
|
|
|
|
|
state.selectedItemIndex =
|
|
|
|
|
code === 'ArrowDown'
|
|
|
|
|
? (state.selectedItemIndex + 1) % state.selectedItemIds.length
|
|
|
|
|
: (state.selectedItemIndex - 1 + state.selectedItemIds.length) % state.selectedItemIds.length;
|
|
|
|
|
const current = state.items.get(state.selectedItemIds[state.selectedItemIndex]);
|
|
|
|
|
if (current) {
|
|
|
|
|
updateStatus(itemLabel(current));
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
const nextByInitial = findNextIndexByInitial(
|
|
|
|
|
state.selectedItemIds,
|
|
|
|
|
state.selectedItemIndex,
|
|
|
|
|
key,
|
|
|
|
|
(itemId) => {
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
return item ? itemLabel(item) : '';
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
if (nextByInitial >= 0) {
|
|
|
|
|
state.selectedItemIndex = nextByInitial;
|
|
|
|
|
const current = state.items.get(state.selectedItemIds[state.selectedItemIndex]);
|
|
|
|
|
if (current) {
|
|
|
|
|
updateStatus(itemLabel(current));
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const selected = state.items.get(state.selectedItemIds[state.selectedItemIndex]);
|
|
|
|
|
if (!selected) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.selectionContext = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const context = state.selectionContext;
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.selectionContext = null;
|
|
|
|
|
if (context === 'pickup') {
|
|
|
|
|
signaling.send({ type: 'item_pickup', itemId: selected.id });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (context === 'delete') {
|
|
|
|
|
signaling.send({ type: 'item_delete', itemId: selected.id });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (context === 'edit') {
|
|
|
|
|
beginItemProperties(selected);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (context === 'use') {
|
|
|
|
|
useItem(selected);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 16:50:12 -05:00
|
|
|
if (context === 'inspect') {
|
2026-02-20 17:43:05 -05:00
|
|
|
beginItemProperties(selected, true);
|
2026-02-20 16:50:12 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.selectionContext = null;
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:41:47 -05:00
|
|
|
function handleItemPropertiesModeInput(code: string, key: string): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
const itemId = state.selectedItemId;
|
|
|
|
|
if (!itemId) {
|
|
|
|
|
state.mode = 'normal';
|
2026-02-20 17:46:43 -05:00
|
|
|
state.editingPropertyKey = null;
|
|
|
|
|
state.itemPropertyOptionValues = [];
|
|
|
|
|
state.itemPropertyOptionIndex = 0;
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
if (!item) {
|
|
|
|
|
state.mode = 'normal';
|
2026-02-20 17:46:43 -05:00
|
|
|
state.editingPropertyKey = null;
|
|
|
|
|
state.itemPropertyOptionValues = [];
|
|
|
|
|
state.itemPropertyOptionIndex = 0;
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus('Item no longer exists.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'ArrowDown' || code === 'ArrowUp') {
|
|
|
|
|
state.itemPropertyIndex =
|
|
|
|
|
code === 'ArrowDown'
|
|
|
|
|
? (state.itemPropertyIndex + 1) % state.itemPropertyKeys.length
|
|
|
|
|
: (state.itemPropertyIndex - 1 + state.itemPropertyKeys.length) % state.itemPropertyKeys.length;
|
|
|
|
|
const key = state.itemPropertyKeys[state.itemPropertyIndex];
|
|
|
|
|
const value = getItemPropertyValue(item, key);
|
2026-02-21 16:15:41 -05:00
|
|
|
updateStatus(`${itemPropertyLabel(key)}: ${value}`);
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
const nextByInitial = findNextIndexByInitial(
|
|
|
|
|
state.itemPropertyKeys,
|
|
|
|
|
state.itemPropertyIndex,
|
|
|
|
|
key,
|
|
|
|
|
(propertyKey) => propertyKey,
|
|
|
|
|
);
|
|
|
|
|
if (nextByInitial >= 0) {
|
|
|
|
|
state.itemPropertyIndex = nextByInitial;
|
|
|
|
|
const selectedKey = state.itemPropertyKeys[state.itemPropertyIndex];
|
|
|
|
|
const value = getItemPropertyValue(item, selectedKey);
|
2026-02-21 16:15:41 -05:00
|
|
|
updateStatus(`${itemPropertyLabel(selectedKey)}: ${value}`);
|
2026-02-21 01:41:47 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const key = state.itemPropertyKeys[state.itemPropertyIndex];
|
2026-02-20 18:01:27 -05:00
|
|
|
if (!EDITABLE_ITEM_PROPERTY_KEYS.has(key)) {
|
2026-02-21 16:15:41 -05:00
|
|
|
updateStatus(`${itemPropertyLabel(key)} is not editable.`);
|
2026-02-20 17:43:05 -05:00
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-21 16:15:41 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-20 17:46:43 -05:00
|
|
|
if (OPTION_ITEM_PROPERTY_VALUES[key]) {
|
|
|
|
|
openItemPropertyOptionSelect(item, key);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
2026-02-21 16:15:41 -05:00
|
|
|
updateStatus(`Edit ${itemPropertyLabel(key)}: ${state.nicknameInput}`);
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.selectedItemId = null;
|
|
|
|
|
state.itemPropertyKeys = [];
|
|
|
|
|
state.itemPropertyIndex = 0;
|
2026-02-20 17:46:43 -05:00
|
|
|
state.editingPropertyKey = null;
|
|
|
|
|
state.itemPropertyOptionValues = [];
|
|
|
|
|
state.itemPropertyOptionIndex = 0;
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus('Closed item properties.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:36:16 -05:00
|
|
|
function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boolean): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
const itemId = state.selectedItemId;
|
|
|
|
|
const propertyKey = state.editingPropertyKey;
|
|
|
|
|
if (!itemId || !propertyKey) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const value = state.nicknameInput.trim();
|
|
|
|
|
if (propertyKey === 'title') {
|
|
|
|
|
if (!value) {
|
|
|
|
|
updateStatus('Value is required.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, title: value });
|
|
|
|
|
} else if (propertyKey === 'streamUrl') {
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { streamUrl: value } });
|
|
|
|
|
} else if (propertyKey === 'enabled') {
|
|
|
|
|
const normalized = value.toLowerCase();
|
|
|
|
|
if (!['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'].includes(normalized)) {
|
|
|
|
|
updateStatus('enabled must be on or off.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const enabled = ['on', 'true', '1', 'yes'].includes(normalized);
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { enabled } });
|
|
|
|
|
} else if (propertyKey === 'volume') {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
|
|
|
|
|
updateStatus('volume must be an integer between 0 and 100.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { volume: parsed } });
|
2026-02-20 16:39:44 -05:00
|
|
|
} else if (propertyKey === 'effect') {
|
|
|
|
|
const normalized = value.trim().toLowerCase() as EffectId;
|
|
|
|
|
if (!EFFECT_IDS.has(normalized)) {
|
|
|
|
|
updateStatus(`effect must be one of: ${EFFECT_SEQUENCE.map((effect) => effect.id).join(', ')}.`);
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { effect: normalized } });
|
|
|
|
|
} else if (propertyKey === 'effectValue') {
|
|
|
|
|
const parsed = Number(value);
|
2026-02-21 03:10:53 -05:00
|
|
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) {
|
|
|
|
|
updateStatus('effectValue must be a number between 0 and 100.');
|
2026-02-20 16:39:44 -05:00
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } });
|
2026-02-21 00:55:19 -05:00
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { spaces: spaces.join(', ') } });
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (propertyKey === 'sides' || propertyKey === 'number') {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
|
|
|
|
updateStatus(`${propertyKey} must be an integer between 1 and 100.`);
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: parsed } });
|
|
|
|
|
}
|
|
|
|
|
state.mode = 'itemProperties';
|
|
|
|
|
state.editingPropertyKey = null;
|
|
|
|
|
replaceTextOnNextType = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'itemProperties';
|
|
|
|
|
state.editingPropertyKey = null;
|
|
|
|
|
replaceTextOnNextType = false;
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 03:36:16 -05:00
|
|
|
applyTextInputEdit(code, key, 500, ctrlKey, true);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:41:47 -05:00
|
|
|
function handleItemPropertyOptionSelectModeInput(code: string, key: string): void {
|
2026-02-20 17:46:43 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'ArrowDown' || code === 'ArrowUp') {
|
|
|
|
|
state.itemPropertyOptionIndex =
|
|
|
|
|
code === 'ArrowDown'
|
|
|
|
|
? (state.itemPropertyOptionIndex + 1) % state.itemPropertyOptionValues.length
|
|
|
|
|
: (state.itemPropertyOptionIndex - 1 + state.itemPropertyOptionValues.length) % state.itemPropertyOptionValues.length;
|
2026-02-20 17:58:15 -05:00
|
|
|
updateStatus(state.itemPropertyOptionValues[state.itemPropertyOptionIndex]);
|
2026-02-20 17:46:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
const nextByInitial = findNextIndexByInitial(
|
|
|
|
|
state.itemPropertyOptionValues,
|
|
|
|
|
state.itemPropertyOptionIndex,
|
|
|
|
|
key,
|
|
|
|
|
(value) => value,
|
|
|
|
|
);
|
|
|
|
|
if (nextByInitial >= 0) {
|
|
|
|
|
state.itemPropertyOptionIndex = nextByInitial;
|
|
|
|
|
updateStatus(state.itemPropertyOptionValues[state.itemPropertyOptionIndex]);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 17:46:43 -05:00
|
|
|
|
|
|
|
|
if (code === 'Enter') {
|
|
|
|
|
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 (code === 'Escape') {
|
|
|
|
|
state.mode = 'itemProperties';
|
|
|
|
|
state.editingPropertyKey = null;
|
|
|
|
|
state.itemPropertyOptionValues = [];
|
|
|
|
|
state.itemPropertyOptionIndex = 0;
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:36:16 -05:00
|
|
|
function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void {
|
2026-02-20 08:16:43 -05:00
|
|
|
if (code === 'Enter') {
|
|
|
|
|
const clean = sanitizeName(state.nicknameInput);
|
|
|
|
|
if (clean) {
|
|
|
|
|
const payload: OutgoingMessage = { type: 'update_nickname', nickname: clean };
|
|
|
|
|
signaling.send(payload);
|
|
|
|
|
audio.sfxUiConfirm();
|
|
|
|
|
} else {
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
replaceTextOnNextType = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'Escape') {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
replaceTextOnNextType = false;
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:36:16 -05:00
|
|
|
applyTextInputEdit(code, key, NICKNAME_MAX_LENGTH, ctrlKey, true);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTypingKey(code: string): boolean {
|
|
|
|
|
return code.startsWith('Key') || code === 'Space';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:41:47 -05:00
|
|
|
function findNextIndexByInitial<T>(
|
|
|
|
|
entries: readonly T[],
|
|
|
|
|
currentIndex: number,
|
|
|
|
|
key: string,
|
|
|
|
|
labelFor: (entry: T) => string,
|
|
|
|
|
): number {
|
|
|
|
|
if (entries.length === 0 || key.length !== 1 || !/[a-z]/i.test(key)) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
const target = key.toLowerCase();
|
|
|
|
|
for (let step = 1; step <= entries.length; step += 1) {
|
|
|
|
|
const candidateIndex = (currentIndex + step) % entries.length;
|
|
|
|
|
const label = labelFor(entries[candidateIndex]).trim().toLowerCase();
|
|
|
|
|
if (label.startsWith(target)) {
|
|
|
|
|
return candidateIndex;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
function setupInputHandlers(): void {
|
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
|
|
|
const code = event.code;
|
|
|
|
|
|
|
|
|
|
if (!dom.settingsModal.classList.contains('hidden') && code === 'Escape') {
|
|
|
|
|
closeSettings();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!state.running) return;
|
|
|
|
|
if (document.activeElement !== dom.canvas) return;
|
2026-02-21 03:43:11 -05:00
|
|
|
if (event.altKey) return;
|
|
|
|
|
if (event.ctrlKey && !isTextEditingMode(state.mode)) return;
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
if (state.mode !== 'normal' || !code.startsWith('Arrow')) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:43:11 -05:00
|
|
|
if (event.ctrlKey && isTextEditingMode(state.mode)) {
|
|
|
|
|
if (code === 'KeyC') {
|
|
|
|
|
const text = state.nicknameInput;
|
|
|
|
|
internalClipboardText = text;
|
|
|
|
|
void navigator.clipboard?.writeText(text).catch(() => undefined);
|
|
|
|
|
updateStatus('copied');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'KeyX') {
|
|
|
|
|
const text = state.nicknameInput;
|
|
|
|
|
internalClipboardText = text;
|
|
|
|
|
void navigator.clipboard?.writeText(text).catch(() => undefined);
|
|
|
|
|
state.nicknameInput = '';
|
|
|
|
|
state.cursorPos = 0;
|
|
|
|
|
replaceTextOnNextType = false;
|
|
|
|
|
updateStatus('cut');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'KeyV') {
|
2026-02-21 03:47:43 -05:00
|
|
|
void handlePasteShortcut();
|
|
|
|
|
return;
|
2026-02-21 03:43:11 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
if (isTypingKey(code) && state.keysPressed[code]) return;
|
|
|
|
|
|
|
|
|
|
if (state.mode === 'nickname') {
|
2026-02-21 03:36:16 -05:00
|
|
|
handleNicknameModeInput(code, event.key, event.ctrlKey);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (state.mode === 'chat') {
|
2026-02-21 03:36:16 -05:00
|
|
|
handleChatModeInput(code, event.key, event.ctrlKey);
|
2026-02-21 02:06:32 -05:00
|
|
|
} else if (state.mode === 'effectSelect') {
|
|
|
|
|
handleEffectSelectModeInput(code, event.key);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (state.mode === 'listUsers') {
|
2026-02-21 01:41:47 -05:00
|
|
|
handleListModeInput(code, event.key);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (state.mode === 'listItems') {
|
2026-02-21 01:41:47 -05:00
|
|
|
handleListItemsModeInput(code, event.key);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (state.mode === 'addItem') {
|
2026-02-21 01:41:47 -05:00
|
|
|
handleAddItemModeInput(code, event.key);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (state.mode === 'selectItem') {
|
2026-02-21 01:41:47 -05:00
|
|
|
handleSelectItemModeInput(code, event.key);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (state.mode === 'itemProperties') {
|
2026-02-21 01:41:47 -05:00
|
|
|
handleItemPropertiesModeInput(code, event.key);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else if (state.mode === 'itemPropertyEdit') {
|
2026-02-21 03:36:16 -05:00
|
|
|
handleItemPropertyEditModeInput(code, event.key, event.ctrlKey);
|
2026-02-20 17:46:43 -05:00
|
|
|
} else if (state.mode === 'itemPropertyOptionSelect') {
|
2026-02-21 01:41:47 -05:00
|
|
|
handleItemPropertyOptionSelectModeInput(code, event.key);
|
2026-02-20 08:16:43 -05:00
|
|
|
} else {
|
|
|
|
|
handleNormalModeInput(code, event.shiftKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.keysPressed[code] = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keyup', (event) => {
|
|
|
|
|
state.keysPressed[event.code] = false;
|
|
|
|
|
});
|
2026-02-20 18:02:42 -05:00
|
|
|
|
|
|
|
|
document.addEventListener('paste', (event) => {
|
|
|
|
|
if (document.activeElement !== dom.canvas) return;
|
|
|
|
|
if (!state.running) return;
|
2026-02-21 03:43:11 -05:00
|
|
|
const pasted = event.clipboardData?.getData('text') ?? internalClipboardText;
|
2026-02-20 18:02:42 -05:00
|
|
|
if (!pasteIntoActiveTextInput(pasted)) return;
|
|
|
|
|
event.preventDefault();
|
2026-02-21 03:43:11 -05:00
|
|
|
updateStatus('pasted');
|
2026-02-20 18:02:42 -05:00
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function populateAudioDevices(): Promise<void> {
|
|
|
|
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:30:54 -05:00
|
|
|
let temporaryStream: MediaStream | null = null;
|
2026-02-20 08:16:43 -05:00
|
|
|
try {
|
2026-02-20 16:30:54 -05:00
|
|
|
temporaryStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
2026-02-20 08:16:43 -05:00
|
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
|
|
|
|
|
|
|
|
dom.audioInputSelect.innerHTML = '';
|
|
|
|
|
dom.audioOutputSelect.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
for (const device of devices) {
|
|
|
|
|
if (device.kind === 'audioinput') {
|
|
|
|
|
dom.audioInputSelect.add(new Option(device.label || `Microphone ${dom.audioInputSelect.length + 1}`, device.deviceId));
|
|
|
|
|
}
|
|
|
|
|
if (device.kind === 'audiooutput') {
|
|
|
|
|
const option = new Option(device.label || `Speaker ${dom.audioOutputSelect.length + 1}`, device.deviceId);
|
|
|
|
|
dom.audioOutputSelect.add(option);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preferredInputDeviceId && Array.from(dom.audioInputSelect.options).some((option) => option.value === preferredInputDeviceId)) {
|
|
|
|
|
dom.audioInputSelect.value = preferredInputDeviceId;
|
|
|
|
|
preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName;
|
|
|
|
|
} else if (dom.audioInputSelect.options.length > 0) {
|
|
|
|
|
preferredInputDeviceId = dom.audioInputSelect.value;
|
|
|
|
|
preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName;
|
|
|
|
|
localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId);
|
|
|
|
|
localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preferredOutputDeviceId && Array.from(dom.audioOutputSelect.options).some((option) => option.value === preferredOutputDeviceId)) {
|
|
|
|
|
dom.audioOutputSelect.value = preferredOutputDeviceId;
|
|
|
|
|
preferredOutputDeviceName = dom.audioOutputSelect.selectedOptions[0]?.text || preferredOutputDeviceName;
|
|
|
|
|
void peerManager.setOutputDevice(preferredOutputDeviceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sinkCapable = typeof (HTMLMediaElement.prototype as HTMLMediaElement & { setSinkId?: unknown }).setSinkId === 'function';
|
|
|
|
|
dom.audioOutputSelect.disabled = !sinkCapable;
|
|
|
|
|
updateDeviceSummary();
|
|
|
|
|
} catch {
|
|
|
|
|
updateStatus('Could not list devices.');
|
2026-02-20 16:30:54 -05:00
|
|
|
} finally {
|
|
|
|
|
temporaryStream?.getTracks().forEach((track) => track.stop());
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openSettings(): void {
|
|
|
|
|
lastFocusedElement = document.activeElement;
|
|
|
|
|
dom.settingsModal.classList.remove('hidden');
|
|
|
|
|
void populateAudioDevices();
|
|
|
|
|
dom.audioInputSelect.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeSettings(): void {
|
|
|
|
|
dom.settingsModal.classList.add('hidden');
|
|
|
|
|
if (lastFocusedElement instanceof HTMLElement) {
|
|
|
|
|
lastFocusedElement.focus();
|
|
|
|
|
} else {
|
|
|
|
|
dom.canvas.focus();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setupUiHandlers(): void {
|
2026-02-20 18:19:42 -05:00
|
|
|
const persistOnUnload = (): void => {
|
|
|
|
|
if (!state.running) return;
|
|
|
|
|
persistPlayerPosition();
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener('pagehide', persistOnUnload);
|
|
|
|
|
window.addEventListener('beforeunload', persistOnUnload);
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
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<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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setupInputHandlers();
|
|
|
|
|
setupUiHandlers();
|
|
|
|
|
const storedNickname = sanitizeName(localStorage.getItem(NICKNAME_STORAGE_KEY) || '');
|
|
|
|
|
dom.preconnectNickname.value = storedNickname;
|
|
|
|
|
if (storedNickname) {
|
|
|
|
|
state.player.nickname = storedNickname;
|
|
|
|
|
}
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
updateDeviceSummary();
|
|
|
|
|
updateStatus('Welcome to the Chat Grid. Press the Settings button to configure your audio, then Connect to join the grid.');
|