RTS
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 3257b06f11
# FRONTIER WAR — 3D Sci-Fi Real-Time Strategy Build a complete, playable **3D real-time strategy game** in a single HTML file, in the tradition of Warcraft and Command & Conquer, with an original science-fiction setting. The player commands an expedition competing with a rival force for control of a resource-rich frontier world. Choose your own visual style, level of geometric detail, faction identity, and presentation. Make the world, units, and buildings distinctive and readable at both base-management and army-command scales. Render the battlefield in real-time 3D, with a movable angled camera, zoom, and rotation. Generate or embed the game's assets so the delivered artifact works within the authoring environment's dependency constraints. The defining experience is **escalation**: begin with one headquarters and a few workers, explore an unknown map, establish an economy, expand into new territory, and command increasingly large combined-arms forces against an active opponent. All of these systems must work together in actual play. ## 1. A Complete Skirmish - One substantial map, one human player, and one autonomous AI opponent. Both sides begin with a headquarters, a few workers, and enough resources to start developing. - Aim for a satisfying first match of roughly 5–10 minutes, while allowing longer matches and much larger armies if neither side finishes the other quickly. - Give the player a short, safe opening to establish production and scout before serious enemy pressure begins. - Victory comes from destroying all enemy headquarters; defeat comes from losing all of your own. Additional headquarters can be built as expansions. Make these rules clear before play. - Include a title/start screen with concise instructions, pause/resume, victory and defeat screens, and a restart that resets the whole match. ## 2. Exploration and Fog of War - The map begins unexplored except around the player's starting forces. The enemy base's location is initially unknown; do not reveal it through the minimap, UI, targeting, or camera movement. - Distinguish **unexplored**, **explored but currently out of sight**, and **currently visible** areas. Friendly units and buildings reveal nearby terrain as they move or are constructed. - Explored terrain remains known, but enemy units disappear when they leave current vision. Previously seen enemy buildings may remain as last-known silhouettes, without revealing subsequent construction or destruction until revisited. - Apply the same information rules to the battlefield and minimap. Moving the camera does not reveal terrain. Orders and automatic attacks must not acquire hidden enemies. - Make scouting useful: routes, resource fields, enemy expansions, and approaching armies should be discoveries. The map needs enough space and alternative routes for opponents to encounter each other away from their starting bases. - Give the AI its own vision and scouting behavior; it should discover targets rather than know every player position automatically. ## 3. Economy and Expandable Supply - Use one primary harvestable resource, distributed among finite deposits across the map. Workers travel to deposits, gather, carry resources, and return them to a friendly headquarters or resource drop-off building. - Units, buildings, and upgrades compete for this resource. Prices and build times must create meaningful choices between immediate defense, a stronger economy, expansion, and technology. - Nearby deposits eventually deplete. Provide enough total resources for both sides to establish several bases and sustain large armies. Expanding should improve harvesting efficiency by placing drop-offs near distant deposits. - Units consume supply; completed supply buildings increase capacity. Show **used / available supply** clearly. Supply is reusable capacity, not a resource spent permanently when a unit is trained. - Support repeated supply expansion into the hundreds, with further growth toward thousands if the economy supports it. Avoid a small fixed population ceiling that ends the game's escalation. - Reserve supply consistently for queued units and release reservations on cancellation. Supply shortages pause production with clear feedback. Losing supply infrastructure can block further production but must not delete existing units. - Show resource totals, gathering feedback, construction progress, production queues, costs, prerequisites, and supply requirements. Unavailable actions explain why they are unavailable. ## 4. Building and Territorial Expansion - Workers construct buildings at player-chosen locations, with a placement preview, valid/invalid feedback, resource deduction, and visible construction over time. - Include the functional roles of headquarters/resource return, supply infrastructure, ground-unit production, air-unit production, research, and defensive emplacement. You may combine roles where it makes the design clearer. - Buildings occupy space, take damage, and can be destroyed. Prevent overlapping placements and ensure construction does not trap units permanently. - Allow additional headquarters and production facilities. Multiple production buildings operate independently, with queues and rally points, so investment translates into increasing army output. - Shape the map with terrain obstacles, chokepoints, open fighting areas, and contested resource sites. Ground units navigate around obstacles; flying units can cross them. - Territory matters through vision, safe harvesting, forward production, defensive positions, and travel time. The player should have reasons to defend expansions and raid the enemy's economy. ## 5. Unit Variety and Combat - Include workers and at least **four complementary combat types**: a close-combat ground unit, a ranged ground unit, a heavy armored ground unit, and a flying combat unit. Invent their names, appearance, and specific roles. - Use at least two damage types and two armor categories, with real damage modifiers that produce useful counters. Distinguish which weapons can target ground, air, or both; provide an accessible counter to aircraft. - Make differences in speed, range, durability, damage, cost, supply, and production time tactically meaningful. No single unit should dominate every matchup. - Units obey movement, attack, attack-move, stop, and hold-position commands. Automatic targeting respects sight, range, and valid target types. Attack-move engages encountered enemies and resumes movement when the fight ends. - Combat has readable weapon effects, health feedback, impacts, and destruction. Close-combat units must reach their targets; ranged units fight at range; aircraft visibly operate above the ground. - Groups navigate around buildings and terrain, spread into usable positions, and reach their destinations without collapsing into one overlapping point or becoming permanently stuck. Large battles must continue to move and resolve. - Offer a small set of meaningful researched upgrades, with resource costs and research times, that improve capabilities and compete with spending on more units or bases. ## 6. Commanding an Army - Left-click selects units or buildings; drag a rectangle to select groups. Shift modifies selection. Right-click issues contextual move, attack, gather, or resource-return orders. - Support numbered control groups, selecting units of the same type, queued orders, and clear command buttons or hotkeys for attack-move, stop, and hold position. - Use keyboard camera panning and/or edge scrolling, wheel zoom, and an accessible rotation control. Clicking the minimap moves the camera; issuing orders through it is also useful. - Show selection indicators, order destinations, and a selection panel with unit counts, health, type information, and available actions. Mixed selections and very large groups must remain manageable. - Provide a minimap with fog of war, friendly forces, visible enemies, known resources, and the current camera footprint. - Production and research panels show progress and allow cancellation with a clear refund rule. Newly produced units follow rally orders. - Keep HUD interaction separate from battlefield input so using a panel never accidentally issues an order or changes selection behind it. ## 7. A Developing Opponent - The AI runs a real economy: harvests finite resources, builds supply and production, trains units, researches upgrades, and expands toward new deposits. - It scouts, assembles forces, attacks, and defends threatened assets. Attacks should grow from early raids into larger combined-arms engagements as its economy develops. - It uses the same costs, supply rules, construction times, movement, combat, and visibility rules as the player. Do not substitute free periodic army spawns for an economy. - It should remain active throughout the match and pose a winnable challenge. The player can weaken it by killing workers, cutting supply, or destroying expansions and production. ## 8. Scale, Presentation, and Feedback - Make growth visible: a sparse starting outpost becomes a network of bases, resource routes, production facilities, defenses, and large armies. - Design for **hundreds of individually simulated active units**, with growth toward thousands as a stretch goal. Units must still gather, receive orders, move, and fight; decorative crowds or a displayed army count do not satisfy scale. - Choose rendering and simulation techniques that preserve responsive controls as populations grow. You decide the appropriate visual detail and performance tradeoffs. Avoid a hidden small unit cap. - Keep unit roles, faction ownership, flight versus ground movement, selection, and major threats readable at different zoom levels. Give buildings recognizable functional identities. - Use audio and visual feedback for commands, production completion, supply shortages, combat, and attacks on the player's bases. If audio is included, generate it procedurally or embed it, and provide mute control. - Include concise in-game help for controls, economy, supply, counters, and the victory condition. The player should be able to learn by playing. ## Priorities 1. **A complete, responsive RTS loop:** gather, build, produce, command, fight, win or lose. 2. **Escalation of scale:** expandable economy, supply, territory, and production that sustain hundreds of active units. 3. **Discovery and uncertainty:** meaningful scouting and consistent fog of war. 4. **Strategic choices:** spending tradeoffs, vulnerable expansions, varied units, and effective counters. 5. **Convincing 3D presentation:** choose an appropriate style and level of detail, and make the growing war readable and satisfying.
Inspect original source 116,360 bytes · SHA-256 75aae957045f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FRONTIER WAR — 3D RTS</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#05070c;font-family:"Segoe UI",Tahoma,Arial,sans-serif;color:#dfe9f5;user-select:none}
#gl{position:fixed;inset:0;width:100vw;height:100vh;display:block}
.hud{position:fixed;z-index:2}
#top{left:0;right:0;top:0;height:40px;display:flex;align-items:center;gap:18px;padding:0 14px;background:linear-gradient(#0b1220f0,#0b1220b0);border-bottom:1px solid #2b4a6b;font-size:14px}
#top .k{color:#7fd6ff;font-weight:700;letter-spacing:2px}
#top .ore{color:#7dffb0}
#top .sup{color:#ffd27a}
#topbtns{margin-left:auto;display:flex;gap:6px}
#msg{position:fixed;z-index:2;top:52px;left:50%;transform:translateX(-50%);pointer-events:none;font-size:14px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:3px}
#msg div{background:#0a1220c0;padding:2px 9px;border-radius:3px;text-shadow:0 0 4px #000}
.btn{background:linear-gradient(#18304a,#0e1d2e);border:1px solid #3d6a94;color:#dfeaf7;border-radius:4px;padding:5px 10px;cursor:pointer;font-size:13px;font-family:inherit}
.btn:hover{background:linear-gradient(#22456a,#16304a);border-color:#7fd6ff}
#mmbox{left:10px;bottom:10px;width:236px;background:#0a1220e8;border:1px solid #2b4a6b;border-radius:4px;padding:6px}
#mm{width:224px;height:224px;display:block;cursor:crosshair;background:#000}
#mmctl{display:flex;gap:4px;margin-top:4px}
#mmctl .btn{flex:1;padding:3px;font-size:12px}
#bottom{left:256px;right:10px;bottom:10px;height:236px;display:flex;gap:8px}
#selp{flex:1;background:#0a1220e8;border:1px solid #2b4a6b;border-radius:4px;padding:8px;display:flex;flex-direction:column;min-width:0}
#selinfo{font-size:13px;line-height:1.45;max-height:90px;overflow:auto}
#selinfo h3{margin:0 0 3px;font-size:15px;color:#7fd6ff}
#selgrid{display:flex;flex-wrap:wrap;gap:3px;margin-top:6px;overflow:auto;max-height:110px}
.uicon{width:38px;height:38px;border:1px solid #3d6a94;border-radius:3px;background:#122338;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:10px;cursor:pointer;position:relative}
.uicon b{font-size:13px;color:#ffe066}
.uicon.hl{border-color:#ffe066;box-shadow:0 0 5px #ffe066}
#queue{display:flex;gap:4px;margin-top:4px;min-height:30px;flex-wrap:wrap}
.qi{width:64px;height:34px;border:1px solid #3d6a94;background:#122338;font-size:10px;position:relative;cursor:pointer;overflow:hidden;padding:2px;line-height:1.2}
.qi .bar{position:absolute;left:0;bottom:0;height:4px;background:#7fd6ff}
#cmdp{width:390px;background:#0a1220e8;border:1px solid #2b4a6b;border-radius:4px;padding:8px;display:flex;flex-direction:column;gap:6px}
#cmdgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:4px}
.cbtn{background:linear-gradient(#18304a,#0e1d2e);border:1px solid #3d6a94;border-radius:4px;color:#dfeaf7;padding:4px;font-size:11px;cursor:pointer;min-height:46px;text-align:left;line-height:1.25;font-family:inherit}
.cbtn:hover{border-color:#7fd6ff}
.cbtn.dis{opacity:.4;cursor:not-allowed}
.cbtn.act{border-color:#ffe066;box-shadow:0 0 5px #ffe06699}
.cbtn b{color:#ffe066}
#hint{font-size:11px;color:#8aa3bf;line-height:1.35}
#status{font-size:12px;color:#ffcf8a;min-height:16px}
.overlay{position:fixed;inset:0;z-index:10;background:rgba(3,6,12,.84);display:flex;align-items:center;justify-content:center}
.overlay.hide{display:none}
.panel{background:#0b1626;border:1px solid #3d6a94;border-radius:8px;padding:24px 28px;max-width:880px;max-height:94vh;overflow:auto;box-shadow:0 0 30px #0008}
.panel h1{margin:0 0 6px;font-size:34px;letter-spacing:4px;color:#7fd6ff}
.panel h2{margin:8px 0 6px;font-size:17px;color:#ffe066}
.panel p,.panel li{font-size:14px;line-height:1.5;color:#c6d6e8}
.panel ul{margin:4px 0;padding-left:20px}
.panel .cols{display:flex;gap:26px}
.panel .cols>div{flex:1}
.panel .row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
kbd{background:#1a2d44;border:1px solid #3d6a94;border-radius:3px;padding:0 4px;font-family:monospace;font-size:12px}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div id="top" class="hud">
<span class="k">FRONTIER WAR</span>
<span class="ore">⬢ Crystal <b id="hOre">0</b></span>
<span class="sup">⌂ Supply <b id="hSup">0/0</b></span>
<span id="hInc" style="color:#9fb3c8;font-size:12px"></span>
<span>⏱ <b id="hTime">0:00</b></span>
<div id="topbtns">
<button class="btn" id="btnHelp">Help (F1)</button>
<button class="btn" id="btnPause">Pause (Esc)</button>
<button class="btn" id="btnMute">Sound: On (M)</button>
<button class="btn" id="btnRestart">Restart</button>
</div>
</div>
<div id="msg" class="hud"></div>
<div id="mmbox" class="hud">
<canvas id="mm" width="224" height="224"></canvas>
<div id="mmctl">
<button class="btn" id="rotL">◀ Rot</button>
<button class="btn" id="rotR">Rot ▶</button>
<button class="btn" id="tiltU">Tilt+</button>
<button class="btn" id="tiltD">Tilt−</button>
<button class="btn" id="zoomI">+</button>
<button class="btn" id="zoomO">−</button>
</div>
</div>
<div id="bottom" class="hud">
<div id="selp">
<div id="selinfo"></div>
<div id="selgrid"></div>
<div id="queue"></div>
</div>
<div id="cmdp">
<div id="cmdgrid"></div>
<div id="status"></div>
<div id="hint">LMB select · drag box · Shift add · RMB order · 1-9 recall · Ctrl+1-9 set group · A attack-move · S stop · H hold · Space last alert · WASD/edge pan · wheel zoom · [ ] rotate · MMB drag rotate/tilt · Esc cancel/pause</div>
</div>
</div>
<div id="menu" class="overlay">
<div class="panel">
<h1>FRONTIER WAR</h1>
<p>Two expeditions race for the crystal fields of a hostile frontier world. Gather Crystal, expand, build an army, and destroy every enemy Headquarters to win.</p>
<div class="cols">
<div>
<h2>Victory & Defeat</h2>
<ul>
<li><b>Win:</b> destroy every enemy Headquarters (HQ or Command Post).</li>
<li><b>Lose:</b> all of your Headquarters are destroyed.</li>
<li>The enemy base location is unknown. Scout to find it.</li>
<li>Command Posts (400 ◆) are extra HQs: they train Drones and count for victory.</li>
</ul>
<h2>Economy & Supply</h2>
<ul>
<li>Drones mine Crystal from finite deposits and return it to an HQ, Command Post or Ore Silo.</li>
<li>Silos beside distant deposits cut walking time.</li>
<li>Supply is reusable: each living unit uses supply. Habitat Pods and HQs raise the cap.</li>
<li>Queued units reserve supply; cancelling refunds 100% and releases it.</li>
</ul>
</div>
<div>
<h2>Units & Counters</h2>
<ul>
<li><b>Blade</b> — fast melee, cheap. Kinetic. Ground only. Shreds Lancers.</li>
<li><b>Lancer</b> — energy ranged (6 cells). Can hit air. Good vs Bulwarks.</li>
<li><b>Bulwark</b> — armored heavy. Kinetic weapons do half damage to armor.</li>
<li><b>Hornet</b> — fast air striker. Ground only. Lancers and Turrets shoot it down.</li>
<li><b>Plasma Turret</b> — static energy defense, strong vs armor.</li>
</ul>
</div>
</div>
<div class="row">
<button class="btn" id="btnStart" style="font-size:18px;padding:10px 26px">Begin Expedition</button>
<span style="font-size:12px;color:#8aa3bf">Press F1 anytime for the field manual.</span>
</div>
</div>
</div>
<div id="help" class="overlay hide">
<div class="panel">
<h1 style="font-size:24px">Field Manual</h1>
<div class="cols">
<div>
<h2>Controls</h2>
<ul>
<li>Left click / drag: select. <kbd>Shift</kbd> adds. Double-click a unit: select all of its type on screen.</li>
<li>Right click: move, attack, gather crystal, return cargo, or help build.</li>
<li><kbd>A</kbd> attack-move · <kbd>S</kbd> stop · <kbd>H</kbd> hold position</li>
<li><kbd>Ctrl+1–9</kbd> set control group · <kbd>1–9</kbd> recall</li>
<li>Camera: <kbd>WASD</kbd>/arrows or screen edge · wheel zoom · <kbd>[</kbd> <kbd>]</kbd> rotate · middle-drag rotate/tilt · <kbd>Space</kbd> last alert</li>
<li><kbd>Esc</kbd> cancels placement. Click a queue slot to cancel (100% refund).</li>
</ul>
</div>
<div>
<h2>Economy & Fog</h2>
<ul>
<li>Drones mine automatically. Only deposited Crystal pays.</li>
<li>Ore Silo / HQ / Command Post are drop-off points. Build a Silo beside far deposits.</li>
<li>Habitat Pod +8 supply, HQ/CP +12. Production pauses when supply is full.</li>
<li>Lab upgrades: Weapon Matrix I/II (+20% damage), Plating (+20% HP), Drive Tuning (+15% speed).</li>
<li>Black = unexplored. Dim = explored but not in sight (buildings stay as last-known). Enemy units vanish out of sight.</li>
<li>Chain: Barracks → Lab / Foundry / Aerodrome / Turret. HQs train Drones.</li>
</ul>
</div>
</div>
<div class="row"><button class="btn" id="btnHelpClose">Close</button></div>
</div>
</div>
<div id="pause" class="overlay hide">
<div class="panel" style="text-align:center">
<h1>PAUSED</h1>
<div class="row" style="justify-content:center"><button class="btn" id="btnResume" style="font-size:16px;padding:8px 20px">Resume</button><button class="btn" id="btnPauseRestart" style="font-size:16px;padding:8px 20px">Restart Match</button></div>
</div>
</div>
<div id="end" class="overlay hide">
<div class="panel" style="text-align:center">
<h1 id="endTitle">VICTORY</h1>
<p id="endText"></p>
<div class="row" style="justify-content:center"><button class="btn" id="btnEndRestart" style="font-size:16px;padding:8px 20px">Play Again</button></div>
</div>
</div>
<script>
'use strict';
// ============ CONSTANTS ============
const MW = 120, MH = 120, N = MW * MH, W1 = MW + 1;
const P = 0, E = 1;
const TICK = 0.05;
const DX = [1, -1, 0, 0, 1, 1, -1, -1], DY = [0, 0, 1, -1, 1, -1, 1, -1];
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const dist = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
const inMap = (x, y) => x >= 0 && y >= 0 && x < MW && y < MH;
const fmtT = s => { s = Math.floor(s); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
let seed = 4242;
function srand() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
const rnd = Math.random;
// damage: kin (kinetic) / en (energy) vs light / armor
const DMG = { kin: { light: 1.0, armor: 0.5 }, en: { light: 0.9, armor: 1.5 } };
const UNITS = {
drone: { name: 'Drone', hp: 40, speed: 2.6, sight: 7, range: 0.9, dmg: 3, cd: 1.5, dtype: 'kin', armor: 'light', cost: 50, sup: 1, time: 12, size: 0.32, worker: true, from: 'hq' },
blade: { name: 'Blade', hp: 90, speed: 2.8, sight: 8, range: 1.1, dmg: 10, cd: 0.9, dtype: 'kin', armor: 'light', cost: 55, sup: 1, time: 10, size: 0.42, from: 'barracks' },
lancer: { name: 'Lancer', hp: 65, speed: 2.3, sight: 9, range: 6, dmg: 11, cd: 1.2, dtype: 'en', armor: 'light', cost: 75, sup: 1, time: 13, size: 0.4, from: 'barracks', canAir: true },
bulwark:{ name: 'Bulwark', hp: 340, speed: 1.5, sight: 8, range: 4.2, dmg: 26, cd: 2.0, dtype: 'en', armor: 'armor', cost: 180, sup: 3, time: 24, size: 0.8, from: 'foundry' },
hornet: { name: 'Hornet', hp: 110, speed: 3.6, sight: 10, range: 4.5, dmg: 14, cd: 1.1, dtype: 'kin', armor: 'light', cost: 140, sup: 2, time: 20, size: 0.55, from: 'aero', flyer: true },
};
const BLD = {
hq: { name: 'Headquarters', w: 4, h: 4, hp: 1400, cost: 0, time: 0, sup: 12, drop: true, trains: ['drone'], hq: true, sight: 10, col: [70, 130, 230], hgt: 5 },
cp: { name: 'Command Post', w: 4, h: 4, hp: 1000, cost: 400, time: 45, sup: 12, drop: true, trains: ['drone'], hq: true, sight: 9, col: [70, 130, 230], hgt: 5 },
silo: { name: 'Ore Silo', w: 2, h: 2, hp: 450, cost: 100, time: 20, drop: true, sight: 6, col: [60, 200, 130], hgt: 2.4 },
pod: { name: 'Habitat Pod', w: 2, h: 2, hp: 320, cost: 100, time: 18, sup: 8, sight: 5, col: [230, 190, 80], hgt: 3 },
barracks:{ name: 'Barracks', w: 3, h: 3, hp: 700, cost: 150, time: 35, trains: ['blade', 'lancer'], sight: 7, col: [150, 150, 170], hgt: 3.6 },
lab: { name: 'Research Lab', w: 3, h: 3, hp: 600, cost: 150, time: 30, research: true, req: 'barracks', sight: 7, col: [180, 100, 230], hgt: 3.2 },
foundry: { name: 'Foundry', w: 3, h: 3, hp: 850, cost: 220, time: 40, trains: ['bulwark'], req: 'barracks', sight: 7, col: [220, 120, 80], hgt: 4 },
aero: { name: 'Aerodrome', w: 3, h: 3, hp: 700, cost: 230, time: 40, trains: ['hornet'], req: 'barracks', sight: 8, col: [90, 210, 210], hgt: 4.2 },
turret: { name: 'Plasma Turret', w: 2, h: 2, hp: 500, cost: 120, time: 25, req: 'barracks', sight: 8, col: [230, 80, 170], hgt: 2.6, atk: { range: 7.5, dmg: 13, cd: 1.0, dtype: 'en' } },
};
const UPG = {
wpn1: { name: 'Weapon Matrix I', cost: 150, time: 30, desc: '+20% damage' },
wpn2: { name: 'Weapon Matrix II', cost: 250, time: 45, desc: '+20% damage', req: 'wpn1' },
arm1: { name: 'Plating I', cost: 150, time: 30, desc: '+20% max HP' },
spd1: { name: 'Drive Tuning', cost: 120, time: 25, desc: '+15% speed' },
};
const BUILD_MENU = ['silo', 'pod', 'barracks', 'lab', 'foundry', 'aero', 'turret', 'cp'];
const BUILD_KEY = { silo: 'Q', pod: 'W', barracks: 'E', lab: 'R', foundry: 'T', aero: 'Y', turret: 'U', cp: 'I' };
const BASE_P = [14, 14], BASE_E = [106, 106];
const DEP_SITES = [
[24, 9, 1400], [9, 24, 1400], [33, 33, 2000], [26, 50, 1800], [50, 26, 1800],
[60, 60, 3200], [52, 74, 2400], [74, 52, 2400], [60, 18, 2200], [18, 60, 2200],
[96, 60, 2200], [60, 96, 2200], [102, 22, 2000], [22, 102, 2000],
[87, 87, 2000], [72, 96, 1800], [96, 72, 1800], [40, 96, 1800], [96, 40, 1800]
];
// ============ WORLD ARRAYS ============
const VH = new Float32Array(W1 * (MH + 1));
const CSH = new Float32Array(N);
const TONE = new Float32Array(N);
const PF = { g: new Float32Array(N), par: new Int32Array(N), stp: new Uint32Array(N), cls: new Uint32Array(N), st: 0 };
let heapF = [], heapI = [];
const HB = 60;
const HBUCK = []; for (let i = 0; i < HB * HB; i++) HBUCK.push([]);
let G = null;
const UIS = { sel: [], groups: {}, placing: null, lastAlert: null, mouse: { x: 0, y: 0, in: false }, drag: null, lastClick: { t: 0, id: -1 }, status: '', statusT: 0 };
const CAM = { fx: 14, fz: 14, yaw: 0.7, pitch: 0.95, dist: 42 };
// ============ TERRAIN ============
function baseDist(x, y) { return Math.min(dist(x, y, BASE_P[0], BASE_P[1]), dist(x, y, BASE_E[0], BASE_E[1])); }
function hgt(x, y) {
const h = 1.1 * Math.sin(x * 0.07 + 1.3) * Math.cos(y * 0.065) + 0.6 * Math.sin((x + y) * 0.045 + 2.1) + 0.35 * Math.cos((x - y) * 0.11);
const fl = clamp((baseDist(x, y) - 9) / 12, 0, 1);
return h * (0.12 + 0.88 * fl);
}
function cellRock(x, y) { if (x < 0 || y < 0 || x >= MW || y >= MH) return 1; return G.terr[y * MW + x]; }
function buildHeights() {
for (let vy = 0; vy <= MH; vy++) for (let vx = 0; vx <= MW; vx++) {
const c = cellRock(vx - 1, vy - 1) + cellRock(vx, vy - 1) + cellRock(vx - 1, vy) + cellRock(vx, vy);
VH[vy * W1 + vx] = hgt(vx, vy) + 0.9 * c / 4;
}
for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
TONE[y * MW + x] = ((((x * 73856093) ^ (y * 19349663)) >>> 0) % 1000) / 1000;
}
}
function heightAt(x, y) {
x = clamp(x, 0, MW - 0.001); y = clamp(y, 0, MH - 0.001);
const ix = x | 0, iy = y | 0, fx = x - ix, fy = y - iy;
const a = VH[iy * W1 + ix], b = VH[iy * W1 + ix + 1], c = VH[(iy + 1) * W1 + ix], d = VH[(iy + 1) * W1 + ix + 1];
return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy;
}
function reachableAll(sx, sy, targets) {
const seen = new Uint8Array(N); const q = [sy * MW + sx]; seen[q[0]] = 1;
for (let qi = 0; qi < q.length; qi++) {
const c = q[qi], cx = c % MW, cy = (c / MW) | 0;
for (let d = 0; d < 4; d++) {
const nx = cx + DX[d], ny = cy + DY[d];
if (nx < 0 || ny < 0 || nx >= MW || ny >= MH) continue;
const ni = ny * MW + nx;
if (G.terr[ni] || seen[ni]) continue;
seen[ni] = 1; q.push(ni);
}
}
return targets.every(t => seen[t[1] * MW + t[0]]);
}
function genTerrain() {
for (let att = 0; att < 80; att++) {
seed = 4242 + att * 131;
G.terr.fill(0);
for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) if (x < 2 || y < 2 || x >= MW - 2 || y >= MH - 2) G.terr[y * MW + x] = 1;
for (let i = 0; i < 70; i++) {
const cx = 3 + srand() * (MW - 6), cy = 3 + srand() * (MH - 6), r = 1.5 + srand() * 2.6;
if (dist(cx, cy, BASE_P[0], BASE_P[1]) < 17 || dist(cx, cy, BASE_E[0], BASE_E[1]) < 17) continue;
if (dist(cx, cy, 60, 60) < 8) continue;
for (let y = Math.floor(cy - r - 1); y <= cy + r + 1; y++) for (let x = Math.floor(cx - r - 1); x <= cx + r + 1; x++) {
if (!inMap(x, y)) continue;
if (dist(x + 0.5, y + 0.5, cx, cy) < r && srand() > 0.08) G.terr[y * MW + x] = 1;
}
}
const clr = (cx, cy, rr) => { for (let y = cy - rr; y <= cy + rr; y++) for (let x = cx - rr; x <= cx + rr; x++) if (inMap(x, y)) G.terr[y * MW + x] = 0; };
clr(BASE_P[0], BASE_P[1], 8); clr(BASE_E[0], BASE_E[1], 8);
DEP_SITES.forEach(d => clr(d[0], d[1], 2));
const tg = DEP_SITES.map(d => [d[0], d[1]]).concat([BASE_P, BASE_E]);
if (reachableAll(BASE_P[0], BASE_P[1], tg)) break;
}
seed = 4242;
buildHeights();
}
// ============ ENTITIES ============
function ent(id) { return G.ent.get(id) || null; }
function mkUnit(type, team, x, y) {
const s = UNITS[type];
const u = {
id: G.nid++, isB: false, type, team, s, x, y, hp: s.hp, maxHp: s.hp,
size: s.size, flyer: !!s.flyer, canAir: !!s.canAir, sup: s.sup,
order: null, oq: [], target: null, cd: rnd() * 0.5, acq: rnd() * 0.5,
moving: false, gx: x, gy: y, path: null, pi: 0, pathReq: false,
chk: { x, y, t: 0 }, rep: 0, carry: 0, mineAcc: 0, gs: 'seek', dep: null, dead: false,
flash: 0, face: team === P ? 0 : Math.PI, bob: rnd() * 6, hold: false, lastHit: -99, sepT: 0,
lastOrderTo: null, lastAtk: -99
};
if (u.s.from === 'hq' && u.s.worker) u.isW = true;
G.units.push(u); G.ent.set(u.id, u);
const up = G.up[team];
if (up.arm) { u.maxHp = s.hp * 1.2; u.hp = u.maxHp; }
return u;
}
function cellsOf(b, fn) { for (let j = b.y; j < b.y + b.h; j++) for (let i = b.x; i < b.x + b.w; i++) fn(j * MW + i); }
function mkBld(type, team, x, y, done) {
const d = BLD[type];
const b = {
id: G.nid++, isB: true, type, team, x, y, w: d.w, h: d.h, cx: x + d.w / 2, cy: y + d.h / 2,
hp: d.hp, maxHp: d.hp, done: !!done, prog: done ? 1 : 0, queue: [], rally: null, dead: false,
flash: 0, atkCd: 0, lastHit: -99, hq: !!d.hq, builders: 0, sup: 0, drop: !!d.drop, buildSite: !done, alertT: -99
};
cellsOf(b, k => { G.occ[k] = b.id; G.solid[k] = 1; });
G.blds.push(b); G.ent.set(b.id, b);
if (b.done) b.sup = d.sup || 0;
return b;
}
function freeBld(b) { cellsOf(b, k => { if (G.occ[k] === b.id) { G.occ[k] = -1; G.solid[k] = G.terr[k]; } }); }
function bDist(u, b) {
const dx = Math.max(b.x - u.x, 0, u.x - (b.x + b.w)), dy = Math.max(b.y - u.y, 0, u.y - (b.y + b.h));
return Math.hypot(dx, dy);
}
function edgeGoal(u, b) { return [clamp(u.x, b.x - 0.7, b.x + b.w + 0.7), clamp(u.y, b.y - 0.7, b.y + b.h + 0.7)]; }
function tDist(u, t) { return t.isB ? bDist(u, t) : Math.max(0, dist(u.x, u.y, t.x, t.y) - t.size); }
function cidx(x, y) { return clamp(y | 0, 0, MH - 1) * MW + clamp(x | 0, 0, MW - 1); }
function vis(team, x, y) { return G.vis[team][cidx(x, y)] === 1; }
function visEnt(team, e) {
if (e.team === team) return true;
if (e.isDep) return G.expl[team][cidx(e.x, e.y)] === 1;
if (e.isB) return vis(team, e.cx, e.cy) || vis(team, e.x + 0.5, e.y + 0.5);
return vis(team, e.x, e.y);
}
function hbKey(x, y) { return clamp((y * 0.5) | 0, 0, HB - 1) * HB + clamp((x * 0.5) | 0, 0, HB - 1); }
function buildHash() { for (const b of HBUCK) b.length = 0; for (const u of G.units) HBUCK[hbKey(u.x, u.y)].push(u); }
const NEAR = [];
function near(x, y, r) {
NEAR.length = 0;
const x0 = clamp(((x - r) * 0.5) | 0, 0, HB - 1), x1 = clamp(((x + r) * 0.5) | 0, 0, HB - 1);
const y0 = clamp(((y - r) * 0.5) | 0, 0, HB - 1), y1 = clamp(((y + r) * 0.5) | 0, 0, HB - 1);
for (let by = y0; by <= y1; by++) for (let bx = x0; bx <= x1; bx++) {
const arr = HBUCK[by * HB + bx];
for (let k = 0; k < arr.length; k++) { const u = arr[k]; if (!u.dead && (u.x - x) ** 2 + (u.y - y) ** 2 <= r * r) NEAR.push(u); }
}
return NEAR;
}
function nearestFree(x, y, rad) {
x = clamp(Math.floor(x), 0, MW - 1); y = clamp(Math.floor(y), 0, MH - 1);
let best = -1, bd = 1e9;
for (let dy = -rad; dy <= rad; dy++) for (let dx = -rad; dx <= rad; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MW || ny >= MH) continue;
const i = ny * MW + nx; if (G.solid[i]) continue;
const d = dx * dx + dy * dy; if (d < bd) { bd = d; best = i; }
}
return best;
}
// binary heap
function hpush(f, i) {
heapF.push(f); heapI.push(i); let k = heapF.length - 1;
while (k > 0) { const p = (k - 1) >> 1; if (heapF[p] <= f) break; heapF[k] = heapF[p]; heapI[k] = heapI[p]; k = p; }
heapF[k] = f; heapI[k] = i;
}
function hpop() {
const top = heapI[0]; const lf = heapF.pop(), li = heapI.pop(); const n = heapF.length;
if (n > 0) {
let k = 0;
while (true) {
let c = 2 * k + 1; if (c >= n) break;
if (c + 1 < n && heapF[c + 1] < heapF[c]) c++;
if (heapF[c] >= lf) break;
heapF[k] = heapF[c]; heapI[k] = heapI[c]; k = c;
}
heapF[k] = lf; heapI[k] = li;
}
return top;
}
function findPath(sx, sy, gx, gy) {
const goal = nearestFree(gx, gy, 6); if (goal < 0) return null;
const start = cidx(sx, sy);
if (start === goal) return [goal];
const S = ++PF.st; heapF = []; heapI = [];
const gxg = goal % MW, gyg = (goal / MW) | 0;
const h = (i) => { const dx = Math.abs((i % MW) - gxg), dy = Math.abs(((i / MW) | 0) - gyg); return Math.max(dx, dy) + 0.414 * Math.min(dx, dy); };
PF.stp[start] = S; PF.g[start] = 0; PF.par[start] = -1; hpush(h(start), start);
let iter = 0;
while (heapI.length) {
const c = hpop();
if (PF.cls[c] === S) continue;
PF.cls[c] = S;
if (c === goal) break;
if (++iter > 9000) break;
const cx = c % MW, cy = (c / MW) | 0;
for (let d = 0; d < 8; d++) {
const nx = cx + DX[d], ny = cy + DY[d];
if (nx < 0 || ny < 0 || nx >= MW || ny >= MH) continue;
const ni = ny * MW + nx;
if (G.solid[ni] || PF.cls[ni] === S) continue;
if (DX[d] && DY[d] && (G.solid[cy * MW + nx] || G.solid[ny * MW + cx])) continue;
const ng = PF.g[c] + (DX[d] && DY[d] ? 1.414 : 1);
if (PF.stp[ni] !== S || ng < PF.g[ni]) { PF.stp[ni] = S; PF.g[ni] = ng; PF.par[ni] = c; hpush(ng + h(ni), ni); }
}
}
if (PF.cls[goal] !== S) return null;
const path = []; let c = goal;
while (c !== -1) { path.push(c); if (c === start) break; c = PF.par[c]; }
path.reverse(); return path;
}
const pathQ = [];
function requestPath(u) { if (!u.pathReq) { u.pathReq = true; pathQ.push(u); } }
function setGoal(u, x, y) {
if (u.moving && Math.abs(u.gx - x) < 0.25 && Math.abs(u.gy - y) < 0.25) return;
u.gx = x; u.gy = y; u.moving = true; u.path = null; u.pi = 0;
if (!u.flyer) requestPath(u);
}
function stopMove(u) { u.moving = false; u.path = null; u.pathReq = false; }
// ============ GAME STATE ============
function newGame() {
G = {
t: 0, nid: 1, over: false, winner: -1,
terr: new Uint8Array(N), solid: new Uint8Array(N), occ: new Int32Array(N).fill(-1),
vis: [new Uint8Array(N), new Uint8Array(N)], expl: [new Uint8Array(N), new Uint8Array(N)],
mem: [new Map(), new Map()],
units: [], blds: [], ent: new Map(), deps: [],
up: [{ wpn: 0, arm: false, spd: false, done: {} }, { wpn: 0, arm: false, spd: false, done: {} }],
res: [500, 500], supC: [0, 0], supU: [0, 0],
fx: [], alerts: [], stats: [{ built: 0, lost: 0, kills: 0 }, { built: 0, lost: 0, kills: 0 }],
incomeLog: [], ai: null, visT: 0, aiT: 0
};
genTerrain();
G.deps = DEP_SITES.map(d => ({ id: G.nid++, isDep: true, x: d[0], y: d[1], amt: d[2], max: d[2], dead: false, claim: 0 }));
G.deps.forEach(d => G.ent.set(d.id, d));
mkBld('hq', P, BASE_P[0] - 2, BASE_P[1] - 2, true);
mkBld('hq', E, BASE_E[0] - 2, BASE_E[1] - 2, true);
for (let i = 0; i < 5; i++) mkUnit('drone', P, BASE_P[0] + 3.5 + (i % 3) * 0.9, BASE_P[1] + 4.5 + Math.floor(i / 3) * 0.9);
for (let i = 0; i < 5; i++) mkUnit('drone', E, BASE_E[0] + 3.5 + (i % 3) * 0.9, BASE_E[1] + 4.5 + Math.floor(i / 3) * 0.9);
mkUnit('blade', P, BASE_P[0] + 6, BASE_P[1] + 2);
mkUnit('blade', P, BASE_P[0] + 6, BASE_P[1] + 3);
G.ai = mkAI();
G.units.forEach(u => { if (u.team === P) u.order = { t: 'stop' }; });
G.units.forEach(u => { if (u.team === P && u.isW) u.order = null; });
for (const u of G.units) u.order = null;
recalcAllSup();
updateVision(true);
UIS.sel = []; UIS.groups = {}; UIS.placing = null; UIS.lastAlert = null; UIS.drag = null;
CAM.fx = BASE_P[0] + 3; CAM.fz = BASE_P[1] + 3; CAM.yaw = 0.75; CAM.pitch = 0.95; CAM.dist = 40;
G.alerts.push({ x: BASE_P[0], y: BASE_P[1], t: 0, text: 'Expedition landed. Drones: mine crystal (gather). Scout with the blades.', team: P });
}
function mkAI() {
return { timer: 1, known: new Map(), wave: false, waveT: 0, nextWave: 240, waves: 0, scoutI: 0, target: null, defendUntil: 0, buildIdx: 0, lastBuildT: 0, pending: null, queued: 0 };
}
// ============ SUPPLY ============
function recalcAllSup() {
for (const t of [P, E]) {
let cap = 0, used = 0;
for (const b of G.blds) if (b.team === t && b.done && !b.dead) cap += BLD[b.type].sup || 0;
for (const u of G.units) if (u.team === t && !u.dead) used += u.sup;
for (const b of G.blds) if (b.team === t && !b.dead) for (const q of b.queue) if (q.kind === 'unit') used += q.sup;
G.supC[t] = cap; G.supU[t] = used;
}
}
function hasBld(team, type) { return G.blds.some(b => b.team === team && b.type === type && b.done && !b.dead); }
function reqOK(team, type) { const r = BLD[type].req; return !r || hasBld(team, r); }
function nearestDrop(u) {
let best = null, bd = 1e9;
for (const b of G.blds) {
if (b.team !== u.team || !b.done || b.dead || !b.drop) continue;
const d = bDist(u, b); if (d < bd) { bd = d; best = b; }
}
return best;
}
function oreAdd(team, n, x, y) {
G.res[team] += n;
if (team === P) { G.incomeLog.push([G.t, n]); if (x !== undefined) fxText(x, y, '+' + n, '#7dffb0'); }
}
// placement validation
function placeErr(type, x, y, team) {
const d = BLD[type];
if (x < 0 || y < 0 || x + d.w > MW || y + d.h > MH) return 'Out of bounds';
for (let j = y; j < y + d.h; j++) for (let i = x; i < x + d.w; i++) {
const k = j * MW + i;
if (G.terr[k]) return 'Blocked by rock';
if (G.occ[k] !== -1) return 'Occupied';
if (team === P && !G.expl[P][k]) return 'Unexplored ground';
}
for (const dp of G.deps) if (!dp.dead && dp.x >= x - 1.5 && dp.x <= x + d.w + 0.5 && dp.y >= y - 1.5 && dp.y <= y + d.h + 0.5) return 'Too close to crystal deposit';
for (const u of G.units) {
if (u.team !== team || u.flyer || u.dead) continue;
if (u.x > x - 0.3 && u.x < x + d.w + 0.3 && u.y > y - 0.3 && u.y < y + d.h + 0.3) return 'Units in the way';
}
return null;
}
function canStart(team, type) {
const d = BLD[type];
if (type === 'hq') return 'HQs cannot be built';
if (!reqOK(team, type)) return 'Requires ' + BLD[d.req].name;
if (G.res[team] < d.cost) return 'Need ' + d.cost + ' crystal';
return null;
}
function startBuild(team, type, x, y, builder) {
const why = canStart(team, type) || placeErr(type, x, y, team);
if (why) return why;
G.res[team] -= BLD[type].cost;
const b = mkBld(type, team, x, y, false);
if (builder) giveBuild(builder, b);
recalcAllSup();
return null;
}
function giveBuild(u, b) {
if (u.carry > 0 || u.gs === 'mine') { u.oq.unshift(u.order); }
u.order = { t: 'build', b: b.id }; stopMove(u); u.gs = 'seek';
}
// production
function enqueueCheck(b, kind, key) {
const d = BLD[b.type];
if (!b.done) return 'Under construction';
if (kind === 'unit') {
const ud = UNITS[key];
if (!d.trains || !d.trains.includes(key)) return 'Cannot train here';
if (!reqOK(b.team, b.type) && ud.from !== 'hq') return 'Missing prerequisite';
if (b.queue.length >= 6) return 'Queue full (6)';
if (G.res[b.team] < ud.cost) return 'Need ' + ud.cost + ' crystal';
if (G.supU[b.team] + ud.sup > G.supC[b.team]) return 'Supply full — build Habitat Pods';
return null;
}
const u = UPG[key];
if (!d.research) return 'Needs a Research Lab';
if (G.up[b.team].done[key]) return 'Already researched';
if (u.req && !G.up[b.team].done[u.req]) return 'Requires ' + UPG[u.req].name;
if (G.blds.some(o => o.team === b.team && o.queue.some(q => q.kind === 'upg' && q.key === key))) return 'Already in research';
if (b.queue.length >= 4) return 'Queue full (4)';
if (G.res[b.team] < u.cost) return 'Need ' + u.cost + ' crystal';
return null;
}
function enqueue(b, kind, key) {
const why = enqueueCheck(b, kind, key);
if (why) return why;
if (kind === 'unit') {
const ud = UNITS[key];
G.res[b.team] -= ud.cost;
b.queue.push({ kind, type: key, t: 0, time: ud.time, cost: ud.cost, sup: ud.sup });
} else {
const u = UPG[key];
G.res[b.team] -= u.cost;
b.queue.push({ kind, key, t: 0, time: u.time, cost: u.cost, sup: 0 });
}
recalcAllSup();
return null;
}
function cancelQueue(b, i) {
const q = b.queue[i]; if (!q) return;
G.res[b.team] += q.cost;
b.queue.splice(i, 1);
recalcAllSup();
if (b.team === P) say('click', 0.5);
}
function updateQueues(dt) {
for (const b of G.blds) {
if (b.dead || !b.done || !b.queue.length) continue;
const q = b.queue[0];
q.blocked = G.supU[b.team] > G.supC[b.team];
if (q.blocked) continue;
q.t += dt;
if (q.t >= q.time) {
b.queue.shift();
if (q.kind === 'unit') spawnFrom(b, q.type);
else completeUpg(b, q.key);
recalcAllSup();
}
}
}
function completeUpg(b, key) {
const up = G.up[b.team];
up.done[key] = true;
if (key === 'wpn1') up.wpn = Math.max(up.wpn, 1);
if (key === 'wpn2') up.wpn = 2;
if (key === 'arm1') { up.arm = true; for (const u of G.units) if (u.team === b.team && !u.dead) { const r = u.hp / u.maxHp; u.maxHp = u.s.hp * 1.2; u.hp = u.maxHp * r; } }
if (key === 'spd1') up.spd = true;
if (b.team === P) { say('research'); alertP(b.cx, b.cy, UPG[key].name + ' complete'); }
}
function spawnFrom(b, type) {
const spot = nearestFree(b.cx, b.cy, b.w + 4);
if (spot < 0) return null;
const u = mkUnit(type, b.team, (spot % MW) + 0.5, ((spot / MW) | 0) + 0.5);
const r = b.rally;
if (r && r.kind === 'gather' && UNITS[type].worker) u.order = { t: 'gather', dep: r.dep };
else if (r && r.kind === 'move') u.order = UNITS[type].worker ? { t: 'move', x: r.x, y: r.y } : { t: 'amove', x: r.x, y: r.y };
else if (UNITS[type].worker) u.order = null;
if (b.team === P) say('ready');
return u;
}
// construction
function updateConstruction(dt) {
for (const b of G.blds) {
if (b.dead || b.done) continue;
let n = 0;
for (const u of G.units) if (u.team === b.team && !u.dead && u.order && u.order.t === 'build' && u.order.b === b.id && bDist(u, b) < 1.8) n++;
b.builders = n;
if (n === 0) continue;
b.prog += dt * Math.pow(n, 0.7) / BLD[b.type].time;
if (b.prog >= 1) {
b.prog = 1; b.done = true; b.buildSite = false; b.sup = BLD[b.type].sup || 0;
G.stats[b.team].built++;
for (const u of G.units) if (u.team === b.team && u.order && u.order.t === 'build' && u.order.b === b.id) { u.order = u.oq.length ? u.oq.shift() : null; stopMove(u); }
if (b.team === P) { say('built'); alertP(b.cx, b.cy, BLD[b.type].name + ' online', true); }
recalcAllSup();
}
}
}
function alertP(x, y, text, quiet) { G.alerts.push({ x, y, t: G.t, text, team: P, quiet }); }
// ============ VISION ============
function markCircle(t, cx, cy, r) {
const V = G.vis[t], X = G.expl[t];
const x0 = Math.max(0, Math.floor(cx - r)), x1 = Math.min(MW - 1, Math.ceil(cx + r));
const y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(MH - 1, Math.ceil(cy + r));
const r2 = r * r;
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
const dx = x + 0.5 - cx, dy = y + 0.5 - cy;
if (dx * dx + dy * dy <= r2) { const k = y * MW + x; V[k] = 1; X[k] = 1; }
}
}
function updateVision() {
for (const t of [P, E]) G.vis[t].fill(0);
for (const u of G.units) if (!u.dead) { markCircle(u.team, u.x, u.y, u.s.sight); }
for (const b of G.blds) if (!b.dead) markCircle(b.team, b.cx, b.cy, b.done ? BLD[b.type].sight : 3);
// player memory of enemy buildings (last-known silhouettes)
const mem = G.mem[P];
for (const b of G.blds) {
if (b.team !== E || b.dead) continue;
if (vis(P, b.cx, b.cy)) mem.set(b.id, { id: b.id, type: b.type, x: b.x, y: b.y, w: b.w, h: b.h, team: E, seen: G.t });
}
for (const [id, m] of mem) {
const e = G.ent.get(id);
if (!e || e.dead) { if (vis(P, m.x + m.w / 2, m.y + m.h / 2)) mem.delete(id); }
}
// AI memory (same rules)
const amem = G.ai.known;
for (const b of G.blds) {
if (b.team !== P || b.dead) continue;
if (vis(E, b.cx, b.cy)) amem.set(b.id, { id: b.id, type: b.type, x: b.x, y: b.y, w: b.w, h: b.h, team: P, seen: G.t });
}
for (const [id, m] of amem) {
const e = G.ent.get(id);
if (!e || e.dead) { if (vis(E, m.x + m.w / 2, m.y + m.h / 2)) amem.delete(id); }
}
}
// ============ TARGETING ============
function canHit(att, tgt) { return !tgt.flyer || att.canAir || att.isTurret; }
function validTarget(u, t) {
if (!t || t.dead || t.team === u.team) return false;
if (t.isDep) return false;
if (!visEnt(u.team, t)) return false;
return canHit(u, t);
}
function findTarget(u, range) {
let best = null, bd = 1e9;
const list = near(u.x, u.y, range);
for (let i = 0; i < list.length; i++) {
const e = list[i];
if (e.team === u.team || e.dead || !canHit(u, e) || !visEnt(u.team, e)) continue;
const d = dist(u.x, u.y, e.x, e.y);
if (d < bd) { bd = d; best = e; }
}
if (best) return best;
// buildings
for (const b of G.blds) {
if (b.team === u.team || b.dead || !visEnt(u.team, b)) continue;
const d = bDist(u, b);
if (d <= range && d < bd) { bd = d; best = b; }
}
return best;
}
// damage
function damageOf(team, base, dtype, armor) {
const up = G.up[team];
const m = base * (up.wpn === 2 ? 1.44 : up.wpn === 1 ? 1.2 : 1);
return m * DMG[dtype][armor];
}
function fire(att, tgt, team, dmg, col, kind) {
const ax = att.x, ay = att.y;
const h0 = heightAt(ax, ay) + (att.flyer ? 2.4 : 0.9);
const tx = tgt.isB ? tgt.cx : tgt.x, ty = tgt.isB ? tgt.cy : tgt.y;
G.fx.push({ k: kind, ax, ay, h0, tx, ty, h1: heightAt(tx, ty) + 0.7, col, life: 0.14 });
if (kind === 'beam' || kind === 'bolt') sfx(kind === 'beam' ? 'lance' : 'shot', att.team === P ? 0.4 : 0.2);
else sfx('shot', att.team === P ? 0.3 : 0.15);
if (tgt.isB) damageBuilding(tgt, dmg, team);
else damageUnit(tgt, dmg, team);
G.fx.push({ k: 'spark', x: tx, y: ty, h: heightAt(tx, ty) + 0.8, col, life: 0.25, team: tgt.team });
}
function damageUnit(u, dmg, byTeam) {
if (u.dead) return;
u.hp -= dmg; u.lastHit = G.t;
if (u.hp <= 0) killUnit(u, byTeam);
}
function damageBuilding(b, dmg, byTeam) {
if (b.dead) return;
b.hp -= dmg; b.lastHit = G.t;
if (b.team === P && G.t - b.alertT > 8) { b.alertT = G.t; alertP(b.cx, b.cy, 'Base under attack: ' + BLD[b.type].name); sfx('attack'); }
if (b.hp <= 0) destroyBuilding(b, byTeam);
}
function destroyBuilding(b, byTeam) {
b.dead = true; G.stats[b.team].lost++; G.stats[byTeam].kills++;
explode(b.cx, b.cy, BLD[b.type].hgt * 0.5, b.team, 2.8);
sfx('boom');
if (b.team === P) alertP(b.cx, b.cy, BLD[b.type].name + ' destroyed', false);
recalcAllSup();
}
function killUnit(u, byTeam) {
if (u.dead) return;
u.dead = true; G.stats[u.team].lost++; G.stats[byTeam].kills++;
explode(u.x, u.y, 0.3, u.team, 0.6);
if (u.team === P && u.isW) sfx('dead', 0.5);
if (u.team === P && !u.isW && G.t - (u.lastAlertT || -9) > 2) { alertP(u.x, u.y, u.s.name + ' lost', true); u.lastAlertT = G.t; }
recalcAllSup();
}
function explode(x, y, h, team, size) {
const col = team === P ? '#7fd6ff' : '#ff7a9a';
G.fx.push({ k: 'boom', x, y, h: heightAt(x, y) + h, life: 0.5, size, col });
for (let i = 0; i < 10 * size; i++) G.fx.push({ k: 'chunk', x, y, h: heightAt(x, y) + h, vx: (rnd() - 0.5) * 6, vy: (rnd() - 0.5) * 6, vh: rnd() * 6, life: 0.6 + rnd() * 0.4, col: rnd() < 0.5 ? col : '#c8d0dc' });
}
// ============ UNIT STEPS ============
const CHASE_R = 0.6;
function unitSpeed(u) { return u.s.speed * (G.up[u.team].spd ? 1.15 : 1); }
function followPath(u, dt) {
if (!u.moving) return;
const sp = unitSpeed(u) * dt;
if (u.flyer) {
const dx = u.gx - u.x, dy = u.gy - u.y, d = Math.hypot(dx, dy);
if (d < 0.2) { stopMove(u); onArrive(u); return; }
const m = Math.min(sp, d); u.x += dx / d * m; u.y += dy / d * m; return;
}
if (!u.path) {
if (!u.pathReq) { return; }
u.pathReq = false;
u.path = findPath(u.x, u.y, u.gx, u.gy);
u.pi = 0;
if (!u.path) { stopMove(u); onArrive(u, true); return; }
u.pathBuilt = true;
}
if (u.pi >= u.path.length) { stopMove(u); onArrive(u); return; }
const c = u.path[u.pi];
let tx = (c % MW) + 0.5, ty = ((c / MW) | 0) + 0.5;
if (u.pi === u.path.length - 1) { tx = u.gx; ty = u.gy; }
let dx = tx - u.x, dy = ty - u.y, d = Math.hypot(dx, dy);
if (d < 0.18) { u.pi++; if (u.pi >= u.path.length) { stopMove(u); onArrive(u); } return; }
const m = Math.min(sp, d);
const nx = u.x + dx / d * m, ny = u.y + dy / d * m;
let moved = false;
if (!G.solid[cidx(nx, u.y)]) { u.x = nx; moved = true; }
if (!G.solid[cidx(u.x, ny)]) { u.y = ny; moved = true; }
if (!moved) { u.stuck += dt; if (u.stuck > 0.6) { u.stuck = 0; u.pathReq = true; u.path = null; u.rep++; if (u.rep > 6) { u.rep = 0; stopMove(u); onArrive(u, true); } } }
}
function onArrive(u, fail) {
if (u.order && u.order.t === 'move') u.order = null;
if (u.order && u.order.t === 'amove' && !fail && dist(u.x, u.y, u.order.x, u.order.y) < 1.2) u.order = null;
if (u.order && u.order.t === 'amove' && fail) u.order = null;
u.rep = 0;
}
function goTo(u, x, y) { setGoal(u, x, y); }
function separate(u, dt) {
const list = near(u.x, u.y, 0.9);
for (let i = 0; i < list.length; i++) {
const o = list[i]; if (o === u || o.dead) continue;
if (o.flyer !== u.flyer) continue;
let dx = u.x - o.x, dy = u.y - o.y; let d2 = dx * dx + dy * dy;
const minD = (u.size + o.size) * 0.9;
if (d2 < 1e-6) { dx = rnd() - 0.5; dy = rnd() - 0.5; d2 = 0.01; }
if (d2 < minD * minD) {
const d = Math.sqrt(d2), push = (minD - d) * 2.2 * dt;
const nx = u.x + dx / d * push, ny = u.y + dy / d * push;
if (u.flyer || !G.solid[cidx(nx, ny)]) { u.x = nx; u.y = ny; }
}
}
}
function workerStep(u, dt) {
const o = u.order;
if (o && o.t === 'build') {
const b = G.ent.get(o.b);
if (!b || b.dead || b.done) { u.order = null; return; }
if (bDist(u, b) <= 1.5) { stopMove(u); return; }
const e = edgeGoal(u, b); setGoal(u, e[0], e[1]); return;
}
if (o && (o.t === 'move' || o.t === 'amove')) { if (!u.moving) u.order = null; return; }
if (o && o.t === 'stop') { stopMove(u); u.order = null; return; }
let dep = null;
if (o && o.t === 'gather') { dep = G.ent.get(o.dep); if (!dep || dep.dead || dep.amt <= 0) { u.order = null; dep = null; } }
const returning = u.carry >= 10 || (o && o.t === 'return') || (u.carry > 0 && !dep && (!o || o.t !== 'gather'));
if (returning && u.carry > 0) {
const drop = nearestDrop(u);
if (!drop) { stopMove(u); return; }
if (bDist(u, drop) <= 1.2) {
oreAdd(u.team, u.carry, drop.cx, drop.cy); u.carry = 0; stopMove(u);
if (o && o.t === 'return') u.order = null;
} else { const e = edgeGoal(u, drop); setGoal(u, e[0], e[1]); }
return;
}
if (o && o.t === 'return' && u.carry === 0) { u.order = null; return; }
if (!dep) {
// auto-pick nearest visible deposit
let best = null, bd = 1e9;
for (const d of G.deps) {
if (d.dead || d.amt <= 0 || G.expl[u.team][cidx(d.x, d.y)] === 0) continue;
const dd = (d.x - u.x) ** 2 + (d.y - u.y) ** 2;
if (dd < bd) { bd = dd; best = d; }
}
dep = best; if (!dep) { stopMove(u); return; }
}
const dd = dist(u.x, u.y, dep.x + 0.5, dep.y + 0.5);
if (dd <= 1.1) {
stopMove(u);
u.mineAcc = (u.mineAcc || 0) + dt;
while (u.mineAcc >= 0.35 && dep.amt > 0 && u.carry < 10) { u.mineAcc -= 0.35; dep.amt--; u.carry++; }
if (dep.amt <= 0) { dep.dead = true; G.ent.delete(dep.id); }
if (u.carry >= 10) u.mineAcc = 0;
} else {
setGoal(u, dep.x + 0.5, dep.y + 0.5);
}
}
// combat-unit step
function combatStep(u, dt) {
u.acq -= dt; u.cd -= dt;
const o = u.order;
let tgt = null;
const holdM = u.hold;
if (o && o.t === 'attack') {
tgt = G.ent.get(o.id);
if (!validTarget(u, tgt)) { u.order = { t: 'amove', x: u.x, y: u.y }; tgt = null; }
}
const auto = !o || o.t === 'amove' || o.t === 'hold' || holdM;
if (u.acq <= 0) {
u.acq = 0.35 + rnd() * 0.1;
if (auto && !(u.target && validTarget(u, u.target) && dist(u.x, u.y, u.target.x || u.target.cx, u.target.y || u.target.cy) < (o ? 12 : u.s.sight + 2))) {
const rng = holdM ? u.s.range + 0.2 : (o && o.t === 'amove' ? u.s.sight : u.s.sight);
u.target = findTarget(u, rng);
}
}
if (!tgt && auto && u.target && validTarget(u, u.target)) tgt = u.target;
else if (!tgt && u.target && !validTarget(u, u.target)) u.target = null;
if (tgt) {
const d = tDist(u, tgt);
if (d <= u.s.range) {
if (u.cd <= 0) {
u.cd = u.s.cd;
const att = { ...u };
const isLance = u.s.range > 3.5 && u.type !== 'hornet';
fire(u, tgt, u.team, damageOf(u.team, u.s.dmg, u.s.dtype, tgt.isB ? 'armor' : (tgt.flyer ? 'light' : (tgt.s ? tgt.s.armor : 'light'))),
u.team === P ? '#7fe8ff' : '#ff6a8a', isLance ? (u.type === 'lancer' ? 'beam' : 'bolt') : 'tracer');
}
if (u.moving && (o && o.t === 'attack' || !o || o.t !== 'move')) { if (u.s.range <= 1.5) {} else { stopMove(u); } }
if (!u.moving) { u.face = Math.atan2(tgt.y - u.y, tgt.x - u.x); }
} else if (!holdM && (u.flyer || true)) {
u.chaseT = (u.chaseT || 0) - dt;
if (u.chaseT <= 0 || !u.moving) {
u.chaseT = 0.4;
if (tgt.isB) { const e = edgeGoal(u, tgt); setGoal(u, e[0], e[1]); }
else setGoal(u, tgt.x, tgt.y);
}
}
} else {
if (o && o.t === 'amove' && !u.moving) {
if (dist(u.x, u.y, o.x, o.y) > 1.3) setGoal(u, o.x, o.y); else u.order = null;
}
}
if (o && o.t === 'move' && !u.moving) u.order = null;
if (u.target && !validTarget(u, u.target)) u.target = null;
}
function buildingStep(b, dt) {
if (!b.done || b.dead) return;
const d = BLD[b.type];
if (!d.atk) return;
b.atkCd -= dt;
if (b.atkCd > 0) return;
let best = null, bd = 1e9;
for (const u of near(b.cx, b.cy, d.atk.range)) {
if (u.team === b.team || u.dead || !canHit({ canAir: true, isTurret: true }, u) || !visEnt(b.team, u)) continue;
const dd = dist(b.cx, b.cy, u.x, u.y); if (dd < bd) { bd = dd; best = u; }
}
if (!best) return;
b.atkCd = d.atk.cd;
const dmg = damageOf(b.team, d.atk.dmg, d.atk.dtype, best.flyer ? 'light' : best.s.armor);
fire({ x: b.cx, y: b.cy, team: b.team, flyer: false, isTurret: true, canAir: true, isB: false }, best, b.team, dmg, b.team === P ? '#7fe8ff' : '#ff6a8a', 'bolt');
}
function unitMain(u, dt) {
if (u.dead) return;
if (u.isW) {
workerStep(u, dt);
} else {
combatStep(u, dt);
}
// face
if (u.moving) u.face = Math.atan2(u.gy - u.y, u.gx - u.x);
u.face = u.face || 0;
followPath(u, dt);
}
// ============ AUDIO (procedural WebAudio) ============
let AC = null, muted = false, lastSay = {};
function audio() { if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { AC = null; } } return AC; }
function tone(f, d, type, vol, slide) {
const c = audio(); if (!c || muted) return;
const o = c.createOscillator(), g = c.createGain();
o.type = type || 'square'; o.frequency.value = f;
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, f * slide), c.currentTime + d);
g.gain.value = vol || 0.05; g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + d);
o.connect(g); g.connect(c.destination); o.start(); o.stop(c.currentTime + d);
}
function noise(d, vol) {
const c = audio(); if (!c || muted) return;
const len = c.sampleRate * d, buf = c.createBuffer(1, len, c.sampleRate), ch = buf.getChannelData(0);
for (let i = 0; i < len; i++) ch[i] = (rnd() * 2 - 1) * (1 - i / len);
const s = c.createBufferSource(), g = c.createGain(); s.buffer = buf; g.gain.value = vol || 0.08;
s.connect(g); g.connect(c.destination); s.start();
}
let sfxCool = {};
function sfx(name, vol) {
const t = G ? G.t : 0;
if (sfxCool[name] && t - sfxCool[name] < 0.08) return;
sfxCool[name] = t;
const v = vol === undefined ? 1 : vol;
switch (name) {
case 'shot': tone(520 + rnd() * 200, 0.08, 'square', 0.02 * v, 0.5); break;
case 'lance': tone(880, 0.18, 'sawtooth', 0.025 * v, 0.4); break;
case 'boom': noise(0.6, 0.12 * v); tone(90, 0.5, 'sine', 0.12 * v, 0.4); break;
case 'attack': tone(300, 0.25, 'triangle', 0.1, 1.6); break;
case 'dead': tone(180, 0.2, 'sawtooth', 0.04, 0.5); break;
case 'click': tone(660, 0.04, 'sine', 0.05 * v); break;
case 'deny': tone(140, 0.12, 'square', 0.05); break;
case 'ready': tone(660, 0.1, 'sine', 0.06); setTimeout(() => tone(990, 0.12, 'sine', 0.06), 90); break;
case 'built': tone(440, 0.12, 'triangle', 0.07); setTimeout(() => tone(660, 0.2, 'triangle', 0.07), 110); break;
case 'research': tone(520, 0.12, 'triangle', 0.07); setTimeout(() => tone(780, 0.25, 'triangle', 0.07), 120); break;
case 'shortage': tone(200, 0.15, 'square', 0.05); break;
case 'mine': tone(1400, 0.02, 'sine', 0.012); break;
}
}
function say(name, vol) { if (!G) return; const t = G.t; if (lastSay[name] && t - lastSay[name] < 2.5) return; lastSay[name] = t; sfx(name, vol); }
function toggleMute() { muted = !muted; document.getElementById('btnMute').textContent = 'Sound: ' + (muted ? 'Off' : 'On') + ' (M)'; }
// ============ FX ============
function fxText(x, y, text, col) { G.fx.push({ k: 'text', x, y, h: 3, text, col, life: 1.0 }); }
// ============ AI ============
// The AI follows the same rules: it only knows what its vision/memory reveals.
function aiTick(dt) {
const ai = G.ai, team = E;
ai.timer -= dt; if (ai.timer > 0) return;
ai.timer = 1.0 + rnd() * 0.6;
const myHq = G.blds.filter(b => b.team === E && !b.dead);
if (!myHq.length) return;
const hq = myHq.find(b => b.hq) || myHq[0];
const cnt = t => G.blds.filter(b => b.team === team && b.type === t && !b.dead).length;
const units = G.units.filter(u => u.team === E && !u.dead);
const drones = units.filter(u => u.isW), army = units.filter(u => !u.isW);
const idleDrones = drones.filter(u => !u.order);
const nDrones = drones.length;
// keep economy running: drones
const hqs = G.blds.filter(b => b.team === E && b.hq && b.done && !b.dead);
// train drones
const dronesWanted = Math.min(18 + Math.floor(G.t / 40), 34);
for (const h of hqs) {
if (nDrones + h.queue.length < dronesWanted && G.res[E] >= 50 && h.queue.length < 2) enqueue(h, 'unit', 'drone');
}
// assign idle drones: gather nearest deposit (drone autopick handles it)
// supply
if (G.supC[E] - G.supU[E] < 8 && G.res[E] >= 100 && !G.blds.some(b => b.team === E && b.type === 'pod' && !b.done && !b.dead) && cnt('pod') < 14) {
aiPlace(E, 'pod', hq, drones);
}
// economy: silos near far deposits
if (cnt('silo') < 3 && G.res[E] >= 120 && nDrones >= 10 && G.t > 60) {
const far = G.deps.filter(d => !d.dead && d.amt > 300 && dist(d.x, d.y, hq.cx, hq.cy) > 14 && !ai.claimed);
if (far.length && !G.blds.some(b => b.team === E && b.type === 'silo' && !b.done && !b.dead)) {
const d = far.sort((a, b) => dist(a.x, a.y, hq.cx, hq.cy) - dist(b.x, b.y, hq.cx, hq.cy))[0];
aiPlaceNear(E, 'silo', d.x + 2, d.y + 2, drones);
}
}
// production core
if (!cnt('barracks') && G.res[E] >= 150) aiPlace(E, 'barracks', hq, drones);
if (cnt('barracks') && !cnt('lab') && G.res[E] >= 200 && G.t > 90) aiPlace(E, 'lab', hq, drones);
if (cnt('barracks') && !cnt('foundry') && G.res[E] >= 300 && G.t > 150) aiPlace(E, 'foundry', hq, drones);
if (cnt('barracks') && !cnt('aero') && G.res[E] >= 300 && G.t > 240) aiPlace(E, 'aero', hq, drones);
if (cnt('barracks') && cnt('turret') < 2 && G.res[E] >= 250 && G.t > 180) aiPlace(E, 'turret', hq, drones);
// extra barracks / factories
if (cnt('barracks') < 2 && G.res[E] >= 400 && G.t > 200) aiPlace(E, 'barracks', hq, drones);
// expansion
if (G.t > 300 && cnt('cp') < 2 && G.res[E] >= 500 && G.supC[E] >= 16) aiPlace(E, 'cp', hq, drones);
// research
for (const lab of G.blds.filter(b => b.team === E && b.type === 'lab' && b.done && !b.dead)) {
for (const k of ['wpn1', 'arm1', 'spd1', 'wpn2']) {
if (!G.up[E].done[k] && G.res[E] > UPG[k].cost + 100 && !enqueueCheck(lab, 'upg', k)) { enqueue(lab, 'upg', k); break; }
}
}
// production (mix)
for (const b of G.blds.filter(b => b.team === E && b.done && !b.dead && b.queue.length < 3 && BLD[b.type].trains)) {
if (b.type === 'hq' || b.type === 'cp') continue;
const choices = BLD[b.type].trains.filter(t => t !== 'drone');
if (!choices.length) continue;
const pick = choices[Math.floor(rnd() * choices.length)];
if (!enqueueCheck(b, 'unit', pick)) enqueue(b, 'unit', pick);
}
// attacks: wave-based. first raid around 4 min, larger with time
ai.waveT = ai.waveT ? ai.waveT : 0;
const armyCount = army.length;
if (!ai.wave && G.t > (ai.nextWave || 240) && armyCount >= 6 + ai.waves * 2) {
ai.wave = true; ai.waves++; ai.nextWave = G.t + 180 + Math.max(0, 80 - ai.waves * 6);
const tgt = aiTargetFind();
for (const u of army) { u.order = { t: 'amove', x: tgt.x, y: tgt.y }; }
ai.target = tgt;
}
if (ai.wave) {
const alive = G.units.filter(u => u.team === E && !u.dead && !u.isW);
if (alive.length < 3) { ai.wave = false; }
else {
// retarget if enemy base destroyed or lost
if (!ai.target || !G.blds.some(b => b.team === P && !b.dead && b.x === ai.target.x && b.y === ai.target.y)) {
const tg = aiTargetFind(); ai.target = tg;
}
for (const u of alive) if (!u.order || u.order.t === 'move') u.order = { t: 'amove', x: ai.target.x, y: ai.target.y };
}
} else {
// defend: rally army at home
for (const u of army) if (!u.order || (u.order.t === 'amove' && dist(u.x, u.y, hq.cx, hq.cy) > 18)) u.order = { t: 'amove', x: hq.cx + 2, y: hq.cy + 2 };
}
// defend home against visible threats
for (const b of G.blds) {
if (b.team !== E || b.dead) continue;
}
const threat = G.units.find(u => u.team === P && !u.dead && vis(E, u.x, u.y) && dist(u.x, u.y, hq.cx, hq.cy) < 14);
if (threat && !ai.wave) {
for (const u of army) u.order = { t: 'amove', x: threat.x, y: threat.y };
}
// scouting: send a drone/unit toward unexplored areas
if (G.t > 35 && !ai.scouted) { ai.scouted = true; }
if (!ai.wave && army.length && G.t > 25 && !ai.scout) {
const scoutUnit = army.find(u => !u.order || u.order.t !== 'amove') || army[0];
const dst = unexploredPoint(E, hq.cx, hq.cy);
if (dst) { ai.scout = scoutUnit.id; scoutUnit.order = { t: 'amove', x: dst.x, y: dst.y }; }
}
if (ai.scout && !G.ent.get(ai.scout)) ai.scout = null;
if (ai.scout && G.ent.get(ai.scout) && !G.ent.get(ai.scout).order) ai.scout = null;
// economy: rebuild lost HQ/expansion
if (!G.blds.some(b => b.team === E && b.hq && !b.dead) && G.res[E] >= 0) { /* defeated */ }
// assign idle drones: auto-gather is handled
// AI silos: near deposit with enough stock
if (G.res[E] > 1200) { /* spend more: extra barracks */ if (cnt('barracks') < 4) aiPlace(E, 'barracks', hq, drones); }
}
function aiTargetFind() {
// AI knows only remembered enemy structures; otherwise gets a probing point across the map
const mem = [...G.ai.known.values()].filter(m => m.team === P);
if (mem.length) { const m = mem[Math.floor(rnd() * mem.length)]; return { x: m.x + m.w / 2, y: m.y + m.h / 2 }; }
return { x: BASE_P[0] + 2, y: BASE_P[1] + 2 };
}
function unexploredPoint(team, sx, sy) {
for (let tries = 0; tries < 40; tries++) {
const x = 3 + rnd() * (MW - 6), y = 3 + rnd() * (MH - 6);
if (!G.expl[team][cidx(x, y)] && !G.terr[cidx(x, y)]) return { x, y };
}
return null;
}
function aiPlace(team, type, hq, drones) {
if (G.res[team] < BLD[type].cost) return false;
if (BLD[type].req && !hasBld(team, BLD[type].req)) return false;
const spots = [];
const cx = hq.cx, cy = hq.cy;
for (let r = 5; r < 22; r += 2) {
for (let a = 0; a < 16; a++) {
const ang = a / 16 * Math.PI * 2;
const x = Math.round(cx + Math.cos(ang) * r - BLD[type].w / 2), y = Math.round(cy + Math.sin(ang) * r - BLD[type].h / 2);
if (!placeErr(type, x, y, team)) { spots.push([x, y]); }
}
if (spots.length) break;
}
if (!spots.length) return false;
const [x, y] = spots[Math.floor(rnd() * spots.length)];
const d = drones.find(u => !u.order || u.order.t === 'gather') || null;
return startBuild(team, type, x, y, d) === null;
}
function aiPlaceNear(team, type, nx, ny, drones) {
const cands = [];
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
const x = Math.round(nx + dx), y = Math.round(ny + dy);
if (!placeErr(type, x, y, team)) cands.push([x, y]);
}
if (!cands.length) return false;
const [x, y] = cands[0];
const d = drones[0] || null;
return startBuild(team, type, x, y, d) === null;
}
// ============ SIMULATION TICK ============
let simAcc = 0;
function simTick(dt) {
G.t += dt;
// rebuild spatial hash for neighbor queries
buildHash();
// AI first so orders apply this tick
G.aiT += dt; if (G.aiT >= 0.5) { G.aiT = 0; aiTick(0.5); }
updateConstruction(dt);
updateQueues(dt);
// path requests (budgeted)
let budget = 40;
while (pathQ.length && budget-- > 0) {
const u = pathQ.shift(); if (u.dead) continue;
u.pathReq = false;
if (u.moving && !u.flyer) { u.path = findPath(u.x, u.y, u.gx, u.gy); u.pi = 0; if (!u.path) { u.gx = u.x; u.gy = u.y; stopMove(u); onArrive(u, true); } }
}
for (const u of G.units) {
if (u.dead) continue;
unitMain(u, dt);
}
for (const b of G.blds) if (!b.dead) buildingStep(b, dt);
for (const u of G.units) if (!u.dead && !u.flyer) separate(u, dt);
for (const u of G.units) if (!u.dead && u.flyer) separate(u, dt);
// keep units off solid cells
for (const u of G.units) {
if (u.dead || u.flyer) continue;
if (G.solid[cidx(u.x, u.y)] && !G.occ.length) continue;
const k = cidx(u.x, u.y);
if (G.solid[k] && G.occ[k] === -1) { const f = nearestFree(u.x, u.y, 2); if (f >= 0) { u.x = (f % MW) + 0.5; u.y = ((f / MW) | 0) + 0.5; } }
}
// drop buildings whose hp fell
for (const b of G.blds) if (b.dead) { /* removed in cleanup */ }
for (const fx of G.fx) fx.life -= dt;
G.fx = G.fx.filter(f => f.life > 0);
G.alerts = G.alerts.filter(a => G.t - a.t < 30).slice(-40);
cleanupDead();
G.visT += dt; if (G.visT >= 0.25) { G.visT = 0; updateVision(); }
checkWin();
}
function cleanupDead() {
let any = false;
for (const b of G.blds) if (b.dead) { freeBld(b); G.ent.delete(b.id); any = true; }
if (any) G.blds = G.blds.filter(b => !b.dead);
let anyU = false;
for (const u of G.units) if (u.dead) { G.ent.delete(u.id); anyU = true; }
if (anyU) G.units = G.units.filter(u => !u.dead);
for (const u of G.units) if (u.target && (u.target.dead || !G.ent.has(u.target.id))) u.target = null;
for (const dp of G.deps) if (dp.dead) G.ent.delete(dp.id);
if (G.deps.some(d => d.dead)) G.deps = G.deps.filter(d => !d.dead);
// dead handles for selection
if (UIS.sel.some(e => e.dead)) UIS.sel = UIS.sel.filter(e => !e.dead);
for (const k in UIS.groups) UIS.groups[k] = UIS.groups[k].filter(e => !e.dead);
if (any || anyU) recalcAllSup();
if (G.ent.size < 0) console.log('x');
}
function checkWin() {
if (G.over) return;
const pHq = G.blds.some(b => b.team === P && b.hq && !b.dead);
const eHq = G.blds.some(b => b.team === E && b.hq && !b.dead);
if (!pHq) endGame(E);
else if (!eHq) endGame(P);
}
function endGame(winner) {
if (G.over) return;
G.over = true; G.winner = winner;
const win = winner === P;
document.getElementById('endTitle').textContent = win ? 'VICTORY' : 'DEFEAT';
document.getElementById('endTitle').style.color = win ? '#7dffb0' : '#ff7a8a';
const s = G.stats[P];
document.getElementById('endText').innerHTML = (win ? 'The enemy HQs have fallen. The frontier is yours.' : 'Your last Headquarters was destroyed.') +
'<br><br>Time: ' + fmtT(G.t) + ' · Units lost: ' + s.lost + ' · Enemy losses: ' + G.stats[E].lost + ' · Structures built: ' + s.built;
document.getElementById('end').classList.remove('hide');
}
// ============ RENDERING (projected 3D, painter-sorted) ============
const cvs = document.getElementById('gl');
const ctx = cvs.getContext('2d');
const mmC = document.getElementById('mm'), mmX = mmC.getContext('2d');
const mmBuf = document.createElement('canvas'); mmBuf.width = MW; mmBuf.height = MH; const mmBx = mmBuf.getContext('2d');
let VW = 1, VHt = 1, DPR = 1;
const CV = { px: 0, py: 0, pz: 0, fx: 0, fy: 0, fz: 0, rx: 0, ry: 0, rz: 0, ux: 0, uy: 0, uz: 0, f: 1, cx: 0, cy: 0 };
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
VW = innerWidth; VHt = innerHeight;
cvs.width = VW * DPR; cvs.height = VHt * DPR;
cvs.style.width = VW + 'px'; cvs.style.height = VHt + 'px';
CV.cx = VW / 2; CV.cy = VHt / 2 - 20;
CV.f = (VHt * 0.5) / Math.tan(0.39);
}
window.addEventListener('resize', resize);
function camSetup() {
const ty = heightAt(CAM.fx, CAM.fz);
const cp = Math.cos(CAM.pitch), sp = Math.sin(CAM.pitch);
CV.px = CAM.fx + CAM.dist * Math.sin(CAM.yaw) * cp;
CV.py = ty + CAM.dist * sp + 0;
CV.pz = CAM.fz + CAM.dist * Math.cos(CAM.yaw) * cp;
let fx = CAM.fx - CV.px, fy = ty - CV.py, fz = CAM.fz - CV.pz;
const fl = Math.hypot(fx, fy, fz); fx /= fl; fy /= fl; fz /= fl;
CV.fx = fx; CV.fy = fy; CV.fz = fz;
// right = normalize(-fz, 0, fx)
let rx = -fz, rz = fx; const rl = Math.hypot(rx, rz); CV.rx = rx / rl; CV.ry = 0; CV.rz = rz / rl;
// up = right x fwd
CV.ux = CV.ry * fz - CV.rz * fy; CV.uy = CV.rz * fx - CV.rx * fz; CV.uz = CV.rx * fy - CV.ry * fx;
}
// project world (x, y, z) -> {x,y,d} ; d is depth along forward; returns null if behind
const PJ = { x: 0, y: 0, d: 0 };
function proj(x, y, z) {
const dx = x - CV.px, dy = y - CV.py, dz = z - CV.pz;
const d = dx * CV.fx + dy * CV.fy + dz * CV.fz;
if (d < 0.5) return null;
const sx = dx * CV.rx + dy * CV.ry + dz * CV.rz;
const sy = dx * CV.ux + dy * CV.uy + dz * CV.uz;
PJ.x = CV.cx + CV.f * sx / d; PJ.y = CV.cy - CV.f * sy / d; PJ.d = d;
return PJ;
}
function scaleAt(d) { return CV.f / d; }
// screen -> ground point (on terrain)
function screenToGround(sx, sy) {
const xc = (sx - CV.cx) / CV.f, yc = -(sy - CV.cy) / CV.f;
let dx = CV.fx + CV.rx * xc + CV.ux * yc, dy = CV.fy + CV.ry * xc + CV.uy * yc, dz = CV.fz + CV.rz * xc + CV.uz * yc;
let h = 0, t = 0;
for (let it = 0; it < 3; it++) {
t = (h - CV.py) / dy;
const gx = CV.px + dx * t, gz = CV.pz + dz * t;
h = heightAt(gx, gz);
}
t = (h - CV.py) / dy;
return { x: CV.px + dx * t, z: CV.pz + dz * t };
}
function shade(hex, k) { return hex; }
function rgb(r, g, b, a) { return a === undefined ? `rgb(${r|0},${g|0},${b|0})` : `rgba(${r|0},${g|0},${b|0},${a})`; }
const BUCK = 12;
const bucketPaths = Array.from({ length: BUCK }, () => []);
const bucketCols = [];
function buildBucketColors() {
// index = state*4 + tone
for (let st = 0; st < 3; st++) for (let t = 0; t < 4; t++) {
const k = t / 3;
let c;
if (st === 0) c = [6, 8, 14];
else if (st === 1) c = [28 + k * 8, 36 + k * 6, 40 + k * 6];
else c = [70 + k * 26, 92 + k * 18, 84 + k * 10];
bucketCols.push(st === 0 ? rgb(...c) : rgb(...c));
}
}
buildBucketColors();
// ground terrain pass
function drawTerrain() {
const ex = G.expl[P], vs = G.vis[P];
for (const b of bucketPaths) b.length = 0;
const range = CAM.dist * 2.2 + 16;
const x0 = Math.max(0, Math.floor(CAM.fx - range)), x1 = Math.min(MW - 1, Math.ceil(CAM.fx + range));
const z0 = Math.max(0, Math.floor(CAM.fz - range)), z1 = Math.min(MH - 1, Math.ceil(CAM.fz + range));
for (let y = z0; y <= z1; y++) for (let x = x0; x <= x1; x++) {
const k = y * MW + x;
if (G.terr[k]) continue;
const st = vs[k] ? 2 : ex[k] ? 1 : 0;
const tb = st === 0 ? 0 : (TONE[k] * 4) | 0;
const bi = st === 0 ? 0 : st * 4 + Math.min(3, tb);
const bucketIdx = st === 0 ? 0 : bi;
const a = VH[y * W1 + x], b = VH[y * W1 + x + 1], c = VH[(y + 1) * W1 + x + 1], d = VH[(y + 1) * W1 + x];
if (!proj(x, a, y)) continue;
const p0 = { x: PJ.x, y: PJ.y }; proj(x + 1, b, y); const p1 = { x: PJ.x, y: PJ.y }; proj(x + 1, c, y + 1); const p2 = { x: PJ.x, y: PJ.y }; proj(x, d, y + 1); const p3 = { x: PJ.x, y: PJ.y };
bucketPaths[bucketIdx].push(p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
}
for (let bi = 0; bi < BUCK; bi++) {
const arr = bucketPaths[bi]; if (!arr.length) continue;
const colIdx = bi;
ctx.fillStyle = bucketCols[colIdx] || '#000';
ctx.beginPath();
for (let i = 0; i < arr.length; i += 8) {
ctx.moveTo(arr[i], arr[i + 1]); ctx.lineTo(arr[i + 2], arr[i + 3]); ctx.lineTo(arr[i + 4], arr[i + 5]); ctx.lineTo(arr[i + 6], arr[i + 7]); ctx.closePath();
}
ctx.fill();
}
// subtle grid on visible ground
ctx.strokeStyle = 'rgba(120,200,255,0.05)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let x = Math.floor(CAM.fx - 14); x <= CAM.fx + 14; x += 4) {
if (x < 0 || x > MW) continue;
for (let z = Math.floor(CAM.fz - 14); z < CAM.fz + 14; z += 1) {
if (z < 0 || z >= MH) continue;
if (!vs[cidx(x, z)]) continue;
const p = proj(x, heightAt(x, z) + 0.02, z); if (!p) continue; ctx.moveTo(p.x, p.y);
const q = proj(x, heightAt(x, z + 1) + 0.02, z + 1); if (q) ctx.lineTo(q.x, q.y);
}
}
ctx.stroke();
}
// territory halo and shadows (ground-level)
function drawGroundDecor() {
ctx.lineWidth = 1.5;
for (const b of G.blds) {
if (b.team !== P || b.dead || !b.done) continue;
const r = 6;
ctx.beginPath(); let first = true;
for (let i = 0; i <= 24; i++) {
const a = i / 24 * Math.PI * 2, x = b.cx + Math.cos(a) * r, z = b.cy + Math.sin(a) * r;
const p = proj(x, heightAt(x, z) + 0.05, z); if (!p) continue;
if (first) { ctx.moveTo(p.x, p.y); first = false; } else ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = 'rgba(110,200,255,0.18)'; ctx.stroke();
}
}
function groundShadow(x, z, rad, alpha) {
const h = heightAt(x, z);
const p = proj(x, h + 0.03, z); if (!p) return;
const s = scaleAt(p.d);
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(1.5, rad * s), Math.max(1, rad * s * 0.5), 0, 0, Math.PI * 2); ctx.fill();
}
function shadowPass() {
for (const u of G.units) {
if (u.dead || !visEnt(P, u) && u.team !== P) continue;
if (u.team !== P && !visEnt(P, u)) continue;
if (u.flyer) { const h = heightAt(u.x, u.y) + 2.4; const p = proj(u.x, h, u.y); if (!p) continue; groundShadow(u.x, u.y, u.size * 1.2, 0.25); }
else groundShadow(u.x, u.y, u.size * 1.2, 0.35);
}
for (const b of G.blds) {
if (b.dead) continue;
if (b.team !== P && !visEnt(P, b)) continue;
const d = BLD[b.type];
const h = heightAt(b.cx, b.cy);
const s = b.hw || 0;
// dark footprint
const pts = [[b.x, b.y], [b.x + b.w, b.y], [b.x + b.w, b.y + b.h], [b.x, b.y + b.h]];
ctx.beginPath(); let f = true;
for (const [x, z] of pts) { const p = proj(x, heightAt(x, z) + 0.02, z); if (!p) continue; if (f) { ctx.moveTo(p.x, p.y); f = false; } else ctx.lineTo(p.x, p.y); }
ctx.closePath(); ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.fill();
}
}
// draws ground-selection rings and move markers
function selectionPass() {
for (const e of UIS.sel) {
if (e.dead) continue;
if (e.isB) {
const pts = [[e.x - 0.2, e.y - 0.2], [e.x + e.w + 0.2, e.y - 0.2], [e.x + e.w + 0.2, e.y + e.h + 0.2], [e.x - 0.2, e.y + e.h + 0.2]];
ctx.beginPath(); let f = true;
for (const [x, z] of pts) { const p = proj(x, heightAt(x, z) + 0.05, z); if (!p) continue; if (f) { ctx.moveTo(p.x, p.y); f = false; } else ctx.lineTo(p.x, p.y); }
ctx.closePath(); ctx.strokeStyle = '#ffe066'; ctx.lineWidth = 2; ctx.stroke();
} else {
const h = heightAt(e.x, e.y);
const p = proj(e.x, h + 0.05, e.y); if (!p) continue;
const s = scaleAt(p.d);
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(6, (e.size + 0.25) * s * 1.1), Math.max(3, (e.size + 0.25) * s * 0.55), 0, 0, Math.PI * 2);
ctx.strokeStyle = e.team === P ? '#8dff9c' : '#ff5555'; ctx.lineWidth = 2; ctx.stroke();
}
}
// order markers for selected own units
const t = G.t;
for (const u of UIS.sel) {
if (!u.moving || u.dead || u.team !== P) continue;
const h = heightAt(u.gx, u.gy);
const p = proj(u.gx, h + 0.1, u.gy); if (!p) continue;
const s = scaleAt(p.d);
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(3, 0.5 * s) + Math.sin(t * 8) * 1.5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(160,255,160,0.8)'; ctx.lineWidth = 2; ctx.stroke();
break;
}
}
// ============ objects ============
const OBJ = [];
function pushObj(d, fn) { OBJ.push({ d, fn }); }
function drawRock(x, y) {
const k = y * MW + x;
const t = TONE[k];
const ha = VH[y * W1 + x] + 0.7, hb = VH[y * W1 + x + 1] + 0.7, hc = VH[(y + 1) * W1 + x + 1] + 0.7, hd = VH[(y + 1) * W1 + x] + 0.7;
const top = [[x, ha, y], [x + 1, hb, y], [x + 1, hc, y + 1], [x, hd, y + 1]];
const mid = proj(x + 0.5, (ha + hc) / 2, y + 0.5); if (!mid) return;
const base = 92 + t * 30;
const side = (A, B, ya, yb, nx, nz, col) => {
// face visible if camera on the outward side
const mx = (A[0] + B[0]) / 2, mz = (A[2] + B[2]) / 2;
if ((CV.px - mx) * nx + (CV.pz - mz) * nz <= 0) return;
const p1 = proj(A[0], ya, A[2]), p2 = proj(B[0], yb, B[2]), p3 = proj(B[0], B[1] !== undefined ? yb : 0, B[2]);
if (!p1 || !p2) return;
const pb1 = proj(A[0], heightAtFloor(A[0], A[2]), A[2]), pb2 = proj(B[0], heightAtFloor(B[0], B[2]), B[2]);
if (!pb1 || !pb2) return;
ctx.fillStyle = rgb(base * 0.55, base * 0.6, base * 0.7);
ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(pb2.x, pb2.y); ctx.lineTo(pb1.x, pb1.y); ctx.closePath(); ctx.fill();
};
const fnn = () => {
// sides
if (!G.terr[y * MW + x + (x > 0 ? 0 : 0)]) {}
const nb = (dx, dy) => { const xx = x + dx, yy = y + dy; return xx < 0 || yy < 0 || xx >= MW || yy >= MH || !G.terr[yy * MW + xx]; };
if (nb(0, -1)) side([x, 0, y], [x + 1, 0, y], 0, 0, 0, -1, 0);
if (nb(0, 1)) side([x, 0, y + 1], [x + 1, 0, y + 1], 0, 0, 0, 1, 0);
if (nb(-1, 0)) side([x, 0, y], [x, 0, y + 1], 0, 0, -1, 0, 0);
if (nb(1, 0)) side([x + 1, 0, y], [x + 1, 0, y + 1], 0, 0, 1, 0, 0);
ctx.beginPath();
for (let i = 0; i < 4; i++) { const p = proj(top[i][0], top[i][1], top[i][2]); if (!p) return; if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }
ctx.closePath(); ctx.fillStyle = rgb(base * 0.9, base * 0.95, base * 1.05); ctx.fill();
};
// draw-time side faces use the top corners; fn draws everything
pushObj(mid.d, () => drawRockFn(x, y, t));
}
function heightAtFloor(x, z) { return heightAt(x, z); }
function drawRockFn(x, y, t) {
const base = 96 + t * 34;
const hv = (ix, iy) => VH[iy * W1 + ix] + 0.7;
const A = [x, y], B = [x + 1, y], C = [x + 1, y + 1], D = [x, y + 1];
const hA = hv(x, y), hB = hv(x + 1, y), hC = hv(x + 1, y + 1), hD = hv(x, y + 1);
const sideFace = (p1, p2, h1, h2, nx, nz, shadeK) => {
const mx = (p1[0] + p2[0]) / 2, mz = (p1[1] + p2[1]) / 2;
if ((CV.px - mx) * nx + (CV.pz - mz) * nz <= 0) return;
const q1 = proj(p1[0], h1, p1[1]), q2 = proj(p2[0], h2, p2[1]);
const g1 = proj(p1[0], heightAt(p1[0], p1[1]), p1[1]), g2 = proj(p2[0], heightAt(p2[0], p2[1]), p2[1]);
if (!q1 || !q2 || !g1 || !g2) return;
ctx.beginPath(); ctx.moveTo(q1.x, q1.y); ctx.lineTo(q2.x, q2.y); ctx.lineTo(g2.x, g2.y); ctx.lineTo(g1.x, g1.y); ctx.closePath();
ctx.fillStyle = rgb(base * shadeK, base * shadeK * 1.05, base * shadeK * 1.15); ctx.fill();
};
const nb = (dx, dy) => { const xx = x + dx, yy = y + dy; return xx < 0 || yy < 0 || xx >= MW || yy >= MH || !G.terr[yy * MW + xx]; };
if (nb(0, -1)) sideFace(A, B, hA, hB, 0, -1, 0.6);
if (nb(0, 1)) sideFace(D, C, hD, hC, 0, 1, 0.7);
if (nb(-1, 0)) sideFace(A, D, hA, hD, -1, 0, 0.65);
if (nb(1, 0)) sideFace(B, C, hB, hC, 1, 0, 0.8);
const pa = proj(A[0], hA, A[1]), pb = proj(B[0], hB, B[1]), pc = proj(C[0], hC, C[1]), pd = proj(D[0], hD, D[1]);
if (!pa || !pb || !pc || !pd) return;
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.lineTo(pc.x, pc.y); ctx.lineTo(pd.x, pd.y); ctx.closePath();
ctx.fillStyle = rgb(base, base * 1.05, base * 1.12); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1; ctx.stroke();
}
function collectRocks() {
const ex = G.expl[P], range = CAM.dist * 1.6 + 10;
const x0 = Math.max(0, Math.floor(CAM.fx - range)), x1 = Math.min(MW - 1, Math.ceil(CAM.fx + range));
const z0 = Math.max(0, Math.floor(CAM.fz - range)), z1 = Math.min(MH - 1, Math.ceil(CAM.fz + range));
for (let y = z0; y <= z1; y++) for (let x = x0; x <= x1; x++) {
const k = y * MW + x;
if (!G.terr[k] || !ex[k]) continue;
const p = proj(x + 0.5, heightAt(x + 0.5, y + 0.5) + 0.7, y + 0.5); if (!p) continue;
if (p.x < -60 || p.x > VW + 60 || p.y < -60 || p.y > VHt + 60) continue;
pushObj(p.d, () => drawRockFn(x, y, TONE[k]));
}
}
function collectDeposits() {
for (const d of G.deps) {
if (!G.expl[P][cidx(d.x, d.y)]) continue;
const p = proj(d.x + 0.5, heightAt(d.x, d.y), d.y + 0.5); if (!p) continue;
pushObj(p.d, () => drawDeposit(d));
}
}
function drawDeposit(d) {
const h = heightAt(d.x + 0.5, d.y + 0.5);
const p = proj(d.x + 0.5, h, d.y + 0.5); if (!p) return;
const s = scaleAt(p.d);
const amt = d.amt / d.max;
const seen = G.vis[P][cidx(d.x, d.y)] === 1;
const n = 5;
const bright = seen ? 1 : 0.55;
for (let i = 0; i < n; i++) {
const a = i * 1.7 + d.x, r = (0.3 + (i % 3) * 0.2) * (0.5 + amt * 0.6);
const hx = Math.cos(a) * 0.5 * (0.6 + i * 0.1), hz = Math.sin(a) * 0.5;
const top = proj(d.x + 0.5 + hx, h + 0.4 + r, d.y + 0.5 + hz); if (!top) continue;
const bot = proj(d.x + 0.5 + hx, h, d.y + 0.5 + hz); if (!bot) continue;
const w = Math.max(2, 0.18 * s);
ctx.fillStyle = `rgba(${(120 + i * 20) | 0},${(255 - i * 12) | 0},${(200 + i * 8) | 0},${bright})`;
ctx.beginPath(); ctx.moveTo(top.x, top.y); ctx.lineTo(bot.x + w, bot.y); ctx.lineTo(bot.x - w, bot.y); ctx.closePath(); ctx.fill();
}
ctx.fillStyle = `rgba(125,255,180,${0.35 * bright})`;
const g = proj(d.x + 0.5, h + 0.02, d.y + 0.5);
if (g) { ctx.beginPath(); ctx.ellipse(g.x, g.y, 0.9 * s, 0.45 * s, 0, 0, Math.PI * 2); ctx.fill(); }
}
// ============ UNITS & BUILDINGS RENDER ============
function teamCol(team, a) { return team === P ? [80, 170, 255] : [255, 90, 110]; }
function drawBox(b, col, h0, hgt, alpha, bright) {
// footprint corners (x,z), world-space heights from terrain
const x0 = b.x + 0.05, x1 = b.x + b.w - 0.05, z0 = b.y + 0.05, z1 = b.y + b.h - 0.05;
const gh = heightAt(b.cx, b.cy);
const base = gh + h0, top = base + hgt;
const c = [[x0, z0], [x1, z0], [x1, z1], [x0, z1]];
const faces = [
{ n: [0, -1], a: 0, b: 1 }, { n: [1, 0], a: 1, b: 2 }, { n: [0, 1], a: 2, b: 3 }, { n: [-1, 0], a: 3, b: 0 }
];
const T = c.map(p => proj(p[0], top, p[1]));
const B = c.map(p => proj(p[0], base, p[1]));
if (T.some(v => !v) || B.some(v => !v)) return;
const mx = (x0 + x1) / 2, mz = (z0 + z1) / 2;
const shades = [0.7, 0.85, 0.6, 0.95];
const vis = [];
for (let i = 0; i < 4; i++) {
const f = faces[i];
const fx = c[f.a][0] + c[f.b][0], fz = c[f.a][1] + c[f.b][1];
const dx = CV.px - fx / 2, dz = CV.pz - fz / 2;
if (dx * f.n[0] + dz * f.n[1] > 0) vis.push(i);
}
ctx.save();
ctx.globalAlpha = alpha;
for (const i of vis) {
const f = faces[i];
ctx.beginPath();
ctx.moveTo(T[f.a].x, T[f.a].y); ctx.lineTo(T[f.b].x, T[f.b].y); ctx.lineTo(B[f.b].x, B[f.b].y); ctx.lineTo(B[f.a].x, B[f.a].y); ctx.closePath();
const s = shades[i] * bright;
ctx.fillStyle = rgb(col[0] * s, col[1] * s, col[2] * s); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 1; ctx.stroke();
}
ctx.beginPath(); ctx.moveTo(T[0].x, T[0].y); for (let i = 1; i < 4; i++) ctx.lineTo(T[i].x, T[i].y); ctx.closePath();
ctx.fillStyle = rgb(Math.min(255, col[0] * 1.15 + 20), Math.min(255, col[1] * 1.15 + 20), Math.min(255, col[2] * 1.15 + 20)); ctx.fill();
ctx.restore();
return { T, top: T.reduce((a, p) => Math.min(a, p.y), 1e9) };
}
function drawBuilding(b) {
const d = BLD[b.type];
const vs = G.vis[P];
const mine = b.team === P;
const seen = mine || vs[cidx(b.cx, b.cy)] === 1;
if (!mine && !seen) {
// last-known silhouette
const m = G.mem[P].get(b.id);
if (!m) return;
}
const col = mine ? d.col : [d.col[0] * 0.9 + 60, d.col[1] * 0.5 + 25, d.col[2] * 0.5 + 30];
const prog = b.done ? 1 : b.prog;
const hgt = b.done ? d.hgt : d.hgt * Math.max(0.15, prog);
const alpha = b.done ? 1 : 0.6 + 0.4 * Math.sin(G.t * 6) * 0.2;
const bright = seen ? (b.flash > 0 ? 1.8 : 1) : 0.5;
if (b.flash > 0) b.flash -= 0.02;
const r = drawBox(b, col, 0, hgt, alpha, bright);
if (!r) return;
// functional roof details
const cp = proj(b.cx, heightAt(b.cx, b.cy) + hgt + 0.1, b.cy); if (!cp) return;
const s = scaleAt(cp.d);
ctx.save();
if (b.done) {
if (b.type === 'hq' || b.type === 'cp') { ctx.fillStyle = 'rgba(160,220,255,0.9)'; ctx.beginPath(); ctx.arc(cp.x, cp.y - 0.4 * s, 0.9 * s, 0, 7); ctx.fill(); }
if (b.type === 'barracks') { ctx.fillStyle = '#cfd6e0'; ctx.fillRect(cp.x - 0.6 * s, cp.y - 0.6 * s, 1.2 * s, 0.5 * s); }
if (b.type === 'lab') { ctx.fillStyle = '#e0a8ff'; ctx.beginPath(); ctx.arc(cp.x, cp.y, 0.7 * s, 0, 7); ctx.fill(); }
if (b.type === 'foundry') { ctx.fillStyle = '#ffb07a'; ctx.fillRect(cp.x - 0.4 * s, cp.y - 0.9 * s, 0.8 * s, 0.9 * s); }
if (b.type === 'aero') { ctx.strokeStyle = '#bff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(cp.x - 1.1 * s, cp.y); ctx.lineTo(cp.x + 1.1 * s, cp.y); ctx.stroke(); }
if (b.type === 'turret') { ctx.fillStyle = '#ff9ed0'; ctx.beginPath(); ctx.arc(cp.x, cp.y, 0.45 * s, 0, 7); ctx.fill(); }
if (b.type === 'silo') { ctx.fillStyle = '#9fffd0'; ctx.fillRect(cp.x - 0.5 * s, cp.y - 0.4 * s, s, 0.8 * s); }
if (b.type === 'pod') { ctx.fillStyle = '#ffe9a0'; ctx.beginPath(); ctx.arc(cp.x, cp.y, 0.6 * s, 0, 7); ctx.fill(); }
} else {
ctx.fillStyle = 'rgba(255,220,120,0.9)'; ctx.fillRect(cp.x - 1.2 * s, cp.y - 0.3 * s, 2.4 * s * prog, 0.4 * s);
}
// turret weapon glow
if (d.atk && b.done) { ctx.fillStyle = 'rgba(255,120,200,0.8)'; ctx.beginPath(); ctx.arc(cp.x, cp.y - 1.2 * s, 0.25 * s, 0, 7); ctx.fill(); }
ctx.restore();
}
function drawUnit(u) {
const mine = u.team === P;
const base = heightAt(u.x, u.y);
const bob = u.flyer ? 2.4 + Math.sin(G.t * 3 + u.bob) * 0.12 : 0;
const p = proj(u.x, base + bob + 0.12, u.y); if (!p) return;
const s = scaleAt(p.d);
if (s < 1.5 && !mine) { }
const size = Math.max(3, u.size * s * 1.5);
const seen = true;
const c = mine ? [120, 210, 255] : [255, 110, 130];
const fr = u.face || 0;
ctx.save();
const up = G.up[u.team];
const flash = G.t - u.lastHit < 0.12;
// body colour variants
let fill;
if (u.isW) {
fill = mine ? '#b8ffd6' : '#ffb8b0';
} else fill = mine ? '#dff3ff' : '#ffe0e4';
if (flash) fill = '#ffffff';
if (u.type === 'drone') {
// small diamond with orb
ctx.translate(p.x, p.y - size * 1.4);
ctx.fillStyle = fill; ctx.beginPath(); ctx.moveTo(0, -size); ctx.lineTo(size * 0.8, 0); ctx.lineTo(0, size); ctx.lineTo(-size * 0.8, 0); ctx.closePath(); ctx.fill();
ctx.fillStyle = mine ? '#40ffa0' : '#ff7a6a'; ctx.beginPath(); ctx.arc(0, 0, size * 0.35, 0, 7); ctx.fill();
if (u.carry > 0) { ctx.fillStyle = '#7dffb0'; ctx.fillRect(-1, size * 0.9, 2, 2); }
} else if (u.type === 'blade') {
ctx.translate(p.x, p.y - size * 1.2); ctx.rotate(fr + Math.PI / 2);
ctx.fillStyle = fill; ctx.beginPath(); ctx.moveTo(0, -size * 2.2); ctx.lineTo(size * 0.7, size * 0.8); ctx.lineTo(0, size * 0.4); ctx.lineTo(-size * 0.7, size * 0.8); ctx.closePath(); ctx.fill();
ctx.fillStyle = mine ? '#4ab8ff' : '#ff4a6a'; ctx.beginPath(); ctx.arc(0, 0, size * 0.4, 0, 7); ctx.fill();
} else if (u.type === 'lancer') {
ctx.translate(p.x, p.y - size * 1.5);
ctx.fillStyle = fill; ctx.fillRect(-size * 0.35, -size * 0.8, size * 0.7, size * 2.2);
ctx.fillStyle = mine ? '#7fe8ff' : '#ff8a9a'; ctx.beginPath(); ctx.arc(0, -size * 1.0, size * 0.55, 0, 7); ctx.fill();
} else if (u.type === 'bulwark') {
ctx.translate(p.x, p.y - size * 1.4);
ctx.fillStyle = mine ? '#5f8fbb' : '#9a5060'; ctx.fillRect(-size * 1.1, -size * 1.0, size * 2.2, size * 2.0);
ctx.fillStyle = fill; ctx.fillRect(-size * 0.8, -size * 0.6, size * 1.6, size * 0.5);
ctx.fillStyle = mine ? '#9fe8ff' : '#ffb0b8'; ctx.fillRect(-size * 0.25, -size * 0.2, size * 0.5, size * 0.6);
} else if (u.type === 'hornet') {
ctx.translate(p.x, p.y - size * 0.3);
ctx.rotate(fr + Math.PI / 2);
ctx.fillStyle = mine ? '#9ef0ff' : '#ffb4bc';
ctx.beginPath(); ctx.moveTo(0, -size * 1.6); ctx.lineTo(size * 1.6, size * 0.6); ctx.lineTo(0, 0); ctx.lineTo(-size * 1.6, size * 0.6); ctx.closePath(); ctx.fill();
ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(0, 0, size * 0.5, 0, 7); ctx.fill();
}
ctx.restore();
// shadow-line from air unit
if (u.flyer) {
const g = proj(u.x, heightAt(u.x, u.y) + 0.02, u.y);
if (g) { ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(g.x, g.y, size * 1.1, size * 0.5, 0, 0, 7); ctx.fill(); }
}
// health bar when damaged or selected
const hp = u.hp / u.maxHp;
if (hp < 0.999 || UIS.sel.includes(u)) {
const bw = Math.max(12, size * 2.6), bx = p.x - bw / 2, by = p.y - size * 4.2 - (u.flyer ? 0 : 0);
ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(bx, by, bw, 3);
ctx.fillStyle = hp > 0.5 ? '#6f6' : hp > 0.25 ? '#fc4' : '#f55'; ctx.fillRect(bx, by, bw * hp, 3);
}
}
function drawHpBar(b) {
const hp = b.hp / b.maxHp;
if (hp >= 0.999 && !UIS.sel.includes(b)) return;
const p = proj(b.cx, heightAt(b.cx, b.cy) + BLD[b.type].hgt + 1, b.cy); if (!p) return;
const bw = 46; ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(p.x - bw / 2, p.y, bw, 4);
ctx.fillStyle = hp > 0.5 ? '#6f6' : hp > 0.25 ? '#fc4' : '#f55'; ctx.fillRect(p.x - bw / 2, p.y, bw * hp, 4);
}
// projectile / effect pass
function drawFx() {
for (const f of G.fx) {
if (f.k === 'beam' || f.k === 'bolt' || f.k === 'tracer') {
const a = proj(f.ax, f.h0, f.ay), b = proj(f.tx, f.h1, f.ty); if (!a || !b) continue;
const al = Math.max(0, f.life / 0.14);
ctx.save(); ctx.globalAlpha = al;
ctx.strokeStyle = f.col; ctx.lineCap = 'round';
if (f.k === 'beam') { ctx.lineWidth = 3.5; ctx.shadowColor = f.col; ctx.shadowBlur = 8; }
else if (f.k === 'bolt') { ctx.lineWidth = 2.5; }
else { ctx.lineWidth = 1.2; ctx.strokeStyle = '#ffe9a0'; }
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.restore();
} else if (f.k === 'spark') {
const p = proj(f.x, f.h, f.y); if (!p) continue;
const s = scaleAt(p.d);
ctx.globalAlpha = Math.max(0, f.life / 0.25);
ctx.fillStyle = f.col; ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(2, 0.9 * s), 0, 7); ctx.fill();
ctx.globalAlpha = 1;
} else if (f.k === 'boom') {
const p = proj(f.x, f.h, f.y); if (!p) continue;
const s = scaleAt(p.d);
const t = 1 - f.life / 0.5;
ctx.globalAlpha = Math.max(0, 1 - t);
ctx.fillStyle = '#ffd27a'; ctx.beginPath(); ctx.arc(p.x, p.y, (0.6 + t * f.size) * s, 0, 7); ctx.fill();
ctx.fillStyle = f.col; ctx.beginPath(); ctx.arc(p.x, p.y, (0.3 + t * f.size * 1.4) * s, 0, 7); ctx.globalAlpha = Math.max(0, (1 - t) * 0.6); ctx.fill();
ctx.globalAlpha = 1;
} else if (f.k === 'chunk') {
const p = proj(f.x + f.vx * (0.6 - f.life) * 0.3, f.h + f.vh * (0.6 - f.life) * 0.5 - (0.6 - f.life) * 3, f.y + f.vy * (0.6 - f.life) * 0.3); if (!p) continue;
ctx.globalAlpha = Math.max(0, f.life / 0.8); ctx.fillStyle = f.col; ctx.fillRect(p.x, p.y, 2, 2); ctx.globalAlpha = 1;
} else if (f.k === 'text') {
const p = proj(f.x, heightAt(f.x, f.y) + f.h + (1 - f.life) * 2, f.y); if (!p) continue;
ctx.globalAlpha = Math.min(1, f.life * 2);
ctx.font = 'bold 13px Segoe UI, Arial'; ctx.fillStyle = f.col; ctx.textAlign = 'center';
ctx.fillText(f.text, p.x, p.y); ctx.globalAlpha = 1;
}
}
}
// ============ FRAME RENDER ============
function drawGhost(p) {
// placement preview
if (!UIS.placing || !UIS.mouse.in) return;
const d = BLD[UIS.placing], g = screenToGround(UIS.mouse.x, UIS.mouse.y);
const x = Math.floor(g.x - d.w / 2 + 0.5), y = Math.floor(g.z - d.h / 2 + 0.5);
const err = placeErr(UIS.placing, x, y, P) || canStart(P, UIS.placing);
const col = err ? 'rgba(255,80,80,0.55)' : 'rgba(120,255,160,0.55)';
const pts = [[x, y], [x + d.w, y], [x + d.w, y + d.h], [x, y + d.h]];
ctx.beginPath(); let f = true;
for (const [px, pz] of pts) { const q = proj(px, heightAt(px, pz) + 0.08, pz); if (!q) continue; if (f) { ctx.moveTo(q.x, q.y); f = false; } else ctx.lineTo(q.x, q.y); }
ctx.closePath(); ctx.fillStyle = col; ctx.fill(); ctx.strokeStyle = err ? '#ff5555' : '#7dffb0'; ctx.lineWidth = 2; ctx.stroke();
const ghostB = { x, y, w: d.w, h: d.h, cx: x + d.w / 2, cy: y + d.h / 2, done: true, prog: 1, flash: 0, team: P, type: UIS.placing, hp: 1, maxHp: 1, id: -1 };
ctx.globalAlpha = 0.55; drawBox(ghostB, d.col, 0, d.hgt, 0.6, 1); ctx.globalAlpha = 1;
if (err) { const q = proj(x + d.w / 2, heightAt(x, y) + 2, y + d.h / 2); if (q) { ctx.fillStyle = '#ffb0b0'; ctx.font = '12px Segoe UI'; ctx.textAlign = 'center'; ctx.fillText(err, q.x, q.y); } }
}
function drawRallies() {
for (const b of UIS.sel) {
if (!b.isB || b.team !== P || !b.rally) continue;
const tx = b.rally.kind === 'gather' ? (b.rally.dep ? b.rally.dep.x + 0.5 : 0) : b.rally.x;
const tz = b.rally.kind === 'gather' ? (b.rally.dep ? b.rally.dep.y + 0.5 : 0) : b.rally.y;
const q = proj(tx, heightAt(tx, tz) + 0.2, tz); if (!q) continue;
ctx.strokeStyle = b.rally.kind === 'gather' ? '#7dffb0' : '#7fd6ff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(q.x - 6, q.y); ctx.lineTo(q.x + 6, q.y); ctx.moveTo(q.x, q.y - 6); ctx.lineTo(q.x, q.y + 6); ctx.stroke();
}
}
function render() {
if (!G) return;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.fillStyle = '#05070c'; ctx.fillRect(0, 0, VW, VHt);
camSetup();
drawTerrain();
collectRocks();
collectDeposits();
OBJ.length = 0;
shadowPass();
drawGroundDecor();
selectionPass();
// ground-level clickable: buildings & units collected as objects
for (const b of G.blds) {
if (b.dead) continue;
if (b.team === P || vis(P, b.cx, b.cy)) { const p = proj(b.cx, heightAt(b.cx, b.cy), b.cy); if (p) pushObj(p.d, () => drawBuilding(b)); }
else { const m = G.mem[P].get(b.id); if (m) { const p = proj(m.x + m.w / 2, heightAt(m.x, m.y), m.y + m.h / 2); if (p) pushObj(p.d, () => drawGhostB(m)); } }
}
for (const [id, m] of G.mem[P]) {
const e = G.ent.get(id);
if (e && !e.dead && vis(P, e.cx, e.cy)) continue;
if (!e || e.dead || !vis(P, m.x + 0.5, m.y + 0.5)) { const p = proj(m.x + m.w / 2, heightAt(m.x, m.y), m.y + m.h / 2); if (p && !(e && !e.dead && e.team === E && vis(P, e.cx, e.cy))) pushObj(p.d, () => drawGhostB(m)); }
}
for (const u of G.units) {
if (u.dead) continue;
if (u.team !== P && !vis(P, u.x, u.y)) continue;
const p = proj(u.x, heightAt(u.x, u.y), u.y); if (!p) continue;
pushObj(p.d, () => drawUnit(u));
}
OBJ.sort((a, b) => b.d - a.d);
for (const o of OBJ) o.fn();
drawFx();
drawHpBars();
drawGhost();
drawRallies();
drawDragBox();
drawFog();
drawFloatingLabels();
}
function drawGhostB(m) {
const b = { x: m.x, y: m.y, w: m.w, h: m.h, cx: m.x + m.w / 2, cy: m.y + m.h / 2, team: E, type: m.type, done: true, prog: 1, flash: 0, hp: 1, maxHp: 1, id: m.id };
const d = BLD[m.type];
drawBox(b, [d.col[0] * 0.4 + 30, d.col[1] * 0.4 + 30, d.col[2] * 0.4 + 40], 0, d.hgt, 0.45, 0.8);
}
function drawHpBars() {
for (const b of G.blds) {
if (b.dead || (b.team !== P && !vis(P, b.cx, b.cy))) continue;
if (b.hp < b.maxHp * 0.999 || UIS.sel.includes(b)) drawHpBar(b);
}
}
function drawFog() { /* fog is expressed via terrain buckets; ensure edge darkening */
const g = ctx.createRadialGradient(VW / 2, VHt / 2, VHt * 0.45, VW / 2, VHt / 2, VHt * 0.95);
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = g; ctx.fillRect(0, 0, VW, VHt);
}
function drawFloatingLabels() { }
function drawDragBox() {
const d = UIS.drag; if (!d || !d.active) return;
const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1), w = Math.abs(d.x1 - d.x0), h = Math.abs(d.y1 - d.y0);
ctx.fillStyle = 'rgba(120,255,160,0.10)'; ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#7dffb0'; ctx.lineWidth = 1; ctx.strokeRect(x, y, w, h);
}
// ============ MINIMAP ============
const MMS = 224 / MW;
function drawMinimap() {
if (!G) return;
const img = mmBx.createImageData(MW, MH);
const ex = G.expl[P], vs = G.vis[P];
for (let k = 0; k < N; k++) {
let r = 4, g = 6, b = 10;
if (ex[k]) {
if (G.terr[k]) { r = 70; g = 80; b = 90; }
else { r = 22; g = 40; b = 34; if (vs[k]) { r = 40; g = 78; b = 60; } else { r = 14; g = 24; b = 26; } }
} else if (G.terr[k]) { r = 16; g = 18; b = 22; }
img.data[k * 4] = r; img.data[k * 4 + 1] = g; img.data[k * 4 + 2] = b; img.data[k * 4 + 3] = 255;
}
mmBx.putImageData(img, 0, 0);
mmX.imageSmoothingEnabled = false;
mmX.clearRect(0, 0, 224, 224);
mmX.drawImage(mmBuf, 0, 0, 224, 224);
// deposits known
for (const d of G.deps) {
if (!ex[cidx(d.x, d.y)]) continue;
mmX.fillStyle = vs[cidx(d.x, d.y)] ? '#7dffb0' : '#3a8a66';
mmX.fillRect(d.x * MMS - 1, d.y * MMS - 1, 3, 3);
}
// buildings
for (const b of G.blds) {
if (b.dead) continue;
if (b.team === P) mmX.fillStyle = '#7fd6ff';
else if (!vis(P, b.cx, b.cy)) continue;
else mmX.fillStyle = '#ff6a7a';
mmX.fillRect(b.x * MMS, b.y * MMS, Math.max(3, b.w * MMS), Math.max(3, b.h * MMS));
}
for (const [, m] of G.mem[P]) {
const e = G.ent.get(m.id);
if (e && !e.dead && vis(P, e.cx, e.cy)) continue;
mmX.strokeStyle = 'rgba(255,110,130,0.6)'; mmX.lineWidth = 1;
mmX.strokeRect(m.x * MMS + 0.5, m.y * MMS + 0.5, Math.max(3, m.w * MMS), Math.max(3, m.h * MMS));
}
// units
for (const u of G.units) {
if (u.dead) continue;
if (u.team !== P && !vis(P, u.x, u.y)) continue;
mmX.fillStyle = u.team === P ? (u.isW ? '#b8ffd6' : '#7fe8ff') : '#ff5a7a';
mmX.fillRect(u.x * MMS - 1, u.y * MMS - 1, 2, 2);
}
// alerts
for (const a of G.alerts) if (!a.quiet && G.t - a.t < 6) { mmX.strokeStyle = '#ffdd55'; mmX.beginPath(); mmX.arc(a.x * MMS, a.y * MMS, 6 + Math.sin(G.t * 8) * 2, 0, 7); mmX.stroke(); }
// camera footprint: project screen corners to ground
mmX.strokeStyle = '#ffffff'; mmX.lineWidth = 1;
const corners = [[0, 0], [VW, 0], [VW, VHt], [0, VHt]].map(([x, y]) => screenToGround(x, y));
mmX.beginPath();
corners.forEach((c, i) => { const x = clamp(c.x, -10, MW + 10) * MMS, y = clamp(c.z, -10, MH + 10) * MMS; if (i) mmX.lineTo(x, y); else mmX.moveTo(x, y); });
mmX.closePath(); mmX.stroke();
}
// ============ INPUT & COMMANDS ============
const KEYS = {};
function paused() { return G && (UIS.paused || UIS.helpOpen); }
function screenPos(e) { return { x: e.clientX, y: e.clientY }; }
function pickAt(sx, sy, ownOnly) {
let best = null, bd = 1e9;
for (const u of G.units) {
if (u.dead) continue;
if (!(u.team === P || vis(P, u.x, u.y))) continue;
if (ownOnly && u.team !== P) continue;
const h = heightAt(u.x, u.y) + (u.flyer ? 2.4 : 0);
const p = proj(u.x, h + 0.4, u.y); if (!p) continue;
const s = scaleAt(p.d);
const r = Math.max(10, u.size * s * 1.6 + 6);
const d = Math.hypot(p.x - sx, p.y - sy);
if (d < r && d < bd) { bd = d; best = u; }
}
if (best) return best;
const g = screenToGround(sx, sy);
for (const b of G.blds) {
if (b.dead) continue;
if (ownOnly && b.team !== P) continue;
if (!(b.team === P || vis(P, b.cx, b.cy))) continue;
if (g.x >= b.x - 0.2 && g.x <= b.x + b.w + 0.2 && g.z >= b.y - 0.2 && g.z <= b.y + b.h + 0.2) return b;
}
for (const d of G.deps) {
if (d.dead || !G.expl[P][cidx(d.x, d.y)]) continue;
if (Math.hypot(g.x - d.x - 0.5, g.z - d.y - 0.5) < 1.8) return d;
}
return null;
}
function unitsInBox(x0, y0, x1, y1) {
const out = [];
for (const u of G.units) {
if (u.dead || u.team !== P) continue;
const p = proj(u.x, heightAt(u.x, u.y) + (u.flyer ? 2.4 : 0), u.y); if (!p) continue;
if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1) out.push(u);
}
return out;
}
function setSel(list, add) {
if (!add) UIS.sel = [];
for (const e of list) if (!UIS.sel.includes(e)) UIS.sel.push(e);
// units take precedence over buildings
if (UIS.sel.some(e => !e.isB)) UIS.sel = UIS.sel.filter(e => !e.isB || false);
if (UIS.sel.length > 1) UIS.sel = UIS.sel.filter(e => e.team === P || UIS.sel.length === 1);
UIS.placing = null;
UIS.mode = null;
if (UIS.sel.length === 1 && UIS.sel[0].isB === undefined) {}
say('click', 0.4);
}
function selectGroup(n, add) {
const g = UIS.groups[n]; if (!g) return;
const alive = g.filter(e => !e.dead);
if (add) { for (const e of alive) if (!UIS.sel.includes(e)) UIS.sel.push(e); }
else UIS.sel = alive.slice();
}
function selectionUnits() { return UIS.sel.filter(e => !e.isB && !e.dead && e.team === P); }
function selectionBuildings() { return UIS.sel.filter(e => e.isB && !e.dead && e.team === P); }
function issueMove(units, gx, gz, add) {
const cnt = units.length; const side = Math.ceil(Math.sqrt(cnt));
const spots = [];
for (let i = 0; i < cnt; i++) {
const row = Math.floor(i / side), col = i % side;
spots.push([gx + (col - (side - 1) / 2) * 1.1, gz + (row - (side - 1) / 2) * 1.1]);
}
units.forEach((u, i) => {
let sx = spots[i][0], sz = spots[i][1];
const f = nearestFree(sx, sz, 3); if (f >= 0) { sx = (f % MW) + 0.5; sz = ((f / MW) | 0) + 0.5; }
if (!u.flyer && G.solid[cidx(sx, sz)]) { sx = gx; sz = gz; }
const o = { t: 'move', x: sx, y: sz };
if (add && u.order) u.oq.push(o); else { u.order = o; u.oq = []; }
if (!u.isW) { u.hold = false; u.target = null; }
if (u.isW) { u.order = o; u.carry = u.carry; }
setGoal(u, sx, sz);
u.order = o;
});
fxAt(gx, gz, '#9dffb8');
}
function fxAt(x, z, col) { G.fx.push({ k: 'ping', x, y: z, h: heightAt(x, z) + 0.2, life: 0.5, col }); }
function commandRight(sx, sy, e, shift) {
const units = selectionUnits(); const bl = selectionBuildings();
if (!units.length && bl.length) {
// rally points for production buildings
const g = screenToGround(sx, sy);
for (const b of bl) {
if (!BLD[b.type].trains && !BLD[b.type].drop) continue;
if (e && e.isDep) b.rally = { kind: 'gather', dep: e }; else b.rally = { kind: 'move', x: g.x, y: g.z };
}
flash('Rally point set');
return;
}
if (!units.length) return;
const g = screenToGround(sx, sy);
const combat = units.filter(u => !u.isW), workers = units.filter(u => u.isW);
// targets
if (e && !e.isDep && e.team !== P && visEnt(P, e) && !e.dead) {
const atk = combat.filter(u => !e.flyer || u.canAir);
atk.forEach(u => { u.order = { t: 'attack', id: e.id }; u.oq = []; u.hold = false; });
const rest = combat.filter(u => !atk.includes(u));
issueMove(rest, g.x, g.z, shift);
fxAt(e.isB ? e.cx : e.x, e.isB ? e.cy : e.y, '#ff6a7a');
if (!atk.length && !combat.length) flash('Drones cannot attack');
if (combat.length && !atk.length) flash(e.flyer ? 'Ground units cannot hit aircraft (Lancers can)' : '');
}
if (e && e.isDep && workers.length) {
for (const w of workers) { w.order = { t: 'gather', dep: e }; w.oq = []; stopMove(w); }
if (combat.length) issueMove(combat, g.x, g.z, shift);
fxAt(e.x + 0.5, e.y + 0.5, '#7dffb0');
return;
}
if (e && e.isB && e.team === P && !e.done && workers.length) {
for (const w of workers) { giveBuild(w, e); }
if (combat.length) issueMove(combat, g.x, g.z, shift);
return;
}
if (e && e.isB && e.team === P && e.done && workers.length && BLD[e.type].drop) {
for (const w of workers) { if (w.carry > 0) { w.order = { t: 'return' }; stopMove(w); w.oq = []; } }
const others = workers.filter(w => w.carry === 0);
if (others.length) issueMove(others, g.x, g.z, shift);
if (combat.length) issueMove(combat, g.x, g.z, shift);
return;
}
if (workers.length) {
// free ground: move
issueMove(workers, g.x, g.z, shift);
// drones that are carrying return by default when idle? keep as move
}
if (combat.length) {
if (UIS.mode === 'amove') { issueMove(combat, g.x, g.z, shift); combat.forEach(u => { if (u.order) u.order.t = 'amove'; }); }
else issueMove(combat, g.x, g.z, shift);
combat.forEach(u => { if (u.order && u.order.t === 'move') { u.order.t = 'move'; } });
}
UIS.mode = null;
}
function flash(text) { UIS.status = text; UIS.statusT = 3; }
function doBuildMode(type) {
if (!selectionUnits().some(u => u.isW)) { flash('Select a Drone to construct'); return; }
const why = canStart(P, type);
if (why) { flash(BLD[type].name + ': ' + why); say('deny'); return; }
UIS.placing = type; UIS.mode = null;
flash('Click a valid site · Esc cancels · shift-click places more');
}
function placeAt(sx, sy, shift) {
const type = UIS.placing; const d = BLD[type];
const g = screenToGround(sx, sy);
const x = Math.floor(g.x - d.w / 2 + 0.5), y = Math.floor(g.z - d.h / 2 + 0.5);
const drones = selectionUnits().filter(u => u.isW);
if (!drones.length) { UIS.placing = null; return; }
let best = drones[0], bd = 1e9;
for (const w of drones) { const dd = Math.hypot(w.x - x, w.y - y); if (dd < bd) { bd = dd; best = w; } }
const why = startBuild(P, type, x, y, best);
if (why) { flash(why); say('deny'); return; }
say('click');
if (!shift) UIS.placing = null;
// extra drones help
for (const w of drones) if (w !== best && w.order && w.order.t === 'gather') { }
}
// card definitions
function cardFor(list) {
if (!list.length) return [];
const sel = list[0];
if (list.some(u => u.isW) && sel.isW !== undefined) {
const units = list.filter(u => !u.dead);
const cards = [];
for (const k of BUILD_MENU) {
const d = BLD[k];
const why = canStart(P, k);
cards.push({ label: d.name, cost: d.cost, key: BUILD_KEY[k], why, act: () => doBuildMode(k), placing: UIS.placing === k, sup: d.sup, bk: k });
}
cards.push({ label: 'Stop', key: 'S', act: () => { for (const u of units) { u.order = null; u.oq = []; stopMove(u); } }, why: null });
cards.push({ label: 'Gather', key: 'G', act: () => { for (const u of units) { if (!u.isW) continue; u.order = { t: 'gather', dep: null }; stopMove(u); } }, why: null });
return cards;
}
if (list.length === 1 && list[0].isB) {
const b = list[0];
const d = BLD[b.type];
const cards = [];
if (!b.done) { return cards; }
if (d.trains) {
for (const t of d.trains) {
const ud = UNITS[t];
const why = enqueueCheck(b, 'unit', t);
cards.push({ label: ud.name, cost: ud.cost, key: t === 'drone' ? 'Q' : (t === 'blade' || t === 'bulwark' || t === 'hornet' ? 'Q' : 'W'), why, act: () => { const w = enqueue(b, 'unit', t); if (w) { flash(w); say('deny'); } else { say('click'); } }, sup: ud.sup, time: ud.time });
}
}
if (d.research) {
for (const k of Object.keys(UPG)) {
const u = UPG[k];
const why = enqueueCheck(b, 'upg', k);
cards.push({ label: u.name, cost: u.cost, key: { wpn1: 'Q', wpn2: 'W', arm1: 'E', spd1: 'R' }[k], why: G.up[P].done[k] ? 'Done' : why, act: () => { const w = enqueue(b, 'upg', k); if (w) { flash(w); say('deny'); } else say('click'); }, time: u.time });
}
}
return cards;
}
// combat units
const cu = list.filter(u => !u.isW);
if (cu.length) {
return [
{ label: 'Attack-move', key: 'A', act: () => { UIS.mode = 'amove'; flash('Attack-move: click ground'); }, why: null },
{ label: 'Stop', key: 'S', act: () => { for (const u of cu) { u.order = null; u.oq = []; u.target = null; stopMove(u); u.hold = false; } }, why: null },
{ label: 'Hold', key: 'H', act: () => { for (const u of cu) { u.order = null; u.oq = []; stopMove(u); u.hold = true; } }, why: null },
{ label: 'Select all of type', key: 'T', act: () => { UIS.sel = G.units.filter(u => u.team === P && !u.dead && u.type === cu[0].type); }, why: null },
];
}
return [];
}
// ============ HUD ============
let hudDirty = 0;
function hudBar() {
document.getElementById('hOre').textContent = Math.floor(G.res[P]);
const sup = document.getElementById('hSup');
sup.textContent = G.supU[P] + ' / ' + G.supC[P];
sup.style.color = G.supU[P] >= G.supC[P] ? '#ff7a7a' : '#ffd27a';
document.getElementById('hTime').textContent = fmtT(G.t);
const cut = G.t - 60;
let inc = 0; for (const [t, n] of G.incomeLog) if (t > cut) inc += n;
document.getElementById('hInc').textContent = '+' + Math.round(inc) + ' / min';
G.incomeLog = G.incomeLog.filter(e => e[0] > G.t - 120);
}
function renderPanel() {
const info = document.getElementById('selinfo');
const grid = document.getElementById('selgrid');
const q = document.getElementById('queue');
const cmd = document.getElementById('cmdgrid');
const st = document.getElementById('status');
const sel = UIS.sel.filter(e => !e.dead && (e.team === P || e.isDep));
UIS.sel = sel;
let html = '';
if (!sel.length) html = '<h3>No selection</h3>Select units or buildings. Drag to box-select.';
else if (sel.length === 1 && sel[0].isDep) {
const d = sel[0]; html = `<h3>Crystal Deposit</h3>Remaining: <b>${Math.floor(d.amt)}</b> / ${d.max}<br>Drones gather here. Drop-off: nearest HQ, CP or Silo.`;
} else if (sel.length === 1 && sel[0].isB) {
const b = sel[0], d = BLD[b.type];
html = `<h3>${d.name}</h3>HP ${Math.ceil(b.hp)}/${b.maxHp}` + (b.done ? '' : `<br>Constructing ${Math.floor(b.prog * 100)}% (${b.builders} drones)`) +
(d.sup ? `<br>Supply +${d.sup}` : '') + (d.drop ? '<br>Drop-off point for Crystal' : '') +
(d.atk ? `<br>Auto-turret: range ${d.atk.range}, ${d.atk.dmg} dmg` : '') +
(b.done && d.trains ? `<br>Rally: ${b.rally ? (b.rally.kind === 'gather' ? 'Crystal' : 'ground') : 'none (right-click ground or deposit)'}` : '');
} else {
const counts = {};
for (const e of sel) { const k = e.isB ? BLD[e.type].name : (UNITS[e.type].name); counts[k] = (counts[k] || 0) + 1; }
const hpSum = sel.reduce((a, e) => a + e.hp, 0), mhp = sel.reduce((a, e) => a + e.maxHp, 0);
if (sel.length === 1 && !sel[0].isB) {
const u = sel[0], s = UNITS[u.type];
html = `<h3>${s.name}</h3>HP ${Math.ceil(u.hp)}/${u.maxHp} · Dmg ${s.dmg} (${s.dtype === 'en' ? 'energy' : 'kinetic'}) · Range ${s.range}<br>` +
`Armor: ${s.armor} · Speed ${s.speed} · Supply ${s.sup}` + (u.isW ? `<br>Carrying ${u.carry}/10 crystal · ${u.order ? u.order.t : 'idle'}` : `<br>${u.canAir ? 'Can hit air' : (u.flyer ? 'Flying — air only' : 'Ground only')}` + ` · Order: ${u.order ? u.order.t : (u.hold ? 'hold' : 'idle')}`);
} else {
html = `<h3>${sel.length} selected</h3>` + Object.entries(counts).map(([k, v]) => `${k} × ${v}`).join(' · ') + `<br>Total HP ${Math.ceil(hpSum)}/${Math.ceil(mhp)}`;
}
}
info.innerHTML = html;
// icon grid
let gh = '';
const shown = sel.slice(0, 60);
for (let i = 0; i < shown.length; i++) {
const e = shown[i];
const lab = e.isB ? BLD[e.type].name.slice(0, 5) : e.isDep ? 'Ore' : UNITS[e.type].name.slice(0, 5);
const hp = e.isDep ? 1 : e.hp / e.maxHp;
gh += `<div class="uicon${UIS.sel.length === 1 ? ' hl' : ''}" data-i="${i}">${lab}<i style="width:${hp * 100}%"></i></div>`;
}
if (sel.length > 60) gh += `<div class="uicon">+${sel.length - 60}</div>`;
if (grid.dataset.h !== gh) { grid.innerHTML = gh; grid.dataset.h = gh; }
// queue
let qh = '';
const single = sel.length === 1 && sel[0].isB ? sel[0] : null;
if (single && single.queue) {
single.queue.forEach((it, i) => {
const pct = Math.min(100, it.t / it.time * 100);
const name = it.kind === 'unit' ? UNITS[it.type].name : UPG[it.key].name;
qh += `<div class="qi" data-q="${i}">${name}${it.blocked ? '<br><span class="blk">Supply</span>' : ''}<div class="bar" style="width:${i === 0 ? pct : 0}%"></div></div>`;
});
if (single.queue.length) qh += `<div style="font-size:11px;color:#8aa3bf;align-self:center">Click a slot to cancel (100% refund)</div>`;
}
if (q.dataset.h !== qh) { q.innerHTML = qh; q.dataset.h = qh; }
// command card
const cards = cardFor(sel);
let ch = '';
cards.forEach((c, i) => {
const dis = c.why && c.why.length ? ' dis' : '';
const cls = (c.placing ? ' act' : '') + (UIS.mode === 'amove' && c.label === 'Attack-move' ? ' act' : '');
ch += `<button class="cbtn${dis}${cls}" data-c="${i}" title="${c.why || ''}">
<b>[${c.key}]</b> ${c.label}${c.cost !== undefined ? ' · ◆' + c.cost : ''}${c.sup ? ' · sup ' + c.sup : ''}<br>
<span style="color:${c.why ? '#ff9a9a' : '#8aa3bf'}">${c.why ? c.why : (c.time ? c.time + 's' : '')}</span></button>`;
});
if (cmd.dataset.h !== ch) { cmd.innerHTML = ch; cmd.dataset.h = ch; }
cmd.cards = cards;
// status
if (UIS.statusT > 0) st.textContent = UIS.status; else st.textContent = '';
if (UIS.placing && UIS.statusT <= 0) st.textContent = 'Placing ' + BLD[UIS.placing].name + ' · click to build';
}
let cmdCards = [];
function bindPanel() {
document.getElementById('cmdgrid').addEventListener('click', ev => {
const btn = ev.target.closest('.cbtn'); if (!btn) return;
const c = cardFor(UIS.sel.filter(e => e.team === P || e.isDep))[+btn.dataset.c];
if (c && c.act) c.act();
ev.stopPropagation();
});
document.getElementById('queue').addEventListener('click', ev => {
const q = ev.target.closest('.qi'); if (!q) return;
const b = UIS.sel[0]; if (b && b.isB) cancelQueue(b, +q.dataset.q);
});
document.getElementById('selgrid').addEventListener('click', ev => {
const ic = ev.target.closest('.uicon'); if (!ic || ic.dataset.i === undefined) return;
const e = UIS.sel[+ic.dataset.i];
if (!e) return;
if (ev.shiftKey) { UIS.sel = UIS.sel.filter(x => x !== e); }
else UIS.sel = [e];
});
document.getElementById('selp').addEventListener('mousedown', ev => ev.stopPropagation());
document.getElementById('cmdp').addEventListener('mousedown', ev => ev.stopPropagation());
document.getElementById('cmdp').addEventListener('contextmenu', ev => ev.preventDefault());
document.getElementById('selp').addEventListener('contextmenu', ev => ev.preventDefault());
document.getElementById('mm').addEventListener('mousedown', ev => {
ev.stopPropagation();
const r = mmC.getBoundingClientRect();
const mx = (ev.clientX - r.left) / 224 * MW, mz = (ev.clientY - r.top) / 224 * MH;
if (ev.button === 2) { const e2 = { x: mx, z: mz }; issueMinimapOrder(mx, mz); return; }
CAM.fx = mx; CAM.fz = mz;
});
document.getElementById('mm').addEventListener('contextmenu', ev => ev.preventDefault());
document.getElementById('mmbox').addEventListener('mousedown', ev => ev.stopPropagation());
}
function issueMinimapOrder(mx, mz) {
const units = selectionUnits();
if (!units.length) return;
issueMove(units.filter(u => !u.isW), mx, mz, false);
for (const w of units.filter(u => u.isW)) { w.order = { t: 'move', x: mx, y: mz }; setGoal(w, mx, mz); }
}
// group keys
function groupsSet(n) { UIS.groups[n] = UIS.sel.slice(); flash('Group ' + n + ' set'); }
function groupsRecall(n) { if (!UIS.groups[n]) return; UIS.sel = UIS.groups[n].filter(e => !e.dead); CAM.fx = (UIS.sel[0] && UIS.sel[0].x) || CAM.fx; }
// ============ MAIN LOOP & WIRING ============
const cv2 = cvs;
function screenToGroundSafe(sx, sy) { return screenToGround(sx, sy); }
function pointerGround() { if (!UIS.mouse.in) return null; return screenToGround(UIS.mouse.x, UIS.mouse.y); }
function canvasMouse(ev) { UIS.mouse.x = ev.clientX; UIS.mouse.y = ev.clientY; UIS.mouse.in = true; }
let dragStart = null, midDrag = null;
cvs.addEventListener('contextmenu', e => e.preventDefault());
cvs.addEventListener('mousedown', ev => {
if (!G || G.over || UIS.paused || UIS.helpOpen) return;
canvasMouse(ev);
if (ev.button === 1) { midDrag = { x: ev.clientX, y: ev.clientY, yaw: CAM.yaw, pitch: CAM.pitch }; ev.preventDefault(); return; }
if (ev.button === 0) {
if (UIS.placing) { placeAt(ev.clientX, ev.clientY, ev.shiftKey); return; }
if (UIS.mode === 'amove') {
const g = screenToGround(ev.clientX, ev.clientY);
const cu = selectionUnits().filter(u => !u.isW);
issueMove(cu, g.x, g.z, ev.shiftKey); cu.forEach(u => { if (u.order) u.order.t = 'amove'; });
UIS.mode = null; return;
}
dragStart = { x: ev.clientX, y: ev.clientY, shift: ev.shiftKey, t: performance.now() };
UIS.drag = { x0: ev.clientX, y0: ev.clientY, x1: ev.clientX, y1: ev.clientY, active: false };
}
if (ev.button === 2) {
if (UIS.placing) { UIS.placing = null; flash('Placement cancelled'); return; }
if (UIS.mode) { UIS.mode = null; return; }
const t = pickAt(ev.clientX, ev.clientY, false);
commandRight(ev.clientX, ev.clientY, t, ev.shiftKey);
}
});
window.addEventListener('mousemove', ev => {
canvasMouse(ev);
if (midDrag) {
CAM.yaw = midDrag.yaw - (ev.clientX - midDrag.x) * 0.006;
CAM.pitch = clamp(midDrag.pitch + (ev.clientY - midDrag.y) * 0.004, 0.35, 1.45);
return;
}
if (UIS.drag) {
UIS.drag.x1 = ev.clientX; UIS.drag.y1 = ev.clientY;
if (Math.hypot(UIS.drag.x1 - UIS.drag.x0, UIS.drag.y1 - UIS.drag.y0) > 5) UIS.drag.active = true;
}
});
window.addEventListener('mouseup', ev => {
if (midDrag && ev.button === 1) { midDrag = null; return; }
if (ev.button !== 0 || !UIS.drag) return;
const d = UIS.drag; UIS.drag = null;
if (!G || G.over) return;
const shift = ev.shiftKey;
if (d.active) {
const x0 = Math.min(d.x0, d.x1), x1 = Math.max(d.x0, d.x1), y0 = Math.min(d.y0, d.y1), y1 = Math.max(d.y0, d.y1);
const list = unitsInBox(x0, y0, x1, y1);
const own = list.filter(u => !u.isW || true);
if (own.length) {
const combat = own.filter(u => !u.isW);
setSel(combat.length ? combat : own, shift);
} else if (!shift) UIS.sel = [];
say('click', 0.3);
} else {
const t = pickAt(ev.clientX, ev.clientY, false);
const now = performance.now();
if (t && t.team === P && !t.isB && UIS.lastClick.id === t.id && now - UIS.lastClick.t < 350) {
// double click: select all same-type visible own units
const list = G.units.filter(u => u.team === P && !u.dead && u.type === t.type && (vis(P, u.x, u.y) || true));
UIS.sel = list.filter(u => { const p = proj(u.x, heightAt(u.x, u.y), u.y); return p && p.x >= 0 && p.x <= VW && p.y >= 0 && p.y <= VHt; });
} else if (t) {
if (shift) { if (UIS.sel.includes(t)) UIS.sel = UIS.sel.filter(e => e !== t); else UIS.sel.push(t); }
else UIS.sel = [t];
if (t.team === E && !t.isDep) flash('Enemy: ' + (t.isB ? BLD[t.type].name : UNITS[t.type].name));
say('click', 0.3);
} else if (!shift) { UIS.sel = []; }
UIS.lastClick = { t: now, id: t ? t.id : -1 };
UIS.placing = null;
}
});
cvs.addEventListener('wheel', ev => {
ev.preventDefault();
CAM.dist = clamp(CAM.dist * (ev.deltaY > 0 ? 1.1 : 0.9), 14, 90);
}, { passive: false });
window.addEventListener('keydown', ev => {
const k = ev.key.toLowerCase();
if (ev.target && ev.target.tagName === 'INPUT') return;
if (!G) return;
if (ev.key === 'F1') { ev.preventDefault(); toggleHelp(); return; }
if (ev.key === 'Escape') {
if (UIS.helpOpen) { toggleHelp(); return; }
if (UIS.placing || UIS.mode) { UIS.placing = null; UIS.mode = null; return; }
togglePause(); return;
}
if (G.over || UIS.paused || UIS.helpOpen) return;
if (k === 'm') { toggleMute(); return; }
KEYS[k] = true;
if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(ev.key.toLowerCase())) ev.preventDefault();
if (k === ' ') { jumpAlert(); return; }
if (k === '[') CAM.yaw += 0.25;
if (k === ']') CAM.yaw -= 0.25;
if (/^[0-9]$/.test(k)) {
const n = +k;
if (ev.ctrlKey || ev.metaKey) { ev.preventDefault(); groupsSet(n); }
else if (ev.shiftKey) { /* shift+number: add */ }
else { groupsRecall(n); if (ev.timeStamp - (UIS.lastGroupT || 0) < 400 && UIS.lastGroup === n) { CAM.fx = UIS.sel[0] ? UIS.sel[0].x : CAM.fx; CAM.fz = UIS.sel[0] ? UIS.sel[0].y : CAM.fz; } UIS.lastGroupT = ev.timeStamp; UIS.lastGroup = n; }
return;
}
if (k === 's' && !ev.ctrlKey) { for (const u of selectionUnits()) { u.order = null; u.oq = []; u.target = null; stopMove(u); u.hold = false; } flash('Stop'); return; }
if (k === 'h') { for (const u of selectionUnits()) if (!u.isW) { u.hold = true; u.order = null; stopMove(u); } flash('Hold position'); return; }
if (k === 'a') { if (selectionUnits().some(u => !u.isW)) { UIS.mode = 'amove'; flash('Attack-move: click ground'); } return; }
if (k === 'g') { for (const u of selectionUnits()) if (u.isW) { u.order = { t: 'gather', dep: null }; stopMove(u); } return; }
// building hotkeys
if (UIS.sel.length && selectionUnits().some(u => u.isW)) {
const bk = Object.keys(BUILD_KEY).find(b => BUILD_KEY[b].toLowerCase() === k);
if (bk) { doBuildMode(bk); return; }
}
// production hotkeys on selected building
const single = UIS.sel.length === 1 && UIS.sel[0].isB ? UIS.sel[0] : null;
if (single && single.done) {
const cards = cardFor([single]);
const idx2 = cards.findIndex(c => c.key && c.key.toLowerCase() === k);
if (idx2 >= 0) cards[idx2].act();
}
});
window.addEventListener('keyup', ev => { KEYS[ev.key.toLowerCase()] = false; });
window.addEventListener('blur', () => { for (const k in KEYS) KEYS[k] = false; });
function jumpAlert() {
const a = [...G.alerts].reverse().find(x => x.team === P && !x.quiet) || [...G.alerts].reverse().find(x => x.team === P);
if (a) { CAM.fx = a.x; CAM.fz = a.y; }
}
function togglePause() {
if (!G || G.over) return;
UIS.paused = !UIS.paused;
document.getElementById('pause').classList.toggle('hide', !UIS.paused);
}
function toggleHelp() {
UIS.helpOpen = !UIS.helpOpen;
document.getElementById('help').classList.toggle('hide', !UIS.helpOpen);
}
// camera update
function updateCamera(dt) {
const sp = 28 * dt * (CAM.dist / 40);
let dx = 0, dz = 0;
if (KEYS['w'] || KEYS['arrowup']) dz -= 1;
if (KEYS['s'] && !UIS.sel.length) dz += 1;
if (KEYS['arrowdown']) dz += 1;
if (KEYS['a'] && UIS.mode !== 'amove') dx -= 1;
if (KEYS['arrowleft']) dx -= 1;
if (KEYS['d']) dx += 1;
if (KEYS['arrowright']) dx += 1;
if (UIS.mouse.in && VW && !midDrag && !UIS.placing) {
const m = 14;
if (UIS.mouse.x < m) dx -= 1;
if (UIS.mouse.x > VW - m) dx += 1;
if (UIS.mouse.y < m) dz -= 1;
if (UIS.mouse.y > VHt - m) dz += 1;
}
if (dx || dz) {
const s = Math.sin(CAM.yaw), c = Math.cos(CAM.yaw);
const wx = dx * c + dz * s, wz = -dx * s + dz * c;
const l = Math.hypot(wx, wz) || 1;
CAM.fx += wx / l * sp; CAM.fz += wz / l * sp;
}
CAM.fx = clamp(CAM.fx, 0, MW); CAM.fz = clamp(CAM.fz, 0, MH);
}
function bindUI() {
const onBtn = (id, fn) => document.getElementById(id).addEventListener('click', fn);
onBtn('btnStart', () => { document.getElementById('menu').classList.add('hide'); startMatch(); });
onBtn('btnHelp', toggleHelp); onBtn('btnHelpClose', toggleHelp);
onBtn('btnPause', togglePause); onBtn('btnResume', togglePause);
onBtn('btnMute', toggleMute);
onBtn('btnRestart', restart); onBtn('btnPauseRestart', restart); onBtn('btnEndRestart', restart);
onBtn('rotL', () => CAM.yaw += 0.3); onBtn('rotR', () => CAM.yaw -= 0.3);
onBtn('tiltU', () => CAM.pitch = clamp(CAM.pitch + 0.1, 0.35, 1.45));
onBtn('tiltD', () => CAM.pitch = clamp(CAM.pitch - 0.1, 0.35, 1.45));
onBtn('zoomI', () => CAM.dist = clamp(CAM.dist * 0.85, 14, 90));
onBtn('zoomO', () => CAM.dist = clamp(CAM.dist * 1.15, 14, 90));
['#top', '#mmbox', '#bottom', '#msg'].forEach(s => { const el = document.querySelector(s); el.addEventListener('mousedown', e => e.stopPropagation()); });
bindPanel();
}
function restart() {
document.getElementById('end').classList.add('hide');
document.getElementById('pause').classList.add('hide');
UIS.paused = false;
startMatch();
}
function startMatch() {
G = null;
newGame();
G.tickAcc = 0;
UIS.paused = false; UIS.helpOpen = false;
sfxCool = {}; lastSay = {};
G.over = false;
document.getElementById('menu').classList.add('hide');
}
// message ticker
let msgEls = [];
function renderMsgs() {
const box = document.getElementById('msg');
const recent = G.alerts.filter(a => !a.quiet && G.t - a.t < 5).slice(-3);
box.innerHTML = recent.map(a => `<div style="color:${a.team === P ? '#ffdd66' : '#ffa0a0'}">${a.text}</div>`).join('');
if (UIS.statusT > 0) { UIS.statusT -= 1 / 60; }
}
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
let dt = Math.min(0.1, (now - last) / 1000); last = now;
if (!G) { return; }
if (!UIS.paused && !UIS.helpOpen && !G.over) {
updateCamera(dt);
G.tickAcc = (G.tickAcc || 0) + dt;
let steps = 0;
while (G.tickAcc >= TICK && steps < 6) { G.tickAcc -= TICK; simTick(TICK); steps++; }
if (steps >= 6) G.tickAcc = 0;
}
if (UIS.statusT > 0) UIS.statusT -= dt;
hudBar();
renderPanel();
renderMsgs();
render();
drawMinimap();
if (G.over && !document.getElementById('end').classList.contains('hide')) {}
}
window.addEventListener('resize', resize);
resize();
bindUI();
requestAnimationFrame(frame);
newGame(); // prepare world behind start screen
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"0c751d28-bc3a-4b9d-8de6-0a92a8609ac7","tokensIn":478748,"tokensOut":2597,"tokensTotal":481345,"cost":0.011960729999999998,"turns":10,"toolCalls":9,"failedToolCalls":0,"timestamp":"2026-10-08T05:41:43.539Z"} -->