Add per-item behavior registry for client item runtime

This commit is contained in:
Jage9
2026-02-24 02:13:25 -05:00
parent cc33e24cd4
commit 949766c6f6
12 changed files with 303 additions and 88 deletions

View File

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

View File

@@ -0,0 +1,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<void> {
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<IncomingMessage, { type: 'item_action_result' }>): 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<IncomingMessage, { type: 'item_piano_note' }>): 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);
}
}
}

View File

@@ -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 {};
}

View File

@@ -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 {};
}

View File

@@ -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);
},
};
}

View File

@@ -1,3 +1,2 @@
export { pianoDefinition } from './definition'; export { pianoDefinition } from './definition';
export { PianoController } from './runtime'; export { createPianoBehavior } from './behavior';

View File

@@ -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 {};
}

View File

@@ -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<string, WorldItem>;
player: { id: string | null; x: number; y: number };
};
audio: {
ensureContext: () => Promise<void>;
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<void>;
onCleanup?: () => void;
onUseResultMessage?: (message: IncomingMessage) => void;
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;
onRemotePianoNote?: (message: Extract<IncomingMessage, { type: 'item_piano_note' }>) => void;
onStopAllRemoteNotesForSender?: (senderId: string) => void;
};

View File

@@ -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 {};
}

View File

@@ -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 {};
}

View File

@@ -57,7 +57,7 @@ import {
} from './items/itemRegistry'; } from './items/itemRegistry';
import { createItemPropertyEditor } from './items/itemPropertyEditor'; import { createItemPropertyEditor } from './items/itemPropertyEditor';
import { createItemPropertyPresentation } from './items/itemPropertyPresentation'; 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 { NICKNAME_STORAGE_KEY, SettingsStore } from './settings/settingsStore';
import { runConnectFlow, runDisconnectFlow, type ConnectFlowDeps } from './session/connectionFlow'; import { runConnectFlow, runDisconnectFlow, type ConnectFlowDeps } from './session/connectionFlow';
import { MediaSession } from './session/mediaSession'; import { MediaSession } from './session/mediaSession';
@@ -281,12 +281,13 @@ const mediaSession = new MediaSession({
micInputGainStep: MIC_INPUT_GAIN_STEP, micInputGainStep: MIC_INPUT_GAIN_STEP,
}); });
const pianoController = new PianoController({ const itemBehaviorRegistry = new ItemBehaviorRegistry({
state, state,
audio, audio,
signalingSend: (message) => signaling.send(message), signalingSend: (message) => signaling.send(message),
updateStatus, updateStatus,
openHelpViewer: (lines, returnMode) => openHelpViewer(lines, returnMode), openHelpViewer: (lines, returnMode) => openHelpViewer(lines, returnMode),
withBase,
}); });
audio.setOutputMode(outputMode); audio.setOutputMode(outputMode);
@@ -296,8 +297,7 @@ loadAudioLayerState();
loadMicInputGain(); loadMicInputGain();
loadMasterVolume(); loadMasterVolume();
void loadHelp(); void loadHelp();
void pianoController.loadHelpFromUrl(withBase('piano.json')); void itemBehaviorRegistry.initialize();
void pianoController.loadDemoFromUrl(withBase('piano_demo.json'));
void loadChangelog(); void loadChangelog();
/** Fetches a required DOM element and casts it to the requested element type. */ /** 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); lastSubscriptionRefreshTileY = Math.round(state.player.y);
stopTeleportLoopAudio(); stopTeleportLoopAudio();
activeTeleport = null; activeTeleport = null;
pianoController.cleanup(); itemBehaviorRegistry.cleanup();
} }
const onAppMessage = createOnMessageHandler({ const onAppMessage = createOnMessageHandler({
@@ -1344,9 +1344,9 @@ const onAppMessage = createOnMessageHandler({
gain, gain,
); );
}, },
playRemotePianoNote: (note) => pianoController.playRemoteNote(note), handleItemActionResultStatus: (message) => itemBehaviorRegistry.onActionResultStatus(message),
stopRemotePianoNote: (senderId, keyId) => pianoController.stopRemoteNote(senderId, keyId), handleRemotePianoNote: (message) => itemBehaviorRegistry.onRemotePianoNote(message),
stopAllRemotePianoNotesForSender: (senderId) => pianoController.stopAllRemoteNotesForSender(senderId), stopAllRemoteNotesForSender: (senderId) => itemBehaviorRegistry.stopAllRemoteNotesForSender(senderId),
TELEPORT_SOUND_URL, TELEPORT_SOUND_URL,
TELEPORT_START_SOUND_URL, TELEPORT_START_SOUND_URL,
getAudioLayers: () => audioLayers, getAudioLayers: () => audioLayers,
@@ -1408,21 +1408,8 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
startHeartbeat(); startHeartbeat();
} }
await onAppMessage(message); await onAppMessage(message);
pianoController.onUseResultMessage(message); itemBehaviorRegistry.onUseResultMessage(message);
if ( itemBehaviorRegistry.onWorldUpdate();
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();
applyConfiguredPeerListenGains(); applyConfiguredPeerListenGains();
if (restartAnnouncement) { if (restartAnnouncement) {
setConnectionStatus(restartAnnouncement); setConnectionStatus(restartAnnouncement);
@@ -2145,7 +2132,7 @@ const itemPropertyEditor = createItemPropertyEditor({
suppressItemPropertyEchoUntilMs = Math.max(suppressItemPropertyEchoUntilMs, Date.now() + Math.max(0, ms)); suppressItemPropertyEchoUntilMs = Math.max(suppressItemPropertyEchoUntilMs, Date.now() + Math.max(0, ms));
}, },
onPreviewPropertyChange: (item, key, value) => { onPreviewPropertyChange: (item, key, value) => {
pianoController.onPreviewPropertyChange(item, key, value); itemBehaviorRegistry.onPropertyPreviewChange(item, key, value);
}, },
updateStatus, updateStatus,
sfxUiBlip: () => audio.sfxUiBlip(), sfxUiBlip: () => audio.sfxUiBlip(),
@@ -2299,7 +2286,9 @@ function setupInputHandlers(): void {
nickname: handleNicknameModeInput, nickname: handleNicknameModeInput,
chat: handleChatModeInput, chat: handleChatModeInput,
micGainEdit: handleMicGainEditModeInput, micGainEdit: handleMicGainEditModeInput,
pianoUse: (currentCode) => pianoController.handleModeInput(currentCode), pianoUse: (currentCode) => {
itemBehaviorRegistry.handleModeInput(state.mode, currentCode);
},
effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey), effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey),
helpView: (currentCode) => handleHelpViewModeInput(currentCode), helpView: (currentCode) => handleHelpViewModeInput(currentCode),
listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey), listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey),
@@ -2321,7 +2310,7 @@ 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) {
pianoController.handleModeKeyUp(code); itemBehaviorRegistry.handleModeKeyUp(state.mode, code);
} }
if (code) { if (code) {
state.keysPressed[code] = false; state.keysPressed[code] = false;

View File

@@ -46,24 +46,9 @@ type MessageHandlerDeps = {
sanitizeName: (value: string) => string; sanitizeName: (value: string) => string;
randomFootstepUrl: () => string; randomFootstepUrl: () => string;
playRemoteSpatialStepOrTeleport: (url: string, peerX: number, peerY: number) => void; playRemoteSpatialStepOrTeleport: (url: string, peerX: number, peerY: number) => void;
playRemotePianoNote: (note: { handleItemActionResultStatus: (message: Extract<IncomingMessage, { type: 'item_action_result' }>) => boolean;
itemId: string; handleRemotePianoNote: (message: Extract<IncomingMessage, { type: 'item_piano_note' }>) => void;
senderId: string; stopAllRemoteNotesForSender: (senderId: string) => void;
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;
TELEPORT_SOUND_URL: string; TELEPORT_SOUND_URL: string;
TELEPORT_START_SOUND_URL: string; TELEPORT_START_SOUND_URL: string;
getAudioLayers: () => { world: boolean; item: boolean }; getAudioLayers: () => { world: boolean; item: boolean };
@@ -177,7 +162,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
if (peer) { if (peer) {
deps.updateStatus(`${peer.nickname} has left.`); deps.updateStatus(`${peer.nickname} has left.`);
} }
deps.stopAllRemotePianoNotesForSender(message.id); deps.stopAllRemoteNotesForSender(message.id);
deps.state.peers.delete(message.id); deps.state.peers.delete(message.id);
deps.peerManager.removePeer(message.id); deps.peerManager.removePeer(message.id);
break; break;
@@ -241,23 +226,12 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
} }
case 'item_action_result': { case 'item_action_result': {
const pianoStatusMessages = new Set([ const handledByItemBehavior = deps.handleItemActionResultStatus(message);
'record', if (handledByItemBehavior) {
'pause', break;
'resume', }
'play',
'stop',
'No recording saved on this piano.',
'Stop recording before playback.',
'This piano is already recording.',
]);
if (message.ok) { if (message.ok) {
if (message.action === 'use') { if (message.action === 'use') {
if (pianoStatusMessages.has(message.message)) {
deps.updateStatus(message.message);
deps.audioUiBlip();
break;
}
deps.pushChatMessage(message.message); deps.pushChatMessage(message.message);
const item = message.itemId ? deps.getItemById(message.itemId) : null; const item = message.itemId ? deps.getItemById(message.itemId) : null;
if (!item?.useSound && item && item.type !== 'piano') { if (!item?.useSound && item && item.type !== 'piano') {
@@ -268,11 +242,6 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.audioUiConfirm(); deps.audioUiConfirm();
} }
} else { } else {
if (message.action === 'use' && pianoStatusMessages.has(message.message)) {
deps.updateStatus(message.message);
deps.audioUiCancel();
break;
}
deps.pushChatMessage(message.message); deps.pushChatMessage(message.message);
deps.audioUiCancel(); deps.audioUiCancel();
} }
@@ -290,26 +259,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
case 'item_piano_note': { case 'item_piano_note': {
if (!deps.getAudioLayers().item) break; if (!deps.getAudioLayers().item) break;
if (message.on) { deps.handleRemotePianoNote(message);
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);
}
break; break;
} }
} }