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

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