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:
Tyler Marques 2025-03-13 11:33:30 -04:00
parent dff01db83f
commit ae2db39729
No known key found for this signature in database
GPG key ID: 7672EFD79378341C
13 changed files with 1363 additions and 1322 deletions

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

View file

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

View file

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

View file

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

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";
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
View 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();
}

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

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

View file

@ -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)

View file

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

View file

@ -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 = {