Add per-item behavior registry for client item runtime
This commit is contained in:
104
client/src/items/types/behaviorRegistry.ts
Normal file
104
client/src/items/types/behaviorRegistry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
client/src/items/types/clock/behavior.ts
Normal file
7
client/src/items/types/clock/behavior.ts
Normal 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 {};
|
||||
}
|
||||
|
||||
7
client/src/items/types/dice/behavior.ts
Normal file
7
client/src/items/types/dice/behavior.ts
Normal 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 {};
|
||||
}
|
||||
|
||||
102
client/src/items/types/piano/behavior.ts
Normal file
102
client/src/items/types/piano/behavior.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export { pianoDefinition } from './definition';
|
||||
export { PianoController } from './runtime';
|
||||
|
||||
export { createPianoBehavior } from './behavior';
|
||||
|
||||
7
client/src/items/types/radioStation/behavior.ts
Normal file
7
client/src/items/types/radioStation/behavior.ts
Normal 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 {};
|
||||
}
|
||||
|
||||
36
client/src/items/types/runtimeShared.ts
Normal file
36
client/src/items/types/runtimeShared.ts
Normal 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;
|
||||
};
|
||||
7
client/src/items/types/wheel/behavior.ts
Normal file
7
client/src/items/types/wheel/behavior.ts
Normal 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 {};
|
||||
}
|
||||
|
||||
7
client/src/items/types/widget/behavior.ts
Normal file
7
client/src/items/types/widget/behavior.ts
Normal 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 {};
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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<IncomingMessage, { type: 'item_action_result' }>) => boolean;
|
||||
handleRemotePianoNote: (message: Extract<IncomingMessage, { type: 'item_piano_note' }>) => 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user