Merge pull request #17 from Tylermarques/bugfix/animations-disappearing

Animations now present, they just don't always move to the right space.
This commit is contained in:
AlxAI 2025-03-15 12:46:52 -07:00 committed by GitHub
commit 502445a0d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2287 additions and 2004 deletions

View file

@ -9,7 +9,7 @@
"x": 720,
"y": 651
},
"type": null
"type": "Inpassable"
},
"ADR": {
"label": {
@ -215,7 +215,7 @@
"x": 929.6,
"y": 538.9
},
"type": null,
"type": "Water",
"isSupplyCenter": false
},
"LYO": {
@ -223,7 +223,7 @@
"x": 525.8,
"y": 1107
},
"type": null,
"type": "Water",
"isSupplyCenter": false
},
"HEL": {

View file

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

View file

@ -1,202 +0,0 @@
{
"unplayable": {
"fill": "#D5D5D5"
},
"impassable": {
"fill": "#353433"
},
"background": {
"fill": "#FFFFFF"
},
"labeltext": {
"font-size": "12px",
"fill": "black",
"font-family": "Arial"
},
"currentnoterect": {
"fill": "#c5dfea",
"stroke-width": "0",
"stroke": "black"
},
"currentnotetext": {
"font-family": "serif,sans-serif",
"font-size": "12px",
"fill": "black",
"stroke": "black"
},
"currentphasetext": {
"font-family": "serif,sans-serif",
"font-size": "14px",
"fill": "black",
"stroke": "black"
},
"invisibleContent": {
"stroke": "#000000",
"fill": "#000000",
"fill-opacity": "0.0",
"opacity": "0.0"
},
"provinceRed": {
"fill": "url(#patternRed)",
"stroke": "black",
"stroke-width": "2"
},
"provinceBrown": {
"fill": "url(#patternBrown)",
"stroke": "black",
"stroke-width": "2"
},
"provinceGreen": {
"fill": "url(#patternGreen)",
"stroke": "black",
"stroke-width": "2"
},
"provinceBlack": {
"fill": "url(#patternBlack)",
"stroke": "black",
"stroke-width": "2"
},
"provinceBlue": {
"fill": "url(#patternBlue)",
"stroke": "black",
"stroke-width": "2"
},
"nopower": {
"fill": "#ddd2af",
"stroke": "black",
"stroke-linejoin": "round",
"stroke-width": "2"
},
"water": {
"fill": "#c5dfea",
"stroke": "black",
"stroke-linejoin": "round",
"stroke-width": "2"
},
"britain": {
"fill": "royalblue",
"stroke": "black",
"stroke-width": "2"
},
"egypt": {
"fill": "#808000",
"stroke": "black",
"stroke-width": "2"
},
"france": {
"fill": "#00FFFF",
"stroke": "black",
"stroke-width": "2"
},
"germany": {
"fill": "darkgrey",
"stroke": "black",
"stroke-width": "2"
},
"italy": {
"fill": "#80FF80",
"stroke": "black",
"stroke-width": "2"
},
"poland": {
"fill": "#FF0000",
"stroke": "black",
"stroke-width": "2"
},
"russia": {
"fill": "#008000",
"stroke": "black",
"stroke-width": "2"
},
"spain": {
"fill": "#FF8080",
"stroke": "black",
"stroke-width": "2"
},
"turkey": {
"fill": "#FFFF00",
"stroke": "black",
"stroke-width": "2"
},
"ukraine": {
"fill": "#FF00FF",
"stroke": "black",
"stroke-width": "2"
},
"unitbritain": {
"fill": "deepskyblue",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitegypt": {
"fill": "#808000",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitfrance": {
"fill": "#00FFFF",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitgermany": {
"fill": "darkgrey",
"stroke": "black",
"fill-opacity": "0.90"
},
"unititaly": {
"fill": "#80FF80",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitpoland": {
"fill": "#FF0000",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitrussia": {
"fill": "#008000",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitspain": {
"fill": "#FF8080",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitturkey": {
"fill": "#FFFF00",
"stroke": "black",
"fill-opacity": "0.90"
},
"unitukraine": {
"fill": "#FF00FF",
"stroke": "black",
"fill-opacity": "0.90"
},
"supportorder": {
"stroke-width": "2",
"fill": "none",
"stroke-dasharray": "5,5"
},
"convoyorder": {
"stroke-dasharray": "15,5",
"stroke-width": "2",
"fill": "none"
},
"shadowdash": {
"stroke-width": "4",
"fill": "none",
"stroke": "black",
"opacity": "0.45"
},
"varwidthorder": {
"fill": "none"
},
"varwidthshadow": {
"fill": "none",
"stroke": "black"
},
"style1": {
"fill": "darkGray"
}
}

View file

@ -0,0 +1,48 @@
{
"unplayable": {
"fill": "#D5D5D5"
},
"impassable": {
"fill": "#353433"
},
"labeltext": {
"font-size": "12px",
"fill": "black",
"font-family": "Arial"
},
"currentnoterect": {
"fill": "#c5dfea",
"stroke-width": "0",
"stroke": "black"
},
"currentnotetext": {
"font-family": "serif,sans-serif",
"font-size": "12px",
"fill": "black",
"stroke": "black"
},
"currentphasetext": {
"font-family": "serif,sans-serif",
"font-size": "14px",
"fill": "black",
"stroke": "black"
},
"invisibleContent": {
"stroke": "#000000",
"fill": "#000000",
"fill-opacity": "0.0",
"opacity": "0.0"
},
"nopower": {
"fill": "#ddd2af",
"stroke": "black",
"stroke-linejoin": "round",
"stroke-width": "2"
},
"water": {
"fill": "#c5dfea",
"stroke": "black",
"stroke-linejoin": "round",
"stroke-width": "2"
}
}

View file

@ -9,7 +9,7 @@
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
<div class="container">
<div class="top-controls">

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,4 @@
export const config = {
playbackSpeed: 500, // Default speed in ms
isDebugMode: true
}

View file

@ -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;
};
}

View file

@ -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 <audio> and play
const audio = new Audio(`assets / sounds / ${chosen} `);
audio.play().catch(err => {
// In case of browser auto-play restrictions, you may see warnings in console
console.warn("Audio play was interrupted:", err);
});
}
/**
* Appends text to the scrolling news banner.
* If the banner is at its default text or empty, replace it entirely.
* Otherwise, just append " | " + newText.
*/
function addToNewsBanner(newText) {
const bannerEl = document.getElementById('news-banner-content');
if (!bannerEl) return;
// If the banner only has the default text or is empty, replace it
if (
bannerEl.textContent.trim() === 'Diplomatic actions unfolding...' ||
bannerEl.textContent.trim() === ''
) {
bannerEl.textContent = newText;
} else {
// Otherwise append with a separator
bannerEl.textContent += ' | ' + newText;
}
}

View file

@ -0,0 +1,140 @@
import * as THREE from "three"
import { type Province, type CoordinateData, CoordinateDataSchema, PowerENUM } from "./types/map"
import type { GameSchemaType } from "./types/gameState";
import { GameSchema } from "./types/gameState";
import { prevBtn, nextBtn, playBtn, speedSelector, mapView } from "./domElements";
import { createChatWindows } from "./domElements/chatWindows";
import { logger } from "./logger";
import { OrbitControls } from "three/examples/jsm/Addons.js";
import { displayInitialPhase } from "./phase";
import * as TWEEN from "@tweenjs/tween.js";
//FIXME: This whole file is a mess. Need to organize and format
//
// NEW: Add a lock for text-to-speech
export let isSpeaking = false; // Lock to pause game flow while TTS is active
export let currentPhaseIndex = 0;
enum AvailableMaps {
STANDARD = "standard"
}
// Move these definitions BEFORE they're used
function getRandomPower(): PowerENUM {
const values = Object.values(PowerENUM)
const idx = Math.floor(Math.random() * values.length);
return values[idx];
}
export const currentPower = getRandomPower();
class GameState {
boardState: CoordinateData
gameData: GameSchemaType | null
phaseIndex: number
boardName: string
// state locks
messagesPlaying: boolean
isPlaying: boolean
isSpeaking: boolean
isAnimating: boolean
//Scene for three.js
scene: THREE.Scene
// camera and controls
camControls: OrbitControls
camera: THREE.PerspectiveCamera
renderer: THREE.WebGLRenderer
unitMeshes: THREE.Group[]
// Animations needed for this turn
unitAnimations: TWEEN.Tween[]
//
playbackTimer: number
constructor(boardName: AvailableMaps) {
this.phaseIndex = 0
this.gameData = null
this.boardName = boardName
// State locks
this.isSpeaking = false
this.isPlaying = false
this.isAnimating = false
this.messagesPlaying = false
this.scene = new THREE.Scene()
this.unitMeshes = []
this.unitAnimations = []
}
loadGameData = (gameDataString: string): Promise<void> => {
return new Promise((resolve, reject) => {
this.gameData = GameSchema.parse(JSON.parse(gameDataString));
logger.log(`Game data loaded: ${this.gameData.phases?.length || 0} phases found.`)
this.phaseIndex = 0;
if (this.gameData.phases?.length) {
prevBtn.disabled = false;
nextBtn.disabled = false;
playBtn.disabled = false;
speedSelector.disabled = false;
// Initialize chat windows
createChatWindows();
displayInitialPhase()
resolve()
} else {
reject()
}
})
}
loadBoardState = (): Promise<void> => {
return new Promise((resolve, reject) => {
fetch(`./assets/maps/${this.boardName}/coords.json`)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load coordinates: ${response.status}`);
}
return response.json()
})
.then((data) => {
this.boardState = CoordinateDataSchema.parse(data)
resolve()
})
.catch(error => {
console.error(error);
throw error
});
})
}
initScene = () => {
this.scene.background = new THREE.Color(0x87CEEB);
// Camera
this.camera = new THREE.PerspectiveCamera(
60,
mapView.clientWidth / mapView.clientHeight,
1,
3000
);
this.camera.position.set(0, 800, 900); // MODIFIED: Increased z-value to account for map shift
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(mapView.clientWidth, mapView.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
mapView.appendChild(this.renderer.domElement);
// Controls
this.camControls = new OrbitControls(this.camera, this.renderer.domElement);
this.camControls.enableDamping = true;
this.camControls.dampingFactor = 0.05;
this.camControls.screenSpacePanning = true;
this.camControls.minDistance = 100;
this.camControls.maxDistance = 2000;
this.camControls.maxPolarAngle = Math.PI / 2; // Limit so you don't flip under the map
this.camControls.target.set(0, 0, 100); // ADDED: Set control target to new map center
}
}
export let gameState = new GameState(AvailableMaps.STANDARD);

View file

@ -0,0 +1,43 @@
import { gameState, currentPower } from "./gameState";
import { currentPhaseIndex } from "./gameState";
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)
}
// New function to update info panel with useful information
updateInfoPanel = () => {
const totalPhases = gameState.gameData?.phases?.length || 0;
const currentPhaseNumber = currentPhaseIndex + 1;
const phaseName = gameState.gameData?.phases?.[currentPhaseIndex]?.name || 'Unknown';
this.infoPanel.innerHTML = `
<div><strong>Power:</strong> <span class="power-${currentPower.toLowerCase()}">${currentPower}</span></div>
<div><strong>Current Phase:</strong> ${phaseName} (${currentPhaseNumber}/${totalPhases})</div>
<hr/>
<h4>All-Time Leaderboard</h4>
<ul style="list-style:none;padding-left:0;margin:0;">
<li>Austria: 0</li>
<li>England: 0</li>
<li>France: 0</li>
<li>Germany: 0</li>
<li>Italy: 0</li>
<li>Russia: 0</li>
<li>Turkey: 0</li>
</ul>
`;
}
}
export const logger = new Logger()

File diff suppressed because it is too large Load diff

235
ai_animation/src/main.ts Normal file
View file

@ -0,0 +1,235 @@
import * as THREE from "three";
import "./style.css"
import { initMap } from "./map/create";
import { createTweenAnimations } from "./units/animate";
import * as TWEEN from "@tweenjs/tween.js";
import { gameState } from "./gameState";
import { logger } from "./logger";
import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements";
import { updateChatWindows } from "./domElements/chatWindows";
import { displayPhaseWithAnimation } from "./phase";
import { config } from "./config";
//TODO: Create a function that finds a suitable unit location within a given polygon, for placing units better
// Currently the location for label, unit, and SC are all the same manually picked location
//const isDebugMode = process.env.NODE_ENV === 'development' || localStorage.getItem('debug') === 'true';
const isDebugMode = config.isDebugMode;
// --- CORE VARIABLES ---
let cameraPanTime = 0; // Timer that drives the camera panning
const cameraPanSpeed = 0.0005; // Smaller = slower
// --- INITIALIZE SCENE ---
function initScene() {
gameState.initScene()
// Lighting (keep it simple)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
gameState.scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
dirLight.position.set(300, 400, 300);
gameState.scene.add(dirLight);
// Load coordinate data, then build the map
gameState.loadBoardState().then(() => {
initMap(gameState.scene).then(() => {
// 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);
// Kick off animation loop
animate();
// Initialize info panel
logger.updateInfoPanel();
}
// --- ANIMATION LOOP ---
function animate() {
requestAnimationFrame(animate);
if (gameState.isPlaying) {
// Pan camera slowly in playback mode
cameraPanTime += cameraPanSpeed;
const angle = 0.9 * Math.sin(cameraPanTime) + 1.2;
const radius = 1300;
gameState.camera.position.set(
radius * Math.cos(angle),
650 + 80 * Math.sin(cameraPanTime * 0.5),
100 + radius * Math.sin(angle)
);
// If messages are done playing but we haven't started unit animations yet
if (!gameState.messagesPlaying && !gameState.isSpeaking && gameState.unitAnimations.length === 0 && gameState.isPlaying) {
if (gameState.gameData && gameState.gameData.phases) {
const prevIndex = gameState.phaseIndex > 0 ? gameState.phaseIndex - 1 : gameState.gameData.phases.length - 1;
createTweenAnimations(
gameState.gameData.phases[gameState.phaseIndex],
gameState.gameData.phases[prevIndex]
);
}
}
} else {
gameState.camControls.update();
}
// Process unit movement animations using TWEEN.js update
// Check if all animations are complete
if (gameState.unitAnimations.length > 0) {
// Filter out completed animations
gameState.unitAnimations = gameState.unitAnimations.filter(anim => anim.isPlaying());
gameState.unitAnimations.forEach((anim) => anim.update())
// If all animations are complete and we're in playback mode
if (gameState.unitAnimations.length === 0 && gameState.isPlaying && !gameState.messagesPlaying) {
// Schedule next phase after a pause delay
gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.playbackSpeed);
}
}
// Update any pulsing or wave animations on supply centers or units
if (gameState.scene.userData.animatedObjects) {
gameState.scene.userData.animatedObjects.forEach(obj => {
if (obj.userData.pulseAnimation) {
const anim = obj.userData.pulseAnimation;
anim.time += anim.speed;
if (obj.userData.glowMesh) {
const pulseValue = Math.sin(anim.time) * anim.intensity + 0.5;
obj.userData.glowMesh.material.opacity = 0.2 + (pulseValue * 0.3);
obj.userData.glowMesh.scale.set(
1 + (pulseValue * 0.1),
1 + (pulseValue * 0.1),
1 + (pulseValue * 0.1)
);
}
// Subtle bobbing up/down
obj.position.y = 2 + Math.sin(anim.time) * 0.5;
}
});
}
gameState.camControls.update();
gameState.renderer.render(gameState.scene, gameState.camera);
}
// --- RESIZE HANDLER ---
function onWindowResize() {
gameState.camera.aspect = mapView.clientWidth / mapView.clientHeight;
gameState.camera.updateProjectionMatrix();
gameState.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 => {
gameState.loadGameData(data);
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}`)
});
}
// --- PLAYBACK CONTROLS ---
function togglePlayback() {
if (!gameState.gameData || gameState.gameData.phases.length <= 1) return;
// NEW: If we're speaking, don't allow toggling playback
if (gameState.isSpeaking) return;
gameState.isPlaying = !gameState.isPlaying;
if (gameState.isPlaying) {
playBtn.textContent = "⏸ Pause";
prevBtn.disabled = true;
nextBtn.disabled = true;
// First, show the messages of the current phase if it's the initial playback
const phase = gameState.gameData.phases[gameState.phaseIndex];
if (phase.messages && phase.messages.length) {
// Show messages with stepwise animation
updateChatWindows(phase, true);
} else {
// No messages, go straight to unit animations
displayPhaseWithAnimation(gameState.phaseIndex);
}
} else {
playBtn.textContent = "▶ Play";
if (gameState.playbackTimer) {
clearTimeout(gameState.playbackTimer);
gameState.playbackTimer = null;
}
gameState.messagesPlaying = false;
prevBtn.disabled = false;
nextBtn.disabled = false;
}
}
// --- EVENT HANDLERS ---
loadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', e => {
const file = e.target.files[0];
if (file) {
loadGameBtnFunction(file);
}
});
prevBtn.addEventListener('click', () => {
if (gameState.phaseIndex > 0) {
gameState.phaseIndex--;
displayPhaseWithAnimation(gameState.phaseIndex);
}
});
nextBtn.addEventListener('click', () => {
if (gameState.gameData && gameState.phaseIndex < gameState.gameData.phases.length - 1) {
gameState.phaseIndex++;
displayPhaseWithAnimation(gameState.phaseIndex);
}
});
playBtn.addEventListener('click', togglePlayback);
speedSelector.addEventListener('change', e => {
config.playbackSpeed = parseInt(e.target.value);
// If we're currently playing, restart the timer with the new speed
if (gameState.isPlaying && gameState.playbackTimer) {
clearTimeout(gameState.playbackTimer);
gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.playbackSpeed);
}
});
// --- BOOTSTRAP ON PAGE LOAD ---
window.addEventListener('load', initScene);

View file

@ -0,0 +1,109 @@
import * as THREE from "three";
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js';
import { createLabel } from "./labels"
import { gameState } from "../gameState";
import { getPowerHexColor } from "../units/create";
export function initMap(scene): Promise<void> {
return new Promise((resolve, reject) => {
const loader = new SVGLoader();
loader.load('assets/maps/standard/map.svg',
function (data) {
fetch('assets/maps/standard/styles.json')
.then(resp => resp.json())
.then(map_styles => {
const paths = data.paths;
const group = new THREE.Group();
const textGroup = new THREE.Group();
let fillColor;
for (let i = 0; i < paths.length; i++) {
fillColor = "";
const path = paths[i];
// The "standard" map has keys like _mos, so remove that then send them to caps
let provinceKey = path.userData.node.id.substring(1).toUpperCase();
let nodeClass = path.userData.node.classList[0]
switch (nodeClass) {
case undefined:
continue
case "water":
fillColor = "#c5dfea"
break
case "nopower":
fillColor = getPowerHexColor(undefined)
}
const material = new THREE.MeshBasicMaterial({
color: fillColor,
side: THREE.DoubleSide,
depthWrite: false
});
const shapes = SVGLoader.createShapes(path);
for (let j = 0; j < shapes.length; j++) {
const shape = shapes[j];
const geometry = new THREE.ShapeGeometry(shape);
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = Math.PI / 2;
if (provinceKey && gameState.boardState.provinces[provinceKey]) {
gameState.boardState.provinces[provinceKey].mesh = mesh
}
// Create an edges geometry from the shape geometry.
const edges = new THREE.EdgesGeometry(geometry);
// Create a line material with black color for the border.
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2 });
// Create the line segments object to display the border.
const line = new THREE.LineSegments(edges, lineMaterial);
// Add the border as a child of the mesh.
mesh.add(line);
group.add(mesh);
}
}
// Load all the labels for each map position
const fontLoader = new FontLoader();
fontLoader.load('assets/fonts/helvetiker_regular.typeface.json', function (font) {
for (const [key, value] of Object.entries(gameState.boardState.provinces)) {
textGroup.add(createLabel(font, key, value))
}
})
// This rotates the SVG the "correct" way round, and scales it down
group.scale.set(1, -1, 1)
textGroup.rotation.x = Math.PI / 2;
textGroup.scale.set(1, -1, 1)
// After adding all meshes to the group, update its matrix:
group.updateMatrixWorld(true);
textGroup.updateMatrixWorld(true);
// Compute the bounding box of the group:
const box = new THREE.Box3().setFromObject(group);
const center = new THREE.Vector3();
box.getCenter(center);
gameState.camera.position.set(center.x, center.y + 1100, 1600)
gameState.camControls.target = center
scene.add(group);
scene.add(textGroup);
resolve()
})
.catch(error => {
console.error('Error loading map styles:', error);
});
},
// Progress function
undefined,
function (error) { console.log(error) })
})
}

View file

@ -0,0 +1,122 @@
import { getPowerHexColor } from "../units/create";
import { gameState } from "../gameState";
import { leaderboard } from "../domElements";
import type { GamePhase } from "../types/gameState";
import { ProvTypeENUM } from "../types/map";
export function updateSupplyCenterOwnership(centers) {
if (!centers) return;
const ownershipMap = {};
// centers is typically { "AUSTRIA":["VIE","BUD"], "FRANCE":["PAR","MAR"], ... }
for (const [power, provinces] of Object.entries(centers)) {
provinces.forEach(p => {
// No messages, animate units immediately
ownershipMap[p.toUpperCase()] = power.toUpperCase();
});
}
gameState.unitMeshes.forEach(obj => {
if (obj.userData && obj.userData.isSupplyCenter) {
const prov = obj.userData.province;
const owner = ownershipMap[prov];
if (owner) {
const c = getPowerHexColor(owner);
obj.userData.starMesh.material.color.setHex(c);
// Add a pulsing animation
if (!obj.userData.pulseAnimation) {
obj.userData.pulseAnimation = {
speed: 0.003 + Math.random() * 0.002,
intensity: 0.3,
time: Math.random() * Math.PI * 2
};
if (!gameState.scene.userData.animatedObjects) gameState.scene.userData.animatedObjects = [];
gameState.scene.userData.animatedObjects.push(obj);
}
} else {
// Neutral
obj.userData.starMesh.material.color.setHex(0xFFD700);
// remove pulse
obj.userData.pulseAnimation = null;
}
}
});
}
export function updateLeaderboard(phase) {
// Get supply center counts
const centerCounts = {};
const unitCounts = {};
// Count supply centers by power
if (phase.state?.centers) {
for (const [power, provinces] of Object.entries(phase.state.centers)) {
centerCounts[power] = provinces.length;
}
}
// Count units by power
if (phase.state?.units) {
for (const [power, units] of Object.entries(phase.state.units)) {
unitCounts[power] = units.length;
}
}
// Combine all powers from both centers and units
const allPowers = new Set([
...Object.keys(centerCounts),
...Object.keys(unitCounts)
]);
// Sort powers by supply center count (descending)
const sortedPowers = Array.from(allPowers).sort((a, b) => {
return (centerCounts[b] || 0) - (centerCounts[a] || 0);
});
// Build HTML for leaderboard
let html = `<strong>Council Standings</strong><br/>`;
sortedPowers.forEach(power => {
const centers = centerCounts[power] || 0;
const units = unitCounts[power] || 0;
// Use CSS classes instead of inline styles for better contrast
html += `<div style="margin: 5px 0; display: flex; justify-content: space-between;">
<span class="power-${power.toLowerCase()}">${power}</span>
<span>${centers} SCs, ${units} units</span>
</div>`;
});
// Add victory condition reminder
html += `<hr style="border-color: #555; margin: 8px 0;"/>
<small>Victory: 18 supply centers</small>`;
leaderboard.innerHTML = html;
}
export function updateMapOwnership(currentPhase: GamePhase) {
//FIXME: This only works in the forward direction, we currently don't update ownership correctly when going to previous phase
for (const [power, unitArr] of Object.entries(currentPhase.state.units)) {
unitArr.forEach(unitStr => {
const match = unitStr.match(/^([AF])\s+(.+)$/);
if (!match) return;
const location = match[2];
const normalized = location.toUpperCase().replace('/', '_');
const base = normalized.split('_')[0];
if (gameState.boardState.provinces[base] === undefined) {
console.log(base)
}
gameState.boardState.provinces[base].owner = power
})
}
for (const [key, value] of Object.entries(gameState.boardState.provinces)) {
// Update the color of the provinces if needed
if (gameState.boardState.provinces[key].owner && gameState.boardState?.provinces[key].type != ProvTypeENUM.WATER) {
let powerColor = getPowerHexColor(gameState.boardState.provinces[key].owner)
let powerColorHex = parseInt(powerColor.substring(1), 16);
gameState.boardState.provinces[key].mesh?.material.color.setHex(powerColorHex)
}
}
}

View file

@ -0,0 +1,35 @@
function hashStringToPosition(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
const x = (hash % 800) - 400;
const z = ((hash >> 8) % 800) - 400;
return { x, y: 0, z };
}
//TODO: Make coordinateData come from gameState
export function getProvincePosition(coordinateData, loc) {
// Convert e.g. "Spa/sc" to "SPA_SC" if needed
const normalized = loc.toUpperCase().replace('/', '_');
const base = normalized.split('_')[0];
if (coordinateData && coordinateData.provinces) {
if (coordinateData.provinces[normalized]) {
return {
"x": coordinateData.provinces[normalized].label.x,
"y": 10,
"z": coordinateData.provinces[normalized].label.y
};
}
if (coordinateData.provinces[base]) {
return coordinateData.provinces[base].label;
}
}
// Fallback with offset
const pos = hashStringToPosition(loc);
return { x: pos.x, y: pos.y, z: pos.z + 100 };
}

122
ai_animation/src/phase.ts Normal file
View file

@ -0,0 +1,122 @@
import { gameState } from "./gameState";
import { logger } from "./logger";
import { phaseDisplay } from "./domElements";
import { createSupplyCenters } from "./units/create";
import { createUnitMesh } from "./units/create";
import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership } from "./map/state";
import { updateChatWindows } from "./domElements/chatWindows";
import { createTweenAnimations } from "./units/animate";
// New function to display initial state without messages
export function displayInitialPhase() {
let index = 0
if (!gameState.gameData || !gameState.gameData.phases || index < 0 || index >= gameState.gameData.phases.length) {
logger.log("Invalid phase index.")
return;
}
// Clear any existing units
const supplyCenters = gameState.unitMeshes.filter(m => m.userData && m.userData.isSupplyCenter);
const oldUnits = gameState.unitMeshes.filter(m => m.userData && !m.userData.isSupplyCenter);
oldUnits.forEach(m => gameState.scene.remove(m));
gameState.unitMeshes = supplyCenters;
const phase = gameState.gameData.phases[index];
phaseDisplay.textContent = `Era: ${phase.name || 'Unknown Era'} (${index + 1}/${gameState.gameData.phases.length})`;
// Show supply centers
let newSCs = createSupplyCenters();
newSCs.forEach((sc) => gameState.scene.add(sc))
if (phase.state?.centers) {
updateSupplyCenterOwnership(phase.state.centers);
}
// Show units
if (phase.state?.units) {
for (const [power, unitArr] of Object.entries(phase.state.units)) {
unitArr.forEach(unitStr => {
const match = unitStr.match(/^([AF])\s+(.+)$/);
if (match) {
let newUnit = createUnitMesh({
power: power.toUpperCase(),
type: match[1],
province: match[2],
});
gameState.scene.add(newUnit)
gameState.unitMeshes.push(newUnit)
}
});
}
}
updateLeaderboard(phase);
updateMapOwnership(phase)
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
logger.updateInfoPanel();
}
export function displayPhaseWithAnimation(index) {
if (!gameState.gameData || !gameState.gameData.phases || index < 0 || index >= gameState.gameData.phases.length) {
logger.log("Invalid phase index.")
return;
}
const prevIndex = index > 0 ? index - 1 : gameState.gameData.phases.length - 1;
const currentPhase = gameState.gameData.phases[index];
const previousPhase = gameState.gameData.phases[prevIndex];
phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'} (${index + 1}/${gameState.gameData.phases.length})`;
// Rebuild supply centers, remove old units
// First show messages, THEN animate units after
// First show messages with stepwise animation
updateChatWindows(currentPhase, true);
// Ownership
if (currentPhase.state?.centers) {
updateSupplyCenterOwnership(currentPhase.state.centers);
}
// Update leaderboard
updateLeaderboard(currentPhase);
updateMapOwnership(currentPhase)
createTweenAnimations(currentPhase, previousPhase);
let msg = `Phase: ${currentPhase.name}\nSCs: ${JSON.stringify(currentPhase.state.centers)} \nUnits: ${currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None'} `
// Panel
// Add: Update info panel
logger.updateInfoPanel();
}
async function advanceToNextPhase() {
// Only show a summary if we have at least started the first phase
// and only if the just-ended phase has a "summary" property.
if (gameState.gameData && gameState.gameData.phases && gameState.phaseIndex >= 0) {
const justEndedPhase = gameState.gameData.phases[gameState.phaseIndex];
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);
}
}
// If we've reached the end, loop back to the beginning
if (gameState.phaseIndex >= gameState.gameData.phases.length - 1) {
gameState.phaseIndex = 0;
} else {
gameState.phaseIndex++;
}
// Display the new phase with animation
displayPhaseWithAnimation(gameState.phaseIndex);
}

View file

@ -0,0 +1,73 @@
// --- NEW: ElevenLabs TTS helper function ---
const ELEVENLABS_API_KEY = import.meta.env.VITE_ELEVENLABS_API_KEY || "";
const VOICE_ID = "onwK4e9ZLuTAKqWW03F9";
const MODEL_ID = "eleven_multilingual_v2";
/**
* Call ElevenLabs TTS to speak out loud. Returns a promise that
* resolves only after the audio finishes playing (or fails).
* Now accepts only the first 100 characters for brevity.
*/
async function speakSummary(summaryText) {
if (!ELEVENLABS_API_KEY) {
console.warn("No ElevenLabs API key found. Skipping TTS.");
return;
}
// Set the speaking flag to block other animations/transitions
isSpeaking = true;
try {
// Truncate text to first 100 characters for ElevenLabs
const truncatedText = summaryText.substring(0, 100);
if (truncatedText.length < summaryText.length) {
console.log(`TTS text truncated from ${summaryText.length} to 100 characters`);
}
// Hit ElevenLabs TTS endpoint with the truncated text
const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}`, {
method: "POST",
headers: {
"xi-api-key": ELEVENLABS_API_KEY,
"Content-Type": "application/json",
"Accept": "audio/mpeg"
},
body: JSON.stringify({
text: truncatedText,
model_id: MODEL_ID,
// Optional fine-tuning parameters
// voice_settings: { stability: 0.3, similarity_boost: 0.8 },
})
});
if (!response.ok) {
throw new Error(`ElevenLabs TTS error: ${response.statusText}`);
}
// 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);
audio.play().then(() => {
audio.onended = () => {
// Clear the speaking flag when audio finishes
isSpeaking = false;
resolve();
};
}).catch(err => {
console.error("Audio playback error", err);
// Make sure to clear the flag even if there's an error
isSpeaking = false;
reject(err);
});
});
} catch (err) {
console.error("Failed to generate TTS from ElevenLabs:", err);
// Make sure to clear the flag if there's any exception
isSpeaking = false;
}
}

View file

@ -0,0 +1,26 @@
import { z } from 'zod';
import { PowerENUMSchema } from './map';
import { OrderFromString } from './unitOrders';
import { ProvinceENUMSchema } from './map';
const PhaseSchema = z.object({
messages: z.array(z.any()),
name: z.string(),
orders: z.record(PowerENUMSchema, z.array(OrderFromString).nullable()),
results: z.record(z.string(), z.array(z.any())),
state: z.object({
units: z.record(PowerENUMSchema, z.array(z.string())),
centers: z.record(PowerENUMSchema, z.array(ProvinceENUMSchema))
}),
year: z.number().optional(),
});
export const GameSchema = z.object({
map: z.string(),
id: z.string(),
phases: z.array(PhaseSchema),
});
export type GamePhase = z.infer<typeof PhaseSchema>;
export type GameSchemaType = z.infer<typeof GameSchema>;

View file

@ -1,22 +1,134 @@
import { z } from "zod";
enum ProvTypeEnum {
export enum ProvTypeENUM {
WATER = "Water",
COAST = "Coast",
LAND = "Land",
}
type Province = {
label: {
x: number
y: number
}
type: ProvTypeEnum
unit?: {
x: number
y: number
}
INPASSABLE = "Inpassable",
}
type CoordinateData = {
provinces: Province[]
export enum PowerENUM {
ENGLAND = "England",
FRANCE = "France",
TURKEY = "Turkey",
AUSTRIA = "Austria",
GERMANY = "Germany",
ITALY = "Italy",
RUSSIA = "Russia",
}
export const ProvTypeSchema = z.nativeEnum(ProvTypeENUM);
export const PowerENUMSchema = z.preprocess((arg) => {
if (typeof arg === "string") {
// Normalize the string: "austria" or "AUSTRIA" becomes "Austria"
return arg.charAt(0).toUpperCase() + arg.slice(1).toLowerCase();
}
return arg;
}, z.nativeEnum(PowerENUM));
export const LabelSchema = z.object({
x: z.number(),
y: z.number(),
});
export const UnitSchema = z.object({
x: z.number(),
y: z.number(),
});
export const ProvinceSchema = z.object({
label: LabelSchema,
type: ProvTypeSchema,
unit: UnitSchema.optional(),
owner: PowerENUMSchema.optional(),
isSupplyCenter: z.boolean().optional()
});
export const CoordinateDataSchema = z.object({
provinces: z.record(z.string(), ProvinceSchema),
});
export type Province = z.infer<typeof ProvinceSchema>;
export type CoordinateData = z.infer<typeof CoordinateDataSchema>;
enum ProvinceENUM {
ANK = "ANK",
ARM = "ARM",
CON = "CON",
MOS = "MOS",
SEV = "SEV",
STP = "STP",
SYR = "SYR",
UKR = "UKR",
LVN = "LVN",
WAR = "WAR",
PRU = "PRU",
SIL = "SIL",
BER = "BER",
KIE = "KIE",
RUH = "RUH",
MUN = "MUN",
RUM = "RUM",
BUL = "BUL",
GRE = "GRE",
SMY = "SMY",
ALB = "ALB",
SER = "SER",
BUD = "BUD",
GAL = "GAL",
VIE = "VIE",
BOH = "BOH",
TYR = "TYR",
TRI = "TRI",
FIN = "FIN",
SWE = "SWE",
NWY = "NWY",
DEN = "DEN",
HOL = "HOL",
BEL = "BEL",
SWI = "SWI",
VEN = "VEN",
PIE = "PIE",
TUS = "TUS",
ROM = "ROM",
APU = "APU",
NAP = "NAP",
BUR = "BUR",
MAR = "MAR",
GAS = "GAS",
PIC = "PIC",
PAR = "PAR",
BRE = "BRE",
SPA = "SPA",
POR = "POR",
NAF = "NAF",
TUN = "TUN",
LON = "LON",
WAL = "WAL",
LVP = "LVP",
YOR = "YOR",
EDI = "EDI",
CLY = "CLY",
NAT = "NAT",
NRG = "NRG",
BAR = "BAR",
BOT = "BOT",
BAL = "BAL",
SKA = "SKA",
HEL = "HEL",
NTH = "NTH",
ENG = "ENG",
IRI = "IRI",
MID = "MID",
WES = "WES",
GOL = "GOL",
TYN = "TYN",
ADR = "ADR",
ION = "ION",
AEG = "AEG",
EAS = "EAS",
BLA = "BLA",
}
export const ProvinceENUMSchema = z.nativeEnum(ProvinceENUM)

View file

@ -0,0 +1,82 @@
import { z } from "zod";
export const OrderFromString = z.string().transform((orderStr) => {
// Split the order into tokens by whitespace.
const tokens = orderStr.trim().split(/\s+/);
// The first token is the unit type (A or F)
const unitType = tokens[0];
// The second token is the origin province.
const origin = tokens[1];
// Check if this order is a support order.
if (tokens.includes("S")) {
const indexS = tokens.indexOf("S");
// The tokens immediately after "S" define the supported unit.
const supportedUnitType = tokens[indexS + 1];
const supportedOrigin = tokens[indexS + 2];
let supportedDestination = null;
// If there is a hyphen following, then a destination is specified.
if (tokens.length > indexS + 3 && tokens[indexS + 3] === "-") {
supportedDestination = tokens[indexS + 4];
}
return {
type: "support",
unit: { type: unitType, origin },
support: {
unit: { type: supportedUnitType, origin: supportedOrigin },
// If no destination is given, this means the supported unit is holding.
destination: supportedDestination,
},
raw: orderStr,
};
}
// Check if the order is a hold order.
else if (tokens.includes("H")) {
return {
type: "hold",
unit: { type: unitType, origin },
raw: orderStr,
};
}
// Check if order is a disband
else if (tokens.includes("D")) {
return {
type: "disband",
unit: { type: unitType, origin },
raw: orderStr
}
}
// Check if order is Bounce
else if (tokens.includes("B")) {
return {
type: "bounce",
unit: { type: unitType, origin },
raw: orderStr
}
}
else if (tokens.includes("R")) {
return {
type: "retreat",
unit: { type: unitType, origin },
destination: tokens[-1],
raw: orderStr
}
}
// Otherwise, assume it's a move order if a hyphen ("-") is present.
else if (tokens.includes("-")) {
const dashIndex = tokens.indexOf("-");
// The token immediately after "-" is the destination.
const destination = tokens[dashIndex + 1];
return {
type: "move",
unit: { type: unitType, origin },
destination,
raw: orderStr,
};
}
// If none of the expected tokens are found, throw an error.
else {
throw new Error(`Order format not recognized: ${orderStr}`);
}
});
export type UnitOrder = z.infer<typeof OrderFromString>

View file

@ -0,0 +1,25 @@
import * as THREE from "three"
import { PowerENUM } from "./map"
export enum UnitTypeENUM {
A = "A",
F = "Fleet"
}
export type UnitData = {
province: string
power: PowerENUM
type: UnitTypeENUM
}
export type UnitMesh = {
mesh?: THREE.Group
userData: {
province: string
isSupplyCenter: boolean
power: PowerENUM
starMesh?: THREE.Mesh
glowMesh: THREE.Mesh
}
}

View file

@ -0,0 +1,210 @@
import * as THREE from "three";
import type { GamePhase } from "../types/gameState";
import { createUnitMesh } from "./create";
import { UnitMesh } from "../types/units";
import { getProvincePosition } from "../map/utils";
import * as TWEEN from "@tweenjs/tween.js";
import { gameState } from "../gameState";
import type { UnitOrder } from "../types/unitOrders";
//FIXME: Move this to a file with all the constants
let animationDuration = 1500; // Duration of unit movement animation in ms
enum AnimationTypeENUM {
CREATE,
MOVE,
DELETE,
}
export type UnitAnimation = {
duration: number
endPos: any
startPos: any
object: THREE.Group
startTime: number
animationType?: AnimationTypeENUM
}
function getUnit(unitOrder: UnitOrder) {
let posUnits = gameState.unitMeshes.filter((unit) => {
return (unit.userData.province === unitOrder.unit.origin && unit.userData.type === unitOrder.unit.type)
})
// TODO: Need to do something here if we get multiple results
return gameState.unitMeshes.indexOf(posUnits[0])
}
export function createTweenAnimations(currentPhase: GamePhase, previousPhase: GamePhase | null) {
for (const [power, orders] of Object.entries(previousPhase.orders)) {
for (const order of orders) {
let unitIndex = getUnit(order);
if (unitIndex === -1) continue; // Skip if unit not found
switch (order.type) {
case "move":
let destinationVector = getProvincePosition(gameState.boardState, order.destination);
if (!destinationVector) continue; // Skip if destination not found
// Create a tween for smooth movement
let anim = new TWEEN.Tween(gameState.unitMeshes[unitIndex].position)
.to({
x: destinationVector.x,
y: 10, // Keep consistent height
z: destinationVector.z
}, 1500)
.easing(TWEEN.Easing.Quadratic.InOut) // Add easing for smoother motion
.onUpdate(() => {
// Add a slight bobbing effect during movement
gameState.unitMeshes[unitIndex].position.y = 10 + Math.sin(Date.now() * 0.05) * 2;
// For fleets, add a gentle rocking motion
if (gameState.unitMeshes[unitIndex].userData.type === 'F') {
gameState.unitMeshes[unitIndex].rotation.z = Math.sin(Date.now() * 0.03) * 0.1;
gameState.unitMeshes[unitIndex].rotation.x = Math.sin(Date.now() * 0.02) * 0.1;
}
})
.onComplete(() => {
// Update the unit's province data when animation completes
// Reset height and rotation
gameState.unitMeshes[unitIndex].position.y = 10;
if (gameState.unitMeshes[unitIndex].userData.type === 'F') {
gameState.unitMeshes[unitIndex].rotation.z = 0;
gameState.unitMeshes[unitIndex].rotation.x = 0;
}
})
.start();
gameState.unitMeshes[unitIndex].userData.province = order.destination;
gameState.unitAnimations.push(anim);
break;
case "disband":
gameState.scene.remove(gameState.unitMeshes[unitIndex]);
// Remove from unitMeshes array
gameState.unitMeshes.splice(unitIndex, 1);
break;
case "bounce":
break;
}
}
}
}
export function createAnimationsForPhaseTransition(unitMeshes: UnitMesh[], currentPhase: GamePhase, previousPhase: GamePhase | null): UnitAnimation[] {
let unitAnimations: UnitAnimation[] = []
// Prepare unit position maps
const previousUnitPositions = {};
if (previousPhase.state?.units) {
for (const [power, unitArr] of Object.entries(previousPhase.state.units)) {
unitArr.forEach(unitStr => {
const match = unitStr.match(/^([AF])\s+(.+)$/);
if (match) {
const key = `${power} -${match[1]} -${match[2]} `;
previousUnitPositions[key] = getProvincePosition(gameState.boardState, match[2]);
}
});
}
}
// Animate new units from old positions (or spawn from below)
if (currentPhase.state?.units) {
for (const [power, unitArr] of Object.entries(currentPhase.state.units)) {
unitArr.forEach(unitStr => {
// For each unit, create a new mesh
const match = unitStr.match(/^([AF])\s+(.+)$/);
if (!match) return;
const unitType = match[1];
const location = match[2];
// Current final
const currentPos = getProvincePosition(gameState.boardState, location);
let startPos;
let matchFound = false;
for (const prevKey in previousUnitPositions) {
if (prevKey.startsWith(`${power} -${unitType} `)) {
startPos = previousUnitPositions[prevKey];
matchFound = true;
delete previousUnitPositions[prevKey];
break;
}
}
if (!matchFound) {
// TODO: Add a spawn animation?
//
// New spawn
startPos = { x: currentPos.x, y: -20, z: currentPos.z };
}
const unitMesh = createUnitMesh({
power: power,
province: location,
type: unitType,
});
unitMesh.position.set(startPos.x, 10, startPos.z);
// Animate
unitAnimations.push({
object: unitMesh,
startPos,
endPos: currentPos,
startTime: Date.now(),
duration: animationDuration
});
});
}
}
return unitAnimations
}
// Easing function for smooth animations
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
export function proccessUnitAnimationWithTween(anim: Tween) {
anim.update()
}
export function processUnitAnimation(anim: UnitAnimation): boolean {
const currentTime = Date.now();
const elapsed = currentTime - anim.startTime;
// Calculate progress (0 to 1)
const progress = Math.min(1, elapsed / anim.duration);
// Apply movement
if (progress < 1) {
// Apply easing for more natural movement - ease in and out
const easedProgress = easeInOutCubic(progress);
// Update position
anim.object.position.x = anim.startPos.x + (anim.endPos.x - anim.startPos.x) * easedProgress;
anim.object.position.z = anim.startPos.z + (anim.endPos.z - anim.startPos.z) * easedProgress;
// Subtle bobbing up and down during movement
anim.object.position.y = 10 + Math.sin(progress * Math.PI * 2) * 5;
// For fleets (ships), add a gentle rocking motion
if (anim.object.userData.type === 'F') {
anim.object.rotation.z = Math.sin(progress * Math.PI * 3) * 0.05;
anim.object.rotation.x = Math.sin(progress * Math.PI * 2) * 0.05;
}
if (anim.object.position.x == anim.startPos.x) {
console.log("We ain't moving")
}
anim.object.updateMatrix()
return false
} else {
// Set final position
anim.object.position.x = anim.endPos.x;
anim.object.position.z = anim.endPos.z;
anim.object.position.y = 10; // Reset height
// Reset rotation for ships
if (anim.object.userData.type === 'F') {
anim.object.rotation.z = 0;
anim.object.rotation.x = 0;
}
return true
}
}

View file

@ -0,0 +1,147 @@
import * as THREE from "three";
import { UnitData, UnitMesh } from "../types/units";
import { PowerENUM } from "../types/map";
import { gameState } from "../gameState";
import { getProvincePosition } from "../map/utils";
// Get color for a power
export function getPowerHexColor(power: PowerENUM) {
let defaultColor = '#ddd2af'
if (power === undefined) return defaultColor
const powerColors = {
'AUSTRIA': '#c40000',
'ENGLAND': '#00008B',
'FRANCE': '#0fa0d0',
'GERMANY': '#666666',
'ITALY': '#008000',
'RUSSIA': '#cccccc',
'TURKEY': '#e0c846',
};
return powerColors[power.toUpperCase()] || defaultColor; // fallback to neutral
}
function createArmy(color: string): THREE.Group {
let group = new THREE.Group();
// Army: a block + small head for soldier-like appearance
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 20, 10),
new THREE.MeshStandardMaterial({ color })
);
body.position.y = 10;
group.add(body);
// Head
const head = new THREE.Mesh(
new THREE.SphereGeometry(4, 12, 12),
new THREE.MeshStandardMaterial({ color })
);
head.position.set(0, 24, 0);
group.add(head);
return group
}
function createFleet(color: string): THREE.Group {
let group = new THREE.Group();
// Fleet: a rectangle + a mast and sail
const hull = new THREE.Mesh(
new THREE.BoxGeometry(30, 8, 15),
new THREE.MeshStandardMaterial({ color: 0x8B4513 })
);
hull.position.y = 4;
group.add(hull);
// Mast
const mast = new THREE.Mesh(
new THREE.CylinderGeometry(1, 1, 30, 8),
new THREE.MeshStandardMaterial({ color: 0x000000 })
);
mast.position.y = 15;
group.add(mast);
// Sail
const sail = new THREE.Mesh(
new THREE.PlaneGeometry(20, 15),
new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide })
);
sail.rotation.y = Math.PI / 2;
sail.position.set(0, 15, 0);
group.add(sail);
return group
}
export function createSupplyCenters(): THREE.Group[] {
let supplyCenterMeshes: THREE.Group[] = [];
if (!gameState.boardState || !gameState.boardState.provinces) throw new Error("Game not initialized, cannot create SCs");
for (const [province, data] of Object.entries(gameState.boardState.provinces)) {
if (data.isSupplyCenter && gameState.boardState.provinces[province]) {
// Build a small pillar + star in 3D
const scGroup = new THREE.Group();
const baseGeom = new THREE.CylinderGeometry(12, 12, 3, 16);
const baseMat = new THREE.MeshStandardMaterial({ color: 0x333333 });
const base = new THREE.Mesh(baseGeom, baseMat);
base.position.y = 1.5;
scGroup.add(base);
const pillarGeom = new THREE.CylinderGeometry(2.5, 2.5, 12, 8);
const pillarMat = new THREE.MeshStandardMaterial({ color: 0xcccccc });
const pillar = new THREE.Mesh(pillarGeom, pillarMat);
pillar.position.y = 7.5;
scGroup.add(pillar);
// We'll just do a cone star for simplicity
const starGeom = new THREE.ConeGeometry(6, 10, 5);
const starMat = new THREE.MeshStandardMaterial({ color: 0xFFD700 });
const starMesh = new THREE.Mesh(starGeom, starMat);
starMesh.rotation.x = Math.PI; // point upwards
starMesh.position.y = 14;
scGroup.add(starMesh);
// Optionally add a glow disc
const glowGeom = new THREE.CircleGeometry(15, 32);
const glowMat = new THREE.MeshBasicMaterial({ color: 0xFFFFAA, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
const glowMesh = new THREE.Mesh(glowGeom, glowMat);
glowMesh.rotation.x = -Math.PI / 2;
glowMesh.position.y = 2;
scGroup.add(glowMesh);
// Store userData for ownership changes
scGroup.userData = {
province,
isSupplyCenter: true,
owner: null,
starMesh,
glowMesh
};
const pos = getProvincePosition(gameState.boardState, province);
scGroup.position.set(pos.x, 2, pos.z);
supplyCenterMeshes.push(scGroup)
}
}
return supplyCenterMeshes
}
export function createUnitMesh(unitData: UnitData): THREE.Group {
const color = getPowerHexColor(unitData.power);
let group: THREE.Group | null;
// Minimal shape difference for armies vs fleets
if (unitData.type === 'A') {
group = createArmy(color)
} else {
group = createFleet(color)
}
let pos = getProvincePosition(gameState.boardState, unitData.province)
group.position.set(pos.x, pos.y, pos.z)
// Store metadata
group.userData = {
power: unitData.power,
type: unitData.type,
province: unitData.province
};
return group;
}