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