mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-19 12:58:09 +00:00
2886 lines
No EOL
100 KiB
HTML
2886 lines
No EOL
100 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Diplomacy Map Test</title>
|
|
<!-- Import map for Three.js and dependencies -->
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
|
"three/examples/jsm/controls/OrbitControls.js": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js",
|
|
"three/examples/jsm/loaders/GLTFLoader.js": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js"
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
.container {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.top-controls {
|
|
padding: 10px;
|
|
background-color: #333;
|
|
color: white;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.map-view {
|
|
flex-grow: 1;
|
|
background-color: #87CEEB;
|
|
}
|
|
button {
|
|
padding: 8px 16px;
|
|
background-color: #4CAF50;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
margin-right: 10px;
|
|
}
|
|
button:hover {
|
|
background-color: #45a049;
|
|
}
|
|
#phase-display {
|
|
font-weight: bold;
|
|
}
|
|
#file-input {
|
|
display: none;
|
|
}
|
|
#info-panel {
|
|
position: absolute;
|
|
top: 60px;
|
|
right: 10px;
|
|
width: 300px;
|
|
background-color: rgba(255, 255, 255, 0.8);
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="top-controls">
|
|
<div>
|
|
<button id="load-btn">Load Game</button>
|
|
<button id="prev-btn" disabled>← Prev</button>
|
|
<button id="next-btn" disabled>Next →</button>
|
|
<span id="phase-display">No game loaded</span>
|
|
</div>
|
|
</div>
|
|
<div id="map-view" class="map-view"></div>
|
|
<input type="file" id="file-input" accept=".json">
|
|
<div id="info-panel"></div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
|
|
// Basic variables
|
|
let scene, camera, renderer, controls;
|
|
let gameData = null;
|
|
let currentPhaseIndex = 0;
|
|
let coordinateData = null; // Will hold map coordinates
|
|
let unitMeshes = []; // Track unit meshes for cleanup
|
|
let mapGeometry; // Shared geometry for map components
|
|
|
|
// 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');
|
|
|
|
// Initialize Three.js scene
|
|
function initScene() {
|
|
// Create scene
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x87CEEB); // Sky blue background
|
|
|
|
// Setup camera
|
|
camera = new THREE.PerspectiveCamera(
|
|
60,
|
|
mapView.clientWidth / mapView.clientHeight,
|
|
1,
|
|
5000
|
|
);
|
|
camera.position.set(0, 500, 500);
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
// Setup renderer with improved quality
|
|
renderer = new THREE.WebGLRenderer({
|
|
antialias: true,
|
|
alpha: true
|
|
});
|
|
renderer.setSize(mapView.clientWidth, mapView.clientHeight);
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
mapView.appendChild(renderer.domElement);
|
|
|
|
// Add controls with better defaults
|
|
controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.05;
|
|
controls.screenSpacePanning = true;
|
|
controls.minDistance = 100;
|
|
controls.maxDistance = 1000;
|
|
controls.maxPolarAngle = Math.PI / 2.1; // Limit rotation to prevent seeing under the map
|
|
|
|
// Add camera preset buttons
|
|
createCameraPresetButtons();
|
|
|
|
// Enhanced lighting setup
|
|
setupLighting();
|
|
|
|
// Add an environment (skybox, fog)
|
|
setupEnvironment();
|
|
|
|
// Add a subtle grid for reference that fades with distance
|
|
const gridHelper = new THREE.GridHelper(1000, 50, 0x444444, 0x222222);
|
|
gridHelper.material.opacity = 0.2;
|
|
gridHelper.material.transparent = true;
|
|
scene.add(gridHelper);
|
|
|
|
// Load the coordinate data
|
|
loadCoordinateData().then(() => {
|
|
// Create enhanced map with texture once coordinates are loaded
|
|
createMapWithTexture();
|
|
|
|
// Disable territory disks - we'll draw territories directly on the canvas map
|
|
// createTerritoryBoundaries();
|
|
|
|
// Add territory labels
|
|
if (coordinateData && coordinateData.coordinates) {
|
|
console.log('Adding territory labels...');
|
|
for (const [province, position] of Object.entries(coordinateData.coordinates)) {
|
|
// Don't add labels for coast variants (they have underscores)
|
|
if (!province.includes('_')) {
|
|
addLabel(province, position.x, position.z);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add supply centers
|
|
displaySupplyCenters();
|
|
|
|
// Add territory interactivity
|
|
addTerritoryInteractivity();
|
|
|
|
// Optimize label visibility
|
|
optimizeLabels();
|
|
});
|
|
|
|
// Start animation loop
|
|
animate();
|
|
|
|
// Add window resize handler
|
|
window.addEventListener('resize', onWindowResize);
|
|
}
|
|
|
|
// Enhanced lighting setup
|
|
function setupLighting() {
|
|
// Clear existing lights if any
|
|
scene.children.forEach(child => {
|
|
if (child.isLight) scene.remove(child);
|
|
});
|
|
|
|
// Add ambient light for base illumination
|
|
const ambientLight = new THREE.AmbientLight(0xCCDDFF, 0.4);
|
|
scene.add(ambientLight);
|
|
|
|
// Main directional light (sun-like)
|
|
const mainLight = new THREE.DirectionalLight(0xFFFFCC, 0.8);
|
|
mainLight.position.set(300, 400, 300);
|
|
mainLight.castShadow = true;
|
|
|
|
// Better shadow quality
|
|
mainLight.shadow.mapSize.width = 2048;
|
|
mainLight.shadow.mapSize.height = 2048;
|
|
mainLight.shadow.camera.near = 0.5;
|
|
mainLight.shadow.camera.far = 1000;
|
|
mainLight.shadow.camera.left = -500;
|
|
mainLight.shadow.camera.right = 500;
|
|
mainLight.shadow.camera.top = 500;
|
|
mainLight.shadow.camera.bottom = -500;
|
|
scene.add(mainLight);
|
|
|
|
// Helper backlight
|
|
const backLight = new THREE.DirectionalLight(0x9999FF, 0.3);
|
|
backLight.position.set(-200, 200, -300);
|
|
scene.add(backLight);
|
|
|
|
// Add some point lights for atmosphere
|
|
const colors = [0xFF6666, 0x66AAFF, 0x66FFAA, 0xFFFF66];
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
const pointLight = new THREE.PointLight(colors[i % colors.length], 0.4, 300);
|
|
const angle = (i / 4) * Math.PI * 2;
|
|
pointLight.position.set(
|
|
Math.cos(angle) * 400,
|
|
100,
|
|
Math.sin(angle) * 400
|
|
);
|
|
scene.add(pointLight);
|
|
}
|
|
}
|
|
|
|
// Create an environmental atmosphere
|
|
function setupEnvironment() {
|
|
// Add subtle fog for depth
|
|
scene.fog = new THREE.FogExp2(0xCCDDFF, 0.0005);
|
|
|
|
// Store initial background color for day/night transitions
|
|
scene.userData.dayColor = new THREE.Color(0x87CEEB);
|
|
scene.userData.nightColor = new THREE.Color(0x001133);
|
|
scene.userData.currentTimeOfDay = 'day';
|
|
}
|
|
|
|
// Create camera preset buttons
|
|
function createCameraPresetButtons() {
|
|
const presetContainer = document.createElement('div');
|
|
presetContainer.style.position = 'absolute';
|
|
presetContainer.style.bottom = '20px';
|
|
presetContainer.style.left = '20px';
|
|
presetContainer.style.zIndex = '100';
|
|
|
|
const presets = [
|
|
{ name: 'Top View', position: [0, 600, 0], target: [0, 0, 0] },
|
|
{ name: 'Europe', position: [0, 400, 400], target: [0, 0, 0] },
|
|
{ name: 'West', position: [-500, 300, 0], target: [0, 0, 0] },
|
|
{ name: 'East', position: [500, 300, 0], target: [0, 0, 0] },
|
|
{ name: 'North', position: [0, 300, -500], target: [0, 0, 0] },
|
|
{ name: 'South', position: [0, 300, 500], target: [0, 0, 0] },
|
|
{ name: 'Toggle Day/Night', action: toggleDayNight }
|
|
];
|
|
|
|
presets.forEach(preset => {
|
|
const btn = document.createElement('button');
|
|
btn.innerText = preset.name;
|
|
btn.style.margin = '5px';
|
|
btn.style.padding = '8px 12px';
|
|
btn.style.backgroundColor = '#444';
|
|
btn.style.color = 'white';
|
|
btn.style.border = 'none';
|
|
btn.style.borderRadius = '4px';
|
|
btn.style.cursor = 'pointer';
|
|
|
|
btn.addEventListener('click', () => {
|
|
if (preset.action) {
|
|
preset.action();
|
|
} else {
|
|
moveCamera(preset.position, preset.target);
|
|
}
|
|
});
|
|
|
|
presetContainer.appendChild(btn);
|
|
});
|
|
|
|
document.body.appendChild(presetContainer);
|
|
}
|
|
|
|
// Smoothly move camera to new position and target
|
|
function moveCamera(position, target) {
|
|
const startPosition = camera.position.clone();
|
|
const startTarget = controls.target.clone();
|
|
const endPosition = new THREE.Vector3(...position);
|
|
const endTarget = new THREE.Vector3(...target);
|
|
|
|
const duration = 1000; // ms
|
|
const startTime = Date.now();
|
|
|
|
function updateCamera() {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Use easing function for smoother motion
|
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
|
|
// Interpolate position and target
|
|
camera.position.lerpVectors(startPosition, endPosition, easeProgress);
|
|
controls.target.lerpVectors(startTarget, endTarget, easeProgress);
|
|
controls.update();
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(updateCamera);
|
|
}
|
|
}
|
|
|
|
updateCamera();
|
|
}
|
|
|
|
// Toggle between day and night mode
|
|
function toggleDayNight() {
|
|
const isDay = scene.userData.currentTimeOfDay === 'day';
|
|
const targetColor = isDay ? scene.userData.nightColor : scene.userData.dayColor;
|
|
const currentColor = scene.background.clone();
|
|
|
|
// Get all directional lights
|
|
const lights = scene.children.filter(child => child.isDirectionalLight);
|
|
const currentIntensities = lights.map(light => light.intensity);
|
|
const targetIntensities = isDay ? lights.map(light => light.intensity * 0.3) : lights.map(light => light.intensity / 0.3);
|
|
|
|
// Get the ambient light
|
|
const ambientLight = scene.children.find(child => child.isAmbientLight);
|
|
const currentAmbientIntensity = ambientLight ? ambientLight.intensity : 0.4;
|
|
const targetAmbientIntensity = isDay ? currentAmbientIntensity * 0.5 : currentAmbientIntensity / 0.5;
|
|
|
|
// Update fog
|
|
const currentFogColor = scene.fog.color.clone();
|
|
const targetFogColor = isDay ? new THREE.Color(0x001133) : new THREE.Color(0xCCDDFF);
|
|
|
|
const duration = 1000; // ms
|
|
const startTime = Date.now();
|
|
|
|
function updateLighting() {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Use easing function for smoother transition
|
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
|
|
// Interpolate background color
|
|
scene.background.lerpColors(currentColor, targetColor, easeProgress);
|
|
|
|
// Interpolate light intensities
|
|
lights.forEach((light, index) => {
|
|
light.intensity = currentIntensities[index] + (targetIntensities[index] - currentIntensities[index]) * easeProgress;
|
|
});
|
|
|
|
// Interpolate ambient light intensity
|
|
if (ambientLight) {
|
|
ambientLight.intensity = currentAmbientIntensity + (targetAmbientIntensity - currentAmbientIntensity) * easeProgress;
|
|
}
|
|
|
|
// Interpolate fog color
|
|
scene.fog.color.lerpColors(currentFogColor, targetFogColor, easeProgress);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(updateLighting);
|
|
} else {
|
|
// Update current time of day
|
|
scene.userData.currentTimeOfDay = isDay ? 'night' : 'day';
|
|
}
|
|
}
|
|
|
|
updateLighting();
|
|
}
|
|
|
|
// Create territory boundaries based on coordinate data
|
|
function createTerritoryBoundaries() {
|
|
if (!coordinateData || !coordinateData.coordinates) {
|
|
console.warn('No coordinate data available for territory boundaries');
|
|
return;
|
|
}
|
|
|
|
// Group provinces by country
|
|
const countries = {
|
|
'AUSTRIA': ['VIE', 'BUD', 'TRI', 'GAL', 'BOH', 'TYR'],
|
|
'ENGLAND': ['LON', 'EDI', 'LVP', 'WAL', 'YOR', 'CLY'],
|
|
'FRANCE': ['PAR', 'BRE', 'MAR', 'PIC', 'GAS', 'BUR'],
|
|
'GERMANY': ['BER', 'MUN', 'KIE', 'RUH', 'PRU', 'SIL'],
|
|
'ITALY': ['ROM', 'VEN', 'NAP', 'TUS', 'PIE', 'APU'],
|
|
'RUSSIA': ['STP', 'MOS', 'WAR', 'SEV', 'UKR', 'LVN'],
|
|
'TURKEY': ['CON', 'ANK', 'SMY', 'SYR', 'ARM']
|
|
};
|
|
|
|
// Sea provinces for special styling
|
|
const seaProvinces = [
|
|
'NAO', 'NWG', 'BAR', 'NTH', 'SKA', 'HEL', 'BAL', 'BOT', 'ENG', 'IRI', 'MAO',
|
|
'WES', 'LYO', 'TYS', 'ADR', 'ION', 'AEG', 'EAS', 'BLA'
|
|
];
|
|
|
|
// Country colors with better contrast and theme
|
|
const countryColors = {
|
|
'AUSTRIA': 0xd82b2b,
|
|
'ENGLAND': 0x223a87,
|
|
'FRANCE': 0x3699d4,
|
|
'GERMANY': 0x232323,
|
|
'ITALY': 0x35a335,
|
|
'RUSSIA': 0xcccccc,
|
|
'TURKEY': 0xe0c846
|
|
};
|
|
|
|
// Create a group for all territory boundaries
|
|
const territoriesGroup = new THREE.Group();
|
|
territoriesGroup.name = 'territories';
|
|
|
|
// Main group for land territories
|
|
const landGroup = new THREE.Group();
|
|
landGroup.name = 'land_territories';
|
|
|
|
// Main group for sea territories
|
|
const seaGroup = new THREE.Group();
|
|
seaGroup.name = 'sea_territories';
|
|
|
|
// Process each province
|
|
for (const [province, position] of Object.entries(coordinateData.coordinates)) {
|
|
if (!province.includes('_')) { // Skip coast variants
|
|
// Determine if this is a sea province
|
|
const isSea = seaProvinces.includes(province);
|
|
|
|
// Determine country by checking each country's provinces
|
|
let country = null;
|
|
for (const [countryName, provinces] of Object.entries(countries)) {
|
|
if (provinces.includes(province)) {
|
|
country = countryName;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Larger radius for territories
|
|
const radius = isSea ? 35 : 33;
|
|
|
|
// Create appearance based on territory type
|
|
if (isSea) {
|
|
// Sea territory - blue with wave effect
|
|
const circleGeometry = new THREE.CircleGeometry(radius, 32);
|
|
const circleMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x1a3c6e,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
side: THREE.DoubleSide,
|
|
metalness: 0.3,
|
|
roughness: 0.7
|
|
});
|
|
|
|
const circleMesh = new THREE.Mesh(circleGeometry, circleMaterial);
|
|
circleMesh.position.set(position.x, 0.2, position.z); // Slightly lower than land
|
|
circleMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
|
|
// Add a wave effect overlay
|
|
const waveGeometry = new THREE.CircleGeometry(radius - 2, 32);
|
|
const waveTexture = createWaveTexture();
|
|
const waveMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x66aadd,
|
|
transparent: true,
|
|
opacity: 0.7,
|
|
alphaMap: waveTexture,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const waveMesh = new THREE.Mesh(waveGeometry, waveMaterial);
|
|
waveMesh.position.set(position.x, 0.3, position.z);
|
|
waveMesh.rotation.x = -Math.PI / 2;
|
|
|
|
// Store province data
|
|
circleMesh.userData = {
|
|
province,
|
|
country,
|
|
isSea: true
|
|
};
|
|
|
|
// Add territory border (thicker for seas)
|
|
const borderGeometry = new THREE.RingGeometry(radius - 0.5, radius + 5, 32);
|
|
const borderMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x0a1a3c,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const borderMesh = new THREE.Mesh(borderGeometry, borderMaterial);
|
|
borderMesh.position.set(position.x, 0.25, position.z);
|
|
borderMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
|
|
// Add to sea group
|
|
seaGroup.add(circleMesh);
|
|
seaGroup.add(waveMesh);
|
|
seaGroup.add(borderMesh);
|
|
|
|
// Add animation for waves
|
|
if (!scene.userData.animatedObjects) {
|
|
scene.userData.animatedObjects = [];
|
|
}
|
|
|
|
waveMesh.userData.waveAnimation = {
|
|
speed: 0.0015 + Math.random() * 0.001,
|
|
time: Math.random() * Math.PI * 2
|
|
};
|
|
|
|
scene.userData.animatedObjects.push(waveMesh);
|
|
|
|
} else {
|
|
// Land territory - color based on country
|
|
const color = country ? countryColors[country] : 0xb19b69; // Default to tan for neutral provinces
|
|
|
|
// Create a more natural territory shape
|
|
const circleGeometry = new THREE.CircleGeometry(radius, 32);
|
|
const circleMaterial = new THREE.MeshStandardMaterial({
|
|
color,
|
|
transparent: false,
|
|
side: THREE.DoubleSide,
|
|
metalness: 0.1,
|
|
roughness: 0.9
|
|
});
|
|
|
|
const circleMesh = new THREE.Mesh(circleGeometry, circleMaterial);
|
|
circleMesh.position.set(position.x, 0.5, position.z); // Higher than sea
|
|
circleMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
|
|
// Add a raised border around the territory (thinner for land)
|
|
const borderGeometry = new THREE.RingGeometry(radius - 0.5, radius + 3.5, 32);
|
|
|
|
// Use contrasting border color based on territory color brightness
|
|
const brightness = getBrightness(color);
|
|
const borderColor = brightness > 0.6 ? 0x333333 : 0xdddddd;
|
|
|
|
const borderMaterial = new THREE.MeshStandardMaterial({
|
|
color: borderColor,
|
|
transparent: true,
|
|
opacity: 0.9,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const borderMesh = new THREE.Mesh(borderGeometry, borderMaterial);
|
|
borderMesh.position.set(position.x, 0.55, position.z); // Slightly above territory
|
|
borderMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
|
|
// Add subtle terrain texture
|
|
const terrainGeometry = new THREE.CircleGeometry(radius - 2, 32);
|
|
const terrainTexture = createTerrainTexture(country);
|
|
const terrainMaterial = new THREE.MeshStandardMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.5,
|
|
alphaMap: terrainTexture,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const terrainMesh = new THREE.Mesh(terrainGeometry, terrainMaterial);
|
|
terrainMesh.position.set(position.x, 0.6, position.z);
|
|
terrainMesh.rotation.x = -Math.PI / 2;
|
|
|
|
// Store province data
|
|
circleMesh.userData = {
|
|
province,
|
|
country,
|
|
isSea: false
|
|
};
|
|
|
|
// Add to land group
|
|
landGroup.add(circleMesh);
|
|
landGroup.add(borderMesh);
|
|
landGroup.add(terrainMesh);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add land and sea groups to the main territories group
|
|
territoriesGroup.add(seaGroup); // Sea goes first (below land)
|
|
territoriesGroup.add(landGroup); // Land on top
|
|
|
|
// Add to scene
|
|
scene.add(territoriesGroup);
|
|
}
|
|
|
|
// Create wave texture for sea territories
|
|
function createWaveTexture() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 128;
|
|
canvas.height = 128;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Create gradient background
|
|
const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
|
|
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.1)');
|
|
gradient.addColorStop(0.7, 'rgba(255, 255, 255, 0.05)');
|
|
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, 128, 128);
|
|
|
|
// Add wave patterns
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
|
ctx.lineWidth = 2;
|
|
|
|
// Draw wavy lines
|
|
for (let i = 0; i < 8; i++) {
|
|
const y = i * 16 + 8;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
|
|
for (let x = 0; x < 128; x += 16) {
|
|
const amplitude = 4;
|
|
const yOffset = Math.sin(x / 16 + i) * amplitude;
|
|
|
|
ctx.quadraticCurveTo(
|
|
x + 8, y + yOffset,
|
|
x + 16, y
|
|
);
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
return texture;
|
|
}
|
|
|
|
// Create terrain texture for land territories
|
|
function createTerrainTexture(country) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 128;
|
|
canvas.height = 128;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Different patterns based on country
|
|
if (country) {
|
|
// Color based on country
|
|
switch(country) {
|
|
case 'AUSTRIA':
|
|
drawMountainTexture(ctx);
|
|
break;
|
|
case 'ITALY':
|
|
drawMediterraneanTexture(ctx);
|
|
break;
|
|
case 'RUSSIA':
|
|
drawTundraTexture(ctx);
|
|
break;
|
|
case 'GERMANY':
|
|
drawForestTexture(ctx);
|
|
break;
|
|
case 'FRANCE':
|
|
drawPlainsTexture(ctx);
|
|
break;
|
|
case 'ENGLAND':
|
|
drawIslandTexture(ctx);
|
|
break;
|
|
case 'TURKEY':
|
|
drawAridTexture(ctx);
|
|
break;
|
|
default:
|
|
drawGenericLandTexture(ctx);
|
|
}
|
|
} else {
|
|
// Generic neutral territory
|
|
drawGenericLandTexture(ctx);
|
|
}
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
return texture;
|
|
}
|
|
|
|
// Utility to get brightness of a color
|
|
function getBrightness(hexColor) {
|
|
const r = (hexColor >> 16) & 255;
|
|
const g = (hexColor >> 8) & 255;
|
|
const b = hexColor & 255;
|
|
|
|
// Convert to relative luminance
|
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
}
|
|
|
|
// Draw various terrain textures
|
|
function drawMountainTexture(ctx) {
|
|
// Draw small triangle patterns for mountains
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
const x = Math.random() * 128;
|
|
const y = Math.random() * 128;
|
|
const size = 5 + Math.random() * 10;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y + size);
|
|
ctx.lineTo(x - size / 2, y - size / 2);
|
|
ctx.lineTo(x + size / 2, y - size / 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function drawMediterraneanTexture(ctx) {
|
|
// Olive/vineyard-like pattern
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
|
|
|
for (let i = 0; i < 30; i++) {
|
|
const x = Math.random() * 128;
|
|
const y = Math.random() * 128;
|
|
const radius = 2 + Math.random() * 4;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function drawTundraTexture(ctx) {
|
|
// Sparse dots for tundra
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
|
|
|
for (let i = 0; i < 80; i++) {
|
|
const x = Math.random() * 128;
|
|
const y = Math.random() * 128;
|
|
const radius = 1 + Math.random() * 2;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function drawForestTexture(ctx) {
|
|
// Small tree-like shapes
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const x = Math.random() * 128;
|
|
const y = Math.random() * 128;
|
|
const size = 3 + Math.random() * 6;
|
|
|
|
// Tree top
|
|
ctx.beginPath();
|
|
ctx.arc(x, y - size/2, size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Trunk
|
|
ctx.fillRect(x - 1, y, 2, size);
|
|
}
|
|
}
|
|
|
|
function drawPlainsTexture(ctx) {
|
|
// Horizontal line patterns for fields
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
const y = i * 10 + Math.random() * 5;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(128, y);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawIslandTexture(ctx) {
|
|
// Small dots and lines for coastal features
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
|
ctx.lineWidth = 1;
|
|
|
|
// Dots
|
|
for (let i = 0; i < 50; i++) {
|
|
const x = Math.random() * 128;
|
|
const y = Math.random() * 128;
|
|
const radius = 1 + Math.random();
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Curved lines for terrain contours
|
|
for (let i = 0; i < 5; i++) {
|
|
const x1 = Math.random() * 128;
|
|
const y1 = Math.random() * 128;
|
|
const x2 = x1 + (Math.random() * 40 - 20);
|
|
const y2 = y1 + (Math.random() * 40 - 20);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.quadraticCurveTo(
|
|
(x1 + x2) / 2 + (Math.random() * 20 - 10),
|
|
(y1 + y2) / 2 + (Math.random() * 20 - 10),
|
|
x2, y2
|
|
);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawAridTexture(ctx) {
|
|
// Sand dune-like patterns
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const yBase = i * 12 + Math.random() * 5;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, yBase);
|
|
|
|
for (let x = 0; x < 128; x += 16) {
|
|
const amplitude = 2 + Math.random() * 2;
|
|
const yOffset = Math.sin(x / 16) * amplitude;
|
|
|
|
ctx.quadraticCurveTo(
|
|
x + 8, yBase + yOffset,
|
|
x + 16, yBase
|
|
);
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawGenericLandTexture(ctx) {
|
|
// Simple noise pattern
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
const x = Math.random() * 128;
|
|
const y = Math.random() * 128;
|
|
const radius = 1 + Math.random() * 3;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Load coordinate data
|
|
function loadCoordinateData() {
|
|
return new Promise((resolve, reject) => {
|
|
fetch('./assets/maps/standard_coords.json')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
// Try alternate path
|
|
return fetch('../assets/maps/standard_coords.json');
|
|
}
|
|
return response;
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
// Try another alternate path
|
|
return fetch('/diplomacy/animation/assets/maps/standard_coords.json');
|
|
}
|
|
return response;
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load coordinates: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
coordinateData = data;
|
|
infoPanel.textContent = 'Coordinate data loaded successfully!';
|
|
console.log('Loaded coordinates for', Object.keys(coordinateData.coordinates).length, 'provinces');
|
|
resolve(coordinateData);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading coordinates:', error);
|
|
infoPanel.textContent = `Error loading coordinates: ${error.message}. Using placeholder positions.`;
|
|
// Resolve anyway so we can continue with placeholder positions
|
|
resolve(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Animation loop
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
// Update pulsing animations for supply centers
|
|
if (scene.userData.animatedObjects) {
|
|
scene.userData.animatedObjects.forEach(obj => {
|
|
// Handle supply center pulsing
|
|
if (obj.userData.pulseAnimation) {
|
|
const anim = obj.userData.pulseAnimation;
|
|
anim.time += anim.speed;
|
|
|
|
// Pulsing effect on the glow
|
|
if (obj.userData.glowMesh) {
|
|
const pulseValue = Math.sin(anim.time) * anim.intensity + 0.5;
|
|
obj.userData.glowMesh.material.opacity = 0.2 + (pulseValue * 0.3);
|
|
obj.userData.glowMesh.scale.set(
|
|
1 + (pulseValue * 0.1),
|
|
1 + (pulseValue * 0.1),
|
|
1 + (pulseValue * 0.1)
|
|
);
|
|
}
|
|
|
|
// Subtle height movement
|
|
obj.position.y = 2 + (Math.sin(anim.time) * 0.5);
|
|
}
|
|
|
|
// Handle wave animation for sea territories
|
|
if (obj.userData.waveAnimation) {
|
|
const anim = obj.userData.waveAnimation;
|
|
anim.time += anim.speed;
|
|
|
|
// Rotate texture coordinates for wave effect
|
|
if (obj.material && obj.material.map) {
|
|
obj.material.map.offset.x = Math.sin(anim.time) * 0.05;
|
|
obj.material.map.offset.y = Math.cos(anim.time) * 0.05;
|
|
obj.material.map.needsUpdate = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
// Handle window resize
|
|
function onWindowResize() {
|
|
camera.aspect = mapView.clientWidth / mapView.clientHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(mapView.clientWidth, mapView.clientHeight);
|
|
}
|
|
|
|
// Create a map with texture
|
|
function createMapWithTexture() {
|
|
// Create a large plane for the map
|
|
mapGeometry = new THREE.PlaneGeometry(1000, 1000);
|
|
|
|
// Display loading status
|
|
infoPanel.textContent = 'Loading map texture...';
|
|
|
|
// Use the SVG map directly
|
|
const svgMapPaths = [
|
|
'./diplomacy/maps/svg/standard.svg',
|
|
'../maps/svg/standard.svg',
|
|
'../../maps/svg/standard.svg',
|
|
'/maps/svg/standard.svg',
|
|
'/diplomacy/maps/svg/standard.svg',
|
|
'./maps/svg/standard.svg' // Add another path to try
|
|
];
|
|
|
|
// First try to load the SVG map
|
|
return fetchSVGMap(svgMapPaths)
|
|
.then(svgData => {
|
|
if (svgData) {
|
|
// Convert SVG to image texture
|
|
const texture = createTextureFromSVG(svgData);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const mapMesh = new THREE.Mesh(mapGeometry, material);
|
|
mapMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
mapMesh.position.y = -1; // Slightly below other elements
|
|
scene.add(mapMesh);
|
|
|
|
infoPanel.textContent = 'Map loaded successfully using SVG!';
|
|
return mapMesh;
|
|
} else {
|
|
// Fallback to trying to load image textures
|
|
return tryLoadImageTextures();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading SVG map:', error);
|
|
// Fallback to trying to load image textures
|
|
return tryLoadImageTextures();
|
|
});
|
|
}
|
|
|
|
// Fetch SVG map from various possible paths
|
|
function fetchSVGMap(paths) {
|
|
const fetchPromises = paths.map(path =>
|
|
fetch(path)
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
return response.text();
|
|
})
|
|
.catch(() => null)
|
|
);
|
|
|
|
return Promise.all(fetchPromises).then(results => {
|
|
// Find the first successful result
|
|
const svgData = results.find(data => data !== null);
|
|
return svgData;
|
|
});
|
|
}
|
|
|
|
// Create a texture from SVG data
|
|
function createTextureFromSVG(svgData) {
|
|
// Create a data URL from the SVG
|
|
const blob = new Blob([svgData], {type: 'image/svg+xml'});
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// Load the texture
|
|
const texture = new THREE.TextureLoader().load(url,
|
|
// onLoad callback
|
|
function(texture) {
|
|
// Cleanup
|
|
URL.revokeObjectURL(url);
|
|
// Configure texture settings
|
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
console.log('SVG texture loaded successfully');
|
|
},
|
|
// onProgress callback (not used)
|
|
undefined,
|
|
// onError callback
|
|
function(error) {
|
|
console.error('Error loading SVG texture:', error);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
);
|
|
|
|
return texture;
|
|
}
|
|
|
|
// Try to load map image textures as fallback
|
|
function tryLoadImageTextures() {
|
|
// Try to load the map texture
|
|
const textureLoader = new THREE.TextureLoader();
|
|
|
|
// List of possible paths to try
|
|
const texturePaths = [
|
|
'./assets/maps/standard_map.jpg',
|
|
'../assets/maps/standard_map.jpg',
|
|
'/diplomacy/animation/assets/maps/standard_map.jpg',
|
|
'./diplomacy/assets/maps/standard_map.jpg', // Add another path to try
|
|
// Remove the problematic base64 image that's causing errors
|
|
];
|
|
|
|
// Add a resource checker that runs after all attempts
|
|
let textureLoadSuccess = false;
|
|
|
|
// Attempt loading each texture until one works
|
|
return new Promise((resolve) => {
|
|
let currentIndex = 0;
|
|
|
|
function tryNextTexture() {
|
|
if (currentIndex >= texturePaths.length) {
|
|
// If all paths failed, use enhanced fallback
|
|
return resolve(createFallbackMap());
|
|
}
|
|
|
|
console.log(`Attempting to load map texture from ${texturePaths[currentIndex]}`);
|
|
textureLoader.load(
|
|
texturePaths[currentIndex],
|
|
(texture) => {
|
|
// Check if texture is valid (has dimensions)
|
|
if (texture.image && texture.image.width > 0 && texture.image.height > 0) {
|
|
console.log(`Successfully loaded map texture from ${texturePaths[currentIndex]}`);
|
|
textureLoadSuccess = true;
|
|
infoPanel.textContent = 'Map texture loaded successfully';
|
|
|
|
// Configure texture settings for better appearance
|
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
// Create mesh with the texture
|
|
const mapMesh = new THREE.Mesh(mapGeometry, material);
|
|
mapMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
mapMesh.position.y = -1; // Slightly below other elements
|
|
scene.add(mapMesh);
|
|
|
|
resolve(mapMesh);
|
|
} else {
|
|
console.warn(`Loaded texture from ${texturePaths[currentIndex]} appears invalid (empty or corrupt)`);
|
|
currentIndex++;
|
|
tryNextTexture();
|
|
}
|
|
},
|
|
undefined, // onProgress callback not needed
|
|
(error) => {
|
|
console.warn(`Failed to load texture from ${texturePaths[currentIndex]}: ${error.message}, trying next path`);
|
|
currentIndex++;
|
|
tryNextTexture();
|
|
}
|
|
);
|
|
}
|
|
|
|
tryNextTexture();
|
|
});
|
|
}
|
|
|
|
// Fix optimizeLabels to make labels always visible at normal zoom levels
|
|
function optimizeLabels() {
|
|
// Find all label sprites
|
|
const labels = scene.children.filter(child =>
|
|
child.isSprite && child.userData && child.userData.isLabel);
|
|
|
|
// First set all labels to visible by default and enlarge them
|
|
labels.forEach(label => {
|
|
label.visible = true;
|
|
// Make labels more visible by default
|
|
label.scale.set(100, 50, 1); // Larger size for better readability
|
|
});
|
|
|
|
// Register a camera change callback
|
|
controls.addEventListener('change', () => {
|
|
const distance = camera.position.y; // Height is a good approximation of distance for top-down view
|
|
|
|
labels.forEach(label => {
|
|
// Only hide labels at very far distances (>800 instead of 400)
|
|
if (distance > 800) {
|
|
label.visible = false;
|
|
} else {
|
|
label.visible = true;
|
|
|
|
// Adjust size based on distance for readable text
|
|
const scale = Math.max(0.8, Math.min(1.2, 800 / distance));
|
|
label.scale.set(scale * 100, scale * 50, 1);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Improve the label function to create more visible labels
|
|
function addLabel(text, x, z) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 128;
|
|
canvas.height = 64;
|
|
|
|
const context = canvas.getContext('2d');
|
|
|
|
// Make label background more opaque for better readability
|
|
context.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
context.strokeStyle = 'rgba(0, 0, 0, 0.8)';
|
|
context.lineWidth = 3; // Thicker border
|
|
context.strokeRect(2, 2, canvas.width - 4, canvas.height - 4);
|
|
|
|
// Use larger, bolder text for better visibility
|
|
context.font = 'bold 28px Arial';
|
|
context.fillStyle = 'black';
|
|
context.textAlign = 'center';
|
|
context.textBaseline = 'middle';
|
|
context.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
|
|
// Create texture and sprite
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.minFilter = THREE.LinearFilter; // Prevent blurry text
|
|
const material = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthTest: false, // Always show on top
|
|
sizeAttenuation: false // Keep consistent size regardless of distance
|
|
});
|
|
const sprite = new THREE.Sprite(material);
|
|
|
|
sprite.position.set(x, 40, z); // Position higher above the map for better visibility
|
|
sprite.scale.set(100, 50, 1); // Larger size for better readability
|
|
|
|
// Mark as label for optimization
|
|
sprite.userData.isLabel = true;
|
|
sprite.userData.text = text;
|
|
|
|
scene.add(sprite);
|
|
unitMeshes.push(sprite); // Add to unit meshes array for proper cleanup
|
|
|
|
return sprite;
|
|
}
|
|
|
|
// Display a unit on the map
|
|
function displayUnit(unitData) {
|
|
// Create more sophisticated unit representations
|
|
let geometry;
|
|
let color;
|
|
|
|
// Color based on power
|
|
switch(unitData.power) {
|
|
case 'AUSTRIA': color = 0xFF0000; break;
|
|
case 'ENGLAND': color = 0x0000FF; break;
|
|
case 'FRANCE': color = 0x00FFFF; break;
|
|
case 'GERMANY': color = 0x000000; break;
|
|
case 'ITALY': color = 0x00FF00; break;
|
|
case 'RUSSIA': color = 0xFFFFFF; break;
|
|
case 'TURKEY': color = 0xFFFF00; break;
|
|
default: color = 0xAAAAAA;
|
|
}
|
|
|
|
// Different shapes for army vs fleet with more details
|
|
if (unitData.type === 'A') { // Army
|
|
// Create a more complex army unit (soldier-like)
|
|
geometry = new THREE.Group();
|
|
|
|
// Body (main cube)
|
|
const body = new THREE.Mesh(
|
|
new THREE.BoxGeometry(15, 20, 10),
|
|
new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: new THREE.Color(color).multiplyScalar(0.2),
|
|
metalness: 0.3,
|
|
roughness: 0.7
|
|
})
|
|
);
|
|
geometry.add(body);
|
|
|
|
// Head (smaller cube on top)
|
|
const head = new THREE.Mesh(
|
|
new THREE.BoxGeometry(8, 8, 8),
|
|
new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: new THREE.Color(color).multiplyScalar(0.2),
|
|
metalness: 0.3,
|
|
roughness: 0.7
|
|
})
|
|
);
|
|
head.position.set(0, 14, 0);
|
|
geometry.add(head);
|
|
|
|
// Add a spear or flag
|
|
const spearPole = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(1, 1, 30, 8),
|
|
new THREE.MeshStandardMaterial({ color: 0x8B4513 })
|
|
);
|
|
spearPole.position.set(10, 5, 0);
|
|
spearPole.rotation.z = Math.PI / 6; // Tilt
|
|
geometry.add(spearPole);
|
|
|
|
// Add a flag to the pole
|
|
const flag = new THREE.Mesh(
|
|
new THREE.BoxGeometry(15, 8, 1),
|
|
new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: new THREE.Color(color).multiplyScalar(0.4),
|
|
metalness: 0.1,
|
|
roughness: 0.9,
|
|
side: THREE.DoubleSide
|
|
})
|
|
);
|
|
flag.position.set(15, 15, 0);
|
|
flag.rotation.z = Math.PI / 6; // Match spear tilt
|
|
geometry.add(flag);
|
|
|
|
// Add a base
|
|
const base = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(12, 12, 3, 16),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x333333,
|
|
metalness: 0.5,
|
|
roughness: 0.5
|
|
})
|
|
);
|
|
base.position.set(0, -10, 0);
|
|
geometry.add(base);
|
|
|
|
} else { // Fleet
|
|
// Create a more sophisticated ship
|
|
geometry = new THREE.Group();
|
|
|
|
// Ship hull
|
|
const hull = new THREE.Mesh(
|
|
new THREE.BoxGeometry(30, 8, 15),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x8B4513,
|
|
metalness: 0.3,
|
|
roughness: 0.7
|
|
})
|
|
);
|
|
geometry.add(hull);
|
|
|
|
// Front point (bow)
|
|
const bow = new THREE.Mesh(
|
|
new THREE.ConeGeometry(7.5, 15, 4, 1),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x8B4513,
|
|
metalness: 0.3,
|
|
roughness: 0.7
|
|
})
|
|
);
|
|
bow.rotation.set(0, 0, -Math.PI / 2);
|
|
bow.position.set(22.5, 0, 0);
|
|
geometry.add(bow);
|
|
|
|
// Sail
|
|
const sail = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(20, 25),
|
|
new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: new THREE.Color(color).multiplyScalar(0.2),
|
|
transparent: true,
|
|
opacity: 0.9,
|
|
side: THREE.DoubleSide
|
|
})
|
|
);
|
|
sail.rotation.set(0, Math.PI / 2, 0);
|
|
sail.position.set(0, 15, 0);
|
|
geometry.add(sail);
|
|
|
|
// Mast
|
|
const mast = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(1, 1, 30, 8),
|
|
new THREE.MeshStandardMaterial({ color: 0x8B4513 })
|
|
);
|
|
mast.position.set(0, 15, 0);
|
|
geometry.add(mast);
|
|
|
|
// Base
|
|
const base = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(12, 12, 3, 16),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x333333,
|
|
metalness: 0.5,
|
|
roughness: 0.5
|
|
})
|
|
);
|
|
base.position.set(0, -8, 0);
|
|
geometry.add(base);
|
|
}
|
|
|
|
// Get position for this province
|
|
const position = getProvincePosition(unitData.location);
|
|
|
|
// Create a unit container for the geometry
|
|
let unitMesh;
|
|
|
|
// For simple meshes, the geometry is the mesh
|
|
if (unitData.type === 'A' || unitData.type === 'F') {
|
|
unitMesh = geometry;
|
|
unitMesh.position.set(position.x, 15, position.z); // Higher position for better visibility
|
|
} else {
|
|
// If something went wrong and we don't have proper geometry
|
|
const fallbackMaterial = new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: new THREE.Color(color).multiplyScalar(0.2),
|
|
metalness: 0.3,
|
|
roughness: 0.7
|
|
});
|
|
unitMesh = new THREE.Mesh(new THREE.BoxGeometry(20, 20, 20), fallbackMaterial);
|
|
unitMesh.position.set(position.x, 10, position.z);
|
|
}
|
|
|
|
// For fleets, rotate them to face a consistent direction
|
|
if (unitData.type === 'F') {
|
|
unitMesh.rotation.y = Math.PI / 4;
|
|
}
|
|
|
|
// Add to scene
|
|
scene.add(unitMesh);
|
|
|
|
// Store metadata about the unit
|
|
unitMesh.userData = {
|
|
id: unitData.id,
|
|
power: unitData.power,
|
|
type: unitData.type,
|
|
location: unitData.location
|
|
};
|
|
|
|
// Add outline for highlighting (optional)
|
|
const outlineMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0xFFFFFF,
|
|
side: THREE.BackSide
|
|
});
|
|
|
|
// Track for cleanup
|
|
unitMeshes.push(unitMesh);
|
|
|
|
return unitMesh;
|
|
}
|
|
|
|
// Get proper position for a province using coordinate data
|
|
function getProvincePosition(location) {
|
|
// Handle both formats for special coast locations
|
|
const normalizedLocation = location.toUpperCase().replace('/', '_');
|
|
const baseLocation = normalizedLocation.split('_')[0];
|
|
|
|
// If we have coordinate data, use it
|
|
if (coordinateData && coordinateData.coordinates) {
|
|
// Try exact match first
|
|
if (coordinateData.coordinates[normalizedLocation]) {
|
|
return coordinateData.coordinates[normalizedLocation];
|
|
}
|
|
|
|
// Then try base location
|
|
if (coordinateData.coordinates[baseLocation]) {
|
|
return coordinateData.coordinates[baseLocation];
|
|
}
|
|
}
|
|
|
|
// Fallback to hash function if we don't have the coordinates
|
|
console.warn(`No coordinates found for ${location}, using hash position`);
|
|
return hashStringToPosition(location);
|
|
}
|
|
|
|
// Fallback hash function to position units when coordinates aren't available
|
|
function hashStringToPosition(str) {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
|
hash |= 0; // Convert to 32bit integer
|
|
}
|
|
|
|
// Use modulo to keep within bounds of map (-500 to 500)
|
|
const x = (hash % 800) - 400;
|
|
const z = ((hash >> 8) % 800) - 400;
|
|
|
|
return { x, y: 0, z };
|
|
}
|
|
|
|
// Load a game file
|
|
function loadGame(file) {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = function(e) {
|
|
try {
|
|
// Parse the JSON
|
|
let data = JSON.parse(e.target.result);
|
|
|
|
console.log('Parsed game data:', data);
|
|
infoPanel.textContent = 'Loading game data...';
|
|
|
|
// Just use the data directly without conversion
|
|
gameData = data;
|
|
|
|
|
|
// Set phase index to start
|
|
currentPhaseIndex = 0;
|
|
|
|
// Check if phases exist
|
|
if (!gameData.phases || !Array.isArray(gameData.phases) || gameData.phases.length === 0) {
|
|
infoPanel.textContent = 'Error: No phases found in game data';
|
|
return;
|
|
}
|
|
|
|
// Enable navigation buttons
|
|
prevBtn.disabled = false;
|
|
nextBtn.disabled = false;
|
|
|
|
// Display the first phase
|
|
displayPhase(currentPhaseIndex);
|
|
|
|
infoPanel.textContent = `Game loaded successfully!\n${gameData.phases.length} phases found.`;
|
|
} catch (error) {
|
|
infoPanel.textContent = `Error loading game: ${error.message}`;
|
|
console.error('Error loading game:', error);
|
|
}
|
|
};
|
|
|
|
reader.onerror = function() {
|
|
infoPanel.textContent = 'Error reading file';
|
|
};
|
|
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// Display a specific phase
|
|
function displayPhase(index) {
|
|
if (!gameData || !gameData.phases || index < 0 || index >= gameData.phases.length) {
|
|
infoPanel.textContent = 'Invalid phase index';
|
|
return;
|
|
}
|
|
|
|
// Clean up previous unit meshes
|
|
unitMeshes.forEach(mesh => {
|
|
scene.remove(mesh);
|
|
});
|
|
unitMeshes = [];
|
|
|
|
// Get the current phase
|
|
const phase = gameData.phases[index];
|
|
infoPanel.textContent = `Displaying phase: ${phase.name || 'Unknown'}`;
|
|
|
|
// Update phase display header
|
|
phaseDisplay.textContent = `${phase.name} (${index + 1}/${gameData.phases.length})`;
|
|
|
|
// Check if we have state data
|
|
if (!phase.state) {
|
|
infoPanel.textContent += '\nNo state data found in this phase';
|
|
return;
|
|
}
|
|
|
|
// Update supply centers if available
|
|
if (phase.state.centers) {
|
|
updateSupplyCenterOwnership(phase.state.centers);
|
|
infoPanel.textContent += `\nSupply centers updated`;
|
|
}
|
|
|
|
// Process units - check if exists in the state object
|
|
if (phase.state.units) {
|
|
const unitsList = [];
|
|
|
|
// Process units from state (format is power -> array of unit strings)
|
|
// Example: {"AUSTRIA":["A VIE","A BUD","F TRI"],"ENGLAND":["F LON","F EDI","A LVP"], ... }
|
|
for (const [power, units] of Object.entries(phase.state.units)) {
|
|
if (Array.isArray(units)) {
|
|
units.forEach(unitStr => {
|
|
// Parse unit string (format: "A VIE" or "F LON")
|
|
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
|
if (match) {
|
|
const unitType = match[1]; // 'A' or 'F'
|
|
const location = match[2]; // 'VIE', 'LON', etc.
|
|
|
|
unitsList.push({
|
|
power: power.toUpperCase(),
|
|
type: unitType,
|
|
location: location,
|
|
id: `${power}-${unitType}-${location}`
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`Found ${unitsList.length} units to display:`, unitsList);
|
|
|
|
// Display each unit
|
|
unitsList.forEach(unit => {
|
|
try {
|
|
displayUnit(unit);
|
|
} catch (error) {
|
|
console.error(`Error displaying unit ${unit.power} ${unit.type} ${unit.location}:`, error);
|
|
}
|
|
});
|
|
|
|
infoPanel.textContent += `\nDisplayed ${unitsList.length} units on the map`;
|
|
} else {
|
|
infoPanel.textContent += '\nNo units found in this phase';
|
|
}
|
|
|
|
// Process orders if available
|
|
if (phase.orders) {
|
|
let orderInfo = 'Orders:\n';
|
|
let orderCount = 0;
|
|
|
|
// Handle object format of orders
|
|
for (const [power, orders] of Object.entries(phase.orders)) {
|
|
if (Array.isArray(orders)) {
|
|
orders.forEach(order => {
|
|
orderInfo += `${power.toUpperCase()}: ${order}\n`;
|
|
orderCount++;
|
|
});
|
|
}
|
|
}
|
|
|
|
if (orderCount > 0) {
|
|
infoPanel.textContent = orderInfo;
|
|
} else {
|
|
infoPanel.textContent += '\nNo orders found in this phase';
|
|
}
|
|
} else {
|
|
infoPanel.textContent += '\nNo orders found in this phase';
|
|
}
|
|
|
|
// Log info about this phase
|
|
console.log(`Displaying phase ${index + 1}/${gameData.phases.length}:`, phase);
|
|
console.log(`- Units: ${unitsList ? unitsList.length : 0}`);
|
|
console.log(`- Orders: ${phase.orders ? Object.values(phase.orders).flat().length : 0}`);
|
|
}
|
|
|
|
// Display supply centers as yellow cylinders
|
|
function displaySupplyCenters() {
|
|
if (!coordinateData || !coordinateData.provinces) {
|
|
console.warn('No province data available for supply centers');
|
|
return;
|
|
}
|
|
|
|
// Create a more sophisticated supply center representation
|
|
for (const [province, data] of Object.entries(coordinateData.provinces)) {
|
|
// Check if this province is a supply center
|
|
if (data.isSupplyCenter) {
|
|
// Get the position for this province
|
|
const position = getProvincePosition(province);
|
|
|
|
// Create a group for the supply center elements
|
|
const scGroup = new THREE.Group();
|
|
|
|
// Base (platform)
|
|
const baseGeometry = new THREE.CylinderGeometry(15, 18, 3, 16);
|
|
const baseMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x333333,
|
|
metalness: 0.6,
|
|
roughness: 0.3
|
|
});
|
|
const base = new THREE.Mesh(baseGeometry, baseMaterial);
|
|
base.position.y = 1;
|
|
scGroup.add(base);
|
|
|
|
// Center pillar
|
|
const pillarGeometry = new THREE.CylinderGeometry(3, 3, 12, 8);
|
|
const pillarMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xCCCCCC,
|
|
metalness: 0.8,
|
|
roughness: 0.2
|
|
});
|
|
const pillar = new THREE.Mesh(pillarGeometry, pillarMaterial);
|
|
pillar.position.y = 7;
|
|
scGroup.add(pillar);
|
|
|
|
// Star (using custom geometry)
|
|
const starPoints = 5;
|
|
const starOuterRadius = 10;
|
|
const starInnerRadius = 5;
|
|
const starGeometry = new THREE.BufferGeometry();
|
|
|
|
// Create star shape vertices
|
|
const vertices = [];
|
|
const angle = Math.PI * 2 / starPoints;
|
|
|
|
for (let i = 0; i < starPoints; i++) {
|
|
// Outer point
|
|
const outerAngle = i * angle;
|
|
vertices.push(
|
|
Math.cos(outerAngle) * starOuterRadius,
|
|
0,
|
|
Math.sin(outerAngle) * starOuterRadius
|
|
);
|
|
|
|
// Inner point
|
|
const innerAngle = outerAngle + angle / 2;
|
|
vertices.push(
|
|
Math.cos(innerAngle) * starInnerRadius,
|
|
0,
|
|
Math.sin(innerAngle) * starInnerRadius
|
|
);
|
|
}
|
|
|
|
// Create faces (triangles)
|
|
const indices = [];
|
|
for (let i = 0; i < starPoints * 2 - 2; i++) {
|
|
indices.push(0, i + 1, i + 2);
|
|
}
|
|
indices.push(0, starPoints * 2 - 1, 1);
|
|
|
|
starGeometry.setIndex(indices);
|
|
starGeometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
|
|
starGeometry.computeVertexNormals();
|
|
|
|
// Star material (yellow by default, will update based on ownership)
|
|
const starMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xFFD700,
|
|
emissive: 0x666600,
|
|
metalness: 0.8,
|
|
roughness: 0.2,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const starMesh = new THREE.Mesh(starGeometry, starMaterial);
|
|
starMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
starMesh.position.y = 14; // Above the pillar
|
|
scGroup.add(starMesh);
|
|
|
|
// Add a subtle glow effect
|
|
const glowGeometry = new THREE.CircleGeometry(15, 32);
|
|
const glowMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0xFFFFAA,
|
|
transparent: true,
|
|
opacity: 0.3,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const glowMesh = new THREE.Mesh(glowGeometry, glowMaterial);
|
|
glowMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
glowMesh.position.y = 1.5; // Just above the base
|
|
scGroup.add(glowMesh);
|
|
|
|
// Position the entire group
|
|
scGroup.position.set(position.x, 2, position.z);
|
|
|
|
// Store center data for ownership updates
|
|
scGroup.userData = {
|
|
province: province,
|
|
isSupplyCenter: true,
|
|
owner: null,
|
|
starMesh: starMesh,
|
|
glowMesh: glowMesh
|
|
};
|
|
|
|
// Add to the scene
|
|
scene.add(scGroup);
|
|
unitMeshes.push(scGroup); // Add to unit meshes for cleanup
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update supply center ownership
|
|
function updateSupplyCenterOwnership(centers) {
|
|
if (!centers) return;
|
|
|
|
// Create a map of province to power
|
|
const centerOwnership = {};
|
|
|
|
// Process centers from the game data
|
|
for (const [power, powerCenters] of Object.entries(centers)) {
|
|
if (Array.isArray(powerCenters)) {
|
|
powerCenters.forEach(center => {
|
|
centerOwnership[center.toUpperCase()] = power.toUpperCase();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Power-specific colors for consistency
|
|
const powerColors = {
|
|
'AUSTRIA': 0xFF0000,
|
|
'ENGLAND': 0x0000FF,
|
|
'FRANCE': 0x00FFFF,
|
|
'GERMANY': 0x000000,
|
|
'ITALY': 0x00FF00,
|
|
'RUSSIA': 0xFFFFFF,
|
|
'TURKEY': 0xFFFF00
|
|
};
|
|
|
|
// Update the colors of the supply center markers
|
|
unitMeshes.forEach(mesh => {
|
|
if (mesh.userData.isSupplyCenter) {
|
|
const province = mesh.userData.province;
|
|
const owner = centerOwnership[province];
|
|
|
|
// Store the owner for future reference
|
|
mesh.userData.owner = owner;
|
|
|
|
if (owner && mesh.userData.starMesh) {
|
|
// Get color for this power
|
|
const color = powerColors[owner] || 0xAAAAAA;
|
|
|
|
// Update star color to match owner
|
|
mesh.userData.starMesh.material.color.set(color);
|
|
mesh.userData.starMesh.material.emissive.set(new THREE.Color(color).multiplyScalar(0.3));
|
|
|
|
// Update glow color to match owner (with transparency)
|
|
if (mesh.userData.glowMesh) {
|
|
mesh.userData.glowMesh.material.color.set(color);
|
|
}
|
|
|
|
// Add a pulsing animation to owned supply centers
|
|
if (!mesh.userData.pulseAnimation) {
|
|
mesh.userData.pulseAnimation = {
|
|
speed: 0.003 + Math.random() * 0.002, // Slightly randomized speed
|
|
intensity: 0.3,
|
|
time: Math.random() * Math.PI * 2 // Random starting phase
|
|
};
|
|
|
|
// Add this mesh to a list of animated objects
|
|
if (!scene.userData.animatedObjects) {
|
|
scene.userData.animatedObjects = [];
|
|
}
|
|
scene.userData.animatedObjects.push(mesh);
|
|
}
|
|
} else {
|
|
// Reset to default yellow for unowned centers
|
|
if (mesh.userData.starMesh) {
|
|
mesh.userData.starMesh.material.color.set(0xFFD700);
|
|
mesh.userData.starMesh.material.emissive.set(0x666600);
|
|
}
|
|
if (mesh.userData.glowMesh) {
|
|
mesh.userData.glowMesh.material.color.set(0xFFFFAA);
|
|
}
|
|
|
|
// Remove any animation
|
|
mesh.userData.pulseAnimation = null;
|
|
|
|
// Remove from animated objects list if it exists
|
|
if (scene.userData.animatedObjects) {
|
|
const index = scene.userData.animatedObjects.indexOf(mesh);
|
|
if (index !== -1) {
|
|
scene.userData.animatedObjects.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Event listeners
|
|
loadBtn.addEventListener('click', () => {
|
|
fileInput.click();
|
|
});
|
|
|
|
fileInput.addEventListener('change', (event) => {
|
|
const file = event.target.files[0];
|
|
if (file) {
|
|
loadGame(file);
|
|
}
|
|
});
|
|
|
|
prevBtn.addEventListener('click', () => {
|
|
if (currentPhaseIndex > 0) {
|
|
currentPhaseIndex--;
|
|
displayPhase(currentPhaseIndex);
|
|
}
|
|
});
|
|
|
|
nextBtn.addEventListener('click', () => {
|
|
if (gameData && currentPhaseIndex < gameData.phases.length - 1) {
|
|
currentPhaseIndex++;
|
|
displayPhase(currentPhaseIndex);
|
|
}
|
|
});
|
|
|
|
// Add interactive hovering and selection for territories
|
|
function addTerritoryInteractivity() {
|
|
// Create a raycaster for mouse interaction
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouse = new THREE.Vector2();
|
|
|
|
// Track hovering and selection
|
|
let hoveredObject = null;
|
|
let selectedObject = null;
|
|
|
|
// Detail panel for territory information
|
|
const detailPanel = document.createElement('div');
|
|
detailPanel.style.position = 'absolute';
|
|
detailPanel.style.top = '80px';
|
|
detailPanel.style.left = '20px';
|
|
detailPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
|
detailPanel.style.color = 'white';
|
|
detailPanel.style.padding = '10px';
|
|
detailPanel.style.borderRadius = '5px';
|
|
detailPanel.style.display = 'none';
|
|
detailPanel.style.zIndex = '100';
|
|
detailPanel.style.maxWidth = '300px';
|
|
document.body.appendChild(detailPanel);
|
|
|
|
// Handle mouse move for hovering
|
|
mapView.addEventListener('mousemove', (event) => {
|
|
// Calculate mouse position in normalized device coordinates
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
// Cast a ray from the camera through the mouse position
|
|
raycaster.setFromCamera(mouse, camera);
|
|
|
|
// Find intersections with territory objects
|
|
const territories = scene.getObjectByName('territories');
|
|
let intersects = [];
|
|
|
|
if (territories) {
|
|
// Only check for intersections with territory circles (not borders)
|
|
const territoryCircles = territories.children.filter(child =>
|
|
child.geometry instanceof THREE.CircleGeometry);
|
|
|
|
intersects = raycaster.intersectObjects(territoryCircles);
|
|
}
|
|
|
|
// Add supply centers to intersect check
|
|
const supplyCenters = scene.children.filter(child =>
|
|
child.userData && child.userData.isSupplyCenter);
|
|
|
|
intersects = intersects.concat(raycaster.intersectObjects(supplyCenters));
|
|
|
|
// Handle hover highlights
|
|
if (intersects.length > 0) {
|
|
const intersectedObject = intersects[0].object;
|
|
|
|
// Skip if we're already hovering this object
|
|
if (hoveredObject === intersectedObject) return;
|
|
|
|
// Remove highlight from previous hovered object
|
|
if (hoveredObject) {
|
|
if (hoveredObject.userData.originalScale) {
|
|
hoveredObject.scale.copy(hoveredObject.userData.originalScale);
|
|
}
|
|
if (hoveredObject.material && hoveredObject.userData.originalEmissive) {
|
|
hoveredObject.material.emissive.copy(hoveredObject.userData.originalEmissive);
|
|
}
|
|
}
|
|
|
|
// Add highlight to new hovered object
|
|
hoveredObject = intersectedObject;
|
|
|
|
// Store original scale for restoration
|
|
hoveredObject.userData.originalScale = hoveredObject.scale.clone();
|
|
|
|
// Slightly enlarge the hovered object
|
|
hoveredObject.scale.multiplyScalar(1.1);
|
|
|
|
// Show detail panel with territory information
|
|
const territoryData = getObjectData(hoveredObject);
|
|
if (territoryData) {
|
|
detailPanel.innerHTML = territoryData;
|
|
detailPanel.style.display = 'block';
|
|
|
|
// Position the panel near the mouse but avoid edge clipping
|
|
detailPanel.style.left = `${Math.min(event.clientX + 20, window.innerWidth - 320)}px`;
|
|
detailPanel.style.top = `${Math.min(event.clientY - 30, window.innerHeight - 200)}px`;
|
|
}
|
|
|
|
// Add glow effect if material has emissive property
|
|
if (hoveredObject.material && hoveredObject.material.emissive) {
|
|
hoveredObject.userData.originalEmissive = hoveredObject.material.emissive.clone();
|
|
hoveredObject.material.emissive.set(0x444444);
|
|
}
|
|
|
|
// Change cursor to indicate interactive element
|
|
renderer.domElement.style.cursor = 'pointer';
|
|
} else {
|
|
// No intersections, restore previous hovered object
|
|
if (hoveredObject) {
|
|
if (hoveredObject.userData.originalScale) {
|
|
hoveredObject.scale.copy(hoveredObject.userData.originalScale);
|
|
}
|
|
if (hoveredObject.material && hoveredObject.userData.originalEmissive) {
|
|
hoveredObject.material.emissive.copy(hoveredObject.userData.originalEmissive);
|
|
}
|
|
hoveredObject = null;
|
|
|
|
// Hide detail panel
|
|
detailPanel.style.display = 'none';
|
|
|
|
// Reset cursor
|
|
renderer.domElement.style.cursor = 'auto';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle click for selection
|
|
mapView.addEventListener('click', () => {
|
|
if (hoveredObject) {
|
|
if (selectedObject) {
|
|
// Deselect if clicking the same object
|
|
if (selectedObject === hoveredObject) {
|
|
if (selectedObject.material) {
|
|
selectedObject.material.emissive.copy(selectedObject.userData.originalEmissive || new THREE.Color(0x000000));
|
|
}
|
|
selectedObject = null;
|
|
return;
|
|
}
|
|
|
|
// Deselect previous selection
|
|
if (selectedObject.material) {
|
|
selectedObject.material.emissive.copy(selectedObject.userData.originalEmissive || new THREE.Color(0x000000));
|
|
}
|
|
}
|
|
|
|
// Select new object
|
|
selectedObject = hoveredObject;
|
|
|
|
// Add permanent highlight to selected object
|
|
if (selectedObject.material) {
|
|
selectedObject.material.emissive.set(0x666666);
|
|
}
|
|
|
|
// Update info panel with details
|
|
if (selectedObject.userData.province) {
|
|
// Show province details in the main info panel
|
|
showProvinceDetails(selectedObject.userData.province);
|
|
}
|
|
}
|
|
});
|
|
|
|
function getObjectData(object) {
|
|
// Get data from territory or supply center
|
|
let html = '';
|
|
|
|
// For territories
|
|
if (object.userData.province) {
|
|
const province = object.userData.province;
|
|
const country = object.userData.country;
|
|
|
|
html = `<div style="font-weight: bold; margin-bottom: 5px;">${province}</div>`;
|
|
|
|
if (country) {
|
|
html += `<div>Country: ${country}</div>`;
|
|
}
|
|
|
|
if (object.userData.isSupplyCenter ||
|
|
(object.parent && object.parent.userData && object.parent.userData.isSupplyCenter)) {
|
|
html += `<div style="margin-top: 5px;">Supply Center</div>`;
|
|
|
|
// If owned, show owner
|
|
const owner = object.userData.owner || (object.parent && object.parent.userData.owner);
|
|
if (owner) {
|
|
html += `<div>Controlled by: ${owner}</div>`;
|
|
}
|
|
}
|
|
|
|
// Add any units in this location
|
|
const units = unitMeshes.filter(unit =>
|
|
unit.userData && unit.userData.location === province);
|
|
|
|
if (units.length > 0) {
|
|
html += `<div style="margin-top: 5px; border-top: 1px solid #666; padding-top: 5px;">Units:</div>`;
|
|
|
|
units.forEach(unit => {
|
|
if (unit.userData.power && unit.userData.type) {
|
|
html += `<div>${unit.userData.power} ${unit.userData.type}</div>`;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function showProvinceDetails(province) {
|
|
// Prepare details for the main info panel
|
|
let details = `<strong>Province: ${province}</strong><br>`;
|
|
|
|
// Look for units at this location in the current phase
|
|
if (gameData && gameData.phases && currentPhaseIndex >= 0) {
|
|
const phase = gameData.phases[currentPhaseIndex];
|
|
|
|
// Check for units
|
|
if (phase.state && phase.state.units) {
|
|
for (const [power, units] of Object.entries(phase.state.units)) {
|
|
if (Array.isArray(units)) {
|
|
units.forEach(unitStr => {
|
|
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
|
if (match && match[2] === province) {
|
|
details += `<br>Unit: ${power.toUpperCase()} ${match[1]} (${match[1] === 'A' ? 'Army' : 'Fleet'})<br>`;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for supply center ownership
|
|
if (phase.state && phase.state.centers) {
|
|
for (const [power, centers] of Object.entries(phase.state.centers)) {
|
|
if (Array.isArray(centers) && centers.includes(province)) {
|
|
details += `<br>Supply Center owned by: ${power.toUpperCase()}<br>`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for orders affecting this province
|
|
if (phase.orders) {
|
|
details += `<br><strong>Orders:</strong><br>`;
|
|
let ordersFound = false;
|
|
|
|
for (const [power, orders] of Object.entries(phase.orders)) {
|
|
if (Array.isArray(orders)) {
|
|
orders.forEach(order => {
|
|
if (order.includes(province)) {
|
|
details += `${power.toUpperCase()}: ${order}<br>`;
|
|
ordersFound = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!ordersFound) {
|
|
details += "No orders involving this province<br>";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the info panel
|
|
infoPanel.innerHTML = details;
|
|
}
|
|
}
|
|
|
|
// Create fallback texture with enhanced visual appearance
|
|
function createFallbackMap() {
|
|
console.warn('Using enhanced fallback map due to texture loading failure');
|
|
infoPanel.textContent = 'Using enhanced diplomacy map (custom rendered)';
|
|
|
|
// Create a canvas for dynamic map rendering
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 2048;
|
|
canvas.height = 2048;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Fill with a gradient sea background
|
|
const seaGradient = ctx.createRadialGradient(
|
|
canvas.width / 2, canvas.height / 2, 0,
|
|
canvas.width / 2, canvas.height / 2, canvas.width / 1.5
|
|
);
|
|
seaGradient.addColorStop(0, '#1a3c6e'); // Deep blue center
|
|
seaGradient.addColorStop(0.7, '#2a5d9e'); // Mid-tone blue
|
|
seaGradient.addColorStop(0.9, '#3973ac'); // Lighter blue edges
|
|
seaGradient.addColorStop(1, '#4b8bc5'); // Very light blue at extremes
|
|
|
|
ctx.fillStyle = seaGradient;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw accurate map with province boundaries
|
|
if (coordinateData && coordinateData.coordinates) {
|
|
// Draw the map with accurate territory boundaries
|
|
drawAccurateMap(ctx, canvas.width, canvas.height);
|
|
} else {
|
|
// Fallback to simplified country outlines if no coordinate data
|
|
drawSimplifiedMap(ctx, canvas.width, canvas.height);
|
|
}
|
|
|
|
// Create texture from canvas
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
// Make sure we have geometry available
|
|
if (!mapGeometry) {
|
|
mapGeometry = new THREE.PlaneGeometry(1000, 1000);
|
|
}
|
|
|
|
const mapMesh = new THREE.Mesh(mapGeometry, material);
|
|
mapMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
mapMesh.position.y = -1; // Slightly below other elements
|
|
scene.add(mapMesh);
|
|
|
|
// Create invisible territory hitboxes for interaction
|
|
createInvisibleTerritoryHitboxes();
|
|
|
|
return mapMesh;
|
|
}
|
|
|
|
// Draw accurate map with territory boundaries
|
|
function drawAccurateMap(ctx, width, height) {
|
|
const scaleX = width / 1000;
|
|
const scaleY = height / 1000;
|
|
const offsetX = width / 2;
|
|
const offsetY = height / 2;
|
|
|
|
// Group provinces by country (for default coloring)
|
|
const countries = {
|
|
'AUSTRIA': ['VIE', 'BUD', 'TRI', 'GAL', 'BOH', 'TYR'],
|
|
'ENGLAND': ['LON', 'EDI', 'LVP', 'WAL', 'YOR', 'CLY'],
|
|
'FRANCE': ['PAR', 'BRE', 'MAR', 'PIC', 'GAS', 'BUR'],
|
|
'GERMANY': ['BER', 'MUN', 'KIE', 'RUH', 'PRU', 'SIL'],
|
|
'ITALY': ['ROM', 'VEN', 'NAP', 'TUS', 'PIE', 'APU'],
|
|
'RUSSIA': ['STP', 'MOS', 'WAR', 'SEV', 'UKR', 'LVN'],
|
|
'TURKEY': ['CON', 'ANK', 'SMY', 'SYR', 'ARM']
|
|
};
|
|
|
|
const seaProvinces = [
|
|
'NAO', 'NWG', 'BAR', 'NTH', 'SKA', 'HEL', 'BAL', 'BOT', 'ENG', 'IRI', 'MAO',
|
|
'WES', 'LYO', 'TYS', 'ADR', 'ION', 'AEG', 'EAS', 'BLA'
|
|
];
|
|
|
|
const countryColors = {
|
|
'AUSTRIA': '#d82b2b',
|
|
'ENGLAND': '#223a87',
|
|
'FRANCE': '#3699d4',
|
|
'GERMANY': '#232323',
|
|
'ITALY': '#35a335',
|
|
'RUSSIA': '#cccccc',
|
|
'TURKEY': '#e0c846'
|
|
};
|
|
|
|
// Map power to color (consistent with unit colors in displayUnit)
|
|
const powerColors = {
|
|
'AUSTRIA': '#FF0000',
|
|
'ENGLAND': '#0000FF',
|
|
'FRANCE': '#00FFFF',
|
|
'GERMANY': '#000000',
|
|
'ITALY': '#00FF00',
|
|
'RUSSIA': '#FFFFFF',
|
|
'TURKEY': '#FFFF00'
|
|
};
|
|
|
|
const neutralLandColor = '#b19b69';
|
|
|
|
// Get current phase data
|
|
let supplyCenterOwnership = {};
|
|
let unitOccupancy = {};
|
|
|
|
if (gameData && gameData.phases && currentPhaseIndex >= 0) {
|
|
const phase = gameData.phases[currentPhaseIndex];
|
|
|
|
// Supply center ownership
|
|
if (phase.state && phase.state.centers) {
|
|
for (const [power, centers] of Object.entries(phase.state.centers)) {
|
|
if (Array.isArray(centers)) {
|
|
centers.forEach(center => {
|
|
supplyCenterOwnership[center.toUpperCase()] = power.toUpperCase();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unit occupancy
|
|
if (phase.state && phase.state.units) {
|
|
for (const [power, units] of Object.entries(phase.state.units)) {
|
|
if (Array.isArray(units)) {
|
|
units.forEach(unitStr => {
|
|
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
|
if (match) {
|
|
const location = match[2].toUpperCase();
|
|
unitOccupancy[location] = power.toUpperCase();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw sea territories
|
|
for (const province of seaProvinces) {
|
|
if (coordinateData.coordinates[province]) {
|
|
const position = coordinateData.coordinates[province];
|
|
const x = (position.x * scaleX) + offsetX;
|
|
const y = (position.z * scaleY) + offsetY;
|
|
|
|
const connectedProvinces = getConnectedProvinces(province);
|
|
|
|
// Color sea based on unit occupancy, if any
|
|
const occupyingPower = unitOccupancy[province];
|
|
const color = occupyingPower ? powerColors[occupyingPower] : '#1a3c6e';
|
|
const opacity = occupyingPower ? 0.9 : 0.7;
|
|
|
|
if (connectedProvinces.length > 0) {
|
|
drawTerritoryPolygon(ctx, province, connectedProvinces, color, opacity);
|
|
} else {
|
|
const radius = 35;
|
|
const seaGradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
|
seaGradient.addColorStop(0, color);
|
|
seaGradient.addColorStop(1, '#1a3c6e');
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = seaGradient;
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#0a1a3c';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
drawWavePattern(ctx, x, y, 30);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw land territories
|
|
const drawnProvinces = new Set();
|
|
|
|
// First, draw by country for default coloring
|
|
for (const [country, provinces] of Object.entries(countries)) {
|
|
const defaultColor = countryColors[country];
|
|
|
|
for (const province of provinces) {
|
|
if (coordinateData.coordinates[province] && !drawnProvinces.has(province)) {
|
|
const position = coordinateData.coordinates[province];
|
|
const x = (position.x * scaleX) + offsetX;
|
|
const y = (position.z * scaleY) + offsetY;
|
|
|
|
// Determine color based on supply center or unit
|
|
let color = defaultColor;
|
|
if (supplyCenterOwnership[province]) {
|
|
color = powerColors[supplyCenterOwnership[province]];
|
|
} else if (unitOccupancy[province]) {
|
|
color = powerColors[unitOccupancy[province]];
|
|
}
|
|
|
|
const connectedProvinces = getConnectedProvinces(province);
|
|
|
|
if (connectedProvinces.length > 0) {
|
|
drawTerritoryPolygon(ctx, province, connectedProvinces, color, 0.8);
|
|
} else {
|
|
drawNaturalTerritory(ctx, x, y, 33, color);
|
|
}
|
|
|
|
drawnProvinces.add(province);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw remaining neutral provinces
|
|
for (const [province, position] of Object.entries(coordinateData.coordinates)) {
|
|
if (!province.includes('_') && !drawnProvinces.has(province) && !seaProvinces.includes(province)) {
|
|
const x = (position.x * scaleX) + offsetX;
|
|
const y = (position.z * scaleY) + offsetY;
|
|
|
|
// Determine color based on supply center or unit
|
|
let color = neutralLandColor;
|
|
if (supplyCenterOwnership[province]) {
|
|
color = powerColors[supplyCenterOwnership[province]];
|
|
} else if (unitOccupancy[province]) {
|
|
color = powerColors[unitOccupancy[province]];
|
|
}
|
|
|
|
const connectedProvinces = getConnectedProvinces(province);
|
|
|
|
if (connectedProvinces.length > 0) {
|
|
drawTerritoryPolygon(ctx, province, connectedProvinces, color, 0.8);
|
|
} else {
|
|
drawNaturalTerritory(ctx, x, y, 33, color);
|
|
}
|
|
|
|
drawnProvinces.add(province);
|
|
}
|
|
}
|
|
|
|
drawTerritoryBorders(ctx, width, height);
|
|
drawSupplyCenters(ctx, width, height);
|
|
drawProvinceNames(ctx, width, height);
|
|
}
|
|
|
|
// Helper function to get connected provinces (uses adjacency data or approximates)
|
|
function getConnectedProvinces(province) {
|
|
// If we have explicit adjacency data, use it
|
|
const connections = {
|
|
// These are approximate connections to create more realistic territory shapes
|
|
// Generally, each territory connects to 3-6 neighbors
|
|
'LON': ['WAL', 'YOR', 'NTH', 'ENG'],
|
|
'EDI': ['CLY', 'YOR', 'NWG', 'NTH'],
|
|
'LVP': ['CLY', 'EDI', 'WAL', 'IRI'],
|
|
'WAL': ['LON', 'LVP', 'IRI', 'ENG'],
|
|
'YOR': ['LON', 'EDI', 'LVP', 'NTH'],
|
|
'CLY': ['EDI', 'LVP', 'NAO', 'NWG'],
|
|
|
|
'BRE': ['PIC', 'PAR', 'GAS', 'MAO', 'ENG'],
|
|
'PAR': ['BRE', 'PIC', 'BUR', 'GAS'],
|
|
'MAR': ['GAS', 'BUR', 'PIE', 'SPA', 'LYO'],
|
|
'PIC': ['BRE', 'PAR', 'BUR', 'BEL', 'ENG'],
|
|
'GAS': ['BRE', 'PAR', 'BUR', 'MAR', 'SPA', 'MAO'],
|
|
'BUR': ['PIC', 'PAR', 'GAS', 'MAR', 'MUN', 'RUH', 'BEL'],
|
|
|
|
// Add more connections for other territories
|
|
// This is just a sample - a complete map would define all connections
|
|
};
|
|
|
|
if (connections[province]) {
|
|
return connections[province];
|
|
}
|
|
|
|
// If we don't have explicit connections, approximate based on coordinates
|
|
const result = [];
|
|
|
|
if (!coordinateData || !coordinateData.coordinates || !coordinateData.coordinates[province]) {
|
|
return result;
|
|
}
|
|
|
|
const pos = coordinateData.coordinates[province];
|
|
const threshold = 150; // Distance threshold to consider provinces connected
|
|
|
|
for (const [otherProvince, otherPos] of Object.entries(coordinateData.coordinates)) {
|
|
if (otherProvince !== province && !otherProvince.includes('_')) {
|
|
// Calculate distance between provinces
|
|
const dx = pos.x - otherPos.x;
|
|
const dz = pos.z - otherPos.z;
|
|
const distance = Math.sqrt(dx * dx + dz * dz);
|
|
|
|
if (distance < threshold) {
|
|
result.push(otherProvince);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Draw a territory polygon based on connected provinces
|
|
function drawTerritoryPolygon(ctx, province, connectedProvinces, color, opacity) {
|
|
if (!coordinateData || !coordinateData.coordinates) return;
|
|
|
|
const position = coordinateData.coordinates[province];
|
|
if (!position) return;
|
|
|
|
const scaleX = ctx.canvas.width / 1000;
|
|
const scaleY = ctx.canvas.height / 1000;
|
|
const offsetX = ctx.canvas.width / 2;
|
|
const offsetY = ctx.canvas.height / 2;
|
|
|
|
const x = (position.x * scaleX) + offsetX;
|
|
const y = (position.z * scaleY) + offsetY;
|
|
|
|
const isSea = ['NAO', 'NWG', 'BAR', 'NTH', 'SKA', 'HEL', 'BAL', 'BOT', 'ENG', 'IRI', 'MAO',
|
|
'WES', 'LYO', 'TYS', 'ADR', 'ION', 'AEG', 'EAS', 'BLA'].includes(province);
|
|
|
|
// Create points for the polygon
|
|
const points = [];
|
|
const center = { x, y };
|
|
|
|
for (const connectedProvince of connectedProvinces) {
|
|
if (coordinateData.coordinates[connectedProvince]) {
|
|
const connPos = coordinateData.coordinates[connectedProvince];
|
|
const connX = (connPos.x * scaleX) + offsetX;
|
|
const connY = (connPos.z * scaleY) + offsetY;
|
|
|
|
const midX = x + (connX - x) * 0.4;
|
|
const midY = y + (connY - y) * 0.4;
|
|
|
|
const dx = connX - x;
|
|
const dy = connY - y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
const perpX = dy / dist;
|
|
const perpY = -dx / dist;
|
|
|
|
const scale = 40;
|
|
const pointX = midX + perpX * scale;
|
|
const pointY = midY + perpY * scale;
|
|
|
|
points.push({ x: pointX, y: pointY, angle: Math.atan2(pointY - y, pointX - x) });
|
|
}
|
|
}
|
|
|
|
points.sort((a, b) => a.angle - b.angle);
|
|
|
|
if (points.length > 2) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x, points[0].y);
|
|
|
|
for (let i = 1; i < points.length; i++) {
|
|
ctx.lineTo(points[i].x, points[i].y);
|
|
}
|
|
|
|
ctx.closePath();
|
|
|
|
if (isSea) {
|
|
// Deeper sea color with gradient
|
|
const seaGradient = ctx.createRadialGradient(x, y, 0, x, y, 50);
|
|
seaGradient.addColorStop(0, '#0a2a5c'); // Very dark blue center
|
|
seaGradient.addColorStop(1, '#1a3c6e'); // Dark blue edge
|
|
ctx.fillStyle = seaGradient;
|
|
ctx.fill();
|
|
|
|
// Subtle sea border
|
|
ctx.strokeStyle = '#0a1a3c';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// Add wave pattern for sea
|
|
drawWavePattern(ctx, x, y, 30);
|
|
} else {
|
|
// Land territory
|
|
drawNaturalTerritory(ctx, x, y, 33, color);
|
|
}
|
|
} else {
|
|
if (isSea) {
|
|
const radius = 35;
|
|
const seaGradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
|
seaGradient.addColorStop(0, '#0a2a5c');
|
|
seaGradient.addColorStop(1, '#1a3c6e');
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = seaGradient;
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#0a1a3c';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
drawWavePattern(ctx, x, y, 30);
|
|
} else {
|
|
drawNaturalTerritory(ctx, x, y, 33, color);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw a more natural territory shape (not a perfect circle)
|
|
function drawNaturalTerritory(ctx, x, y, radius, color) {
|
|
ctx.beginPath();
|
|
|
|
const points = 16; // More points for a smoother, yet irregular shape
|
|
const angleStep = (Math.PI * 2) / points;
|
|
|
|
for (let i = 0; i < points; i++) {
|
|
const angle = i * angleStep;
|
|
// Vary the radius more significantly for each point
|
|
const variation = 0.7 + (Math.random() * 0.6); // Wider variation
|
|
const pointRadius = radius * variation;
|
|
|
|
// Add a slight distortion using Perlin-like noise simulation
|
|
const noise = Math.sin(angle * 3 + i) * 0.2;
|
|
const distortedRadius = pointRadius * (1 + noise);
|
|
|
|
const px = x + Math.cos(angle) * distortedRadius;
|
|
const py = y + Math.sin(angle) * distortedRadius;
|
|
|
|
if (i === 0) {
|
|
ctx.moveTo(px, py);
|
|
} else {
|
|
// Use quadratic curves for smoother transitions between points
|
|
const midAngle = angle - angleStep / 2;
|
|
const midRadius = (pointRadius + (radius * (0.7 + Math.random() * 0.6))) / 2;
|
|
const midX = x + Math.cos(midAngle) * midRadius;
|
|
const midY = y + Math.sin(midAngle) * midRadius;
|
|
ctx.quadraticCurveTo(midX, midY, px, py);
|
|
}
|
|
}
|
|
|
|
ctx.closePath();
|
|
|
|
// Add a gradient fill for land to make it more visually distinct
|
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
|
gradient.addColorStop(0, color + 'FF'); // Opaque center
|
|
gradient.addColorStop(1, color + 'AA'); // Slightly transparent edge
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.fill();
|
|
|
|
// Add a subtle border
|
|
ctx.strokeStyle = '#333333';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw wave patterns for sea territories
|
|
function drawWavePattern(ctx, x, y, size) {
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; // Slightly more visible waves
|
|
ctx.lineWidth = 1.5;
|
|
|
|
for (let i = 0; i < 4; i++) { // More waves for better texture
|
|
const offset = i * (size / 4) - size / 2;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x - size / 2, y + offset);
|
|
|
|
for (let j = 0; j < 3; j++) {
|
|
const segX = x - size / 2 + ((j + 1) * size / 3);
|
|
const segY = y + offset + (j % 2 === 0 ? 6 : -6);
|
|
|
|
ctx.quadraticCurveTo(
|
|
x - size / 2 + (j * size / 3) + (size / 6),
|
|
y + offset + (j % 2 === 0 ? -6 : 6),
|
|
segX, segY
|
|
);
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Draw borders between territories
|
|
function drawTerritoryBorders(ctx, width, height) {
|
|
const scaleX = width / 1000;
|
|
const scaleY = height / 1000;
|
|
const offsetX = width / 2;
|
|
const offsetY = height / 2;
|
|
|
|
// Sea provinces for identifying land-sea borders
|
|
const seaProvinces = [
|
|
'NAO', 'NWG', 'BAR', 'NTH', 'SKA', 'HEL', 'BAL', 'BOT', 'ENG', 'IRI', 'MAO',
|
|
'WES', 'LYO', 'TYS', 'ADR', 'ION', 'AEG', 'EAS', 'BLA'
|
|
];
|
|
|
|
// For each province, draw borders with its neighbors
|
|
for (const [province, position] of Object.entries(coordinateData.coordinates)) {
|
|
if (!province.includes('_')) { // Skip coast variants
|
|
const x1 = (position.x * scaleX) + offsetX;
|
|
const y1 = (position.z * scaleY) + offsetY;
|
|
|
|
const isSea = seaProvinces.includes(province);
|
|
|
|
// Get connected neighbors
|
|
const neighbors = getConnectedProvinces(province);
|
|
|
|
for (const neighbor of neighbors) {
|
|
if (coordinateData.coordinates[neighbor] && !neighbor.includes('_')) {
|
|
const neighborPos = coordinateData.coordinates[neighbor];
|
|
const x2 = (neighborPos.x * scaleX) + offsetX;
|
|
const y2 = (neighborPos.z * scaleY) + offsetY;
|
|
|
|
const isNeighborSea = seaProvinces.includes(neighbor);
|
|
|
|
// Only draw each border once (when province < neighbor)
|
|
if (province < neighbor) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
|
|
// Make borders slightly curved for more natural appearance
|
|
const midX = (x1 + x2) / 2;
|
|
const midY = (y1 + y2) / 2;
|
|
|
|
// Add a slight curve
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Calculate perpendicular direction for the curve
|
|
const perpX = -dy / dist;
|
|
const perpY = dx / dist;
|
|
|
|
// Apply a small curve
|
|
const curveSize = 10 + Math.random() * 10;
|
|
const curveX = midX + perpX * curveSize;
|
|
const curveY = midY + perpY * curveSize;
|
|
|
|
// Draw the curved line
|
|
ctx.quadraticCurveTo(curveX, curveY, x2, y2);
|
|
|
|
// Style based on type of border
|
|
if (isSea !== isNeighborSea) {
|
|
// Land-sea border (coastline)
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 2.5;
|
|
} else if (isSea && isNeighborSea) {
|
|
// Sea-sea border (lighter)
|
|
ctx.strokeStyle = 'rgba(20, 60, 120, 0.5)';
|
|
ctx.lineWidth = 1.5;
|
|
} else {
|
|
// Land-land border
|
|
ctx.strokeStyle = '#333333';
|
|
ctx.lineWidth = 2;
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw supply centers on the map
|
|
function drawSupplyCenters(ctx, width, height) {
|
|
if (!coordinateData || !coordinateData.provinces) return;
|
|
|
|
const scaleX = width / 1000;
|
|
const scaleY = height / 1000;
|
|
const offsetX = width / 2;
|
|
const offsetY = height / 2;
|
|
|
|
// Draw supply centers as star symbols
|
|
for (const [province, data] of Object.entries(coordinateData.provinces)) {
|
|
if (data.isSupplyCenter && coordinateData.coordinates[province]) {
|
|
const position = coordinateData.coordinates[province];
|
|
const x = (position.x * scaleX) + offsetX;
|
|
const y = (position.z * scaleY) + offsetY;
|
|
|
|
// Draw a star symbol
|
|
const starPoints = 5;
|
|
const outerRadius = 12;
|
|
const innerRadius = 6;
|
|
|
|
ctx.beginPath();
|
|
|
|
for (let i = 0; i < starPoints * 2; i++) {
|
|
const radius = i % 2 === 0 ? outerRadius : innerRadius;
|
|
const angle = (Math.PI * i) / starPoints;
|
|
|
|
const px = x + Math.cos(angle) * radius;
|
|
const py = y + Math.sin(angle) * radius;
|
|
|
|
if (i === 0) {
|
|
ctx.moveTo(px, py);
|
|
} else {
|
|
ctx.lineTo(px, py);
|
|
}
|
|
}
|
|
|
|
ctx.closePath();
|
|
|
|
// Fill and stroke
|
|
ctx.fillStyle = '#FFD700'; // Gold
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#333333';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw province names on the map
|
|
function drawProvinceNames(ctx, width, height) {
|
|
const scaleX = width / 1000;
|
|
const scaleY = height / 1000;
|
|
const offsetX = width / 2;
|
|
const offsetY = height / 2;
|
|
|
|
for (const [province, position] of Object.entries(coordinateData.coordinates)) {
|
|
if (!province.includes('_')) { // Skip coast variants
|
|
const x = (position.x * scaleX) + offsetX;
|
|
const y = (position.z * scaleY) + offsetY;
|
|
|
|
// Add the province name
|
|
ctx.font = 'bold 20px Arial';
|
|
ctx.fillStyle = '#000000';
|
|
ctx.strokeStyle = '#FFFFFF';
|
|
ctx.lineWidth = 3;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
// Draw text outline for better visibility
|
|
ctx.strokeText(province, x, y);
|
|
ctx.fillText(province, x, y);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create invisible hitboxes for territory interaction
|
|
function createInvisibleTerritoryHitboxes() {
|
|
if (!coordinateData || !coordinateData.coordinates) return;
|
|
|
|
// Create a group for all territory hitboxes
|
|
const territoriesGroup = new THREE.Group();
|
|
territoriesGroup.name = 'territories';
|
|
|
|
// Sea provinces for special styling
|
|
const seaProvinces = [
|
|
'NAO', 'NWG', 'BAR', 'NTH', 'SKA', 'HEL', 'BAL', 'BOT', 'ENG', 'IRI', 'MAO',
|
|
'WES', 'LYO', 'TYS', 'ADR', 'ION', 'AEG', 'EAS', 'BLA'
|
|
];
|
|
|
|
// Group provinces by country
|
|
const countries = {
|
|
'AUSTRIA': ['VIE', 'BUD', 'TRI', 'GAL', 'BOH', 'TYR'],
|
|
'ENGLAND': ['LON', 'EDI', 'LVP', 'WAL', 'YOR', 'CLY'],
|
|
'FRANCE': ['PAR', 'BRE', 'MAR', 'PIC', 'GAS', 'BUR'],
|
|
'GERMANY': ['BER', 'MUN', 'KIE', 'RUH', 'PRU', 'SIL'],
|
|
'ITALY': ['ROM', 'VEN', 'NAP', 'TUS', 'PIE', 'APU'],
|
|
'RUSSIA': ['STP', 'MOS', 'WAR', 'SEV', 'UKR', 'LVN'],
|
|
'TURKEY': ['CON', 'ANK', 'SMY', 'SYR', 'ARM']
|
|
};
|
|
|
|
// Determine country for each province
|
|
const provinceToCountry = {};
|
|
for (const [country, provinces] of Object.entries(countries)) {
|
|
for (const province of provinces) {
|
|
provinceToCountry[province] = country;
|
|
}
|
|
}
|
|
|
|
// Create hitboxes for each province
|
|
for (const [province, position] of Object.entries(coordinateData.coordinates)) {
|
|
if (!province.includes('_')) { // Skip coast variants
|
|
// Determine if this is a sea province
|
|
const isSea = seaProvinces.includes(province);
|
|
|
|
// Determine country
|
|
const country = provinceToCountry[province];
|
|
|
|
// Create a circular hitbox (nearly invisible)
|
|
const radius = 30; // Slightly smaller than visual territory
|
|
const geometry = new THREE.CircleGeometry(radius, 32);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
transparent: true,
|
|
opacity: 0.01, // Nearly invisible
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const circleMesh = new THREE.Mesh(geometry, material);
|
|
circleMesh.position.set(position.x, 0.5, position.z);
|
|
circleMesh.rotation.x = -Math.PI / 2; // Make it horizontal
|
|
|
|
// Store province data for interaction
|
|
circleMesh.userData = {
|
|
province,
|
|
country,
|
|
isSea
|
|
};
|
|
|
|
// Add to the territories group
|
|
territoriesGroup.add(circleMesh);
|
|
}
|
|
}
|
|
|
|
// Add to scene
|
|
scene.add(territoriesGroup);
|
|
}
|
|
|
|
// Draw a simplified map with country outlines for the fallback
|
|
function drawSimplifiedMap(ctx, width, height) {
|
|
// Sea opacity fill
|
|
ctx.fillStyle = 'rgba(30, 70, 150, 0.1)';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
// Country regions - approximate simplified polygons for each major power
|
|
const regions = {
|
|
'ENGLAND': [
|
|
[0.35, 0.25], [0.38, 0.2], [0.42, 0.22], [0.43, 0.25],
|
|
[0.40, 0.27], [0.37, 0.3], [0.35, 0.25]
|
|
],
|
|
'FRANCE': [
|
|
[0.4, 0.35], [0.35, 0.4], [0.3, 0.45], [0.35, 0.5],
|
|
[0.4, 0.48], [0.45, 0.45], [0.42, 0.40], [0.4, 0.35]
|
|
],
|
|
'GERMANY': [
|
|
[0.48, 0.31], [0.5, 0.36], [0.55, 0.38], [0.5, 0.43],
|
|
[0.45, 0.4], [0.43, 0.35], [0.48, 0.31]
|
|
],
|
|
'ITALY': [
|
|
[0.45, 0.48], [0.5, 0.5], [0.53, 0.53], [0.5, 0.58],
|
|
[0.45, 0.55], [0.43, 0.5], [0.45, 0.48]
|
|
],
|
|
'AUSTRIA': [
|
|
[0.55, 0.42], [0.6, 0.45], [0.58, 0.5], [0.53, 0.48],
|
|
[0.52, 0.45], [0.55, 0.42]
|
|
],
|
|
'RUSSIA': [
|
|
[0.6, 0.25], [0.7, 0.3], [0.75, 0.4], [0.7, 0.5],
|
|
[0.62, 0.45], [0.58, 0.4], [0.55, 0.35], [0.6, 0.25]
|
|
],
|
|
'TURKEY': [
|
|
[0.65, 0.5], [0.7, 0.55], [0.75, 0.6], [0.65, 0.6],
|
|
[0.6, 0.55], [0.65, 0.5]
|
|
]
|
|
};
|
|
|
|
// Color palette for countries
|
|
const countryColors = {
|
|
'ENGLAND': '#223a87',
|
|
'FRANCE': '#3699d4',
|
|
'GERMANY': '#232323',
|
|
'ITALY': '#35a335',
|
|
'AUSTRIA': '#d82b2b',
|
|
'RUSSIA': '#dad6d6',
|
|
'TURKEY': '#e0c846'
|
|
};
|
|
|
|
// Draw each country region
|
|
Object.entries(regions).forEach(([country, points]) => {
|
|
ctx.beginPath();
|
|
|
|
// Scale points to canvas size
|
|
const scaledPoints = points.map(([x, y]) => [x * width, y * height]);
|
|
|
|
// Draw the country shape
|
|
ctx.moveTo(scaledPoints[0][0], scaledPoints[0][1]);
|
|
for (let i = 1; i < scaledPoints.length; i++) {
|
|
ctx.lineTo(scaledPoints[i][0], scaledPoints[i][1]);
|
|
}
|
|
ctx.closePath();
|
|
|
|
// Fill with country color (semi-transparent)
|
|
ctx.fillStyle = countryColors[country] + '88'; // Add alpha
|
|
ctx.fill();
|
|
|
|
// Add a border
|
|
ctx.strokeStyle = '#333333';
|
|
ctx.lineWidth = 8;
|
|
ctx.stroke();
|
|
|
|
// Add country label
|
|
ctx.font = 'bold 48px Arial';
|
|
ctx.fillStyle = '#000000';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
// Calculate center point for label
|
|
const centerX = scaledPoints.reduce((sum, [x]) => sum + x, 0) / scaledPoints.length;
|
|
const centerY = scaledPoints.reduce((sum, [_, y]) => sum + y, 0) / scaledPoints.length;
|
|
|
|
ctx.fillText(country, centerX, centerY);
|
|
});
|
|
|
|
// Add province markers if coordinate data is available
|
|
if (coordinateData && coordinateData.coordinates) {
|
|
const scaleX = width / 1000;
|
|
const scaleY = height / 1000;
|
|
const offsetX = width / 2;
|
|
const offsetY = height / 2;
|
|
|
|
// Draw province markers and names
|
|
for (const [province, position] of Object.entries(coordinateData.coordinates)) {
|
|
if (!province.includes('_')) { // Skip coast variants
|
|
const x = (position.x * scaleX) + offsetX;
|
|
const y = (position.z * scaleY) + offsetY;
|
|
|
|
// Draw a dot for the province
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 6, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#000000';
|
|
ctx.fill();
|
|
|
|
// Add the province name
|
|
ctx.font = '20px Arial';
|
|
ctx.fillStyle = '#000000';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(province, x, y + 25);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize the scene when page loads
|
|
window.addEventListener('load', initScene);
|
|
</script>
|
|
</body>
|
|
</html> |