AI_Diplomacy/diplomacy/animation/renderer/MapRenderer.js
2025-03-04 11:35:02 -08:00

1099 lines
No EOL
35 KiB
JavaScript

// ==============================================================================
// Copyright (C) 2023
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
// details.
//
// You should have received a copy of the GNU Affero General Public License along
// with this program. If not, see <https://www.gnu.org/licenses/>.
// ==============================================================================
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { CoordinateMapper } from '../utils/CoordinateMapper.js';
import { UnitModels } from './UnitModels.js';
import { AnimationEffects } from './AnimationEffects.js';
import { OrderVisualizer } from './OrderVisualizer.js';
/**
* Handles the 3D rendering for the animation system.
* This class is responsible for setting up the Three.js environment,
* rendering the map, and managing the game units and animations.
*/
export class MapRenderer {
/**
* Initialize the map renderer
* @param {Object} options - Configuration options
* @param {string} options.containerId - ID of the container element
* @param {string} options.mapVariant - Map variant to use (standard, ancmed, etc.)
* @param {CoordinateMapper} options.coordinateMapper - CoordinateMapper instance
* @param {string} options.detailLevel - Detail level for unit models ('low', 'medium', 'high')
* @param {boolean} options.debug - Whether to show debug elements
*/
constructor(options) {
this.containerId = options.containerId;
this.container = document.getElementById(this.containerId);
this.mapVariant = options.mapVariant || 'standard';
this.coordinateMapper = options.coordinateMapper;
this.detailLevel = options.detailLevel || 'medium';
this.debug = options.debug || false;
// Scene objects
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.lights = [];
// Map and unit objects
this.mapTexture = null;
this.mapMesh = null;
this.units = new Map();
this.unitModels = null;
// Animation related objects
this.animationEffects = null;
this.orderVisualizer = null;
this.pendingAnimations = [];
this.activeAnimations = [];
// Animation settings
this.animationSpeed = 1.0;
this.easing = true;
// Initialize everything
this._init();
}
/**
* Initialize the Three.js renderer
* @private
*/
_init() {
// Create scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x87CEEB); // Sky blue background
// Setup camera
this.camera = new THREE.PerspectiveCamera(
60, // Field of view
this.container.clientWidth / this.container.clientHeight, // Aspect ratio
1, // Near clipping plane
5000 // Far clipping plane
);
this.camera.position.set(0, 500, 500);
this.camera.lookAt(0, 0, 0);
// Setup renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.shadowMap.enabled = true;
this.container.appendChild(this.renderer.domElement);
// Setup controls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.screenSpacePanning = false;
this.controls.minDistance = 100;
this.controls.maxDistance = 1500;
this.controls.maxPolarAngle = Math.PI / 2;
// Add a hemisphere light for ambient lighting
const hemisphereLight = new THREE.HemisphereLight(0xFFFFFF, 0x202020, 1);
this.scene.add(hemisphereLight);
// Add a directional light for shadows and depth
const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 0.8);
directionalLight.position.set(300, 400, 300);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 100;
directionalLight.shadow.camera.far = 1500;
directionalLight.shadow.camera.left = -500;
directionalLight.shadow.camera.right = 500;
directionalLight.shadow.camera.top = 500;
directionalLight.shadow.camera.bottom = -500;
this.scene.add(directionalLight);
this.lights.push(directionalLight);
// Add a secondary directional light from the opposite direction
const secondaryLight = new THREE.DirectionalLight(0xFFFFFF, 0.3);
secondaryLight.position.set(-300, 200, -300);
this.scene.add(secondaryLight);
this.lights.push(secondaryLight);
// Add ground grid for debugging
if (this.debug) {
const gridHelper = new THREE.GridHelper(1000, 20);
this.scene.add(gridHelper);
const axesHelper = new THREE.AxesHelper(500);
this.scene.add(axesHelper);
}
// Initialize animation effects and order visualizer
this.animationEffects = new AnimationEffects(this.scene);
this.orderVisualizer = new OrderVisualizer(this.animationEffects, this.coordinateMapper);
// Load map textures and models
this._loadMapAssets();
// Handle window resize
window.addEventListener('resize', this._onWindowResize.bind(this));
this.isInitialized = true;
}
/**
* Load map textures and unit models
* @private
*/
_loadMapAssets() {
// Load the map texture
const textureLoader = new THREE.TextureLoader();
// First try to load from SVG path (which is used by the web version)
const svgPath = `./diplomacy/animation/assets/maps/${this.mapVariant}.svg`;
console.log(`[MapRenderer] Attempting to load map from: ${svgPath}`);
textureLoader.load(
svgPath,
(texture) => {
console.log(`[MapRenderer] Successfully loaded map texture: ${svgPath}`);
this.mapTexture = texture;
this._createMapMesh();
},
undefined, // Progress callback
(error) => {
console.warn(`[MapRenderer] Failed to load SVG map: ${error.message}`);
// Try alternate path
const altPath = `./assets/maps/${this.mapVariant}.svg`;
console.log(`[MapRenderer] Trying alternate path: ${altPath}`);
textureLoader.load(
altPath,
(texture) => {
console.log(`[MapRenderer] Successfully loaded map texture from alternate path`);
this.mapTexture = texture;
this._createMapMesh();
},
undefined,
(altError) => {
console.warn(`[MapRenderer] Failed to load SVG map from alternate path: ${altError.message}`);
// Try fallback to animation assets path
const fallbackPath = `./assets/maps/${this.mapVariant}_map.jpg`;
console.log(`[MapRenderer] Attempting fallback map load from: ${fallbackPath}`);
textureLoader.load(
fallbackPath,
(texture) => {
console.log(`[MapRenderer] Successfully loaded fallback map texture`);
this.mapTexture = texture;
this._createMapMesh();
},
undefined,
(fallbackError) => {
console.warn(`[MapRenderer] Failed to load fallback map: ${fallbackError.message}`);
// Try one more fallback with original path format
const lastFallbackPath = `/diplomacy/animation/assets/maps/${this.mapVariant}_map.jpg`;
console.log(`[MapRenderer] Attempting last fallback path: ${lastFallbackPath}`);
textureLoader.load(
lastFallbackPath,
(texture) => {
console.log(`[MapRenderer] Successfully loaded from last fallback path`);
this.mapTexture = texture;
this._createMapMesh();
},
undefined,
(lastError) => {
console.warn(`[MapRenderer] All loading attempts failed, creating placeholder map`);
// Create a placeholder if all attempts fail
this._createPlaceholderMap();
}
);
}
);
}
);
// TODO: Load unit models in Phase 2
// For now, we'll use simple shapes for units
this.unitModels = new UnitModels({ detailLevel: this.detailLevel });
}
/**
* Create placeholder map with a grid pattern
* @private
*/
_createPlaceholderMap() {
console.log('[MapRenderer] Creating placeholder map');
// Create a canvas for the placeholder texture
const canvas = document.createElement('canvas');
canvas.width = 2048;
canvas.height = 2048;
const ctx = canvas.getContext('2d');
// Fill with base color for ocean
ctx.fillStyle = '#8BAED8';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Get all province locations
const allLocations = this.coordinateMapper.getAllLocations();
// Draw provinces
this._drawProvincesOnCanvas(ctx, allLocations, canvas.width, canvas.height);
// Create a texture from the canvas
this.mapTexture = new THREE.CanvasTexture(canvas);
// Create the map mesh
this._createMapMesh();
}
/**
* Draw provinces on a canvas for the placeholder map
* @param {CanvasRenderingContext2D} ctx - The canvas context
* @param {string[]} locations - List of province locations
* @param {number} width - Canvas width
* @param {number} height - Canvas height
* @private
*/
_drawProvincesOnCanvas(ctx, locations, width, height) {
// Track which provinces we've drawn
const drawnProvinces = new Set();
// First draw sea provinces (so land provinces appear on top)
for (const location of locations) {
const provinceInfo = this.coordinateMapper.getProvinceInfo(location);
if (provinceInfo && provinceInfo.type === 'sea') {
this._drawProvinceOnCanvas(ctx, location, width, height);
drawnProvinces.add(location);
}
}
// Then draw land provinces
for (const location of locations) {
if (!drawnProvinces.has(location)) {
this._drawProvinceOnCanvas(ctx, location, width, height);
}
}
// Draw borders between provinces
this._drawProvinceBorders(ctx, locations, width, height);
// Draw province labels
this._drawProvinceLabels(ctx, locations, width, height);
}
/**
* Draw a single province on the canvas
* @param {CanvasRenderingContext2D} ctx - The canvas context
* @param {string} location - Province location
* @param {number} width - Canvas width
* @param {number} height - Canvas height
* @private
*/
_drawProvinceOnCanvas(ctx, location, width, height) {
const pos = this.coordinateMapper.getPositionForLocation(location);
const provinceInfo = this.coordinateMapper.getProvinceInfo(location);
if (!pos || !provinceInfo) return;
// Map 3D coordinates to 2D canvas
// We're assuming the 3D map has coordinates from -500 to 500 in X and Z
const x = (pos.x + 500) * (width / 1000);
const y = (pos.z + 500) * (height / 1000);
// Determine province color based on type
const isSupplyCenter = provinceInfo.isSupplyCenter;
const type = provinceInfo.type;
// Set radius based on importance
const radius = isSupplyCenter ? 45 : 40;
// Draw province shape (circle for now, could be more complex shape in the future)
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
// Set province fill color
if (type === 'sea') {
ctx.fillStyle = '#4A87C5'; // Darker blue for sea
} else {
// Land provinces
if (isSupplyCenter) {
ctx.fillStyle = '#D4C499'; // Beige for supply centers
} else {
ctx.fillStyle = '#B8AA85'; // Tan for regular land
}
}
ctx.fill();
// Add a subtle highlight for supply centers
if (isSupplyCenter) {
ctx.beginPath();
ctx.arc(x, y, radius - 10, 0, Math.PI * 2);
ctx.fillStyle = type === 'sea' ? '#5899D9' : '#E7D6A9';
ctx.fill();
}
}
/**
* Draw borders between provinces
* @param {CanvasRenderingContext2D} ctx - The canvas context
* @param {string[]} locations - List of province locations
* @param {number} width - Canvas width
* @param {number} height - Canvas height
* @private
*/
_drawProvinceBorders(ctx, locations, width, height) {
// Draw connecting lines between adjacent provinces
// For now, we'll use a simple approach and connect some known adjacent provinces
// This is a simplified adjacency list for the standard map
// In a full implementation, this would be loaded from a configuration file
const adjacencyList = {
// Western Europe
'BRE': ['PAR', 'PIC', 'ENG', 'MAO', 'GAS'],
'PAR': ['BRE', 'PIC', 'BUR', 'GAS'],
'PIC': ['BRE', 'PAR', 'BUR', 'BEL', 'ENG'],
'BEL': ['PIC', 'BUR', 'RUH', 'HOL', 'NTH', 'ENG'],
'HOL': ['BEL', 'RUH', 'KIE', 'HEL', 'NTH'],
// Great Britain
'LON': ['YOR', 'WAL', 'ENG', 'NTH'],
'YOR': ['LON', 'WAL', 'EDI', 'NTH'],
'EDI': ['YOR', 'CLY', 'NTH', 'NWG'],
'WAL': ['LON', 'YOR', 'IRI', 'ENG'],
'CLY': ['EDI', 'NWG', 'NAO'],
// Etc. for other regions
// This is just a partial list for demonstration
};
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
// Draw connections based on adjacency list
for (const [province, adjacentProvinces] of Object.entries(adjacencyList)) {
const pos1 = this.coordinateMapper.getPositionForLocation(province);
if (!pos1) continue;
const x1 = (pos1.x + 500) * (width / 1000);
const y1 = (pos1.z + 500) * (height / 1000);
for (const adjacentProvince of adjacentProvinces) {
const pos2 = this.coordinateMapper.getPositionForLocation(adjacentProvince);
if (!pos2) continue;
const x2 = (pos2.x + 500) * (width / 1000);
const y2 = (pos2.z + 500) * (height / 1000);
// Draw line between provinces
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
/**
* Draw province labels on the canvas
* @param {CanvasRenderingContext2D} ctx - The canvas context
* @param {string[]} locations - List of province locations
* @param {number} width - Canvas width
* @param {number} height - Canvas height
* @private
*/
_drawProvinceLabels(ctx, locations, width, height) {
// Draw province names
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const location of locations) {
const pos = this.coordinateMapper.getPositionForLocation(location);
const provinceInfo = this.coordinateMapper.getProvinceInfo(location);
if (!pos || !provinceInfo) continue;
const x = (pos.x + 500) * (width / 1000);
const y = (pos.z + 500) * (height / 1000);
// Set text color based on province type
ctx.fillStyle = provinceInfo.type === 'sea' ? '#FFFFFF' : '#000000';
// Draw province name
ctx.fillText(location, x, y);
}
}
/**
* Create the 3D mesh for the map
* @private
*/
_createMapMesh() {
// Create a large plane for the map
const geometry = new THREE.PlaneGeometry(1000, 1000);
// Apply the map texture to a material
const material = new THREE.MeshBasicMaterial({
map: this.mapTexture,
side: THREE.DoubleSide
});
// Create mesh and position it horizontally at y=0
this.mapMesh = new THREE.Mesh(geometry, material);
this.mapMesh.rotation.x = -Math.PI / 2; // Rotate to horizontal
this.mapMesh.position.y = -1; // Slightly below units to prevent z-fighting
// Add to scene
this.scene.add(this.mapMesh);
// After map is created, add supply center markers
this._addSupplyCenterMarkers();
}
/**
* Add visual markers for supply centers
* @private
*/
_addSupplyCenterMarkers() {
// Get all province locations
const allLocations = this.coordinateMapper.getAllLocations();
// Create a marker for each supply center
allLocations.forEach(location => {
if (this.coordinateMapper.isSupplyCenter(location)) {
const pos = this.coordinateMapper.getPositionForLocation(location);
// Create a small cylinder as marker
const geometry = new THREE.CylinderGeometry(5, 5, 2, 16);
const material = new THREE.MeshBasicMaterial({ color: 0xffff00 });
const marker = new THREE.Mesh(geometry, material);
// Tag the marker so we can find it later
marker.userData = {
type: 'supplyCenter',
location: location
};
marker.position.set(pos.x, 1, pos.z); // Position at the supply center
this.scene.add(marker);
}
});
}
/**
* Handle window resize
* @private
*/
_onWindowResize() {
if (!this.camera || !this.renderer) return;
this.camera.aspect = this.container.clientWidth / this.container.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
}
/**
* Start the render loop
*/
startRendering() {
if (!this.isInitialized) {
console.warn('[MapRenderer] Cannot start rendering: not initialized');
return;
}
this.isRendering = true;
this.lastRenderTime = performance.now();
this._renderLoop();
}
/**
* Stop the render loop
*/
stopRendering() {
this.isRendering = false;
}
/**
* The main render loop
* @private
*/
_renderLoop() {
if (!this.isRendering) return;
const now = performance.now();
const deltaTime = (now - this.lastRenderTime) / 1000; // in seconds
this.lastRenderTime = now;
// Update controls
this.controls.update();
// Update animations
this._updateAnimations(deltaTime);
// Render the scene
this.renderer.render(this.scene, this.camera);
// Schedule the next frame
requestAnimationFrame(() => this._renderLoop());
}
/**
* Update animation states
* @param {number} deltaTime - Time since last update in seconds
* @private
*/
_updateAnimations(deltaTime) {
// Update animation effects
if (this.animationEffects) {
this.animationEffects.update(deltaTime);
}
// Update active unit animations
this._updateUnitAnimations(deltaTime);
}
/**
* Update unit animations
* @param {number} deltaTime - Time since last update in seconds
* @private
*/
_updateUnitAnimations(deltaTime) {
// Process active animations
const completedAnimations = [];
this.activeAnimations.forEach(animation => {
// Update progress based on speed and deltaTime
animation.progress += (deltaTime * this.animationSpeed) / animation.duration;
// Cap progress at 1.0
if (animation.progress > 1.0) {
animation.progress = 1.0;
completedAnimations.push(animation);
}
// Get the actual unit
const unit = this.units.get(animation.unitId);
if (!unit) return;
// Calculate position along the path using easing
let t = animation.progress;
// Apply easing if enabled (cubic ease in/out)
if (this.easing) {
t = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// Get position along the path
const pathPosition = this._getPositionAlongPath(animation.path, t);
// For armies: add a small bounce effect
let yOffset = 10; // Base height above the map
if (unit.data.type === 'A') {
yOffset += Math.sin(t * Math.PI) * 15; // Add bounce effect with sine wave
}
// Apply the position to the unit model
unit.model.position.set(pathPosition.x, yOffset, pathPosition.z);
// Update unit's internal position
unit.position = { x: pathPosition.x, y: yOffset, z: pathPosition.z };
// For fleets: rotate the model to face the movement direction
if (unit.data.type === 'F' && t > 0 && t < 1) {
// Calculate direction vector
const nextT = Math.min(1, t + 0.05);
const nextPos = this._getPositionAlongPath(animation.path, nextT);
const dir = new THREE.Vector3(nextPos.x - pathPosition.x, 0, nextPos.z - pathPosition.z).normalize();
// Calculate rotation
const angle = Math.atan2(dir.x, dir.z);
unit.model.rotation.y = angle;
}
});
// Remove completed animations
completedAnimations.forEach(animation => {
const index = this.activeAnimations.indexOf(animation);
if (index !== -1) {
this.activeAnimations.splice(index, 1);
}
// Update unit location
if (animation.onComplete) {
animation.onComplete();
}
// Start next animation if queue is not empty
this._startNextAnimation();
});
}
/**
* Start the next animation in the queue
* @private
*/
_startNextAnimation() {
if (this.pendingAnimations.length > 0) {
const nextAnimation = this.pendingAnimations.shift();
this.activeAnimations.push(nextAnimation);
}
}
/**
* Get position along a path with given progress (0.0 - 1.0)
* @param {Array<THREE.Vector3>} path - Array of path points
* @param {number} t - Progress along the path (0.0 - 1.0)
* @returns {THREE.Vector3} Position along the path
* @private
*/
_getPositionAlongPath(path, t) {
// Handle edge cases
if (t <= 0) return path[0];
if (t >= 1) return path[path.length - 1];
// Calculate the point index we're between
const segmentCount = path.length - 1;
const segmentIndex = Math.min(Math.floor(t * segmentCount), segmentCount - 1);
// Calculate progress within this segment
const segmentT = (t * segmentCount) - segmentIndex;
// Get the two points we're between
const p0 = path[segmentIndex];
const p1 = path[segmentIndex + 1];
// Interpolate between the points
return new THREE.Vector3(
p0.x + (p1.x - p0.x) * segmentT,
p0.y + (p1.y - p0.y) * segmentT,
p0.z + (p1.z - p0.z) * segmentT
);
}
/**
* Visualize an order on the map
* @param {Object} orderData - The order data
* @param {string} orderData.text - The order text
* @param {string} orderData.power - The power giving the order
* @param {boolean} orderData.success - Whether the order succeeded
* @returns {Object} Visualization elements created
*/
visualizeOrder(orderData) {
if (!this.orderVisualizer) return null;
return this.orderVisualizer.visualizeOrder(orderData);
}
/**
* Clear all order visualizations
*/
clearOrderVisualizations() {
if (this.orderVisualizer) {
this.orderVisualizer.clearAllVisualizations();
}
}
/**
* Remove a specific order visualization
* @param {string} orderText - The order text to remove
*/
removeOrderVisualization(orderText) {
if (this.orderVisualizer) {
this.orderVisualizer.removeVisualization(orderText);
}
}
/**
* Animate unit movement between provinces
* @param {string} unitId - The unit ID
* @param {string} fromLocation - Starting location
* @param {string} toLocation - Destination location
* @param {Object} options - Animation options
* @param {number} options.duration - Animation duration in seconds (default: 1.5)
* @param {number} options.arcHeight - Height of the arc for movement (default: 30)
* @param {number} options.steps - Number of steps in the path (default: 10)
* @param {boolean} options.queueAnimation - Whether to queue this animation (default: true)
* @param {Function} options.onComplete - Callback when animation completes
* @returns {boolean} Whether the animation was started or queued
*/
animateUnitMovement(unitId, fromLocation, toLocation, options = {}) {
// Get unit info
const unit = this.units.get(unitId);
if (!unit) {
console.warn(`[MapRenderer] Cannot animate movement: unknown unit ${unitId}`);
return false;
}
// Set option defaults
const duration = options.duration || 1.5;
const arcHeight = options.arcHeight || 30;
const steps = options.steps || 10;
const queueAnimation = options.queueAnimation !== false;
// Get path between locations
const path = this.coordinateMapper.getPathBetween(fromLocation, toLocation, steps, arcHeight);
if (!path || path.length < 2) {
console.warn(`[MapRenderer] Cannot animate movement: could not calculate path from ${fromLocation} to ${toLocation}`);
return false;
}
// Create the animation object
const animation = {
unitId,
fromLocation,
toLocation,
path,
duration,
progress: 0,
onComplete: () => {
// Update the unit's internal location when animation completes
unit.data.location = toLocation;
// Call the user-provided callback if any
if (options.onComplete) {
options.onComplete();
}
}
};
// Either start immediately or queue
if (queueAnimation && this.activeAnimations.length > 0) {
this.pendingAnimations.push(animation);
} else {
this.activeAnimations.push(animation);
}
return true;
}
/**
* Set the animation speed multiplier
* @param {number} speed - Speed multiplier (1.0 = normal)
*/
setAnimationSpeed(speed) {
this.animationSpeed = Math.max(0.1, Math.min(5.0, speed));
}
/**
* Enable or disable animation easing
* @param {boolean} enabled - Whether easing is enabled
*/
setEasing(enabled) {
this.easing = enabled;
}
/**
* Pause all unit animations
*/
pauseAnimations() {
this.previousAnimationSpeed = this.animationSpeed;
this.animationSpeed = 0;
}
/**
* Resume all unit animations
*/
resumeAnimations() {
this.animationSpeed = this.previousAnimationSpeed || 1.0;
}
/**
* Cancel all pending and active animations
* @param {boolean} finishActive - Whether to finish active animations immediately
*/
cancelAnimations(finishActive = false) {
// Clear pending animations
this.pendingAnimations = [];
if (finishActive) {
// Complete all active animations immediately
this.activeAnimations.forEach(animation => {
// Set progress to 1.0 to finish
animation.progress = 1.0;
// Update unit position to the end of the path
const unit = this.units.get(animation.unitId);
if (unit) {
const finalPos = animation.path[animation.path.length - 1];
unit.model.position.set(finalPos.x, 10, finalPos.z);
unit.position = { x: finalPos.x, y: 10, z: finalPos.z };
unit.data.location = animation.toLocation;
// Call completion callback
if (animation.onComplete) {
animation.onComplete();
}
}
});
// Clear active animations
this.activeAnimations = [];
}
}
/**
* Add a unit to the scene
* @param {Object} unitData - The unit data
* @param {string} unitData.id - Unique ID for the unit
* @param {string} unitData.type - Unit type ('A' for army, 'F' for fleet)
* @param {string} unitData.location - Unit location (e.g. "LON", "PAR")
* @param {string} unitData.power - The power controlling the unit
* @param {Object} [unitData.color] - Optional color override
*/
addUnit(unitData) {
// Skip if already exists
if (this.units.has(unitData.id)) {
console.warn(`[MapRenderer] Unit already exists: ${unitData.id}`);
return;
}
// Get location coordinates
const position = this.coordinateMapper.getPositionForLocation(unitData.location);
if (!position) {
console.warn(`[MapRenderer] Cannot add unit: unknown location ${unitData.location}`);
return;
}
// Determine unit type and model
const unitType = unitData.type === 'A' ? 'army' : 'fleet';
const unitModel = this.unitModels.getUnitModel(unitType, unitData.power);
// Determine unit color based on power
const powerColors = {
AUSTRIA: 0xBF1E2E, // Red
ENGLAND: 0x1B5EC0, // Blue
FRANCE: 0x127BBF, // Light Blue
GERMANY: 0x454545, // Gray/Black
ITALY: 0x087E3B, // Green
RUSSIA: 0xFFFFFF, // White
TURKEY: 0xFFD700 // Yellow
};
// Use power color or default to gray
const color = unitData.color || powerColors[unitData.power] || 0x888888;
// Apply color to unit
unitModel.traverse(child => {
// Only apply to the main body, not to flags/sails which stay white
if (child.isMesh && child !== unitModel.children[0]) {
child.material = child.material.clone();
child.material.color.setHex(color);
}
});
// Position the unit
unitModel.position.set(position.x, 10, position.z); // Higher above the map
// Add to scene
this.scene.add(unitModel);
// Store reference to unit
this.units.set(unitData.id, {
data: unitData,
model: unitModel,
position: { ...position, y: 10 },
animation: null
});
}
/**
* Remove a unit from the scene
* @param {string} unitId - The unit ID
*/
removeUnit(unitId) {
const unit = this.units.get(unitId);
if (!unit) return;
// Remove from scene
this.scene.remove(unit.model);
// Remove from units map
this.units.delete(unitId);
}
/**
* Clear all units from the scene
*/
clearUnits() {
// Remove all units from the scene
this.units.forEach(unit => {
this.scene.remove(unit.model);
});
// Clear the units map
this.units.clear();
}
/**
* Update unit position
* @param {string} unitId - The unit ID
* @param {string} location - The new location
*/
updateUnitPosition(unitId, location) {
// Get the unit
const unit = this.units.get(unitId);
if (!unit) {
console.warn(`[MapRenderer] Cannot update position: unknown unit ${unitId}`);
return;
}
// Get new position
const position = this.coordinateMapper.getPositionForLocation(location);
if (!position) {
console.warn(`[MapRenderer] Cannot update position: unknown location ${location}`);
return;
}
// Update unit data
unit.data.location = location;
// Update position (without animation in Phase 1)
unit.model.position.set(position.x, 10, position.z);
unit.position = { ...position, y: 10 };
}
/**
* Update the map variant and reload assets
* @param {string} mapVariant - The new map variant
* @returns {Promise<boolean>} A promise that resolves to true if the map was updated successfully
*/
updateMapVariant(mapVariant) {
if (this.mapVariant === mapVariant) {
return Promise.resolve(true);
}
console.log(`[MapRenderer] Changing map variant from ${this.mapVariant} to ${mapVariant}`);
// Validate map variant
const validVariants = ['standard', 'ancmed', 'modern', 'pure'];
if (!validVariants.includes(mapVariant)) {
console.error(`[MapRenderer] Invalid map variant: ${mapVariant}`);
return Promise.reject(new Error(`Invalid map variant: ${mapVariant}`));
}
return new Promise((resolve, reject) => {
// Remove existing map mesh
if (this.mapMesh) {
this.scene.remove(this.mapMesh);
this.mapMesh = null;
}
// Clean up existing supply center markers
this._removeSupplyCenterMarkers();
// Update map variant
this.mapVariant = mapVariant;
// Create new coordinate mapper
this.coordinateMapper = new CoordinateMapper(this.mapVariant);
// Reload map assets
const textureLoader = new THREE.TextureLoader();
const mapPath = `/diplomacy/animation/assets/maps/${this.mapVariant}_map.jpg`;
textureLoader.load(
mapPath,
(texture) => {
console.log(`[MapRenderer] Successfully loaded map texture: ${mapPath}`);
this.mapTexture = texture;
this._createMapMesh();
// Clear units (they will need to be repositioned for the new map)
this.clearUnits();
resolve(true);
},
undefined, // Progress callback
(error) => {
console.warn(`[MapRenderer] Failed to load map texture: ${error.message}`);
// If we failed to load the actual texture, create a placeholder
this._createPlaceholderMap();
// Clear units (they will need to be repositioned for the new map)
this.clearUnits();
resolve(true); // Still resolve as we created a placeholder
}
);
});
}
/**
* Remove supply center markers from the scene
* @private
*/
_removeSupplyCenterMarkers() {
// Find and remove all supply center markers
// We'll identify them by name
const markersToRemove = [];
this.scene.traverse(object => {
if (object.userData && object.userData.type === 'supplyCenter') {
markersToRemove.push(object);
}
});
markersToRemove.forEach(marker => {
this.scene.remove(marker);
});
}
/**
* Clean up resources
*/
dispose() {
this.stopRendering();
// Remove event listeners
window.removeEventListener('resize', this._onWindowResize);
// Dispose of Three.js resources
this.scene.traverse(object => {
if (object.geometry) {
object.geometry.dispose();
}
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(material => material.dispose());
} else {
object.material.dispose();
}
}
});
// Remove canvas from DOM
if (this.renderer) {
this.container.removeChild(this.renderer.domElement);
this.renderer.dispose();
}
// Clear references
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.units.clear();
}
}