Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 25, 2026 09:26
Show Gist options
  • Select an option

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

Select an option

Save senko/c7004d97f7cfa61bd05e1bdf8c26031d to your computer and use it in GitHub Desktop.
RTS game by Opus 5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ironhold — Realtime Strategy</title>
<style>
:root{
--panel:#1b1f2a;
--panel2:#232937;
--edge:#39415a;
--gold:#ffd166;
--wood:#b9834b;
--text:#d9e0f0;
--dim:#8b95ad;
--accent:#6fd3ff;
--green:#7bd47b;
--red:#ff6b6b;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:#0b0d13;color:var(--text);
font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;user-select:none;-webkit-user-select:none}
#app{position:fixed;inset:0;display:flex;flex-direction:column}
/* ---------- top bar ---------- */
#topbar{height:38px;flex:0 0 38px;display:flex;align-items:center;gap:18px;padding:0 14px;
background:linear-gradient(#2a3145,#171b26);border-bottom:2px solid #0a0c11;
box-shadow:0 2px 8px rgba(0,0,0,.6);z-index:5;font-size:14px}
.res{display:flex;align-items:center;gap:7px;font-weight:600;letter-spacing:.4px}
.res .gem{width:15px;height:15px;border-radius:3px;transform:rotate(45deg);box-shadow:inset 0 0 0 1px rgba(255,255,255,.25)}
.gem.g{background:linear-gradient(135deg,#ffe9a8,#e0a020)}
.gem.w{background:linear-gradient(135deg,#d8a778,#7a4f28);border-radius:2px;transform:none}
.gem.s{background:linear-gradient(135deg,#a8d8ff,#3a7bbd);border-radius:50%;transform:none}
.gem.e{background:linear-gradient(135deg,#c9b4ff,#6a4bbd);border-radius:50%;transform:none}
#title{margin-left:auto;font-weight:700;letter-spacing:3px;color:#8c98b8;font-size:12px}
.btn-min{background:#2c3245;border:1px solid #454e69;color:#c8d2e8;border-radius:4px;padding:3px 9px;
font-size:12px;cursor:pointer}
.btn-min:hover{background:#3a4260;color:#fff}
/* ---------- viewport ---------- */
#viewport{position:relative;flex:1 1 auto;overflow:hidden;background:#05070b}
#game{position:absolute;inset:0;display:block;cursor:default}
#alerts{position:absolute;top:10px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;
gap:6px;align-items:center;pointer-events:none;z-index:4}
.alert{background:rgba(20,24,34,.9);border:1px solid #4a5573;border-left:3px solid var(--red);
padding:6px 14px;border-radius:4px;font-size:13px;animation:fade 3.4s forwards}
@keyframes fade{0%{opacity:0;transform:translateY(-6px)}8%{opacity:1;transform:none}75%{opacity:1}100%{opacity:0}}
#banner{position:absolute;top:34%;left:50%;transform:translate(-50%,-50%);text-align:center;
pointer-events:none;opacity:0;transition:opacity .8s;z-index:6}
#banner.show{opacity:1}
#banner h1{font-size:44px;letter-spacing:6px;color:#ffe6a0;text-shadow:0 0 26px rgba(255,200,80,.55),0 3px 0 #4a3a10}
#banner p{color:#cfd8ee;margin-top:8px;letter-spacing:1px}
/* ---------- bottom hud ---------- */
#hud{flex:0 0 176px;height:176px;display:flex;gap:8px;padding:8px;
background:linear-gradient(#232a3b,#141822);border-top:2px solid #0a0c11;
box-shadow:0 -3px 10px rgba(0,0,0,.6);z-index:5}
.frame{background:var(--panel);border:1px solid #414b68;border-radius:6px;
box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 2px 6px rgba(0,0,0,.4)}
#minimapWrap{flex:0 0 160px;width:160px;height:160px;position:relative;padding:4px}
#minimap{width:100%;height:100%;display:block;border-radius:3px;background:#000;cursor:crosshair}
#infoPanel{flex:1 1 auto;padding:8px 10px;overflow:hidden;position:relative;min-width:0}
#cardPanel{flex:0 0 268px;width:268px;padding:6px;display:grid;grid-template-columns:repeat(4,1fr);
grid-template-rows:repeat(3,1fr);gap:5px}
.cmd{position:relative;background:linear-gradient(#39415c,#252b3d);border:1px solid #525d80;border-radius:5px;
cursor:pointer;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0}
.cmd:hover{border-color:#8fa4d8;background:linear-gradient(#48527a,#2c3448)}
.cmd:active{transform:translateY(1px)}
.cmd.disabled{filter:grayscale(.85) brightness(.55);cursor:not-allowed}
.cmd .hk{position:absolute;top:1px;left:3px;font-size:10px;color:#ffd166;font-weight:700;text-shadow:0 1px 2px #000}
.cmd canvas{width:100%;height:100%;display:block}
.cmd.empty{background:#191d28;border:1px solid #2a3145;cursor:default;pointer-events:none;box-shadow:none}
#tip{position:absolute;bottom:184px;right:8px;width:262px;background:rgba(16,19,27,.97);
border:1px solid #5a668c;border-radius:6px;padding:9px 11px;font-size:12.5px;display:none;z-index:20;
box-shadow:0 6px 18px rgba(0,0,0,.6)}
#tip .tn{font-weight:700;color:#fff;margin-bottom:3px;font-size:13.5px}
#tip .tc{color:var(--dim);margin-bottom:4px}
#tip .td{color:#b9c4dc;line-height:1.35}
#tip .cost{display:inline-flex;gap:4px;align-items:center;margin-right:9px}
.selRow{display:flex;flex-wrap:wrap;gap:4px;align-content:flex-start;height:100%;overflow:hidden}
.portrait{width:44px;height:44px;position:relative;background:#2a3145;border:1px solid #4c577a;border-radius:4px;
cursor:pointer;overflow:hidden}
.portrait canvas{width:100%;height:100%}
.portrait .hpb{position:absolute;left:2px;right:2px;bottom:2px;height:3px;background:#111;border-radius:2px}
.portrait .hpf{height:100%;background:var(--green);border-radius:2px}
.portrait.hurt .hpf{background:#e8c04a}
.portrait.crit .hpf{background:var(--red)}
.single{display:flex;gap:12px;height:100%}
.single .big{flex:0 0 88px;width:88px;height:88px;border:1px solid #4c577a;border-radius:6px;
overflow:hidden;background:radial-gradient(circle at 50% 35%,#3c465f,#1a1f2b)}
.single .big canvas{width:100%;height:100%}
.single .meta{flex:1 1 auto;min-width:0}
.single h3{font-size:16px;margin-bottom:2px;color:#fff;letter-spacing:.5px}
.single .sub{font-size:12px;color:var(--dim);margin-bottom:6px}
.stat{font-size:12px;color:#b9c4dc;margin-bottom:2px}
.stat b{color:#fff;font-weight:600}
.bar{height:9px;background:#0d1018;border:1px solid #3d465f;border-radius:5px;overflow:hidden;margin:3px 0 6px;max-width:230px}
.bar > div{height:100%;background:linear-gradient(#8fe08f,#3f9f3f)}
.bar.prog > div{background:linear-gradient(#7fd0ff,#2a7fc0)}
.queue{display:flex;gap:4px;margin-top:6px}
.qslot{width:30px;height:30px;background:#232a3b;border:1px solid #48527a;border-radius:3px;overflow:hidden;
position:relative;cursor:pointer}
.qslot canvas{width:100%;height:100%}
.qslot:hover{border-color:#ff8f8f}
.hint{color:var(--dim);font-size:12px;line-height:1.5}
.hint b{color:#cfd8ee}
kbd{background:#2c3245;border:1px solid #4c577a;border-bottom-width:2px;border-radius:3px;padding:0 4px;
font-size:11px;font-family:inherit;color:#e6ecfa}
/* ---------- help overlay ---------- */
@media (max-height:780px){
#hud{flex:0 0 146px;height:146px}
#minimapWrap{flex:0 0 134px;width:134px;height:134px}
#cardPanel{flex:0 0 232px;width:232px}
#tip{bottom:154px}
}
#help{position:absolute;inset:0;background:rgba(6,8,13,.88);z-index:30;display:none;
align-items:center;justify-content:center}
#help.show{display:flex}
#helpBox{width:min(760px,92%);max-height:86%;overflow:auto;background:var(--panel);border:1px solid #4c577a;
border-radius:10px;padding:22px 26px;box-shadow:0 20px 60px rgba(0,0,0,.7)}
#helpBox h2{color:#ffe6a0;letter-spacing:3px;margin-bottom:4px;font-size:22px}
#helpBox h4{margin:16px 0 6px;color:var(--accent);font-size:13px;letter-spacing:2px;text-transform:uppercase}
#helpBox p,#helpBox li{font-size:13.5px;line-height:1.6;color:#c3cde2}
#helpBox ul{list-style:none}
#helpBox li{display:flex;gap:10px;margin-bottom:3px}
#helpBox li span:first-child{flex:0 0 168px;color:#9fb0d0}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:0 26px}
#closeHelp{margin-top:18px;background:#39456a;border:1px solid #5d6b96;color:#fff;padding:7px 18px;
border-radius:5px;cursor:pointer;font-size:13px}
#closeHelp:hover{background:#4a5a8c}
</style>
</head>
<body>
<div id="app">
<div id="topbar">
<div class="res"><div class="gem g"></div><span id="rGold">0</span></div>
<div class="res"><div class="gem w"></div><span id="rWood">0</span></div>
<div class="res"><div class="gem s"></div><span id="rSupply">0/0</span></div>
<div class="res"><div class="gem e"></div><span id="rExplored">0%</span></div>
<div class="res" style="color:#8b95ad;font-weight:400"><span id="rClock">0:00</span></div>
<div id="title">IRONHOLD</div>
<button class="btn-min" id="newBtn">New Map</button>
<button class="btn-min" id="helpBtn">Help (H)</button>
</div>
<div id="viewport">
<canvas id="game"></canvas>
<div id="alerts"></div>
<div id="banner"><h1>THE MAP IS YOURS</h1><p>Every corner of the land has been charted.</p></div>
<div id="banner2" style="position:absolute;top:34%;left:50%;transform:translate(-50%,-50%);text-align:center;
pointer-events:none;opacity:0;transition:opacity .8s;z-index:6">
<h1 style="font-size:42px;letter-spacing:6px;color:#ff9b8f;text-shadow:0 0 26px rgba(255,80,60,.5),0 3px 0 #3a1010">YOUR HOLDING HAS FALLEN</h1>
<p style="color:#cfd8ee;margin-top:8px;letter-spacing:1px">Nothing remains. Start a new map to try again.</p>
</div>
<div id="help">
<div id="helpBox">
<h2>IRONHOLD</h2>
<p style="color:#8b95ad">Build a base, harvest gold and lumber, raise an army — and chart every last tile of the map.</p>
<div class="cols">
<div>
<h4>Camera</h4>
<ul>
<li><span>Arrow keys</span><span>Scroll</span></li>
<li><span>Mouse to screen edge</span><span>Scroll</span></li>
<li><span>Middle-drag</span><span>Pan</span></li>
<li><span>Mouse wheel</span><span>Zoom</span></li>
<li><span>Click minimap</span><span>Jump there</span></li>
<li><span><kbd>Space</kbd></span><span>Centre on base</span></li>
<li><span><kbd>N</kbd></span><span>Find uncharted land</span></li>
</ul>
<h4>Selection</h4>
<ul>
<li><span>Left click / drag</span><span>Select</span></li>
<li><span><kbd>Shift</kbd>+click</span><span>Add to selection</span></li>
<li><span>Double click</span><span>All of that type</span></li>
<li><span><kbd>Ctrl</kbd>+<kbd>1..9</kbd></span><span>Make control group</span></li>
<li><span><kbd>1..9</kbd></span><span>Recall group</span></li>
</ul>
</div>
<div>
<h4>Orders</h4>
<ul>
<li><span>Right click</span><span>Move / harvest / attack</span></li>
<li><span><kbd>B</kbd></span><span>Build menu (workers)</span></li>
<li><span><kbd>A</kbd> + click</span><span>Attack-move</span></li>
<li><span><kbd>S</kbd></span><span>Stop</span></li>
<li><span><kbd>Esc</kbd></span><span>Cancel / deselect</span></li>
</ul>
<h4>Economy</h4>
<ul>
<li><span>Workers</span><span>Mine gold, chop trees, build</span></li>
<li><span>Farms</span><span>Raise the supply cap</span></li>
<li><span>Lumber Mill / Hall</span><span>Resource drop-off points</span></li>
<li><span>Scouts</span><span>Fast, huge sight — explore!</span></li>
</ul>
</div>
</div>
<p style="margin-top:14px;color:#8b95ad;font-size:12.5px">Wild beasts roam the wilderness. They will not hunt you, but they defend their ground — and drop gold when slain.</p>
<button id="closeHelp">Close</button>
</div>
</div>
</div>
<div id="hud">
<div id="minimapWrap" class="frame"><canvas id="minimap"></canvas></div>
<div id="infoPanel" class="frame"></div>
<div id="cardPanel" class="frame"></div>
</div>
<div id="tip"></div>
</div>
<script>
"use strict";
/* ============================================================================
IRONHOLD — a small realtime strategy game
========================================================================== */
/* ---------------------------------------------------------------- utilities */
const TAU = Math.PI * 2;
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
const dist = (ax, ay, bx, by) => Math.sqrt(dist2(ax, ay, bx, by));
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;
};
}
/* ------------------------------------------------------------------ config */
const TS = 32; // tile size in world pixels
const MAP_W = 72, MAP_H = 72; // map size in tiles
const MAP_PX_W = MAP_W * TS, MAP_PX_H = MAP_H * TS;
const T_DEEP = 0, T_WATER = 1, T_SAND = 2, T_GRASS = 3, T_DIRT = 4, T_ROCK = 5;
const PASSABLE = [false, false, true, true, true, false];
const UNIT_DEFS = {
worker: {
name: "Peasant", hp: 45, speed: 74, sight: 6, dmg: 4, range: 20, cd: 1.1, supply: 1,
gold: 50, wood: 0, time: 8, carry: 10, build: 1, from: "hall", hotkey: "Q",
desc: "Harvests gold and lumber, and raises every building in your domain."
},
soldier: {
name: "Footman", hp: 110, speed: 66, sight: 7, dmg: 11, range: 22, cd: 1.0, supply: 2,
gold: 80, wood: 10, time: 11, armor: 2, from: "barracks", hotkey: "W",
desc: "Sturdy melee infantry. The backbone of any warband."
},
archer: {
name: "Archer", hp: 70, speed: 70, sight: 9, dmg: 9, range: 145, cd: 1.25, supply: 2,
gold: 70, wood: 45, time: 12, ranged: true, from: "barracks", req: "mill", hotkey: "E",
desc: "Strikes from afar. Requires a Lumber Mill for seasoned bowstaves."
},
scout: {
name: "Outrider", hp: 65, speed: 130, sight: 13, dmg: 6, range: 20, cd: 1.2, supply: 1,
gold: 60, wood: 20, time: 8, from: "barracks", hotkey: "R",
desc: "Swift rider with exceptional vision. The best tool for charting the map."
}
};
const BUILD_DEFS = {
hall: {
name: "Town Hall", w: 3, h: 3, hp: 900, sight: 10, gold: 350, wood: 120, time: 40,
supply: 10, dropoff: true, trains: ["worker"], hotkey: "T",
desc: "Trains peasants, accepts harvested resources and provides 10 supply."
},
farm: {
name: "Farm", w: 2, h: 2, hp: 340, sight: 5, gold: 60, wood: 20, time: 14,
supply: 8, hotkey: "F",
desc: "Feeds your people. Provides 8 supply."
},
barracks: {
name: "Barracks", w: 3, h: 3, hp: 700, sight: 7, gold: 150, wood: 60, time: 24,
trains: ["soldier", "archer", "scout"], hotkey: "B",
desc: "Trains footmen, archers and outriders."
},
mill: {
name: "Lumber Mill", w: 2, h: 2, hp: 480, sight: 6, gold: 120, wood: 30, time: 18,
dropoff: true, hotkey: "L",
desc: "A drop-off point for gold and lumber. Unlocks the Archer."
},
tower: {
name: "Guard Tower", w: 2, h: 2, hp: 500, sight: 10, gold: 90, wood: 70, time: 16,
dmg: 16, range: 190, cd: 1.4, hotkey: "G",
desc: "Fires upon hostile creatures that stray too close."
}
};
const GOLD_PER_MINE = 1800;
const WOOD_PER_TREE = 120;
/* ------------------------------------------------------------------- state */
const G = {
gold: 550, wood: 300, supplyUsed: 0, supplyCap: 0,
time: 0, running: true, won: false, lost: false,
units: [], buildings: [], resources: [], projectiles: [], effects: [],
selection: [], groups: {}, nextId: 1,
placing: null, // {type} while placing a building
attackMove: false, // 'A' pressed, awaiting click
buildMenu: false,
exploredCount: 0, explorableTotal: 1
};
/* map arrays */
let terrain, blocked, resAt, explored, visible, explorable;
/* camera */
const cam = { x: 0, y: 0, zoom: 1, minZoom: 0.55, maxZoom: 1.8 };
/* ---------------------------------------------------------------- map gen */
function makeNoise(rng, size) {
const g = new Float32Array(size * size);
for (let i = 0; i < g.length; i++) g[i] = rng();
const sm = t => t * t * (3 - 2 * t);
return (x, y) => {
const fx = x * (size - 1), fy = y * (size - 1);
const x0 = Math.floor(fx), y0 = Math.floor(fy);
const x1 = Math.min(x0 + 1, size - 1), y1 = Math.min(y0 + 1, size - 1);
const tx = sm(fx - x0), ty = sm(fy - y0);
const a = g[y0 * size + x0], b = g[y0 * size + x1];
const c = g[y1 * size + x0], d = g[y1 * size + x1];
return lerp(lerp(a, b, tx), lerp(c, d, tx), ty);
};
}
let rng;
let startTile = { x: 34, y: 34 };
function generateMap(seed) {
rng = mulberry32(seed);
terrain = new Uint8Array(MAP_W * MAP_H);
blocked = new Uint8Array(MAP_W * MAP_H);
resAt = new Int32Array(MAP_W * MAP_H).fill(-1);
explored = new Uint8Array(MAP_W * MAP_H);
visible = new Uint8Array(MAP_W * MAP_H);
explorable = new Uint8Array(MAP_W * MAP_H);
const n1 = makeNoise(rng, 6), n2 = makeNoise(rng, 13), n3 = makeNoise(rng, 27);
const m1 = makeNoise(rng, 8), m2 = makeNoise(rng, 19);
const height = (x, y) => 0.56 * n1(x, y) + 0.30 * n2(x, y) + 0.14 * n3(x, y);
const moist = (x, y) => 0.65 * m1(x, y) + 0.35 * m2(x, y);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const u = x / (MAP_W - 1), v = y / (MAP_H - 1);
// push the borders down so the land is ringed by ocean
const edge = Math.min(u, v, 1 - u, 1 - v);
const falloff = clamp(edge / 0.11, 0, 1);
let h = height(u, v) * (0.35 + 0.65 * falloff) + 0.06 * falloff;
let t;
if (h < 0.20) t = T_DEEP;
else if (h < 0.27) t = T_WATER;
else if (h < 0.30) t = T_SAND;
else if (h > 0.675) t = T_ROCK;
else t = (moist(u, v) < 0.40 && h > 0.42) ? T_DIRT : T_GRASS;
terrain[y * MAP_W + x] = t;
}
}
// sand only belongs on a shoreline; inland patches become dry earth
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = y * MAP_W + x;
if (terrain[i] !== T_SAND) continue;
let coastal = false;
for (let dy = -2; dy <= 2 && !coastal; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const nt = terrain[ny * MAP_W + nx];
if (nt === T_WATER || nt === T_DEEP) { coastal = true; break; }
}
}
if (!coastal) terrain[i] = T_DIRT;
}
}
// ---- pick a start location: open ground near the centre
let best = null, bestScore = -1;
for (let i = 0; i < 900; i++) {
const x = 8 + Math.floor(rng() * (MAP_W - 16));
const y = 8 + Math.floor(rng() * (MAP_H - 16));
let open = 0;
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
const t = terrain[(y + dy) * MAP_W + (x + dx)];
if (t === T_GRASS || t === T_DIRT) open++;
}
const centre = 1 - dist(x, y, MAP_W / 2, MAP_H / 2) / (MAP_W * 0.6);
const score = open / 81 + centre * 0.55;
if (score > bestScore) { bestScore = score; best = { x, y }; }
}
startTile = best;
// flatten the home valley
for (let dy = -5; dy <= 5; dy++) for (let dx = -5; dx <= 5; dx++) {
const x = startTile.x + dx, y = startTile.y + dy;
if (x < 1 || y < 1 || x >= MAP_W - 1 || y >= MAP_H - 1) continue;
if (dx * dx + dy * dy <= 30) terrain[y * MAP_W + x] = T_GRASS;
}
// ---- reachability & explorability pass ------------------------------
// Anything the player could never see is turned into deep ocean, so that
// 100% exploration is always attainable.
const reach = floodReach(startTile.x, startTile.y);
const R = 6; // <= the smallest unit sight radius, so 100% is always reachable
const offs = [];
for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++)
if (dx * dx + dy * dy <= R * R + 1) offs.push([dx, dy]);
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
if (!reach[y * MAP_W + x]) continue;
for (const [dx, dy] of offs) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
explorable[ny * MAP_W + nx] = 1;
}
}
for (let i = 0; i < terrain.length; i++) if (!explorable[i]) terrain[i] = T_DEEP;
G.explorableTotal = explorable.reduce((a, b) => a + b, 0);
// ---- forests --------------------------------------------------------
const forest = makeNoise(rng, 15), forest2 = makeNoise(rng, 31);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = y * MAP_W + x;
if (terrain[i] !== T_GRASS) continue;
if (dist(x, y, startTile.x, startTile.y) < 5.5) continue;
const f = forest(x / MAP_W, y / MAP_H) * 0.72 + forest2(x / MAP_W, y / MAP_H) * 0.28;
if (f > 0.60 && rng() < 0.86) addResource("tree", x, y);
}
}
// a guaranteed grove beside the start
seedGrove(startTile.x + 6, startTile.y - 4, 4);
seedGrove(startTile.x - 6, startTile.y + 5, 4);
// ---- gold mines ------------------------------------------------------
const mines = [];
const tryMine = (x, y, force) => {
if (x < 2 || y < 2 || x >= MAP_W - 3 || y >= MAP_H - 3) return false;
for (const m of mines) if (dist(x, y, m.x, m.y) < 11) return false;
for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) {
const i = (y + dy) * MAP_W + (x + dx);
if (!PASSABLE[terrain[i]] && !force) return false;
if (blocked[i]) return false;
}
// must have some open ground around it
let open = 0;
for (let dy = -2; dy <= 3; dy++) for (let dx = -2; dx <= 3; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
if (PASSABLE[terrain[ny * MAP_W + nx]] && resAt[ny * MAP_W + nx] < 0) open++;
}
if (open < 14) return false;
for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) {
terrain[(y + dy) * MAP_W + (x + dx)] = T_DIRT;
const ri = resAt[(y + dy) * MAP_W + (x + dx)];
if (ri >= 0) removeResourceIndex(ri);
}
addResource("gold", x, y, 2, 2);
mines.push({ x, y });
return true;
};
// home mine
for (const [dx, dy] of [[6, 4], [-7, -3], [4, -7], [-4, 7], [8, -6], [-8, 6]]) {
if (tryMine(startTile.x + dx, startTile.y + dy, true)) break;
}
let guard = 0;
while (mines.length < 11 && guard++ < 4000) {
tryMine(2 + Math.floor(rng() * (MAP_W - 5)), 2 + Math.floor(rng() * (MAP_H - 5)), false);
}
// ---- what actually counts towards 100% -------------------------------
// Forests and mines block the ground they sit on, so re-derive the goal
// from tiles a unit can genuinely stand on. Anything else the player
// happens to reveal is a bonus rather than a requirement.
{
const stand = new Uint8Array(MAP_W * MAP_H);
for (let i = 0; i < stand.length; i++) if (reach[i] && !blocked[i]) stand[i] = 1;
const countable = new Uint8Array(MAP_W * MAP_H);
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
if (!stand[y * MAP_W + x]) continue;
for (const [dx, dy] of offs) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
countable[ny * MAP_W + nx] = 1;
}
}
explorable = countable;
G.explorableTotal = countable.reduce((a, b) => a + b, 0);
}
// ---- wildlife --------------------------------------------------------
let packs = 0, guard2 = 0;
while (packs < 15 && guard2++ < 4000) {
const x = 3 + Math.floor(rng() * (MAP_W - 6)), y = 3 + Math.floor(rng() * (MAP_H - 6));
const i = y * MAP_W + x;
if (!PASSABLE[terrain[i]] || blocked[i]) continue;
if (dist(x, y, startTile.x, startTile.y) < 13) continue;
const n = 2 + Math.floor(rng() * 3);
for (let k = 0; k < n; k++) {
const u = spawnUnit("beast", x * TS + TS / 2 + (rng() - .5) * 60, y * TS + TS / 2 + (rng() - .5) * 60, "neutral");
u.home = { x: u.x, y: u.y };
}
packs++;
}
}
function seedGrove(cx, cy, r) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const x = cx + dx, y = cy + dy;
if (x < 1 || y < 1 || x >= MAP_W - 1 || y >= MAP_H - 1) continue;
if (dx * dx + dy * dy > r * r) continue;
const i = y * MAP_W + x;
if (terrain[i] !== T_GRASS || blocked[i]) continue;
if (dist(x, y, startTile.x, startTile.y) < 5.5) continue;
if (rng() < 0.75) addResource("tree", x, y);
}
}
function floodReach(sx, sy) {
const seen = new Uint8Array(MAP_W * MAP_H);
const q = [sy * MAP_W + sx]; seen[sy * MAP_W + sx] = 1;
let head = 0;
while (head < q.length) {
const i = q[head++]; const x = i % MAP_W, y = (i / MAP_W) | 0;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const ni = ny * MAP_W + nx;
if (seen[ni] || !PASSABLE[terrain[ni]]) continue;
seen[ni] = 1; q.push(ni);
}
}
return seen;
}
/* -------------------------------------------------------------- resources */
function addResource(type, tx, ty, w = 1, h = 1) {
const r = {
id: G.nextId++, type, tx, ty, w, h,
amount: type === "gold" ? GOLD_PER_MINE : WOOD_PER_TREE,
max: type === "gold" ? GOLD_PER_MINE : WOOD_PER_TREE,
variant: Math.floor(rng() * 4), dead: false,
// purely cosmetic jitter so forests don't line up on the tile grid
jx: type === "tree" ? (rng() - .5) * 15 : 0,
jy: type === "tree" ? (rng() - .5) * 11 : 0,
x: (tx + w / 2) * TS, y: (ty + h / 2) * TS
};
G.resources.push(r);
const idx = G.resources.length - 1;
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
const i = (ty + dy) * MAP_W + (tx + dx);
resAt[i] = idx; blocked[i] = 1;
}
return r;
}
function removeResourceIndex(idx) {
const r = G.resources[idx];
if (!r || r.dead) return;
r.dead = true;
for (let dy = 0; dy < r.h; dy++) for (let dx = 0; dx < r.w; dx++) {
const i = (r.ty + dy) * MAP_W + (r.tx + dx);
if (resAt[i] === idx) { resAt[i] = -1; blocked[i] = 0; }
}
}
function resourceAtTile(tx, ty) {
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return null;
const idx = resAt[ty * MAP_W + tx];
if (idx < 0) return null;
const r = G.resources[idx];
return (r && !r.dead) ? r : null;
}
/* ------------------------------------------------------------- pathfinding */
const N = MAP_W * MAP_H;
const pfG = new Float32Array(N), pfF = new Float32Array(N);
const pfFrom = new Int32Array(N), pfMark = new Int32Array(N), pfClosed = new Int32Array(N);
let pfStamp = 0;
/* a node may be re-pushed once per incoming direction, so leave room for it */
const heapArr = new Int32Array(N * 8); let heapLen = 0;
function hPush(id) {
if (heapLen >= heapArr.length) return;
let i = heapLen++; heapArr[i] = id;
while (i > 0) {
const p = (i - 1) >> 1;
if (pfF[heapArr[p]] <= pfF[heapArr[i]]) break;
const t = heapArr[p]; heapArr[p] = heapArr[i]; heapArr[i] = t; i = p;
}
}
function hPop() {
const top = heapArr[0]; heapArr[0] = heapArr[--heapLen];
let i = 0;
for (;;) {
const l = i * 2 + 1, r = l + 1; let s = i;
if (l < heapLen && pfF[heapArr[l]] < pfF[heapArr[s]]) s = l;
if (r < heapLen && pfF[heapArr[r]] < pfF[heapArr[s]]) s = r;
if (s === i) break;
const t = heapArr[s]; heapArr[s] = heapArr[i]; heapArr[i] = t; i = s;
}
return top;
}
const walkable = i => PASSABLE[terrain[i]] && !blocked[i];
function findPath(sx, sy, gx, gy, limit = 24000) {
if (sx === gx && sy === gy) return [];
let goal = gy * MAP_W + gx;
if (!walkable(goal)) {
const alt = nearestWalkable(gx, gy, 8);
if (!alt) return null;
goal = alt.y * MAP_W + alt.x; gx = alt.x; gy = alt.y;
}
const start = sy * MAP_W + sx;
pfStamp++; heapLen = 0;
pfG[start] = 0; pfF[start] = octile(sx, sy, gx, gy);
pfMark[start] = pfStamp; pfFrom[start] = -1;
hPush(start);
let expanded = 0;
let bestNode = start, bestH = pfF[start];
while (heapLen > 0) {
const cur = hPop();
if (pfClosed[cur] === pfStamp) continue;
pfClosed[cur] = pfStamp;
if (cur === goal) return buildPath(cur);
if (++expanded > limit) break;
const cx = cur % MAP_W, cy = (cur / MAP_W) | 0;
for (let d = 0; d < 8; d++) {
const dx = DIRS[d][0], dy = DIRS[d][1];
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const ni = ny * MAP_W + nx;
if (!walkable(ni)) continue;
if (dx && dy) { // no corner cutting
if (!walkable(cy * MAP_W + nx) || !walkable(ny * MAP_W + cx)) continue;
}
const step = (dx && dy) ? 1.4142 : 1;
const ng = pfG[cur] + step;
if (pfMark[ni] === pfStamp && ng >= pfG[ni]) continue;
pfMark[ni] = pfStamp; pfG[ni] = ng; pfFrom[ni] = cur;
const h = octile(nx, ny, gx, gy);
pfF[ni] = ng + h * 1.05;
if (h < bestH) { bestH = h; bestNode = ni; }
hPush(ni);
}
}
if (bestNode !== start) return buildPath(bestNode);
return null;
}
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
function octile(ax, ay, bx, by) {
const dx = Math.abs(ax - bx), dy = Math.abs(ay - by);
return (dx + dy) + (1.4142 - 2) * Math.min(dx, dy);
}
function buildPath(end) {
const out = [];
let c = end;
while (c !== -1) { out.push({ x: c % MAP_W, y: (c / MAP_W) | 0 }); c = pfFrom[c]; }
out.reverse(); // out[0] is the tile the unit stands on
return smoothPath(out);
}
/* string-pulling: keep only the waypoints we cannot see past */
function smoothPath(p) {
if (p.length < 3) return p.slice(1);
const out = [];
let i = 0;
while (i < p.length - 1) {
let j = p.length - 1;
for (; j > i + 1; j--) if (lineClear(p[i].x, p[i].y, p[j].x, p[j].y)) break;
out.push(p[j]); i = j;
}
return out;
}
function lineClear(x0, y0, x1, y1) {
const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
let err = dx - dy, x = x0, y = y0;
for (let guard = 0; guard < 400; guard++) {
if (!walkable(y * MAP_W + x)) return false;
if (x === x1 && y === y1) return true;
const e2 = err * 2;
if (e2 > -dy) { err -= dy; x += sx; }
if (e2 < dx) { err += dx; y += sy; }
if (!walkable(y * MAP_W + x)) return false;
}
return false;
}
function nearestWalkable(tx, ty, maxR) {
for (let r = 0; r <= maxR; 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 x = tx + dx, y = ty + dy;
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue;
if (walkable(y * MAP_W + x)) return { x, y };
}
}
return null;
}
/* free tile adjacent to a rectangle, closest to (fx,fy) in tiles */
function adjacentTile(tx, ty, w, h, fx, fy) {
let best = null, bd = Infinity;
for (let x = tx - 1; x <= tx + w; x++) {
for (let y = ty - 1; y <= ty + h; y++) {
const inside = x >= tx && x < tx + w && y >= ty && y < ty + h;
if (inside) continue;
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue;
if (!walkable(y * MAP_W + x)) continue;
const d = dist2(x, y, fx, fy);
if (d < bd) { bd = d; best = { x, y }; }
}
}
return best;
}
/* ------------------------------------------------------------- entity ctor */
function spawnUnit(type, x, y, team = "player") {
const def = type === "beast" ? BEAST_DEF : UNIT_DEFS[type];
const u = {
id: G.nextId++, kind: "unit", type, team, def,
x, y, hp: def.hp, maxHp: def.hp, facing: rng ? rng() * TAU : 0,
path: null, pi: 0, tx: x, ty: y,
state: "idle", order: null, target: null, resource: null,
carrying: 0, carryType: null, harvestT: 0, cd: 0, anim: rng ? rng() * 10 : 0,
vx: 0, vy: 0, selected: false, home: null, attackMove: false, repathCd: 0
};
G.units.push(u);
return u;
}
const BEAST_DEF = {
name: "Wild Beast", hp: 85, speed: 60, sight: 6, dmg: 8, range: 22, cd: 1.1,
supply: 0, bounty: 25, desc: "A territorial creature of the wilds."
};
function placeBuilding(type, tx, ty, complete = false) {
const def = BUILD_DEFS[type];
const b = {
id: G.nextId++, kind: "building", type, def, tx, ty, w: def.w, h: def.h,
x: (tx + def.w / 2) * TS, y: (ty + def.h / 2) * TS,
hp: complete ? def.hp : Math.round(def.hp * 0.12), maxHp: def.hp,
done: complete, progress: complete ? 1 : 0, builders: 0,
queue: [], trainT: 0, rally: null, selected: false, cd: 0, flash: 0,
team: "player"
};
G.buildings.push(b);
for (let dy = 0; dy < b.h; dy++) for (let dx = 0; dx < b.w; dx++)
blocked[(ty + dy) * MAP_W + (tx + dx)] = 1;
recalcSupply();
return b;
}
function destroyBuilding(b) {
b.dead = true;
for (let dy = 0; dy < b.h; dy++) for (let dx = 0; dx < b.w; dx++)
blocked[(b.ty + dy) * MAP_W + (b.tx + dx)] = 0;
for (const u of G.units) if (u.target === b) { u.target = null; u.state = "idle"; }
recalcSupply();
}
function recalcSupply() {
let cap = 0;
for (const b of G.buildings) if (!b.dead && b.done && b.def.supply) cap += b.def.supply;
G.supplyCap = Math.min(cap, 120);
let used = 0;
for (const u of G.units) if (u.team === "player") used += u.def.supply || 0;
for (const b of G.buildings) if (!b.dead) for (const q of b.queue) used += UNIT_DEFS[q].supply;
G.supplyUsed = used;
}
function canPlaceAt(type, tx, ty) {
const def = BUILD_DEFS[type];
if (tx < 0 || ty < 0 || tx + def.w > MAP_W || ty + def.h > MAP_H) return false;
for (let dy = 0; dy < def.h; dy++) for (let dx = 0; dx < def.w; dx++) {
const i = (ty + dy) * MAP_W + (tx + dx);
if (!PASSABLE[terrain[i]] || blocked[i] || !explored[i]) return false;
}
// don't wall yourself in on top of units? units can walk out, allow it.
return true;
}
function hasBuilding(type) {
return G.buildings.some(b => !b.dead && b.done && b.type === type);
}
/* ------------------------------------------------------------------- costs */
function canAfford(def) { return G.gold >= (def.gold || 0) && G.wood >= (def.wood || 0); }
function pay(def) { G.gold -= def.gold || 0; G.wood -= def.wood || 0; }
function refund(def) { G.gold += def.gold || 0; G.wood += def.wood || 0; }
/* ------------------------------------------------------------------- fog */
const sightOffsets = {};
function offsetsFor(r) {
if (sightOffsets[r]) return sightOffsets[r];
const a = [];
const rr = r * r + r * 0.6;
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++)
if (dx * dx + dy * dy <= rr) a.push(dy * MAP_W + dx);
sightOffsets[r] = a;
return a;
}
let fogTimer = 0;
function updateFog() {
visible.fill(0);
const stamp = (px, py, r) => {
const tx = (px / TS) | 0, ty = (py / TS) | 0;
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return;
const base = ty * MAP_W + tx;
const offs = offsetsFor(r);
for (let k = 0; k < offs.length; k++) {
const i = base + offs[k];
if (i < 0 || i >= N) continue;
// guard against horizontal wrap
const ox = (i % MAP_W) - tx;
if (ox > r || ox < -r) continue;
visible[i] = 1;
if (!explored[i]) { explored[i] = 1; if (explorable[i]) G.exploredCount++; }
}
};
for (const u of G.units) if (u.team === "player" && !u.dead) stamp(u.x, u.y, u.def.sight);
for (const b of G.buildings) if (!b.dead) stamp(b.x, b.y, b.done ? b.def.sight : 4);
if (!G.won && G.exploredCount >= G.explorableTotal) {
G.won = true;
const el = document.getElementById("banner");
el.style.opacity = 1;
setTimeout(() => { el.style.opacity = 0; }, 7000);
}
if (!G.lost && !G.buildings.some(b => !b.dead) && !G.units.some(u => !u.dead && u.team === "player")) {
G.lost = true;
document.getElementById("banner2").style.opacity = 1;
}
}
const tileVisible = (px, py) => {
const tx = (px / TS) | 0, ty = (py / TS) | 0;
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false;
return visible[ty * MAP_W + tx] === 1;
};
const tileExplored = (px, py) => {
const tx = (px / TS) | 0, ty = (py / TS) | 0;
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false;
return explored[ty * MAP_W + tx] === 1;
};
/* ======================================================================== */
/* SPRITES */
/* ======================================================================== */
function mkCanvas(w, h) {
const c = document.createElement("canvas");
c.width = w; c.height = h;
return c;
}
function shade(ctx, x, y, rx, ry, a = .3) {
ctx.fillStyle = `rgba(0,0,0,${a})`;
ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, TAU); ctx.fill();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/* ---- unit sprites: drawn centred on (0,0), roughly 26px tall ------------ */
const TEAM = { body: "#3f7fd6", light: "#79b3f5", dark: "#22497f" };
const NEUTRAL = { body: "#8a5a3a", light: "#b78455", dark: "#4d2f1c" };
function drawUnitShape(ctx, type, t, facing, team) {
const col = team === "player" ? TEAM : NEUTRAL;
const bob = Math.sin(t * 9) * 0.9;
const swing = Math.sin(t * 9) * 3;
const fx = Math.cos(facing), fy = Math.sin(facing);
shade(ctx, 0, 8, 8, 3.5, .32);
ctx.save();
ctx.translate(0, bob);
if (type === "beast") {
// four-legged creature
ctx.fillStyle = col.dark;
ctx.beginPath(); ctx.ellipse(-2, 4, 3, 2.4, 0, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.ellipse(5, 4, 3, 2.4, 0, 0, TAU); ctx.fill();
ctx.fillStyle = col.body;
ctx.beginPath(); ctx.ellipse(0, 0, 9, 6, 0, 0, TAU); ctx.fill();
ctx.fillStyle = col.light;
ctx.beginPath(); ctx.ellipse(-1, -1.5, 7, 3.6, 0, 0, TAU); ctx.fill();
ctx.fillStyle = col.body;
ctx.beginPath(); ctx.ellipse(fx * 8, fy * 6 - 1, 4.6, 4, 0, 0, TAU); ctx.fill();
ctx.fillStyle = "#ffcf6a";
ctx.beginPath(); ctx.arc(fx * 9.5 - fy * 1.8, fy * 7 + fx * 1.8 - 1.5, 1, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(fx * 9.5 + fy * 1.8, fy * 7 - fx * 1.8 - 1.5, 1, 0, TAU); ctx.fill();
// tail
ctx.strokeStyle = col.dark; ctx.lineWidth = 2; ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(-fx * 8, -fy * 6);
ctx.lineTo(-fx * 14 + swing * .4, -fy * 10 + swing * .4); ctx.stroke();
ctx.restore();
return;
}
// legs
ctx.strokeStyle = "#33302c"; ctx.lineWidth = 2.6; ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(-2.4, 3); ctx.lineTo(-2.4 + swing * .35, 8); ctx.stroke();
ctx.beginPath(); ctx.moveTo(2.4, 3); ctx.lineTo(2.4 - swing * .35, 8); ctx.stroke();
if (type === "scout") {
// horse under the rider
ctx.fillStyle = "#6b5138";
ctx.beginPath(); ctx.ellipse(0, 3, 9.5, 5, 0, 0, TAU); ctx.fill();
ctx.fillStyle = "#7d603f";
ctx.beginPath(); ctx.ellipse(fx * 7, fy * 5 + 1, 4, 3.4, 0, 0, TAU); ctx.fill();
}
// torso
const ty = type === "scout" ? -4 : 0;
ctx.fillStyle = col.body;
roundRect(ctx, -4.6, -5 + ty, 9.2, 10, 3.4); ctx.fill();
ctx.fillStyle = col.light;
roundRect(ctx, -4.6, -5 + ty, 9.2, 4.4, 3); ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,.45)"; ctx.lineWidth = 1;
roundRect(ctx, -4.6, -5 + ty, 9.2, 10, 3.4); ctx.stroke();
// head
ctx.fillStyle = "#e8c39a";
ctx.beginPath(); ctx.arc(0, -8.5 + ty, 3.6, 0, TAU); ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,.35)"; ctx.stroke();
if (type === "worker") {
ctx.fillStyle = "#b3721f"; // straw hat
ctx.beginPath(); ctx.ellipse(0, -10.5 + ty, 5.6, 2.4, 0, 0, TAU); ctx.fill();
ctx.fillStyle = "#d18f2e";
ctx.beginPath(); ctx.arc(0, -11.4 + ty, 2.8, Math.PI, TAU); ctx.fill();
// pickaxe
ctx.save(); ctx.translate(fx * 5.5, fy * 4 - 3 + ty); ctx.rotate(facing + swing * .05);
ctx.strokeStyle = "#6b4a2a"; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo(-3, 3); ctx.lineTo(3, -4); ctx.stroke();
ctx.strokeStyle = "#c9d3e3"; ctx.lineWidth = 2.2;
ctx.beginPath(); ctx.arc(3.2, -4.6, 2.6, -0.4, 2.4); ctx.stroke();
ctx.restore();
} else if (type === "soldier") {
ctx.fillStyle = "#b9c4d8"; // helm
ctx.beginPath(); ctx.arc(0, -9.2 + ty, 3.9, Math.PI * 1.05, TAU * 1.02); ctx.fill();
ctx.fillStyle = "#8f9cb5";
ctx.fillRect(-4, -9.4 + ty, 8, 1.6);
// sword
ctx.save(); ctx.translate(fx * 6, fy * 4 - 2 + ty); ctx.rotate(facing - 0.6 + swing * .04);
ctx.fillStyle = "#dce4f2"; ctx.fillRect(-0.9, -9, 1.8, 10);
ctx.fillStyle = "#8b6a3a"; ctx.fillRect(-2.6, 0.4, 5.2, 1.6);
ctx.restore();
// shield
ctx.save(); ctx.translate(-fx * 5.5, -fy * 4 + ty);
ctx.fillStyle = col.dark; roundRect(ctx, -3, -4, 6, 8, 2); ctx.fill();
ctx.fillStyle = "#e4c56a"; ctx.fillRect(-0.8, -3, 1.6, 6);
ctx.restore();
} else if (type === "archer") {
ctx.fillStyle = "#4a7c3f"; // hood
ctx.beginPath(); ctx.arc(0, -9 + ty, 4, Math.PI, TAU); ctx.fill();
ctx.beginPath(); ctx.moveTo(-4, -9 + ty); ctx.lineTo(-6.5, -4.5 + ty); ctx.lineTo(-1, -8 + ty);
ctx.closePath(); ctx.fill();
// bow
ctx.save(); ctx.translate(fx * 6, fy * 5 - 2 + ty); ctx.rotate(facing);
ctx.strokeStyle = "#8a5a2c"; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(0, 0, 5.4, -1.25, 1.25); ctx.stroke();
ctx.strokeStyle = "rgba(240,240,240,.75)"; ctx.lineWidth = .7;
ctx.beginPath(); ctx.moveTo(1.7, -5.1); ctx.lineTo(1.7, 5.1); ctx.stroke();
ctx.restore();
} else if (type === "scout") {
ctx.fillStyle = "#c9563f"; // cape
ctx.beginPath();
ctx.moveTo(-fx * 4, -fy * 3 - 4 + ty);
ctx.lineTo(-fx * 11 - fy * 4, -fy * 8 + fx * 4 + 2 + ty);
ctx.lineTo(-fx * 11 + fy * 4, -fy * 8 - fx * 4 + 2 + ty);
ctx.closePath(); ctx.fill();
ctx.fillStyle = "#3d4a63";
ctx.beginPath(); ctx.ellipse(0, -10.6 + ty, 5, 2, 0, 0, TAU); ctx.fill();
}
ctx.restore();
}
/* ---- baked unit sprites -------------------------------------------------
Vector-drawing every unit every frame is the single most expensive thing on
screen, so each (type, facing, animation frame) is rendered once and reused. */
const SPR = 48, SPR_OX = 24, SPR_OY = 30, DIR_STEPS = 16, ANIM_FRAMES = 4;
const unitSpriteCache = new Map();
function unitSprite(type, team, d, f) {
const key = type + "|" + team + "|" + d + "|" + f;
let c = unitSpriteCache.get(key);
if (c) return c;
c = mkCanvas(SPR, SPR);
const g = c.getContext("2d");
g.translate(SPR_OX, SPR_OY);
drawUnitShape(g, type, (f / ANIM_FRAMES) * (TAU / 9), d / DIR_STEPS * TAU, team);
unitSpriteCache.set(key, c);
return c;
}
function drawUnitCached(ctx, u) {
const d = ((Math.round(u.facing / TAU * DIR_STEPS) % DIR_STEPS) + DIR_STEPS) % DIR_STEPS;
const f = Math.floor(u.anim * 9 / TAU * ANIM_FRAMES) % ANIM_FRAMES;
ctx.drawImage(unitSprite(u.type, u.team, d, f), u.x - SPR_OX, u.y - SPR_OY);
}
/* ---- resource sprites -------------------------------------------------- */
const treeSprites = [], goldSprite = mkCanvas(TS * 2 + 16, TS * 2 + 20);
function buildResourceSprites() {
const r2 = mulberry32(9911);
for (let v = 0; v < 4; v++) {
const c = mkCanvas(TS + 16, TS + 22), g = c.getContext("2d");
g.translate((TS + 16) / 2, TS + 12);
shade(g, 0, 0, 11, 5, .3);
const trunk = "#5a3f24";
g.fillStyle = trunk;
g.fillRect(-2.4, -12, 4.8, 12);
g.fillStyle = "#4a3320"; g.fillRect(-2.4, -12, 1.6, 12);
const layers = [
{ y: -14, r: 12, c: "#2f5c2c" },
{ y: -19, r: 10, c: "#3a7136" },
{ y: -24, r: 7.5, c: "#4b8a41" }
];
for (const L of layers) {
g.fillStyle = L.c;
g.beginPath();
const pts = 9;
for (let i = 0; i <= pts; i++) {
const a = (i / pts) * TAU;
const rr = L.r * (0.82 + 0.28 * r2());
const px = Math.cos(a) * rr, py = L.y + Math.sin(a) * rr * 0.62;
i ? g.lineTo(px, py) : g.moveTo(px, py);
}
g.closePath(); g.fill();
}
g.fillStyle = "rgba(180,235,150,.30)";
g.beginPath(); g.ellipse(-3, -25, 4.5, 3, -0.4, 0, TAU); g.fill();
treeSprites.push(c);
}
// gold mine (2x2)
const g = goldSprite.getContext("2d");
g.translate(goldSprite.width / 2, goldSprite.height - 14);
shade(g, 0, 2, 28, 11, .32);
g.fillStyle = "#6a6f7a";
g.beginPath();
g.moveTo(-30, 4); g.lineTo(-22, -18); g.lineTo(-6, -26); g.lineTo(12, -24);
g.lineTo(28, -12); g.lineTo(30, 4); g.closePath(); g.fill();
g.fillStyle = "#7d838f";
g.beginPath();
g.moveTo(-22, -4); g.lineTo(-15, -20); g.lineTo(-2, -25); g.lineTo(6, -18);
g.lineTo(2, -2); g.closePath(); g.fill();
g.fillStyle = "#3a3f49";
g.beginPath(); g.ellipse(4, -4, 10, 8, 0, 0, TAU); g.fill();
const crystals = [[-16, -6, 7], [-8, -12, 9], [16, -8, 8], [22, -2, 6], [8, -18, 6]];
for (const [cx, cy, s] of crystals) {
g.fillStyle = "#f5c542";
g.beginPath(); g.moveTo(cx, cy - s); g.lineTo(cx + s * .55, cy); g.lineTo(cx, cy + s * .45);
g.lineTo(cx - s * .55, cy); g.closePath(); g.fill();
g.fillStyle = "#ffe9a0";
g.beginPath(); g.moveTo(cx, cy - s); g.lineTo(cx + s * .55, cy); g.lineTo(cx, cy); g.closePath(); g.fill();
g.strokeStyle = "rgba(90,60,0,.5)"; g.lineWidth = 1; g.stroke();
}
}
/* ---- building drawing --------------------------------------------------
Everything is drawn in a fake 3/4 view: a ground footprint, a body that
rises above it, and a roof that overhangs. Local origin is the centre of
the footprint. */
function crenellations(ctx, x, y, w, hgt, n, fill) {
ctx.fillStyle = fill;
const bw = w / (n * 2 - 1);
for (let i = 0; i < n; i++) ctx.fillRect(x + i * bw * 2, y, bw, hgt);
}
function stoneCourses(ctx, x, y, w, hgt, rows) {
ctx.save();
ctx.beginPath(); ctx.rect(x, y, w, hgt); ctx.clip();
ctx.strokeStyle = "rgba(56,58,68,.30)"; ctx.lineWidth = 1;
for (let r = 1; r < rows; r++) {
const yy = Math.round(y + hgt * r / rows) + .5;
ctx.beginPath(); ctx.moveTo(x, yy); ctx.lineTo(x + w, yy); ctx.stroke();
const off = (r % 2) ? 0 : w / 8;
for (let c = 0; c <= 4; c++) {
const xx = Math.round(x + off + c * w / 4) + .5;
if (xx <= x || xx >= x + w) continue;
ctx.beginPath(); ctx.moveTo(xx, yy); ctx.lineTo(xx, yy - hgt / rows); ctx.stroke();
}
}
ctx.restore();
}
function gableRoof(ctx, cx, apexY, left, right, eaveY, dark, light) {
ctx.fillStyle = dark;
ctx.beginPath();
ctx.moveTo(cx, apexY); ctx.lineTo(right, eaveY); ctx.lineTo(left, eaveY);
ctx.closePath(); ctx.fill();
ctx.fillStyle = light;
ctx.beginPath();
ctx.moveTo(cx, apexY); ctx.lineTo(cx, eaveY); ctx.lineTo(left, eaveY);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,.28)"; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(cx, apexY); ctx.lineTo(right, eaveY); ctx.lineTo(left, eaveY);
ctx.closePath(); ctx.stroke();
}
function drawBuildingShape(ctx, type, w, h, progress, t) {
const W = w * TS, H = h * TS;
const px = -W / 2, py = -H / 2;
const groundY = py + H * .96;
// ground shadow
ctx.fillStyle = "rgba(0,0,0,.32)";
ctx.beginPath();
ctx.ellipse(0, groundY - H * .10, W * .46, H * .20, 0, 0, TAU); ctx.fill();
if (progress < 1) {
// foundation + scaffolding
ctx.fillStyle = "#5c5344";
roundRect(ctx, px + 4, py + H * .34, W - 8, H * .58, 4); ctx.fill();
ctx.fillStyle = "rgba(120,190,255,.16)";
const fh = H * .58 * progress;
ctx.fillRect(px + 4, py + H * .92 - fh, W - 8, fh);
ctx.strokeStyle = "#93794b"; ctx.lineWidth = 2.5; ctx.lineCap = "round";
for (let i = 0; i <= w * 2; i++) {
const x = px + 5 + (W - 10) * i / (w * 2);
ctx.beginPath(); ctx.moveTo(x, py + H * .28); ctx.lineTo(x, py + H * .94); ctx.stroke();
}
for (let i = 0; i < 2; i++) {
const y = py + H * (.44 + i * .28);
ctx.beginPath(); ctx.moveTo(px + 3, y); ctx.lineTo(px + W - 3, y); ctx.stroke();
}
ctx.strokeStyle = "rgba(255,255,255,.18)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(px + 5, py + H * .94); ctx.lineTo(px + W - 5, py + H * .30); ctx.stroke();
return;
}
if (type === "hall") {
const bodyTop = py + H * .30, bodyBot = py + H * .92;
// corner turrets (back pair drawn first)
for (const sx of [-1, 1]) {
const cx = sx * (W * .40);
ctx.fillStyle = "#7e808b";
roundRect(ctx, cx - 9, bodyTop - H * .10, 18, bodyBot - bodyTop + H * .06, 3); ctx.fill();
ctx.fillStyle = "#94969f";
roundRect(ctx, cx - 9, bodyTop - H * .10, 9, bodyBot - bodyTop + H * .06, 3); ctx.fill();
crenellations(ctx, cx - 10, bodyTop - H * .16, 20, 8, 3, "#8a8c96");
ctx.fillStyle = "#2f3038";
ctx.fillRect(cx - 2.5, bodyTop + 6, 5, 9);
}
// main body
ctx.fillStyle = "#8d8f99";
roundRect(ctx, px + 10, bodyTop, W - 20, bodyBot - bodyTop, 3); ctx.fill();
ctx.fillStyle = "#a2a4ad";
roundRect(ctx, px + 10, bodyTop, (W - 20) * .42, bodyBot - bodyTop, 3); ctx.fill();
stoneCourses(ctx, px + 10, bodyTop, W - 20, bodyBot - bodyTop, 5);
// big roof
gableRoof(ctx, 0, py + H * .02, px + 4, px + W - 4, bodyTop + 3, "#8e4038", "#b25a4c");
ctx.strokeStyle = "rgba(255,220,200,.22)"; ctx.lineWidth = 1;
for (let i = 1; i < 5; i++) {
const f = i / 5;
ctx.beginPath();
ctx.moveTo(lerp(0, px + 4, f), lerp(py + H * .02, bodyTop + 3, f));
ctx.lineTo(lerp(0, px + W - 4, f), lerp(py + H * .02, bodyTop + 3, f));
ctx.stroke();
}
// door and steps
ctx.fillStyle = "#c8c6bd";
roundRect(ctx, -14, bodyBot - 6, 28, 8, 2); ctx.fill();
ctx.fillStyle = "#3f2c19";
ctx.beginPath();
ctx.moveTo(-9, bodyBot - 4); ctx.lineTo(-9, bodyBot - 20);
ctx.arc(0, bodyBot - 20, 9, Math.PI, 0); ctx.lineTo(9, bodyBot - 4);
ctx.closePath(); ctx.fill();
ctx.fillStyle = "#5c4327";
ctx.fillRect(-1, bodyBot - 22, 2, 18);
// windows
ctx.fillStyle = "#ffdf9a";
for (const wx of [-W * .22, W * .22]) ctx.fillRect(wx - 3, bodyTop + H * .22, 6, 9);
// banner on the ridge
ctx.strokeStyle = "#6a5a3a"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, py + H * .02); ctx.lineTo(0, py - H * .16); ctx.stroke();
ctx.fillStyle = TEAM.body;
ctx.beginPath();
ctx.moveTo(0, py - H * .16); ctx.lineTo(W * .20, py - H * .11);
ctx.lineTo(0, py - H * .06); ctx.closePath(); ctx.fill();
} else if (type === "barracks") {
const bodyTop = py + H * .34, bodyBot = py + H * .92;
ctx.fillStyle = "#7f8189";
roundRect(ctx, px + 5, bodyTop, W - 10, bodyBot - bodyTop, 3); ctx.fill();
ctx.fillStyle = "#93959d";
roundRect(ctx, px + 5, bodyTop, (W - 10) * .40, bodyBot - bodyTop, 3); ctx.fill();
stoneCourses(ctx, px + 5, bodyTop, W - 10, bodyBot - bodyTop, 5);
// flat crenellated roof
ctx.fillStyle = "#6a6c76";
roundRect(ctx, px + 2, py + H * .22, W - 4, H * .14, 3); ctx.fill();
ctx.fillStyle = "#7c7e88";
roundRect(ctx, px + 2, py + H * .22, W - 4, H * .06, 3); ctx.fill();
crenellations(ctx, px + 3, py + H * .14, W - 6, 12, 5, "#767884");
// door
ctx.fillStyle = "#3f2c19";
roundRect(ctx, -11, bodyBot - 24, 22, 24, 3); ctx.fill();
ctx.fillStyle = "#5c4327"; ctx.fillRect(-1, bodyBot - 24, 2, 24);
// crossed swords sign
ctx.strokeStyle = "#e2e7f2"; ctx.lineWidth = 2.6; ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(-W * .30, bodyTop + 10); ctx.lineTo(-W * .30 + 16, bodyTop + 26);
ctx.moveTo(-W * .30 + 16, bodyTop + 10); ctx.lineTo(-W * .30, bodyTop + 26);
ctx.stroke();
ctx.strokeStyle = "#8b6a3a"; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(W * .22, bodyBot - 4); ctx.lineTo(W * .26, bodyTop + 8);
ctx.moveTo(W * .30, bodyBot - 4); ctx.lineTo(W * .26, bodyTop + 8); ctx.stroke();
ctx.fillStyle = "#c9d3e3";
ctx.beginPath();
ctx.moveTo(W * .26, bodyTop + 2); ctx.lineTo(W * .30, bodyTop + 12);
ctx.lineTo(W * .22, bodyTop + 12); ctx.closePath(); ctx.fill();
} else if (type === "farm") {
// fenced field on the lower half
ctx.fillStyle = "#7d6338";
roundRect(ctx, px + 3, py + H * .58, W - 6, H * .36, 3); ctx.fill();
ctx.strokeStyle = "#c9a94e"; ctx.lineWidth = 1.6;
for (let i = 0; i < 7; i++) {
const wx = px + 8 + i * (W - 16) / 6;
const s = Math.sin(t * 1.8 + i) * 1.6;
ctx.beginPath(); ctx.moveTo(wx, py + H * .92); ctx.lineTo(wx + s, py + H * .62); ctx.stroke();
}
ctx.strokeStyle = "#6b5230"; ctx.lineWidth = 1.6;
ctx.strokeRect(px + 3.5, py + H * .58, W - 7, H * .36);
// cottage
const bodyTop = py + H * .30, bodyBot = py + H * .62;
ctx.fillStyle = "#a08560";
roundRect(ctx, px + 5, bodyTop, W - 10, bodyBot - bodyTop, 2); ctx.fill();
ctx.fillStyle = "#c2a67c";
roundRect(ctx, px + 5, bodyTop, (W - 10) * .45, bodyBot - bodyTop, 2); ctx.fill();
ctx.strokeStyle = "rgba(90,66,40,.55)"; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(px + 6, bodyTop + 2); ctx.lineTo(px + W - 6, bodyBot - 2);
ctx.moveTo(px + W - 6, bodyTop + 2); ctx.lineTo(px + 6, bodyBot - 2); ctx.stroke();
gableRoof(ctx, 0, py + H * .04, px + 1, px + W - 1, bodyTop + 2, "#8a6a30", "#b08c42");
ctx.fillStyle = "#3f2c19";
roundRect(ctx, -6, bodyBot - 14, 12, 14, 2); ctx.fill();
} else if (type === "mill") {
const bodyTop = py + H * .32, bodyBot = py + H * .90;
ctx.fillStyle = "#7a5a34";
roundRect(ctx, px + 4, bodyTop, W - 8, bodyBot - bodyTop, 3); ctx.fill();
ctx.fillStyle = "#96703f";
for (let i = 0; i < 5; i++)
ctx.fillRect(px + 6, bodyTop + 3 + i * ((bodyBot - bodyTop - 4) / 5), W - 12, 4);
gableRoof(ctx, 0, py + H * .04, px + 1, px + W - 1, bodyTop + 2, "#4e5866", "#68748a");
// spinning saw
ctx.save();
ctx.translate(W * .22, py + H * .70);
ctx.rotate(t * 2.2);
ctx.fillStyle = "#8c96a8";
for (let i = 0; i < 9; i++) {
const a = i / 9 * TAU;
ctx.beginPath();
ctx.moveTo(Math.cos(a) * 8, Math.sin(a) * 8);
ctx.lineTo(Math.cos(a + .26) * 11, Math.sin(a + .26) * 11);
ctx.lineTo(Math.cos(a + .52) * 8, Math.sin(a + .52) * 8);
ctx.closePath(); ctx.fill();
}
ctx.fillStyle = "#c3ccdb"; ctx.beginPath(); ctx.arc(0, 0, 8, 0, TAU); ctx.fill();
ctx.fillStyle = "#5b6474"; ctx.beginPath(); ctx.arc(0, 0, 2.4, 0, TAU); ctx.fill();
ctx.restore();
// log pile
for (let i = 0; i < 3; i++) {
ctx.fillStyle = "#6b4a2a";
ctx.beginPath(); ctx.ellipse(px + 12 + i * 8, py + H * .86 - (i === 1 ? 5 : 0), 5.5, 3.4, 0, 0, TAU); ctx.fill();
ctx.fillStyle = "#a5773f";
ctx.beginPath(); ctx.ellipse(px + 12 + i * 8, py + H * .86 - (i === 1 ? 5 : 0), 3, 1.8, 0, 0, TAU); ctx.fill();
}
} else if (type === "tower") {
const bodyTop = py + H * .26, bodyBot = py + H * .92;
ctx.fillStyle = "#7c7e88";
roundRect(ctx, px + 11, bodyTop, W - 22, bodyBot - bodyTop, 3); ctx.fill();
ctx.fillStyle = "#91939d";
roundRect(ctx, px + 11, bodyTop, (W - 22) * .42, bodyBot - bodyTop, 3); ctx.fill();
stoneCourses(ctx, px + 11, bodyTop, W - 22, bodyBot - bodyTop, 6);
// overhanging battlement platform
ctx.fillStyle = "#63656f";
roundRect(ctx, px + 4, py + H * .14, W - 8, H * .14, 3); ctx.fill();
ctx.fillStyle = "#787a85";
roundRect(ctx, px + 4, py + H * .14, W - 8, H * .06, 3); ctx.fill();
crenellations(ctx, px + 5, py + H * .06, W - 10, 11, 4, "#8a8c96");
ctx.fillStyle = "#2a2b33";
ctx.fillRect(-3, py + H * .46, 6, 12);
ctx.fillStyle = "#c95a3a";
ctx.fillRect(px + 6, py + H * .04, 3, 8);
}
}
/* ---- icon cache for the command card ----------------------------------- */
const iconCache = {};
function iconFor(key) {
if (iconCache[key]) return iconCache[key];
const c = mkCanvas(64, 64), g = c.getContext("2d");
const grd = g.createLinearGradient(0, 0, 0, 64);
grd.addColorStop(0, "#41506e"); grd.addColorStop(1, "#1a2030");
g.fillStyle = grd; g.fillRect(0, 0, 64, 64);
if (UNIT_DEFS[key] || key === "beast") {
g.save(); g.translate(32, 40); g.scale(2.05, 2.05);
drawUnitShape(g, key, 0.35, -Math.PI / 2 + 0.35, "player");
g.restore();
} else if (BUILD_DEFS[key]) {
const d = BUILD_DEFS[key];
const s = 1.5 / Math.max(d.w, d.h) * 1.35;
g.save(); g.translate(32, 38); g.scale(s, s);
drawBuildingShape(g, key, d.w, d.h, 1, 0);
g.restore();
} else {
g.save(); g.translate(32, 32);
g.lineCap = "round"; g.lineJoin = "round";
if (key === "stop") {
g.fillStyle = "#e05a5a"; roundRect(g, -13, -13, 26, 26, 5); g.fill();
g.fillStyle = "#fff"; g.font = "bold 20px sans-serif"; g.textAlign = "center";
g.fillText("S", 0, 7);
} else if (key === "move") {
g.strokeStyle = "#8fe08f"; g.lineWidth = 4;
g.beginPath(); g.moveTo(-14, 0); g.lineTo(12, 0); g.stroke();
g.beginPath(); g.moveTo(4, -8); g.lineTo(14, 0); g.lineTo(4, 8); g.closePath();
g.fillStyle = "#8fe08f"; g.fill();
} else if (key === "attack") {
g.strokeStyle = "#ff8f6b"; g.lineWidth = 5;
g.beginPath(); g.moveTo(-11, -11); g.lineTo(11, 11); g.moveTo(11, -11); g.lineTo(-11, 11);
g.stroke();
} else if (key === "build") {
g.strokeStyle = "#e8c56a"; g.lineWidth = 4;
g.beginPath(); g.moveTo(-10, 8); g.lineTo(6, -8); g.stroke();
g.strokeStyle = "#c9d3e3"; g.lineWidth = 6;
g.beginPath(); g.arc(8, -9, 6, -0.5, 2.3); g.stroke();
} else if (key === "back") {
g.strokeStyle = "#c9d3e3"; g.lineWidth = 4;
g.beginPath(); g.moveTo(12, 0); g.lineTo(-12, 0); g.stroke();
g.beginPath(); g.moveTo(-4, -8); g.lineTo(-14, 0); g.lineTo(-4, 8); g.closePath();
g.fillStyle = "#c9d3e3"; g.fill();
} else if (key === "rally") {
g.strokeStyle = "#7fd0ff"; g.lineWidth = 3;
g.beginPath(); g.moveTo(-6, 13); g.lineTo(-6, -13); g.stroke();
g.fillStyle = "#7fd0ff";
g.beginPath(); g.moveTo(-6, -13); g.lineTo(12, -8); g.lineTo(-6, -3); g.closePath(); g.fill();
}
g.restore();
}
g.strokeStyle = "rgba(255,255,255,.10)"; g.lineWidth = 2; g.strokeRect(1, 1, 62, 62);
iconCache[key] = c;
return c;
}
/* ======================================================================== */
/* TERRAIN RENDER */
/* ======================================================================== */
let terrainCanvas, miniTerrain;
const TERRAIN_RGB = {
[T_DEEP]: [21, 52, 84], [T_WATER]: [34, 92, 142], [T_SAND]: [206, 188, 138],
[T_GRASS]: [78, 121, 57], [T_DIRT]: [138, 113, 71], [T_ROCK]: [110, 113, 122]
};
function bakeTerrain() {
terrainCanvas = mkCanvas(MAP_PX_W, MAP_PX_H);
const g = terrainCanvas.getContext("2d");
const r2 = mulberry32(4242);
// smooth, low-frequency tinting keeps the ground from looking like a chequerboard
const tintA = makeNoise(r2, 22), tintB = makeNoise(r2, 47);
const rgbStr = (c, f) => "rgb(" +
clamp(c[0] * (1 + f), 0, 255).toFixed(0) + "," +
clamp(c[1] * (1 + f), 0, 255).toFixed(0) + "," +
clamp(c[2] * (1 + f), 0, 255).toFixed(0) + ")";
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const t = terrain[y * MAP_W + x];
const px = x * TS, py = y * TS;
const u = x / MAP_W, v = y / MAP_H;
const n = (tintA(u, v) * 0.68 + tintB(u, v) * 0.32 - 0.5) * 2; // -1 .. 1
const amp = (t === T_GRASS || t === T_ROCK) ? 0.11 : 0.08;
g.fillStyle = rgbStr(TERRAIN_RGB[t], n * amp);
g.fillRect(px, py, TS, TS);
if (t === T_GRASS) {
for (let k = 0; k < 4; k++) {
g.fillStyle = r2() < .5 ? "rgba(126,178,94,.16)" : "rgba(52,88,42,.16)";
g.fillRect(px + r2() * TS, py + r2() * TS, 2 + r2() * 4, 2);
}
if (r2() < .09) {
g.fillStyle = r2() < .5 ? "rgba(232,226,150,.5)" : "rgba(206,206,216,.3)";
g.beginPath(); g.arc(px + r2() * TS, py + r2() * TS, 1.5, 0, TAU); g.fill();
}
} else if (t === T_DIRT) {
g.fillStyle = "rgba(60,45,25,.12)";
for (let k = 0; k < 4; k++)
g.fillRect(px + r2() * TS, py + r2() * TS, 2 + r2() * 4, 2);
} else if (t === T_SAND) {
g.fillStyle = "rgba(255,246,210,.2)";
for (let k = 0; k < 3; k++)
g.fillRect(px + r2() * TS, py + r2() * TS, 2 + r2() * 3, 2);
} else if (t === T_ROCK) {
for (let k = 0; k < 2; k++) {
const a = px + 5 + r2() * (TS - 14), b = py + 5 + r2() * (TS - 14);
const rr = 5 + r2() * 5;
g.fillStyle = "rgba(30,33,40,.30)";
g.beginPath(); g.ellipse(a, b + 2, rr, rr * .72, 0, 0, TAU); g.fill();
g.fillStyle = "rgba(150,155,168,.45)";
g.beginPath(); g.ellipse(a, b, rr * .92, rr * .66, 0, 0, TAU); g.fill();
g.fillStyle = "rgba(210,216,228,.28)";
g.beginPath(); g.ellipse(a - rr * .2, b - rr * .2, rr * .5, rr * .3, 0, 0, TAU); g.fill();
}
}
}
}
// organic blotches to break up the tile grid entirely
g.save();
for (let k = 0; k < 5200; k++) {
const px = r2() * MAP_PX_W, py = r2() * MAP_PX_H;
const t = terrain[clamp((py / TS) | 0, 0, MAP_H - 1) * MAP_W + clamp((px / TS) | 0, 0, MAP_W - 1)];
if (t === T_WATER || t === T_DEEP) continue;
const rr = 12 + r2() * 46;
const dark = r2() < .5;
g.fillStyle = t === T_ROCK
? (dark ? "rgba(40,44,54,.055)" : "rgba(190,196,210,.045)")
: (dark ? "rgba(30,52,22,.055)" : "rgba(150,196,110,.045)");
g.beginPath(); g.ellipse(px, py, rr, rr * (.6 + r2() * .5), r2() * 3, 0, TAU); g.fill();
}
g.restore();
// soften transitions: draw a lighter edge on water tiles adjacent to land
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const t = terrain[y * MAP_W + x];
const px = x * TS, py = y * TS;
const nb = (dx, dy) => {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return t;
return terrain[ny * MAP_W + nx];
};
if (t === T_WATER || t === T_DEEP) {
// ragged shoreline: let the land spill a little way into the water tile
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nt = nb(dx, dy);
if (nt !== T_SAND && nt !== T_GRASS && nt !== T_DIRT) continue;
g.fillStyle = rgbStr(TERRAIN_RGB[nt], (r2() - .5) * .1);
for (let k = 0; k < 5; k++) {
const along = r2() * TS, into = r2() * 8;
const cx2 = px + (dx ? (dx > 0 ? TS - into : into) : along);
const cy2 = py + (dy ? (dy > 0 ? TS - into : into) : along);
g.beginPath();
g.ellipse(cx2, cy2, 2 + r2() * 4, 2 + r2() * 3, r2() * 3, 0, TAU);
g.fill();
}
}
g.fillStyle = t === T_WATER ? "rgba(160,205,235,.28)" : "rgba(70,135,185,.24)";
if (nb(0, -1) >= T_SAND) g.fillRect(px, py, TS, 3);
if (nb(0, 1) >= T_SAND) g.fillRect(px, py + TS - 3, TS, 3);
if (nb(-1, 0) >= T_SAND) g.fillRect(px, py, 3, TS);
if (nb(1, 0) >= T_SAND) g.fillRect(px + TS - 3, py, 3, TS);
}
if (t === T_ROCK) {
// cliff rim: dark on the shaded sides, bright lip on top
g.fillStyle = "rgba(18,20,26,.34)";
if (nb(0, 1) !== T_ROCK) g.fillRect(px, py + TS - 6, TS, 6);
if (nb(1, 0) !== T_ROCK) g.fillRect(px + TS - 5, py, 5, TS);
g.fillStyle = "rgba(18,20,26,.20)";
if (nb(-1, 0) !== T_ROCK) g.fillRect(px, py, 4, TS);
if (nb(0, -1) !== T_ROCK) {
g.fillStyle = "rgba(226,232,244,.20)";
g.fillRect(px, py, TS, 4);
}
}
// ragged dithering along land-to-land seams hides the square tiles
// (rock keeps its hard edge — those are cliffs)
const isLand = q => q === T_GRASS || q === T_DIRT || q === T_SAND;
if (isLand(t)) {
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nt = nb(dx, dy);
if (!isLand(nt) || nt === t) continue;
// sand seams get a lighter touch — big pale dots read as noise
const soft = (t === T_SAND || nt === T_SAND);
const dots = soft ? 3 : 6, rad = soft ? 2.4 : 4;
g.fillStyle = rgbStr(TERRAIN_RGB[nt], (r2() - .5) * .12);
for (let k = 0; k < dots; k++) {
const along = r2() * TS, into = r2() * (soft ? 6 : 9);
const cx2 = px + (dx ? (dx > 0 ? TS - into : into) : along);
const cy2 = py + (dy ? (dy > 0 ? TS - into : into) : along);
g.beginPath();
g.ellipse(cx2, cy2, 1.6 + r2() * rad, 1.6 + r2() * rad * .8, r2() * 3, 0, TAU);
g.fill();
}
}
}
}
}
// minimap terrain
miniTerrain = mkCanvas(MAP_W, MAP_H);
const mg = miniTerrain.getContext("2d");
const img = mg.createImageData(MAP_W, MAP_H);
for (let i = 0; i < N; i++) {
const c = TERRAIN_RGB[terrain[i]];
img.data[i * 4] = c[0]; img.data[i * 4 + 1] = c[1];
img.data[i * 4 + 2] = c[2]; img.data[i * 4 + 3] = 255;
}
mg.putImageData(img, 0, 0);
miniFull = mkCanvas(MAP_W, MAP_H);
bakeMiniFull();
}
/* terrain + living resources, repainted only when something changes */
let miniFull;
function bakeMiniFull() {
const g = miniFull.getContext("2d");
g.clearRect(0, 0, MAP_W, MAP_H);
g.drawImage(miniTerrain, 0, 0);
for (const r of G.resources) {
if (r.dead) continue;
g.fillStyle = r.type === "gold" ? "#f5c542" : "#2c5c2c";
g.fillRect(r.tx, r.ty, r.w, r.h);
}
minimapDirty = false;
}
/* ======================================================================== */
/* GAME LOGIC */
/* ======================================================================== */
function nearestDropoff(x, y) {
let best = null, bd = Infinity;
for (const b of G.buildings) {
if (b.dead || !b.done || !b.def.dropoff) continue;
const d = dist2(x, y, b.x, b.y);
if (d < bd) { bd = d; best = b; }
}
return best;
}
function nearestResource(x, y, type, exclude) {
let best = null, bd = Infinity;
for (const r of G.resources) {
if (r.dead || r.type !== type || r === exclude) continue;
if (!explored[r.ty * MAP_W + r.tx]) continue;
const d = dist2(x, y, r.x, r.y);
if (d < bd) { bd = d; best = r; }
}
return best;
}
function issueMove(u, wx, wy, attackMove = false) {
u.order = "move"; u.state = "move"; u.attackMove = attackMove;
u.resource = null; u.target = null; u.buildTarget = null; u.resumeMove = null;
if (!setDestination(u, wx, wy)) { u.state = "idle"; u.attackMove = false; }
}
function setDestination(u, wx, wy) {
const sx = clamp((u.x / TS) | 0, 0, MAP_W - 1), sy = clamp((u.y / TS) | 0, 0, MAP_H - 1);
const gx = clamp((wx / TS) | 0, 0, MAP_W - 1), gy = clamp((wy / TS) | 0, 0, MAP_H - 1);
const p = findPath(sx, sy, gx, gy);
if (!p) { u.path = null; u.state = "idle"; return false; }
u.path = p; u.pi = 0;
u.finalX = wx; u.finalY = wy;
return true;
}
function moveToTile(u, tx, ty) {
return setDestination(u, tx * TS + TS / 2, ty * TS + TS / 2);
}
function issueHarvest(u, res) {
if (u.type !== "worker") { issueMove(u, res.x, res.y); return; }
u.order = "harvest"; u.resource = res; u.target = null; u.buildTarget = null;
if (u.carrying > 0 && u.carryType !== res.type) { u.carrying = 0; u.carryType = null; }
u.state = u.carrying >= u.def.carry ? "return" : "toResource";
routeWorker(u);
}
function issueAttack(u, target) {
u.order = "attack"; u.target = target; u.resource = null; u.buildTarget = null;
u.state = "attack"; u.attackMove = false;
}
function issueBuild(u, b) {
u.order = "build"; u.buildTarget = b; u.resource = null; u.target = null;
u.state = "toBuild";
const t = adjacentTile(b.tx, b.ty, b.w, b.h, (u.x / TS) | 0, (u.y / TS) | 0);
if (t) moveToTile(u, t.x, t.y); else u.path = null;
}
function stopUnit(u) {
u.order = null; u.state = "idle"; u.path = null; u.target = null;
u.resource = null; u.buildTarget = null; u.attackMove = false; u.resumeMove = null;
}
function routeWorker(u) {
if (u.state === "toResource") {
const r = u.resource;
if (!r || r.dead) { u.resource = null; u.state = "idle"; return; }
const t = adjacentTile(r.tx, r.ty, r.w, r.h, (u.x / TS) | 0, (u.y / TS) | 0);
if (t) moveToTile(u, t.x, t.y);
else u.path = null;
} else if (u.state === "return") {
const d = nearestDropoff(u.x, u.y);
if (!d) { u.state = "idle"; return; }
u.dropTarget = d;
const t = adjacentTile(d.tx, d.ty, d.w, d.h, (u.x / TS) | 0, (u.y / TS) | 0);
if (t) moveToTile(u, t.x, t.y);
else u.path = null;
}
}
function nearRect(u, tx, ty, w, h, pad = 6) {
const cx = clamp(u.x, tx * TS, (tx + w) * TS);
const cy = clamp(u.y, ty * TS, (ty + h) * TS);
return dist2(u.x, u.y, cx, cy) <= (TS * 0.75 + pad) * (TS * 0.75 + pad);
}
/* --------------------------------------------------------- unit update */
function updateUnit(u, dt) {
u.anim += dt;
if (u.cd > 0) u.cd -= dt;
if (u.repathCd > 0) u.repathCd -= dt;
if (u.team === "neutral") { updateBeast(u, dt); return; }
switch (u.state) {
case "move": {
if (!advance(u, dt)) {
u.state = "idle"; u.path = null; u.attackMove = false;
}
if (u.attackMove) acquireTarget(u);
break;
}
case "attack": {
const t = u.target;
if (!t || t.dead || (t.hp !== undefined && t.hp <= 0)) {
u.target = null;
if (u.resumeMove) { // carry on with the attack-move
const r = u.resumeMove; u.resumeMove = null;
issueMove(u, r.x, r.y, true);
} else u.state = "idle";
break;
}
const range = u.def.range + (t.kind === "building" ? Math.max(t.w, t.h) * TS * .5 : 8);
const d = dist(u.x, u.y, t.x, t.y);
if (d <= range) {
u.path = null;
u.facing = Math.atan2(t.y - u.y, t.x - u.x);
if (u.cd <= 0) {
u.cd = u.def.cd;
if (u.def.ranged) spawnProjectile(u, t);
else { damage(t, u.def.dmg, u); addEffect("slash", u.x + Math.cos(u.facing) * 14, u.y + Math.sin(u.facing) * 14, u.facing); }
}
} else {
if (u.repathCd <= 0 && (!u.path || dist2(u.finalX || 0, u.finalY || 0, t.x, t.y) > 900)) {
u.repathCd = 0.5;
setDestination(u, t.x, t.y);
}
if (!advance(u, dt)) {
if (d > u.def.sight * TS * 1.6) { // lost it — give up the chase
u.target = null;
if (u.resumeMove) { const r = u.resumeMove; u.resumeMove = null; issueMove(u, r.x, r.y, true); }
else u.state = "idle";
} else u.repathCd = 0;
}
}
break;
}
case "toResource": {
const r = u.resource;
if (!r || r.dead) {
const alt = r ? nearestResource(u.x, u.y, r.type, r) : null;
if (alt) { u.resource = alt; routeWorker(u); }
else { u.state = "idle"; noMoreResource(r && r.type); u.resource = null; }
break;
}
if (nearRect(u, r.tx, r.ty, r.w, r.h)) {
u.path = null; u.state = "harvest";
u.harvestT = r.type === "gold" ? 1.1 : 1.5;
u.facing = Math.atan2(r.y - u.y, r.x - u.x);
} else if (!advance(u, dt)) {
if (u.repathCd <= 0) { u.repathCd = 0.7; routeWorker(u); }
}
break;
}
case "harvest": {
const r = u.resource;
if (!r || r.dead) { u.state = "idle"; u.resource = null; break; }
u.harvestT -= dt;
if (u.harvestT <= 0) {
const take = Math.min(u.def.carry - u.carrying, r.amount);
r.amount -= take; u.carrying += take; u.carryType = r.type;
addEffect("chip", r.x + (Math.random() - .5) * 16, r.y - 8, 0, r.type);
if (r.amount <= 0) {
removeResourceIndex(G.resources.indexOf(r));
markMinimapDirty();
}
if (u.carrying >= u.def.carry || !r || r.dead) {
u.state = "return"; routeWorker(u);
} else {
u.harvestT = r.type === "gold" ? 1.1 : 1.5;
}
}
break;
}
case "return": {
const d = u.dropTarget;
if (!d || d.dead || !d.done) { u.dropTarget = null; routeWorker(u); if (!u.dropTarget) u.state = "idle"; break; }
if (nearRect(u, d.tx, d.ty, d.w, d.h)) {
if (u.carryType === "gold") G.gold += u.carrying; else G.wood += u.carrying;
addFloater(d.x, d.y - 20, "+" + u.carrying, u.carryType === "gold" ? "#ffd166" : "#c39b6a");
u.carrying = 0;
if (u.resource && !u.resource.dead) { u.state = "toResource"; routeWorker(u); }
else {
const want = u.carryType || "gold";
const alt = nearestResource(u.x, u.y, want);
if (alt) { u.resource = alt; u.state = "toResource"; routeWorker(u); }
else { u.state = "idle"; u.resource = null; noMoreResource(want); }
}
} else if (!advance(u, dt)) {
if (u.repathCd <= 0) { u.repathCd = 0.7; routeWorker(u); }
}
break;
}
case "toBuild": {
const b = u.buildTarget;
if (!b || b.dead || b.done) { u.buildTarget = null; u.state = "idle"; break; }
if (nearRect(u, b.tx, b.ty, b.w, b.h)) { u.path = null; u.state = "building"; }
else if (!advance(u, dt)) {
if (u.repathCd <= 0) {
u.repathCd = 0.8;
const t = adjacentTile(b.tx, b.ty, b.w, b.h, (u.x / TS) | 0, (u.y / TS) | 0);
if (t) moveToTile(u, t.x, t.y); else { u.state = "idle"; u.buildTarget = null; }
}
}
break;
}
case "building": {
const b = u.buildTarget;
if (!b || b.dead || b.done) {
const nb = b && b.done ? b : null;
u.buildTarget = null; u.state = "idle";
if (nb) { // after finishing, go back to work if there is anything nearby
const alt = nearestResource(u.x, u.y, "gold");
if (alt && dist(u.x, u.y, alt.x, alt.y) < TS * 12) issueHarvest(u, alt);
}
break;
}
if (!nearRect(u, b.tx, b.ty, b.w, b.h)) { u.state = "toBuild"; break; }
b.builders++;
u.facing = Math.atan2(b.y - u.y, b.x - u.x);
if (Math.random() < dt * 8) addEffect("chip", u.x + Math.cos(u.facing) * 12, u.y + Math.sin(u.facing) * 12 - 6, 0, "wood");
break;
}
default: { // idle
if (u.def.dmg && u.type !== "worker") acquireTarget(u);
break;
}
}
separate(u, dt);
}
function acquireTarget(u) {
if (u.target && !u.target.dead && u.target.hp > 0) return;
let best = null, bd = (u.def.sight * TS) * (u.def.sight * TS);
for (const e of G.units) {
if (e.dead || e.team === u.team) continue;
// wildlife minds its own business — only pick a fight on an explicit
// attack-move, or with a beast that is already attacking someone
if (!u.attackMove && e.team === "neutral" && !e.target) continue;
const d = dist2(u.x, u.y, e.x, e.y);
if (d < bd) { bd = d; best = e; }
}
if (best) {
const keepPath = u.state === "move" && u.attackMove;
u.target = best;
u.state = "attack";
if (keepPath) u.resumeMove = { x: u.finalX, y: u.finalY };
}
}
function updateBeast(u, dt) {
if (u.target && (u.target.dead || u.target.hp <= 0)) u.target = null;
if (u.target) {
const t = u.target;
const d = dist(u.x, u.y, t.x, t.y);
if (d > u.def.sight * TS * 2.2 || dist(u.x, u.y, u.home.x, u.home.y) > TS * 12) {
u.target = null; u.state = "wander"; u.path = null; return;
}
if (d <= u.def.range + 8) {
u.path = null;
u.facing = Math.atan2(t.y - u.y, t.x - u.x);
if (u.cd <= 0) {
u.cd = u.def.cd; damage(t, u.def.dmg, u);
addEffect("slash", u.x + Math.cos(u.facing) * 12, u.y + Math.sin(u.facing) * 12, u.facing);
}
} else {
if (u.repathCd <= 0) { u.repathCd = .6; setDestination(u, t.x, t.y); }
advance(u, dt);
}
return;
}
// lazy wander around home
u.wanderT = (u.wanderT || 0) - dt;
if (u.wanderT <= 0) {
u.wanderT = 3 + Math.random() * 5;
if (Math.random() < .6) {
const a = Math.random() * TAU, r = Math.random() * TS * 3.5;
setDestination(u, u.home.x + Math.cos(a) * r, u.home.y + Math.sin(a) * r);
} else u.path = null;
}
advance(u, dt);
separate(u, dt);
}
/* moves the unit along its path; returns false when there is nothing to do */
function advance(u, dt) {
if (!u.path || u.pi >= u.path.length) return false;
const wp = u.path[u.pi];
const tx = wp.x * TS + TS / 2, ty = wp.y * TS + TS / 2;
const dx = tx - u.x, dy = ty - u.y;
const d = Math.hypot(dx, dy);
const step = u.def.speed * dt;
if (d <= step + 1) {
u.x = tx; u.y = ty; u.pi++;
if (u.pi >= u.path.length) { u.path = null; return false; }
return true;
}
u.facing = Math.atan2(dy, dx);
u.x += dx / d * step; u.y += dy / d * step;
return true;
}
/* soft collision so units don't stack in a single point */
let hashGrid = new Map();
function rebuildHash() {
hashGrid.clear();
for (const u of G.units) {
if (u.dead) continue;
const k = ((u.x / 48) | 0) + "," + ((u.y / 48) | 0);
let a = hashGrid.get(k);
if (!a) { a = []; hashGrid.set(k, a); }
a.push(u);
}
}
function separate(u, dt) {
const gx = (u.x / 48) | 0, gy = (u.y / 48) | 0;
let px = 0, py = 0;
for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++) {
const a = hashGrid.get((gx + ox) + "," + (gy + oy));
if (!a) continue;
for (const o of a) {
if (o === u || o.dead) continue;
const dx = u.x - o.x, dy = u.y - o.y;
const d2 = dx * dx + dy * dy;
const R = 15;
if (d2 > R * R || d2 < 0.0001) continue;
const d = Math.sqrt(d2);
const f = (R - d) / R;
px += dx / d * f; py += dy / d * f;
}
}
if (px || py) {
const sp = 46 * dt;
let nx = u.x + px * sp, ny = u.y + py * sp;
if (walkableAtPx(nx, u.y)) u.x = nx;
if (walkableAtPx(u.x, ny)) u.y = ny;
}
// never end up inside terrain / buildings
if (!walkableAtPx(u.x, u.y)) {
const t = nearestWalkable((u.x / TS) | 0, (u.y / TS) | 0, 6);
if (t) {
const cx = t.x * TS + TS / 2, cy = t.y * TS + TS / 2;
const a = Math.atan2(cy - u.y, cx - u.x);
u.x += Math.cos(a) * 70 * dt; u.y += Math.sin(a) * 70 * dt;
}
}
u.x = clamp(u.x, 4, MAP_PX_W - 4); u.y = clamp(u.y, 4, MAP_PX_H - 4);
}
function walkableAtPx(px, py) {
const tx = (px / TS) | 0, ty = (py / TS) | 0;
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false;
return walkable(ty * MAP_W + tx);
}
/* ------------------------------------------------------------ combat bits */
function spawnProjectile(u, t) {
G.projectiles.push({
x: u.x, y: u.y - 8, tx: t.x, ty: t.y - 6, target: t,
speed: 340, dmg: u.def.dmg, src: u, life: 2
});
}
function damage(t, amount, src) {
if (!t || t.dead) return;
const armor = t.def && t.def.armor ? t.def.armor : 0;
t.hp -= Math.max(1, amount - armor);
t.flash = 0.15;
if (t.kind === "unit" && t.team === "neutral" && !t.target && src) t.target = src;
if (t.kind === "unit" && t.team === "player" && t.type !== "worker" && !t.target && src) t.target = src;
if (t.hp <= 0) {
if (t.kind === "unit") {
t.dead = true;
if (t.team === "neutral") {
G.gold += t.def.bounty || 0;
addFloater(t.x, t.y - 16, "+" + (t.def.bounty || 0), "#ffd166");
} else if (G.time - lastLossMsg > 6) {
lastLossMsg = G.time;
alertMsg(t.def.name + " lost!");
}
addEffect("puff", t.x, t.y, 0);
for (const o of G.units) if (o.target === t) { o.target = null; if (o.state === "attack") o.state = "idle"; }
recalcSupply();
} else {
destroyBuilding(t);
addEffect("puff", t.x, t.y, 0);
alertMsg(t.def.name + " destroyed!");
}
}
}
function addEffect(type, x, y, a, extra) {
G.effects.push({ type, x, y, a, life: type === "puff" ? .6 : .3, max: type === "puff" ? .6 : .3, extra });
}
function addFloater(x, y, text, color) {
G.effects.push({ type: "float", x, y, text, color, life: 1.3, max: 1.3 });
}
let lastLossMsg = -99;
const lastOutOf = { gold: -99, tree: -99 };
function noMoreResource(kind) {
if (!kind || G.time - lastOutOf[kind] < 25) return;
lastOutOf[kind] = G.time;
alertMsg(kind === "gold"
? "No gold mine within your explored lands — scout for more"
: "No forest within your explored lands — scout for more");
}
/* send an unemployed worker to the closest known resource */
function autoAssignWorker(u) {
const gold = nearestResource(u.x, u.y, "gold");
const tree = nearestResource(u.x, u.y, "tree");
let pick = gold;
if (!pick) pick = tree;
else if (tree && G.wood < G.gold * 0.4) pick = tree;
if (pick) issueHarvest(u, pick);
}
function alertMsg(text) {
const d = document.createElement("div");
d.className = "alert"; d.textContent = text;
const box = document.getElementById("alerts");
box.appendChild(d);
setTimeout(() => d.remove(), 3600);
}
/* -------------------------------------------------------- building update */
function updateBuilding(b, dt) {
if (b.flash > 0) b.flash -= dt;
if (!b.done) {
b.workers = b.builders; // stable copy for the UI / renderer
if (b.builders > 0) {
const rate = (1 / b.def.time) * (1 + (b.builders - 1) * 0.55);
b.progress += rate * dt;
b.hp = Math.min(b.maxHp, b.def.hp * (0.12 + 0.88 * b.progress));
if (b.progress >= 1) {
b.progress = 1; b.done = true; b.hp = b.maxHp;
recalcSupply(); markMinimapDirty();
addEffect("puff", b.x, b.y, 0);
alertMsg(b.def.name + " complete");
refreshCard();
}
}
b.builders = 0;
return;
}
// training
if (b.queue.length) {
const type = b.queue[0];
b.trainT += dt;
const need = UNIT_DEFS[type].time;
if (b.trainT >= need) {
// supply was reserved when the unit was queued, so it can always pop
b.queue.shift(); b.trainT = 0;
const spot = adjacentTile(b.tx, b.ty, b.w, b.h, b.tx + b.w / 2, b.ty + b.h + 1) ||
nearestWalkable((b.tx + b.w / 2) | 0, (b.ty + b.h) | 0, 8);
const u = spawnUnit(type, (spot ? spot.x : b.tx) * TS + TS / 2, (spot ? spot.y : b.ty + b.h) * TS + TS / 2);
recalcSupply();
if (b.rally) {
const r = resourceAtTile((b.rally.x / TS) | 0, (b.rally.y / TS) | 0);
if (r && type === "worker") issueHarvest(u, r);
else issueMove(u, b.rally.x, b.rally.y);
} else if (type === "worker") {
autoAssignWorker(u); // fresh peasants get straight to work
}
alertMsg(UNIT_DEFS[type].name + " ready");
refreshCard();
}
}
// tower attack
if (b.type === "tower") {
if (b.cd > 0) b.cd -= dt;
if (b.cd <= 0) {
let best = null, bd = b.def.range * b.def.range;
for (const e of G.units) {
if (e.dead || e.team === "player") continue;
const d = dist2(b.x, b.y, e.x, e.y);
if (d < bd) { bd = d; best = e; }
}
if (best) {
b.cd = b.def.cd;
G.projectiles.push({ x: b.x, y: b.y - 30, tx: best.x, ty: best.y, target: best, speed: 380, dmg: b.def.dmg, src: b, life: 2 });
}
}
}
}
/* ======================================================================== */
/* INPUT */
/* ======================================================================== */
const cv = document.getElementById("game");
const ctx = cv.getContext("2d");
const mm = document.getElementById("minimap");
const mmCtx = mm.getContext("2d");
let DPR = Math.min(window.devicePixelRatio || 1, 2);
let VW = 800, VH = 600;
function resize() {
const r = document.getElementById("viewport").getBoundingClientRect();
VW = Math.floor(r.width); VH = Math.floor(r.height);
DPR = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.floor(VW * DPR); cv.height = Math.floor(VH * DPR);
cv.style.width = VW + "px"; cv.style.height = VH + "px";
const mr = mm.getBoundingClientRect();
mm.width = Math.floor(mr.width * DPR); mm.height = Math.floor(mr.height * DPR);
clampCam();
}
window.addEventListener("resize", resize);
function clampCam() {
const w = VW / cam.zoom, h = VH / cam.zoom;
cam.x = clamp(cam.x, 0, Math.max(0, MAP_PX_W - w));
cam.y = clamp(cam.y, 0, Math.max(0, MAP_PX_H - h));
}
const screenToWorld = (sx, sy) => ({ x: cam.x + sx / cam.zoom, y: cam.y + sy / cam.zoom });
const worldToScreen = (wx, wy) => ({ x: (wx - cam.x) * cam.zoom, y: (wy - cam.y) * cam.zoom });
function centerOn(wx, wy) {
cam.x = wx - VW / (2 * cam.zoom); cam.y = wy - VH / (2 * cam.zoom); clampCam();
}
const mouse = { x: 0, y: 0, wx: 0, wy: 0, down: false, dragging: false, sx: 0, sy: 0, inside: false, mid: false };
const keys = {};
cv.addEventListener("contextmenu", e => e.preventDefault());
cv.addEventListener("mousemove", e => {
const r = cv.getBoundingClientRect();
mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; mouse.inside = true;
const w = screenToWorld(mouse.x, mouse.y);
mouse.wx = w.x; mouse.wy = w.y;
if (mouse.mid) {
cam.x -= e.movementX / cam.zoom; cam.y -= e.movementY / cam.zoom; clampCam();
}
if (mouse.down && !mouse.dragging) {
if (Math.abs(mouse.x - mouse.sx) > 5 || Math.abs(mouse.y - mouse.sy) > 5) mouse.dragging = true;
}
});
cv.addEventListener("mouseleave", () => { mouse.inside = false; });
cv.addEventListener("mousedown", e => {
const r = cv.getBoundingClientRect();
mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
const w = screenToWorld(mouse.x, mouse.y);
mouse.wx = w.x; mouse.wy = w.y;
if (e.button === 1) { mouse.mid = true; e.preventDefault(); return; }
if (e.button === 0) {
if (G.placing) { tryPlace(); return; }
if (G.attackMove) { commandAttackMove(w.x, w.y); G.attackMove = false; cv.style.cursor = "default"; return; }
mouse.down = true; mouse.dragging = false; mouse.sx = mouse.x; mouse.sy = mouse.y;
} else if (e.button === 2) {
if (G.placing) { cancelPlacing(); return; }
if (G.attackMove) { G.attackMove = false; cv.style.cursor = "default"; return; }
rightClick(w.x, w.y);
}
});
window.addEventListener("mouseup", e => {
if (e.button === 1) { mouse.mid = false; return; }
if (e.button !== 0 || !mouse.down) return;
mouse.down = false;
const r = cv.getBoundingClientRect();
const mx = e.clientX - r.left, my = e.clientY - r.top;
if (mouse.dragging) {
const a = screenToWorld(Math.min(mouse.sx, mx), Math.min(mouse.sy, my));
const b = screenToWorld(Math.max(mouse.sx, mx), Math.max(mouse.sy, my));
boxSelect(a.x, a.y, b.x, b.y, e.shiftKey);
} else {
clickSelect(mouse.wx, mouse.wy, e.shiftKey, e.detail >= 2);
}
mouse.dragging = false;
});
cv.addEventListener("wheel", e => {
e.preventDefault();
const before = screenToWorld(mouse.x, mouse.y);
cam.zoom = clamp(cam.zoom * (e.deltaY < 0 ? 1.12 : 0.89), cam.minZoom, cam.maxZoom);
const after = screenToWorld(mouse.x, mouse.y);
cam.x += before.x - after.x; cam.y += before.y - after.y;
clampCam();
}, { passive: false });
/* ---- selection --------------------------------------------------------- */
function clearSelection() {
for (const e of G.selection) e.selected = false;
G.selection = [];
}
function setSelection(list) {
clearSelection();
G.selection = list;
for (const e of list) e.selected = true;
G.buildMenu = false;
refreshCard(); refreshInfo();
}
function unitAt(wx, wy) {
let best = null, bd = 20 * 20;
for (const u of G.units) {
if (u.dead) continue;
if (u.team !== "player" && !tileVisible(u.x, u.y)) continue;
const d = dist2(wx, wy, u.x, u.y - 6);
if (d < bd) { bd = d; best = u; }
}
return best;
}
function buildingAt(wx, wy) {
const tx = (wx / TS) | 0, ty = (wy / TS) | 0;
for (const b of G.buildings) {
if (b.dead) continue;
if (tx >= b.tx && tx < b.tx + b.w && ty >= b.ty && ty < b.ty + b.h) return b;
}
return null;
}
function clickSelect(wx, wy, add, dbl) {
const u = unitAt(wx, wy);
if (u) {
if (dbl && u.team === "player") {
const a = screenToWorld(0, 0), b = screenToWorld(VW, VH);
const list = G.units.filter(o => !o.dead && o.team === "player" && o.type === u.type &&
o.x >= a.x && o.x <= b.x && o.y >= a.y && o.y <= b.y);
setSelection(list); return;
}
if (add && u.team === "player") {
if (u.selected) { u.selected = false; G.selection = G.selection.filter(o => o !== u); }
else { u.selected = true; G.selection.push(u); }
refreshCard(); refreshInfo(); return;
}
setSelection([u]); return;
}
const b = buildingAt(wx, wy);
if (b && tileExplored(b.x, b.y)) {
if (add && b.team === "player") {
if (!b.selected) { b.selected = true; G.selection.push(b); }
} else setSelection([b]);
refreshCard(); refreshInfo(); return;
}
const r = resourceAtTile((wx / TS) | 0, (wy / TS) | 0);
if (r && tileExplored(r.x, r.y)) { setSelection([r]); return; }
if (!add) setSelection([]);
}
function boxSelect(x0, y0, x1, y1, add) {
const list = G.units.filter(u => !u.dead && u.team === "player" &&
u.x >= x0 && u.x <= x1 && u.y >= y0 - 10 && u.y <= y1 + 10);
if (list.length === 0) {
const bl = G.buildings.filter(b => !b.dead && b.x >= x0 && b.x <= x1 && b.y >= y0 && b.y <= y1);
if (bl.length) { setSelection(add ? [...new Set([...G.selection, ...bl])] : [bl[0]]); return; }
if (!add) setSelection([]);
return;
}
setSelection(add ? [...new Set([...G.selection.filter(e => e.kind === "unit"), ...list])] : list);
}
/* ---- orders ------------------------------------------------------------ */
function selectedUnits() { return G.selection.filter(e => e.kind === "unit" && e.team === "player" && !e.dead); }
function formationOffsets(n) {
const out = [];
const cols = Math.ceil(Math.sqrt(n));
for (let i = 0; i < n; i++) {
const cx = i % cols, cy = (i / cols) | 0;
out.push({ x: (cx - (cols - 1) / 2) * 22, y: (cy - (Math.ceil(n / cols) - 1) / 2) * 22 });
}
return out;
}
function rightClick(wx, wy) {
const units = selectedUnits();
const sel = G.selection;
// rally point for buildings
const bsel = sel.filter(e => e.kind === "building" && e.done);
if (bsel.length && !units.length) {
for (const b of bsel) b.rally = { x: wx, y: wy };
addEffect("rally", wx, wy, 0);
return;
}
if (!units.length) return;
const target = unitAt(wx, wy);
if (target && target.team !== "player") {
for (const u of units) if (u.def.dmg) issueAttack(u, target);
addEffect("attackPing", wx, wy, 0);
return;
}
const bld = buildingAt(wx, wy);
if (bld && !bld.done) {
let any = false;
for (const u of units) if (u.type === "worker") { issueBuild(u, bld); any = true; }
if (any) { addEffect("ping", bld.x, bld.y, 0); return; }
}
const res = resourceAtTile((wx / TS) | 0, (wy / TS) | 0);
if (res && tileExplored(res.x, res.y)) {
let any = false;
for (const u of units) if (u.type === "worker") { issueHarvest(u, res); any = true; }
if (any) { addEffect("ping", res.x, res.y, 0); return; }
}
if (bld && bld.done && bld.def.dropoff) {
let any = false;
for (const u of units) if (u.type === "worker" && u.carrying > 0) {
u.dropTarget = bld; u.state = "return"; routeWorker(u); any = true;
}
if (any) { addEffect("ping", bld.x, bld.y, 0); return; }
}
const offs = formationOffsets(units.length);
units.forEach((u, i) => issueMove(u, wx + offs[i].x, wy + offs[i].y, false));
addEffect("ping", wx, wy, 0);
}
function commandAttackMove(wx, wy) {
const units = selectedUnits();
const offs = formationOffsets(units.length);
units.forEach((u, i) => { if (u.def.dmg) issueMove(u, wx + offs[i].x, wy + offs[i].y, true); });
addEffect("attackPing", wx, wy, 0);
}
/* ---- building placement ------------------------------------------------ */
function startPlacing(type) {
const def = BUILD_DEFS[type];
if (!canAfford(def)) { alertMsg("Not enough resources"); return; }
G.placing = { type };
cv.style.cursor = "crosshair";
}
function cancelPlacing() { G.placing = null; cv.style.cursor = "default"; refreshCard(); }
function tryPlace() {
const type = G.placing.type, def = BUILD_DEFS[type];
const tx = Math.floor(mouse.wx / TS - (def.w - 1) / 2);
const ty = Math.floor(mouse.wy / TS - (def.h - 1) / 2);
if (!canPlaceAt(type, tx, ty)) { alertMsg("Cannot build there"); return; }
if (!canAfford(def)) { alertMsg("Not enough resources"); cancelPlacing(); return; }
pay(def);
const b = placeBuilding(type, tx, ty, false);
markMinimapDirty();
const workers = selectedUnits().filter(u => u.type === "worker");
if (workers.length) {
for (const w of workers.slice(0, 5)) issueBuild(w, b);
} else {
// grab the closest idle worker
let best = null, bd = Infinity;
for (const u of G.units) {
if (u.dead || u.type !== "worker" || u.team !== "player") continue;
const d = dist2(u.x, u.y, b.x, b.y);
if (d < bd) { bd = d; best = u; }
}
if (best) issueBuild(best, b);
}
if (!keys.shift) cancelPlacing();
refreshCard();
}
/* ---- keyboard ---------------------------------------------------------- */
let lastGroupKey = null, lastGroupTime = 0;
window.addEventListener("keydown", e => {
const k = e.key.toLowerCase();
keys[k] = true; keys.shift = e.shiftKey;
if (k === "escape") {
if (G.placing) cancelPlacing();
else if (G.buildMenu) { G.buildMenu = false; refreshCard(); }
else if (G.attackMove) { G.attackMove = false; cv.style.cursor = "default"; }
else setSelection([]);
document.getElementById("help").classList.remove("show");
return;
}
if (k === "h") { document.getElementById("help").classList.toggle("show"); return; }
if (e.target && e.target.tagName === "INPUT") return;
if (e.ctrlKey && k >= "1" && k <= "9") {
G.groups[k] = G.selection.filter(x => !x.dead).slice();
alertMsg("Group " + k + " set (" + G.groups[k].length + ")");
e.preventDefault(); return;
}
if (!e.ctrlKey && k >= "1" && k <= "9") {
const g = (G.groups[k] || []).filter(x => !x.dead);
if (g.length) {
const now = performance.now();
if (lastGroupKey === k && now - lastGroupTime < 400) centerOn(g[0].x, g[0].y);
lastGroupKey = k; lastGroupTime = now;
setSelection(g);
}
return;
}
if (k === " ") {
const hall = G.buildings.find(b => !b.dead && b.type === "hall");
if (hall) centerOn(hall.x, hall.y);
e.preventDefault(); return;
}
if (k === "n") { jumpToUnexplored(); return; }
// command-card hotkeys
const cmds = currentCommands();
for (const c of cmds) {
if (c && c.hotkey && c.hotkey.toLowerCase() === k && !c.disabled) { c.action(); return; }
}
});
window.addEventListener("keyup", e => { keys[e.key.toLowerCase()] = false; keys.shift = e.shiftKey; });
/* centres the view on the nearest patch of land you have never laid eyes on */
function jumpToUnexplored() {
const cx = cam.x + VW / (2 * cam.zoom), cy = cam.y + VH / (2 * cam.zoom);
let best = null, bd = Infinity;
for (let i = 0; i < N; i++) {
if (explored[i] || !explorable[i]) continue;
const x = (i % MAP_W) * TS + TS / 2, y = ((i / MAP_W) | 0) * TS + TS / 2;
const d = dist2(x, y, cx, cy);
if (d < bd) { bd = d; best = { x, y }; }
}
if (!best) { alertMsg("Every tile has been charted"); return; }
centerOn(best.x, best.y);
addEffect("ping", best.x, best.y, 0);
addEffect("ping", best.x, best.y, 0);
alertMsg("Uncharted land ahead");
}
/* ---- minimap input ----------------------------------------------------- */
let mmDown = false;
function mmToWorld(e) {
const r = mm.getBoundingClientRect();
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
const y = clamp((e.clientY - r.top) / r.height, 0, 1);
return { x: x * MAP_PX_W, y: y * MAP_PX_H };
}
mm.addEventListener("mousedown", e => {
e.preventDefault();
const w = mmToWorld(e);
if (e.button === 2) {
rightClick(w.x, w.y);
} else { mmDown = true; centerOn(w.x, w.y); }
});
mm.addEventListener("contextmenu", e => e.preventDefault());
window.addEventListener("mousemove", e => { if (mmDown) { const w = mmToWorld(e); centerOn(w.x, w.y); } });
window.addEventListener("mouseup", () => { mmDown = false; });
/* ======================================================================== */
/* UI */
/* ======================================================================== */
const cardPanel = document.getElementById("cardPanel");
const infoPanel = document.getElementById("infoPanel");
const tip = document.getElementById("tip");
function costHTML(def) {
let s = "";
if (def.gold) s += `<span class="cost"><span class="gem g" style="width:10px;height:10px"></span>${def.gold}</span>`;
if (def.wood) s += `<span class="cost"><span class="gem w" style="width:10px;height:10px"></span>${def.wood}</span>`;
if (def.supply) s += `<span class="cost"><span class="gem s" style="width:10px;height:10px"></span>${def.supply}</span>`;
if (def.time) s += `<span class="cost" style="color:#8b95ad">${def.time}s</span>`;
return s;
}
function currentCommands() {
const cmds = new Array(12).fill(null);
const sel = G.selection;
const units = selectedUnits();
if (!sel.length) return cmds;
if (units.length) {
const hasWorker = units.some(u => u.type === "worker");
const hasCombat = units.some(u => u.def.dmg && u.type !== "worker");
if (G.buildMenu && hasWorker) {
let i = 0;
for (const key of ["hall", "farm", "barracks", "mill", "tower"]) {
const d = BUILD_DEFS[key];
cmds[i++] = {
icon: key, hotkey: d.hotkey, name: d.name, def: d, desc: d.desc,
disabled: !canAfford(d),
action: () => { startPlacing(key); }
};
}
cmds[11] = { icon: "back", hotkey: "Esc", name: "Cancel", desc: "Return to the previous menu.",
action: () => { G.buildMenu = false; refreshCard(); } };
return cmds;
}
cmds[0] = { icon: "move", hotkey: "M", name: "Move", desc: "Right-click the ground to move.",
action: () => alertMsg("Right-click to move") };
cmds[1] = { icon: "stop", hotkey: "S", name: "Stop", desc: "Halt all current orders.",
action: () => { for (const u of units) stopUnit(u); } };
if (hasCombat || hasWorker) {
cmds[2] = { icon: "attack", hotkey: "A", name: "Attack", desc: "Attack-move: engage anything on the way.",
action: () => { G.attackMove = true; cv.style.cursor = "crosshair"; } };
}
if (hasWorker) {
cmds[4] = { icon: "build", hotkey: "B", name: "Build", desc: "Raise a structure.",
action: () => { G.buildMenu = true; refreshCard(); } };
}
return cmds;
}
const b = sel.find(e => e.kind === "building");
if (b && b.team === "player") {
if (!b.done) {
cmds[11] = { icon: "stop", hotkey: "X", name: "Cancel construction", desc: "Refund half of the cost.",
action: () => { G.gold += Math.round(b.def.gold / 2); G.wood += Math.round(b.def.wood / 2); destroyBuilding(b); setSelection([]); markMinimapDirty(); } };
return cmds;
}
let i = 0;
if (b.def.trains) {
for (const t of b.def.trains) {
const d = UNIT_DEFS[t];
const missingReq = d.req && !hasBuilding(d.req);
cmds[i++] = {
icon: t, hotkey: d.hotkey, name: d.name, def: d,
desc: d.desc + (missingReq ? ` (Requires ${BUILD_DEFS[d.req].name})` : ""),
disabled: !canAfford(d) || missingReq,
action: () => trainUnit(b, t)
};
}
cmds[8] = { icon: "rally", hotkey: "Y", name: "Set rally point",
desc: "Right-click anywhere to set where new units gather.",
action: () => alertMsg("Right-click to place the rally point") };
}
return cmds;
}
return cmds;
}
function trainUnit(b, type) {
const d = UNIT_DEFS[type];
if (d.req && !hasBuilding(d.req)) { alertMsg("Requires " + BUILD_DEFS[d.req].name); return; }
if (!canAfford(d)) { alertMsg("Not enough resources"); return; }
if (G.supplyUsed + d.supply > G.supplyCap) { alertMsg("Not enough supply — build a Farm"); return; }
if (b.queue.length >= 6) { alertMsg("Queue is full"); return; }
pay(d); b.queue.push(type); recalcSupply(); refreshCard(); refreshInfo();
}
let cardSig = "";
function refreshCard(force) {
const cmds = currentCommands();
const sig = cmds.map(c => c ? c.icon + (c.disabled ? "0" : "1") : "-").join("|") + "/" + G.selection.length;
if (sig === cardSig && !force) return;
cardSig = sig;
hideTip();
cardPanel.innerHTML = "";
for (let i = 0; i < 12; i++) {
const c = cmds[i];
const el = document.createElement("button");
if (!c) { el.className = "cmd empty"; cardPanel.appendChild(el); continue; }
el.className = "cmd" + (c.disabled ? " disabled" : "");
const ic = iconFor(c.icon);
const cc = mkCanvas(64, 64); cc.getContext("2d").drawImage(ic, 0, 0);
el.appendChild(cc);
const hk = document.createElement("div"); hk.className = "hk"; hk.textContent = c.hotkey || "";
el.appendChild(hk);
el.onclick = () => { if (!c.disabled) c.action(); };
el.onmouseenter = () => showTip(c);
el.onmouseleave = hideTip;
cardPanel.appendChild(el);
}
}
function showTip(c) {
tip.innerHTML = `<div class="tn">${c.name}${c.hotkey ? ` <span style="color:#ffd166">[${c.hotkey}]</span>` : ""}</div>` +
(c.def ? `<div class="tc">${costHTML(c.def)}</div>` : "") +
`<div class="td">${c.desc || ""}</div>`;
tip.style.display = "block";
}
function hideTip() { tip.style.display = "none"; }
cardPanel.addEventListener("mouseleave", hideTip);
let infoSig = "";
let infoLive = null; // callbacks that refresh the numbers without rebuilding
function refreshInfo() {
const sel = G.selection.filter(e => !e.dead);
if (sel.length !== G.selection.length) G.selection = sel;
if (!sel.length) {
if (infoSig !== "none") {
infoSig = "none"; infoLive = null;
infoPanel.innerHTML = `<div class="hint">
<b>No selection.</b><br>
Left-click or drag to select. Right-click to order.<br>
<kbd>B</kbd> build · <kbd>A</kbd> attack-move · <kbd>S</kbd> stop<br>
<kbd>Space</kbd> base · <kbd>N</kbd> uncharted land · <kbd>H</kbd> help<br>
<span style="color:#7fa8d8">Goal: reveal every tile of the map.</span>
</div>`;
}
return;
}
if (sel.length === 1) {
const e = sel[0];
const sig = "single" + e.id + (e.kind === "building"
? "|" + e.queue.join(",") + (e.done ? "1" : "0w" + (e.workers | 0))
: "|" + e.state);
if (sig === infoSig) { if (infoLive) infoLive(); return; }
infoSig = sig; infoLive = null;
infoPanel.innerHTML = "";
const wrap = document.createElement("div"); wrap.className = "single";
const big = document.createElement("div"); big.className = "big";
const bc = mkCanvas(88, 88); const g = bc.getContext("2d");
if (e.kind === "unit") { g.save(); g.translate(44, 56); g.scale(2.7, 2.7); drawUnitShape(g, e.type, .3, -Math.PI / 2 + .4, e.team); g.restore(); }
else if (e.kind === "building") { const s = 2.0 / Math.max(e.w, e.h); g.save(); g.translate(44, 52); g.scale(s, s); drawBuildingShape(g, e.type, e.w, e.h, 1, 0); g.restore(); }
else { // resource
g.save(); g.translate(44, 60);
if (e.type === "gold") { g.scale(.85, .85); g.drawImage(goldSprite, -goldSprite.width / 2, -goldSprite.height + 14); }
else { g.drawImage(treeSprites[e.variant], -treeSprites[0].width / 2, -treeSprites[0].height + 12); }
g.restore();
}
big.appendChild(bc); wrap.appendChild(big);
const meta = document.createElement("div"); meta.className = "meta";
if (e.kind === "unit") {
meta.innerHTML = `<h3>${e.def.name}</h3><div class="sub">${e.team === "player" ? "Your unit" : "Wild creature"} · ${stateLabel(e)}</div>
<div class="bar"><div data-role="hpbar" style="width:${(e.hp / e.maxHp * 100).toFixed(0)}%"></div></div>
<div class="stat">Health <b data-role="hptext">${Math.max(0, Math.ceil(e.hp))} / ${e.maxHp}</b></div>
<div class="stat">Damage <b>${e.def.dmg}</b> · Range <b>${e.def.range > 40 ? "ranged" : "melee"}</b> · Sight <b>${e.def.sight}</b></div>
${e.type === "worker" ? `<div class="stat">Carrying <b data-role="cargo">${e.carrying ? e.carrying + " " + e.carryType : "nothing"}</b></div>` : ""}`;
const bar = meta.querySelector('[data-role="hpbar"]');
const txt = meta.querySelector('[data-role="hptext"]');
const cargo = meta.querySelector('[data-role="cargo"]');
infoLive = () => {
bar.style.width = clamp(e.hp / e.maxHp * 100, 0, 100).toFixed(0) + "%";
txt.textContent = Math.max(0, Math.ceil(e.hp)) + " / " + e.maxHp;
if (cargo) cargo.textContent = e.carrying ? e.carrying + " " + e.carryType : "nothing";
};
} else if (e.kind === "building") {
let q = "";
if (e.done && e.queue.length) {
const d = UNIT_DEFS[e.queue[0]];
q = `<div class="stat" style="margin-top:4px">Training <b>${d.name}</b></div>
<div class="bar prog"><div data-role="trainbar" style="width:${(e.trainT / d.time * 100).toFixed(0)}%"></div></div>`;
}
meta.innerHTML = `<h3>${e.def.name}</h3><div class="sub">${e.done ? "Operational"
: (e.workers ? "Under construction · " + e.workers + " peasant" + (e.workers > 1 ? "s" : "")
: "<span style='color:#ffb46b'>Waiting for a peasant</span>")}${e.def.supply ? " · +" + e.def.supply + " supply" : ""}</div>
<div class="bar"><div data-role="hpbar" style="width:${(e.hp / e.maxHp * 100).toFixed(0)}%"></div></div>
<div class="stat">Health <b data-role="hptext">${Math.ceil(e.hp)} / ${e.maxHp}</b></div>
${!e.done ? `<div class="stat">Construction <b data-role="progtext">${(e.progress * 100).toFixed(0)}%</b></div>
<div class="bar prog"><div data-role="progbar" style="width:${(e.progress * 100).toFixed(0)}%"></div></div>` : ""}
${q}`;
const bar = meta.querySelector('[data-role="hpbar"]');
const txt = meta.querySelector('[data-role="hptext"]');
const pb = meta.querySelector('[data-role="progbar"]');
const pt = meta.querySelector('[data-role="progtext"]');
const tb = meta.querySelector('[data-role="trainbar"]');
infoLive = () => {
bar.style.width = clamp(e.hp / e.maxHp * 100, 0, 100).toFixed(0) + "%";
txt.textContent = Math.ceil(e.hp) + " / " + e.maxHp;
if (pb) { pb.style.width = (e.progress * 100).toFixed(0) + "%"; pt.textContent = (e.progress * 100).toFixed(0) + "%"; }
if (tb && e.queue.length) tb.style.width = clamp(e.trainT / UNIT_DEFS[e.queue[0]].time * 100, 0, 100).toFixed(0) + "%";
};
if (e.done && e.queue.length) {
const qd = document.createElement("div"); qd.className = "queue";
e.queue.forEach((t, i) => {
const s = document.createElement("div"); s.className = "qslot";
const c = mkCanvas(64, 64); c.getContext("2d").drawImage(iconFor(t), 0, 0);
s.appendChild(c); s.title = "Cancel " + UNIT_DEFS[t].name;
s.onclick = () => { refund(UNIT_DEFS[t]); e.queue.splice(i, 1); if (i === 0) e.trainT = 0; recalcSupply(); infoSig = ""; refreshInfo(); refreshCard(true); };
qd.appendChild(s);
});
meta.appendChild(qd);
}
} else {
meta.innerHTML = `<h3>${e.type === "gold" ? "Gold Mine" : "Forest"}</h3>
<div class="sub">${e.type === "gold" ? "Rich in ore" : "Timber"}</div>
<div class="bar"><div data-role="hpbar" style="width:${(e.amount / e.max * 100).toFixed(0)}%;background:${e.type === "gold" ? "linear-gradient(#ffe9a0,#e0a020)" : "linear-gradient(#8fd08f,#3f8f3f)"}"></div></div>
<div class="stat">Remaining <b data-role="hptext">${e.amount}</b></div>
<div class="stat" style="color:#8b95ad">Right-click with a peasant selected to harvest.</div>`;
const bar = meta.querySelector('[data-role="hpbar"]');
const txt = meta.querySelector('[data-role="hptext"]');
infoLive = () => {
bar.style.width = (e.amount / e.max * 100).toFixed(0) + "%";
txt.textContent = e.amount;
};
}
wrap.appendChild(meta);
infoPanel.appendChild(wrap);
return;
}
// multiple — rebuild only when the roster itself changes
const shown = sel.slice(0, 32);
const sig = "multi:" + shown.map(e => e.id).join(",");
if (sig === infoSig) { if (infoLive) infoLive(); return; }
infoSig = sig;
infoPanel.innerHTML = "";
const row = document.createElement("div"); row.className = "selRow";
const live = [];
shown.forEach(e => {
const p = document.createElement("div");
p.className = "portrait";
const c = mkCanvas(64, 64);
c.getContext("2d").drawImage(iconFor(e.type), 0, 0);
p.appendChild(c);
const hb = document.createElement("div"); hb.className = "hpb";
const hf = document.createElement("div"); hf.className = "hpf";
hb.appendChild(hf); p.appendChild(hb);
p.title = (e.def && e.def.name) || e.type;
p.onclick = ev => { ev.stopPropagation(); setSelection([e]); };
row.appendChild(p);
live.push({ e, p, hf });
});
infoPanel.appendChild(row);
if (sel.length > shown.length) {
const more = document.createElement("div");
more.className = "hint"; more.style.marginTop = "4px";
more.textContent = "+" + (sel.length - shown.length) + " more";
infoPanel.appendChild(more);
}
infoLive = () => {
for (const L of live) {
const frac = clamp(L.e.hp / L.e.maxHp, 0, 1);
L.hf.style.width = (frac * 100) + "%";
const cls = "portrait" + (frac < .35 ? " crit" : frac < .7 ? " hurt" : "");
if (L.p.className !== cls) L.p.className = cls;
}
};
infoLive();
}
function stateLabel(u) {
switch (u.state) {
case "idle": return "Standing by";
case "move": return u.attackMove ? "Attack-moving" : "Moving";
case "attack": return "Fighting";
case "toResource": return "Going to harvest";
case "harvest": return u.resource && u.resource.type === "gold" ? "Mining gold" : "Chopping wood";
case "return": return "Returning cargo";
case "toBuild": case "building": return "Building";
case "wander": return "Roaming";
default: return "—";
}
}
/* ======================================================================== */
/* RENDER */
/* ======================================================================== */
const fogCanvas = mkCanvas(MAP_W, MAP_H);
const fogCtx = fogCanvas.getContext("2d");
const fogImg = fogCtx.createImageData(MAP_W, MAP_H);
function drawFog() {
const d = fogImg.data;
for (let i = 0; i < N; i++) {
const e = explored[i], v = visible[i];
d[i * 4] = 0; d[i * 4 + 1] = 0; d[i * 4 + 2] = 2;
d[i * 4 + 3] = e ? (v ? 0 : 122) : 255;
}
fogCtx.putImageData(fogImg, 0, 0);
}
let minimapDirty = true;
function markMinimapDirty() { minimapDirty = true; }
function render(t) {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, VW, VH);
ctx.imageSmoothingEnabled = false;
const z = cam.zoom;
ctx.save();
ctx.scale(z, z);
ctx.translate(-cam.x, -cam.y);
const vx0 = cam.x, vy0 = cam.y, vx1 = cam.x + VW / z, vy1 = cam.y + VH / z;
// terrain
ctx.drawImage(terrainCanvas,
Math.max(0, vx0), Math.max(0, vy0),
Math.min(MAP_PX_W, vx1) - Math.max(0, vx0), Math.min(MAP_PX_H, vy1) - Math.max(0, vy0),
Math.max(0, vx0), Math.max(0, vy0),
Math.min(MAP_PX_W, vx1) - Math.max(0, vx0), Math.min(MAP_PX_H, vy1) - Math.max(0, vy0));
ctx.imageSmoothingEnabled = true;
// water shimmer
ctx.save();
ctx.globalAlpha = .10;
ctx.strokeStyle = "#cfeaff"; ctx.lineWidth = 1.5;
const t0 = ((vy0 / TS) | 0), t1 = Math.min(MAP_H - 1, (vy1 / TS) | 0);
const s0 = ((vx0 / TS) | 0), s1 = Math.min(MAP_W - 1, (vx1 / TS) | 0);
for (let y = Math.max(0, t0); y <= t1; y++) {
for (let x = Math.max(0, s0); x <= s1; x++) {
const tt = terrain[y * MAP_W + x];
if (tt !== T_WATER && tt !== T_DEEP) continue;
if (((x * 7 + y * 13) % 5) !== 0) continue;
const ph = t * 1.5 + x * .7 + y * .5;
ctx.beginPath();
ctx.moveTo(x * TS + 6, y * TS + 16 + Math.sin(ph) * 2);
ctx.lineTo(x * TS + 26, y * TS + 16 + Math.sin(ph + 1) * 2);
ctx.stroke();
}
}
ctx.restore();
// resources (remembered once explored)
for (const r of G.resources) {
if (r.dead) continue;
if (r.x < vx0 - 60 || r.x > vx1 + 60 || r.y < vy0 - 60 || r.y > vy1 + 60) continue;
if (!explored[r.ty * MAP_W + r.tx]) continue;
if (r.type === "tree") {
const s = treeSprites[r.variant];
ctx.drawImage(s, r.x - s.width / 2 + r.jx, r.y + TS / 2 - s.height + 10 + r.jy);
} else {
ctx.drawImage(goldSprite, r.x - goldSprite.width / 2, r.y + TS - goldSprite.height + 4);
// depletion tint
if (r.amount < r.max * .34) {
ctx.save(); ctx.globalAlpha = .35; ctx.fillStyle = "#000";
ctx.beginPath(); ctx.ellipse(r.x, r.y + 4, 30, 16, 0, 0, TAU); ctx.fill(); ctx.restore();
}
}
if (r.selected) drawSelRing(r.x, r.y + 6, Math.max(r.w, r.h) * 18, "#ffd166");
}
// buildings
const sorted = G.buildings.filter(b => !b.dead && explored[b.ty * MAP_W + b.tx]).sort((a, b) => a.y - b.y);
for (const b of sorted) {
if (b.x < vx0 - 120 || b.x > vx1 + 120 || b.y < vy0 - 120 || b.y > vy1 + 120) continue;
ctx.save();
ctx.translate(b.x, b.y);
if (b.flash > 0) { ctx.filter = "brightness(1.9)"; }
drawBuildingShape(ctx, b.type, b.w, b.h, b.done ? 1 : b.progress, t);
ctx.restore();
if (b.selected) drawSelRing(b.x, b.y + b.h * TS * .3, Math.max(b.w, b.h) * TS * .55, "#8fe08f");
if (b.hp < b.maxHp || !b.done) drawBar(b.x, b.y - b.h * TS * .55, 34, b.hp / b.maxHp, "#5ad35a");
if (!b.done) {
drawBar(b.x, b.y + b.h * TS * .5 + 4, 34, b.progress, b.workers ? "#67b8ff" : "#e0a02a");
if (!b.workers) { // nobody is working on this site
const pulse = .55 + .45 * Math.sin(t * 5);
ctx.save();
ctx.globalAlpha = pulse;
ctx.fillStyle = "#ffc061";
ctx.strokeStyle = "rgba(0,0,0,.6)"; ctx.lineWidth = 2.5;
ctx.font = "bold 20px Segoe UI, sans-serif"; ctx.textAlign = "center";
const yy = b.y - b.h * TS * .55 - 8;
ctx.strokeText("!", b.x, yy); ctx.fillText("!", b.x, yy);
ctx.restore();
}
}
if (b.rally && b.selected) {
ctx.strokeStyle = "rgba(127,208,255,.65)"; ctx.lineWidth = 2; ctx.setLineDash([6, 5]);
ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.rally.x, b.rally.y); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#7fd0ff";
ctx.beginPath(); ctx.arc(b.rally.x, b.rally.y, 4, 0, TAU); ctx.fill();
}
}
// units
const vis = G.units.filter(u => !u.dead &&
u.x > vx0 - 40 && u.x < vx1 + 40 && u.y > vy0 - 40 && u.y < vy1 + 40 &&
(u.team === "player" || tileVisible(u.x, u.y))).sort((a, b) => a.y - b.y);
for (const u of vis) {
if (u.selected) drawSelRing(u.x, u.y + 7, 12, u.team === "player" ? "#8fe08f" : "#ff8f6b");
if (u.flash > 0) {
u.flash -= 1 / 60;
ctx.save(); ctx.filter = "brightness(2.1)"; drawUnitCached(ctx, u); ctx.restore();
} else drawUnitCached(ctx, u);
if (u.carrying > 0) {
ctx.fillStyle = u.carryType === "gold" ? "#f5c542" : "#8a5a2c";
ctx.strokeStyle = "rgba(0,0,0,.4)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(u.x + 7, u.y - 11, 3.4, 0, TAU); ctx.fill(); ctx.stroke();
}
if (u.hp < u.maxHp) drawBar(u.x, u.y - 16, 20, u.hp / u.maxHp, u.team === "player" ? "#5ad35a" : "#e05a5a");
}
// projectiles
for (const p of G.projectiles) {
const a = Math.atan2(p.ty - p.y, p.tx - p.x);
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(a);
ctx.strokeStyle = "#f0e6c8"; ctx.lineWidth = 2; ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(-7, 0); ctx.lineTo(6, 0); ctx.stroke();
ctx.strokeStyle = "#c0c8d8"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(-7, 0); ctx.lineTo(-10, -2.5); ctx.moveTo(-7, 0); ctx.lineTo(-10, 2.5); ctx.stroke();
ctx.restore();
}
// effects
for (const e of G.effects) {
const k = e.life / e.max;
ctx.save();
if (e.type === "ping" || e.type === "attackPing" || e.type === "rally") {
ctx.strokeStyle = e.type === "attackPing" ? "#ff8f6b" : e.type === "rally" ? "#7fd0ff" : "#8fe08f";
ctx.lineWidth = 2.4; ctx.globalAlpha = k;
ctx.beginPath(); ctx.arc(e.x, e.y, (1 - k) * 22 + 4, 0, TAU); ctx.stroke();
} else if (e.type === "slash") {
ctx.globalAlpha = k * 1.5;
ctx.strokeStyle = "#fff6d8"; ctx.lineWidth = 3; ctx.lineCap = "round";
ctx.beginPath(); ctx.arc(e.x, e.y, 9, e.a - .9, e.a + .9); ctx.stroke();
} else if (e.type === "chip") {
ctx.globalAlpha = k;
ctx.fillStyle = e.extra === "gold" ? "#ffd166" : "#a9743f";
for (let i = 0; i < 3; i++) {
const a = i * 2.1 + (1 - k) * 3;
ctx.beginPath();
ctx.arc(e.x + Math.cos(a) * (1 - k) * 14, e.y + Math.sin(a) * (1 - k) * 10 - (1 - k) * 6, 2, 0, TAU);
ctx.fill();
}
} else if (e.type === "puff") {
ctx.globalAlpha = k * .7;
ctx.fillStyle = "#d9d2c4";
for (let i = 0; i < 6; i++) {
const a = i * 1.05;
ctx.beginPath();
ctx.arc(e.x + Math.cos(a) * (1 - k) * 24, e.y + Math.sin(a) * (1 - k) * 16, 7 * k + 2, 0, TAU);
ctx.fill();
}
} else if (e.type === "float") {
ctx.globalAlpha = Math.min(1, k * 1.6);
ctx.fillStyle = e.color; ctx.font = "bold 13px Segoe UI, sans-serif";
ctx.textAlign = "center";
ctx.strokeStyle = "rgba(0,0,0,.65)"; ctx.lineWidth = 3;
ctx.strokeText(e.text, e.x, e.y - (1 - k) * 22);
ctx.fillText(e.text, e.x, e.y - (1 - k) * 22);
}
ctx.restore();
}
// placement ghost
if (G.placing) {
const def = BUILD_DEFS[G.placing.type];
const tx = Math.floor(mouse.wx / TS - (def.w - 1) / 2);
const ty = Math.floor(mouse.wy / TS - (def.h - 1) / 2);
const ok = canPlaceAt(G.placing.type, tx, ty) && canAfford(def);
ctx.save();
ctx.globalAlpha = .55;
ctx.translate((tx + def.w / 2) * TS, (ty + def.h / 2) * TS);
drawBuildingShape(ctx, G.placing.type, def.w, def.h, 1, t);
ctx.restore();
for (let dy = 0; dy < def.h; dy++) for (let dx = 0; dx < def.w; dx++) {
const x = tx + dx, y = ty + dy;
let good = x >= 0 && y >= 0 && x < MAP_W && y < MAP_H &&
PASSABLE[terrain[y * MAP_W + x]] && !blocked[y * MAP_W + x] && explored[y * MAP_W + x];
ctx.fillStyle = good ? "rgba(110,230,120,.28)" : "rgba(235,90,90,.34)";
ctx.fillRect(x * TS + 1, y * TS + 1, TS - 2, TS - 2);
ctx.strokeStyle = good ? "rgba(140,255,150,.5)" : "rgba(255,120,120,.6)";
ctx.lineWidth = 1; ctx.strokeRect(x * TS + 1, y * TS + 1, TS - 2, TS - 2);
}
if (!ok) { /* colours already communicate it */ }
}
// fog of war — upscaled with smoothing so the edges feather softly
drawFog();
ctx.imageSmoothingEnabled = true;
{
const fx0 = clamp(Math.floor(vx0 / TS) - 1, 0, MAP_W);
const fy0 = clamp(Math.floor(vy0 / TS) - 1, 0, MAP_H);
const fx1 = clamp(Math.ceil(vx1 / TS) + 1, 0, MAP_W);
const fy1 = clamp(Math.ceil(vy1 / TS) + 1, 0, MAP_H);
if (fx1 > fx0 && fy1 > fy0) {
ctx.drawImage(fogCanvas, fx0, fy0, fx1 - fx0, fy1 - fy0,
fx0 * TS, fy0 * TS, (fx1 - fx0) * TS, (fy1 - fy0) * TS);
}
}
// beyond the map edge everything is black
ctx.fillStyle = "#05070b";
if (vx0 < 0) ctx.fillRect(vx0 - 4, vy0 - 4, -vx0 + 4, vy1 - vy0 + 8);
if (vy0 < 0) ctx.fillRect(vx0 - 4, vy0 - 4, vx1 - vx0 + 8, -vy0 + 4);
if (vx1 > MAP_PX_W) ctx.fillRect(MAP_PX_W, vy0 - 4, vx1 - MAP_PX_W + 4, vy1 - vy0 + 8);
if (vy1 > MAP_PX_H) ctx.fillRect(vx0 - 4, MAP_PX_H, vx1 - vx0 + 8, vy1 - MAP_PX_H + 4);
ctx.restore();
// ---- screen space ----
if (mouse.down && mouse.dragging) {
const x = Math.min(mouse.sx, mouse.x), y = Math.min(mouse.sy, mouse.y);
const w = Math.abs(mouse.x - mouse.sx), h = Math.abs(mouse.y - mouse.sy);
ctx.fillStyle = "rgba(120,220,140,.12)";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "rgba(150,240,170,.9)"; ctx.lineWidth = 1.5;
ctx.strokeRect(x + .5, y + .5, w, h);
}
if (G.attackMove) {
ctx.strokeStyle = "#ff8f6b"; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(mouse.x - 12, mouse.y); ctx.lineTo(mouse.x + 12, mouse.y);
ctx.moveTo(mouse.x, mouse.y - 12); ctx.lineTo(mouse.x, mouse.y + 12);
ctx.stroke();
ctx.beginPath(); ctx.arc(mouse.x, mouse.y, 7, 0, TAU); ctx.stroke();
}
}
function drawSelRing(x, y, r, color) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(x, y, r, r * .5, 0, 0, TAU); ctx.stroke();
ctx.globalAlpha = .18; ctx.fillStyle = color; ctx.fill();
ctx.restore();
}
function drawBar(cx, y, w, frac, color) {
ctx.fillStyle = "rgba(0,0,0,.65)";
ctx.fillRect(cx - w / 2 - 1, y - 1, w + 2, 5);
ctx.fillStyle = color;
ctx.fillRect(cx - w / 2, y, w * clamp(frac, 0, 1), 3);
}
/* ---- minimap ----------------------------------------------------------- */
function renderMinimap() {
const W = mm.width, H = mm.height;
mmCtx.setTransform(1, 0, 0, 1, 0, 0);
mmCtx.imageSmoothingEnabled = false;
mmCtx.fillStyle = "#05070b"; mmCtx.fillRect(0, 0, W, H);
if (minimapDirty) bakeMiniFull();
mmCtx.drawImage(miniFull, 0, 0, MAP_W, MAP_H, 0, 0, W, H);
const sx = W / MAP_W, sy = H / MAP_H;
// fog
mmCtx.drawImage(fogCanvas, 0, 0, MAP_W, MAP_H, 0, 0, W, H);
// entities
for (const b of G.buildings) {
if (b.dead || !explored[b.ty * MAP_W + b.tx]) continue;
mmCtx.fillStyle = b.done ? "#6fb6ff" : "#4a7fb0";
mmCtx.fillRect(b.tx * sx, b.ty * sy, b.w * sx, b.h * sy);
}
for (const u of G.units) {
if (u.dead) continue;
if (u.team !== "player" && !tileVisible(u.x, u.y)) continue;
mmCtx.fillStyle = u.team === "player" ? (u.selected ? "#ffffff" : "#7fe0ff") : "#ff6b6b";
const s = Math.max(2, sx * .9);
mmCtx.fillRect(u.x / TS * sx - s / 2, u.y / TS * sy - s / 2, s, s);
}
// viewport rect
mmCtx.strokeStyle = "rgba(255,255,255,.85)"; mmCtx.lineWidth = 1.5;
mmCtx.strokeRect(cam.x / TS * sx, cam.y / TS * sy, (VW / cam.zoom) / TS * sx, (VH / cam.zoom) / TS * sy);
}
/* ======================================================================== */
/* MAIN LOOP */
/* ======================================================================== */
let last = performance.now();
let uiTimer = 0;
function tick(now) {
let dt = (now - last) / 1000;
last = now;
dt = Math.min(dt, 0.05);
G.time += dt;
// camera scrolling (letters are reserved for command hotkeys)
const sp = 620 / cam.zoom * dt;
if (keys.arrowup) cam.y -= sp;
if (keys.arrowdown) cam.y += sp;
if (keys.arrowleft) cam.x -= sp;
if (keys.arrowright) cam.x += sp;
if (mouse.inside && !mouse.mid) {
const M = 14;
if (mouse.x < M) cam.x -= sp;
if (mouse.x > VW - M) cam.x += sp;
if (mouse.y < M) cam.y -= sp;
if (mouse.y > VH - M) cam.y += sp;
}
clampCam();
// simulation
rebuildHash();
for (const b of G.buildings) if (!b.dead) updateBuilding(b, dt);
for (const u of G.units) if (!u.dead) updateUnit(u, dt);
// projectiles
for (const p of G.projectiles) {
if (p.target && !p.target.dead) { p.tx = p.target.x; p.ty = p.target.y - 6; }
const dx = p.tx - p.x, dy = p.ty - p.y, d = Math.hypot(dx, dy);
const st = p.speed * dt;
p.life -= dt;
if (d <= st || p.life <= 0) {
if (p.target && !p.target.dead && d <= st + 6) damage(p.target, p.dmg, p.src);
p.done = true;
} else { p.x += dx / d * st; p.y += dy / d * st; }
}
G.projectiles = G.projectiles.filter(p => !p.done);
for (const e of G.effects) e.life -= dt;
G.effects = G.effects.filter(e => e.life > 0);
// cull dead
if (G.units.some(u => u.dead)) {
G.units = G.units.filter(u => !u.dead);
G.selection = G.selection.filter(e => !e.dead);
infoSig = ""; cardSig = "";
}
if (G.buildings.some(b => b.dead)) {
G.buildings = G.buildings.filter(b => !b.dead);
G.selection = G.selection.filter(e => !e.dead);
infoSig = ""; cardSig = "";
}
fogTimer -= dt;
if (fogTimer <= 0) { fogTimer = 0.1; updateFog(); }
render(G.time);
renderMinimap();
uiTimer -= dt;
if (uiTimer <= 0) {
uiTimer = 0.12;
document.getElementById("rGold").textContent = Math.floor(G.gold);
document.getElementById("rWood").textContent = Math.floor(G.wood);
const supEl = document.getElementById("rSupply");
supEl.textContent = G.supplyUsed + "/" + G.supplyCap;
supEl.style.color = G.supplyUsed >= G.supplyCap ? "#ff9b6b" : "";
const pct = G.exploredCount / G.explorableTotal * 100;
document.getElementById("rExplored").textContent =
(pct >= 95 && pct < 100 ? pct.toFixed(1) : Math.floor(pct)) + "%";
const m = Math.floor(G.time / 60), s = Math.floor(G.time % 60);
document.getElementById("rClock").textContent = m + ":" + (s < 10 ? "0" : "") + s;
refreshCard(); refreshInfo();
}
requestAnimationFrame(tick);
}
/* ======================================================================== */
/* BOOT */
/* ======================================================================== */
function boot() {
const seed = (Math.random() * 1e9) | 0;
generateMap(seed);
buildResourceSprites();
bakeTerrain();
// starting base
const hall = placeBuilding("hall", startTile.x - 1, startTile.y - 1, true);
const spots = [];
for (let r = 3; r < 7 && spots.length < 5; r++) {
for (let a = 0; a < 16 && spots.length < 5; a++) {
const ang = a / 16 * TAU;
const x = Math.round(startTile.x + Math.cos(ang) * r);
const y = Math.round(startTile.y + Math.sin(ang) * r);
if (x < 1 || y < 1 || x >= MAP_W - 1 || y >= MAP_H - 1) continue;
if (!walkable(y * MAP_W + x)) continue;
if (spots.some(s => s.x === x && s.y === y)) continue;
spots.push({ x, y });
}
}
const mine = nearestResourceAny(hall.x, hall.y, "gold");
spots.slice(0, 5).forEach((s, i) => {
const u = spawnUnit("worker", s.x * TS + TS / 2, s.y * TS + TS / 2);
if (mine && i < 3) issueHarvest(u, mine);
else {
const tree = nearestResourceAny(hall.x, hall.y, "tree");
if (tree) issueHarvest(u, tree);
}
});
recalcSupply();
resize();
updateFog();
centerOn(hall.x, hall.y);
refreshCard(true); refreshInfo();
document.getElementById("help").classList.add("show");
requestAnimationFrame(tick);
}
function nearestResourceAny(x, y, type) {
let best = null, bd = Infinity;
for (const r of G.resources) {
if (r.dead || r.type !== type) continue;
const d = dist2(x, y, r.x, r.y);
if (d < bd) { bd = d; best = r; }
}
return best;
}
document.getElementById("helpBtn").onclick = () => document.getElementById("help").classList.toggle("show");
document.getElementById("newBtn").onclick = () => location.reload();
document.getElementById("closeHelp").onclick = () => document.getElementById("help").classList.remove("show");
document.getElementById("help").onclick = e => {
if (e.target.id === "help") document.getElementById("help").classList.remove("show");
};
boot();
</script>
</body>
</html>
@programmersocool

Copy link
Copy Markdown

was this zero-shot?

@senko

senko commented Jul 25, 2026

Copy link
Copy Markdown
Author

Yes. Here's the entire prompt:

Create a simple but functional real time strategy (RTS) game similar to old WarCraft, StarCraft or Command & Conquer games. The player should be able to build buildings, create units, gather resources and should uncover the whole map. No AI or multiplayer needed. Use simple but nice-looking graphics. No sound. Implement everything in HTML/CSS/JS, everything in a single file (you can use 3rd-party js or css libraries/frameworks via CDN).

It had playwright-cli for local testing which it used.

Ran for ~1h (41min of which was API time), token cost ~$21.

Compare with Fable and others here.

@Recursing

Copy link
Copy Markdown

Thanks @senko for running this !

Is there a table somewhere with time, cost, and html file size? I think it would be interesting to compare (while of course there's lots of noise between re-runs)

@senko

senko commented Jul 25, 2026

Copy link
Copy Markdown
Author

Sadly I don't have such a table, previously I wasn't capturing the time and cost, only started keeping track recently.

I might start recording that in the list above, but as you said, it is a really noisy test and is more useful to give you yet another data-point for relative strenghts of models in an intuitive/visual/silly way, than to provide hard data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment