Add context-aware command palette

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

View File

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

View File

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

View File

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

View File

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