From ae2db39729f522842d63dd4e1dd0f8682e02a0b8 Mon Sep 17 00:00:00 2001 From: Tyler Marques Date: Thu, 13 Mar 2025 11:33:30 -0400 Subject: [PATCH] WIP: Moving most globals into gamestate The gamestate object is now where most of the "state" of both the map and the board live. The units load, just working on loading the colors of the map ownership again. --- ai_animation/src/domElements.ts | 45 + ai_animation/src/domElements/chatWindows.ts | 615 ++++++++++ ai_animation/src/gameState.ts | 153 ++- ai_animation/src/logger.ts | 27 +- ai_animation/src/main.ts | 1222 +------------------ ai_animation/src/map/create.ts | 180 +-- ai_animation/src/map/state.ts | 122 ++ ai_animation/src/phase.ts | 58 + ai_animation/src/speech.ts | 73 ++ ai_animation/src/types/gameState.ts | 29 +- ai_animation/src/types/map.ts | 93 +- ai_animation/src/units/animate.ts | 10 +- ai_animation/src/units/create.ts | 58 + 13 files changed, 1363 insertions(+), 1322 deletions(-) create mode 100644 ai_animation/src/domElements.ts create mode 100644 ai_animation/src/domElements/chatWindows.ts create mode 100644 ai_animation/src/map/state.ts create mode 100644 ai_animation/src/phase.ts create mode 100644 ai_animation/src/speech.ts diff --git a/ai_animation/src/domElements.ts b/ai_animation/src/domElements.ts new file mode 100644 index 0000000..3952f0c --- /dev/null +++ b/ai_animation/src/domElements.ts @@ -0,0 +1,45 @@ +import { gameState } from "./gameState"; +import { logger } from "./logger"; +// --- LOADING & DISPLAYING GAME PHASES --- +export function loadGameBtnFunction(file) { + const reader = new FileReader(); + reader.onload = e => { + gameState.loadGameData(e.target?.result) + }; + reader.onerror = () => { + logger.log("Error reading file.") + }; + reader.readAsText(file); +} +export const loadBtn = document.getElementById('load-btn'); +export const fileInput = document.getElementById('file-input'); +export const prevBtn = document.getElementById('prev-btn'); +export const nextBtn = document.getElementById('next-btn'); +export const playBtn = document.getElementById('play-btn'); +export const speedSelector = document.getElementById('speed-selector'); +export const phaseDisplay = document.getElementById('phase-display'); +export const mapView = document.getElementById('map-view'); +export const leaderboard = document.getElementById('leaderboard'); + +// Add roundRect polyfill for browsers that don't support it +if (!CanvasRenderingContext2D.prototype.roundRect) { + CanvasRenderingContext2D.prototype.roundRect = function (x, y, width, height, radius) { + if (typeof radius === 'undefined') { + radius = 5; + } + this.beginPath(); + this.moveTo(x + radius, y); + this.lineTo(x + width - radius, y); + this.arcTo(x + width, y, x + width, y + radius, radius); + this.lineTo(x + width, y + height - radius); + this.arcTo(x + width, y + height, x + width - radius, y + height, radius); + this.lineTo(x + radius, y + height); + this.arcTo(x, y + height, x, y + height - radius, radius); + this.lineTo(x, y + radius); + this.arcTo(x, y, x + radius, y, radius); + this.closePath(); + return this; + }; +} + + diff --git a/ai_animation/src/domElements/chatWindows.ts b/ai_animation/src/domElements/chatWindows.ts new file mode 100644 index 0000000..fe9d331 --- /dev/null +++ b/ai_animation/src/domElements/chatWindows.ts @@ -0,0 +1,615 @@ +import * as THREE from "three"; +import { currentPower } from "../gameState"; + +let faceIconCache = {}; // Cache for generated face icons + +// Add a message counter to track sound effect frequency +let messageCounter = 0; +let chatWindows = {}; // Store chat window elements by power +// --- CHAT WINDOW FUNCTIONS --- +export function createChatWindows() { + // Clear existing chat windows + const chatContainer = document.getElementById('chat-container'); + chatContainer.innerHTML = ''; + chatWindows = {}; + + // Create a chat window for each power (except the current power) + const powers = ['AUSTRIA', 'ENGLAND', 'FRANCE', 'GERMANY', 'ITALY', 'RUSSIA', 'TURKEY']; + + // Filter out the current power for chat windows + const otherPowers = powers.filter(power => power !== currentPower); + + // Add a GLOBAL chat window first + createChatWindow('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 = 'GLOBAL'; + } else { + titleElement.className = `power - ${power.toLowerCase()} `; + titleElement.textContent = power; + } + 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 + + 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() + }; +} + +// Modified to accumulate messages instead of resetting and only animate for new messages +function updateChatWindows(phase, stepMessages = false) { + if (!phase.messages || !phase.messages.length) { + messagesPlaying = false; + return; + } + + const relevantMessages = phase.messages.filter(msg => { + return ( + msg.sender === currentPower || + msg.recipient === currentPower || + msg.recipient === 'GLOBAL' + ); + }); + relevantMessages.sort((a, b) => a.time_sent - b.time_sent); + + if (!stepMessages) { + // Normal: show all at once + relevantMessages.forEach(msg => { + const isNew = addMessageToChat(msg, phase.name); + if (isNew) { + // Increment message counter and play sound on every third message + messageCounter++; + animateHeadNod(msg, (messageCounter % 3 === 0)); + } + }); + messagesPlaying = false; + } else { + // Stepwise + messagesPlaying = true; + let index = 0; + + // Define the showNext function that will be called after each message animation completes + const showNext = () => { + if (index >= relevantMessages.length) { + messagesPlaying = false; + if (unitAnimations.length === 0 && isPlaying && !isSpeaking) { + // Call the async function without awaiting it here + playbackTimer = setTimeout(() => advanceToNextPhase(), playbackSpeed); + } + return; + } + + const msg = relevantMessages[index]; + index++; // Increment index before adding message so word animation knows the correct next message + + const isNew = addMessageToChat(msg, phase.name, true, showNext); // Pass showNext as callback + + if (isNew && !isDebugMode) { + // Increment message counter + messageCounter++; + + // Only animate head and play sound for every third message + animateHeadNod(msg, (messageCounter % 3 === 0)); + } else if (isDebugMode) { + // In debug mode, immediately call showNext to skip waiting for animation + showNext(); + } else { + setTimeout(showNext, playbackSpeed * 3); + } + }; + + // Start the message sequence + showNext(); + } +} + +// Modified to support word-by-word animation and callback +function addMessageToChat(msg, phaseName, animateWords = false, onComplete = null) { + // Determine which chat window to use + let targetPower; + if (msg.recipient === 'GLOBAL') { + targetPower = 'GLOBAL'; + } else { + targetPower = msg.sender === 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 = `${msg.sender}: `; + 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 = phaseName; + messageElement.appendChild(timeDiv); + } else { + // Private chat - outgoing or incoming style + const isOutgoing = msg.sender === 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 = phaseName; + 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 +function animateMessageWords(message, contentSpanId, targetPower, messagesContainer, onComplete) { + 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 - keep messagesPlaying true until next message starts + + // Add a slight delay after the last word for readability + setTimeout(() => { + if (onComplete) { + onComplete(); // Call the completion callback + } + }, Math.min(playbackSpeed / 3, 150)); + + return; + } + + // Add space if not the first word + if (wordIndex > 0) { + contentSpan.textContent += ' '; + } + + // Add the next word + contentSpan.textContent += words[wordIndex]; + wordIndex++; + + // Schedule the next word with a delay based on word length and playback speed + const delay = Math.max(30, Math.min(120, playbackSpeed / 10 * (words[wordIndex - 1].length / 4))); + setTimeout(addNextWord, delay); + + // Scroll to ensure newest content is visible + 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 === 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 = { + 'GLOBAL': 0xf5f5f5, // Brighter white + 'AUSTRIA': 0xff0000, // Brighter red + 'ENGLAND': 0x0000ff, // Brighter blue + 'FRANCE': 0x00bfff, // Brighter cyan + 'GERMANY': 0x1a1a1a, // Darker gray for better contrast + 'ITALY': 0x00cc00, // Brighter green + 'RUSSIA': 0xe0e0e0, // Brighter gray + 'TURKEY': 0xffcc00 // Brighter yellow + }; + const headColor = colorMap[power] || 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