import * as THREE from "three"; import { gameState } from "../gameState"; import { config } from "../config"; import { GamePhase, Message } from "../types/gameState"; import { getPowerDisplayName } from '../utils/powerNames'; import { PowerENUM } from '../types/map'; //TODO: Sometimes the LLMs use lists, and they don't work in the chats. The just appear as bullets within a single line. // //TODO: We are getting a mixing of chats from different phases. In game 0, F1902M starts using chat before S1902M finishes let faceIconCache = {}; // Cache for generated face icons // Add a message counter to track sound effect frequency let messageCounter = 0; type chatWindowMap = { [key in PowerENUM]: { element: HTMLHtmlElement, messagesContainer: HTMLHtmlElement, isGlobal: boolean, seenMessages: Set } } let chatWindows: chatWindowMap // --- CHAT WINDOW FUNCTIONS --- export function createChatWindows() { // Clear existing chat windows const chatContainer = document.getElementById('chat-container'); if (!chatContainer) { throw new Error("Could not get element with ID 'chat-container'") } chatContainer.innerHTML = ''; chatWindows = {}; // Create a chat window for each power (except the current power) const powers = [PowerENUM.AUSTRIA, PowerENUM.ENGLAND, PowerENUM.FRANCE, PowerENUM.GERMANY, PowerENUM.ITALY, PowerENUM.RUSSIA, PowerENUM.TURKEY]; // Filter out the current power for chat windows const otherPowers = powers.filter(power => power !== gameState.currentPower); // Add a GLOBAL chat window first createChatWindow(PowerENUM.GLOBAL, true); // Create chat windows for each power except the current one otherPowers.forEach(power => { createChatWindow(power); }); } // Modified to use 3D face icons properly function createChatWindow(power, isGlobal = false) { const chatContainer = document.getElementById('chat-container'); const chatWindow = document.createElement('div'); chatWindow.className = 'chat-window'; chatWindow.id = `chat-${power}`; chatWindow.style.position = 'relative'; // Add relative positioning for absolute child positioning // Create a slimmer header with appropriate styling const header = document.createElement('div'); header.className = 'chat-header'; // Adjust header to accommodate larger face icons header.style.display = 'flex'; header.style.alignItems = 'center'; header.style.padding = '4px 8px'; // Reduced vertical padding header.style.height = '24px'; // Explicit smaller height header.style.backgroundColor = 'rgba(78, 62, 41, 0.7)'; // Semi-transparent background header.style.borderBottom = '1px solid rgba(78, 62, 41, 1)'; // Solid bottom border // Create the title element const titleElement = document.createElement('span'); if (isGlobal) { titleElement.style.color = '#ffffff'; titleElement.textContent = getPowerDisplayName(PowerENUM.GLOBAL); } else { titleElement.className = `power-${power.toLowerCase()}`; titleElement.textContent = getPowerDisplayName(power as PowerENUM); } titleElement.style.fontWeight = 'bold'; // Make text more prominent titleElement.style.textShadow = '1px 1px 2px rgba(0,0,0,0.7)'; // Add text shadow for better readability header.appendChild(titleElement); // Create container for 3D face icon that floats over the header const faceHolder = document.createElement('div'); faceHolder.style.width = '64px'; faceHolder.style.height = '64px'; faceHolder.style.position = 'absolute'; // Position absolutely faceHolder.style.right = '10px'; // From right edge faceHolder.style.top = '0px'; // ADJUSTED: Moved lower to align with the header faceHolder.style.cursor = 'pointer'; faceHolder.style.borderRadius = '50%'; faceHolder.style.overflow = 'hidden'; faceHolder.style.boxShadow = '0 2px 5px rgba(0,0,0,0.5)'; faceHolder.style.border = '2px solid #fff'; faceHolder.style.zIndex = '10'; // Ensure it's above other elements faceHolder.id = `face-${power}`; // Generate the face icon and add it to the chat window (not header) generateFaceIcon(power).then(dataURL => { const img = document.createElement('img'); img.src = dataURL; img.style.width = '100%'; img.style.height = '100%'; img.id = `face-img-${power}`; // Add ID for animation targeting // Add subtle idle animation setInterval(() => { if (!img.dataset.animating && Math.random() < 0.1) { idleAnimation(img); } }, 3000); faceHolder.appendChild(img); }); // Create messages container with extra top padding to avoid overlap with floating head header.appendChild(faceHolder); // Create messages container const messagesContainer = document.createElement('div'); messagesContainer.className = 'chat-messages'; messagesContainer.id = `messages-${power}`; messagesContainer.style.paddingTop = '8px'; // Add padding to prevent content being hidden under face // Add toggle functionality header.addEventListener('click', () => { chatWindow.classList.toggle('chat-collapsed'); }); // Assemble chat window - add faceHolder directly to chatWindow, not header chatWindow.appendChild(header); chatWindow.appendChild(faceHolder); chatWindow.appendChild(messagesContainer); // Add to container chatContainer.appendChild(chatWindow); // Store reference chatWindows[power] = { element: chatWindow, messagesContainer: messagesContainer, isGlobal: isGlobal, seenMessages: new Set() }; } function fiterAndSortChatMessagesForPhase(phase: GamePhase): Message[] { let relevantMessages = phase.messages.filter(msg => { return ( msg.sender === gameState.currentPower || msg.recipient === gameState.currentPower || msg.recipient === 'GLOBAL' ); }); relevantMessages.sort((a, b) => a.time_sent - b.time_sent); return relevantMessages } // Modified to accumulate messages instead of resetting and only animate for new messages /** * Updates chat windows with messages for the current phase * @param phase The current game phase containing messages * @param stepMessages Whether to animate messages one-by-word (true) or show all at once (false) */ export function updateChatWindows(stepMessages = false, callback?: () => void) { // Exit early if no messages if (!gameState.currentPhase.messages || !gameState.currentPhase.messages.length) { console.log("No messages to display for this phase"); return; } // Only show messages relevant to the current player (sent by them, to them, or global) const relevantMessages = gameState.currentPhase.messages.filter(msg => { return ( msg.sender === gameState.currentPower || msg.recipient === gameState.currentPower || msg.recipient === 'GLOBAL' ); }); // Sort messages by time sent relevantMessages.sort((a, b) => a.time_sent - b.time_sent); // Log message count but only in debug mode to reduce noise if (config.isDebugMode) { console.log(`Found ${relevantMessages.length} messages for player ${gameState.currentPower} in phase ${gameState.currentPhase.name}`); } // Stepwise mode: show one message at a time, animating word-by-word let index = 0; // Store the start time for debugging const messageStartTime = Date.now(); // Function to process the next message const showNext = () => { // If we're not playing or user has manually advanced, stop message animation if (!gameState.isPlaying && !config.isDebugMode) { console.log("Playback stopped, halting message animations"); return; } // All messages have been displayed if (index >= relevantMessages.length) { if (config.isDebugMode) { console.log(`All messages displayed in ${Date.now() - messageStartTime}ms`); } console.log("Messages complete, triggering next phase"); if (callback) callback(); return; } // Get the next message const msg = relevantMessages[index]; // Only log in debug mode to reduce console noise if (config.isDebugMode) { console.log(`Displaying message ${index + 1}/${relevantMessages.length}: ${msg.sender} to ${msg.recipient}`); } // Function to call after message animation completes const onMessageComplete = () => { index++; // Only increment after animation completes showNext() }; // Add the message with word animation addMessageToChat(msg, true, onMessageComplete); // Animate head and play sound for new messages (not just when not in debug mode) messageCounter++; animateHeadNod(msg, (messageCounter % config.soundEffectFrequency === 0)); }; // Start the message sequence with initial delay gameState.eventQueue.scheduleDelay(50, showNext, `start-message-sequence-${Date.now()}`); } // Modified to support word-by-word animation and callback function addMessageToChat(msg: Message, animateWords: boolean = false, onComplete: Function | null = null) { // Determine which chat window to use let targetPower; if (msg.recipient === 'GLOBAL') { targetPower = 'GLOBAL'; } else { targetPower = msg.sender === gameState.currentPower ? msg.recipient : msg.sender; } if (!chatWindows[targetPower]) return false; // Create a unique ID for this message to avoid duplication const msgId = `${msg.sender}-${msg.recipient}-${msg.time_sent}-${msg.message}`; // Skip if we've already shown this message if (chatWindows[targetPower].seenMessages.has(msgId)) { return false; // Not a new message } // Mark as seen chatWindows[targetPower].seenMessages.add(msgId); const messagesContainer = chatWindows[targetPower].messagesContainer; const messageElement = document.createElement('div'); // Style based on sender/recipient if (targetPower === 'GLOBAL') { // Global chat shows sender info const senderColor = msg.sender.toLowerCase(); messageElement.className = 'chat-message message-incoming'; // Add the header with the sender name immediately const headerSpan = document.createElement('span'); headerSpan.style.fontWeight = 'bold'; headerSpan.className = `power-${senderColor}`; headerSpan.textContent = `${getPowerDisplayName(msg.sender as PowerENUM)}: `; messageElement.appendChild(headerSpan); // Create a span for the message content that will be filled word by word const contentSpan = document.createElement('span'); contentSpan.id = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`; messageElement.appendChild(contentSpan); // Add timestamp const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; timeDiv.textContent = gameState.currentPhase.name; messageElement.appendChild(timeDiv); } else { // Private chat - outgoing or incoming style const isOutgoing = msg.sender === gameState.currentPower; messageElement.className = `chat-message ${isOutgoing ? 'message-outgoing' : 'message-incoming'}`; // Create content span const contentSpan = document.createElement('span'); contentSpan.id = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`; messageElement.appendChild(contentSpan); // Add timestamp const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; timeDiv.textContent = gameState.currentPhase.name; messageElement.appendChild(timeDiv); } // Add to container messagesContainer.appendChild(messageElement); // Scroll to bottom messagesContainer.scrollTop = messagesContainer.scrollHeight; if (animateWords) { // Start word-by-word animation const contentSpanId = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`; animateMessageWords(msg.message, contentSpanId, targetPower, messagesContainer, onComplete); } else { // Show entire message at once const contentSpan = messageElement.querySelector(`#msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`); if (contentSpan) { contentSpan.textContent = msg.message; } // If there's a completion callback, call it immediately for non-animated messages if (onComplete) { onComplete(); } } return true; // This was a new message } // New function to animate message words one at a time /** * Animates message text one word at a time * @param message The full message text to animate * @param contentSpanId The ID of the span element to animate within * @param targetPower The power the message is displayed for * @param messagesContainer The container holding the messages * @param onComplete Callback function to run when animation completes */ function animateMessageWords(message: string, contentSpanId: string, targetPower: string, messagesContainer: HTMLElement, onComplete: (() => void) | null) { const words = message.split(/\s+/); const contentSpan = document.getElementById(contentSpanId); if (!contentSpan) { // If span not found, still call onComplete to avoid breaking the game flow if (onComplete) onComplete(); return; } // Clear any existing content contentSpan.textContent = ''; let wordIndex = 0; // Function to add the next word const addNextWord = () => { if (wordIndex >= words.length) { // All words added - message is complete console.log(`Finished animating message with ${words.length} words in ${targetPower} chat`); // Add a slight delay after the last word for readability gameState.eventQueue.scheduleDelay(config.messageCompletionDelay, () => { if (onComplete) { onComplete(); // Call the completion callback } }, `message-complete-${Date.now()}`); return; } // Add space if not the first word if (wordIndex > 0) { contentSpan.textContent += ' '; } // Add the next word contentSpan.textContent += words[wordIndex]; wordIndex++; // Calculate delay based on word length and playback speed // Longer words get slightly longer display time const wordLength = words[wordIndex - 1].length; // Use consistent word delay from config const delay = Math.max(config.messageWordDelay, Math.min(200, config.messageWordDelay * (wordLength / 4))); gameState.eventQueue.scheduleDelay(delay, addNextWord, `add-word-${wordIndex}-${Date.now()}`); // Scroll to ensure newest content is visible // Use requestAnimationFrame to batch DOM updates in streaming mode const isStreamingModeForScroll = import.meta.env.MODE === 'production' || import.meta.env.VITE_STREAMING_MODE === 'true'; if (isStreamingModeForScroll) { requestAnimationFrame(() => { messagesContainer.scrollTop = messagesContainer.scrollHeight; }); } else { messagesContainer.scrollTop = messagesContainer.scrollHeight; } }; // Start animation addNextWord(); } // Modified to support conditional sound effects function animateHeadNod(msg, playSoundEffect = true) { // Determine which chat window's head to animate let targetPower; if (msg.recipient === 'GLOBAL') { targetPower = 'GLOBAL'; } else { targetPower = msg.sender === gameState.currentPower ? msg.recipient : msg.sender; } const chatWindow = chatWindows[targetPower]?.element; if (!chatWindow) return; // Find the face image and animate it const img = chatWindow.querySelector(`#face-img-${targetPower}`); if (!img) return; img.dataset.animating = 'true'; // Choose a random animation type for variety const animationType = Math.floor(Math.random() * 4); let animation; switch (animationType) { case 0: // Nod animation animation = img.animate([ { transform: 'rotate(0deg) scale(1)' }, { transform: 'rotate(15deg) scale(1.1)' }, { transform: 'rotate(-10deg) scale(1.05)' }, { transform: 'rotate(5deg) scale(1.02)' }, { transform: 'rotate(0deg) scale(1)' } ], { duration: 600, easing: 'ease-in-out' }); break; case 1: // Bounce animation animation = img.animate([ { transform: 'translateY(0) scale(1)' }, { transform: 'translateY(-8px) scale(1.15)' }, { transform: 'translateY(3px) scale(0.95)' }, { transform: 'translateY(-2px) scale(1.05)' }, { transform: 'translateY(0) scale(1)' } ], { duration: 700, easing: 'ease-in-out' }); break; case 2: // Shake animation animation = img.animate([ { transform: 'translate(0, 0) rotate(0deg)' }, { transform: 'translate(-5px, -3px) rotate(-5deg)' }, { transform: 'translate(5px, 2px) rotate(5deg)' }, { transform: 'translate(-5px, 1px) rotate(-3deg)' }, { transform: 'translate(0, 0) rotate(0deg)' } ], { duration: 500, easing: 'ease-in-out' }); break; case 3: // Pulse animation animation = img.animate([ { transform: 'scale(1)', boxShadow: '0 0 0 0 rgba(255,255,255,0.7)' }, { transform: 'scale(1.2)', boxShadow: '0 0 0 10px rgba(255,255,255,0)' }, { transform: 'scale(1)', boxShadow: '0 0 0 0 rgba(255,255,255,0)' } ], { duration: 800, easing: 'ease-out' }); break; } animation.onfinish = () => { img.dataset.animating = 'false'; }; // Trigger random snippet only if playSoundEffect is true if (playSoundEffect) { playRandomSoundEffect(); } } // Generate a 3D face icon for chat windows with higher contrast async function generateFaceIcon(power) { if (faceIconCache[power]) { return faceIconCache[power]; } // Even larger renderer size for better quality const offWidth = 192, offHeight = 192; // Increased from 128x128 to 192x192 const offRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); offRenderer.setSize(offWidth, offHeight); offRenderer.setPixelRatio(1); // Scene const offScene = new THREE.Scene(); offScene.background = null; // Camera const offCamera = new THREE.PerspectiveCamera(45, offWidth / offHeight, 0.1, 1000); offCamera.position.set(0, 0, 50); // Power-specific colors with higher contrast/saturation const colorMap: Record = { [PowerENUM.GLOBAL]: 0xf5f5f5, // Brighter white [PowerENUM.AUSTRIA]: 0xff0000, // Brighter red [PowerENUM.ENGLAND]: 0x0000ff, // Brighter blue [PowerENUM.FRANCE]: 0x00bfff, // Brighter cyan [PowerENUM.GERMANY]: 0x1a1a1a, // Darker gray for better contrast [PowerENUM.ITALY]: 0x00cc00, // Brighter green [PowerENUM.RUSSIA]: 0xe0e0e0, // Brighter gray [PowerENUM.TURKEY]: 0xffcc00, // Brighter yellow [PowerENUM.EUROPE]: 0xf5f5f5, // Same as global }; const headColor = colorMap[power as PowerENUM] || 0x808080; // Larger head geometry const headGeom = new THREE.BoxGeometry(20, 20, 20); // Increased from 16x16x16 const headMat = new THREE.MeshStandardMaterial({ color: headColor }); const headMesh = new THREE.Mesh(headGeom, headMat); offScene.add(headMesh); // Create outline for better visibility (a slightly larger black box behind) const outlineGeom = new THREE.BoxGeometry(22, 22, 19); const outlineMat = new THREE.MeshBasicMaterial({ color: 0x000000 }); const outlineMesh = new THREE.Mesh(outlineGeom, outlineMat); outlineMesh.position.z = -2; // Place it behind the head offScene.add(outlineMesh); // Larger eyes with better contrast const eyeGeom = new THREE.BoxGeometry(3.5, 3.5, 3.5); // Increased from 2.5x2.5x2.5 const eyeMat = new THREE.MeshStandardMaterial({ color: 0x000000 }); const leftEye = new THREE.Mesh(eyeGeom, eyeMat); leftEye.position.set(-4.5, 2, 10); // Adjusted position offScene.add(leftEye); const rightEye = new THREE.Mesh(eyeGeom, eyeMat); rightEye.position.set(4.5, 2, 10); // Adjusted position offScene.add(rightEye); // Add a simple mouth const mouthGeom = new THREE.BoxGeometry(8, 1.5, 1); const mouthMat = new THREE.MeshBasicMaterial({ color: 0x000000 }); const mouth = new THREE.Mesh(mouthGeom, mouthMat); mouth.position.set(0, -3, 10); offScene.add(mouth); // Brighter lighting for better contrast const light = new THREE.DirectionalLight(0xffffff, 1.2); // Increased intensity light.position.set(0, 20, 30); offScene.add(light); // Add more lights for better definition const fillLight = new THREE.DirectionalLight(0xffffff, 0.5); fillLight.position.set(-20, 0, 20); offScene.add(fillLight); offScene.add(new THREE.AmbientLight(0xffffff, 0.4)); // Slightly brighter ambient // Slight head rotation headMesh.rotation.y = Math.PI / 6; // More pronounced angle // Render to a texture const renderTarget = new THREE.WebGLRenderTarget(offWidth, offHeight); offRenderer.setRenderTarget(renderTarget); offRenderer.render(offScene, offCamera); // Get pixels const pixels = new Uint8Array(offWidth * offHeight * 4); offRenderer.readRenderTargetPixels(renderTarget, 0, 0, offWidth, offHeight, pixels); // Convert to canvas const canvas = document.createElement('canvas'); canvas.width = offWidth; canvas.height = offHeight; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(offWidth, offHeight); imageData.data.set(pixels); // Flip image (WebGL coordinate system is inverted) flipImageDataVertically(imageData, offWidth, offHeight); ctx.putImageData(imageData, 0, 0); // Get data URL const dataURL = canvas.toDataURL('image/png'); faceIconCache[power] = dataURL; // Cleanup offRenderer.dispose(); renderTarget.dispose(); return dataURL; } // Add a subtle idle animation for faces function idleAnimation(img) { if (img.dataset.animating === 'true') return; img.dataset.animating = 'true'; const animation = img.animate([ { transform: 'rotate(0deg) scale(1)' }, { transform: 'rotate(-2deg) scale(0.98)' }, { transform: 'rotate(0deg) scale(1)' } ], { duration: 1500, easing: 'ease-in-out' }); animation.onfinish = () => { img.dataset.animating = 'false'; }; } // Helper to flip image data vertically function flipImageDataVertically(imageData, width, height) { const bytesPerRow = width * 4; const temp = new Uint8ClampedArray(bytesPerRow); for (let y = 0; y < height / 2; y++) { const topOffset = y * bytesPerRow; const bottomOffset = (height - y - 1) * bytesPerRow; temp.set(imageData.data.slice(topOffset, topOffset + bytesPerRow)); imageData.data.set(imageData.data.slice(bottomOffset, bottomOffset + bytesPerRow), topOffset); imageData.data.set(temp, bottomOffset); } } // --- NEW: Function to play a random sound effect --- function playRandomSoundEffect() { // List all the sound snippet filenames in assets/sounds const soundEffects = [ 'snippet_2.mp3', 'snippet_3.mp3', 'snippet_4.mp3', 'snippet_9.mp3', 'snippet_10.mp3', 'snippet_11.mp3', 'snippet_12.mp3', 'snippet_13.mp3', 'snippet_14.mp3', 'snippet_15.mp3', 'snippet_16.mp3', 'snippet_17.mp3', ]; // Pick one at random const chosen = soundEffects[Math.floor(Math.random() * soundEffects.length)]; // Create an