diff --git a/client/public/version.js b/client/public/version.js index 7964015..dfe099a 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.24 R225"; +window.CHGRID_WEB_VERSION = "2026.02.24 R226"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/items/types/behaviorRegistry.ts b/client/src/items/types/behaviorRegistry.ts new file mode 100644 index 0000000..17a1f50 --- /dev/null +++ b/client/src/items/types/behaviorRegistry.ts @@ -0,0 +1,104 @@ +import { type IncomingMessage } from '../../network/protocol'; +import { type GameMode, type WorldItem } from '../../state/gameState'; +import { createClockBehavior } from './clock/behavior'; +import { createDiceBehavior } from './dice/behavior'; +import { createPianoBehavior } from './piano/behavior'; +import { createRadioStationBehavior } from './radioStation/behavior'; +import { type ItemBehavior, type ItemBehaviorDeps } from './runtimeShared'; +import { createWheelBehavior } from './wheel/behavior'; +import { createWidgetBehavior } from './widget/behavior'; + +/** Runtime registry that composes all per-item client behavior modules. */ +export class ItemBehaviorRegistry { + private readonly behaviors: ItemBehavior[]; + + constructor(deps: ItemBehaviorDeps) { + this.behaviors = [ + createClockBehavior(deps), + createDiceBehavior(deps), + createPianoBehavior(deps), + createRadioStationBehavior(deps), + createWheelBehavior(deps), + createWidgetBehavior(deps), + ]; + } + + /** Runs per-item initialization hooks after app bootstrap. */ + async initialize(): Promise { + for (const behavior of this.behaviors) { + await behavior.onInit?.(); + } + } + + /** Runs all per-item teardown hooks during disconnect/reset flows. */ + cleanup(): void { + for (const behavior of this.behaviors) { + behavior.onCleanup?.(); + } + } + + /** Forwards incoming messages to behavior-specific use-result hooks. */ + onUseResultMessage(message: IncomingMessage): void { + for (const behavior of this.behaviors) { + behavior.onUseResultMessage?.(message); + } + } + + /** Lets item behaviors consume custom action-result status handling. */ + onActionResultStatus(message: Extract): boolean { + for (const behavior of this.behaviors) { + if (behavior.onActionResultStatus?.(message)) { + return true; + } + } + return false; + } + + /** Runs per-item world-update hooks after state changes. */ + onWorldUpdate(): void { + for (const behavior of this.behaviors) { + behavior.onWorldUpdate?.(); + } + } + + /** Routes property preview changes into per-item behavior hooks. */ + onPropertyPreviewChange(item: WorldItem, key: string, value: unknown): void { + for (const behavior of this.behaviors) { + behavior.onPropertyPreviewChange?.(item, key, value); + } + } + + /** Gives item behaviors first chance to handle mode input. */ + handleModeInput(mode: GameMode, code: string): boolean { + for (const behavior of this.behaviors) { + if (behavior.handleModeInput?.(mode, code)) { + return true; + } + } + return false; + } + + /** Gives item behaviors first chance to handle mode key-up events. */ + handleModeKeyUp(mode: GameMode, code: string): boolean { + for (const behavior of this.behaviors) { + if (behavior.handleModeKeyUp?.(mode, code)) { + return true; + } + } + return false; + } + + /** Routes incoming item-piano-note packets to the item behavior owning that protocol. */ + onRemotePianoNote(message: Extract): void { + for (const behavior of this.behaviors) { + behavior.onRemotePianoNote?.(message); + } + } + + /** Stops all remote notes for one sender across behavior modules that own remote note runtimes. */ + stopAllRemoteNotesForSender(senderId: string): void { + for (const behavior of this.behaviors) { + behavior.onStopAllRemoteNotesForSender?.(senderId); + } + } +} diff --git a/client/src/items/types/clock/behavior.ts b/client/src/items/types/clock/behavior.ts new file mode 100644 index 0000000..b2484e5 --- /dev/null +++ b/client/src/items/types/clock/behavior.ts @@ -0,0 +1,7 @@ +import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; + +/** Creates runtime behavior hooks for clock items. */ +export function createClockBehavior(_deps: ItemBehaviorDeps): ItemBehavior { + return {}; +} + diff --git a/client/src/items/types/dice/behavior.ts b/client/src/items/types/dice/behavior.ts new file mode 100644 index 0000000..60ec35b --- /dev/null +++ b/client/src/items/types/dice/behavior.ts @@ -0,0 +1,7 @@ +import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; + +/** Creates runtime behavior hooks for dice items. */ +export function createDiceBehavior(_deps: ItemBehaviorDeps): ItemBehavior { + return {}; +} + diff --git a/client/src/items/types/piano/behavior.ts b/client/src/items/types/piano/behavior.ts new file mode 100644 index 0000000..0015b9f --- /dev/null +++ b/client/src/items/types/piano/behavior.ts @@ -0,0 +1,102 @@ +import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; +import { PianoController } from './runtime'; + +/** Creates runtime behavior hooks for piano items. */ +export function createPianoBehavior(deps: ItemBehaviorDeps): ItemBehavior { + const controller = new PianoController({ + state: deps.state, + audio: deps.audio, + signalingSend: deps.signalingSend, + updateStatus: deps.updateStatus, + openHelpViewer: deps.openHelpViewer, + }); + + const statusMessages = new Set([ + 'record', + 'pause', + 'resume', + 'play', + 'stop', + 'No recording saved on this piano.', + 'Stop recording before playback.', + 'This piano is already recording.', + ]); + + return { + onInit: async () => { + await controller.loadHelpFromUrl(deps.withBase('piano.json')); + await controller.loadDemoFromUrl(deps.withBase('piano_demo.json')); + }, + onCleanup: () => { + controller.cleanup(); + }, + onUseResultMessage: (message) => { + controller.onUseResultMessage(message); + if ( + message.type === 'item_action_result' && + message.ok && + message.action === 'use' && + typeof message.itemId === 'string' && + typeof message.message === 'string' && + message.message.toLowerCase().includes('begin playing') + ) { + const item = deps.state.items.get(message.itemId); + if (item?.type === 'piano') { + void controller.startUseMode(item.id); + } + } + }, + onActionResultStatus: (message) => { + if (message.action !== 'use') return false; + if (!statusMessages.has(message.message)) return false; + deps.updateStatus(message.message); + if (message.ok) { + deps.audio.sfxUiBlip(); + } else { + deps.audio.sfxUiCancel(); + } + return true; + }, + onPropertyPreviewChange: (item, key, value) => { + controller.onPreviewPropertyChange(item, key, value); + }, + onWorldUpdate: () => { + controller.syncAfterWorldUpdate(); + }, + handleModeInput: (mode, code) => { + if (mode !== 'pianoUse') return false; + controller.handleModeInput(code); + return true; + }, + handleModeKeyUp: (mode, code) => { + if (mode !== 'pianoUse') return false; + controller.handleModeKeyUp(code); + return true; + }, + onRemotePianoNote: (message) => { + if (message.on) { + controller.playRemoteNote({ + itemId: message.itemId, + senderId: message.senderId, + keyId: message.keyId, + midi: message.midi, + instrument: message.instrument, + voiceMode: message.voiceMode, + octave: message.octave, + attack: message.attack, + decay: message.decay, + release: message.release, + brightness: message.brightness, + x: message.x, + y: message.y, + emitRange: message.emitRange, + }); + } else { + controller.stopRemoteNote(message.senderId, message.keyId); + } + }, + onStopAllRemoteNotesForSender: (senderId) => { + controller.stopAllRemoteNotesForSender(senderId); + }, + }; +} diff --git a/client/src/items/types/piano/index.ts b/client/src/items/types/piano/index.ts index 1532358..10c9275 100644 --- a/client/src/items/types/piano/index.ts +++ b/client/src/items/types/piano/index.ts @@ -1,3 +1,2 @@ export { pianoDefinition } from './definition'; -export { PianoController } from './runtime'; - +export { createPianoBehavior } from './behavior'; diff --git a/client/src/items/types/radioStation/behavior.ts b/client/src/items/types/radioStation/behavior.ts new file mode 100644 index 0000000..1b30836 --- /dev/null +++ b/client/src/items/types/radioStation/behavior.ts @@ -0,0 +1,7 @@ +import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; + +/** Creates runtime behavior hooks for radio_station items. */ +export function createRadioStationBehavior(_deps: ItemBehaviorDeps): ItemBehavior { + return {}; +} + diff --git a/client/src/items/types/runtimeShared.ts b/client/src/items/types/runtimeShared.ts new file mode 100644 index 0000000..cf9660e --- /dev/null +++ b/client/src/items/types/runtimeShared.ts @@ -0,0 +1,36 @@ +import { type IncomingMessage, type OutgoingMessage } from '../../network/protocol'; +import { type GameMode, type WorldItem } from '../../state/gameState'; + +/** Shared dependencies made available to all client item behavior modules. */ +export type ItemBehaviorDeps = { + state: { + mode: GameMode; + items: Map; + player: { id: string | null; x: number; y: number }; + }; + audio: { + ensureContext: () => Promise; + context: AudioContext | null; + getOutputDestinationNode: () => AudioNode | null; + sfxUiBlip: () => void; + sfxUiCancel: () => void; + }; + signalingSend: (message: OutgoingMessage) => void; + updateStatus: (message: string) => void; + openHelpViewer: (lines: string[], returnMode: GameMode) => void; + withBase: (path: string) => string; +}; + +/** Optional per-item behavior hooks used by the client runtime. */ +export type ItemBehavior = { + onInit?: () => void | Promise; + onCleanup?: () => void; + onUseResultMessage?: (message: IncomingMessage) => void; + onActionResultStatus?: (message: Extract) => boolean; + onPropertyPreviewChange?: (item: WorldItem, key: string, value: unknown) => void; + onWorldUpdate?: () => void; + handleModeInput?: (mode: GameMode, code: string) => boolean; + handleModeKeyUp?: (mode: GameMode, code: string) => boolean; + onRemotePianoNote?: (message: Extract) => void; + onStopAllRemoteNotesForSender?: (senderId: string) => void; +}; diff --git a/client/src/items/types/wheel/behavior.ts b/client/src/items/types/wheel/behavior.ts new file mode 100644 index 0000000..0f57435 --- /dev/null +++ b/client/src/items/types/wheel/behavior.ts @@ -0,0 +1,7 @@ +import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; + +/** Creates runtime behavior hooks for wheel items. */ +export function createWheelBehavior(_deps: ItemBehaviorDeps): ItemBehavior { + return {}; +} + diff --git a/client/src/items/types/widget/behavior.ts b/client/src/items/types/widget/behavior.ts new file mode 100644 index 0000000..98e7ec0 --- /dev/null +++ b/client/src/items/types/widget/behavior.ts @@ -0,0 +1,7 @@ +import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; + +/** Creates runtime behavior hooks for widget items. */ +export function createWidgetBehavior(_deps: ItemBehaviorDeps): ItemBehavior { + return {}; +} + diff --git a/client/src/main.ts b/client/src/main.ts index 215c794..314c6fd 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -57,7 +57,7 @@ import { } from './items/itemRegistry'; import { createItemPropertyEditor } from './items/itemPropertyEditor'; import { createItemPropertyPresentation } from './items/itemPropertyPresentation'; -import { PianoController } from './items/types/piano'; +import { ItemBehaviorRegistry } from './items/types/behaviorRegistry'; import { NICKNAME_STORAGE_KEY, SettingsStore } from './settings/settingsStore'; import { runConnectFlow, runDisconnectFlow, type ConnectFlowDeps } from './session/connectionFlow'; import { MediaSession } from './session/mediaSession'; @@ -281,12 +281,13 @@ const mediaSession = new MediaSession({ micInputGainStep: MIC_INPUT_GAIN_STEP, }); -const pianoController = new PianoController({ +const itemBehaviorRegistry = new ItemBehaviorRegistry({ state, audio, signalingSend: (message) => signaling.send(message), updateStatus, openHelpViewer: (lines, returnMode) => openHelpViewer(lines, returnMode), + withBase, }); audio.setOutputMode(outputMode); @@ -296,8 +297,7 @@ loadAudioLayerState(); loadMicInputGain(); loadMasterVolume(); void loadHelp(); -void pianoController.loadHelpFromUrl(withBase('piano.json')); -void pianoController.loadDemoFromUrl(withBase('piano_demo.json')); +void itemBehaviorRegistry.initialize(); void loadChangelog(); /** Fetches a required DOM element and casts it to the requested element type. */ @@ -1308,7 +1308,7 @@ function disconnect(): void { lastSubscriptionRefreshTileY = Math.round(state.player.y); stopTeleportLoopAudio(); activeTeleport = null; - pianoController.cleanup(); + itemBehaviorRegistry.cleanup(); } const onAppMessage = createOnMessageHandler({ @@ -1344,9 +1344,9 @@ const onAppMessage = createOnMessageHandler({ gain, ); }, - playRemotePianoNote: (note) => pianoController.playRemoteNote(note), - stopRemotePianoNote: (senderId, keyId) => pianoController.stopRemoteNote(senderId, keyId), - stopAllRemotePianoNotesForSender: (senderId) => pianoController.stopAllRemoteNotesForSender(senderId), + handleItemActionResultStatus: (message) => itemBehaviorRegistry.onActionResultStatus(message), + handleRemotePianoNote: (message) => itemBehaviorRegistry.onRemotePianoNote(message), + stopAllRemoteNotesForSender: (senderId) => itemBehaviorRegistry.stopAllRemoteNotesForSender(senderId), TELEPORT_SOUND_URL, TELEPORT_START_SOUND_URL, getAudioLayers: () => audioLayers, @@ -1408,21 +1408,8 @@ async function onSignalingMessage(message: IncomingMessage): Promise { startHeartbeat(); } await onAppMessage(message); - pianoController.onUseResultMessage(message); - if ( - message.type === 'item_action_result' && - message.ok && - message.action === 'use' && - typeof message.itemId === 'string' && - typeof message.message === 'string' && - message.message.toLowerCase().includes('begin playing') - ) { - const item = state.items.get(message.itemId); - if (item?.type === 'piano') { - await pianoController.startUseMode(item.id); - } - } - pianoController.syncAfterWorldUpdate(); + itemBehaviorRegistry.onUseResultMessage(message); + itemBehaviorRegistry.onWorldUpdate(); applyConfiguredPeerListenGains(); if (restartAnnouncement) { setConnectionStatus(restartAnnouncement); @@ -2145,7 +2132,7 @@ const itemPropertyEditor = createItemPropertyEditor({ suppressItemPropertyEchoUntilMs = Math.max(suppressItemPropertyEchoUntilMs, Date.now() + Math.max(0, ms)); }, onPreviewPropertyChange: (item, key, value) => { - pianoController.onPreviewPropertyChange(item, key, value); + itemBehaviorRegistry.onPropertyPreviewChange(item, key, value); }, updateStatus, sfxUiBlip: () => audio.sfxUiBlip(), @@ -2299,7 +2286,9 @@ function setupInputHandlers(): void { nickname: handleNicknameModeInput, chat: handleChatModeInput, micGainEdit: handleMicGainEditModeInput, - pianoUse: (currentCode) => pianoController.handleModeInput(currentCode), + pianoUse: (currentCode) => { + itemBehaviorRegistry.handleModeInput(state.mode, currentCode); + }, effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey), helpView: (currentCode) => handleHelpViewModeInput(currentCode), listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey), @@ -2321,7 +2310,7 @@ function setupInputHandlers(): void { document.addEventListener('keyup', (event) => { const code = normalizeInputCode(event); if (state.mode === 'pianoUse' && code) { - pianoController.handleModeKeyUp(code); + itemBehaviorRegistry.handleModeKeyUp(state.mode, code); } if (code) { state.keysPressed[code] = false; diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index fd1485c..5c6ac47 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -46,24 +46,9 @@ type MessageHandlerDeps = { sanitizeName: (value: string) => string; randomFootstepUrl: () => string; playRemoteSpatialStepOrTeleport: (url: string, peerX: number, peerY: number) => void; - playRemotePianoNote: (note: { - itemId: string; - senderId: string; - keyId: string; - midi: number; - instrument: string; - voiceMode: 'mono' | 'poly'; - octave: number; - attack: number; - decay: number; - release: number; - brightness: number; - x: number; - y: number; - emitRange: number; - }) => void; - stopRemotePianoNote: (senderId: string, keyId: string) => void; - stopAllRemotePianoNotesForSender: (senderId: string) => void; + handleItemActionResultStatus: (message: Extract) => boolean; + handleRemotePianoNote: (message: Extract) => void; + stopAllRemoteNotesForSender: (senderId: string) => void; TELEPORT_SOUND_URL: string; TELEPORT_START_SOUND_URL: string; getAudioLayers: () => { world: boolean; item: boolean }; @@ -177,7 +162,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco if (peer) { deps.updateStatus(`${peer.nickname} has left.`); } - deps.stopAllRemotePianoNotesForSender(message.id); + deps.stopAllRemoteNotesForSender(message.id); deps.state.peers.delete(message.id); deps.peerManager.removePeer(message.id); break; @@ -241,23 +226,12 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco } case 'item_action_result': { - const pianoStatusMessages = new Set([ - 'record', - 'pause', - 'resume', - 'play', - 'stop', - 'No recording saved on this piano.', - 'Stop recording before playback.', - 'This piano is already recording.', - ]); + const handledByItemBehavior = deps.handleItemActionResultStatus(message); + if (handledByItemBehavior) { + break; + } if (message.ok) { if (message.action === 'use') { - if (pianoStatusMessages.has(message.message)) { - deps.updateStatus(message.message); - deps.audioUiBlip(); - break; - } deps.pushChatMessage(message.message); const item = message.itemId ? deps.getItemById(message.itemId) : null; if (!item?.useSound && item && item.type !== 'piano') { @@ -268,11 +242,6 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco deps.audioUiConfirm(); } } else { - if (message.action === 'use' && pianoStatusMessages.has(message.message)) { - deps.updateStatus(message.message); - deps.audioUiCancel(); - break; - } deps.pushChatMessage(message.message); deps.audioUiCancel(); } @@ -290,26 +259,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco case 'item_piano_note': { if (!deps.getAudioLayers().item) break; - if (message.on) { - deps.playRemotePianoNote({ - itemId: message.itemId, - senderId: message.senderId, - keyId: message.keyId, - midi: message.midi, - instrument: message.instrument, - voiceMode: message.voiceMode, - octave: message.octave, - attack: message.attack, - decay: message.decay, - release: message.release, - brightness: message.brightness, - x: message.x, - y: message.y, - emitRange: message.emitRange, - }); - } else { - deps.stopRemotePianoNote(message.senderId, message.keyId); - } + deps.handleRemotePianoNote(message); break; } }