FIX: Map jump bug, leaderboard misname

Reintroducing the fix for the bug in tween.js (issue 677) that causes
the map to jump at the end of the tween.

Leaderboard name was used twice in the code base, once for the modal at
the beginning of games, and also for the rotating display in the bottom
left. I've removed the modal at the beggining of the game as its data is
stale and not updated yet. I've renamed the bottom left to
'rotatingDisplay' and the bottom right to 'leaderboard'.
This commit is contained in:
Tyler Marques 2025-06-07 21:04:00 -07:00
parent d52753569b
commit b8a8f5a6f2
No known key found for this signature in database
GPG key ID: CB99EDCF41D3016F
13 changed files with 213 additions and 264 deletions

View file

@ -15,7 +15,6 @@
<div class="top-controls">
<div>
<button id="load-btn">Load Game</button>
<button id="standings-btn">📊 Leaderboard</button>
<button id="prev-btn" disabled>← Prev</button>
<button id="next-btn" disabled>Next →</button>
<button id="play-btn" disabled>▶ Play</button>
@ -30,9 +29,8 @@
</div>
<div id="map-view" class="map-view"></div>
<input type="file" id="file-input" accept=".json">
<div id="info-panel"></div>
<!-- New leaderboard element -->
<div id="leaderboard"></div>
<div id="rotating-display"></div>
<!-- Chat windows container -->
<div id="chat-container"></div>
<!-- Add this after the info-panel div -->

View file

@ -10,28 +10,27 @@ let isAudioInitialized = false;
* Only loads in streaming mode to avoid unnecessary downloads
*/
export function initializeBackgroundAudio(): void {
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
if (!isStreamingMode || isAudioInitialized) {
return;
if (isAudioInitialized) {
throw new Error("Attempted to init audio twice.")
}
isAudioInitialized = true;
// Create audio element
backgroundAudio = new Audio();
backgroundAudio.loop = true;
backgroundAudio.volume = 0.4; // 40% volume as requested
// For now, we'll use a placeholder - you should download and convert the wave file
// to a smaller MP3 format (aim for < 10MB) and place it in public/sounds/
backgroundAudio.src = './sounds/background_ambience.mp3';
// Handle audio loading
backgroundAudio.addEventListener('canplaythrough', () => {
console.log('Background audio loaded and ready to play');
});
backgroundAudio.addEventListener('error', (e) => {
console.error('Failed to load background audio:', e);
});
@ -66,4 +65,4 @@ export function setBackgroundAudioVolume(volume: number): void {
if (backgroundAudio) {
backgroundAudio.volume = Math.max(0, Math.min(1, volume));
}
}
}

View file

@ -0,0 +1,77 @@
import { gameState } from "../gameState";
import { getPowerDisplayName } from "../utils/powerNames";
import { PowerENUM } from "../types/map";
let containerElement = document.getElementById("leaderboard")
export function initLeaderBoard() {
if (!containerElement) {
console.error(`Container element with ID "${containerId}" not found`);
return;
}
}
// Updated function to update leaderboard with useful information and smooth transitions
export function updateLeaderboard() {
const totalPhases = gameState.gameData?.phases?.length || 0;
const currentPhaseNumber = gameState.phaseIndex + 1;
const phaseName = gameState.gameData?.phases?.[gameState.phaseIndex]?.name || 'Unknown';
// Add fade-out transition
containerElement.style.transition = 'opacity 0.3s ease-out';
containerElement.style.opacity = '0';
// Update content after fade-out
setTimeout(() => {
// Get supply center counts for the current phase
const scCounts = getSupplyCenterCounts();
containerElement.innerHTML = `
<div><strong>Power:</strong> <span class="power-${gameState.currentPower.toLowerCase()}">${getPowerDisplayName(gameState.currentPower)}</span></div>
<div><strong>Current Phase:</strong> ${phaseName}</div>
<hr/>
<h4>Supply Center Counts</h4>
<ul style="list-style:none;padding-left:0;margin:0;">
<li><span class="power-austria">${getPowerDisplayName(PowerENUM.AUSTRIA)}:</span> ${scCounts.AUSTRIA || 0}</li>
<li><span class="power-england">${getPowerDisplayName(PowerENUM.ENGLAND)}:</span> ${scCounts.ENGLAND || 0}</li>
<li><span class="power-france">${getPowerDisplayName(PowerENUM.FRANCE)}:</span> ${scCounts.FRANCE || 0}</li>
<li><span class="power-germany">${getPowerDisplayName(PowerENUM.GERMANY)}:</span> ${scCounts.GERMANY || 0}</li>
<li><span class="power-italy">${getPowerDisplayName(PowerENUM.ITALY)}:</span> ${scCounts.ITALY || 0}</li>
<li><span class="power-russia">${getPowerDisplayName(PowerENUM.RUSSIA)}:</span> ${scCounts.RUSSIA || 0}</li>
<li><span class="power-turkey">${getPowerDisplayName(PowerENUM.TURKEY)}:</span> ${scCounts.TURKEY || 0}</li>
</ul>
`;
// Fade back in
containerElement.style.opacity = '1';
}, 300);
}
// Helper function to count supply centers for each power
function getSupplyCenterCounts() {
const counts = {
AUSTRIA: 0,
ENGLAND: 0,
FRANCE: 0,
GERMANY: 0,
ITALY: 0,
RUSSIA: 0,
TURKEY: 0
};
// Get current phase's supply center data
const centers = gameState.gameData?.phases?.[gameState.phaseIndex]?.state?.centers;
if (centers) {
// Count supply centers for each power
Object.entries(centers).forEach(([power, provinces]) => {
if (power && Array.isArray(provinces)) {
counts[power as keyof typeof counts] = provinces.length;
}
});
}
return counts;
}

View file

@ -40,7 +40,7 @@ let isInitialized = false;
* Initialize the rotating display
* @param containerId The ID of the container element
*/
export function initRotatingDisplay(containerId: string): void {
export function initRotatingDisplay(containerId: string = 'rotating-display'): void {
containerElement = document.getElementById(containerId);
if (!containerElement) {

View file

@ -4,10 +4,10 @@
export const config = {
// Default speed in milliseconds for animations and transitions
playbackSpeed: 500,
// Streaming mode specific timing
get streamingPlaybackSpeed(): number {
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
const isStreamingMode = import.meta.env.MODE === 'production'
return isStreamingMode ? 1000 : this.playbackSpeed; // Slower for streaming
},
@ -74,13 +74,13 @@ export const config = {
unitBobFrequency: 0.8,
fleetRollFrequency: 0.5,
fleetPitchFrequency: 0.3,
// Supply center pulse frequency (Hz)
supplyPulseFrequency: 1.0,
// Province highlight flash frequency (Hz)
provinceFlashFrequency: 2.0,
// Maximum frame delta time (seconds) to prevent animation jumps
maxDeltaTime: 0.1
}

View file

@ -71,9 +71,8 @@ if (null === mapView) throw new Error("Element with ID 'map-view' not found");
export const leaderboard = document.getElementById('leaderboard');
if (null === leaderboard) throw new Error("Element with ID 'leaderboard' not found");
export const standingsBtn = document.getElementById('standings-btn');
if (null === standingsBtn) throw new Error("Element with ID 'standings-btn' not found");
export const rotatingDisplay = document.getElementById('rotating-display');
if (null === rotatingDisplay) throw new Error("Element with ID 'rotating-display' not found");
// Debug menu elements
export const debugMenu = document.getElementById('debug-menu');
if (null === debugMenu) throw new Error("Element with ID 'debug-menu' not found");

View file

@ -201,20 +201,20 @@ export function updateChatWindows(phase: any, stepMessages = false) {
console.log(`All messages displayed in ${Date.now() - messageStartTime}ms`);
}
gameState.messagesPlaying = false;
// Trigger unit animations now that messages are done
// This imports a circular dependency, so we use a dynamic import
import('../units/animate').then(({ createAnimationsForNextPhase }) => {
const phaseIndex = gameState.phaseIndex;
const isFirstPhase = phaseIndex === 0;
const previousPhase = !isFirstPhase && phaseIndex > 0 ? gameState.gameData.phases[phaseIndex - 1] : null;
if (!isFirstPhase && previousPhase) {
console.log("Messages complete, starting unit animations");
createAnimationsForNextPhase();
}
});
return;
}
@ -231,10 +231,7 @@ export function updateChatWindows(phase: any, stepMessages = false) {
index++; // Only increment after animation completes
// Schedule next message with proper delay
// In streaming mode, add extra delay to prevent message overlap
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
const messageDelay = isStreamingMode ? config.effectivePlaybackSpeed : config.effectivePlaybackSpeed / 2;
setTimeout(showNext, messageDelay);
setTimeout(showNext, config.effectivePlaybackSpeed);
};
// Add the message with word animation
@ -398,14 +395,13 @@ function animateMessageWords(message: string, contentSpanId: string, targetPower
// Longer words get slightly longer display time
const wordLength = words[wordIndex - 1].length;
// In streaming mode, use a more consistent delay to prevent overlap
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
const baseDelay = isStreamingMode ? 150 : config.effectivePlaybackSpeed / 10;
const baseDelay = config.effectivePlaybackSpeed
const delay = Math.max(50, Math.min(200, baseDelay * (wordLength / 4)));
setTimeout(addNextWord, delay);
// Scroll to ensure newest content is visible
// Use requestAnimationFrame to batch DOM updates in streaming mode
const isStreamingModeForScroll = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
const isStreamingModeForScroll = import.meta.env.MODE === 'production' || import.meta.env.VITE_STREAMING_MODE === 'true';
if (isStreamingModeForScroll) {
requestAnimationFrame(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
@ -672,17 +668,17 @@ function playRandomSoundEffect() {
// Create an <audio> and play
const audio = new Audio(`./sounds/${chosen}`);
audio.volume = 0.5; // Set volume to 50% to avoid being too loud
if (config.isDebugMode || config.isTestingMode) {
console.debug("Not playing sounds in debug or testing mode");
if (config.isDebugMode || config.isTestingMode) {
console.debug("Not playing sounds in debug or testing mode");
return;
}
console.log(`Attempting to play sound: ${chosen}`);
// Try to play the audio
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise
.then(() => {

View file

@ -1,7 +1,7 @@
import { StandingsData, StandingsEntry, SortBy, SortDirection, SortOptions } from '../types/standings';
import { gameState } from '../gameState';
import { logger } from '../logger';
import { standingsBtn } from '../domElements';
//import { standingsBtn } from '../domElements';
import { getPowerDisplayName } from '../utils/powerNames';
import { PowerENUM } from '../types/map';
@ -25,22 +25,22 @@ export function initStandingsBoard(): void {
if (!document.getElementById('standings-board-container')) {
createStandingsBoardElements();
}
// Get references to the created elements
standingsBoardContainer = document.getElementById('standings-board-container');
standingsTable = document.getElementById('standings-table');
closeButton = document.getElementById('standings-close-btn');
// Add event listeners
if (closeButton) {
closeButton.addEventListener('click', hideStandingsBoard);
}
// Add click handler for the standings button
if (standingsBtn) {
standingsBtn.addEventListener('click', toggleStandingsBoard);
}
// Load standings data
loadStandingsData();
}
@ -52,34 +52,34 @@ function createStandingsBoardElements(): void {
const container = document.createElement('div');
container.id = 'standings-board-container';
container.className = 'standings-board-container';
// Create header
const header = document.createElement('div');
header.className = 'standings-header';
const title = document.createElement('h2');
title.textContent = 'AI Diplomacy Leaderboard';
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.id = 'standings-close-btn';
closeBtn.textContent = '×';
closeBtn.title = 'Close Leaderboard';
header.appendChild(closeBtn);
container.appendChild(header);
// Create table container
const tableContainer = document.createElement('div');
tableContainer.className = 'standings-table-container';
const table = document.createElement('table');
table.id = 'standings-table';
table.className = 'standings-table';
tableContainer.appendChild(table);
container.appendChild(tableContainer);
// Create legend/info section
const legend = document.createElement('div');
legend.className = 'standings-legend';
@ -87,7 +87,7 @@ function createStandingsBoardElements(): void {
<p>Numbers indicate wins per Power-Model combination. Click column headers to sort.</p>
`;
container.appendChild(legend);
// Add to document
document.body.appendChild(container);
}
@ -119,33 +119,33 @@ function loadStandingsData(): void {
function parseCSV(csvText: string): StandingsData {
const lines = csvText.split('\n').filter(line => line.trim().length > 0);
const headers = lines[0].split(',').map(h => h.trim());
// First column is 'Model', rest are power names
const powers = headers.slice(1);
// Process each data row
const entries: StandingsEntry[] = [];
const models: string[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
const model = values[0];
models.push(model);
// Create wins record
const wins: Record<string, number> = {};
let totalWins = 0;
for (let j = 1; j < values.length; j++) {
const power = powers[j - 1];
const winCount = parseInt(values[j]) || 0;
wins[power] = winCount;
totalWins += winCount;
}
entries.push({ model, wins, totalWins });
}
return { models, powers, entries };
}
@ -154,21 +154,21 @@ function parseCSV(csvText: string): StandingsData {
*/
function renderStandingsTable(): void {
if (!standingsTable || !standingsData) return;
// Clear existing content
standingsTable.innerHTML = '';
// Create header row
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
// Model column header
const modelHeader = document.createElement('th');
modelHeader.textContent = 'Model';
modelHeader.className = 'model-header';
modelHeader.addEventListener('click', () => sortTable(SortBy.MODEL));
headerRow.appendChild(modelHeader);
// Power column headers
standingsData.powers.forEach(power => {
const th = document.createElement('th');
@ -177,23 +177,23 @@ function renderStandingsTable(): void {
th.addEventListener('click', () => sortTable(`power_${power}`));
headerRow.appendChild(th);
});
// Total column header
const totalHeader = document.createElement('th');
totalHeader.textContent = 'Total';
totalHeader.className = 'total-header';
totalHeader.addEventListener('click', () => sortTable(SortBy.TOTAL_WINS));
headerRow.appendChild(totalHeader);
thead.appendChild(headerRow);
standingsTable.appendChild(thead);
// Sort entries based on current sort options
const sortedEntries = [...standingsData.entries].sort((a, b) => {
if (currentSort.by === SortBy.MODEL) {
const comparison = a.model.localeCompare(b.model);
return currentSort.direction === SortDirection.ASC ? comparison : -comparison;
}
}
else if (currentSort.by === SortBy.TOTAL_WINS) {
const comparison = a.totalWins - b.totalWins;
return currentSort.direction === SortDirection.ASC ? comparison : -comparison;
@ -205,19 +205,19 @@ function renderStandingsTable(): void {
}
return 0;
});
// Create table body
const tbody = document.createElement('tbody');
sortedEntries.forEach(entry => {
const row = document.createElement('tr');
// Model cell
const modelCell = document.createElement('td');
modelCell.textContent = entry.model;
modelCell.className = 'model-cell';
row.appendChild(modelCell);
// Power cells
standingsData.powers.forEach(power => {
const td = document.createElement('td');
@ -229,18 +229,18 @@ function renderStandingsTable(): void {
if (wins >= 5) td.classList.add('top-wins');
row.appendChild(td);
});
// Total cell
const totalCell = document.createElement('td');
totalCell.textContent = entry.totalWins.toString();
totalCell.className = 'total-cell';
row.appendChild(totalCell);
tbody.appendChild(row);
});
standingsTable.appendChild(tbody);
// Update sort indicators
updateSortIndicators();
}
@ -250,24 +250,24 @@ function renderStandingsTable(): void {
*/
function updateSortIndicators(): void {
if (!standingsTable) return;
// Remove all sort indicators
const allHeaders = standingsTable.querySelectorAll('th');
allHeaders.forEach(header => {
header.classList.remove('sort-asc', 'sort-desc');
});
// Add indicator to current sort column
let headerSelector = '.model-header';
if (currentSort.by === SortBy.TOTAL_WINS) {
headerSelector = '.total-header';
}
}
else if (currentSort.by.startsWith('power_')) {
const power = currentSort.by.replace('power_', '');
headerSelector = `.power-${power.toLowerCase()}`;
}
const header = standingsTable.querySelector(headerSelector);
if (header) {
header.classList.add(
@ -282,9 +282,9 @@ function updateSortIndicators(): void {
function sortTable(sortBy: SortBy | string): void {
if (currentSort.by === sortBy) {
// Toggle direction if already sorting by this column
currentSort.direction =
currentSort.direction === SortDirection.ASC
? SortDirection.DESC
currentSort.direction =
currentSort.direction === SortDirection.ASC
? SortDirection.DESC
: SortDirection.ASC;
} else {
// Set new sort column with default direction
@ -293,7 +293,7 @@ function sortTable(sortBy: SortBy | string): void {
direction: sortBy === SortBy.MODEL ? SortDirection.ASC : SortDirection.DESC
};
}
renderStandingsTable();
}
@ -338,4 +338,4 @@ export function updateStandingsBoardVisibility(): void {
} else {
hideStandingsBoard();
}
}
}

View file

@ -9,8 +9,8 @@ import { logger } from "./logger";
import { OrbitControls } from "three/examples/jsm/Addons.js";
import { displayInitialPhase, togglePlayback } from "./phase";
import { Tween, Group as TweenGroup } from "@tweenjs/tween.js";
import { hideStandingsBoard, } from "./domElements/standingsBoard";
import { MomentsDataSchema, Moment, NormalizedMomentsData } from "./types/moments";
import { updateLeaderboard } from "./components/leaderboard";
//FIXME: This whole file is a mess. Need to organize and format
@ -26,28 +26,28 @@ function getRandomPower(gameData?: GameSchemaType): PowerENUM {
const allPowers = Object.values(PowerENUM).filter(power =>
power !== PowerENUM.GLOBAL && power !== PowerENUM.EUROPE
);
// If no game data provided, return any random power
if (!gameData || !gameData.phases || gameData.phases.length === 0) {
const idx = Math.floor(Math.random() * allPowers.length);
return allPowers[idx];
}
// Get the last phase to check supply centers
const lastPhase = gameData.phases[gameData.phases.length - 1];
// Filter powers that have more than 2 supply centers
const eligiblePowers = allPowers.filter(power => {
const centers = lastPhase.state?.centers?.[power];
return centers && centers.length > 2;
});
// If no powers have more than 2 centers, fall back to any power
if (eligiblePowers.length === 0) {
const idx = Math.floor(Math.random() * allPowers.length);
return allPowers[idx];
}
const idx = Math.floor(Math.random() * eligiblePowers.length);
return eligiblePowers[idx];
}
@ -339,13 +339,12 @@ class GameState {
})
.then(() => {
console.log(`Game file with id ${gameId} loaded and parsed successfully`);
// Explicitly hide standings board after loading game
hideStandingsBoard();
// Update rotating display and relationship popup with game data
if (this.gameData) {
updateRotatingDisplay(this.gameData, this.phaseIndex, this.currentPower);
this.gameId = gameId
updateGameIdDisplay();
updateLeaderboard();
resolve()
}
})
@ -376,9 +375,7 @@ class GameState {
if (mapView === null) {
throw Error("Cannot find mapView element, unable to continue.")
}
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
this.scene.background = new THREE.Color(0x87CEEB);
// Camera
@ -389,28 +386,17 @@ class GameState {
3000
);
this.camera.position.set(0, 800, 900); // MODIFIED: Increased z-value to account for map shift
// Renderer with streaming optimizations
this.renderer = new THREE.WebGLRenderer({
antialias: !isStreamingMode, // Disable antialiasing in streaming mode
this.renderer = new THREE.WebGLRenderer({
powerPreference: "high-performance",
preserveDrawingBuffer: isStreamingMode // Required for streaming
});
this.renderer.setSize(mapView.clientWidth, mapView.clientHeight);
// Force lower pixel ratio for streaming to reduce GPU load
if (isStreamingMode) {
this.renderer.setPixelRatio(1);
} else {
this.renderer.setPixelRatio(window.devicePixelRatio);
}
mapView.appendChild(this.renderer.domElement);
// Controls with streaming optimizations
this.camControls = new OrbitControls(this.camera, this.renderer.domElement);
this.camControls.enableDamping = !isStreamingMode; // Disable damping for immediate response
this.camControls.dampingFactor = 0.05;
this.camControls.screenSpacePanning = true;
this.camControls.minDistance = 100;
this.camControls.maxDistance = 2000;

View file

@ -20,67 +20,5 @@ class Logger {
console.log(msg);
}
// Updated function to update info panel with useful information and smooth transitions
updateInfoPanel = () => {
const totalPhases = gameState.gameData?.phases?.length || 0;
const currentPhaseNumber = gameState.phaseIndex + 1;
const phaseName = gameState.gameData?.phases?.[gameState.phaseIndex]?.name || 'Unknown';
// Add fade-out transition
this.infoPanel.style.transition = 'opacity 0.3s ease-out';
this.infoPanel.style.opacity = '0';
// Update content after fade-out
setTimeout(() => {
// Get supply center counts for the current phase
const scCounts = this.getSupplyCenterCounts();
this.infoPanel.innerHTML = `
<div><strong>Power:</strong> <span class="power-${gameState.currentPower.toLowerCase()}">${getPowerDisplayName(gameState.currentPower)}</span></div>
<div><strong>Current Phase:</strong> ${phaseName}</div>
<hr/>
<h4>Supply Center Counts</h4>
<ul style="list-style:none;padding-left:0;margin:0;">
<li><span class="power-austria">${getPowerDisplayName(PowerENUM.AUSTRIA)}:</span> ${scCounts.AUSTRIA || 0}</li>
<li><span class="power-england">${getPowerDisplayName(PowerENUM.ENGLAND)}:</span> ${scCounts.ENGLAND || 0}</li>
<li><span class="power-france">${getPowerDisplayName(PowerENUM.FRANCE)}:</span> ${scCounts.FRANCE || 0}</li>
<li><span class="power-germany">${getPowerDisplayName(PowerENUM.GERMANY)}:</span> ${scCounts.GERMANY || 0}</li>
<li><span class="power-italy">${getPowerDisplayName(PowerENUM.ITALY)}:</span> ${scCounts.ITALY || 0}</li>
<li><span class="power-russia">${getPowerDisplayName(PowerENUM.RUSSIA)}:</span> ${scCounts.RUSSIA || 0}</li>
<li><span class="power-turkey">${getPowerDisplayName(PowerENUM.TURKEY)}:</span> ${scCounts.TURKEY || 0}</li>
</ul>
`;
// Fade back in
this.infoPanel.style.opacity = '1';
}, 300);
}
// Helper function to count supply centers for each power
getSupplyCenterCounts = () => {
const counts = {
AUSTRIA: 0,
ENGLAND: 0,
FRANCE: 0,
GERMANY: 0,
ITALY: 0,
RUSSIA: 0,
TURKEY: 0
};
// Get current phase's supply center data
const centers = gameState.gameData?.phases?.[gameState.phaseIndex]?.state?.centers;
if (centers) {
// Count supply centers for each power
Object.entries(centers).forEach(([power, provinces]) => {
if (power && Array.isArray(provinces)) {
counts[power as keyof typeof counts] = provinces.length;
}
});
}
return counts;
}
}
export const logger = new Logger()

View file

@ -5,7 +5,6 @@ import { gameState } from "./gameState";
import { logger } from "./logger";
import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements";
import { updateChatWindows } from "./domElements/chatWindows";
import { initStandingsBoard, hideStandingsBoard, showStandingsBoard } from "./domElements/standingsBoard";
import { displayPhaseWithAnimation, advanceToNextPhase, resetToPhase, nextPhase, previousPhase, togglePlayback } from "./phase";
import { config } from "./config";
import { Tween, Group, Easing } from "@tweenjs/tween.js";
@ -13,84 +12,50 @@ import { initRotatingDisplay, updateRotatingDisplay } from "./components/rotatin
import { closeTwoPowerConversation, showTwoPowerConversation } from "./components/twoPowerConversation";
import { PowerENUM } from "./types/map";
import { debugMenuInstance } from "./debug/debugMenu";
import { sineWave } from "./utils/timing";
import { initializeBackgroundAudio, startBackgroundAudio } from "./backgroundAudio";
import { initLeaderBoard, updateLeaderboard } from "./components/leaderboard";
//TODO: Create a function that finds a suitable unit location within a given polygon, for placing units better
// Currently the location for label, unit, and SC are all the same manually picked location
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true'
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE
// --- INITIALIZE SCENE ---
function initScene() {
gameState.createThreeScene()
// Initialize background audio for streaming mode
initializeBackgroundAudio();
// Enable audio on first user interaction (to comply with browser autoplay policies)
let audioEnabled = false;
const enableAudio = () => {
if (!audioEnabled) {
console.log('User interaction detected, audio enabled');
audioEnabled = true;
// Create and play a silent audio to unlock audio context
const silentAudio = new Audio();
silentAudio.volume = 0;
silentAudio.play().catch(() => {});
// Start background audio in streaming mode
if (isStreamingMode) {
startBackgroundAudio();
}
// Remove the listener after first interaction
document.removeEventListener('click', enableAudio);
document.removeEventListener('keydown', enableAudio);
}
};
document.addEventListener('click', enableAudio);
document.addEventListener('keydown', enableAudio);
initializeBackgroundAudio()
// Initialize standings board
initStandingsBoard();
// TODO: Re-add standinds board when it has an actual use, and not stale data
//
//initStandingsBoard();
// Load coordinate data, then build the map
gameState.loadBoardState().then(() => {
initMap(gameState.scene).then(() => {
// Update info panel with initial power information
logger.updateInfoPanel();
updateLeaderboard();
// Initialize rotating display
initRotatingDisplay('leaderboard');
// Only show standings board at startup if no game is loaded
if (!gameState.gameData || !gameState.gameData.phases || gameState.gameData.phases.length === 0) {
showStandingsBoard();
}
initRotatingDisplay();
gameState.cameraPanAnim = createCameraPan()
// Load default game file if in debug mode
if (config.isDebugMode || isStreamingMode) {
gameState.loadGameFile(0);
gameState.loadGameFile(0);
// Initialize info panel
logger.updateInfoPanel();
}
// Initialize debug menu if in debug mode
if (config.isDebugMode) {
debugMenuInstance.show();
}
if (isStreamingMode) {
startBackgroundAudio()
setTimeout(() => {
togglePlayback();
// Try to start background audio when auto-starting
startBackgroundAudio();
}, 10000) // Increased delay to 10 seconds for Chrome to fully load in Docker
togglePlayback()
}, 2000)
}
})
}).catch(err => {
@ -102,8 +67,7 @@ function initScene() {
window.addEventListener('resize', onWindowResize);
// Kick off animation loop
requestAnimationFrame(animate);
animate();
}
function createCameraPan(): Group {
@ -145,24 +109,14 @@ function createCameraPan(): Group {
* Main animation loop that runs continuously
* Handles camera movement, animations, and game state transitions
*/
let lastTime = 0;
function animate(currentTime: number = 0) {
// Calculate delta time in seconds
let deltaTime = lastTime ? (currentTime - lastTime) / 1000 : 0;
lastTime = currentTime;
// Clamp delta time to prevent animation jumps when tab loses focus
deltaTime = Math.min(deltaTime, config.animation.maxDeltaTime);
// Update global timing in gameState
gameState.deltaTime = deltaTime;
gameState.globalTime = currentTime / 1000; // Store in seconds
function animate() {
requestAnimationFrame(animate);
if (gameState.isPlaying) {
// Update the camera angle with delta time
// Pass currentTime to update() to fix the Tween.js bug properly
gameState.cameraPanAnim.update(currentTime);
// 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
gameState.cameraPanAnim.update();
gameState.cameraPanAnim.update();
} else {
// Manual camera controls when not in playback mode
@ -180,8 +134,8 @@ function animate(currentTime: number = 0) {
console.log("All unit animations have completed");
}
// Call update on each active animation with current time
gameState.unitAnimations.forEach((anim) => anim.update(currentTime))
// Call update on each active animation
gameState.unitAnimations.forEach((anim) => anim.update())
}
@ -191,29 +145,33 @@ function animate(currentTime: number = 0) {
console.log(`Scheduling next phase in ${config.effectivePlaybackSpeed}ms`);
gameState.nextPhaseScheduled = true;
gameState.playbackTimer = setTimeout(() => {
advanceToNextPhase()
try {
advanceToNextPhase()
} catch {
// FIXME: This is a dumb patch for us not being able to find the unit we expect to find.
// 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()
gameState.nextPhaseScheduled;
}
}, config.effectivePlaybackSpeed);
}
// Update any pulsing or wave animations on supply centers or units
// In streaming mode, reduce animation frequency
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
const frameSkip = isStreamingMode ? 2 : 1; // Skip every other frame in streaming
if (gameState.scene.userData.animatedObjects && (Math.floor(currentTime / 16.67) % frameSkip === 0)) {
if (gameState.scene.userData.animatedObjects) {
gameState.scene.userData.animatedObjects.forEach(obj => {
if (obj.userData.pulseAnimation) {
const anim = obj.userData.pulseAnimation;
// Use delta time for consistent animation speed regardless of frame rate
anim.time += anim.speed * deltaTime * frameSkip; // Compensate for skipped frames
anim.time += anim.speed;
if (obj.userData.glowMesh) {
const pulseValue = sineWave(config.animation.supplyPulseFrequency, anim.time, anim.intensity, 0.5);
const pulseValue = Math.sin(anim.time) * anim.intensity + 0.5;
obj.userData.glowMesh.material.opacity = 0.2 + (pulseValue * 0.3);
const scale = 1 + (pulseValue * 0.1);
obj.userData.glowMesh.scale.set(scale, scale, scale);
obj.userData.glowMesh.scale.set(
1 + (pulseValue * 0.1),
1 + (pulseValue * 0.1),
1 + (pulseValue * 0.1)
);
}
// Subtle bobbing up/down - reduce in streaming mode
const bobAmount = isStreamingMode ? 0.25 : 0.5;
obj.position.y = 2 + sineWave(config.animation.supplyPulseFrequency, anim.time, bobAmount);
// Subtle bobbing up/down
obj.position.y = 2 + Math.sin(anim.time) * 0.5;
}
});
}
@ -244,7 +202,6 @@ fileInput.addEventListener('change', e => {
if (file) {
loadGameBtnFunction(file);
// Explicitly hide standings board after loading game
hideStandingsBoard();
// Update rotating display and relationship popup with game data
if (gameState.gameData) {
updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower);

View file

@ -2,7 +2,7 @@ import { gameState } from "./gameState";
import { logger } from "./logger";
import { updatePhaseDisplay, playBtn, prevBtn, nextBtn } from "./domElements";
import { initUnits } from "./units/create";
import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state";
import { updateSupplyCenterOwnership, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state";
import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows";
import { createAnimationsForNextPhase } from "./units/animate";
import { speakSummary } from "./speech";
@ -11,6 +11,7 @@ import { debugMenuInstance } from "./debug/debugMenu";
import { showTwoPowerConversation, closeTwoPowerConversation } from "./components/twoPowerConversation";
import { closeVictoryModal, showVictoryModal } from "./components/victoryModal";
import { notifyPhaseChange } from "./webhooks/phaseNotifier";
import { updateLeaderboard } from "./components/leaderboard";
import { updateRotatingDisplay } from "./components/rotatingDisplay";
const MOMENT_THRESHOLD = 8.0
@ -199,7 +200,7 @@ export function displayPhase(skipMessages = false) {
// Update UI elements with smooth transitions
updateLeaderboard(currentPhase);
updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower);
_updateMapOwnership();
// Add phase info to news banner if not already there
@ -210,8 +211,8 @@ export function displayPhase(skipMessages = false) {
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
// Update info panel with power information
logger.updateInfoPanel();
// Update leaderboard with power information
updateLeaderboard();
// Show messages with animation or immediately based on skipMessages flag
if (!skipMessages) {
@ -288,9 +289,7 @@ export function advanceToNextPhase() {
console.log(`Processing phase transition for ${currentPhase.name}`);
}
// In streaming mode, add extra delay before speech to ensure phase is fully displayed
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE === 'True' || import.meta.env.VITE_STREAMING_MODE === 'true';
const speechDelay = isStreamingMode ? 2000 : 0; // 2 second delay in streaming mode
const speechDelay = 2000
// First show summary if available
if (currentPhase.summary && currentPhase.summary.trim() !== '') {

View file

@ -99,10 +99,10 @@
}
/* -----------------
Info Panel
Leaderboard
(lower-right)
----------------- */
#info-panel {
#leaderboard {
position: absolute;
bottom: 20px;
right: 10px;
@ -129,7 +129,7 @@
Leaderboard
(lower-left)
----------------- */
#leaderboard {
#rotating-display {
position: absolute;
bottom: 20px;
left: 10px;