diff --git a/ai_animation/package-lock.json b/ai_animation/package-lock.json
index eb2a585..e05252b 100644
--- a/ai_animation/package-lock.json
+++ b/ai_animation/package-lock.json
@@ -8,7 +8,10 @@
"name": "ai_animation",
"version": "0.0.0",
"dependencies": {
- "three": "^0.174.0"
+ "@tweenjs/tween.js": "^25.0.0",
+ "@types/three": "^0.174.0",
+ "three": "^0.174.0",
+ "zod": "^3.24.2"
},
"devDependencies": {
"typescript": "~5.7.2",
@@ -706,6 +709,12 @@
"win32"
]
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "25.0.0",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
+ "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -713,6 +722,44 @@
"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/three/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/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",
@@ -754,6 +801,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",
@@ -769,6 +822,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",
@@ -964,6 +1023,15 @@
"optional": true
}
}
+ },
+ "node_modules/zod": {
+ "version": "3.24.2",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
+ "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/ai_animation/package.json b/ai_animation/package.json
index 620c979..431f9d7 100644
--- a/ai_animation/package.json
+++ b/ai_animation/package.json
@@ -13,7 +13,9 @@
"vite": "^6.2.0"
},
"dependencies": {
- "three": "^0.174.0"
- },
- "packageManager": "pnpm@9.12.2+sha512.22721b3a11f81661ae1ec68ce1a7b879425a1ca5b991c975b074ac220b187ce56c708fe5db69f4c962c989452eee76c82877f4ee80f474cebd61ee13461b6228"
+ "@tweenjs/tween.js": "^25.0.0",
+ "@types/three": "^0.174.0",
+ "three": "^0.174.0",
+ "zod": "^3.24.2"
+ }
}
diff --git a/ai_animation/src/config.ts b/ai_animation/src/config.ts
new file mode 100644
index 0000000..f102d6f
--- /dev/null
+++ b/ai_animation/src/config.ts
@@ -0,0 +1,4 @@
+export const config = {
+ playbackSpeed: 500, // Default speed in ms
+ isDebugMode: true
+}
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..b6ce9c8
--- /dev/null
+++ b/ai_animation/src/domElements/chatWindows.ts
@@ -0,0 +1,616 @@
+import * as THREE from "three";
+import { currentPower, gameState } from "../gameState";
+import { config } from "../config";
+
+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
+export function updateChatWindows(phase, stepMessages = false) {
+ if (!phase.messages || !phase.messages.length) {
+ gameState.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));
+ }
+ });
+ gameState.messagesPlaying = false;
+ } else {
+ // Stepwise
+ gameState.messagesPlaying = true;
+ let index = 0;
+
+ // Define the showNext function that will be called after each message animation completes
+ const showNext = () => {
+ if (index >= relevantMessages.length) {
+ gameState.messagesPlaying = false;
+ if (gameState.isAnimating && gameState.isPlaying && !gameState.isSpeaking) {
+ // Call the async function without awaiting it here
+ gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.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 && !config.isDebugMode) {
+ // Increment message counter
+ messageCounter++;
+
+ // Only animate head and play sound for every third message
+ animateHeadNod(msg, (messageCounter % 3 === 0));
+ } else if (config.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(config.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, config.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