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