Add context-aware command palette
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
"keys": "Arrow Keys",
|
||||
"description": "Move on the grid"
|
||||
},
|
||||
{
|
||||
"keys": "Shift+K / Applications / Shift+F10",
|
||||
"description": "Open the command palette in supported modes"
|
||||
},
|
||||
{
|
||||
"keys": "C",
|
||||
"description": "Speak your coordinates"
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"keys": "1 through 0",
|
||||
"description": "Change instrument presets."
|
||||
},
|
||||
{
|
||||
"keys": "Shift+K / Applications / Shift+F10",
|
||||
"description": "Open the command palette."
|
||||
},
|
||||
{
|
||||
"keys": "Minus / Equals",
|
||||
"description": "Shift octave down or up."
|
||||
@@ -38,6 +42,10 @@
|
||||
{
|
||||
"keys": "Escape",
|
||||
"description": "Exit piano mode."
|
||||
},
|
||||
{
|
||||
"keys": "Shifted note keys",
|
||||
"description": "Ignored in piano mode so shifted shortcuts stay distinct."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Maintainer-controlled web client version.
|
||||
// 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.
|
||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||
|
||||
19
client/src/input/commandTypes.ts
Normal file
19
client/src/input/commandTypes.ts
Normal 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}`;
|
||||
}
|
||||
@@ -22,12 +22,15 @@ export type MainModeCommand =
|
||||
| 'secondaryUseItem'
|
||||
| 'speakUsers'
|
||||
| 'addItem'
|
||||
| 'locateOrListItems'
|
||||
| 'locateNearestItem'
|
||||
| 'listItems'
|
||||
| 'pickupDropItem'
|
||||
| 'openItemManagement'
|
||||
| 'editOrInspectItem'
|
||||
| 'editItem'
|
||||
| 'inspectItem'
|
||||
| 'pingServer'
|
||||
| 'locateOrListUsers'
|
||||
| 'locateNearestUser'
|
||||
| 'listUsers'
|
||||
| 'openHelp'
|
||||
| 'openChat'
|
||||
| 'openAdminMenu'
|
||||
@@ -44,9 +47,9 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod
|
||||
if (code === 'KeyN') return shiftKey ? null : 'editNickname';
|
||||
if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute';
|
||||
if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer';
|
||||
if (code === 'Digit2') return 'toggleItemLayer';
|
||||
if (code === 'Digit3') return 'toggleMediaLayer';
|
||||
if (code === 'Digit4') return 'toggleWorldLayer';
|
||||
if (code === 'Digit2') return shiftKey ? null : 'toggleItemLayer';
|
||||
if (code === 'Digit3') return shiftKey ? null : 'toggleMediaLayer';
|
||||
if (code === 'Digit4') return shiftKey ? null : 'toggleWorldLayer';
|
||||
if (code === 'KeyE') return shiftKey ? null : 'openEffectSelect';
|
||||
if (code === 'Equal') return shiftKey ? 'effectValueUp' : 'masterVolumeUp';
|
||||
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 === 'KeyU') return shiftKey ? null : 'speakUsers';
|
||||
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 === 'KeyO') return 'editOrInspectItem';
|
||||
if (code === 'KeyO') return shiftKey ? 'inspectItem' : 'editItem';
|
||||
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 === 'KeyZ') return shiftKey ? 'openAdminMenu' : 'openItemManagement';
|
||||
if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev';
|
||||
if (code === 'Period') return shiftKey ? 'chatLast' : 'chatNext';
|
||||
if (code === 'Escape') return 'escape';
|
||||
if (code === 'Escape') return shiftKey ? null : 'escape';
|
||||
return null;
|
||||
}
|
||||
|
||||
327
client/src/input/mainModeCommands.ts
Normal file
327
client/src/input/mainModeCommands.ts
Normal 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));
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
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 DispatchOptions = {
|
||||
mode: GameMode;
|
||||
code: string;
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
input: ModeInput;
|
||||
handlers: ModeHandlers;
|
||||
onNormalMode: (code: string, shiftKey: boolean) => void;
|
||||
};
|
||||
@@ -20,8 +18,8 @@ type DispatchOptions = {
|
||||
export function dispatchModeInput(options: DispatchOptions): void {
|
||||
const modeHandler = options.handlers[options.mode];
|
||||
if (modeHandler) {
|
||||
modeHandler(options.code, options.key, options.ctrlKey);
|
||||
modeHandler(options.input);
|
||||
return;
|
||||
}
|
||||
options.onNormalMode(options.code, options.shiftKey);
|
||||
options.onNormalMode(options.input.code, options.input.shiftKey);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type IncomingMessage } from '../../network/protocol';
|
||||
import { type GameMode, type WorldItem } from '../../state/gameState';
|
||||
import { type CommandDescriptor, type ModeInput } from '../../input/commandTypes';
|
||||
import { createPianoBehavior } from './piano/behavior';
|
||||
import { type ItemBehavior, type ItemBehaviorDeps } from './runtimeShared';
|
||||
|
||||
@@ -57,9 +58,9 @@ export class ItemBehaviorRegistry {
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (behavior.handleModeInput?.(mode, code)) {
|
||||
if (behavior.handleModeInput?.(mode, input)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -67,9 +68,31 @@ export class ItemBehaviorRegistry {
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +40,24 @@ export function createPianoBehavior(deps: ItemBehaviorDeps): ItemBehavior {
|
||||
onWorldUpdate: () => {
|
||||
controller.syncAfterWorldUpdate();
|
||||
},
|
||||
handleModeInput: (mode, code) => {
|
||||
handleModeInput: (mode, input) => {
|
||||
if (mode !== 'pianoUse') return false;
|
||||
controller.handleModeInput(code);
|
||||
controller.handleModeInput(input);
|
||||
return true;
|
||||
},
|
||||
handleModeKeyUp: (mode, code) => {
|
||||
handleModeKeyUp: (mode, input) => {
|
||||
if (mode !== 'pianoUse') return false;
|
||||
controller.handleModeKeyUp(code);
|
||||
controller.handleModeKeyUp(input);
|
||||
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) => {
|
||||
if (message.on) {
|
||||
controller.playRemoteNote({
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isPianoInstrumentId,
|
||||
type PianoInstrumentId,
|
||||
} from '../../../audio/pianoSynth';
|
||||
import { type CommandDescriptor, type ModeInput } from '../../../input/commandTypes';
|
||||
import { type OutgoingMessage } from '../../../network/protocol';
|
||||
import { type GameMode, type WorldItem } from '../../../state/gameState';
|
||||
import { getItemPropertyOptionValues } from '../../itemRegistry';
|
||||
@@ -33,6 +34,26 @@ const PIANO_SHARP_KEY_MIDI_BY_CODE: Record<string, number> = {
|
||||
BracketRight: 78,
|
||||
};
|
||||
|
||||
type PianoModeCommandId =
|
||||
| 'openHelp'
|
||||
| 'stopUseMode'
|
||||
| 'playDemo'
|
||||
| 'toggleRecord'
|
||||
| 'playbackRecording'
|
||||
| 'stopPlaybackAndRecording'
|
||||
| 'octaveDown'
|
||||
| 'octaveUp'
|
||||
| 'instrumentPreset1'
|
||||
| 'instrumentPreset2'
|
||||
| 'instrumentPreset3'
|
||||
| 'instrumentPreset4'
|
||||
| 'instrumentPreset5'
|
||||
| 'instrumentPreset6'
|
||||
| 'instrumentPreset7'
|
||||
| 'instrumentPreset8'
|
||||
| 'instrumentPreset9'
|
||||
| 'instrumentPreset10';
|
||||
|
||||
type PianoDemoEvent = {
|
||||
t: number;
|
||||
keyId: string;
|
||||
@@ -258,14 +279,102 @@ export class PianoController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles realtime keyboard performance while piano item mode is active. */
|
||||
handleModeInput(code: string): void {
|
||||
if (code === 'Escape') {
|
||||
this.stopUseMode(true);
|
||||
return;
|
||||
/** Returns palette-visible commands while piano item mode is active. */
|
||||
getModeCommands(): CommandDescriptor<PianoModeCommandId>[] {
|
||||
if (!this.activePianoItemId) {
|
||||
return [];
|
||||
}
|
||||
if (code === 'Slash') {
|
||||
this.deps.openHelpViewer(this.helpViewerLines, 'pianoUse');
|
||||
const commands: CommandDescriptor<PianoModeCommandId>[] = [
|
||||
{
|
||||
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;
|
||||
}
|
||||
const itemId = this.activePianoItemId;
|
||||
@@ -278,109 +387,31 @@ export class PianoController {
|
||||
this.stopUseMode(false);
|
||||
return;
|
||||
}
|
||||
if (code === 'Enter') {
|
||||
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);
|
||||
const midi = this.getPianoMidiForCode(input);
|
||||
if (midi === null) return;
|
||||
if (this.activePianoKeys.has(code)) return;
|
||||
if (this.activePianoKeys.has(input.code)) return;
|
||||
const config = this.getPianoParams(item);
|
||||
const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12));
|
||||
this.activePianoKeys.add(code);
|
||||
this.activePianoKeyMidi.set(code, playedMidi);
|
||||
this.activePianoHeldOrder.push(code);
|
||||
this.activePianoKeys.add(input.code);
|
||||
this.activePianoKeyMidi.set(input.code, playedMidi);
|
||||
this.activePianoHeldOrder.push(input.code);
|
||||
if (config.voiceMode === 'mono') {
|
||||
const previousCode = this.activePianoMonophonicKey;
|
||||
if (previousCode && previousCode !== code) {
|
||||
if (previousCode && previousCode !== input.code) {
|
||||
const previousMidi = this.activePianoKeyMidi.get(previousCode);
|
||||
this.pianoSynth.noteOff(previousCode);
|
||||
if (Number.isFinite(previousMidi)) {
|
||||
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. */
|
||||
handleModeKeyUp(code: string): void {
|
||||
handleModeKeyUp(input: Pick<ModeInput, 'code' | 'shiftKey'>): void {
|
||||
const { code } = input;
|
||||
if (!this.activePianoKeys.delete(code)) return;
|
||||
const orderIndex = this.activePianoHeldOrder.lastIndexOf(code);
|
||||
if (orderIndex >= 0) {
|
||||
@@ -677,7 +708,139 @@ export class PianoController {
|
||||
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) {
|
||||
return PIANO_WHITE_KEY_MIDI_BY_CODE[code]!;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type IncomingMessage, type OutgoingMessage } from '../../network/protocol';
|
||||
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. */
|
||||
export type ItemBehaviorDeps = {
|
||||
@@ -29,8 +30,10 @@ export type ItemBehavior = {
|
||||
onActionResultStatus?: (message: Extract<IncomingMessage, { type: 'item_action_result' }>) => boolean;
|
||||
onPropertyPreviewChange?: (item: WorldItem, key: string, value: unknown) => void;
|
||||
onWorldUpdate?: () => void;
|
||||
handleModeInput?: (mode: GameMode, code: string) => boolean;
|
||||
handleModeKeyUp?: (mode: GameMode, code: string) => boolean;
|
||||
handleModeInput?: (mode: GameMode, input: ModeInput) => 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;
|
||||
onPianoStatus?: (message: Extract<IncomingMessage, { type: 'item_piano_status' }>) => void;
|
||||
onStopAllRemoteNotesForSender?: (senderId: string) => void;
|
||||
|
||||
@@ -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,82 +2162,77 @@ function toggleMute(): void {
|
||||
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
|
||||
}
|
||||
|
||||
/** Handles command-mode keybindings while in main gameplay mode. */
|
||||
function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
if (code !== 'Escape' && pendingEscapeDisconnect) {
|
||||
pendingEscapeDisconnect = false;
|
||||
function getCurrentSquareItems(): WorldItem[] {
|
||||
return getItemsAtPosition(state.player.x, state.player.y);
|
||||
}
|
||||
const command = resolveMainModeCommand(code, shiftKey);
|
||||
if (!command) return;
|
||||
|
||||
switch (command) {
|
||||
case 'editNickname':
|
||||
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();
|
||||
return;
|
||||
case 'toggleMute':
|
||||
toggleMute();
|
||||
return;
|
||||
case 'toggleOutputMode':
|
||||
}
|
||||
|
||||
function toggleOutputModeCommand(): void {
|
||||
outputMode = audio.toggleOutputMode();
|
||||
mediaSession.saveOutputMode(outputMode);
|
||||
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
|
||||
audio.sfxUiBlip();
|
||||
return;
|
||||
case 'toggleLoopback': {
|
||||
}
|
||||
|
||||
function toggleLoopbackCommand(): void {
|
||||
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;
|
||||
|
||||
function adjustMasterVolumeCommand(step: number): void {
|
||||
const next = audio.adjustMasterVolume(step);
|
||||
persistMasterVolume(next);
|
||||
updateStatus(`Master volume ${next}`);
|
||||
audio.sfxEffectLevel(next === 50);
|
||||
return;
|
||||
}
|
||||
case 'openEffectSelect': {
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
case 'effectValueUp':
|
||||
case 'effectValueDown': {
|
||||
const step = command === 'effectValueUp' ? 5 : -5;
|
||||
|
||||
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}`);
|
||||
return;
|
||||
}
|
||||
case 'speakCoordinates':
|
||||
|
||||
function speakCoordinatesCommand(): void {
|
||||
updateStatus(`${formatCoordinate(state.player.x)}, ${formatCoordinate(state.player.y)}`);
|
||||
audio.sfxUiBlip();
|
||||
return;
|
||||
case 'openMicGainEdit':
|
||||
}
|
||||
|
||||
function openMicGainEditCommand(): void {
|
||||
if (!voiceSendAllowed) {
|
||||
updateStatus('Voice send is disabled for this account.');
|
||||
audio.sfxUiCancel();
|
||||
@@ -2245,34 +2245,37 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
micGainLoopbackRestoreState = audio.isLoopbackEnabled();
|
||||
audio.setLoopbackEnabled(true);
|
||||
announceMenuEntry('Microphone gain', state.nicknameInput);
|
||||
return;
|
||||
case 'calibrateMicrophone':
|
||||
}
|
||||
|
||||
function calibrateMicrophoneCommand(): void {
|
||||
if (!voiceSendAllowed) {
|
||||
updateStatus('Voice send is disabled for this account.');
|
||||
audio.sfxUiCancel();
|
||||
return;
|
||||
}
|
||||
void calibrateMicInputGain();
|
||||
return;
|
||||
case 'openAdminMenu': {
|
||||
}
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
case 'useItem': {
|
||||
|
||||
function useItemCommand(): void {
|
||||
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'));
|
||||
const usable = getUsableItemsOnCurrentSquare();
|
||||
if (usable.length === 0) {
|
||||
updateStatus('No usable items here.');
|
||||
audio.sfxUiCancel();
|
||||
@@ -2283,16 +2286,15 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
return;
|
||||
}
|
||||
beginItemSelection('use', usable);
|
||||
return;
|
||||
}
|
||||
case 'secondaryUseItem': {
|
||||
|
||||
function secondaryUseItemCommand(): void {
|
||||
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'));
|
||||
const usable = getUsableItemsOnCurrentSquare();
|
||||
if (usable.length === 0) {
|
||||
updateStatus('No usable items here.');
|
||||
audio.sfxUiCancel();
|
||||
@@ -2303,16 +2305,16 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
return;
|
||||
}
|
||||
beginItemSelection('secondaryUse', usable);
|
||||
return;
|
||||
}
|
||||
case 'speakUsers': {
|
||||
const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((p) => p.nickname)];
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
case 'addItem': {
|
||||
|
||||
function addItemCommand(): void {
|
||||
const itemTypeSequence = getItemTypeSequence();
|
||||
if (itemTypeSequence.length === 0) {
|
||||
updateStatus('No item types available.');
|
||||
@@ -2322,15 +2324,9 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
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;
|
||||
}
|
||||
|
||||
function listItemsCommand(): void {
|
||||
state.sortedItemIds = Array.from(state.items.entries())
|
||||
.filter(([, item]) => !item.carrierId)
|
||||
.sort(
|
||||
@@ -2347,19 +2343,19 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
state.itemListIndex = 0;
|
||||
state.mode = 'listItems';
|
||||
const first = state.items.get(state.sortedItemIds[0]);
|
||||
if (first) {
|
||||
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}`,
|
||||
);
|
||||
} else {
|
||||
audio.sfxUiCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
{
|
||||
|
||||
function locateNearestItemCommand(): void {
|
||||
const nearest = getNearestItem(state);
|
||||
if (!nearest.itemId) {
|
||||
updateStatus('No items to locate.');
|
||||
@@ -2369,18 +2365,16 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
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;
|
||||
updateStatus(`${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`);
|
||||
}
|
||||
case 'pickupDropItem': {
|
||||
|
||||
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 = getItemsAtPosition(state.player.x, state.player.y);
|
||||
const squareItems = getCurrentSquareItems();
|
||||
if (squareItems.length === 0) {
|
||||
updateStatus('No items to pick up.');
|
||||
audio.sfxUiCancel();
|
||||
@@ -2391,10 +2385,10 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
return;
|
||||
}
|
||||
beginItemSelection('pickup', squareItems);
|
||||
return;
|
||||
}
|
||||
case 'openItemManagement': {
|
||||
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
||||
|
||||
function openItemManagementCommand(): void {
|
||||
const squareItems = getCurrentSquareItems();
|
||||
if (squareItems.length === 0) {
|
||||
updateStatus('No items to manage on this square.');
|
||||
audio.sfxUiCancel();
|
||||
@@ -2411,28 +2405,11 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
return;
|
||||
}
|
||||
beginItemSelection('manage', manageable);
|
||||
return;
|
||||
}
|
||||
case 'editOrInspectItem': {
|
||||
const squareItems = getItemsAtPosition(state.player.x, state.player.y);
|
||||
|
||||
function editItemCommand(): void {
|
||||
const squareItems = getCurrentSquareItems();
|
||||
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.');
|
||||
@@ -2447,13 +2424,32 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
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;
|
||||
}
|
||||
case 'pingServer':
|
||||
signaling.send({ type: 'ping', clientSentAt: Date.now() });
|
||||
beginItemProperties(carried, true);
|
||||
return;
|
||||
case 'locateOrListUsers':
|
||||
if (shiftKey) {
|
||||
}
|
||||
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();
|
||||
@@ -2465,7 +2461,10 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
state.listIndex = 0;
|
||||
state.mode = 'listUsers';
|
||||
const first = state.peers.get(state.sortedPeerIds[0]);
|
||||
if (first) {
|
||||
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)}`;
|
||||
@@ -2473,12 +2472,9 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
`${userCount} ${userLabelText}`,
|
||||
`${first.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`,
|
||||
);
|
||||
} else {
|
||||
audio.sfxUiCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
{
|
||||
|
||||
function locateNearestUserCommand(): void {
|
||||
const nearest = getNearestPeer(state);
|
||||
if (!nearest.peerId) {
|
||||
updateStatus('No users to locate.');
|
||||
@@ -2488,35 +2484,23 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
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;
|
||||
updateStatus(`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`);
|
||||
}
|
||||
case 'openHelp':
|
||||
|
||||
function openHelpCommand(): void {
|
||||
openHelpViewer(mainHelpViewerLines);
|
||||
return;
|
||||
case 'openChat':
|
||||
}
|
||||
|
||||
function openChatCommand(): void {
|
||||
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':
|
||||
}
|
||||
|
||||
function escapeCommand(): void {
|
||||
if (pendingEscapeDisconnect) {
|
||||
pendingEscapeDisconnect = false;
|
||||
disconnect();
|
||||
@@ -2525,8 +2509,118 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
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) {
|
||||
pendingEscapeDisconnect = false;
|
||||
}
|
||||
const command = resolveMainModeCommand(code, shiftKey);
|
||||
if (!command) 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;
|
||||
|
||||
@@ -27,6 +27,7 @@ export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | '
|
||||
|
||||
export type GameMode =
|
||||
| 'normal'
|
||||
| 'commandPalette'
|
||||
| 'helpView'
|
||||
| 'nickname'
|
||||
| 'chat'
|
||||
|
||||
@@ -6,6 +6,7 @@ This document is the authoritative keymap for the client.
|
||||
|
||||
### Movement
|
||||
- `Arrow Keys`: Move
|
||||
- `Shift+K`, `Applications`, or `Shift+F10`: Open the command palette in supported modes
|
||||
- `?`: Open help viewer
|
||||
- `C`: Speak coordinates
|
||||
- `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)
|
||||
- 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
|
||||
|
||||
- `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)
|
||||
- `W E T Y U O P ]`: Play sharps
|
||||
- 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
|
||||
- `-` / `=`: Shift octave down/up
|
||||
- `Z`: Start, pause, or resume recording on this piano (max 30s recorded time)
|
||||
|
||||
Reference in New Issue
Block a user