diff --git a/ai_animation/src/backgroundAudio.test.ts b/ai_animation/src/backgroundAudio.test.ts deleted file mode 100644 index 6c575f8..0000000 --- a/ai_animation/src/backgroundAudio.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Tests for background audio looping functionality - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -// Mock HTMLAudioElement -class MockAudio { - loop = false; - volume = 1; - paused = true; - src = ''; - currentTime = 0; - duration = 10; - - constructor() { - // Constructor can be empty or set default values - } - - play = vi.fn().mockResolvedValue(undefined); - pause = vi.fn(); - addEventListener = vi.fn(); -} - -describe('Background Audio Looping', () => { - let originalAudio: any; - - beforeEach(() => { - // Store original and replace with mock - originalAudio = global.Audio; - global.Audio = MockAudio as any; - - // Clear any module state by re-importing - vi.resetModules(); - }); - - afterEach(() => { - // Restore original Audio constructor - global.Audio = originalAudio; - }); - - it('should set loop property to true during initialization', async () => { - // Import fresh module to avoid state pollution - const { initializeBackgroundAudio } = await import('./backgroundAudio'); - - // Initialize background audio - initializeBackgroundAudio(); - - // The actual test is that initialization doesn't throw and sets up the audio correctly - // We can't directly access the private audio instance, but we can test the behavior - expect(global.Audio).toBeDefined(); - }); - - it('should handle audio loop property correctly', () => { - // Create a mock audio instance to test loop behavior - const audioElement = new MockAudio() as any; - - // Set loop to true (like our code does) - audioElement.loop = true; - audioElement.duration = 5; // 5 second track - - // Simulate audio playing and ending - audioElement.paused = false; - audioElement.currentTime = 0; - - // Simulate what happens when audio reaches the end - audioElement.currentTime = audioElement.duration; - - // With loop=true, browser automatically restarts - const simulateLoopBehavior = () => { - if (audioElement.loop && !audioElement.paused && audioElement.currentTime >= audioElement.duration) { - audioElement.currentTime = 0; // Browser resets to start - return true; // Indicates loop occurred - } - return false; - }; - - // Test loop behavior - const looped = simulateLoopBehavior(); - - expect(audioElement.loop).toBe(true); - expect(looped).toBe(true); - expect(audioElement.currentTime).toBe(0); // Should be reset to start - }); - - it('should verify loop property is essential for continuous playback', () => { - const audioWithLoop = new MockAudio() as any; - const audioWithoutLoop = new MockAudio() as any; - - // Setup both audio elements - audioWithLoop.loop = true; - audioWithoutLoop.loop = false; - - audioWithLoop.duration = 10; - audioWithoutLoop.duration = 10; - - // Both start playing - audioWithLoop.paused = false; - audioWithoutLoop.paused = false; - - // Both reach the end - audioWithLoop.currentTime = audioWithLoop.duration; - audioWithoutLoop.currentTime = audioWithoutLoop.duration; - - // Simulate end behavior - const testLooping = (audio: any) => { - if (audio.currentTime >= audio.duration) { - if (audio.loop) { - audio.currentTime = 0; // Loop back to start - return 'looped'; - } else { - audio.paused = true; // Stop playing - return 'stopped'; - } - } - return 'playing'; - }; - - const resultWithLoop = testLooping(audioWithLoop); - const resultWithoutLoop = testLooping(audioWithoutLoop); - - expect(resultWithLoop).toBe('looped'); - expect(resultWithoutLoop).toBe('stopped'); - expect(audioWithLoop.currentTime).toBe(0); // Reset to start - expect(audioWithoutLoop.paused).toBe(true); // Stopped - }); - - it('should test the actual module behavior', async () => { - // Import fresh module - const { initializeBackgroundAudio, startBackgroundAudio, stopBackgroundAudio } = await import('./backgroundAudio'); - - // Test initialization doesn't throw - expect(() => initializeBackgroundAudio()).not.toThrow(); - - // Test double initialization protection - expect(() => initializeBackgroundAudio()).toThrow('Attempted to init audio twice.'); - }); - - it('should demonstrate loop property importance with realistic scenario', () => { - // This test demonstrates why loop=true is critical for background music - const backgroundTrack = new MockAudio() as any; - - // Our code sets this to true - backgroundTrack.loop = true; - backgroundTrack.volume = 0.4; - backgroundTrack.src = './sounds/background_ambience.mp3'; - backgroundTrack.duration = 30; // 30 second ambient track - - // Start playing - backgroundTrack.paused = false; - backgroundTrack.currentTime = 0; - - // Simulate game running for longer than track duration - const gameRuntime = 90; // 90 seconds - const timeStep = 1; // 1 second steps - - let currentGameTime = 0; - let trackRestarts = 0; - - while (currentGameTime < gameRuntime) { - currentGameTime += timeStep; - backgroundTrack.currentTime += timeStep; - - // Check if track ended and needs to loop - if (backgroundTrack.currentTime >= backgroundTrack.duration) { - if (backgroundTrack.loop) { - backgroundTrack.currentTime = 0; // Restart - trackRestarts++; - } else { - backgroundTrack.paused = true; // Would stop without loop - break; - } - } - } - - // With a 30-second track and 90-second game, we expect 3 restarts (0-30, 30-60, 60-90) - expect(backgroundTrack.loop).toBe(true); - expect(trackRestarts).toBe(3); - expect(backgroundTrack.paused).toBe(false); // Still playing - expect(currentGameTime).toBe(gameRuntime); // Game completed full duration - }); -}); \ No newline at end of file diff --git a/ai_animation/src/components/twoPowerConversation.test.ts b/ai_animation/src/components/momentModal.test.ts similarity index 93% rename from ai_animation/src/components/twoPowerConversation.test.ts rename to ai_animation/src/components/momentModal.test.ts index cd5cddd..4fd99dd 100644 --- a/ai_animation/src/components/twoPowerConversation.test.ts +++ b/ai_animation/src/components/momentModal.test.ts @@ -32,7 +32,7 @@ vi.mock('../gameState', () => { start: vi.fn(), stop: vi.fn() }; - + return { gameState: { isPlaying: false, @@ -91,7 +91,7 @@ Object.defineProperty(global, 'document', { }); // Import after all mocking -import { showTwoPowerConversation, closeTwoPowerConversation } from './twoPowerConversation'; +import { showMomentModal, closeMomentModal } from './momentModal'; // Get direct reference to the mocked gameState let gameState: any; @@ -101,13 +101,13 @@ describe('twoPowerConversation', () => { // Get the mocked gameState const gameStateModule = await import('../gameState'); gameState = gameStateModule.gameState; - + // Reset mocked game state gameState.isPlaying = false; gameState.isDisplayingMoment = false; gameState.phaseIndex = 0; gameState.gameData.phases[0].messages = []; - + // Reset all mocks vi.clearAllMocks(); gameState.eventQueue.pendingEvents = []; @@ -116,9 +116,9 @@ describe('twoPowerConversation', () => { describe('showTwoPowerConversation', () => { it('should throw error when no messages found (indicates data quality issue)', () => { gameState.isPlaying = true; - + expect(() => { - showTwoPowerConversation({ + showMomentModal({ power1: 'FRANCE', power2: 'GERMANY' }); @@ -127,10 +127,10 @@ describe('twoPowerConversation', () => { it('should throw error when empty messages array provided', () => { gameState.isPlaying = false; - + expect(() => { - showTwoPowerConversation({ - power1: 'FRANCE', + showMomentModal({ + power1: 'FRANCE', power2: 'GERMANY', messages: [] }); @@ -143,14 +143,14 @@ describe('twoPowerConversation', () => { { sender: 'FRANCE', recipient: 'GERMANY', message: 'Hello', time_sent: '1' } ]; - showTwoPowerConversation({ + showMomentModal({ power1: 'FRANCE', power2: 'GERMANY' }); // Should have scheduled events in the queue expect(gameState.eventQueue.scheduleDelay).toHaveBeenCalled(); - + // Should have marked as displaying moment expect(gameState.isDisplayingMoment).toBe(true); }); @@ -163,24 +163,24 @@ describe('twoPowerConversation', () => { { sender: 'FRANCE', recipient: 'GERMANY', message: 'Test', time_sent: '1' } ]; - showTwoPowerConversation({ + showMomentModal({ power1: 'FRANCE', power2: 'GERMANY' }); // Should have scheduled events in the queue expect(gameState.eventQueue.scheduleDelay).toHaveBeenCalled(); - + // Should have marked as displaying moment expect(gameState.isDisplayingMoment).toBe(true); }); it('should throw error for empty message arrays as they indicate data quality issues', () => { gameState.isPlaying = true; - + // Test with empty messages - should throw error expect(() => { - showTwoPowerConversation({ + showMomentModal({ power1: 'FRANCE', power2: 'GERMANY', messages: [] @@ -188,4 +188,4 @@ describe('twoPowerConversation', () => { }).toThrow('High-interest moment detected between FRANCE and GERMANY but no messages found'); }); }); -}); \ No newline at end of file +}); diff --git a/ai_animation/src/components/twoPowerConversation.ts b/ai_animation/src/components/momentModal.ts similarity index 97% rename from ai_animation/src/components/twoPowerConversation.ts rename to ai_animation/src/components/momentModal.ts index 9962f1c..8361411 100644 --- a/ai_animation/src/components/twoPowerConversation.ts +++ b/ai_animation/src/components/momentModal.ts @@ -4,8 +4,9 @@ import { getPowerDisplayName } from '../utils/powerNames'; import { PowerENUM } from '../types/map'; import { Moment } from '../types/moments'; import { Message } from '../types/gameState'; +import { ScheduledEvent } from '../events.ts' -interface TwoPowerDialogueOptions { +interface MomentDialogueOptions { moment: Moment; power1?: PowerENUM; power2?: PowerENUM; @@ -20,11 +21,11 @@ let dialogueOverlay: HTMLElement | null = null; * Shows a dialogue box displaying conversation between two powers * @param options Configuration for the dialogue display */ -export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void { +export function showMomentModal(options: MomentDialogueOptions): void { const { moment, power1, power2, title, onClose } = options; // Close any existing dialogue - closeTwoPowerConversation(); + closeMomentModal(); if (moment.raw_messages.length === 0) { throw new Error( @@ -36,6 +37,16 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void showConversationModalSequence(title, moment, onClose); } +export function createMomentEvent(moment: Moment): ScheduledEvent { + return { + id: `moment-${moment.phase}`, + callback: () => showMomentModal({ moment }), + triggerAtTime + + } + +} + /** * Shows the conversation modal and sequences all messages through the event queue */ @@ -100,7 +111,7 @@ function scheduleMessageSequence( // Schedule conversation close after all messages are shown gameState.eventQueue.scheduleDelay(config.conversationFinalDelay, () => { console.log('Closing two-power conversation and calling onClose callback'); - closeTwoPowerConversation(); + closeMomentModal(); if (callbackOnClose) callbackOnClose(); }, `close-conversation-after-messages-${Date.now()}`); return; @@ -162,7 +173,7 @@ function displaySingleMessage( * Closes the two-power conversation dialogue * @param immediate If true, removes overlay immediately without animation */ -export function closeTwoPowerConversation(immediate: boolean = false): void { +export function closeMomentModal(immediate: boolean = false): void { dialogueOverlay = document.getElementById("dialogue-overlay") if (dialogueOverlay) { if (immediate) { @@ -214,9 +225,7 @@ function createDialogueContainer(power1: string, power2: string, title?: string, const container = document.createElement('div'); container.className = 'dialogue-container'; container.style.cssText = ` - background: radial-gradient(ellipse at center, #f7ecd1 0%, #dbc08c 100%); - border: 3px solid #4f3b16; - border-radius: 8px; + background: radial-gradient(ellipse at center, #f7ecd1 0%, #dbc08w8px; box-shadow: 0 0 15px rgba(0,0,0,0.5); width: 90%; height: 85%; @@ -466,7 +475,7 @@ function setupEventListeners(onClose?: () => void): void { const closeButton = dialogueOverlay.querySelector('.close-button'); const handleClose = () => { - closeTwoPowerConversation(true); // immediate close for manual actions + closeMomentModal(true); // immediate close for manual actions onClose?.(); // When manually closed, still advance phase if playing diff --git a/ai_animation/src/components/newsBanner.ts b/ai_animation/src/components/newsBanner.ts new file mode 100644 index 0000000..80248a1 --- /dev/null +++ b/ai_animation/src/components/newsBanner.ts @@ -0,0 +1,53 @@ +import { gameState } from "../gameState"; +import { config } from "../config"; +import { GamePhase } from "../types/gameState"; +import { ScheduledEvent } from "../events"; + + +const bannerEl = document.getElementById('news-banner-content'); +if (!bannerEl) throw Error("News banner not properly initialized") + +export function createUpdateNewsBannerEvent(phase: GamePhase): ScheduledEvent { + return { id: `updateNewsBanner-${phase.name}`, callback: () => addToNewsBanner(phase.summary) } +} + +function clearNewsBanner() { + bannerEl.textContent = '' +} +/** + * Appends text to the scrolling news banner. + * If the banner is at its default text or empty, replace it entirely. + * Otherwise, just append " | " + newText. + * @param newText Text to add to the news banner + */ +function addToNewsBanner(newText: string): void { + if (!bannerEl) { + console.warn("News banner element not found"); + return; + } + + if (config.isDebugMode) { + console.log(`Adding to news banner: "${newText}"`); + } + + // Add a fade-out transition + const transitionDuration = config.uiTransitionDuration; + bannerEl.style.transition = `opacity ${transitionDuration}s ease-out`; + bannerEl.style.opacity = '0'; + + gameState.eventQueue.scheduleDelay(config.uiFadeDelay, () => { + // 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; + } + + // Fade back in + bannerEl.style.opacity = '1'; + }, `banner-fade-in-${Date.now()}`); +} diff --git a/ai_animation/src/components/rotatingDisplay.ts b/ai_animation/src/components/rotatingDisplay.ts index b4a4300..16eef7a 100644 --- a/ai_animation/src/components/rotatingDisplay.ts +++ b/ai_animation/src/components/rotatingDisplay.ts @@ -1,6 +1,7 @@ import { gameState } from "../gameState"; import { PowerENUM } from "../types/map"; import { GameSchemaType } from "../types/gameState"; +import { ScheduledEvent } from "../events"; // Enum for the different display types export enum DisplayType { @@ -104,15 +105,14 @@ function rotateToNextDisplay(): void { * @param currentPlayerPower The power the current player is controlling * @param forceUpdate Whether to force a full update even if the display type hasn't changed */ -export function updateRotatingDisplay( +export function createUpdateRotatingDisplayEvent( gameData: GameSchemaType, currentPhaseIndex: number, currentPlayerPower: PowerENUM, forceUpdate: boolean = false -): void { +): ScheduledEvent { if (!isInitialized || !containerElement) { - console.warn("Rotating display not initialized"); - return; + throw Error("Rotating display is not initialized and cannot have an event created") } // Check if we need to do a full re-render diff --git a/ai_animation/src/debug/showRandomMoment.ts b/ai_animation/src/debug/showRandomMoment.ts index eb62b0a..e410200 100644 --- a/ai_animation/src/debug/showRandomMoment.ts +++ b/ai_animation/src/debug/showRandomMoment.ts @@ -4,7 +4,7 @@ */ import { gameState } from '../gameState'; -import { showTwoPowerConversation } from '../components/twoPowerConversation'; +import { showMomentModal } from '../components/momentModal'; import { Moment } from '../types/moments'; import { _setPhase } from '../phase'; import { config } from '../config'; @@ -33,11 +33,11 @@ export function initShowRandomMomentTool(debugMenu: any) { // Add button functionality const showBtn = document.getElementById('debug-show-random-moment'); const refreshBtn = document.getElementById('debug-refresh-moment-list'); - + if (showBtn) { showBtn.addEventListener('click', showRandomMoment); } - + if (refreshBtn) { refreshBtn.addEventListener('click', updateMomentStatus); } @@ -89,21 +89,21 @@ function getEligibleMoments(): Moment[] { for (let j = i + 1; j < moment.powers_involved.length; j++) { const power1 = moment.powers_involved[i].toUpperCase(); const power2 = moment.powers_involved[j].toUpperCase(); - + const hasConversation = phase.messages.some(msg => { const sender = msg.sender?.toUpperCase(); const recipient = msg.recipient?.toUpperCase(); - + return (sender === power1 && recipient === power2) || - (sender === power2 && recipient === power1); + (sender === power2 && recipient === power1); }); - + if (hasConversation) { return true; } } } - + return false; }); } @@ -126,21 +126,21 @@ function findBestPowerPairForMoment(moment: Moment): { power1: string; power2: s for (let j = i + 1; j < moment.powers_involved.length; j++) { const power1 = moment.powers_involved[i].toUpperCase(); const power2 = moment.powers_involved[j].toUpperCase(); - + const messageCount = phase.messages.filter(msg => { const sender = msg.sender?.toUpperCase(); const recipient = msg.recipient?.toUpperCase(); - + return (sender === power1 && recipient === power2) || - (sender === power2 && recipient === power1); + (sender === power2 && recipient === power1); }).length; - + if (messageCount > 0 && (!bestPair || messageCount > bestPair.messageCount)) { bestPair = { power1, power2, messageCount }; } } } - + return bestPair; } @@ -149,7 +149,7 @@ function findBestPowerPairForMoment(moment: Moment): { power1: string; power2: s */ function showRandomMoment() { const eligibleMoments = getEligibleMoments(); - + if (eligibleMoments.length === 0) { console.warn('No eligible moments found for conversation display'); const statusElement = document.getElementById('debug-moment-status'); @@ -166,10 +166,10 @@ function showRandomMoment() { // Pick a random moment const randomMoment = eligibleMoments[Math.floor(Math.random() * eligibleMoments.length)]; - + // Find the best power pair for this moment const powerPair = findBestPowerPairForMoment(randomMoment); - + if (!powerPair) { console.warn('No valid power pair found for selected moment'); return; @@ -181,15 +181,15 @@ function showRandomMoment() { // Find the phase index for this moment const phaseIndex = gameState.gameData.phases.findIndex(p => p.name === randomMoment.phase); - + if (phaseIndex === -1) { const errorMsg = `CRITICAL ERROR: Phase ${randomMoment.phase} from moment data not found in game data! This indicates a serious data integrity issue.`; console.error(errorMsg); - + if (config.isDebugMode) { alert(errorMsg + '\n\nAvailable phases: ' + gameState.gameData.phases.map(p => p.name).join(', ')); } - + throw new Error(errorMsg); } @@ -198,7 +198,7 @@ function showRandomMoment() { _setPhase(phaseIndex); // Show the moment using the two-power conversation display - showTwoPowerConversation({ + showMomentModal({ power1: powerPair.power1, power2: powerPair.power2, moment: randomMoment, @@ -215,4 +215,4 @@ function showRandomMoment() { statusElement.textContent = `Showing: ${randomMoment.category}`; statusElement.style.color = '#4dabf7'; } -} \ No newline at end of file +} diff --git a/ai_animation/src/domElements.ts b/ai_animation/src/domElements.ts index 4e2aa37..51b0316 100644 --- a/ai_animation/src/domElements.ts +++ b/ai_animation/src/domElements.ts @@ -1,5 +1,7 @@ +import { ScheduledEvent } from "./events"; import { gameState } from "./gameState"; import { logger } from "./logger"; +import { GamePhase } from "./types/gameState"; /** * Helper function to get a DOM element by ID and throw an error if not found @@ -29,32 +31,31 @@ function getRequiredTypedElement(id: string): T { return element; } -export function updatePhaseDisplay() { - const currentPhase = gameState.gameData.phases[gameState.phaseIndex]; +export function createUpdateUIEvent(phase: GamePhase): ScheduledEvent { // Add fade-out effect phaseDisplay.style.transition = 'opacity 0.3s ease-out'; phaseDisplay.style.opacity = '0'; // Update text after fade-out - gameState.eventQueue.scheduleDelay(300, () => { - phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'}`; + let callback = () => { + phaseDisplay.textContent = `Era: ${phase.name || 'Unknown Era'}`; // Fade back in phaseDisplay.style.opacity = '1'; - }, `phase-display-update-${Date.now()}`); + } + return { id: `updateUI-${phase.name}`, callback } } + export function updateGameIdDisplay() { // Add fade-out effect gameIdDisplay.style.transition = 'opacity 0.3s ease-out'; gameIdDisplay.style.opacity = '0'; // Update text after fade-out - gameState.eventQueue.scheduleDelay(300, () => { - gameIdDisplay.textContent = `Game: ${gameState.gameId}`; - // Fade back in - gameIdDisplay.style.opacity = '1'; - }, `game-id-display-update-${Date.now()}`); + gameIdDisplay.textContent = `Game: ${gameState.gameId}`; + // Fade back in + gameIdDisplay.style.opacity = '1'; } export function loadGameBtnFunction(file: File) { diff --git a/ai_animation/src/domElements/chatWindows.ts b/ai_animation/src/domElements/chatWindows.ts index 020336e..2b88530 100644 --- a/ai_animation/src/domElements/chatWindows.ts +++ b/ai_animation/src/domElements/chatWindows.ts @@ -676,41 +676,3 @@ function playRandomSoundEffect() { } } -/** - * Appends text to the scrolling news banner. - * If the banner is at its default text or empty, replace it entirely. - * Otherwise, just append " | " + newText. - * @param newText Text to add to the news banner - */ -export function addToNewsBanner(newText: string): void { - const bannerEl = document.getElementById('news-banner-content'); - if (!bannerEl) { - console.warn("News banner element not found"); - return; - } - - if (config.isDebugMode) { - console.log(`Adding to news banner: "${newText}"`); - } - - // Add a fade-out transition - const transitionDuration = config.uiTransitionDuration; - bannerEl.style.transition = `opacity ${transitionDuration}s ease-out`; - bannerEl.style.opacity = '0'; - - gameState.eventQueue.scheduleDelay(config.uiFadeDelay, () => { - // 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; - } - - // Fade back in - bannerEl.style.opacity = '1'; - }, `banner-fade-in-${Date.now()}`); -} diff --git a/ai_animation/src/events.ts b/ai_animation/src/events.ts new file mode 100644 index 0000000..00bfd7d --- /dev/null +++ b/ai_animation/src/events.ts @@ -0,0 +1,77 @@ +/** + * Event queue system for deterministic animations + */ + +export interface ScheduledEvent { + id: string; + callback: () => void; + resolved?: boolean; + error?: Error; // If the event caused an error, store it here. +} + +export class EventQueue { + private events: ScheduledEvent[] = []; + private startTime: number = 0; + private isRunning: boolean = false; + + /** + * Start the event queue with current time as reference + */ + start(): void { + this.isRunning = true; + } + + /** + * Stop the event queue + */ + stop(): void { + this.isRunning = false; + } + + /** + * Reset the event queue, clearing all events + * @param resetCallback Optional callback to run after reset (for cleanup) + */ + reset(resetCallback?: () => void): void { + this.events = []; + this.isRunning = false; + if (resetCallback) { + resetCallback(); + } + } + + /** + * Add an event to the queue + */ + schedule(event: ScheduledEvent): void { + this.events.push(event); + } + + + /** + * Update the event queue, triggering events that are ready + */ + update(): void { + if (!this.isRunning) return; + if (this.events.length < 1) return; + + if (this.events[0].resolved) { + this.events.shift() + this.events[0].callback() + } + } + + /** + * Get remaining events count + */ + get pendingCount(): number { + return this.events.filter(e => !e.resolved).length; + } + + /** + * Get all events (for debugging) + */ + get allEvents(): ScheduledEvent[] { + return [...this.events]; + } +} diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index 637d58a..52b8eec 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -4,7 +4,7 @@ import type { GameSchemaType, Message } from "./types/gameState"; import { debugMenuInstance } from "./debug/debugMenu.ts" import { config } from "./config.ts" import { GameSchema, type MessageSchema } from "./types/gameState"; -import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay, updatePhaseDisplay } from "./domElements"; +import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay, createUpdateUIEvent } from "./domElements"; import { createChatWindows } from "./domElements/chatWindows"; import { logger } from "./logger"; import { OrbitControls } from "three/examples/jsm/Addons.js"; @@ -13,7 +13,8 @@ import { Tween, Group as TweenGroup } from "@tweenjs/tween.js"; import { MomentsDataSchema, Moment, NormalizedMomentsData } from "./types/moments"; import { updateLeaderboard } from "./components/leaderboard"; import { closeVictoryModal } from "./components/victoryModal.ts"; -import { EventQueue } from "./types/events"; +import { EventQueue } from "./events.ts"; +import { createAnimationsForNextPhase } from "./units/animate.ts"; //FIXME: This whole file is a mess. Need to organize and format @@ -80,6 +81,7 @@ function loadFileFromServer(filePath: string): Promise { }) }) } + function initializeBackgroundAudio(): Audio { // Create audio element @@ -185,12 +187,37 @@ class GameState { } set phaseIndex(val: number) { this._phaseIndex = val - updatePhaseDisplay() } get phaseIndex() { return this._phaseIndex } + _fillEventQueue = (gameData: GameSchemaType) => { + for (let phase of gameData.phases) { + // Update Phase Display + let updateUIEvent = createUpdateUIEvent(phase) + this.eventQueue.schedule(updateUIEvent) + + // News Banner Text + this.eventQueue.schedule(createNewsBannerUpdateEvent(phase)) + // Narrator Audio + this.eventQueue.schedule(createNarratorAudioEvent(phase)) + // Messages play first + let messageEvents = createMessageEvents(phase) + this.eventQueue.schedule(messageEvents) + + // Check if there is a moment to display + let phaseMoment = this.checkPhaseHasMoment(phase.name) + if (phaseMoment) { + this.eventQueue.schedule(createMomentEvent(phaseMoment)) + } + let animationEvents = createAnimationsForNextPhase(phase) + this.eventQueue.schedule(animationEvents) + + } + + + } /** * Load game data from a JSON string and initialize the game state * @param gameDataString JSON string containing game data @@ -355,7 +382,7 @@ class GameState { // Ensure any open overlays are cleaned up before loading next game if (this.isDisplayingMoment) { // Import at runtime to avoid circular dependency - import('./components/twoPowerConversation').then(({ closeTwoPowerConversation }) => { + import('./components/momentModal.ts').then(({ closeMomentModal: closeTwoPowerConversation }) => { closeTwoPowerConversation(true); }); } @@ -367,11 +394,6 @@ class GameState { } this.loadGameFile(gameId).then(() => { gameState.gameId = gameId - if (contPlaying) { - this.eventQueue.scheduleDelay(config.victoryModalDisplayMs, () => { - togglePlayback(true) - }, `load-next-game-playback-${Date.now()}`) - } }).catch(() => { console.warn("caught error trying to advance game. Setting gameId to 0 and restarting...") this.loadGameFile(0) diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index e32f606..d28b38f 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -36,8 +36,10 @@ function initScene() { gameState.loadBoardState().then(() => { initMap(gameState.scene).then(() => { + // TODO: Re-add the rotating display + // // Initialize rotating display - initRotatingDisplay(); + //initRotatingDisplay(); gameState.cameraPanAnim = createCameraPan() @@ -68,11 +70,7 @@ function initScene() { debugMenuInstance.show(); } if (isStreamingMode) { - gameState.eventQueue.start(); - gameState.eventQueue.scheduleDelay(2000, () => { - togglePlayback() - }, `streaming-mode-start-${Date.now()}`) } }) }).catch(err => { diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index 9acf60b..852a9b9 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -1,14 +1,15 @@ import { gameState } from "./gameState"; import { logger } from "./logger"; -import { updatePhaseDisplay, playBtn, prevBtn, nextBtn } from "./domElements"; +import { playBtn, prevBtn, nextBtn } from "./domElements"; import { initUnits } from "./units/create"; import { updateSupplyCenterOwnership, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state"; -import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows"; +import { updateChatWindows } from "./domElements/chatWindows"; +import { createUpdateNewsBannerEvent } from "./components/newsBanner"; import { createAnimationsForNextPhase } from "./units/animate"; import { speakSummary } from "./speech"; import { config } from "./config"; import { debugMenuInstance } from "./debug/debugMenu"; -import { showTwoPowerConversation, closeTwoPowerConversation } from "./components/twoPowerConversation"; +import { showMomentModal, closeMomentModal } from "./components/momentModal"; import { closeVictoryModal, showVictoryModal } from "./components/victoryModal"; import { notifyPhaseChange } from "./webhooks/phaseNotifier"; import { updateLeaderboard } from "./components/leaderboard"; @@ -91,7 +92,6 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { playBtn.textContent = "⏸ Pause"; prevBtn.disabled = true; nextBtn.disabled = true; - logger.log("Starting playback..."); // Start background audio when playback starts gameState.audio.play(); @@ -101,36 +101,19 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[1].start() - // First, show the messages of the current phase if it's the initial playback - if (gameState.currentPhase.messages && gameState.currentPhase.messages.length) { - // Show messages with stepwise animation - logger.log(`Playing ${gameState.currentPhase.messages.length} messages from phase ${gameState.phaseIndex + 1}/${gameState.gameData.phases.length}`); - displayPhase() - } else { - // No messages, go straight to unit animations - logger.log("No messages for this phase, proceeding to animations"); - } } else { if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[0].pause(); playBtn.textContent = "▶ Play"; - gameState.audio.pause() - // (playbackTimer is replaced by event queue system) // Stop background audio when pausing gameState.audio.pause(); // Ensure any open two-power conversations are closed when pausing - closeTwoPowerConversation(true); // immediate = true + closeMomentModal(true); // immediate = true // Stop and reset event queue when pausing with cleanup gameState.eventQueue.stop(); - gameState.eventQueue.reset(() => { - // Ensure proper state cleanup when events are canceled - gameState.messagesPlaying = false; - gameState.isAnimating = false; - }); - gameState.messagesPlaying = false; prevBtn.disabled = false; nextBtn.disabled = false; } @@ -138,10 +121,12 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { export function scheduleNextPhase() { + throw new Error("Function is deprecated") gameState.eventQueue.scheduleDelay(0, nextPhase) } export function scheduleSummarySpeech() { + throw new Error("Function is deprecated") // Delay speech in streaming mode gameState.eventQueue.scheduleDelay(config.speechDelay, () => { // Speak the summary and advance after @@ -159,15 +144,10 @@ export function nextPhase() { const power1 = moment.powers_involved[0]; const power2 = moment.powers_involved[1]; - showTwoPowerConversation({ + showMomentModal({ power1: power1, power2: power2, moment: moment, - onClose: () => { - // Schedule the speaking of the summary after the conversation closes - scheduleSummarySpeech(); - if (gameState.isPlaying) _setPhase(gameState.phaseIndex + 1) - } }) } else { // No conversation to show, proceed with normal flow @@ -216,13 +196,10 @@ export function displayPhase(skipMessages = false) { // Update UI elements with smooth transitions - updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower, true); + // TODO: Re-add the rotatingDisplay. Removed it as it won't fit in the eventQueue. + // updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower, true); _updateMapOwnership(); - // Add phase info to news banner if not already there - const phaseBannerText = `Phase: ${currentPhase.name}: ${currentPhase.summary}`; - addToNewsBanner(phaseBannerText); - // Log phase details to console only, don't update info panel with this const phaseInfo = `Phase: ${currentPhase.name}\nSCs: ${currentPhase.state?.centers ? JSON.stringify(currentPhase.state.centers) : 'None'}\nUnits: ${currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None'}`; console.log(phaseInfo); // Use console.log instead of logger.log @@ -231,7 +208,7 @@ export function displayPhase(skipMessages = false) { updateLeaderboard(); // Show messages with animation or immediately based on skipMessages flag - updateChatWindows(true, scheduleNextPhase); + //updateChatWindows(true, scheduleNextPhase); // Only animate if not the first phase and animations are requested if (!isFirstPhase && !skipMessages) { diff --git a/ai_animation/src/types/events.ts b/ai_animation/src/types/events.ts deleted file mode 100644 index e70d2e8..0000000 --- a/ai_animation/src/types/events.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Event queue system for deterministic animations - */ - -export interface ScheduledEvent { - id: string; - triggerAtTime: number; // Relative to startTime in seconds - callback: () => void; - resolved?: boolean; - priority?: number; // Higher numbers execute first for events at same time - error?: Error; // If the event caused an error, store it here. -} - -export class EventQueue { - private events: ScheduledEvent[] = []; - private startTime: number = 0; - private isRunning: boolean = false; - - /** - * Start the event queue with current time as reference - */ - start(): void { - this.startTime = performance.now(); - this.isRunning = true; - } - - /** - * Stop the event queue - */ - stop(): void { - this.isRunning = false; - } - - /** - * Reset the event queue, clearing all events - * @param resetCallback Optional callback to run after reset (for cleanup) - */ - reset(resetCallback?: () => void): void { - this.events = []; - this.isRunning = false; - if (resetCallback) { - resetCallback(); - } - } - - /** - * Add an event to the queue - */ - schedule(event: ScheduledEvent): void { - this.events.push(event); - // Keep events sorted by trigger time, then by priority - this.events.sort((a, b) => { - if (a.triggerAtTime === b.triggerAtTime) { - return (b.priority || 0) - (a.priority || 0); - } - return a.triggerAtTime - b.triggerAtTime; - }); - } - - /** - * Remove resolved events from the queue - */ - cleanup(): void { - this.events = this.events.filter(e => !e.resolved); - } - - /** - * Update the event queue, triggering events that are ready - */ - update(): void { - if (!this.isRunning) return; - - const now = performance.now(); - const elapsed = now - this.startTime; - - for (const event of this.events) { - if (!event.resolved && elapsed >= event.triggerAtTime) { - try { - - event.callback(); - } catch (err) { - // TODO: Need some system here to catch and report errors, but we mark them as resolved now so that we don't call an erroring fucntion repeatedly. - this.events.slice(this.events.indexOf(event), 1) - if (err instanceof Error) { - event.error = err - console.error(err) - } else { - console.error(`Got type "${typeof err} as error for event with id ${event.id}`) - console.error(err) - } - } finally { - event.resolved = true; - } - } - } - - // Clean up resolved events periodically - if (this.events.length > 0 && this.events.every(e => e.resolved)) { - this.cleanup(); - } - } - - /** - * Get remaining events count - */ - get pendingCount(): number { - return this.events.filter(e => !e.resolved).length; - } - - /** - * Get all events (for debugging) - */ - get allEvents(): ScheduledEvent[] { - return [...this.events]; - } - - /** - * Schedule a simple delay callback (like setTimeout) - */ - scheduleDelay(delayMs: number, callback: () => void, id?: string): void { - const now = performance.now(); - const elapsed = this.isRunning ? now - this.startTime : 0; - this.schedule({ - id: id || `delay-${Date.now()}-${Math.random()}`, - triggerAtTime: elapsed + (delayMs), // Schedule relative to current time - callback - }); - } - - /** - * Schedule a recurring event (like setInterval) - * Returns a function to cancel the recurring event - */ - scheduleRecurring(intervalMs: number, callback: () => void, id?: string): () => void { - let counter = 0; - const baseId = id || `recurring-${Date.now()}`; - const now = performance.now(); - const startElapsed = this.isRunning ? now - this.startTime : 0; - - const scheduleNext = () => { - counter++; - this.schedule({ - id: `${baseId}-${counter}`, - triggerAtTime: startElapsed + (intervalMs * counter), - callback: () => { - callback(); - scheduleNext(); // Schedule the next occurrence - } - }); - }; - - scheduleNext(); - - // Return cancel function - return () => { - // Mark all future events for this recurring schedule as resolved - this.events.forEach(event => { - if (event.id.startsWith(baseId) && !event.resolved) { - event.resolved = true; - } - }); - }; - } -}