Files
VDM/web/js/ui.js
T
2025-09-05 01:15:54 -04:00

469 lines
20 KiB
JavaScript

// web/js/ui.js
import { initAudio } from './audio.js';
/**
* @typedef {import('./state.js').AppState} AppState
* @typedef {import('./api.js').initApi} ApiModule
*/
/**
* @typedef {object} AppUI - The public interface of the UI module.
* @property {(api: ReturnType<ApiModule>) => void} setApi
* @property {(type: string, data: any, isBatch: boolean) => void} logMessage
* @property {(messages: any[]) => void} loadChatHistory
* @property {(room: any) => void} updateRoomState
* @property {(url: string) => void} playAudioFile
* @property {() => void} handleStreamStart
* @property {(content: string) => void} handleChatChunk
* @property {(chunk: ArrayBuffer) => void} handleAudioChunk
* @property {(finalMessage: any) => void} handleStreamEnd
* @property {(roomId: string) => void} showRoomView
* @property {() => void} showConnectionView
* @property {() => void} showLoginView
*/
// List of available avatar styles.
const AVATAR_STYLES = [
"adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile",
"bottts", "croodles", "fun-emoji", "icons", "identicon", "initials",
"lorelei", "micah", "miniavs", "open-peeps", "personas", "pixel-art", "rings"
];
// NEW: Define available slash commands for the previewer.
const COMMANDS = {
"/roll": "[dice] - Rolls dice (e.g., 2d6+3). Defaults to 1d20.",
"/ooc": "[message] - Sends an out-of-character message.",
"/remember": "[fact] - Saves a fact to the GM's long-term memory.",
"/save": "- Saves the current game session.",
"/next": "- Submits the current turn actions to the GM."
};
/**
* Initializes and returns the UI module.
* @param {AppState} state - The central state object.
* @returns {AppUI}
*/
export function initUI(state) {
// A cache for all DOM elements we will interact with.
const dom = {
// Auth Forms & Views
loginView: document.getElementById('login-view'),
registerView: document.getElementById('register-view'),
loginForm: document.getElementById('login-form'),
registerForm: document.getElementById('register-form'),
loginNameInput: document.getElementById('login-name'),
loginPasswordInput: document.getElementById('login-password'),
registerNameInput: document.getElementById('register-name'),
registerPasswordInput: document.getElementById('register-password'),
registerConfirmPasswordInput: document.getElementById('register-confirm-password'),
loginError: document.getElementById('login-error'),
registerError: document.getElementById('register-error'),
switchToRegisterBtn: document.getElementById('switch-to-register'),
switchToLoginBtn: document.getElementById('switch-to-login'),
// Avatar Selection
avatarSelectionGrid: document.getElementById('avatar-selection-grid'),
registerAvatarPreview: document.getElementById('register-avatar-preview'),
// Main App View
mainAppView: document.getElementById('main-app-view'),
sidebar: document.querySelector('.sidebar'),
chatArea: document.querySelector('.chat-area'),
// Player Info & Connection
playerIdentity: document.getElementById('player-identity'),
playerCardAvatar: document.getElementById('player-card-avatar'),
playerCardName: document.getElementById('player-card-name'),
logoutButton: document.getElementById('logout-button'),
connectionForm: document.getElementById('connection-form'),
roomIdInput: document.getElementById('room-id-input'),
joinButton: document.getElementById('join-button'),
// In-Room Info
roomInfo: document.getElementById('room-info'),
roomName: document.getElementById('room-name'),
playerList: document.getElementById('player-list'),
hostControlsContainer: document.getElementById('host-controls-container'),
startGameButton: document.getElementById('start-game-button'),
resumeGameButton: document.getElementById('resume-game-button'),
leaveButton: document.getElementById('leave-button'),
// Chat
chatLog: document.getElementById('chat-log'),
messageInput: document.getElementById('message-input'),
sendButton: document.getElementById('send-button'),
resolveButton: document.getElementById('resolve-button'),
gmThinkingIndicator: document.getElementById('gm-thinking-indicator'),
commandPreview: document.getElementById('command-preview'), // Command preview element
themeToggle: document.getElementById('theme-toggle'),
};
// Module dependencies, to be set later.
let api = null;
const audio = initAudio(state);
/** Main render function to switch between primary UI views */
function _render() {
const mainElement = document.querySelector('main');
dom.loginView.style.display = 'none';
dom.registerView.style.display = 'none';
dom.mainAppView.style.display = 'none';
if (state.uiView === 'login' || state.uiView === 'register') {
mainElement.className = 'auth-mode';
if (state.uiView === 'login') {
dom.loginView.style.display = 'flex';
} else {
dom.registerView.style.display = 'flex';
}
} else { // 'chat' or other authenticated views
mainElement.className = 'app-mode';
dom.mainAppView.style.display = 'flex';
if (state.playerInfo) {
dom.playerIdentity.style.display = 'flex';
dom.playerCardName.textContent = state.playerInfo.name;
dom.playerCardAvatar.src = `https://api.dicebear.com/9.x/${state.playerInfo.avatar_style}/svg?seed=${encodeURIComponent(state.playerInfo.name)}`;
} else {
dom.playerIdentity.style.display = 'none';
}
if (state.isConnected) {
dom.connectionForm.style.display = 'none';
dom.roomInfo.style.display = 'flex';
} else {
dom.connectionForm.style.display = 'flex';
dom.roomInfo.style.display = 'none';
}
}
}
/** NEW: Updates the command preview based on input */
function _updateCommandPreview() {
const text = dom.messageInput.value;
if (text.startsWith('/')) {
const [typedCmd] = text.split(' ');
let html = '<h4>Commands</h4><ul>';
for (const [cmd, desc] of Object.entries(COMMANDS)) {
if (cmd.startsWith(typedCmd)) {
html += `<li><strong>${cmd}</strong>: ${desc}</li>`;
}
}
html += '</ul>';
dom.commandPreview.innerHTML = html;
dom.commandPreview.style.display = 'block';
} else {
dom.commandPreview.style.display = 'none';
}
}
// --- Other private functions (_updateAvatarSelectionUI, _populateAvatarGrid, etc. are unchanged) ---
function _updateAvatarSelectionUI() {
const name = dom.registerNameInput.value.trim() || 'player';
dom.registerAvatarPreview.src = `https://api.dicebear.com/9.x/${state.selectedAvatarStyle}/svg?seed=${encodeURIComponent(name)}`;
dom.avatarSelectionGrid.querySelectorAll('.avatar-option').forEach(opt => {
opt.classList.toggle('selected', opt.dataset.style === state.selectedAvatarStyle);
});
}
function _populateAvatarGrid() {
const grid = dom.avatarSelectionGrid;
grid.innerHTML = '';
AVATAR_STYLES.forEach(style => {
const option = document.createElement('div');
option.className = 'avatar-option';
option.dataset.style = style;
const img = document.createElement('img');
img.src = `https://api.dicebear.com/9.x/${style}/svg`;
img.alt = style;
option.appendChild(img);
grid.appendChild(option);
});
_updateAvatarSelectionUI();
}
function _showError(element, message) {
element.textContent = message;
element.style.display = 'block';
}
function _hideError(element) {
element.style.display = 'none';
}
/** Attach all event listeners for the application */
function _attachListeners() {
// --- Auth & Connection Listeners (mostly unchanged) ---
dom.switchToRegisterBtn.addEventListener('click', () => { state.uiView = 'register'; _render(); });
dom.switchToLoginBtn.addEventListener('click', () => { state.uiView = 'login'; _render(); });
dom.registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
_hideError(dom.registerError);
const name = dom.registerNameInput.value.trim();
const password = dom.registerPasswordInput.value;
const confirm = dom.registerConfirmPasswordInput.value;
if (password !== confirm) {
_showError(dom.registerError, "Passwords do not match.");
return;
}
const result = await api.register(name, state.selectedAvatarStyle, password);
if (result.success) {
alert(result.message);
state.uiView = 'login';
_render();
} else {
_showError(dom.registerError, result.message);
}
});
dom.loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
_hideError(dom.loginError);
const name = dom.loginNameInput.value.trim();
const password = dom.loginPasswordInput.value;
const result = await api.login(name, password);
if (result.success) {
state.playerInfo = result.data;
localStorage.setItem('vdm-player', JSON.stringify(result.data));
state.uiView = 'chat';
_render();
} else {
_showError(dom.loginError, result.message);
}
});
dom.logoutButton.addEventListener('click', () => api.logout());
dom.joinButton.addEventListener('click', () => {
const roomId = dom.roomIdInput.value.trim();
if (roomId) api.connectToRoom(roomId);
});
dom.leaveButton.addEventListener('click', () => api.disconnect());
// --- Avatar Selection Listeners (unchanged) ---
dom.registerNameInput.addEventListener('input', () => _updateAvatarSelectionUI());
dom.avatarSelectionGrid.addEventListener('click', (e) => {
const option = e.target.closest('.avatar-option');
if (option && option.dataset.style) {
state.selectedAvatarStyle = option.dataset.style;
_updateAvatarSelectionUI();
}
});
// --- Chat Listeners ---
dom.sendButton.addEventListener('click', () => {
const message = dom.messageInput.value.trim();
if (message) api.sendMessage('say', { message });
dom.messageInput.value = '';
_updateCommandPreview();
dom.messageInput.focus();
});
dom.messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
dom.sendButton.click();
}
});
// NEW: Add input event listener for command preview
dom.messageInput.addEventListener('input', _updateCommandPreview);
dom.resolveButton.addEventListener('click', () => api.sendMessage('submit_turn'));
dom.startGameButton.addEventListener('click', () => api.sendMessage('start_game'));
dom.resumeGameButton.addEventListener('click', () => api.sendMessage('resume_game'));
// --- Misc Listeners (unchanged) ---
dom.themeToggle.addEventListener('click', () => {
const isLight = document.body.classList.toggle('light-theme');
localStorage.setItem('vdm-theme', isLight ? 'light' : 'dark');
});
const resumeAudio = () => {
if (state.audioContext && state.audioContext.state === 'suspended') {
state.audioContext.resume();
}
document.body.removeEventListener('click', resumeAudio);
};
document.body.addEventListener('click', resumeAudio);
}
// --- Initialize ---
const savedTheme = localStorage.getItem('vdm-theme') || 'dark';
document.body.classList.toggle('light-theme', savedTheme === 'light');
_populateAvatarGrid();
_attachListeners();
_render(); // Set initial view
/** @type {AppUI} */
const publicInterface = {
setApi(apiModule) {
api = apiModule;
},
logMessage(type, data, isBatch = false) {
const msgDiv = document.createElement('div');
msgDiv.classList.add('msg', type);
if (type === 'system') {
msgDiv.textContent = data.message;
} else if (type === 'chat') {
msgDiv.classList.add(data.author_id === 'gm' ? 'gm' : 'player');
if (data.is_ooc) msgDiv.classList.add('ooc');
const avatarImg = document.createElement('img');
avatarImg.className = 'msg-avatar';
const player = state.room?.players[data.author_id];
const avatarStyle = player ? player.avatar_style : 'bottts';
const avatarSeed = data.author_id === 'gm' ? 'GM' : encodeURIComponent(data.author_name);
avatarImg.src = `https://api.dicebear.com/9.x/${data.author_id === 'gm' ? 'bottts' : avatarStyle}/svg?seed=${avatarSeed}`;
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
const authorSpan = document.createElement('span');
authorSpan.className = 'author';
authorSpan.textContent = data.author_name;
const messageSpan = document.createElement('span');
let contentHTML = data.content.replace(/`([^`]+)`/g, '<code>$1</code>');
contentHTML = contentHTML.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
messageSpan.innerHTML = contentHTML;
contentDiv.appendChild(authorSpan);
contentDiv.appendChild(messageSpan);
msgDiv.appendChild(avatarImg);
msgDiv.appendChild(contentDiv);
}
dom.chatLog.appendChild(msgDiv);
if (!isBatch) {
dom.chatLog.scrollTop = dom.chatLog.scrollHeight;
}
},
// NEW: Function to load historical messages
loadChatHistory(messages) {
dom.chatLog.innerHTML = ''; // Clear the chat log first
messages.forEach(msg => {
// The 'chat' type is hardcoded as only chat messages are in history
this.logMessage('chat', msg, true);
});
dom.chatLog.scrollTop = dom.chatLog.scrollHeight; // Scroll to bottom after batch rendering
},
// --- Other public methods (updateRoomState, streaming handlers, etc.) are mostly unchanged ---
updateRoomState(room) {
state.room = room;
dom.roomName.textContent = room.room_id;
dom.playerList.innerHTML = '';
Object.values(room.players).forEach(player => {
const playerLi = document.createElement('li');
playerLi.className = player.is_active ? 'player-active' : 'player-inactive';
const avatarImg = document.createElement('img');
avatarImg.className = 'player-list-avatar';
avatarImg.src = `https://api.dicebear.com/9.x/${player.avatar_style}/svg?seed=${encodeURIComponent(player.name)}`;
const nameSpan = document.createElement('span');
nameSpan.className = 'player-name';
nameSpan.textContent = player.name;
if (room.host_player_id === player.id) {
nameSpan.textContent += ' 👑'; // Host indicator
}
const hpSpan = document.createElement('span');
hpSpan.className = 'player-hp';
if (player.sheet) {
hpSpan.textContent = `${player.sheet.hp}/${player.sheet.max_hp} HP`;
}
playerLi.appendChild(avatarImg);
playerLi.appendChild(nameSpan);
playerLi.appendChild(hpSpan);
dom.playerList.appendChild(playerLi);
});
const isHost = (state.playerInfo && room.host_player_id === state.playerInfo.id);
const inLobby = room.game_state === "LOBBY";
const gmIsProcessing = room.turn_state === "GM_PROCESSING";
const actionsExist = Object.keys(room.current_turn_actions || {}).length > 0;
dom.gmThinkingIndicator.style.display = gmIsProcessing ? 'flex' : 'none';
dom.hostControlsContainer.style.display = isHost ? 'flex' : 'none';
dom.startGameButton.style.display = inLobby && isHost ? 'block' : 'none';
dom.resumeGameButton.style.display = !inLobby && isHost && room.messages.length > 0 ? 'block' : 'none';
dom.resolveButton.disabled = !actionsExist || gmIsProcessing;
dom.messageInput.disabled = gmIsProcessing || inLobby;
dom.sendButton.disabled = gmIsProcessing || inLobby;
},
showRoomView(roomId) {
state.isConnected = true;
_render();
},
showConnectionView() {
state.isConnected = false;
_render();
},
showLoginView() {
state.uiView = 'login';
state.isConnected = false;
state.playerInfo = null; // Ensure player info is cleared
_render();
},
handleStreamStart() {
const msgDiv = document.createElement('div');
msgDiv.classList.add('msg', 'chat', 'gm', 'streaming');
const avatarImg = document.createElement('img');
avatarImg.src = `https://api.dicebear.com/9.x/bottts/svg?seed=GM`;
avatarImg.className = 'msg-avatar';
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
const authorSpan = document.createElement('span');
authorSpan.className = 'author';
authorSpan.textContent = 'GM';
const messageSpan = document.createElement('span');
contentDiv.appendChild(authorSpan);
contentDiv.appendChild(messageSpan);
msgDiv.appendChild(avatarImg);
msgDiv.appendChild(contentDiv);
dom.chatLog.appendChild(msgDiv);
state.activeStream = {
messageElement: msgDiv,
contentElement: messageSpan,
};
audio.startStream();
},
handleChatChunk(content) {
if (state.activeStream && state.activeStream.contentElement) {
state.activeStream.contentElement.textContent += content;
dom.chatLog.scrollTop = dom.chatLog.scrollHeight;
}
},
handleAudioChunk(chunk) {
audio.queueAndPlay(chunk);
},
handleStreamEnd(finalMessage) {
if (state.activeStream && state.activeStream.messageElement) {
state.activeStream.messageElement.classList.remove('streaming');
}
state.activeStream = null;
audio.endStream();
},
playAudioFile(url) {
audio.playFullAudioFile(url);
}
};
return publicInterface;
}