Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 10, 2026 06:11
Show Gist options
  • Select an option

  • Save senko/0b5b2c89260f87b5bd5261ac92609283 to your computer and use it in GitHub Desktop.

Select an option

Save senko/0b5b2c89260f87b5bd5261ac92609283 to your computer and use it in GitHub Desktop.
RTS game by GPT-5.6 Sol
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="icon" href="data:," />
<title>Frontier Command</title>
<style>
:root {
--ink: #dcecf2;
--muted: #7894a0;
--cyan: #57e6e6;
--amber: #ffbd56;
--red: #f26b63;
--panel: rgba(8, 18, 25, .92);
--line: rgba(129, 187, 201, .18);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #071014; color: var(--ink); }
body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; }
button { font: inherit; }
#game { position: fixed; inset: 0; width: 100%; height: 100%; cursor: crosshair; background: #14231d; }
#vignette { position: fixed; inset: 0; pointer-events: none; box-shadow: inset 0 0 130px rgba(0,0,0,.52); }
.topbar {
position: fixed; left: 0; right: 0; top: 0; height: 68px; z-index: 5;
display: flex; align-items: stretch; background: linear-gradient(180deg, rgba(6,16,23,.98), rgba(6,16,23,.82));
border-bottom: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0,0,0,.22);
}
.brand { width: 280px; padding: 13px 22px 0; border-right: 1px solid var(--line); letter-spacing: .13em; }
.brand b { display: block; color: #fff; font-size: 17px; line-height: 1; }
.brand span { color: var(--cyan); font-size: 9px; letter-spacing: .25em; }
.resources { display: flex; align-items: center; gap: 32px; padding: 0 26px; }
.metric { display: grid; grid-template-columns: 22px auto; grid-template-rows: 18px 14px; column-gap: 9px; min-width: 90px; }
.metric .glyph { grid-row: 1 / 3; align-self: center; color: var(--cyan); font-size: 18px; text-shadow: 0 0 12px rgba(87,230,230,.45); }
.metric strong { font-size: 15px; font-weight: 800; line-height: 18px; font-variant-numeric: tabular-nums; }
.metric small { color: var(--muted); text-transform: uppercase; font-size: 8px; letter-spacing: .18em; }
.metric:nth-child(2) .glyph { color: var(--amber); }
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; padding: 0 18px; }
.icon-btn { width: 37px; height: 37px; border: 1px solid var(--line); border-radius: 7px; background: rgba(255,255,255,.035); color: #a9c3cc; cursor: pointer; }
.icon-btn:hover { color: #fff; border-color: rgba(87,230,230,.42); background: rgba(87,230,230,.08); }
.mission {
position: fixed; z-index: 4; left: 18px; top: 86px; width: 278px; padding: 16px 17px;
background: linear-gradient(145deg, rgba(8,20,27,.94), rgba(9,25,31,.82)); border: 1px solid var(--line); border-radius: 9px;
box-shadow: 0 14px 34px rgba(0,0,0,.25); backdrop-filter: blur(8px);
}
.eyebrow { color: var(--cyan); font-size: 8px; font-weight: 800; letter-spacing: .22em; text-transform: uppercase; }
.mission h2 { margin: 5px 0 8px; font-size: 15px; letter-spacing: .02em; }
.mission p { margin: 0 0 12px; color: #8aa3ad; font-size: 11px; line-height: 1.5; }
.progress-track { height: 5px; background: rgba(255,255,255,.07); overflow: hidden; border-radius: 9px; }
#exploreBar { width: 0%; height: 100%; background: linear-gradient(90deg, #1d8c99, var(--cyan)); box-shadow: 0 0 12px var(--cyan); transition: width .25s; }
.progress-label { display: flex; justify-content: space-between; margin-top: 7px; color: #86a1ac; font-size: 9px; text-transform: uppercase; letter-spacing: .12em; }
#eventLog { margin-top: 13px; padding-top: 11px; border-top: 1px solid var(--line); min-height: 16px; color: #afc1c8; font-size: 10px; }
#eventLog::before { content: "●"; color: var(--cyan); margin-right: 7px; }
.command-panel {
position: fixed; z-index: 5; right: 18px; top: 86px; width: 286px; max-height: calc(100vh - 320px); min-height: 220px;
background: var(--panel); border: 1px solid var(--line); border-radius: 9px; box-shadow: 0 16px 40px rgba(0,0,0,.32); overflow: hidden;
backdrop-filter: blur(9px);
}
.panel-head { display: flex; align-items: center; justify-content: space-between; height: 43px; padding: 0 14px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.025); }
.panel-head span { color: #99b2bb; font-size: 9px; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; }
.panel-head kbd { color: #52717d; border: 1px solid #28404a; background: transparent; border-radius: 4px; font-size: 8px; padding: 3px 5px; }
#selectionInfo { padding: 14px; }
.selection-title { display: flex; gap: 12px; align-items: center; margin-bottom: 13px; }
.portrait { width: 48px; height: 48px; flex: 0 0 48px; display: grid; place-items: center; border-radius: 7px; border: 1px solid rgba(87,230,230,.28); background: radial-gradient(circle at 50% 35%, rgba(87,230,230,.18), rgba(87,230,230,.03)); color: var(--cyan); font-size: 23px; }
.selection-title h3 { margin: 0 0 4px; font-size: 14px; }
.selection-title p { margin: 0; color: var(--muted); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
.health { height: 4px; margin-top: 7px; width: 150px; overflow: hidden; border-radius: 4px; background: rgba(255,255,255,.08); }
.health i { display: block; width: 100%; height: 100%; background: #53d399; }
.queue { margin: 2px 0 12px; padding: 9px 10px; background: rgba(255,255,255,.03); border-radius: 6px; color: #91aab4; font-size: 9px; }
.queue .progress-track { margin-top: 6px; height: 3px; }
.queue .progress-track i { display: block; height: 100%; background: var(--amber); }
.commands { display: grid; grid-template-columns: repeat(2, 1fr); gap: 7px; }
.command {
position: relative; min-height: 65px; padding: 9px 8px 8px; text-align: left; color: #cfe0e5; cursor: pointer;
border: 1px solid rgba(130,178,191,.18); border-radius: 6px; background: linear-gradient(145deg, rgba(255,255,255,.05), rgba(255,255,255,.018));
}
.command:hover:not(:disabled) { border-color: rgba(87,230,230,.55); background: rgba(87,230,230,.085); transform: translateY(-1px); }
.command:disabled { cursor: not-allowed; opacity: .35; filter: grayscale(.5); }
.command .cmd-icon { display: block; margin-bottom: 5px; color: var(--cyan); font-size: 17px; }
.command b { display: block; font-size: 9px; letter-spacing: .05em; }
.command small { color: #6f8b96; font-size: 8px; }
.command .hotkey { position: absolute; top: 6px; right: 7px; color: #4f6b75; font-size: 8px; }
.empty-help { color: #77919b; font-size: 11px; line-height: 1.6; }
.empty-help b { color: #b8ccd3; }
.minimap-wrap { position: fixed; z-index: 6; right: 18px; bottom: 18px; width: 286px; height: 184px; padding: 12px; border-radius: 9px; border: 1px solid var(--line); background: rgba(5,15,21,.94); box-shadow: 0 15px 35px rgba(0,0,0,.35); }
.minimap-head { display: flex; justify-content: space-between; height: 20px; color: #6f8c97; font-size: 8px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
.minimap-head span:last-child { color: var(--cyan); }
#minimap { width: 260px; height: 140px; display: block; border: 1px solid rgba(137,188,200,.16); background: #050b0e; cursor: pointer; }
.selection-strip { position: fixed; z-index: 4; left: 50%; bottom: 18px; transform: translateX(-50%); display: flex; align-items: center; min-width: 300px; height: 62px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: rgba(7,17,23,.92); box-shadow: 0 14px 34px rgba(0,0,0,.3); }
#selectionCards { display: flex; gap: 6px; }
.unit-card { width: 43px; height: 43px; display: grid; place-items: center; border-radius: 5px; border: 1px solid rgba(128,182,195,.18); color: #80abb6; background: rgba(255,255,255,.035); font-size: 16px; }
.unit-card.active { color: var(--cyan); border-color: rgba(87,230,230,.4); }
.strip-hint { margin-left: 14px; padding-left: 14px; border-left: 1px solid var(--line); color: #627d87; font-size: 9px; line-height: 1.45; }
#toast { position: fixed; z-index: 12; left: 50%; top: 86px; transform: translate(-50%, -12px); padding: 10px 15px; border-radius: 6px; border: 1px solid rgba(87,230,230,.3); background: rgba(4,15,20,.94); color: #c8e3e8; font-size: 10px; letter-spacing: .06em; opacity: 0; pointer-events: none; transition: .22s; }
#toast.show { opacity: 1; transform: translate(-50%, 0); }
#toast.error { border-color: rgba(242,107,99,.45); color: #ffc0bb; }
.overlay { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; background: radial-gradient(circle at 50% 45%, rgba(14,37,43,.82), rgba(2,7,10,.97)); transition: opacity .5s; }
.overlay.hidden { opacity: 0; pointer-events: none; }
.briefing { width: min(620px, calc(100vw - 36px)); padding: 38px 42px; border: 1px solid rgba(103,194,206,.25); border-radius: 12px; background: linear-gradient(145deg, rgba(11,27,35,.96), rgba(5,15,20,.98)); box-shadow: 0 35px 100px #000; }
.briefing .logo { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 23px; border: 1px solid rgba(87,230,230,.4); border-radius: 50%; color: var(--cyan); font-size: 25px; box-shadow: inset 0 0 25px rgba(87,230,230,.09), 0 0 30px rgba(87,230,230,.08); }
.briefing h1 { margin: 5px 0 12px; color: white; font-size: clamp(28px, 5vw, 48px); line-height: .96; letter-spacing: -.035em; }
.briefing .lead { color: #8ea7b0; max-width: 500px; font-size: 13px; line-height: 1.65; }
.brief-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 12px; margin: 25px 0; }
.brief-item { padding: 13px; min-height: 86px; border: 1px solid var(--line); border-radius: 7px; background: rgba(255,255,255,.025); }
.brief-item b { display: block; margin-bottom: 6px; color: #d8e8ec; font-size: 10px; letter-spacing: .06em; }
.brief-item span { color: #6f8b95; font-size: 9px; line-height: 1.45; }
.primary { min-width: 190px; padding: 13px 19px; border: 1px solid #5ee8e8; border-radius: 6px; background: var(--cyan); color: #062025; font-weight: 900; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; cursor: pointer; box-shadow: 0 0 30px rgba(87,230,230,.15); }
.primary:hover { background: #83ffff; }
.pause-card { text-align: center; width: 380px; }
.pause-card h2 { margin: 4px 0 10px; font-size: 30px; }
@media (max-width: 900px) {
.brand { width: 190px; }
.resources { gap: 12px; padding: 0 12px; }
.metric { min-width: 72px; }
.mission { width: 230px; }
.command-panel { width: 240px; }
.minimap-wrap { width: 240px; height: 160px; }
#minimap { width: 214px; height: 116px; }
.brief-grid { grid-template-columns: 1fr; }
.brief-item { min-height: 0; }
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="vignette"></div>
<header class="topbar">
<div class="brand"><b>FRONTIER COMMAND</b><span>SECTOR K-42</span></div>
<div class="resources">
<div class="metric"><span class="glyph">◆</span><strong id="credits">450</strong><small>Aether</small></div>
<div class="metric"><span class="glyph">◈</span><strong id="supply">1 / 8</strong><small>Supply</small></div>
<div class="metric"><span class="glyph">⌁</span><strong id="workers">1</strong><small>Collectors</small></div>
</div>
<div class="top-actions">
<button class="icon-btn" id="centerBtn" title="Center headquarters (H)">⌖</button>
<button class="icon-btn" id="pauseBtn" title="Pause (P)">Ⅱ</button>
<button class="icon-btn" id="helpBtn" title="Mission briefing">?</button>
</div>
</header>
<section class="mission">
<div class="eyebrow">Primary directive</div>
<h2>Survey the frontier</h2>
<p>Build a mobile force and reveal 90% of this uncharted sector.</p>
<div class="progress-track"><div id="exploreBar"></div></div>
<div class="progress-label"><span>Map data</span><span id="exploreText">0%</span></div>
<div id="eventLog">Command link established.</div>
</section>
<aside class="command-panel">
<div class="panel-head"><span>Command matrix</span><kbd>ESC CANCEL</kbd></div>
<div id="selectionInfo"></div>
</aside>
<div class="minimap-wrap">
<div class="minimap-head"><span>Tactical overview</span><span>Live</span></div>
<canvas id="minimap" width="520" height="280"></canvas>
</div>
<div class="selection-strip">
<div id="selectionCards"></div>
<div class="strip-hint"><b>LEFT</b> SELECT / DRAG<br><b>RIGHT</b> MOVE OR HARVEST</div>
</div>
<div id="toast"></div>
<div class="overlay" id="briefing">
<div class="briefing">
<div class="logo">⌁</div>
<div class="eyebrow">New expedition // 07:40 local</div>
<h1>Claim the<br>silent frontier.</h1>
<p class="lead">Your command core has landed in an unmapped sector rich in Aether crystal. Establish an outpost, grow your force, and chart the wilderness.</p>
<div class="brief-grid">
<div class="brief-item"><b>01 · GATHER</b><span>Select your Collector, then right-click glowing crystal fields.</span></div>
<div class="brief-item"><b>02 · EXPAND</b><span>Build relays for supply, then barracks and factories.</span></div>
<div class="brief-item"><b>03 · EXPLORE</b><span>Send units outward or construct sensor outposts.</span></div>
</div>
<button class="primary" id="startBtn">Begin operation</button>
</div>
</div>
<div class="overlay hidden" id="pauseOverlay">
<div class="briefing pause-card">
<div class="eyebrow">Command channel</div>
<h2>Operation paused</h2>
<p class="lead">The simulation is standing by.</p>
<button class="primary" id="resumeBtn">Resume</button>
</div>
</div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const mini = document.getElementById('minimap');
const mctx = mini.getContext('2d');
const $ = id => document.getElementById(id);
const WORLD = { w: 2688, h: 1824, tile: 48 };
const FOG = 48;
const fogCols = Math.ceil(WORLD.w / FOG);
const fogRows = Math.ceil(WORLD.h / FOG);
const discovered = new Uint8Array(fogCols * fogRows);
const visible = new Uint8Array(fogCols * fogRows);
const TYPES = {
worker: { name:'Collector', icon:'⌁', hp:80, speed:78, sight:190, cost:80, time:7, pop:1, radius:13, desc:'Harvests Aether and constructs buildings.' },
ranger: { name:'Pathfinder', icon:'⌖', hp:120, speed:104, sight:270, cost:110, time:10, pop:1, radius:12, desc:'Fast scout with an expanded sensor radius.' },
tank: { name:'Bulwark', icon:'▰', hp:330, speed:58, sight:220, cost:260, time:17, pop:3, radius:20, desc:'Heavy expedition vehicle.' },
hq: { name:'Command Core', icon:'⬡', hp:1200, w:128, h:112, sight:285, desc:'Primary base and Aether drop-off.' },
barracks:{ name:'Field Barracks', icon:'▥', hp:650, w:106, h:90, sight:180, cost:180, time:15, desc:'Trains Pathfinders.' },
factory: { name:'Vehicle Foundry', icon:'▣', hp:900, w:132, h:100, sight:190, cost:320, time:23, desc:'Constructs Bulwark vehicles.' },
relay: { name:'Supply Relay', icon:'◇', hp:420, w:76, h:76, sight:155, cost:120, time:11, desc:'Adds 8 supply capacity.' },
outpost: { name:'Sensor Outpost', icon:'⌾', hp:440, w:70, h:70, sight:460, cost:150, time:13, desc:'Reveals a large area of the map.' },
refinery:{ name:'Aether Depot', icon:'◆', hp:700, w:96, h:86, sight:175, cost:210, time:16, desc:'Remote resource drop-off with a 20% yield bonus.' }
};
const state = {
credits: 450,
units: [],
buildings: [],
nodes: [],
selected: [],
pendingBuild: null,
camera: { x: 250, y: 230, zoom: 1 },
keys: new Set(),
mouse: { x: 0, y: 0, wx: 0, wy: 0, down: false, startX: 0, startY: 0, startWX: 0, startWY: 0, dragging: false },
paused: true,
started: false,
lastTime: performance.now(),
nextId: 1,
fogTimer: 0,
uiTimer: 0,
explored: 0,
victory: false
};
let toastTimer = 0;
let uiSignature = '';
let seed = 731927;
const rng = () => {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const dist = (a,b) => Math.hypot(a.x-b.x, a.y-b.y);
const hash = (x,y) => {
let n = Math.imul(x + 17, 374761393) + Math.imul(y + 31, 668265263);
n = Math.imul(n ^ n >>> 13, 1274126177);
return ((n ^ n >>> 16) >>> 0) / 4294967295;
};
const decorations = [];
for (let i=0; i<330; i++) decorations.push({
x: 40 + rng()*(WORLD.w-80), y: 40 + rng()*(WORLD.h-80),
type: rng() < .62 ? 'grass' : rng() < .75 ? 'rock' : 'flower',
s: .55+rng()*1.2, a: rng()*Math.PI*2
});
function addUnit(type, x, y) {
const t = TYPES[type];
const u = { id: state.nextId++, kind:'unit', type, x, y, tx:x, ty:y, angle:0, hp:t.hp, maxHp:t.hp, task:null, carry:0, maxCarry:type==='worker'?80:0, harvestTimer:0, selected:false };
state.units.push(u); return u;
}
function addBuilding(type, x, y, complete=true) {
const t = TYPES[type];
const b = { id: state.nextId++, kind:'building', type, x, y, hp:t.hp, maxHp:t.hp, complete, progress:complete?1:0, queue:[], selected:false };
state.buildings.push(b); return b;
}
function addNode(x,y,amount=850) {
state.nodes.push({ id:state.nextId++, kind:'node', x,y, amount, maxAmount:amount, radius:32, phase:rng()*9 });
}
const hq = addBuilding('hq', 510, 480, true);
const starter = addUnit('worker', 610, 515);
addNode(300, 355, 1000); addNode(330, 650, 900); addNode(790, 340, 850);
const nodeSpots = [
[1130,260],[1500,230],[2080,330],[2450,250],[950,720],[1360,610],[1800,650],[2290,770],
[360,1010],[730,1160],[1160,1040],[1510,990],[1960,1120],[2420,1010],
[260,1500],[720,1570],[1260,1470],[1670,1570],[2110,1450],[2460,1580]
];
nodeSpots.forEach((p,i) => addNode(p[0]+(rng()-.5)*80,p[1]+(rng()-.5)*70,650+Math.floor(rng()*650)));
function screenToWorld(x,y) { return { x: state.camera.x + x/state.camera.zoom, y: state.camera.y + y/state.camera.zoom }; }
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
canvas.width = Math.floor(innerWidth*dpr); canvas.height = Math.floor(innerHeight*dpr);
canvas.style.width = innerWidth+'px'; canvas.style.height = innerHeight+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
clampCamera();
}
function clampCamera() {
const viewW = innerWidth/state.camera.zoom, viewH = innerHeight/state.camera.zoom;
state.camera.x = clamp(state.camera.x, 0, Math.max(0,WORLD.w-viewW));
state.camera.y = clamp(state.camera.y, 0, Math.max(0,WORLD.h-viewH));
}
function centerOn(x,y) {
state.camera.x = x-innerWidth/(2*state.camera.zoom);
state.camera.y = y-innerHeight/(2*state.camera.zoom);
clampCamera();
}
function setEvent(text) { $('eventLog').textContent = text; }
function toast(text, error=false) {
clearTimeout(toastTimer); const el=$('toast'); el.textContent=text; el.className=error?'show error':'show';
toastTimer=setTimeout(()=>el.className='',2100);
}
function supply() {
let used = state.units.reduce((n,u)=>n+TYPES[u.type].pop,0);
for (const b of state.buildings) for (const q of b.queue) used += TYPES[q.type].pop || 0;
let max = 8 + state.buildings.filter(b=>b.type==='relay'&&b.complete).length*8;
return {used,max};
}
function hasBuilding(type) { return state.buildings.some(b=>b.type===type&&b.complete); }
function train(building,type) {
const t=TYPES[type], sp=supply();
if (!building.complete) return toast('Construction is not complete.',true);
if (state.credits<t.cost) return toast(`Need ${t.cost-state.credits} more Aether.`,true);
if (sp.used+t.pop>sp.max) return toast('Build a Supply Relay first.',true);
if (building.queue.length>=4) return toast('Production queue is full.',true);
state.credits-=t.cost; building.queue.push({type, progress:0});
setEvent(`${t.name} added to the production queue.`); updateUI(true);
}
function beginBuild(type) {
const t=TYPES[type];
if (state.credits<t.cost) return toast(`Need ${t.cost-state.credits} more Aether.`,true);
if (type==='factory'&&!hasBuilding('barracks')) return toast('A Field Barracks is required.',true);
state.pendingBuild=type; canvas.style.cursor='copy'; toast(`Place ${t.name} · right-click to cancel`); updateUI(true);
}
function validBuild(type,x,y) {
const t=TYPES[type];
if (x-t.w/2<20||y-t.h/2<80||x+t.w/2>WORLD.w-20||y+t.h/2>WORLD.h-20) return false;
for (const b of state.buildings) {
const o=TYPES[b.type];
if (Math.abs(x-b.x)<(t.w+o.w)/2+28 && Math.abs(y-b.y)<(t.h+o.h)/2+28) return false;
}
for (const n of state.nodes) if (n.amount>0 && Math.hypot(x-n.x,y-n.y)<Math.max(t.w,t.h)/2+n.radius+24) return false;
return true;
}
function placeBuild(x,y) {
const type=state.pendingBuild, t=TYPES[type];
if (!validBuild(type,x,y)) return toast('Cannot build at this location.',true);
if (state.credits<t.cost) { cancelBuild(); return toast('Insufficient Aether.',true); }
state.credits-=t.cost;
const b=addBuilding(type,x,y,false);
const workers=state.selected.filter(e=>e.kind==='unit'&&e.type==='worker');
workers.forEach(u=>u.task={kind:'build',buildingId:b.id});
state.pendingBuild=null; canvas.style.cursor='crosshair';
setEvent(`${t.name} construction started.`); select([b]); updateUI(true);
}
function cancelBuild() { state.pendingBuild=null; canvas.style.cursor='crosshair'; updateUI(true); }
function nearestDropoff(u) {
const options=state.buildings.filter(b=>b.complete&&(b.type==='hq'||b.type==='refinery'));
return options.sort((a,b)=>dist(u,a)-dist(u,b))[0] || hq;
}
function orderGather(u,node) {
if (u.type!=='worker') return;
u.task={kind:'gather',nodeId:node.id,phase:u.carry>0?'return':'toNode'};
}
function orderMove(units,x,y) {
const cols=Math.ceil(Math.sqrt(units.length));
units.forEach((u,i)=>{
const ox=((i%cols)-(cols-1)/2)*35, oy=(Math.floor(i/cols)-(Math.ceil(units.length/cols)-1)/2)*35;
u.tx=clamp(x+ox,15,WORLD.w-15); u.ty=clamp(y+oy,75,WORLD.h-15); u.task={kind:'move'};
});
}
function moveToward(u,x,y,dt,range=3) {
const dx=x-u.x, dy=y-u.y, d=Math.hypot(dx,dy);
if (d<=range) return true;
const speed=TYPES[u.type].speed, step=Math.min(d-range,speed*dt);
u.x+=dx/d*step; u.y+=dy/d*step; u.angle=Math.atan2(dy,dx); return d-step<=range+.1;
}
function updateUnit(u,dt) {
if (!u.task) return;
if (u.task.kind==='move') {
if (moveToward(u,u.tx,u.ty,dt,3)) u.task=null;
} else if (u.task.kind==='gather') {
const node=state.nodes.find(n=>n.id===u.task.nodeId);
if (!node||node.amount<=0) { u.task=null; if(node) setEvent('Aether field depleted.'); return; }
if (u.task.phase==='toNode') {
if (moveToward(u,node.x,node.y,dt,node.radius+10)) { u.task.phase='harvest'; u.harvestTimer=0; }
} else if (u.task.phase==='harvest') {
u.harvestTimer+=dt;
if (u.harvestTimer>=.32) {
const take=Math.min(5,node.amount,u.maxCarry-u.carry); node.amount-=take; u.carry+=take; u.harvestTimer=0;
if (u.carry>=u.maxCarry||node.amount<=0) u.task.phase='return';
}
} else {
const drop=nearestDropoff(u), t=TYPES[drop.type];
if (moveToward(u,drop.x,drop.y,dt,Math.max(t.w,t.h)/2+13)) {
const bonus=drop.type==='refinery'?1.2:1;
const delivered=Math.round(u.carry*bonus); state.credits+=delivered; u.carry=0;
setEvent(`Collector delivered ${delivered} Aether${bonus>1?' with depot bonus':''}.`);
u.task.phase=node.amount>0?'toNode':'return'; if(node.amount<=0)u.task=null;
}
}
} else if (u.task.kind==='build') {
const b=state.buildings.find(b=>b.id===u.task.buildingId);
if (!b||b.complete) { u.task=null; return; }
const t=TYPES[b.type];
if (moveToward(u,b.x,b.y,dt,Math.max(t.w,t.h)/2+19)) {
b.progress+=dt/t.time;
b.hp=Math.max(1,Math.floor(b.maxHp*b.progress));
if (b.progress>=1) { b.progress=1; b.hp=b.maxHp; b.complete=true; u.task=null; setEvent(`${t.name} is operational.`); toast(`${t.name} complete`); updateUI(true); }
}
}
}
function updateBuilding(b,dt) {
if (!b.complete||!b.queue.length) return;
const q=b.queue[0], t=TYPES[q.type]; q.progress+=dt/t.time;
if (q.progress>=1) {
const bt=TYPES[b.type], angle=rng()*Math.PI*2;
const u=addUnit(q.type,b.x+Math.cos(angle)*(bt.w/2+34),b.y+Math.sin(angle)*(bt.h/2+34));
u.tx=u.x+Math.cos(angle)*32; u.ty=u.y+Math.sin(angle)*32; u.task={kind:'move'};
b.queue.shift(); setEvent(`${t.name} deployment complete.`); toast(`${t.name} ready`); updateUI(true);
}
}
function updateFog() {
visible.fill(0);
const reveal=(x,y,r)=>{
const minX=clamp(Math.floor((x-r)/FOG),0,fogCols-1), maxX=clamp(Math.floor((x+r)/FOG),0,fogCols-1);
const minY=clamp(Math.floor((y-r)/FOG),0,fogRows-1), maxY=clamp(Math.floor((y+r)/FOG),0,fogRows-1);
for(let gy=minY;gy<=maxY;gy++) for(let gx=minX;gx<=maxX;gx++) {
const cx=gx*FOG+FOG/2,cy=gy*FOG+FOG/2;
if(Math.hypot(cx-x,cy-y)<=r+FOG*.65){const i=gy*fogCols+gx;visible[i]=1;discovered[i]=1;}
}
};
state.units.forEach(u=>reveal(u.x,u.y,TYPES[u.type].sight));
state.buildings.filter(b=>b.complete).forEach(b=>reveal(b.x,b.y,TYPES[b.type].sight));
let count=0; for(const n of discovered)count+=n;
state.explored=count/discovered.length;
if(!state.victory&&state.explored>=.9){state.victory=true;toast('SECTOR SURVEY COMPLETE');setEvent('Mission complete. The frontier is mapped.');}
}
function update(dt) {
let pan=420/state.camera.zoom;
if(state.keys.has('shift'))pan*=1.7;
if(state.keys.has('w')||state.keys.has('arrowup'))state.camera.y-=pan*dt;
if(state.keys.has('s')||state.keys.has('arrowdown'))state.camera.y+=pan*dt;
if(state.keys.has('a')||state.keys.has('arrowleft'))state.camera.x-=pan*dt;
if(state.keys.has('d')||state.keys.has('arrowright'))state.camera.x+=pan*dt;
clampCamera();
state.units.forEach(u=>updateUnit(u,dt));
state.buildings.forEach(b=>updateBuilding(b,dt));
state.fogTimer+=dt; state.uiTimer+=dt;
if(state.fogTimer>.22){updateFog();state.fogTimer=0;}
if(state.uiTimer>.2){updateUI();state.uiTimer=0;}
}
function roundedRect(c,x,y,w,h,r) {
r=Math.min(r,w/2,h/2); c.beginPath(); c.moveTo(x+r,y); c.arcTo(x+w,y,x+w,y+h,r); c.arcTo(x+w,y+h,x,y+h,r); c.arcTo(x,y+h,x,y,r); c.arcTo(x,y,x+w,y,r); c.closePath();
}
function drawTerrain() {
const cam=state.camera,z=cam.zoom;
const startX=Math.max(0,Math.floor(cam.x/WORLD.tile)), endX=Math.min(Math.ceil(WORLD.w/WORLD.tile),Math.ceil((cam.x+innerWidth/z)/WORLD.tile));
const startY=Math.max(0,Math.floor(cam.y/WORLD.tile)), endY=Math.min(Math.ceil(WORLD.h/WORLD.tile),Math.ceil((cam.y+innerHeight/z)/WORLD.tile));
for(let y=startY;y<endY;y++)for(let x=startX;x<endX;x++){
const h=hash(x,y),h2=hash(Math.floor(x/3),Math.floor(y/3));
ctx.fillStyle=h2>.7?(h>.5?'#1d3529':'#1b3227'):h>.5?'#1a3026':'#182d24';
ctx.fillRect(x*WORLD.tile,y*WORLD.tile,WORLD.tile+1,WORLD.tile+1);
if(h>.87){ctx.fillStyle='rgba(123,157,107,.055)';ctx.beginPath();ctx.arc(x*WORLD.tile+13+h*21,y*WORLD.tile+18+h2*17,11,0,7);ctx.fill();}
}
ctx.strokeStyle='rgba(145,186,151,.032)';ctx.lineWidth=1/z;ctx.beginPath();
for(let x=startX;x<=endX;x++){ctx.moveTo(x*WORLD.tile,startY*WORLD.tile);ctx.lineTo(x*WORLD.tile,endY*WORLD.tile);}
for(let y=startY;y<=endY;y++){ctx.moveTo(startX*WORLD.tile,y*WORLD.tile);ctx.lineTo(endX*WORLD.tile,y*WORLD.tile);}ctx.stroke();
// Landing scar and old routes.
ctx.lineCap='round';ctx.strokeStyle='rgba(113,139,119,.08)';ctx.lineWidth=25;
ctx.beginPath();ctx.moveTo(450,490);ctx.bezierCurveTo(870,570,1040,880,1500,930);ctx.bezierCurveTo(1900,980,2180,850,2650,920);ctx.stroke();
ctx.strokeStyle='rgba(10,20,17,.15)';ctx.lineWidth=2;ctx.setLineDash([8,16]);ctx.stroke();ctx.setLineDash([]);
for(const d of decorations){
if(d.x<cam.x-20||d.y<cam.y-20||d.x>cam.x+innerWidth/z+20||d.y>cam.y+innerHeight/z+20)continue;
ctx.save();ctx.translate(d.x,d.y);ctx.rotate(d.a);ctx.scale(d.s,d.s);
if(d.type==='grass'){ctx.strokeStyle='rgba(114,157,116,.32)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,4);ctx.quadraticCurveTo(-4,-2,-3,-8);ctx.moveTo(0,4);ctx.quadraticCurveTo(3,-3,5,-7);ctx.moveTo(0,4);ctx.lineTo(0,-10);ctx.stroke();}
else if(d.type==='rock'){ctx.fillStyle='rgba(77,95,82,.42)';ctx.beginPath();ctx.moveTo(-6,3);ctx.lineTo(-4,-4);ctx.lineTo(2,-7);ctx.lineTo(7,0);ctx.lineTo(4,5);ctx.closePath();ctx.fill();ctx.strokeStyle='rgba(164,179,154,.12)';ctx.stroke();}
else{ctx.fillStyle='rgba(93,190,168,.35)';ctx.beginPath();ctx.arc(0,0,2.2,0,7);ctx.fill();}
ctx.restore();
}
}
function drawNode(n,time) {
if(n.amount<=0)return;
const ratio=n.amount/n.maxAmount, count=Math.max(3,Math.ceil(8*ratio));
ctx.save();ctx.translate(n.x,n.y);
const glow=ctx.createRadialGradient(0,5,2,0,5,45);glow.addColorStop(0,'rgba(74,229,239,.18)');glow.addColorStop(1,'rgba(74,229,239,0)');ctx.fillStyle=glow;ctx.fillRect(-50,-45,100,100);
ctx.fillStyle='rgba(4,15,18,.35)';ctx.beginPath();ctx.ellipse(0,17,37,15,0,0,Math.PI*2);ctx.fill();
for(let i=0;i<count;i++){
const a=n.phase+i*2.399,rad=8+(i%3)*8, x=Math.cos(a)*rad, y=Math.sin(a)*rad*.52;
const h=20+(i%4)*7, w=6+(i%3)*2, pulse=.8+Math.sin(time*2+n.phase+i)*.12;
ctx.save();ctx.translate(x,y);ctx.scale(pulse,1);
ctx.fillStyle=i%3===0?'#74f4ee':i%3===1?'#36bbc7':'#4dd5d5';
ctx.beginPath();ctx.moveTo(0,-h);ctx.lineTo(w,-h*.22);ctx.lineTo(w*.7,h*.3);ctx.lineTo(0,h*.48);ctx.lineTo(-w*.75,h*.25);ctx.lineTo(-w,-h*.2);ctx.closePath();ctx.fill();
ctx.strokeStyle='rgba(202,255,255,.6)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,-h);ctx.lineTo(0,h*.42);ctx.stroke();ctx.restore();
}
ctx.restore();
}
function drawBuilding(b,time) {
const t=TYPES[b.type],w=t.w,h=t.h;
ctx.save();ctx.translate(b.x,b.y);
ctx.fillStyle='rgba(0,0,0,.28)';ctx.beginPath();ctx.ellipse(5,h*.43,w*.52,h*.23,0,0,7);ctx.fill();
if(!b.complete)ctx.globalAlpha=.5+.12*Math.sin(time*4);
if(b.type==='hq'){
ctx.fillStyle='#263940';roundedRect(ctx,-w/2,-h/2,w,h,13);ctx.fill();ctx.strokeStyle='#48626a';ctx.lineWidth=3;ctx.stroke();
ctx.fillStyle='#18282e';roundedRect(ctx,-45,-40,90,59,9);ctx.fill();
ctx.fillStyle='#3c5259';ctx.fillRect(-58,24,116,17);ctx.fillStyle='#f0a94f';ctx.fillRect(-43,28,22,4);ctx.fillRect(-13,28,22,4);ctx.fillRect(17,28,22,4);
ctx.fillStyle='rgba(87,230,230,.18)';ctx.beginPath();ctx.arc(0,-10,28,0,7);ctx.fill();ctx.strokeStyle='#57e6e6';ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,-10,17,0,7);ctx.stroke();
ctx.fillStyle='#9afcfa';ctx.beginPath();ctx.arc(0,-10,6+Math.sin(time*3)*1.2,0,7);ctx.fill();
ctx.strokeStyle='#66818a';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(-46,-47);ctx.lineTo(-52,-70);ctx.moveTo(46,-47);ctx.lineTo(54,-69);ctx.stroke();ctx.fillStyle='#57e6e6';ctx.fillRect(-55,-72,6,5);ctx.fillRect(51,-72,6,5);
} else if(b.type==='barracks'){
ctx.fillStyle='#2b393c';roundedRect(ctx,-w/2,-h/2,w,h,8);ctx.fill();ctx.strokeStyle='#53656a';ctx.lineWidth=2;ctx.stroke();
ctx.fillStyle='#17262b';ctx.fillRect(-39,-30,78,37);ctx.fillStyle='#354c52';ctx.fillRect(-45,15,90,20);
ctx.fillStyle='#d7824c';for(let i=-38;i<38;i+=16){ctx.save();ctx.translate(i,25);ctx.rotate(-.65);ctx.fillRect(-2,-10,4,20);ctx.restore();}
ctx.fillStyle='#72d3d2';ctx.fillRect(-31,-20,17,6);ctx.fillRect(-7,-20,17,6);ctx.fillRect(17,-20,17,6);
} else if(b.type==='factory'){
ctx.fillStyle='#303c3d';roundedRect(ctx,-w/2,-h/2,w,h,8);ctx.fill();ctx.strokeStyle='#637074';ctx.lineWidth=3;ctx.stroke();
ctx.fillStyle='#16242a';roundedRect(ctx,-48,-34,96,58,5);ctx.fill();ctx.strokeStyle='#435b61';ctx.lineWidth=2;ctx.stroke();
for(let i=-38;i<=38;i+=19){ctx.strokeStyle='rgba(114,149,157,.35)';ctx.beginPath();ctx.moveTo(i,-28);ctx.lineTo(i,18);ctx.stroke();}
ctx.fillStyle='#ecb354';ctx.fillRect(-52,31,104,7);ctx.fillStyle='#57e6e6';ctx.fillRect(-52,-45,30,4);
ctx.fillStyle='#1d2b2f';ctx.beginPath();ctx.arc(47,-38,14,0,7);ctx.fill();ctx.strokeStyle='#61757a';ctx.stroke();
} else if(b.type==='relay'){
ctx.fillStyle='#26383d';ctx.beginPath();for(let i=0;i<8;i++){const a=Math.PI/8+i*Math.PI/4;ctx.lineTo(Math.cos(a)*w*.48,Math.sin(a)*h*.48);}ctx.closePath();ctx.fill();ctx.strokeStyle='#567079';ctx.lineWidth=2;ctx.stroke();
ctx.strokeStyle='rgba(87,230,230,.7)';ctx.lineWidth=3;ctx.beginPath();ctx.arc(0,0,20+Math.sin(time*2)*2,0,7);ctx.stroke();ctx.fillStyle='#57e6e6';ctx.beginPath();ctx.arc(0,0,7,0,7);ctx.fill();
} else if(b.type==='outpost'){
ctx.fillStyle='#26363b';ctx.beginPath();ctx.arc(0,8,33,0,7);ctx.fill();ctx.strokeStyle='#5a747c';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#17272c';ctx.beginPath();ctx.arc(0,8,21,0,7);ctx.fill();
ctx.strokeStyle='#79edeb';ctx.lineWidth=2;ctx.save();ctx.rotate(time*.55);ctx.beginPath();ctx.moveTo(0,8);ctx.lineTo(34,8);ctx.arc(0,8,34,0,.55);ctx.stroke();ctx.restore();
ctx.fillStyle='#80f5f1';ctx.beginPath();ctx.arc(0,8,5,0,7);ctx.fill();ctx.strokeStyle='#677e84';ctx.lineWidth=4;ctx.beginPath();ctx.moveTo(0,-18);ctx.lineTo(0,-55);ctx.stroke();ctx.fillStyle='#57e6e6';ctx.fillRect(-4,-59,8,7);
} else if(b.type==='refinery'){
ctx.fillStyle='#2a3b40';roundedRect(ctx,-w/2,-h/2,w,h,10);ctx.fill();ctx.strokeStyle='#526d75';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#13272d';ctx.beginPath();ctx.arc(-20,-4,23,0,7);ctx.fill();ctx.strokeStyle='#39c6ce';ctx.stroke();
ctx.fillStyle='#56dde0';ctx.beginPath();ctx.moveTo(-20,-21);ctx.lineTo(-9,-2);ctx.lineTo(-20,16);ctx.lineTo(-31,-2);ctx.closePath();ctx.fill();ctx.fillStyle='#3d5459';ctx.fillRect(14,-25,21,50);ctx.fillStyle='#e4a551';ctx.fillRect(18,-18,13,5);ctx.fillRect(18,-7,13,5);ctx.fillRect(18,4,13,5);
}
if(!b.complete){
ctx.globalAlpha=1;ctx.strokeStyle='rgba(255,189,86,.75)';ctx.lineWidth=2;ctx.setLineDash([7,5]);ctx.strokeRect(-w/2-5,-h/2-5,w+10,h+10);ctx.setLineDash([]);
ctx.fillStyle='rgba(5,15,19,.85)';ctx.fillRect(-w/2,h/2+9,w,6);ctx.fillStyle='#ffbd56';ctx.fillRect(-w/2,h/2+9,w*clamp(b.progress,0,1),6);
}
if(b.selected){ctx.globalAlpha=1;ctx.strokeStyle='#65f0e9';ctx.lineWidth=2;ctx.setLineDash([5,5]);roundedRect(ctx,-w/2-8,-h/2-8,w+16,h+16,12);ctx.stroke();ctx.setLineDash([]);}
ctx.restore();
}
function drawUnit(u,time) {
const t=TYPES[u.type];ctx.save();ctx.translate(u.x,u.y);ctx.rotate(u.angle);
ctx.fillStyle='rgba(0,0,0,.34)';ctx.beginPath();ctx.ellipse(2,5,t.radius*1.15,t.radius*.67,0,0,7);ctx.fill();
if(u.type==='worker'){
ctx.fillStyle='#15272d';roundedRect(ctx,-14,-10,28,20,6);ctx.fill();ctx.strokeStyle='#54717a';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#e1a44e';ctx.fillRect(-11,-6,9,12);ctx.fillStyle='#314b52';ctx.fillRect(4,-6,6,12);ctx.fillStyle='#67e6e3';ctx.fillRect(6,-3,3,6);
ctx.strokeStyle='#94acaf';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(10,-7);ctx.lineTo(18,-12);ctx.lineTo(21,-8);ctx.stroke();
} else if(u.type==='ranger'){
ctx.fillStyle='#28474e';ctx.beginPath();ctx.arc(0,1,11,0,7);ctx.fill();ctx.strokeStyle='#6e969e';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#d3b18d';ctx.beginPath();ctx.arc(2,-4,5,0,7);ctx.fill();ctx.fillStyle='#57d8d7';ctx.fillRect(-10,3,8,5);ctx.strokeStyle='#aec0c0';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(7,1);ctx.lineTo(20,1);ctx.stroke();ctx.fillStyle='#39484b';ctx.fillRect(15,-2,8,5);
} else {
ctx.fillStyle='#182529';roundedRect(ctx,-22,-15,44,30,7);ctx.fill();ctx.fillStyle='#41545a';ctx.fillRect(-17,-18,34,36);ctx.fillStyle='#202f34';ctx.beginPath();ctx.arc(3,0,11,0,7);ctx.fill();ctx.strokeStyle='#647b80';ctx.lineWidth=2;ctx.stroke();ctx.strokeStyle='#9faeae';ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(7,0);ctx.lineTo(29,0);ctx.stroke();ctx.fillStyle='#59e4e1';ctx.fillRect(-12,-10,8,3);ctx.fillRect(-12,7,8,3);
}
ctx.restore();
if(u.selected){ctx.strokeStyle='#69f0e9';ctx.lineWidth=2;ctx.beginPath();ctx.ellipse(u.x,u.y+t.radius*.72,t.radius*1.45,t.radius*.57,0,0,7);ctx.stroke();}
if(u.carry>0){ctx.fillStyle='rgba(4,14,18,.8)';ctx.fillRect(u.x-15,u.y-t.radius-11,30,4);ctx.fillStyle='#56e5e1';ctx.fillRect(u.x-15,u.y-t.radius-11,30*(u.carry/u.maxCarry),4);}
if(u.task&&u.task.kind==='gather'&&u.task.phase==='harvest'){ctx.fillStyle=`rgba(87,230,230,${.5+Math.sin(time*8)*.25})`;ctx.beginPath();ctx.arc(u.x,u.y-18,2,0,7);ctx.fill();}
}
function drawFog() {
const cam=state.camera,z=cam.zoom;
const minX=clamp(Math.floor(cam.x/FOG),0,fogCols-1), maxX=clamp(Math.ceil((cam.x+innerWidth/z)/FOG),0,fogCols-1);
const minY=clamp(Math.floor(cam.y/FOG),0,fogRows-1), maxY=clamp(Math.ceil((cam.y+innerHeight/z)/FOG),0,fogRows-1);
for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){
const i=y*fogCols+x;if(visible[i])continue;
ctx.fillStyle=discovered[i]?'rgba(2,8,11,.58)':'rgba(1,4,6,.965)';ctx.fillRect(x*FOG-.5,y*FOG-.5,FOG+1,FOG+1);
}
}
function render(time) {
ctx.clearRect(0,0,innerWidth,innerHeight);
ctx.save();ctx.scale(state.camera.zoom,state.camera.zoom);ctx.translate(-state.camera.x,-state.camera.y);
drawTerrain();
state.nodes.forEach(n=>drawNode(n,time));
state.buildings.forEach(b=>drawBuilding(b,time));
state.units.slice().sort((a,b)=>a.y-b.y).forEach(u=>drawUnit(u,time));
if(state.pendingBuild){const t=TYPES[state.pendingBuild],ok=validBuild(state.pendingBuild,state.mouse.wx,state.mouse.wy);ctx.globalAlpha=.65;ctx.fillStyle=ok?'rgba(87,230,230,.26)':'rgba(242,107,99,.3)';ctx.strokeStyle=ok?'#57e6e6':'#f26b63';ctx.lineWidth=2/state.camera.zoom;ctx.fillRect(state.mouse.wx-t.w/2,state.mouse.wy-t.h/2,t.w,t.h);ctx.strokeRect(state.mouse.wx-t.w/2,state.mouse.wy-t.h/2,t.w,t.h);ctx.globalAlpha=1;}
drawFog();
if(state.mouse.down&&state.mouse.dragging&&!state.pendingBuild){const x=Math.min(state.mouse.startWX,state.mouse.wx),y=Math.min(state.mouse.startWY,state.mouse.wy),w=Math.abs(state.mouse.wx-state.mouse.startWX),h=Math.abs(state.mouse.wy-state.mouse.startWY);ctx.fillStyle='rgba(87,230,230,.08)';ctx.fillRect(x,y,w,h);ctx.strokeStyle='#6be7e4';ctx.lineWidth=1/state.camera.zoom;ctx.strokeRect(x,y,w,h);}
ctx.restore();
drawMinimap();
}
function drawMinimap() {
const w=mini.width,h=mini.height,sx=w/WORLD.w,sy=h/WORLD.h;
mctx.fillStyle='#07100f';mctx.fillRect(0,0,w,h);
for(let y=0;y<fogRows;y++)for(let x=0;x<fogCols;x++){
const i=y*fogCols+x;if(!discovered[i])continue;
mctx.fillStyle=visible[i]?'#274436':'#172922';mctx.fillRect(x*FOG*sx,y*FOG*sy,FOG*sx+1,FOG*sy+1);
}
for(const n of state.nodes){if(n.amount<=0)continue;const gx=clamp(Math.floor(n.x/FOG),0,fogCols-1),gy=clamp(Math.floor(n.y/FOG),0,fogRows-1);if(discovered[gy*fogCols+gx]){mctx.fillStyle='#4fe5df';mctx.beginPath();mctx.arc(n.x*sx,n.y*sy,4,0,7);mctx.fill();}}
mctx.fillStyle='#ffbb58';for(const b of state.buildings){if(b.complete)mctx.fillRect(b.x*sx-3,b.y*sy-3,6,6);}
mctx.fillStyle='#d7ffff';for(const u of state.units){mctx.fillRect(u.x*sx-1.5,u.y*sy-1.5,3,3);}
mctx.strokeStyle='rgba(255,255,255,.7)';mctx.lineWidth=2;const z=state.camera.zoom;
mctx.strokeRect(state.camera.x*sx,state.camera.y*sy,innerWidth/z*sx,innerHeight/z*sy);
}
function select(items,add=false) {
if(!add){state.selected.forEach(e=>e.selected=false);state.selected=[];}
for(const e of items){if(!state.selected.includes(e)){e.selected=true;state.selected.push(e);}}
updateUI(true);
}
function entityAt(x,y) {
for(let i=state.units.length-1;i>=0;i--){const u=state.units[i];if(Math.hypot(x-u.x,y-u.y)<=TYPES[u.type].radius+7)return u;}
for(let i=state.buildings.length-1;i>=0;i--){const b=state.buildings[i],t=TYPES[b.type];if(Math.abs(x-b.x)<=t.w/2&&Math.abs(y-b.y)<=t.h/2)return b;}
return null;
}
function nodeAt(x,y) { return state.nodes.find(n=>n.amount>0&&Math.hypot(x-n.x,y-n.y)<=n.radius+16); }
function cmdButton(icon,name,cost,action,hotkey='',disabled=false) {
return `<button class="command" data-action="${action}" ${disabled?'disabled':''}><span class="cmd-icon">${icon}</span><b>${name}</b><small>${cost}</small>${hotkey?`<span class="hotkey">${hotkey}</span>`:''}</button>`;
}
function updateUI(force=false) {
const sp=supply();
$('credits').textContent=Math.floor(state.credits);$('supply').textContent=`${sp.used} / ${sp.max}`;$('workers').textContent=state.units.filter(u=>u.type==='worker').length;
$('exploreBar').style.width=(state.explored*100).toFixed(1)+'%';$('exploreText').textContent=Math.floor(state.explored*100)+'%';
const ids=state.selected.map(e=>e.id).join(','),queues=state.buildings.map(b=>b.queue.map(q=>q.type+q.progress.toFixed(1)).join()).join('|');
const sig=`${ids}/${state.pendingBuild}/${Math.floor(state.credits/10)}/${sp.used}/${sp.max}/${queues}/${state.buildings.map(b=>b.complete?'1':'0').join('')}`;
if(!force&&sig===uiSignature)return;uiSignature=sig;
const cards=$('selectionCards'); cards.innerHTML='';
state.selected.slice(0,8).forEach((e,i)=>{const d=document.createElement('div');d.className='unit-card '+(i===0?'active':'');d.textContent=TYPES[e.type].icon;d.title=TYPES[e.type].name;d.onclick=()=>select([e]);cards.appendChild(d);});
if(!state.selected.length){$('selectionInfo').innerHTML=`<div class="empty-help"><b>No units selected.</b><br><br>Drag a box around units, or left-click a building to access production. Use <b>WASD</b> to pan and the mouse wheel to zoom.</div>`;return;}
const primary=state.selected[0],t=TYPES[primary.type];
let subtitle=primary.kind==='unit'?(primary.task?primary.task.kind.toUpperCase():'READY'):(primary.complete?'OPERATIONAL':'CONSTRUCTING');
let html=`<div class="selection-title"><div class="portrait">${t.icon}</div><div><h3>${state.selected.length>1?state.selected.length+' units selected':t.name}</h3><p>${subtitle}</p><div class="health"><i style="width:${primary.hp/primary.maxHp*100}%"></i></div></div></div>`;
if(primary.kind==='building'&&primary.queue.length){const q=primary.queue[0];html+=`<div class="queue">Producing ${TYPES[q.type].name} · ${primary.queue.length} queued<div class="progress-track"><i style="width:${q.progress*100}%"></i></div></div>`;}
html+='<div class="commands">';
const selectedWorkers=state.selected.filter(e=>e.kind==='unit'&&e.type==='worker');
if(selectedWorkers.length){
html+=cmdButton('▥','Barracks','180 Aether','build-barracks','B',state.credits<180);
html+=cmdButton('▣','Foundry','320 Aether','build-factory','F',state.credits<320||!hasBuilding('barracks'));
html+=cmdButton('◇','Supply Relay','120 Aether','build-relay','R',state.credits<120);
html+=cmdButton('⌾','Sensor Post','150 Aether','build-outpost','O',state.credits<150);
html+=cmdButton('◆','Aether Depot','210 Aether','build-refinery','E',state.credits<210);
html+=cmdButton('■','Stop','Cancel order','stop','X');
} else if(primary.kind==='building'){
if(primary.type==='hq')html+=cmdButton('⌁','Collector','80 · 1 supply','train-worker','1',state.credits<80||sp.used+1>sp.max);
if(primary.type==='barracks')html+=cmdButton('⌖','Pathfinder','110 · 1 supply','train-ranger','1',state.credits<110||sp.used+1>sp.max||!primary.complete);
if(primary.type==='factory')html+=cmdButton('▰','Bulwark','260 · 3 supply','train-tank','1',state.credits<260||sp.used+3>sp.max||!primary.complete);
if(!['hq','barracks','factory'].includes(primary.type))html+=`<div class="empty-help">${t.desc}</div>`;
} else {
html+=cmdButton('■','Stop','Cancel order','stop','X');
html+=cmdButton('⌖','Center view','Selected group','center-selection','C');
}
html+='</div>';$('selectionInfo').innerHTML=html;
document.querySelectorAll('[data-action]').forEach(btn=>btn.addEventListener('click',()=>handleAction(btn.dataset.action)));
}
function handleAction(a) {
const p=state.selected[0];
if(a.startsWith('build-'))beginBuild(a.slice(6));
else if(a.startsWith('train-')&&p&&p.kind==='building')train(p,a.slice(6));
else if(a==='stop'){state.selected.filter(e=>e.kind==='unit').forEach(u=>{u.task=null;u.tx=u.x;u.ty=u.y;});setEvent('Selected units standing by.');updateUI(true);}
else if(a==='center-selection'&&p)centerOn(p.x,p.y);
}
function pointerPos(e) { const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}; }
canvas.addEventListener('pointerdown',e=>{
if(e.button!==0||state.paused)return;const p=pointerPos(e),w=screenToWorld(p.x,p.y);
state.mouse.down=true;state.mouse.dragging=false;state.mouse.startX=p.x;state.mouse.startY=p.y;state.mouse.startWX=w.x;state.mouse.startWY=w.y;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove',e=>{
const p=pointerPos(e),w=screenToWorld(p.x,p.y);Object.assign(state.mouse,{x:p.x,y:p.y,wx:w.x,wy:w.y});
if(state.mouse.down&&Math.hypot(p.x-state.mouse.startX,p.y-state.mouse.startY)>7)state.mouse.dragging=true;
});
canvas.addEventListener('pointerup',e=>{
if(e.button!==0||!state.mouse.down)return;state.mouse.down=false;
if(state.pendingBuild){placeBuild(state.mouse.wx,state.mouse.wy);return;}
if(state.mouse.dragging){const x1=Math.min(state.mouse.startWX,state.mouse.wx),x2=Math.max(state.mouse.startWX,state.mouse.wx),y1=Math.min(state.mouse.startWY,state.mouse.wy),y2=Math.max(state.mouse.startWY,state.mouse.wy);select(state.units.filter(u=>u.x>=x1&&u.x<=x2&&u.y>=y1&&u.y<=y2),e.shiftKey);}
else{const ent=entityAt(state.mouse.wx,state.mouse.wy);select(ent?[ent]:[],e.shiftKey);}
state.mouse.dragging=false;
});
canvas.addEventListener('contextmenu',e=>{
e.preventDefault();if(state.paused)return;
if(state.pendingBuild){cancelBuild();toast('Construction placement cancelled.');return;}
const p=pointerPos(e),w=screenToWorld(p.x,p.y),node=nodeAt(w.x,w.y),units=state.selected.filter(e=>e.kind==='unit');
if(node){const workers=units.filter(u=>u.type==='worker');workers.forEach(u=>orderGather(u,node));if(workers.length){setEvent(`${workers.length} Collector${workers.length>1?'s':''} assigned to Aether field.`);return;}}
if(units.length){orderMove(units,w.x,w.y);setEvent(`${units.length} unit${units.length>1?'s':''} moving to waypoint.`);}
});
canvas.addEventListener('wheel',e=>{
e.preventDefault();if(state.paused)return;const p=pointerPos(e),before=screenToWorld(p.x,p.y),factor=e.deltaY>0?.9:1.1;
state.camera.zoom=clamp(state.camera.zoom*factor,.58,1.55);state.camera.x=before.x-p.x/state.camera.zoom;state.camera.y=before.y-p.y/state.camera.zoom;clampCamera();
},{passive:false});
mini.addEventListener('click',e=>{const r=mini.getBoundingClientRect(),x=(e.clientX-r.left)/r.width*WORLD.w,y=(e.clientY-r.top)/r.height*WORLD.h;centerOn(x,y);});
window.addEventListener('keydown',e=>{
const k=e.key.toLowerCase();state.keys.add(k);if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k))e.preventDefault();
if(e.repeat)return;
if(k==='escape'){if(state.pendingBuild)cancelBuild();else select([]);}
if(k==='p')togglePause();if(k==='h')centerOn(hq.x,hq.y);
if(k==='x'&&state.selected.some(e=>e.kind==='unit'))handleAction('stop');
const primary=state.selected[0], workers=state.selected.some(e=>e.kind==='unit'&&e.type==='worker');
if(workers&&['b','f','r','o','e'].includes(k)){
const buildKey={b:'barracks',f:'factory',r:'relay',o:'outpost',e:'refinery'}[k];
if(!state.pendingBuild)beginBuild(buildKey);
}
if(k==='1'&&primary&&primary.kind==='building'){
const product={hq:'worker',barracks:'ranger',factory:'tank'}[primary.type];
if(product)train(primary,product);
}
if(k==='c'&&primary)centerOn(primary.x,primary.y);
});
window.addEventListener('keyup',e=>state.keys.delete(e.key.toLowerCase()));
window.addEventListener('resize',resize);
function togglePause(force) {
if(!state.started)return;state.paused=force!==undefined?force:!state.paused;
$('pauseOverlay').classList.toggle('hidden',!state.paused);$('pauseBtn').textContent=state.paused?'▶':'Ⅱ';state.lastTime=performance.now();
}
$('startBtn').onclick=()=>{state.started=true;state.paused=false;$('briefing').classList.add('hidden');select([starter]);updateFog();centerOn(560,490);setEvent('Select the Collector and harvest a nearby crystal field.');};
$('resumeBtn').onclick=()=>togglePause(false);$('pauseBtn').onclick=()=>togglePause();$('centerBtn').onclick=()=>centerOn(hq.x,hq.y);
$('helpBtn').onclick=()=>{if(state.started)togglePause(true);$('briefing').classList.remove('hidden');};
$('briefing').addEventListener('click',e=>{if(e.target.id==='briefing'&&state.started){$('briefing').classList.add('hidden');togglePause(false);}});
function loop(now) {
const dt=Math.min(.05,(now-state.lastTime)/1000);state.lastTime=now;if(!state.paused)update(dt);render(now/1000);requestAnimationFrame(loop);
}
resize();updateFog();updateUI(true);requestAnimationFrame(loop);
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment