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.
// 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";

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

View File

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

View File

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