diff --git a/diplomacy/animation/simple-test.html b/diplomacy/animation/simple-test.html index edd4595..84d0f05 100644 --- a/diplomacy/animation/simple-test.html +++ b/diplomacy/animation/simple-test.html @@ -96,6 +96,12 @@ + + No game loaded @@ -117,16 +123,24 @@ let coordinateData = null; let unitMeshes = []; // To store references for units + supply center 3D objects let mapPlane = null; // The fallback map plane + let isPlaying = false; // Track playback state + let playbackSpeed = 500; // Default speed in ms + let playbackTimer = null; // Timer reference for playback + let animationDuration = 1500; // Duration of unit movement animation in ms + let unitAnimations = []; // Track ongoing unit animations + let territoryTransitions = []; // Track territory color transitions // --- DOM ELEMENTS --- - const loadBtn = document.getElementById('load-btn'); - const fileInput = document.getElementById('file-input'); - const prevBtn = document.getElementById('prev-btn'); - const nextBtn = document.getElementById('next-btn'); - const phaseDisplay = document.getElementById('phase-display'); - const infoPanel = document.getElementById('info-panel'); - const mapView = document.getElementById('map-view'); - const leaderboard = document.getElementById('leaderboard'); + const loadBtn = document.getElementById('load-btn'); + const fileInput = document.getElementById('file-input'); + const prevBtn = document.getElementById('prev-btn'); + const nextBtn = document.getElementById('next-btn'); + const playBtn = document.getElementById('play-btn'); + const speedSelector = document.getElementById('speed-selector'); + const phaseDisplay = document.getElementById('phase-display'); + const infoPanel = document.getElementById('info-panel'); + const mapView = document.getElementById('map-view'); + const leaderboard = document.getElementById('leaderboard'); // Add roundRect polyfill for browsers that don't support it if (!CanvasRenderingContext2D.prototype.roundRect) { @@ -231,6 +245,74 @@ function animate() { requestAnimationFrame(animate); + const currentTime = Date.now(); + + // Process unit movement animations + if (unitAnimations.length > 0) { + unitAnimations.forEach((anim, index) => { + // Calculate progress (0 to 1) + const elapsed = currentTime - anim.startTime; + const progress = Math.min(1, elapsed / anim.duration); + + // Apply movement + if (progress < 1) { + // Apply easing for more natural movement - ease in and out + const easedProgress = easeInOutCubic(progress); + + // Update position + anim.object.position.x = anim.startPos.x + (anim.endPos.x - anim.startPos.x) * easedProgress; + anim.object.position.z = anim.startPos.z + (anim.endPos.z - anim.startPos.z) * easedProgress; + + // Subtle bobbing up and down during movement + anim.object.position.y = 10 + Math.sin(progress * Math.PI * 2) * 5; + + // For fleets (ships), add a gentle rocking motion + if (anim.object.userData.type === 'F') { + anim.object.rotation.z = Math.sin(progress * Math.PI * 3) * 0.05; + anim.object.rotation.x = Math.sin(progress * Math.PI * 2) * 0.05; + } + } else { + // Animation complete, remove from active animations + unitAnimations.splice(index, 1); + + // Set final position + anim.object.position.x = anim.endPos.x; + anim.object.position.z = anim.endPos.z; + anim.object.position.y = 10; // Reset height + + // Reset rotation for ships + if (anim.object.userData.type === 'F') { + anim.object.rotation.z = 0; + anim.object.rotation.x = 0; + } + + // If this was the last animation and playback is active, continue after delay + if (unitAnimations.length === 0 && isPlaying) { + // Schedule next phase after a pause delay + playbackTimer = setTimeout(() => advanceToNextPhase(), playbackSpeed); + } + } + }); + } + + // Process territory color transitions + if (territoryTransitions.length > 0) { + territoryTransitions.forEach((transition, index) => { + const elapsed = currentTime - transition.startTime; + const progress = Math.min(1, elapsed / transition.duration); + + if (progress < 1) { + // Interpolate colors + const easedProgress = easeInOutCubic(progress); + transition.canvas.style.opacity = easedProgress; + } else { + // Transition complete + territoryTransitions.splice(index, 1); + transition.canvas.style.opacity = 1; + } + }); + } + // Update any pulsing or wave animations on supply centers or units if (scene.userData.animatedObjects) { scene.userData.animatedObjects.forEach(obj => { @@ -256,6 +338,11 @@ renderer.render(scene, camera); } + // Easing function for smooth animations + function easeInOutCubic(t) { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + // --- RESIZE HANDLER --- function onWindowResize() { camera.aspect = mapView.clientWidth / mapView.clientHeight; @@ -415,7 +502,7 @@ // Apply bubble-physics to make territories squish against each other applyTerritorySquishing(provinces); - + // Draw each province as an irregular territory // Draw in reverse order so water is on bottom, land on top provinces @@ -1284,6 +1371,8 @@ if (gameData.phases?.length) { prevBtn.disabled = false; nextBtn.disabled = false; + playBtn.disabled = false; + speedSelector.disabled = false; displayPhase(currentPhaseIndex); } } catch (err) { @@ -1305,7 +1394,7 @@ // Clear old units unitMeshes.forEach(m => scene.remove(m)); unitMeshes = []; - + const phase = gameData.phases[index]; phaseDisplay.textContent = `${phase.name || 'Unknown Phase'} (${index + 1}/${gameData.phases.length})`; @@ -1320,7 +1409,7 @@ ownershipMap[p.toUpperCase()] = power.toUpperCase(); }); } - + // Then add territories where units are located (if not already claimed by supply centers) for (const [power, unitArray] of Object.entries(units)) { unitArray.forEach(unitStr => { @@ -1425,6 +1514,276 @@ leaderboard.innerHTML = html; } + // --- PLAYBACK CONTROLS --- + function togglePlayback() { + if (!gameData || gameData.phases.length <= 1) return; + + isPlaying = !isPlaying; + + if (isPlaying) { + // Update button text to show pause + playBtn.textContent = "⏸ Pause"; + + // Disable manual navigation during playback + prevBtn.disabled = true; + nextBtn.disabled = true; + + // Start playback + advanceToNextPhase(); + } else { + // Update button text to show play + playBtn.textContent = "▶ Play"; + + // Clear any pending timers + if (playbackTimer) { + clearTimeout(playbackTimer); + playbackTimer = null; + } + + // Cancel any ongoing animations + unitAnimations = []; + + // Re-enable manual navigation + prevBtn.disabled = false; + nextBtn.disabled = false; + } + } + + function advanceToNextPhase() { + // If we've reached the end, loop back to the beginning + if (currentPhaseIndex >= gameData.phases.length - 1) { + currentPhaseIndex = 0; + } else { + currentPhaseIndex++; + } + + // Display the new phase with animation + displayPhaseWithAnimation(currentPhaseIndex); + } + + function displayPhaseWithAnimation(index) { + if (!gameData || !gameData.phases || index < 0 || index >= gameData.phases.length) { + infoPanel.textContent = "Invalid phase index."; + return; + } + + const previousIndex = index > 0 ? index - 1 : gameData.phases.length - 1; + const currentPhase = gameData.phases[index]; + const previousPhase = gameData.phases[previousIndex]; + + phaseDisplay.textContent = `${currentPhase.name || 'Unknown Phase'} (${index + 1}/${gameData.phases.length})`; + + // Build ownership maps for territory coloring + const currentCenters = currentPhase.state?.centers || {}; + const currentUnits = currentPhase.state?.units || {}; + const previousCenters = previousPhase.state?.centers || {}; + const previousUnits = previousPhase.state?.units || {}; + + const currentOwnershipMap = buildOwnershipMap(currentCenters, currentUnits); + const previousOwnershipMap = buildOwnershipMap(previousCenters, previousUnits); + + // Update map with new territory colors + createFallbackMap(currentOwnershipMap); + + // Clear previous unit meshes (except supply centers) + const supplyCenters = unitMeshes.filter(m => m.userData && m.userData.isSupplyCenter); + const oldUnits = unitMeshes.filter(m => m.userData && !m.userData.isSupplyCenter); + + oldUnits.forEach(m => scene.remove(m)); + unitMeshes = supplyCenters; // Keep supply centers + + // Update supply center ownership + if (currentPhase.state?.centers) { + updateSupplyCenterOwnership(currentPhase.state.centers); + } + + // Create unit position maps for animation + const previousUnitPositions = {}; + const currentUnitPositions = {}; + + // Map previous unit positions + if (previousPhase.state?.units) { + for (const [power, unitArr] of Object.entries(previousPhase.state.units)) { + unitArr.forEach(unitStr => { + const match = unitStr.match(/^([AF])\s+(.+)$/); + if (match) { + const key = `${power}-${match[1]}-${match[2]}`; + previousUnitPositions[key] = getProvincePosition(match[2]); + } + }); + } + } + + // Create and position new units, with animation from previous positions if available + if (currentPhase.state?.units) { + for (const [power, unitArr] of Object.entries(currentPhase.state.units)) { + unitArr.forEach(unitStr => { + const match = unitStr.match(/^([AF])\s+(.+)$/); + if (match) { + const unitType = match[1]; + const location = match[2]; + const key = `${power}-${unitType}-${location}`; + + // Create the unit mesh + const unitMesh = createUnitMesh({ + power: power.toUpperCase(), + type: unitType, + location: location, + }); + + // Get current position + const currentPos = getProvincePosition(location); + currentUnitPositions[key] = currentPos; + + // Choose starting position + let startPos; + + // Try to find a matching unit in the previous phase + let matchFound = false; + for (const prevKey in previousUnitPositions) { + // Check if same power and type (but possibly different location) + if (prevKey.startsWith(`${power}-${unitType}`)) { + startPos = previousUnitPositions[prevKey]; + matchFound = true; + delete previousUnitPositions[prevKey]; // Mark as matched + break; + } + } + + if (!matchFound) { + // New unit - create with a "spawn" animation + startPos = { + x: currentPos.x, + y: -20, // Start below the map + z: currentPos.z + }; + } + + // Position at start position + unitMesh.position.set(startPos.x, 10, startPos.z); + scene.add(unitMesh); + unitMeshes.push(unitMesh); + + // Add animation to move to final position + unitAnimations.push({ + object: unitMesh, + startPos: startPos, + endPos: currentPos, + startTime: Date.now(), + duration: animationDuration + }); + } + }); + } + } + + // Update the leaderboard + updateLeaderboard(currentPhase); + + // Show phase info + infoPanel.textContent = `Phase: ${currentPhase.name}\nSupply centers: ${ + currentPhase.state?.centers ? JSON.stringify(currentPhase.state.centers) : 'None' + }\nUnits: ${ + currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None' + }`; + } + + // Helper function to create ownership map from centers and units + function buildOwnershipMap(centers, units) { + const ownershipMap = {}; + + // First add supply centers + for (const [power, provinces] of Object.entries(centers)) { + provinces.forEach(p => { + ownershipMap[p.toUpperCase()] = power.toUpperCase(); + }); + } + + // Then add territories with units + for (const [power, unitArray] of Object.entries(units)) { + unitArray.forEach(unitStr => { + const match = unitStr.match(/^([AF])\s+(.+)$/); + if (match) { + const location = match[2].toUpperCase().split('/')[0]; + if (!ownershipMap[location]) { + ownershipMap[location] = power.toUpperCase(); + } + } + }); + } + + return ownershipMap; + } + + // Create a unit mesh (extracted from displayUnit for reuse) + function createUnitMesh(unitData) { + // Choose color by power + const powerColors = { + 'AUSTRIA': 0xc40000, + 'ENGLAND': 0x00008B, + 'FRANCE': 0x0fa0d0, + 'GERMANY': 0x444444, + 'ITALY': 0x008000, + 'RUSSIA': 0xcccccc, + 'TURKEY': 0xe0c846 + }; + const color = powerColors[unitData.power] || 0xAAAAAA; + + let group = new THREE.Group(); + // Minimal shape difference for armies vs fleets + if (unitData.type === 'A') { + // Army: a block + small head for soldier-like appearance + const body = new THREE.Mesh( + new THREE.BoxGeometry(15, 20, 10), + new THREE.MeshStandardMaterial({ color }) + ); + body.position.y = 10; + group.add(body); + + // Head + const head = new THREE.Mesh( + new THREE.SphereGeometry(4, 12, 12), + new THREE.MeshStandardMaterial({ color }) + ); + head.position.set(0, 24, 0); + group.add(head); + } else { + // Fleet: a rectangle + a mast and sail + const hull = new THREE.Mesh( + new THREE.BoxGeometry(30, 8, 15), + new THREE.MeshStandardMaterial({ color: 0x8B4513 }) + ); + hull.position.y = 4; + group.add(hull); + + // Mast + const mast = new THREE.Mesh( + new THREE.CylinderGeometry(1, 1, 30, 8), + new THREE.MeshStandardMaterial({ color: 0x000000 }) + ); + mast.position.y = 15; + group.add(mast); + + // Sail + const sail = new THREE.Mesh( + new THREE.PlaneGeometry(20, 15), + new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide }) + ); + sail.rotation.y = Math.PI/2; + sail.position.set(0, 15, 0); + group.add(sail); + } + + // Store metadata + group.userData = { + power: unitData.power, + type: unitData.type, + location: unitData.location + }; + + return group; + } + // --- EVENT HANDLERS --- loadBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', e => { @@ -1447,6 +1806,17 @@ } }); + playBtn.addEventListener('click', togglePlayback); + + speedSelector.addEventListener('change', e => { + playbackSpeed = parseInt(e.target.value); + // If we're currently playing, restart the timer with the new speed + if (isPlaying && playbackTimer) { + clearTimeout(playbackTimer); + playbackTimer = setTimeout(() => advanceToNextPhase(), playbackSpeed); + } + }); + // --- BOOTSTRAP ON PAGE LOAD --- window.addEventListener('load', initScene);