Skip to content

Instantly share code, notes, and snippets.

@senko
Created June 10, 2026 21:48
Show Gist options
  • Select an option

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

Select an option

Save senko/a1b3a5a128742fd9674bd5d823d905f9 to your computer and use it in GitHub Desktop.
RTS game by Fable 5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tiny Realm — a mini RTS</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; overflow: hidden; background: #0b0e14;
font-family: "Segoe UI", "Trebuchet MS", Verdana, sans-serif; user-select: none;
-webkit-user-select: none; }
canvas { display: block; }
#game { position: fixed; inset: 0; cursor: default; }
/* ---------- top bar ---------- */
#topbar {
position: fixed; top: 0; left: 0; right: 0; height: 42px; z-index: 10;
display: flex; align-items: center; gap: 22px; padding: 0 16px;
background: linear-gradient(#2b2418, #1a150d);
border-bottom: 2px solid #6b5631; box-shadow: 0 2px 10px rgba(0,0,0,.6);
color: #e8d9a8; font-size: 15px; font-weight: 600;
}
#topbar .res { display: flex; align-items: center; gap: 6px; min-width: 86px; }
#topbar .res .ico { font-size: 17px; }
#topbar .spacer { flex: 1; }
#topbar .title { color: #c9b06a; font-size: 15px; letter-spacing: 2px; font-weight: 700; }
#helpBtn {
background: linear-gradient(#5a4a2a, #3d321c); border: 1px solid #8a7340; color: #ffe9b0;
border-radius: 6px; padding: 4px 12px; cursor: pointer; font-weight: 700; font-size: 13px;
}
#helpBtn:hover { filter: brightness(1.2); }
/* ---------- bottom bar ---------- */
#bottom {
position: fixed; left: 0; right: 0; bottom: 0; height: 158px; z-index: 10;
display: flex; gap: 10px; padding: 8px;
background: linear-gradient(#2b2418, #15110a);
border-top: 2px solid #6b5631; box-shadow: 0 -2px 12px rgba(0,0,0,.6);
}
.panel {
background: rgba(0,0,0,.35); border: 1px solid #564626; border-radius: 8px;
}
#minimapWrap { width: 150px; height: 140px; padding: 3px; flex: none; }
#minimap { width: 142px; height: 132px; image-rendering: pixelated; cursor: pointer;
border-radius: 4px; }
#info { flex: 1; padding: 10px 14px; color: #e8d9a8; overflow: hidden; min-width: 0; }
#info h3 { margin: 0 0 6px 0; font-size: 16px; color: #ffe9b0; display:flex; align-items:center; gap:8px; }
#info h3 .bigico { font-size: 24px; }
#info .sub { font-size: 12px; color: #b3a274; margin-bottom: 6px; }
.hpbarOuter { width: 220px; height: 10px; background: #2a2a2a; border: 1px solid #000;
border-radius: 4px; overflow: hidden; margin: 4px 0; }
.hpbarInner { height: 100%; background: linear-gradient(#7ce06b, #3f9e33); }
.qrow { display: flex; gap: 6px; margin-top: 6px; align-items: center; }
.qitem { width: 34px; height: 34px; border: 1px solid #6b5631; border-radius: 6px;
background: #1e1a10; display: flex; align-items: center; justify-content: center;
font-size: 18px; cursor: pointer; position: relative; overflow: hidden; }
.qitem:hover { border-color: #d44; }
.qitem .qprog { position: absolute; left: 0; bottom: 0; height: 4px; background: #f3c33c; }
.qhint { font-size: 11px; color: #8d7d52; }
#cmds { width: 332px; flex: none; padding: 8px; display: grid;
grid-template-columns: repeat(3, 1fr); grid-auto-rows: 42px; gap: 6px; align-content: start; }
.cmd {
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: linear-gradient(#4d4128, #2e2716); color: #ffe9b0;
border: 1px solid #8a7340; border-radius: 6px; cursor: pointer;
font-size: 12px; font-weight: 700; line-height: 1.1; padding: 2px;
}
.cmd small { font-weight: 400; font-size: 10px; color: #cdb877; }
.cmd:hover:not(.off) { filter: brightness(1.25); }
.cmd:active:not(.off) { transform: translateY(1px); }
.cmd.off { opacity: .38; cursor: not-allowed; }
.cmd.danger { border-color: #a33; color: #ffb9a8; }
/* ---------- toast & overlays ---------- */
#toast {
position: fixed; bottom: 172px; left: 50%; transform: translateX(-50%); z-index: 20;
background: rgba(20,14,4,.92); border: 1px solid #8a7340; color: #ffe9b0;
padding: 7px 18px; border-radius: 8px; font-size: 14px; pointer-events: none;
opacity: 0; transition: opacity .25s;
}
.overlay {
position: fixed; inset: 0; z-index: 50; background: rgba(5,7,12,.82);
display: flex; align-items: center; justify-content: center;
}
.card {
width: 560px; max-width: 92vw; max-height: 86vh; overflow: auto;
background: linear-gradient(#332a18, #1d160b); border: 2px solid #8a7340;
border-radius: 14px; padding: 26px 32px; color: #e8d9a8; box-shadow: 0 10px 60px #000;
}
.card h1 { margin: 0 0 4px; color: #ffd87a; letter-spacing: 3px; font-size: 30px; text-align: center; }
.card h2 { margin: 0 0 18px; color: #b3a274; font-size: 14px; font-weight: 400; text-align: center; }
.card h4 { color: #ffd87a; margin: 14px 0 6px; }
.card p, .card li { font-size: 13.5px; line-height: 1.55; }
.card ul { margin: 4px 0; padding-left: 20px; }
.card kbd { background: #11202b; border: 1px solid #46627a; border-radius: 4px;
padding: 1px 6px; font-size: 12px; color: #cfe6ff; }
.bigbtn {
display: block; margin: 20px auto 0; padding: 10px 44px; font-size: 17px; font-weight: 700;
background: linear-gradient(#7a9c3f, #4e6b23); color: #f4ffd9; border: 1px solid #aacc66;
border-radius: 8px; cursor: pointer; letter-spacing: 1px;
}
.bigbtn:hover { filter: brightness(1.15); }
.winstats { text-align:center; font-size: 15px; margin-top: 10px; color:#ffe9b0; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="topbar">
<span class="title">⚜ TINY REALM</span>
<span class="res"><span class="ico">🟡</span><span id="rGold">0</span></span>
<span class="res"><span class="ico">🪵</span><span id="rWood">0</span></span>
<span class="res"><span class="ico">👥</span><span id="rSupply">0/0</span></span>
<span class="res"><span class="ico">🗺️</span><span id="rExplored">0%</span></span>
<span class="spacer"></span>
<span class="res" style="min-width:0"><span id="rTime">0:00</span></span>
<button id="helpBtn">? Help</button>
</div>
<div id="bottom">
<div id="minimapWrap" class="panel"><canvas id="minimap" width="144" height="144"></canvas></div>
<div id="info" class="panel"></div>
<div id="cmds" class="panel"></div>
</div>
<div id="toast"></div>
<div id="intro" class="overlay">
<div class="card">
<h1>⚜ TINY REALM</h1>
<h2>a tiny real-time strategy game</h2>
<p><b style="color:#ffd87a">Your goal:</b> build up your settlement and
<b>uncover the entire map</b>. Gather <b>🟡 gold</b> from mines and <b>🪵 wood</b>
from forests, raise buildings, train an army — and beware, giant spiders 🕷️ guard
the richest mines.</p>
<h4>Controls</h4>
<ul>
<li><b>Left-click / drag</b> — select your units &amp; buildings</li>
<li><b>Right-click</b> — move, gather, attack, or resume construction</li>
<li><b>WASD / arrows / edge of screen / minimap</b> — scroll the map</li>
<li><kbd>Esc</kbd> cancel &amp; deselect &nbsp;·&nbsp; <kbd>S</kbd> stop &nbsp;·&nbsp; <kbd>Space</kbd> jump to Town Hall &nbsp;·&nbsp; <kbd>H</kbd> help</li>
</ul>
<h4>Tips</h4>
<ul>
<li>Workers build everything — select one and use the build buttons.</li>
<li>Farms and Town Halls raise your supply cap.</li>
<li>Watchtowers reveal a huge area — great for exploring.</li>
<li>A building site paused? Right-click it with a worker to resume.</li>
</ul>
<button class="bigbtn" id="startBtn">⚔ &nbsp;PLAY&nbsp; ⚔</button>
</div>
</div>
<div id="winOverlay" class="overlay" style="display:none">
<div class="card">
<h1>🎉 VICTORY</h1>
<h2>The whole realm lies revealed before you!</h2>
<div class="winstats" id="winStats"></div>
<button class="bigbtn" id="continueBtn">Keep playing</button>
</div>
</div>
<script>
'use strict';
/* =====================================================================
TINY REALM — a single-file mini RTS
===================================================================== */
// ------------------------------ constants ---------------------------
const TILE = 32, MW = 72, MH = 72;
const T = { GRASS: 0, WATER: 1, TREE: 2, STUMP: 3 };
const REACH = 27; // px, melee / interact reach to a building rect
const TREE_REACH = 50; // px, reach to a tree tile centre
const CARRY_MAX = 10;
const BLD = {
townhall: { name: 'Town Hall', w: 3, h: 3, hp: 1200, cost: { g: 350, w: 250 }, time: 35, sight: 9, supply: 10, icon: '🏰', desc: 'Resource drop-off. Trains workers. +10 supply.' },
farm: { name: 'Farm', w: 2, h: 2, hp: 350, cost: { g: 60, w: 40 }, time: 14, sight: 5, supply: 6, icon: '🌾', desc: '+6 supply.' },
barracks: { name: 'Barracks', w: 3, h: 3, hp: 800, cost: { g: 120, w: 80 }, time: 22, sight: 7, supply: 0, icon: '⚔️', desc: 'Trains soldiers and archers.' },
tower: { name: 'Watchtower', w: 2, h: 2, hp: 500, cost: { g: 40, w: 80 }, time: 12, sight: 13, supply: 0, icon: '🗼', desc: 'Reveals a large area.' },
};
const UDEF = {
worker: { name: 'Worker', hp: 50, speed: 95, dmg: 4, range: 26, atkSpd: 1.1, sight: 7, cost: { g: 50, w: 0 }, time: 8, icon: '👷', desc: 'Gathers resources and builds.' },
soldier: { name: 'Soldier', hp: 120, dmg: 11, speed: 90, range: 28, atkSpd: 0.9, sight: 7, cost: { g: 90, w: 10 }, time: 10, icon: '🛡️', desc: 'Sturdy melee fighter.' },
archer: { name: 'Archer', hp: 70, dmg: 8, speed: 95, range: 160, atkSpd: 1.1, sight: 8, cost: { g: 70, w: 40 }, time: 11, icon: '🏹', desc: 'Ranged attacker.' },
spider: { name: 'Giant Spider', hp: 70, dmg: 7, speed: 100, range: 26, atkSpd: 1.0, sight: 5, cost: { g: 0, w: 0 }, time: 0, icon: '🕷️', desc: 'Guards the wild gold mines.' },
};
// ------------------------------ state -------------------------------
const terrain = new Uint8Array(MW * MH);
const treeWood = new Uint16Array(MW * MH);
const bgrid = new Array(MW * MH).fill(null); // building or mine occupying tile
const explored = new Uint8Array(MW * MH);
const visible = new Uint8Array(MW * MH);
const treeVar = new Uint8Array(MW * MH); // cosmetic per-tile hash
let units = [], buildings = [], mines = [], fx = [];
let sel = []; // current selection (entities)
let res = { g: 300, w: 250 };
let supplyMax = 0, supplyUsed = 0;
let exploredCount = 0;
let cam = { x: 0, y: 0 };
let gameTime = 0, started = false, won = false;
let placing = null; // {kind} while placing a building
let mouse = { x: 0, y: 0, inside: false, drag: null, mmPan: false };
let visTimer = 0, uiTimer = 0, selDirty = true;
let now0 = performance.now();
const idx = (x, y) => y * MW + x;
const inB = (x, y) => x >= 0 && y >= 0 && x < MW && y < MH;
const dist = (ax, ay, bx, by) => Math.hypot(bx - ax, by - ay);
const tOf = v => Math.floor(v / TILE);
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const hash2 = (x, y) => { let h = (x * 374761393 + y * 668265263) | 0; h = (h ^ (h >> 13)) * 1274126177; return ((h ^ (h >> 16)) >>> 0); };
function isBlocked(x, y) {
if (!inB(x, y)) return true;
const t = terrain[idx(x, y)];
return t === T.WATER || t === T.TREE || bgrid[idx(x, y)] !== null;
}
// ------------------------------ map generation ----------------------
function genMap() {
for (let tries = 0; tries < 40; tries++) {
terrain.fill(T.GRASS); treeWood.fill(0); bgrid.fill(null);
mines = []; units = []; buildings = [];
for (let i = 0; i < MW * MH; i++) treeVar[i] = hash2(i % MW, (i / MW) | 0) & 255;
const blob = (cx, cy, size, type) => {
let frontier = [[cx, cy]], placed = 0;
const seen = new Set([cx + ',' + cy]);
while (frontier.length && placed < size) {
const [x, y] = frontier.splice((Math.random() * frontier.length) | 0, 1)[0];
if (!inB(x, y)) continue;
terrain[idx(x, y)] = type;
if (type === T.TREE) treeWood[idx(x, y)] = 120;
placed++;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const k = (x+dx) + ',' + (y+dy);
if (!seen.has(k) && Math.random() < 0.75) { seen.add(k); frontier.push([x+dx, y+dy]); }
}
}
};
// lakes
for (let i = 0; i < 6; i++)
blob(8 + (Math.random() * (MW - 16)) | 0, 8 + (Math.random() * (MH - 16)) | 0, 12 + Math.random() * 22, T.WATER);
// forests
for (let i = 0; i < 11; i++)
blob((Math.random() * MW) | 0, (Math.random() * MH) | 0, 25 + Math.random() * 45, T.TREE);
// home base area
const hx = 9, hy = 9;
for (let y = hy - 6; y <= hy + 8; y++) for (let x = hx - 6; x <= hx + 8; x++)
if (inB(x, y)) { terrain[idx(x, y)] = T.GRASS; treeWood[idx(x, y)] = 0; }
// a guaranteed starter forest
blob(hx + 9, hy - 2, 30, T.TREE);
// gold mines: one near home + 5 wild ones spread out
const mineSpots = [{ x: hx - 2, y: hy + 7 }];
let guard = 0;
while (mineSpots.length < 6 && guard++ < 4000) {
const x = 4 + (Math.random() * (MW - 10)) | 0, y = 4 + (Math.random() * (MH - 10)) | 0;
if (mineSpots.every(m => dist(m.x, m.y, x, y) > 17) && dist(x, y, hx, hy) > 20) mineSpots.push({ x, y });
}
if (mineSpots.length < 6) continue;
for (const s of mineSpots) {
for (let y = s.y - 1; y <= s.y + 2; y++) for (let x = s.x - 1; x <= s.x + 2; x++)
if (inB(x, y)) { terrain[idx(x, y)] = T.GRASS; treeWood[idx(x, y)] = 0; }
const m = { kind: 'mine', tx: s.x, ty: s.y, w: 2, h: 2, gold: 3000, depleted: false };
mines.push(m);
for (let y = s.y; y < s.y + 2; y++) for (let x = s.x; x < s.x + 2; x++) bgrid[idx(x, y)] = m;
}
// town hall
const th = makeBuilding('townhall', hx, hy, true);
// reachability: flood from base, trees count as passable (they can be chopped)
const reach = new Uint8Array(MW * MH);
const q = [[hx - 1, hy]]; reach[idx(hx - 1, hy)] = 1;
while (q.length) {
const [x, y] = q.pop();
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nx = x + dx, ny = y + dy;
if (!inB(nx, ny) || reach[idx(nx, ny)]) continue;
const t = terrain[idx(nx, ny)];
if (t === T.WATER || bgrid[idx(nx, ny)]) continue;
reach[idx(nx, ny)] = 1; q.push([nx, ny]);
}
}
// every mine must be touchable, every tile must be visible from a reachable tile
const mineOk = mines.every(m => {
for (let y = m.ty - 1; y <= m.ty + m.h; y++) for (let x = m.tx - 1; x <= m.tx + m.w; x++)
if (inB(x, y) && reach[idx(x, y)]) return true;
return false;
});
let coverOk = mineOk;
if (coverOk) outer:
for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
let seen = false;
for (let yy = Math.max(0, y - 5); yy <= Math.min(MH - 1, y + 5) && !seen; yy++)
for (let xx = Math.max(0, x - 5); xx <= Math.min(MW - 1, x + 5); xx++)
if (reach[idx(xx, yy)] && dist(x, y, xx, yy) <= 5.2) { seen = true; break; }
if (!seen) { coverOk = false; break outer; }
}
if (!coverOk) continue;
// starting workers
for (let i = 0; i < 4; i++) spawnUnit('worker', 0, hx + 3 + i, hy + 3);
// spiders guard the wild mines (not the home one)
for (let mi = 1; mi < mines.length; mi++) {
const m = mines[mi];
const n = 2 + (mi % 2);
for (let i = 0; i < n; i++) {
const t = freeTileNear(m.tx - 2 + ((i * 5) % 7), m.ty - 2 + ((i * 3) % 7));
if (t) {
const u = spawnUnit('spider', 1, t.x, t.y);
u.guard = { x: u.x, y: u.y };
}
}
}
cam.x = clamp(th.tx * TILE + th.w * TILE / 2 - VW / 2, 0, Math.max(0, MW * TILE - VW));
cam.y = clamp(th.ty * TILE + th.h * TILE / 2 - VH / 2, 0, Math.max(0, MH * TILE - VH + 158));
return;
}
throw new Error('map generation failed');
}
// ------------------------------ entities ----------------------------
function makeBuilding(kind, tx, ty, finished) {
const d = BLD[kind];
const b = { kind, tx, ty, w: d.w, h: d.h, hp: d.hp, maxHp: d.hp,
progress: finished ? d.time : 0, built: !!finished,
queue: [], rally: null, dead: false };
buildings.push(b);
for (let y = ty; y < ty + d.h; y++) for (let x = tx; x < tx + d.w; x++) bgrid[idx(x, y)] = b;
recalcSupply(); visTimer = 99;
return b;
}
function spawnUnit(kind, faction, tx, ty) {
const d = UDEF[kind];
const u = { kind, faction, x: tx * TILE + 16, y: ty * TILE + 16,
hp: d.hp, maxHp: d.hp, speed: d.speed, dmg: d.dmg, range: d.range,
atkSpd: d.atkSpd, sight: d.sight,
order: { kind: 'idle' }, path: null, pathI: 0, destT: null,
carry: null, lastGather: null, work: 0, cd: 0, repath: 0,
flash: 0, moving: false, dir: 1, bob: Math.random() * 7, dead: false };
units.push(u);
if (faction === 0) recalcSupply();
return u;
}
function recalcSupply() {
supplyUsed = units.filter(u => u.faction === 0 && !u.dead).length;
supplyMax = Math.min(100, buildings.reduce((s, b) => s + (b.built && !b.dead ? (BLD[b.kind].supply || 0) : 0), 0));
}
function kill(e) {
e.dead = true;
if (e.kind === 'spider') {
res.g += 25; toast('🕷️ Spider slain! +25 gold');
fx.push({ type: 'splat', x: e.x, y: e.y, t: 0 });
}
sel = sel.filter(s => s !== e); selDirty = true;
recalcSupply();
}
// ------------------------------ pathfinding (A*) --------------------
const PF_N = MW * MH;
const pfG = new Float32Array(PF_N), pfF = new Float32Array(PF_N);
const pfCame = new Int32Array(PF_N), pfGen = new Int32Array(PF_N), pfClosed = new Int32Array(PF_N);
let pfCur = 0;
function findPath(sx, sy, tx, ty) {
if (sx === tx && sy === ty) return [];
if (isBlocked(tx, ty)) return null;
pfCur++;
const open = []; // binary heap of node indices
const push = i => { open.push(i); let c = open.length - 1;
while (c > 0) { const p = (c - 1) >> 1; if (pfF[open[c]] < pfF[open[p]]) { [open[c], open[p]] = [open[p], open[c]]; c = p; } else break; } };
const pop = () => { const top = open[0], last = open.pop();
if (open.length) { open[0] = last; let i = 0;
for (;;) { const l = 2 * i + 1, r = l + 1; let m = i;
if (l < open.length && pfF[open[l]] < pfF[open[m]]) m = l;
if (r < open.length && pfF[open[r]] < pfF[open[m]]) m = r;
if (m === i) break; [open[i], open[m]] = [open[m], open[i]]; i = m; } }
return top; };
const h = (x, y) => Math.hypot(tx - x, ty - y);
const s = idx(sx, sy);
pfG[s] = 0; pfF[s] = h(sx, sy); pfCame[s] = -1; pfGen[s] = pfCur;
push(s);
let iter = 0;
while (open.length && iter++ < 14000) {
const cur = pop();
if (pfClosed[cur] === pfCur) continue;
pfClosed[cur] = pfCur;
const cx = cur % MW, cy = (cur / MW) | 0;
if (cx === tx && cy === ty) {
const path = [];
for (let n = cur; n !== s; n = pfCame[n]) path.push({ x: n % MW, y: (n / MW) | 0 });
return path.reverse();
}
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx, ny = cy + dy;
if (isBlocked(nx, ny)) continue;
if (dx && dy && (isBlocked(cx + dx, cy) || isBlocked(cx, cy + dy))) continue; // no corner cutting
const ni = idx(nx, ny);
if (pfClosed[ni] === pfCur) continue;
const g = pfG[cur] + (dx && dy ? 1.4142 : 1);
if (pfGen[ni] !== pfCur || g < pfG[ni]) {
pfGen[ni] = pfCur; pfG[ni] = g; pfF[ni] = g + h(nx, ny); pfCame[ni] = cur;
push(ni);
}
}
}
return null;
}
function setPath(u, tx, ty) {
const p = findPath(tOf(u.x), tOf(u.y), tx, ty);
if (!p) return false;
u.path = p; u.pathI = 0; u.destT = { x: tx, y: ty };
return true;
}
function followPath(u, dt) {
if (!u.path) return false;
if (u.pathI >= u.path.length) { u.path = null; return false; }
const wp = u.path[u.pathI];
if (isBlocked(wp.x, wp.y)) { // something was built in the way
if (!u.destT || !setPath(u, u.destT.x, u.destT.y)) { u.path = null; return false; }
return true;
}
const px = wp.x * TILE + 16, py = wp.y * TILE + 16;
const dx = px - u.x, dy = py - u.y, d = Math.hypot(dx, dy), step = u.speed * dt;
if (d <= step) { u.x = px; u.y = py; u.pathI++; if (u.pathI >= u.path.length) { u.path = null; return false; } }
else { u.x += dx / d * step; u.y += dy / d * step; if (Math.abs(dx) > 2) u.dir = dx > 0 ? 1 : -1; }
u.moving = true;
return true;
}
// ------------------------------ spatial helpers ---------------------
function* spiral(cx, cy, maxR) {
yield { x: cx, y: cy };
for (let r = 1; r <= maxR; r++) {
for (let x = cx - r; x <= cx + r; x++) { yield { x, y: cy - r }; yield { x, y: cy + r }; }
for (let y = cy - r + 1; y <= cy + r - 1; y++) { yield { x: cx - r, y }; yield { x: cx + r, y }; }
}
}
function unitOn(tx, ty) {
return units.some(u => !u.dead && tOf(u.x) === tx && tOf(u.y) === ty);
}
function freeTileNear(tx, ty, maxR = 8, noUnits = true) {
for (const t of spiral(tx, ty, maxR))
if (inB(t.x, t.y) && !isBlocked(t.x, t.y) && (!noUnits || !unitOn(t.x, t.y))) return t;
return null;
}
function nearestFreeAdj(u, tx, ty, w, h) {
let best = null, bd = 1e9;
for (let x = tx - 1; x <= tx + w; x++) for (let y = ty - 1; y <= ty + h; y++) {
const onEdge = x === tx - 1 || x === tx + w || y === ty - 1 || y === ty + h;
if (!onEdge || !inB(x, y) || isBlocked(x, y)) continue;
const d = dist(u.x, u.y, x * TILE + 16, y * TILE + 16);
if (d < bd) { bd = d; best = { x, y }; }
}
return best;
}
function distToRect(px, py, e) {
const x1 = e.tx * TILE, y1 = e.ty * TILE, x2 = x1 + e.w * TILE, y2 = y1 + e.h * TILE;
const dx = Math.max(x1 - px, 0, px - x2), dy = Math.max(y1 - py, 0, py - y2);
return Math.hypot(dx, dy);
}
function findNearestTree(tx, ty, r) {
let best = null, bd = 1e9;
for (let y = Math.max(0, ty - r); y <= Math.min(MH - 1, ty + r); y++)
for (let x = Math.max(0, tx - r); x <= Math.min(MW - 1, tx + r); x++)
if (terrain[idx(x, y)] === T.TREE) {
const d = dist(tx, ty, x, y);
if (d < bd) { bd = d; best = { x, y }; }
}
return best;
}
function nearestDepot(u) {
let best = null, bd = 1e9;
for (const b of buildings)
if (b.kind === 'townhall' && b.built && !b.dead) {
const d = distToRect(u.x, u.y, b);
if (d < bd) { bd = d; best = b; }
}
return best;
}
// ------------------------------ orders / unit AI --------------------
function orderMove(u, tx, ty) { u.order = { kind: 'move' }; u.work = 0; setPath(u, tx, ty); }
function reachOrPath(u, dt, tx, ty, w, h, reachPx, ent) {
// returns true once standing in reach of a rect; walks there otherwise
const d = ent ? distToRect(u.x, u.y, ent)
: dist(u.x, u.y, tx * TILE + 16, ty * TILE + 16);
if (d <= reachPx) { u.path = null; return true; }
if (!followPath(u, dt)) {
const t = nearestFreeAdj(u, tx, ty, w, h);
if (!t || !setPath(u, t.x, t.y)) u.order = { kind: 'idle' };
}
return false;
}
function updateUnit(u, dt) {
u.moving = false;
if (u.flash > 0) u.flash -= dt;
if (u.cd > 0) u.cd -= dt;
u.repath -= dt;
const o = u.order;
if (u.kind === 'spider') { updateSpider(u, dt); return; }
switch (o.kind) {
case 'idle': break;
case 'move':
if (!followPath(u, dt)) u.order = { kind: 'idle' };
break;
case 'chop': {
if (u.carry && u.carry.type === 'wood' && u.carry.amt >= CARRY_MAX) { u.order = { kind: 'return' }; break; }
let i = idx(o.tx, o.ty);
if (terrain[i] !== T.TREE) {
const nt = findNearestTree(o.tx, o.ty, 7);
if (nt) { o.tx = nt.x; o.ty = nt.y; u.path = null; i = idx(o.tx, o.ty); }
else { u.order = (u.carry && u.carry.amt) ? { kind: 'return' } : { kind: 'idle' }; break; }
}
if (dist(u.x, u.y, o.tx * TILE + 16, o.ty * TILE + 16) <= TREE_REACH) {
u.path = null;
u.dir = (o.tx * TILE + 16) > u.x ? 1 : -1;
u.work += dt; u.anim = 'work';
if (u.work >= 0.45) {
u.work = 0;
if (!u.carry || u.carry.type !== 'wood') u.carry = { type: 'wood', amt: 0 };
const take = Math.min(2, treeWood[i], CARRY_MAX - u.carry.amt);
u.carry.amt += take; treeWood[i] -= take;
fx.push({ type: 'chip', x: o.tx * TILE + 16, y: o.ty * TILE + 10, t: 0 });
if (treeWood[i] <= 0) terrain[i] = T.STUMP;
if (u.carry.amt >= CARRY_MAX) { u.lastGather = { ...o }; u.order = { kind: 'return' }; }
}
} else if (!followPath(u, dt)) {
const t = nearestFreeAdj(u, o.tx, o.ty, 1, 1);
if (!t || !setPath(u, t.x, t.y)) u.order = { kind: 'idle' };
}
break;
}
case 'mine': {
const m = o.mine;
if (m.depleted) {
const alt = mines.find(mm => !mm.depleted && distToRect(u.x, u.y, mm) < 12 * TILE);
if (alt) { o.mine = alt; u.path = null; }
else { u.order = (u.carry && u.carry.amt) ? { kind: 'return' } : { kind: 'idle' }; }
break;
}
if (u.carry && u.carry.type === 'gold' && u.carry.amt >= CARRY_MAX) { u.lastGather = { ...o }; u.order = { kind: 'return' }; break; }
if (reachOrPath(u, dt, m.tx, m.ty, m.w, m.h, REACH, m)) {
u.dir = (m.tx * TILE + 32) > u.x ? 1 : -1;
u.work += dt; u.anim = 'work';
if (u.work >= 0.5) {
u.work = 0;
if (!u.carry || u.carry.type !== 'gold') u.carry = { type: 'gold', amt: 0 };
const take = Math.min(2, m.gold, CARRY_MAX - u.carry.amt);
u.carry.amt += take; m.gold -= take;
if (m.gold <= 0) { m.depleted = true; toast('⛏️ A gold mine has collapsed — it is empty!'); }
if (u.carry.amt >= CARRY_MAX) { u.lastGather = { ...o }; u.order = { kind: 'return' }; }
}
}
break;
}
case 'return': {
const d = nearestDepot(u);
if (!d) { u.order = { kind: 'idle' }; break; }
if (reachOrPath(u, dt, d.tx, d.ty, d.w, d.h, REACH, d)) {
if (u.carry) { res[u.carry.type === 'gold' ? 'g' : 'w'] += u.carry.amt; u.carry = null; }
if (u.lastGather) { u.order = { ...u.lastGather }; u.path = null; }
else u.order = { kind: 'idle' };
}
break;
}
case 'build': {
const b = o.b;
if (b.dead) { u.order = { kind: 'idle' }; break; }
if (b.built) { u.order = { kind: 'idle' }; break; }
if (reachOrPath(u, dt, b.tx, b.ty, b.w, b.h, REACH, b)) {
u.anim = 'work'; u.work += dt;
u.dir = (b.tx * TILE + b.w * 16) > u.x ? 1 : -1;
b.progress += dt;
if (b.progress >= BLD[b.kind].time) finishBuilding(b);
}
break;
}
case 'attack': {
const t = o.target;
if (!t || t.dead) { u.order = { kind: 'idle' }; break; }
const d = dist(u.x, u.y, t.x, t.y);
if (d <= u.range + 6) {
u.path = null;
u.dir = t.x > u.x ? 1 : -1;
u.anim = 'fight';
if (u.cd <= 0) {
u.cd = u.atkSpd;
if (u.kind === 'archer') fx.push({ type: 'arrow', x: u.x, y: u.y - 8, tx: t.x, ty: t.y - 6, t: 0, d: dist(u.x, u.y, t.x, t.y) });
else fx.push({ type: 'hit', x: t.x + (Math.random() * 10 - 5), y: t.y - 8, t: 0 });
t.hp -= u.dmg; t.flash = 0.18;
aggroSpider(t, u);
if (t.hp <= 0) kill(t);
}
} else {
if (!u.path && u.repath <= 0) {
u.repath = 0.5;
const tt = freeTileNear(tOf(t.x), tOf(t.y), 4);
if (tt) setPath(u, tt.x, tt.y);
}
followPath(u, dt);
}
break;
}
}
}
function aggroSpider(victim, attacker) {
if (victim.kind === 'spider' && !victim.dead && victim.order.kind !== 'attack')
victim.order = { kind: 'attack', target: attacker };
}
function updateSpider(u, dt) {
const o = u.order;
if (o.kind === 'attack') {
const t = o.target;
const far = dist(u.x, u.y, u.guard.x, u.guard.y) > 9 * TILE;
if (!t || t.dead || far) { u.order = { kind: 'home' }; u.path = null; return; }
const d = dist(u.x, u.y, t.x, t.y);
if (d <= u.range + 4) {
u.path = null; u.dir = t.x > u.x ? 1 : -1;
if (u.cd <= 0) {
u.cd = u.atkSpd; t.hp -= u.dmg; t.flash = 0.18;
fx.push({ type: 'hit', x: t.x, y: t.y - 8, t: 0 });
if (t.hp <= 0) kill(t);
}
} else {
if (!u.path && u.repath <= 0) {
u.repath = 0.4;
const tt = freeTileNear(tOf(t.x), tOf(t.y), 3);
if (tt) setPath(u, tt.x, tt.y);
}
followPath(u, dt);
}
return;
}
if (o.kind === 'home') {
if (!u.path) { const t = freeTileNear(tOf(u.guard.x), tOf(u.guard.y), 3); if (t) setPath(u, t.x, t.y); }
if (!followPath(u, dt)) { u.order = { kind: 'idle' }; u.hp = Math.min(u.maxHp, u.hp + 20); }
return;
}
// idle: look for prey
let best = null, bd = 4.2 * TILE;
for (const e of units)
if (e.faction === 0 && !e.dead) {
const d = dist(u.x, u.y, e.x, e.y);
if (d < bd) { bd = d; best = e; }
}
if (best) u.order = { kind: 'attack', target: best };
}
// keep units from stacking on the same spot
function separate() {
for (let i = 0; i < units.length; i++) {
const a = units[i]; if (a.dead) continue;
for (let j = i + 1; j < units.length; j++) {
const b = units[j]; if (b.dead) continue;
let dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy);
if (d > 18 || d === 0) continue;
dx /= d; dy /= d;
const push = (18 - d) / 2;
const ax = a.x - dx * push, ay = a.y - dy * push;
const bx2 = b.x + dx * push, by2 = b.y + dy * push;
if (!isBlocked(tOf(ax), tOf(ay))) { a.x = ax; a.y = ay; }
if (!isBlocked(tOf(bx2), tOf(by2))) { b.x = bx2; b.y = by2; }
}
}
}
// ------------------------------ buildings ---------------------------
function finishBuilding(b) {
b.built = true; b.progress = BLD[b.kind].time; b.hp = b.maxHp;
recalcSupply(); visTimer = 99; selDirty = true;
toast(`${BLD[b.kind].icon} ${BLD[b.kind].name} completed!`);
}
function canAfford(c) { return res.g >= c.g && res.w >= c.w; }
function pay(c) { res.g -= c.g; res.w -= c.w; }
function refund(c) { res.g += c.g; res.w += c.w; }
function tryTrain(b, kind) {
const d = UDEF[kind];
if (!canAfford(d.cost)) { toast('Not enough resources!'); return; }
if (supplyUsed + b.queue.length >= supplyMax) { toast('Build more farms — supply cap reached!'); return; }
if (b.queue.length >= 5) { toast('Queue is full.'); return; }
pay(d.cost);
b.queue.push({ kind, t: 0 });
selDirty = true;
}
function updateBuilding(b, dt) {
if (!b.built || !b.queue.length) return;
const q = b.queue[0];
q.t += dt;
if (q.t >= UDEF[q.kind].time) {
b.queue.shift();
const spot = freeTileNear(b.tx + (b.w >> 1), b.ty + b.h, 7) || freeTileNear(b.tx, b.ty - 1, 7);
if (spot) {
const u = spawnUnit(q.kind, 0, spot.x, spot.y);
if (b.rally) orderMove(u, clamp(tOf(b.rally.x), 0, MW - 1), clamp(tOf(b.rally.y), 0, MH - 1));
}
selDirty = true;
}
}
// ------------------------------ placement ---------------------------
function placementTiles() {
const d = BLD[placing.kind];
const wx = cam.x + mouse.x, wy = cam.y + mouse.y;
const tx = clamp(Math.round(wx / TILE - d.w / 2), 0, MW - d.w);
const ty = clamp(Math.round(wy / TILE - d.h / 2), 0, MH - d.h);
return { tx, ty, d };
}
function placementValid(tx, ty, d) {
for (let y = ty; y < ty + d.h; y++) for (let x = tx; x < tx + d.w; x++) {
const t = terrain[idx(x, y)];
if ((t !== T.GRASS && t !== T.STUMP) || bgrid[idx(x, y)] || !explored[idx(x, y)] || unitOn(x, y)) return false;
}
return true;
}
function tryPlace() {
const { tx, ty, d } = placementTiles();
if (!placementValid(tx, ty, d)) { toast('Cannot build there.'); return; }
if (!canAfford(d.cost)) { toast('Not enough resources!'); return; }
pay(d.cost);
const b = makeBuilding(placing.kind, tx, ty, false);
const workers = sel.filter(e => e.kind === 'worker' && !e.dead);
for (const w of workers) { w.order = { kind: 'build', b }; w.path = null; }
placing = null; selDirty = true;
}
// ------------------------------ vision / fog ------------------------
const fogCv = document.createElement('canvas'); fogCv.width = MW; fogCv.height = MH;
const fogCtx = fogCv.getContext('2d');
const fogImg = fogCtx.createImageData(MW, MH);
function markCircle(cx, cy, r) {
const r2 = r * r;
for (let y = Math.max(0, cy - r); y <= Math.min(MH - 1, cy + r); y++)
for (let x = Math.max(0, cx - r); x <= Math.min(MW - 1, cx + r); x++) {
const dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy <= r2 + r * 0.5) {
const i = idx(x, y);
visible[i] = 1;
if (!explored[i]) { explored[i] = 1; exploredCount++; }
}
}
}
function recomputeVision() {
visible.fill(0);
for (const u of units) if (u.faction === 0 && !u.dead) markCircle(tOf(u.x), tOf(u.y), u.sight);
for (const b of buildings) if (!b.dead) markCircle(b.tx + (b.w >> 1), b.ty + (b.h >> 1), b.built ? BLD[b.kind].sight : 4);
const px = fogImg.data;
for (let i = 0; i < MW * MH; i++) {
const o = i * 4;
px[o] = 8; px[o + 1] = 10; px[o + 2] = 18;
px[o + 3] = explored[i] ? (visible[i] ? 0 : 115) : 255;
}
fogCtx.putImageData(fogImg, 0, 0);
if (!won && exploredCount >= MW * MH) triggerWin();
}
function triggerWin() {
won = true;
const m = Math.floor(gameTime / 60), s = Math.floor(gameTime % 60);
document.getElementById('winStats').innerHTML =
`Time: <b>${m}:${String(s).padStart(2, '0')}</b> &nbsp;·&nbsp; Units: <b>${supplyUsed}</b> &nbsp;·&nbsp; Buildings: <b>${buildings.filter(b => b.built).length}</b>`;
document.getElementById('winOverlay').style.display = 'flex';
}
// ------------------------------ rendering: terrain prerender --------
const terrCv = document.createElement('canvas');
terrCv.width = MW * TILE; terrCv.height = MH * TILE;
function prerenderTerrain() {
const c = terrCv.getContext('2d');
for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
const i = idx(x, y), v = treeVar[i], px = x * TILE, py = y * TILE;
if (terrain[i] === T.WATER) {
c.fillStyle = (v & 1) ? '#27628f' : '#246089';
c.fillRect(px, py, TILE, TILE);
if (v % 5 === 0) { c.fillStyle = 'rgba(255,255,255,.10)'; c.fillRect(px + 6 + (v % 13), py + 10 + (v % 7), 9, 2); }
} else {
const g = 64 + (v % 4) * 5;
c.fillStyle = `rgb(${52 + (v % 3) * 4},${110 + (v % 4) * 6},${56 + (v % 3) * 4})`;
c.fillRect(px, py, TILE, TILE);
if (v % 7 === 0) { c.fillStyle = 'rgba(0,0,0,.07)'; c.fillRect(px + (v % 16), py + (v >> 4) % 16, 10, 10); }
if (v % 31 === 0) { c.fillStyle = '#e8e26b'; c.fillRect(px + (v % 24) + 3, py + (v % 19) + 4, 3, 3); }
if (v % 53 === 0) { c.fillStyle = '#e8f0ff'; c.fillRect(px + (v % 20) + 5, py + (v % 23) + 4, 3, 3); }
}
}
// shoreline highlights
c.fillStyle = 'rgba(180,220,255,.35)';
for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
if (terrain[idx(x, y)] !== T.WATER) continue;
const px = x * TILE, py = y * TILE;
if (y > 0 && terrain[idx(x, y - 1)] !== T.WATER) c.fillRect(px, py, TILE, 3);
if (y < MH - 1 && terrain[idx(x, y + 1)] !== T.WATER) c.fillRect(px, py + TILE - 3, TILE, 3);
if (x > 0 && terrain[idx(x - 1, y)] !== T.WATER) c.fillRect(px, py, 3, TILE);
if (x < MW - 1 && terrain[idx(x + 1, y)] !== T.WATER) c.fillRect(px + TILE - 3, py, 3, TILE);
}
}
const miniTerr = document.createElement('canvas');
miniTerr.width = MW * 2; miniTerr.height = MH * 2;
function prerenderMini() {
const c = miniTerr.getContext('2d');
for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
const t = terrain[idx(x, y)];
c.fillStyle = t === T.WATER ? '#246089' : '#3a7a3e';
c.fillRect(x * 2, y * 2, 2, 2);
}
}
// ------------------------------ rendering: entities -----------------
const cv = document.getElementById('game');
const ctx = cv.getContext('2d');
let VW = 0, VH = 0, DPR = 1;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
VW = window.innerWidth; VH = window.innerHeight;
cv.width = VW * DPR; cv.height = VH * DPR;
cv.style.width = VW + 'px'; cv.style.height = VH + 'px';
}
window.addEventListener('resize', resize); resize();
function drawTree(x, y, i, time) {
const v = treeVar[i];
const sway = Math.sin(time * 0.9 + v) * 1.2;
const s = 0.85 + (v % 5) * 0.06;
ctx.fillStyle = 'rgba(0,0,0,.25)';
ctx.beginPath(); ctx.ellipse(x + 16, y + 27, 11 * s, 4, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#5d4225';
ctx.fillRect(x + 14, y + 16, 4, 11);
const greens = ['#2e6b33', '#2f7a38', '#357f3c'];
ctx.fillStyle = greens[v % 3];
ctx.beginPath(); ctx.ellipse(x + 16 + sway * 0.4, y + 14, 11 * s, 9 * s, 0, 0, 7); ctx.fill();
ctx.fillStyle = greens[(v + 1) % 3];
ctx.beginPath(); ctx.ellipse(x + 16 + sway, y + 7, 8 * s, 7 * s, 0, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.13)';
ctx.beginPath(); ctx.ellipse(x + 13 + sway, y + 5, 3.5, 2.5, 0, 0, 7); ctx.fill();
}
function drawStump(x, y) {
ctx.fillStyle = '#6b4e2e'; ctx.beginPath(); ctx.ellipse(x + 16, y + 20, 6, 4, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#a9885a'; ctx.beginPath(); ctx.ellipse(x + 16, y + 18, 6, 4, 0, 0, 7); ctx.fill();
}
function drawMine(m, time) {
const x = m.tx * TILE, y = m.ty * TILE;
ctx.fillStyle = 'rgba(0,0,0,.25)';
ctx.beginPath(); ctx.ellipse(x + 32, y + 56, 32, 9, 0, 0, 7); ctx.fill();
ctx.fillStyle = m.depleted ? '#5d5a52' : '#7c7468';
ctx.beginPath();
ctx.moveTo(x + 2, y + 58); ctx.quadraticCurveTo(x + 6, y + 14, x + 32, y + 8);
ctx.quadraticCurveTo(x + 58, y + 14, x + 62, y + 58); ctx.closePath(); ctx.fill();
ctx.fillStyle = m.depleted ? '#4a4741' : '#675f52';
ctx.beginPath();
ctx.moveTo(x + 10, y + 58); ctx.quadraticCurveTo(x + 14, y + 24, x + 32, y + 20);
ctx.quadraticCurveTo(x + 50, y + 24, x + 54, y + 58); ctx.closePath(); ctx.fill();
// entrance
ctx.fillStyle = '#17120c';
ctx.beginPath();
ctx.moveTo(x + 22, y + 58); ctx.quadraticCurveTo(x + 32, y + 30, x + 42, y + 58); ctx.closePath(); ctx.fill();
if (!m.depleted) {
const tw = Math.sin(time * 4 + m.tx) > 0.3;
ctx.fillStyle = '#ffd84d';
ctx.fillRect(x + 14, y + 34, 4, 4); ctx.fillRect(x + 46, y + 40, 4, 4);
if (tw) ctx.fillRect(x + 30, y + 24, 4, 4);
} else {
ctx.fillStyle = '#3a372f';
ctx.fillRect(x + 24, y + 48, 16, 10);
}
}
function teamFill(shade) { return shade ? '#2f5e96' : '#3b76bd'; }
function drawBuilding(b, time) {
const x = b.tx * TILE, y = b.ty * TILE, W = b.w * TILE, H = b.h * TILE;
ctx.fillStyle = 'rgba(0,0,0,.28)';
ctx.beginPath(); ctx.ellipse(x + W / 2, y + H - 4, W / 2, 8, 0, 0, 7); ctx.fill();
if (!b.built) { // construction site
const p = b.progress / BLD[b.kind].time;
ctx.fillStyle = '#7a6038'; ctx.fillRect(x + 2, y + 2, W - 4, H - 4);
ctx.fillStyle = '#5c4527'; ctx.fillRect(x + 4, y + 4, W - 8, H - 8);
ctx.strokeStyle = '#a98850'; ctx.lineWidth = 3;
ctx.strokeRect(x + 6, y + 6, W - 12, H - 12);
ctx.beginPath(); ctx.moveTo(x + 6, y + 6); ctx.lineTo(x + W - 6, y + H - 6);
ctx.moveTo(x + W - 6, y + 6); ctx.lineTo(x + 6, y + H - 6); ctx.stroke();
if (p > 0.45) { ctx.globalAlpha = 0.55; drawFinishedBuilding(b, x, y, W, H, time); ctx.globalAlpha = 1; }
// progress bar
ctx.fillStyle = '#111'; ctx.fillRect(x + 4, y - 9, W - 8, 6);
ctx.fillStyle = '#f3c33c'; ctx.fillRect(x + 5, y - 8, (W - 10) * p, 4);
return;
}
drawFinishedBuilding(b, x, y, W, H, time);
}
function drawFinishedBuilding(b, x, y, W, H, time) {
if (b.kind === 'townhall') {
ctx.fillStyle = '#8d8478'; ctx.fillRect(x + 6, y + 30, W - 12, H - 34);
ctx.fillStyle = '#797062'; ctx.fillRect(x + 6, y + 30, W - 12, 6);
ctx.fillStyle = '#9c2f2f';
ctx.beginPath(); ctx.moveTo(x, y + 34); ctx.lineTo(x + W / 2, y + 2); ctx.lineTo(x + W, y + 34); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#7e2424';
ctx.beginPath(); ctx.moveTo(x, y + 34); ctx.lineTo(x + W / 2, y + 8); ctx.lineTo(x + W / 2, y + 2); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#43301c'; ctx.fillRect(x + W / 2 - 9, y + H - 26, 18, 22);
ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + W / 2 - 6, y + H - 22, 12, 18);
ctx.fillStyle = '#d9c9a8';
ctx.fillRect(x + 14, y + 40, 10, 10); ctx.fillRect(x + W - 24, y + 40, 10, 10);
// flag
ctx.strokeStyle = '#3a2c18'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x + W / 2, y + 2); ctx.lineTo(x + W / 2, y - 16); ctx.stroke();
const fw = Math.sin(time * 3) * 2;
ctx.fillStyle = teamFill();
ctx.beginPath(); ctx.moveTo(x + W / 2, y - 16); ctx.lineTo(x + W / 2 + 16 + fw, y - 12);
ctx.lineTo(x + W / 2, y - 7); ctx.closePath(); ctx.fill();
} else if (b.kind === 'farm') {
ctx.fillStyle = '#b59a6a'; ctx.fillRect(x + 2, y + 18, W - 4, H - 20);
ctx.fillStyle = '#caa874';
for (let r = 0; r < 4; r++) ctx.fillRect(x + 5, y + 22 + r * 9, W - 10, 4);
ctx.fillStyle = '#7a4d28';
ctx.beginPath(); ctx.moveTo(x, y + 20); ctx.lineTo(x + 18, y + 2); ctx.lineTo(x + 36, y + 20); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#8d6038'; ctx.fillRect(x + 6, y + 20, 24, 14);
ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + 14, y + 24, 8, 10);
} else if (b.kind === 'barracks') {
ctx.fillStyle = '#6e6a64'; ctx.fillRect(x + 4, y + 14, W - 8, H - 18);
ctx.fillStyle = '#7d7972';
for (let i = 0; i < 5; i++) ctx.fillRect(x + 4 + i * ((W - 8) / 5) + 2, y + 8, (W - 8) / 5 - 6, 10);
ctx.fillStyle = '#5b5751';
ctx.fillRect(x + 4, y + 34, W - 8, 4);
ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + W / 2 - 10, y + H - 28, 20, 24);
ctx.fillStyle = '#85817a';
ctx.fillRect(x + 14, y + 44, 10, 12); ctx.fillRect(x + W - 24, y + 44, 10, 12);
ctx.fillStyle = teamFill();
ctx.beginPath(); ctx.moveTo(x + W / 2, y + 16); ctx.lineTo(x + W / 2 + 12, y + 20); ctx.lineTo(x + W / 2, y + 24); ctx.closePath(); ctx.fill();
} else if (b.kind === 'tower') {
ctx.fillStyle = '#8d8478';
ctx.fillRect(x + 12, y - 18, W - 24, H + 12);
ctx.fillStyle = '#797062'; ctx.fillRect(x + 12, y - 18, 6, H + 12);
ctx.fillStyle = '#6e6a64'; ctx.fillRect(x + 6, y - 26, W - 12, 14);
ctx.fillStyle = '#7d7972';
for (let i = 0; i < 4; i++) ctx.fillRect(x + 7 + i * ((W - 14) / 4) + 1, y - 32, (W - 14) / 4 - 4, 8);
ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + W / 2 - 5, y + H - 18, 10, 14);
ctx.fillStyle = '#ffd84d';
const gl = 0.6 + Math.sin(time * 5) * 0.3;
ctx.globalAlpha = gl; ctx.fillRect(x + W / 2 - 3, y - 24, 6, 8); ctx.globalAlpha = 1;
}
}
function drawUnit(u, time) {
const bob = u.moving ? Math.sin(time * 11 + u.bob) * 1.6 : 0;
const x = u.x, y = u.y + bob;
ctx.fillStyle = 'rgba(0,0,0,.3)';
ctx.beginPath(); ctx.ellipse(x, u.y + 9, 8, 3.4, 0, 0, 7); ctx.fill();
const flash = u.flash > 0;
if (u.kind === 'spider') {
ctx.strokeStyle = flash ? '#fff' : '#3b2a20'; ctx.lineWidth = 2;
for (let i = 0; i < 4; i++) {
const a = -0.7 + i * 0.46 + (u.moving ? Math.sin(time * 14 + i) * 0.18 : 0);
ctx.beginPath(); ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(a) * 12, y + 4 + Math.sin(a) * 5);
ctx.moveTo(x, y);
ctx.lineTo(x - Math.cos(a) * 12, y + 4 + Math.sin(a) * 5);
ctx.stroke();
}
ctx.fillStyle = flash ? '#fff' : '#54281e';
ctx.beginPath(); ctx.ellipse(x, y, 8.5, 6.5, 0, 0, 7); ctx.fill();
ctx.fillStyle = flash ? '#fff' : '#6d3526';
ctx.beginPath(); ctx.ellipse(x + u.dir * 5, y - 2, 5, 4.4, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#ffd84d';
ctx.fillRect(x + u.dir * 6 - 2, y - 4, 2, 2); ctx.fillRect(x + u.dir * 6 + 1, y - 4, 2, 2);
return;
}
const d = u.dir;
const working = u.anim === 'work' && (u.order.kind === 'chop' || u.order.kind === 'mine' || u.order.kind === 'build');
const swing = working ? Math.sin(time * 9 + u.bob) : (u.order.kind === 'attack' ? Math.sin(time * 12) : 0);
// body
const bodyCol = flash ? '#ffffff' : (u.kind === 'worker' ? '#b07a3e' : u.kind === 'soldier' ? teamFill() : '#3f7a46');
ctx.fillStyle = bodyCol;
ctx.beginPath(); ctx.ellipse(x, y, 6.6, 8, 0, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,.18)';
ctx.beginPath(); ctx.ellipse(x - d * 2, y + 1, 4.5, 6, 0, 0, 7); ctx.fill();
// head
ctx.fillStyle = flash ? '#fff' : '#e8b88a';
ctx.beginPath(); ctx.arc(x + d * 1, y - 9, 4.6, 0, 7); ctx.fill();
if (u.kind === 'worker') {
ctx.fillStyle = flash ? '#fff' : '#d9a430'; // straw hat
ctx.beginPath(); ctx.ellipse(x + d, y - 11.5, 6.4, 2.6, 0, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(x + d, y - 12.5, 3.4, Math.PI, 0); ctx.fill();
// tool
ctx.strokeStyle = '#5d4225'; ctx.lineWidth = 2;
const ta = d * (working ? (-0.9 + swing * 0.8) : -0.5);
ctx.beginPath(); ctx.moveTo(x + d * 4, y - 2);
ctx.lineTo(x + d * 4 + Math.cos(ta) * 11 * d, y - 2 - Math.abs(Math.sin(ta)) * 11); ctx.stroke();
ctx.fillStyle = '#9aa2ad';
ctx.fillRect(x + d * 4 + Math.cos(ta) * 11 * d - 2, y - 2 - Math.abs(Math.sin(ta)) * 11 - 3, 5, 4);
if (u.carry && u.carry.amt > 0) {
ctx.fillStyle = u.carry.type === 'gold' ? '#ffd84d' : '#8d6038';
ctx.beginPath(); ctx.arc(x - d * 6, y - 4, 3.6, 0, 7); ctx.fill();
}
} else if (u.kind === 'soldier') {
ctx.fillStyle = flash ? '#fff' : '#9aa2ad'; // helmet
ctx.beginPath(); ctx.arc(x + d, y - 10, 4.4, Math.PI, 0); ctx.fill();
ctx.fillRect(x + d - 4.4, y - 10, 8.8, 2);
// sword
ctx.strokeStyle = '#cfd6df'; ctx.lineWidth = 2.4;
const sa = -0.4 + swing * 0.9;
ctx.beginPath(); ctx.moveTo(x + d * 5, y - 1);
ctx.lineTo(x + d * 5 + d * Math.cos(sa) * 12, y - 1 - Math.sin(sa + 0.8) * 9); ctx.stroke();
// shield
ctx.fillStyle = flash ? '#fff' : '#7e5a2e';
ctx.beginPath(); ctx.ellipse(x - d * 6, y - 1, 3.4, 5, 0, 0, 7); ctx.fill();
} else if (u.kind === 'archer') {
ctx.fillStyle = flash ? '#fff' : '#2e5e35'; // hood
ctx.beginPath(); ctx.moveTo(x + d - 5, y - 7); ctx.lineTo(x + d, y - 16); ctx.lineTo(x + d + 5, y - 7); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#5d4225'; ctx.lineWidth = 2; // bow
ctx.beginPath(); ctx.arc(x + d * 7, y - 4, 7, d > 0 ? -1.1 : Math.PI - 1.1 + 2.2, d > 0 ? 1.1 : Math.PI + 1.1 + 2.2); ctx.stroke();
}
}
function drawHpBar(x, y, w, frac) {
ctx.fillStyle = '#101010'; ctx.fillRect(x - w / 2, y, w, 4.5);
ctx.fillStyle = frac > 0.5 ? '#52c234' : frac > 0.25 ? '#e0a92e' : '#d04a2a';
ctx.fillRect(x - w / 2 + 1, y + 1, (w - 2) * frac, 2.5);
}
// ------------------------------ main render -------------------------
function render(time) {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, VW, VH);
ctx.save();
ctx.translate(-Math.round(cam.x), -Math.round(cam.y));
// terrain
ctx.drawImage(terrCv, cam.x, cam.y, VW, VH, cam.x, cam.y, VW, VH);
const tx0 = Math.max(0, tOf(cam.x) - 1), ty0 = Math.max(0, tOf(cam.y) - 1);
const tx1 = Math.min(MW - 1, tOf(cam.x + VW) + 1), ty1 = Math.min(MH - 1, tOf(cam.y + VH) + 1);
// stumps first, then mines/buildings/units sorted by y, then trees overlapping
for (let y = ty0; y <= ty1; y++) for (let x = tx0; x <= tx1; x++)
if (terrain[idx(x, y)] === T.STUMP) drawStump(x * TILE, y * TILE);
const drawables = [];
for (const m of mines)
if (m.tx + 2 >= tx0 && m.tx <= tx1 && m.ty + 2 >= ty0 && m.ty <= ty1)
drawables.push({ y: (m.ty + m.h) * TILE, f: () => drawMine(m, time) });
for (const b of buildings)
if (!b.dead && b.tx + b.w >= tx0 && b.tx <= tx1 && b.ty + b.h >= ty0 && b.ty <= ty1)
drawables.push({ y: (b.ty + b.h) * TILE, f: () => drawBuilding(b, time) });
for (const u of units)
if (!u.dead && u.x > cam.x - 40 && u.x < cam.x + VW + 40 && u.y > cam.y - 40 && u.y < cam.y + VH + 40) {
if (u.faction === 1 && !visible[idx(tOf(u.x), tOf(u.y))]) continue; // hidden creeps
drawables.push({ y: u.y + 10, f: () => {
if (sel.includes(u)) {
ctx.strokeStyle = '#7ce06b'; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.ellipse(u.x, u.y + 9, 10, 4.6, 0, 0, 7); ctx.stroke();
}
drawUnit(u, time);
if (u.hp < u.maxHp || sel.includes(u)) drawHpBar(u.x, u.y - 20, 22, u.hp / u.maxHp);
}});
}
for (let y = ty0; y <= ty1; y++) for (let x = tx0; x <= tx1; x++)
if (terrain[idx(x, y)] === T.TREE)
drawables.push({ y: y * TILE + 27, f: () => drawTree(x * TILE, y * TILE, idx(x, y), time) });
drawables.sort((a, b) => a.y - b.y);
for (const d of drawables) d.f();
// selected building decorations
for (const e of sel) if (e.w && !e.dead) {
const x = e.tx * TILE, y = e.ty * TILE, W = e.w * TILE, H = e.h * TILE;
ctx.strokeStyle = '#7ce06b'; ctx.lineWidth = 2;
const L = 10;
for (const [cx, cy, dx, dy] of [[x, y, 1, 1], [x + W, y, -1, 1], [x, y + H, 1, -1], [x + W, y + H, -1, -1]]) {
ctx.beginPath(); ctx.moveTo(cx + dx * L, cy); ctx.lineTo(cx, cy); ctx.lineTo(cx, cy + dy * L); ctx.stroke();
}
if (e.rally) {
ctx.strokeStyle = '#ffd84d'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(e.rally.x, e.rally.y + 4); ctx.lineTo(e.rally.x, e.rally.y - 14); ctx.stroke();
ctx.fillStyle = '#ffd84d';
ctx.beginPath(); ctx.moveTo(e.rally.x, e.rally.y - 14); ctx.lineTo(e.rally.x + 11, e.rally.y - 10); ctx.lineTo(e.rally.x, e.rally.y - 6); ctx.closePath(); ctx.fill();
}
if (e.hp !== undefined && e.maxHp) drawHpBar(x + W / 2, y - (e.kind === 'tower' ? 40 : 16), W - 8, e.hp / e.maxHp);
}
// effects
for (const f of fx) {
if (f.type === 'hit') {
ctx.strokeStyle = `rgba(255,230,120,${1 - f.t * 3})`; ctx.lineWidth = 2;
const r = 3 + f.t * 26;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const a = i * 1.256 + f.t * 5;
ctx.moveTo(f.x + Math.cos(a) * r * 0.4, f.y + Math.sin(a) * r * 0.4);
ctx.lineTo(f.x + Math.cos(a) * r, f.y + Math.sin(a) * r);
}
ctx.stroke();
} else if (f.type === 'arrow') {
const p = Math.min(1, f.t * 300 / Math.max(1, f.d));
const ax = f.x + (f.tx - f.x) * p, ay = f.y + (f.ty - f.y) * p - Math.sin(p * Math.PI) * 14;
ctx.strokeStyle = '#e8d9a8'; ctx.lineWidth = 2;
const ang = Math.atan2(f.ty - f.y, f.tx - f.x);
ctx.beginPath(); ctx.moveTo(ax - Math.cos(ang) * 6, ay - Math.sin(ang) * 6); ctx.lineTo(ax + Math.cos(ang) * 4, ay + Math.sin(ang) * 4); ctx.stroke();
} else if (f.type === 'chip') {
ctx.fillStyle = `rgba(170,130,80,${1 - f.t * 2.5})`;
ctx.fillRect(f.x - 6 + f.t * 24, f.y - f.t * 30 + f.t * f.t * 70, 3, 3);
ctx.fillRect(f.x + 4 - f.t * 20, f.y - f.t * 36 + f.t * f.t * 70, 3, 3);
} else if (f.type === 'splat') {
ctx.fillStyle = `rgba(90,40,30,${0.7 - f.t * 0.5})`;
ctx.beginPath(); ctx.ellipse(f.x, f.y + 6, 10 + f.t * 6, 5 + f.t * 3, 0, 0, 7); ctx.fill();
}
}
// placement ghost
if (placing) {
const { tx, ty, d } = placementTiles();
const ok = placementValid(tx, ty, d);
ctx.globalAlpha = 0.55;
const tmp = { kind: placing.kind, tx, ty, w: d.w, h: d.h, built: true };
drawFinishedBuilding(tmp, tx * TILE, ty * TILE, d.w * TILE, d.h * TILE, time);
ctx.globalAlpha = 1;
ctx.fillStyle = ok ? 'rgba(110,220,110,.3)' : 'rgba(230,80,60,.4)';
ctx.fillRect(tx * TILE, ty * TILE, d.w * TILE, d.h * TILE);
ctx.strokeStyle = ok ? '#7ce06b' : '#e0563c'; ctx.lineWidth = 2;
ctx.strokeRect(tx * TILE + 1, ty * TILE + 1, d.w * TILE - 2, d.h * TILE - 2);
}
// fog of war (soft-scaled)
ctx.imageSmoothingEnabled = true;
ctx.drawImage(fogCv, 0, 0, MW, MH, 0, 0, MW * TILE, MH * TILE);
ctx.imageSmoothingEnabled = false;
ctx.restore();
// selection drag box
if (mouse.drag && mouse.drag.box) {
const d = mouse.drag;
ctx.strokeStyle = '#7ce06b'; ctx.lineWidth = 1.5;
ctx.fillStyle = 'rgba(124,224,107,.12)';
const x = Math.min(d.sx, mouse.x), y = Math.min(d.sy, mouse.y);
const w = Math.abs(mouse.x - d.sx), h = Math.abs(mouse.y - d.sy);
ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h);
}
renderMinimap();
}
// ------------------------------ minimap -----------------------------
const mmCv = document.getElementById('minimap');
const mmCtx = mmCv.getContext('2d');
function renderMinimap() {
const S = mmCv.width / MW; // 2 px per tile
mmCtx.clearRect(0, 0, mmCv.width, mmCv.height);
mmCtx.drawImage(miniTerr, 0, 0);
for (const m of mines) {
mmCtx.fillStyle = m.depleted ? '#777' : '#ffd84d';
mmCtx.fillRect(m.tx * S, m.ty * S, 4, 4);
}
for (const b of buildings) if (!b.dead) {
mmCtx.fillStyle = '#4d9aef';
mmCtx.fillRect(b.tx * S, b.ty * S, b.w * S, b.h * S);
}
for (const u of units) if (!u.dead) {
const i = idx(tOf(u.x), tOf(u.y));
if (u.faction === 1 && !visible[i]) continue;
mmCtx.fillStyle = u.faction === 0 ? '#aee4ff' : '#e0563c';
mmCtx.fillRect(tOf(u.x) * S, tOf(u.y) * S, 2, 2);
}
mmCtx.imageSmoothingEnabled = false;
mmCtx.drawImage(fogCv, 0, 0, MW, MH, 0, 0, mmCv.width, mmCv.height);
mmCtx.strokeStyle = '#fff'; mmCtx.lineWidth = 1;
mmCtx.strokeRect(cam.x / TILE * S, cam.y / TILE * S, VW / TILE * S, VH / TILE * S);
}
// ------------------------------ UI panels ---------------------------
const elGold = document.getElementById('rGold'), elWood = document.getElementById('rWood');
const elSup = document.getElementById('rSupply'), elExp = document.getElementById('rExplored');
const elTime = document.getElementById('rTime');
const elInfo = document.getElementById('info'), elCmds = document.getElementById('cmds');
let toastTimer = null;
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.style.opacity = 1;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.style.opacity = 0, 2400);
}
function fmtCost(c) { return `${c.g ? c.g + '🟡' : ''}${c.g && c.w ? ' ' : ''}${c.w ? c.w + '🪵' : ''}`; }
function refreshTopbar() {
elGold.textContent = res.g; elWood.textContent = res.w;
elSup.textContent = supplyUsed + '/' + supplyMax;
elSup.style.color = supplyUsed >= supplyMax ? '#ff9078' : '';
elExp.textContent = Math.floor(exploredCount / (MW * MH) * 100) + '%';
const m = Math.floor(gameTime / 60), s = Math.floor(gameTime % 60);
elTime.textContent = m + ':' + String(s).padStart(2, '0');
}
function cmdBtn(label, sub, cb, off, danger) {
const b = document.createElement('button');
b.className = 'cmd' + (off ? ' off' : '') + (danger ? ' danger' : '');
b.innerHTML = `<span>${label}</span>` + (sub ? `<small>${sub}</small>` : '');
if (!off) b.onclick = cb;
elCmds.appendChild(b);
return b;
}
function rebuildPanels() {
elInfo.innerHTML = ''; elCmds.innerHTML = '';
selDirty = false;
if (!sel.length) {
elInfo.innerHTML = `<h3><span class="bigico">⚜</span> Tiny Realm</h3>
<div class="sub">Select units with left-click / drag. Explore the whole map to win!</div>
<div class="sub">Explored: ${Math.floor(exploredCount / (MW * MH) * 100)}% of the realm</div>`;
return;
}
const first = sel[0];
if (sel.length > 1) {
const counts = {};
for (const u of sel) counts[u.kind] = (counts[u.kind] || 0) + 1;
elInfo.innerHTML = `<h3><span class="bigico">⚑</span> ${sel.length} units selected</h3>
<div class="sub">${Object.entries(counts).map(([k, n]) => `${UDEF[k].icon} ${UDEF[k].name} ×${n}`).join(' &nbsp; ')}</div>`;
if (sel.some(u => u.kind === 'worker')) buildButtons();
cmdBtn('✋ Stop', 'S', stopSelected);
return;
}
if (first.w) { // building or mine
if (first.kind === 'mine') {
elInfo.innerHTML = `<h3><span class="bigico">⛏️</span> Gold Mine</h3>
<div class="sub">${first.depleted ? 'Depleted — nothing remains.' : 'Gold remaining: <b style="color:#ffd84d">' + first.gold + '</b>'}</div>
<div class="sub">Send workers here to mine gold.</div>`;
return;
}
const d = BLD[first.kind];
let html = `<h3><span class="bigico">${d.icon}</span> ${d.name}${first.built ? '' : ' (under construction)'}</h3>
<div class="sub">${d.desc}</div>`;
if (!first.built) {
const p = Math.floor(first.progress / d.time * 100);
html += `<div class="hpbarOuter"><div class="hpbarInner" style="width:${p}%;background:linear-gradient(#ffe084,#cf9d2e)"></div></div>
<div class="sub">Construction: ${p}% — right-click with a worker to ${p > 0 ? 'resume' : 'start'} building</div>`;
} else {
html += `<div class="hpbarOuter"><div class="hpbarInner" style="width:${first.hp / first.maxHp * 100}%"></div></div>
<div class="sub">HP ${first.hp}/${first.maxHp}</div>`;
if (first.queue.length) {
html += `<div class="qrow">` + first.queue.map((q, i) =>
`<div class="qitem" data-qi="${i}" title="Click to cancel">${UDEF[q.kind].icon}
<div class="qprog" style="width:${i === 0 ? Math.floor(q.t / UDEF[q.kind].time * 100) : 0}%"></div></div>`).join('') +
`<span class="qhint">click to cancel</span></div>`;
}
}
elInfo.innerHTML = html;
elInfo.querySelectorAll('.qitem').forEach(el => el.onclick = () => {
const i = +el.dataset.qi;
const q = first.queue.splice(i, 1)[0];
if (q) { refund(UDEF[q.kind].cost); selDirty = true; }
});
if (first.built) {
if (first.kind === 'townhall')
cmdBtn(`${UDEF.worker.icon} Worker`, fmtCost(UDEF.worker.cost), () => tryTrain(first, 'worker'));
if (first.kind === 'barracks') {
cmdBtn(`${UDEF.soldier.icon} Soldier`, fmtCost(UDEF.soldier.cost), () => tryTrain(first, 'soldier'));
cmdBtn(`${UDEF.archer.icon} Archer`, fmtCost(UDEF.archer.cost), () => tryTrain(first, 'archer'));
}
if (first.kind === 'townhall' || first.kind === 'barracks') {
const hint = document.createElement('div');
hint.className = 'qhint'; hint.style.gridColumn = '1 / -1';
hint.textContent = 'Right-click on the map to set a rally point.';
elCmds.appendChild(hint);
}
}
return;
}
// single unit
const d = UDEF[first.kind];
let extra = '';
if (first.carry && first.carry.amt) extra = `Carrying ${first.carry.amt} ${first.carry.type === 'gold' ? '🟡 gold' : '🪵 wood'}`;
else extra = { idle: 'Idle', move: 'Moving', chop: 'Chopping wood', mine: 'Mining gold', return: 'Delivering resources', build: 'Building', attack: 'Fighting!' }[first.order.kind] || '';
elInfo.innerHTML = `<h3><span class="bigico">${d.icon}</span> ${d.name}</h3>
<div class="sub">${d.desc}</div>
<div class="hpbarOuter"><div class="hpbarInner" style="width:${first.hp / first.maxHp * 100}%"></div></div>
<div class="sub">HP ${Math.ceil(first.hp)}/${first.maxHp} &nbsp; ${extra}</div>`;
if (first.kind === 'worker') buildButtons();
cmdBtn('✋ Stop', 'S', stopSelected);
}
function buildButtons() {
for (const k of ['farm', 'barracks', 'tower', 'townhall']) {
const d = BLD[k];
cmdBtn(`${d.icon} ${d.name}`, fmtCost(d.cost), () => {
if (!canAfford(d.cost)) { toast('Not enough resources!'); return; }
placing = { kind: k };
}, !canAfford(d.cost));
}
}
function stopSelected() {
for (const u of sel) if (!u.w) { u.order = { kind: 'idle' }; u.path = null; }
}
// ------------------------------ input -------------------------------
const keys = {};
window.addEventListener('keydown', e => {
if (e.repeat) return;
keys[e.key.toLowerCase()] = true;
if (e.key === 'Escape') {
if (placing) placing = null;
else { sel = []; selDirty = true; }
}
if (e.key.toLowerCase() === 's') stopSelected();
if (e.key.toLowerCase() === 'h') document.getElementById('intro').style.display = 'flex';
if (e.key === ' ') {
const th = buildings.find(b => b.kind === 'townhall' && !b.dead);
if (th) { cam.x = th.tx * TILE - VW / 2 + 48; cam.y = th.ty * TILE - VH / 2 + 48; }
e.preventDefault();
}
});
window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
cv.addEventListener('contextmenu', e => e.preventDefault());
cv.addEventListener('mousedown', e => {
if (!started) return;
if (e.button === 0) {
if (placing) { tryPlace(); return; }
mouse.drag = { sx: e.clientX, sy: e.clientY, box: false };
} else if (e.button === 1) {
mouse.mmPan = { x: e.clientX, y: e.clientY }; e.preventDefault();
} else if (e.button === 2) {
if (placing) { placing = null; return; }
rightClick(cam.x + e.clientX, cam.y + e.clientY);
}
});
document.addEventListener('mouseleave', () => mouse.inside = false);
window.addEventListener('mousemove', e => {
mouse.x = e.clientX; mouse.y = e.clientY; mouse.inside = true;
if (mouse.drag && !mouse.drag.box &&
Math.hypot(e.clientX - mouse.drag.sx, e.clientY - mouse.drag.sy) > 6) mouse.drag.box = true;
if (mouse.mmPan) {
cam.x -= e.clientX - mouse.mmPan.x; cam.y -= e.clientY - mouse.mmPan.y;
mouse.mmPan = { x: e.clientX, y: e.clientY };
}
});
window.addEventListener('mouseup', e => {
if (e.button === 1) { mouse.mmPan = false; return; }
if (e.button !== 0 || !mouse.drag) return;
const d = mouse.drag; mouse.drag = null;
if (!started) return;
if (d.box) {
const x1 = cam.x + Math.min(d.sx, e.clientX), x2 = cam.x + Math.max(d.sx, e.clientX);
const y1 = cam.y + Math.min(d.sy, e.clientY), y2 = cam.y + Math.max(d.sy, e.clientY);
const picked = units.filter(u => u.faction === 0 && !u.dead && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2);
if (picked.length) { sel = picked; selDirty = true; return; }
}
// click select
const wx = cam.x + e.clientX, wy = cam.y + e.clientY;
let best = null, bd = 18;
for (const u of units) {
if (u.dead) continue;
if (u.faction === 1 && !visible[idx(tOf(u.x), tOf(u.y))]) continue;
const dd = dist(wx, wy, u.x, u.y - 4);
if (dd < bd) { bd = dd; best = u; }
}
if (best && best.faction === 0) { sel = [best]; selDirty = true; return; }
const tx = tOf(wx), ty = tOf(wy);
const g = inB(tx, ty) ? bgrid[idx(tx, ty)] : null;
if (g && explored[idx(tx, ty)]) { sel = [g]; selDirty = true; return; }
sel = []; selDirty = true;
});
function rightClick(wx, wy) {
const tx = clamp(tOf(wx), 0, MW - 1), ty = clamp(tOf(wy), 0, MH - 1);
const i = idx(tx, ty);
// building selected → set rally
if (sel.length === 1 && sel[0].w && sel[0].kind !== 'mine' && sel[0].built) {
sel[0].rally = { x: wx, y: wy };
return;
}
const myUnits = sel.filter(e => !e.w && e.faction === 0 && !e.dead);
if (!myUnits.length) return;
// enemy creep?
let creep = null, cd = 24;
for (const u of units) if (u.faction === 1 && !u.dead && visible[idx(tOf(u.x), tOf(u.y))]) {
const dd = dist(wx, wy, u.x, u.y);
if (dd < cd) { cd = dd; creep = u; }
}
if (creep) {
for (const u of myUnits) { u.order = { kind: 'attack', target: creep }; u.path = null; u.repath = 0; }
return;
}
const g = bgrid[i];
if (g && explored[i]) {
if (g.kind === 'mine' && !g.depleted) {
for (const u of myUnits)
if (u.kind === 'worker') { u.order = { kind: 'mine', mine: g }; u.lastGather = { kind: 'mine', mine: g }; u.path = null; }
else moveGroup([u], tx, ty);
return;
}
if (g.kind !== 'mine' && !g.built && !g.dead) {
let any = false;
for (const u of myUnits)
if (u.kind === 'worker') { u.order = { kind: 'build', b: g }; u.path = null; any = true; }
if (any) return;
}
moveGroup(myUnits, tx, ty);
return;
}
if (terrain[i] === T.TREE && explored[i]) {
let any = false;
for (const u of myUnits)
if (u.kind === 'worker') { u.order = { kind: 'chop', tx, ty }; u.lastGather = { kind: 'chop', tx, ty }; u.path = null; any = true; }
const rest = myUnits.filter(u => u.kind !== 'worker');
if (rest.length) moveGroup(rest, tx, ty);
if (any || rest.length) return;
}
moveGroup(myUnits, tx, ty);
}
function moveGroup(group, tx, ty) {
const it = spiral(tx, ty, 7);
for (const u of group) {
let t = null;
for (const c of it) if (inB(c.x, c.y) && !isBlocked(c.x, c.y)) { t = c; break; }
if (!t) t = { x: tx, y: ty };
u.order = { kind: 'move' }; u.path = null;
setPath(u, t.x, t.y);
}
fx.push({ type: 'hit', x: tx * TILE + 16, y: ty * TILE + 16, t: 0.12 });
}
// minimap interaction
function mmJump(e) {
const r = mmCv.getBoundingClientRect();
const fx2 = (e.clientX - r.left) / r.width, fy = (e.clientY - r.top) / r.height;
cam.x = fx2 * MW * TILE - VW / 2; cam.y = fy * MH * TILE - VH / 2;
}
mmCv.addEventListener('mousedown', e => { if (e.button === 0) { mmJump(e); mmCv.dataset.drag = '1'; } });
window.addEventListener('mouseup', () => delete mmCv.dataset.drag);
mmCv.addEventListener('mousemove', e => { if (mmCv.dataset.drag) mmJump(e); });
// ------------------------------ camera ------------------------------
function updateCamera(dt) {
const sp = 620 * dt;
if (keys['arrowleft'] || keys['a']) cam.x -= sp;
if (keys['arrowright'] || keys['d']) cam.x += sp;
if (keys['arrowup'] || keys['w']) cam.y -= sp;
if (keys['arrowdown']) cam.y += sp; // (S is the stop hotkey, so only arrow-down scrolls down)
// edge scroll
const M = 14;
if (mouse.inside) {
if (mouse.x < M) cam.x -= sp;
if (mouse.x > VW - M) cam.x += sp;
if (mouse.y < M + 42 && mouse.y >= 42) cam.y -= sp;
if (mouse.y > VH - M) cam.y += sp;
}
cam.x = clamp(cam.x, 0, Math.max(0, MW * TILE - VW));
cam.y = clamp(cam.y, 0, Math.max(0, MH * TILE - VH + 158));
}
// ------------------------------ main loop ---------------------------
function tick(nowMs) {
requestAnimationFrame(tick);
const dt = Math.min(0.05, (nowMs - now0) / 1000);
now0 = nowMs;
const time = nowMs / 1000;
if (started) {
gameTime += dt;
updateCamera(dt);
for (const u of units) if (!u.dead) updateUnit(u, dt);
for (const b of buildings) if (!b.dead) updateBuilding(b, dt);
separate();
if (units.some(u => u.dead)) units = units.filter(u => !u.dead);
visTimer += dt;
if (visTimer > 0.15) { visTimer = 0; recomputeVision(); }
for (const f of fx) f.t += dt;
fx = fx.filter(f => f.t < (f.type === 'arrow' ? f.d / 300 : f.type === 'splat' ? 1.4 : 0.4));
uiTimer += dt;
if (selDirty || uiTimer > 0.25) { uiTimer = 0; rebuildPanels(); }
refreshTopbar();
}
render(time);
}
// ------------------------------ boot --------------------------------
genMap();
prerenderTerrain();
prerenderMini();
recomputeVision();
rebuildPanels();
refreshTopbar();
document.getElementById('startBtn').onclick = () => {
document.getElementById('intro').style.display = 'none';
if (!started) { started = true; toast('Gather resources and explore the realm! Right-click to command.'); }
};
document.getElementById('helpBtn').onclick = () => document.getElementById('intro').style.display = 'flex';
document.getElementById('continueBtn').onclick = () => document.getElementById('winOverlay').style.display = 'none';
requestAnimationFrame(tick);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment