WIP Creating pre-filled event queue

This commit is contained in:
Tyler Marques 2025-08-06 10:37:31 -07:00
parent 44ace48402
commit ea4025aa73
No known key found for this signature in database
GPG key ID: CB99EDCF41D3016F
13 changed files with 245 additions and 492 deletions

View file

@ -1,182 +0,0 @@
/**
* Tests for background audio looping functionality
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Mock HTMLAudioElement
class MockAudio {
loop = false;
volume = 1;
paused = true;
src = '';
currentTime = 0;
duration = 10;
constructor() {
// Constructor can be empty or set default values
}
play = vi.fn().mockResolvedValue(undefined);
pause = vi.fn();
addEventListener = vi.fn();
}
describe('Background Audio Looping', () => {
let originalAudio: any;
beforeEach(() => {
// Store original and replace with mock
originalAudio = global.Audio;
global.Audio = MockAudio as any;
// Clear any module state by re-importing
vi.resetModules();
});
afterEach(() => {
// Restore original Audio constructor
global.Audio = originalAudio;
});
it('should set loop property to true during initialization', async () => {
// Import fresh module to avoid state pollution
const { initializeBackgroundAudio } = await import('./backgroundAudio');
// Initialize background audio
initializeBackgroundAudio();
// The actual test is that initialization doesn't throw and sets up the audio correctly
// We can't directly access the private audio instance, but we can test the behavior
expect(global.Audio).toBeDefined();
});
it('should handle audio loop property correctly', () => {
// Create a mock audio instance to test loop behavior
const audioElement = new MockAudio() as any;
// Set loop to true (like our code does)
audioElement.loop = true;
audioElement.duration = 5; // 5 second track
// Simulate audio playing and ending
audioElement.paused = false;
audioElement.currentTime = 0;
// Simulate what happens when audio reaches the end
audioElement.currentTime = audioElement.duration;
// With loop=true, browser automatically restarts
const simulateLoopBehavior = () => {
if (audioElement.loop && !audioElement.paused && audioElement.currentTime >= audioElement.duration) {
audioElement.currentTime = 0; // Browser resets to start
return true; // Indicates loop occurred
}
return false;
};
// Test loop behavior
const looped = simulateLoopBehavior();
expect(audioElement.loop).toBe(true);
expect(looped).toBe(true);
expect(audioElement.currentTime).toBe(0); // Should be reset to start
});
it('should verify loop property is essential for continuous playback', () => {
const audioWithLoop = new MockAudio() as any;
const audioWithoutLoop = new MockAudio() as any;
// Setup both audio elements
audioWithLoop.loop = true;
audioWithoutLoop.loop = false;
audioWithLoop.duration = 10;
audioWithoutLoop.duration = 10;
// Both start playing
audioWithLoop.paused = false;
audioWithoutLoop.paused = false;
// Both reach the end
audioWithLoop.currentTime = audioWithLoop.duration;
audioWithoutLoop.currentTime = audioWithoutLoop.duration;
// Simulate end behavior
const testLooping = (audio: any) => {
if (audio.currentTime >= audio.duration) {
if (audio.loop) {
audio.currentTime = 0; // Loop back to start
return 'looped';
} else {
audio.paused = true; // Stop playing
return 'stopped';
}
}
return 'playing';
};
const resultWithLoop = testLooping(audioWithLoop);
const resultWithoutLoop = testLooping(audioWithoutLoop);
expect(resultWithLoop).toBe('looped');
expect(resultWithoutLoop).toBe('stopped');
expect(audioWithLoop.currentTime).toBe(0); // Reset to start
expect(audioWithoutLoop.paused).toBe(true); // Stopped
});
it('should test the actual module behavior', async () => {
// Import fresh module
const { initializeBackgroundAudio, startBackgroundAudio, stopBackgroundAudio } = await import('./backgroundAudio');
// Test initialization doesn't throw
expect(() => initializeBackgroundAudio()).not.toThrow();
// Test double initialization protection
expect(() => initializeBackgroundAudio()).toThrow('Attempted to init audio twice.');
});
it('should demonstrate loop property importance with realistic scenario', () => {
// This test demonstrates why loop=true is critical for background music
const backgroundTrack = new MockAudio() as any;
// Our code sets this to true
backgroundTrack.loop = true;
backgroundTrack.volume = 0.4;
backgroundTrack.src = './sounds/background_ambience.mp3';
backgroundTrack.duration = 30; // 30 second ambient track
// Start playing
backgroundTrack.paused = false;
backgroundTrack.currentTime = 0;
// Simulate game running for longer than track duration
const gameRuntime = 90; // 90 seconds
const timeStep = 1; // 1 second steps
let currentGameTime = 0;
let trackRestarts = 0;
while (currentGameTime < gameRuntime) {
currentGameTime += timeStep;
backgroundTrack.currentTime += timeStep;
// Check if track ended and needs to loop
if (backgroundTrack.currentTime >= backgroundTrack.duration) {
if (backgroundTrack.loop) {
backgroundTrack.currentTime = 0; // Restart
trackRestarts++;
} else {
backgroundTrack.paused = true; // Would stop without loop
break;
}
}
}
// With a 30-second track and 90-second game, we expect 3 restarts (0-30, 30-60, 60-90)
expect(backgroundTrack.loop).toBe(true);
expect(trackRestarts).toBe(3);
expect(backgroundTrack.paused).toBe(false); // Still playing
expect(currentGameTime).toBe(gameRuntime); // Game completed full duration
});
});

View file

@ -32,7 +32,7 @@ vi.mock('../gameState', () => {
start: vi.fn(),
stop: vi.fn()
};
return {
gameState: {
isPlaying: false,
@ -91,7 +91,7 @@ Object.defineProperty(global, 'document', {
});
// Import after all mocking
import { showTwoPowerConversation, closeTwoPowerConversation } from './twoPowerConversation';
import { showMomentModal, closeMomentModal } from './momentModal';
// Get direct reference to the mocked gameState
let gameState: any;
@ -101,13 +101,13 @@ describe('twoPowerConversation', () => {
// Get the mocked gameState
const gameStateModule = await import('../gameState');
gameState = gameStateModule.gameState;
// Reset mocked game state
gameState.isPlaying = false;
gameState.isDisplayingMoment = false;
gameState.phaseIndex = 0;
gameState.gameData.phases[0].messages = [];
// Reset all mocks
vi.clearAllMocks();
gameState.eventQueue.pendingEvents = [];
@ -116,9 +116,9 @@ describe('twoPowerConversation', () => {
describe('showTwoPowerConversation', () => {
it('should throw error when no messages found (indicates data quality issue)', () => {
gameState.isPlaying = true;
expect(() => {
showTwoPowerConversation({
showMomentModal({
power1: 'FRANCE',
power2: 'GERMANY'
});
@ -127,10 +127,10 @@ describe('twoPowerConversation', () => {
it('should throw error when empty messages array provided', () => {
gameState.isPlaying = false;
expect(() => {
showTwoPowerConversation({
power1: 'FRANCE',
showMomentModal({
power1: 'FRANCE',
power2: 'GERMANY',
messages: []
});
@ -143,14 +143,14 @@ describe('twoPowerConversation', () => {
{ sender: 'FRANCE', recipient: 'GERMANY', message: 'Hello', time_sent: '1' }
];
showTwoPowerConversation({
showMomentModal({
power1: 'FRANCE',
power2: 'GERMANY'
});
// Should have scheduled events in the queue
expect(gameState.eventQueue.scheduleDelay).toHaveBeenCalled();
// Should have marked as displaying moment
expect(gameState.isDisplayingMoment).toBe(true);
});
@ -163,24 +163,24 @@ describe('twoPowerConversation', () => {
{ sender: 'FRANCE', recipient: 'GERMANY', message: 'Test', time_sent: '1' }
];
showTwoPowerConversation({
showMomentModal({
power1: 'FRANCE',
power2: 'GERMANY'
});
// Should have scheduled events in the queue
expect(gameState.eventQueue.scheduleDelay).toHaveBeenCalled();
// Should have marked as displaying moment
expect(gameState.isDisplayingMoment).toBe(true);
});
it('should throw error for empty message arrays as they indicate data quality issues', () => {
gameState.isPlaying = true;
// Test with empty messages - should throw error
expect(() => {
showTwoPowerConversation({
showMomentModal({
power1: 'FRANCE',
power2: 'GERMANY',
messages: []
@ -188,4 +188,4 @@ describe('twoPowerConversation', () => {
}).toThrow('High-interest moment detected between FRANCE and GERMANY but no messages found');
});
});
});
});

View file

@ -4,8 +4,9 @@ import { getPowerDisplayName } from '../utils/powerNames';
import { PowerENUM } from '../types/map';
import { Moment } from '../types/moments';
import { Message } from '../types/gameState';
import { ScheduledEvent } from '../events.ts'
interface TwoPowerDialogueOptions {
interface MomentDialogueOptions {
moment: Moment;
power1?: PowerENUM;
power2?: PowerENUM;
@ -20,11 +21,11 @@ let dialogueOverlay: HTMLElement | null = null;
* Shows a dialogue box displaying conversation between two powers
* @param options Configuration for the dialogue display
*/
export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void {
export function showMomentModal(options: MomentDialogueOptions): void {
const { moment, power1, power2, title, onClose } = options;
// Close any existing dialogue
closeTwoPowerConversation();
closeMomentModal();
if (moment.raw_messages.length === 0) {
throw new Error(
@ -36,6 +37,16 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void
showConversationModalSequence(title, moment, onClose);
}
export function createMomentEvent(moment: Moment): ScheduledEvent {
return {
id: `moment-${moment.phase}`,
callback: () => showMomentModal({ moment }),
triggerAtTime
}
}
/**
* Shows the conversation modal and sequences all messages through the event queue
*/
@ -100,7 +111,7 @@ function scheduleMessageSequence(
// Schedule conversation close after all messages are shown
gameState.eventQueue.scheduleDelay(config.conversationFinalDelay, () => {
console.log('Closing two-power conversation and calling onClose callback');
closeTwoPowerConversation();
closeMomentModal();
if (callbackOnClose) callbackOnClose();
}, `close-conversation-after-messages-${Date.now()}`);
return;
@ -162,7 +173,7 @@ function displaySingleMessage(
* Closes the two-power conversation dialogue
* @param immediate If true, removes overlay immediately without animation
*/
export function closeTwoPowerConversation(immediate: boolean = false): void {
export function closeMomentModal(immediate: boolean = false): void {
dialogueOverlay = document.getElementById("dialogue-overlay")
if (dialogueOverlay) {
if (immediate) {
@ -214,9 +225,7 @@ function createDialogueContainer(power1: string, power2: string, title?: string,
const container = document.createElement('div');
container.className = 'dialogue-container';
container.style.cssText = `
background: radial-gradient(ellipse at center, #f7ecd1 0%, #dbc08c 100%);
border: 3px solid #4f3b16;
border-radius: 8px;
background: radial-gradient(ellipse at center, #f7ecd1 0%, #dbc08w8px;
box-shadow: 0 0 15px rgba(0,0,0,0.5);
width: 90%;
height: 85%;
@ -466,7 +475,7 @@ function setupEventListeners(onClose?: () => void): void {
const closeButton = dialogueOverlay.querySelector('.close-button');
const handleClose = () => {
closeTwoPowerConversation(true); // immediate close for manual actions
closeMomentModal(true); // immediate close for manual actions
onClose?.();
// When manually closed, still advance phase if playing

View file

@ -0,0 +1,53 @@
import { gameState } from "../gameState";
import { config } from "../config";
import { GamePhase } from "../types/gameState";
import { ScheduledEvent } from "../events";
const bannerEl = document.getElementById('news-banner-content');
if (!bannerEl) throw Error("News banner not properly initialized")
export function createUpdateNewsBannerEvent(phase: GamePhase): ScheduledEvent {
return { id: `updateNewsBanner-${phase.name}`, callback: () => addToNewsBanner(phase.summary) }
}
function clearNewsBanner() {
bannerEl.textContent = ''
}
/**
* Appends text to the scrolling news banner.
* If the banner is at its default text or empty, replace it entirely.
* Otherwise, just append " | " + newText.
* @param newText Text to add to the news banner
*/
function addToNewsBanner(newText: string): void {
if (!bannerEl) {
console.warn("News banner element not found");
return;
}
if (config.isDebugMode) {
console.log(`Adding to news banner: "${newText}"`);
}
// Add a fade-out transition
const transitionDuration = config.uiTransitionDuration;
bannerEl.style.transition = `opacity ${transitionDuration}s ease-out`;
bannerEl.style.opacity = '0';
gameState.eventQueue.scheduleDelay(config.uiFadeDelay, () => {
// If the banner only has the default text or is empty, replace it
if (
bannerEl.textContent?.trim() === 'Diplomatic actions unfolding...' ||
bannerEl.textContent?.trim() === ''
) {
bannerEl.textContent = newText;
} else {
// Otherwise append with a separator
bannerEl.textContent += ' | ' + newText;
}
// Fade back in
bannerEl.style.opacity = '1';
}, `banner-fade-in-${Date.now()}`);
}

View file

@ -1,6 +1,7 @@
import { gameState } from "../gameState";
import { PowerENUM } from "../types/map";
import { GameSchemaType } from "../types/gameState";
import { ScheduledEvent } from "../events";
// Enum for the different display types
export enum DisplayType {
@ -104,15 +105,14 @@ function rotateToNextDisplay(): void {
* @param currentPlayerPower The power the current player is controlling
* @param forceUpdate Whether to force a full update even if the display type hasn't changed
*/
export function updateRotatingDisplay(
export function createUpdateRotatingDisplayEvent(
gameData: GameSchemaType,
currentPhaseIndex: number,
currentPlayerPower: PowerENUM,
forceUpdate: boolean = false
): void {
): ScheduledEvent {
if (!isInitialized || !containerElement) {
console.warn("Rotating display not initialized");
return;
throw Error("Rotating display is not initialized and cannot have an event created")
}
// Check if we need to do a full re-render

View file

@ -4,7 +4,7 @@
*/
import { gameState } from '../gameState';
import { showTwoPowerConversation } from '../components/twoPowerConversation';
import { showMomentModal } from '../components/momentModal';
import { Moment } from '../types/moments';
import { _setPhase } from '../phase';
import { config } from '../config';
@ -33,11 +33,11 @@ export function initShowRandomMomentTool(debugMenu: any) {
// Add button functionality
const showBtn = document.getElementById('debug-show-random-moment');
const refreshBtn = document.getElementById('debug-refresh-moment-list');
if (showBtn) {
showBtn.addEventListener('click', showRandomMoment);
}
if (refreshBtn) {
refreshBtn.addEventListener('click', updateMomentStatus);
}
@ -89,21 +89,21 @@ function getEligibleMoments(): Moment[] {
for (let j = i + 1; j < moment.powers_involved.length; j++) {
const power1 = moment.powers_involved[i].toUpperCase();
const power2 = moment.powers_involved[j].toUpperCase();
const hasConversation = phase.messages.some(msg => {
const sender = msg.sender?.toUpperCase();
const recipient = msg.recipient?.toUpperCase();
return (sender === power1 && recipient === power2) ||
(sender === power2 && recipient === power1);
(sender === power2 && recipient === power1);
});
if (hasConversation) {
return true;
}
}
}
return false;
});
}
@ -126,21 +126,21 @@ function findBestPowerPairForMoment(moment: Moment): { power1: string; power2: s
for (let j = i + 1; j < moment.powers_involved.length; j++) {
const power1 = moment.powers_involved[i].toUpperCase();
const power2 = moment.powers_involved[j].toUpperCase();
const messageCount = phase.messages.filter(msg => {
const sender = msg.sender?.toUpperCase();
const recipient = msg.recipient?.toUpperCase();
return (sender === power1 && recipient === power2) ||
(sender === power2 && recipient === power1);
(sender === power2 && recipient === power1);
}).length;
if (messageCount > 0 && (!bestPair || messageCount > bestPair.messageCount)) {
bestPair = { power1, power2, messageCount };
}
}
}
return bestPair;
}
@ -149,7 +149,7 @@ function findBestPowerPairForMoment(moment: Moment): { power1: string; power2: s
*/
function showRandomMoment() {
const eligibleMoments = getEligibleMoments();
if (eligibleMoments.length === 0) {
console.warn('No eligible moments found for conversation display');
const statusElement = document.getElementById('debug-moment-status');
@ -166,10 +166,10 @@ function showRandomMoment() {
// Pick a random moment
const randomMoment = eligibleMoments[Math.floor(Math.random() * eligibleMoments.length)];
// Find the best power pair for this moment
const powerPair = findBestPowerPairForMoment(randomMoment);
if (!powerPair) {
console.warn('No valid power pair found for selected moment');
return;
@ -181,15 +181,15 @@ function showRandomMoment() {
// Find the phase index for this moment
const phaseIndex = gameState.gameData.phases.findIndex(p => p.name === randomMoment.phase);
if (phaseIndex === -1) {
const errorMsg = `CRITICAL ERROR: Phase ${randomMoment.phase} from moment data not found in game data! This indicates a serious data integrity issue.`;
console.error(errorMsg);
if (config.isDebugMode) {
alert(errorMsg + '\n\nAvailable phases: ' + gameState.gameData.phases.map(p => p.name).join(', '));
}
throw new Error(errorMsg);
}
@ -198,7 +198,7 @@ function showRandomMoment() {
_setPhase(phaseIndex);
// Show the moment using the two-power conversation display
showTwoPowerConversation({
showMomentModal({
power1: powerPair.power1,
power2: powerPair.power2,
moment: randomMoment,
@ -215,4 +215,4 @@ function showRandomMoment() {
statusElement.textContent = `Showing: ${randomMoment.category}`;
statusElement.style.color = '#4dabf7';
}
}
}

View file

@ -1,5 +1,7 @@
import { ScheduledEvent } from "./events";
import { gameState } from "./gameState";
import { logger } from "./logger";
import { GamePhase } from "./types/gameState";
/**
* Helper function to get a DOM element by ID and throw an error if not found
@ -29,32 +31,31 @@ function getRequiredTypedElement<T extends HTMLElement>(id: string): T {
return element;
}
export function updatePhaseDisplay() {
const currentPhase = gameState.gameData.phases[gameState.phaseIndex];
export function createUpdateUIEvent(phase: GamePhase): ScheduledEvent {
// Add fade-out effect
phaseDisplay.style.transition = 'opacity 0.3s ease-out';
phaseDisplay.style.opacity = '0';
// Update text after fade-out
gameState.eventQueue.scheduleDelay(300, () => {
phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'}`;
let callback = () => {
phaseDisplay.textContent = `Era: ${phase.name || 'Unknown Era'}`;
// Fade back in
phaseDisplay.style.opacity = '1';
}, `phase-display-update-${Date.now()}`);
}
return { id: `updateUI-${phase.name}`, callback }
}
export function updateGameIdDisplay() {
// Add fade-out effect
gameIdDisplay.style.transition = 'opacity 0.3s ease-out';
gameIdDisplay.style.opacity = '0';
// Update text after fade-out
gameState.eventQueue.scheduleDelay(300, () => {
gameIdDisplay.textContent = `Game: ${gameState.gameId}`;
// Fade back in
gameIdDisplay.style.opacity = '1';
}, `game-id-display-update-${Date.now()}`);
gameIdDisplay.textContent = `Game: ${gameState.gameId}`;
// Fade back in
gameIdDisplay.style.opacity = '1';
}
export function loadGameBtnFunction(file: File) {

View file

@ -676,41 +676,3 @@ function playRandomSoundEffect() {
}
}
/**
* Appends text to the scrolling news banner.
* If the banner is at its default text or empty, replace it entirely.
* Otherwise, just append " | " + newText.
* @param newText Text to add to the news banner
*/
export function addToNewsBanner(newText: string): void {
const bannerEl = document.getElementById('news-banner-content');
if (!bannerEl) {
console.warn("News banner element not found");
return;
}
if (config.isDebugMode) {
console.log(`Adding to news banner: "${newText}"`);
}
// Add a fade-out transition
const transitionDuration = config.uiTransitionDuration;
bannerEl.style.transition = `opacity ${transitionDuration}s ease-out`;
bannerEl.style.opacity = '0';
gameState.eventQueue.scheduleDelay(config.uiFadeDelay, () => {
// If the banner only has the default text or is empty, replace it
if (
bannerEl.textContent?.trim() === 'Diplomatic actions unfolding...' ||
bannerEl.textContent?.trim() === ''
) {
bannerEl.textContent = newText;
} else {
// Otherwise append with a separator
bannerEl.textContent += ' | ' + newText;
}
// Fade back in
bannerEl.style.opacity = '1';
}, `banner-fade-in-${Date.now()}`);
}

View file

@ -0,0 +1,77 @@
/**
* Event queue system for deterministic animations
*/
export interface ScheduledEvent {
id: string;
callback: () => void;
resolved?: boolean;
error?: Error; // If the event caused an error, store it here.
}
export class EventQueue {
private events: ScheduledEvent[] = [];
private startTime: number = 0;
private isRunning: boolean = false;
/**
* Start the event queue with current time as reference
*/
start(): void {
this.isRunning = true;
}
/**
* Stop the event queue
*/
stop(): void {
this.isRunning = false;
}
/**
* Reset the event queue, clearing all events
* @param resetCallback Optional callback to run after reset (for cleanup)
*/
reset(resetCallback?: () => void): void {
this.events = [];
this.isRunning = false;
if (resetCallback) {
resetCallback();
}
}
/**
* Add an event to the queue
*/
schedule(event: ScheduledEvent): void {
this.events.push(event);
}
/**
* Update the event queue, triggering events that are ready
*/
update(): void {
if (!this.isRunning) return;
if (this.events.length < 1) return;
if (this.events[0].resolved) {
this.events.shift()
this.events[0].callback()
}
}
/**
* Get remaining events count
*/
get pendingCount(): number {
return this.events.filter(e => !e.resolved).length;
}
/**
* Get all events (for debugging)
*/
get allEvents(): ScheduledEvent[] {
return [...this.events];
}
}

View file

@ -4,7 +4,7 @@ import type { GameSchemaType, Message } from "./types/gameState";
import { debugMenuInstance } from "./debug/debugMenu.ts"
import { config } from "./config.ts"
import { GameSchema, type MessageSchema } from "./types/gameState";
import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay, updatePhaseDisplay } from "./domElements";
import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay, createUpdateUIEvent } from "./domElements";
import { createChatWindows } from "./domElements/chatWindows";
import { logger } from "./logger";
import { OrbitControls } from "three/examples/jsm/Addons.js";
@ -13,7 +13,8 @@ import { Tween, Group as TweenGroup } from "@tweenjs/tween.js";
import { MomentsDataSchema, Moment, NormalizedMomentsData } from "./types/moments";
import { updateLeaderboard } from "./components/leaderboard";
import { closeVictoryModal } from "./components/victoryModal.ts";
import { EventQueue } from "./types/events";
import { EventQueue } from "./events.ts";
import { createAnimationsForNextPhase } from "./units/animate.ts";
//FIXME: This whole file is a mess. Need to organize and format
@ -80,6 +81,7 @@ function loadFileFromServer(filePath: string): Promise<string> {
})
})
}
function initializeBackgroundAudio(): Audio {
// Create audio element
@ -185,12 +187,37 @@ class GameState {
}
set phaseIndex(val: number) {
this._phaseIndex = val
updatePhaseDisplay()
}
get phaseIndex() {
return this._phaseIndex
}
_fillEventQueue = (gameData: GameSchemaType) => {
for (let phase of gameData.phases) {
// Update Phase Display
let updateUIEvent = createUpdateUIEvent(phase)
this.eventQueue.schedule(updateUIEvent)
// News Banner Text
this.eventQueue.schedule(createNewsBannerUpdateEvent(phase))
// Narrator Audio
this.eventQueue.schedule(createNarratorAudioEvent(phase))
// Messages play first
let messageEvents = createMessageEvents(phase)
this.eventQueue.schedule(messageEvents)
// Check if there is a moment to display
let phaseMoment = this.checkPhaseHasMoment(phase.name)
if (phaseMoment) {
this.eventQueue.schedule(createMomentEvent(phaseMoment))
}
let animationEvents = createAnimationsForNextPhase(phase)
this.eventQueue.schedule(animationEvents)
}
}
/**
* Load game data from a JSON string and initialize the game state
* @param gameDataString JSON string containing game data
@ -355,7 +382,7 @@ class GameState {
// Ensure any open overlays are cleaned up before loading next game
if (this.isDisplayingMoment) {
// Import at runtime to avoid circular dependency
import('./components/twoPowerConversation').then(({ closeTwoPowerConversation }) => {
import('./components/momentModal.ts').then(({ closeMomentModal: closeTwoPowerConversation }) => {
closeTwoPowerConversation(true);
});
}
@ -367,11 +394,6 @@ class GameState {
}
this.loadGameFile(gameId).then(() => {
gameState.gameId = gameId
if (contPlaying) {
this.eventQueue.scheduleDelay(config.victoryModalDisplayMs, () => {
togglePlayback(true)
}, `load-next-game-playback-${Date.now()}`)
}
}).catch(() => {
console.warn("caught error trying to advance game. Setting gameId to 0 and restarting...")
this.loadGameFile(0)

View file

@ -36,8 +36,10 @@ function initScene() {
gameState.loadBoardState().then(() => {
initMap(gameState.scene).then(() => {
// TODO: Re-add the rotating display
//
// Initialize rotating display
initRotatingDisplay();
//initRotatingDisplay();
gameState.cameraPanAnim = createCameraPan()
@ -68,11 +70,7 @@ function initScene() {
debugMenuInstance.show();
}
if (isStreamingMode) {
gameState.eventQueue.start();
gameState.eventQueue.scheduleDelay(2000, () => {
togglePlayback()
}, `streaming-mode-start-${Date.now()}`)
}
})
}).catch(err => {

View file

@ -1,14 +1,15 @@
import { gameState } from "./gameState";
import { logger } from "./logger";
import { updatePhaseDisplay, playBtn, prevBtn, nextBtn } from "./domElements";
import { playBtn, prevBtn, nextBtn } from "./domElements";
import { initUnits } from "./units/create";
import { updateSupplyCenterOwnership, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state";
import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows";
import { updateChatWindows } from "./domElements/chatWindows";
import { createUpdateNewsBannerEvent } from "./components/newsBanner";
import { createAnimationsForNextPhase } from "./units/animate";
import { speakSummary } from "./speech";
import { config } from "./config";
import { debugMenuInstance } from "./debug/debugMenu";
import { showTwoPowerConversation, closeTwoPowerConversation } from "./components/twoPowerConversation";
import { showMomentModal, closeMomentModal } from "./components/momentModal";
import { closeVictoryModal, showVictoryModal } from "./components/victoryModal";
import { notifyPhaseChange } from "./webhooks/phaseNotifier";
import { updateLeaderboard } from "./components/leaderboard";
@ -91,7 +92,6 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) {
playBtn.textContent = "⏸ Pause";
prevBtn.disabled = true;
nextBtn.disabled = true;
logger.log("Starting playback...");
// Start background audio when playback starts
gameState.audio.play();
@ -101,36 +101,19 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) {
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[1].start()
// First, show the messages of the current phase if it's the initial playback
if (gameState.currentPhase.messages && gameState.currentPhase.messages.length) {
// Show messages with stepwise animation
logger.log(`Playing ${gameState.currentPhase.messages.length} messages from phase ${gameState.phaseIndex + 1}/${gameState.gameData.phases.length}`);
displayPhase()
} else {
// No messages, go straight to unit animations
logger.log("No messages for this phase, proceeding to animations");
}
} else {
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[0].pause();
playBtn.textContent = "▶ Play";
gameState.audio.pause()
// (playbackTimer is replaced by event queue system)
// Stop background audio when pausing
gameState.audio.pause();
// Ensure any open two-power conversations are closed when pausing
closeTwoPowerConversation(true); // immediate = true
closeMomentModal(true); // immediate = true
// Stop and reset event queue when pausing with cleanup
gameState.eventQueue.stop();
gameState.eventQueue.reset(() => {
// Ensure proper state cleanup when events are canceled
gameState.messagesPlaying = false;
gameState.isAnimating = false;
});
gameState.messagesPlaying = false;
prevBtn.disabled = false;
nextBtn.disabled = false;
}
@ -138,10 +121,12 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) {
export function scheduleNextPhase() {
throw new Error("Function is deprecated")
gameState.eventQueue.scheduleDelay(0, nextPhase)
}
export function scheduleSummarySpeech() {
throw new Error("Function is deprecated")
// Delay speech in streaming mode
gameState.eventQueue.scheduleDelay(config.speechDelay, () => {
// Speak the summary and advance after
@ -159,15 +144,10 @@ export function nextPhase() {
const power1 = moment.powers_involved[0];
const power2 = moment.powers_involved[1];
showTwoPowerConversation({
showMomentModal({
power1: power1,
power2: power2,
moment: moment,
onClose: () => {
// Schedule the speaking of the summary after the conversation closes
scheduleSummarySpeech();
if (gameState.isPlaying) _setPhase(gameState.phaseIndex + 1)
}
})
} else {
// No conversation to show, proceed with normal flow
@ -216,13 +196,10 @@ export function displayPhase(skipMessages = false) {
// Update UI elements with smooth transitions
updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower, true);
// TODO: Re-add the rotatingDisplay. Removed it as it won't fit in the eventQueue.
// updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower, true);
_updateMapOwnership();
// Add phase info to news banner if not already there
const phaseBannerText = `Phase: ${currentPhase.name}: ${currentPhase.summary}`;
addToNewsBanner(phaseBannerText);
// Log phase details to console only, don't update info panel with this
const phaseInfo = `Phase: ${currentPhase.name}\nSCs: ${currentPhase.state?.centers ? JSON.stringify(currentPhase.state.centers) : 'None'}\nUnits: ${currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None'}`;
console.log(phaseInfo); // Use console.log instead of logger.log
@ -231,7 +208,7 @@ export function displayPhase(skipMessages = false) {
updateLeaderboard();
// Show messages with animation or immediately based on skipMessages flag
updateChatWindows(true, scheduleNextPhase);
//updateChatWindows(true, scheduleNextPhase);
// Only animate if not the first phase and animations are requested
if (!isFirstPhase && !skipMessages) {

View file

@ -1,164 +0,0 @@
/**
* Event queue system for deterministic animations
*/
export interface ScheduledEvent {
id: string;
triggerAtTime: number; // Relative to startTime in seconds
callback: () => void;
resolved?: boolean;
priority?: number; // Higher numbers execute first for events at same time
error?: Error; // If the event caused an error, store it here.
}
export class EventQueue {
private events: ScheduledEvent[] = [];
private startTime: number = 0;
private isRunning: boolean = false;
/**
* Start the event queue with current time as reference
*/
start(): void {
this.startTime = performance.now();
this.isRunning = true;
}
/**
* Stop the event queue
*/
stop(): void {
this.isRunning = false;
}
/**
* Reset the event queue, clearing all events
* @param resetCallback Optional callback to run after reset (for cleanup)
*/
reset(resetCallback?: () => void): void {
this.events = [];
this.isRunning = false;
if (resetCallback) {
resetCallback();
}
}
/**
* Add an event to the queue
*/
schedule(event: ScheduledEvent): void {
this.events.push(event);
// Keep events sorted by trigger time, then by priority
this.events.sort((a, b) => {
if (a.triggerAtTime === b.triggerAtTime) {
return (b.priority || 0) - (a.priority || 0);
}
return a.triggerAtTime - b.triggerAtTime;
});
}
/**
* Remove resolved events from the queue
*/
cleanup(): void {
this.events = this.events.filter(e => !e.resolved);
}
/**
* Update the event queue, triggering events that are ready
*/
update(): void {
if (!this.isRunning) return;
const now = performance.now();
const elapsed = now - this.startTime;
for (const event of this.events) {
if (!event.resolved && elapsed >= event.triggerAtTime) {
try {
event.callback();
} catch (err) {
// TODO: Need some system here to catch and report errors, but we mark them as resolved now so that we don't call an erroring fucntion repeatedly.
this.events.slice(this.events.indexOf(event), 1)
if (err instanceof Error) {
event.error = err
console.error(err)
} else {
console.error(`Got type "${typeof err} as error for event with id ${event.id}`)
console.error(err)
}
} finally {
event.resolved = true;
}
}
}
// Clean up resolved events periodically
if (this.events.length > 0 && this.events.every(e => e.resolved)) {
this.cleanup();
}
}
/**
* Get remaining events count
*/
get pendingCount(): number {
return this.events.filter(e => !e.resolved).length;
}
/**
* Get all events (for debugging)
*/
get allEvents(): ScheduledEvent[] {
return [...this.events];
}
/**
* Schedule a simple delay callback (like setTimeout)
*/
scheduleDelay(delayMs: number, callback: () => void, id?: string): void {
const now = performance.now();
const elapsed = this.isRunning ? now - this.startTime : 0;
this.schedule({
id: id || `delay-${Date.now()}-${Math.random()}`,
triggerAtTime: elapsed + (delayMs), // Schedule relative to current time
callback
});
}
/**
* Schedule a recurring event (like setInterval)
* Returns a function to cancel the recurring event
*/
scheduleRecurring(intervalMs: number, callback: () => void, id?: string): () => void {
let counter = 0;
const baseId = id || `recurring-${Date.now()}`;
const now = performance.now();
const startElapsed = this.isRunning ? now - this.startTime : 0;
const scheduleNext = () => {
counter++;
this.schedule({
id: `${baseId}-${counter}`,
triggerAtTime: startElapsed + (intervalMs * counter),
callback: () => {
callback();
scheduleNext(); // Schedule the next occurrence
}
});
};
scheduleNext();
// Return cancel function
return () => {
// Mark all future events for this recurring schedule as resolved
this.events.forEach(event => {
if (event.id.startsWith(baseId) && !event.resolved) {
event.resolved = true;
}
});
};
}
}