Spaces:
Running
Running
<html lang="ko"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
<title>Advanced Tactical Terrain Simulator - 6 Nations Military</title> | |
<style> | |
.discord-badge { | |
position: fixed; | |
top: 10px; | |
left: 10px; | |
z-index: 9999; | |
} | |
* { margin:0; padding:0; box-sizing:border-box; } | |
body { font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background:#0a0a0a; color:#fff; overflow:hidden; } | |
#container { display:flex; height:100vh; } | |
#sidebar { | |
width:420px; background:linear-gradient(180deg,#1a1a1a 0%,#2a2a2a 100%); | |
padding:15px; overflow-y:auto; border-right:3px solid #444; | |
} | |
#map-container { flex:1; position:relative; background:#000; min-width:0; min-height:480px; } | |
#map-canvas, #terrain-background, #contour-canvas { | |
width:100%; height:100%; position:absolute; top:0; left:0; | |
image-rendering:auto; | |
} | |
#terrain-background { z-index:1; } | |
#contour-canvas { z-index:2; pointer-events:none; } | |
#map-canvas { cursor:crosshair; z-index:3; } | |
.battlefield-divider { | |
position:absolute; left:0; right:0; height:3px; | |
background:linear-gradient(90deg,transparent 0%,#ff0000 10%,#ffff00 50%,#ff0000 90%,transparent 100%); | |
z-index:10; pointer-events:none; box-shadow:0 0 10px rgba(255,0,0,0.5); | |
} | |
#blue-zone, #red-zone { | |
position:absolute; left:50%; transform:translateX(-50%); font-weight:bold; font-size:16px; | |
text-shadow:2px 2px 4px rgba(0,0,0,0.8); z-index:20; | |
} | |
#blue-zone { top:10px; color:#2196F3; } | |
#red-zone { bottom:10px; color:#f44336; } | |
.control-section { margin-bottom:20px; padding:12px; background:rgba(40,40,40,0.9); border-radius:8px; border:1px solid #555; } | |
.control-section h3 { | |
margin-bottom:10px; color:#4CAF50; font-size:13px; text-transform:uppercase; letter-spacing:1px; | |
border-bottom:1px solid #333; padding-bottom:5px; | |
} | |
.terrain-selector { display:grid; grid-template-columns:repeat(2, 1fr); gap:8px; margin-bottom:15px; } | |
.terrain-btn { | |
padding:10px; background:#333; border:2px solid #555; color:#fff; cursor:pointer; | |
border-radius:5px; transition:all .3s; font-size:11px; text-align:center; | |
} | |
.terrain-btn:hover { background:#444; border-color:#4CAF50; } | |
.terrain-btn.active { background:#4CAF50; border-color:#4CAF50; } | |
.country-selector { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin-bottom:15px; } | |
.country-btn { | |
padding:10px; border:2px solid #666; background:#444; color:#fff; cursor:pointer; | |
border-radius:5px; transition:all .3s; font-weight:bold; font-size:11px; text-align:center; | |
} | |
.country-btn:hover { transform:translateY(-2px); box-shadow:0 3px 10px rgba(0,0,0,0.5); } | |
.country-btn.active { border-width:3px; } | |
.country-btn.roka { border-color:#2196F3; } | |
.country-btn.kpa { border-color:#f44336; } | |
.country-btn.usa { border-color:#1976D2; } | |
.country-btn.russia { border-color:#D32F2F; } | |
.country-btn.ukraine { border-color:#FFD700; } | |
.country-btn.china { border-color:#FF5722; } | |
.country-btn.active.roka { background:linear-gradient(135deg,#2196F3 0%,#1976D2 100%); } | |
.country-btn.active.kpa { background:linear-gradient(135deg,#f44336 0%,#d32f2f 100%); } | |
.country-btn.active.usa { background:linear-gradient(135deg,#1976D2 0%,#0D47A1 100%); } | |
.country-btn.active.russia { background:linear-gradient(135deg,#D32F2F 0%,#B71C1C 100%); } | |
.country-btn.active.ukraine { background:linear-gradient(135deg,#FFD700 0%,#FFC107 100%); color:#000; } | |
.country-btn.active.china { background:linear-gradient(135deg,#FF5722 0%,#E64A19 100%); } | |
.side-selector { display:flex; gap:10px; margin-bottom:10px; } | |
.side-btn { | |
flex:1; padding:8px; border:2px solid #666; background:#333; color:#fff; cursor:pointer; | |
border-radius:5px; transition:all .3s; font-size:11px; text-align:center; | |
} | |
.side-btn.active { background:#4CAF50; border-color:#4CAF50; } | |
.unit-hierarchy { margin-bottom:15px; max-height:300px; overflow-y:auto; } | |
.unit-level-title { font-size:12px; color:#888; margin-bottom:5px; font-weight:bold; } | |
.unit-options { display:grid; grid-template-columns:repeat(2,1fr); gap:5px; } | |
.unit-btn { | |
padding:8px; background:#3a3a3a; border:2px solid #555; color:#fff; cursor:pointer; | |
border-radius:4px; transition:all .3s; font-size:10px; text-align:center; | |
} | |
.unit-btn:hover { background:#4a4a4a; border-color:#4CAF50; transform:translateY(-1px); } | |
.unit-btn.active { background:#4CAF50; border-color:#4CAF50; } | |
#info-panel { padding:12px; background:rgba(50,50,50,0.9); border-radius:6px; font-size:11px; line-height:1.6; } | |
#info-panel div { margin:4px 0; padding:4px; background:rgba(30,30,30,0.8); border-radius:3px; } | |
.weapons-list { font-size:10px; color:#aaa; margin-left:10px; } | |
.status-bar { | |
position:absolute; bottom:0; left:0; right:0; background:rgba(0,0,0,0.9); | |
padding:10px 20px; display:flex; justify-content:space-between; align-items:center; | |
backdrop-filter:blur(10px); z-index:20; border-top:1px solid #333; | |
} | |
.coordinates { color:#4CAF50; font-family:'Courier New', monospace; font-size:12px; } | |
.control-buttons { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; } | |
.control-btn { | |
padding:10px; background:linear-gradient(135deg,#555 0%,#444 100%); | |
border:1px solid #666; color:#fff; cursor:pointer; border-radius:5px; transition:all .3s; font-size:11px; | |
} | |
.control-btn:hover { background:linear-gradient(135deg,#666 0%,#555 100%); transform:translateY(-1px); } | |
.ai-deploy-btn { | |
width:100%; padding:15px; background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); | |
border:none; color:#fff; cursor:pointer; border-radius:5px; font-size:13px; font-weight:bold; transition:all .3s; margin-bottom:10px; | |
} | |
.ai-deploy-btn:hover { transform:scale(1.02); box-shadow:0 5px 15px rgba(102,126,234,0.4); } | |
.stats { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin-top:10px; } | |
.stat-item { padding:8px; background:rgba(30,30,30,0.8); border-radius:5px; text-align:center; border:1px solid #333; } | |
.stat-value { font-size:16px; font-weight:bold; color:#4CAF50; } | |
.stat-label { font-size:10px; color:#888; margin-top:2px; } | |
.legend { | |
position:absolute; top:20px; right:20px; background:rgba(0,0,0,0.9); | |
padding:12px; border-radius:8px; backdrop-filter:blur(10px); z-index:20; border:1px solid #333; font-size:11px; | |
} | |
.legend-title { color:#4CAF50; font-weight:bold; margin-bottom:8px; border-bottom:1px solid #333; padding-bottom:5px; } | |
.legend-item { display:flex; align-items:center; margin:5px 0; } | |
.legend-symbol { width:25px; height:20px; margin-right:8px; display:flex; align-items:center; justify-content:center; font-weight:bold; font-size:14px; } | |
.scale-indicator { | |
position:absolute; bottom:50px; right:20px; background:rgba(0,0,0,0.9); | |
padding:10px; border-radius:5px; z-index:20; border:1px solid #333; font-size:11px; | |
color:#4CAF50; | |
} | |
.terrain-controls { | |
display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; | |
} | |
.terrain-control-btn { | |
padding:6px 12px; background:#444; border:1px solid #666; color:#fff; | |
cursor:pointer; border-radius:4px; font-size:10px; transition:all .3s; | |
} | |
.terrain-control-btn:hover { background:#555; border-color:#4CAF50; } | |
.terrain-control-btn.active { background:#4CAF50; border-color:#4CAF50; } | |
.speed-control { | |
margin-top:15px; padding:15px; background:rgba(30,30,30,0.9); border-radius:8px; border:1px solid #666; | |
} | |
.speed-control-title { | |
font-size:12px; color:#FFC107; margin-bottom:10px; font-weight:bold; text-transform:uppercase; | |
display:flex; align-items:center; justify-content:space-between; | |
} | |
.speed-slider-container { position:relative; margin:15px 0; } | |
.speed-slider { | |
width:100%; -webkit-appearance:none; appearance:none; height:8px; | |
background:linear-gradient(90deg, #333 0%, #666 100%); outline:none; opacity:0.9; | |
border-radius:4px; cursor:pointer; | |
} | |
.speed-slider:hover { opacity:1; } | |
.speed-slider::-webkit-slider-thumb { | |
-webkit-appearance:none; appearance:none; width:20px; height:20px; | |
background:linear-gradient(135deg, #FFC107 0%, #FF9800 100%); cursor:pointer; | |
border-radius:50%; border:2px solid #fff; box-shadow:0 2px 6px rgba(0,0,0,0.5); | |
} | |
.speed-slider::-moz-range-thumb { | |
width:20px; height:20px; background:linear-gradient(135deg, #FFC107 0%, #FF9800 100%); | |
cursor:pointer; border-radius:50%; border:2px solid #fff; box-shadow:0 2px 6px rgba(0,0,0,0.5); | |
} | |
.speed-marks { | |
display:flex; justify-content:space-between; margin-top:8px; font-size:10px; color:#888; | |
} | |
.speed-mark { text-align:center; cursor:pointer; transition:color 0.3s; } | |
.speed-mark:hover { color:#FFC107; } | |
.speed-mark.active { color:#FFC107; font-weight:bold; } | |
.current-speed { | |
margin-top:10px; padding:8px; background:rgba(255,193,7,0.1); border:1px solid #FFC107; | |
border-radius:4px; text-align:center; font-size:12px; color:#FFC107; | |
} | |
.time-display { | |
display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin-top:10px; | |
} | |
.time-item { | |
padding:6px; background:rgba(40,40,40,0.8); border-radius:4px; text-align:center; | |
border:1px solid #444; | |
} | |
.time-label { font-size:9px; color:#888; } | |
.time-value { font-size:11px; color:#4CAF50; font-weight:bold; } | |
.zoom-controls { | |
position:absolute; top:20px; left:20px; background:rgba(0,0,0,0.9); | |
padding:8px; border-radius:8px; z-index:20; border:1px solid #333; | |
display:flex; gap:5px; align-items:center; | |
} | |
.zoom-btn { | |
width:30px; height:30px; background:#444; border:1px solid #666; color:#fff; | |
cursor:pointer; border-radius:4px; font-size:16px; transition:all .3s; | |
display:flex; align-items:center; justify-content:center; | |
} | |
.zoom-btn:hover { background:#555; border-color:#4CAF50; } | |
.zoom-level { color:#4CAF50; font-size:11px; margin:0 8px; min-width:50px; text-align:center; } | |
::-webkit-scrollbar { width:8px; } | |
::-webkit-scrollbar-track { background:#1a1a1a; } | |
::-webkit-scrollbar-thumb { background:#555; border-radius:4px; } | |
::-webkit-scrollbar-thumb:hover { background:#666; } | |
</style> | |
</head> | |
<body> | |
<a href="https://discord.gg/openfreeai" target="_blank" class="discord-badge"> | |
<img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%230000ff&labelColor=%23800080&logo=discord&logoColor=white&style=for-the-badge" alt="badge"> | |
</a> | |
<div id="container"> | |
<div id="sidebar"> | |
<div class="control-section"> | |
<h3>šŗļø OPERATION TERRAIN</h3> | |
<div class="terrain-selector"> | |
<button class="terrain-btn active" data-terrain="urban">šļø Urban Warfare<br><small>Cities, Roads, Villages</small></button> | |
<button class="terrain-btn" data-terrain="mountain">ā°ļø Mountain Highland<br><small>Rough Terrain, Villages</small></button> | |
</div> | |
<div class="terrain-controls"> | |
<button class="terrain-control-btn active" id="toggle-contours">š Contours</button> | |
<button class="terrain-control-btn active" id="toggle-roads">š£ļø Roads</button> | |
<button class="terrain-control-btn active" id="toggle-rivers">š§ Rivers</button> | |
<button class="terrain-control-btn" id="toggle-heatmap">š”ļø Heatmap</button> | |
<button class="terrain-control-btn" id="regenerate-terrain">š New Terrain</button> | |
</div> | |
</div> | |
<div class="control-section"> | |
<h3>āļø MISSION TYPE</h3> | |
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px;"> | |
<div style="padding:10px; background:rgba(33,150,243,0.2); border-radius:5px; border:1px solid #2196F3;"> | |
<div style="font-size:11px; color:#2196F3; margin-bottom:5px;">BLUE TEAM MISSION</div> | |
<select id="blue-mission" style="width:100%; padding:5px; background:#333; color:#fff; border:1px solid #2196F3; border-radius:3px;"> | |
<option value="attack">š”ļø ATTACK</option> | |
<option value="defend">š”ļø DEFEND</option> | |
</select> | |
</div> | |
<div style="padding:10px; background:rgba(244,67,54,0.2); border-radius:5px; border:1px solid #f44336;"> | |
<div style="font-size:11px; color:#f44336; margin-bottom:5px;">RED TEAM MISSION</div> | |
<select id="red-mission" style="width:100%; padding:5px; background:#333; color:#fff; border:1px solid #f44336; border-radius:3px;"> | |
<option value="attack">š”ļø ATTACK</option> | |
<option value="defend" selected>š”ļø DEFEND</option> | |
</select> | |
</div> | |
</div> | |
<div style="padding:8px; background:rgba(76,175,80,0.2); border-radius:5px; border:1px solid #4CAF50;"> | |
<div style="font-size:10px; color:#4CAF50; line-height:1.4;"> | |
<strong>Lanchester's Law Applied:</strong><br> | |
Defenders get 3:1 advantage ratio<br> | |
High ground provides additional bonus | |
</div> | |
</div> | |
</div> | |
<div class="control-section"> | |
<h3>šļø NATION & FORCE SELECTION</h3> | |
<div class="side-selector"> | |
<button class="side-btn active" data-side="blue">BLUE TEAM (South)</button> | |
<button class="side-btn" data-side="red">RED TEAM (North)</button> | |
</div> | |
<div class="country-selector"> | |
<button class="country-btn roka active" data-country="roka">š°š· South Korea<br/>ROK</button> | |
<button class="country-btn kpa" data-country="kpa">š°šµ North Korea<br/>DPRK</button> | |
<button class="country-btn usa" data-country="usa">šŗšø United States<br/>USA</button> | |
<button class="country-btn russia" data-country="russia">š·šŗ Russia<br/>RUS</button> | |
<button class="country-btn ukraine" data-country="ukraine">šŗš¦ Ukraine<br/>UKR</button> | |
<button class="country-btn china" data-country="china">šØš³ China<br/>PRC</button> | |
</div> | |
<div class="unit-hierarchy" id="units-list"> | |
<!-- Unit list will be dynamically generated --> | |
</div> | |
</div> | |
<div class="control-section"> | |
<h3>š² QUICK DEPLOYMENT</h3> | |
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;"> | |
<button class="control-btn" id="random-blue" style="background:linear-gradient(135deg,#2196F3 0%,#1976D2 100%);"> | |
š² Deploy Blue Regiment | |
</button> | |
<button class="control-btn" id="random-red" style="background:linear-gradient(135deg,#f44336 0%,#d32f2f 100%);"> | |
š² Deploy Red Regiment | |
</button> | |
</div> | |
<button class="control-btn" id="random-both" style="width:100%; margin-top:8px; background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);"> | |
āļø Full Battle Setup | |
</button> | |
<p style="font-size:10px; color:#888; text-align:center; margin-top:10px;"> | |
Deploys full regiment with proper organization | |
</p> | |
</div> | |
<div class="control-section"> | |
<h3>š COMBAT POWER ANALYSIS</h3> | |
<div class="stats"> | |
<div class="stat-item"><div class="stat-value" id="blue-units">0</div><div class="stat-label">Blue Forces</div></div> | |
<div class="stat-item"><div class="stat-value" id="red-units">0</div><div class="stat-label">Red Forces</div></div> | |
<div class="stat-item"><div class="stat-value" id="firepower-ratio">0%</div><div class="stat-label">Fire Superiority</div></div> | |
<div class="stat-item"><div class="stat-value" id="coverage">0%</div><div class="stat-label">Fire Coverage</div></div> | |
</div> | |
</div> | |
<div class="control-section"> | |
<h3>š SELECTED UNIT INFO</h3> | |
<div id="info-panel"> | |
<div>Unit: <span id="unit-name">-</span></div> | |
<div>Nation: <span id="unit-country">-</span></div> | |
<div>Size: <span id="unit-size">-</span></div> | |
<div>Strength: <span id="unit-strength">-</span></div> | |
<div>Weapons:<div class="weapons-list" id="unit-weapons">-</div></div> | |
<div>Effective Range: <span id="unit-range">-</span></div> | |
<div>Position: <span id="unit-position">-</span></div> | |
<div>Elevation: <span id="unit-elevation">-</span></div> | |
</div> | |
</div> | |
<div class="control-section"> | |
<h3>āļø OPERATION TOOLS</h3> | |
<div class="control-buttons"> | |
<button class="control-btn" id="analyze-coverage">Fire Coverage Analysis</button> | |
<button class="control-btn" id="analyze-terrain">Terrain Analysis</button> | |
<button class="control-btn" id="run-simulation">Combat Simulation</button> | |
<button class="control-btn" id="clear-all">Clear All</button> | |
</div> | |
</div> | |
<div class="control-section"> | |
<h3>š„ BATTLE COMMAND</h3> | |
<button class="control-btn" id="start-battle" style="width:100%; padding:15px; background:linear-gradient(135deg,#ff5252 0%,#ff1744 100%); font-size:14px; font-weight:bold;"> | |
āļø START ENGAGEMENT | |
</button> | |
<button class="control-btn" id="pause-battle" style="width:100%; padding:10px; margin-top:5px; background:#666; display:none;"> | |
āøļø PAUSE BATTLE | |
</button> | |
<div class="speed-control"> | |
<div class="speed-control-title"> | |
<span>ā±ļø BATTLE TIME SPEED</span> | |
<span id="speed-display">1 MIN</span> | |
</div> | |
<div class="speed-slider-container"> | |
<input type="range" class="speed-slider" id="speed-slider" min="0" max="4" value="0" step="1"> | |
<div class="speed-marks"> | |
<div class="speed-mark active" data-speed="0">1M</div> | |
<div class="speed-mark" data-speed="1">10M</div> | |
<div class="speed-mark" data-speed="2">1H</div> | |
<div class="speed-mark" data-speed="3">6H</div> | |
<div class="speed-mark" data-speed="4">1D</div> | |
</div> | |
</div> | |
<div class="current-speed" id="current-speed"> | |
1 second = 1 minute of battle time | |
</div> | |
<div class="time-display"> | |
<div class="time-item"> | |
<div class="time-label">Battle Time</div> | |
<div class="time-value" id="battle-time">00:00:00</div> | |
</div> | |
<div class="time-item"> | |
<div class="time-label">Real Time</div> | |
<div class="time-value" id="real-time">00:00:00</div> | |
</div> | |
</div> | |
</div> | |
<div style="margin-top:10px; padding:10px; background:rgba(30,30,30,0.8); border-radius:5px;"> | |
<div style="font-size:11px; color:#888;">Battle Status</div> | |
<div id="battle-status" style="font-size:12px; color:#4CAF50; margin-top:5px;">Standby</div> | |
<div style="margin-top:10px;"> | |
<div style="font-size:10px; color:#888;">Blue Casualties</div> | |
<div id="blue-casualties" style="font-size:14px; color:#2196F3;">0</div> | |
</div> | |
<div style="margin-top:5px;"> | |
<div style="font-size:10px; color:#888;">Red Casualties</div> | |
<div id="red-casualties" style="font-size:14px; color:#f44336;">0</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div id="map-container"> | |
<canvas id="terrain-background"></canvas> | |
<canvas id="contour-canvas"></canvas> | |
<canvas id="map-canvas"></canvas> | |
<div class="zoom-controls"> | |
<button class="zoom-btn" id="zoom-in">+</button> | |
<div class="zoom-level" id="zoom-level">100%</div> | |
<button class="zoom-btn" id="zoom-out">ā</button> | |
</div> | |
<div class="battlefield-divider" style="top:40%;"></div> | |
<div id="blue-zone">[ BLUE FORCE SECTOR ]</div> | |
<div id="red-zone">[ RED FORCE SECTOR ]</div> | |
<div class="legend"> | |
<div class="legend-title">Unit Symbols</div> | |
<div class="legend-item"><div class="legend-symbol" style="color:#2196F3;">ā </div><span>Blue Battalion</span></div> | |
<div class="legend-item"><div class="legend-symbol" style="color:#2196F3;">ā</div><span>Blue Company</span></div> | |
<div class="legend-item"><div class="legend-symbol" style="color:#2196F3;">ā²</div><span>Blue Platoon</span></div> | |
<div class="legend-item"><div class="legend-symbol" style="color:#f44336;">ā </div><span>Red Battalion</span></div> | |
<div class="legend-item"><div class="legend-symbol" style="color:#f44336;">ā</div><span>Red Company</span></div> | |
<div class="legend-item"><div class="legend-symbol" style="color:#f44336;">ā²</div><span>Red Platoon</span></div> | |
</div> | |
<div class="scale-indicator" id="scale-indicator"> | |
Battle Area: 20km Ć 15km<br> | |
Scale: 1:5000<br> | |
Grid: 100m | |
</div> | |
<div class="status-bar"> | |
<div class="coordinates"> | |
Pos: X=<span id="mouse-x">0</span>, Y=<span id="mouse-y">0</span> | | |
Real: <span id="real-coords">0,0</span>km | | |
Elev: <span id="elevation">0</span>m | | |
Slope: <span id="slope">0</span>° | | |
Terrain: <span id="terrain-type">-</span> | |
</div> | |
<button class="control-btn" id="generate-report" style="padding:8px 20px;">š Operation Report</button> | |
</div> | |
</div> | |
</div> | |
<script> | |
// ===== Constants/Globals ===== | |
let METERS_PER_PIXEL = 25; | |
const CONTACT_RADIUS = 20; | |
const SCALE = 5000; | |
const CONTOUR_INTERVAL = 0.05; | |
const TICK_INTERVAL = 100; // 100ms per tick | |
let canvas, ctx, terrainCanvas, terrainCtx, contourCanvas, contourCtx; | |
let selectedCountry = 'roka'; | |
let selectedSide = 'blue'; | |
let selectedUnitType = 'infantry_company'; | |
let selectedTerrain = 'urban'; | |
let units = []; | |
let heightMap = []; | |
let roads = []; | |
let rivers = []; | |
let settlements = []; | |
let mapWidth = 0, mapHeight = 0; | |
let battlefieldDivider = 0; | |
let zoomLevel = 1; | |
let offsetX = 0, offsetY = 0; | |
let isDragging = false; | |
let dragStartClientX = 0, dragStartClientY = 0; | |
let dragStartOffsetX = 0, dragStartOffsetY = 0; | |
let seed = Math.random() * 10000; | |
let blueMission = 'attack'; | |
let redMission = 'defend'; | |
let showContours = true; | |
let showRoads = true; | |
let showRivers = true; | |
let showHeatmap = false; | |
let battleActive = false; | |
let battleInterval = null; | |
let battleAnimationFrame = null; | |
let blueCasualties = 0; | |
let redCasualties = 0; | |
let explosions = []; | |
let bulletTrails = []; | |
let battleSpeed = 0; | |
let battleTimeElapsed = 0; | |
let realTimeElapsed = 0; | |
let battleStartTime = 0; | |
const speedMultipliers = [60, 600, 3600, 21600, 86400]; | |
const speedLabels = ['1 MIN', '10 MIN', '1 HOUR', '6 HOURS', '1 DAY']; | |
const speedDescriptions = [ | |
'1 second = 1 minute of battle time', | |
'1 second = 10 minutes of battle time', | |
'1 second = 1 hour of battle time', | |
'1 second = 6 hours of battle time', | |
'1 second = 1 day of battle time' | |
]; | |
// ===== Country Unit Specifications ===== | |
const countryUnits = { | |
roka: { | |
hq:{name:'HQ Battalion',symbol:'ā¬',size:'battalion',strength:50,weapons:['Command Systems','K2 Rifle','Intel Equipment'],range:1000,firepower:30,speed:50}, | |
infantry_company:{name:'Infantry Company',symbol:'ā',size:'company',strength:140,weapons:['K2 Rifle','K201 Grenade Launcher','K3 LMG','K6 HMG'],range:800,firepower:100,speed:100}, | |
infantry_platoon:{name:'Infantry Platoon',symbol:'ā²',size:'platoon',strength:40,weapons:['K2 Rifle','K201 GL','K3 LMG'],range:600,firepower:40,speed:100}, | |
mortar_81mm:{name:'81mm Mortar Company',symbol:'ā',size:'company',strength:80,weapons:['KM29A1 81mm Mortar','K2 Rifle'],range:5700,firepower:150,speed:80}, | |
antitank_platoon:{name:'AT Platoon',symbol:'ā',size:'platoon',strength:35,weapons:['Hyungung ATGM','Panzerfaust 3','K2 Rifle'],range:2500,firepower:120,speed:100}, | |
tank_company:{name:'Tank Company',symbol:'ā ',size:'company',strength:60,weapons:['K2 Black Panther (120mm)','K6 HMG'],range:4000,firepower:350,speed:500}, | |
mech_company:{name:'Mechanized Company',symbol:'ā£',size:'company',strength:120,weapons:['K21 IFV (40mm)','K6 HMG','K2 Rifle'],range:3000,firepower:250,speed:800}, | |
artillery_155mm:{name:'K9 SPH Battalion',symbol:'⬢',size:'battalion',strength:150,weapons:['K9 Thunder 155mm SPH','K2 Rifle'],range:18000,firepower:500,speed:400}, | |
mlrs_chunmoo:{name:'Chunmoo MLRS Company',symbol:'ā',size:'company',strength:90,weapons:['K239 Chunmoo MLRS','K2 Rifle'],range:20000,firepower:600,speed:400}, | |
attack_heli:{name:'AH-64E Company',symbol:'ā',size:'company',strength:40,weapons:['AH-64E Apache (30mm)','Hellfire Missile','Hydra 70 Rocket'],range:8000,firepower:450,speed:3000}, | |
attack_drone:{name:'Attack Drone Company',symbol:'ā',size:'company',strength:30,weapons:['Loitering Munition','Recon Drone','Small Guided Missile'],range:10000,firepower:200,speed:1500}, | |
air_defense:{name:'Cheonma AD Platoon',symbol:'ā¦',size:'platoon',strength:40,weapons:['Cheonma SAM','Shingung MANPADS','K2 Rifle'],range:3500,firepower:100,speed:400}, | |
support_company:{name:'Support Company',symbol:'ā',size:'company',strength:150,weapons:['Transport Vehicles','Maintenance Equipment','K2 Rifle'],range:500,firepower:30,speed:500} | |
}, | |
kpa: { | |
hq:{name:'HQ Battalion',symbol:'ā¬',size:'battalion',strength:45,weapons:['Command Systems','AK-74','Intel Equipment'],range:800,firepower:25,speed:50}, | |
infantry_company:{name:'Infantry Company',symbol:'ā',size:'company',strength:130,weapons:['AK-74/Type-88','RPK LMG','RPG-7','PKM MG'],range:700,firepower:85,speed:100}, | |
infantry_platoon:{name:'Infantry Platoon',symbol:'ā²',size:'platoon',strength:35,weapons:['AK-74/Type-88','RPK LMG'],range:500,firepower:35,speed:100}, | |
light_infantry:{name:'Light Infantry Platoon',symbol:'ā½',size:'platoon',strength:30,weapons:['AK-74','RPG-7','LMG'],range:600,firepower:40,speed:120}, | |
mortar_82mm:{name:'82mm Mortar Company',symbol:'ā',size:'company',strength:75,weapons:['82mm Mortar','AK-74'],range:4000,firepower:130,speed:80}, | |
antitank_platoon:{name:'AT Platoon',symbol:'ā',size:'platoon',strength:30,weapons:['Bulsae-3 ATGM','RPG-7','AK-74'],range:2000,firepower:100,speed:100}, | |
tank_company:{name:'Tank Company',symbol:'ā ',size:'company',strength:55,weapons:['Chonma/Songun Tank (125mm)','PKT MG'],range:3500,firepower:300,speed:400}, | |
mech_company:{name:'Mechanized Company',symbol:'ā£',size:'company',strength:110,weapons:['BMP-2 (30mm)','PKT MG','AK-74'],range:2500,firepower:200,speed:700}, | |
mrls_company:{name:'BM-21 MLRS Company',symbol:'ā',size:'company',strength:90,weapons:['BM-21 122mm MLRS','AK-74'],range:20000,firepower:400,speed:400}, | |
artillery_152mm:{name:'152mm SPH Battalion',symbol:'⬢',size:'battalion',strength:140,weapons:['2S3 Akatsiya 152mm SPH','AK-74'],range:17300,firepower:400,speed:350}, | |
attack_heli:{name:'Mi-24 Attack Heli',symbol:'ā',size:'company',strength:35,weapons:['Mi-24 Hind (12.7mm)','AT-2 Missile','S-5 Rocket'],range:6000,firepower:350,speed:2500}, | |
antiair_platoon:{name:'AAA Platoon',symbol:'ā¦',size:'platoon',strength:35,weapons:['ZPU-4 14.5mm AAA','SA-7 MANPADS','AK-74'],range:3000,firepower:80,speed:300}, | |
support_company:{name:'Support Company',symbol:'ā',size:'company',strength:140,weapons:['Transport Vehicles','Maintenance Equipment','AK-74'],range:400,firepower:25,speed:400} | |
}, | |
usa: { | |
hq:{name:'Battalion HQ',symbol:'ā¬',size:'battalion',strength:60,weapons:['Command Systems','M4A1 Carbine','SINCGARS Radio'],range:1200,firepower:35,speed:50}, | |
infantry_company:{name:'Infantry Company',symbol:'ā',size:'company',strength:150,weapons:['M4A1 Carbine','M249 SAW','M240B MG','M320 GL','AT4'],range:900,firepower:120,speed:100}, | |
infantry_platoon:{name:'Infantry Platoon',symbol:'ā²',size:'platoon',strength:45,weapons:['M4A1 Carbine','M249 SAW','M320 GL'],range:700,firepower:50,speed:100}, | |
ranger_platoon:{name:'Ranger Platoon',symbol:'ā',size:'platoon',strength:40,weapons:['M4A1 SOPMOD','Mk48 MG','Carl Gustaf','M320 GL'],range:1000,firepower:80,speed:150}, | |
mortar_120mm:{name:'120mm Mortar Platoon',symbol:'ā',size:'platoon',strength:60,weapons:['M120 120mm Mortar','M4A1 Carbine'],range:7200,firepower:180,speed:80}, | |
javelin_team:{name:'Javelin Team',symbol:'ā',size:'squad',strength:12,weapons:['FGM-148 Javelin','M4A1 Carbine'],range:4750,firepower:150,speed:100}, | |
abrams_platoon:{name:'M1A2 Platoon',symbol:'ā ',size:'platoon',strength:16,weapons:['M1A2 SEPv3 Abrams (120mm)','M2 .50 Cal','M240 MG'],range:4000,firepower:400,speed:600}, | |
bradley_platoon:{name:'Bradley Platoon',symbol:'ā£',size:'platoon',strength:28,weapons:['M2A3 Bradley (25mm)','TOW Missile','M240C MG'],range:3000,firepower:280,speed:900}, | |
stryker_platoon:{name:'Stryker Platoon',symbol:'ā¢',size:'platoon',strength:36,weapons:['M1296 Stryker ICV (30mm)','M2 .50 Cal','M4A1 Carbine'],range:2500,firepower:220,speed:1000}, | |
paladin_battery:{name:'M109A7 Battery',symbol:'⬢',size:'battery',strength:100,weapons:['M109A7 Paladin 155mm SPH','M4A1 Carbine'],range:20000,firepower:550,speed:400}, | |
himars_battery:{name:'HIMARS Battery',symbol:'ā',size:'battery',strength:80,weapons:['M142 HIMARS','GMLRS Rockets','M4A1 Carbine'],range:20000,firepower:700,speed:500}, | |
apache_company:{name:'AH-64E Company',symbol:'ā',size:'company',strength:45,weapons:['AH-64E Apache (30mm)','AGM-114 Hellfire','Hydra 70 Rockets'],range:8000,firepower:500,speed:3000}, | |
reaper_team:{name:'MQ-9 Reaper',symbol:'ā',size:'team',strength:10,weapons:['MQ-9 Reaper','AGM-114 Hellfire','GBU-12 Paveway'],range:15000,firepower:300,speed:2000}, | |
patriot_battery:{name:'Patriot Battery',symbol:'ā¦',size:'battery',strength:90,weapons:['MIM-104 Patriot','PAC-3 MSE','M4A1 Carbine'],range:20000,firepower:200,speed:400}, | |
support_company:{name:'Support Company',symbol:'ā',size:'company',strength:160,weapons:['M1083 FMTV','M4A1 Carbine'],range:500,firepower:35,speed:600} | |
}, | |
russia: { | |
hq:{name:'Battalion HQ',symbol:'ā¬',size:'battalion',strength:55,weapons:['Command Systems','AK-12','R-187 Azart Radio'],range:1000,firepower:30,speed:50}, | |
motor_rifle_company:{name:'Motor Rifle Company',symbol:'ā',size:'company',strength:135,weapons:['AK-12','PKP Pecheneg','RPG-7V2','AGS-30'],range:800,firepower:95,speed:100}, | |
motor_rifle_platoon:{name:'Motor Rifle Platoon',symbol:'ā²',size:'platoon',strength:38,weapons:['AK-12','PKP Pecheneg','RPG-7V2'],range:600,firepower:40,speed:100}, | |
vdv_platoon:{name:'VDV Platoon',symbol:'ā',size:'platoon',strength:35,weapons:['AK-12','PKP Pecheneg','RPG-28','AGS-30'],range:800,firepower:65,speed:150}, | |
mortar_120mm:{name:'120mm Mortar',symbol:'ā',size:'battery',strength:70,weapons:['2S12 Sani 120mm Mortar','AK-12'],range:7100,firepower:160,speed:80}, | |
kornet_team:{name:'Kornet-EM Team',symbol:'ā',size:'squad',strength:10,weapons:['9M133 Kornet-EM ATGM','AK-12'],range:8000,firepower:140,speed:100}, | |
t90_platoon:{name:'T-90M Platoon',symbol:'ā ',size:'platoon',strength:12,weapons:['T-90M Proryv (125mm)','Kord 12.7mm','PKT 7.62mm'],range:4000,firepower:380,speed:500}, | |
t72_company:{name:'T-72B3 Company',symbol:'ā ',size:'company',strength:39,weapons:['T-72B3M (125mm)','Kord 12.7mm','PKT 7.62mm'],range:3500,firepower:320,speed:450}, | |
bmp3_platoon:{name:'BMP-3 Platoon',symbol:'ā£',size:'platoon',strength:30,weapons:['BMP-3 (100mm+30mm)','PKT 7.62mm','AK-12'],range:3000,firepower:260,speed:750}, | |
btr82_platoon:{name:'BTR-82A Platoon',symbol:'ā¢',size:'platoon',strength:32,weapons:['BTR-82A (30mm)','PKT 7.62mm','AK-12'],range:2000,firepower:180,speed:900}, | |
msta_battery:{name:'2S19 Msta-S',symbol:'⬢',size:'battery',strength:120,weapons:['2S19 Msta-S 152mm SPH','AK-12'],range:20000,firepower:480,speed:400}, | |
grad_battery:{name:'BM-21 Grad',symbol:'ā',size:'battery',strength:90,weapons:['BM-21 Grad 122mm MLRS','AK-12'],range:20000,firepower:450,speed:400}, | |
ka52_squadron:{name:'Ka-52 Squadron',symbol:'ā',size:'squadron',strength:30,weapons:['Ka-52 Alligator (30mm)','Vikhr ATGM','S-8 Rockets'],range:10000,firepower:420,speed:2800}, | |
orlan_team:{name:'Orlan-10 UAV',symbol:'ā',size:'team',strength:8,weapons:['Orlan-10 UAV','Laser Designator'],range:12000,firepower:50,speed:1200}, | |
pantsir_battery:{name:'Pantsir-S1',symbol:'ā¦',size:'battery',strength:50,weapons:['Pantsir-S1 (30mm)','57E6 SAM','AK-12'],range:20000,firepower:150,speed:400}, | |
support_company:{name:'Support Company',symbol:'ā',size:'company',strength:150,weapons:['KamAZ-5350','AK-12'],range:400,firepower:25,speed:500} | |
}, | |
ukraine: { | |
hq:{name:'Battalion HQ',symbol:'ā¬',size:'battalion',strength:50,weapons:['Command Systems','AK-74M','Harris Radio'],range:1000,firepower:30,speed:50}, | |
mech_infantry_company:{name:'Mech Infantry Company',symbol:'ā',size:'company',strength:130,weapons:['AK-74M','Fort-221','PKM','RPG-7'],range:750,firepower:90,speed:100}, | |
infantry_platoon:{name:'Infantry Platoon',symbol:'ā²',size:'platoon',strength:36,weapons:['AK-74M','Fort-221','PKM'],range:600,firepower:38,speed:100}, | |
azov_platoon:{name:'Azov Platoon',symbol:'ā',size:'platoon',strength:35,weapons:['M4 WAC-47','Mk48 MG','NLAW','Carl Gustaf'],range:900,firepower:70,speed:120}, | |
mortar_120mm:{name:'120mm Mortar',symbol:'ā',size:'battery',strength:65,weapons:['M120-15 Molot 120mm','AK-74M'],range:7000,firepower:155,speed:80}, | |
stugna_team:{name:'Stugna-P Team',symbol:'ā',size:'squad',strength:8,weapons:['Stugna-P ATGM','AK-74M'],range:5000,firepower:130,speed:100}, | |
nlaw_team:{name:'NLAW Team',symbol:'ā',size:'squad',strength:6,weapons:['NLAW','AK-74M'],range:800,firepower:100,speed:100}, | |
t64_company:{name:'T-64BV Company',symbol:'ā ',size:'company',strength:39,weapons:['T-64BV (125mm)','NSVT 12.7mm','PKT 7.62mm'],range:3500,firepower:300,speed:450}, | |
bmp2_platoon:{name:'BMP-2 Platoon',symbol:'ā£',size:'platoon',strength:28,weapons:['BMP-2 (30mm)','Konkurs ATGM','PKT 7.62mm'],range:2500,firepower:200,speed:700}, | |
btr4_platoon:{name:'BTR-4E Platoon',symbol:'ā¢',size:'platoon',strength:30,weapons:['BTR-4E (30mm)','Barrier ATGM','KT-7.62 MG'],range:2500,firepower:210,speed:850}, | |
m777_battery:{name:'M777 Battery',symbol:'⬢',size:'battery',strength:100,weapons:['M777 155mm Howitzer','AK-74M'],range:20000,firepower:450,speed:300}, | |
caesar_battery:{name:'CAESAR Battery',symbol:'⬢',size:'battery',strength:80,weapons:['CAESAR 155mm SPG','AK-74M'],range:20000,firepower:470,speed:450}, | |
himars_battery:{name:'M142 HIMARS',symbol:'ā',size:'battery',strength:70,weapons:['M142 HIMARS','GMLRS','AK-74M'],range:20000,firepower:650,speed:500}, | |
bayraktar_team:{name:'Bayraktar TB2',symbol:'ā',size:'team',strength:12,weapons:['Bayraktar TB2','MAM-L','MAM-C'],range:15000,firepower:250,speed:1800}, | |
gepard_platoon:{name:'Gepard SPAAG',symbol:'ā¦',size:'platoon',strength:20,weapons:['Flakpanzer Gepard (35mm)','AK-74M'],range:5500,firepower:120,speed:400}, | |
support_company:{name:'Support Company',symbol:'ā',size:'company',strength:140,weapons:['KrAZ-6322','AK-74M'],range:400,firepower:25,speed:500} | |
}, | |
china: { | |
hq:{name:'Battalion HQ',symbol:'ā¬',size:'battalion',strength:55,weapons:['Command Systems','QBZ-95','Digital Radio'],range:1100,firepower:32,speed:50}, | |
infantry_company:{name:'Infantry Company',symbol:'ā',size:'company',strength:145,weapons:['QBZ-95','QJY-88 MG','PF-98 RPG','QLZ-87 GL'],range:850,firepower:105,speed:100}, | |
infantry_platoon:{name:'Infantry Platoon',symbol:'ā²',size:'platoon',strength:42,weapons:['QBZ-95','QJY-88 MG','PF-89 RPG'],range:650,firepower:45,speed:100}, | |
special_forces:{name:'Special Forces',symbol:'ā',size:'platoon',strength:30,weapons:['QBZ-95-1','CS/LR4 Sniper','HJ-12 ATGM','QJY-201 MG'],range:1200,firepower:85,speed:150}, | |
mortar_120mm:{name:'120mm Mortar Company',symbol:'ā',size:'company',strength:75,weapons:['PP87 120mm Mortar','QBZ-95'],range:6800,firepower:165,speed:80}, | |
hj12_team:{name:'HJ-12 AT Team',symbol:'ā',size:'squad',strength:8,weapons:['HJ-12 ATGM','QBZ-95'],range:4000,firepower:135,speed:100}, | |
type99a_company:{name:'Type 99A Tank Company',symbol:'ā ',size:'company',strength:39,weapons:['ZTZ-99A (125mm)','QJC-88 12.7mm','Type 86 MG'],range:4000,firepower:370,speed:550}, | |
zbd04_platoon:{name:'ZBD-04A IFV Platoon',symbol:'ā£',size:'platoon',strength:30,weapons:['ZBD-04A (100mm+30mm)','HJ-8 ATGM','Type 86 MG'],range:3000,firepower:270,speed:800}, | |
zbl08_platoon:{name:'ZBL-08 APC Platoon',symbol:'ā¢',size:'platoon',strength:33,weapons:['ZBL-08 (30mm)','HJ-73C ATGM','QJC-88 MG'],range:2500,firepower:220,speed:950}, | |
plz05_battery:{name:'PLZ-05 SPH Battalion',symbol:'⬢',size:'battalion',strength:120,weapons:['PLZ-05 155mm SPH','QBZ-95'],range:20000,firepower:520,speed:400}, | |
pcl191_battery:{name:'PCL-191 MLRS',symbol:'ā',size:'battery',strength:90,weapons:['PCL-191 MLRS System','QBZ-95'],range:20000,firepower:750,speed:450}, | |
wz10_squadron:{name:'WZ-10 Squadron',symbol:'ā',size:'squadron',strength:35,weapons:['WZ-10 (23mm)','HJ-10 ATGM','TY-90 AAM'],range:8000,firepower:430,speed:2700}, | |
ch5_team:{name:'CH-5 UAV',symbol:'ā',size:'team',strength:10,weapons:['CH-5 UAV','AR-1 Missile','AR-2 Missile'],range:14000,firepower:280,speed:1600}, | |
hq17_battery:{name:'HQ-17 SAM',symbol:'ā¦',size:'battery',strength:60,weapons:['HQ-17 SAM System','35mm AAA','QBZ-95'],range:15000,firepower:140,speed:400}, | |
support_company:{name:'Support Company',symbol:'ā',size:'company',strength:155,weapons:['Dongfeng Mengshi','QBZ-95'],range:450,firepower:28,speed:600} | |
} | |
}; | |
const countryInfo = { | |
roka: { name: 'ROK Army', color: '#2196F3' }, | |
kpa: { name: 'Korean People\'s Army', color: '#f44336' }, | |
usa: { name: 'US Army', color: '#1976D2' }, | |
russia: { name: 'Russian Ground Forces', color: '#D32F2F' }, | |
ukraine: { name: 'Ukrainian Army', color: '#FFD700' }, | |
china: { name: 'PLA Ground Force', color: '#FF5722' } | |
}; | |
// Mission type controls | |
document.getElementById('blue-mission').addEventListener('change', function() { | |
blueMission = this.value; | |
if (blueMission === 'defend' && redMission === 'defend') { | |
redMission = 'attack'; | |
document.getElementById('red-mission').value = 'attack'; | |
} else if (blueMission === 'attack' && redMission === 'attack') { | |
redMission = 'defend'; | |
document.getElementById('red-mission').value = 'defend'; | |
} | |
}); | |
document.getElementById('red-mission').addEventListener('change', function() { | |
redMission = this.value; | |
if (redMission === 'defend' && blueMission === 'defend') { | |
blueMission = 'attack'; | |
document.getElementById('blue-mission').value = 'attack'; | |
} else if (redMission === 'attack' && blueMission === 'attack') { | |
blueMission = 'defend'; | |
document.getElementById('blue-mission').value = 'defend'; | |
} | |
}); | |
// ===== CRITICAL FIX: Common View Transform ===== | |
function applyViewTransform(context) { | |
context.save(); | |
context.translate(mapWidth/2, mapHeight/2); | |
context.scale(zoomLevel, zoomLevel); | |
context.translate(-mapWidth/2 + offsetX, -mapHeight/2 + offsetY); | |
} | |
function restoreViewTransform(context) { | |
context.restore(); | |
} | |
// ===== Enhanced Terrain System ===== | |
function noise(x, y, scale, octaves) { | |
let value = 0; | |
let amplitude = 1; | |
let frequency = scale; | |
let maxValue = 0; | |
for (let i = 0; i < octaves; i++) { | |
value += amplitude * simpleNoise(x * frequency + seed, y * frequency + seed); | |
maxValue += amplitude; | |
amplitude *= 0.5; | |
frequency *= 2; | |
} | |
return value / maxValue; | |
} | |
function simpleNoise(x, y) { | |
const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; | |
return (n - Math.floor(n)) * 2 - 1; | |
} | |
function generateTerrain() { | |
console.log('Generating terrain:', selectedTerrain); | |
seed = Math.random() * 10000; | |
heightMap = []; | |
roads = []; | |
rivers = []; | |
settlements = []; | |
const gridSize = 50; | |
for (let y = 0; y < gridSize; y++) { | |
heightMap[y] = []; | |
for (let x = 0; x < gridSize; x++) { | |
let height = 0; | |
switch(selectedTerrain) { | |
case 'mountain': | |
height = noise(x, y, 0.03, 4) * 0.6; | |
height = Math.pow(Math.abs(height), 1.2) * Math.sign(height); | |
break; | |
case 'urban': | |
height = noise(x, y, 0.015, 2) * 0.1 + 0.15; | |
break; | |
default: | |
height = 0.3; | |
} | |
height = Math.max(0, Math.min(1, (height + 1) / 2)); | |
heightMap[y][x] = height; | |
} | |
} | |
generateRoads(gridSize); | |
generateRivers(gridSize); | |
generateSettlements(gridSize); | |
console.log('Terrain generated successfully'); | |
} | |
function generateRoads(gridSize) { | |
roads = []; | |
// ėė”넼 ė ė§ģ“ ģģ± (źø°ģ”“ė³“ė¤ 2ė°°) | |
const numRoads = selectedTerrain === 'urban' ? 16 : 8; | |
// 주ģ ėė” ė¤ķøģķ¬ ģģ± | |
for (let i = 0; i < numRoads; i++) { | |
const road = []; | |
let x, y, targetX, targetY; | |
if (i < 4) { | |
// ėØė¶ ģ°ź²° 주ģ ėė” | |
x = (gridSize / 4) * (i + 0.5) + (Math.random() - 0.5) * 10; | |
y = 0; | |
targetX = x + (Math.random() - 0.5) * 20; | |
targetY = gridSize - 1; | |
} else if (i < 8) { | |
// ėģ ģ°ź²° 주ģ ėė” | |
x = 0; | |
y = (gridSize / 4) * (i - 3.5) + (Math.random() - 0.5) * 10; | |
targetX = gridSize - 1; | |
targetY = y + (Math.random() - 0.5) * 20; | |
} else { | |
// ėź°ģ ė° ė³“ģ”° ėė” | |
x = Math.random() * gridSize; | |
y = Math.random() * gridSize; | |
targetX = Math.random() * gridSize; | |
targetY = Math.random() * gridSize; | |
} | |
const maxSteps = 100; | |
for (let step = 0; step < maxSteps; step++) { | |
road.push({ x: Math.floor(x), y: Math.floor(y) }); | |
const dx = targetX - x; | |
const dy = targetY - y; | |
const dist = Math.sqrt(dx * dx + dy * dy); | |
if (dist < 2) break; | |
const angle = Math.atan2(dy, dx); | |
// ėė”넼 ģ¢ ė ģ§ģ ģ ģ¼ė” | |
x += Math.cos(angle) * 1.5 + (Math.random() - 0.5) * 0.3; | |
y += Math.sin(angle) * 1.5 + (Math.random() - 0.5) * 0.3; | |
x = Math.max(0, Math.min(gridSize - 1, x)); | |
y = Math.max(0, Math.min(gridSize - 1, y)); | |
} | |
if (road.length > 5) { | |
roads.push(road); | |
} | |
} | |
} | |
function generateRivers(gridSize) { | |
rivers = []; | |
const numRivers = selectedTerrain === 'mountain' ? 3 : 1; | |
for (let i = 0; i < numRivers; i++) { | |
const river = []; | |
let x = Math.random() * gridSize; | |
let y = Math.random() * gridSize; | |
const maxSteps = 100; | |
for (let step = 0; step < maxSteps; step++) { | |
const ix = Math.floor(x); | |
const iy = Math.floor(y); | |
if (ix < 0 || ix >= gridSize || iy < 0 || iy >= gridSize) break; | |
river.push({ x: ix, y: iy }); | |
let moved = false; | |
const currentHeight = heightMap[iy] && heightMap[iy][ix] ? heightMap[iy][ix] : 0; | |
for (let attempts = 0; attempts < 8; attempts++) { | |
const angle = Math.random() * Math.PI * 2; | |
const nx = x + Math.cos(angle) * 1.5; | |
const ny = y + Math.sin(angle) * 1.5; | |
const nix = Math.floor(nx); | |
const niy = Math.floor(ny); | |
if (nix >= 0 && nix < gridSize && niy >= 0 && niy < gridSize) { | |
const nextHeight = heightMap[niy] && heightMap[niy][nix] ? heightMap[niy][nix] : 0; | |
if (nextHeight <= currentHeight) { | |
x = nx; | |
y = ny; | |
moved = true; | |
break; | |
} | |
} | |
} | |
if (!moved) { | |
x += (Math.random() - 0.5) * 2; | |
y += (Math.random() - 0.5) * 2; | |
} | |
x = Math.max(0, Math.min(gridSize - 1, x)); | |
y = Math.max(0, Math.min(gridSize - 1, y)); | |
if (currentHeight < 0.2) break; | |
} | |
if (river.length > 5) { | |
rivers.push(river); | |
} | |
} | |
} | |
function generateSettlements(gridSize) { | |
settlements = []; | |
// ėė” źµģ°Øģ ź³¼ ėė”ė³ģ ė§ģ ė°°ģ¹ | |
const villagePositions = []; | |
// ėė” źµģ°Øģ 찾기 | |
for (let i = 0; i < roads.length - 1; i++) { | |
for (let j = i + 1; j < roads.length; j++) { | |
for (let p1 of roads[i]) { | |
for (let p2 of roads[j]) { | |
const dist = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); | |
if (dist < 3) { | |
villagePositions.push({ | |
x: Math.floor((p1.x + p2.x) / 2), | |
y: Math.floor((p1.y + p2.y) / 2), | |
importance: 2 // źµģ°Øģ ģ ģ¤ģė ėģ | |
}); | |
} | |
} | |
} | |
} | |
} | |
// ėė”ė³ģ ģ¶ź° ė§ģ ė°°ģ¹ | |
for (let road of roads) { | |
const numVillages = Math.floor(road.length / 15) + 1; | |
for (let i = 0; i < numVillages; i++) { | |
const idx = Math.floor(Math.random() * road.length); | |
const point = road[idx]; | |
if (point) { | |
villagePositions.push({ | |
x: point.x + Math.floor((Math.random() - 0.5) * 2), | |
y: point.y + Math.floor((Math.random() - 0.5) * 2), | |
importance: 1 | |
}); | |
} | |
} | |
} | |
// ģ¤ė³µ ģ ź±° ė° ģµģ¢ ė§ģ ģģ± | |
const uniquePositions = []; | |
for (let pos of villagePositions) { | |
let tooClose = false; | |
for (let existing of uniquePositions) { | |
const dist = Math.sqrt(Math.pow(pos.x - existing.x, 2) + Math.pow(pos.y - existing.y, 2)); | |
if (dist < 5) { | |
tooClose = true; | |
break; | |
} | |
} | |
if (!tooClose && pos.x >= 0 && pos.x < gridSize && pos.y >= 0 && pos.y < gridSize) { | |
uniquePositions.push(pos); | |
} | |
} | |
// ė§ģ ģģ± | |
for (let pos of uniquePositions) { | |
const height = heightMap[pos.y][pos.x]; | |
if (height > 0.15 && height < 0.8) { // 물과 ėģ ģ° ģ ģø | |
let type, size; | |
if (selectedTerrain === 'urban') { | |
if (pos.importance === 2 && Math.random() < 0.5) { | |
type = 'city'; | |
size = 12 + Math.random() * 8; | |
} else if (pos.importance === 2 || Math.random() < 0.3) { | |
type = 'town'; | |
size = 8 + Math.random() * 4; | |
} else { | |
type = 'village'; | |
size = 4 + Math.random() * 3; | |
} | |
} else { // mountain | |
if (pos.importance === 2 && Math.random() < 0.3) { | |
type = 'town'; | |
size = 6 + Math.random() * 3; | |
} else { | |
type = 'village'; | |
size = 3 + Math.random() * 2; | |
} | |
} | |
settlements.push({ | |
x: pos.x, | |
y: pos.y, | |
type: type, | |
size: size, | |
importance: pos.importance | |
}); | |
} | |
} | |
console.log(`Generated ${settlements.length} settlements along ${roads.length} roads`); | |
} | |
// ===== FIXED: drawTerrain with View Transform ===== | |
function drawTerrain() { | |
if (!terrainCtx || !heightMap || heightMap.length === 0) return; | |
const gridSize = heightMap.length; | |
const cellWidth = mapWidth / gridSize; | |
const cellHeight = mapHeight / gridSize; | |
terrainCtx.clearRect(0, 0, mapWidth, mapHeight); | |
applyViewTransform(terrainCtx); | |
try { | |
for (let y = 0; y < gridSize; y++) { | |
for (let x = 0; x < gridSize; x++) { | |
if (!heightMap[y] || heightMap[y][x] === undefined) continue; | |
const height = heightMap[y][x]; | |
if (showHeatmap) { | |
const hue = (1 - height) * 240; | |
terrainCtx.fillStyle = `hsl(${hue}, 70%, 50%)`; | |
} else { | |
terrainCtx.fillStyle = getTerrainColor(height); | |
} | |
terrainCtx.fillRect( | |
x * cellWidth, | |
y * cellHeight, | |
cellWidth + 1, | |
cellHeight + 1 | |
); | |
} | |
} | |
if (showRivers && rivers && rivers.length > 0) { | |
rivers.forEach(river => { | |
if (!river || river.length === 0) return; | |
terrainCtx.strokeStyle = '#4682B4'; | |
terrainCtx.lineWidth = 3; | |
terrainCtx.beginPath(); | |
river.forEach((point, index) => { | |
if (!point) return; | |
const x = point.x * cellWidth; | |
const y = point.y * cellHeight; | |
if (index === 0) { | |
terrainCtx.moveTo(x, y); | |
} else { | |
terrainCtx.lineTo(x, y); | |
} | |
}); | |
terrainCtx.stroke(); | |
}); | |
} | |
if (showRoads && roads && roads.length > 0) { | |
roads.forEach(road => { | |
if (!road || road.length === 0) return; | |
terrainCtx.strokeStyle = '#555'; | |
terrainCtx.lineWidth = 2; | |
terrainCtx.setLineDash([5, 3]); | |
terrainCtx.beginPath(); | |
road.forEach((point, index) => { | |
if (!point) return; | |
const x = point.x * cellWidth; | |
const y = point.y * cellHeight; | |
if (index === 0) { | |
terrainCtx.moveTo(x, y); | |
} else { | |
terrainCtx.lineTo(x, y); | |
} | |
}); | |
terrainCtx.stroke(); | |
terrainCtx.setLineDash([]); | |
}); | |
} | |
if (settlements && settlements.length > 0) { | |
settlements.forEach(settlement => { | |
if (!settlement) return; | |
const x = settlement.x * cellWidth; | |
const y = settlement.y * cellHeight; | |
if (settlement.type === 'city') { | |
terrainCtx.fillStyle = '#666'; | |
terrainCtx.fillRect(x - settlement.size/2, y - settlement.size/2, settlement.size, settlement.size); | |
terrainCtx.strokeStyle = '#333'; | |
terrainCtx.strokeRect(x - settlement.size/2, y - settlement.size/2, settlement.size, settlement.size); | |
} else if (settlement.type === 'town') { | |
terrainCtx.fillStyle = '#7A6A5A'; | |
terrainCtx.fillRect(x - settlement.size/2, y - settlement.size/2, settlement.size, settlement.size); | |
} else if (settlement.type === 'village') { | |
terrainCtx.fillStyle = '#8B7355'; | |
terrainCtx.beginPath(); | |
terrainCtx.arc(x, y, settlement.size, 0, Math.PI * 2); | |
terrainCtx.fill(); | |
} | |
}); | |
} | |
} catch(error) { | |
console.error('Error drawing terrain:', error); | |
} | |
restoreViewTransform(terrainCtx); | |
} | |
// ===== FIXED: drawContours with View Transform ===== | |
function drawContours() { | |
if (!contourCtx || !showContours || !heightMap || heightMap.length === 0) return; | |
const gridSize = heightMap.length; | |
const cellWidth = mapWidth / gridSize; | |
const cellHeight = mapHeight / gridSize; | |
contourCtx.clearRect(0, 0, mapWidth, mapHeight); | |
applyViewTransform(contourCtx); | |
const contourLevels = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]; | |
try { | |
for (let level of contourLevels) { | |
contourCtx.beginPath(); | |
for (let y = 0; y < gridSize - 1; y++) { | |
for (let x = 0; x < gridSize - 1; x++) { | |
if (!heightMap[y] || !heightMap[y+1] || | |
heightMap[y][x] === undefined || heightMap[y][x+1] === undefined || | |
heightMap[y+1][x] === undefined || heightMap[y+1][x+1] === undefined) { | |
continue; | |
} | |
const corners = [ | |
heightMap[y][x], | |
heightMap[y][x + 1], | |
heightMap[y + 1][x + 1], | |
heightMap[y + 1][x] | |
]; | |
drawContourCell( | |
x * cellWidth, | |
y * cellHeight, | |
cellWidth, | |
cellHeight, | |
corners, | |
level | |
); | |
} | |
} | |
if (level % 0.2 === 0) { | |
contourCtx.lineWidth = 2; | |
contourCtx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; | |
} else { | |
contourCtx.lineWidth = 1; | |
contourCtx.strokeStyle = 'rgba(0, 0, 0, 0.3)'; | |
} | |
contourCtx.stroke(); | |
} | |
} catch(error) { | |
console.error('Error drawing contours:', error); | |
} | |
restoreViewTransform(contourCtx); | |
} | |
function drawContourCell(x, y, width, height, corners, level) { | |
let state = 0; | |
if (corners[0] > level) state |= 1; | |
if (corners[1] > level) state |= 2; | |
if (corners[2] > level) state |= 4; | |
if (corners[3] > level) state |= 8; | |
switch(state) { | |
case 1: case 14: | |
drawLine(contourCtx, x, y + height * interpolate(corners[0], corners[3], level), | |
x + width * interpolate(corners[0], corners[1], level), y); | |
break; | |
case 2: case 13: | |
drawLine(contourCtx, x + width * interpolate(corners[0], corners[1], level), y, | |
x + width, y + height * interpolate(corners[1], corners[2], level)); | |
break; | |
case 3: case 12: | |
drawLine(contourCtx, x, y + height * interpolate(corners[0], corners[3], level), | |
x + width, y + height * interpolate(corners[1], corners[2], level)); | |
break; | |
case 4: case 11: | |
drawLine(contourCtx, x + width, y + height * interpolate(corners[1], corners[2], level), | |
x + width * interpolate(corners[3], corners[2], level), y + height); | |
break; | |
case 5: | |
drawLine(contourCtx, x, y + height * interpolate(corners[0], corners[3], level), | |
x + width * interpolate(corners[0], corners[1], level), y); | |
drawLine(contourCtx, x + width, y + height * interpolate(corners[1], corners[2], level), | |
x + width * interpolate(corners[3], corners[2], level), y + height); | |
break; | |
case 6: case 9: | |
drawLine(contourCtx, x + width * interpolate(corners[0], corners[1], level), y, | |
x + width * interpolate(corners[3], corners[2], level), y + height); | |
break; | |
case 7: case 8: | |
drawLine(contourCtx, x, y + height * interpolate(corners[0], corners[3], level), | |
x + width * interpolate(corners[3], corners[2], level), y + height); | |
break; | |
case 10: | |
drawLine(contourCtx, x + width * interpolate(corners[0], corners[1], level), y, | |
x + width, y + height * interpolate(corners[1], corners[2], level)); | |
drawLine(contourCtx, x, y + height * interpolate(corners[0], corners[3], level), | |
x + width * interpolate(corners[3], corners[2], level), y + height); | |
break; | |
} | |
} | |
function interpolate(v1, v2, level) { | |
if (v1 === v2) return 0; | |
return (level - v1) / (v2 - v1); | |
} | |
function drawLine(context, x1, y1, x2, y2) { | |
context.moveTo(x1, y1); | |
context.lineTo(x2, y2); | |
} | |
function getTerrainColor(height) { | |
if (height < 0.15) return '#0d4f8b'; | |
if (height < 0.25) return '#1e7eb8'; | |
if (height < 0.35) return '#61a861'; | |
if (height < 0.45) return '#8fc68f'; | |
if (height < 0.55) return '#c4d4aa'; | |
if (height < 0.65) return '#f5deb3'; | |
if (height < 0.75) return '#d2b48c'; | |
if (height < 0.85) return '#8b5a2b'; | |
return '#d9d9d9'; // ģ°ķ ķģģ¼ė” ė³ź²½ | |
} | |
function getHeightAt(x, y) { | |
if (!heightMap || heightMap.length === 0) return 0.3; | |
const gridSize = heightMap.length; | |
const cellWidth = mapWidth / gridSize; | |
const cellHeight = mapHeight / gridSize; | |
const gridX = Math.floor(x / cellWidth); | |
const gridY = Math.floor(y / cellHeight); | |
if (gridX >= 0 && gridX < gridSize && gridY >= 0 && gridY < gridSize) { | |
if (heightMap[gridY] && heightMap[gridY][gridX] !== undefined) { | |
return heightMap[gridY][gridX]; | |
} | |
} | |
return 0.3; | |
} | |
function getSlope(x, y) { | |
if (!heightMap || heightMap.length === 0) return 0; | |
const gridSize = heightMap.length; | |
const cellWidth = mapWidth / gridSize; | |
const cellHeight = mapHeight / gridSize; | |
const gx = Math.floor(x / cellWidth); | |
const gy = Math.floor(y / cellHeight); | |
if (gx <= 0 || gx >= gridSize - 1 || gy <= 0 || gy >= gridSize - 1) return 0; | |
// ź³ ė ģ¤ģ¼ģ¼: 0~1 -> 0~1000m | |
const h = (ix, iy) => (heightMap[iy]?.[ix] ?? 0) * 1000; | |
const dzdx = (h(gx+1, gy) - h(gx-1, gy)) / (2 * cellWidth * METERS_PER_PIXEL); | |
const dzdy = (h(gx, gy+1) - h(gx, gy-1)) / (2 * cellHeight * METERS_PER_PIXEL); | |
const gradient = Math.sqrt(dzdx*dzdx + dzdy*dzdy); // ģķ 1mė¹ ź³ ė ė³ķ(m/m) | |
const angleRad = Math.atan(gradient); | |
return Math.round(angleRad * 180 / Math.PI); // ė ėØģ | |
} | |
// ===== Initialization ===== | |
window.addEventListener('DOMContentLoaded', () => { | |
console.log('Initializing simulator...'); | |
canvas = document.getElementById('map-canvas'); | |
terrainCanvas = document.getElementById('terrain-background'); | |
contourCanvas = document.getElementById('contour-canvas'); | |
if (!canvas || !terrainCanvas || !contourCanvas) { | |
console.error('Canvas elements not found'); | |
return; | |
} | |
ctx = canvas.getContext('2d'); | |
terrainCtx = terrainCanvas.getContext('2d'); | |
contourCtx = contourCanvas.getContext('2d'); | |
if (!ctx || !terrainCtx || !contourCtx) { | |
console.error('Canvas context initialization failed'); | |
return; | |
} | |
// Initialize canvas size first | |
setCanvasSizeWithDPR(); | |
// Setup event listeners | |
setupEventListeners(); | |
setupSpeedControl(); | |
updateUnitsList(); | |
// Generate and draw terrain | |
try { | |
generateTerrain(); | |
drawTerrain(); | |
drawContours(); | |
drawMap(); | |
console.log('Terrain generated and drawn successfully'); | |
} catch(error) { | |
console.error('Error during initialization:', error); | |
} | |
}); | |
function setCanvasSizeWithDPR(){ | |
const container = document.getElementById('map-container'); | |
if (!container) { | |
console.error('Map container not found'); | |
return; | |
} | |
const rect = container.getBoundingClientRect(); | |
const dpr = window.devicePixelRatio || 1; | |
// Ensure minimum size | |
mapWidth = Math.max(600, Math.floor(rect.width)); | |
mapHeight = Math.max(400, Math.floor(rect.height)); | |
battlefieldDivider = Math.floor(mapHeight * 0.4); | |
METERS_PER_PIXEL = ((20000 / mapWidth) + (15000 / mapHeight)) / 2; | |
console.log(`Canvas size: ${mapWidth}x${mapHeight}, DPR: ${dpr}, Scale: ${METERS_PER_PIXEL}m/px`); | |
// Set canvas display size | |
terrainCanvas.style.width = mapWidth + 'px'; | |
terrainCanvas.style.height = mapHeight + 'px'; | |
contourCanvas.style.width = mapWidth + 'px'; | |
contourCanvas.style.height = mapHeight + 'px'; | |
canvas.style.width = mapWidth + 'px'; | |
canvas.style.height = mapHeight + 'px'; | |
// Set canvas actual size with DPR | |
terrainCanvas.width = mapWidth * dpr; | |
terrainCanvas.height = mapHeight * dpr; | |
contourCanvas.width = mapWidth * dpr; | |
contourCanvas.height = mapHeight * dpr; | |
canvas.width = mapWidth * dpr; | |
canvas.height = mapHeight * dpr; | |
// Scale context for DPR | |
terrainCtx.scale(dpr, dpr); | |
contourCtx.scale(dpr, dpr); | |
ctx.scale(dpr, dpr); | |
const scaleText = `Battle Area: 20km Ć 15km<br>Scale: 1:${SCALE}<br>Grid: ${Math.round(METERS_PER_PIXEL)}m/px`; | |
document.getElementById('scale-indicator').innerHTML = scaleText; | |
} | |
function setupEventListeners() { | |
document.querySelectorAll('.terrain-btn').forEach(btn=>{ | |
btn.addEventListener('click', function(){ selectTerrain(this.getAttribute('data-terrain'), this); }); | |
}); | |
document.getElementById('toggle-contours').addEventListener('click', function() { | |
showContours = !showContours; | |
this.classList.toggle('active'); | |
drawContours(); | |
}); | |
document.getElementById('toggle-roads').addEventListener('click', function() { | |
showRoads = !showRoads; | |
this.classList.toggle('active'); | |
drawTerrain(); | |
}); | |
document.getElementById('toggle-rivers').addEventListener('click', function() { | |
showRivers = !showRivers; | |
this.classList.toggle('active'); | |
drawTerrain(); | |
}); | |
document.getElementById('toggle-heatmap').addEventListener('click', function() { | |
showHeatmap = !showHeatmap; | |
this.classList.toggle('active'); | |
drawTerrain(); | |
}); | |
document.getElementById('regenerate-terrain').addEventListener('click', function() { | |
generateTerrain(); | |
drawTerrain(); | |
drawContours(); | |
}); | |
document.querySelectorAll('.side-btn').forEach(btn=>{ | |
btn.addEventListener('click', function(){ selectSide(this.getAttribute('data-side')); }); | |
}); | |
document.querySelectorAll('.country-btn').forEach(btn=>{ | |
btn.addEventListener('click', function(){ selectCountry(this.getAttribute('data-country')); }); | |
}); | |
document.getElementById('zoom-in').addEventListener('click', zoomIn); | |
document.getElementById('zoom-out').addEventListener('click', zoomOut); | |
document.getElementById('random-blue').addEventListener('click', () => randomRegimentDeployment('blue')); | |
document.getElementById('random-red').addEventListener('click', () => randomRegimentDeployment('red')); | |
document.getElementById('random-both').addEventListener('click', randomFullBattleSetup); | |
document.getElementById('analyze-coverage').addEventListener('click', analyzeCoverage); | |
document.getElementById('analyze-terrain').addEventListener('click', analyzeTerrain); | |
document.getElementById('run-simulation').addEventListener('click', runSimulation); | |
document.getElementById('clear-all').addEventListener('click', clearAll); | |
document.getElementById('generate-report').addEventListener('click', generateReport); | |
document.getElementById('start-battle').addEventListener('click', startBattle); | |
document.getElementById('pause-battle').addEventListener('click', pauseBattle); | |
canvas.addEventListener('click', handleMapClick); | |
canvas.addEventListener('mousemove', handleMouseMove); | |
canvas.addEventListener('mousedown', handleMouseDown); | |
canvas.addEventListener('mouseup', handleMouseUp); | |
canvas.addEventListener('wheel', handleWheel, { passive: false }); | |
canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // ģ°ķ“ė¦ ė©ė“ ė°©ģ§ | |
// Window resize handler | |
window.addEventListener('resize', () => { | |
setCanvasSizeWithDPR(); | |
drawTerrain(); | |
drawContours(); | |
drawMap(); | |
}); | |
} | |
function setupSpeedControl() { | |
const slider = document.getElementById('speed-slider'); | |
const speedDisplay = document.getElementById('speed-display'); | |
const currentSpeed = document.getElementById('current-speed'); | |
const speedMarks = document.querySelectorAll('.speed-mark'); | |
slider.addEventListener('input', function() { | |
battleSpeed = parseInt(this.value); | |
updateSpeedDisplay(); | |
}); | |
speedMarks.forEach(mark => { | |
mark.addEventListener('click', function() { | |
const speed = parseInt(this.getAttribute('data-speed')); | |
slider.value = speed; | |
battleSpeed = speed; | |
updateSpeedDisplay(); | |
}); | |
}); | |
function updateSpeedDisplay() { | |
speedDisplay.textContent = speedLabels[battleSpeed]; | |
currentSpeed.textContent = speedDescriptions[battleSpeed]; | |
speedMarks.forEach(mark => { | |
mark.classList.remove('active'); | |
if (parseInt(mark.getAttribute('data-speed')) === battleSpeed) { | |
mark.classList.add('active'); | |
} | |
}); | |
} | |
} | |
function formatTime(seconds) { | |
const hours = Math.floor(seconds / 3600); | |
const minutes = Math.floor((seconds % 3600) / 60); | |
const secs = Math.floor(seconds % 60); | |
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; | |
} | |
function updateTimeDisplay() { | |
if (battleActive && battleStartTime) { | |
const now = Date.now(); | |
const realDelta = (now - battleStartTime) / 1000; // seconds | |
realTimeElapsed = realDelta; | |
// Calculate battle time based on speed multiplier | |
battleTimeElapsed = realDelta * speedMultipliers[battleSpeed]; | |
document.getElementById('battle-time').textContent = formatTime(battleTimeElapsed); | |
document.getElementById('real-time').textContent = formatTime(realTimeElapsed); | |
} | |
} | |
// ===== Zoom Functions with Cursor-centered Zoom ===== | |
function zoomAt(cx, cy, factor) { | |
const prevZoom = zoomLevel; | |
const newZoom = Math.min(4, Math.max(0.5, zoomLevel * factor)); | |
if (newZoom === prevZoom) return; | |
const rect = canvas.getBoundingClientRect(); | |
const scaleX = mapWidth / rect.width; | |
const scaleY = mapHeight / rect.height; | |
// ķė©“ ģ¢ķ -> ė§µ ģ¢ķ | |
const x = ((cx - rect.left) * scaleX - mapWidth/2) / prevZoom + mapWidth/2 - offsetX; | |
const y = ((cy - rect.top) * scaleY - mapHeight/2) / prevZoom + mapHeight/2 - offsetY; | |
zoomLevel = newZoom; | |
// ģ»¤ģź° ź°ė¦¬ķ¤ė ģė ķ¬ģøķøź° ķė©“ģ ź³ ģ ėėė” ģ¤ķģ 볓ģ | |
const x2 = ((cx - rect.left) * scaleX - mapWidth/2) / zoomLevel + mapWidth/2 - offsetX; | |
const y2 = ((cy - rect.top) * scaleY - mapHeight/2) / zoomLevel + mapHeight/2 - offsetY; | |
offsetX += (x2 - x); | |
offsetY += (y2 - y); | |
updateZoomDisplay(); | |
drawMap(); | |
drawTerrain(); | |
drawContours(); | |
} | |
function zoomIn(e) { | |
const rect = canvas.getBoundingClientRect(); | |
const cx = e?.clientX ?? (rect.left + rect.width/2); | |
const cy = e?.clientY ?? (rect.top + rect.height/2); | |
zoomAt(cx, cy, 1.2); | |
} | |
function zoomOut(e) { | |
const rect = canvas.getBoundingClientRect(); | |
const cx = e?.clientX ?? (rect.left + rect.width/2); | |
const cy = e?.clientY ?? (rect.top + rect.height/2); | |
zoomAt(cx, cy, 1/1.2); | |
} | |
function updateZoomDisplay() { | |
document.getElementById('zoom-level').textContent = Math.round(zoomLevel * 100) + '%'; | |
} | |
function handleWheel(e) { | |
e.preventDefault(); | |
if (e.deltaY < 0) { | |
zoomIn(e); | |
} else { | |
zoomOut(e); | |
} | |
} | |
function handleMouseDown(e) { | |
// ģ¤ė²ķ¼, ģ°ė²ķ¼, ėė Shift+ģ¢ķ“ė¦ ķģ© | |
if (e.button === 1 || e.button === 2 || (e.button === 0 && e.shiftKey)) { | |
isDragging = true; | |
dragStartClientX = e.clientX; | |
dragStartClientY = e.clientY; | |
dragStartOffsetX = offsetX; | |
dragStartOffsetY = offsetY; | |
canvas.style.cursor = 'grabbing'; | |
e.preventDefault(); | |
} | |
} | |
function handleMouseUp() { | |
isDragging = false; | |
canvas.style.cursor = 'crosshair'; | |
} | |
// ===== Selection Functions ===== | |
function selectTerrain(type, element){ | |
selectedTerrain = type; | |
document.querySelectorAll('.terrain-btn').forEach(b=>b.classList.remove('active')); | |
if(element) element.classList.add('active'); | |
if(battleActive) { | |
pauseBattle(); | |
} | |
try { | |
generateTerrain(); | |
drawTerrain(); | |
drawContours(); | |
drawMap(); | |
} catch(error) { | |
console.error('Error generating terrain:', error); | |
alert('Error generating terrain. Please try again.'); | |
} | |
} | |
function selectSide(side){ | |
selectedSide = side; | |
document.querySelectorAll('.side-btn').forEach(b=>b.classList.remove('active')); | |
document.querySelector(`.side-btn[data-side="${side}"]`).classList.add('active'); | |
} | |
function selectCountry(country){ | |
selectedCountry = country; | |
document.querySelectorAll('.country-btn').forEach(b=>b.classList.remove('active')); | |
document.querySelector(`.country-btn[data-country="${country}"]`).classList.add('active'); | |
updateUnitsList(); | |
} | |
function updateUnitsList(){ | |
const unitsList = document.getElementById('units-list'); | |
const units = countryUnits[selectedCountry]; | |
const info = countryInfo[selectedCountry]; | |
let html = `<div class="unit-level-title" style="color:${info.color}; margin-bottom:10px;">${info.name} Order of Battle</div>`; | |
html += '<div class="unit-options">'; | |
for(const [key, unit] of Object.entries(units)){ | |
html += `<button class="unit-btn${key === 'infantry_company' ? ' active' : ''}" data-unit="${key}">${unit.name}</button>`; | |
} | |
html += '</div>'; | |
unitsList.innerHTML = html; | |
selectedUnitType = 'infantry_company'; | |
document.querySelectorAll('.unit-btn').forEach(btn=>{ | |
btn.addEventListener('click', function(){ selectUnit(this.getAttribute('data-unit'), this); }); | |
}); | |
} | |
function selectUnit(type, element){ | |
selectedUnitType = type; | |
document.querySelectorAll('.unit-btn').forEach(b=>b.classList.remove('active')); | |
if(element) element.classList.add('active'); | |
} | |
// ===== Map Drawing ===== | |
function drawMap(){ | |
if(!ctx || !mapWidth || !mapHeight) { | |
console.error('Canvas not ready for drawing'); | |
return; | |
} | |
ctx.clearRect(0, 0, mapWidth, mapHeight); | |
applyViewTransform(ctx); | |
ctx.strokeStyle = 'rgba(255,255,0,0.5)'; | |
ctx.lineWidth = 2; | |
ctx.setLineDash([15, 5]); | |
ctx.beginPath(); | |
ctx.moveTo(0, battlefieldDivider); | |
ctx.lineTo(mapWidth, battlefieldDivider); | |
ctx.stroke(); | |
ctx.setLineDash([]); | |
units.forEach(u => { | |
drawUnit(u); | |
if(u.selected) drawUnitRange(u); | |
}); | |
restoreViewTransform(ctx); | |
} | |
function drawUnit(unit){ | |
if(unit.health<=0) return; | |
const spec = countryUnits[unit.country][unit.type]; | |
const unitColor = unit.side === 'blue' ? '#2196F3' : '#f44336'; | |
ctx.save(); | |
ctx.globalAlpha = Math.max(0.3, unit.health/100); | |
ctx.font = `bold ${spec.size==='battalion'?'20px':spec.size==='company'?'16px':'14px'} Arial`; | |
ctx.textAlign='center'; ctx.textBaseline='middle'; | |
ctx.fillStyle = unit.isRetreating? '#888' : unitColor; | |
ctx.fillText(spec.symbol, unit.x, unit.y); | |
ctx.font='8px Arial'; | |
ctx.fillStyle='rgba(255,255,255,0.7)'; | |
const countryEmoji = {roka:'š°š·',kpa:'š°šµ',usa:'šŗšø',russia:'š·šŗ',ukraine:'šŗš¦',china:'šØš³'}; | |
ctx.fillText(countryEmoji[unit.country], unit.x, unit.y-20); | |
ctx.font='10px Arial'; ctx.fillStyle='rgba(255,255,255,0.85)'; | |
ctx.fillText(spec.name, unit.x, unit.y+15); | |
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(unit.x-20, unit.y-25, 40, 4); | |
const hpColor = unit.health>60? '#4CAF50' : unit.health>30? '#FFC107' : '#F44336'; | |
ctx.fillStyle=hpColor; ctx.fillRect(unit.x-20, unit.y-25, 40*(unit.health/100), 4); | |
if(unit.inCombat && !unit.isRetreating){ ctx.strokeStyle='#FF5722'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(unit.x,unit.y,25,0,Math.PI*2); ctx.stroke(); } | |
if(unit.selected){ ctx.strokeStyle='#ffeb3b'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(unit.x,unit.y,20,0,Math.PI*2); ctx.stroke(); } | |
ctx.restore(); | |
} | |
function drawUnitRange(unit){ | |
const spec = countryUnits[unit.country][unit.type]; | |
const r = spec.range / METERS_PER_PIXEL; | |
ctx.strokeStyle = unit.side === 'blue' ? 'rgba(33,150,243,0.2)' : 'rgba(244,67,54,0.2)'; | |
ctx.lineWidth=1; ctx.setLineDash([5,5]); | |
ctx.beginPath(); ctx.arc(unit.x, unit.y, r, 0, Math.PI*2); ctx.stroke(); ctx.setLineDash([]); | |
} | |
// ===== FIXED: Input Handling with proper coordinate transformation ===== | |
function handleMapClick(e){ | |
if (isDragging) return; | |
const rect = canvas.getBoundingClientRect(); | |
const scaleX = mapWidth / rect.width; | |
const scaleY = mapHeight / rect.height; | |
const x = ((e.clientX - rect.left) * scaleX - mapWidth/2) / zoomLevel + mapWidth/2 - offsetX; | |
const y = ((e.clientY - rect.top) * scaleY - mapHeight/2) / zoomLevel + mapHeight/2 - offsetY; | |
console.log(`Click at: ${x}, ${y}, Side: ${selectedSide}, Divider: ${battlefieldDivider}`); | |
let clicked = null; | |
units.forEach(u => { | |
const d = Math.hypot(u.x - x, u.y - y); | |
if(d < 20) clicked = u; | |
u.selected = false; | |
}); | |
if(clicked){ | |
clicked.selected = true; | |
updateUnitInfo(clicked); | |
console.log('Selected unit:', clicked); | |
} else { | |
if(selectedSide === 'red' && y > battlefieldDivider){ | |
alert('ā ļø Red Team units can only be deployed in the northern sector!'); | |
return; | |
} | |
if(selectedSide === 'blue' && y < battlefieldDivider){ | |
alert('ā ļø Blue Team units can only be deployed in the southern sector!'); | |
return; | |
} | |
const height = getHeightAt(x, y); | |
if(height < 0.15){ | |
alert('ā ļø Cannot deploy units on water!'); | |
return; | |
} | |
console.log('Placing unit at:', x, y); | |
placeUnit(x, y); | |
} | |
drawMap(); | |
} | |
function placeUnit(x, y){ | |
const newUnit = { | |
id: Date.now(), | |
type: selectedUnitType, | |
country: selectedCountry, | |
side: selectedSide, | |
x: x, | |
y: y, | |
targetX: x, | |
targetY: y, | |
selected: false, | |
health: 100, | |
morale: 100, | |
ammo: 100, | |
isRetreating: false, | |
inCombat: false, | |
lastFired: 0, | |
kills: 0, | |
currentTarget: null, | |
mission: selectedSide === 'blue' ? blueMission : redMission | |
}; | |
units.push(newUnit); | |
console.log('Unit placed:', newUnit); | |
updateStats(); | |
drawMap(); | |
} | |
// ===== FIXED: Mouse Move with drag redraw ===== | |
function handleMouseMove(e){ | |
const rect = canvas.getBoundingClientRect(); | |
const scaleX = mapWidth / rect.width; | |
const scaleY = mapHeight / rect.height; | |
if (isDragging) { | |
// ģ¤ģ ź³ ė ¤ķ“ ģ“ėė ģ¶ģ | |
const dx = (e.clientX - dragStartClientX) * scaleX / zoomLevel; | |
const dy = (e.clientY - dragStartClientY) * scaleY / zoomLevel; | |
offsetX = dragStartOffsetX + dx; | |
offsetY = dragStartOffsetY + dy; | |
drawMap(); | |
drawTerrain(); | |
drawContours(); | |
return; | |
} | |
const x = ((e.clientX - rect.left) * scaleX - mapWidth/2) / zoomLevel + mapWidth/2 - offsetX; | |
const y = ((e.clientY - rect.top) * scaleY - mapHeight/2) / zoomLevel + mapHeight/2 - offsetY; | |
const realX = Math.round(x * METERS_PER_PIXEL / 1000); | |
const realY = Math.round(y * METERS_PER_PIXEL / 1000); | |
const height = getHeightAt(x, y); | |
const elevation = Math.round(height * 1000); | |
const slope = getSlope(x, y); | |
document.getElementById('mouse-x').textContent = Math.round(x); | |
document.getElementById('mouse-y').textContent = Math.round(y); | |
document.getElementById('real-coords').textContent = `${realX},${realY}`; | |
document.getElementById('elevation').textContent = elevation; | |
document.getElementById('slope').textContent = slope; | |
let terrainType = 'Plains'; | |
if (height < 0.2) terrainType = 'Water'; | |
else if (height < 0.35) terrainType = 'Lowland'; | |
else if (height < 0.55) terrainType = 'Hills'; | |
else if (height < 0.75) terrainType = 'Highland'; | |
else terrainType = 'Mountain'; | |
document.getElementById('terrain-type').textContent = terrainType; | |
} | |
function updateUnitInfo(unit){ | |
const spec = countryUnits[unit.country][unit.type]; | |
const info = countryInfo[unit.country]; | |
const elevation = Math.round(getHeightAt(unit.x, unit.y) * 1000); | |
document.getElementById('unit-name').textContent = spec.name; | |
document.getElementById('unit-country').textContent = info.name; | |
document.getElementById('unit-size').textContent = spec.size==='battalion'?'Battalion':spec.size==='company'?'Company':spec.size==='platoon'?'Platoon':'Squad'; | |
document.getElementById('unit-strength').textContent = spec.strength+' troops'; | |
document.getElementById('unit-weapons').innerHTML = spec.weapons.join('<br>'); | |
document.getElementById('unit-range').textContent = spec.range+'m'; | |
document.getElementById('unit-position').textContent = `(${Math.round(unit.x)}, ${Math.round(unit.y)})`; | |
document.getElementById('unit-elevation').textContent = elevation+'m'; | |
} | |
function updateStats(){ | |
const blueCount = units.filter(u=>u.side==='blue').length; | |
const redCount = units.filter(u=>u.side==='red').length; | |
document.getElementById('blue-units').textContent = blueCount; | |
document.getElementById('red-units').textContent = redCount; | |
let blueF=0, redF=0; | |
units.forEach(u=>{ | |
const spec = countryUnits[u.country][u.type]; | |
if(u.side==='blue') blueF += spec.firepower; | |
else redF += spec.firepower; | |
}); | |
const total = blueF+redF; | |
const ratio = total>0? Math.round((blueF/total)*100):0; | |
document.getElementById('firepower-ratio').textContent = ratio+'%'; | |
} | |
// ===== Random Deployment Functions (Fixed rendering issue) ===== | |
function randomRegimentDeployment(side) { | |
const existingUnits = units.filter(u => u.side === side); | |
if (existingUnits.length > 0) { | |
if (!confirm(`Remove existing ${side} units and deploy new regiment?`)) return; | |
units = units.filter(u => u.side !== side); | |
} | |
const country = side === 'blue' ? | |
['roka', 'usa', 'ukraine'][Math.floor(Math.random() * 3)] : | |
['kpa', 'russia', 'china'][Math.floor(Math.random() * 3)]; | |
const deployment = []; | |
deployment.push({ type: 'hq', x: mapWidth/2, y: side === 'blue' ? mapHeight * 0.85 : mapHeight * 0.15 }); | |
for (let b = 0; b < 3; b++) { | |
const bx = (mapWidth / 4) * (b + 1); | |
const by = side === 'blue' ? mapHeight * 0.7 : mapHeight * 0.3; | |
for (let c = 0; c < 3; c++) { | |
const cx = bx + (Math.random() - 0.5) * 100; | |
const cy = by + (side === 'blue' ? 1 : -1) * c * 40; | |
deployment.push({ type: 'infantry_company', x: cx, y: cy }); | |
} | |
} | |
// Fixed: Use correct unit types for each country | |
const mortarType = country === 'roka' ? 'mortar_81mm' : | |
country === 'kpa' ? 'mortar_82mm' : | |
country === 'usa' ? 'mortar_120mm' : | |
country === 'russia' ? 'mortar_120mm' : | |
country === 'ukraine' ? 'mortar_120mm' : | |
'mortar_120mm'; | |
// Fixed: Country-specific AT types | |
const atTypeMap = { | |
roka: 'antitank_platoon', | |
kpa: 'antitank_platoon', | |
usa: 'javelin_team', | |
russia: 'kornet_team', | |
ukraine: 'stugna_team', | |
china: 'hj12_team' | |
}; | |
const atType = atTypeMap[country]; | |
deployment.push({ type: mortarType, x: mapWidth * 0.2, y: side === 'blue' ? mapHeight * 0.8 : mapHeight * 0.2 }); | |
deployment.push({ type: atType, x: mapWidth * 0.8, y: side === 'blue' ? mapHeight * 0.8 : mapHeight * 0.2 }); | |
// Deploy all units at once and then redraw | |
deployment.forEach((d, idx) => { | |
units.push({ | |
id: Date.now() + idx, | |
type: d.type, | |
country: country, | |
side: side, | |
x: d.x, | |
y: d.y, | |
targetX: d.x, | |
targetY: d.y, | |
selected: false, | |
health: 100, | |
morale: 100, | |
ammo: 100, | |
isRetreating: false, | |
inCombat: false, | |
lastFired: 0, | |
kills: 0, | |
currentTarget: null, | |
mission: side === 'blue' ? blueMission : redMission | |
}); | |
}); | |
// Update and redraw everything | |
updateStats(); | |
drawMap(); | |
console.log(`Deployed ${deployment.length} ${side} units`); | |
} | |
function randomFullBattleSetup() { | |
if (units.length > 0) { | |
if (!confirm('Clear all units and deploy full battle setup?')) return; | |
units = []; | |
} | |
// Deploy both teams | |
randomRegimentDeployment('red'); | |
randomRegimentDeployment('blue'); | |
// Force redraw after both deployments | |
setTimeout(() => { | |
drawMap(); | |
updateStats(); | |
}, 100); | |
} | |
function analyzeCoverage(){ | |
// 먼ģ ė§µģ ķ“리ģ“ķź³ źø°ė³ø ė ģ“ģ“ ė¤ģ 그림 | |
drawMap(); | |
let covered=0, total=0; | |
for(let x=0;x<mapWidth;x+=20){ | |
for(let y=battlefieldDivider;y<mapHeight;y+=20){ | |
total++; | |
let ok=false; | |
for (const u of units){ | |
if(u.side!=='blue') continue; | |
const spec = countryUnits[u.country][u.type]; | |
const d = Math.hypot(u.x-x, u.y-y); | |
if (d <= spec.range / METERS_PER_PIXEL) { | |
ok=true; | |
break; | |
} | |
} | |
if(ok) covered++; | |
} | |
} | |
const pct = total>0? Math.round((covered/total)*100):0; | |
document.getElementById('coverage').textContent = pct+'%'; | |
ctx.save(); | |
ctx.globalAlpha=0.15; | |
for (const u of units){ | |
if(u.side==='blue'){ | |
const spec = countryUnits[u.country][u.type]; | |
const r = spec.range/METERS_PER_PIXEL; | |
const g = ctx.createRadialGradient(u.x,u.y,0,u.x,u.y,r); | |
g.addColorStop(0,'rgba(33,150,243,0.5)'); | |
g.addColorStop(1,'rgba(33,150,243,0)'); | |
ctx.fillStyle=g; | |
ctx.beginPath(); | |
ctx.arc(u.x,u.y,r,0,Math.PI*2); | |
ctx.fill(); | |
} | |
} | |
ctx.restore(); | |
} | |
function analyzeTerrain(){ | |
const avgHeight = heightMap.flat().reduce((a,b)=>a+b,0) / (heightMap.length * heightMap[0].length); | |
const maxHeight = Math.max(...heightMap.flat()); | |
const minHeight = Math.min(...heightMap.flat()); | |
alert(`Terrain Analysis\n\nTerrain Type: ${selectedTerrain}\nAverage Elevation: ${Math.round(avgHeight*1000)}m\nMax Elevation: ${Math.round(maxHeight*1000)}m\nMin Elevation: ${Math.round(minHeight*1000)}m\n\nTactical Considerations:\n⢠High ground provides range advantage\n⢠Rivers create natural barriers\n⢠Roads enable rapid movement\n⢠Urban areas provide cover`); | |
} | |
function runSimulation(){ | |
const blueForces = units.filter(u=>u.side==='blue'); | |
const redForces = units.filter(u=>u.side==='red'); | |
if(blueForces.length===0 || redForces.length===0){ alert('Both sides must have units deployed for simulation!'); return; } | |
let blueStrength=0, redStrength=0, blueFire=0, redFire=0; | |
blueForces.forEach(u=>{ | |
const s = countryUnits[u.country][u.type]; | |
const height = getHeightAt(u.x, u.y); | |
const heightBonus = height > 0.6 ? 1.2 : 1.0; | |
blueStrength += s.strength; | |
blueFire += s.firepower * ((u.health??100)/100) * heightBonus; | |
}); | |
redForces.forEach(u=>{ | |
const s = countryUnits[u.country][u.type]; | |
const height = getHeightAt(u.x, u.y); | |
const heightBonus = height > 0.6 ? 1.2 : 1.0; | |
redStrength += s.strength; | |
redFire += s.firepower * ((u.health??100)/100) * heightBonus; | |
}); | |
if (blueMission === 'defend' && redMission === 'attack') { | |
blueFire *= 3.0; | |
} else if (redMission === 'defend' && blueMission === 'attack') { | |
redFire *= 3.0; | |
} | |
if ((blueMission === 'defend' || redMission === 'defend') && | |
(selectedTerrain === 'urban' || selectedTerrain === 'mountain')) { | |
if (blueMission === 'defend') blueFire *= 1.2; | |
if (redMission === 'defend') redFire *= 1.2; | |
} | |
const totalFp = blueFire + redFire; | |
const winProb = totalFp>0? (blueFire/totalFp*100):50; | |
let advice=''; | |
if(winProb>70) advice='ā Overwhelming Superiority - Full Attack Viable'; | |
else if(winProb>60) advice='ā Offensive Operations Favorable'; | |
else if(winProb>40) advice='ā ļø Cautious Approach Required'; | |
else advice='šØ Critical Situation - Consider withdrawal'; | |
const missionStatus = `\n[Mission Type]\n⢠Blue Team: ${blueMission.toUpperCase()}\n⢠Red Team: ${redMission.toUpperCase()}\n`; | |
const lanchesterNote = blueMission === 'defend' || redMission === 'defend' ? | |
'\n[Lanchester\'s Law Applied]\n⢠Defender has 3:1 combat advantage\n' : ''; | |
alert(`šÆ Combat Simulation Results\n\n[Force Analysis]\n⢠Blue Firepower: ${Math.round(blueFire)}\n⢠Red Firepower: ${Math.round(redFire)}\n⢠Terrain: ${selectedTerrain}${missionStatus}${lanchesterNote}\n[Battle Prediction]\n⢠Blue Victory Probability: ${winProb.toFixed(1)}%\n\n[Tactical Recommendation]\n${advice}`); | |
} | |
function generateReport(){ | |
const blueForces = units.filter(u=>u.side==='blue'); | |
const redForces = units.filter(u=>u.side==='red'); | |
let report = 'āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n'; | |
report += ' TACTICAL OPERATION REPORT\n'; | |
report += 'āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n\n'; | |
report += `Report Time: ${new Date().toLocaleString('en-US')}\n`; | |
report += `Terrain Type: ${selectedTerrain}\n`; | |
report += `Map Scale: 1:${SCALE}\n`; | |
report += `Operation Area: 20km Ć 15km\n\n`; | |
report += `ā¶ Blue Forces: ${blueForces.length} units\n`; | |
report += `ā¶ Red Forces: ${redForces.length} units\n\n`; | |
if(blueCasualties>0 || redCasualties>0){ | |
report += `[Combat Losses]\n`; | |
report += `⢠Blue Team: ${blueCasualties} casualties\n`; | |
report += `⢠Red Team: ${redCasualties} casualties\n\n`; | |
} | |
if(battleTimeElapsed > 0){ | |
report += `[Battle Duration]\n`; | |
report += `⢠Battle Time: ${formatTime(battleTimeElapsed)}\n`; | |
report += `⢠Real Time: ${formatTime(realTimeElapsed)}\n`; | |
} | |
alert(report); | |
} | |
function clearAll(){ | |
if(!confirm('Clear all units and reset?')) return; | |
units=[]; | |
battleActive=false; | |
// Stop battle sound | |
battleSound.pause(); | |
battleSound.currentTime = 0; | |
if(battleInterval) clearInterval(battleInterval); | |
if(battleAnimationFrame) cancelAnimationFrame(battleAnimationFrame); | |
blueCasualties=0; | |
redCasualties=0; | |
explosions=[]; | |
bulletTrails=[]; | |
battleTimeElapsed=0; | |
realTimeElapsed=0; | |
battleStartTime = 0; | |
document.getElementById('battle-status').textContent='Standby'; | |
document.getElementById('blue-casualties').textContent='0'; | |
document.getElementById('red-casualties').textContent='0'; | |
document.getElementById('battle-time').textContent='00:00:00'; | |
document.getElementById('real-time').textContent='00:00:00'; | |
document.getElementById('start-battle').style.display='block'; | |
document.getElementById('start-battle').textContent='āļø START ENGAGEMENT'; | |
document.getElementById('pause-battle').style.display='none'; | |
drawMap(); | |
updateStats(); | |
document.getElementById('coverage').textContent='0%'; | |
} | |
// ===== Battle System with Realistic Movement and Sound ===== | |
const battleSound = new Audio('war.mp3'); | |
battleSound.loop = true; | |
battleSound.volume = 0.7; | |
function startBattle(){ | |
const blueForces = units.filter(u=>u.side==='blue' && u.health>0); | |
const redForces = units.filter(u=>u.side==='red' && u.health>0); | |
if(blueForces.length===0 || redForces.length===0){ | |
alert('Both sides must have units to start battle!'); | |
return; | |
} | |
battleActive = true; | |
battleStartTime = Date.now(); | |
battleTimeElapsed = 0; | |
realTimeElapsed = 0; | |
// Start battle sound | |
battleSound.currentTime = 0; | |
battleSound.play().catch(e => console.log('Audio play failed:', e)); | |
console.log('Battle started at:', battleStartTime); | |
document.getElementById('battle-status').textContent = 'š„ ENGAGED'; | |
document.getElementById('start-battle').style.display = 'none'; | |
document.getElementById('pause-battle').style.display = 'block'; | |
// Clear any existing intervals | |
if(battleInterval) { | |
clearInterval(battleInterval); | |
battleInterval = null; | |
} | |
if(battleAnimationFrame) { | |
cancelAnimationFrame(battleAnimationFrame); | |
battleAnimationFrame = null; | |
} | |
// Start the battle loop | |
startBattleLoop(); | |
} | |
function startBattleLoop() { | |
// Battle logic update every 100ms (10 times per second) | |
battleInterval = setInterval(() => { | |
if(battleActive) { | |
battleTick(); | |
updateTimeDisplay(); | |
} | |
}, TICK_INTERVAL); | |
// Start continuous animation | |
function animate() { | |
if(battleActive) { | |
// Clear canvas | |
ctx.clearRect(0, 0, mapWidth, mapHeight); | |
// Apply transform | |
ctx.save(); | |
ctx.translate(mapWidth/2, mapHeight/2); | |
ctx.scale(zoomLevel, zoomLevel); | |
ctx.translate(-mapWidth/2 + offsetX, -mapHeight/2 + offsetY); | |
// Draw battlefield divider | |
ctx.strokeStyle = 'rgba(255,255,0,0.5)'; | |
ctx.lineWidth = 2; | |
ctx.setLineDash([15, 5]); | |
ctx.beginPath(); | |
ctx.moveTo(0, battlefieldDivider); | |
ctx.lineTo(mapWidth, battlefieldDivider); | |
ctx.stroke(); | |
ctx.setLineDash([]); | |
// Draw all units | |
units.forEach(u => { | |
if(u.health > 0) { | |
drawUnit(u); | |
if(u.selected) drawUnitRange(u); | |
} | |
}); | |
// Draw battle effects | |
explosions.forEach(exp => { | |
ctx.save(); | |
ctx.globalAlpha = exp.opacity; | |
ctx.fillStyle = exp.color; | |
ctx.beginPath(); | |
ctx.arc(exp.x, exp.y, exp.radius, 0, Math.PI * 2); | |
ctx.fill(); | |
ctx.restore(); | |
}); | |
bulletTrails.forEach(b => { | |
ctx.save(); | |
ctx.strokeStyle = b.color; | |
ctx.lineWidth = 2; | |
ctx.globalAlpha = 1 - b.progress; | |
const cx = b.from.x + (b.to.x - b.from.x) * b.progress; | |
const cy = b.from.y + (b.to.y - b.from.y) * b.progress; | |
ctx.beginPath(); | |
ctx.moveTo(b.from.x, b.from.y); | |
ctx.lineTo(cx, cy); | |
ctx.stroke(); | |
ctx.restore(); | |
}); | |
ctx.restore(); | |
battleAnimationFrame = requestAnimationFrame(animate); | |
} | |
} | |
animate(); | |
} | |
function pauseBattle(){ | |
battleActive = false; | |
// Pause battle sound | |
battleSound.pause(); | |
if(battleInterval) { | |
clearInterval(battleInterval); | |
battleInterval = null; | |
} | |
if(battleAnimationFrame) { | |
cancelAnimationFrame(battleAnimationFrame); | |
battleAnimationFrame = null; | |
} | |
document.getElementById('battle-status').textContent = 'āøļø PAUSED'; | |
document.getElementById('start-battle').style.display = 'block'; | |
document.getElementById('start-battle').textContent = 'ā¶ļø RESUME BATTLE'; | |
document.getElementById('pause-battle').style.display = 'none'; | |
// Redraw static map | |
drawMap(); | |
} | |
function battleTick(){ | |
if(!battleActive) return; | |
const res = checkVictoryConditions(); | |
if(res){ | |
endBattle(res); | |
return; | |
} | |
// Move and update all units | |
units.forEach(u => { | |
if(u.health <= 0) return; | |
// AI decision making | |
makeAIDecision(u); | |
// Move the unit with realistic speed | |
moveUnitRealistic(u); | |
// Combat | |
if(u.inCombat && !u.isRetreating) { | |
engageEnemy(u); | |
} | |
// Recover morale | |
if(!u.inCombat && u.morale < 100) { | |
u.morale = Math.min(100, u.morale + 0.5); | |
} | |
}); | |
// Update visual effects | |
explosions = explosions.filter(e => { | |
e.radius += 2; | |
e.opacity -= 0.05; | |
return e.opacity > 0; | |
}); | |
bulletTrails = bulletTrails.filter(b => { | |
b.progress += 0.1; | |
return b.progress < 1; | |
}); | |
} | |
function makeAIDecision(unit){ | |
const spec = countryUnits[unit.country][unit.type]; | |
const info = findNearestEnemy(unit); | |
if(!info) return; | |
const {target, dist} = info; | |
const rangePx = spec.range / METERS_PER_PIXEL; | |
const unitHeight = getHeightAt(unit.x, unit.y); | |
const targetHeight = getHeightAt(target.x, target.y); | |
const heightAdvantage = unitHeight > targetHeight; | |
// HQ units stay back | |
if (unit.type === 'hq') { | |
if (unit.side === 'blue' && unit.y < battlefieldDivider + 100) { | |
unit.targetY = Math.max(battlefieldDivider + 100, unit.y); | |
} else if (unit.side === 'red' && unit.y > battlefieldDivider - 100) { | |
unit.targetY = Math.min(battlefieldDivider - 100, unit.y); | |
} | |
} | |
// Retreat if low health | |
if(unit.health < 30 || unit.morale < 20){ | |
unit.isRetreating = true; | |
unit.inCombat = false; | |
unit.currentTarget = null; | |
unit.targetX = unit.x + (Math.random() - 0.5) * 50; | |
unit.targetY = (unit.side === 'blue') ? (mapHeight - 50) : 50; | |
return; | |
} else { | |
unit.isRetreating = false; | |
} | |
const effectiveRange = rangePx * (heightAdvantage ? 1.2 : 1.0); | |
// In range - engage | |
if(dist <= effectiveRange){ | |
unit.inCombat = true; | |
unit.currentTarget = target; | |
if (unit.mission === 'defend') { | |
// Defenders hold position | |
unit.targetX = unit.x; | |
unit.targetY = unit.y; | |
} | |
else if (unit.mission === 'attack') { | |
// Attackers try to maintain optimal range but move more cautiously | |
const angle = Math.atan2(target.y - unit.y, target.x - unit.x); | |
const optimal = effectiveRange * 0.8; | |
if (dist > optimal) { | |
// Move forward cautiously - reduced from 20 to 5 pixels | |
unit.targetX = unit.x + Math.cos(angle) * 5; | |
unit.targetY = unit.y + Math.sin(angle) * 5; | |
} else { | |
// Small tactical movements | |
unit.targetX = unit.x + (Math.random() - 0.5) * 2; | |
unit.targetY = unit.y + (Math.random() - 0.5) * 2; | |
} | |
} | |
return; | |
} | |
// Out of range - move toward enemy | |
unit.inCombat = false; | |
unit.currentTarget = null; | |
if (unit.mission === 'attack') { | |
const angle = Math.atan2(target.y - unit.y, target.x - unit.x); | |
// Reduced advance distance from 100 to 20 pixels | |
const advanceDistance = Math.min(20, dist - effectiveRange * 0.8); | |
unit.targetX = unit.x + Math.cos(angle) * advanceDistance; | |
unit.targetY = unit.y + Math.sin(angle) * advanceDistance; | |
// Don't let HQ cross divider | |
if (unit.type === 'hq') { | |
if (unit.side === 'blue') { | |
unit.targetY = Math.min(battlefieldDivider - 50, unit.targetY); | |
} else { | |
unit.targetY = Math.max(battlefieldDivider + 50, unit.targetY); | |
} | |
} | |
} else if (unit.mission === 'defend') { | |
// Defenders make very small adjustments | |
if (dist < effectiveRange * 1.5) { | |
unit.targetX = unit.x + (Math.random() - 0.5) * 5; | |
unit.targetY = unit.y + (Math.random() - 0.5) * 5; | |
} | |
} | |
} | |
// New realistic movement function with road and village bonuses | |
function moveUnitRealistic(unit) { | |
const dx = unit.targetX - unit.x; | |
const dy = unit.targetY - unit.y; | |
const dist = Math.hypot(dx, dy); | |
if(dist < 0.5) { | |
unit.x = unit.targetX; | |
unit.y = unit.targetY; | |
return; | |
} | |
// Get unit specification for speed | |
const spec = countryUnits[unit.country][unit.type]; | |
// Base speed in meters per minute (realistic military speeds) | |
let speedMPM = spec.speed || 100; // Default 100m/min for infantry | |
// Convert to pixels per tick | |
const gameMinutesPerTick = 0.1 * speedMultipliers[battleSpeed] / 60; | |
const metersPerTick = speedMPM * gameMinutesPerTick; | |
let pixelsPerTick = metersPerTick / METERS_PER_PIXEL; | |
// Check if unit is on or near a road (massive speed boost) | |
const gridSize = heightMap.length; | |
const cellWidth = mapWidth / gridSize; | |
const cellHeight = mapHeight / gridSize; | |
const gridX = Math.floor(unit.x / cellWidth); | |
const gridY = Math.floor(unit.y / cellHeight); | |
let onRoad = false; | |
for (let road of roads) { | |
for (let point of road) { | |
const roadDist = Math.sqrt(Math.pow(gridX - point.x, 2) + Math.pow(gridY - point.y, 2)); | |
if (roadDist < 2) { // Within 2 grid cells of road | |
onRoad = true; | |
break; | |
} | |
} | |
if (onRoad) break; | |
} | |
if (onRoad) { | |
// Roads provide 2.5x speed for vehicles, 1.5x for infantry | |
if (spec.symbol === 'ā ' || spec.symbol === 'ā£' || spec.symbol === 'ā¢' || | |
spec.symbol === '⬢' || spec.symbol === 'ā') { | |
pixelsPerTick *= 2.5; // Vehicles move much faster on roads | |
} else { | |
pixelsPerTick *= 1.5; // Infantry also benefits from roads | |
} | |
} | |
// Terrain effects | |
const height = getHeightAt(unit.x, unit.y); | |
const slope = getSlope(unit.x, unit.y); | |
let terrainMultiplier = 1.0; | |
// Slope penalties (reduced when on road) | |
if (!onRoad) { | |
if (slope > 30) { | |
terrainMultiplier *= 0.3; | |
} else if (slope > 15) { | |
terrainMultiplier *= 0.5; | |
} else if (slope > 5) { | |
terrainMultiplier *= 0.8; | |
} | |
} else { | |
// Roads reduce slope penalties | |
if (slope > 30) { | |
terrainMultiplier *= 0.6; | |
} else if (slope > 15) { | |
terrainMultiplier *= 0.8; | |
} | |
} | |
// Check if in a settlement (slows movement for attackers) | |
let inSettlement = false; | |
for (let settlement of settlements) { | |
const settlementX = settlement.x * cellWidth; | |
const settlementY = settlement.y * cellHeight; | |
const settlementDist = Math.hypot(unit.x - settlementX, unit.y - settlementY); | |
if (settlementDist < settlement.size * 2) { | |
inSettlement = true; | |
// Defenders move normally in settlements, attackers are slowed | |
const unitMission = unit.side === 'blue' ? blueMission : redMission; | |
if (unitMission === 'attack') { | |
terrainMultiplier *= 0.5; // Attackers move slowly through urban areas | |
} | |
break; | |
} | |
} | |
// Terrain type penalties (modified) | |
if (!onRoad && !inSettlement) { | |
if (selectedTerrain === 'urban') { | |
terrainMultiplier *= 0.7; | |
} else if (selectedTerrain === 'mountain') { | |
terrainMultiplier *= 0.5; | |
} | |
} | |
// Water is very slow for ground units | |
if (height < 0.2) { | |
// Air units not affected by water | |
if (!spec.symbol.includes('ā') && !unit.type.includes('heli') && !unit.type.includes('drone')) { | |
terrainMultiplier *= 0.1; | |
} | |
} | |
// Apply status modifiers | |
if (unit.isRetreating) { | |
terrainMultiplier *= 1.3; | |
} | |
if (unit.health < 50) { | |
terrainMultiplier *= 0.8; | |
} | |
// Calculate final speed | |
let finalSpeed = pixelsPerTick * terrainMultiplier; | |
// Ensure minimum movement | |
finalSpeed = Math.max(0.1, finalSpeed); | |
// Move the unit | |
if (dist > finalSpeed) { | |
unit.x += (dx / dist) * finalSpeed; | |
unit.y += (dy / dist) * finalSpeed; | |
} else { | |
unit.x = unit.targetX; | |
unit.y = unit.targetY; | |
} | |
} | |
function findNearestEnemy(unit){ | |
const enemies = units.filter(u=>u.side!==unit.side && u.health>0); | |
if(enemies.length===0) return null; | |
let best=null, bestD=Infinity; | |
enemies.forEach(e=>{ | |
const d = Math.hypot(unit.x-e.x, unit.y-e.y); | |
if(d<bestD){ bestD=d; best=e; } | |
}); | |
return { target:best, dist:bestD }; | |
} | |
function engageEnemy(unit){ | |
if(!unit.currentTarget || unit.currentTarget.health<=0){ | |
const info = findNearestEnemy(unit); | |
if(!info) { unit.inCombat=false; return; } | |
unit.currentTarget = info.target; | |
} | |
const spec = countryUnits[unit.country][unit.type]; | |
const targetDist = Math.hypot(unit.x-unit.currentTarget.x, unit.y-unit.currentTarget.y); | |
const maxRange = spec.range / METERS_PER_PIXEL; | |
if (targetDist > maxRange) { | |
unit.inCombat = false; | |
unit.currentTarget = null; | |
return; | |
} | |
const now = Date.now(); | |
// ė°ģ¬ ź°ź²©(ķė „ģ ė°ė¼) | |
let fireRate = 3000 / (spec.firepower / 50); | |
if(now - unit.lastFired < fireRate) return; | |
unit.lastFired = now; | |
const rangePercentage = targetDist / maxRange; | |
// ėŖ ģ¤ė„ ź³ģ° | |
let accuracy = 0.9 - (rangePercentage * 0.4); | |
const unitHeight = getHeightAt(unit.x, unit.y); | |
const targetHeight = getHeightAt(unit.currentTarget.x, unit.currentTarget.y); | |
if (unitHeight > targetHeight) { | |
accuracy *= 1.2; | |
} | |
const unitMission = unit.side === 'blue' ? blueMission : redMission; | |
const targetMission = unit.currentTarget.side === 'blue' ? blueMission : redMission; | |
let combatMultiplier = 1.0; | |
if (unitMission === 'defend' && targetMission === 'attack') { | |
combatMultiplier = 3.0; // ė°©ģ“ģ ģ°ģ (3:1) | |
accuracy *= 1.3; | |
} else if (unitMission === 'attack' && targetMission === 'defend') { | |
combatMultiplier = 0.33; | |
accuracy *= 0.7; | |
} | |
// ģ ģ°©ģ§(ėģ/ė§ģ) ė°©ģ“ ė³“ėģ¤ | |
const gridSize = heightMap.length; | |
const cellWidth = mapWidth / gridSize; | |
const cellHeight = mapHeight / gridSize; | |
let targetInSettlement = false; | |
for (let settlement of settlements) { | |
const settlementX = settlement.x * cellWidth; | |
const settlementY = settlement.y * cellHeight; | |
const settlementDist = Math.hypot(unit.currentTarget.x - settlementX, unit.currentTarget.y - settlementY); | |
if (settlementDist < settlement.size * 2) { | |
targetInSettlement = true; | |
if (targetMission === 'defend') { | |
combatMultiplier *= 0.3; // ķ° ķ¼ķ“ ź²½ź° | |
accuracy *= 0.6; // ėŖ ģ¤ė„ ķė½ | |
if (settlement.type === 'city') { | |
combatMultiplier *= 0.5; | |
accuracy *= 0.7; | |
} else if (settlement.type === 'town') { | |
combatMultiplier *= 0.7; | |
accuracy *= 0.8; | |
} | |
} | |
break; | |
} | |
} | |
if (targetMission === 'defend' && !targetInSettlement) { | |
if (selectedTerrain === 'urban' || selectedTerrain === 'mountain') { | |
combatMultiplier *= 0.8; | |
if (unitMission === 'defend') { | |
accuracy *= 1.1; | |
} | |
} | |
} | |
accuracy = Math.max(0.1, Math.min(0.95, accuracy)); | |
// ģź° ķØź³¼(ķė ź¶¤ģ ) | |
let trailColor = (unit.side==='blue'?'#2196F3':'#f44336'); | |
bulletTrails.push({ | |
from:{x:unit.x, y:unit.y}, | |
to:{x:unit.currentTarget.x, y:unit.currentTarget.y}, | |
color:trailColor, | |
progress:0 | |
}); | |
if(Math.random() < accuracy){ | |
let damage = (spec.firepower/10) * (Math.random()*0.5 + 0.75); | |
damage *= combatMultiplier; | |
damage *= (1 - rangePercentage * 0.3); | |
unit.currentTarget.health -= damage; | |
unit.currentTarget.morale -= damage/2; | |
explosions.push({ | |
x:unit.currentTarget.x, | |
y:unit.currentTarget.y, | |
radius:10, | |
color:(unit.side==='blue'?'#2196F3':'#f44336'), | |
opacity:0.8 | |
}); | |
if(unit.currentTarget.health<=0){ | |
const targetSpec = countryUnits[unit.currentTarget.country][unit.currentTarget.type]; | |
const casualties = targetSpec.strength; | |
if(unit.currentTarget.side==='blue'){ | |
blueCasualties += casualties; | |
document.getElementById('blue-casualties').textContent = blueCasualties; | |
}else{ | |
redCasualties += casualties; | |
document.getElementById('red-casualties').textContent = redCasualties; | |
} | |
unit.kills++; | |
unit.currentTarget.inCombat=false; | |
// HQ 격ķ ģ ģźµ° ģ¬źø° ģ ķ | |
if (unit.currentTarget.type === 'hq') { | |
units.filter(u => u.side === unit.currentTarget.side && u.health > 0) | |
.forEach(u => u.morale -= 30); | |
} | |
} | |
} | |
} | |
function checkVictoryConditions(){ | |
const blueAlive = units.filter(u=>u.side==='blue' && u.health>0); | |
const redAlive = units.filter(u=>u.side==='red' && u.health>0); | |
if(blueAlive.length===0 && redAlive.length===0) return 'draw'; | |
const blueHQ = units.find(u => u.side === 'blue' && u.type === 'hq'); | |
const redHQ = units.find(u => u.side === 'red' && u.type === 'hq'); | |
if (blueHQ && blueHQ.health <= 0) { | |
return 'red_victory_hq'; | |
} | |
if (redHQ && redHQ.health <= 0) { | |
return 'blue_victory_hq'; | |
} | |
if (blueHQ && blueHQ.health > 0) { | |
const enemiesNearby = units.filter(u => | |
u.side === 'red' && | |
u.health > 0 && | |
Math.hypot(u.x - blueHQ.x, u.y - blueHQ.y) < 150 | |
); | |
if (enemiesNearby.length >= 4) { | |
const angles = enemiesNearby.map(e => | |
Math.atan2(e.y - blueHQ.y, e.x - blueHQ.x) | |
); | |
const quadrants = new Set(angles.map(a => Math.floor((a + Math.PI) / (Math.PI/2)))); | |
if (quadrants.size >= 3) { | |
return 'red_victory_encircle'; | |
} | |
} | |
} | |
if (redHQ && redHQ.health > 0) { | |
const enemiesNearby = units.filter(u => | |
u.side === 'blue' && | |
u.health > 0 && | |
Math.hypot(u.x - redHQ.x, u.y - redHQ.y) < 150 | |
); | |
if (enemiesNearby.length >= 4) { | |
const angles = enemiesNearby.map(e => | |
Math.atan2(e.y - redHQ.y, e.x - redHQ.x) | |
); | |
const quadrants = new Set(angles.map(a => Math.floor((a + Math.PI) / (Math.PI/2)))); | |
if (quadrants.size >= 3) { | |
return 'blue_victory_encircle'; | |
} | |
} | |
} | |
const blueAll = units.filter(u=>u.side==='blue'); | |
const redAll = units.filter(u=>u.side==='red'); | |
if(blueAlive.length < Math.max(1, Math.floor(blueAll.length*0.1))) return 'red_victory_attrition'; | |
if(redAlive.length < Math.max(1, Math.floor(redAll.length*0.1))) return 'blue_victory_attrition'; | |
return ''; | |
} | |
function endBattle(result){ | |
battleActive=false; | |
// Stop battle sound | |
battleSound.pause(); | |
battleSound.currentTime = 0; | |
if(battleInterval) clearInterval(battleInterval); | |
if(battleAnimationFrame) cancelAnimationFrame(battleAnimationFrame); | |
let message=''; | |
let details=''; | |
switch(result){ | |
case 'blue_victory_hq': | |
message='šµ BLUE TEAM VICTORY!'; | |
details='Enemy command destroyed - Red forces in disarray'; | |
break; | |
case 'red_victory_hq': | |
message='š“ RED TEAM VICTORY!'; | |
details='Enemy command destroyed - Blue forces in disarray'; | |
break; | |
case 'blue_victory_encircle': | |
message='šµ BLUE TEAM VICTORY!'; | |
details='Red HQ encircled and forced to surrender'; | |
break; | |
case 'red_victory_encircle': | |
message='š“ RED TEAM VICTORY!'; | |
details='Blue HQ encircled and forced to surrender'; | |
break; | |
case 'blue_victory_attrition': | |
message='šµ BLUE TEAM VICTORY!'; | |
details='Red forces decimated - 90% casualties'; | |
break; | |
case 'red_victory_attrition': | |
message='š“ RED TEAM VICTORY!'; | |
details='Blue forces decimated - 90% casualties'; | |
break; | |
case 'draw': | |
message='āļø MUTUAL DESTRUCTION'; | |
details='Both forces annihilated'; | |
break; | |
default: | |
message='Battle Ended'; | |
break; | |
} | |
document.getElementById('battle-status').textContent='Battle Ended'; | |
document.getElementById('start-battle').style.display='block'; | |
document.getElementById('start-battle').textContent='āļø START ENGAGEMENT'; | |
document.getElementById('pause-battle').style.display='none'; | |
alert(`Battle Complete!\n\n${message}\n${details}\n\n[Battle Results]\nBlue Casualties: ${blueCasualties}\nRed Casualties: ${redCasualties}\n\n[Battle Duration]\nBattle Time: ${formatTime(battleTimeElapsed)}\nReal Time: ${formatTime(realTimeElapsed)}\n\n[Victory Conditions]\n⢠HQ Destruction\n⢠Complete Encirclement\n⢠90% Force Attrition`); | |
} | |
</script> | |
</body> | |
</html> |