Add context-aware command palette

This commit is contained in:
Jage9
2026-03-08 19:27:23 -04:00
parent 9e41013fe8
commit 1741bcc2bc
14 changed files with 1241 additions and 518 deletions

View File

@@ -23,7 +23,9 @@ import {
moveCursorWordRight,
shouldReplaceCurrentText,
} from './input/textInput';
import { resolveMainModeCommand } from './input/mainCommandRouter';
import { formatCommandMenuLabel, type CommandDescriptor, type ModeInput } from './input/commandTypes';
import { getAvailableMainModeCommands } from './input/mainModeCommands';
import { resolveMainModeCommand, type MainModeCommand } from './input/mainCommandRouter';
import { dispatchModeInput } from './input/modeDispatcher';
import { handleListControlKey } from './input/listController';
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
@@ -314,6 +316,9 @@ let mainHelpViewerLines: string[] = [];
let helpViewerLines: string[] = [];
let helpViewerIndex = 0;
let helpViewerReturnMode: GameMode = 'normal';
const commandPaletteCommands: Array<CommandDescriptor & { run: () => void | Promise<void> }> = [];
let commandPaletteIndex = 0;
let commandPaletteReturnMode: GameMode = 'normal';
let heartbeatTimerId: number | null = null;
let heartbeatNextPingId = -1;
let heartbeatAwaitingPong = false;
@@ -2157,6 +2162,457 @@ function toggleMute(): void {
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
}
function getCurrentSquareItems(): WorldItem[] {
return getItemsAtPosition(state.player.x, state.player.y);
}
function getUsableItemsOnCurrentSquare(): WorldItem[] {
return getCurrentSquareItems().filter((item) => item.capabilities.includes('usable'));
}
function getManageableItemsOnCurrentSquare(): WorldItem[] {
return getCurrentSquareItems().filter((item) => itemManagementOptionsFor(item).length > 0);
}
function canEditCurrentItem(): boolean {
return getCurrentSquareItems().length > 0 || Boolean(getCarriedItem());
}
function canInspectCurrentItem(): boolean {
return canEditCurrentItem();
}
function openNicknameEditor(): void {
state.mode = 'nickname';
state.nicknameInput = state.player.nickname;
state.cursorPos = state.player.nickname.length;
replaceTextOnNextType = true;
updateStatus(`Nickname edit: ${state.nicknameInput}`);
audio.sfxUiBlip();
}
function toggleOutputModeCommand(): void {
outputMode = audio.toggleOutputMode();
mediaSession.saveOutputMode(outputMode);
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
audio.sfxUiBlip();
}
function toggleLoopbackCommand(): void {
const enabled = audio.toggleLoopback();
updateStatus(enabled ? 'Loopback on.' : 'Loopback off.');
audio.sfxUiBlip();
}
function adjustMasterVolumeCommand(step: number): void {
const next = audio.adjustMasterVolume(step);
persistMasterVolume(next);
updateStatus(`Master volume ${next}`);
audio.sfxEffectLevel(next === 50);
}
function openEffectSelectCommand(): void {
const currentEffect = audio.getCurrentEffect();
const currentIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === currentEffect.id);
state.effectSelectIndex = currentIndex >= 0 ? currentIndex : 0;
state.mode = 'effectSelect';
announceMenuEntry('Effects', EFFECT_SEQUENCE[state.effectSelectIndex].label);
}
function adjustEffectValueCommand(step: number): void {
const adjusted = audio.adjustCurrentEffectLevel(step);
if (!adjusted) return;
persistEffectLevels();
audio.sfxEffectLevel(adjusted.value === adjusted.defaultValue);
updateStatus(`${adjusted.label} ${adjusted.value}`);
}
function speakCoordinatesCommand(): void {
updateStatus(`${formatCoordinate(state.player.x)}, ${formatCoordinate(state.player.y)}`);
audio.sfxUiBlip();
}
function openMicGainEditCommand(): void {
if (!voiceSendAllowed) {
updateStatus('Voice send is disabled for this account.');
audio.sfxUiCancel();
return;
}
state.mode = 'micGainEdit';
state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP);
state.cursorPos = state.nicknameInput.length;
replaceTextOnNextType = true;
micGainLoopbackRestoreState = audio.isLoopbackEnabled();
audio.setLoopbackEnabled(true);
announceMenuEntry('Microphone gain', state.nicknameInput);
}
function calibrateMicrophoneCommand(): void {
if (!voiceSendAllowed) {
updateStatus('Voice send is disabled for this account.');
audio.sfxUiCancel();
return;
}
void calibrateMicInputGain();
}
function openAdminMenuCommand(): void {
const actions = getAvailableAdminActions();
if (actions.length === 0) {
updateStatus('No admin actions available.');
audio.sfxUiCancel();
return;
}
adminMenuActions.splice(0, adminMenuActions.length, ...actions);
adminMenuIndex = 0;
state.mode = 'adminMenu';
announceMenuEntry('Admin', adminMenuActions[0].label);
}
function useItemCommand(): void {
const carried = getCarriedItem();
if (carried) {
useItem(carried);
return;
}
const usable = getUsableItemsOnCurrentSquare();
if (usable.length === 0) {
updateStatus('No usable items here.');
audio.sfxUiCancel();
return;
}
if (usable.length === 1) {
useItem(usable[0]);
return;
}
beginItemSelection('use', usable);
}
function secondaryUseItemCommand(): void {
const carried = getCarriedItem();
if (carried) {
secondaryUseItem(carried);
return;
}
const usable = getUsableItemsOnCurrentSquare();
if (usable.length === 0) {
updateStatus('No usable items here.');
audio.sfxUiCancel();
return;
}
if (usable.length === 1) {
secondaryUseItem(usable[0]);
return;
}
beginItemSelection('secondaryUse', usable);
}
function speakUsersCommand(): void {
const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((peer) => peer.nickname)];
const label = allUsers.length === 1 ? 'user' : 'users';
updateStatus(`${allUsers.length} ${label}: ${allUsers.join(', ')}`);
audio.sfxUiBlip();
}
function addItemCommand(): void {
const itemTypeSequence = getItemTypeSequence();
if (itemTypeSequence.length === 0) {
updateStatus('No item types available.');
audio.sfxUiCancel();
return;
}
state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1));
state.mode = 'addItem';
announceMenuEntry('Add item', itemTypeLabel(itemTypeSequence[state.addItemTypeIndex]));
}
function listItemsCommand(): void {
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) {
audio.sfxUiCancel();
return;
}
const itemCount = state.sortedItemIds.length;
const itemLabelText = itemCount === 1 ? 'item' : 'items';
announceMenuEntry(
`${itemCount} ${itemLabelText}`,
`${itemLabel(first)}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
);
}
function locateNearestItemCommand(): void {
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}`);
}
function pickupDropItemCommand(): void {
const carried = getCarriedItem();
if (carried) {
signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y });
return;
}
const squareItems = getCurrentSquareItems();
if (squareItems.length === 0) {
updateStatus('No items to pick up.');
audio.sfxUiCancel();
return;
}
if (squareItems.length === 1) {
signaling.send({ type: 'item_pickup', itemId: squareItems[0].id });
return;
}
beginItemSelection('pickup', squareItems);
}
function openItemManagementCommand(): void {
const squareItems = getCurrentSquareItems();
if (squareItems.length === 0) {
updateStatus('No items to manage on this square.');
audio.sfxUiCancel();
return;
}
const manageable = squareItems.filter((item) => itemManagementOptionsFor(item).length > 0);
if (manageable.length === 0) {
updateStatus('No permitted item management actions here.');
audio.sfxUiCancel();
return;
}
if (manageable.length === 1) {
beginItemManagement(manageable[0]);
return;
}
beginItemSelection('manage', manageable);
}
function editItemCommand(): void {
const squareItems = getCurrentSquareItems();
const carried = getCarriedItem();
if (squareItems.length === 0) {
if (!carried) {
updateStatus('No editable item here.');
audio.sfxUiCancel();
return;
}
beginItemProperties(carried);
return;
}
if (squareItems.length === 1) {
beginItemProperties(squareItems[0]);
return;
}
beginItemSelection('edit', squareItems);
}
function inspectItemCommand(): void {
const squareItems = getCurrentSquareItems();
const carried = getCarriedItem();
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);
}
function pingServerCommand(): void {
signaling.send({ type: 'ping', clientSentAt: Date.now() });
}
function listUsersCommand(): void {
if (state.peers.size === 0) {
updateStatus('No users to list.');
audio.sfxUiCancel();
return;
}
state.sortedPeerIds = Array.from(state.peers.entries())
.sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' }))
.map(([id]) => id);
state.listIndex = 0;
state.mode = 'listUsers';
const first = state.peers.get(state.sortedPeerIds[0]);
if (!first) {
audio.sfxUiCancel();
return;
}
const userCount = state.sortedPeerIds.length;
const userLabelText = userCount === 1 ? 'user' : 'users';
const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(first.nickname), MIC_INPUT_GAIN_STEP)}`;
announceMenuEntry(
`${userCount} ${userLabelText}`,
`${first.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
);
}
function locateNearestUserCommand(): void {
const nearest = getNearestPeer(state);
if (!nearest.peerId) {
updateStatus('No users to locate.');
audio.sfxUiCancel();
return;
}
const peer = state.peers.get(nearest.peerId);
if (!peer) return;
audio.sfxLocate({ x: peer.x - state.player.x, y: peer.y - state.player.y });
updateStatus(`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`);
}
function openHelpCommand(): void {
openHelpViewer(mainHelpViewerLines);
}
function openChatCommand(): void {
state.mode = 'chat';
state.nicknameInput = '';
state.cursorPos = 0;
replaceTextOnNextType = false;
updateStatus('Chat.');
audio.sfxUiBlip();
}
function escapeCommand(): void {
if (pendingEscapeDisconnect) {
pendingEscapeDisconnect = false;
disconnect();
return;
}
pendingEscapeDisconnect = true;
updateStatus('Press Escape again to disconnect.');
audio.sfxUiCancel();
}
const mainModeCommandHandlers: Record<MainModeCommand, () => void> = {
editNickname: openNicknameEditor,
toggleMute,
toggleOutputMode: toggleOutputModeCommand,
toggleLoopback: toggleLoopbackCommand,
toggleVoiceLayer: () => toggleAudioLayer('voice'),
toggleItemLayer: () => toggleAudioLayer('item'),
toggleMediaLayer: () => toggleAudioLayer('media'),
toggleWorldLayer: () => toggleAudioLayer('world'),
masterVolumeUp: () => adjustMasterVolumeCommand(5),
masterVolumeDown: () => adjustMasterVolumeCommand(-5),
openEffectSelect: openEffectSelectCommand,
effectValueUp: () => adjustEffectValueCommand(5),
effectValueDown: () => adjustEffectValueCommand(-5),
speakCoordinates: speakCoordinatesCommand,
openMicGainEdit: openMicGainEditCommand,
calibrateMicrophone: calibrateMicrophoneCommand,
useItem: useItemCommand,
secondaryUseItem: secondaryUseItemCommand,
speakUsers: speakUsersCommand,
addItem: addItemCommand,
locateNearestItem: locateNearestItemCommand,
listItems: listItemsCommand,
pickupDropItem: pickupDropItemCommand,
openItemManagement: openItemManagementCommand,
editItem: editItemCommand,
inspectItem: inspectItemCommand,
pingServer: pingServerCommand,
locateNearestUser: locateNearestUserCommand,
listUsers: listUsersCommand,
openHelp: openHelpCommand,
openChat: openChatCommand,
openAdminMenu: openAdminMenuCommand,
chatPrev: () => navigateChatBuffer('prev'),
chatNext: () => navigateChatBuffer('next'),
chatFirst: () => navigateChatBuffer('first'),
chatLast: () => navigateChatBuffer('last'),
escape: escapeCommand,
};
function getAvailableCommandPaletteEntriesForMode(mode: GameMode): Array<CommandDescriptor & { run: () => void | Promise<void> }> {
if (mode === 'normal') {
const descriptors = getAvailableMainModeCommands({
voiceSendAllowed,
mainHelpAvailable: mainHelpViewerLines.length > 0,
hasAdminActions: getAvailableAdminActions().length > 0,
itemTypeCount: getItemTypeSequence().length,
visibleItemCount: Array.from(state.items.values()).filter((item) => !item.carrierId).length,
userCount: state.peers.size,
chatMessageCount: messageBuffer.length,
hasCarriedItem: Boolean(getCarriedItem()),
squareItemCount: getCurrentSquareItems().length,
usableItemCount: getUsableItemsOnCurrentSquare().length,
manageableItemCount: getManageableItemsOnCurrentSquare().length,
hasEditableItemTarget: canEditCurrentItem(),
hasInspectableItemTarget: canInspectCurrentItem(),
});
return descriptors.map((descriptor) => ({
...descriptor,
run: mainModeCommandHandlers[descriptor.id],
}));
}
if (mode === 'pianoUse') {
return itemBehaviorRegistry.getModeCommands(mode).map((descriptor) => ({
...descriptor,
run: () => {
itemBehaviorRegistry.runModeCommand(mode, descriptor.id);
},
}));
}
return [];
}
function canOpenCommandPaletteInMode(mode: GameMode): boolean {
return mode === 'normal' || mode === 'pianoUse' || mode === 'commandPalette';
}
function openCommandPalette(): void {
const sourceMode = state.mode;
if (sourceMode === 'commandPalette') {
return;
}
const commands = getAvailableCommandPaletteEntriesForMode(sourceMode);
if (commands.length === 0) {
updateStatus('No commands available in this mode.');
audio.sfxUiCancel();
return;
}
commandPaletteCommands.splice(0, commandPaletteCommands.length, ...commands);
commandPaletteIndex = 0;
commandPaletteReturnMode = sourceMode;
state.mode = 'commandPalette';
announceMenuEntry('Commands', formatCommandMenuLabel(commandPaletteCommands[0]));
}
function executeCommandPaletteSelection(): void {
const selected = commandPaletteCommands[commandPaletteIndex];
if (!selected) return;
state.mode = commandPaletteReturnMode;
void selected.run();
}
/** Handles command-mode keybindings while in main gameplay mode. */
function handleNormalModeInput(code: string, shiftKey: boolean): void {
if (code !== 'Escape' && pendingEscapeDisconnect) {
@@ -2164,369 +2620,7 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
}
const command = resolveMainModeCommand(code, shiftKey);
if (!command) return;
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':
outputMode = audio.toggleOutputMode();
mediaSession.saveOutputMode(outputMode);
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
audio.sfxUiBlip();
return;
case 'toggleLoopback': {
const enabled = audio.toggleLoopback();
updateStatus(enabled ? 'Loopback on.' : 'Loopback off.');
audio.sfxUiBlip();
return;
}
case 'toggleVoiceLayer':
toggleAudioLayer('voice');
return;
case 'toggleItemLayer':
toggleAudioLayer('item');
return;
case 'toggleMediaLayer':
toggleAudioLayer('media');
return;
case 'toggleWorldLayer':
toggleAudioLayer('world');
return;
case 'masterVolumeUp':
case 'masterVolumeDown': {
const step = command === 'masterVolumeUp' ? 5 : -5;
const next = audio.adjustMasterVolume(step);
persistMasterVolume(next);
updateStatus(`Master volume ${next}`);
audio.sfxEffectLevel(next === 50);
return;
}
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';
announceMenuEntry('Effects', EFFECT_SEQUENCE[state.effectSelectIndex].label);
return;
}
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}`);
return;
}
case 'speakCoordinates':
updateStatus(`${formatCoordinate(state.player.x)}, ${formatCoordinate(state.player.y)}`);
audio.sfxUiBlip();
return;
case 'openMicGainEdit':
if (!voiceSendAllowed) {
updateStatus('Voice send is disabled for this account.');
audio.sfxUiCancel();
return;
}
state.mode = 'micGainEdit';
state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP);
state.cursorPos = state.nicknameInput.length;
replaceTextOnNextType = true;
micGainLoopbackRestoreState = audio.isLoopbackEnabled();
audio.setLoopbackEnabled(true);
announceMenuEntry('Microphone gain', state.nicknameInput);
return;
case 'calibrateMicrophone':
if (!voiceSendAllowed) {
updateStatus('Voice send is disabled for this account.');
audio.sfxUiCancel();
return;
}
void calibrateMicInputGain();
return;
case 'openAdminMenu': {
const actions = getAvailableAdminActions();
if (actions.length === 0) {
return;
}
adminMenuActions.splice(0, adminMenuActions.length, ...actions);
adminMenuIndex = 0;
state.mode = 'adminMenu';
announceMenuEntry('Admin', adminMenuActions[0].label);
return;
}
case 'useItem': {
const carried = getCarriedItem();
if (carried) {
useItem(carried);
return;
}
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
const usable = squareItems.filter((item) => item.capabilities.includes('usable'));
if (usable.length === 0) {
updateStatus('No usable items here.');
audio.sfxUiCancel();
return;
}
if (usable.length === 1) {
useItem(usable[0]);
return;
}
beginItemSelection('use', usable);
return;
}
case 'secondaryUseItem': {
const carried = getCarriedItem();
if (carried) {
secondaryUseItem(carried);
return;
}
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
const usable = squareItems.filter((item) => item.capabilities.includes('usable'));
if (usable.length === 0) {
updateStatus('No usable items here.');
audio.sfxUiCancel();
return;
}
if (usable.length === 1) {
secondaryUseItem(usable[0]);
return;
}
beginItemSelection('secondaryUse', 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;
}
case 'addItem': {
const itemTypeSequence = getItemTypeSequence();
if (itemTypeSequence.length === 0) {
updateStatus('No item types available.');
audio.sfxUiCancel();
return;
}
state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1));
state.mode = 'addItem';
announceMenuEntry('Add item', itemTypeLabel(itemTypeSequence[state.addItemTypeIndex]));
return;
}
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) {
const itemCount = state.sortedItemIds.length;
const itemLabelText = itemCount === 1 ? 'item' : 'items';
announceMenuEntry(
`${itemCount} ${itemLabelText}`,
`${itemLabel(first)}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
);
} else {
audio.sfxUiCancel();
}
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 'pickupDropItem': {
const carried = getCarriedItem();
if (carried) {
signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y });
return;
}
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
if (squareItems.length === 0) {
updateStatus('No items to pick up.');
audio.sfxUiCancel();
return;
}
if (squareItems.length === 1) {
signaling.send({ type: 'item_pickup', itemId: squareItems[0].id });
return;
}
beginItemSelection('pickup', squareItems);
return;
}
case 'openItemManagement': {
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
if (squareItems.length === 0) {
updateStatus('No items to manage on this square.');
audio.sfxUiCancel();
return;
}
const manageable = squareItems.filter((item) => itemManagementOptionsFor(item).length > 0);
if (manageable.length === 0) {
updateStatus('No permitted item management actions here.');
audio.sfxUiCancel();
return;
}
if (manageable.length === 1) {
beginItemManagement(manageable[0]);
return;
}
beginItemSelection('manage', manageable);
return;
}
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;
}
if (squareItems.length === 0) {
if (!carried) {
updateStatus('No editable item here.');
audio.sfxUiCancel();
return;
}
beginItemProperties(carried);
return;
}
if (squareItems.length === 1) {
beginItemProperties(squareItems[0]);
return;
}
beginItemSelection('edit', squareItems);
return;
}
case 'pingServer':
signaling.send({ type: 'ping', clientSentAt: Date.now() });
return;
case 'locateOrListUsers':
if (shiftKey) {
if (state.peers.size === 0) {
updateStatus('No users to list.');
audio.sfxUiCancel();
return;
}
state.sortedPeerIds = Array.from(state.peers.entries())
.sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' }))
.map(([id]) => id);
state.listIndex = 0;
state.mode = 'listUsers';
const first = state.peers.get(state.sortedPeerIds[0]);
if (first) {
const userCount = state.sortedPeerIds.length;
const userLabelText = userCount === 1 ? 'user' : 'users';
const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(first.nickname), MIC_INPUT_GAIN_STEP)}`;
announceMenuEntry(
`${userCount} ${userLabelText}`,
`${first.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
);
} else {
audio.sfxUiCancel();
}
return;
}
{
const nearest = getNearestPeer(state);
if (!nearest.peerId) {
updateStatus('No users to locate.');
audio.sfxUiCancel();
return;
}
const peer = state.peers.get(nearest.peerId);
if (!peer) return;
audio.sfxLocate({ x: peer.x - state.player.x, y: peer.y - state.player.y });
updateStatus(
`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`,
);
return;
}
case 'openHelp':
openHelpViewer(mainHelpViewerLines);
return;
case 'openChat':
state.mode = 'chat';
state.nicknameInput = '';
state.cursorPos = 0;
replaceTextOnNextType = false;
updateStatus('Chat.');
audio.sfxUiBlip();
return;
case 'chatPrev':
navigateChatBuffer('prev');
return;
case 'chatNext':
navigateChatBuffer('next');
return;
case 'chatFirst':
navigateChatBuffer('first');
return;
case 'chatLast':
navigateChatBuffer('last');
return;
case 'escape':
if (pendingEscapeDisconnect) {
pendingEscapeDisconnect = false;
disconnect();
return;
}
pendingEscapeDisconnect = true;
updateStatus('Press Escape again to disconnect.');
audio.sfxUiCancel();
return;
}
mainModeCommandHandlers[command]();
}
/** Handles linear help viewer navigation and exit keys. */
@@ -2569,6 +2663,39 @@ function handleHelpViewModeInput(code: string): void {
}
}
/** Handles command palette list navigation, tooltips, and execution. */
function handleCommandPaletteModeInput(code: string, key: string): void {
if (commandPaletteCommands.length === 0) {
state.mode = commandPaletteReturnMode;
updateStatus('No commands available.');
audio.sfxUiCancel();
return;
}
const control = handleListControlKey(code, key, commandPaletteCommands, commandPaletteIndex, (entry) => formatCommandMenuLabel(entry));
if (control.type === 'move') {
commandPaletteIndex = control.index;
updateStatus(formatCommandMenuLabel(commandPaletteCommands[commandPaletteIndex]));
audio.sfxUiBlip();
return;
}
if (code === 'Space') {
const selected = commandPaletteCommands[commandPaletteIndex];
if (!selected) return;
updateStatus(selected.tooltip || 'No tooltip available.');
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
executeCommandPaletteSelection();
return;
}
if (control.type === 'cancel') {
state.mode = commandPaletteReturnMode;
updateStatus('Closed commands.');
audio.sfxUiCancel();
}
}
/** Handles chat compose mode including submit/cancel and inline editing keys. */
function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void {
const editAction = getEditSessionAction(code);
@@ -3503,6 +3630,12 @@ function setupInputHandlers(): void {
const code = normalizeInputCode(event);
if (!code) return;
const hasShortcutModifier = event.ctrlKey || event.metaKey;
const input: ModeInput = {
code,
key: event.key,
ctrlKey: hasShortcutModifier,
shiftKey: event.shiftKey,
};
if (!dom.settingsModal.classList.contains('hidden') && code === 'Escape') {
closeSettings();
@@ -3548,41 +3681,58 @@ function setupInputHandlers(): void {
if (isTypingKey(code) && state.keysPressed[code]) return;
const opensCommandPalette =
canOpenCommandPaletteInMode(state.mode) &&
((code === 'KeyK' && event.shiftKey) || code === 'ContextMenu' || (code === 'F10' && event.shiftKey));
if (opensCommandPalette) {
if (pendingEscapeDisconnect) {
pendingEscapeDisconnect = false;
}
openCommandPalette();
state.keysPressed[code] = true;
return;
}
dispatchModeInput({
mode: state.mode,
code,
key: event.key,
ctrlKey: hasShortcutModifier,
shiftKey: event.shiftKey,
input,
handlers: {
nickname: handleNicknameModeInput,
chat: handleChatModeInput,
micGainEdit: handleMicGainEditModeInput,
pianoUse: (currentCode) => {
itemBehaviorRegistry.handleModeInput(state.mode, currentCode);
nickname: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
handleNicknameModeInput(currentCode, currentKey, currentCtrlKey),
chat: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
handleChatModeInput(currentCode, currentKey, currentCtrlKey),
micGainEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
handleMicGainEditModeInput(currentCode, currentKey, currentCtrlKey),
pianoUse: (currentInput) => {
itemBehaviorRegistry.handleModeInput(state.mode, currentInput);
},
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),
itemManageOptions: (currentCode, currentKey) => handleItemManageOptionsModeInput(currentCode, currentKey),
itemManageTransferUser: (currentCode, currentKey) => handleItemManageTransferUserModeInput(currentCode, currentKey),
confirmYesNo: (currentCode, currentKey) => handleConfirmYesNoModeInput(currentCode, currentKey),
adminMenu: (currentCode, currentKey) => handleAdminMenuModeInput(currentCode, currentKey),
adminRoleList: (currentCode, currentKey) => handleAdminRoleListModeInput(currentCode, currentKey),
adminRolePermissionList: (currentCode, currentKey) => handleAdminRolePermissionListModeInput(currentCode, currentKey),
adminRoleDeleteReplacement: (currentCode, currentKey) => handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey),
adminUserList: (currentCode, currentKey) => handleAdminUserListModeInput(currentCode, currentKey),
adminUserRoleSelect: (currentCode, currentKey) => handleAdminUserRoleSelectModeInput(currentCode, currentKey),
adminUserDeleteConfirm: (currentCode, currentKey) => handleAdminUserDeleteConfirmModeInput(currentCode, currentKey),
adminRoleNameEdit: (currentCode, currentKey, currentCtrlKey) =>
commandPalette: ({ code: currentCode, key: currentKey }) => handleCommandPaletteModeInput(currentCode, currentKey),
effectSelect: ({ code: currentCode, key: currentKey }) => handleEffectSelectModeInput(currentCode, currentKey),
helpView: ({ code: currentCode }) => handleHelpViewModeInput(currentCode),
listUsers: ({ code: currentCode, key: currentKey }) => handleListModeInput(currentCode, currentKey),
listItems: ({ code: currentCode, key: currentKey }) => handleListItemsModeInput(currentCode, currentKey),
addItem: ({ code: currentCode, key: currentKey }) => handleAddItemModeInput(currentCode, currentKey),
selectItem: ({ code: currentCode, key: currentKey }) => handleSelectItemModeInput(currentCode, currentKey),
itemManageOptions: ({ code: currentCode, key: currentKey }) => handleItemManageOptionsModeInput(currentCode, currentKey),
itemManageTransferUser: ({ code: currentCode, key: currentKey }) =>
handleItemManageTransferUserModeInput(currentCode, currentKey),
confirmYesNo: ({ code: currentCode, key: currentKey }) => handleConfirmYesNoModeInput(currentCode, currentKey),
adminMenu: ({ code: currentCode, key: currentKey }) => handleAdminMenuModeInput(currentCode, currentKey),
adminRoleList: ({ code: currentCode, key: currentKey }) => handleAdminRoleListModeInput(currentCode, currentKey),
adminRolePermissionList: ({ code: currentCode, key: currentKey }) =>
handleAdminRolePermissionListModeInput(currentCode, currentKey),
adminRoleDeleteReplacement: ({ code: currentCode, key: currentKey }) =>
handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey),
adminUserList: ({ code: currentCode, key: currentKey }) => handleAdminUserListModeInput(currentCode, currentKey),
adminUserRoleSelect: ({ code: currentCode, key: currentKey }) => handleAdminUserRoleSelectModeInput(currentCode, currentKey),
adminUserDeleteConfirm: ({ code: currentCode, key: currentKey }) => handleAdminUserDeleteConfirmModeInput(currentCode, currentKey),
adminRoleNameEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
handleAdminRoleNameEditModeInput(currentCode, currentKey, currentCtrlKey),
itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey),
itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) =>
itemProperties: ({ code: currentCode, key: currentKey }) =>
itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey),
itemPropertyEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
itemPropertyOptionSelect: (currentCode, currentKey) =>
itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
},
onNormalMode: handleNormalModeInput,
@@ -3594,7 +3744,10 @@ function setupInputHandlers(): void {
document.addEventListener('keyup', (event) => {
const code = normalizeInputCode(event);
if (state.mode === 'pianoUse' && code) {
itemBehaviorRegistry.handleModeKeyUp(state.mode, code);
itemBehaviorRegistry.handleModeKeyUp(state.mode, {
code,
shiftKey: event.shiftKey,
});
}
if (code) {
state.keysPressed[code] = false;