diff --git a/ai_animation/src/backgroundAudio.test.ts b/ai_animation/src/backgroundAudio.test.ts new file mode 100644 index 0000000..6c575f8 --- /dev/null +++ b/ai_animation/src/backgroundAudio.test.ts @@ -0,0 +1,182 @@ +/** + * 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/backgroundAudio.ts b/ai_animation/src/backgroundAudio.ts index 2ec6acd..b3cdb11 100644 --- a/ai_animation/src/backgroundAudio.ts +++ b/ai_animation/src/backgroundAudio.ts @@ -41,10 +41,20 @@ export function initializeBackgroundAudio(): void { * Will only work after user interaction due to browser policies */ export function startBackgroundAudio(): void { - if (backgroundAudio && backgroundAudio.paused) { - backgroundAudio.play().catch(err => { - console.log('Background audio autoplay blocked, will retry on user interaction:', err); + if (!backgroundAudio) { + console.warn('Background audio not initialized'); + return; + } + + if (backgroundAudio.paused) { + console.log('Starting background audio...'); + backgroundAudio.play().then(() => { + console.log('Background audio started successfully'); + }).catch(err => { + console.warn('Background audio autoplay blocked, will retry on user interaction:', err); }); + } else { + console.log('Background audio already playing'); } } @@ -52,8 +62,16 @@ export function startBackgroundAudio(): void { * Stop background audio */ export function stopBackgroundAudio(): void { - if (backgroundAudio && !backgroundAudio.paused) { + if (!backgroundAudio) { + console.warn('Background audio not initialized'); + return; + } + + if (!backgroundAudio.paused) { + console.log('Stopping background audio...'); backgroundAudio.pause(); + } else { + console.log('Background audio already paused'); } } diff --git a/ai_animation/src/components/rotatingDisplay.ts b/ai_animation/src/components/rotatingDisplay.ts index 66b7d0b..b4a4300 100644 --- a/ai_animation/src/components/rotatingDisplay.ts +++ b/ai_animation/src/components/rotatingDisplay.ts @@ -125,7 +125,7 @@ export function updateRotatingDisplay( //containerElement.style.opacity = '0'; // Update content after fade-out - setTimeout(() => { + gameState.eventQueue.scheduleDelay(300, () => { // Render the appropriate view based on current display type switch (currentDisplayType) { case DisplayType.SC_HISTORY_CHART: @@ -141,7 +141,7 @@ export function updateRotatingDisplay( // Update last rendered type lastRenderedDisplayType = currentDisplayType; - }, 300); + }, `rotating-display-update-${Date.now()}`); } } diff --git a/ai_animation/src/components/twoPowerConversation.ts b/ai_animation/src/components/twoPowerConversation.ts index eee9d98..8f14d21 100644 --- a/ai_animation/src/components/twoPowerConversation.ts +++ b/ai_animation/src/components/twoPowerConversation.ts @@ -41,6 +41,26 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void return; } + // Mark as displaying moment immediately + gameState.isDisplayingMoment = true; + + // Schedule the conversation to be shown through event queue + gameState.eventQueue.scheduleDelay(0, () => { + showConversationModalSequence(power1, power2, title, moment, conversationMessages, onClose); + }, `show-conversation-${Date.now()}`); +} + +/** + * Shows the conversation modal and sequences all messages through the event queue + */ +function showConversationModalSequence( + power1: string, + power2: string, + title: string | undefined, + moment: Moment | undefined, + conversationMessages: ConversationMessage[], + onClose?: () => void +): void { // Create overlay dialogueOverlay = createDialogueOverlay(); @@ -60,28 +80,107 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void dialogueOverlay.appendChild(dialogueContainer); document.body.appendChild(dialogueOverlay); - gameState.isDisplayingMoment = true; - // Set up event listeners setupEventListeners(onClose); - // Animate messages - animateMessages(conversationArea, conversationMessages, power1, power2); + // Trigger fade in + gameState.eventQueue.scheduleDelay(10, () => dialogueOverlay!.style.opacity = '1', `fade-in-overlay-${Date.now()}`); + + // Schedule messages to be displayed sequentially through event queue + scheduleMessageSequence(conversationArea, conversationMessages, power1, power2); +} + +/** + * Schedules all messages to be displayed sequentially through the event queue + */ +function scheduleMessageSequence( + container: HTMLElement, + messages: ConversationMessage[], + power1: string, + power2: string +): void { + let currentDelay = 500; // Start after modal is fully visible + + // Calculate timing based on mode + const messageDisplayTime = config.isInstantMode ? 100 : config.effectivePlaybackSpeed; + const messageAnimationTime = config.isInstantMode ? 50 : 300; + + messages.forEach((message, index) => { + // Schedule each message display + gameState.eventQueue.scheduleDelay(currentDelay, () => { + displaySingleMessage(container, message, power1, power2); + }, `display-message-${index}-${Date.now()}`); + + // Increment delay for next message + currentDelay += messageDisplayTime + messageAnimationTime; + }); + + // Schedule conversation close after all messages are shown + const totalConversationTime = currentDelay + (config.isInstantMode ? 500 : 2000); // Extra delay before closing + gameState.eventQueue.scheduleDelay(totalConversationTime, () => { + closeTwoPowerConversation(); + + // After closing conversation, advance to next phase if playing + if (gameState.isPlaying) { + // Import _setPhase dynamically to avoid circular dependency + import('../phase').then(({ _setPhase }) => { + _setPhase(gameState.phaseIndex + 1); + }); + } + }, `close-conversation-after-messages-${Date.now()}`); +} + +/** + * Displays a single message with animation + */ +function displaySingleMessage( + container: HTMLElement, + message: ConversationMessage, + power1: string, + power2: string +): void { + const messageElement = createMessageElement(message, power1, power2); + container.appendChild(messageElement); + + // Animate message appearance + messageElement.style.opacity = '0'; + messageElement.style.transform = 'translateY(20px)'; + + // Use event queue for smooth animation + gameState.eventQueue.scheduleDelay(50, () => { + messageElement.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + messageElement.style.opacity = '1'; + messageElement.style.transform = 'translateY(0)'; + + // Scroll to bottom + container.scrollTop = container.scrollHeight; + }, `animate-message-${Date.now()}`); } /** * Closes the two-power conversation dialogue + * @param immediate If true, removes overlay immediately without animation */ -export function closeTwoPowerConversation(): void { +export function closeTwoPowerConversation(immediate: boolean = false): void { if (dialogueOverlay) { - dialogueOverlay.classList.add('fade-out'); - setTimeout(() => { + if (immediate) { + // Immediate cleanup for phase transitions if (dialogueOverlay?.parentNode) { dialogueOverlay.parentNode.removeChild(dialogueOverlay); } dialogueOverlay = null; gameState.isDisplayingMoment = false; - }, 300); + } else { + // Normal fade-out animation + dialogueOverlay.classList.add('fade-out'); + gameState.eventQueue.scheduleDelay(300, () => { + if (dialogueOverlay?.parentNode) { + dialogueOverlay.parentNode.removeChild(dialogueOverlay); + } + dialogueOverlay = null; + gameState.isDisplayingMoment = false; + }, `close-conversation-${Date.now()}`); + } } } @@ -131,7 +230,7 @@ function createDialogueOverlay(): HTMLElement { `; // Trigger fade in - setTimeout(() => overlay.style.opacity = '1', 10); + gameState.eventQueue.scheduleDelay(10, () => overlay.style.opacity = '1', `fade-in-overlay-${Date.now()}`); return overlay; } @@ -395,8 +494,15 @@ function setupEventListeners(onClose?: () => void): void { const closeButton = dialogueOverlay.querySelector('.close-button'); const handleClose = () => { - closeTwoPowerConversation(); + closeTwoPowerConversation(true); // immediate close for manual actions onClose?.(); + + // When manually closed, still advance phase if playing + if (gameState.isPlaying) { + import('../phase').then(({ _setPhase }) => { + _setPhase(gameState.phaseIndex + 1); + }); + } }; // Close button click @@ -419,37 +525,6 @@ function setupEventListeners(onClose?: () => void): void { }); } -/** - * Animates the display of messages in the conversation - */ -async function animateMessages( - container: HTMLElement, - messages: ConversationMessage[], - power1: string, - power2: string -): Promise { - for (const message of messages) { - const messageElement = createMessageElement(message, power1, power2); - container.appendChild(messageElement); - - // Animate message appearance - messageElement.style.opacity = '0'; - messageElement.style.transform = 'translateY(20px)'; - - await new Promise(resolve => { - setTimeout(() => { - messageElement.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; - messageElement.style.opacity = '1'; - messageElement.style.transform = 'translateY(0)'; - - // Scroll to bottom - container.scrollTop = container.scrollHeight; - - setTimeout(resolve, 300 + (1000 / config.effectivePlaybackSpeed)); - }, 100); - }); - } -} /** * Creates a message element for display diff --git a/ai_animation/src/components/victoryModal.ts b/ai_animation/src/components/victoryModal.ts index 828a795..24b058d 100644 --- a/ai_animation/src/components/victoryModal.ts +++ b/ai_animation/src/components/victoryModal.ts @@ -87,7 +87,7 @@ function createVictoryOverlay(): HTMLElement { document.head.appendChild(style); // Trigger fade in - setTimeout(() => overlay.style.opacity = '1', 10); + gameState.eventQueue.scheduleDelay(10, () => overlay.style.opacity = '1', `victory-modal-fade-in-${Date.now()}`); return overlay; } diff --git a/ai_animation/src/domElements.ts b/ai_animation/src/domElements.ts index 1903d88..4e2aa37 100644 --- a/ai_animation/src/domElements.ts +++ b/ai_animation/src/domElements.ts @@ -37,11 +37,11 @@ export function updatePhaseDisplay() { phaseDisplay.style.opacity = '0'; // Update text after fade-out - setTimeout(() => { + gameState.eventQueue.scheduleDelay(300, () => { phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'}`; // Fade back in phaseDisplay.style.opacity = '1'; - }, 300); + }, `phase-display-update-${Date.now()}`); } export function updateGameIdDisplay() { @@ -50,11 +50,11 @@ export function updateGameIdDisplay() { gameIdDisplay.style.opacity = '0'; // Update text after fade-out - setTimeout(() => { + gameState.eventQueue.scheduleDelay(300, () => { gameIdDisplay.textContent = `Game: ${gameState.gameId}`; // Fade back in gameIdDisplay.style.opacity = '1'; - }, 300); + }, `game-id-display-update-${Date.now()}`); } export function loadGameBtnFunction(file: File) { diff --git a/ai_animation/src/domElements/chatWindows.ts b/ai_animation/src/domElements/chatWindows.ts index ca9a767..058224a 100644 --- a/ai_animation/src/domElements/chatWindows.ts +++ b/ai_animation/src/domElements/chatWindows.ts @@ -267,7 +267,7 @@ export function updateChatWindows(stepMessages = false) { index++; // Only increment after animation completes // Schedule next message with proper delay - setTimeout(showNext, config.effectivePlaybackSpeed); + gameState.eventQueue.scheduleDelay(config.effectivePlaybackSpeed, showNext, `show-next-message-${index}-${Date.now()}`); }; // Add the message with word animation @@ -284,7 +284,7 @@ export function updateChatWindows(stepMessages = false) { }; // Start the message sequence with initial delay - setTimeout(showNext, 50); + gameState.eventQueue.scheduleDelay(50, showNext, `start-message-sequence-${Date.now()}`); } } @@ -409,11 +409,11 @@ function animateMessageWords(message: string, contentSpanId: string, targetPower console.log(`Finished animating message with ${words.length} words in ${targetPower} chat`); // Add a slight delay after the last word for readability - setTimeout(() => { + gameState.eventQueue.scheduleDelay(Math.min(config.effectivePlaybackSpeed / 3, 150), () => { if (onComplete) { onComplete(); // Call the completion callback } - }, Math.min(config.effectivePlaybackSpeed / 3, 150)); + }, `message-complete-${Date.now()}`); return; } @@ -433,7 +433,7 @@ function animateMessageWords(message: string, contentSpanId: string, targetPower // In streaming mode, use a more consistent delay to prevent overlap const baseDelay = config.effectivePlaybackSpeed const delay = Math.max(50, Math.min(200, baseDelay * (wordLength / 4))); - setTimeout(addNextWord, delay); + gameState.eventQueue.scheduleDelay(delay, addNextWord, `add-word-${wordIndex}-${Date.now()}`); // Scroll to ensure newest content is visible // Use requestAnimationFrame to batch DOM updates in streaming mode @@ -749,7 +749,7 @@ export function addToNewsBanner(newText: string): void { bannerEl.style.transition = `opacity ${transitionDuration}s ease-out`; bannerEl.style.opacity = '0'; - setTimeout(() => { + gameState.eventQueue.scheduleDelay(config.isInstantMode ? 0 : 300, () => { // If the banner only has the default text or is empty, replace it if ( bannerEl.textContent?.trim() === 'Diplomatic actions unfolding...' || @@ -763,5 +763,5 @@ export function addToNewsBanner(newText: string): void { // Fade back in bannerEl.style.opacity = '1'; - }, config.isInstantMode ? 0 : 300); + }, `banner-fade-in-${Date.now()}`); } diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index 0d7c1ce..54bf96d 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -13,6 +13,7 @@ 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"; //FIXME: This whole file is a mess. Need to organize and format @@ -112,9 +113,6 @@ class GameState { // Animations needed for this turn unitAnimations: Tween[] - // - playbackTimer!: number - // Camera Animation during playing cameraPanAnim!: TweenGroup @@ -122,6 +120,9 @@ class GameState { globalTime: number deltaTime: number + // Event queue for deterministic animations + eventQueue: EventQueue + constructor(boardName: AvailableMaps) { this.phaseIndex = 0 this.boardName = boardName @@ -141,6 +142,7 @@ class GameState { this.unitAnimations = [] this.globalTime = 0 this.deltaTime = 0 + this.eventQueue = new EventQueue() this.loadBoardState() } @@ -303,6 +305,13 @@ class GameState { * Loads the next game in the order, reseting the board and gameState */ loadNextGame = (setPlayback: boolean = false) => { + // 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 }) => { + closeTwoPowerConversation(true); + }); + } let gameId = this.gameId + 1 let contPlaying = false @@ -312,9 +321,9 @@ class GameState { this.loadGameFile(gameId).then(() => { gameState.gameId = gameId if (contPlaying) { - setTimeout(() => { + this.eventQueue.scheduleDelay(config.victoryModalDisplayMs, () => { togglePlayback(true) - }, config.victoryModalDisplayMs) + }, `load-next-game-playback-${Date.now()}`) } }).catch(() => { console.warn("caught error trying to advance game. Setting gameId to 0 and restarting...") diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index 7b1d31e..2c0733b 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -46,10 +46,19 @@ function initScene() { updateLeaderboard(); if (phaseStartIdx !== undefined) { - setTimeout(() => { + gameState.eventQueue.start(); + gameState.eventQueue.scheduleDelay(500, () => { // FIXME: Race condition waiting to happen. I'm delaying this call as I'm too tired to do this properly right now. _setPhase(phaseStartIdx) - }, 500) + }, `phase-start-delay-${Date.now()}`) + } else if (!isStreamingMode && !config.isTestingMode) { + // Auto-start playback for normal mode (not streaming, no specific phase, not testing) + // Note: Background audio will start when togglePlayback is called (which provides user interaction context) + gameState.eventQueue.start(); + gameState.eventQueue.scheduleDelay(1000, () => { + console.log("Auto-starting playback after game load"); + togglePlayback(true); // true = explicitly set to playing + }, `auto-start-playback-${Date.now()}`); } }) @@ -60,9 +69,10 @@ function initScene() { } if (isStreamingMode) { startBackgroundAudio() - setTimeout(() => { + gameState.eventQueue.start(); + gameState.eventQueue.scheduleDelay(2000, () => { togglePlayback() - }, 2000) + }, `streaming-mode-start-${Date.now()}`) } }) }).catch(err => { @@ -117,9 +127,11 @@ function createCameraPan(): Group { * Handles camera movement, animations, and game state transitions */ function animate() { - - requestAnimationFrame(animate); + + // Update event queue + gameState.eventQueue.update(); + if (gameState.isPlaying) { // Update the camera angle // FIXME: This has to call the update functino twice inorder to avoid a bug in Tween.js, see here https://github.com/tweenjs/tween.js/issues/677 @@ -128,10 +140,10 @@ function animate() { // If all animations are complete if (gameState.unitAnimations.length === 0 && !gameState.messagesPlaying && !gameState.isSpeaking && !gameState.nextPhaseScheduled) { - // Schedule next phase after a pause delay + // Schedule next phase after a pause delay using event queue console.log(`Scheduling next phase in ${config.effectivePlaybackSpeed}ms`); gameState.nextPhaseScheduled = true; - gameState.playbackTimer = setTimeout(() => { + gameState.eventQueue.scheduleDelay(config.effectivePlaybackSpeed, () => { try { advanceToNextPhase() } catch { @@ -139,7 +151,7 @@ function animate() { // We should instead bee figuring out why units aren't where we expect them to be when the engine has said that is a valid move nextPhase() } - }, config.effectivePlaybackSpeed); + }, `next-phase-${Date.now()}`); } } else { // Manual camera controls when not in playback mode @@ -219,14 +231,27 @@ nextBtn.addEventListener('click', () => { nextPhase() }); -playBtn.addEventListener('click', () => { togglePlayback() }); +playBtn.addEventListener('click', () => { + // Ensure background audio is ready when user manually clicks play + startBackgroundAudio(); + togglePlayback(); +}); speedSelector.addEventListener('change', e => { config.playbackSpeed = parseInt(e.target.value); - // If we're currently playing, restart the timer with the new speed - if (gameState.isPlaying && gameState.playbackTimer) { - clearTimeout(gameState.playbackTimer); - gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.effectivePlaybackSpeed); + // If we're currently playing, restart the event queue with the new speed + if (gameState.isPlaying) { + // Reset and restart event queue to pick up new speed + gameState.eventQueue.reset(); + gameState.eventQueue.start(); + gameState.nextPhaseScheduled = true; + gameState.eventQueue.scheduleDelay(config.effectivePlaybackSpeed, () => { + try { + advanceToNextPhase() + } catch { + nextPhase() + } + }, `speed-change-next-phase-${Date.now()}`); } }); diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index 90e0708..ed84ad8 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -13,6 +13,7 @@ import { closeVictoryModal, showVictoryModal } from "./components/victoryModal"; import { notifyPhaseChange } from "./webhooks/phaseNotifier"; import { updateLeaderboard } from "./components/leaderboard"; import { updateRotatingDisplay } from "./components/rotatingDisplay"; +import { startBackgroundAudio, stopBackgroundAudio } from "./backgroundAudio"; const MOMENT_THRESHOLD = 8.0 // If we're in debug mode or instant mode, show it quick, otherwise show it for 30 seconds @@ -46,12 +47,25 @@ export function _setPhase(phaseIndex: number) { displayPhase(true) } else { // Clear any existing animations to prevent overlap - if (gameState.playbackTimer) { - clearTimeout(gameState.playbackTimer); - gameState.playbackTimer = 0; + // (playbackTimer is replaced by event queue system) + + // Ensure any open two-power conversations are closed immediately before resetting event queue + if (gameState.isDisplayingMoment) { + closeTwoPowerConversation(true); // immediate = true } - // Reset animation state + // Reset event queue for new phase with cleanup callback + gameState.eventQueue.reset(() => { + // Ensure proper state cleanup when events are canceled + gameState.messagesPlaying = false; + gameState.isAnimating = false; + }); + + if (gameState.isPlaying) { + gameState.eventQueue.start(); + } + + // Reset animation state (redundant but kept for clarity) gameState.isAnimating = false; gameState.messagesPlaying = false; @@ -100,6 +114,12 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { nextBtn.disabled = true; logger.log("Starting playback..."); + // Start background audio when playback starts + startBackgroundAudio(); + + // Start event queue for deterministic animations + gameState.eventQueue.start(); + if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[1].start() // First, show the messages of the current phase if it's the initial playback @@ -115,10 +135,24 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { } else { if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[0].pause(); playBtn.textContent = "▶ Play"; - if (gameState.playbackTimer) { - clearTimeout(gameState.playbackTimer); - gameState.playbackTimer = null; + // (playbackTimer is replaced by event queue system) + + // Stop background audio when pausing + stopBackgroundAudio(); + + // Ensure any open two-power conversations are closed when pausing + if (gameState.isDisplayingMoment) { + closeTwoPowerConversation(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; @@ -141,11 +175,9 @@ export function nextPhase() { moment: moment }) if (gameState.isPlaying) { - - setTimeout(() => { - closeTwoPowerConversation() - _setPhase(gameState.phaseIndex + 1) - }, MOMENT_DISPLAY_TIMEOUT_MS) + // The conversation will close itself and then advance the phase + // We don't need to schedule a timeout here anymore since the conversation manages its own timing + console.log("Two-power conversation will handle its own timing and phase advancement"); } else { _setPhase(gameState.phaseIndex + 1) } @@ -293,7 +325,7 @@ export function advanceToNextPhase() { // First show summary if available if (currentPhase.summary && currentPhase.summary.trim() !== '') { // Delay speech in streaming mode - setTimeout(() => { + gameState.eventQueue.scheduleDelay(speechDelay, () => { // Speak the summary and advance after if (!gameState.isSpeaking) { speakSummary(currentPhase.summary) @@ -314,7 +346,7 @@ export function advanceToNextPhase() { } else { console.error("Attempted to start speaking when already speaking..."); } - }, speechDelay); + }, `speech-delay-${Date.now()}`); } else { console.log("No summary available, skipping speech"); // No summary to speak, advance immediately @@ -364,9 +396,9 @@ function displayFinalPhase() { maxCenters, finalStandings, }); - setTimeout(() => { + gameState.eventQueue.scheduleDelay(config.victoryModalDisplayMs, () => { gameState.loadNextGame(true) - }, config.victoryModalDisplayMs) + }, `victory-modal-timeout-${Date.now()}`) } else { logger.log("Could not determine game winner"); diff --git a/ai_animation/src/types/events.ts b/ai_animation/src/types/events.ts new file mode 100644 index 0000000..61cfdee --- /dev/null +++ b/ai_animation/src/types/events.ts @@ -0,0 +1,149 @@ +/** + * 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 +} + +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() / 1000; + 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(event => !event.resolved); + } + + /** + * Update the event queue, triggering events that are ready + */ + update(): void { + if (!this.isRunning) return; + + const now = performance.now() / 1000; + const elapsed = now - this.startTime; + + for (const event of this.events) { + if (!event.resolved && elapsed >= event.triggerAtTime) { + event.callback(); + 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() / 1000; + const elapsed = this.isRunning ? now - this.startTime : 0; + this.schedule({ + id: id || `delay-${Date.now()}-${Math.random()}`, + triggerAtTime: elapsed + (delayMs / 1000), // 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() / 1000; + const startElapsed = this.isRunning ? now - this.startTime : 0; + + const scheduleNext = () => { + counter++; + this.schedule({ + id: `${baseId}-${counter}`, + triggerAtTime: startElapsed + (intervalMs * counter) / 1000, + 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; + } + }); + }; + } +} \ No newline at end of file