Add context-aware command palette
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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'
|
| '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;
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user