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,
|
|
|
|
|
} from './audio/effects';
|
2026-02-22 03:06:31 -05:00
|
|
|
import {
|
|
|
|
|
RadioStationRuntime,
|
|
|
|
|
getProxyUrlForStream,
|
|
|
|
|
normalizeRadioChannel,
|
|
|
|
|
normalizeRadioEffect,
|
|
|
|
|
normalizeRadioEffectValue,
|
|
|
|
|
shouldProxyStreamUrl,
|
|
|
|
|
} from './audio/radioStationRuntime';
|
2026-02-21 16:13:48 -05:00
|
|
|
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
2026-02-23 00:05:01 -05:00
|
|
|
import {
|
|
|
|
|
DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT,
|
|
|
|
|
PIANO_INSTRUMENT_OPTIONS,
|
|
|
|
|
PianoSynth,
|
|
|
|
|
type PianoInstrumentId,
|
|
|
|
|
} from './audio/pianoSynth';
|
2026-02-21 19:37:08 -05:00
|
|
|
import { normalizeDegrees } from './audio/spatial';
|
2026-02-21 03:57:49 -05:00
|
|
|
import {
|
|
|
|
|
applyPastedText,
|
|
|
|
|
applyTextInput,
|
|
|
|
|
describeBackspaceDeletedCharacter,
|
2026-02-22 03:21:58 -05:00
|
|
|
describeDeleteDeletedCharacter,
|
2026-02-21 03:57:49 -05:00
|
|
|
describeCursorCharacter,
|
|
|
|
|
describeCursorWordOrCharacter,
|
|
|
|
|
mapTextInputKey,
|
|
|
|
|
moveCursorWordLeft,
|
|
|
|
|
moveCursorWordRight,
|
|
|
|
|
shouldReplaceCurrentText,
|
|
|
|
|
} from './input/textInput';
|
2026-02-22 16:49:15 -05:00
|
|
|
import { resolveMainModeCommand } from './input/mainCommandRouter';
|
2026-02-22 17:33:31 -05:00
|
|
|
import { dispatchModeInput } from './input/modeDispatcher';
|
2026-02-22 16:58:57 -05:00
|
|
|
import { handleListControlKey } from './input/listController';
|
|
|
|
|
import { getEditSessionAction } from './input/editSession';
|
2026-02-22 16:41:19 -05:00
|
|
|
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
2026-02-20 08:16:43 -05:00
|
|
|
import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
|
2026-02-22 17:05:36 -05:00
|
|
|
import { createOnMessageHandler } from './network/messageHandlers';
|
2026-02-20 08:16:43 -05:00
|
|
|
import { SignalingClient } from './network/signalingClient';
|
|
|
|
|
import { CanvasRenderer } from './render/canvasRenderer';
|
|
|
|
|
import {
|
|
|
|
|
GRID_SIZE,
|
|
|
|
|
MOVE_COOLDOWN_MS,
|
|
|
|
|
createInitialState,
|
|
|
|
|
getDirection,
|
|
|
|
|
getNearestItem,
|
|
|
|
|
getNearestPeer,
|
2026-02-23 01:08:50 -05:00
|
|
|
type GameMode,
|
2026-02-20 08:16:43 -05:00
|
|
|
type WorldItem,
|
|
|
|
|
} from './state/gameState';
|
2026-02-21 18:31:25 -05:00
|
|
|
import {
|
2026-02-21 19:12:58 -05:00
|
|
|
applyServerItemUiDefinitions,
|
|
|
|
|
getDefaultClockTimeZone,
|
|
|
|
|
getItemTypeGlobalProperties,
|
|
|
|
|
getItemTypeSequence,
|
2026-02-21 18:31:25 -05:00
|
|
|
getEditableItemPropertyKeys,
|
|
|
|
|
getInspectItemPropertyKeys,
|
|
|
|
|
getItemPropertyOptionValues,
|
2026-02-21 20:47:02 -05:00
|
|
|
getItemPropertyMetadata,
|
2026-02-21 18:31:25 -05:00
|
|
|
itemPropertyLabel,
|
2026-02-21 20:47:02 -05:00
|
|
|
getItemTypeTooltip,
|
2026-02-21 18:31:25 -05:00
|
|
|
itemTypeLabel,
|
|
|
|
|
} from './items/itemRegistry';
|
2026-02-22 17:05:36 -05:00
|
|
|
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
2026-02-22 17:33:31 -05:00
|
|
|
import { NICKNAME_STORAGE_KEY, SettingsStore } from './settings/settingsStore';
|
|
|
|
|
import { runConnectFlow, runDisconnectFlow, type ConnectFlowDeps } from './session/connectionFlow';
|
|
|
|
|
import { MediaSession } from './session/mediaSession';
|
|
|
|
|
import { type AudioLayerState } from './types/audio';
|
2026-02-22 17:05:36 -05:00
|
|
|
import { setupUiHandlers as setupDomUiHandlers } from './ui/domBindings';
|
2026-02-20 08:16:43 -05:00
|
|
|
import { PeerManager } from './webrtc/peerManager';
|
|
|
|
|
|
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_MAX_LENGTH = 32;
|
2026-02-22 16:16:16 -05:00
|
|
|
const MIC_CALIBRATION_DURATION_MS = 5000;
|
|
|
|
|
const MIC_CALIBRATION_SAMPLE_INTERVAL_MS = 50;
|
2026-02-22 16:24:27 -05:00
|
|
|
const MIC_CALIBRATION_MIN_GAIN = 0.5;
|
|
|
|
|
const MIC_CALIBRATION_MAX_GAIN = 4;
|
2026-02-22 16:16:16 -05:00
|
|
|
const MIC_CALIBRATION_TARGET_RMS = 0.12;
|
|
|
|
|
const MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD = 0.003;
|
2026-02-22 16:24:27 -05:00
|
|
|
const MIC_INPUT_GAIN_SCALE_MULTIPLIER = 2;
|
2026-02-22 16:31:36 -05:00
|
|
|
const MIC_INPUT_GAIN_STEP = 0.05;
|
2026-02-22 18:20:13 -05:00
|
|
|
const HEARTBEAT_INTERVAL_MS = 10_000;
|
2026-02-22 18:52:06 -05:00
|
|
|
const RECONNECT_DELAY_MS = 5_000;
|
2026-02-22 18:47:09 -05:00
|
|
|
const RECONNECT_MAX_ATTEMPTS = 3;
|
2026-02-22 19:31:44 -05:00
|
|
|
const AUDIO_SUBSCRIPTION_REFRESH_MS = 500;
|
2026-02-22 20:02:25 -05:00
|
|
|
const TELEPORT_SQUARES_PER_SECOND = 20;
|
|
|
|
|
const TELEPORT_SYNC_INTERVAL_MS = 100;
|
2026-02-22 23:42:17 -05:00
|
|
|
const PIANO_WHITE_KEY_MIDI_BY_CODE: Record<string, number> = {
|
|
|
|
|
KeyA: 60,
|
|
|
|
|
KeyS: 62,
|
|
|
|
|
KeyD: 64,
|
|
|
|
|
KeyF: 65,
|
|
|
|
|
KeyG: 67,
|
|
|
|
|
KeyH: 69,
|
|
|
|
|
KeyJ: 71,
|
|
|
|
|
KeyK: 72,
|
|
|
|
|
KeyL: 74,
|
|
|
|
|
Semicolon: 76,
|
|
|
|
|
Quote: 77,
|
|
|
|
|
};
|
|
|
|
|
const PIANO_SHARP_KEY_MIDI_BY_CODE: Record<string, number> = {
|
|
|
|
|
KeyW: 61,
|
|
|
|
|
KeyE: 63,
|
|
|
|
|
KeyT: 66,
|
|
|
|
|
KeyY: 68,
|
|
|
|
|
KeyU: 70,
|
|
|
|
|
KeyO: 73,
|
|
|
|
|
KeyP: 75,
|
|
|
|
|
BracketRight: 78,
|
|
|
|
|
};
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
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 = {
|
2026-02-22 19:54:55 -05:00
|
|
|
connectionStatus: HTMLElement;
|
2026-02-20 08:16:43 -05:00
|
|
|
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 = {
|
2026-02-22 19:54:55 -05:00
|
|
|
connectionStatus: requiredById('connectionStatus'),
|
2026-02-20 08:16:43 -05:00
|
|
|
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:51:07 -05:00
|
|
|
type HelpItem = {
|
|
|
|
|
keys: string;
|
|
|
|
|
description: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type HelpSection = {
|
|
|
|
|
title: string;
|
|
|
|
|
items: HelpItem[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type HelpData = {
|
|
|
|
|
sections: HelpSection[];
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-23 01:08:50 -05:00
|
|
|
/** Builds linearized help-view lines from sectioned help content. */
|
|
|
|
|
function buildHelpLines(help: HelpData): string[] {
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
for (const section of help.sections) {
|
|
|
|
|
lines.push(section.title);
|
|
|
|
|
for (const item of section.items) {
|
|
|
|
|
lines.push(`${item.keys}: ${item.description}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return lines;
|
|
|
|
|
}
|
|
|
|
|
|
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-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';
|
|
|
|
|
const APP_BASE_URL = import.meta.env.BASE_URL || '/';
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Resolves an app-relative path against the configured Vite base path. */
|
2026-02-20 08:16:43 -05:00
|
|
|
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-22 20:23:38 -05:00
|
|
|
const TELEPORT_START_SOUND_URL = withBase('sounds/teleport_start.ogg');
|
2026-02-22 20:26:59 -05:00
|
|
|
const TELEPORT_START_GAIN = 0.1;
|
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();
|
2026-02-22 17:33:31 -05:00
|
|
|
const settings = new SettingsStore();
|
2026-02-21 19:12:58 -05:00
|
|
|
let worldGridSize = GRID_SIZE;
|
2026-02-22 03:30:26 -05:00
|
|
|
let lastWallCollisionDirection: string | null = null;
|
2026-02-20 08:16:43 -05:00
|
|
|
let statusTimeout: number | null = null;
|
|
|
|
|
let lastFocusedElement: Element | null = null;
|
|
|
|
|
let lastAnnouncementText = '';
|
|
|
|
|
let lastAnnouncementAt = 0;
|
2026-02-22 17:33:31 -05:00
|
|
|
let outputMode = settings.loadOutputMode();
|
2026-02-20 08:16:43 -05:00
|
|
|
const messageBuffer: string[] = [];
|
|
|
|
|
let messageCursor = -1;
|
2026-02-22 03:14:01 -05:00
|
|
|
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
2026-02-21 19:37:08 -05:00
|
|
|
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig);
|
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-22 17:40:44 -05:00
|
|
|
let micGainLoopbackRestoreState: boolean | null = null;
|
2026-02-23 01:08:50 -05:00
|
|
|
let mainHelpViewerLines: string[] = [];
|
|
|
|
|
let pianoHelpViewerLines: string[] = [];
|
2026-02-21 16:55:41 -05:00
|
|
|
let helpViewerLines: string[] = [];
|
|
|
|
|
let helpViewerIndex = 0;
|
2026-02-23 01:08:50 -05:00
|
|
|
let helpViewerReturnMode: GameMode = 'normal';
|
2026-02-22 18:20:13 -05:00
|
|
|
let heartbeatTimerId: number | null = null;
|
|
|
|
|
let heartbeatNextPingId = -1;
|
2026-02-22 18:24:53 -05:00
|
|
|
let heartbeatAwaitingPong = false;
|
2026-02-22 18:20:13 -05:00
|
|
|
let reconnectInFlight = false;
|
|
|
|
|
let activeServerInstanceId: string | null = null;
|
2026-02-22 18:40:26 -05:00
|
|
|
let reloadScheduledForVersionMismatch = false;
|
2026-02-22 19:15:03 -05:00
|
|
|
let peerListenGainByNickname = settings.loadPeerListenGains();
|
2026-02-21 16:30:31 -05:00
|
|
|
let audioLayers: AudioLayerState = {
|
|
|
|
|
voice: true,
|
|
|
|
|
item: true,
|
|
|
|
|
media: true,
|
|
|
|
|
world: true,
|
|
|
|
|
};
|
2026-02-22 19:31:44 -05:00
|
|
|
let lastSubscriptionRefreshAt = 0;
|
2026-02-22 20:02:25 -05:00
|
|
|
let lastSubscriptionRefreshTileX = Math.round(state.player.x);
|
|
|
|
|
let lastSubscriptionRefreshTileY = Math.round(state.player.y);
|
2026-02-22 19:31:44 -05:00
|
|
|
let subscriptionRefreshInFlight = false;
|
|
|
|
|
let subscriptionRefreshPending = false;
|
2026-02-22 20:50:04 -05:00
|
|
|
let suppressItemPropertyEchoUntilMs = 0;
|
2026-02-22 20:23:38 -05:00
|
|
|
let activeTeleportLoopStop: (() => void) | null = null;
|
|
|
|
|
let activeTeleportLoopToken = 0;
|
2026-02-22 23:42:17 -05:00
|
|
|
let activePianoItemId: string | null = null;
|
|
|
|
|
const activePianoKeys = new Set<string>();
|
2026-02-23 00:22:36 -05:00
|
|
|
const activePianoKeyMidi = new Map<string, number>();
|
2026-02-23 00:36:36 -05:00
|
|
|
const activePianoHeldOrder: string[] = [];
|
|
|
|
|
let activePianoMonophonicKey: string | null = null;
|
2026-02-22 23:42:17 -05:00
|
|
|
const activeRemotePianoKeys = new Set<string>();
|
2026-02-22 23:51:13 -05:00
|
|
|
let pianoPreviewTimeoutId: number | null = null;
|
2026-02-22 20:02:25 -05:00
|
|
|
let activeTeleport:
|
|
|
|
|
| {
|
|
|
|
|
startX: number;
|
|
|
|
|
startY: number;
|
|
|
|
|
targetX: number;
|
|
|
|
|
targetY: number;
|
|
|
|
|
startedAtMs: number;
|
|
|
|
|
durationMs: number;
|
|
|
|
|
lastSyncAtMs: number;
|
|
|
|
|
lastSentX: number;
|
|
|
|
|
lastSentY: number;
|
|
|
|
|
completionStatus: string;
|
|
|
|
|
}
|
|
|
|
|
| null = null;
|
2026-02-22 23:42:17 -05:00
|
|
|
const pianoSynth = new PianoSynth();
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
const signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
|
|
|
const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`;
|
2026-02-22 18:29:44 -05:00
|
|
|
const signaling = new SignalingClient(signalingUrl, handleSignalingStatus);
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
const peerManager = new PeerManager(
|
|
|
|
|
audio,
|
|
|
|
|
(targetId, payload) => {
|
|
|
|
|
signaling.send({ type: 'signal', targetId, ...payload });
|
|
|
|
|
},
|
2026-02-22 17:33:31 -05:00
|
|
|
() => mediaSession.getOutboundStream(),
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus,
|
|
|
|
|
);
|
2026-02-22 17:33:31 -05:00
|
|
|
const mediaSession = new MediaSession({
|
|
|
|
|
state,
|
|
|
|
|
audio,
|
|
|
|
|
peerManager,
|
|
|
|
|
settings,
|
|
|
|
|
dom,
|
|
|
|
|
updateStatus,
|
|
|
|
|
micCalibrationDurationMs: MIC_CALIBRATION_DURATION_MS,
|
|
|
|
|
micCalibrationSampleIntervalMs: MIC_CALIBRATION_SAMPLE_INTERVAL_MS,
|
|
|
|
|
micCalibrationMinGain: MIC_CALIBRATION_MIN_GAIN,
|
|
|
|
|
micCalibrationMaxGain: MIC_CALIBRATION_MAX_GAIN,
|
|
|
|
|
micCalibrationTargetRms: MIC_CALIBRATION_TARGET_RMS,
|
|
|
|
|
micCalibrationActiveRmsThreshold: MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD,
|
|
|
|
|
micInputGainScaleMultiplier: MIC_INPUT_GAIN_SCALE_MULTIPLIER,
|
|
|
|
|
micInputGainStep: MIC_INPUT_GAIN_STEP,
|
|
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.setOutputMode(outputMode);
|
|
|
|
|
|
|
|
|
|
loadEffectLevels();
|
2026-02-21 16:30:31 -05:00
|
|
|
loadAudioLayerState();
|
2026-02-22 16:16:16 -05:00
|
|
|
loadMicInputGain();
|
2026-02-22 18:33:55 -05:00
|
|
|
loadMasterVolume();
|
2026-02-21 16:51:07 -05:00
|
|
|
void loadHelp();
|
2026-02-23 01:08:50 -05:00
|
|
|
void loadPianoHelp();
|
2026-02-21 02:19:33 -05:00
|
|
|
void loadChangelog();
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Fetches a required DOM element and casts it to the requested element type. */
|
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-22 17:23:33 -05:00
|
|
|
/** Returns the configured display timezone when valid, otherwise the default fallback. */
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Formats epoch milliseconds as `YYYY-MM-DD HH:mm` in the configured display timezone. */
|
2026-02-21 02:52:01 -05:00
|
|
|
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-22 17:23:33 -05:00
|
|
|
/** Toggles updates panel visibility and syncs associated ARIA state. */
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Renders help sections into the footer help container and builds linearized viewer lines. */
|
2026-02-21 16:51:07 -05:00
|
|
|
function renderHelp(help: HelpData): void {
|
2026-02-23 01:08:50 -05:00
|
|
|
const lines = buildHelpLines(help);
|
2026-02-21 16:51:07 -05:00
|
|
|
dom.instructions.innerHTML = '';
|
|
|
|
|
const heading = document.createElement('h2');
|
|
|
|
|
heading.textContent = 'Help';
|
|
|
|
|
dom.instructions.appendChild(heading);
|
|
|
|
|
for (const section of help.sections) {
|
|
|
|
|
const sectionHeading = document.createElement('h3');
|
|
|
|
|
sectionHeading.textContent = section.title;
|
|
|
|
|
dom.instructions.appendChild(sectionHeading);
|
2026-02-21 16:55:41 -05:00
|
|
|
lines.push(section.title);
|
2026-02-21 16:51:07 -05:00
|
|
|
for (const item of section.items) {
|
|
|
|
|
const line = document.createElement('p');
|
|
|
|
|
const keys = document.createElement('b');
|
|
|
|
|
keys.textContent = `${item.keys}:`;
|
|
|
|
|
line.appendChild(keys);
|
|
|
|
|
line.append(` ${item.description}`);
|
|
|
|
|
dom.instructions.appendChild(line);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-23 01:08:50 -05:00
|
|
|
mainHelpViewerLines = lines;
|
2026-02-21 16:55:41 -05:00
|
|
|
helpViewerLines = lines;
|
|
|
|
|
helpViewerIndex = 0;
|
2026-02-21 16:51:07 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Loads runtime help content from `help.json` and applies it when available. */
|
2026-02-21 16:51:07 -05:00
|
|
|
async function loadHelp(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(withBase('help.json'), { cache: 'no-store' });
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const help = (await response.json()) as HelpData;
|
|
|
|
|
if (!Array.isArray(help.sections) || help.sections.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
renderHelp(help);
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep existing/static help if loading fails.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 01:08:50 -05:00
|
|
|
/** Loads piano-mode help content from `piano.json` for in-mode help viewing. */
|
|
|
|
|
async function loadPianoHelp(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(withBase('piano.json'), { cache: 'no-store' });
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const help = (await response.json()) as HelpData;
|
|
|
|
|
if (!Array.isArray(help.sections) || help.sections.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pianoHelpViewerLines = buildHelpLines(help);
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep piano help unavailable if loading fails.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Renders changelog sections into the collapsible updates panel. */
|
2026-02-21 02:19:33 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Loads changelog entries from `changelog.json` and wires the panel toggle button. */
|
2026-02-21 02:19:33 -05:00
|
|
|
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-22 17:23:33 -05:00
|
|
|
/** Announces status text via ARIA with brief de-duplication and auto-clear timing. */
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:54:55 -05:00
|
|
|
/** Updates persistent connection/update status shown under the page heading. */
|
|
|
|
|
function setConnectionStatus(message: string): void {
|
|
|
|
|
dom.connectionStatus.textContent = String(message).trim();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Sanitizes user nicknames to printable/safe characters and enforces max length. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function sanitizeName(value: string): string {
|
|
|
|
|
return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Enables/disables the connect button based on state and nickname validity. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function updateConnectAvailability(): void {
|
|
|
|
|
if (state.running) {
|
|
|
|
|
dom.connectButton.disabled = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const hasNickname = sanitizeName(dom.preconnectNickname.value).length > 0;
|
2026-02-22 17:33:31 -05:00
|
|
|
dom.connectButton.disabled = mediaSession.isConnecting() || !hasNickname;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Restores persisted outbound effect levels from local storage. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function loadEffectLevels(): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
const parsed = settings.loadEffectLevels();
|
|
|
|
|
if (!parsed) return;
|
|
|
|
|
audio.setEffectLevels(parsed);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Persists current outbound effect levels to local storage. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function persistEffectLevels(): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
settings.saveEffectLevels(audio.getEffectLevels());
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Restores local audio-layer toggles and applies initial voice-layer state. */
|
2026-02-21 16:30:31 -05:00
|
|
|
function loadAudioLayerState(): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
audioLayers = settings.loadAudioLayers();
|
2026-02-21 16:30:31 -05:00
|
|
|
audio.setVoiceLayerEnabled(audioLayers.voice);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Persists current audio-layer toggles to local storage. */
|
2026-02-21 16:30:31 -05:00
|
|
|
function persistAudioLayerState(): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
settings.saveAudioLayers(audioLayers);
|
2026-02-21 16:30:31 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Clamps microphone input gain to the supported calibration bounds. */
|
2026-02-22 16:16:16 -05:00
|
|
|
function clampMicInputGain(value: number): number {
|
|
|
|
|
if (!Number.isFinite(value)) return 1;
|
|
|
|
|
return Math.max(MIC_CALIBRATION_MIN_GAIN, Math.min(MIC_CALIBRATION_MAX_GAIN, value));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Loads persisted microphone input gain and applies default when missing. */
|
2026-02-22 16:16:16 -05:00
|
|
|
function loadMicInputGain(): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
const parsed = settings.loadMicInputGain();
|
|
|
|
|
if (parsed === null) {
|
2026-02-22 16:24:27 -05:00
|
|
|
audio.setOutboundInputGain(2);
|
2026-02-22 16:16:16 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
audio.setOutboundInputGain(clampMicInputGain(parsed));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Persists microphone input gain to local storage. */
|
2026-02-22 16:16:16 -05:00
|
|
|
function persistMicInputGain(value: number): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
settings.saveMicInputGain(value);
|
2026-02-22 16:16:16 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:33:55 -05:00
|
|
|
/** Loads persisted master output volume and applies default when missing. */
|
|
|
|
|
function loadMasterVolume(): void {
|
|
|
|
|
const parsed = settings.loadMasterVolume();
|
|
|
|
|
if (parsed === null) {
|
|
|
|
|
audio.setMasterVolume(50);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
audio.setMasterVolume(parsed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Persists master output volume to local storage. */
|
|
|
|
|
function persistMasterVolume(value: number): void {
|
|
|
|
|
settings.saveMasterVolume(value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:15:03 -05:00
|
|
|
/** Normalizes nickname for local per-user listen-gain preference keys. */
|
|
|
|
|
function peerListenGainKey(nickname: string): string {
|
|
|
|
|
return nickname.trim().toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Returns configured listen gain for a nickname (default 1.0). */
|
|
|
|
|
function getPeerListenGainForNickname(nickname: string): number {
|
|
|
|
|
const key = peerListenGainKey(nickname);
|
|
|
|
|
const raw = peerListenGainByNickname[key];
|
|
|
|
|
if (!Number.isFinite(raw)) return 1;
|
|
|
|
|
return clampMicInputGain(raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Persists local listen gain preference for a nickname. */
|
|
|
|
|
function setPeerListenGainForNickname(nickname: string, gain: number): void {
|
|
|
|
|
const key = peerListenGainKey(nickname);
|
|
|
|
|
peerListenGainByNickname = { ...peerListenGainByNickname, [key]: clampMicInputGain(gain) };
|
|
|
|
|
settings.savePeerListenGains(peerListenGainByNickname);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Applies stored listen-gain preferences to currently known peer runtimes. */
|
|
|
|
|
function applyConfiguredPeerListenGains(): void {
|
|
|
|
|
for (const [peerId, peerState] of state.peers.entries()) {
|
|
|
|
|
peerManager.setPeerListenGain(peerId, getPeerListenGainForNickname(peerState.nickname));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Applies current layer toggles to peer voice, media streams, and item emitters. */
|
2026-02-21 16:30:31 -05:00
|
|
|
async function applyAudioLayerState(): Promise<void> {
|
|
|
|
|
audio.setVoiceLayerEnabled(audioLayers.voice);
|
|
|
|
|
if (audioLayers.voice) {
|
|
|
|
|
await peerManager.resumeRemoteAudio();
|
|
|
|
|
} else {
|
|
|
|
|
peerManager.suspendRemoteAudio();
|
|
|
|
|
}
|
2026-02-22 19:31:44 -05:00
|
|
|
const listenerPosition = { x: state.player.x, y: state.player.y };
|
|
|
|
|
await radioRuntime.setLayerEnabled(audioLayers.media, state.items.values(), listenerPosition);
|
|
|
|
|
await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values(), listenerPosition);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:07:02 -05:00
|
|
|
/** Refreshes distance-gated radio/item stream subscriptions for a listener position. */
|
|
|
|
|
async function refreshAudioSubscriptionsAt(listenerPosition: { x: number; y: number }, force = false): Promise<void> {
|
2026-02-22 20:43:57 -05:00
|
|
|
await refreshAudioSubscriptionsForListeners([listenerPosition], force);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Refreshes distance-gated radio/item stream subscriptions for one or more listener positions. */
|
|
|
|
|
async function refreshAudioSubscriptionsForListeners(
|
|
|
|
|
listenerPositions: Array<{ x: number; y: number }>,
|
|
|
|
|
force = false,
|
|
|
|
|
): Promise<void> {
|
2026-02-22 19:31:44 -05:00
|
|
|
if (!state.running) return;
|
2026-02-22 20:43:57 -05:00
|
|
|
if (listenerPositions.length === 0) return;
|
2026-02-22 19:31:44 -05:00
|
|
|
const now = Date.now();
|
2026-02-22 20:43:57 -05:00
|
|
|
const anchorListener = listenerPositions[listenerPositions.length - 1];
|
|
|
|
|
const tileX = Math.round(anchorListener.x);
|
|
|
|
|
const tileY = Math.round(anchorListener.y);
|
2026-02-22 20:02:25 -05:00
|
|
|
const moved = tileX !== lastSubscriptionRefreshTileX || tileY !== lastSubscriptionRefreshTileY;
|
2026-02-22 19:31:44 -05:00
|
|
|
if (!force && !moved && now - lastSubscriptionRefreshAt < AUDIO_SUBSCRIPTION_REFRESH_MS) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (subscriptionRefreshInFlight) {
|
|
|
|
|
subscriptionRefreshPending = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
subscriptionRefreshInFlight = true;
|
|
|
|
|
lastSubscriptionRefreshAt = now;
|
2026-02-22 20:02:25 -05:00
|
|
|
lastSubscriptionRefreshTileX = tileX;
|
|
|
|
|
lastSubscriptionRefreshTileY = tileY;
|
2026-02-22 19:31:44 -05:00
|
|
|
try {
|
2026-02-22 20:43:57 -05:00
|
|
|
await radioRuntime.sync(state.items.values(), listenerPositions);
|
|
|
|
|
await itemEmitRuntime.sync(state.items.values(), listenerPositions);
|
2026-02-22 19:31:44 -05:00
|
|
|
} finally {
|
|
|
|
|
subscriptionRefreshInFlight = false;
|
|
|
|
|
if (subscriptionRefreshPending) {
|
|
|
|
|
subscriptionRefreshPending = false;
|
|
|
|
|
void refreshAudioSubscriptions(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 16:30:31 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:07:02 -05:00
|
|
|
/** Refreshes distance-gated radio/item stream subscriptions on movement or timer cadence. */
|
|
|
|
|
async function refreshAudioSubscriptions(force = false): Promise<void> {
|
|
|
|
|
if (activeTeleport) {
|
2026-02-22 20:43:57 -05:00
|
|
|
await refreshAudioSubscriptionsForListeners(
|
|
|
|
|
[
|
|
|
|
|
{ x: activeTeleport.startX, y: activeTeleport.startY },
|
|
|
|
|
{ x: activeTeleport.targetX, y: activeTeleport.targetY },
|
|
|
|
|
],
|
|
|
|
|
force,
|
|
|
|
|
);
|
2026-02-22 20:07:02 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await refreshAudioSubscriptionsAt({ x: state.player.x, y: state.player.y }, force);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Toggles a single audio layer and applies the change immediately. */
|
2026-02-21 16:30:31 -05:00
|
|
|
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-22 18:29:44 -05:00
|
|
|
/** Routes signaling transport status messages through chat buffer + status output. */
|
|
|
|
|
function handleSignalingStatus(message: string): void {
|
2026-02-22 18:40:26 -05:00
|
|
|
if (message === 'Connected.') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 18:47:09 -05:00
|
|
|
if (message === 'Disconnected.' && state.running && !reconnectInFlight) {
|
2026-02-22 19:54:55 -05:00
|
|
|
setConnectionStatus('Disconnected from server. Reconnecting...');
|
2026-02-22 18:52:06 -05:00
|
|
|
pushChatMessage('Disconnected from server. Reconnecting...');
|
2026-02-22 18:47:09 -05:00
|
|
|
void reconnectAfterSocketClose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 18:52:06 -05:00
|
|
|
if (message === 'Disconnected.') {
|
2026-02-22 19:54:55 -05:00
|
|
|
setConnectionStatus('Disconnected from server.');
|
2026-02-22 18:52:06 -05:00
|
|
|
pushChatMessage('Disconnected from server.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 18:29:44 -05:00
|
|
|
pushChatMessage(message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:41:26 -05:00
|
|
|
/** Performs cache-busted navigation so the browser loads the newest client bundle. */
|
2026-02-22 18:57:40 -05:00
|
|
|
function reloadClientForVersion(version: string): void {
|
|
|
|
|
const nextUrl = new URL(window.location.href);
|
|
|
|
|
nextUrl.searchParams.set('v', version || 'unknown');
|
|
|
|
|
nextUrl.searchParams.set('t', String(Date.now()));
|
|
|
|
|
window.location.replace(nextUrl.toString());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:49:14 -05:00
|
|
|
/** Returns true when this page load came from the version-mismatch reload flow. */
|
|
|
|
|
function isVersionReloadedSession(): boolean {
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
return params.has('v') && params.has('t');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Appends a chat/system line to the bounded status history buffer. */
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Classifies a system chat line into a corresponding notification sound, when applicable. */
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Resolves incoming sound references to playable URLs, including proxy routing when needed. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function resolveIncomingSoundUrl(url: string): string {
|
|
|
|
|
const raw = String(url || '').trim();
|
|
|
|
|
if (!raw) return '';
|
2026-02-22 03:06:31 -05:00
|
|
|
if (/^https?:/i.test(raw)) {
|
|
|
|
|
return shouldProxyStreamUrl(raw) ? getProxyUrlForStream(raw) : raw;
|
|
|
|
|
}
|
|
|
|
|
if (/^(data:|blob:)/i.test(raw)) return raw;
|
2026-02-20 08:16:43 -05:00
|
|
|
if (raw.startsWith('/sounds/')) {
|
|
|
|
|
return withBase(raw.slice(1));
|
|
|
|
|
}
|
|
|
|
|
if (raw.startsWith('sounds/')) {
|
|
|
|
|
return withBase(raw);
|
|
|
|
|
}
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Navigates buffered chat lines and speaks the selected entry. */
|
2026-02-20 08:16:43 -05:00
|
|
|
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]);
|
2026-02-22 18:38:11 -05:00
|
|
|
if (target === 'prev' || target === 'next') {
|
|
|
|
|
const atStart = messageCursor === 0;
|
|
|
|
|
const atEnd = messageCursor === messageBuffer.length - 1;
|
|
|
|
|
if (atStart || atEnd) {
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Updates compact input/output device summary labels in the pre-connect UI. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function updateDeviceSummary(): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
mediaSession.updateDeviceSummary();
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns peer nicknames currently occupying the given grid cell. */
|
2026-02-20 08:16:43 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns a user-facing item label including type information. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function itemLabel(item: WorldItem): string {
|
|
|
|
|
return `${item.title} (${itemTypeLabel(item.type)})`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Resolves effective spatial audio configuration for an item, with global fallbacks. */
|
2026-02-21 19:37:08 -05:00
|
|
|
function getItemSpatialConfig(item: WorldItem): { range: number; directional: boolean; facingDeg: number } {
|
|
|
|
|
const global = getItemTypeGlobalProperties(item.type);
|
2026-02-21 20:31:34 -05:00
|
|
|
const rawParamRange = Number(item.params.emitRange);
|
|
|
|
|
const rawGlobalRange = Number(global.emitRange);
|
|
|
|
|
const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange;
|
2026-02-21 19:37:08 -05:00
|
|
|
const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15;
|
2026-02-21 22:20:15 -05:00
|
|
|
const directional = typeof item.params.directional === 'boolean' ? item.params.directional : global.directional === true;
|
2026-02-21 19:37:08 -05:00
|
|
|
const rawFacing = Number(item.params.facing ?? 0);
|
|
|
|
|
const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0;
|
|
|
|
|
return { range, directional, facingDeg };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 23:42:17 -05:00
|
|
|
/** Resolves piano params with safe defaults for local play mode. */
|
2026-02-23 00:05:01 -05:00
|
|
|
function getPianoParams(item: WorldItem): {
|
|
|
|
|
instrument: PianoInstrumentId;
|
2026-02-23 00:22:36 -05:00
|
|
|
voiceMode: 'mono' | 'poly';
|
|
|
|
|
octave: number;
|
2026-02-23 00:05:01 -05:00
|
|
|
attack: number;
|
|
|
|
|
decay: number;
|
|
|
|
|
release: number;
|
|
|
|
|
brightness: number;
|
|
|
|
|
emitRange: number;
|
|
|
|
|
} {
|
2026-02-22 23:42:17 -05:00
|
|
|
const rawInstrument = String(item.params.instrument ?? 'piano').trim().toLowerCase();
|
|
|
|
|
const instrument: PianoInstrumentId =
|
|
|
|
|
rawInstrument === 'electric_piano' ||
|
|
|
|
|
rawInstrument === 'guitar' ||
|
|
|
|
|
rawInstrument === 'organ' ||
|
|
|
|
|
rawInstrument === 'bass' ||
|
|
|
|
|
rawInstrument === 'violin' ||
|
|
|
|
|
rawInstrument === 'synth_lead' ||
|
2026-02-22 23:51:13 -05:00
|
|
|
rawInstrument === 'nintendo' ||
|
2026-02-22 23:42:17 -05:00
|
|
|
rawInstrument === 'drum_kit'
|
|
|
|
|
? rawInstrument
|
|
|
|
|
: 'piano';
|
|
|
|
|
const rawAttack = Number(item.params.attack);
|
|
|
|
|
const rawDecay = Number(item.params.decay);
|
2026-02-23 00:22:36 -05:00
|
|
|
const rawOctave = Number(item.params.octave);
|
|
|
|
|
const rawVoiceMode = String(item.params.voiceMode ?? defaultsVoiceModeForInstrument(instrument)).trim().toLowerCase();
|
2026-02-23 00:05:01 -05:00
|
|
|
const rawRelease = Number(item.params.release);
|
|
|
|
|
const rawBrightness = Number(item.params.brightness);
|
2026-02-22 23:42:17 -05:00
|
|
|
const rawEmitRange = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type).emitRange ?? 15);
|
2026-02-23 00:05:01 -05:00
|
|
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
2026-02-22 23:42:17 -05:00
|
|
|
return {
|
|
|
|
|
instrument,
|
2026-02-23 00:22:36 -05:00
|
|
|
voiceMode: rawVoiceMode === 'mono' ? 'mono' : 'poly',
|
|
|
|
|
octave: Math.max(-2, Math.min(2, Number.isFinite(rawOctave) ? Math.round(rawOctave) : defaultsOctaveForInstrument(instrument))),
|
2026-02-23 00:05:01 -05:00
|
|
|
attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : defaults.attack)),
|
|
|
|
|
decay: Math.max(0, Math.min(100, Number.isFinite(rawDecay) ? Math.round(rawDecay) : defaults.decay)),
|
|
|
|
|
release: Math.max(0, Math.min(100, Number.isFinite(rawRelease) ? Math.round(rawRelease) : defaults.release)),
|
|
|
|
|
brightness: Math.max(0, Math.min(100, Number.isFinite(rawBrightness) ? Math.round(rawBrightness) : defaults.brightness)),
|
2026-02-22 23:42:17 -05:00
|
|
|
emitRange: Math.max(5, Math.min(20, Number.isFinite(rawEmitRange) ? Math.round(rawEmitRange) : 15)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 00:22:36 -05:00
|
|
|
/** Returns default voice mode for a given piano instrument. */
|
|
|
|
|
function defaultsVoiceModeForInstrument(instrument: PianoInstrumentId): 'mono' | 'poly' {
|
|
|
|
|
if (instrument === 'bass' || instrument === 'violin' || instrument === 'brass') return 'mono';
|
|
|
|
|
return 'poly';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Returns default octave offset for a given piano instrument. */
|
|
|
|
|
function defaultsOctaveForInstrument(instrument: PianoInstrumentId): number {
|
|
|
|
|
return instrument === 'bass' ? -1 : 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 23:42:17 -05:00
|
|
|
/** Normalizes arbitrary instrument strings into supported piano synth ids. */
|
|
|
|
|
function normalizePianoInstrument(value: unknown): PianoInstrumentId {
|
|
|
|
|
const raw = String(value ?? 'piano').trim().toLowerCase();
|
|
|
|
|
if (raw === 'electric_piano') return 'electric_piano';
|
|
|
|
|
if (raw === 'guitar') return 'guitar';
|
|
|
|
|
if (raw === 'organ') return 'organ';
|
|
|
|
|
if (raw === 'bass') return 'bass';
|
|
|
|
|
if (raw === 'violin') return 'violin';
|
|
|
|
|
if (raw === 'synth_lead') return 'synth_lead';
|
2026-02-23 00:22:36 -05:00
|
|
|
if (raw === 'brass') return 'brass';
|
2026-02-22 23:51:13 -05:00
|
|
|
if (raw === 'nintendo') return 'nintendo';
|
2026-02-22 23:42:17 -05:00
|
|
|
if (raw === 'drum_kit') return 'drum_kit';
|
|
|
|
|
return 'piano';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Returns playable MIDI note for a piano-mode key code, or null when unmapped. */
|
|
|
|
|
function getPianoMidiForCode(code: string): number | null {
|
|
|
|
|
if (code in PIANO_WHITE_KEY_MIDI_BY_CODE) {
|
|
|
|
|
return PIANO_WHITE_KEY_MIDI_BY_CODE[code]!;
|
|
|
|
|
}
|
|
|
|
|
if (code in PIANO_SHARP_KEY_MIDI_BY_CODE) {
|
|
|
|
|
return PIANO_SHARP_KEY_MIDI_BY_CODE[code]!;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Starts local piano key mode for one used piano item. */
|
|
|
|
|
async function startPianoUseMode(itemId: string): Promise<void> {
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
if (!item || item.type !== 'piano') return;
|
|
|
|
|
activePianoItemId = itemId;
|
|
|
|
|
activePianoKeys.clear();
|
2026-02-23 00:26:38 -05:00
|
|
|
activePianoKeyMidi.clear();
|
2026-02-23 00:36:36 -05:00
|
|
|
activePianoHeldOrder.length = 0;
|
|
|
|
|
activePianoMonophonicKey = null;
|
2026-02-22 23:42:17 -05:00
|
|
|
state.mode = 'pianoUse';
|
|
|
|
|
await audio.ensureContext();
|
2026-02-23 01:08:50 -05:00
|
|
|
updateStatus(`using ${item.title}, press question mark for help.`);
|
2026-02-22 23:42:17 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Exits local piano key mode and releases any held notes. */
|
|
|
|
|
function stopPianoUseMode(announce = true): void {
|
|
|
|
|
if (!activePianoItemId) return;
|
|
|
|
|
const itemId = activePianoItemId;
|
|
|
|
|
for (const code of Array.from(activePianoKeys)) {
|
2026-02-23 00:22:36 -05:00
|
|
|
const midi = activePianoKeyMidi.get(code);
|
|
|
|
|
if (!Number.isFinite(midi)) continue;
|
2026-02-22 23:42:17 -05:00
|
|
|
signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false });
|
|
|
|
|
pianoSynth.noteOff(code);
|
|
|
|
|
}
|
|
|
|
|
activePianoItemId = null;
|
|
|
|
|
activePianoKeys.clear();
|
2026-02-23 00:22:36 -05:00
|
|
|
activePianoKeyMidi.clear();
|
2026-02-23 00:36:36 -05:00
|
|
|
activePianoHeldOrder.length = 0;
|
|
|
|
|
activePianoMonophonicKey = null;
|
2026-02-22 23:42:17 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
if (announce) {
|
|
|
|
|
updateStatus('Stopped piano.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 00:36:36 -05:00
|
|
|
/** Starts one local piano note and sends the matching network note-on packet. */
|
|
|
|
|
function playLocalPianoNote(
|
|
|
|
|
item: WorldItem,
|
|
|
|
|
itemId: string,
|
|
|
|
|
keyId: string,
|
|
|
|
|
midi: number,
|
|
|
|
|
config: ReturnType<typeof getPianoParams>,
|
|
|
|
|
): void {
|
|
|
|
|
const ctx = audio.context;
|
|
|
|
|
const destination = audio.getOutputDestinationNode();
|
|
|
|
|
if (!ctx || !destination) return;
|
|
|
|
|
const sourceX = item.carrierId === state.player.id ? state.player.x : item.x;
|
|
|
|
|
const sourceY = item.carrierId === state.player.id ? state.player.y : item.y;
|
|
|
|
|
pianoSynth.noteOn(
|
|
|
|
|
keyId,
|
|
|
|
|
`local:${itemId}`,
|
|
|
|
|
midi,
|
|
|
|
|
config.instrument,
|
|
|
|
|
config.voiceMode,
|
|
|
|
|
config.attack,
|
|
|
|
|
config.decay,
|
|
|
|
|
config.release,
|
|
|
|
|
config.brightness,
|
|
|
|
|
{ audioCtx: ctx, destination },
|
|
|
|
|
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange },
|
|
|
|
|
);
|
|
|
|
|
signaling.send({ type: 'item_piano_note', itemId, keyId, midi, on: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Handles key release while in piano mode, including mono fallback retrigger behavior. */
|
|
|
|
|
function handlePianoUseModeKeyUp(code: string): void {
|
|
|
|
|
if (!activePianoKeys.delete(code)) return;
|
|
|
|
|
const orderIndex = activePianoHeldOrder.lastIndexOf(code);
|
|
|
|
|
if (orderIndex >= 0) {
|
|
|
|
|
activePianoHeldOrder.splice(orderIndex, 1);
|
|
|
|
|
}
|
|
|
|
|
const itemId = activePianoItemId;
|
|
|
|
|
const midi = activePianoKeyMidi.get(code);
|
|
|
|
|
activePianoKeyMidi.delete(code);
|
|
|
|
|
if (!itemId || !Number.isFinite(midi)) {
|
|
|
|
|
pianoSynth.noteOff(code);
|
|
|
|
|
if (activePianoMonophonicKey === code) {
|
|
|
|
|
activePianoMonophonicKey = null;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
if (!item || item.type !== 'piano') {
|
|
|
|
|
pianoSynth.noteOff(code);
|
|
|
|
|
if (activePianoMonophonicKey === code) {
|
|
|
|
|
activePianoMonophonicKey = null;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const config = getPianoParams(item);
|
|
|
|
|
if (config.voiceMode !== 'mono') {
|
|
|
|
|
pianoSynth.noteOff(code);
|
|
|
|
|
signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (activePianoMonophonicKey !== code) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pianoSynth.noteOff(code);
|
|
|
|
|
signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false });
|
|
|
|
|
const fallbackCode = activePianoHeldOrder[activePianoHeldOrder.length - 1] ?? null;
|
|
|
|
|
if (!fallbackCode) {
|
|
|
|
|
activePianoMonophonicKey = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const fallbackMidi = activePianoKeyMidi.get(fallbackCode);
|
|
|
|
|
if (!Number.isFinite(fallbackMidi)) {
|
|
|
|
|
activePianoMonophonicKey = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
activePianoMonophonicKey = fallbackCode;
|
|
|
|
|
playLocalPianoNote(item, itemId, fallbackCode, fallbackMidi, config);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 23:51:13 -05:00
|
|
|
/** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */
|
2026-02-23 00:05:01 -05:00
|
|
|
async function previewPianoSettingChange(
|
|
|
|
|
item: WorldItem,
|
2026-02-23 00:45:17 -05:00
|
|
|
overrides: Partial<{ instrument: PianoInstrumentId; octave: number; attack: number; decay: number; release: number; brightness: number }>,
|
2026-02-23 00:05:01 -05:00
|
|
|
): Promise<void> {
|
2026-02-22 23:51:13 -05:00
|
|
|
if (item.type !== 'piano') return;
|
|
|
|
|
await audio.ensureContext();
|
|
|
|
|
const ctx = audio.context;
|
|
|
|
|
const destination = audio.getOutputDestinationNode();
|
|
|
|
|
if (!ctx || !destination) return;
|
|
|
|
|
const current = getPianoParams(item);
|
|
|
|
|
const instrument = overrides.instrument ?? current.instrument;
|
2026-02-23 00:45:17 -05:00
|
|
|
const octave = Math.max(-2, Math.min(2, Math.round(overrides.octave ?? current.octave)));
|
2026-02-22 23:51:13 -05:00
|
|
|
const attack = Math.max(0, Math.min(100, Math.round(overrides.attack ?? current.attack)));
|
|
|
|
|
const decay = Math.max(0, Math.min(100, Math.round(overrides.decay ?? current.decay)));
|
2026-02-23 00:05:01 -05:00
|
|
|
const release = Math.max(0, Math.min(100, Math.round(overrides.release ?? current.release)));
|
|
|
|
|
const brightness = Math.max(0, Math.min(100, Math.round(overrides.brightness ?? current.brightness)));
|
2026-02-22 23:51:13 -05:00
|
|
|
const sourceX = item.carrierId === state.player.id ? state.player.x : item.x;
|
|
|
|
|
const sourceY = item.carrierId === state.player.id ? state.player.y : item.y;
|
|
|
|
|
const previewKeyId = '__piano_preview_c4__';
|
|
|
|
|
pianoSynth.noteOff(previewKeyId);
|
|
|
|
|
pianoSynth.noteOn(
|
|
|
|
|
previewKeyId,
|
2026-02-23 00:22:36 -05:00
|
|
|
'preview',
|
2026-02-23 00:45:17 -05:00
|
|
|
Math.max(0, Math.min(127, 60 + octave * 12)),
|
2026-02-22 23:51:13 -05:00
|
|
|
instrument,
|
2026-02-23 00:22:36 -05:00
|
|
|
'poly',
|
2026-02-22 23:51:13 -05:00
|
|
|
attack,
|
|
|
|
|
decay,
|
2026-02-23 00:05:01 -05:00
|
|
|
release,
|
|
|
|
|
brightness,
|
2026-02-22 23:51:13 -05:00
|
|
|
{ audioCtx: ctx, destination },
|
|
|
|
|
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: current.emitRange },
|
|
|
|
|
);
|
|
|
|
|
if (pianoPreviewTimeoutId !== null) {
|
|
|
|
|
window.clearTimeout(pianoPreviewTimeoutId);
|
|
|
|
|
}
|
|
|
|
|
pianoPreviewTimeoutId = window.setTimeout(() => {
|
|
|
|
|
pianoSynth.noteOff(previewKeyId);
|
|
|
|
|
pianoPreviewTimeoutId = null;
|
|
|
|
|
}, 320);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 23:42:17 -05:00
|
|
|
/** Plays one inbound piano note from another user using item spatial position. */
|
|
|
|
|
function playRemotePianoNote(note: {
|
|
|
|
|
itemId: string;
|
|
|
|
|
senderId: string;
|
|
|
|
|
keyId: string;
|
|
|
|
|
midi: number;
|
|
|
|
|
instrument: string;
|
2026-02-23 00:22:36 -05:00
|
|
|
voiceMode: 'mono' | 'poly';
|
|
|
|
|
octave: number;
|
2026-02-22 23:42:17 -05:00
|
|
|
attack: number;
|
|
|
|
|
decay: number;
|
2026-02-23 00:05:01 -05:00
|
|
|
release: number;
|
|
|
|
|
brightness: number;
|
2026-02-22 23:42:17 -05:00
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
emitRange: number;
|
|
|
|
|
}): void {
|
|
|
|
|
const ctx = audio.context;
|
|
|
|
|
const destination = audio.getOutputDestinationNode();
|
|
|
|
|
if (!ctx || !destination) return;
|
2026-02-23 00:22:36 -05:00
|
|
|
const runtimeKey = `${note.senderId}:${note.itemId}:${note.keyId}`;
|
2026-02-22 23:42:17 -05:00
|
|
|
if (activeRemotePianoKeys.has(runtimeKey)) return;
|
2026-02-23 00:22:36 -05:00
|
|
|
if (note.voiceMode === 'mono') {
|
|
|
|
|
stopRemotePianoNotesForSource(note.senderId, note.itemId);
|
|
|
|
|
}
|
2026-02-22 23:42:17 -05:00
|
|
|
activeRemotePianoKeys.add(runtimeKey);
|
|
|
|
|
pianoSynth.noteOn(
|
|
|
|
|
runtimeKey,
|
2026-02-23 00:22:36 -05:00
|
|
|
`remote:${note.senderId}:${note.itemId}`,
|
2026-02-22 23:42:17 -05:00
|
|
|
Math.max(0, Math.min(127, Math.round(note.midi))),
|
|
|
|
|
normalizePianoInstrument(note.instrument),
|
2026-02-23 00:22:36 -05:00
|
|
|
note.voiceMode,
|
2026-02-22 23:42:17 -05:00
|
|
|
Math.max(0, Math.min(100, Math.round(note.attack))),
|
|
|
|
|
Math.max(0, Math.min(100, Math.round(note.decay))),
|
2026-02-23 00:05:01 -05:00
|
|
|
Math.max(0, Math.min(100, Math.round(note.release))),
|
|
|
|
|
Math.max(0, Math.min(100, Math.round(note.brightness))),
|
2026-02-22 23:42:17 -05:00
|
|
|
{ audioCtx: ctx, destination },
|
|
|
|
|
{
|
|
|
|
|
x: note.x - state.player.x,
|
|
|
|
|
y: note.y - state.player.y,
|
|
|
|
|
range: Math.max(1, Math.round(note.emitRange)),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Stops one inbound piano note previously started for another user. */
|
|
|
|
|
function stopRemotePianoNote(senderId: string, keyId: string): void {
|
2026-02-23 00:22:36 -05:00
|
|
|
const suffix = `:${keyId}`;
|
|
|
|
|
for (const runtimeKey of Array.from(activeRemotePianoKeys)) {
|
|
|
|
|
if (!runtimeKey.startsWith(`${senderId}:`)) continue;
|
|
|
|
|
if (!runtimeKey.endsWith(suffix)) continue;
|
|
|
|
|
activeRemotePianoKeys.delete(runtimeKey);
|
|
|
|
|
pianoSynth.noteOff(runtimeKey);
|
|
|
|
|
}
|
2026-02-22 23:42:17 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Stops all currently active remote piano notes for a sender id. */
|
|
|
|
|
function stopAllRemotePianoNotesForSender(senderId: string): void {
|
|
|
|
|
const prefix = `${senderId}:`;
|
|
|
|
|
for (const runtimeKey of Array.from(activeRemotePianoKeys)) {
|
|
|
|
|
if (!runtimeKey.startsWith(prefix)) continue;
|
|
|
|
|
activeRemotePianoKeys.delete(runtimeKey);
|
|
|
|
|
pianoSynth.noteOff(runtimeKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 00:22:36 -05:00
|
|
|
/** Stops all remote piano notes for one sender+item source group. */
|
|
|
|
|
function stopRemotePianoNotesForSource(senderId: string, itemId: string): void {
|
|
|
|
|
const prefix = `${senderId}:${itemId}:`;
|
|
|
|
|
for (const runtimeKey of Array.from(activeRemotePianoKeys)) {
|
|
|
|
|
if (!runtimeKey.startsWith(prefix)) continue;
|
|
|
|
|
activeRemotePianoKeys.delete(runtimeKey);
|
|
|
|
|
pianoSynth.noteOff(runtimeKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Enters help-view mode and announces the first help line. */
|
2026-02-23 01:08:50 -05:00
|
|
|
function openHelpViewer(lines: string[], returnMode: GameMode = 'normal'): void {
|
|
|
|
|
if (lines.length === 0) {
|
2026-02-21 16:55:41 -05:00
|
|
|
updateStatus('Help unavailable.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 01:08:50 -05:00
|
|
|
helpViewerLines = lines;
|
|
|
|
|
helpViewerReturnMode = returnMode;
|
2026-02-21 16:55:41 -05:00
|
|
|
state.mode = 'helpView';
|
|
|
|
|
helpViewerIndex = 0;
|
|
|
|
|
updateStatus(helpViewerLines[helpViewerIndex]);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns non-carried items occupying a given grid position. */
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns the item currently carried by the local player, if any. */
|
2026-02-20 08:16:43 -05:00
|
|
|
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-22 17:23:33 -05:00
|
|
|
/** Opens the shared item-selection flow for the provided context and items. */
|
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-22 17:23:33 -05:00
|
|
|
/** Opens item property browsing/editing mode for one item. */
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Sends an item-use request for the selected item. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function useItem(item: WorldItem): void {
|
|
|
|
|
signaling.send({ type: 'item_use', itemId: item.id });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Opens option-list selection mode for list-based item properties. */
|
2026-02-20 17:46:43 -05:00
|
|
|
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
2026-02-21 18:31:25 -05:00
|
|
|
const options = getItemPropertyOptionValues(key);
|
2026-02-20 17:46:43 -05:00
|
|
|
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-22 17:23:33 -05:00
|
|
|
/** Returns the active text-input max length for the current UI mode, if applicable. */
|
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;
|
2026-02-22 16:28:11 -05:00
|
|
|
if (mode === 'micGainEdit') return 8;
|
2026-02-20 18:02:42 -05:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Applies pasted text into whichever mode currently owns the shared text edit buffer. */
|
2026-02-20 18:02:42 -05:00
|
|
|
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-22 17:23:33 -05:00
|
|
|
/** Whether the current mode uses the shared single-line text editing pipeline. */
|
2026-02-21 03:43:11 -05:00
|
|
|
function isTextEditingMode(mode: typeof state.mode): boolean {
|
2026-02-22 16:28:11 -05:00
|
|
|
return mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit' || mode === 'micGainEdit';
|
2026-02-21 03:43:11 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Applies keyboard edits to the shared text buffer and emits cursor/deletion speech hints. */
|
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
|
|
|
}
|
2026-02-22 03:21:58 -05:00
|
|
|
if (code === 'Delete') {
|
|
|
|
|
const spoken = describeDeleteDeletedCharacter(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
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns a formatted display value for an item property key, with per-key normalization. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function getItemPropertyValue(item: WorldItem, key: string): string {
|
2026-02-21 22:20:15 -05:00
|
|
|
const toSoundDisplayName = (rawValue: unknown): string => {
|
|
|
|
|
const raw = String(rawValue ?? '').trim();
|
|
|
|
|
if (!raw) return 'none';
|
|
|
|
|
if (raw.toLowerCase() === 'none') return 'none';
|
|
|
|
|
const withoutQuery = raw.split('?')[0].split('#')[0];
|
|
|
|
|
const segments = withoutQuery.split('/').filter((part) => part.length > 0);
|
|
|
|
|
return segments[segments.length - 1] ?? raw;
|
|
|
|
|
};
|
2026-02-20 08:16:43 -05:00
|
|
|
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 22:20:15 -05:00
|
|
|
if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound);
|
|
|
|
|
if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound);
|
2026-02-20 08:16:43 -05:00
|
|
|
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
|
2026-02-21 22:20:15 -05:00
|
|
|
if (key === 'directional') {
|
|
|
|
|
if (typeof item.params.directional === 'boolean') {
|
|
|
|
|
return item.params.directional ? 'on' : 'off';
|
|
|
|
|
}
|
|
|
|
|
return getItemTypeGlobalProperties(item.type).directional === true ? 'on' : 'off';
|
|
|
|
|
}
|
2026-02-21 19:12:58 -05:00
|
|
|
if (key === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone());
|
2026-02-21 16:01:40 -05:00
|
|
|
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
|
2026-02-21 22:55:20 -05:00
|
|
|
if (key === 'mediaChannel') return normalizeRadioChannel(item.params.mediaChannel);
|
|
|
|
|
if (key === 'mediaEffect') return normalizeRadioEffect(item.params.mediaEffect);
|
|
|
|
|
if (key === 'mediaEffectValue') return String(normalizeRadioEffectValue(item.params.mediaEffectValue));
|
|
|
|
|
if (key === 'emitEffect') return normalizeRadioEffect(item.params.emitEffect);
|
|
|
|
|
if (key === 'emitEffectValue') return String(normalizeRadioEffectValue(item.params.emitEffectValue));
|
2026-02-21 19:37:08 -05:00
|
|
|
if (key === 'facing') {
|
|
|
|
|
const parsed = Number(item.params.facing ?? 0);
|
|
|
|
|
if (!Number.isFinite(parsed)) return '0';
|
|
|
|
|
return String(Math.round(normalizeDegrees(parsed) * 10) / 10);
|
|
|
|
|
}
|
2026-02-21 20:31:34 -05:00
|
|
|
if (key === 'emitRange') {
|
|
|
|
|
const parsed = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type)?.emitRange ?? 15);
|
|
|
|
|
if (!Number.isFinite(parsed)) return '15';
|
|
|
|
|
return String(Math.round(parsed));
|
|
|
|
|
}
|
2026-02-21 23:21:40 -05:00
|
|
|
const paramValue = item.params[key];
|
|
|
|
|
if (paramValue !== undefined) return String(paramValue);
|
2026-02-21 19:12:58 -05:00
|
|
|
const globalValue = getItemTypeGlobalProperties(item.type)?.[key];
|
2026-02-21 01:13:29 -05:00
|
|
|
if (globalValue !== undefined) return String(globalValue);
|
2026-02-21 23:21:40 -05:00
|
|
|
return '';
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Infers value type for item-property help when metadata is missing. */
|
2026-02-21 20:47:02 -05:00
|
|
|
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
|
|
|
|
|
if (key === 'useSound' || key === 'emitSound') return 'sound';
|
2026-02-22 02:12:03 -05:00
|
|
|
if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean';
|
2026-02-23 00:22:36 -05:00
|
|
|
if (key === 'mediaChannel' || key === 'mediaEffect' || key === 'emitEffect' || key === 'timeZone' || key === 'instrument' || key === 'voiceMode') return 'list';
|
2026-02-21 20:47:02 -05:00
|
|
|
if (
|
|
|
|
|
key === 'x' ||
|
|
|
|
|
key === 'y' ||
|
|
|
|
|
key === 'version' ||
|
2026-02-21 22:38:48 -05:00
|
|
|
key === 'mediaVolume' ||
|
|
|
|
|
key === 'emitVolume' ||
|
2026-02-21 23:10:17 -05:00
|
|
|
key === 'emitSoundSpeed' ||
|
2026-02-21 23:17:18 -05:00
|
|
|
key === 'emitSoundTempo' ||
|
2026-02-21 22:55:20 -05:00
|
|
|
key === 'mediaEffectValue' ||
|
|
|
|
|
key === 'emitEffectValue' ||
|
2026-02-21 20:47:02 -05:00
|
|
|
key === 'facing' ||
|
|
|
|
|
key === 'emitRange' ||
|
2026-02-23 00:22:36 -05:00
|
|
|
key === 'octave' ||
|
2026-02-22 23:42:17 -05:00
|
|
|
key === 'attack' ||
|
|
|
|
|
key === 'decay' ||
|
2026-02-23 00:05:01 -05:00
|
|
|
key === 'release' ||
|
|
|
|
|
key === 'brightness' ||
|
2026-02-21 20:47:02 -05:00
|
|
|
key === 'sides' ||
|
|
|
|
|
key === 'number' ||
|
|
|
|
|
key === 'useCooldownMs'
|
|
|
|
|
) {
|
|
|
|
|
return 'number';
|
|
|
|
|
}
|
|
|
|
|
if (key in item.params || key in getItemTypeGlobalProperties(item.type)) {
|
|
|
|
|
const value = item.params[key] ?? getItemTypeGlobalProperties(item.type)?.[key];
|
|
|
|
|
if (typeof value === 'boolean') return 'boolean';
|
|
|
|
|
if (typeof value === 'number') return 'number';
|
|
|
|
|
if (typeof value === 'string') return 'text';
|
|
|
|
|
}
|
|
|
|
|
return 'text';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Provides tooltip fallbacks for inspect-only/system item properties. */
|
2026-02-21 21:03:18 -05:00
|
|
|
function getFallbackInspectPropertyTooltip(key: string): string | undefined {
|
|
|
|
|
if (key === 'type') return 'The item type identifier.';
|
|
|
|
|
if (key === 'x') return 'X coordinate on the grid.';
|
|
|
|
|
if (key === 'y') return 'Y coordinate on the grid.';
|
|
|
|
|
if (key === 'carrierId') return 'Current carrier user id, or none when on the ground.';
|
|
|
|
|
if (key === 'version') return 'Server version for this item, incremented after each update.';
|
|
|
|
|
if (key === 'createdBy') return 'User id of who created this item.';
|
|
|
|
|
if (key === 'createdAt') return 'Timestamp when this item was created.';
|
|
|
|
|
if (key === 'updatedAt') return 'Timestamp when this item was last updated.';
|
|
|
|
|
if (key === 'capabilities') return 'Server-declared actions supported by this item.';
|
|
|
|
|
if (key === 'useSound') return 'One-shot sound played when use succeeds.';
|
|
|
|
|
if (key === 'emitSound') return 'Looping emitted sound source for this item.';
|
|
|
|
|
if (key === 'useCooldownMs') return 'Global cooldown in milliseconds between uses.';
|
|
|
|
|
if (key === 'directional') return 'Whether emitted audio favors item facing direction.';
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns whether a property is editable for the given item type. */
|
2026-02-22 04:03:30 -05:00
|
|
|
function isItemPropertyEditable(item: WorldItem, key: string): boolean {
|
|
|
|
|
return getEditableItemPropertyKeys(item).includes(key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Builds spoken tooltip/help text for the current item property row. */
|
2026-02-21 20:47:02 -05:00
|
|
|
function describeItemPropertyHelp(item: WorldItem, key: string): string {
|
|
|
|
|
const metadata = getItemPropertyMetadata(item.type, key);
|
|
|
|
|
const parts: string[] = [];
|
2026-02-21 21:03:18 -05:00
|
|
|
const tooltip = metadata?.tooltip ?? getFallbackInspectPropertyTooltip(key);
|
|
|
|
|
if (tooltip) {
|
|
|
|
|
parts.push(tooltip);
|
2026-02-21 20:47:02 -05:00
|
|
|
} else {
|
|
|
|
|
parts.push('No tooltip available.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const valueType = metadata?.valueType ?? inferItemPropertyValueType(item, key);
|
|
|
|
|
if (valueType) {
|
|
|
|
|
parts.push(`Type: ${valueType}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (metadata?.range) {
|
|
|
|
|
const stepText = metadata.range.step !== undefined ? ` step ${metadata.range.step}` : '';
|
|
|
|
|
parts.push(`Range: ${metadata.range.min} to ${metadata.range.max}${stepText}.`);
|
2026-02-21 20:59:18 -05:00
|
|
|
} else {
|
|
|
|
|
const options = getItemPropertyOptionValues(key);
|
|
|
|
|
if (options && options.length > 0) {
|
|
|
|
|
parts.push(`Options: ${options.join(', ')}.`);
|
|
|
|
|
}
|
2026-02-21 20:47:02 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 03:50:52 -05:00
|
|
|
if (metadata?.maxLength !== undefined) {
|
|
|
|
|
parts.push(`Max length: ${metadata.maxLength} characters.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 04:03:30 -05:00
|
|
|
parts.push(isItemPropertyEditable(item, key) ? 'Editable.' : 'Read only.');
|
2026-02-21 20:47:02 -05:00
|
|
|
return parts.join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Validates and normalizes numeric item-property edits using metadata ranges/steps. */
|
2026-02-21 20:47:02 -05:00
|
|
|
function validateNumericItemPropertyInput(
|
|
|
|
|
item: WorldItem,
|
|
|
|
|
key: string,
|
|
|
|
|
rawValue: string,
|
|
|
|
|
requireInteger: boolean,
|
|
|
|
|
): { ok: true; value: number } | { ok: false; message: string } {
|
|
|
|
|
const parsed = Number(rawValue);
|
|
|
|
|
if (!Number.isFinite(parsed)) {
|
|
|
|
|
return { ok: false, message: `${itemPropertyLabel(key)} must be a number.` };
|
|
|
|
|
}
|
|
|
|
|
if (requireInteger && !Number.isInteger(parsed)) {
|
|
|
|
|
return { ok: false, message: `${itemPropertyLabel(key)} must be an integer.` };
|
|
|
|
|
}
|
|
|
|
|
const range = getItemPropertyMetadata(item.type, key)?.range;
|
|
|
|
|
if (range && (parsed < range.min || parsed > range.max)) {
|
|
|
|
|
return { ok: false, message: `${itemPropertyLabel(key)} must be between ${range.min} and ${range.max}.` };
|
|
|
|
|
}
|
|
|
|
|
if (!range) {
|
|
|
|
|
return { ok: true, value: parsed };
|
|
|
|
|
}
|
|
|
|
|
const step = range.step;
|
|
|
|
|
if (step && step > 0) {
|
2026-02-22 16:31:36 -05:00
|
|
|
const normalized = snapNumberToStep(parsed, step, range.min);
|
|
|
|
|
return { ok: true, value: normalized };
|
2026-02-21 20:47:02 -05:00
|
|
|
}
|
|
|
|
|
return { ok: true, value: parsed };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns singular/plural square wording for distance announcements. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function squareWord(distance: number): string {
|
|
|
|
|
return distance === 1 ? 'square' : 'squares';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Builds a spoken distance+direction phrase between two grid coordinates. */
|
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-22 22:19:42 -05:00
|
|
|
/** Formats a coordinate with up to 2 decimals while trimming trailing zeros. */
|
|
|
|
|
function formatCoordinate(value: number): string {
|
|
|
|
|
if (!Number.isFinite(value)) return '0';
|
|
|
|
|
return value.toFixed(2).replace(/\.?0+$/, '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Persists current local player coordinates for reconnect/refresh restore. */
|
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-22 17:23:33 -05:00
|
|
|
/** Picks one random footstep sample URL. */
|
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-22 20:23:38 -05:00
|
|
|
/** Stops active teleport loop audio, if one is running. */
|
|
|
|
|
function stopTeleportLoopAudio(): void {
|
|
|
|
|
if (!activeTeleportLoopStop) return;
|
|
|
|
|
activeTeleportLoopStop();
|
|
|
|
|
activeTeleportLoopStop = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:02:25 -05:00
|
|
|
/** Starts animated teleport movement toward a target tile at fixed squares-per-second pace. */
|
|
|
|
|
function startTeleportTo(targetX: number, targetY: number, completionStatus: string): void {
|
|
|
|
|
const startX = state.player.x;
|
|
|
|
|
const startY = state.player.y;
|
|
|
|
|
const distance = Math.hypot(targetX - startX, targetY - startY);
|
|
|
|
|
const durationMs = Math.max(1, (distance / TELEPORT_SQUARES_PER_SECOND) * 1000);
|
|
|
|
|
const nowMs = performance.now();
|
|
|
|
|
activeTeleport = {
|
|
|
|
|
startX,
|
|
|
|
|
startY,
|
|
|
|
|
targetX,
|
|
|
|
|
targetY,
|
|
|
|
|
startedAtMs: nowMs,
|
|
|
|
|
durationMs,
|
|
|
|
|
lastSyncAtMs: nowMs,
|
|
|
|
|
lastSentX: Math.round(startX),
|
|
|
|
|
lastSentY: Math.round(startY),
|
|
|
|
|
completionStatus,
|
|
|
|
|
};
|
2026-02-22 20:23:38 -05:00
|
|
|
stopTeleportLoopAudio();
|
|
|
|
|
activeTeleportLoopToken += 1;
|
|
|
|
|
const loopToken = activeTeleportLoopToken;
|
2026-02-22 20:26:59 -05:00
|
|
|
void audio.startLoopingSample(TELEPORT_START_SOUND_URL, TELEPORT_START_GAIN).then((stopLoop) => {
|
2026-02-22 20:23:38 -05:00
|
|
|
if (!stopLoop) return;
|
|
|
|
|
if (activeTeleport && loopToken === activeTeleportLoopToken) {
|
|
|
|
|
activeTeleportLoopStop = stopLoop;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
stopLoop();
|
|
|
|
|
});
|
2026-02-22 20:43:57 -05:00
|
|
|
void refreshAudioSubscriptionsForListeners(
|
|
|
|
|
[
|
|
|
|
|
{ x: startX, y: startY },
|
|
|
|
|
{ x: targetX, y: targetY },
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
);
|
2026-02-22 20:02:25 -05:00
|
|
|
state.keysPressed.ArrowUp = false;
|
|
|
|
|
state.keysPressed.ArrowDown = false;
|
|
|
|
|
state.keysPressed.ArrowLeft = false;
|
|
|
|
|
state.keysPressed.ArrowRight = false;
|
|
|
|
|
lastWallCollisionDirection = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Advances active teleport animation, syncs intermediate server positions, and finalizes arrival. */
|
|
|
|
|
function updateTeleport(): void {
|
|
|
|
|
if (!activeTeleport) return;
|
|
|
|
|
const nowMs = performance.now();
|
|
|
|
|
const elapsedMs = nowMs - activeTeleport.startedAtMs;
|
|
|
|
|
const progress = Math.max(0, Math.min(1, elapsedMs / activeTeleport.durationMs));
|
|
|
|
|
state.player.x = activeTeleport.startX + (activeTeleport.targetX - activeTeleport.startX) * progress;
|
|
|
|
|
state.player.y = activeTeleport.startY + (activeTeleport.targetY - activeTeleport.startY) * progress;
|
|
|
|
|
|
|
|
|
|
if (nowMs - activeTeleport.lastSyncAtMs >= TELEPORT_SYNC_INTERVAL_MS) {
|
|
|
|
|
activeTeleport.lastSyncAtMs = nowMs;
|
|
|
|
|
const syncX = Math.round(state.player.x);
|
|
|
|
|
const syncY = Math.round(state.player.y);
|
|
|
|
|
if (syncX !== activeTeleport.lastSentX || syncY !== activeTeleport.lastSentY) {
|
|
|
|
|
activeTeleport.lastSentX = syncX;
|
|
|
|
|
activeTeleport.lastSentY = syncY;
|
|
|
|
|
signaling.send({ type: 'update_position', x: syncX, y: syncY });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (progress < 1) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const completionStatus = activeTeleport.completionStatus;
|
|
|
|
|
state.player.x = activeTeleport.targetX;
|
|
|
|
|
state.player.y = activeTeleport.targetY;
|
|
|
|
|
signaling.send({ type: 'update_position', x: activeTeleport.targetX, y: activeTeleport.targetY });
|
|
|
|
|
activeTeleport = null;
|
2026-02-22 20:23:38 -05:00
|
|
|
stopTeleportLoopAudio();
|
2026-02-22 20:02:25 -05:00
|
|
|
persistPlayerPosition();
|
|
|
|
|
void refreshAudioSubscriptions(true);
|
|
|
|
|
void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN);
|
|
|
|
|
updateStatus(completionStatus);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Main animation/update loop for movement, spatial audio, and rendering. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function gameLoop(): void {
|
|
|
|
|
if (!state.running) return;
|
2026-02-22 20:02:25 -05:00
|
|
|
updateTeleport();
|
2026-02-20 08:16:43 -05:00
|
|
|
handleMovement();
|
2026-02-22 20:07:02 -05:00
|
|
|
if (!activeTeleport) {
|
|
|
|
|
void refreshAudioSubscriptions();
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y });
|
2026-02-22 21:37:15 -05:00
|
|
|
audio.updateSpatialSamples({ 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Applies held-arrow movement with bounds checks, tile cues, and server position sync. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function handleMovement(): void {
|
|
|
|
|
if (state.mode !== 'normal') return;
|
2026-02-22 20:02:25 -05:00
|
|
|
if (activeTeleport) return;
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
|
|
|
|
|
2026-02-22 03:30:26 -05:00
|
|
|
if (dx === 0 && dy === 0) {
|
|
|
|
|
lastWallCollisionDirection = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
const nextX = state.player.x + dx;
|
|
|
|
|
const nextY = state.player.y + dy;
|
2026-02-22 03:30:26 -05:00
|
|
|
const attemptedDirection = `${dx},${dy}`;
|
2026-02-21 19:12:58 -05:00
|
|
|
if (nextX < 0 || nextY < 0 || nextX >= worldGridSize || nextY >= worldGridSize) {
|
2026-02-21 00:55:19 -05:00
|
|
|
state.player.lastMoveTime = now;
|
2026-02-22 03:30:26 -05:00
|
|
|
if (lastWallCollisionDirection !== attemptedDirection) {
|
|
|
|
|
void audio.playSample(WALL_SOUND_URL, 1);
|
|
|
|
|
lastWallCollisionDirection = attemptedDirection;
|
|
|
|
|
}
|
2026-02-21 00:55:19 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
state.player.x = nextX;
|
|
|
|
|
state.player.y = nextY;
|
2026-02-22 03:30:26 -05:00
|
|
|
lastWallCollisionDirection = null;
|
2026-02-20 18:19:42 -05:00
|
|
|
persistPlayerPosition();
|
2026-02-20 08:16:43 -05:00
|
|
|
state.player.lastMoveTime = now;
|
2026-02-22 19:31:44 -05:00
|
|
|
void refreshAudioSubscriptions(true);
|
2026-02-22 19:43:15 -05:00
|
|
|
void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, MOVE_COOLDOWN_MS);
|
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('. '));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Checks microphone permission state when Permissions API support is available. */
|
2026-02-20 08:16:43 -05:00
|
|
|
async function checkMicPermission(): Promise<boolean> {
|
2026-02-22 17:33:31 -05:00
|
|
|
return mediaSession.checkMicPermission();
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
|
2026-02-20 08:16:43 -05:00
|
|
|
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
|
2026-02-22 17:33:31 -05:00
|
|
|
await mediaSession.setupLocalMedia(audioDeviceId);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Runs a short RMS sample to estimate and apply a usable microphone input gain. */
|
2026-02-22 16:16:16 -05:00
|
|
|
async function calibrateMicInputGain(): Promise<void> {
|
2026-02-22 17:33:31 -05:00
|
|
|
await mediaSession.calibrateMicInputGain(clampMicInputGain, persistMicInputGain);
|
2026-02-22 16:16:16 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Stops local capture tracks and clears outbound stream references. */
|
2026-02-20 16:30:54 -05:00
|
|
|
function stopLocalMedia(): void {
|
2026-02-22 17:33:31 -05:00
|
|
|
mediaSession.stopLocalMedia();
|
2026-02-20 16:30:54 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Maps browser media/capture errors to user-facing remediation text. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function describeMediaError(error: unknown): string {
|
2026-02-22 17:33:31 -05:00
|
|
|
return mediaSession.describeMediaError(error);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:40:44 -05:00
|
|
|
/** Restores loopback state captured when entering microphone gain edit mode. */
|
|
|
|
|
function restoreLoopbackAfterMicGainEdit(): void {
|
|
|
|
|
if (micGainLoopbackRestoreState === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
audio.setLoopbackEnabled(micGainLoopbackRestoreState);
|
|
|
|
|
micGainLoopbackRestoreState = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:20:13 -05:00
|
|
|
/** Stops heartbeat timer and clears in-memory heartbeat state. */
|
|
|
|
|
function stopHeartbeat(): void {
|
|
|
|
|
if (heartbeatTimerId !== null) {
|
|
|
|
|
window.clearInterval(heartbeatTimerId);
|
|
|
|
|
heartbeatTimerId = null;
|
|
|
|
|
}
|
2026-02-22 18:24:53 -05:00
|
|
|
heartbeatAwaitingPong = false;
|
2026-02-22 18:20:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Sends one heartbeat ping packet using reserved negative ids. */
|
|
|
|
|
function sendHeartbeatPing(): void {
|
|
|
|
|
signaling.send({ type: 'ping', clientSentAt: heartbeatNextPingId });
|
|
|
|
|
heartbeatNextPingId -= 1;
|
2026-02-22 18:24:53 -05:00
|
|
|
heartbeatAwaitingPong = true;
|
2026-02-22 18:20:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Starts heartbeat timer for stale-connection detection. */
|
|
|
|
|
function startHeartbeat(): void {
|
|
|
|
|
stopHeartbeat();
|
2026-02-22 18:24:53 -05:00
|
|
|
heartbeatAwaitingPong = false;
|
2026-02-22 18:20:13 -05:00
|
|
|
sendHeartbeatPing();
|
|
|
|
|
heartbeatTimerId = window.setInterval(() => {
|
|
|
|
|
if (!state.running) return;
|
2026-02-22 18:24:53 -05:00
|
|
|
if (heartbeatAwaitingPong) {
|
2026-02-22 18:20:13 -05:00
|
|
|
void reconnectAfterHeartbeatTimeout();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
sendHeartbeatPing();
|
|
|
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Performs one reconnect attempt when heartbeat timeout indicates stale signaling. */
|
|
|
|
|
async function reconnectAfterHeartbeatTimeout(): Promise<void> {
|
2026-02-22 18:47:09 -05:00
|
|
|
await reconnectWithRetry('heartbeat');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Performs immediate reconnect when websocket closes unexpectedly. */
|
|
|
|
|
async function reconnectAfterSocketClose(): Promise<void> {
|
|
|
|
|
await reconnectWithRetry('socketClose');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Reconnects after disconnect with delay and bounded retry attempts. */
|
|
|
|
|
async function reconnectWithRetry(reason: 'heartbeat' | 'socketClose'): Promise<void> {
|
2026-02-22 18:20:13 -05:00
|
|
|
if (reconnectInFlight || !state.running) return;
|
|
|
|
|
reconnectInFlight = true;
|
|
|
|
|
stopHeartbeat();
|
2026-02-22 18:47:09 -05:00
|
|
|
if (reason === 'heartbeat') {
|
|
|
|
|
pushChatMessage('Connection stale. Reconnecting...');
|
|
|
|
|
}
|
2026-02-22 18:20:13 -05:00
|
|
|
disconnect();
|
2026-02-22 18:47:09 -05:00
|
|
|
for (let attempt = 1; attempt <= RECONNECT_MAX_ATTEMPTS; attempt += 1) {
|
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, RECONNECT_DELAY_MS));
|
2026-02-22 18:20:13 -05:00
|
|
|
await connect();
|
2026-02-22 18:57:40 -05:00
|
|
|
const waitStartedAt = Date.now();
|
|
|
|
|
while (!state.running && Date.now() - waitStartedAt < 4_000) {
|
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, 100));
|
|
|
|
|
}
|
2026-02-22 18:47:09 -05:00
|
|
|
if (state.running) {
|
|
|
|
|
reconnectInFlight = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (attempt < RECONNECT_MAX_ATTEMPTS) {
|
2026-02-22 18:52:06 -05:00
|
|
|
pushChatMessage(`Reconnect attempt ${attempt} failed. Retrying in 5 seconds...`);
|
2026-02-22 18:47:09 -05:00
|
|
|
}
|
2026-02-22 18:20:13 -05:00
|
|
|
}
|
2026-02-22 18:47:09 -05:00
|
|
|
pushChatMessage('Reconnect failed after 3 attempts. Press Connect to retry.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
reconnectInFlight = false;
|
2026-02-22 18:20:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:33:31 -05:00
|
|
|
/** Builds dependencies shared by connect/disconnect flow helpers. */
|
|
|
|
|
function getConnectionFlowDeps(): ConnectFlowDeps {
|
|
|
|
|
return {
|
|
|
|
|
state,
|
|
|
|
|
dom,
|
|
|
|
|
sanitizeName,
|
2026-02-22 18:57:40 -05:00
|
|
|
updateStatus: (message) => {
|
2026-02-22 19:54:55 -05:00
|
|
|
if (message === 'Disconnected.') {
|
|
|
|
|
setConnectionStatus('Disconnected.');
|
|
|
|
|
} else if (message.startsWith('Connect failed.')) {
|
|
|
|
|
setConnectionStatus(message);
|
|
|
|
|
}
|
2026-02-22 18:57:40 -05:00
|
|
|
if (reconnectInFlight && message === 'Disconnected.') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pushChatMessage(message);
|
|
|
|
|
},
|
2026-02-22 17:33:31 -05:00
|
|
|
updateConnectAvailability,
|
|
|
|
|
settingsSaveNickname: (value) => settings.saveNickname(value),
|
|
|
|
|
mediaIsConnecting: () => mediaSession.isConnecting(),
|
|
|
|
|
mediaSetConnecting: (value) => mediaSession.setConnecting(value),
|
|
|
|
|
mediaCheckMicPermission: () => checkMicPermission(),
|
|
|
|
|
mediaPopulateAudioDevices: () => populateAudioDevices(),
|
|
|
|
|
mediaGetPreferredInputDeviceId: () => mediaSession.getPreferredInputDeviceId(),
|
|
|
|
|
mediaSetupLocalMedia: (audioDeviceId) => setupLocalMedia(audioDeviceId),
|
|
|
|
|
mediaDescribeError: (error) => describeMediaError(error),
|
|
|
|
|
mediaStopLocalMedia: () => stopLocalMedia(),
|
|
|
|
|
signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise<void>),
|
|
|
|
|
signalingDisconnect: () => signaling.disconnect(),
|
2026-02-22 18:20:13 -05:00
|
|
|
onMessage: (message) => onSignalingMessage(message as IncomingMessage),
|
2026-02-22 17:33:31 -05:00
|
|
|
worldGridSize,
|
|
|
|
|
persistPlayerPosition,
|
|
|
|
|
peerManagerCleanupAll: () => peerManager.cleanupAll(),
|
|
|
|
|
radioCleanupAll: () => radioRuntime.cleanupAll(),
|
|
|
|
|
emitCleanupAll: () => itemEmitRuntime.cleanupAll(),
|
|
|
|
|
playLogoutSound: () => {
|
|
|
|
|
void audio.playSample(SYSTEM_SOUND_URLS.logout, 1);
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Performs end-to-end connect flow: validation, media setup, then signaling connection. */
|
2026-02-20 08:16:43 -05:00
|
|
|
async function connect(): Promise<void> {
|
2026-02-22 19:54:55 -05:00
|
|
|
setConnectionStatus('Connecting...');
|
2026-02-22 17:33:31 -05:00
|
|
|
await runConnectFlow(getConnectionFlowDeps());
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Tears down active session state, media, peers, and UI back to pre-connect mode. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function disconnect(): void {
|
2026-02-22 18:20:13 -05:00
|
|
|
stopHeartbeat();
|
2026-02-22 17:33:31 -05:00
|
|
|
runDisconnectFlow(getConnectionFlowDeps());
|
2026-02-22 19:54:55 -05:00
|
|
|
setConnectionStatus('Disconnected.');
|
2026-02-20 17:46:43 -05:00
|
|
|
pendingEscapeDisconnect = false;
|
2026-02-22 17:40:44 -05:00
|
|
|
restoreLoopbackAfterMicGainEdit();
|
2026-02-22 19:31:44 -05:00
|
|
|
subscriptionRefreshPending = false;
|
|
|
|
|
subscriptionRefreshInFlight = false;
|
|
|
|
|
lastSubscriptionRefreshAt = 0;
|
2026-02-22 20:02:25 -05:00
|
|
|
lastSubscriptionRefreshTileX = Math.round(state.player.x);
|
|
|
|
|
lastSubscriptionRefreshTileY = Math.round(state.player.y);
|
2026-02-22 20:23:38 -05:00
|
|
|
stopTeleportLoopAudio();
|
2026-02-22 20:02:25 -05:00
|
|
|
activeTeleport = null;
|
2026-02-22 23:42:17 -05:00
|
|
|
stopPianoUseMode(false);
|
|
|
|
|
for (const key of Array.from(activeRemotePianoKeys)) {
|
|
|
|
|
activeRemotePianoKeys.delete(key);
|
|
|
|
|
pianoSynth.noteOff(key);
|
|
|
|
|
}
|
2026-02-22 23:51:13 -05:00
|
|
|
if (pianoPreviewTimeoutId !== null) {
|
|
|
|
|
window.clearTimeout(pianoPreviewTimeoutId);
|
|
|
|
|
pianoPreviewTimeoutId = null;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:20:13 -05:00
|
|
|
const onAppMessage = createOnMessageHandler({
|
2026-02-22 17:05:36 -05:00
|
|
|
getWorldGridSize: () => worldGridSize,
|
|
|
|
|
setWorldGridSize: (size) => {
|
|
|
|
|
worldGridSize = size;
|
|
|
|
|
},
|
|
|
|
|
setConnecting: (value) => {
|
2026-02-22 17:33:31 -05:00
|
|
|
mediaSession.setConnecting(value);
|
|
|
|
|
updateConnectAvailability();
|
2026-02-22 17:05:36 -05:00
|
|
|
},
|
|
|
|
|
rendererSetGridSize: (size) => renderer.setGridSize(size),
|
|
|
|
|
applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters<typeof applyServerItemUiDefinitions>[0]),
|
|
|
|
|
state,
|
|
|
|
|
dom,
|
|
|
|
|
signalingSend: (message) => signaling.send(message as OutgoingMessage),
|
|
|
|
|
peerManager,
|
2026-02-22 19:31:44 -05:00
|
|
|
refreshAudioSubscriptions,
|
|
|
|
|
cleanupItemAudio: (itemId) => {
|
|
|
|
|
radioRuntime.cleanup(itemId);
|
|
|
|
|
itemEmitRuntime.cleanup(itemId);
|
|
|
|
|
},
|
2026-02-22 17:05:36 -05:00
|
|
|
applyAudioLayerState,
|
|
|
|
|
gameLoop,
|
|
|
|
|
sanitizeName,
|
|
|
|
|
randomFootstepUrl,
|
|
|
|
|
playRemoteSpatialStepOrTeleport: (url, peerX, peerY) => {
|
2026-02-22 20:26:59 -05:00
|
|
|
const gain = url === TELEPORT_START_SOUND_URL ? TELEPORT_START_GAIN : FOOTSTEP_GAIN;
|
2026-02-22 17:05:36 -05:00
|
|
|
void audio.playSpatialSample(
|
|
|
|
|
url,
|
2026-02-22 21:37:15 -05:00
|
|
|
{ x: peerX, y: peerY },
|
|
|
|
|
{ x: state.player.x, y: state.player.y },
|
2026-02-22 20:26:59 -05:00
|
|
|
gain,
|
2026-02-22 17:05:36 -05:00
|
|
|
);
|
|
|
|
|
},
|
2026-02-22 23:42:17 -05:00
|
|
|
playRemotePianoNote,
|
|
|
|
|
stopRemotePianoNote,
|
|
|
|
|
stopAllRemotePianoNotesForSender,
|
2026-02-22 17:05:36 -05:00
|
|
|
TELEPORT_SOUND_URL,
|
2026-02-22 20:23:38 -05:00
|
|
|
TELEPORT_START_SOUND_URL,
|
2026-02-22 21:07:01 -05:00
|
|
|
getAudioLayers: () => audioLayers,
|
2026-02-22 17:05:36 -05:00
|
|
|
pushChatMessage,
|
|
|
|
|
classifySystemMessageSound,
|
|
|
|
|
SYSTEM_SOUND_URLS,
|
|
|
|
|
playSample: (url, gain = 1) => {
|
|
|
|
|
void audio.playSample(url, gain);
|
|
|
|
|
},
|
|
|
|
|
updateStatus,
|
|
|
|
|
audioUiBlip: () => audio.sfxUiBlip(),
|
|
|
|
|
audioUiConfirm: () => audio.sfxUiConfirm(),
|
|
|
|
|
audioUiCancel: () => audio.sfxUiCancel(),
|
|
|
|
|
NICKNAME_STORAGE_KEY,
|
|
|
|
|
getCarriedItemId: () => getCarriedItem()?.id ?? null,
|
|
|
|
|
itemPropertyLabel,
|
|
|
|
|
getItemPropertyValue,
|
|
|
|
|
getItemById: (itemId) => state.items.get(itemId),
|
2026-02-22 20:50:04 -05:00
|
|
|
shouldAnnounceItemPropertyEcho: () => Date.now() >= suppressItemPropertyEchoUntilMs,
|
2026-02-22 17:05:36 -05:00
|
|
|
playLocateToneAt: (x, y) => audio.sfxLocate({ x: x - state.player.x, y: y - state.player.y }),
|
|
|
|
|
resolveIncomingSoundUrl,
|
|
|
|
|
playIncomingItemUseSound: (url, x, y) => {
|
2026-02-22 21:37:15 -05:00
|
|
|
void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1);
|
2026-02-22 17:05:36 -05:00
|
|
|
},
|
|
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-02-22 18:20:13 -05:00
|
|
|
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
|
|
|
|
async function onSignalingMessage(message: IncomingMessage): Promise<void> {
|
|
|
|
|
if (message.type === 'pong' && message.clientSentAt < 0) {
|
2026-02-22 18:24:53 -05:00
|
|
|
heartbeatAwaitingPong = false;
|
2026-02-22 18:20:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let restartAnnouncement: string | null = null;
|
2026-02-22 18:40:26 -05:00
|
|
|
let connectedAnnouncement: string | null = null;
|
2026-02-22 18:20:13 -05:00
|
|
|
if (message.type === 'welcome') {
|
|
|
|
|
const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null;
|
|
|
|
|
const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown';
|
2026-02-22 18:52:06 -05:00
|
|
|
connectedAnnouncement = reconnectInFlight
|
|
|
|
|
? `Reconnected to server. Version ${incomingVersion}.`
|
|
|
|
|
: `Connected to server. Version ${incomingVersion}.`;
|
2026-02-22 18:40:26 -05:00
|
|
|
if (
|
|
|
|
|
!reloadScheduledForVersionMismatch &&
|
|
|
|
|
APP_VERSION &&
|
|
|
|
|
incomingVersion &&
|
|
|
|
|
incomingVersion !== 'unknown' &&
|
|
|
|
|
incomingVersion !== APP_VERSION
|
|
|
|
|
) {
|
|
|
|
|
reloadScheduledForVersionMismatch = true;
|
|
|
|
|
pushChatMessage(`Server version ${incomingVersion} detected. Reloading client...`);
|
|
|
|
|
window.setTimeout(() => {
|
2026-02-22 18:57:40 -05:00
|
|
|
reloadClientForVersion(incomingVersion);
|
2026-02-22 18:40:26 -05:00
|
|
|
}, 50);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 18:20:13 -05:00
|
|
|
if (activeServerInstanceId && incomingInstanceId && activeServerInstanceId !== incomingInstanceId) {
|
2026-02-22 18:40:26 -05:00
|
|
|
restartAnnouncement = 'Server restarted.';
|
2026-02-22 18:20:13 -05:00
|
|
|
}
|
|
|
|
|
activeServerInstanceId = incomingInstanceId;
|
|
|
|
|
startHeartbeat();
|
|
|
|
|
}
|
|
|
|
|
await onAppMessage(message);
|
2026-02-22 23:42:17 -05:00
|
|
|
if (
|
|
|
|
|
message.type === 'item_action_result' &&
|
|
|
|
|
message.ok &&
|
|
|
|
|
message.action === 'use' &&
|
2026-02-23 01:01:43 -05:00
|
|
|
typeof message.itemId === 'string' &&
|
|
|
|
|
typeof message.message === 'string' &&
|
|
|
|
|
message.message.toLowerCase().includes('begin playing')
|
2026-02-22 23:42:17 -05:00
|
|
|
) {
|
|
|
|
|
const item = state.items.get(message.itemId);
|
|
|
|
|
if (item?.type === 'piano') {
|
|
|
|
|
await startPianoUseMode(item.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (activePianoItemId && !state.items.has(activePianoItemId)) {
|
|
|
|
|
stopPianoUseMode(false);
|
|
|
|
|
}
|
2026-02-22 19:15:03 -05:00
|
|
|
applyConfiguredPeerListenGains();
|
2026-02-22 18:20:13 -05:00
|
|
|
if (restartAnnouncement) {
|
2026-02-22 19:54:55 -05:00
|
|
|
setConnectionStatus(restartAnnouncement);
|
2026-02-22 18:29:44 -05:00
|
|
|
pushChatMessage(restartAnnouncement);
|
2026-02-22 18:20:13 -05:00
|
|
|
audio.sfxUiConfirm();
|
|
|
|
|
}
|
2026-02-22 18:52:06 -05:00
|
|
|
if (connectedAnnouncement) {
|
2026-02-22 19:54:55 -05:00
|
|
|
setConnectionStatus(connectedAnnouncement);
|
2026-02-22 18:52:06 -05:00
|
|
|
pushChatMessage(connectedAnnouncement);
|
|
|
|
|
}
|
2026-02-22 18:20:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Toggles local microphone track mute state. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function toggleMute(): void {
|
|
|
|
|
state.isMuted = !state.isMuted;
|
2026-02-22 17:33:31 -05:00
|
|
|
mediaSession.applyMuteToTrack(state.isMuted);
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles command-mode keybindings while in main gameplay mode. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
2026-02-20 17:46:43 -05:00
|
|
|
if (code !== 'Escape' && pendingEscapeDisconnect) {
|
|
|
|
|
pendingEscapeDisconnect = false;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
const command = resolveMainModeCommand(code, shiftKey);
|
|
|
|
|
if (!command) return;
|
2026-02-20 17:46:43 -05:00
|
|
|
|
2026-02-22 16:49:15 -05:00
|
|
|
switch (command) {
|
|
|
|
|
case 'editNickname':
|
|
|
|
|
state.mode = 'nickname';
|
|
|
|
|
state.nicknameInput = state.player.nickname;
|
|
|
|
|
state.cursorPos = state.player.nickname.length;
|
|
|
|
|
replaceTextOnNextType = true;
|
|
|
|
|
updateStatus(`Nickname edit: ${state.nicknameInput}`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
case 'toggleMute':
|
|
|
|
|
toggleMute();
|
|
|
|
|
return;
|
|
|
|
|
case 'toggleOutputMode':
|
2026-02-20 08:16:43 -05:00
|
|
|
outputMode = audio.toggleOutputMode();
|
2026-02-22 17:33:31 -05:00
|
|
|
mediaSession.saveOutputMode(outputMode);
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'toggleLoopback': {
|
|
|
|
|
const enabled = audio.toggleLoopback();
|
|
|
|
|
updateStatus(enabled ? 'Loopback on.' : 'Loopback off.');
|
|
|
|
|
audio.sfxUiBlip();
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'toggleVoiceLayer':
|
|
|
|
|
toggleAudioLayer('voice');
|
2026-02-22 16:16:16 -05:00
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'toggleItemLayer':
|
|
|
|
|
toggleAudioLayer('item');
|
|
|
|
|
return;
|
|
|
|
|
case 'toggleMediaLayer':
|
|
|
|
|
toggleAudioLayer('media');
|
|
|
|
|
return;
|
|
|
|
|
case 'toggleWorldLayer':
|
|
|
|
|
toggleAudioLayer('world');
|
|
|
|
|
return;
|
2026-02-22 18:33:55 -05:00
|
|
|
case 'masterVolumeUp':
|
|
|
|
|
case 'masterVolumeDown': {
|
|
|
|
|
const step = command === 'masterVolumeUp' ? 5 : -5;
|
|
|
|
|
const next = audio.adjustMasterVolume(step);
|
|
|
|
|
persistMasterVolume(next);
|
|
|
|
|
updateStatus(`Master volume ${next}`);
|
2026-02-22 18:38:11 -05:00
|
|
|
audio.sfxEffectLevel(next === 50);
|
2026-02-22 18:33:55 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'openEffectSelect': {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'effectValueUp':
|
|
|
|
|
case 'effectValueDown': {
|
|
|
|
|
const step = command === 'effectValueUp' ? 5 : -5;
|
|
|
|
|
const adjusted = audio.adjustCurrentEffectLevel(step);
|
|
|
|
|
if (!adjusted) return;
|
|
|
|
|
persistEffectLevels();
|
|
|
|
|
audio.sfxEffectLevel(adjusted.value === adjusted.defaultValue);
|
|
|
|
|
updateStatus(`${adjusted.label} ${adjusted.value}`);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'speakCoordinates':
|
2026-02-22 22:19:42 -05:00
|
|
|
updateStatus(`${formatCoordinate(state.player.x)}, ${formatCoordinate(state.player.y)}`);
|
2026-02-22 16:49:15 -05:00
|
|
|
audio.sfxUiBlip();
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'openMicGainEdit':
|
|
|
|
|
state.mode = 'micGainEdit';
|
|
|
|
|
state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP);
|
|
|
|
|
state.cursorPos = state.nicknameInput.length;
|
|
|
|
|
replaceTextOnNextType = true;
|
2026-02-22 17:40:44 -05:00
|
|
|
micGainLoopbackRestoreState = audio.isLoopbackEnabled();
|
|
|
|
|
audio.setLoopbackEnabled(true);
|
2026-02-22 16:49:15 -05:00
|
|
|
updateStatus(`Set microphone gain: ${state.nicknameInput}`);
|
|
|
|
|
audio.sfxUiBlip();
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'calibrateMicrophone':
|
|
|
|
|
void calibrateMicInputGain();
|
2026-02-21 19:12:58 -05:00
|
|
|
return;
|
2026-02-22 17:16:31 -05:00
|
|
|
case 'useItem': {
|
|
|
|
|
const carried = getCarriedItem();
|
|
|
|
|
if (carried) {
|
|
|
|
|
useItem(carried);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 17:16:31 -05:00
|
|
|
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]);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 17:16:31 -05:00
|
|
|
beginItemSelection('use', usable);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
case 'speakUsers': {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'addItem': {
|
|
|
|
|
const itemTypeSequence = getItemTypeSequence();
|
|
|
|
|
if (itemTypeSequence.length === 0) {
|
|
|
|
|
updateStatus('No item types available.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1));
|
|
|
|
|
state.mode = 'addItem';
|
|
|
|
|
updateStatus(`Add item: ${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`);
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'locateOrListItems':
|
|
|
|
|
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) {
|
2026-02-22 18:57:40 -05:00
|
|
|
const itemCount = state.sortedItemIds.length;
|
|
|
|
|
const itemLabelText = itemCount === 1 ? 'item' : 'items';
|
2026-02-22 16:49:15 -05:00
|
|
|
updateStatus(
|
2026-02-22 18:59:43 -05:00
|
|
|
`${itemCount} ${itemLabelText}. ${itemLabel(first)}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
|
2026-02-22 16:49:15 -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(
|
|
|
|
|
`${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
case 'pickupDropOrDelete': {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
|
|
|
|
if (squareItems.length === 0) {
|
2026-02-22 16:49:15 -05:00
|
|
|
updateStatus('No items to pick up.');
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (squareItems.length === 1) {
|
2026-02-22 16:49:15 -05:00
|
|
|
signaling.send({ type: 'item_pickup', itemId: squareItems[0].id });
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
beginItemSelection('pickup', squareItems);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'editOrInspectItem': {
|
|
|
|
|
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
|
|
|
|
const carried = getCarriedItem();
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
if (squareItems.length === 0) {
|
|
|
|
|
if (!carried) {
|
|
|
|
|
updateStatus('No item to inspect.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemProperties(carried, true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (squareItems.length === 1) {
|
|
|
|
|
beginItemProperties(squareItems[0], true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
beginItemSelection('inspect', squareItems);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 16:50:12 -05:00
|
|
|
if (squareItems.length === 0) {
|
|
|
|
|
if (!carried) {
|
2026-02-22 16:49:15 -05:00
|
|
|
updateStatus('No editable item here.');
|
2026-02-20 16:50:12 -05:00
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
beginItemProperties(carried);
|
2026-02-20 16:50:12 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (squareItems.length === 1) {
|
2026-02-22 16:49:15 -05:00
|
|
|
beginItemProperties(squareItems[0]);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
beginItemSelection('edit', squareItems);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'pingServer':
|
|
|
|
|
signaling.send({ type: 'ping', clientSentAt: Date.now() });
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'locateOrListUsers':
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
if (state.peers.size === 0) {
|
|
|
|
|
updateStatus('No users to list.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.sortedPeerIds = Array.from(state.peers.entries())
|
2026-02-22 19:35:28 -05:00
|
|
|
.sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' }))
|
2026-02-22 16:49:15 -05:00
|
|
|
.map(([id]) => id);
|
|
|
|
|
state.listIndex = 0;
|
|
|
|
|
state.mode = 'listUsers';
|
|
|
|
|
const first = state.peers.get(state.sortedPeerIds[0]);
|
|
|
|
|
if (first) {
|
2026-02-22 18:57:40 -05:00
|
|
|
const userCount = state.sortedPeerIds.length;
|
|
|
|
|
const userLabelText = userCount === 1 ? 'user' : 'users';
|
2026-02-22 19:35:28 -05:00
|
|
|
const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(first.nickname), MIC_INPUT_GAIN_STEP)}`;
|
2026-02-22 16:49:15 -05:00
|
|
|
updateStatus(
|
2026-02-22 19:35:28 -05:00
|
|
|
`${userCount} ${userLabelText}. ${first.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
|
2026-02-22 16:49:15 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
audio.sfxUiBlip();
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
{
|
|
|
|
|
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 });
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus(
|
2026-02-22 16:49:15 -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
|
|
|
);
|
2026-02-22 16:49:15 -05:00
|
|
|
return;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'openHelp':
|
2026-02-23 01:08:50 -05:00
|
|
|
openHelpViewer(mainHelpViewerLines);
|
2026-02-22 16:49:15 -05:00
|
|
|
return;
|
|
|
|
|
case 'openChat':
|
|
|
|
|
state.mode = 'chat';
|
|
|
|
|
state.nicknameInput = '';
|
|
|
|
|
state.cursorPos = 0;
|
|
|
|
|
replaceTextOnNextType = false;
|
|
|
|
|
updateStatus('Chat.');
|
2026-02-20 08:16:43 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'chatPrev':
|
|
|
|
|
navigateChatBuffer('prev');
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'chatNext':
|
|
|
|
|
navigateChatBuffer('next');
|
|
|
|
|
return;
|
|
|
|
|
case 'chatFirst':
|
2026-02-20 08:16:43 -05:00
|
|
|
navigateChatBuffer('first');
|
2026-02-22 16:49:15 -05:00
|
|
|
return;
|
|
|
|
|
case 'chatLast':
|
2026-02-20 08:16:43 -05:00
|
|
|
navigateChatBuffer('last');
|
2026-02-20 17:46:43 -05:00
|
|
|
return;
|
2026-02-22 16:49:15 -05:00
|
|
|
case 'escape':
|
|
|
|
|
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-22 17:23:33 -05:00
|
|
|
/** Handles linear help viewer navigation and exit keys. */
|
2026-02-21 16:55:41 -05:00
|
|
|
function handleHelpViewModeInput(code: string): void {
|
|
|
|
|
if (helpViewerLines.length === 0) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Help unavailable.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (code === 'ArrowDown') {
|
|
|
|
|
helpViewerIndex = Math.min(helpViewerLines.length - 1, helpViewerIndex + 1);
|
|
|
|
|
updateStatus(helpViewerLines[helpViewerIndex]);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'ArrowUp') {
|
|
|
|
|
helpViewerIndex = Math.max(0, helpViewerIndex - 1);
|
|
|
|
|
updateStatus(helpViewerLines[helpViewerIndex]);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Home') {
|
|
|
|
|
helpViewerIndex = 0;
|
|
|
|
|
updateStatus(helpViewerLines[helpViewerIndex]);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'End') {
|
|
|
|
|
helpViewerIndex = helpViewerLines.length - 1;
|
|
|
|
|
updateStatus(helpViewerLines[helpViewerIndex]);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (code === 'Escape') {
|
2026-02-23 01:08:50 -05:00
|
|
|
state.mode = helpViewerReturnMode;
|
2026-02-21 16:55:41 -05:00
|
|
|
updateStatus('Closed help.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles chat compose mode including submit/cancel and inline editing keys. */
|
2026-02-21 03:36:16 -05:00
|
|
|
function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void {
|
2026-02-22 16:58:57 -05:00
|
|
|
const editAction = getEditSessionAction(code);
|
|
|
|
|
if (editAction === 'submit') {
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
if (editAction === 'cancel') {
|
2026-02-20 08:16:43 -05:00
|
|
|
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-22 17:23:33 -05:00
|
|
|
/** Handles direct microphone gain editing mode with keyboard stepping and validation. */
|
2026-02-22 16:28:11 -05:00
|
|
|
function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean): void {
|
2026-02-22 17:12:28 -05:00
|
|
|
if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'PageUp' || code === 'PageDown') {
|
2026-02-22 16:31:36 -05:00
|
|
|
const raw = Number(state.nicknameInput.trim());
|
|
|
|
|
const base = Number.isFinite(raw) ? raw : audio.getOutboundInputGain();
|
2026-02-22 17:12:28 -05:00
|
|
|
const multiplier = code === 'PageUp' || code === 'PageDown' ? 10 : 1;
|
|
|
|
|
const delta = (code === 'ArrowUp' || code === 'PageUp' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP) * multiplier;
|
2026-02-22 16:37:42 -05:00
|
|
|
const attempted = snapNumberToStep(base + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN);
|
|
|
|
|
const next = clampMicInputGain(attempted);
|
2026-02-22 16:31:36 -05:00
|
|
|
state.nicknameInput = formatSteppedNumber(next, MIC_INPUT_GAIN_STEP);
|
|
|
|
|
state.cursorPos = state.nicknameInput.length;
|
|
|
|
|
replaceTextOnNextType = false;
|
2026-02-22 17:40:44 -05:00
|
|
|
audio.setOutboundInputGain(next);
|
2026-02-22 16:37:42 -05:00
|
|
|
updateStatus(state.nicknameInput);
|
|
|
|
|
if (Math.abs(next - base) < 1e-9 || Math.abs(next - attempted) > 1e-9) {
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
} else {
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
2026-02-22 16:31:36 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
const editAction = getEditSessionAction(code);
|
|
|
|
|
if (editAction === 'submit') {
|
2026-02-22 16:28:11 -05:00
|
|
|
const value = Number(state.nicknameInput.trim());
|
2026-02-22 16:31:36 -05:00
|
|
|
if (!Number.isFinite(value)) {
|
|
|
|
|
updateStatus(`Volume must be between ${MIC_CALIBRATION_MIN_GAIN} and ${MIC_CALIBRATION_MAX_GAIN}.`);
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const snapped = snapNumberToStep(value, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN);
|
|
|
|
|
if (snapped < MIC_CALIBRATION_MIN_GAIN || snapped > MIC_CALIBRATION_MAX_GAIN) {
|
2026-02-22 16:28:11 -05:00
|
|
|
updateStatus(`Volume must be between ${MIC_CALIBRATION_MIN_GAIN} and ${MIC_CALIBRATION_MAX_GAIN}.`);
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:31:36 -05:00
|
|
|
const applied = audio.setOutboundInputGain(snapped);
|
2026-02-22 16:28:11 -05:00
|
|
|
persistMicInputGain(applied);
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
replaceTextOnNextType = false;
|
2026-02-22 17:40:44 -05:00
|
|
|
restoreLoopbackAfterMicGainEdit();
|
2026-02-22 16:45:45 -05:00
|
|
|
updateStatus(`Microphone gain set to ${formatSteppedNumber(applied, MIC_INPUT_GAIN_STEP)}.`);
|
2026-02-22 16:28:11 -05:00
|
|
|
audio.sfxUiConfirm();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
if (editAction === 'cancel') {
|
2026-02-22 16:28:11 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
replaceTextOnNextType = false;
|
2026-02-22 17:40:44 -05:00
|
|
|
restoreLoopbackAfterMicGainEdit();
|
2026-02-22 16:28:11 -05:00
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyTextInputEdit(code, key, 8, ctrlKey, true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 23:42:17 -05:00
|
|
|
/** Handles realtime keyboard performance while piano item mode is active. */
|
|
|
|
|
function handlePianoUseModeInput(code: string): void {
|
2026-02-23 00:26:38 -05:00
|
|
|
if (code === 'Escape') {
|
2026-02-22 23:42:17 -05:00
|
|
|
stopPianoUseMode(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 01:08:50 -05:00
|
|
|
if (code === 'Slash') {
|
|
|
|
|
openHelpViewer(pianoHelpViewerLines, 'pianoUse');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 23:42:17 -05:00
|
|
|
const itemId = activePianoItemId;
|
|
|
|
|
if (!itemId) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
if (!item || item.type !== 'piano') {
|
|
|
|
|
stopPianoUseMode(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 00:57:18 -05:00
|
|
|
if (code === 'KeyZ') {
|
2026-02-23 00:36:36 -05:00
|
|
|
signaling.send({ type: 'item_piano_recording', itemId, action: 'toggle_record' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 00:57:18 -05:00
|
|
|
if (code === 'KeyX') {
|
2026-02-23 00:36:36 -05:00
|
|
|
signaling.send({ type: 'item_piano_recording', itemId, action: 'playback' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 00:57:18 -05:00
|
|
|
if (code === 'KeyC') {
|
2026-02-23 00:45:17 -05:00
|
|
|
signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_playback' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 00:26:38 -05:00
|
|
|
if (code === 'Equal' || code === 'Minus') {
|
|
|
|
|
const current = getPianoParams(item).octave;
|
|
|
|
|
const next = Math.max(-2, Math.min(2, current + (code === 'Equal' ? 1 : -1)));
|
|
|
|
|
item.params.octave = next;
|
|
|
|
|
signaling.send({ type: 'item_update', itemId, params: { octave: next } });
|
2026-02-23 00:45:17 -05:00
|
|
|
void previewPianoSettingChange(item, { octave: next });
|
2026-02-23 00:26:38 -05:00
|
|
|
updateStatus(`octave ${next}.`);
|
|
|
|
|
if (next === current) {
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
} else {
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 00:05:01 -05:00
|
|
|
if (code.startsWith('Digit')) {
|
|
|
|
|
const digit = Number(code.slice(5));
|
2026-02-23 00:22:36 -05:00
|
|
|
const instrumentIndex = digit === 0 ? 9 : digit - 1;
|
|
|
|
|
if (Number.isInteger(instrumentIndex) && instrumentIndex >= 0 && instrumentIndex < PIANO_INSTRUMENT_OPTIONS.length) {
|
|
|
|
|
const instrument = PIANO_INSTRUMENT_OPTIONS[instrumentIndex];
|
2026-02-23 00:05:01 -05:00
|
|
|
if (instrument) {
|
|
|
|
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
2026-02-23 00:22:36 -05:00
|
|
|
const voiceMode = defaultsVoiceModeForInstrument(instrument);
|
|
|
|
|
const octave = defaultsOctaveForInstrument(instrument);
|
2026-02-23 00:05:01 -05:00
|
|
|
item.params.instrument = instrument;
|
2026-02-23 00:22:36 -05:00
|
|
|
item.params.voiceMode = voiceMode;
|
|
|
|
|
item.params.octave = octave;
|
2026-02-23 00:05:01 -05:00
|
|
|
item.params.attack = defaults.attack;
|
|
|
|
|
item.params.decay = defaults.decay;
|
|
|
|
|
item.params.release = defaults.release;
|
|
|
|
|
item.params.brightness = defaults.brightness;
|
|
|
|
|
signaling.send({
|
|
|
|
|
type: 'item_update',
|
|
|
|
|
itemId,
|
|
|
|
|
params: {
|
|
|
|
|
instrument,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
void previewPianoSettingChange(item, {
|
|
|
|
|
instrument,
|
2026-02-23 00:45:17 -05:00
|
|
|
octave,
|
2026-02-23 00:05:01 -05:00
|
|
|
attack: defaults.attack,
|
|
|
|
|
decay: defaults.decay,
|
|
|
|
|
release: defaults.release,
|
|
|
|
|
brightness: defaults.brightness,
|
|
|
|
|
});
|
|
|
|
|
updateStatus(`Instrument ${instrument}.`);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 23:42:17 -05:00
|
|
|
const midi = getPianoMidiForCode(code);
|
|
|
|
|
if (midi === null) return;
|
|
|
|
|
if (activePianoKeys.has(code)) return;
|
2026-02-23 00:22:36 -05:00
|
|
|
const config = getPianoParams(item);
|
|
|
|
|
const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12));
|
2026-02-22 23:42:17 -05:00
|
|
|
activePianoKeys.add(code);
|
2026-02-23 00:22:36 -05:00
|
|
|
activePianoKeyMidi.set(code, playedMidi);
|
2026-02-23 00:36:36 -05:00
|
|
|
activePianoHeldOrder.push(code);
|
|
|
|
|
if (config.voiceMode === 'mono') {
|
|
|
|
|
const previousCode = activePianoMonophonicKey;
|
|
|
|
|
if (previousCode && previousCode !== code) {
|
|
|
|
|
const previousMidi = activePianoKeyMidi.get(previousCode);
|
|
|
|
|
pianoSynth.noteOff(previousCode);
|
|
|
|
|
if (Number.isFinite(previousMidi)) {
|
|
|
|
|
signaling.send({ type: 'item_piano_note', itemId, keyId: previousCode, midi: previousMidi, on: false });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
activePianoMonophonicKey = code;
|
|
|
|
|
}
|
|
|
|
|
playLocalPianoNote(item, itemId, code, playedMidi, config);
|
2026-02-22 23:42:17 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles effect menu list navigation and selection. */
|
2026-02-21 02:06:32 -05:00
|
|
|
function handleEffectSelectModeInput(code: string, key: string): void {
|
2026-02-22 16:58:57 -05:00
|
|
|
const control = handleListControlKey(code, key, EFFECT_SEQUENCE, state.effectSelectIndex, (effect) => effect.label);
|
|
|
|
|
if (control.type === 'move') {
|
|
|
|
|
state.effectSelectIndex = control.index;
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'select') {
|
2026-02-21 02:06:32 -05:00
|
|
|
const selected = EFFECT_SEQUENCE[state.effectSelectIndex];
|
|
|
|
|
const effect = audio.setOutboundEffect(selected.id);
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus(effect.label);
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'cancel') {
|
2026-02-21 02:06:32 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles list navigation for nearby/known users and teleport-on-select. */
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:15:03 -05:00
|
|
|
if (code === 'ArrowLeft' || code === 'ArrowRight') {
|
2026-02-22 19:35:28 -05:00
|
|
|
const peerId = state.sortedPeerIds[state.listIndex];
|
|
|
|
|
const entry = state.peers.get(peerId);
|
2026-02-22 19:22:22 -05:00
|
|
|
if (!entry) return;
|
|
|
|
|
const current = getPeerListenGainForNickname(entry.nickname);
|
2026-02-22 19:15:03 -05:00
|
|
|
const delta = code === 'ArrowRight' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP;
|
|
|
|
|
const attempted = snapNumberToStep(current + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN);
|
|
|
|
|
const next = clampMicInputGain(attempted);
|
2026-02-22 19:22:22 -05:00
|
|
|
setPeerListenGainForNickname(entry.nickname, next);
|
2026-02-22 19:35:28 -05:00
|
|
|
peerManager.setPeerListenGain(peerId, next);
|
2026-02-22 19:22:22 -05:00
|
|
|
updateStatus(`${entry.nickname} volume ${formatSteppedNumber(next, MIC_INPUT_GAIN_STEP)}.`);
|
2026-02-22 19:15:03 -05:00
|
|
|
if (Math.abs(next - current) < 1e-9 || Math.abs(next - attempted) > 1e-9) {
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
} else {
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:35:28 -05:00
|
|
|
const control = handleListControlKey(
|
|
|
|
|
code,
|
|
|
|
|
key,
|
|
|
|
|
state.sortedPeerIds,
|
|
|
|
|
state.listIndex,
|
|
|
|
|
(peerId) => state.peers.get(peerId)?.nickname ?? '',
|
|
|
|
|
);
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'move') {
|
|
|
|
|
state.listIndex = control.index;
|
2026-02-22 19:35:28 -05:00
|
|
|
const entry = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
2026-02-22 19:22:22 -05:00
|
|
|
if (!entry) return;
|
2026-02-22 19:35:28 -05:00
|
|
|
const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(entry.nickname), MIC_INPUT_GAIN_STEP)}`;
|
2026-02-20 08:16:43 -05:00
|
|
|
updateStatus(
|
2026-02-22 19:22:22 -05:00
|
|
|
`${entry.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, entry.x, entry.y)}, ${entry.x}, ${entry.y}`,
|
2026-02-20 08:16:43 -05:00
|
|
|
);
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.reason === 'initial') {
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'select') {
|
2026-02-22 19:35:28 -05:00
|
|
|
const entry = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
2026-02-22 19:22:22 -05:00
|
|
|
if (!entry) return;
|
|
|
|
|
if (state.player.x === entry.x && state.player.y === entry.y) {
|
2026-02-21 04:03:49 -05:00
|
|
|
updateStatus('Already here.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
state.mode = 'normal';
|
2026-02-22 20:02:25 -05:00
|
|
|
startTeleportTo(entry.x, entry.y, `Moved to ${entry.nickname}.`);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'cancel') {
|
2026-02-20 08:16:43 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Exit list mode.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles item list navigation and teleport-on-select. */
|
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;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
|
|
|
|
|
const control = handleListControlKey(code, key, state.sortedItemIds, state.itemListIndex, (itemId) => {
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
return item ? itemLabel(item) : '';
|
|
|
|
|
});
|
|
|
|
|
if (control.type === 'move') {
|
|
|
|
|
state.itemListIndex = control.index;
|
2026-02-21 01:41:47 -05:00
|
|
|
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
|
|
|
);
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.reason === 'initial') {
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
2026-02-21 01:41:47 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'select') {
|
2026-02-20 08:16:43 -05:00
|
|
|
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.mode = 'normal';
|
2026-02-22 20:02:25 -05:00
|
|
|
startTeleportTo(item.x, item.y, `Moved to ${itemLabel(item)}.`);
|
2026-02-20 08:16:43 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'cancel') {
|
2026-02-20 08:16:43 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Exit item list mode.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles add-item type selection and item-type tooltip readout. */
|
2026-02-21 01:41:47 -05:00
|
|
|
function handleAddItemModeInput(code: string, key: string): void {
|
2026-02-21 19:12:58 -05:00
|
|
|
const itemTypeSequence = getItemTypeSequence();
|
|
|
|
|
if (itemTypeSequence.length === 0) {
|
|
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('No item types available.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
const control = handleListControlKey(code, key, itemTypeSequence, state.addItemTypeIndex, (itemType) => itemTypeLabel(itemType));
|
|
|
|
|
if (control.type === 'move') {
|
|
|
|
|
state.addItemTypeIndex = control.index;
|
2026-02-21 19:12:58 -05:00
|
|
|
updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`);
|
2026-02-21 01:41:47 -05:00
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 20:47:02 -05:00
|
|
|
if (code === 'Space') {
|
|
|
|
|
const itemType = itemTypeSequence[state.addItemTypeIndex];
|
|
|
|
|
const tooltip = getItemTypeTooltip(itemType);
|
|
|
|
|
updateStatus(tooltip ? tooltip : 'No tooltip available.');
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'select') {
|
2026-02-21 19:12:58 -05:00
|
|
|
signaling.send({ type: 'item_add', itemType: itemTypeSequence[state.addItemTypeIndex] });
|
2026-02-20 08:16:43 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'cancel') {
|
2026-02-20 08:16:43 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles generic selected-item list flow used by pickup/delete/edit/use/inspect contexts. */
|
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;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
const control = handleListControlKey(code, key, state.selectedItemIds, state.selectedItemIndex, (itemId) => {
|
|
|
|
|
const item = state.items.get(itemId);
|
|
|
|
|
return item ? itemLabel(item) : '';
|
|
|
|
|
});
|
|
|
|
|
if (control.type === 'move') {
|
|
|
|
|
state.selectedItemIndex = control.index;
|
2026-02-21 01:41:47 -05:00
|
|
|
const current = state.items.get(state.selectedItemIds[state.selectedItemIndex]);
|
|
|
|
|
if (current) {
|
|
|
|
|
updateStatus(itemLabel(current));
|
|
|
|
|
audio.sfxUiBlip();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'select') {
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-22 16:58:57 -05:00
|
|
|
if (control.type === 'cancel') {
|
2026-02-20 08:16:43 -05:00
|
|
|
state.mode = 'normal';
|
|
|
|
|
state.selectionContext = null;
|
|
|
|
|
updateStatus('Cancelled.');
|
|
|
|
|
audio.sfxUiCancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:05:36 -05:00
|
|
|
const itemPropertyEditor = createItemPropertyEditor({
|
|
|
|
|
state,
|
|
|
|
|
signalingSend: (message) => signaling.send(message as OutgoingMessage),
|
|
|
|
|
getItemPropertyValue,
|
|
|
|
|
itemPropertyLabel,
|
|
|
|
|
isItemPropertyEditable,
|
|
|
|
|
getItemPropertyOptionValues,
|
|
|
|
|
openItemPropertyOptionSelect,
|
|
|
|
|
describeItemPropertyHelp,
|
|
|
|
|
getItemPropertyMetadata,
|
|
|
|
|
validateNumericItemPropertyInput,
|
|
|
|
|
clampEffectLevel,
|
|
|
|
|
effectIds: EFFECT_IDS as Set<string>,
|
|
|
|
|
effectSequenceIdsCsv: EFFECT_SEQUENCE.map((effect) => effect.id).join(', '),
|
|
|
|
|
applyTextInputEdit,
|
|
|
|
|
setReplaceTextOnNextType: (value) => {
|
|
|
|
|
replaceTextOnNextType = value;
|
|
|
|
|
},
|
2026-02-22 20:50:04 -05:00
|
|
|
suppressItemPropertyEchoMs: (ms) => {
|
|
|
|
|
suppressItemPropertyEchoUntilMs = Math.max(suppressItemPropertyEchoUntilMs, Date.now() + Math.max(0, ms));
|
|
|
|
|
},
|
2026-02-22 23:51:13 -05:00
|
|
|
onPreviewPropertyChange: (item, key, value) => {
|
|
|
|
|
if (item.type !== 'piano') return;
|
|
|
|
|
if (key === 'instrument') {
|
|
|
|
|
const instrument = normalizePianoInstrument(value);
|
2026-02-23 00:05:01 -05:00
|
|
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
2026-02-23 00:45:17 -05:00
|
|
|
const octave = defaultsOctaveForInstrument(instrument);
|
2026-02-23 00:05:01 -05:00
|
|
|
void previewPianoSettingChange(item, {
|
|
|
|
|
instrument,
|
2026-02-23 00:45:17 -05:00
|
|
|
octave,
|
2026-02-23 00:05:01 -05:00
|
|
|
attack: defaults.attack,
|
|
|
|
|
decay: defaults.decay,
|
|
|
|
|
release: defaults.release,
|
|
|
|
|
brightness: defaults.brightness,
|
|
|
|
|
});
|
2026-02-22 23:51:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (key === 'attack') {
|
|
|
|
|
const attack = Number(value);
|
|
|
|
|
if (!Number.isFinite(attack)) return;
|
|
|
|
|
void previewPianoSettingChange(item, { attack });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (key === 'decay') {
|
|
|
|
|
const decay = Number(value);
|
|
|
|
|
if (!Number.isFinite(decay)) return;
|
|
|
|
|
void previewPianoSettingChange(item, { decay });
|
2026-02-23 00:05:01 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (key === 'release') {
|
|
|
|
|
const release = Number(value);
|
|
|
|
|
if (!Number.isFinite(release)) return;
|
|
|
|
|
void previewPianoSettingChange(item, { release });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (key === 'brightness') {
|
|
|
|
|
const brightness = Number(value);
|
|
|
|
|
if (!Number.isFinite(brightness)) return;
|
|
|
|
|
void previewPianoSettingChange(item, { brightness });
|
2026-02-23 00:22:36 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (key === 'octave') {
|
2026-02-23 00:45:17 -05:00
|
|
|
const octave = Number(value);
|
|
|
|
|
if (!Number.isFinite(octave)) return;
|
|
|
|
|
void previewPianoSettingChange(item, { octave });
|
2026-02-22 23:51:13 -05:00
|
|
|
}
|
|
|
|
|
},
|
2026-02-22 17:05:36 -05:00
|
|
|
updateStatus,
|
|
|
|
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
|
|
|
|
sfxUiCancel: () => audio.sfxUiCancel(),
|
|
|
|
|
});
|
2026-02-20 17:46:43 -05:00
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Handles nickname edit mode submission/cancel and text editing keys. */
|
2026-02-21 03:36:16 -05:00
|
|
|
function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void {
|
2026-02-22 16:58:57 -05:00
|
|
|
const editAction = getEditSessionAction(code);
|
|
|
|
|
if (editAction === 'submit') {
|
2026-02-20 08:16:43 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:58:57 -05:00
|
|
|
if (editAction === 'cancel') {
|
2026-02-20 08:16:43 -05:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Returns whether a key code should be treated as a repeat-suppressed typing key. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function isTypingKey(code: string): boolean {
|
|
|
|
|
return code.startsWith('Key') || code === 'Space';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:53:09 -05:00
|
|
|
/** Maps normalized `event.key` values to canonical `event.code` strings when code is unavailable. */
|
|
|
|
|
function codeFromKey(key: string, location: number): string | null {
|
|
|
|
|
if (key === 'Escape' || key === 'Esc') return 'Escape';
|
|
|
|
|
if (key === 'Enter' || key === 'Return') return 'Enter';
|
|
|
|
|
if (key === 'Backspace') return 'Backspace';
|
|
|
|
|
if (key === 'Delete' || key === 'Del') return 'Delete';
|
|
|
|
|
if (key === 'ArrowUp' || key === 'Up') return 'ArrowUp';
|
|
|
|
|
if (key === 'ArrowDown' || key === 'Down') return 'ArrowDown';
|
|
|
|
|
if (key === 'ArrowLeft' || key === 'Left') return 'ArrowLeft';
|
|
|
|
|
if (key === 'ArrowRight' || key === 'Right') return 'ArrowRight';
|
|
|
|
|
if (key === 'Home') return 'Home';
|
|
|
|
|
if (key === 'End') return 'End';
|
|
|
|
|
if (key === 'PageUp') return 'PageUp';
|
|
|
|
|
if (key === 'PageDown') return 'PageDown';
|
|
|
|
|
if (key === 'Tab') return 'Tab';
|
|
|
|
|
if (key === ' ' || key === 'Spacebar') return 'Space';
|
|
|
|
|
if (key.length === 1) {
|
|
|
|
|
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
|
|
|
|
|
if (/^[0-9]$/.test(key)) return `Digit${key}`;
|
|
|
|
|
if (key === '!') return 'Digit1';
|
|
|
|
|
if (key === '@') return 'Digit2';
|
|
|
|
|
if (key === '#') return 'Digit3';
|
|
|
|
|
if (key === '$') return 'Digit4';
|
|
|
|
|
if (key === '%') return 'Digit5';
|
|
|
|
|
if (key === '^') return 'Digit6';
|
|
|
|
|
if (key === '&') return 'Digit7';
|
|
|
|
|
if (key === '*') return 'Digit8';
|
|
|
|
|
if (key === '(') return 'Digit9';
|
|
|
|
|
if (key === ')') return 'Digit0';
|
|
|
|
|
if (key === '+' && location === 3) return 'NumpadAdd';
|
|
|
|
|
if (key === '-' && location === 3) return 'NumpadSubtract';
|
|
|
|
|
if (key === '+' || key === '=') return 'Equal';
|
|
|
|
|
if (key === '-' || key === '_') return 'Minus';
|
|
|
|
|
if (key === '/' || key === '?') return 'Slash';
|
|
|
|
|
if (key === ',' || key === '<') return 'Comma';
|
|
|
|
|
if (key === '.' || key === '>') return 'Period';
|
2026-02-22 23:42:17 -05:00
|
|
|
if (key === ';' || key === ':') return 'Semicolon';
|
|
|
|
|
if (key === "'" || key === '"') return 'Quote';
|
|
|
|
|
if (key === '[' || key === '{') return 'BracketLeft';
|
|
|
|
|
if (key === ']' || key === '}') return 'BracketRight';
|
|
|
|
|
if (key === '\\' || key === '|') return 'Backslash';
|
2026-02-22 22:53:09 -05:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Returns best-effort canonical key code across desktop + Safari/iOS keyboard event variants. */
|
|
|
|
|
function normalizeInputCode(event: KeyboardEvent): string {
|
|
|
|
|
if (event.code && event.code !== 'Unidentified') {
|
|
|
|
|
return event.code;
|
|
|
|
|
}
|
|
|
|
|
return codeFromKey(event.key, event.location) ?? event.code ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Wires global keyboard/paste input handlers and routes events by current mode. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function setupInputHandlers(): void {
|
|
|
|
|
document.addEventListener('keydown', (event) => {
|
2026-02-22 22:53:09 -05:00
|
|
|
const code = normalizeInputCode(event);
|
|
|
|
|
if (!code) return;
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
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-22 20:02:25 -05:00
|
|
|
if (activeTeleport && code.startsWith('Arrow')) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-02-22 02:08:14 -05:00
|
|
|
const isNativePasteShortcut = event.ctrlKey && isTextEditingMode(state.mode) && code === 'KeyV';
|
|
|
|
|
if ((state.mode !== 'normal' || !code.startsWith('Arrow')) && !isNativePasteShortcut) {
|
2026-02-20 08:16:43 -05:00
|
|
|
event.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 03:43:11 -05:00
|
|
|
if (event.ctrlKey && isTextEditingMode(state.mode)) {
|
2026-02-22 03:21:58 -05:00
|
|
|
if (code === 'KeyV') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-21 03:43:11 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
if (isTypingKey(code) && state.keysPressed[code]) return;
|
|
|
|
|
|
2026-02-22 17:33:31 -05:00
|
|
|
dispatchModeInput({
|
|
|
|
|
mode: state.mode,
|
|
|
|
|
code,
|
|
|
|
|
key: event.key,
|
|
|
|
|
ctrlKey: event.ctrlKey,
|
|
|
|
|
shiftKey: event.shiftKey,
|
|
|
|
|
handlers: {
|
|
|
|
|
nickname: handleNicknameModeInput,
|
|
|
|
|
chat: handleChatModeInput,
|
|
|
|
|
micGainEdit: handleMicGainEditModeInput,
|
2026-02-22 23:42:17 -05:00
|
|
|
pianoUse: (currentCode) => handlePianoUseModeInput(currentCode),
|
2026-02-22 17:33:31 -05:00
|
|
|
effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey),
|
|
|
|
|
helpView: (currentCode) => handleHelpViewModeInput(currentCode),
|
|
|
|
|
listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey),
|
|
|
|
|
listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey),
|
|
|
|
|
addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey),
|
|
|
|
|
selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey),
|
|
|
|
|
itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey),
|
|
|
|
|
itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) =>
|
|
|
|
|
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
|
|
|
|
|
itemPropertyOptionSelect: (currentCode, currentKey) =>
|
|
|
|
|
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
|
|
|
|
|
},
|
|
|
|
|
onNormalMode: handleNormalModeInput,
|
|
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
state.keysPressed[code] = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keyup', (event) => {
|
2026-02-22 22:53:09 -05:00
|
|
|
const code = normalizeInputCode(event);
|
2026-02-22 23:42:17 -05:00
|
|
|
if (state.mode === 'pianoUse' && code) {
|
2026-02-23 00:36:36 -05:00
|
|
|
handlePianoUseModeKeyUp(code);
|
2026-02-22 23:42:17 -05:00
|
|
|
}
|
2026-02-22 22:53:09 -05:00
|
|
|
if (code) {
|
|
|
|
|
state.keysPressed[code] = false;
|
|
|
|
|
}
|
|
|
|
|
if (event.code && event.code !== code) {
|
|
|
|
|
state.keysPressed[event.code] = false;
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
});
|
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
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Enumerates audio devices, updates selectors, and persists preferred choices. */
|
2026-02-20 08:16:43 -05:00
|
|
|
async function populateAudioDevices(): Promise<void> {
|
2026-02-22 17:33:31 -05:00
|
|
|
await mediaSession.populateAudioDevices();
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Opens settings modal and focuses device controls. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function openSettings(): void {
|
|
|
|
|
lastFocusedElement = document.activeElement;
|
|
|
|
|
dom.settingsModal.classList.remove('hidden');
|
|
|
|
|
void populateAudioDevices();
|
|
|
|
|
dom.audioInputSelect.focus();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Closes settings modal and restores focus back to prior element or game canvas. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function closeSettings(): void {
|
|
|
|
|
dom.settingsModal.classList.add('hidden');
|
|
|
|
|
if (lastFocusedElement instanceof HTMLElement) {
|
|
|
|
|
lastFocusedElement.focus();
|
|
|
|
|
} else {
|
|
|
|
|
dom.canvas.focus();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:23:33 -05:00
|
|
|
/** Wires button/form handlers and lifecycle hooks for the main UI shell. */
|
2026-02-20 08:16:43 -05:00
|
|
|
function setupUiHandlers(): void {
|
2026-02-22 17:05:36 -05:00
|
|
|
setupDomUiHandlers({
|
|
|
|
|
dom,
|
|
|
|
|
sanitizeName,
|
|
|
|
|
nicknameStorageKey: NICKNAME_STORAGE_KEY,
|
|
|
|
|
updateConnectAvailability,
|
|
|
|
|
connect,
|
|
|
|
|
disconnect,
|
|
|
|
|
openSettings,
|
|
|
|
|
closeSettings,
|
|
|
|
|
updateStatus,
|
|
|
|
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
|
|
|
|
setupLocalMedia,
|
|
|
|
|
setPreferredInput: (id, name) => {
|
2026-02-22 17:33:31 -05:00
|
|
|
mediaSession.setPreferredInput(id, name);
|
2026-02-22 17:05:36 -05:00
|
|
|
},
|
|
|
|
|
setPreferredOutput: (id, name) => {
|
2026-02-22 17:33:31 -05:00
|
|
|
mediaSession.setPreferredOutput(id, name);
|
2026-02-22 17:05:36 -05:00
|
|
|
},
|
|
|
|
|
updateDeviceSummary,
|
|
|
|
|
setOutputDevice: (id) => peerManager.setOutputDevice(id),
|
|
|
|
|
persistOnUnload: () => {
|
|
|
|
|
if (!state.running) return;
|
|
|
|
|
persistPlayerPosition();
|
|
|
|
|
},
|
2026-02-20 08:16:43 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setupInputHandlers();
|
|
|
|
|
setupUiHandlers();
|
2026-02-22 17:33:31 -05:00
|
|
|
const storedNickname = sanitizeName(settings.loadNickname());
|
2026-02-20 08:16:43 -05:00
|
|
|
dom.preconnectNickname.value = storedNickname;
|
|
|
|
|
if (storedNickname) {
|
|
|
|
|
state.player.nickname = storedNickname;
|
|
|
|
|
}
|
|
|
|
|
updateConnectAvailability();
|
|
|
|
|
updateDeviceSummary();
|
2026-02-22 19:49:14 -05:00
|
|
|
updateStatus(
|
|
|
|
|
isVersionReloadedSession()
|
|
|
|
|
? 'Client updated, please reconnect.'
|
|
|
|
|
: 'Welcome to the Chat Grid. Press the Settings button to configure your audio, then Connect to join the grid.',
|
|
|
|
|
);
|
2026-02-22 19:54:55 -05:00
|
|
|
setConnectionStatus(isVersionReloadedSession() ? 'Client updated, please reconnect.' : 'Not connected.');
|