WIP: An attempt at a completly event queue driven game.

This is a lot of Claude having at the code base, there is no human
review here yet. That is to come.
This commit is contained in:
Tyler Marques 2025-06-09 21:15:02 -07:00
parent d454c68044
commit 2b52a9cbf9
No known key found for this signature in database
GPG key ID: CB99EDCF41D3016F
11 changed files with 584 additions and 94 deletions

View file

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

View file

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

View file

@ -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()}`);
}
}

View file

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

View file

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

View file

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

View file

@ -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()}`);
}

View file

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

View file

@ -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()}`);
}
});

View file

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

View file

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