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

@@ -7,6 +7,10 @@
"keys": "Arrow Keys", "keys": "Arrow Keys",
"description": "Move on the grid" "description": "Move on the grid"
}, },
{
"keys": "Shift+K / Applications / Shift+F10",
"description": "Open the command palette in supported modes"
},
{ {
"keys": "C", "keys": "C",
"description": "Speak your coordinates" "description": "Speak your coordinates"

View File

@@ -15,6 +15,10 @@
"keys": "1 through 0", "keys": "1 through 0",
"description": "Change instrument presets." "description": "Change instrument presets."
}, },
{
"keys": "Shift+K / Applications / Shift+F10",
"description": "Open the command palette."
},
{ {
"keys": "Minus / Equals", "keys": "Minus / Equals",
"description": "Shift octave down or up." "description": "Shift octave down or up."
@@ -38,6 +42,10 @@
{ {
"keys": "Escape", "keys": "Escape",
"description": "Exit piano mode." "description": "Exit piano mode."
},
{
"keys": "Shifted note keys",
"description": "Ignored in piano mode so shifted shortcuts stay distinct."
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.03.01 R334"; window.CHGRID_WEB_VERSION = "2026.03.08 R335";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -0,0 +1,19 @@
export type ModeInput = {
code: string;
key: string;
ctrlKey: boolean;
shiftKey: boolean;
};
export type CommandDescriptor<CommandId extends string = string> = {
id: CommandId;
label: string;
shortcut: string;
tooltip: string;
section: string;
};
/** Formats a palette/menu label as `Name: Key`. */
export function formatCommandMenuLabel(command: Pick<CommandDescriptor, 'label' | 'shortcut'>): string {
return `${command.label}: ${command.shortcut}`;
}

View File

@@ -22,12 +22,15 @@ export type MainModeCommand =
| 'secondaryUseItem' | 'secondaryUseItem'
| 'speakUsers' | 'speakUsers'
| 'addItem' | 'addItem'
| 'locateOrListItems' | 'locateNearestItem'
| 'listItems'
| 'pickupDropItem' | 'pickupDropItem'
| 'openItemManagement' | 'openItemManagement'
| 'editOrInspectItem' | 'editItem'
| 'inspectItem'
| 'pingServer' | 'pingServer'
| 'locateOrListUsers' | 'locateNearestUser'
| 'listUsers'
| 'openHelp' | 'openHelp'
| 'openChat' | 'openChat'
| 'openAdminMenu' | 'openAdminMenu'
@@ -44,9 +47,9 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod
if (code === 'KeyN') return shiftKey ? null : 'editNickname'; if (code === 'KeyN') return shiftKey ? null : 'editNickname';
if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute'; if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute';
if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer'; if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer';
if (code === 'Digit2') return 'toggleItemLayer'; if (code === 'Digit2') return shiftKey ? null : 'toggleItemLayer';
if (code === 'Digit3') return 'toggleMediaLayer'; if (code === 'Digit3') return shiftKey ? null : 'toggleMediaLayer';
if (code === 'Digit4') return 'toggleWorldLayer'; if (code === 'Digit4') return shiftKey ? null : 'toggleWorldLayer';
if (code === 'KeyE') return shiftKey ? null : 'openEffectSelect'; if (code === 'KeyE') return shiftKey ? null : 'openEffectSelect';
if (code === 'Equal') return shiftKey ? 'effectValueUp' : 'masterVolumeUp'; if (code === 'Equal') return shiftKey ? 'effectValueUp' : 'masterVolumeUp';
if (code === 'Minus') return shiftKey ? 'effectValueDown' : 'masterVolumeDown'; if (code === 'Minus') return shiftKey ? 'effectValueDown' : 'masterVolumeDown';
@@ -57,15 +60,15 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod
if (code === 'Enter') return shiftKey ? 'secondaryUseItem' : 'useItem'; if (code === 'Enter') return shiftKey ? 'secondaryUseItem' : 'useItem';
if (code === 'KeyU') return shiftKey ? null : 'speakUsers'; if (code === 'KeyU') return shiftKey ? null : 'speakUsers';
if (code === 'KeyA') return shiftKey ? null : 'addItem'; if (code === 'KeyA') return shiftKey ? null : 'addItem';
if (code === 'KeyI') return 'locateOrListItems'; if (code === 'KeyI') return shiftKey ? 'listItems' : 'locateNearestItem';
if (code === 'KeyD') return shiftKey ? null : 'pickupDropItem'; if (code === 'KeyD') return shiftKey ? null : 'pickupDropItem';
if (code === 'KeyO') return 'editOrInspectItem'; if (code === 'KeyO') return shiftKey ? 'inspectItem' : 'editItem';
if (code === 'KeyP') return shiftKey ? null : 'pingServer'; if (code === 'KeyP') return shiftKey ? null : 'pingServer';
if (code === 'KeyL') return 'locateOrListUsers'; if (code === 'KeyL') return shiftKey ? 'listUsers' : 'locateNearestUser';
if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat'; if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat';
if (code === 'KeyZ') return shiftKey ? 'openAdminMenu' : 'openItemManagement'; if (code === 'KeyZ') return shiftKey ? 'openAdminMenu' : 'openItemManagement';
if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev'; if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev';
if (code === 'Period') return shiftKey ? 'chatLast' : 'chatNext'; if (code === 'Period') return shiftKey ? 'chatLast' : 'chatNext';
if (code === 'Escape') return 'escape'; if (code === 'Escape') return shiftKey ? null : 'escape';
return null; return null;
} }

View File

@@ -0,0 +1,327 @@
import type { CommandDescriptor } from './commandTypes';
import type { MainModeCommand } from './mainCommandRouter';
export type MainModeCommandAvailabilityContext = {
voiceSendAllowed: boolean;
mainHelpAvailable: boolean;
hasAdminActions: boolean;
itemTypeCount: number;
visibleItemCount: number;
userCount: number;
chatMessageCount: number;
hasCarriedItem: boolean;
squareItemCount: number;
usableItemCount: number;
manageableItemCount: number;
hasEditableItemTarget: boolean;
hasInspectableItemTarget: boolean;
};
type MainModeCommandDescriptor = CommandDescriptor<MainModeCommand> & {
isAvailable: (context: MainModeCommandAvailabilityContext) => boolean;
};
const MAIN_MODE_COMMANDS: MainModeCommandDescriptor[] = [
{
id: 'editNickname',
label: 'Edit nickname',
shortcut: 'N',
tooltip: 'Edit your current nickname.',
section: 'Users',
isAvailable: () => true,
},
{
id: 'toggleMute',
label: 'Mute or unmute microphone',
shortcut: 'M',
tooltip: 'Toggle local microphone mute.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'toggleOutputMode',
label: 'Toggle stereo or mono output',
shortcut: 'Shift+M',
tooltip: 'Switch between stereo and mono output.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'toggleLoopback',
label: 'Toggle loopback monitor',
shortcut: 'Shift+1',
tooltip: 'Toggle local microphone loopback monitoring.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'toggleVoiceLayer',
label: 'Toggle voice layer',
shortcut: '1',
tooltip: 'Enable or disable voice audio.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'toggleItemLayer',
label: 'Toggle item layer',
shortcut: '2',
tooltip: 'Enable or disable item sounds.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'toggleMediaLayer',
label: 'Toggle media layer',
shortcut: '3',
tooltip: 'Enable or disable media audio such as radio.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'toggleWorldLayer',
label: 'Toggle world layer',
shortcut: '4',
tooltip: 'Enable or disable other world sounds.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'masterVolumeUp',
label: 'Raise master volume',
shortcut: '=',
tooltip: 'Increase overall output volume.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'masterVolumeDown',
label: 'Lower master volume',
shortcut: '-',
tooltip: 'Decrease overall output volume.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'openEffectSelect',
label: 'Open effect select',
shortcut: 'E',
tooltip: 'Open the effects menu.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'effectValueUp',
label: 'Raise active effect value',
shortcut: 'Shift+=',
tooltip: 'Increase the selected effect amount.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'effectValueDown',
label: 'Lower active effect value',
shortcut: 'Shift+-',
tooltip: 'Decrease the selected effect amount.',
section: 'Audio',
isAvailable: () => true,
},
{
id: 'speakCoordinates',
label: 'Speak coordinates',
shortcut: 'C',
tooltip: 'Announce your current coordinates.',
section: 'Navigation',
isAvailable: () => true,
},
{
id: 'openMicGainEdit',
label: 'Set microphone gain',
shortcut: 'V',
tooltip: 'Open microphone gain editing.',
section: 'Audio',
isAvailable: (context) => context.voiceSendAllowed,
},
{
id: 'calibrateMicrophone',
label: 'Calibrate microphone',
shortcut: 'Shift+V',
tooltip: 'Run microphone calibration.',
section: 'Audio',
isAvailable: (context) => context.voiceSendAllowed,
},
{
id: 'useItem',
label: 'Use item',
shortcut: 'Enter',
tooltip: 'Use the carried item or a usable item on your current square.',
section: 'Items',
isAvailable: (context) => context.hasCarriedItem || context.usableItemCount > 0,
},
{
id: 'secondaryUseItem',
label: 'Secondary item action',
shortcut: 'Shift+Enter',
tooltip: 'Run the secondary action for the carried item or a usable item on your current square.',
section: 'Items',
isAvailable: (context) => context.hasCarriedItem || context.usableItemCount > 0,
},
{
id: 'speakUsers',
label: 'Speak connected users',
shortcut: 'U',
tooltip: 'Announce connected users including yourself.',
section: 'Users',
isAvailable: () => true,
},
{
id: 'addItem',
label: 'Add item',
shortcut: 'A',
tooltip: 'Open the add-item menu.',
section: 'Items',
isAvailable: (context) => context.itemTypeCount > 0,
},
{
id: 'locateNearestItem',
label: 'Locate nearest item',
shortcut: 'I',
tooltip: 'Announce the nearest visible item.',
section: 'Items',
isAvailable: (context) => context.visibleItemCount > 0,
},
{
id: 'listItems',
label: 'List items',
shortcut: 'Shift+I',
tooltip: 'Open the nearby item list and teleport with Enter.',
section: 'Items',
isAvailable: (context) => context.visibleItemCount > 0,
},
{
id: 'pickupDropItem',
label: 'Pick up or drop item',
shortcut: 'D',
tooltip: 'Pick up an item on your square or drop your carried item.',
section: 'Items',
isAvailable: (context) => context.hasCarriedItem || context.squareItemCount > 0,
},
{
id: 'openItemManagement',
label: 'Item management',
shortcut: 'Z',
tooltip: 'Open item management actions for items on your square.',
section: 'Items',
isAvailable: (context) => context.manageableItemCount > 0,
},
{
id: 'editItem',
label: 'Edit item properties',
shortcut: 'O',
tooltip: 'Edit the carried item or an item on your current square.',
section: 'Items',
isAvailable: (context) => context.hasEditableItemTarget,
},
{
id: 'inspectItem',
label: 'Inspect item properties',
shortcut: 'Shift+O',
tooltip: 'Inspect all properties for the carried item or an item on your current square.',
section: 'Items',
isAvailable: (context) => context.hasInspectableItemTarget,
},
{
id: 'pingServer',
label: 'Ping server',
shortcut: 'P',
tooltip: 'Measure round-trip latency to the server.',
section: 'Network',
isAvailable: () => true,
},
{
id: 'locateNearestUser',
label: 'Locate nearest user',
shortcut: 'L',
tooltip: 'Announce the nearest connected user.',
section: 'Users',
isAvailable: (context) => context.userCount > 0,
},
{
id: 'listUsers',
label: 'List users',
shortcut: 'Shift+L',
tooltip: 'Open the user list; Enter teleports and left or right adjust listen volume.',
section: 'Users',
isAvailable: (context) => context.userCount > 0,
},
{
id: 'openHelp',
label: 'Open help',
shortcut: '?',
tooltip: 'Open the main help viewer.',
section: 'Help',
isAvailable: (context) => context.mainHelpAvailable,
},
{
id: 'openChat',
label: 'Open chat',
shortcut: '/',
tooltip: 'Start typing a chat message.',
section: 'Chat',
isAvailable: () => true,
},
{
id: 'openAdminMenu',
label: 'Open admin menu',
shortcut: 'Shift+Z',
tooltip: 'Open the admin actions menu when permitted.',
section: 'Admin',
isAvailable: (context) => context.hasAdminActions,
},
{
id: 'chatPrev',
label: 'Previous chat message',
shortcut: ',',
tooltip: 'Read the previous buffered chat message.',
section: 'Chat',
isAvailable: (context) => context.chatMessageCount > 0,
},
{
id: 'chatNext',
label: 'Next chat message',
shortcut: '.',
tooltip: 'Read the next buffered chat message.',
section: 'Chat',
isAvailable: (context) => context.chatMessageCount > 0,
},
{
id: 'chatFirst',
label: 'First chat message',
shortcut: 'Shift+,',
tooltip: 'Jump to the first buffered chat message.',
section: 'Chat',
isAvailable: (context) => context.chatMessageCount > 0,
},
{
id: 'chatLast',
label: 'Last chat message',
shortcut: 'Shift+.',
tooltip: 'Jump to the last buffered chat message.',
section: 'Chat',
isAvailable: (context) => context.chatMessageCount > 0,
},
{
id: 'escape',
label: 'Disconnect prompt',
shortcut: 'Escape',
tooltip: 'Press once for a disconnect prompt and again to disconnect.',
section: 'System',
isAvailable: () => true,
},
];
export function getAvailableMainModeCommands(
context: MainModeCommandAvailabilityContext,
): MainModeCommandDescriptor[] {
return MAIN_MODE_COMMANDS.filter((command) => command.isAvailable(context));
}

View File

@@ -1,15 +1,13 @@
import type { GameMode } from '../state/gameState'; import type { GameMode } from '../state/gameState';
import type { ModeInput } from './commandTypes';
type ModeHandler = (code: string, key: string, ctrlKey: boolean) => void; type ModeHandler = (input: ModeInput) => void;
type ModeHandlers = Partial<Record<GameMode, ModeHandler>>; type ModeHandlers = Partial<Record<GameMode, ModeHandler>>;
type DispatchOptions = { type DispatchOptions = {
mode: GameMode; mode: GameMode;
code: string; input: ModeInput;
key: string;
ctrlKey: boolean;
shiftKey: boolean;
handlers: ModeHandlers; handlers: ModeHandlers;
onNormalMode: (code: string, shiftKey: boolean) => void; onNormalMode: (code: string, shiftKey: boolean) => void;
}; };
@@ -20,8 +18,8 @@ type DispatchOptions = {
export function dispatchModeInput(options: DispatchOptions): void { export function dispatchModeInput(options: DispatchOptions): void {
const modeHandler = options.handlers[options.mode]; const modeHandler = options.handlers[options.mode];
if (modeHandler) { if (modeHandler) {
modeHandler(options.code, options.key, options.ctrlKey); modeHandler(options.input);
return; return;
} }
options.onNormalMode(options.code, options.shiftKey); options.onNormalMode(options.input.code, options.input.shiftKey);
} }

View File

@@ -1,5 +1,6 @@
import { type IncomingMessage } from '../../network/protocol'; import { type IncomingMessage } from '../../network/protocol';
import { type GameMode, type WorldItem } from '../../state/gameState'; import { type GameMode, type WorldItem } from '../../state/gameState';
import { type CommandDescriptor, type ModeInput } from '../../input/commandTypes';
import { createPianoBehavior } from './piano/behavior'; import { createPianoBehavior } from './piano/behavior';
import { type ItemBehavior, type ItemBehaviorDeps } from './runtimeShared'; import { type ItemBehavior, type ItemBehaviorDeps } from './runtimeShared';
@@ -57,9 +58,9 @@ export class ItemBehaviorRegistry {
} }
/** Gives item behaviors first chance to handle mode input. */ /** Gives item behaviors first chance to handle mode input. */
handleModeInput(mode: GameMode, code: string): boolean { handleModeInput(mode: GameMode, input: ModeInput): boolean {
for (const behavior of this.behaviors) { for (const behavior of this.behaviors) {
if (behavior.handleModeInput?.(mode, code)) { if (behavior.handleModeInput?.(mode, input)) {
return true; return true;
} }
} }
@@ -67,9 +68,31 @@ export class ItemBehaviorRegistry {
} }
/** Gives item behaviors first chance to handle mode key-up events. */ /** Gives item behaviors first chance to handle mode key-up events. */
handleModeKeyUp(mode: GameMode, code: string): boolean { handleModeKeyUp(mode: GameMode, input: Pick<ModeInput, 'code' | 'shiftKey'>): boolean {
for (const behavior of this.behaviors) { for (const behavior of this.behaviors) {
if (behavior.handleModeKeyUp?.(mode, code)) { if (behavior.handleModeKeyUp?.(mode, input)) {
return true;
}
}
return false;
}
/** Returns palette-visible commands for the active item-owned mode, if any. */
getModeCommands(mode: GameMode): CommandDescriptor[] {
const commands: CommandDescriptor[] = [];
for (const behavior of this.behaviors) {
const next = behavior.getModeCommands?.(mode);
if (next && next.length > 0) {
commands.push(...next);
}
}
return commands;
}
/** Runs an item-owned mode command by id, returning true when handled. */
runModeCommand(mode: GameMode, commandId: string): boolean {
for (const behavior of this.behaviors) {
if (behavior.runModeCommand?.(mode, commandId)) {
return true; return true;
} }
} }

View File

@@ -40,16 +40,24 @@ export function createPianoBehavior(deps: ItemBehaviorDeps): ItemBehavior {
onWorldUpdate: () => { onWorldUpdate: () => {
controller.syncAfterWorldUpdate(); controller.syncAfterWorldUpdate();
}, },
handleModeInput: (mode, code) => { handleModeInput: (mode, input) => {
if (mode !== 'pianoUse') return false; if (mode !== 'pianoUse') return false;
controller.handleModeInput(code); controller.handleModeInput(input);
return true; return true;
}, },
handleModeKeyUp: (mode, code) => { handleModeKeyUp: (mode, input) => {
if (mode !== 'pianoUse') return false; if (mode !== 'pianoUse') return false;
controller.handleModeKeyUp(code); controller.handleModeKeyUp(input);
return true; return true;
}, },
getModeCommands: (mode) => {
if (mode !== 'pianoUse') return [];
return controller.getModeCommands();
},
runModeCommand: (mode, commandId) => {
if (mode !== 'pianoUse') return false;
return controller.runModeCommand(commandId);
},
onRemotePianoNote: (message) => { onRemotePianoNote: (message) => {
if (message.on) { if (message.on) {
controller.playRemoteNote({ controller.playRemoteNote({

View File

@@ -4,6 +4,7 @@ import {
isPianoInstrumentId, isPianoInstrumentId,
type PianoInstrumentId, type PianoInstrumentId,
} from '../../../audio/pianoSynth'; } from '../../../audio/pianoSynth';
import { type CommandDescriptor, type ModeInput } from '../../../input/commandTypes';
import { type OutgoingMessage } from '../../../network/protocol'; import { type OutgoingMessage } from '../../../network/protocol';
import { type GameMode, type WorldItem } from '../../../state/gameState'; import { type GameMode, type WorldItem } from '../../../state/gameState';
import { getItemPropertyOptionValues } from '../../itemRegistry'; import { getItemPropertyOptionValues } from '../../itemRegistry';
@@ -33,6 +34,26 @@ const PIANO_SHARP_KEY_MIDI_BY_CODE: Record<string, number> = {
BracketRight: 78, BracketRight: 78,
}; };
type PianoModeCommandId =
| 'openHelp'
| 'stopUseMode'
| 'playDemo'
| 'toggleRecord'
| 'playbackRecording'
| 'stopPlaybackAndRecording'
| 'octaveDown'
| 'octaveUp'
| 'instrumentPreset1'
| 'instrumentPreset2'
| 'instrumentPreset3'
| 'instrumentPreset4'
| 'instrumentPreset5'
| 'instrumentPreset6'
| 'instrumentPreset7'
| 'instrumentPreset8'
| 'instrumentPreset9'
| 'instrumentPreset10';
type PianoDemoEvent = { type PianoDemoEvent = {
t: number; t: number;
keyId: string; keyId: string;
@@ -258,14 +279,102 @@ export class PianoController {
} }
} }
/** Handles realtime keyboard performance while piano item mode is active. */ /** Returns palette-visible commands while piano item mode is active. */
handleModeInput(code: string): void { getModeCommands(): CommandDescriptor<PianoModeCommandId>[] {
if (code === 'Escape') { if (!this.activePianoItemId) {
this.stopUseMode(true); return [];
return;
} }
if (code === 'Slash') { const commands: CommandDescriptor<PianoModeCommandId>[] = [
this.deps.openHelpViewer(this.helpViewerLines, 'pianoUse'); {
id: 'openHelp',
label: 'Open piano help',
shortcut: '?',
tooltip: 'Open piano help.',
section: 'Piano',
},
{
id: 'stopUseMode',
label: 'Exit piano mode',
shortcut: 'Escape',
tooltip: 'Stop using the current piano.',
section: 'Piano',
},
{
id: 'playDemo',
label: 'Play demo',
shortcut: 'Enter',
tooltip: 'Play the piano demo melody.',
section: 'Piano',
},
{
id: 'toggleRecord',
label: 'Toggle recording',
shortcut: 'Z',
tooltip: 'Start, pause, or resume piano recording.',
section: 'Piano',
},
{
id: 'playbackRecording',
label: 'Play recording',
shortcut: 'X',
tooltip: 'Play the saved piano recording.',
section: 'Piano',
},
{
id: 'stopPlaybackAndRecording',
label: 'Stop playback or recording',
shortcut: 'C',
tooltip: 'Stop demo playback, recording playback, and active recording.',
section: 'Piano',
},
{
id: 'octaveDown',
label: 'Lower octave',
shortcut: '-',
tooltip: 'Shift the piano octave down.',
section: 'Piano',
},
{
id: 'octaveUp',
label: 'Raise octave',
shortcut: '=',
tooltip: 'Shift the piano octave up.',
section: 'Piano',
},
];
const instruments = this.getShortcutInstruments();
for (let index = 0; index < instruments.length; index += 1) {
const slot = index + 1;
const instrument = instruments[index];
if (!instrument) continue;
commands.push({
id: `instrumentPreset${slot}` as PianoModeCommandId,
label: `Switch to ${this.formatInstrumentLabel(instrument)} preset`,
shortcut: slot === 10 ? '0' : String(slot),
tooltip: `Switch to instrument preset ${slot}: ${this.formatInstrumentLabel(instrument)}.`,
section: 'Piano',
});
}
return commands.filter((command) => this.isCommandAvailable(command.id));
}
/** Runs one piano mode command by id. */
runModeCommand(commandId: string): boolean {
if (!this.activePianoItemId) {
return false;
}
const resolvedId = commandId as PianoModeCommandId;
if (!this.isCommandAvailable(resolvedId)) {
return false;
}
return this.executeCommand(resolvedId);
}
/** Handles realtime keyboard performance while piano item mode is active. */
handleModeInput(input: ModeInput): void {
const command = this.resolveCommand(input);
if (command) {
this.executeCommand(command);
return; return;
} }
const itemId = this.activePianoItemId; const itemId = this.activePianoItemId;
@@ -278,109 +387,31 @@ export class PianoController {
this.stopUseMode(false); this.stopUseMode(false);
return; return;
} }
if (code === 'Enter') { const midi = this.getPianoMidiForCode(input);
if (this.activePianoRecordingState !== 'idle') {
this.deps.updateStatus('Stop or pause recording first.');
this.deps.audio.sfxUiCancel();
return;
}
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' });
this.startDemo(item, itemId);
this.deps.updateStatus('demo play');
this.deps.audio.sfxUiBlip();
return;
}
if (code === 'KeyZ') {
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'toggle_record' });
return;
}
if (code === 'KeyX') {
if (this.activePianoRecordingState !== 'idle') {
this.deps.updateStatus('Stop or pause recording first.');
this.deps.audio.sfxUiCancel();
return;
}
this.stopDemo(true);
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'playback' });
return;
}
if (code === 'KeyC') {
this.stopDemo(true);
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' });
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_record' });
this.activePianoRecordingState = 'idle';
return;
}
if (code === 'Equal' || code === 'Minus') {
const current = this.getPianoParams(item).octave;
const next = Math.max(-2, Math.min(2, current + (code === 'Equal' ? 1 : -1)));
item.params.octave = next;
this.deps.signalingSend({ type: 'item_update', itemId, params: { octave: next } });
this.deps.updateStatus(`octave ${next}.`);
return;
}
if (code.startsWith('Digit')) {
const digit = Number(code.slice(5));
const instrumentIndex = digit === 0 ? 9 : digit - 1;
const shortcutInstruments = this.getShortcutInstruments();
if (Number.isInteger(instrumentIndex) && instrumentIndex >= 0 && instrumentIndex < shortcutInstruments.length) {
const instrument = shortcutInstruments[instrumentIndex];
if (instrument) {
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
const voiceMode = this.defaultsVoiceModeForInstrument(instrument);
const octave = this.defaultsOctaveForInstrument(instrument);
item.params.instrument = instrument;
item.params.voiceMode = voiceMode;
item.params.octave = octave;
item.params.attack = defaults.attack;
item.params.decay = defaults.decay;
item.params.release = defaults.release;
item.params.brightness = defaults.brightness;
this.deps.signalingSend({
type: 'item_update',
itemId,
params: {
instrument,
},
});
void this.previewSettingChange(item, {
instrument,
octave,
attack: defaults.attack,
decay: defaults.decay,
release: defaults.release,
brightness: defaults.brightness,
});
this.deps.updateStatus(`Instrument ${instrument}.`);
}
return;
}
}
const midi = this.getPianoMidiForCode(code);
if (midi === null) return; if (midi === null) return;
if (this.activePianoKeys.has(code)) return; if (this.activePianoKeys.has(input.code)) return;
const config = this.getPianoParams(item); const config = this.getPianoParams(item);
const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12)); const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12));
this.activePianoKeys.add(code); this.activePianoKeys.add(input.code);
this.activePianoKeyMidi.set(code, playedMidi); this.activePianoKeyMidi.set(input.code, playedMidi);
this.activePianoHeldOrder.push(code); this.activePianoHeldOrder.push(input.code);
if (config.voiceMode === 'mono') { if (config.voiceMode === 'mono') {
const previousCode = this.activePianoMonophonicKey; const previousCode = this.activePianoMonophonicKey;
if (previousCode && previousCode !== code) { if (previousCode && previousCode !== input.code) {
const previousMidi = this.activePianoKeyMidi.get(previousCode); const previousMidi = this.activePianoKeyMidi.get(previousCode);
this.pianoSynth.noteOff(previousCode); this.pianoSynth.noteOff(previousCode);
if (Number.isFinite(previousMidi)) { if (Number.isFinite(previousMidi)) {
this.deps.signalingSend({ type: 'item_piano_note', itemId, keyId: previousCode, midi: previousMidi, on: false }); this.deps.signalingSend({ type: 'item_piano_note', itemId, keyId: previousCode, midi: previousMidi, on: false });
} }
} }
this.activePianoMonophonicKey = code; this.activePianoMonophonicKey = input.code;
} }
this.playLocalNote(item, itemId, code, playedMidi, config); this.playLocalNote(item, itemId, input.code, playedMidi, config);
} }
/** Handles key release while in piano mode, including mono fallback retrigger behavior. */ /** Handles key release while in piano mode, including mono fallback retrigger behavior. */
handleModeKeyUp(code: string): void { handleModeKeyUp(input: Pick<ModeInput, 'code' | 'shiftKey'>): void {
const { code } = input;
if (!this.activePianoKeys.delete(code)) return; if (!this.activePianoKeys.delete(code)) return;
const orderIndex = this.activePianoHeldOrder.lastIndexOf(code); const orderIndex = this.activePianoHeldOrder.lastIndexOf(code);
if (orderIndex >= 0) { if (orderIndex >= 0) {
@@ -677,7 +708,139 @@ export class PianoController {
return normalized; return normalized;
} }
private getPianoMidiForCode(code: string): number | null { private formatInstrumentLabel(instrument: PianoInstrumentId): string {
return instrument.replace(/_/g, ' ');
}
private resolveCommand(input: Pick<ModeInput, 'code' | 'shiftKey'>): PianoModeCommandId | null {
if (input.code === 'Escape' && !input.shiftKey) return 'stopUseMode';
if (input.code === 'Slash' && input.shiftKey) return 'openHelp';
if (input.code === 'Enter' && !input.shiftKey) return 'playDemo';
if (input.code === 'KeyZ' && !input.shiftKey) return 'toggleRecord';
if (input.code === 'KeyX' && !input.shiftKey) return 'playbackRecording';
if (input.code === 'KeyC' && !input.shiftKey) return 'stopPlaybackAndRecording';
if (input.code === 'Minus' && !input.shiftKey) return 'octaveDown';
if (input.code === 'Equal' && !input.shiftKey) return 'octaveUp';
if (input.code.startsWith('Digit') && !input.shiftKey) {
const digit = Number(input.code.slice(5));
const slot = digit === 0 ? 10 : digit;
if (Number.isInteger(slot) && slot >= 1 && slot <= 10) {
return `instrumentPreset${slot}` as PianoModeCommandId;
}
}
return null;
}
private isCommandAvailable(commandId: PianoModeCommandId): boolean {
if (!this.activePianoItemId) {
return false;
}
if (commandId === 'playDemo' || commandId === 'playbackRecording') {
return this.activePianoRecordingState === 'idle';
}
if (commandId.startsWith('instrumentPreset')) {
const slot = Number(commandId.slice('instrumentPreset'.length));
return Number.isInteger(slot) && slot >= 1 && slot <= this.getShortcutInstruments().length;
}
return true;
}
private executeCommand(commandId: PianoModeCommandId): boolean {
const itemId = this.activePianoItemId;
if (!itemId) {
this.deps.state.mode = 'normal';
return false;
}
if (commandId === 'openHelp') {
this.deps.openHelpViewer(this.helpViewerLines, 'pianoUse');
return true;
}
if (commandId === 'stopUseMode') {
this.stopUseMode(true);
return true;
}
const item = this.deps.state.items.get(itemId);
if (!item || item.type !== 'piano') {
this.stopUseMode(false);
return false;
}
if (commandId === 'playDemo') {
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' });
this.startDemo(item, itemId);
this.deps.updateStatus('demo play');
this.deps.audio.sfxUiBlip();
return true;
}
if (commandId === 'toggleRecord') {
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'toggle_record' });
return true;
}
if (commandId === 'playbackRecording') {
this.stopDemo(true);
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'playback' });
return true;
}
if (commandId === 'stopPlaybackAndRecording') {
this.stopDemo(true);
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' });
this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_record' });
this.activePianoRecordingState = 'idle';
this.deps.updateStatus('Stopped piano playback and recording.');
this.deps.audio.sfxUiCancel();
return true;
}
if (commandId === 'octaveDown' || commandId === 'octaveUp') {
const current = this.getPianoParams(item).octave;
const next = Math.max(-2, Math.min(2, current + (commandId === 'octaveUp' ? 1 : -1)));
item.params.octave = next;
this.deps.signalingSend({ type: 'item_update', itemId, params: { octave: next } });
this.deps.updateStatus(`octave ${next}.`);
this.deps.audio.sfxUiBlip();
return true;
}
if (commandId.startsWith('instrumentPreset')) {
const slot = Number(commandId.slice('instrumentPreset'.length));
const instrument = this.getShortcutInstruments()[slot - 1];
if (!instrument) {
return false;
}
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
const voiceMode = this.defaultsVoiceModeForInstrument(instrument);
const octave = this.defaultsOctaveForInstrument(instrument);
item.params.instrument = instrument;
item.params.voiceMode = voiceMode;
item.params.octave = octave;
item.params.attack = defaults.attack;
item.params.decay = defaults.decay;
item.params.release = defaults.release;
item.params.brightness = defaults.brightness;
this.deps.signalingSend({
type: 'item_update',
itemId,
params: {
instrument,
},
});
void this.previewSettingChange(item, {
instrument,
octave,
attack: defaults.attack,
decay: defaults.decay,
release: defaults.release,
brightness: defaults.brightness,
});
this.deps.updateStatus(`Instrument ${instrument}.`);
this.deps.audio.sfxUiBlip();
return true;
}
return false;
}
private getPianoMidiForCode(input: Pick<ModeInput, 'code' | 'shiftKey'>): number | null {
if (input.shiftKey) {
return null;
}
const { code } = input;
if (code in PIANO_WHITE_KEY_MIDI_BY_CODE) { if (code in PIANO_WHITE_KEY_MIDI_BY_CODE) {
return PIANO_WHITE_KEY_MIDI_BY_CODE[code]!; return PIANO_WHITE_KEY_MIDI_BY_CODE[code]!;
} }

View File

@@ -1,5 +1,6 @@
import { type IncomingMessage, type OutgoingMessage } from '../../network/protocol'; import { type IncomingMessage, type OutgoingMessage } from '../../network/protocol';
import { type GameMode, type WorldItem } from '../../state/gameState'; import { type GameMode, type WorldItem } from '../../state/gameState';
import { type CommandDescriptor, type ModeInput } from '../../input/commandTypes';
/** Shared dependencies made available to all client item behavior modules. */ /** Shared dependencies made available to all client item behavior modules. */
export type ItemBehaviorDeps = { export type ItemBehaviorDeps = {
@@ -29,8 +30,10 @@ export type ItemBehavior = {
onActionResultStatus?: (message: Extract<IncomingMessage, { type: 'item_action_result' }>) => boolean; onActionResultStatus?: (message: Extract<IncomingMessage, { type: 'item_action_result' }>) => boolean;
onPropertyPreviewChange?: (item: WorldItem, key: string, value: unknown) => void; onPropertyPreviewChange?: (item: WorldItem, key: string, value: unknown) => void;
onWorldUpdate?: () => void; onWorldUpdate?: () => void;
handleModeInput?: (mode: GameMode, code: string) => boolean; handleModeInput?: (mode: GameMode, input: ModeInput) => boolean;
handleModeKeyUp?: (mode: GameMode, code: string) => boolean; handleModeKeyUp?: (mode: GameMode, input: Pick<ModeInput, 'code' | 'shiftKey'>) => boolean;
getModeCommands?: (mode: GameMode) => CommandDescriptor[];
runModeCommand?: (mode: GameMode, commandId: string) => boolean;
onRemotePianoNote?: (message: Extract<IncomingMessage, { type: 'item_piano_note' }>) => void; onRemotePianoNote?: (message: Extract<IncomingMessage, { type: 'item_piano_note' }>) => void;
onPianoStatus?: (message: Extract<IncomingMessage, { type: 'item_piano_status' }>) => void; onPianoStatus?: (message: Extract<IncomingMessage, { type: 'item_piano_status' }>) => void;
onStopAllRemoteNotesForSender?: (senderId: string) => void; onStopAllRemoteNotesForSender?: (senderId: string) => void;

View File

@@ -23,7 +23,9 @@ import {
moveCursorWordRight, moveCursorWordRight,
shouldReplaceCurrentText, shouldReplaceCurrentText,
} from './input/textInput'; } 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 { dispatchModeInput } from './input/modeDispatcher';
import { handleListControlKey } from './input/listController'; import { handleListControlKey } from './input/listController';
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu'; import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
@@ -314,6 +316,9 @@ let mainHelpViewerLines: string[] = [];
let helpViewerLines: string[] = []; let helpViewerLines: string[] = [];
let helpViewerIndex = 0; let helpViewerIndex = 0;
let helpViewerReturnMode: GameMode = 'normal'; 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 heartbeatTimerId: number | null = null;
let heartbeatNextPingId = -1; let heartbeatNextPingId = -1;
let heartbeatAwaitingPong = false; let heartbeatAwaitingPong = false;
@@ -2157,6 +2162,457 @@ function toggleMute(): void {
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.'); 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. */ /** Handles command-mode keybindings while in main gameplay mode. */
function handleNormalModeInput(code: string, shiftKey: boolean): void { function handleNormalModeInput(code: string, shiftKey: boolean): void {
if (code !== 'Escape' && pendingEscapeDisconnect) { if (code !== 'Escape' && pendingEscapeDisconnect) {
@@ -2164,369 +2620,7 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
} }
const command = resolveMainModeCommand(code, shiftKey); const command = resolveMainModeCommand(code, shiftKey);
if (!command) return; if (!command) return;
mainModeCommandHandlers[command]();
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;
}
} }
/** Handles linear help viewer navigation and exit keys. */ /** 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. */ /** Handles chat compose mode including submit/cancel and inline editing keys. */
function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void { function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void {
const editAction = getEditSessionAction(code); const editAction = getEditSessionAction(code);
@@ -3503,6 +3630,12 @@ function setupInputHandlers(): void {
const code = normalizeInputCode(event); const code = normalizeInputCode(event);
if (!code) return; if (!code) return;
const hasShortcutModifier = event.ctrlKey || event.metaKey; 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') { if (!dom.settingsModal.classList.contains('hidden') && code === 'Escape') {
closeSettings(); closeSettings();
@@ -3548,41 +3681,58 @@ function setupInputHandlers(): void {
if (isTypingKey(code) && state.keysPressed[code]) return; 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({ dispatchModeInput({
mode: state.mode, mode: state.mode,
code, input,
key: event.key,
ctrlKey: hasShortcutModifier,
shiftKey: event.shiftKey,
handlers: { handlers: {
nickname: handleNicknameModeInput, nickname: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
chat: handleChatModeInput, handleNicknameModeInput(currentCode, currentKey, currentCtrlKey),
micGainEdit: handleMicGainEditModeInput, chat: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
pianoUse: (currentCode) => { handleChatModeInput(currentCode, currentKey, currentCtrlKey),
itemBehaviorRegistry.handleModeInput(state.mode, currentCode); micGainEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
handleMicGainEditModeInput(currentCode, currentKey, currentCtrlKey),
pianoUse: (currentInput) => {
itemBehaviorRegistry.handleModeInput(state.mode, currentInput);
}, },
effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey), commandPalette: ({ code: currentCode, key: currentKey }) => handleCommandPaletteModeInput(currentCode, currentKey),
helpView: (currentCode) => handleHelpViewModeInput(currentCode), effectSelect: ({ code: currentCode, key: currentKey }) => handleEffectSelectModeInput(currentCode, currentKey),
listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey), helpView: ({ code: currentCode }) => handleHelpViewModeInput(currentCode),
listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey), listUsers: ({ code: currentCode, key: currentKey }) => handleListModeInput(currentCode, currentKey),
addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey), listItems: ({ code: currentCode, key: currentKey }) => handleListItemsModeInput(currentCode, currentKey),
selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey), addItem: ({ code: currentCode, key: currentKey }) => handleAddItemModeInput(currentCode, currentKey),
itemManageOptions: (currentCode, currentKey) => handleItemManageOptionsModeInput(currentCode, currentKey), selectItem: ({ code: currentCode, key: currentKey }) => handleSelectItemModeInput(currentCode, currentKey),
itemManageTransferUser: (currentCode, currentKey) => handleItemManageTransferUserModeInput(currentCode, currentKey), itemManageOptions: ({ code: currentCode, key: currentKey }) => handleItemManageOptionsModeInput(currentCode, currentKey),
confirmYesNo: (currentCode, currentKey) => handleConfirmYesNoModeInput(currentCode, currentKey), itemManageTransferUser: ({ code: currentCode, key: currentKey }) =>
adminMenu: (currentCode, currentKey) => handleAdminMenuModeInput(currentCode, currentKey), handleItemManageTransferUserModeInput(currentCode, currentKey),
adminRoleList: (currentCode, currentKey) => handleAdminRoleListModeInput(currentCode, currentKey), confirmYesNo: ({ code: currentCode, key: currentKey }) => handleConfirmYesNoModeInput(currentCode, currentKey),
adminRolePermissionList: (currentCode, currentKey) => handleAdminRolePermissionListModeInput(currentCode, currentKey), adminMenu: ({ code: currentCode, key: currentKey }) => handleAdminMenuModeInput(currentCode, currentKey),
adminRoleDeleteReplacement: (currentCode, currentKey) => handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey), adminRoleList: ({ code: currentCode, key: currentKey }) => handleAdminRoleListModeInput(currentCode, currentKey),
adminUserList: (currentCode, currentKey) => handleAdminUserListModeInput(currentCode, currentKey), adminRolePermissionList: ({ code: currentCode, key: currentKey }) =>
adminUserRoleSelect: (currentCode, currentKey) => handleAdminUserRoleSelectModeInput(currentCode, currentKey), handleAdminRolePermissionListModeInput(currentCode, currentKey),
adminUserDeleteConfirm: (currentCode, currentKey) => handleAdminUserDeleteConfirmModeInput(currentCode, currentKey), adminRoleDeleteReplacement: ({ code: currentCode, key: currentKey }) =>
adminRoleNameEdit: (currentCode, currentKey, currentCtrlKey) => 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), handleAdminRoleNameEditModeInput(currentCode, currentKey, currentCtrlKey),
itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey), itemProperties: ({ code: currentCode, key: currentKey }) =>
itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey),
itemPropertyEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey), itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
itemPropertyOptionSelect: (currentCode, currentKey) => itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey), itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
}, },
onNormalMode: handleNormalModeInput, onNormalMode: handleNormalModeInput,
@@ -3594,7 +3744,10 @@ function setupInputHandlers(): void {
document.addEventListener('keyup', (event) => { document.addEventListener('keyup', (event) => {
const code = normalizeInputCode(event); const code = normalizeInputCode(event);
if (state.mode === 'pianoUse' && code) { if (state.mode === 'pianoUse' && code) {
itemBehaviorRegistry.handleModeKeyUp(state.mode, code); itemBehaviorRegistry.handleModeKeyUp(state.mode, {
code,
shiftKey: event.shiftKey,
});
} }
if (code) { if (code) {
state.keysPressed[code] = false; state.keysPressed[code] = false;

View File

@@ -27,6 +27,7 @@ export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | '
export type GameMode = export type GameMode =
| 'normal' | 'normal'
| 'commandPalette'
| 'helpView' | 'helpView'
| 'nickname' | 'nickname'
| 'chat' | 'chat'

View File

@@ -6,6 +6,7 @@ This document is the authoritative keymap for the client.
### Movement ### Movement
- `Arrow Keys`: Move - `Arrow Keys`: Move
- `Shift+K`, `Applications`, or `Shift+F10`: Open the command palette in supported modes
- `?`: Open help viewer - `?`: Open help viewer
- `C`: Speak coordinates - `C`: Speak coordinates
- `Escape`: Press once for disconnect prompt, press again to disconnect - `Escape`: Press once for disconnect prompt, press again to disconnect
@@ -83,6 +84,17 @@ Applies to effect select, user/item list modes, item selection, item property li
- `Space`: Read tooltip/help for current option (where metadata is available) - `Space`: Read tooltip/help for current option (where metadata is available)
- First-letter navigation: jump to next matching entry - First-letter navigation: jump to next matching entry
## Command Palette
- Available in `normal` mode and `pianoUse` mode
- Opens with `Shift+K`, `Applications`, or `Shift+F10`
- Shows only commands available in the current mode/context
- `ArrowUp` / `ArrowDown`: Move selection
- `Enter`: Run selected command
- `Escape`: Close palette and return to prior mode
- `Space`: Read tooltip/help for selected command
- First-letter navigation: jump to next matching command
## Yes/No Confirmation Menu ## Yes/No Confirmation Menu
- `ArrowUp` / `ArrowDown`: Move between `No` and `Yes` - `ArrowUp` / `ArrowDown`: Move between `No` and `Yes`
@@ -110,6 +122,7 @@ Applies to effect select, user/item list modes, item selection, item property li
- `A S D F G H J K L ; '`: Play white keys (C major from C4 upward) - `A S D F G H J K L ; '`: Play white keys (C major from C4 upward)
- `W E T Y U O P ]`: Play sharps - `W E T Y U O P ]`: Play sharps
- Multiple keys can be held/played at once - Multiple keys can be held/played at once
- Shifted note keys are ignored; `Shift+K` opens the command palette instead
- `?`: Open piano-mode help viewer - `?`: Open piano-mode help viewer
- `-` / `=`: Shift octave down/up - `-` / `=`: Shift octave down/up
- `Z`: Start, pause, or resume recording on this piano (max 30s recorded time) - `Z`: Start, pause, or resume recording on this piano (max 30s recorded time)