diff --git a/ai_animation/index.html b/ai_animation/index.html index 8bb433b..31c3cb1 100644 --- a/ai_animation/index.html +++ b/ai_animation/index.html @@ -9,7 +9,7 @@
- +
diff --git a/ai_animation/package-lock.json b/ai_animation/package-lock.json index e77d9fa..39ee503 100644 --- a/ai_animation/package-lock.json +++ b/ai_animation/package-lock.json @@ -8,6 +8,7 @@ "name": "ai_animation", "version": "0.0.0", "dependencies": { + "@types/three": "^0.174.0", "three": "^0.174.0", "zod": "^3.24.2" }, @@ -707,6 +708,12 @@ "win32" ] }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -714,6 +721,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", + "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.174.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.174.0.tgz", + "integrity": "sha512-De/+vZnfg2aVWNiuy1Ldu+n2ydgw1osinmiZTAn0necE++eOfsygL8JpZgFjR2uHmAPo89MkxBj3JJ+2BMe+Uw==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.21.tgz", + "integrity": "sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==", + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.55", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.55.tgz", + "integrity": "sha512-p97I8XEC1h04esklFqyIH+UhFrUcj8/1/vBWgc6lAK4jMJc+KbhUy8D4dquHYztFj6pHLqGcp/P1xvBBF4r3DA==", + "license": "BSD-3-Clause" + }, "node_modules/esbuild": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", @@ -755,6 +794,12 @@ "@esbuild/win32-x64": "0.25.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -770,6 +815,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", diff --git a/ai_animation/package.json b/ai_animation/package.json index 2b9f079..b611fbd 100644 --- a/ai_animation/package.json +++ b/ai_animation/package.json @@ -13,6 +13,7 @@ "vite": "^6.2.0" }, "dependencies": { + "@types/three": "^0.174.0", "three": "^0.174.0", "zod": "^3.24.2" } diff --git a/ai_animation/src/logger.ts b/ai_animation/src/logger.ts new file mode 100644 index 0000000..4006bf4 --- /dev/null +++ b/ai_animation/src/logger.ts @@ -0,0 +1,18 @@ + +export default class Logger { + get infoPanel() { + let _panel = document.getElementById('info-panel'); + if (_panel === null) { + throw new Error("Unable to find the element with id 'info-panel'") + } + return _panel + } + log = (msg: string) => { + if (typeof msg !== "string") { + throw new Error(`Logger messages must be strings, you passed a ${typeof msg}`) + } + this.infoPanel.textContent = msg; + + console.log(msg) + } +} diff --git a/ai_animation/src/main.js b/ai_animation/src/main.ts similarity index 95% rename from ai_animation/src/main.js rename to ai_animation/src/main.ts index 398f676..4f244e6 100644 --- a/ai_animation/src/main.js +++ b/ai_animation/src/main.ts @@ -2,9 +2,11 @@ import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { FontLoader } from 'three/addons/loaders/FontLoader.js'; import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'; -import { addMapMouseEvents } from "./map/mouseMovement" import { createLabel } from "./map/labels" import "./style.css" +import { UnitMesh } from "./types/units"; +import { PowerENUM } from "./types/map"; +import Logger from "./logger"; // --- NEW: ElevenLabs TTS helper function --- const ELEVENLABS_API_KEY = import.meta.env.VITE_ELEVENLABS_API_KEY || ""; @@ -55,7 +57,7 @@ async function speakSummary(summaryText) { // Convert response into a playable blob const audioBlob = await response.blob(); const audioUrl = URL.createObjectURL(audioBlob); - + // Play the audio, pause until finished return new Promise((resolve, reject) => { const audio = new Audio(audioUrl); @@ -95,7 +97,7 @@ let scene, camera, renderer, controls; let gameData = null; let currentPhaseIndex = 0; let coordinateData = null; -let unitMeshes = []; // To store references for units + supply center 3D objects +let unitMeshes: UnitMesh[] = []; // To store references for units + supply center 3D objects let mapPlane = null; // The fallback map plane let isPlaying = false; // Track playback state let playbackSpeed = 500; // Default speed in ms @@ -105,6 +107,7 @@ let unitAnimations = []; // Track ongoing unit animations let territoryTransitions = []; // Track territory color transitions let chatWindows = {}; // Store chat window elements by power let currentPower = getRandomPower(); // Now randomly selected +let logger = new Logger() // >>> ADDED: Camera pan and message playback variables let cameraPanTime = 0; // Timer that drives the camera panning @@ -196,14 +199,19 @@ function initScene() { drawMap(); // Create the map plane from the start // target the center of the map controls.target = new THREE.Vector3(800, 0, 800) + // Load default game file if in debug mode + if (isDebugMode) { + loadDefaultGameFile(); + } + }) .catch(err => { console.error("Error loading coordinates:", err); + logger.log(`Error loading coords: ${err.message}`) }); // Handle resizing window.addEventListener('resize', onWindowResize); - addMapMouseEvents(mapView) // Kick off animation loop animate(); @@ -348,6 +356,31 @@ function onWindowResize() { renderer.setSize(mapView.clientWidth, mapView.clientHeight); } +// Load a default game if we're running debug +function loadDefaultGameFile() { + console.log("Loading default game file for debug mode..."); + + // Path to the default game file + const defaultGameFilePath = './assets/default_game.json'; + + fetch(defaultGameFilePath) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to load default game file: ${response.status}`); + } + return response.text(); + }) + .then(data => { + // Create a mock file object to pass to loadGame + const file = new File([data], "default_game.json", { type: "application/json" }); + loadGame(file); + console.log("Default game file loaded successfully"); + }) + .catch(error => { + console.error("Error loading default game file:", error); + logger.log(`Error loading default game: ${error.message}`) + }); +} // --- LOAD COORDINATE DATA --- function loadCoordinateData() { return new Promise((resolve, reject) => { @@ -367,6 +400,7 @@ function loadCoordinateData() { }) .then(data => { coordinateData = data; + logger.log('Coordinate data loaded!') resolve(coordinateData); }) .catch(error => { @@ -692,6 +726,7 @@ function loadGame(file) { reader.onload = e => { try { gameData = JSON.parse(e.target.result); + logger.log(`Game data loaded: ${gameData.phases?.length || 0} phases found.`) currentPhaseIndex = 0; if (gameData.phases?.length) { prevBtn.disabled = false; @@ -705,16 +740,15 @@ function loadGame(file) { // Display initial phase but WITHOUT messages displayInitialPhase(currentPhaseIndex); } - + // Add: Update info panel updateInfoPanel(); } catch (err) { - console.error("Error parsing JSON:", err); - infoPanel.textContent = "Error parsing JSON: " + err.message; + logger.log("Error parsing JSON: " + err.message) } }; reader.onerror = () => { - infoPanel.textContent = "Error reading file."; + logger.log("Error reading file.") }; reader.readAsText(file); } @@ -722,7 +756,7 @@ function loadGame(file) { // New function to display initial state without messages function displayInitialPhase(index) { if (!gameData || !gameData.phases || index < 0 || index >= gameData.phases.length) { - infoPanel.textContent = "Invalid phase index."; + logger.log("Invalid phase index.") return; } @@ -761,10 +795,15 @@ function displayInitialPhase(index) { updateMapOwnership(phase) // DON'T show messages yet - skip updateChatWindows call + if (isDebugMode) { + logger.log(JSON.stringify(phase.state.units)) + } else { + logger.log(`Phase: ${phase.name}\nSCs: ${phase.state?.centers ? JSON.stringify(phase.state.centers) : 'None'}\nUnits: ${phase.state?.units ? JSON.stringify(phase.state.units) : 'None'}`) + } // Add: Update info panel updateInfoPanel(); - + drawMap() } @@ -865,7 +904,7 @@ async function advanceToNextPhase() { if (justEndedPhase.summary && justEndedPhase.summary.trim() !== '') { // UPDATED: First update the news banner with full summary addToNewsBanner(`(${justEndedPhase.name}) ${justEndedPhase.summary}`); - + // Then speak the summary (will be truncated internally) await speakSummary(justEndedPhase.summary); } @@ -884,7 +923,7 @@ async function advanceToNextPhase() { function displayPhaseWithAnimation(index) { if (!gameData || !gameData.phases || index < 0 || index >= gameData.phases.length) { - infoPanel.textContent = "Invalid phase index."; + logger.log("Invalid phase index.") return; } @@ -918,16 +957,14 @@ function displayPhaseWithAnimation(index) { updateLeaderboard(currentPhase); updateMapOwnership(currentPhase) - // No messages, animate units immediately animateUnitsForPhase(currentPhase, previousPhase); } - + let msg = `Phase: ${currentPhase.name}\nSCs: ${JSON.stringify(currentPhase.state.centers)} \nUnits: ${currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None'} ` // Panel - // Remove: infoPanel.textContent = `Phase: ${currentPhase.name}\nSCs: ${currentPhase.state?.centers ? JSON.stringify(currentPhase.state.centers) : 'None'}\nUnits: ${currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None'}`; - + // Add: Update info panel updateInfoPanel(); - + drawMap() } @@ -964,7 +1001,7 @@ function animateUnitsForPhase(currentPhase, previousPhase) { unitArr.forEach(unitStr => { const match = unitStr.match(/^([AF])\s+(.+)$/); if (match) { - const key = `${power}-${match[1]}-${match[2]}`; + const key = `${power} -${match[1]} -${match[2]} `; previousUnitPositions[key] = getProvincePosition(match[2]); } }); @@ -980,7 +1017,7 @@ function animateUnitsForPhase(currentPhase, previousPhase) { if (!match) return; const unitType = match[1]; const location = match[2]; - const key = `${power}-${unitType}-${location}`; + const key = `${power} -${unitType} -${location} `; const unitMesh = createUnitMesh({ power: power.toUpperCase(), type: unitType, @@ -995,7 +1032,7 @@ function animateUnitsForPhase(currentPhase, previousPhase) { let startPos; let matchFound = false; for (const prevKey in previousUnitPositions) { - if (prevKey.startsWith(`${power}-${unitType}`)) { + if (prevKey.startsWith(`${power} -${unitType} `)) { startPos = previousUnitPositions[prevKey]; matchFound = true; delete previousUnitPositions[prevKey]; @@ -1003,6 +1040,8 @@ function animateUnitsForPhase(currentPhase, previousPhase) { } } if (!matchFound) { + // TODO: Add a spawn animation? + // // New spawn startPos = { x: currentPos.x, y: -20, z: currentPos.z }; } @@ -1137,7 +1176,7 @@ function colorShift(hex, percent) { b = Math.min(255, Math.max(0, b + Math.floor(b * percent / 100))); // Convert back to hex - return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')} `; } // --- CHAT WINDOW FUNCTIONS --- @@ -1167,7 +1206,7 @@ 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.id = `chat - ${power} `; chatWindow.style.position = 'relative'; // Add relative positioning for absolute child positioning // Create a slimmer header with appropriate styling @@ -1188,7 +1227,7 @@ function createChatWindow(power, isGlobal = false) { titleElement.style.color = '#ffffff'; titleElement.textContent = 'GLOBAL'; } else { - titleElement.className = `power-${power.toLowerCase()}`; + titleElement.className = `power - ${power.toLowerCase()} `; titleElement.textContent = power; } titleElement.style.fontWeight = 'bold'; // Make text more prominent @@ -1208,7 +1247,7 @@ function createChatWindow(power, isGlobal = false) { 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}`; + faceHolder.id = `face - ${power} `; // Generate the face icon and add it to the chat window (not header) generateFaceIcon(power).then(dataURL => { @@ -1216,9 +1255,9 @@ function createChatWindow(power, isGlobal = false) { 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 - 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(() => { @@ -1237,7 +1276,7 @@ function createChatWindow(power, isGlobal = false) { // Create messages container const messagesContainer = document.createElement('div'); messagesContainer.className = 'chat-messages'; - messagesContainer.id = `messages-${power}`; + messagesContainer.id = `messages - ${power} `; messagesContainer.style.paddingTop = '8px'; // Add padding to prevent content being hidden under face // Add toggle functionality @@ -1304,16 +1343,16 @@ function updateChatWindows(phase, stepMessages = false) { } 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) { @@ -1341,7 +1380,7 @@ function addMessageToChat(msg, phaseName, animateWords = false, onComplete = nul 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}`; + 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)) { @@ -1359,19 +1398,19 @@ function addMessageToChat(msg, phaseName, animateWords = false, onComplete = nul // 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'; @@ -1381,12 +1420,12 @@ function addMessageToChat(msg, phaseName, animateWords = false, onComplete = nul // 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'; @@ -1399,7 +1438,7 @@ function addMessageToChat(msg, phaseName, animateWords = false, onComplete = nul // 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, '-')}`; @@ -1410,7 +1449,7 @@ function addMessageToChat(msg, phaseName, animateWords = false, onComplete = nul if (contentSpan) { contentSpan.textContent = msg.message; } - + // If there's a completion callback, call it immediately for non-animated messages if (onComplete) { onComplete(); @@ -1429,43 +1468,43 @@ function animateMessageWords(message, contentSpanId, targetPower, messagesContai 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))); + 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(); } @@ -1484,7 +1523,7 @@ function animateHeadNod(msg, playSoundEffect = true) { if (!chatWindow) return; // Find the face image and animate it - const img = chatWindow.querySelector(`#face-img-${targetPower}`); + const img = chatWindow.querySelector(`#face - img - ${targetPower} `); if (!img) return; img.dataset.animating = 'true'; @@ -1720,7 +1759,7 @@ function playRandomSoundEffect() { const chosen = soundEffects[Math.floor(Math.random() * soundEffects.length)]; // Create an