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,10 +10,9 @@ 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;

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

@ -7,7 +7,7 @@ export const config = {
// 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
},

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

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

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';

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
@ -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()
}
})
@ -377,8 +376,6 @@ class GameState {
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
@ -392,25 +389,14 @@ class GameState {
// Renderer with streaming optimizations
this.renderer = new THREE.WebGLRenderer({
antialias: !isStreamingMode, // Disable antialiasing in streaming mode
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);
// 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(() => {
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;