diff --git a/ai_animation/src/domElements.ts b/ai_animation/src/domElements.ts index 7d02dde..119fd99 100644 --- a/ai_animation/src/domElements.ts +++ b/ai_animation/src/domElements.ts @@ -1,5 +1,20 @@ import { gameState } from "./gameState"; import { logger } from "./logger"; + + +export function updatePhaseDisplay() { + const currentPhase = gameState.gameData.phases[gameState.phaseIndex]; + // Add fade-out effect + phaseDisplay.style.transition = 'opacity 0.3s ease-out'; + phaseDisplay.style.opacity = '0'; + + // Update text after fade-out + setTimeout(() => { + phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'} (${gameState.phaseIndex + 1}/${gameState.gameData.phases.length})`; + // Fade back in + phaseDisplay.style.opacity = '1'; + }, 300); +} // --- LOADING & DISPLAYING GAME PHASES --- export function loadGameBtnFunction(file) { const reader = new FileReader(); diff --git a/ai_animation/src/domElements/chatWindows.ts b/ai_animation/src/domElements/chatWindows.ts index 0f04021..ad4faba 100644 --- a/ai_animation/src/domElements/chatWindows.ts +++ b/ai_animation/src/domElements/chatWindows.ts @@ -1,7 +1,6 @@ import * as THREE from "three"; import { currentPower, gameState } from "../gameState"; import { config } from "../config"; -import { createTweenAnimations } from "../units/animate"; import { advanceToNextPhase } from "../phase"; let faceIconCache = {}; // Cache for generated face icons @@ -222,7 +221,6 @@ export function updateChatWindows(phase: any, stepMessages = false) { if (config.isDebugMode) { console.log(`Animating orders from ${previousPhase.name} to ${currentPhase.name}`); } - createTweenAnimations(currentPhase, previousPhase); // After animations complete, advance to next phase with longer delay gameState.playbackTimer = setTimeout(() => { diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index b1c9d4c..807e2d3 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -7,7 +7,7 @@ import { createChatWindows } from "./domElements/chatWindows"; import { logger } from "./logger"; import { OrbitControls } from "three/examples/jsm/Addons.js"; import { displayInitialPhase } from "./phase"; -import * as TWEEN from "@tweenjs/tween.js"; +import { Tween, Group as TweenGroup } from "@tweenjs/tween.js"; //FIXME: This whole file is a mess. Need to organize and format // @@ -56,10 +56,14 @@ class GameState { unitMeshes: THREE.Group[] // Animations needed for this turn - unitAnimations: TWEEN.Tween[] + unitAnimations: Tween[] // playbackTimer: number + + // Camera Animation during playing + cameraPanAnim: TweenGroup | undefined + constructor(boardName: AvailableMaps) { this.phaseIndex = 0 this.gameData = null diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index edc0bcb..7dd0dc8 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -1,18 +1,15 @@ import * as THREE from "three"; import "./style.css" import { initMap } from "./map/create"; -import { initUnits } from "./units/create"; -import { createTweenAnimations } from "./units/animate"; -import * as TWEEN from "@tweenjs/tween.js"; +import { createAnimationsForNextPhase as createAnimationsForNextPhase } from "./units/animate"; import { gameState } from "./gameState"; import { logger } from "./logger"; import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements"; import { updateChatWindows } from "./domElements/chatWindows"; -import { initStandingsBoard, updateStandingsBoardVisibility, hideStandingsBoard, showStandingsBoard } from "./domElements/standingsBoard"; -import { displayPhaseWithAnimation, advanceToNextPhase } from "./phase"; -import { speakSummary } from "./speech"; -import { addToNewsBanner } from "./domElements/chatWindows"; +import { initStandingsBoard, hideStandingsBoard, showStandingsBoard } from "./domElements/standingsBoard"; +import { displayPhaseWithAnimation, advanceToNextPhase, resetToPhase } from "./phase"; import { config } from "./config"; +import { Tween, Group } from "@tweenjs/tween.js"; //TODO: Create a function that finds a suitable unit location within a given polygon, for placing units better // Currently the location for label, unit, and SC are all the same manually picked location @@ -59,6 +56,7 @@ function initScene() { if (isStreamingMode) { setTimeout(() => { togglePlayback() + gameState.cameraPanAnim = createCameraPan() }, 2000) } }) @@ -78,8 +76,36 @@ function initScene() { logger.updateInfoPanel(); } +function createCameraPan() { + // Move from the starting camera position to the left side of the map + let moveToStartSweepAnim = new Tween(gameState.camera.position).to({ + x: -100, + y: 650, + z: 1000, + }, 8000).onUpdate((posVector) => { + gameState.camera.position.set(posVector.x, posVector.y, posVector.z) + }) + let cameraSweepOperation = new Tween({ angle: 0 }).to({ + x: 2000, + y: 650, + z: 1000 + }, 20000) + .onUpdate((tweenObj) => { + let radius = 2000 + gameState.camera.position.set( + radius * -Math.cos(tweenObj.angle), + 650 + 80 * Math.sin(cameraPanTime * 0.5), + 1000 + 1000 * Math.sin(tweenObj.angle) + ); + }).yoyo(true).repeat(Infinity) + + moveToStartSweepAnim.chain(cameraSweepOperation) + moveToStartSweepAnim.start() + return new Group(moveToStartSweepAnim, cameraSweepOperation) +} + // --- ANIMATION LOOP --- -/** +/* * Main animation loop that runs continuously * Handles camera movement, animations, and game state transitions */ @@ -87,16 +113,16 @@ function animate() { requestAnimationFrame(animate); if (gameState.isPlaying) { + // FIXME: Doing panning this way is causing stuttering as other operations take time in the main thread. + // + + // TODO: Fix this panning so that it is just a small arc across the "front" of the board. // Pan camera slowly in playback mode cameraPanTime += cameraPanSpeed; - const angle = 0.9 * Math.sin(cameraPanTime) + 1.2; - const radius = 1000; - gameState.camera.position.set( - radius * Math.cos(angle), - 650 + 80 * Math.sin(cameraPanTime * 0.5), - 100 + radius * Math.sin(angle) - ); - + gameState.cameraPanAnim.update() + // const angle = 0.9 * Math.sin(cameraPanTime) + 1.2; + // const radius = 2000; + // // If messages are done playing but we haven't started unit animations yet // AND we're not currently speaking, create animations if (!gameState.messagesPlaying && !gameState.isSpeaking && @@ -116,10 +142,7 @@ function animate() { console.log("Messages complete, starting unit animations"); // Create animations for unit movements based on orders - createTweenAnimations( - gameState.gameData.phases[gameState.phaseIndex], - gameState.gameData.phases[prevIndex] - ); + createAnimationsForNextPhase(); } } } @@ -236,6 +259,9 @@ function togglePlayback() { // NEW: If we're speaking, don't allow toggling playback if (gameState.isSpeaking) return; + // Pause the camera animation + if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll().map(anim => anim.pause()) + gameState.isPlaying = !gameState.isPlaying; if (gameState.isPlaying) { @@ -285,15 +311,11 @@ fileInput.addEventListener('change', e => { prevBtn.addEventListener('click', () => { if (gameState.phaseIndex > 0) { - gameState.phaseIndex--; - displayPhaseWithAnimation(gameState.phaseIndex); + resetToPhase(gameState.phaseIndex - 1) } }); nextBtn.addEventListener('click', () => { - if (gameState.gameData && gameState.phaseIndex < gameState.gameData.phases.length - 1) { - gameState.phaseIndex++; - displayPhaseWithAnimation(gameState.phaseIndex); - } + advanceToNextPhase() }); playBtn.addEventListener('click', togglePlayback); diff --git a/ai_animation/src/map/state.ts b/ai_animation/src/map/state.ts index 0e0ad22..890f440 100644 --- a/ai_animation/src/map/state.ts +++ b/ai_animation/src/map/state.ts @@ -1,8 +1,7 @@ import { getPowerHexColor } from "../units/create"; import { gameState } from "../gameState"; import { leaderboard } from "../domElements"; -import type { GamePhase } from "../types/gameState"; -import { ProvTypeENUM } from "../types/map"; +import { ProvTypeENUM, PowerENUM } from "../types/map"; export function updateSupplyCenterOwnership(centers) { @@ -95,7 +94,12 @@ export function updateLeaderboard(phase) { leaderboard.innerHTML = html; } -export function updateMapOwnership(currentPhase: GamePhase) { +export function updateMapOwnership() { + let currentPhase = gameState.gameData?.phases[gameState.phaseIndex] + if (currentPhase === undefined) { + throw "Currentphase is undefined for index " + gameState.phaseIndex; + + } // Clear existing ownership to avoid stale data for (const key in gameState.boardState.provinces) { if (gameState.boardState.provinces[key].owner) { @@ -103,66 +107,25 @@ export function updateMapOwnership(currentPhase: GamePhase) { } } - // Use the units array directly if available - if (currentPhase.units && currentPhase.units.length > 0) { - // Log for debugging - console.log(`Updating map ownership using ${currentPhase.units.length} units from phase ${currentPhase.name}`); - - currentPhase.units.forEach(unit => { - if (!unit.location || !unit.power) { - console.warn("Unit missing location or power:", unit); - return; - } - - const location = unit.location; - const normalized = location.toUpperCase().replace('/', '_'); - const base = normalized.split('_')[0]; - - if (gameState.boardState.provinces[base] === undefined) { - console.warn(`Province not found: ${base}`); - return; - } - - gameState.boardState.provinces[base].owner = unit.power; - }); - } - // Fallback to state.units if units array is not available - else if (currentPhase.state?.units && Object.keys(currentPhase.state.units).length > 0) { - console.log(`Updating map ownership using state.units from phase ${currentPhase.name}`); - - for (const [power, unitArr] of Object.entries(currentPhase.state.units)) { - unitArr.forEach(unitStr => { - const match = unitStr.match(/^([AF])\s+(.+)$/); - if (!match) { - console.warn(`Could not parse unit string: ${unitStr}`); - return; - } - - const location = match[2]; - const normalized = location.toUpperCase().replace('/', '_'); - const base = normalized.split('_')[0]; - - if (gameState.boardState.provinces[base] === undefined) { - console.warn(`Province not found: ${base}`); - return; - } - - gameState.boardState.provinces[base].owner = power; - }); - } - } else { - console.warn(`No unit data found in phase ${currentPhase.name} to update map ownership`); - } + for (let powerKey of Object.keys(currentPhase.state.influence)) { + for (let provKey of currentPhase.state.influence[powerKey]) { - // Update province colors based on ownership - for (const key in gameState.boardState.provinces) { - const province = gameState.boardState.provinces[key]; - - // Update the color of the provinces if needed - if (province.owner && province.type != ProvTypeENUM.WATER) { - let powerColor = getPowerHexColor(province.owner); - let powerColorHex = parseInt(powerColor.substring(1), 16); - province.mesh?.material.color.setHex(powerColorHex); + const province = gameState.boardState.provinces[provKey]; + province.owner = PowerENUM[powerKey as keyof typeof PowerENUM] + + // Update the color of the provinces if needed, you can only own coast and land + if ([ProvTypeENUM.COAST, ProvTypeENUM.LAND].indexOf(province.type) >= 0) { + if (province.owner) { + let powerColor = getPowerHexColor(province.owner); + let powerColorHex = parseInt(powerColor.substring(1), 16); + province.mesh?.material.color.setHex(powerColorHex); + } else if (province.owner === undefined && province.mesh !== undefined) { + let powerColor = getPowerHexColor(undefined); + let powerColorHex = parseInt(powerColor.substring(1), 16); + province.mesh.material.color.setHex(powerColorHex) + } + } } } } + diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index a5ec6e0..65145a5 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -1,13 +1,14 @@ import { gameState } from "./gameState"; import { logger } from "./logger"; -import { phaseDisplay } from "./domElements"; +import { updatePhaseDisplay } from "./domElements"; import { createSupplyCenters, createUnitMesh, initUnits } from "./units/create"; import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership } from "./map/state"; import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows"; -import { createTweenAnimations } from "./units/animate"; +import { createAnimationsForNextPhase } from "./units/animate"; import { speakSummary } from "./speech"; import { config } from "./config"; + /** * Unified function to display a phase with proper transitions * Handles both initial display and animated transitions between phases @@ -29,19 +30,8 @@ export function displayPhase(skipMessages = false) { const prevIndex = isFirstPhase ? null : (index > 0 ? index - 1 : null); const previousPhase = prevIndex !== null ? gameState.gameData.phases[prevIndex] : null; - // Update phase display with smooth transition - if (phaseDisplay) { - // Add fade-out effect - phaseDisplay.style.transition = 'opacity 0.3s ease-out'; - phaseDisplay.style.opacity = '0'; + updatePhaseDisplay() - // Update text after fade-out - setTimeout(() => { - phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'} (${index + 1}/${gameState.gameData.phases.length})`; - // Fade back in - phaseDisplay.style.opacity = '1'; - }, 300); - } // Update supply centers if (currentPhase.state?.centers) { @@ -51,7 +41,7 @@ export function displayPhase(skipMessages = false) { // Update UI elements with smooth transitions updateLeaderboard(currentPhase); - updateMapOwnership(currentPhase); + updateMapOwnership(); // Add phase info to news banner if not already there const phaseBannerText = `Phase: ${currentPhase.name}`; @@ -74,7 +64,7 @@ export function displayPhase(skipMessages = false) { // Only animate if not the first phase and animations are requested if (!isFirstPhase && !skipMessages) { if (previousPhase) { - createTweenAnimations(currentPhase, previousPhase); + createAnimationsForNextPhase(); } } else { logger.log("No animations for this phase transition"); @@ -100,18 +90,33 @@ export function displayPhaseWithAnimation() { displayPhase(false); } +// Explicityly sets the phase to a given index, +// Removes and recreates all units. +export function resetToPhase(index: number) { + gameState.phaseIndex = index + gameState.unitAnimations = []; + gameState.unitMeshes.map(unitMesh => gameState.scene.remove(unitMesh)) + + updateMapOwnership() + initUnits() + +} + /** * Advances to the next phase in the game sequence * Handles speaking summaries and transitioning to the next phase */ export function advanceToNextPhase() { + // If we're not "playing" through the game, just skipping phases, move everything along + if (!gameState.isPlaying) { + moveToNextPhase() + } console.log("advanceToNextPhase called"); if (!gameState.gameData || !gameState.gameData.phases || gameState.phaseIndex < 0) { logger.log("Cannot advance phase: invalid game state"); return; } - // Reset the nextPhaseScheduled flag to allow scheduling the next phase gameState.nextPhaseScheduled = false; @@ -178,11 +183,9 @@ function moveToNextPhase() { } else { gameState.phaseIndex++; } - if (config.isDebugMode && gameState.gameData) { console.log(`Moving to phase ${gameState.gameData.phases[gameState.phaseIndex].name}`); } - // Display the next phase and start showing its messages displayPhaseWithAnimation(); } diff --git a/ai_animation/src/types/gameState.ts b/ai_animation/src/types/gameState.ts index 4050c3e..47be81d 100644 --- a/ai_animation/src/types/gameState.ts +++ b/ai_animation/src/types/gameState.ts @@ -16,7 +16,9 @@ const PhaseSchema = z.object({ results: z.record(z.string(), z.array(z.any())), state: z.object({ units: z.record(PowerENUMSchema, z.array(z.string())), - centers: z.record(PowerENUMSchema, z.array(ProvinceENUMSchema)) + centers: z.record(PowerENUMSchema, z.array(ProvinceENUMSchema)), + homes: z.record(PowerENUMSchema, z.array(z.string())), + influence: z.record(PowerENUMSchema, z.array(z.string())), }), year: z.number().optional(), summary: z.string().optional(), diff --git a/ai_animation/src/types/map.ts b/ai_animation/src/types/map.ts index 7a95aaa..df81c36 100644 --- a/ai_animation/src/types/map.ts +++ b/ai_animation/src/types/map.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import * as THREE from "three"; export enum ProvTypeENUM { WATER = "Water", @@ -43,7 +44,8 @@ export const ProvinceSchema = z.object({ type: ProvTypeSchema, unit: UnitSchema.optional(), owner: PowerENUMSchema.optional(), - isSupplyCenter: z.boolean().optional() + isSupplyCenter: z.boolean().optional(), + mesh: z.instanceof(THREE.Mesh).optional() }); export const CoordinateDataSchema = z.object({ diff --git a/ai_animation/src/units/animate.ts b/ai_animation/src/units/animate.ts index 7410a1b..9178740 100644 --- a/ai_animation/src/units/animate.ts +++ b/ai_animation/src/units/animate.ts @@ -1,12 +1,9 @@ import * as THREE from "three"; -import type { GamePhase } from "../types/gameState"; import { createUnitMesh } from "./create"; -import { UnitMesh } from "../types/units"; import { getProvincePosition } from "../map/utils"; import * as TWEEN from "@tweenjs/tween.js"; import { gameState } from "../gameState"; import type { UnitOrder } from "../types/unitOrders"; -import { OrderFromString } from "../types/unitOrders"; import { logger } from "../logger"; import { config } from "../config"; // Assuming config is defined in a separate file @@ -49,7 +46,8 @@ function getUnit(unitOrder: UnitOrder, power: string) { * @param previousPhase The previous game phase containing orders to process * **/ -export function createTweenAnimations(currentPhase: GamePhase, previousPhase: GamePhase | null) { +export function createAnimationsForNextPhase() { + let previousPhase = gameState.gameData?.phases[gameState.phaseIndex == 0 ? 0 : gameState.phaseIndex - 1] // Safety check - if no previous phase or no orders, return if (!previousPhase) { logger.log("No previous phase to animate"); diff --git a/ai_animation/src/units/create.ts b/ai_animation/src/units/create.ts index 4424c1c..237a0ae 100644 --- a/ai_animation/src/units/create.ts +++ b/ai_animation/src/units/create.ts @@ -5,7 +5,7 @@ import { gameState } from "../gameState"; import { getProvincePosition } from "../map/utils"; // Get color for a power -export function getPowerHexColor(power: PowerENUM) { +export function getPowerHexColor(power: PowerENUM | undefined): string { let defaultColor = '#ddd2af' if (power === undefined) return defaultColor const powerColors = { @@ -71,7 +71,6 @@ function createFleet(color: string): THREE.Group { } export function createSupplyCenters() { - 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]) { @@ -146,10 +145,10 @@ export function createUnitMesh(unitData: UnitData): THREE.Group { } +// Creates the units for the current gameState.phaseIndex. export function initUnits() { - createSupplyCenters() - for (const [power, unitArr] of Object.entries(gameState.gameData.phases[0].state.units)) { + for (const [power, unitArr] of Object.entries(gameState.gameData.phases[gameState.phaseIndex].state.units)) { unitArr.forEach(unitStr => { const match = unitStr.match(/^([AF])\s+(.+)$/); if (match) {