2026-02-22 17:05:36 -05:00
|
|
|
import { type IncomingMessage } from './protocol';
|
|
|
|
|
import { type WorldItem } from '../state/gameState';
|
|
|
|
|
|
2026-02-22 17:12:28 -05:00
|
|
|
/**
|
|
|
|
|
* Dependency contract for creating a message handler without hard-coupling to `main.ts`.
|
|
|
|
|
*/
|
2026-02-22 17:05:36 -05:00
|
|
|
type MessageHandlerDeps = {
|
|
|
|
|
getWorldGridSize: () => number;
|
|
|
|
|
setWorldGridSize: (size: number) => void;
|
|
|
|
|
setConnecting: (value: boolean) => void;
|
|
|
|
|
rendererSetGridSize: (size: number) => void;
|
2026-02-24 02:49:17 -05:00
|
|
|
applyServerItemUiDefinitions: (defs: unknown) => boolean;
|
2026-02-22 17:05:36 -05:00
|
|
|
state: {
|
|
|
|
|
addItemTypeIndex: number;
|
|
|
|
|
player: { id: string | null; nickname: string; x: number; y: number };
|
|
|
|
|
running: boolean;
|
|
|
|
|
peers: Map<string, { id: string; nickname: string; x: number; y: number }>;
|
|
|
|
|
items: Map<string, WorldItem>;
|
|
|
|
|
mode: string;
|
|
|
|
|
selectedItemId: string | null;
|
|
|
|
|
itemPropertyKeys: string[];
|
|
|
|
|
itemPropertyIndex: number;
|
|
|
|
|
carriedItemId: string | null;
|
|
|
|
|
};
|
|
|
|
|
dom: {
|
|
|
|
|
nicknameContainer: HTMLElement;
|
|
|
|
|
connectButton: HTMLElement;
|
|
|
|
|
disconnectButton: HTMLElement;
|
|
|
|
|
focusGridButton: HTMLElement;
|
|
|
|
|
canvas: HTMLCanvasElement;
|
|
|
|
|
instructions: HTMLElement;
|
|
|
|
|
preconnectNickname: HTMLInputElement;
|
|
|
|
|
};
|
|
|
|
|
signalingSend: (message: unknown) => void;
|
|
|
|
|
peerManager: {
|
|
|
|
|
createOrGetPeer: (id: string, initiator: boolean, user: { id: string; nickname: string; x: number; y: number }) => Promise<unknown>;
|
|
|
|
|
handleSignal: (message: IncomingMessage) => Promise<{ id: string; nickname: string; x: number; y: number }>;
|
|
|
|
|
setPeerPosition: (id: string, x: number, y: number) => void;
|
|
|
|
|
setPeerNickname: (id: string, nickname: string) => void;
|
|
|
|
|
removePeer: (id: string) => void;
|
|
|
|
|
};
|
2026-02-22 19:31:44 -05:00
|
|
|
refreshAudioSubscriptions: (force?: boolean) => Promise<void>;
|
|
|
|
|
cleanupItemAudio: (itemId: string) => void;
|
2026-02-22 17:05:36 -05:00
|
|
|
applyAudioLayerState: () => Promise<void>;
|
|
|
|
|
gameLoop: () => void;
|
|
|
|
|
sanitizeName: (value: string) => string;
|
|
|
|
|
randomFootstepUrl: () => string;
|
|
|
|
|
playRemoteSpatialStepOrTeleport: (url: string, peerX: number, peerY: number) => void;
|
2026-02-24 02:13:25 -05:00
|
|
|
handleItemActionResultStatus: (message: Extract<IncomingMessage, { type: 'item_action_result' }>) => boolean;
|
|
|
|
|
handleRemotePianoNote: (message: Extract<IncomingMessage, { type: 'item_piano_note' }>) => void;
|
|
|
|
|
stopAllRemoteNotesForSender: (senderId: string) => void;
|
2026-02-22 17:05:36 -05:00
|
|
|
TELEPORT_SOUND_URL: string;
|
2026-02-22 20:23:38 -05:00
|
|
|
TELEPORT_START_SOUND_URL: string;
|
2026-02-22 23:42:17 -05:00
|
|
|
getAudioLayers: () => { world: boolean; item: boolean };
|
2026-02-22 17:05:36 -05:00
|
|
|
pushChatMessage: (message: string) => void;
|
|
|
|
|
classifySystemMessageSound: (message: string) => 'logon' | 'logout' | 'notify' | null;
|
|
|
|
|
SYSTEM_SOUND_URLS: { logon: string; logout: string; notify: string };
|
|
|
|
|
playSample: (url: string, gain?: number) => void;
|
|
|
|
|
updateStatus: (message: string) => void;
|
|
|
|
|
audioUiBlip: () => void;
|
|
|
|
|
audioUiConfirm: () => void;
|
|
|
|
|
audioUiCancel: () => void;
|
|
|
|
|
NICKNAME_STORAGE_KEY: string;
|
|
|
|
|
getCarriedItemId: () => string | null;
|
2026-02-24 02:49:17 -05:00
|
|
|
recomputeActiveItemPropertyKeys: (itemId: string) => void;
|
2026-02-22 17:05:36 -05:00
|
|
|
itemPropertyLabel: (key: string) => string;
|
|
|
|
|
getItemPropertyValue: (item: WorldItem, key: string) => string;
|
|
|
|
|
getItemById: (itemId: string) => WorldItem | undefined;
|
2026-02-22 20:50:04 -05:00
|
|
|
shouldAnnounceItemPropertyEcho: () => boolean;
|
2026-02-22 17:05:36 -05:00
|
|
|
playLocateToneAt: (x: number, y: number) => void;
|
|
|
|
|
resolveIncomingSoundUrl: (url: string) => string;
|
|
|
|
|
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-22 17:12:28 -05:00
|
|
|
/**
|
|
|
|
|
* Builds the websocket message dispatcher used by the signaling client.
|
|
|
|
|
*/
|
2026-02-22 17:05:36 -05:00
|
|
|
export function createOnMessageHandler(deps: MessageHandlerDeps): (message: IncomingMessage) => Promise<void> {
|
|
|
|
|
return async function onMessage(message: IncomingMessage): Promise<void> {
|
|
|
|
|
switch (message.type) {
|
|
|
|
|
case 'welcome':
|
|
|
|
|
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {
|
|
|
|
|
deps.setWorldGridSize(message.worldConfig.gridSize);
|
|
|
|
|
}
|
|
|
|
|
deps.rendererSetGridSize(deps.getWorldGridSize());
|
2026-02-24 02:49:17 -05:00
|
|
|
const schemaReady = deps.applyServerItemUiDefinitions(message.uiDefinitions);
|
|
|
|
|
if (!schemaReady) {
|
|
|
|
|
deps.updateStatus('Item schema missing from server. Item menus unavailable.');
|
|
|
|
|
deps.audioUiCancel();
|
|
|
|
|
}
|
2026-02-22 17:05:36 -05:00
|
|
|
deps.state.addItemTypeIndex = 0;
|
|
|
|
|
deps.state.player.id = message.id;
|
|
|
|
|
deps.state.running = true;
|
|
|
|
|
deps.setConnecting(false);
|
|
|
|
|
deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.x));
|
|
|
|
|
deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.y));
|
|
|
|
|
deps.dom.nicknameContainer.classList.add('hidden');
|
|
|
|
|
deps.dom.connectButton.classList.add('hidden');
|
|
|
|
|
deps.dom.disconnectButton.classList.remove('hidden');
|
|
|
|
|
deps.dom.focusGridButton.classList.remove('hidden');
|
|
|
|
|
deps.dom.canvas.classList.remove('hidden');
|
|
|
|
|
deps.dom.instructions.classList.remove('hidden');
|
|
|
|
|
deps.dom.canvas.focus();
|
|
|
|
|
|
|
|
|
|
deps.signalingSend({ type: 'update_position', x: deps.state.player.x, y: deps.state.player.y });
|
|
|
|
|
deps.signalingSend({ type: 'update_nickname', nickname: deps.state.player.nickname });
|
|
|
|
|
|
|
|
|
|
for (const user of message.users) {
|
|
|
|
|
deps.state.peers.set(user.id, { ...user });
|
|
|
|
|
await deps.peerManager.createOrGetPeer(user.id, true, user);
|
|
|
|
|
}
|
|
|
|
|
deps.state.items.clear();
|
|
|
|
|
for (const item of message.items || []) {
|
|
|
|
|
deps.state.items.set(item.id, {
|
|
|
|
|
...item,
|
|
|
|
|
carrierId: item.carrierId ?? null,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-22 19:31:44 -05:00
|
|
|
await deps.refreshAudioSubscriptions(true);
|
2026-02-22 17:05:36 -05:00
|
|
|
await deps.applyAudioLayerState();
|
|
|
|
|
deps.gameLoop();
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'signal': {
|
|
|
|
|
const peer = await deps.peerManager.handleSignal(message);
|
|
|
|
|
if (!deps.state.peers.has(peer.id)) {
|
|
|
|
|
deps.state.peers.set(peer.id, {
|
|
|
|
|
id: peer.id,
|
|
|
|
|
nickname: deps.sanitizeName(peer.nickname) || 'user...',
|
|
|
|
|
x: peer.x,
|
|
|
|
|
y: peer.y,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'update_position': {
|
|
|
|
|
const peer = deps.state.peers.get(message.id);
|
|
|
|
|
const prevX = peer?.x ?? message.x;
|
|
|
|
|
const prevY = peer?.y ?? message.y;
|
|
|
|
|
if (peer) {
|
|
|
|
|
peer.x = message.x;
|
|
|
|
|
peer.y = message.y;
|
|
|
|
|
}
|
|
|
|
|
deps.peerManager.setPeerPosition(message.id, message.x, message.y);
|
|
|
|
|
if (peer) {
|
|
|
|
|
const movementDelta = Math.hypot(message.x - prevX, message.y - prevY);
|
2026-02-22 20:23:38 -05:00
|
|
|
const soundUrl = movementDelta > 1.5 ? deps.TELEPORT_START_SOUND_URL : deps.randomFootstepUrl();
|
2026-02-22 21:07:01 -05:00
|
|
|
if (deps.getAudioLayers().world) {
|
2026-02-22 17:05:36 -05:00
|
|
|
deps.playRemoteSpatialStepOrTeleport(soundUrl, peer.x, peer.y);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'update_nickname': {
|
|
|
|
|
const peer = deps.state.peers.get(message.id);
|
|
|
|
|
if (peer) {
|
|
|
|
|
peer.nickname = deps.sanitizeName(message.nickname) || 'user...';
|
|
|
|
|
}
|
|
|
|
|
deps.peerManager.setPeerNickname(message.id, deps.sanitizeName(message.nickname) || 'user...');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'user_left': {
|
|
|
|
|
const peer = deps.state.peers.get(message.id);
|
|
|
|
|
if (peer) {
|
|
|
|
|
deps.updateStatus(`${peer.nickname} has left.`);
|
|
|
|
|
}
|
2026-02-24 02:13:25 -05:00
|
|
|
deps.stopAllRemoteNotesForSender(message.id);
|
2026-02-22 17:05:36 -05:00
|
|
|
deps.state.peers.delete(message.id);
|
|
|
|
|
deps.peerManager.removePeer(message.id);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'chat_message': {
|
|
|
|
|
if (message.system) {
|
|
|
|
|
deps.pushChatMessage(message.message);
|
|
|
|
|
const sound = deps.classifySystemMessageSound(message.message);
|
|
|
|
|
if (sound) {
|
|
|
|
|
deps.playSample(deps.SYSTEM_SOUND_URLS[sound], 1);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const sender = message.senderNickname || 'Unknown';
|
|
|
|
|
deps.pushChatMessage(`${sender}: ${message.message}`);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'pong': {
|
|
|
|
|
const elapsed = Math.max(0, Date.now() - message.clientSentAt);
|
|
|
|
|
deps.updateStatus(`Ping ${elapsed} ms`);
|
|
|
|
|
deps.audioUiBlip();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'nickname_result': {
|
|
|
|
|
deps.state.player.nickname = deps.sanitizeName(message.effectiveNickname) || 'user...';
|
|
|
|
|
if (message.accepted) {
|
|
|
|
|
deps.dom.preconnectNickname.value = deps.state.player.nickname;
|
|
|
|
|
localStorage.setItem(deps.NICKNAME_STORAGE_KEY, deps.state.player.nickname);
|
|
|
|
|
} else {
|
|
|
|
|
deps.pushChatMessage(message.reason || 'Nickname unavailable.');
|
|
|
|
|
deps.audioUiCancel();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'item_upsert': {
|
|
|
|
|
deps.state.items.set(message.item.id, {
|
|
|
|
|
...message.item,
|
|
|
|
|
carrierId: message.item.carrierId ?? null,
|
|
|
|
|
});
|
|
|
|
|
deps.state.carriedItemId = deps.getCarriedItemId();
|
2026-02-24 02:49:17 -05:00
|
|
|
deps.recomputeActiveItemPropertyKeys(message.item.id);
|
2026-02-22 17:05:36 -05:00
|
|
|
if (deps.state.mode === 'itemProperties' && deps.state.selectedItemId === message.item.id) {
|
|
|
|
|
const key = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex];
|
2026-02-22 20:50:04 -05:00
|
|
|
if (key && deps.shouldAnnounceItemPropertyEcho()) {
|
2026-02-22 17:05:36 -05:00
|
|
|
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 19:31:44 -05:00
|
|
|
await deps.refreshAudioSubscriptions(true);
|
2026-02-22 17:05:36 -05:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'item_remove': {
|
|
|
|
|
deps.state.items.delete(message.itemId);
|
|
|
|
|
deps.state.carriedItemId = deps.getCarriedItemId();
|
2026-02-22 19:31:44 -05:00
|
|
|
deps.cleanupItemAudio(message.itemId);
|
|
|
|
|
await deps.refreshAudioSubscriptions(true);
|
2026-02-22 17:05:36 -05:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'item_action_result': {
|
2026-02-24 02:13:25 -05:00
|
|
|
const handledByItemBehavior = deps.handleItemActionResultStatus(message);
|
|
|
|
|
if (handledByItemBehavior) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-02-22 17:05:36 -05:00
|
|
|
if (message.ok) {
|
|
|
|
|
if (message.action === 'use') {
|
|
|
|
|
deps.pushChatMessage(message.message);
|
|
|
|
|
const item = message.itemId ? deps.getItemById(message.itemId) : null;
|
2026-02-22 23:42:17 -05:00
|
|
|
if (!item?.useSound && item && item.type !== 'piano') {
|
2026-02-22 17:05:36 -05:00
|
|
|
deps.playLocateToneAt(item.x, item.y);
|
|
|
|
|
}
|
|
|
|
|
} else if (message.action !== 'update') {
|
|
|
|
|
deps.pushChatMessage(message.message);
|
|
|
|
|
deps.audioUiConfirm();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
deps.pushChatMessage(message.message);
|
|
|
|
|
deps.audioUiCancel();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'item_use_sound': {
|
|
|
|
|
const soundUrl = deps.resolveIncomingSoundUrl(message.sound);
|
|
|
|
|
if (!soundUrl) break;
|
2026-02-22 21:07:01 -05:00
|
|
|
if (deps.getAudioLayers().world) {
|
2026-02-22 17:05:36 -05:00
|
|
|
deps.playIncomingItemUseSound(soundUrl, message.x, message.y);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-02-22 23:42:17 -05:00
|
|
|
|
|
|
|
|
case 'item_piano_note': {
|
|
|
|
|
if (!deps.getAudioLayers().item) break;
|
2026-02-24 02:13:25 -05:00
|
|
|
deps.handleRemotePianoNote(message);
|
2026-02-22 23:42:17 -05:00
|
|
|
break;
|
|
|
|
|
}
|
2026-02-22 17:05:36 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|