Skip to content

Instantly share code, notes, and snippets.

@senko
Created May 28, 2026 18:09
Show Gist options
  • Select an option

  • Save senko/24d117e680759989a9fff5b2b9ab4615 to your computer and use it in GitHub Desktop.

Select an option

Save senko/24d117e680759989a9fff5b2b9ab4615 to your computer and use it in GitHub Desktop.
RTS game by Opus 4.8 with ultracode via Claude Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Frontier Foundry — RTS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ============================================================ THEME */
:root{
--bg-void:#0a0d12; --panel-0:rgba(16,22,32,.86); --panel-1:rgba(22,30,44,.94);
--panel-edge:#1d2a3e; --line:rgba(120,170,230,.18); --line-bright:rgba(120,200,255,.45);
--txt:#c7d6ea; --txt-dim:#7e93ad; --accent:#36e0ff; --gold:#ffce5c;
--green:#5cffac; --red:#ff5a6e; --amber:#ffb454;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg-void);
font-family:'Rajdhani',sans-serif;color:var(--txt);user-select:none;cursor:default}
#game-root{position:fixed;inset:0}
#world-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;
background:var(--bg-void);image-rendering:auto;cursor:crosshair}
#world-canvas.placing{cursor:cell}
/* ---- HUD overlay ---- */
#hud{position:absolute;inset:0;pointer-events:none;z-index:10;
font-family:'Rajdhani',sans-serif}
#hud .panel{background:linear-gradient(180deg,var(--panel-1),var(--panel-0));
border:1px solid var(--panel-edge);border-radius:7px;
box-shadow:0 6px 22px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.05);
backdrop-filter:blur(3px)}
.corner-tick{position:absolute;width:9px;height:9px;border:2px solid var(--line-bright);pointer-events:none}
.ct-tl{top:-1px;left:-1px;border-right:0;border-bottom:0}
.ct-tr{top:-1px;right:-1px;border-left:0;border-bottom:0}
.ct-bl{bottom:-1px;left:-1px;border-right:0;border-top:0}
.ct-br{bottom:-1px;right:-1px;border-left:0;border-top:0}
/* ---- top bar ---- */
#topbar{position:absolute;top:10px;left:50%;transform:translateX(-50%);
display:flex;align-items:center;gap:22px;padding:8px 20px;pointer-events:auto}
.stat{display:flex;flex-direction:column;align-items:center;line-height:1}
.stat .lbl{font-size:9px;letter-spacing:1.5px;color:var(--txt-dim);text-transform:uppercase;
font-family:'Orbitron',sans-serif;margin-bottom:3px}
.stat .val{font-family:'Orbitron',sans-serif;font-weight:700;font-size:18px}
#credit-val{color:var(--gold);min-width:62px;text-align:center;transition:transform .1s}
#credit-val.flash{transform:scale(1.18)}
#rate-val{font-size:11px;color:var(--green);font-weight:600}
.sep{width:1px;height:30px;background:var(--line)}
#power-wrap{display:flex;flex-direction:column;align-items:center;min-width:120px}
#power-bar{position:relative;width:120px;height:11px;border-radius:6px;overflow:hidden;
background:rgba(0,0,0,.5);border:1px solid var(--line);margin-top:2px}
#power-fill{position:absolute;inset:0;width:100%;transform-origin:left;
background:linear-gradient(90deg,#2db5ff,#36e0ff);transition:transform .35s ease}
#power-bar.low #power-fill{background:linear-gradient(90deg,#ff5a6e,#ffb454);
animation:pulseLow 1s infinite}
@keyframes pulseLow{0%,100%{opacity:1}50%{opacity:.55}}
#power-txt{font-family:'Orbitron',sans-serif;font-size:11px;font-weight:700;margin-top:3px}
.accent{color:var(--accent)} .dim{color:var(--txt-dim)}
/* ---- objectives ---- */
#objectives{position:absolute;top:12px;left:12px;width:236px;pointer-events:auto;
padding:10px 12px 11px}
#obj-head{display:flex;justify-content:space-between;align-items:center;cursor:pointer;
font-family:'Orbitron',sans-serif;font-size:11px;letter-spacing:1.2px;color:var(--accent);
text-transform:uppercase;margin-bottom:8px}
#obj-count{background:rgba(54,224,255,.14);border:1px solid var(--line-bright);
border-radius:10px;padding:1px 8px;font-size:11px;color:var(--txt);transition:transform .25s}
#obj-count.bump{transform:scale(1.35)}
#obj-list.collapsed{display:none}
.obj{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:13.5px;font-weight:500}
.obj .box{flex:0 0 16px;width:16px;height:16px;border-radius:4px;border:1.5px solid var(--txt-dim);
display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--bg-void);
transition:all .3s}
.obj.done .box{background:var(--green);border-color:var(--green);
box-shadow:0 0 9px rgba(92,255,172,.7)}
.obj.done .txt{color:var(--green)}
.obj .txt{flex:1;color:var(--txt)}
.obj .prog{font-size:11px;color:var(--txt-dim);font-family:'Orbitron',sans-serif}
/* ---- bottom bar ---- */
#bottom-bar{position:absolute;left:0;right:0;bottom:0;height:172px;
display:flex;align-items:flex-end;justify-content:space-between;
padding:0 12px 12px;pointer-events:none}
#minimap-panel{position:relative;width:198px;height:198px;padding:5px;pointer-events:auto;
flex:0 0 auto;margin-bottom:-26px}
#minimap{width:188px;height:188px;border-radius:4px;display:block;background:#05070a;cursor:pointer}
#minimap-label{position:absolute;top:-16px;left:2px;font-family:'Orbitron',sans-serif;
font-size:9px;letter-spacing:1.5px;color:var(--txt-dim)}
#selection-panel{flex:1 1 auto;max-width:520px;height:130px;margin:0 12px;
pointer-events:auto;padding:10px 14px;display:flex;align-items:center;gap:14px;overflow:hidden}
#sel-empty{color:var(--txt-dim);font-size:14px;text-align:center;width:100%;line-height:1.7}
#sel-empty b{color:var(--accent)}
#sel-portrait{flex:0 0 96px;width:96px;height:96px;border-radius:6px;
background:radial-gradient(circle at 50% 38%,#1a2738,#0c121c);
border:1px solid var(--line);position:relative;overflow:hidden}
#sel-portrait canvas{position:absolute;inset:0;width:100%;height:100%}
#sel-info{flex:1;min-width:0}
#sel-name{font-family:'Orbitron',sans-serif;font-weight:700;font-size:17px;color:#fff}
#sel-role{font-size:12px;color:var(--txt-dim);letter-spacing:.5px;margin-bottom:6px}
.hpbar{height:9px;border-radius:5px;background:rgba(0,0,0,.5);border:1px solid var(--line);
overflow:hidden;position:relative;margin:3px 0 4px}
.hpbar>i{position:absolute;inset:0;transform-origin:left;
background:linear-gradient(90deg,#3bd66b,#5cffac);transition:transform .2s}
.sel-stats{display:flex;gap:14px;font-size:12.5px;color:var(--txt);flex-wrap:wrap}
.sel-stats b{font-family:'Orbitron',sans-serif;color:var(--accent);font-weight:700}
#sel-extra{font-size:12px;color:var(--gold);margin-top:5px;min-height:15px}
#sel-multi{display:flex;flex-wrap:wrap;gap:5px;align-content:flex-start;max-height:108px;overflow:hidden;flex:1}
.mini-unit{width:30px;height:30px;border-radius:5px;border:1px solid var(--line);
background:#0f1826;position:relative;display:flex;align-items:flex-end}
.mini-unit i{display:block;width:100%;height:3px;background:var(--green);border-radius:2px}
.mini-unit span{position:absolute;top:2px;left:0;right:0;text-align:center;font-size:9px;
font-family:'Orbitron',sans-serif;color:var(--txt)}
#command-card{flex:0 0 268px;height:148px;pointer-events:auto;padding:9px 10px;
display:flex;flex-direction:column;margin-bottom:-26px}
#card-title{font-family:'Orbitron',sans-serif;font-size:10px;letter-spacing:1.4px;
color:var(--txt-dim);text-transform:uppercase;margin-bottom:7px;display:flex;
justify-content:space-between;align-items:center}
#prod-queue{display:flex;gap:3px}
#prod-queue .pip{width:12px;height:12px;border-radius:3px;background:rgba(255,255,255,.12);
border:1px solid var(--line);position:relative}
#prod-queue .pip.active{background:conic-gradient(var(--accent) calc(var(--p)*360deg),rgba(255,255,255,.1) 0)}
#card-grid{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:54px;gap:6px;flex:1;align-content:start}
.card-btn{position:relative;border:1px solid var(--panel-edge);border-radius:6px;
background:linear-gradient(180deg,#1c2738,#121a27);cursor:pointer;overflow:hidden;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;
transition:border-color .15s,transform .08s;pointer-events:auto}
.card-btn:hover{border-color:var(--line-bright)}
.card-btn:active{transform:scale(.94)}
.card-btn .ico{width:26px;height:26px}
.card-btn .nm{font-size:9.5px;font-weight:600;line-height:1;color:var(--txt);text-align:center}
.card-btn .cost{position:absolute;bottom:1px;right:3px;font-size:9px;font-family:'Orbitron',sans-serif;color:var(--gold)}
.card-btn .hk{position:absolute;top:1px;left:3px;font-size:9px;font-family:'Orbitron',sans-serif;
color:var(--accent);opacity:.8}
.card-btn .stk{position:absolute;top:1px;right:3px;font-size:10px;font-family:'Orbitron',sans-serif;
color:#fff;background:rgba(0,0,0,.5);border-radius:6px;padding:0 4px}
.card-btn.disabled{opacity:.42;cursor:not-allowed}
.card-btn.disabled .cost{color:var(--red)}
.card-btn.locked{opacity:.35;cursor:not-allowed;filter:grayscale(.6)}
.card-btn.locked::after{content:'🔒';position:absolute;font-size:15px;opacity:.7}
.card-btn.building::before{content:'';position:absolute;left:0;bottom:0;width:100%;
height:calc(var(--p)*100%);background:rgba(54,224,255,.18);transition:height .1s}
.card-btn.ready{border-color:var(--green);animation:readyPulse 1.1s infinite}
.card-btn.ready .nm{color:var(--green)}
@keyframes readyPulse{0%,100%{box-shadow:0 0 0 rgba(92,255,172,0)}
50%{box-shadow:0 0 12px rgba(92,255,172,.65)}}
.info-card{font-size:12px;color:var(--txt-dim);line-height:1.6;padding:4px 2px}
.info-card b{color:var(--accent);font-family:'Orbitron',sans-serif}
/* ---- help, toasts, tooltip, victory ---- */
#help-hint{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);
font-size:11px;color:var(--txt-dim);background:rgba(10,13,18,.7);padding:5px 14px;
border-radius:14px;border:1px solid var(--line);pointer-events:none;letter-spacing:.4px;
transition:opacity .5s}
#help-hint b{color:var(--accent)}
#toast-layer{position:absolute;top:64px;left:50%;transform:translateX(-50%);
display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;z-index:15}
.toast{font-family:'Orbitron',sans-serif;font-size:13px;font-weight:600;letter-spacing:.6px;
background:linear-gradient(180deg,rgba(22,30,44,.96),rgba(14,20,30,.96));
border:1px solid var(--line-bright);border-radius:6px;padding:7px 18px;color:var(--txt);
box-shadow:0 6px 20px rgba(0,0,0,.5);animation:toastIn .35s ease;white-space:nowrap}
.toast.good{border-color:var(--green);color:var(--green);box-shadow:0 0 20px rgba(92,255,172,.3)}
.toast.warn{border-color:var(--amber);color:var(--amber)}
@keyframes toastIn{from{opacity:0;transform:translateY(-14px)}to{opacity:1;transform:none}}
#tooltip{position:absolute;z-index:20;pointer-events:none;max-width:230px;
background:rgba(10,14,20,.97);border:1px solid var(--line-bright);border-radius:6px;
padding:7px 10px;font-size:12.5px;color:var(--txt);box-shadow:0 6px 18px rgba(0,0,0,.6);
opacity:0;transition:opacity .12s;left:0;top:0;line-height:1.45}
#tooltip.show{opacity:1}
#tooltip .tt-name{font-family:'Orbitron',sans-serif;font-weight:700;color:#fff;font-size:13px}
#tooltip .tt-cost{color:var(--gold);font-family:'Orbitron',sans-serif}
#tooltip .tt-desc{color:var(--txt-dim);margin-top:3px;font-size:11.5px}
#tooltip .tt-lock{color:var(--red);margin-top:3px;font-size:11.5px}
#victory-banner{position:absolute;inset:0;z-index:30;display:none;align-items:center;
justify-content:center;flex-direction:column;pointer-events:none;
background:radial-gradient(circle at 50% 50%,rgba(255,206,92,.10),rgba(10,13,18,.78) 70%)}
#victory-banner.show{display:flex;animation:fadeIn .6s ease}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
#victory-banner .vb-title{font-family:'Orbitron',sans-serif;font-weight:900;font-size:54px;
letter-spacing:4px;color:var(--gold);text-shadow:0 0 30px rgba(255,206,92,.6);text-align:center}
#victory-banner .vb-sub{font-size:18px;color:var(--txt);margin-top:10px;letter-spacing:1px}
#paused-tag{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:16;
font-family:'Orbitron',sans-serif;font-size:40px;font-weight:900;letter-spacing:6px;
color:rgba(255,255,255,.85);display:none;text-shadow:0 0 20px #000}
#paused-tag.show{display:block}
</style>
</head>
<body>
<div id="game-root">
<canvas id="world-canvas"></canvas>
<div id="hud">
<div id="topbar" class="panel" data-notip>
<span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span>
<span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span>
<div class="stat"><span class="lbl">Credits</span><span class="val" id="credit-val">1500</span></div>
<div class="stat"><span class="lbl">Income</span><span class="val" id="rate-val">+0/s</span></div>
<div class="sep"></div>
<div id="power-wrap"><span class="lbl" style="font-size:9px;letter-spacing:1.5px;color:var(--txt-dim);font-family:Orbitron">Power</span>
<div id="power-bar"><div id="power-fill"></div></div>
<span id="power-txt"><span class="accent">0</span>/<span class="dim">0</span></span></div>
<div class="sep"></div>
<div class="stat"><span class="lbl">Units</span><span class="val" id="units-val">0</span></div>
<div class="stat"><span class="lbl">Bases</span><span class="val" id="bldg-val">0</span></div>
<div class="stat"><span class="lbl">Mapped</span><span class="val" id="map-val">0%</span></div>
<div class="sep"></div>
<div class="stat"><span class="lbl">Time</span><span class="val" id="clock-val" style="font-size:15px">0:00</span></div>
</div>
<div id="objectives" class="panel">
<span class="ct ct-tl corner-tick"></span><span class="ct ct-br corner-tick"></span>
<div id="obj-head"><span>◆ Objectives</span><span id="obj-count">0/5</span></div>
<div id="obj-list"></div>
</div>
<div id="bottom-bar">
<div id="minimap-panel" class="panel">
<span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span>
<span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span>
<div id="minimap-label">◈ TACTICAL MAP</div>
<canvas id="minimap" width="188" height="188"></canvas>
</div>
<div id="selection-panel" class="panel">
<span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span>
<span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span>
<div id="sel-empty">Select a unit or building.<br><b>Left-drag</b> to box-select · <b>Right-click</b> to command<br><b>WASD</b>/edges pan · <b>wheel</b> zoom · <b>Space</b> pause</div>
<div id="sel-portrait" style="display:none"><canvas id="portrait-cv" width="96" height="96"></canvas></div>
<div id="sel-info" style="display:none">
<div id="sel-name">—</div><div id="sel-role">—</div>
<div class="hpbar"><i id="sel-hp"></i></div>
<div class="sel-stats" id="sel-stats"></div>
<div id="sel-extra"></div>
</div>
<div id="sel-multi" style="display:none"></div>
</div>
<div id="command-card" class="panel">
<span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span>
<span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span>
<div id="card-title"><span id="card-label">COMMAND</span><div id="prod-queue"></div></div>
<div id="card-grid"></div>
</div>
</div>
<div id="help-hint"><b>Click</b> a Power Plant in the command card to start building your base.</div>
<div id="toast-layer"></div>
</div>
<div id="paused-tag">PAUSED</div>
<div id="tooltip"></div>
<div id="victory-banner">
<div class="vb-title">FRONTIER SECURED</div>
<div class="vb-sub" id="vb-sub"></div>
</div>
</div>
<script>
"use strict";
/* ====================================================================
FRONTIER FOUNDRY — single-file canvas RTS
==================================================================== */
/* ===== S0 CONSTANTS & CONFIG ===================================== */
const TILE=32, MAP_W=96, MAP_H=96;
const WORLD_W=MAP_W*TILE, WORLD_H=MAP_H*TILE, N_TILES=MAP_W*MAP_H;
const SEED=0xC0FFEE;
const ZOOM_MIN=0.55, ZOOM_MAX=2.0, ZOOM_DEFAULT=1.0, ZOOM_STEP=1.12;
const SIM_DT=1/60, MAX_FRAME=0.25, MAX_CATCHUP=5;
const EDGE_PAN=26, PAN_SPEED=950;
const T_GRASS=0,T_ROCK=1,T_WATER=2,T_CRYSTAL=3;
const PASSABLE=[true,false,false,false];
const BUILDABLE=[true,false,false,false];
const START_CREDITS=1500;
const CRYSTAL_PER_TILE=600;
const HARV_BITE=25, HARV_BITE_TIME=0.45;
const HARV_DUMP=75, HARV_DUMP_TIME=0.5;
const HARV_CARGO=150;
const QUEUE_MAX=5, BUILD_RADIUS=6;
const VIS_INTERVAL=1/6, FADE_PER_SEC=255/0.4, MEMORY_DIM=0.62, VIS_THRESHOLD=32;
const FOG_R=0x0a,FOG_G=0x0d,FOG_B=0x12;
const SQRT2=1.41421356;
const MAX_EXPANSIONS=5000, PATHS_PER_FRAME=6;
const ARRIVE_WP=7, ARRIVE_GOAL=4, ARRIVE_DOCK=TILE*1.6;
const ACCEL=16, TURN_RATE=13, SEP_STRENGTH=0.55;
const STUCK_TIME=0.8, STUCK_EPS=2.0;
const MAXP=700;
const P_CHIP=0,P_DUST=1,P_SMOKE=2,P_SPARK=3,P_POOF=4,P_DEATH=5,P_CONFETTI=6;
const CRITTER_COUNT=9, CRITTER_HP=30, CRITTER_RESPAWN=16;
const OBJ_CREDITS=5000, OBJ_EXPLORE=0.80, OBJ_TANKS=3;
const BLDG_DEFS={
cc: {name:'Command Center',cost:0, time:0, hp:1500,fp:[3,3],power:0, sight:9,dock:true, produces:true},
power: {name:'Power Plant', cost:300, time:6, hp:500, fp:[2,2],power:+100,sight:5},
refinery: {name:'Refinery', cost:1500,time:13,hp:900, fp:[3,3],power:-40, sight:6,dock:true, grantsHarvester:true},
barracks: {name:'Barracks', cost:400, time:8, hp:600, fp:[2,2],power:-20, sight:5,produces:true},
warfactory:{name:'War Factory', cost:800, time:14,hp:800, fp:[3,3],power:-50, sight:6,produces:true,req:'barracks'},
watchtower:{name:'Watchtower', cost:200, time:5, hp:400, fp:[1,1],power:-10, sight:11},
};
const UNIT_DEFS={
harvester:{name:'Harvester', cost:700,time:8, hp:600,speed:2.0,sight:4,r:13,cargoMax:150},
rifleman: {name:'Rifleman', cost:100,time:4, hp:100,speed:3.0,sight:5,r:9, atk:8, range:3.5,rof:0.7},
scout: {name:'Scout', cost:150,time:5, hp:80, speed:5.0,sight:9,r:9, atk:4, range:3, rof:0.5},
rocket: {name:'Rocket Soldier',cost:250,time:7, hp:140,speed:2.5,sight:5,r:10,atk:20,range:5, rof:1.4},
tank: {name:'Battle Tank', cost:600,time:12,hp:700,speed:2.2,sight:6,r:15,atk:30,range:6, rof:1.2},
};
const CRITTER_DEF={name:'Critter',hp:CRITTER_HP,speed:1.3,sight:0,r:8,atk:0,range:0};
/* palettes */
const PAL={
grassLo:'#516e38',grassHi:'#74994d',dirtLo:'#5f4d31',dirtHi:'#856b45',
rockLo:'#3a3f47',rockHi:'#5a626e',rockEdge:'#23262c',rockSpec:'#737c8a',
waterLo:'#123b44',waterHi:'#1f5d68',waterFoam:'#49949b',
crystalCore:'#39e0e6',crystalLit:'#bff8fa',crystalDark:'#1b8d98',crystalEdge:'#0c5460',
};
const TEAM={blue:'#3d7bff',blueLit:'#85adff',blueDark:'#1f3f8c',outline:'#0a1124'};
const BLD={
cc:{base:'#4a5a78',lit:'#7286ab',dark:'#2a3346',accent:'#ffcf5c'},
power:{base:'#5a6470',lit:'#828d9c',dark:'#323843',accent:'#ffd23f'},
refinery:{base:'#4f5b6b',lit:'#788aa0',dark:'#2c3640',accent:'#46f0f0'},
barracks:{base:'#6a5a44',lit:'#94805f',dark:'#3b3224',accent:'#d05a3c'},
warfactory:{base:'#525a64',lit:'#7a8492',dark:'#2d323a',accent:'#ff8a3c'},
watchtower:{base:'#5c6573',lit:'#8893a4',dark:'#343a44',accent:'#46f0f0'},
};
const UNIT={
harvester:{hull:'#c9a24a',hullLit:'#f0cd76',dark:'#6e561f',cargo:'#46f0f0'},
rifleman:{body:TEAM.blue,lit:TEAM.blueLit,dark:TEAM.blueDark},
scout:{body:'#37b6c4',lit:'#7eeaf2',dark:'#1c5d66'},
rocket:{body:'#8a6cff',lit:'#b9a8ff',dark:'#3a2c80'},
tank:{body:'#3d7bff',lit:'#85adff',dark:'#1f3f8c',tread:'#1c2029'},
critter:{body:'#b07a52',lit:'#d6a87f',dark:'#5e3f28'},
};
const FX={selValid:'#5dff8a',hpHigh:'#5dff7a',hpMid:'#ffd23f',hpLow:'#ff4d42',
movePing:'rgba(120,255,160,0.95)',gatherPing:'rgba(70,240,240,0.95)',attackPing:'rgba(255,93,82,0.95)',
creditPlus:'#7bff9b'};
/* ===== S1 UTIL / MATH =========================================== */
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
function lerpAngle(a,b,t){let d=((b-a+Math.PI)%(2*Math.PI))-Math.PI;if(d<-Math.PI)d+=2*Math.PI;return a+d*t;}
const dist2=(ax,ay,bx,by)=>{const dx=ax-bx,dy=ay-by;return dx*dx+dy*dy;};
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function fmtClock(s){s=Math.floor(s);const m=Math.floor(s/60);const ss=(s%60).toString().padStart(2,'0');return m+':'+ss;}
function hexToRgb(h){h=h[0]==='#'?h.slice(1):h;return[parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function shade(hex,f){const c=hexToRgb(hex);return'rgb('+clamp(c[0]*f|0,0,255)+','+clamp(c[1]*f|0,0,255)+','+clamp(c[2]*f|0,0,255)+')';}
function lerpColor(a,b,t){const ca=hexToRgb(a),cb=hexToRgb(b);return'rgb('+((ca[0]+(cb[0]-ca[0])*t)|0)+','+((ca[1]+(cb[1]-ca[1])*t)|0)+','+((ca[2]+(cb[2]-ca[2])*t)|0)+')';}
/* ===== S2 COORD TRANSFORMS ====================================== */
const tileIdx=(tx,ty)=>ty*MAP_W+tx;
const inBounds=(tx,ty)=>tx>=0&&ty>=0&&tx<MAP_W&&ty<MAP_H;
const tileCenter=(t)=>t*TILE+TILE/2;
function worldToScreen(wx,wy){const z=G.cam.zoom;return{x:(wx-G.cam.x)*z,y:(wy-G.cam.y)*z};}
function screenToWorld(sx,sy){const z=G.cam.zoom;return{x:sx/z+G.cam.x,y:sy/z+G.cam.y};}
function screenToTile(sx,sy){const w=screenToWorld(sx,sy);return{tx:(w.x/TILE)|0,ty:(w.y/TILE)|0,wx:w.x,wy:w.y};}
function centerCameraOn(wx,wy){const c=G.cam;c.tx=wx-(c.vw/c.zoom)/2;c.ty=wy-(c.vh/c.zoom)/2;clampCamTarget();c.x=c.tx;c.y=c.ty;c.moved=true;}
function viewportTileRect(){const c=G.cam;return{
x0:Math.max(0,(c.x/TILE|0)-1),y0:Math.max(0,(c.y/TILE|0)-1),
x1:Math.min(MAP_W-1,((c.x+c.vw/c.zoom)/TILE|0)+1),y1:Math.min(MAP_H-1,((c.y+c.vh/c.zoom)/TILE|0)+1)};}
/* ===== S3 GLOBAL STATE (G) ====================================== */
const G={
time:0,frame:0,sessionSec:0,paused:false,
terrain:null,occ:null,crystalAmt:null,crystalFieldId:null,passGrid:null,passVersion:0,
noise:null,patchNoise:null,crystalTiles:[],
units:[],buildings:[],projectiles:[],nextId:1,idMap:new Map(),
credits:START_CREDITS,creditsDisplay:START_CREDITS,totalMined:0,maxCredits:START_CREDITS,
incomeRate:0,incomeAccum:0,incomeWindow:0,
powerSupply:0,powerDemand:0,powerRatio:1,throttle:1,
hasBarracks:false,hasWarFactory:false,builtTypes:new Set(),
selection:[],selKind:'none',
controlGroups:[null,[],[],[],[],[],[],[],[],[]],lastRecall:{g:-1,t:-1},
buildSlots:{},placing:null,buildAura:null,
cam:{x:0,y:0,tx:0,ty:0,zoom:1,tzoom:1,vw:0,vh:0,moved:true},
input:{mode:'default',mx:0,my:0,wx:0,wy:0,lDown:false,mDown:false,
downSX:0,downSY:0,dragging:false,boxX0:0,boxY0:0,
panSX:0,panSY:0,panCamX:0,panCamY:0,shift:false,ctrl:false,
keys:Object.create(null),attackArmed:false,hoverEnt:null},
fog:null,particles:null,floaters:null,decals:null,
objectives:[],objDoneCount:0,won:false,wonAt:0,
};
/* ===== S4 TERRAIN GEN =========================================== */
let START_TX=46, START_TY=46;
function smoothField(rng,passes){
let a=new Float32Array(N_TILES);
for(let i=0;i<N_TILES;i++)a[i]=rng();
for(let p=0;p<passes;p++){
const b=new Float32Array(N_TILES);
for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){
let s=0,n=0;
for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){
const xx=x+dx,yy=y+dy;if(xx<0||yy<0||xx>=MAP_W||yy>=MAP_H)continue;
s+=a[yy*MAP_W+xx];n++;
}
b[y*MAP_W+x]=s/n;
}
a=b;
}
let mn=Infinity,mx=-Infinity;
for(let i=0;i<N_TILES;i++){if(a[i]<mn)mn=a[i];if(a[i]>mx)mx=a[i];}
const r=mx-mn||1;
for(let i=0;i<N_TILES;i++)a[i]=(a[i]-mn)/r;
return a;
}
function stampCrystalField(cx,cy,size,rng,fieldId){
// clear a grass ring first so harvesters can stand adjacent
for(let dy=-3;dy<=3;dy++)for(let dx=-3;dx<=3;dx++){
const tx=cx+dx,ty=cy+dy;if(!inBounds(tx,ty))continue;
const i=tileIdx(tx,ty);if(G.terrain[i]===T_ROCK||G.terrain[i]===T_WATER){G.terrain[i]=T_GRASS;}
}
let placed=0,ring=0;
const cand=[];
for(let dy=-2;dy<=2;dy++)for(let dx=-2;dx<=2;dx++){
if(dx*dx+dy*dy<=4.5)cand.push([dx,dy,dx*dx+dy*dy]);
}
cand.sort((a,b)=>a[2]-b[2]+(rng()-0.5)*0.6);
for(const c of cand){
if(placed>=size)break;
const tx=cx+c[0],ty=cy+c[1];
if(tx<2||ty<2||tx>=MAP_W-2||ty>=MAP_H-2)continue;
const i=tileIdx(tx,ty);
G.terrain[i]=T_CRYSTAL;G.crystalAmt[i]=CRYSTAL_PER_TILE;G.crystalFieldId[i]=fieldId;
G.crystalTiles.push({tx,ty,fieldId,i});placed++;
}
}
function generateTerrain(){
const rng=mulberry32(SEED);
G.terrain=new Uint8Array(N_TILES);
G.occ=new Int32Array(N_TILES);
G.crystalAmt=new Uint16Array(N_TILES);
G.crystalFieldId=new Uint8Array(N_TILES);
G.passGrid=new Uint8Array(N_TILES);
G.crystalTiles=[];
const elev=smoothField(rng,3);
G.patchNoise=smoothField(rng,4);
G.noise=smoothField(rng,0);
// base biome by elevation
for(let i=0;i<N_TILES;i++){
const e=elev[i];
if(e<0.27)G.terrain[i]=T_WATER;
else if(e>0.73)G.terrain[i]=T_ROCK;
else G.terrain[i]=T_GRASS;
}
// clear a buildable clearing around start
for(let dy=-6;dy<=6;dy++)for(let dx=-6;dx<=6;dx++){
if(dx*dx+dy*dy>52)continue;
const tx=START_TX+1+dx,ty=START_TY+1+dy;if(!inBounds(tx,ty))continue;
G.terrain[tileIdx(tx,ty)]=T_GRASS;
}
// crystal fields (home + 3 spread out, deterministic)
stampCrystalField(START_TX+8,START_TY,12,rng,0); // home field
stampCrystalField(20,22,14,rng,1);
stampCrystalField(74,28,12,rng,2);
stampCrystalField(26,74,13,rng,3);
stampCrystalField(72,72,15,rng,4);
// border ring -> rock + blocked
for(let x=0;x<MAP_W;x++){G.terrain[tileIdx(x,0)]=T_ROCK;G.terrain[tileIdx(x,MAP_H-1)]=T_ROCK;}
for(let y=0;y<MAP_H;y++){G.terrain[tileIdx(0,y)]=T_ROCK;G.terrain[tileIdx(MAP_W-1,y)]=T_ROCK;}
// build passGrid
for(let i=0;i<N_TILES;i++)G.passGrid[i]=PASSABLE[G.terrain[i]]?0:1;
rebuildCrystalTiles();
}
function rebuildCrystalTiles(){
G.crystalTiles=[];
for(let i=0;i<N_TILES;i++)if(G.terrain[i]===T_CRYSTAL&&G.crystalAmt[i]>0){
G.crystalTiles.push({tx:i%MAP_W,ty:(i/MAP_W)|0,fieldId:G.crystalFieldId[i],i});
}
}
function markPassDirty(){G.passVersion++;}
function depleteCrystal(i){
G.terrain[i]=T_GRASS;G.crystalAmt[i]=0;G.passGrid[i]=0;markPassDirty();
repaintTile(i%MAP_W,(i/MAP_W)|0);
rebuildCrystalTiles();
}
/* ===== S5 ENTITY FACTORIES ====================================== */
function makeUnit(type,wx,wy,team){
const d=UNIT_DEFS[type]||CRITTER_DEF;
const u={id:G.nextId++,kind:'unit',type,team,
x:wx,y:wy,px:wx,py:wy,vx:0,vy:0,facing:Math.random()*6.28,aimFacing:0,r:d.r,
hp:d.hp,hpMax:d.hp,speed:d.speed*TILE,sight:d.sight,atk:d.atk||0,
range:(d.range||0)*TILE,rof:d.rof||1,
order:'idle',moveState:'idle',targetId:0,
waypoints:null,wpIndex:0,goalTile:-1,exactGoal:false,exX:0,exY:0,
pathVersion:-1,pendingGoal:null,_inQueue:false,stuckT:0,stuckCount:0,lastX:wx,lastY:wy,
tileX:(wx/TILE)|0,tileY:(wy/TILE)|0,
cargo:0,cargoMax:d.cargoMax||0,harvState:null,harvTile:-1,harvTimer:0,dockId:0,rallyFieldId:-1,
atkCooldown:0,bob:Math.random()*6.28,wanderT:0,
spawnT:0,selT:0,hpFlash:0,dead:false};
return u;
}
function makeBuilding(type,tx,ty,built){
const d=BLDG_DEFS[type];const[fw,fh]=d.fp;
const b={id:G.nextId++,kind:'building',type,
tx,ty,fw,fh,x:tx*TILE+fw*TILE/2,y:ty*TILE+fh*TILE/2,
hp:d.hp,hpMax:d.hp,sight:d.sight,power:d.power,
tileX:tx+(fw>>1),tileY:ty+(fh>>1),
built:!!built,buildT:built?1:0,buildTime:d.time,
queue:[],prodT:0,rally:null,canProduce:!!d.produces,
isDock:!!d.dock,
placeT:0,glow:0,readyPulse:0,hpFlash:0,selT:0,smokeT:0,blinkT:0,sweepT:0,dead:false};
return b;
}
function stampBuilding(b,blocked){
for(let ty=b.ty;ty<b.ty+b.fh;ty++)for(let tx=b.tx;tx<b.tx+b.fw;tx++){
if(!inBounds(tx,ty))continue;const i=tileIdx(tx,ty);
G.passGrid[i]=blocked?1:0;G.occ[i]=blocked?b.id:0;
}
markPassDirty();
}
function addUnit(u){G.units.push(u);G.idMap.set(u.id,u);G.fog&&(G.fog.dirty=true);return u;}
function addBuilding(b){G.buildings.push(b);G.idMap.set(b.id,b);stampBuilding(b,true);
if(b.built){recomputePower();recomputeTech();G.fog&&(G.fog.dirty=true);}return b;}
function recomputePower(){
let sup=0,dem=0;
for(const b of G.buildings){if(b.dead||!b.built)continue;
if(b.power>0)sup+=b.power;else dem+=-b.power;}
G.powerSupply=sup;G.powerDemand=dem;
G.powerRatio=dem>0?clamp(sup/dem,0,1):1;
G.throttle=0.35+0.65*G.powerRatio;
}
function recomputeTech(){
G.builtTypes.clear();G.hasBarracks=false;G.hasWarFactory=false;
for(const b of G.buildings){if(b.dead||!b.built)continue;
G.builtTypes.add(b.type);
if(b.type==='barracks')G.hasBarracks=true;
if(b.type==='warfactory')G.hasWarFactory=true;}
}
/* ===== S6 CAMERA ================================================ */
function clampCamTarget(){const c=G.cam;
const maxX=Math.max(0,WORLD_W-c.vw/c.zoom),maxY=Math.max(0,WORLD_H-c.vh/c.zoom);
c.tx=clamp(c.tx,0,maxX);c.ty=clamp(c.ty,0,maxY);}
function clampCam(){const c=G.cam;
const maxX=Math.max(0,WORLD_W-c.vw/c.zoom),maxY=Math.max(0,WORLD_H-c.vh/c.zoom);
c.x=clamp(c.x,0,maxX);c.y=clamp(c.y,0,maxY);}
function updateCamera(dt){
const c=G.cam,inp=G.input;
// zoom (target set in wheel handler via c.tzoom). preserve world point under cursor handled there.
const ze=1-Math.exp(-16*dt);
c.zoom+=(c.tzoom-c.zoom)*ze;
if(Math.abs(c.tzoom-c.zoom)<0.0005)c.zoom=c.tzoom;
// keyboard + edge pan (skip while middle-dragging)
if(!inp.mDown){
let dx=0,dy=0;const k=inp.keys;
// A=attack-move and S=stop are unit commands when units selected; don't pan with them then.
const usel=G.selKind==='units';
if(k['KeyW']||k['ArrowUp'])dy-=1;
if((k['KeyS']&&!usel)||k['ArrowDown'])dy+=1;
if((k['KeyA']&&!usel)||k['ArrowLeft'])dx-=1;
if(k['KeyD']||k['ArrowRight'])dx+=1;
// edge pan (only when window focused & mouse inside)
if(inp.insideCanvas){
if(inp.mx<EDGE_PAN)dx-=1;else if(inp.mx>c.vw-EDGE_PAN)dx+=1;
if(inp.my<EDGE_PAN)dy-=1;else if(inp.my>c.vh-EDGE_PAN)dy+=1;
}
if(dx||dy){const sp=PAN_SPEED*dt/c.zoom;c.tx+=dx*sp;c.ty+=dy*sp;}
} else {
// middle-drag: 1:1
c.tx=c.panCamX+(inp.panSX-inp.mx)/c.zoom;
c.ty=c.panCamY+(inp.panSY-inp.my)/c.zoom;
c.x=c.tx;c.y=c.ty;
}
clampCamTarget();
// ease toward target
const s=1-Math.exp(-18*dt);
const ox=c.x,oy=c.y;
c.x+=(c.tx-c.x)*s;c.y+=(c.ty-c.y)*s;
if(Math.abs(c.tx-c.x)<0.05)c.x=c.tx;
if(Math.abs(c.ty-c.y)<0.05)c.y=c.ty;
clampCam();
if(ox!==c.x||oy!==c.y||c.zoom!==c._lz){c.moved=true;c._lz=c.zoom;}
}
/* ===== S7 PATHING =============================================== */
const NX=[1,-1,0,0,1,1,-1,-1],NY=[0,0,1,-1,1,-1,1,-1],NC=[1,1,1,1,SQRT2,SQRT2,SQRT2,SQRT2];
const P={gScore:new Float32Array(N_TILES),fScore:new Float32Array(N_TILES),
cameFrom:new Int32Array(N_TILES),seenStamp:new Int32Array(N_TILES),
closedStamp:new Int32Array(N_TILES),heap:new Int32Array(N_TILES+1),heapLen:0,searchId:0,queue:[]};
function octile(x0,y0,x1,y1){const dx=Math.abs(x0-x1),dy=Math.abs(y0-y1);return(dx+dy)+(SQRT2-2)*Math.min(dx,dy);}
function heapPush(idx){const h=P.heap,f=P.fScore;let i=P.heapLen++;h[i]=idx;
while(i>0){const p=(i-1)>>1;if(f[h[p]]<=f[h[i]])break;const t=h[p];h[p]=h[i];h[i]=t;i=p;}}
function heapPop(){const h=P.heap,f=P.fScore;const top=h[0];const n=--P.heapLen;h[0]=h[n];
let i=0;while(true){let l=i*2+1,r=l+1,s=i;
if(l<n&&f[h[l]]<f[h[s]])s=l;if(r<n&&f[h[r]]<f[h[s]])s=r;if(s===i)break;
const t=h[s];h[s]=h[i];h[i]=t;i=s;}return top;}
function nearestPassableTile(tx,ty){
if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)])return{tx,ty};
for(let r=1;r<14;r++){
for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue;
const nx=tx+dx,ny=ty+dy;
if(inBounds(nx,ny)&&!G.passGrid[tileIdx(nx,ny)])return{tx:nx,ty:ny};
}
}
return null;
}
function reconstruct(node){
const out=[];let c=node;
while(c!==-1){out.push(c);c=P.cameFrom[c];}
out.reverse();return out;
}
function aStar(sx,sy,gx,gy){
if(!inBounds(sx,sy)||!inBounds(gx,gy))return null;
if(G.passGrid[tileIdx(gx,gy)]){const n=nearestPassableTile(gx,gy);if(!n)return null;gx=n.tx;gy=n.ty;}
let start=tileIdx(sx,sy),goal=tileIdx(gx,gy);
if(G.passGrid[start]){const n=nearestPassableTile(sx,sy);if(!n)return null;sx=n.tx;sy=n.ty;start=tileIdx(sx,sy);}
if(start===goal)return[start];
const sid=++P.searchId;P.heapLen=0;
P.gScore[start]=0;P.seenStamp[start]=sid;P.cameFrom[start]=-1;
P.fScore[start]=octile(sx,sy,gx,gy);heapPush(start);
let exp=0,best=start,bestH=P.fScore[start];
while(P.heapLen>0){
const cur=heapPop();
if(P.closedStamp[cur]===sid)continue;
P.closedStamp[cur]=sid;
if(cur===goal)return reconstruct(cur);
if(++exp>MAX_EXPANSIONS)break;
const cx=cur%MAP_W,cy=(cur/MAP_W)|0;
const h0=octile(cx,cy,gx,gy);if(h0<bestH){bestH=h0;best=cur;}
for(let dir=0;dir<8;dir++){
const nx=cx+NX[dir],ny=cy+NY[dir];
if(nx<0||ny<0||nx>=MAP_W||ny>=MAP_H)continue;
const ni=ny*MAP_W+nx;
if(G.passGrid[ni])continue;
if(dir>=4&&(G.passGrid[cy*MAP_W+nx]||G.passGrid[ny*MAP_W+cx]))continue;
const ng=P.gScore[cur]+NC[dir];
if(P.seenStamp[ni]!==sid||ng<P.gScore[ni]){
P.seenStamp[ni]=sid;P.gScore[ni]=ng;P.cameFrom[ni]=cur;
P.fScore[ni]=ng+octile(nx,ny,gx,gy);
if(P.closedStamp[ni]!==sid)heapPush(ni);
}
}
}
return reconstruct(best);
}
function tileLOS(x0,y0,x1,y1){
if(G.passGrid[tileIdx(x0,y0)])return false;
let dx=Math.abs(x1-x0),dy=Math.abs(y1-y0);
let x=x0,y=y0,sx=x1>x0?1:-1,sy=y1>y0?1:-1,err=dx-dy;
let guard=0;
while((x!==x1||y!==y1)&&guard++<512){
const e2=2*err;let mx=false,my=false;
if(e2>-dy){err-=dy;x+=sx;mx=true;}
if(e2<dx){err+=dx;y+=sy;my=true;}
if(mx&&my){if(G.passGrid[tileIdx(x-sx,y)]||G.passGrid[tileIdx(x,y-sy)])return false;}
if(G.passGrid[tileIdx(x,y)])return false;
}
return true;
}
function buildWaypoints(tp){
if(!tp||tp.length===0)return null;
if(tp.length===1){const i=tp[0];return[{x:tileCenter(i%MAP_W),y:tileCenter((i/MAP_W)|0)}];}
const pts=tp.map(i=>({tx:i%MAP_W,ty:(i/MAP_W)|0}));
const res=[pts[0]];let a=0;
for(let b=2;b<pts.length;b++){
if(!tileLOS(pts[a].tx,pts[a].ty,pts[b].tx,pts[b].ty)){res.push(pts[b-1]);a=b-1;}
}
res.push(pts[pts.length-1]);
return res.map(p=>({x:tileCenter(p.tx),y:tileCenter(p.ty)}));
}
function requestPath(u,gx,gy,opts){
opts=opts||{};
u.pendingGoal={gx,gy,exact:!!opts.exact,ex:opts.ex||0,ey:opts.ey||0};
u.goalTile=tileIdx(clamp(gx,0,MAP_W-1),clamp(gy,0,MAP_H-1));
u.moveState='pending';
if(!u._inQueue){u._inQueue=true;P.queue.push(u);}
}
function servicePathQueue(){
let budget=PATHS_PER_FRAME;
while(budget>0&&P.queue.length){
const u=P.queue.shift();u._inQueue=false;
if(u.dead||!u.pendingGoal)continue;
const sx=clamp((u.x/TILE)|0,0,MAP_W-1),sy=clamp((u.y/TILE)|0,0,MAP_H-1);
const pg=u.pendingGoal;
const tp=aStar(sx,sy,pg.gx,pg.gy);
u.waypoints=buildWaypoints(tp);
if(u.waypoints&&u.waypoints.length){
if(pg.exact)u.waypoints[u.waypoints.length-1]={x:pg.ex,y:pg.ey};
u.wpIndex=0;u.pathVersion=G.passVersion;u.moveState='moving';
} else {u.moveState='idle';onArrive(u);}
u.pendingGoal=null;budget--;
}
}
function repathSameGoal(u){
if(u.goalTile<0)return;
requestPath(u,u.goalTile%MAP_W,(u.goalTile/MAP_W)|0,{exact:u.exactGoal,ex:u.exX,ey:u.exY});
}
function issueMove(u,wx,wy,order){
const gx=clamp((wx/TILE)|0,0,MAP_W-1),gy=clamp((wy/TILE)|0,0,MAP_H-1);
const n=nearestPassableTile(gx,gy);
const tx=n?n.tx:gx,ty=n?n.ty:gy;
u.order=order||'move';
u.exactGoal=true;u.exX=clamp(wx,TILE,WORLD_W-TILE);u.exY=clamp(wy,TILE,WORLD_H-TILE);
if(u.type==='harvester'&&order==='move'){u.harvState=null;}
requestPath(u,tx,ty,{exact:true,ex:u.exX,ey:u.exY});
}
function formationSlots(n){
const slots=[{x:0,y:0}];let ring=1;
while(slots.length<n){
const count=ring*6;const rad=ring*TILE*1.25;
for(let k=0;k<count&&slots.length<n;k++){
const a=(k/count)*Math.PI*2;slots.push({x:Math.cos(a)*rad,y:Math.sin(a)*rad});
}
ring++;
}
return slots;
}
function issueGroupMove(units,wx,wy,order){
const list=units.filter(u=>!u.dead);
if(list.length<=1){for(const u of list)issueMove(u,wx,wy,order);return;}
const slots=formationSlots(list.length);
const sorted=list.slice().sort((a,b)=>dist2(a.x,a.y,wx,wy)-dist2(b.x,b.y,wx,wy));
for(let i=0;i<sorted.length;i++){
const sx=wx+slots[i].x,sy=wy+slots[i].y;
issueMove(sorted[i],sx,sy,order);
}
}
function stepMovement(dt){
servicePathQueue();
for(const u of G.units)if(!u.dead)integrateUnit(u,dt);
separateUnits();
for(const u of G.units)if(!u.dead)resolveBlockedTiles(u);
updateStuck(dt);
for(const u of G.units)if(!u.dead)trackTile(u);
}
function integrateUnit(u,dt){
u.spawnT+=dt;
if(u.selT<1)u.selT=Math.min(1,u.selT+dt*5);
if(u.hpFlash>0)u.hpFlash-=dt;
if(u.moveState!=='moving'||!u.waypoints){
u.vx*=Math.exp(-9*dt);u.vy*=Math.exp(-9*dt);
u.x+=u.vx*dt;u.y+=u.vy*dt;
return;
}
let wp=u.waypoints[u.wpIndex];
if(!wp){u.moveState='arrived';onArrive(u);return;}
let dx=wp.x-u.x,dy=wp.y-u.y,d=Math.hypot(dx,dy);
const isLast=u.wpIndex===u.waypoints.length-1;
const arriveR=isLast?ARRIVE_GOAL:ARRIVE_WP;
if(d<=arriveR){
if(isLast){u.moveState='arrived';u.vx*=0.3;u.vy*=0.3;onArrive(u);return;}
u.wpIndex++;
if(u.pathVersion!==G.passVersion)repathSameGoal(u);
return;
}
const nx=dx/d,ny=dy/d;
let target=u.speed;
if(isLast)target=u.speed*clamp(d/TILE,0.25,1);
const dvx=nx*target,dvy=ny*target;
const k=1-Math.exp(-ACCEL*dt);
u.vx+=(dvx-u.vx)*k;u.vy+=(dvy-u.vy)*k;
u.x+=u.vx*dt;u.y+=u.vy*dt;
const sp=Math.hypot(u.vx,u.vy);
if(sp>4)u.facing=lerpAngle(u.facing,Math.atan2(u.vy,u.vx),1-Math.exp(-TURN_RATE*dt));
}
const sepMap=new Map();
function separateUnits(){
sepMap.clear();
for(const u of G.units){if(u.dead)continue;
const cx=(u.x/TILE)|0,cy=(u.y/TILE)|0,key=cx*8192+cy;
let arr=sepMap.get(key);if(!arr){arr=[];sepMap.set(key,arr);}arr.push(u);}
for(const u of G.units){if(u.dead)continue;
const cx=(u.x/TILE)|0,cy=(u.y/TILE)|0;
let pdx=0,pdy=0;
for(let oy=-1;oy<=1;oy++)for(let ox=-1;ox<=1;ox++){
const arr=sepMap.get((cx+ox)*8192+(cy+oy));if(!arr)continue;
for(const v of arr){if(v===u||v.dead)continue;
let dx=u.x-v.x,dy=u.y-v.y,d2=dx*dx+dy*dy;const rr=u.r+v.r;
if(d2<rr*rr){
if(d2<0.01){pdx+=(Math.random()-0.5);pdy+=(Math.random()-0.5);continue;}
const d=Math.sqrt(d2),push=(rr-d)/d*SEP_STRENGTH;
pdx+=dx*push;pdy+=dy*push;
}
}
}
u.x+=pdx;u.y+=pdy;
}
}
function resolveBlockedTiles(u){
const cx=(u.x/TILE)|0,cy=(u.y/TILE)|0,r=u.r;
for(let ty=cy-1;ty<=cy+1;ty++)for(let tx=cx-1;tx<=cx+1;tx++){
if(!inBounds(tx,ty)||!G.passGrid[tileIdx(tx,ty)])continue;
const rx=tx*TILE,ry=ty*TILE;
const nx=clamp(u.x,rx,rx+TILE),ny=clamp(u.y,ry,ry+TILE);
let dx=u.x-nx,dy=u.y-ny,d2=dx*dx+dy*dy;
if(d2<r*r){
if(d2<0.001){
// deep inside: push out toward tile center direction
const ccx=rx+TILE/2,ccy=ry+TILE/2;
dx=u.x-ccx;dy=u.y-ccy;if(dx===0&&dy===0)dx=1;
const dl=Math.hypot(dx,dy);u.x=ccx+dx/dl*(TILE/2+r);u.y=ccy+dy/dl*(TILE/2+r);
} else {
const d=Math.sqrt(d2);u.x=nx+dx/d*r;u.y=ny+dy/d*r;
}
}
}
// world bounds
u.x=clamp(u.x,TILE+r,WORLD_W-TILE-r);u.y=clamp(u.y,TILE+r,WORLD_H-TILE-r);
}
function updateStuck(dt){
for(const u of G.units){
if(u.dead)continue;
if(u.moveState!=='moving'){u.stuckT=0;u.lastX=u.x;u.lastY=u.y;continue;}
const moved=Math.hypot(u.x-u.lastX,u.y-u.lastY);
if(moved<u.speed*dt*0.3)u.stuckT+=dt;else u.stuckT=0;
u.lastX=u.x;u.lastY=u.y;
if(u.stuckT>STUCK_TIME){
u.stuckT=0;u.stuckCount++;
if(u.stuckCount<=2)repathSameGoal(u);
else{u.stuckCount=0;u.moveState='arrived';onArrive(u);}
}
}
}
function onArrive(u){
u.stuckCount=0;
if(u.order==='harvest'){
if(u.harvState==='SEEKING'){u.harvState='HARVESTING';u.harvTimer=0;}
else if(u.harvState==='RETURNING'){u.harvState='DUMPING';u.harvTimer=0;}
else if(u.harvState===null){harvesterAutoSeek(u);}
u.vx=0;u.vy=0;return;
}
if(u.order==='attackmove'){
const t=findEnemyInRange(u,u.sight*TILE);
if(t){u.order='attack';u.targetId=t.id;}else u.order='idle';
u.vx=0;u.vy=0;return;
}
u.order='idle';u.moveState='idle';u.vx*=0.2;u.vy*=0.2;
}
function trackTile(u){
const tx=(u.x/TILE)|0,ty=(u.y/TILE)|0;
if(tx!==u.tileX||ty!==u.tileY){u.tileX=tx;u.tileY=ty;if(u.team==='player')G.fog.dirty=true;}
}
/* ----- harvester routing ----- */
function harvesterSeek(u,fieldId){
if(fieldId===undefined)fieldId=-1;
let best=null,bd=Infinity;
for(const c of G.crystalTiles){
if(G.crystalAmt[c.i]<=0)continue;
if(fieldId>=0&&c.fieldId!==fieldId)continue;
const d=dist2(u.x,u.y,tileCenter(c.tx),tileCenter(c.ty));
if(d<bd){bd=d;best=c;}
}
if(!best){
if(fieldId>=0)return harvesterSeek(u,-1); // assigned field empty -> fall back to any field
u.order='idle';u.harvState=null;u.moveState='idle';return;}
u.harvTile=best.i;
// pick passable neighbor of crystal nearest to unit
let stand=null,sd=Infinity;
for(let dir=0;dir<8;dir++){
const nx=best.tx+NX[dir],ny=best.ty+NY[dir];
if(!inBounds(nx,ny)||G.passGrid[tileIdx(nx,ny)])continue;
const d=dist2(u.x,u.y,tileCenter(nx),tileCenter(ny));
if(d<sd){sd=d;stand={tx:nx,ty:ny};}
}
if(!stand){u.order='idle';u.harvState=null;return;}
u.harvState='SEEKING';u.order='harvest';
requestPath(u,stand.tx,stand.ty,{exact:true,ex:tileCenter(stand.tx),ey:tileCenter(stand.ty)});
}
function harvesterAutoSeek(u){harvesterSeek(u,u.rallyFieldId>=0?u.rallyFieldId:-1);}
function harvesterReturn(u){
let best=null,bd=Infinity;
for(const b of G.buildings){
if(b.dead||!b.built||!b.isDock)continue;
const d=dist2(u.x,u.y,b.x,b.y);if(d<bd){bd=d;best=b;}
}
if(!best){u.harvState=null;u.order='idle';return;}
u.dockId=best.id;
// stand tile = passable tile adjacent to footprint nearest unit
let stand=null,sd=Infinity;
for(let ty=best.ty-1;ty<=best.ty+best.fh;ty++)for(let tx=best.tx-1;tx<=best.tx+best.fw;tx++){
if(!inBounds(tx,ty)||G.passGrid[tileIdx(tx,ty)])continue;
const inFoot=(tx>=best.tx&&tx<best.tx+best.fw&&ty>=best.ty&&ty<best.ty+best.fh);
if(inFoot)continue;
const d=dist2(u.x,u.y,tileCenter(tx),tileCenter(ty));if(d<sd){sd=d;stand={tx,ty};}
}
u.harvState='RETURNING';u.order='harvest';
if(stand)requestPath(u,stand.tx,stand.ty,{exact:true,ex:tileCenter(stand.tx),ey:tileCenter(stand.ty)});
else issueMove(u,best.x,best.y,'harvest');
}
/* ===== S8 FOG OF WAR ============================================ */
function initFog(){
const maskCanvas=document.createElement('canvas');
maskCanvas.width=MAP_W;maskCanvas.height=MAP_H;
const maskCtx=maskCanvas.getContext('2d');
const maskImage=maskCtx.createImageData(MAP_W,MAP_H);
G.fog={
explored:new Uint8Array(N_TILES),visible:new Uint8Array(N_TILES),visTarget:new Uint8Array(N_TILES),
exploredCount:0,dirty:true,visAccum:0,maskCanvas,maskCtx,maskImage,discCache:new Map(),
tick(dt){this.visAccum+=dt;
if(this.visAccum>=VIS_INTERVAL){this.visAccum=0;if(this.dirty){this.recompute();this.dirty=false;}}
this.advanceFade(dt);},
recompute(){this.visTarget.fill(0);
for(const u of G.units){if(u.dead||u.team!=='player'||u.sight<=0)continue;this.stampDisc(u.tileX,u.tileY,u.sight);}
for(const b of G.buildings){if(b.dead||!b.built)continue;this.stampDisc(b.tileX,b.tileY,b.sight);}
const vt=this.visTarget,ex=this.explored;
for(let i=0;i<N_TILES;i++)if(vt[i]&&!ex[i]){ex[i]=1;this.exploredCount++;}},
stampDisc(cx,cy,r){const d=this.getDisc(r),vt=this.visTarget;
for(let k=0;k<d.length;k+=2){const tx=cx+d[k],ty=cy+d[k+1];
if(tx<0||ty<0||tx>=MAP_W||ty>=MAP_H)continue;vt[ty*MAP_W+tx]=255;}},
getDisc(r){let d=this.discCache.get(r);if(d)return d;const o=[],r2=(r+0.5)*(r+0.5);
for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++)if(dx*dx+dy*dy<=r2)o.push(dx,dy);
d=Int16Array.from(o);this.discCache.set(r,d);return d;},
advanceFade(dt){const step=Math.min(255,FADE_PER_SEC*dt)|0,v=this.visible,t=this.visTarget;
for(let i=0;i<N_TILES;i++){const c=v[i],g=t[i];
if(c<g)v[i]=Math.min(g,c+step);else if(c>g)v[i]=Math.max(g,c-step);}},
fogAlpha(i){if(this.explored[i]===0)return 1.0;return MEMORY_DIM*(1-this.visible[i]/255);},
buildMask(){const data=this.maskImage.data;
for(let i=0;i<N_TILES;i++){const a=this.fogAlpha(i),o=i<<2;
data[o]=FOG_R;data[o+1]=FOG_G;data[o+2]=FOG_B;data[o+3]=(a*255)|0;}
this.maskCtx.putImageData(this.maskImage,0,0);},
isVisible(tx,ty){if(!inBounds(tx,ty))return false;return this.visible[tileIdx(tx,ty)]>VIS_THRESHOLD;},
};
}
/* ===== S9 PARTICLES / FLOATERS / DECALS ========================= */
function initFX(){
G.particles={list:[],n:0};
G.floaters=[];
G.decals=[];
}
function spawnP(type,x,y,vx,vy,life,size,color){
const p={type,x,y,vx,vy,life,maxLife:life,size,color,rot:Math.random()*6.28};
G.particles.list.push(p);
if(G.particles.list.length>MAXP)G.particles.list.splice(0,G.particles.list.length-MAXP);
}
function burst(type,x,y,n,opt){
opt=opt||{};
for(let i=0;i<n;i++){
const a=Math.random()*6.28,sp=(opt.sp||40)*(0.4+Math.random()*0.8);
spawnP(type,x+(Math.random()-0.5)*(opt.spread||4),y+(Math.random()-0.5)*(opt.spread||4),
Math.cos(a)*sp,Math.sin(a)*sp-(opt.up||0),(opt.life||0.6)*(0.6+Math.random()*0.6),
opt.size||3,opt.color||'#fff');
}
}
function spawnFloat(x,y,text,color){
G.floaters.push({x,y,vy:-26,life:1.1,maxLife:1.1,text,color});
if(G.floaters.length>40)G.floaters.shift();
}
function spawnPing(x,y,color){G.decals.push({kind:'ping',x,y,t:0,life:0.55,color});}
function updateParticles(dt){
const L=G.particles.list;
for(let i=L.length-1;i>=0;i--){const p=L[i];
p.life-=dt;if(p.life<=0){L.splice(i,1);continue;}
p.x+=p.vx*dt;p.y+=p.vy*dt;
if(p.type===P_SMOKE){p.vy-=14*dt;p.vx*=0.96;}
else if(p.type===P_CHIP||p.type===P_DEATH||p.type===P_CONFETTI){p.vy+=120*dt;p.vx*=0.99;}
else if(p.type===P_DUST||p.type===P_POOF){p.vx*=0.92;p.vy*=0.92;}
else if(p.type===P_SPARK){p.vy+=60*dt;}
}
}
function updateFloaters(dt){
for(let i=G.floaters.length-1;i>=0;i--){const f=G.floaters[i];
f.life-=dt;f.y+=f.vy*dt;f.vy*=0.96;if(f.life<=0)G.floaters.splice(i,1);}
}
function updateDecals(dt){
for(let i=G.decals.length-1;i>=0;i--){const d=G.decals[i];
d.t+=dt;if(d.t>=d.life)G.decals.splice(i,1);}
}
/* ===== S10 PRODUCTION / ECONOMY / COMBAT ========================= */
function startBuild(type){
const d=BLDG_DEFS[type];if(!d)return;
if(type==='warfactory'&&!G.hasBarracks){toast('Requires Barracks','warn');return;}
let slot=G.buildSlots[type];
if(!slot){slot=G.buildSlots[type]={progress:0,building:false,ready:false};G.buildSlots[type]=slot;}
if(slot.building||slot.ready){
// already in progress / ready -> enter placement if ready
if(slot.ready){enterPlacement(type);}
return;
}
if(G.credits<d.cost){toast('Insufficient credits','warn');flashCredit();return;}
G.credits-=d.cost;
slot.building=true;slot.progress=0;slot.ready=false;
hideHelp();
}
function refundBuild(type){
const slot=G.buildSlots[type];if(!slot||(!slot.building&&!slot.ready))return;
G.credits+=BLDG_DEFS[type].cost;
slot.building=false;slot.ready=false;slot.progress=0;
if(G.placing&&G.placing.type===type)cancelPlacement();
toast('Construction refunded');
}
function enterPlacement(type){
const d=BLDG_DEFS[type];G.placing={type,fw:d.fp[0],fh:d.fp[1]};
canvas.classList.add('placing');
hideHelp();
}
function cancelPlacement(){G.placing=null;canvas.classList.remove('placing');}
function placementValid(tx,ty,fw,fh){
let nearBase=false;
for(let y=ty;y<ty+fh;y++)for(let x=tx;x<tx+fw;x++){
if(!inBounds(x,y))return false;
const i=tileIdx(x,y);
if(!BUILDABLE[G.terrain[i]]||G.occ[i]!==0||G.passGrid[i])return false;
}
// within BUILD_RADIUS of any completed friendly building
const ccx=tx+fw/2,ccy=ty+fh/2;
for(const b of G.buildings){
if(b.dead||!b.built)continue;
const bx=b.tx+b.fw/2,by=b.ty+b.fh/2;
const dx=Math.abs(ccx-bx),dy=Math.abs(ccy-by);
if(Math.max(dx,dy)<=BUILD_RADIUS+Math.max(fw,fh,b.fw,b.fh)/2)return true;
}
return false;
}
function placeBuilding(tx,ty){
const pl=G.placing;if(!pl)return false;
if(!placementValid(tx,ty,pl.fw,pl.fh))return false;
const slot=G.buildSlots[pl.type];
const b=makeBuilding(pl.type,tx,ty,true);
b.placeT=1;addBuilding(b);
burst(P_DUST,b.x,b.y,16,{sp:60,life:0.7,size:5,color:'rgba(180,170,150,0.8)',spread:b.fw*TILE*0.6});
if(slot){slot.building=false;slot.ready=false;slot.progress=0;}
const d=BLDG_DEFS[pl.type];
toast(d.name+' online','good');
if(d.grantsHarvester){
const sp=findSpawnTile(b);const h=addUnit(makeUnit('harvester',sp.x,sp.y,'player'));
h.order='harvest';h.harvState=null;burst(P_POOF,sp.x,sp.y,10,{color:'rgba(70,240,240,0.8)',life:0.5});
}
if(!G.input.shift)cancelPlacement();
G.cardSig=null;
return true;
}
function updateProduction(dt){
// building construction timers (sidebar)
for(const type in G.buildSlots){
const slot=G.buildSlots[type];
if(slot.building){
slot.progress+=dt/BLDG_DEFS[type].time*G.throttle;
if(slot.progress>=1){slot.progress=1;slot.building=false;slot.ready=true;
toast(BLDG_DEFS[type].name+' ready — place it','good');}
}
}
// unit production at buildings
for(const b of G.buildings){
if(b.dead||!b.built||b.queue.length===0)continue;
const type=b.queue[0],ud=UNIT_DEFS[type];
b.prodT+=dt/ud.time*G.throttle;
if(b.blinkT>0)b.blinkT-=dt;
if(b.prodT>=1){
b.prodT=0;b.queue.shift();
const sp=findSpawnTile(b);
const u=addUnit(makeUnit(type,sp.x,sp.y,'player'));
u.spawnT=0;burst(P_POOF,sp.x,sp.y,10,{color:'rgba(120,200,255,0.7)',life:0.5,sp:50});
b.readyPulse=1;b.blinkT=0.4;
if(type==='harvester'){u.order='harvest';u.harvState=null;
if(b.rally&&b.rally.gather){u.rallyFieldId=b.rally.fieldId;}}
else if(b.rally){issueMove(u,b.rally.x,b.rally.y,'move');}
}
}
}
function findSpawnTile(b){
// passable tile just outside footprint (prefer below/front)
const order=[[Math.floor(b.fw/2),b.fh],[b.fw,Math.floor(b.fh/2)],[-1,Math.floor(b.fh/2)],
[Math.floor(b.fw/2),-1]];
for(const o of order){
const tx=b.tx+o[0],ty=b.ty+o[1];
if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)])return{x:tileCenter(tx),y:tileCenter(ty)};
}
for(let r=1;r<6;r++)for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
const tx=b.tx+dx,ty=b.ty+dy;
if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)]&&G.occ[tileIdx(tx,ty)]===0)
return{x:tileCenter(tx),y:tileCenter(ty)};
}
return{x:b.x,y:b.y+b.fh*TILE/2+TILE};
}
function trainUnit(b,type){
const ud=UNIT_DEFS[type];if(!ud)return;
if(type==='rocket'&&!G.hasWarFactory){toast('Requires War Factory','warn');return;}
if(b.queue.length>=QUEUE_MAX){toast('Queue full','warn');return;}
if(G.credits<ud.cost){toast('Insufficient credits','warn');flashCredit();return;}
G.credits-=ud.cost;b.queue.push(type);
}
function refundUnit(b,type){
// remove last occurrence of type from queue (not the in-progress lead if it's the only one mid-build? allow)
for(let i=b.queue.length-1;i>=0;i--){
if(b.queue[i]===type){
if(i===0){G.credits+=Math.round(UNIT_DEFS[type].cost*(1-b.prodT));b.prodT=0;}
else G.credits+=UNIT_DEFS[type].cost;
b.queue.splice(i,1);return;
}
}
}
function updateEconomy(dt){
let mined=0;
for(const u of G.units){
if(u.dead||u.type!=='harvester')continue;
mined+=updateHarvester(u,dt);
}
// income smoothing
G.incomeAccum+=mined;G.incomeWindow+=dt;
if(G.incomeWindow>=0.5){G.incomeRate=lerp(G.incomeRate,G.incomeAccum/G.incomeWindow,0.5);
G.incomeAccum=0;G.incomeWindow=0;}
G.totalMined+=mined;
if(G.credits>G.maxCredits)G.maxCredits=G.credits;
}
function updateHarvester(u,dt){
let gained=0;
if(u.harvState===null){
if(u.order!=='move'){harvesterAutoSeek(u);}
return 0;
}
if(u.harvState==='SEEKING'){
// if target crystal gone, re-seek
if(u.harvTile<0||G.crystalAmt[u.harvTile]<=0){if(u.moveState!=='moving')harvesterAutoSeek(u);}
} else if(u.harvState==='HARVESTING'){
u.vx=0;u.vy=0;
if(u.harvTile<0||G.crystalAmt[u.harvTile]<=0){
if(u.cargo>0)harvesterReturn(u);else harvesterAutoSeek(u);return 0;}
u.harvTimer+=dt*G.throttle;
// mining chips
if(Math.random()<dt*14){
const cx=tileCenter(u.harvTile%MAP_W),cy=tileCenter((u.harvTile/MAP_W)|0);
spawnP(P_CHIP,cx+(Math.random()-0.5)*16,cy+(Math.random()-0.5)*16,
(Math.random()-0.5)*40,-30-Math.random()*30,0.5,2.5,PAL.crystalLit);
}
if(u.harvTimer>=HARV_BITE_TIME){
u.harvTimer=0;
const take=Math.min(HARV_BITE,G.crystalAmt[u.harvTile],u.cargoMax-u.cargo);
u.cargo+=take;G.crystalAmt[u.harvTile]-=take;
if(G.crystalAmt[u.harvTile]<=0)depleteCrystal(u.harvTile);
if(u.cargo>=u.cargoMax){harvesterReturn(u);}
else if(u.harvTile<0||G.terrain[u.harvTile]!==T_CRYSTAL){
if(u.cargo>0)harvesterReturn(u);else harvesterAutoSeek(u);}
}
} else if(u.harvState==='RETURNING'){
const dock=G.idMap.get(u.dockId);
if(!dock||dock.dead){harvesterReturn(u);return 0;}
if(u.moveState!=='moving'){
if(dist2(u.x,u.y,dock.x,dock.y)<ARRIVE_DOCK*ARRIVE_DOCK){u.harvState='DUMPING';u.harvTimer=0;}
else harvesterReturn(u);
}
} else if(u.harvState==='DUMPING'){
u.vx=0;u.vy=0;
const dock=G.idMap.get(u.dockId);
if(!dock||dock.dead){harvesterReturn(u);return 0;} // dock destroyed mid-dump -> find another
dock.glow=1;
u.harvTimer+=dt*G.throttle;
if(u.harvTimer>=HARV_DUMP_TIME){
u.harvTimer=0;
const amt=Math.min(HARV_DUMP,u.cargo);
u.cargo-=amt;G.credits+=amt;gained+=amt;
spawnFloat(dock.x,dock.y-dock.fh*TILE/2,'+'+amt,FX.creditPlus);
if(u.cargo<=0){harvesterAutoSeek(u);}
}
}
return gained;
}
function harvesterSeekField(u,fieldId){harvesterSeek(u,fieldId);}
/* ----- critters & flavor combat ----- */
function spawnCritter(){
for(let tries=0;tries<40;tries++){
const edge=Math.floor(Math.random()*4);let tx,ty;
if(edge===0){tx=2+(Math.random()*(MAP_W-4)|0);ty=3;}
else if(edge===1){tx=2+(Math.random()*(MAP_W-4)|0);ty=MAP_H-4;}
else if(edge===2){tx=3;ty=2+(Math.random()*(MAP_H-4)|0);}
else{tx=MAP_W-4;ty=2+(Math.random()*(MAP_H-4)|0);}
if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)]){
const u=makeUnit('critter',tileCenter(tx),tileCenter(ty),'neutral');
u.hp=CRITTER_HP;u.hpMax=CRITTER_HP;u.r=CRITTER_DEF.r;u.speed=CRITTER_DEF.speed*TILE;
addUnit(u);return;
}
}
}
let critterRespawnT=0;
function updateCritters(dt){
let alive=0;
for(const u of G.units){
if(u.dead||u.type!=='critter')continue;alive++;
u.bob+=dt*4;
u.wanderT-=dt;
if(u.wanderT<=0&&u.moveState!=='moving'){
u.wanderT=2+Math.random()*3;
const tx=clamp(u.tileX+((Math.random()*7|0)-3),2,MAP_W-3);
const ty=clamp(u.tileY+((Math.random()*7|0)-3),2,MAP_H-3);
issueMove(u,tileCenter(tx),tileCenter(ty),'move');
}
}
if(alive<CRITTER_COUNT){critterRespawnT-=dt;if(critterRespawnT<=0){spawnCritter();critterRespawnT=CRITTER_RESPAWN/CRITTER_COUNT;}}
}
function findEnemyInRange(u,range){
let best=null,bd=range*range;
for(const v of G.units){
if(v.dead||v.type!=='critter')continue;
const d=dist2(u.x,u.y,v.x,v.y);
if(d<bd){bd=d;best=v;}
}
return best;
}
function updateCombat(dt){
for(const u of G.units){
if(u.dead||u.team!=='player'||u.atk<=0)continue;
if(u.atkCooldown>0)u.atkCooldown-=dt;
let target=null;
if(u.order==='attack'){target=G.idMap.get(u.targetId);
if(!target||target.dead){u.order='idle';u.targetId=0;target=null;}}
if(u.order==='attack'&&target){
const d=Math.hypot(u.x-target.x,u.y-target.y);
if(d>u.range){if(u.moveState!=='moving'||u.goalTile!==tileIdx(target.tileX,target.tileY))
issueMove(u,target.x,target.y,'attack');}
else{u.moveState='idle';u.vx*=0.4;u.vy*=0.4;u.aimFacing=lerpAngle(u.aimFacing,Math.atan2(target.y-u.y,target.x-u.x),0.3);
if(u.atkCooldown<=0)fireAt(u,target);}
} else if(u.order==='idle'||u.order==='hold'){
// auto-fire if a critter is within range (no chasing)
const e=findEnemyInRange(u,u.range);
if(e){u.aimFacing=lerpAngle(u.aimFacing,Math.atan2(e.y-u.y,e.x-u.x),0.3);
if(u.atkCooldown<=0)fireAt(u,e);}
} else if(u.order==='attackmove'){
const e=findEnemyInRange(u,u.sight*TILE);
if(e){u.order='attack';u.targetId=e.id;}
}
}
// projectiles
for(let i=G.projectiles.length-1;i>=0;i--){
const p=G.projectiles[i];p.life-=dt;
const t=G.idMap.get(p.targetId);
if(t&&!t.dead){const dx=t.x-p.x,dy=t.y-p.y,d=Math.hypot(dx,dy);
const step=p.speed*dt;
if(d<=step+t.r){applyDamage(t,p.dmg);hitFX(t.x,t.y,p.color);G.projectiles.splice(i,1);continue;}
p.x+=dx/d*step;p.y+=dy/d*step;}
else{p.x+=p.vx*dt;p.y+=p.vy*dt;}
if(p.life<=0)G.projectiles.splice(i,1);
}
}
function fireAt(u,target){
u.atkCooldown=u.rof;
const ang=Math.atan2(target.y-u.y,target.x-u.x);
const muzzleX=u.x+Math.cos(ang)*u.r,muzzleY=u.y+Math.sin(ang)*u.r;
const color=u.type==='rocket'?'#ff9a3c':u.type==='tank'?'#ffe27a':TEAM.blueLit;
const speed=u.type==='rocket'?260:520;
G.projectiles.push({id:G.nextId++,x:muzzleX,y:muzzleY,vx:Math.cos(ang)*speed,vy:Math.sin(ang)*speed,
speed,targetId:target.id,dmg:u.atk,life:1.6,color});
burst(P_SPARK,muzzleX,muzzleY,3,{sp:90,life:0.18,size:2,color});
}
function applyDamage(t,dmg){
t.hp-=dmg;t.hpFlash=2;
if(t.hp<=0&&!t.dead){
t.dead=true;
burst(P_DEATH,t.x,t.y,16,{sp:80,life:0.7,size:4,color:UNIT.critter.lit});
burst(P_POOF,t.x,t.y,8,{sp:40,life:0.5,size:6,color:'rgba(120,90,60,0.6)'});
}
}
function hitFX(x,y,color){burst(P_SPARK,x,y,6,{sp:120,life:0.3,size:2.5,color});}
/* ===== S11 INPUT ================================================= */
const canvas=document.getElementById('world-canvas');
const ctx=canvas.getContext('2d');
let DPR=1;
function eventToCanvas(e){
const r=canvas.getBoundingClientRect();
return{x:e.clientX-r.left,y:e.clientY-r.top};
}
function pickEntity(wx,wy){
// units first (topmost = last drawn ~ highest y? just nearest within r), then buildings
let best=null,bd=Infinity;
for(const u of G.units){
if(u.dead||u.team!=='player')continue;
if(!G.fog.isVisible(u.tileX,u.tileY))continue;
const d=dist2(wx,wy,u.x,u.y);
if(d<(u.r+5)*(u.r+5)&&d<bd){bd=d;best=u;}
}
if(best)return best;
const tx=(wx/TILE)|0,ty=(wy/TILE)|0;
if(inBounds(tx,ty)){const id=G.occ[tileIdx(tx,ty)];if(id){const b=G.idMap.get(id);if(b&&!b.dead)return b;}}
return null;
}
function clearSelection(){for(const e of G.selection)e.selT=0;G.selection.length=0;G.selKind='none';G.selSig=null;}
function setSelection(ents){
clearSelection();
for(const e of ents){G.selection.push(e);e.selT=0.01;}
G.selKind=ents.length?(ents[0].kind==='building'?'building':'units'):'none';
G.selSig=null;
}
function selectAt(wx,wy,shift){
const e=pickEntity(wx,wy);
if(!e){if(!shift)clearSelection();return;}
if(e.kind==='building'){setSelection([e]);return;}
if(shift&&G.selKind==='units'){
const idx=G.selection.indexOf(e);
if(idx>=0){e.selT=0;G.selection.splice(idx,1);if(!G.selection.length)G.selKind='none';}
else{G.selection.push(e);e.selT=0.01;}
G.selSig=null;
} else setSelection([e]);
}
function boxSelect(x0,y0,x1,y1,shift){
const minx=Math.min(x0,x1),maxx=Math.max(x0,x1),miny=Math.min(y0,y1),maxy=Math.max(y0,y1);
const found=[];
for(const u of G.units){
if(u.dead||u.team!=='player')continue;
if(!G.fog.isVisible(u.tileX,u.tileY))continue;
if(u.x>=minx&&u.x<=maxx&&u.y>=miny&&u.y<=maxy)found.push(u);
}
if(!found.length){if(!shift)clearSelection();return;}
if(shift&&G.selKind==='units'){for(const u of found)if(G.selection.indexOf(u)<0){G.selection.push(u);u.selT=0.01;}G.selSig=null;}
else setSelection(found);
}
function selectSameType(wx,wy){
const e=pickEntity(wx,wy);if(!e||e.kind!=='unit')return;
const found=[];const c=G.cam;
for(const u of G.units){
if(u.dead||u.team!=='player'||u.type!==e.type)continue;
if(!G.fog.isVisible(u.tileX,u.tileY))continue;
const s=worldToScreen(u.x,u.y);
if(s.x>=0&&s.y>=0&&s.x<=c.vw&&s.y<=c.vh)found.push(u);
}
if(found.length)setSelection(found);
}
function commandRight(wx,wy){
// producer building selected -> set rally
if(G.selKind==='building'){
const b=G.selection[0];
if(b&&b.canProduce){
const tx=(wx/TILE)|0,ty=(wy/TILE)|0;
if(b.type==='warfactory'&&inBounds(tx,ty)&&G.terrain[tileIdx(tx,ty)]===T_CRYSTAL){
b.rally={x:wx,y:wy,gather:true,fieldId:G.crystalFieldId[tileIdx(tx,ty)]};spawnPing(wx,wy,FX.gatherPing);
} else {b.rally={x:wx,y:wy};spawnPing(wx,wy,FX.movePing);}
}
return;
}
if(G.selKind!=='units'||!G.selection.length)return;
const units=G.selection.filter(u=>!u.dead);
const tx=(wx/TILE)|0,ty=(wy/TILE)|0;
const tgtEnt=pickCommandTarget(wx,wy);
if(tgtEnt&&tgtEnt.type==='critter'){
// attack
let any=false;
for(const u of units){if(u.atk>0){u.order='attack';u.targetId=tgtEnt.id;issueMove(u,tgtEnt.x,tgtEnt.y,'attack');any=true;}
else issueMove(u,wx,wy,'move');}
spawnPing(tgtEnt.x,tgtEnt.y,FX.attackPing);return;
}
if(inBounds(tx,ty)&&G.terrain[tileIdx(tx,ty)]===T_CRYSTAL){
const harvs=units.filter(u=>u.type==='harvester');
for(const u of harvs){u.order='harvest';u.harvState='SEEKING';u.rallyFieldId=-1;
u.harvTile=tileIdx(tx,ty);
// path to a passable neighbor
let stand=null,sd=Infinity;
for(let dir=0;dir<8;dir++){const nx=tx+NX[dir],ny=ty+NY[dir];
if(!inBounds(nx,ny)||G.passGrid[tileIdx(nx,ny)])continue;
const d=dist2(u.x,u.y,tileCenter(nx),tileCenter(ny));if(d<sd){sd=d;stand={tx:nx,ty:ny};}}
if(stand)requestPath(u,stand.tx,stand.ty,{exact:true,ex:tileCenter(stand.tx),ey:tileCenter(stand.ty)});
else harvesterSeek(u);} // crystal fully walled in -> seek nearest reachable instead of stalling
const others=units.filter(u=>u.type!=='harvester');
if(others.length)issueGroupMove(others,tileCenter(tx),tileCenter(ty),'move');
spawnPing(tileCenter(tx),tileCenter(ty),FX.gatherPing);return;
}
// plain move
issueGroupMove(units,wx,wy,G.input.attackArmed?'attackmove':'move');
spawnPing(wx,wy,G.input.attackArmed?FX.attackPing:FX.movePing);
G.input.attackArmed=false;
}
function pickCommandTarget(wx,wy){
let best=null,bd=Infinity;
for(const u of G.units){
if(u.dead||u.type!=='critter')continue;
if(!G.fog.isVisible(u.tileX,u.tileY))continue;
const d=dist2(wx,wy,u.x,u.y);if(d<(u.r+8)*(u.r+8)&&d<bd){bd=d;best=u;}
}
return best;
}
/* ---- event handlers ---- */
function onMouseDown(e){
const p=eventToCanvas(e);G.input.mx=p.x;G.input.my=p.y;
const w=screenToWorld(p.x,p.y);G.input.wx=w.x;G.input.wy=w.y;
if(e.button===1){ // middle -> pan
e.preventDefault();G.input.mDown=true;G.input.panSX=p.x;G.input.panSY=p.y;
G.input.panCamX=G.cam.tx;G.input.panCamY=G.cam.ty;return;}
if(e.button===2){ // right
if(G.placing){cancelPlacement();return;}
if(!G.won)commandRight(w.x,w.y);return;}
if(e.button===0){
if(G.placing){
const tx=(w.x/TILE|0)-((G.placing.fw-1)>>1),ty=(w.y/TILE|0)-((G.placing.fh-1)>>1);
placeBuilding(tx,ty);return;}
if(G.input.attackArmed&&G.selKind==='units'){ // armed attack-move: left-click sets destination
issueGroupMove(G.selection.filter(u=>!u.dead),w.x,w.y,'attackmove');
spawnPing(w.x,w.y,FX.attackPing);G.input.attackArmed=false;return;}
G.input.lDown=true;G.input.downSX=p.x;G.input.downSY=p.y;G.input.dragging=false;
G.input.boxX0=w.x;G.input.boxY0=w.y;
}
}
function onMouseMove(e){
const p=eventToCanvas(e);G.input.mx=p.x;G.input.my=p.y;G.input.insideCanvas=true;
const w=screenToWorld(p.x,p.y);G.input.wx=w.x;G.input.wy=w.y;
if(G.input.lDown&&!G.placing){
const dd=Math.hypot(p.x-G.input.downSX,p.y-G.input.downSY);
if(dd>5)G.input.dragging=true;
if(G.input.dragging)G.input.mode='box';
}
// hover entity (tooltip-ish / hp flash)
}
function onMouseUp(e){
const p=eventToCanvas(e);
const w=screenToWorld(p.x,p.y);
if(e.button===1){G.input.mDown=false;return;}
if(e.button===0&&G.input.lDown){
G.input.lDown=false;
if(G.input.dragging){
boxSelect(G.input.boxX0,G.input.boxY0,w.x,w.y,G.input.shift);
G.input.dragging=false;G.input.mode='default';
} else {
if(!G.placing)selectAt(w.x,w.y,G.input.shift);
}
}
}
function onWheel(e){
e.preventDefault();
const c=G.cam;const p=eventToCanvas(e);
const before=screenToWorld(p.x,p.y);
let z=c.tzoom*(e.deltaY<0?ZOOM_STEP:1/ZOOM_STEP);
z=clamp(z,ZOOM_MIN,ZOOM_MAX);c.tzoom=z;
// adjust target so point under cursor stays put (use new zoom)
c.tx=before.x-p.x/z;c.ty=before.y-p.y/z;
// also snap current for immediate response
c.zoom=z;c.x=before.x-p.x/z;c.y=before.y-p.y/z;
clampCamTarget();clampCam();c.moved=true;
}
function onDblClick(e){
if(G.placing)return;
const p=eventToCanvas(e);const w=screenToWorld(p.x,p.y);
selectSameType(w.x,w.y);
}
function onKeyDown(e){
if(e.repeat){if(e.code.startsWith('Arrow')||['KeyW','KeyA','KeyS','KeyD'].includes(e.code))return;}
G.input.keys[e.code]=true;
G.input.shift=e.shiftKey;G.input.ctrl=e.ctrlKey||e.metaKey;
const code=e.code;
if(code==='Space'){e.preventDefault();togglePause();return;}
if(code==='Escape'){G.input.attackArmed=false;if(G.placing){cancelPlacement();}else clearSelection();return;}
// control groups
if(/^Digit[1-9]$/.test(code)){
const g=+code.slice(5);
if(e.ctrlKey||e.metaKey){e.preventDefault();assignGroup(g);}
else recallGroup(g);
return;
}
// attack-move / stop / hold
if(code==='KeyA'&&G.selKind==='units'){G.input.attackArmed=true;}
if(code==='KeyS'&&G.selKind==='units'){stopUnits();}
if(code==='KeyH'&&G.selKind==='units'){holdUnits();}
// command card hotkeys
handleCardHotkey(code);
}
function onKeyUp(e){G.input.keys[e.code]=false;G.input.shift=e.shiftKey;G.input.ctrl=e.ctrlKey||e.metaKey;}
function stopUnits(){for(const u of G.selection){if(u.kind!=='unit')continue;
u.moveState='idle';u.waypoints=null;u.vx=0;u.vy=0;
if(u.type==='harvester'){u.order='harvest';if(u.cargo>0)harvesterReturn(u);else u.harvState=null;}
else u.order='idle';}}
function holdUnits(){for(const u of G.selection){if(u.kind!=='unit')continue;
u.order='hold';u.moveState='idle';u.waypoints=null;u.vx=0;u.vy=0;}}
function assignGroup(g){
if(G.selKind!=='units')return;
G.controlGroups[g]=G.selection.filter(u=>!u.dead).map(u=>u.id);
toast('Group '+g+' set');
}
function recallGroup(g){
const ids=G.controlGroups[g];if(!ids||!ids.length)return;
const ents=ids.map(id=>G.idMap.get(id)).filter(e=>e&&!e.dead);
G.controlGroups[g]=ents.map(e=>e.id);
if(!ents.length)return;
setSelection(ents);
const now=performance.now()/1000; // wall-clock so double-tap works even right after a pause
if(G.lastRecall.g===g&&now-G.lastRecall.t<0.35){
let mx=0,my=0;for(const e of ents){mx+=e.x;my+=e.y;}centerCameraOn(mx/ents.length,my/ents.length);}
G.lastRecall={g,t:now};
}
function handleCardHotkey(code){
const map={KeyQ:0,KeyE:1,KeyR:2,KeyT:3,KeyF:4};
if(!(code in map))return;
const slot=map[code];
const btns=document.querySelectorAll('#card-grid .card-btn');
const btn=btns[slot];
if(btn&&!btn.classList.contains('disabled')&&!btn.classList.contains('locked'))btn.click();
}
function togglePause(){G.paused=!G.paused;document.getElementById('paused-tag').classList.toggle('show',G.paused);}
function hideHelp(){const h=document.getElementById('help-hint');if(h)h.style.opacity='0';}
/* ---- minimap input ---- */
const minimap=document.getElementById('minimap');
const mmCtx=minimap.getContext('2d');
const MM=188;
let mmDrag=false;
function minimapJump(e){
const r=minimap.getBoundingClientRect();
const mx=clamp((e.clientX-r.left)/MM,0,1),my=clamp((e.clientY-r.top)/MM,0,1);
centerCameraOn(mx*WORLD_W,my*WORLD_H);
}
minimap.addEventListener('mousedown',e=>{e.preventDefault();mmDrag=true;minimapJump(e);});
window.addEventListener('mousemove',e=>{if(mmDrag)minimapJump(e);});
window.addEventListener('mouseup',()=>{mmDrag=false;});
/* ---- card grid click ---- */
document.getElementById('card-grid').addEventListener('click',e=>{
const btn=e.target.closest('.card-btn');if(!btn||btn.classList.contains('locked'))return;
const act=btn.dataset.act,val=btn.dataset.val;
if(act==='build')startBuild(val);
else if(act==='train'){const b=G.selection[0];if(b&&b.kind==='building')trainUnit(b,val);}
});
document.getElementById('card-grid').addEventListener('contextmenu',e=>{
e.preventDefault();
const btn=e.target.closest('.card-btn');if(!btn)return;
const act=btn.dataset.act,val=btn.dataset.val;
if(act==='build')refundBuild(val);
else if(act==='train'){const b=G.selection[0];if(b&&b.kind==='building')refundUnit(b,val);}
});
/* ===== S12 SIM =================================================== */
function fixedUpdate(dt){
G.time+=dt;G.frame++;
updateProduction(dt);
updateEconomy(dt);
stepMovement(dt);
updateCritters(dt);
updateCombat(dt);
G.fog.tick(dt);
updateParticles(dt);updateFloaters(dt);updateDecals(dt);
// building presentation timers
for(const b of G.buildings){
if(b.placeT>0)b.placeT=Math.max(0,b.placeT-dt*3);
if(b.glow>0)b.glow=Math.max(0,b.glow-dt*2);
if(b.readyPulse>0)b.readyPulse=Math.max(0,b.readyPulse-dt*1.5);
if(b.hpFlash>0)b.hpFlash-=dt;
if(b.type==='warfactory'){b.smokeT-=dt;if(b.smokeT<=0){b.smokeT=0.5;
const sx=b.x+b.fw*TILE*0.28,sy=b.y-b.fh*TILE*0.4;
spawnP(P_SMOKE,sx,sy,(Math.random()-0.5)*8,-18,1.4,7,'rgba(80,80,90,0.5)');}}
}
updateObjectives();
reapDead();
}
function reapDead(){
let removed=false;
for(let i=G.units.length-1;i>=0;i--){const u=G.units[i];
if(u.dead){G.idMap.delete(u.id);G.units.splice(i,1);removed=true;
const si=G.selection.indexOf(u);if(si>=0){G.selection.splice(si,1);G.selSig=null;}}
}
for(let i=G.buildings.length-1;i>=0;i--){const b=G.buildings[i];
if(b.dead){stampBuilding(b,false);G.idMap.delete(b.id);
const si=G.selection.indexOf(b);if(si>=0){G.selection.splice(si,1);G.selSig=null;G.selKind=G.selection.length?G.selKind:'none';}
G.buildings.splice(i,1);removed=true;recomputePower();recomputeTech();}
}
if(removed){if(!G.selection.length)G.selKind='none';}
}
/* ----- objectives ----- */
function initObjectives(){
G.objectives=[
{key:'power',label:'Bring a Power Plant online',done:false,
check:()=>G.builtTypes.has('power')},
{key:'industry',label:'Build a Barracks & War Factory',done:false,
check:()=>G.hasBarracks&&G.hasWarFactory},
{key:'fortune',label:'Bank 5,000 Credits',done:false,
prog:()=>Math.min(1,G.maxCredits/OBJ_CREDITS),progTxt:()=>Math.min(G.maxCredits,OBJ_CREDITS)|0,
check:()=>G.maxCredits>=OBJ_CREDITS},
{key:'explore',label:'Chart 80% of the frontier',done:false,
prog:()=>Math.min(1,(G.fog.exploredCount/N_TILES)/OBJ_EXPLORE),
progTxt:()=>Math.round(G.fog.exploredCount/N_TILES*100)+'%',
check:()=>G.fog.exploredCount/N_TILES>=OBJ_EXPLORE},
{key:'armor',label:'Field 3 Battle Tanks',done:false,
prog:()=>Math.min(1,tankCount()/OBJ_TANKS),progTxt:()=>tankCount()+'/'+OBJ_TANKS,
check:()=>tankCount()>=OBJ_TANKS},
];
}
function tankCount(){let n=0;for(const u of G.units)if(!u.dead&&u.type==='tank'&&u.team==='player')n++;return n;}
function updateObjectives(){
if(G.won)return;
for(const o of G.objectives){
if(o.done)continue;
if(o.check()){o.done=true;G.objDoneCount++;
toast('✔ '+o.label,'good');confetti();bumpObjCount();G.objListSig=null;}
}
if(G.objDoneCount>=G.objectives.length&&!G.won){
G.won=true;G.wonAt=G.sessionSec;showVictory();
}
}
function confetti(){
const c=G.cam;const cx=c.x+c.vw/c.zoom/2,cy=c.y+c.vh/c.zoom*0.35;
const cols=['#ffce5c','#5cffac','#36e0ff','#ff5a6e','#b9a8ff'];
for(let i=0;i<60;i++){const a=Math.random()*6.28,sp=80+Math.random()*160;
spawnP(P_CONFETTI,cx+(Math.random()-0.5)*200,cy,Math.cos(a)*sp,Math.sin(a)*sp-80,
1.6+Math.random(),3+Math.random()*3,cols[i%cols.length]);}
}
/* ===== S13 RENDER ================================================ */
let terrainCanvas,terrainCtx,minimapBase;
function rr(c,x,y,w,h,r){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 bakeTerrain(){
terrainCanvas=document.createElement('canvas');
terrainCanvas.width=WORLD_W;terrainCanvas.height=WORLD_H;
terrainCtx=terrainCanvas.getContext('2d');
const c=terrainCtx;
for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){repaintTileTo(c,tx,ty);}
// baked faint grid
c.strokeStyle='rgba(0,0,0,0.07)';c.lineWidth=1;
c.beginPath();
for(let x=0;x<=MAP_W;x++){c.moveTo(x*TILE,0);c.lineTo(x*TILE,WORLD_H);}
for(let y=0;y<=MAP_H;y++){c.moveTo(0,y*TILE);c.lineTo(WORLD_W,y*TILE);}
c.stroke();
bakeMinimapBase();
}
function repaintTile(tx,ty){repaintTileTo(terrainCtx,tx,ty);
// also patch minimap base
if(minimapBase){const mc=minimapBase.getContext('2d');mc.fillStyle=miniColor(tileIdx(tx,ty));mc.fillRect(tx,ty,1,1);}}
function repaintTileTo(c,tx,ty){
const i=tileIdx(tx,ty),t=G.terrain[i];const x=tx*TILE,y=ty*TILE;
const n=G.noise[i],pn=G.patchNoise[i];
if(t===T_WATER){
const g=c.createLinearGradient(x,y,x,y+TILE);
g.addColorStop(0,PAL.waterHi);g.addColorStop(1,PAL.waterLo);
c.fillStyle=g;c.fillRect(x,y,TILE,TILE);
c.strokeStyle='rgba(120,200,210,0.18)';c.lineWidth=1.5;
c.beginPath();c.moveTo(x+2,y+10+n*4);c.lineTo(x+TILE-2,y+8+n*4);
c.moveTo(x+2,y+22-n*4);c.lineTo(x+TILE-2,y+24-n*4);c.stroke();
// foam where adjacent to land
if(adjLand(tx,ty)){c.strokeStyle=PAL.waterFoam;c.lineWidth=1.5;c.globalAlpha=0.5;
c.strokeRect(x+1.5,y+1.5,TILE-3,TILE-3);c.globalAlpha=1;}
} else if(t===T_ROCK){
c.fillStyle=PAL.rockLo;c.fillRect(x,y,TILE,TILE);
const sh=lerp(0.3,1,n);
c.fillStyle=shade(PAL.rockHi,sh);
c.beginPath();c.moveTo(x+4,y+TILE-4);c.lineTo(x+TILE*0.4,y+6);c.lineTo(x+TILE-5,y+TILE*0.55);
c.lineTo(x+TILE-8,y+TILE-5);c.closePath();c.fill();
c.strokeStyle=PAL.rockEdge;c.lineWidth=1.5;c.stroke();
c.fillStyle=PAL.rockSpec;c.globalAlpha=0.5;
c.fillRect(x+TILE*0.35,y+7,3,3);c.globalAlpha=1;
} else { // grass / dirt (T_GRASS, also under crystal we paint grass base)
const dirt=pn<0.34;
const lo=dirt?PAL.dirtLo:PAL.grassLo,hi=dirt?PAL.dirtHi:PAL.grassHi;
c.fillStyle=lerpColor(lo,hi,clamp(0.25+n*0.7,0,1));c.fillRect(x,y,TILE,TILE);
// speckle
c.globalAlpha=0.18;
c.fillStyle=shade(hi,1.2);
const sx=x+((n*97)%TILE),sy=y+((n*53)%TILE);
c.fillRect(sx,sy,2,2);c.fillRect((x+((n*131)%TILE))|0,(y+((n*29)%TILE))|0,2,2);
c.globalAlpha=1;
// AO bottom/right
c.fillStyle='rgba(0,0,0,0.10)';c.fillRect(x,y+TILE-3,TILE,3);c.fillRect(x+TILE-3,y,3,TILE);
c.fillStyle='rgba(255,255,255,0.05)';c.fillRect(x,y,TILE,2);
}
}
function adjLand(tx,ty){for(let d=0;d<4;d++){const nx=tx+NX[d],ny=ty+NY[d];
if(inBounds(nx,ny)&&G.terrain[tileIdx(nx,ny)]!==T_WATER)return true;}return false;}
function drawCrystals(vr){
const c=ctx;const t=G.time;
for(let ty=vr.y0;ty<=vr.y1;ty++)for(let tx=vr.x0;tx<=vr.x1;tx++){
const i=tileIdx(tx,ty);
if(G.terrain[i]!==T_CRYSTAL||G.crystalAmt[i]<=0)continue;
if(!G.fog.explored[i])continue;
const cx=tileCenter(tx),cy=tileCenter(ty);
const stage=Math.ceil(G.crystalAmt[i]/150); // 1..4
const sc=0.45+stage*0.14;
// ground glow
const gg=c.createRadialGradient(cx,cy,0,cx,cy,TILE*0.7);
gg.addColorStop(0,'rgba(70,240,240,0.30)');gg.addColorStop(1,'rgba(70,240,240,0)');
c.fillStyle=gg;c.fillRect(cx-TILE*0.7,cy-TILE*0.7,TILE*1.4,TILE*1.4);
// gems
const count=Math.min(stage+1,4);
for(let k=0;k<count;k++){
const ang=k/count*6.28+i*0.7;
const ox=Math.cos(ang)*TILE*0.18*(k>0?1:0),oy=Math.sin(ang)*TILE*0.18*(k>0?1:0);
const h=TILE*0.5*sc*(k===0?1:0.7);
const gx=cx+ox,gy=cy+oy;
const grad=c.createLinearGradient(gx-6,gy-h,gx+6,gy+h*0.5);
grad.addColorStop(0,PAL.crystalLit);grad.addColorStop(0.5,PAL.crystalCore);grad.addColorStop(1,PAL.crystalDark);
c.fillStyle=grad;
c.beginPath();c.moveTo(gx,gy-h);c.lineTo(gx+5*sc,gy);c.lineTo(gx,gy+h*0.45);c.lineTo(gx-5*sc,gy);c.closePath();
c.fill();c.strokeStyle=PAL.crystalEdge;c.lineWidth=1;c.stroke();
// sparkle
const sp=(Math.sin(t*3+i+k)*0.5+0.5);
if(sp>0.7){c.fillStyle='rgba(255,255,255,'+((sp-0.7)*2.5)+')';c.beginPath();
c.arc(gx-2,gy-h*0.4,1.4,0,6.28);c.fill();}
}
}
}
function draw(alpha){
const c=ctx,cam=G.cam,z=cam.zoom;
// clear
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle='#05070a';ctx.fillRect(0,0,cam.vw,cam.vh);
// world transform
ctx.setTransform(DPR*z,0,0,DPR*z,-cam.x*z*DPR,-cam.y*z*DPR);
const vr=viewportTileRect();
// 2. terrain blit (visible sub-rect)
const sx=vr.x0*TILE,sy=vr.y0*TILE,sw=(vr.x1-vr.x0+1)*TILE,sh=(vr.y1-vr.y0+1)*TILE;
ctx.imageSmoothingEnabled=false;
ctx.drawImage(terrainCanvas,sx,sy,sw,sh,sx,sy,sw,sh);
ctx.imageSmoothingEnabled=true;
// 3. crystals
drawCrystals(vr);
// 4. build aura (placement)
if(G.placing)drawBuildAura();
// 5. ground decals (pings, rally)
drawDecals();
drawRally();
// 6. buildings (y-sorted)
const blds=G.buildings.filter(b=>!b.dead&&G.fog.explored[tileIdx(b.tileX,b.tileY)]);
blds.sort((a,b)=>(a.ty+a.fh)-(b.ty+b.fh));
for(const b of blds)drawBuilding(b);
// 7. units (only where visible)
const us=G.units.filter(u=>!u.dead);
us.sort((a,b)=>a.y-b.y);
for(const u of us){
if(!G.fog.isVisible(u.tileX,u.tileY))continue;
const rx=lerp(u.px,u.x,alpha),ry=lerp(u.py,u.y,alpha);
drawUnit(u,rx,ry);
}
// 8. above-entity FX
drawParticles();
drawProjectiles();
drawFloaters();
// 9. placement ghost
if(G.placing)drawGhost();
// 10. health bars
drawHealthBars(us,blds,alpha);
// 11. fog (world space, smoothing on)
G.fog.buildMask();
ctx.imageSmoothingEnabled=true;
ctx.drawImage(G.fog.maskCanvas,-TILE/2,-TILE/2,WORLD_W+TILE,WORLD_H+TILE);
// 12. screen-space overlays
ctx.setTransform(DPR,0,0,DPR,0,0);
if(G.input.mode==='box'&&G.input.dragging)drawSelectionBox();
if(G.input.attackArmed)drawAttackCursor();
}
function drawBuilding(b){
const c=ctx,col=BLD[b.type],d=BLDG_DEFS[b.type];
const x=b.tx*TILE,y=b.ty*TILE,w=b.fw*TILE,h=b.fh*TILE;
const dim=!G.fog.isVisible(b.tileX,b.tileY);
c.save();
if(dim)c.globalAlpha=0.62;
// shadow
c.fillStyle='rgba(0,0,0,0.28)';
c.beginPath();c.ellipse(b.x,y+h-3,w*0.46,h*0.18,0,0,6.28);c.fill();
// place bounce
if(b.placeT>0){const s=1+b.placeT*0.12;c.translate(b.x,b.y);c.scale(s,s);c.translate(-b.x,-b.y);}
// body
const g=c.createLinearGradient(x,y,x,y+h);
g.addColorStop(0,col.lit);g.addColorStop(0.5,col.base);g.addColorStop(1,col.dark);
c.fillStyle=g;rr(c,x+3,y+4,w-6,h-7,5);c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
// type detail
drawBuildingDetail(b,x,y,w,h,col);
// selected brackets
if(G.selection.indexOf(b)>=0)drawBuildingBrackets(x,y,w,h);
// ready pulse
if(b.readyPulse>0){c.strokeStyle='rgba(92,255,172,'+b.readyPulse+')';c.lineWidth=3;
rr(c,x+1,y+2,w-2,h-3,6);c.stroke();}
c.restore();
}
function drawBuildingDetail(b,x,y,w,h,col){
const c=ctx,cx=b.x,cy=b.y,t=G.time;
c.lineWidth=2;
if(b.type==='cc'){
c.fillStyle=col.dark;rr(c,x+w*0.3,y+8,w*0.4,h*0.3,3);c.fill();
c.fillStyle=col.accent;c.beginPath();c.arc(cx,y+12,4+Math.sin(t*3)*1,0,6.28);c.fill();
c.fillStyle='rgba(70,240,240,0.7)';c.fillRect(x+5,y+h-13,w-10,5); // dock lip
c.fillStyle=col.lit;c.fillRect(x+4,y+4,5,5);c.fillRect(x+w-9,y+4,5,5);
} else if(b.type==='power'){
c.fillStyle=col.dark;c.beginPath();c.arc(x+w*0.32,y+h*0.4,5,0,6.28);c.arc(x+w*0.68,y+h*0.4,5,0,6.28);c.fill();
const orb=0.4+0.6*G.powerRatio;
c.fillStyle='rgba(255,210,63,'+orb+')';c.beginPath();c.arc(cx,cy+3,6+Math.sin(t*4)*1.5,0,6.28);c.fill();
} else if(b.type==='refinery'){
c.fillStyle=col.dark;rr(c,x+6,y+7,w*0.28,h*0.55,3);c.fill();
c.fillStyle=col.lit;rr(c,x+w*0.45,y+10,w*0.4,h*0.4,3);c.fill();
const dg=b.glow;c.fillStyle='rgba(70,240,240,'+(0.3+dg*0.6)+')';c.fillRect(x+6,y+h-12,w-12,6);
if(dg>0){c.fillStyle='rgba(70,240,240,'+dg*0.4+')';c.beginPath();c.arc(cx,y+h-9,12*dg,0,6.28);c.fill();}
} else if(b.type==='barracks'){
c.fillStyle=col.dark;c.beginPath();c.moveTo(x+4,y+h*0.42);c.lineTo(cx,y+6);c.lineTo(x+w-4,y+h*0.42);c.closePath();c.fill();
c.fillStyle=col.accent;c.fillRect(cx-4,y+h-15,8,11);
// banner
c.fillStyle=col.accent;c.save();c.translate(x+w-7,y+8);
c.beginPath();c.moveTo(0,0);c.lineTo(7+Math.sin(t*3)*1.5,2);c.lineTo(7+Math.sin(t*3)*1.5,9);c.lineTo(0,7);c.closePath();c.fill();c.restore();
} else if(b.type==='warfactory'){
// hazard door
c.fillStyle=col.dark;rr(c,x+6,y+h*0.42,w-12,h*0.45,2);c.fill();
c.fillStyle=col.accent;
for(let i=0;i<4;i++)c.fillRect(x+8+i*((w-16)/4),y+h*0.45,(w-16)/8,h*0.38);
// blink light
c.fillStyle=b.blinkT>0?'#ff5a3c':'#5a2020';c.beginPath();c.arc(x+10,y+10,3,0,6.28);c.fill();
// smokestack
c.fillStyle=col.lit;c.fillRect(x+w*0.72,y+4,7,h*0.35);
} else if(b.type==='watchtower'){
c.fillStyle=col.lit;rr(c,x+w*0.2,y-h*0.5,w*0.6,h*1.0,3);c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
// sweeping lens
const a=t*1.5;c.fillStyle='rgba(70,240,240,0.5)';
c.beginPath();c.moveTo(cx,y);c.arc(cx,y,16,a,a+0.5);c.closePath();c.fill();
c.fillStyle=col.accent;c.beginPath();c.arc(cx,y,3,0,6.28);c.fill();
}
}
function drawBuildingBrackets(x,y,w,h){
const c=ctx,L=8;c.strokeStyle=FX.selValid;c.lineWidth=2.5;
const pts=[[x,y,1,1],[x+w,y,-1,1],[x,y+h,1,-1],[x+w,y+h,-1,-1]];
for(const p of pts){c.beginPath();c.moveTo(p[0],p[1]+p[3]*L);c.lineTo(p[0],p[1]);c.lineTo(p[0]+p[2]*L,p[1]);c.stroke();}
}
function drawUnit(u,x,y){
const c=ctx;
c.save();
const ss=u.spawnT<0.25?clamp(u.spawnT/0.25,0,1):1;
// shadow
c.fillStyle='rgba(0,0,0,0.25)';c.beginPath();c.ellipse(x,y+u.r*0.55,u.r*0.85,u.r*0.4,0,0,6.28);c.fill();
// selection ring (player only)
if(u.team==='player'&&G.selection.indexOf(u)>=0){
const sr=u.r+5,sc=u.selT;
c.strokeStyle=FX.selValid;c.lineWidth=2/G.cam.zoom*G.cam.zoom;c.lineWidth=2.2;
c.globalAlpha=0.9;c.beginPath();c.ellipse(x,y+u.r*0.4,sr*sc,sr*0.5*sc,0,0,6.28);c.stroke();c.globalAlpha=1;
}
c.translate(x,y);if(ss<1)c.scale(ss,ss);
if(u.type==='harvester')drawHarvester(u);
else if(u.type==='rifleman')drawRifleman(u);
else if(u.type==='scout')drawScout(u);
else if(u.type==='rocket')drawRocket(u);
else if(u.type==='tank')drawTank(u);
else if(u.type==='critter')drawCritter(u);
c.restore();
}
function drawHarvester(u){
const c=ctx,col=UNIT.harvester;c.rotate(u.facing);
const g=c.createLinearGradient(0,-u.r,0,u.r);g.addColorStop(0,col.hullLit);g.addColorStop(1,col.dark);
c.fillStyle=g;rr(c,-u.r,-u.r*0.8,u.r*2,u.r*1.6,3);c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
// cargo bay
const f=u.cargo/u.cargoMax;
c.fillStyle='rgba(0,0,0,0.4)';rr(c,-u.r*0.6,-u.r*0.55,u.r*1.0,u.r*1.1,2);c.fill();
if(f>0){c.fillStyle=col.cargo;rr(c,-u.r*0.6,-u.r*0.55+u.r*1.1*(1-f),u.r*1.0,u.r*1.1*f,2);c.fill();}
// scoop
c.fillStyle=col.dark;c.fillRect(u.r*0.7,-u.r*0.5,u.r*0.5,u.r);
}
function drawRifleman(u){
const c=ctx,col=UNIT.rifleman;c.rotate(u.facing);
c.fillStyle=col.dark;c.fillRect(0,-2,u.r+5,4); // barrel
const g=c.createRadialGradient(-2,-2,1,0,0,u.r);g.addColorStop(0,col.lit);g.addColorStop(1,col.body);
c.fillStyle=g;c.beginPath();c.arc(0,0,u.r,0,6.28);c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
c.fillStyle=col.dark;c.beginPath();c.arc(0,0,u.r*0.4,0,6.28);c.fill();
}
function drawScout(u){
const c=ctx,col=UNIT.scout;c.rotate(u.facing);
c.fillStyle=col.body;
c.beginPath();c.moveTo(u.r,0);c.lineTo(-u.r*0.7,u.r*0.8);c.lineTo(-u.r*0.3,0);c.lineTo(-u.r*0.7,-u.r*0.8);c.closePath();c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
c.fillStyle=col.lit;c.beginPath();c.arc(0,0,u.r*0.3,0,6.28);c.fill();
}
function drawRocket(u){
const c=ctx,col=UNIT.rocket;c.rotate(u.facing);
const g=c.createRadialGradient(-2,-2,1,0,0,u.r);g.addColorStop(0,col.lit);g.addColorStop(1,col.body);
c.fillStyle=g;c.beginPath();c.arc(0,0,u.r,0,6.28);c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
c.fillStyle=col.dark;c.fillRect(0,-u.r*0.7,u.r+6,u.r*0.5); // launcher
}
function drawTank(u){
const c=ctx,col=UNIT.tank;
c.save();c.rotate(u.facing);
c.fillStyle=col.tread;c.fillRect(-u.r,-u.r,u.r*2,u.r*0.45);c.fillRect(-u.r,u.r*0.55,u.r*2,u.r*0.45);
const g=c.createLinearGradient(0,-u.r,0,u.r);g.addColorStop(0,col.lit);g.addColorStop(1,col.dark);
c.fillStyle=g;rr(c,-u.r*0.85,-u.r*0.65,u.r*1.7,u.r*1.3,3);c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
c.restore();
// turret (independent aim)
c.save();c.rotate(u.aimFacing||u.facing);
c.fillStyle=col.dark;c.fillRect(0,-2.5,u.r+8,5);
c.fillStyle=col.lit;c.beginPath();c.arc(0,0,u.r*0.55,0,6.28);c.fill();
c.strokeStyle=TEAM.outline;c.lineWidth=1.5;c.stroke();c.restore();
}
function drawCritter(u){
const c=ctx,col=UNIT.critter;
const bob=Math.sin(u.bob)*1.5;
c.fillStyle=col.body;c.beginPath();c.ellipse(0,bob,u.r,u.r*0.85,0,0,6.28);c.fill();
c.strokeStyle=col.dark;c.lineWidth=1.5;c.stroke();
c.fillStyle=col.lit;c.beginPath();c.arc(-u.r*0.3,bob-u.r*0.3,u.r*0.25,0,6.28);c.fill();
// eyes
c.fillStyle='#1a1208';c.beginPath();c.arc(u.r*0.3,bob-1,1.5,0,6.28);c.arc(u.r*0.5,bob-1,1.5,0,6.28);c.fill();
}
function drawHealthBars(us,blds,alpha){
const c=ctx;
for(const u of us){
if(!G.fog.isVisible(u.tileX,u.tileY))continue;
const sel=G.selection.indexOf(u)>=0;
if(!(u.hpFlash>0||sel)||u.type==='critter'&&!(u.hpFlash>0))continue;
if(u.type==='critter'&&u.hpFlash<=0)continue;
const rx=lerp(u.px,u.x,alpha),ry=lerp(u.py,u.y,alpha);
hpBar(rx,ry-u.r-7,u.r*2,u.hp/u.hpMax);
}
for(const b of blds){
const sel=G.selection.indexOf(b)>=0;
if(!(b.hpFlash>0||sel))continue;
hpBar(b.x,b.ty*TILE-6,b.fw*TILE*0.8,b.hp/b.hpMax);
}
}
function hpBar(x,y,w,frac){
const c=ctx,iz=1/G.cam.zoom,h=4*Math.min(1.4,iz);w=Math.max(w,18*Math.min(1.4,iz));
c.fillStyle='rgba(0,0,0,0.6)';c.fillRect(x-w/2-1,y-1,w+2,h+2);
c.fillStyle=frac>0.5?FX.hpHigh:frac>0.25?FX.hpMid:FX.hpLow;
c.fillRect(x-w/2,y,w*clamp(frac,0,1),h);
}
function drawParticles(){
const c=ctx,L=G.particles.list;
for(const p of L){
const a=clamp(p.life/p.maxLife,0,1);
c.globalAlpha=p.type===P_SMOKE?a*0.5:a;
c.fillStyle=p.color;
if(p.type===P_CONFETTI){c.save();c.translate(p.x,p.y);c.rotate(p.rot+p.life*5);
c.fillRect(-p.size/2,-p.size/2,p.size,p.size*0.5);c.restore();}
else{c.beginPath();c.arc(p.x,p.y,p.size*(p.type===P_SMOKE?(2-a):1),0,6.28);c.fill();}
}
c.globalAlpha=1;
}
function drawProjectiles(){
const c=ctx;
for(const p of G.projectiles){
c.strokeStyle=p.color;c.lineWidth=2.5;c.globalAlpha=0.9;
const d=Math.hypot(p.vx,p.vy)||1;
c.beginPath();c.moveTo(p.x,p.y);c.lineTo(p.x-p.vx/d*7,p.y-p.vy/d*7);c.stroke();
c.fillStyle=p.color;c.beginPath();c.arc(p.x,p.y,2,0,6.28);c.fill();
}
c.globalAlpha=1;
}
function drawFloaters(){
const c=ctx;c.textAlign='center';c.font='700 14px Orbitron, sans-serif';
for(const f of G.floaters){
const a=clamp(f.life/f.maxLife,0,1);c.globalAlpha=a;
c.fillStyle='rgba(0,0,0,0.5)';c.fillText(f.text,f.x+1,f.y+1);
c.fillStyle=f.color;c.fillText(f.text,f.x,f.y);
}
c.globalAlpha=1;c.textAlign='left';
}
function drawDecals(){
const c=ctx;
for(const d of G.decals){
if(d.kind==='ping'){const p=d.t/d.life;c.strokeStyle=d.color;c.globalAlpha=1-p;c.lineWidth=2.5;
c.beginPath();c.arc(d.x,d.y,4+p*18,0,6.28);c.stroke();
c.beginPath();c.arc(d.x,d.y,2+p*9,0,6.28);c.stroke();}
}
c.globalAlpha=1;
}
function drawRally(){
const c=ctx;
if(G.selKind!=='building')return;
const b=G.selection[0];if(!b||!b.rally)return;
c.strokeStyle=b.rally.gather?'rgba(70,240,240,0.6)':'rgba(120,255,160,0.6)';
c.lineWidth=2;c.setLineDash([6,6]);
c.beginPath();c.moveTo(b.x,b.y);c.lineTo(b.rally.x,b.rally.y);c.stroke();c.setLineDash([]);
// pennant
const t=G.time;c.fillStyle=b.rally.gather?'#46f0f0':FX.selValid;
c.save();c.translate(b.rally.x,b.rally.y);
c.fillRect(-1,-16,2,16);
c.beginPath();c.moveTo(1,-16);c.lineTo(11+Math.sin(t*4)*2,-13);c.lineTo(1,-9);c.closePath();c.fill();
c.restore();
}
function drawGhost(){
const c=ctx,pl=G.placing;
const tx=(G.input.wx/TILE|0)-((pl.fw-1)>>1),ty=(G.input.wy/TILE|0)-((pl.fh-1)>>1);
const valid=placementValid(tx,ty,pl.fw,pl.fh);
// per-tile cells
for(let y=0;y<pl.fh;y++)for(let x=0;x<pl.fw;x++){
const cx=tx+x,cy=ty+y;
let ok=inBounds(cx,cy)&&BUILDABLE[G.terrain[tileIdx(cx,cy)]]&&G.occ[tileIdx(cx,cy)]===0&&!G.passGrid[tileIdx(cx,cy)];
c.fillStyle=ok&&valid?'rgba(93,255,138,0.28)':'rgba(255,93,82,0.30)';
c.fillRect(cx*TILE+2,cy*TILE+2,TILE-4,TILE-4);
c.strokeStyle=ok&&valid?'rgba(93,255,138,0.7)':'rgba(255,93,82,0.7)';
c.lineWidth=1.5;c.strokeRect(cx*TILE+2,cy*TILE+2,TILE-4,TILE-4);
}
}
function drawBuildAura(){
const c=ctx;c.globalAlpha=0.06;c.fillStyle='#5dff8a';
for(const b of G.buildings){if(b.dead||!b.built)continue;
c.beginPath();c.arc(b.x,b.y,(BUILD_RADIUS+1)*TILE,0,6.28);c.fill();}
c.globalAlpha=1;
}
function drawSelectionBox(){
const c=ctx;const a=worldToScreen(G.input.boxX0,G.input.boxY0);
const x=Math.min(a.x,G.input.mx),y=Math.min(a.y,G.input.my);
const w=Math.abs(G.input.mx-a.x),h=Math.abs(G.input.my-a.y);
c.fillStyle='rgba(93,255,138,0.10)';c.fillRect(x,y,w,h);
c.strokeStyle=FX.selValid;c.lineWidth=1.5;c.setLineDash([5,4]);c.strokeRect(x,y,w,h);c.setLineDash([]);
}
function drawAttackCursor(){
const c=ctx;c.strokeStyle=FX.attackPing;c.lineWidth=2;
c.beginPath();c.arc(G.input.mx,G.input.my,12,0,6.28);c.stroke();
c.beginPath();c.moveTo(G.input.mx-16,G.input.my);c.lineTo(G.input.mx-8,G.input.my);
c.moveTo(G.input.mx+8,G.input.my);c.lineTo(G.input.mx+16,G.input.my);
c.moveTo(G.input.mx,G.input.my-16);c.lineTo(G.input.mx,G.input.my-8);
c.moveTo(G.input.mx,G.input.my+8);c.lineTo(G.input.mx,G.input.my+16);c.stroke();
}
/* ----- minimap ----- */
function bakeMinimapBase(){
minimapBase=document.createElement('canvas');minimapBase.width=MAP_W;minimapBase.height=MAP_H;
const mc=minimapBase.getContext('2d');const img=mc.createImageData(MAP_W,MAP_H);
for(let i=0;i<N_TILES;i++){const col=miniColorRGB(i),o=i<<2;img.data[o]=col[0];img.data[o+1]=col[1];img.data[o+2]=col[2];img.data[o+3]=255;}
mc.putImageData(img,0,0);
}
function miniColorRGB(i){const t=G.terrain[i];
if(t===T_WATER)return[31,93,104];if(t===T_ROCK)return[74,80,90];
if(t===T_CRYSTAL)return[57,224,230];
return G.patchNoise[i]<0.34?[111,90,58]:[96,128,68];}
function miniColor(i){const c=miniColorRGB(i);return'rgb('+c[0]+','+c[1]+','+c[2]+')';}
let mmAccum=0;
function drawMinimap(now){
const c=mmCtx;c.imageSmoothingEnabled=false;
c.clearRect(0,0,MM,MM);
// base terrain scaled
c.drawImage(minimapBase,0,0,MAP_W,MAP_H,0,0,MM,MM);
// fog overlay
c.imageSmoothingEnabled=true;c.globalAlpha=1;
c.drawImage(G.fog.maskCanvas,0,0,MAP_W,MAP_H,0,0,MM,MM);
c.imageSmoothingEnabled=false;
const s=MM/MAP_W;
// buildings (explored)
for(const b of G.buildings){if(b.dead||!G.fog.explored[tileIdx(b.tileX,b.tileY)])continue;
c.fillStyle=BLD[b.type].accent;c.fillRect((b.tx)*s,(b.ty)*s,Math.max(2,b.fw*s),Math.max(2,b.fh*s));}
// units (visible only)
for(const u of G.units){if(u.dead)continue;if(!G.fog.isVisible(u.tileX,u.tileY))continue;
if(u.type==='critter')continue;
c.fillStyle=u.type==='harvester'?'#f0cd76':'#85adff';
c.fillRect((u.x/TILE)*s-1,(u.y/TILE)*s-1,2.5,2.5);}
// viewport rect
const cam=G.cam;const vx=cam.x/WORLD_W*MM,vy=cam.y/WORLD_H*MM;
const vw=(cam.vw/cam.zoom)/WORLD_W*MM,vh=(cam.vh/cam.zoom)/WORLD_H*MM;
c.strokeStyle='rgba(255,255,255,0.85)';c.lineWidth=1.5;c.strokeRect(vx,vy,vw,vh);
}
/* ===== S14 HUD / DOM SYNC ======================================== */
const el={};
['credit-val','rate-val','power-fill','power-bar','power-txt','units-val','bldg-val','map-val',
'clock-val','obj-list','obj-count','sel-empty','sel-portrait','sel-info','sel-multi','sel-name',
'sel-role','sel-hp','sel-stats','sel-extra','card-label','card-grid','prod-queue','portrait-cv',
'tooltip','victory-banner','vb-sub'].forEach(id=>el[id]=document.getElementById(id));
let hudCache={credits:-1,rate:-999,sup:-1,dem:-1,units:-1,bldg:-1,map:-1,clock:'',objSig:'',selSig:'',cardSig:'',queueSig:''};
function updateHUD(frameDt){
// credits roll-up
G.creditsDisplay+=(G.credits-G.creditsDisplay)*(1-Math.exp(-12*frameDt));
const cd=Math.round(G.creditsDisplay);
if(cd!==hudCache.credits){el['credit-val'].textContent=cd;hudCache.credits=cd;}
const rate=Math.round(G.incomeRate);
if(rate!==hudCache.rate){el['rate-val'].textContent=(rate>=0?'+':'')+rate+'/s';hudCache.rate=rate;}
// power
if(G.powerSupply!==hudCache.sup||G.powerDemand!==hudCache.dem){
hudCache.sup=G.powerSupply;hudCache.dem=G.powerDemand;
const frac=G.powerDemand>0?clamp(G.powerSupply/G.powerDemand,0,1):1;
el['power-fill'].style.transform='scaleX('+(G.powerDemand>0?frac:1)+')';
el['power-bar'].classList.toggle('low',G.powerDemand>G.powerSupply);
el['power-txt'].innerHTML='<span class="accent">'+G.powerSupply+'</span>/<span class="dim">'+G.powerDemand+'</span>';
}
// stats
let nu=0;for(const u of G.units)if(!u.dead&&u.team==='player'&&u.type!=='critter')nu++;
if(nu!==hudCache.units){el['units-val'].textContent=nu;hudCache.units=nu;}
let nb=0;for(const b of G.buildings)if(!b.dead&&b.built)nb++;
if(nb!==hudCache.bldg){el['bldg-val'].textContent=nb;hudCache.bldg=nb;}
const mp=Math.round(G.fog.exploredCount/N_TILES*100);
if(mp!==hudCache.map){el['map-val'].textContent=mp+'%';hudCache.map=mp;}
const clk=fmtClock(G.won?G.wonAt:G.sessionSec);
if(clk!==hudCache.clock){el['clock-val'].textContent=clk;hudCache.clock=clk;}
// objectives
const objSig=G.objectives.map(o=>o.done?'1':'0').join('')+G.objDoneCount;
if(objSig!==hudCache.objSig){rebuildObjectives();hudCache.objSig=objSig;}
else updateObjProgress();
if((''+G.objDoneCount+'/'+G.objectives.length)!==el['obj-count'].textContent)
el['obj-count'].textContent=G.objDoneCount+'/'+G.objectives.length;
// selection + card
updateSelectionPanel();
updateCommandCard();
}
function rebuildObjectives(){
const frag=document.createDocumentFragment();
for(const o of G.objectives){
const d=document.createElement('div');d.className='obj'+(o.done?' done':'');
d.innerHTML='<span class="box">'+(o.done?'✔':'')+'</span><span class="txt">'+o.label+'</span>'+
(o.prog&&!o.done?'<span class="prog" data-obj="'+o.key+'">'+(o.progTxt?o.progTxt():'')+'</span>':'');
frag.appendChild(d);
}
el['obj-list'].replaceChildren(frag);
}
function updateObjProgress(){
el['obj-list'].querySelectorAll('.prog').forEach(p=>{
const o=G.objectives.find(x=>x.key===p.dataset.obj);if(o&&o.progTxt)p.textContent=o.progTxt();
});
}
function bumpObjCount(){el['obj-count'].classList.add('bump');setTimeout(()=>el['obj-count'].classList.remove('bump'),260);}
function flashCredit(){el['credit-val'].classList.add('flash');setTimeout(()=>el['credit-val'].classList.remove('flash'),120);}
function selSignature(){
if(G.selKind==='none')return 'none';
if(G.selKind==='building')return 'b'+G.selection[0].id;
return 'u'+G.selection.length+':'+G.selection.map(u=>u.type).slice(0,8).join(',');
}
function updateSelectionPanel(){
const sig=selSignature();
const single=G.selection.length===1?G.selection[0]:null;
if(sig!==hudCache.selSig){
hudCache.selSig=sig;
el['sel-empty'].style.display=G.selKind==='none'?'block':'none';
el['sel-multi'].style.display=(G.selKind==='units'&&G.selection.length>1)?'flex':'none';
const showSingle=G.selection.length===1;
el['sel-portrait'].style.display=showSingle?'block':'none';
el['sel-info'].style.display=showSingle?'block':'none';
if(showSingle){
const e=G.selection[0];
const def=e.kind==='building'?BLDG_DEFS[e.type]:UNIT_DEFS[e.type];
el['sel-name'].textContent=def.name;
el['sel-role'].textContent=roleText(e);
drawPortrait(e);
el['sel-stats'].innerHTML=statText(e);
} else if(G.selKind==='units'&&G.selection.length>1){
const frag=document.createDocumentFragment();
for(const u of G.selection.slice(0,28)){
const m=document.createElement('div');m.className='mini-unit';
m.innerHTML='<span>'+u.type.charAt(0).toUpperCase()+'</span><i></i>';
m.querySelector('i').style.background=u.hp/u.hpMax>0.5?FX.hpHigh:FX.hpMid;
frag.appendChild(m);
}
el['sel-multi'].replaceChildren(frag);
}
}
// live single fields
if(single){
el['sel-hp'].style.transform='scaleX('+clamp(single.hp/single.hpMax,0,1)+')';
el['sel-hp'].style.background=single.hp/single.hpMax>0.5?'linear-gradient(90deg,#3bd66b,#5cffac)':
single.hp/single.hpMax>0.25?'linear-gradient(90deg,#d6a23b,#ffd23f)':'linear-gradient(90deg,#d63b3b,#ff4d42)';
el['sel-extra'].textContent=extraText(single);
}
}
function roleText(e){
if(e.kind==='building'){return e.type==='cc'?'HQ · Production · Drop-off':
e.type==='power'?'Power Supply +100':e.type==='refinery'?'Drop-off · Power -40':
e.type==='barracks'?'Infantry Production':e.type==='warfactory'?'Vehicle Production':'Defense · Wide Vision';}
return e.type==='harvester'?'Resource Gathering':e.type==='scout'?'Recon · Fast':
e.type==='tank'?'Heavy Armor':e.atk>0?'Combat Infantry':'Unit';
}
function statText(e){
let s='<span>HP <b>'+Math.ceil(e.hp)+'</b>/'+e.hpMax+'</span>';
if(e.kind==='unit'){
if(e.atk>0)s+='<span>ATK <b>'+e.atk+'</b></span><span>RNG <b>'+(e.range/TILE).toFixed(1)+'</b></span>';
s+='<span>SPD <b>'+(e.speed/TILE).toFixed(1)+'</b></span><span>SIGHT <b>'+e.sight+'</b></span>';
} else {
if(e.power)s+='<span>PWR <b>'+(e.power>0?'+':'')+e.power+'</b></span>';
s+='<span>SIGHT <b>'+e.sight+'</b></span>';
}
return s;
}
function extraText(e){
if(e.kind==='unit'&&e.type==='harvester'){
return 'Cargo '+Math.round(e.cargo)+'/'+e.cargoMax+(e.harvState?' · '+e.harvState:'');
}
if(e.kind==='building'&&e.queue&&e.queue.length){
return 'Producing '+UNIT_DEFS[e.queue[0]].name+' · Queue '+e.queue.length+'/'+QUEUE_MAX;
}
return '';
}
function drawPortrait(e){
const pc=el['portrait-cv'],c=pc.getContext('2d');c.clearRect(0,0,96,96);
c.save();c.translate(48,52);
if(e.kind==='building'){
c.scale(2.0,2.0);const col=BLD[e.type];
c.fillStyle=col.base;rr(c,-16,-16,32,30,4);c.fill();c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke();
c.fillStyle=col.accent;c.fillRect(-6,-12,12,8);
} else {
c.scale(2.6,2.6);const u={...e,facing:-Math.PI/2,aimFacing:-Math.PI/2};
if(e.type==='harvester')drawHarvester(u);else if(e.type==='rifleman')drawRifleman(u);
else if(e.type==='scout')drawScout(u);else if(e.type==='rocket')drawRocket(u);
else if(e.type==='tank')drawTank(u);else drawCritter(u);
}
c.restore();
}
const CARD_BUILD=[['power','Q'],['refinery','E'],['barracks','R'],['warfactory','T'],['watchtower','F']];
const CARD_BARRACKS=[['rifleman','Q'],['scout','E'],['rocket','R']];
const CARD_WARFACTORY=[['harvester','Q'],['tank','E']];
function cardContext(){
if(G.selKind==='building'){
const b=G.selection[0];
if(b.type==='cc')return{type:'build-cc',label:'CONSTRUCTION'};
if(b.type==='barracks')return{type:'train-barracks',label:'BARRACKS',b};
if(b.type==='warfactory')return{type:'train-warfactory',label:'WAR FACTORY',b};
return{type:'building-info',label:BLDG_DEFS[b.type].name.toUpperCase(),b};
}
if(G.selKind==='units'){
const hasCombat=G.selection.some(u=>u.atk>0);
return{type:hasCombat?'unit-combat':'unit-info',label:'UNITS'};
}
return{type:'build-cc',label:'CONSTRUCTION'};
}
function updateCommandCard(){
const ctxn=cardContext();
const sig=ctxn.type+(ctxn.b?ctxn.b.id:'');
if(sig!==hudCache.cardSig){
hudCache.cardSig=sig;el['card-label'].textContent=ctxn.label;
rebuildCard(ctxn);
}
updateCardStates(ctxn);
updateQueue(ctxn);
}
function rebuildCard(ctxn){
const grid=el['card-grid'];const frag=document.createDocumentFragment();
if(ctxn.type==='build-cc'){
for(const[type,hk]of CARD_BUILD)frag.appendChild(buildBtn('build',type,hk,BLDG_DEFS[type]));
} else if(ctxn.type==='train-barracks'){
for(const[type,hk]of CARD_BARRACKS)frag.appendChild(buildBtn('train',type,hk,UNIT_DEFS[type]));
} else if(ctxn.type==='train-warfactory'){
for(const[type,hk]of CARD_WARFACTORY)frag.appendChild(buildBtn('train',type,hk,UNIT_DEFS[type]));
} else if(ctxn.type==='unit-combat'){
const info=document.createElement('div');info.className='info-card';info.style.gridColumn='1/5';
info.innerHTML='<b>A</b> Attack-move &nbsp; <b>S</b> Stop &nbsp; <b>H</b> Hold<br>'+
'<b>Ctrl+1-9</b> set group &nbsp; <b>1-9</b> recall<br>Right-click to move / attack critters.';
frag.appendChild(info);
} else if(ctxn.type==='unit-info'){
const info=document.createElement('div');info.className='info-card';info.style.gridColumn='1/5';
info.innerHTML='Right-click a <b>crystal field</b> to harvest.<br>Right-click ground to move.<br><b>S</b> Stop · <b>H</b> Hold.';
frag.appendChild(info);
} else {
const info=document.createElement('div');info.className='info-card';info.style.gridColumn='1/5';
info.innerHTML=roleText(ctxn.b)+'.<br>'+(BLDG_DEFS[ctxn.b.type].power?('Power '+(BLDG_DEFS[ctxn.b.type].power>0?'+':'')+BLDG_DEFS[ctxn.b.type].power):'');
frag.appendChild(info);
}
grid.replaceChildren(frag);
}
function buildBtn(act,type,hk,def){
const b=document.createElement('div');b.className='card-btn';b.dataset.act=act;b.dataset.val=type;
b.dataset.tip=type;
const cv=document.createElement('canvas');cv.width=26;cv.height=26;cv.className='ico';
drawIcon(cv,act,type);
b.appendChild(cv);
const nm=document.createElement('div');nm.className='nm';nm.textContent=def.name.replace(' Soldier','').replace('Battle ','');
b.appendChild(nm);
b.insertAdjacentHTML('beforeend','<span class="hk">'+hk+'</span><span class="cost">'+def.cost+'</span><span class="stk"></span>');
return b;
}
function drawIcon(cv,act,type){
const c=cv.getContext('2d');c.clearRect(0,0,26,26);c.save();c.translate(13,14);
if(act==='build'){c.scale(0.8,0.8);const col=BLD[type];
c.fillStyle=col.base;rr(c,-11,-10,22,20,3);c.fill();c.fillStyle=col.accent;c.fillRect(-4,-7,8,5);
c.strokeStyle=TEAM.outline;c.lineWidth=1.5;rr(c,-11,-10,22,20,3);c.stroke();}
else{c.scale(1.1,1.1);const u={...UNIT_DEFS[type],type,r:UNIT_DEFS[type].r*0.7,facing:-Math.PI/2,aimFacing:-Math.PI/2,cargo:60,cargoMax:150};
if(type==='harvester')drawHarvester(u);else if(type==='rifleman')drawRifleman(u);
else if(type==='scout')drawScout(u);else if(type==='rocket')drawRocket(u);else if(type==='tank')drawTank(u);}
c.restore();
}
function updateCardStates(ctxn){
const grid=el['card-grid'];const btns=grid.querySelectorAll('.card-btn');
if(ctxn.type==='build-cc'){
btns.forEach(btn=>{
const type=btn.dataset.val,def=BLDG_DEFS[type],slot=G.buildSlots[type];
let locked=type==='warfactory'&&!G.hasBarracks;
btn.classList.toggle('locked',locked);
const building=slot&&slot.building,ready=slot&&slot.ready;
btn.classList.toggle('building',!!building);
btn.classList.toggle('ready',!!ready);
if(building)btn.style.setProperty('--p',slot.progress);
btn.classList.toggle('disabled',!locked&&!building&&!ready&&G.credits<def.cost);
const nm=btn.querySelector('.nm');if(ready)nm.textContent='READY';
else nm.textContent=def.name.replace(' Plant','').replace(' Factory',' Fac.').replace('Watchtower','Tower');
});
} else if(ctxn.type.startsWith('train')){
const b=ctxn.b;
btns.forEach(btn=>{
const type=btn.dataset.val,def=UNIT_DEFS[type];
let locked=type==='rocket'&&!G.hasWarFactory;
btn.classList.toggle('locked',locked);
btn.classList.toggle('disabled',!locked&&G.credits<def.cost);
const stk=btn.querySelector('.stk');
const n=b.queue.filter(q=>q===type).length;
stk.textContent=n>0?n:'';
const lead=b.queue[0]===type;
btn.style.setProperty('--p',lead?b.prodT:0);
btn.classList.toggle('building',lead&&b.prodT>0);
});
}
}
function updateQueue(ctxn){
const q=el['prod-queue'];
let queue=null,prog=0;
if(ctxn.b&&ctxn.b.queue){queue=ctxn.b.queue;prog=ctxn.b.prodT;}
const sig=queue?queue.join(','):'';
if(sig!==hudCache.queueSig){
hudCache.queueSig=sig;
const frag=document.createDocumentFragment();
if(queue)for(let i=0;i<queue.length;i++){const pip=document.createElement('div');
pip.className='pip'+(i===0?' active':'');frag.appendChild(pip);}
q.replaceChildren(frag);
}
if(queue&&queue.length){const lead=q.firstChild;if(lead)lead.style.setProperty('--p',prog);}
}
let toastT=0;
function toast(msg,cls){
const layer=document.getElementById('toast-layer');
const t=document.createElement('div');t.className='toast'+(cls?' '+cls:'');t.textContent=msg;
layer.appendChild(t);
setTimeout(()=>{t.style.transition='opacity .4s';t.style.opacity='0';setTimeout(()=>t.remove(),400);},1800);
while(layer.children.length>4)layer.firstChild.remove();
}
function showVictory(){
const vb=el['victory-banner'];el['vb-sub'].textContent='All objectives complete · '+fmtClock(G.wonAt);
vb.classList.add('show');
setTimeout(()=>{vb.style.transition='opacity 1s';vb.style.opacity='0';
setTimeout(()=>{vb.classList.remove('show');vb.style.opacity='';vb.style.transition='';},1000);},5000);
}
/* ----- tooltips ----- */
let lastTipEl=null;
document.addEventListener('mousemove',e=>{
const t=e.target.closest('[data-tip]');
const tip=el['tooltip'];
if(!t){if(lastTipEl){tip.classList.remove('show');lastTipEl=null;}return;}
if(t!==lastTipEl){lastTipEl=t;
const type=t.dataset.tip;
const def=BLDG_DEFS[type]||UNIT_DEFS[type];if(!def){tip.classList.remove('show');return;}
let lock='';
if(type==='warfactory'&&!G.hasBarracks)lock='Requires Barracks';
if(type==='rocket'&&!G.hasWarFactory)lock='Requires War Factory';
tip.innerHTML='<div class="tt-name">'+def.name+' <span class="tt-cost">'+def.cost+'cr</span></div>'+
'<div class="tt-desc">'+tipDesc(type)+'</div>'+(lock?'<div class="tt-lock">🔒 '+lock+'</div>':'');
tip.classList.add('show');
}
const tip2=el['tooltip'];
tip2.style.transform='translate('+(e.clientX+14)+'px,'+(e.clientY-10-tip2.offsetHeight)+'px)';
});
function tipDesc(type){const m={
power:'Generates +100 power. Build these to keep production at full speed.',
refinery:'Resource drop-off. Comes with a free Harvester. Build forward, near crystal.',
barracks:'Trains infantry: Rifleman, Scout, Rocket Soldier.',
warfactory:'Builds Harvesters and Battle Tanks. Needs a Barracks first.',
watchtower:'Cheap defense with very wide vision. Great for scouting expansions.',
harvester:'Gathers crystal and ferries it to the nearest drop-off.',
rifleman:'Cheap all-round infantry.',
scout:'Very fast with the widest sight. Reveal the map.',
rocket:'High-damage, long-range infantry. Needs a War Factory.',
tank:'Heavy armor, big punch.',
};return m[type]||'';}
/* ===== S15 MAIN LOOP + BOOT ====================================== */
let lastT=0,acc=0;
function frame(now){
requestAnimationFrame(frame);
if(!lastT)lastT=now;
let frameDt=(now-lastT)/1000;lastT=now;
if(frameDt>MAX_FRAME)frameDt=MAX_FRAME;
if(!G.paused)G.sessionSec+=frameDt;
updateCamera(frameDt);
if(!G.paused){
acc+=frameDt;let steps=0;
while(acc>=SIM_DT&&steps<MAX_CATCHUP){
for(const u of G.units){u.px=u.x;u.py=u.y;}
fixedUpdate(SIM_DT);acc-=SIM_DT;steps++;
}
if(steps===MAX_CATCHUP)acc=0;
}
const alpha=G.paused?1:clamp(acc/SIM_DT,0,1);
draw(alpha);
mmAccum+=frameDt;
if(mmAccum>0.1||G.cam.moved||G.fog.dirty){mmAccum=0;G.cam.moved=false;drawMinimap(now);}
updateHUD(frameDt);
}
function resize(){
const dpr=Math.min(devicePixelRatio||1,2);
const w=canvas.clientWidth,h=canvas.clientHeight;
canvas.width=(w*dpr)|0;canvas.height=(h*dpr)|0;DPR=dpr;
G.cam.vw=w;G.cam.vh=h;clampCamTarget();clampCam();G.cam.moved=true;
}
function init(){
generateTerrain();
initFog();initFX();initObjectives();
bakeTerrain();
resize();
// pre-place Command Center
const cc=addBuilding(makeBuilding('cc',START_TX,START_TY,true));
// starting units
const sx=tileCenter(START_TX+1),sy=tileCenter(START_TY+3);
for(let i=0;i<2;i++){const h=addUnit(makeUnit('harvester',sx-20+i*40,sy+10,'player'));h.order='harvest';h.harvState=null;}
const sc=addUnit(makeUnit('scout',sx+30,sy+20,'player'));
recomputePower();recomputeTech();
// initial fog around base
G.fog.recompute();G.fog.dirty=false;
for(let i=0;i<N_TILES;i++)if(G.fog.visTarget[i])G.fog.visible[i]=255;
// critters
for(let i=0;i<CRITTER_COUNT;i++)spawnCritter();
// camera on CC
centerCameraOn(cc.x,cc.y);
// events
canvas.addEventListener('mousedown',onMouseDown);
window.addEventListener('mousemove',onMouseMove);
window.addEventListener('mouseup',onMouseUp);
canvas.addEventListener('wheel',onWheel,{passive:false});
canvas.addEventListener('dblclick',onDblClick);
canvas.addEventListener('contextmenu',e=>e.preventDefault());
canvas.addEventListener('mouseleave',()=>{G.input.insideCanvas=false;});
window.addEventListener('keydown',onKeyDown);
window.addEventListener('keyup',onKeyUp);
window.addEventListener('resize',resize);
window.addEventListener('blur',()=>{G.input.keys=Object.create(null);});
// objectives panel collapse
document.getElementById('obj-head').addEventListener('click',()=>{
el['obj-list'].classList.toggle('collapsed');});
document.querySelector('#minimap-panel');
requestAnimationFrame(frame);
}
window.addEventListener('load',init);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment