mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-19 12:58:09 +00:00
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:
parent
d454c68044
commit
2b52a9cbf9
11 changed files with 584 additions and 94 deletions
182
ai_animation/src/backgroundAudio.test.ts
Normal file
182
ai_animation/src/backgroundAudio.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
149
ai_animation/src/types/events.ts
Normal file
149
ai_animation/src/types/events.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue