mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-05-01 17:45:26 +00:00
WIP: Moving most globals into gamestate
The gamestate object is now where most of the "state" of both the map and the board live. The units load, just working on loading the colors of the map ownership again.
This commit is contained in:
parent
dff01db83f
commit
ae2db39729
13 changed files with 1363 additions and 1322 deletions
45
ai_animation/src/domElements.ts
Normal file
45
ai_animation/src/domElements.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
615
ai_animation/src/domElements/chatWindows.ts
Normal file
615
ai_animation/src/domElements/chatWindows.ts
Normal file
|
|
@ -0,0 +1,615 @@
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { currentPower } from "../gameState";
|
||||||
|
|
||||||
|
let faceIconCache = {}; // Cache for generated face icons
|
||||||
|
|
||||||
|
// Add a message counter to track sound effect frequency
|
||||||
|
let messageCounter = 0;
|
||||||
|
let chatWindows = {}; // Store chat window elements by power
|
||||||
|
// --- CHAT WINDOW FUNCTIONS ---
|
||||||
|
export function createChatWindows() {
|
||||||
|
// Clear existing chat windows
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
chatContainer.innerHTML = '';
|
||||||
|
chatWindows = {};
|
||||||
|
|
||||||
|
// Create a chat window for each power (except the current power)
|
||||||
|
const powers = ['AUSTRIA', 'ENGLAND', 'FRANCE', 'GERMANY', 'ITALY', 'RUSSIA', 'TURKEY'];
|
||||||
|
|
||||||
|
// Filter out the current power for chat windows
|
||||||
|
const otherPowers = powers.filter(power => power !== currentPower);
|
||||||
|
|
||||||
|
// Add a GLOBAL chat window first
|
||||||
|
createChatWindow('GLOBAL', true);
|
||||||
|
|
||||||
|
// Create chat windows for each power except the current one
|
||||||
|
otherPowers.forEach(power => {
|
||||||
|
createChatWindow(power);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Modified to use 3D face icons properly
|
||||||
|
function createChatWindow(power, isGlobal = false) {
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
const chatWindow = document.createElement('div');
|
||||||
|
chatWindow.className = 'chat-window';
|
||||||
|
chatWindow.id = `chat - ${power} `;
|
||||||
|
chatWindow.style.position = 'relative'; // Add relative positioning for absolute child positioning
|
||||||
|
|
||||||
|
// Create a slimmer header with appropriate styling
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'chat-header';
|
||||||
|
|
||||||
|
// Adjust header to accommodate larger face icons
|
||||||
|
header.style.display = 'flex';
|
||||||
|
header.style.alignItems = 'center';
|
||||||
|
header.style.padding = '4px 8px'; // Reduced vertical padding
|
||||||
|
header.style.height = '24px'; // Explicit smaller height
|
||||||
|
header.style.backgroundColor = 'rgba(78, 62, 41, 0.7)'; // Semi-transparent background
|
||||||
|
header.style.borderBottom = '1px solid rgba(78, 62, 41, 1)'; // Solid bottom border
|
||||||
|
|
||||||
|
// Create the title element
|
||||||
|
const titleElement = document.createElement('span');
|
||||||
|
if (isGlobal) {
|
||||||
|
titleElement.style.color = '#ffffff';
|
||||||
|
titleElement.textContent = 'GLOBAL';
|
||||||
|
} else {
|
||||||
|
titleElement.className = `power - ${power.toLowerCase()} `;
|
||||||
|
titleElement.textContent = power;
|
||||||
|
}
|
||||||
|
titleElement.style.fontWeight = 'bold'; // Make text more prominent
|
||||||
|
titleElement.style.textShadow = '1px 1px 2px rgba(0,0,0,0.7)'; // Add text shadow for better readability
|
||||||
|
header.appendChild(titleElement);
|
||||||
|
|
||||||
|
// Create container for 3D face icon that floats over the header
|
||||||
|
const faceHolder = document.createElement('div');
|
||||||
|
faceHolder.style.width = '64px';
|
||||||
|
faceHolder.style.height = '64px';
|
||||||
|
faceHolder.style.position = 'absolute'; // Position absolutely
|
||||||
|
faceHolder.style.right = '10px'; // From right edge
|
||||||
|
faceHolder.style.top = '0px'; // ADJUSTED: Moved lower to align with the header
|
||||||
|
faceHolder.style.cursor = 'pointer';
|
||||||
|
faceHolder.style.borderRadius = '50%';
|
||||||
|
faceHolder.style.overflow = 'hidden';
|
||||||
|
faceHolder.style.boxShadow = '0 2px 5px rgba(0,0,0,0.5)';
|
||||||
|
faceHolder.style.border = '2px solid #fff';
|
||||||
|
faceHolder.style.zIndex = '10'; // Ensure it's above other elements
|
||||||
|
faceHolder.id = `face - ${power} `;
|
||||||
|
|
||||||
|
// Generate the face icon and add it to the chat window (not header)
|
||||||
|
generateFaceIcon(power).then(dataURL => {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = dataURL;
|
||||||
|
img.style.width = '100%';
|
||||||
|
img.style.height = '100%';
|
||||||
|
img.id = `face - img - ${power} `; // Add ID for animation targeting
|
||||||
|
|
||||||
|
img.id = `face - img - ${power} `; // Add ID for animation targeting
|
||||||
|
|
||||||
|
// Add subtle idle animation
|
||||||
|
setInterval(() => {
|
||||||
|
if (!img.dataset.animating && Math.random() < 0.1) {
|
||||||
|
idleAnimation(img);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
faceHolder.appendChild(img);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create messages container with extra top padding to avoid overlap with floating head
|
||||||
|
|
||||||
|
header.appendChild(faceHolder);
|
||||||
|
|
||||||
|
// Create messages container
|
||||||
|
const messagesContainer = document.createElement('div');
|
||||||
|
messagesContainer.className = 'chat-messages';
|
||||||
|
messagesContainer.id = `messages - ${power} `;
|
||||||
|
messagesContainer.style.paddingTop = '8px'; // Add padding to prevent content being hidden under face
|
||||||
|
|
||||||
|
// Add toggle functionality
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
chatWindow.classList.toggle('chat-collapsed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assemble chat window - add faceHolder directly to chatWindow, not header
|
||||||
|
chatWindow.appendChild(header);
|
||||||
|
chatWindow.appendChild(faceHolder);
|
||||||
|
chatWindow.appendChild(messagesContainer);
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
chatContainer.appendChild(chatWindow);
|
||||||
|
|
||||||
|
// Store reference
|
||||||
|
chatWindows[power] = {
|
||||||
|
element: chatWindow,
|
||||||
|
messagesContainer: messagesContainer,
|
||||||
|
isGlobal: isGlobal,
|
||||||
|
seenMessages: new Set()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified to accumulate messages instead of resetting and only animate for new messages
|
||||||
|
function updateChatWindows(phase, stepMessages = false) {
|
||||||
|
if (!phase.messages || !phase.messages.length) {
|
||||||
|
messagesPlaying = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevantMessages = phase.messages.filter(msg => {
|
||||||
|
return (
|
||||||
|
msg.sender === currentPower ||
|
||||||
|
msg.recipient === currentPower ||
|
||||||
|
msg.recipient === 'GLOBAL'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
relevantMessages.sort((a, b) => a.time_sent - b.time_sent);
|
||||||
|
|
||||||
|
if (!stepMessages) {
|
||||||
|
// Normal: show all at once
|
||||||
|
relevantMessages.forEach(msg => {
|
||||||
|
const isNew = addMessageToChat(msg, phase.name);
|
||||||
|
if (isNew) {
|
||||||
|
// Increment message counter and play sound on every third message
|
||||||
|
messageCounter++;
|
||||||
|
animateHeadNod(msg, (messageCounter % 3 === 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
messagesPlaying = false;
|
||||||
|
} else {
|
||||||
|
// Stepwise
|
||||||
|
messagesPlaying = true;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
// Define the showNext function that will be called after each message animation completes
|
||||||
|
const showNext = () => {
|
||||||
|
if (index >= relevantMessages.length) {
|
||||||
|
messagesPlaying = false;
|
||||||
|
if (unitAnimations.length === 0 && isPlaying && !isSpeaking) {
|
||||||
|
// Call the async function without awaiting it here
|
||||||
|
playbackTimer = setTimeout(() => advanceToNextPhase(), playbackSpeed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = relevantMessages[index];
|
||||||
|
index++; // Increment index before adding message so word animation knows the correct next message
|
||||||
|
|
||||||
|
const isNew = addMessageToChat(msg, phase.name, true, showNext); // Pass showNext as callback
|
||||||
|
|
||||||
|
if (isNew && !isDebugMode) {
|
||||||
|
// Increment message counter
|
||||||
|
messageCounter++;
|
||||||
|
|
||||||
|
// Only animate head and play sound for every third message
|
||||||
|
animateHeadNod(msg, (messageCounter % 3 === 0));
|
||||||
|
} else if (isDebugMode) {
|
||||||
|
// In debug mode, immediately call showNext to skip waiting for animation
|
||||||
|
showNext();
|
||||||
|
} else {
|
||||||
|
setTimeout(showNext, playbackSpeed * 3);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the message sequence
|
||||||
|
showNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified to support word-by-word animation and callback
|
||||||
|
function addMessageToChat(msg, phaseName, animateWords = false, onComplete = null) {
|
||||||
|
// Determine which chat window to use
|
||||||
|
let targetPower;
|
||||||
|
if (msg.recipient === 'GLOBAL') {
|
||||||
|
targetPower = 'GLOBAL';
|
||||||
|
} else {
|
||||||
|
targetPower = msg.sender === currentPower ? msg.recipient : msg.sender;
|
||||||
|
}
|
||||||
|
if (!chatWindows[targetPower]) return false;
|
||||||
|
|
||||||
|
// Create a unique ID for this message to avoid duplication
|
||||||
|
const msgId = `${msg.sender} -${msg.recipient} -${msg.time_sent} -${msg.message} `;
|
||||||
|
|
||||||
|
// Skip if we've already shown this message
|
||||||
|
if (chatWindows[targetPower].seenMessages.has(msgId)) {
|
||||||
|
return false; // Not a new message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as seen
|
||||||
|
chatWindows[targetPower].seenMessages.add(msgId);
|
||||||
|
|
||||||
|
const messagesContainer = chatWindows[targetPower].messagesContainer;
|
||||||
|
const messageElement = document.createElement('div');
|
||||||
|
|
||||||
|
// Style based on sender/recipient
|
||||||
|
if (targetPower === 'GLOBAL') {
|
||||||
|
// Global chat shows sender info
|
||||||
|
const senderColor = msg.sender.toLowerCase();
|
||||||
|
messageElement.className = 'chat-message message-incoming';
|
||||||
|
|
||||||
|
// Add the header with the sender name immediately
|
||||||
|
const headerSpan = document.createElement('span');
|
||||||
|
headerSpan.style.fontWeight = 'bold';
|
||||||
|
headerSpan.className = `power-${senderColor}`;
|
||||||
|
headerSpan.textContent = `${msg.sender}: `;
|
||||||
|
messageElement.appendChild(headerSpan);
|
||||||
|
|
||||||
|
// Create a span for the message content that will be filled word by word
|
||||||
|
const contentSpan = document.createElement('span');
|
||||||
|
contentSpan.id = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
messageElement.appendChild(contentSpan);
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
const timeDiv = document.createElement('div');
|
||||||
|
timeDiv.className = 'message-time';
|
||||||
|
timeDiv.textContent = phaseName;
|
||||||
|
messageElement.appendChild(timeDiv);
|
||||||
|
} else {
|
||||||
|
// Private chat - outgoing or incoming style
|
||||||
|
const isOutgoing = msg.sender === currentPower;
|
||||||
|
messageElement.className = `chat-message ${isOutgoing ? 'message-outgoing' : 'message-incoming'}`;
|
||||||
|
|
||||||
|
// Create content span
|
||||||
|
const contentSpan = document.createElement('span');
|
||||||
|
contentSpan.id = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
messageElement.appendChild(contentSpan);
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
const timeDiv = document.createElement('div');
|
||||||
|
timeDiv.className = 'message-time';
|
||||||
|
timeDiv.textContent = phaseName;
|
||||||
|
messageElement.appendChild(timeDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
messagesContainer.appendChild(messageElement);
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
|
||||||
|
if (animateWords) {
|
||||||
|
// Start word-by-word animation
|
||||||
|
const contentSpanId = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
animateMessageWords(msg.message, contentSpanId, targetPower, messagesContainer, onComplete);
|
||||||
|
} else {
|
||||||
|
// Show entire message at once
|
||||||
|
const contentSpan = messageElement.querySelector(`#msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`);
|
||||||
|
if (contentSpan) {
|
||||||
|
contentSpan.textContent = msg.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's a completion callback, call it immediately for non-animated messages
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // This was a new message
|
||||||
|
}
|
||||||
|
|
||||||
|
// New function to animate message words one at a time
|
||||||
|
function animateMessageWords(message, contentSpanId, targetPower, messagesContainer, onComplete) {
|
||||||
|
const words = message.split(/\s+/);
|
||||||
|
const contentSpan = document.getElementById(contentSpanId);
|
||||||
|
if (!contentSpan) {
|
||||||
|
// If span not found, still call onComplete to avoid breaking the game flow
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing content
|
||||||
|
contentSpan.textContent = '';
|
||||||
|
let wordIndex = 0;
|
||||||
|
|
||||||
|
// Function to add the next word
|
||||||
|
const addNextWord = () => {
|
||||||
|
if (wordIndex >= words.length) {
|
||||||
|
// All words added - keep messagesPlaying true until next message starts
|
||||||
|
|
||||||
|
// Add a slight delay after the last word for readability
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete(); // Call the completion callback
|
||||||
|
}
|
||||||
|
}, Math.min(playbackSpeed / 3, 150));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add space if not the first word
|
||||||
|
if (wordIndex > 0) {
|
||||||
|
contentSpan.textContent += ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the next word
|
||||||
|
contentSpan.textContent += words[wordIndex];
|
||||||
|
wordIndex++;
|
||||||
|
|
||||||
|
// Schedule the next word with a delay based on word length and playback speed
|
||||||
|
const delay = Math.max(30, Math.min(120, playbackSpeed / 10 * (words[wordIndex - 1].length / 4)));
|
||||||
|
setTimeout(addNextWord, delay);
|
||||||
|
|
||||||
|
// Scroll to ensure newest content is visible
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start animation
|
||||||
|
addNextWord();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified to support conditional sound effects
|
||||||
|
function animateHeadNod(msg, playSoundEffect = true) {
|
||||||
|
// Determine which chat window's head to animate
|
||||||
|
let targetPower;
|
||||||
|
if (msg.recipient === 'GLOBAL') {
|
||||||
|
targetPower = 'GLOBAL';
|
||||||
|
} else {
|
||||||
|
targetPower = msg.sender === currentPower ? msg.recipient : msg.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatWindow = chatWindows[targetPower]?.element;
|
||||||
|
if (!chatWindow) return;
|
||||||
|
|
||||||
|
// Find the face image and animate it
|
||||||
|
const img = chatWindow.querySelector(`#face - img - ${targetPower} `);
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
img.dataset.animating = 'true';
|
||||||
|
|
||||||
|
// Choose a random animation type for variety
|
||||||
|
const animationType = Math.floor(Math.random() * 4);
|
||||||
|
|
||||||
|
let animation;
|
||||||
|
|
||||||
|
switch (animationType) {
|
||||||
|
case 0: // Nod animation
|
||||||
|
animation = img.animate([
|
||||||
|
{ transform: 'rotate(0deg) scale(1)' },
|
||||||
|
{ transform: 'rotate(15deg) scale(1.1)' },
|
||||||
|
{ transform: 'rotate(-10deg) scale(1.05)' },
|
||||||
|
{ transform: 'rotate(5deg) scale(1.02)' },
|
||||||
|
{ transform: 'rotate(0deg) scale(1)' }
|
||||||
|
], {
|
||||||
|
duration: 600,
|
||||||
|
easing: 'ease-in-out'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1: // Bounce animation
|
||||||
|
animation = img.animate([
|
||||||
|
{ transform: 'translateY(0) scale(1)' },
|
||||||
|
{ transform: 'translateY(-8px) scale(1.15)' },
|
||||||
|
{ transform: 'translateY(3px) scale(0.95)' },
|
||||||
|
{ transform: 'translateY(-2px) scale(1.05)' },
|
||||||
|
{ transform: 'translateY(0) scale(1)' }
|
||||||
|
], {
|
||||||
|
duration: 700,
|
||||||
|
easing: 'ease-in-out'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // Shake animation
|
||||||
|
animation = img.animate([
|
||||||
|
{ transform: 'translate(0, 0) rotate(0deg)' },
|
||||||
|
{ transform: 'translate(-5px, -3px) rotate(-5deg)' },
|
||||||
|
{ transform: 'translate(5px, 2px) rotate(5deg)' },
|
||||||
|
{ transform: 'translate(-5px, 1px) rotate(-3deg)' },
|
||||||
|
{ transform: 'translate(0, 0) rotate(0deg)' }
|
||||||
|
], {
|
||||||
|
duration: 500,
|
||||||
|
easing: 'ease-in-out'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // Pulse animation
|
||||||
|
animation = img.animate([
|
||||||
|
{ transform: 'scale(1)', boxShadow: '0 0 0 0 rgba(255,255,255,0.7)' },
|
||||||
|
{ transform: 'scale(1.2)', boxShadow: '0 0 0 10px rgba(255,255,255,0)' },
|
||||||
|
{ transform: 'scale(1)', boxShadow: '0 0 0 0 rgba(255,255,255,0)' }
|
||||||
|
], {
|
||||||
|
duration: 800,
|
||||||
|
easing: 'ease-out'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
animation.onfinish = () => {
|
||||||
|
img.dataset.animating = 'false';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger random snippet only if playSoundEffect is true
|
||||||
|
if (playSoundEffect) {
|
||||||
|
playRandomSoundEffect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a 3D face icon for chat windows with higher contrast
|
||||||
|
async function generateFaceIcon(power) {
|
||||||
|
if (faceIconCache[power]) {
|
||||||
|
return faceIconCache[power];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even larger renderer size for better quality
|
||||||
|
const offWidth = 192, offHeight = 192; // Increased from 128x128 to 192x192
|
||||||
|
const offRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
offRenderer.setSize(offWidth, offHeight);
|
||||||
|
offRenderer.setPixelRatio(1);
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
const offScene = new THREE.Scene();
|
||||||
|
offScene.background = null;
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
const offCamera = new THREE.PerspectiveCamera(45, offWidth / offHeight, 0.1, 1000);
|
||||||
|
offCamera.position.set(0, 0, 50);
|
||||||
|
|
||||||
|
// Power-specific colors with higher contrast/saturation
|
||||||
|
const colorMap = {
|
||||||
|
'GLOBAL': 0xf5f5f5, // Brighter white
|
||||||
|
'AUSTRIA': 0xff0000, // Brighter red
|
||||||
|
'ENGLAND': 0x0000ff, // Brighter blue
|
||||||
|
'FRANCE': 0x00bfff, // Brighter cyan
|
||||||
|
'GERMANY': 0x1a1a1a, // Darker gray for better contrast
|
||||||
|
'ITALY': 0x00cc00, // Brighter green
|
||||||
|
'RUSSIA': 0xe0e0e0, // Brighter gray
|
||||||
|
'TURKEY': 0xffcc00 // Brighter yellow
|
||||||
|
};
|
||||||
|
const headColor = colorMap[power] || 0x808080;
|
||||||
|
|
||||||
|
// Larger head geometry
|
||||||
|
const headGeom = new THREE.BoxGeometry(20, 20, 20); // Increased from 16x16x16
|
||||||
|
const headMat = new THREE.MeshStandardMaterial({ color: headColor });
|
||||||
|
const headMesh = new THREE.Mesh(headGeom, headMat);
|
||||||
|
offScene.add(headMesh);
|
||||||
|
|
||||||
|
// Create outline for better visibility (a slightly larger black box behind)
|
||||||
|
const outlineGeom = new THREE.BoxGeometry(22, 22, 19);
|
||||||
|
const outlineMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||||
|
const outlineMesh = new THREE.Mesh(outlineGeom, outlineMat);
|
||||||
|
outlineMesh.position.z = -2; // Place it behind the head
|
||||||
|
offScene.add(outlineMesh);
|
||||||
|
|
||||||
|
// Larger eyes with better contrast
|
||||||
|
const eyeGeom = new THREE.BoxGeometry(3.5, 3.5, 3.5); // Increased from 2.5x2.5x2.5
|
||||||
|
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x000000 });
|
||||||
|
const leftEye = new THREE.Mesh(eyeGeom, eyeMat);
|
||||||
|
leftEye.position.set(-4.5, 2, 10); // Adjusted position
|
||||||
|
offScene.add(leftEye);
|
||||||
|
const rightEye = new THREE.Mesh(eyeGeom, eyeMat);
|
||||||
|
rightEye.position.set(4.5, 2, 10); // Adjusted position
|
||||||
|
offScene.add(rightEye);
|
||||||
|
|
||||||
|
// Add a simple mouth
|
||||||
|
const mouthGeom = new THREE.BoxGeometry(8, 1.5, 1);
|
||||||
|
const mouthMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||||
|
const mouth = new THREE.Mesh(mouthGeom, mouthMat);
|
||||||
|
mouth.position.set(0, -3, 10);
|
||||||
|
offScene.add(mouth);
|
||||||
|
|
||||||
|
// Brighter lighting for better contrast
|
||||||
|
const light = new THREE.DirectionalLight(0xffffff, 1.2); // Increased intensity
|
||||||
|
light.position.set(0, 20, 30);
|
||||||
|
offScene.add(light);
|
||||||
|
|
||||||
|
// Add more lights for better definition
|
||||||
|
const fillLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||||
|
fillLight.position.set(-20, 0, 20);
|
||||||
|
offScene.add(fillLight);
|
||||||
|
|
||||||
|
offScene.add(new THREE.AmbientLight(0xffffff, 0.4)); // Slightly brighter ambient
|
||||||
|
|
||||||
|
// Slight head rotation
|
||||||
|
headMesh.rotation.y = Math.PI / 6; // More pronounced angle
|
||||||
|
|
||||||
|
// Render to a texture
|
||||||
|
const renderTarget = new THREE.WebGLRenderTarget(offWidth, offHeight);
|
||||||
|
offRenderer.setRenderTarget(renderTarget);
|
||||||
|
offRenderer.render(offScene, offCamera);
|
||||||
|
|
||||||
|
// Get pixels
|
||||||
|
const pixels = new Uint8Array(offWidth * offHeight * 4);
|
||||||
|
offRenderer.readRenderTargetPixels(renderTarget, 0, 0, offWidth, offHeight, pixels);
|
||||||
|
|
||||||
|
// Convert to canvas
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = offWidth;
|
||||||
|
canvas.height = offHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const imageData = ctx.createImageData(offWidth, offHeight);
|
||||||
|
imageData.data.set(pixels);
|
||||||
|
|
||||||
|
// Flip image (WebGL coordinate system is inverted)
|
||||||
|
flipImageDataVertically(imageData, offWidth, offHeight);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
// Get data URL
|
||||||
|
const dataURL = canvas.toDataURL('image/png');
|
||||||
|
faceIconCache[power] = dataURL;
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
offRenderer.dispose();
|
||||||
|
renderTarget.dispose();
|
||||||
|
|
||||||
|
return dataURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a subtle idle animation for faces
|
||||||
|
function idleAnimation(img) {
|
||||||
|
if (img.dataset.animating === 'true') return;
|
||||||
|
|
||||||
|
img.dataset.animating = 'true';
|
||||||
|
|
||||||
|
const animation = img.animate([
|
||||||
|
{ transform: 'rotate(0deg) scale(1)' },
|
||||||
|
{ transform: 'rotate(-2deg) scale(0.98)' },
|
||||||
|
{ transform: 'rotate(0deg) scale(1)' }
|
||||||
|
], {
|
||||||
|
duration: 1500,
|
||||||
|
easing: 'ease-in-out'
|
||||||
|
});
|
||||||
|
|
||||||
|
animation.onfinish = () => {
|
||||||
|
img.dataset.animating = 'false';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to flip image data vertically
|
||||||
|
function flipImageDataVertically(imageData, width, height) {
|
||||||
|
const bytesPerRow = width * 4;
|
||||||
|
const temp = new Uint8ClampedArray(bytesPerRow);
|
||||||
|
for (let y = 0; y < height / 2; y++) {
|
||||||
|
const topOffset = y * bytesPerRow;
|
||||||
|
const bottomOffset = (height - y - 1) * bytesPerRow;
|
||||||
|
temp.set(imageData.data.slice(topOffset, topOffset + bytesPerRow));
|
||||||
|
imageData.data.set(imageData.data.slice(bottomOffset, bottomOffset + bytesPerRow), topOffset);
|
||||||
|
imageData.data.set(temp, bottomOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NEW: Function to play a random sound effect ---
|
||||||
|
function playRandomSoundEffect() {
|
||||||
|
// List all the sound snippet filenames in assets/sounds
|
||||||
|
const soundEffects = [
|
||||||
|
'snippet_2.mp3',
|
||||||
|
'snippet_3.mp3',
|
||||||
|
'snippet_4.mp3',
|
||||||
|
'snippet_9.mp3',
|
||||||
|
'snippet_10.mp3',
|
||||||
|
'snippet_11.mp3',
|
||||||
|
'snippet_12.mp3',
|
||||||
|
'snippet_13.mp3',
|
||||||
|
'snippet_14.mp3',
|
||||||
|
'snippet_15.mp3',
|
||||||
|
'snippet_16.mp3',
|
||||||
|
'snippet_17.mp3',
|
||||||
|
];
|
||||||
|
// Pick one at random
|
||||||
|
const chosen = soundEffects[Math.floor(Math.random() * soundEffects.length)];
|
||||||
|
|
||||||
|
// Create an <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,60 +1,129 @@
|
||||||
import { type Province, type CoordinateData, CoordinateDataSchema } from "./types/map"
|
import * as THREE from "three"
|
||||||
import Logger from "./logger";
|
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";
|
||||||
|
|
||||||
const logger = new Logger();
|
//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 {
|
enum AvailableMaps {
|
||||||
STANDARD = "standard"
|
STANDARD = "standard"
|
||||||
}
|
}
|
||||||
|
// Move these definitions BEFORE they're used
|
||||||
|
function getRandomPower(): PowerENUM {
|
||||||
|
const values = Object.values(PowerENUM)
|
||||||
|
|
||||||
export let coordinateData: CoordinateData
|
const idx = Math.floor(Math.random() * values.length);
|
||||||
// --- LOAD COORDINATE DATA ---
|
return values[idx];
|
||||||
export function loadCoordinateData() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
fetch('./assets/maps/standard/coords.json')
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
// Try an alternate path if desired
|
|
||||||
throw new Error("Something went wrong when fetching the coords.json")
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
coordinateData = CoordinateDataSchema.parse(data)
|
|
||||||
//coordinateData = data;
|
|
||||||
logger.log('Coordinate data loaded!')
|
|
||||||
resolve(coordinateData);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error(error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
export const currentPower = getRandomPower();
|
||||||
|
|
||||||
|
|
||||||
export default class {
|
class GameState {
|
||||||
boardState: CoordinateData | null
|
boardState: CoordinateData | null
|
||||||
|
gameData: GameSchemaType | null
|
||||||
|
phaseIndex: number
|
||||||
|
boardName: string
|
||||||
|
isSpeaking: boolean
|
||||||
|
messagesPlaying: boolean
|
||||||
|
isPlaying: boolean
|
||||||
|
|
||||||
|
//Scene for three.js
|
||||||
|
scene: THREE.Scene
|
||||||
|
|
||||||
|
// camera and controls
|
||||||
|
camControls: OrbitControls
|
||||||
|
camera: THREE.PerspectiveCamera
|
||||||
|
renderer: THREE.WebGLRenderer
|
||||||
|
|
||||||
|
//Unit Meshes
|
||||||
|
unitMeshes: THREE.Group[]
|
||||||
|
|
||||||
constructor(boardName: AvailableMaps) {
|
constructor(boardName: AvailableMaps) {
|
||||||
|
this.phaseIndex = 0
|
||||||
this.boardState = null
|
this.boardState = null
|
||||||
this._loadMapData(boardName)
|
this.gameData = null
|
||||||
|
this.boardName = boardName
|
||||||
|
this.isSpeaking = false
|
||||||
|
this.isPlaying = false
|
||||||
|
this.messagesPlaying = false
|
||||||
|
this.scene = new THREE.Scene()
|
||||||
|
this.unitMeshes = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
_loadMapData = (boardName: AvailableMaps) => {
|
// Initialize chat windows
|
||||||
|
createChatWindows();
|
||||||
fetch(`./assets/maps/${boardName}/coords.json`)
|
displayInitialPhase()
|
||||||
.then(response => {
|
resolve()
|
||||||
if (!response.ok) {
|
} else {
|
||||||
throw new Error(`Failed to load coordinates: ${response.status}`);
|
reject()
|
||||||
}
|
}
|
||||||
this.boardState = CoordinateDataSchema.parse(response.json())
|
})
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error(error);
|
|
||||||
throw error
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import { gameState, currentPower } from "./gameState";
|
||||||
|
import { currentPhaseIndex } from "./gameState";
|
||||||
|
|
||||||
export default class Logger {
|
class Logger {
|
||||||
get infoPanel() {
|
get infoPanel() {
|
||||||
let _panel = document.getElementById('info-panel');
|
let _panel = document.getElementById('info-panel');
|
||||||
if (_panel === null) {
|
if (_panel === null) {
|
||||||
|
|
@ -15,4 +17,27 @@ export default class Logger {
|
||||||
|
|
||||||
console.log(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
|
|
@ -2,104 +2,106 @@ import * as THREE from "three";
|
||||||
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
||||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js';
|
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js';
|
||||||
import { createLabel } from "./labels"
|
import { createLabel } from "./labels"
|
||||||
import { coordinateData } from "../gameState";
|
import { gameState } from "../gameState";
|
||||||
import { getPowerHexColor } from "../units/create";
|
import { getPowerHexColor } from "../units/create";
|
||||||
|
|
||||||
export function initMap(scene,) {
|
export function initMap(scene): Promise<void> {
|
||||||
const loader = new SVGLoader();
|
return new Promise((resolve, reject) => {
|
||||||
loader.load('assets/maps/standard/map.svg',
|
const loader = new SVGLoader();
|
||||||
function (data) {
|
loader.load('assets/maps/standard/map.svg',
|
||||||
fetch('assets/maps/standard/styles.json')
|
function (data) {
|
||||||
.then(resp => resp.json())
|
fetch('assets/maps/standard/styles.json')
|
||||||
.then(map_styles => {
|
.then(resp => resp.json())
|
||||||
const paths = data.paths;
|
.then(map_styles => {
|
||||||
const group = new THREE.Group();
|
const paths = data.paths;
|
||||||
const textGroup = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
let fillColor;
|
const textGroup = new THREE.Group();
|
||||||
|
let fillColor;
|
||||||
|
|
||||||
for (let i = 0; i < paths.length; i++) {
|
for (let i = 0; i < paths.length; i++) {
|
||||||
fillColor = "";
|
fillColor = "";
|
||||||
const path = paths[i];
|
const path = paths[i];
|
||||||
// The "standard" map has keys like _mos, so remove that then send them to caps
|
// 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 provinceKey = path.userData.node.id.substring(1).toUpperCase();
|
||||||
let nodeClass = path.userData.node.classList[0]
|
let nodeClass = path.userData.node.classList[0]
|
||||||
|
|
||||||
switch (nodeClass) {
|
switch (nodeClass) {
|
||||||
case undefined:
|
case undefined:
|
||||||
continue
|
continue
|
||||||
case "water":
|
case "water":
|
||||||
fillColor = "#c5dfea"
|
fillColor = "#c5dfea"
|
||||||
break
|
break
|
||||||
case "nopower":
|
case "nopower":
|
||||||
fillColor = getPowerHexColor(undefined)
|
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 && coordinateData.provinces[provinceKey]) {
|
|
||||||
coordinateData.provinces[provinceKey].mesh = mesh
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create an edges geometry from the shape geometry.
|
const material = new THREE.MeshBasicMaterial({
|
||||||
const edges = new THREE.EdgesGeometry(geometry);
|
color: fillColor,
|
||||||
// Create a line material with black color for the border.
|
side: THREE.DoubleSide,
|
||||||
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2 });
|
depthWrite: false
|
||||||
// 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 shapes = SVGLoader.createShapes(path);
|
||||||
const fontLoader = new FontLoader();
|
|
||||||
fontLoader.load('assets/fonts/helvetiker_regular.typeface.json', function (font) {
|
|
||||||
for (const [key, value] of Object.entries(coordinateData.provinces)) {
|
|
||||||
|
|
||||||
textGroup.add(createLabel(font, key, value))
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
scene.add(group);
|
||||||
|
scene.add(textGroup);
|
||||||
|
resolve()
|
||||||
|
|
||||||
})
|
})
|
||||||
// This rotates the SVG the "correct" way round, and scales it down
|
.catch(error => {
|
||||||
group.scale.set(1, -1, 1)
|
console.error('Error loading map styles:', error);
|
||||||
textGroup.rotation.x = Math.PI / 2;
|
});
|
||||||
textGroup.scale.set(1, -1, 1)
|
},
|
||||||
|
// Progress function
|
||||||
// After adding all meshes to the group, update its matrix:
|
undefined,
|
||||||
group.updateMatrixWorld(true);
|
function (error) { console.log(error) })
|
||||||
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);
|
|
||||||
|
|
||||||
|
|
||||||
scene.add(group);
|
|
||||||
scene.add(textGroup);
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading map styles:', error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// Progress function
|
|
||||||
undefined,
|
|
||||||
function (error) { console.log(error) })
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
ai_animation/src/map/state.ts
Normal file
122
ai_animation/src/map/state.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { getPowerHexColor } from "../units/create";
|
||||||
|
import { gameState } from "../gameState";
|
||||||
|
import { leaderboard } from "../domElements";
|
||||||
|
import type { GamePhase } from "../types/gameState";
|
||||||
|
|
||||||
|
|
||||||
|
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 unitType = match[1];
|
||||||
|
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) {
|
||||||
|
let powerColor = getPowerHexColor(gameState.boardState.provinces[key].owner)
|
||||||
|
let powerColorHex = parseInt(powerColor.substring(1), 16);
|
||||||
|
gameState.boardState.provinces[key].mesh?.material.color.setHex(powerColorHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
ai_animation/src/phase.ts
Normal file
58
ai_animation/src/phase.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
}
|
||||||
73
ai_animation/src/speech.ts
Normal file
73
ai_animation/src/speech.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,35 +1,26 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PowerENUM, PowerSchema } from './map';
|
import { PowerENUMSchema } from './map';
|
||||||
import { OrderFromString } from './unitOrders';
|
import { OrderFromString } from './unitOrders';
|
||||||
|
import { ProvinceENUMSchema } from './map';
|
||||||
|
|
||||||
const UnitSchema = z.object({
|
|
||||||
type: z.enum(["A", "F"]),
|
|
||||||
power: z.string(),
|
|
||||||
location: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const OrderSchema = z.object({
|
|
||||||
text: z.string(),
|
|
||||||
power: z.string(),
|
|
||||||
region: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const PhaseSchema = z.object({
|
const PhaseSchema = z.object({
|
||||||
messages: z.array(z.any()),
|
messages: z.array(z.any()),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
orders: z.record(PowerSchema, z.array(OrderFromString)),
|
orders: z.record(PowerENUMSchema, z.array(OrderFromString).nullable()),
|
||||||
results: z.record(PowerSchema, OrderSchema),
|
results: z.record(z.string(), z.array(z.any())),
|
||||||
state: z.object({
|
state: z.object({
|
||||||
units: z.record(z.nativeEnum(PowerENUM), z.array(z.string()))
|
units: z.record(PowerENUMSchema, z.array(z.string())),
|
||||||
|
centers: z.record(PowerENUMSchema, z.array(ProvinceENUMSchema))
|
||||||
}),
|
}),
|
||||||
year: z.number(),
|
year: z.number().optional(),
|
||||||
units: z.array(UnitSchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GameSchema = z.object({
|
export const GameSchema = z.object({
|
||||||
map_name: z.string(),
|
map: z.string(),
|
||||||
game_id: z.string(),
|
id: z.string(),
|
||||||
phases: z.array(PhaseSchema),
|
phases: z.array(PhaseSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GamePhase = z.infer<typeof PhaseSchema>;
|
export type GamePhase = z.infer<typeof PhaseSchema>;
|
||||||
|
export type GameSchemaType = z.infer<typeof GameSchema>;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export enum ProvTypeENUM {
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PowerENUM {
|
export enum PowerENUM {
|
||||||
|
ENGLAND = "England",
|
||||||
FRANCE = "France",
|
FRANCE = "France",
|
||||||
TURKEY = "Turkey",
|
TURKEY = "Turkey",
|
||||||
AUSTRIA = "Austria",
|
AUSTRIA = "Austria",
|
||||||
|
|
@ -16,8 +17,16 @@ export enum PowerENUM {
|
||||||
RUSSIA = "Russia",
|
RUSSIA = "Russia",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const ProvTypeSchema = z.nativeEnum(ProvTypeENUM);
|
export const ProvTypeSchema = z.nativeEnum(ProvTypeENUM);
|
||||||
export const PowerSchema = z.nativeEnum(PowerENUM).transform((val) => (val.charAt(0).toUpperCase() + val.slice(1).toLowerCase()));
|
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({
|
export const LabelSchema = z.object({
|
||||||
x: z.number(),
|
x: z.number(),
|
||||||
|
|
@ -33,7 +42,8 @@ export const ProvinceSchema = z.object({
|
||||||
label: LabelSchema,
|
label: LabelSchema,
|
||||||
type: ProvTypeSchema,
|
type: ProvTypeSchema,
|
||||||
unit: UnitSchema.optional(),
|
unit: UnitSchema.optional(),
|
||||||
owner: PowerSchema.optional(),
|
owner: PowerENUMSchema.optional(),
|
||||||
|
isSupplyCenter: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CoordinateDataSchema = z.object({
|
export const CoordinateDataSchema = z.object({
|
||||||
|
|
@ -42,4 +52,83 @@ export const CoordinateDataSchema = z.object({
|
||||||
|
|
||||||
export type Province = z.infer<typeof ProvinceSchema>;
|
export type Province = z.infer<typeof ProvinceSchema>;
|
||||||
export type CoordinateData = z.infer<typeof CoordinateDataSchema>;
|
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)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import type { GamePhase } from "../types/gameState";
|
||||||
import { createUnitMesh } from "./create";
|
import { createUnitMesh } from "./create";
|
||||||
import { UnitMesh } from "../types/units";
|
import { UnitMesh } from "../types/units";
|
||||||
import { getProvincePosition } from "../map/utils";
|
import { getProvincePosition } from "../map/utils";
|
||||||
import { coordinateData } from "../gameState";
|
|
||||||
import { Tween } from "@tweenjs/tween.js";
|
import { Tween } from "@tweenjs/tween.js";
|
||||||
|
import { gameState } from "../gameState";
|
||||||
|
|
||||||
//FIXME: Move this to a file with all the constants
|
//FIXME: Move this to a file with all the constants
|
||||||
let animationDuration = 1500; // Duration of unit movement animation in ms
|
let animationDuration = 1500; // Duration of unit movement animation in ms
|
||||||
|
|
@ -45,7 +45,7 @@ export function createTweenAnimations(unitMeshes: UnitMesh[], currentPhase: Game
|
||||||
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const key = `${power}-${match[1]}-${match[2]}`;
|
const key = `${power}-${match[1]}-${match[2]}`;
|
||||||
previousUnitPositions[key] = getProvincePosition(coordinateData, match[2]);
|
previousUnitPositions[key] = getProvincePosition(gameState.boardState, match[2]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export function createTweenAnimations(unitMeshes: UnitMesh[], currentPhase: Game
|
||||||
const location = armyOrFleetOrder[2];
|
const location = armyOrFleetOrder[2];
|
||||||
|
|
||||||
// Current final
|
// Current final
|
||||||
const currentPos = getProvincePosition(coordinateData, location);
|
const currentPos = getProvincePosition(gameState.boardState, location);
|
||||||
|
|
||||||
let startPos;
|
let startPos;
|
||||||
let matchFound = false;
|
let matchFound = false;
|
||||||
|
|
@ -106,7 +106,7 @@ export function createAnimationsForPhaseTransition(unitMeshes: UnitMesh[], curre
|
||||||
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const key = `${power} -${match[1]} -${match[2]} `;
|
const key = `${power} -${match[1]} -${match[2]} `;
|
||||||
previousUnitPositions[key] = getProvincePosition(coordinateData, match[2]);
|
previousUnitPositions[key] = getProvincePosition(gameState.boardState, match[2]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +124,7 @@ export function createAnimationsForPhaseTransition(unitMeshes: UnitMesh[], curre
|
||||||
|
|
||||||
|
|
||||||
// Current final
|
// Current final
|
||||||
const currentPos = getProvincePosition(coordinateData, location);
|
const currentPos = getProvincePosition(gameState.boardState, location);
|
||||||
|
|
||||||
let startPos;
|
let startPos;
|
||||||
let matchFound = false;
|
let matchFound = false;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { UnitData, UnitMesh } from "../types/units";
|
import { UnitData, UnitMesh } from "../types/units";
|
||||||
import { PowerENUM } from "../types/map";
|
import { PowerENUM } from "../types/map";
|
||||||
|
import { gameState } from "../gameState";
|
||||||
|
import { getProvincePosition } from "../map/utils";
|
||||||
|
|
||||||
// Get color for a power
|
// Get color for a power
|
||||||
export function getPowerHexColor(power: PowerENUM) {
|
export function getPowerHexColor(power: PowerENUM) {
|
||||||
|
|
@ -67,6 +69,60 @@ function createFleet(color: string): THREE.Group {
|
||||||
group.add(sail);
|
group.add(sail);
|
||||||
return group
|
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): UnitMesh {
|
export function createUnitMesh(unitData: UnitData): UnitMesh {
|
||||||
const color = getPowerHexColor(unitData.power);
|
const color = getPowerHexColor(unitData.power);
|
||||||
let group: THREE.Group | null;
|
let group: THREE.Group | null;
|
||||||
|
|
@ -77,6 +133,8 @@ export function createUnitMesh(unitData: UnitData): UnitMesh {
|
||||||
} else {
|
} else {
|
||||||
group = createFleet(color)
|
group = createFleet(color)
|
||||||
}
|
}
|
||||||
|
let pos = getProvincePosition(gameState.boardState, unitData.province)
|
||||||
|
group.position.set(pos.x, pos.y, pos.z)
|
||||||
|
|
||||||
// Store metadata
|
// Store metadata
|
||||||
group.userData = {
|
group.userData = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue