Skip to content

Instantly share code, notes, and snippets.

@senko
Created June 17, 2026 20:59
Show Gist options
  • Select an option

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

Select an option

Save senko/d3d356c3e2c677216bf761a471b8f5f8 to your computer and use it in GitHub Desktop.
RTS game by GLM-5.2
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mini RTS — Build, Gather, Conquer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: #14181c;
color: #e8e2d4;
font-family: 'Segoe UI', Tahoma, sans-serif;
overflow: hidden;
user-select: none;
height: 100vh;
width: 100vw;
}
#game {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Top bar */
#topbar {
background: linear-gradient(to bottom, #2b3138, #1a1d22);
border-bottom: 2px solid #0a0c0f;
padding: 8px 16px;
display: flex;
gap: 14px;
align-items: center;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
z-index: 10;
}
.resource {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
font-weight: 600;
min-width: 110px;
}
.resource-icon {
width: 18px;
height: 18px;
border-radius: 50%;
box-shadow: inset 0 -2px 4px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.5);
}
.gold-icon { background: radial-gradient(circle at 30% 30%, #ffe680, #c89500); }
.wood-icon { background: radial-gradient(circle at 30% 30%, #a06030, #5a3010); }
.pop-icon { background: radial-gradient(circle at 30% 30%, #6cb0f0, #2a5080); }
#gameTime {
font-family: 'Consolas', monospace;
color: #8aa;
font-size: 13px;
padding: 4px 10px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
#title {
font-weight: 700;
letter-spacing: 1px;
color: #d4b870;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
margin-right: 12px;
}
/* Main area */
#mainArea {
flex: 1;
position: relative;
overflow: hidden;
background: #0a0c0f;
}
#gameCanvas {
display: block;
cursor: crosshair;
}
#minimap {
position: absolute;
top: 10px;
right: 10px;
border: 2px solid #0a0c0f;
box-shadow: 0 0 0 1px #5a5040, 0 4px 12px rgba(0,0,0,0.6);
background: #000;
image-rendering: pixelated;
border-radius: 3px;
cursor: pointer;
}
/* Bottom bar */
#bottombar {
background: linear-gradient(to bottom, #1a1d22, #2b3138);
border-top: 2px solid #0a0c0f;
padding: 10px 14px;
display: flex;
gap: 14px;
min-height: 130px;
box-shadow: 0 -2px 8px rgba(0,0,0,0.4);
z-index: 10;
}
#selectionPanel {
flex: 1;
background: rgba(0,0,0,0.35);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
padding: 10px 14px;
overflow-y: auto;
max-height: 110px;
font-size: 13px;
line-height: 1.5;
}
#selectionPanel .empty {
color: #6a6258;
font-style: italic;
}
#selectionPanel .name {
color: #d4b870;
font-weight: 700;
font-size: 15px;
margin-bottom: 4px;
}
#selectionPanel .stat {
color: #b8b0a0;
margin-right: 14px;
display: inline-block;
}
#selectionPanel .stat b { color: #e8e2d4; }
#selectionPanel .progress {
margin-top: 6px;
background: #0a0c0f;
height: 8px;
border-radius: 4px;
overflow: hidden;
border: 1px solid #333;
}
#selectionPanel .progress > div {
height: 100%;
background: linear-gradient(to right, #d4b870, #f0d090);
transition: width 0.1s linear;
}
#buildPanel {
display: grid;
grid-template-columns: repeat(auto-fill, 88px);
gap: 6px;
align-content: start;
max-width: 540px;
}
.build-btn {
width: 88px;
height: 88px;
background: linear-gradient(to bottom, #3a4048, #252a30);
border: 1px solid #0a0c0f;
border-radius: 5px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #d8d0c0;
font-size: 11px;
padding: 4px;
transition: all 0.12s;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
}
.build-btn:hover:not(.disabled) {
background: linear-gradient(to bottom, #4a5058, #353a40);
border-color: #888;
transform: translateY(-1px);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), 0 2px 6px rgba(0,0,0,0.4);
}
.build-btn.active {
border-color: #ffd700;
background: linear-gradient(to bottom, #4a4030, #352f20);
box-shadow: 0 0 0 1px #ffd700, inset 0 1px 0 rgba(255,255,255,0.12);
}
.build-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.build-btn .icon {
width: 36px;
height: 36px;
margin-bottom: 3px;
}
.build-btn .cost {
font-size: 9px;
color: #8a8270;
line-height: 1.2;
text-align: center;
}
/* Overlay */
#message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.85);
padding: 28px 48px;
border-radius: 10px;
font-size: 28px;
display: none;
text-align: center;
border: 1px solid #5a5040;
box-shadow: 0 0 40px rgba(0,0,0,0.8);
z-index: 20;
}
#message .sub {
font-size: 14px;
color: #8a8270;
margin-top: 10px;
}
#message button {
margin-top: 18px;
padding: 8px 22px;
background: linear-gradient(to bottom, #4a4030, #352f20);
border: 1px solid #d4b870;
color: #d4b870;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
#message button:hover { background: #5a4830; }
.help-text {
position: absolute;
bottom: 12px;
left: 12px;
background: rgba(0,0,0,0.6);
padding: 6px 12px;
border-radius: 4px;
font-size: 11px;
color: #9a9288;
pointer-events: none;
border: 1px solid rgba(255,255,255,0.05);
}
.help-text b { color: #d4b870; }
#tooltip {
position: absolute;
background: rgba(0,0,0,0.92);
border: 1px solid #5a5040;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
display: none;
z-index: 15;
max-width: 220px;
color: #d8d0c0;
}
</style>
</head>
<body>
<div id="game">
<div id="topbar">
<div id="title">⚔ MINI RTS</div>
<div class="resource"><div class="resource-icon gold-icon"></div><span>Gold: <b id="gold">0</b></span></div>
<div class="resource"><div class="resource-icon wood-icon"></div><span>Wood: <b id="wood">0</b></span></div>
<div class="resource"><div class="resource-icon pop-icon"></div><span>Pop: <b id="pop">0</b>/<b id="popMax">0</b></span></div>
<div style="flex:1"></div>
<div id="gameTime">00:00</div>
</div>
<div id="mainArea">
<canvas id="gameCanvas"></canvas>
<canvas id="minimap" width="180" height="180"></canvas>
<div class="help-text">
<b>WASD/Arrows</b> pan &nbsp;|&nbsp; <b>Left-click</b> select &nbsp;|&nbsp; <b>Drag</b> box-select &nbsp;|&nbsp; <b>Right-click</b> move/attack/gather &nbsp;|&nbsp; <b>Esc</b> cancel
</div>
<div id="tooltip"></div>
<div id="message"></div>
</div>
<div id="bottombar">
<div id="selectionPanel">
<div class="empty">Click a unit or building to inspect it.</div>
</div>
<div id="buildPanel"></div>
</div>
</div>
<script>
// ============================================================
// MINI RTS — single-file real-time strategy game
// ============================================================
'use strict';
// ---------- CONFIG ----------
const TILE = 32;
const MAP_W = 64;
const MAP_H = 64;
const WORLD_W = MAP_W * TILE;
const WORLD_H = MAP_H * TILE;
const SIGHT_RANGE = 6; // tiles a unit reveals
const BUILDING_SIGHT = 5;
const CAMERA_SPEED = 600; // px/sec
const EDGE_SCROLL = 24; // px from edge to scroll
const MINIMAP_SIZE = 180;
// Tile types
const T_GRASS = 0, T_WATER = 1, T_TREE = 2, T_GOLD = 3, T_ROCK = 4, T_DIRT = 5;
const BLOCKING_TILES = new Set([T_WATER, T_TREE, T_GOLD, T_ROCK]);
// ---------- BUILDING DEFS ----------
const BUILDINGS = {
townhall: {
name: 'Town Hall', w: 3, h: 3, hp: 1200,
cost: { g: 200, w: 100 }, pop: 5, color: '#5a8ad8', accent: '#2a4a8a',
sight: 6, desc: 'Trains workers, drops off resources'
},
house: {
name: 'House', w: 2, h: 2, hp: 400,
cost: { g: 50, w: 30 }, pop: 5, color: '#b08850', accent: '#604020',
sight: 4, desc: 'Increases population cap by 5'
},
barracks: {
name: 'Barracks', w: 3, h: 3, hp: 800,
cost: { g: 150, w: 80 }, pop: 0, color: '#a05050', accent: '#502020',
sight: 5, desc: 'Trains soldiers'
},
tower: {
name: 'Tower', w: 1, h: 1, hp: 500,
cost: { g: 100, w: 50 }, pop: 0, color: '#787878', accent: '#383838',
sight: 7, range: 6 * TILE, damage: 14, attackCD: 1.0,
desc: 'Auto-attacks nearby enemies'
},
};
// ---------- UNIT DEFS ----------
const UNITS = {
worker: {
name: 'Worker', hp: 40, cost: { g: 50, w: 0 }, pop: 1,
speed: 70, color: '#5fb0e0', accent: '#2a6080',
buildTime: 4, damage: 3, range: 1.0 * TILE, attackCD: 1.0,
canBuild: true, canGather: true, carryCap: 10, gatherTime: 1.2,
sight: 5, radius: 8
},
soldier: {
name: 'Soldier', hp: 70, cost: { g: 60, w: 20 }, pop: 1,
speed: 65, color: '#d05050', accent: '#702020',
buildTime: 7, damage: 9, range: 1.1 * TILE, attackCD: 0.9,
sight: 6, radius: 9
},
};
// ---------- STATE ----------
const state = {
map: [], // 2D array of tile types
resourceTiles: [], // {tx, ty, type, amount}
units: [],
buildings: [],
projectiles: [],
effects: [], // visual fx (hit sparks, build puffs)
gold: 500,
wood: 300,
pop: 0,
popMax: 0,
selection: [],
buildMode: null, // building type string or null
ghost: { tx: 0, ty: 0, valid: false },
camera: { x: 0, y: 0 },
mouse: { x: 0, y: 0, worldX: 0, worldY: 0, tx: 0, ty: 0, down: false, rdown: false, dragStart: null, dragging: false },
keys: {},
revealed: [], // 2D bool array (fog of war)
visible: [], // 2D bool array (currently visible)
gameTime: 0,
enemy: { units: [], buildings: [] }, // simple enemies (defensive only)
running: true,
paused: false,
};
// ---------- CANVAS ----------
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const minimap = document.getElementById('minimap');
const mctx = minimap.getContext('2d');
function resize() {
const main = document.getElementById('mainArea');
canvas.width = main.clientWidth;
canvas.height = main.clientHeight;
}
window.addEventListener('resize', resize);
// ---------- UTIL ----------
function rand(a, b) { return a + Math.random() * (b - a); }
function randi(a, b) { return Math.floor(rand(a, b + 1)); }
function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.sqrt(dx*dx + dy*dy); }
function dist2(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return dx*dx + dy*dy; }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function tileX(tx) { return tx * TILE + TILE/2; }
function tileY(ty) { return ty * TILE + TILE/2; }
function inBounds(tx, ty) { return tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H; }
function showMessage(text, sub, button) {
const m = document.getElementById('message');
m.innerHTML = text + (sub ? `<div class="sub">${sub}</div>` : '') + (button ? `<br><button onclick="document.getElementById('message').style.display='none';location.reload()">${button}</button>` : '');
m.style.display = 'block';
}
// ---------- MAP GENERATION ----------
function generateMap() {
state.map = [];
state.revealed = [];
state.visible = [];
state.resourceTiles = [];
for (let y = 0; y < MAP_H; y++) {
const row = [], revRow = [], visRow = [];
for (let x = 0; x < MAP_W; x++) {
row.push(T_GRASS); revRow.push(false); visRow.push(false);
}
state.map.push(row); state.revealed.push(revRow); state.visible.push(visRow);
}
// Generate noise-ish terrain: water lakes
const numWaters = 4;
for (let i = 0; i < numWaters; i++) {
const cx = randi(8, MAP_W - 8), cy = randi(8, MAP_H - 8);
const r = randi(3, 6);
for (let y = cy - r; y <= cy + r; y++) for (let x = cx - r; x <= cx + r; x++) {
if (!inBounds(x, y)) continue;
const d = dist(x, y, cx, cy);
if (d <= r + rand(-0.5, 0.5)) state.map[y][x] = T_WATER;
}
}
// Forests (clusters of trees)
const numForests = 14;
for (let i = 0; i < numForests; i++) {
const cx = randi(3, MAP_W - 3), cy = randi(3, MAP_H - 3);
const count = randi(8, 20);
for (let j = 0; j < count; j++) {
const x = cx + randi(-3, 3), y = cy + randi(-3, 3);
if (!inBounds(x, y)) continue;
if (state.map[y][x] === T_GRASS && Math.random() < 0.7) {
state.map[y][x] = T_TREE;
state.resourceTiles.push({ tx: x, ty: y, type: 'wood', amount: 80 });
}
}
}
// Gold mines (clusters of gold rocks)
const numGold = 6;
for (let i = 0; i < numGold; i++) {
let attempts = 30;
while (attempts-- > 0) {
const cx = randi(4, MAP_W - 4), cy = randi(4, MAP_H - 4);
// require clear area
let ok = true;
for (let y = cy - 1; y <= cy + 1; y++) for (let x = cx - 1; x <= cx + 1; x++) {
if (!inBounds(x, y) || state.map[y][x] !== T_GRASS) { ok = false; break; }
}
if (!ok) continue;
// place gold mine (single 1x1 gold rock with high amount)
state.map[cy][cx] = T_GOLD;
state.resourceTiles.push({ tx: cx, ty: cy, type: 'gold', amount: 1500 });
// Optionally add small rocks around for decoration
for (let y = cy - 1; y <= cy + 1; y++) for (let x = cx - 1; x <= cx + 1; x++) {
if (x === cx && y === cy) continue;
if (Math.random() < 0.35 && state.map[y][x] === T_GRASS) state.map[y][x] = T_ROCK;
}
break;
}
}
// Scatter a few decorative rocks
for (let i = 0; i < 30; i++) {
const x = randi(0, MAP_W - 1), y = randi(0, MAP_H - 1);
if (state.map[y][x] === T_GRASS) state.map[y][x] = T_ROCK;
}
// Make sure starting area is clear (player start at tile 4,4)
for (let y = 3; y <= 8; y++) for (let x = 3; x <= 8; x++) {
if (state.map[y][x] !== T_WATER) state.map[y][x] = T_GRASS;
}
}
// ---------- PATHFINDING (A* on tile grid) ----------
function isTileBlocked(tx, ty, ignoreEntity) {
if (!inBounds(tx, ty)) return true;
if (BLOCKING_TILES.has(state.map[ty][tx])) return true;
// Check buildings
for (const b of state.buildings) {
if (b === ignoreEntity) continue;
if (tx >= b.tx && tx < b.tx + b.w && ty >= b.ty && ty < b.ty + b.h) return true;
}
for (const b of state.enemy.buildings) {
if (b === ignoreEntity) continue;
if (tx >= b.tx && tx < b.tx + b.w && ty >= b.ty && ty < b.ty + b.h) return true;
}
return false;
}
function findPath(sx, sy, ex, ey, ignoreEntity) {
// A* on tile grid. sx,sy,ex,ey are tile coords.
if (!inBounds(ex, ey)) return null;
if (isTileBlocked(ex, ey, ignoreEntity)) {
// find nearest walkable tile near ex,ey
let found = null;
for (let r = 1; r <= 4 && !found; r++) {
for (let dy = -r; dy <= r && !found; dy++) for (let dx = -r; dx <= r && !found; dx++) {
const nx = ex + dx, ny = ey + dy;
if (inBounds(nx, ny) && !isTileBlocked(nx, ny, ignoreEntity)) {
found = [nx, ny];
}
}
}
if (!found) return null;
ex = found[0]; ey = found[1];
}
const open = [];
const came = new Map();
const g = new Map();
const f = new Map();
const startKey = sx + ',' + sy;
const endKey = ex + ',' + ey;
g.set(startKey, 0);
f.set(startKey, dist(sx, sy, ex, ey));
open.push({ x: sx, y: sy, f: f.get(startKey) });
const closed = new Set();
let iter = 0;
const maxIter = 4000;
while (open.length > 0 && iter++ < maxIter) {
// pop lowest f
let bi = 0;
for (let i = 1; i < open.length; i++) if (open[i].f < open[bi].f) bi = i;
const cur = open.splice(bi, 1)[0];
const ck = cur.x + ',' + cur.y;
if (ck === endKey) {
// reconstruct
const path = [{ x: cur.x, y: cur.y }];
let k = ck;
while (came.has(k)) {
const p = came.get(k);
path.unshift({ x: p.x, y: p.y });
k = p.x + ',' + p.y;
}
return path;
}
closed.add(ck);
const neighbors = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
for (const [dx, dy] of neighbors) {
const nx = cur.x + dx, ny = cur.y + dy;
if (!inBounds(nx, ny)) continue;
const nk = nx + ',' + ny;
if (closed.has(nk)) continue;
// Block diagonal through corners
if (dx !== 0 && dy !== 0) {
if (isTileBlocked(cur.x + dx, cur.y, ignoreEntity) || isTileBlocked(cur.x, cur.y + dy, ignoreEntity)) continue;
}
if (isTileBlocked(nx, ny, ignoreEntity) && nk !== endKey) continue;
const moveCost = (dx !== 0 && dy !== 0) ? 1.414 : 1;
const tg = g.get(ck) + moveCost;
if (!g.has(nk) || tg < g.get(nk)) {
came.set(nk, { x: cur.x, y: cur.y });
g.set(nk, tg);
const h = dist(nx, ny, ex, ey);
f.set(nk, tg + h);
open.push({ x: nx, y: ny, f: tg + h });
}
}
}
return null;
}
// ---------- ENTITY FACTORIES ----------
let nextId = 1;
function makeUnit(type, x, y, owner) {
const def = UNITS[type];
return {
id: nextId++, type, owner, x, y, def,
hp: def.hp, maxHp: def.hp,
tx: Math.floor(x / TILE), ty: Math.floor(y / TILE),
path: null, pathIndex: 0,
target: null, // entity to attack/gather from
targetResource: null, // {tx,ty,type}
carrying: 0, carryType: null,
state: 'idle', // idle | moving | gathering | returning | attacking | building
facing: 0, // radians
attackCD: 0,
gatherTimer: 0,
buildTarget: null,
repathTimer: 0,
};
}
function makeBuilding(type, tx, ty, owner) {
const def = BUILDINGS[type];
return {
id: nextId++, type, owner, tx, ty, w: def.w, h: def.h, def,
x: tx * TILE + (def.w * TILE) / 2,
y: ty * TILE + (def.h * TILE) / 2,
hp: def.hp, maxHp: def.hp,
buildProgress: 1.0, // 1.0 = complete (for player-start buildings)
rallyX: null, rallyY: null,
trainQueue: [], trainTimer: 0,
attackCD: 0,
};
}
// ---------- INIT ----------
function initGame() {
generateMap();
// Player start: town hall at (4,4), 3 workers around it
const th = makeBuilding('townhall', 4, 4, 'player');
state.buildings.push(th);
// workers
for (let i = 0; i < 3; i++) {
const u = makeUnit('worker', th.x + rand(-40, 40), th.y + 60 + i * 20, 'player');
state.units.push(u);
}
// Enemy: a small base far away (top-right corner) — passive defenders only
const eth = makeBuilding('townhall', MAP_W - 8, MAP_H - 8, 'enemy');
eth.hp = eth.maxHp = 800;
state.enemy.buildings.push(eth);
// a couple enemy soldiers standing guard
for (let i = 0; i < 2; i++) {
state.enemy.units.push(makeUnit('soldier', eth.x + rand(-30, 30), eth.y + 70 + i * 25, 'enemy'));
}
// a couple more enemy soldiers scattered
state.enemy.units.push(makeUnit('soldier', MAP_W * TILE * 0.7, MAP_H * TILE * 0.3, 'enemy'));
state.enemy.units.push(makeUnit('soldier', MAP_W * TILE * 0.3, MAP_H * TILE * 0.7, 'enemy'));
// Stats
recalcPop();
// Camera at town hall
state.camera.x = th.x - canvas.width / 2;
state.camera.y = th.y - canvas.height / 2;
clampCamera();
}
function recalcPop() {
let pop = 0, popMax = 0;
for (const b of state.buildings) {
if (b.owner === 'player' && b.buildProgress >= 1.0) popMax += b.def.pop;
}
for (const u of state.units) if (u.owner === 'player') pop += 1;
state.pop = pop; state.popMax = popMax;
}
function clampCamera() {
state.camera.x = clamp(state.camera.x, 0, Math.max(0, WORLD_W - canvas.width));
state.camera.y = clamp(state.camera.y, 0, Math.max(0, WORLD_H - canvas.height));
}
// ---------- ENEMY ENTITIES (helpers) ----------
function allBuildings() { return state.buildings.concat(state.enemy.buildings); }
function allUnits() { return state.units.concat(state.enemy.units); }
// ---------- GATHER LOGIC ----------
function findNearestResourceTile(unit, type) {
let best = null, bestD = Infinity;
for (const r of state.resourceTiles) {
if (r.amount <= 0 || r.type !== type) continue;
const d = dist2(unit.x, unit.y, tileX(r.tx), tileY(r.ty));
if (d < bestD) { bestD = d; best = r; }
}
return best;
}
function findNearestDropoff(unit) {
let best = null, bestD = Infinity;
for (const b of state.buildings) {
if (b.owner !== unit.owner) continue;
if (b.type !== 'townhall') continue;
if (b.buildProgress < 1.0) continue;
const d = dist2(unit.x, unit.y, b.x, b.y);
if (d < bestD) { bestD = d; best = b; }
}
return best;
}
// ---------- COMBAT ----------
function findEnemyTarget(unit, range) {
let best = null, bestD = range * range;
const enemies = unit.owner === 'player' ? state.enemy.units : state.units;
for (const e of enemies) {
if (e.hp <= 0) continue;
const d = dist2(unit.x, unit.y, e.x, e.y);
if (d < bestD) { bestD = d; best = e; }
}
// also check enemy buildings
const enemyBuildings = unit.owner === 'player' ? state.enemy.buildings : state.buildings;
for (const b of enemyBuildings) {
if (b.hp <= 0) continue;
// approximate center distance
const d = dist2(unit.x, unit.y, b.x, b.y);
if (d < bestD + (b.w * TILE / 2) * (b.w * TILE / 2)) {
// closer check: edge distance
const ex = clamp(unit.x, b.tx * TILE, (b.tx + b.w) * TILE);
const ey = clamp(unit.y, b.ty * TILE, (b.ty + b.h) * TILE);
const ed = dist2(unit.x, unit.y, ex, ey);
if (ed < bestD) { bestD = ed; best = b; }
}
}
return best;
}
function dealDamage(target, dmg) {
target.hp -= dmg;
// spawn hit spark
state.effects.push({ type: 'spark', x: target.x, y: target.y, life: 0.25, max: 0.25 });
if (target.hp <= 0) {
state.effects.push({ type: 'death', x: target.x, y: target.y, life: 0.5, max: 0.5 });
}
}
// ---------- UPDATE ----------
function update(dt) {
if (!state.running) return;
state.gameTime += dt;
// Update player units
for (const u of state.units) updateUnit(u, dt);
// Update enemy units (passive: attack nearby player units in range, else idle)
for (const u of state.enemy.units) updateEnemyUnit(u, dt);
// Update buildings
for (const b of state.buildings) updateBuilding(b, dt);
for (const b of state.enemy.buildings) updateEnemyBuilding(b, dt);
// Projectiles
for (const p of state.projectiles) {
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt;
if (p.life <= 0) { if (p.target && p.target.hp > 0) dealDamage(p.target, p.damage); }
}
state.projectiles = state.projectiles.filter(p => p.life > 0);
// Effects
for (const e of state.effects) e.life -= dt;
state.effects = state.effects.filter(e => e.life > 0);
// Remove dead
state.units = state.units.filter(u => u.hp > 0);
state.enemy.units = state.enemy.units.filter(u => u.hp > 0);
state.buildings = state.buildings.filter(b => b.hp > 0);
state.enemy.buildings = state.enemy.buildings.filter(b => b.hp > 0);
// Remove depleted resource tiles
state.resourceTiles = state.resourceTiles.filter(r => r.amount > 0);
for (const r of state.resourceTiles) {
if (r.amount <= 0 && inBounds(r.tx, r.ty) && (state.map[r.ty][r.tx] === T_GOLD || state.map[r.ty][r.tx] === T_TREE)) {
state.map[r.ty][r.tx] = T_GRASS;
}
}
recalcPop();
updateFog();
checkVictory();
}
function updateUnit(u, dt) {
if (u.hp <= 0) return;
u.attackCD = Math.max(0, u.attackCD - dt);
u.repathTimer = Math.max(0, u.repathTimer - dt);
u.tx = Math.floor(u.x / TILE); u.ty = Math.floor(u.y / TILE);
// State machine
switch (u.state) {
case 'moving': {
if (!u.path || u.pathIndex >= u.path.length) { u.state = 'idle'; u.path = null; break; }
followPath(u, dt);
break;
}
case 'gathering': {
if (!u.targetResource || u.targetResource.amount <= 0) {
// try to find new resource of same type nearby
const r = findNearestResourceTile(u, u.carryType || 'wood');
if (r) { u.targetResource = r; } else { u.state = 'idle'; break; }
}
// Move adjacent to resource if far
const r = u.targetResource;
const d = dist(u.x, u.y, tileX(r.tx), tileY(r.ty));
if (d > TILE * 1.2) {
if (!u.path || u.pathIndex >= u.path.length) {
if (u.repathTimer <= 0) {
u.path = findPath(u.tx, u.ty, r.tx, r.ty, u);
u.pathIndex = u.path ? 1 : 0; // skip current tile
u.repathTimer = 0.5;
}
}
if (u.path) followPath(u, dt);
} else {
// gather
u.gatherTimer -= dt;
u.facing = Math.atan2(tileY(r.ty) - u.y, tileX(r.tx) - u.x);
if (u.gatherTimer <= 0) {
const take = Math.min(u.def.carryCap, r.amount);
r.amount -= take;
u.carrying = take;
u.carryType = r.type;
u.gatherTimer = u.def.gatherTime;
// return to nearest dropoff
const d2 = findNearestDropoff(u);
if (d2) {
u.state = 'returning';
u.target = d2;
u.path = findPath(u.tx, u.ty, Math.floor(d2.x / TILE), Math.floor(d2.y / TILE), u);
u.pathIndex = u.path ? 1 : 0;
} else {
u.state = 'idle'; u.carrying = 0; u.carryType = null;
}
}
}
break;
}
case 'returning': {
const t = u.target;
if (!t || t.hp <= 0) { u.state = 'idle'; u.target = null; u.path = null; break; }
// Use edge distance for buildings (so worker can deposit from adjacent tile)
const ex = clamp(u.x, t.tx * TILE, (t.tx + t.w) * TILE);
const ey = clamp(u.y, t.ty * TILE, (t.ty + t.h) * TILE);
const d = dist(u.x, u.y, ex, ey);
if (d > TILE * 1.2) {
if (!u.path || u.pathIndex >= u.path.length) {
if (u.repathTimer <= 0) {
u.path = findPath(u.tx, u.ty, Math.floor(t.x / TILE), Math.floor(t.y / TILE), u);
u.pathIndex = u.path ? 1 : 0;
u.repathTimer = 0.5;
}
}
if (u.path) followPath(u, dt);
} else {
// deposit
if (u.carryType === 'gold') state.gold += u.carrying;
else if (u.carryType === 'wood') state.wood += u.carrying;
const prevType = u.carryType;
u.carrying = 0; u.carryType = null;
// find more of same resource
const r = findNearestResourceTile(u, prevType);
if (r) {
u.targetResource = r;
u.state = 'gathering';
u.path = findPath(u.tx, u.ty, r.tx, r.ty, u);
u.pathIndex = u.path ? 1 : 0;
u.gatherTimer = u.def.gatherTime;
} else {
u.state = 'idle';
}
}
break;
}
case 'attacking': {
const t = u.target;
if (!t || t.hp <= 0) { u.target = null; u.state = 'idle'; break; }
const tx = t.x !== undefined ? t.x : (t.tx ? t.tx * TILE + TILE/2 : 0);
const ty = t.y !== undefined ? t.y : (t.ty ? t.ty * TILE + TILE/2 : 0);
// Use edge distance: if target is a building, compute edge distance
let d;
if (t.w) {
const aex = clamp(u.x, t.tx * TILE, (t.tx + t.w) * TILE);
const aey = clamp(u.y, t.ty * TILE, (t.ty + t.h) * TILE);
d = dist(u.x, u.y, aex, aey);
} else {
d = dist(u.x, u.y, tx, ty) - (t.def.radius || 0);
}
const range = u.def.range;
if (d > range) {
// move closer
if (!u.path || u.pathIndex >= u.path.length) {
if (u.repathTimer <= 0) {
u.path = findPath(u.tx, u.ty, Math.floor(tx / TILE), Math.floor(ty / TILE), u);
u.pathIndex = u.path ? 1 : 0;
u.repathTimer = 0.4;
}
}
if (u.path) followPath(u, dt);
} else {
u.facing = Math.atan2(ty - u.y, tx - u.x);
if (u.attackCD <= 0) {
u.attackCD = u.def.attackCD;
dealDamage(t, u.def.damage);
}
}
break;
}
case 'building': {
const b = u.buildTarget;
if (!b || b.hp <= 0 || b.buildProgress >= 1.0) {
u.buildTarget = null; u.state = 'idle'; break;
}
// Use edge distance for buildings
const bex = clamp(u.x, b.tx * TILE, (b.tx + b.w) * TILE);
const bey = clamp(u.y, b.ty * TILE, (b.ty + b.h) * TILE);
const bd = dist(u.x, u.y, bex, bey);
if (bd > TILE * 1.2) {
if (!u.path || u.pathIndex >= u.path.length) {
if (u.repathTimer <= 0) {
u.path = findPath(u.tx, u.ty, Math.floor(b.x / TILE), Math.floor(b.y / TILE), u);
u.pathIndex = u.path ? 1 : 0;
u.repathTimer = 0.4;
}
}
if (u.path) followPath(u, dt);
} else {
u.facing = Math.atan2(b.y - u.y, b.x - u.x);
b.buildProgress = Math.min(1.0, b.buildProgress + dt / 8); // 8 sec to build
// HP scales with build progress (from 10% to 100%)
b.hp = Math.min(b.maxHp, b.maxHp * (0.1 + 0.9 * b.buildProgress));
if (b.buildProgress >= 1.0) {
state.effects.push({ type: 'puff', x: b.x, y: b.y, life: 0.4, max: 0.4 });
u.buildTarget = null; u.state = 'idle';
}
}
break;
}
}
}
function followPath(u, dt) {
if (!u.path) return;
// target tile center
const target = u.path[u.pathIndex];
if (!target) { u.path = null; return; }
const tx = target.x * TILE + TILE / 2;
const ty = target.y * TILE + TILE / 2;
const dx = tx - u.x, dy = ty - u.y;
const d = Math.sqrt(dx*dx + dy*dy);
if (d < 4) {
u.pathIndex++;
if (u.pathIndex >= u.path.length) { u.path = null; }
return;
}
const speed = u.def.speed;
const step = Math.min(speed * dt, d);
let nx = u.x + (dx / d) * step;
let ny = u.y + (dy / d) * step;
// simple separation: avoid stacking on other units
const ntx = Math.floor(nx / TILE), nty = Math.floor(ny / TILE);
// Check building collision
let blocked = false;
for (const b of allBuildings()) {
if (nx >= b.tx * TILE && nx < (b.tx + b.w) * TILE && ny >= b.ty * TILE && ny < (b.ty + b.h) * TILE) {
blocked = true; break;
}
}
if (!blocked && inBounds(ntx, nty) && BLOCKING_TILES.has(state.map[nty][ntx])) blocked = true;
if (blocked) {
// try repath
if (u.repathTimer <= 0) {
const finalTarget = u.path[u.path.length - 1];
u.path = findPath(u.tx, u.ty, finalTarget.x, finalTarget.y, u);
u.pathIndex = u.path ? 1 : 0;
u.repathTimer = 0.5;
}
} else {
u.x = nx; u.y = ny;
u.facing = Math.atan2(dy, dx);
}
}
function updateEnemyUnit(u, dt) {
if (u.hp <= 0) return;
u.attackCD = Math.max(0, u.attackCD - dt);
u.repathTimer = Math.max(0, u.repathTimer - dt);
u.tx = Math.floor(u.x / TILE); u.ty = Math.floor(u.y / TILE);
// Defensive: attack nearby player units within aggro range (slightly larger than attack range)
const aggroRange = u.def.range + TILE * 3;
const t = findEnemyTarget(u, aggroRange);
if (t) {
// Use edge distance for buildings
let d;
if (t.w) {
const aex = clamp(u.x, t.tx * TILE, (t.tx + t.w) * TILE);
const aey = clamp(u.y, t.ty * TILE, (t.ty + t.h) * TILE);
d = dist(u.x, u.y, aex, aey);
} else {
d = dist(u.x, u.y, t.x, t.y) - (t.def.radius || 0);
}
const range = u.def.range;
if (d > range) {
// approach
if (!u.path || u.pathIndex >= u.path.length) {
if (u.repathTimer <= 0) {
u.path = findPath(u.tx, u.ty, Math.floor(t.x / TILE), Math.floor(t.y / TILE), u);
u.pathIndex = u.path ? 1 : 0;
u.repathTimer = 0.5;
}
}
if (u.path) followPath(u, dt);
} else {
u.facing = Math.atan2(t.y - u.y, t.x - u.x);
if (u.attackCD <= 0) {
u.attackCD = u.def.attackCD;
dealDamage(t, u.def.damage);
}
}
}
}
function updateBuilding(b, dt) {
if (b.buildProgress < 1.0) return; // under construction
b.attackCD = Math.max(0, b.attackCD - dt);
// Training queue
if (b.trainQueue.length > 0) {
b.trainTimer -= dt;
if (b.trainTimer <= 0) {
const unitType = b.trainQueue.shift();
const def = UNITS[unitType];
const nu = makeUnit(unitType, b.x + rand(-20, 20), b.y + b.h * TILE / 2 + 20, 'player');
state.units.push(nu);
// Set rally point
if (b.rallyX !== null) {
nu.path = findPath(nu.tx, nu.ty, Math.floor(b.rallyX / TILE), Math.floor(b.rallyY / TILE), nu);
nu.pathIndex = nu.path ? 1 : 0;
nu.state = 'moving';
}
b.trainTimer = b.trainQueue.length > 0 ? UNITS[b.trainQueue[0]].buildTime : 0;
}
}
// Tower attack
if (b.type === 'tower') {
const t = findEnemyTarget({ owner: 'player', x: b.x, y: b.y }, b.def.range);
if (t && b.attackCD <= 0) {
b.attackCD = b.def.attackCD;
// spawn projectile
const dx = t.x - b.x, dy = t.y - b.y;
const d = Math.sqrt(dx*dx + dy*dy);
const speed = 400;
state.projectiles.push({
x: b.x, y: b.y, vx: dx/d * speed, vy: dy/d * speed,
life: d / speed + 0.05, target: t, damage: b.def.damage
});
}
}
}
function updateEnemyBuilding(b, dt) {
b.attackCD = Math.max(0, b.attackCD - dt);
if (b.type === 'tower') {
const t = findEnemyTarget({ owner: 'enemy', x: b.x, y: b.y }, b.def.range);
if (t && b.attackCD <= 0) {
b.attackCD = b.def.attackCD;
const dx = t.x - b.x, dy = t.y - b.y;
const d = Math.sqrt(dx*dx + dy*dy);
const speed = 400;
state.projectiles.push({
x: b.x, y: b.y, vx: dx/d * speed, vy: dy/d * speed,
life: d / speed + 0.05, target: t, damage: b.def.damage
});
}
}
}
// ---------- FOG OF WAR ----------
function updateFog() {
// Reset current visibility
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) state.visible[y][x] = false;
// Reveal around each player unit & building
const sources = [];
for (const u of state.units) if (u.owner === 'player') sources.push({ x: u.x, y: u.y, r: u.def.sight });
for (const b of state.buildings) if (b.owner === 'player' && b.buildProgress >= 1.0) sources.push({ x: b.x, y: b.y, r: b.def.sight });
for (const s of sources) {
const ctx0 = Math.floor(s.x / TILE), cty = Math.floor(s.y / TILE);
const r = Math.ceil(s.r) + 1;
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const tx = ctx0 + dx, ty = cty + dy;
if (!inBounds(tx, ty)) continue;
if (dx*dx + dy*dy <= s.r * s.r) {
state.revealed[ty][tx] = true;
state.visible[ty][tx] = true;
}
}
}
}
function checkVictory() {
if (state.enemy.buildings.length === 0 && state.enemy.units.length === 0) {
state.running = false;
showMessage('🏆 Victory!', 'You have wiped out the enemy stronghold.', 'Play Again');
}
// Player loses if all buildings + units destroyed
const playerBuildings = state.buildings.filter(b => b.owner === 'player');
if (playerBuildings.length === 0 && state.units.length === 0) {
state.running = false;
showMessage('💀 Defeat', 'Your forces have been wiped out.', 'Play Again');
}
}
// ---------- RENDER ----------
function render() {
ctx.fillStyle = '#0a0c0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const cx = state.camera.x, cy = state.camera.y;
const startX = Math.max(0, Math.floor(cx / TILE));
const startY = Math.max(0, Math.floor(cy / TILE));
const endX = Math.min(MAP_W, Math.ceil((cx + canvas.width) / TILE) + 1);
const endY = Math.min(MAP_H, Math.ceil((cy + canvas.height) / TILE) + 1);
// Tiles
for (let ty = startY; ty < endY; ty++) {
for (let tx = startX; tx < endX; tx++) {
drawTile(tx, ty);
}
}
// Resource amounts (numbers on gold mines / trees when visible)
// Buildings (under construction + complete)
for (const b of state.buildings) drawBuilding(b, 'player');
for (const b of state.enemy.buildings) drawBuilding(b, 'enemy');
// Units
for (const u of state.units) drawUnit(u);
for (const u of state.enemy.units) drawUnit(u);
// Projectiles
for (const p of state.projectiles) {
ctx.fillStyle = '#ffe080';
ctx.beginPath();
ctx.arc(p.x - cx, p.y - cy, 3, 0, Math.PI * 2);
ctx.fill();
}
// Effects
for (const e of state.effects) {
const t = 1 - e.life / e.max;
if (e.type === 'spark') {
ctx.fillStyle = `rgba(255, 220, 80, ${1 - t})`;
ctx.beginPath();
ctx.arc(e.x - cx, e.y - cy, 6 + t * 8, 0, Math.PI * 2);
ctx.fill();
} else if (e.type === 'death') {
ctx.strokeStyle = `rgba(200, 50, 50, ${1 - t})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(e.x - cx, e.y - cy, 4 + t * 18, 0, Math.PI * 2);
ctx.stroke();
} else if (e.type === 'puff') {
ctx.fillStyle = `rgba(220, 220, 220, ${(1 - t) * 0.7})`;
ctx.beginPath();
ctx.arc(e.x - cx, e.y - cy, 8 + t * 30, 0, Math.PI * 2);
ctx.fill();
}
}
// Selection indicators
for (const u of state.selection) {
if (!u || u.hp <= 0) continue;
ctx.strokeStyle = '#7fff7f';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(u.x - cx, u.y - cy + 4, 12, 6, 0, 0, Math.PI * 2);
ctx.stroke();
// hp bar
if (u.hp < u.maxHp) {
const w = 24, h = 4;
ctx.fillStyle = '#000'; ctx.fillRect(u.x - cx - w/2, u.y - cy - 14, w, h);
ctx.fillStyle = '#4fff4f'; ctx.fillRect(u.x - cx - w/2, u.y - cy - 14, w * (u.hp / u.maxHp), h);
}
}
// Building selection indicator
if (state.selection.length === 1 && state.selection[0] && state.selection[0].w) {
const b = state.selection[0];
ctx.strokeStyle = '#7fff7f';
ctx.lineWidth = 2;
ctx.strokeRect(b.tx * TILE - cx, b.ty * TILE - cy, b.w * TILE, b.h * TILE);
}
// Build ghost
if (state.buildMode) {
drawGhost();
}
// Selection box
if (state.mouse.dragging && state.mouse.dragStart) {
const x0 = state.mouse.dragStart.x, y0 = state.mouse.dragStart.y;
const x1 = state.mouse.x, y1 = state.mouse.y;
ctx.strokeStyle = '#7fff7f';
ctx.fillStyle = 'rgba(127,255,127,0.12)';
ctx.lineWidth = 1;
ctx.fillRect(Math.min(x0,x1), Math.min(y0,y1), Math.abs(x1-x0), Math.abs(y1-y0));
ctx.strokeRect(Math.min(x0,x1), Math.min(y0,y1), Math.abs(x1-x0), Math.abs(y1-y0));
}
// Fog overlay (only dim tiles that are revealed but not currently visible)
drawFogOverlay(startX, startY, endX, endY);
// Minimap
drawMinimap();
}
function drawTile(tx, ty) {
const t = state.map[ty][tx];
const px = tx * TILE - state.camera.x;
const py = ty * TILE - state.camera.y;
// base
switch (t) {
case T_GRASS: {
// subtle variation
const seed = (tx * 31 + ty * 17) % 7;
const base = (seed < 2) ? '#5a8a3a' : (seed < 5) ? '#6a9542' : '#557f38';
ctx.fillStyle = base;
ctx.fillRect(px, py, TILE, TILE);
// small grass details
if (seed === 0) {
ctx.fillStyle = 'rgba(80,120,50,0.6)';
ctx.fillRect(px+6, py+8, 2, 2);
ctx.fillRect(px+22, py+18, 2, 2);
}
break;
}
case T_DIRT: {
ctx.fillStyle = '#7a6042';
ctx.fillRect(px, py, TILE, TILE);
break;
}
case T_WATER: {
const wave = Math.sin((state.gameTime * 1.5 + tx * 0.4 + ty * 0.3)) * 8;
ctx.fillStyle = '#2a6090';
ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = '#3a78a8';
ctx.fillRect(px, py + 10 + wave, TILE, 2);
ctx.fillRect(px, py + 22 + wave, TILE, 2);
break;
}
case T_TREE: {
// grass base
ctx.fillStyle = '#557f38';
ctx.fillRect(px, py, TILE, TILE);
// tree trunk + canopy
ctx.fillStyle = '#4a3010';
ctx.fillRect(px + 14, py + 18, 4, 10);
ctx.fillStyle = '#2a5a28';
ctx.beginPath();
ctx.arc(px + 16, py + 14, 11, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3a7038';
ctx.beginPath();
ctx.arc(px + 13, py + 12, 7, 0, Math.PI * 2);
ctx.fill();
// resource amount indicator
const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty);
if (r) {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(px + 2, py + 2, 18, 8);
ctx.fillStyle = '#b0e070';
ctx.font = '8px monospace';
ctx.textAlign = 'left';
ctx.fillText(Math.floor(r.amount), px + 4, py + 9);
}
break;
}
case T_GOLD: {
ctx.fillStyle = '#7a6042';
ctx.fillRect(px, py, TILE, TILE);
// gold rocks
ctx.fillStyle = '#c89500';
ctx.beginPath();
ctx.arc(px + 10, py + 18, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e8b830';
ctx.beginPath();
ctx.arc(px + 20, py + 14, 7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffe070';
ctx.beginPath();
ctx.arc(px + 14, py + 22, 4, 0, Math.PI * 2);
ctx.fill();
// sparkles
ctx.fillStyle = 'rgba(255,255,200,0.7)';
ctx.fillRect(px + 22, py + 10, 1, 1);
ctx.fillRect(px + 8, py + 24, 1, 1);
// amount
const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty);
if (r) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(px + 1, py + 1, 28, 9);
ctx.fillStyle = '#ffe070';
ctx.font = '8px monospace';
ctx.textAlign = 'left';
ctx.fillText('⛏ ' + Math.floor(r.amount), px + 3, py + 8);
}
break;
}
case T_ROCK: {
ctx.fillStyle = '#557f38';
ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = '#6a6868';
ctx.beginPath();
ctx.arc(px + 16, py + 18, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#8a8888';
ctx.beginPath();
ctx.arc(px + 13, py + 15, 5, 0, Math.PI * 2);
ctx.fill();
break;
}
}
}
function drawBuilding(b, owner) {
const def = b.def;
const px = b.tx * TILE - state.camera.x;
const py = b.ty * TILE - state.camera.y;
const w = b.w * TILE, h = b.h * TILE;
const baseColor = owner === 'enemy' ? shiftColor(def.color, -30, 20) : def.color;
const accentColor = owner === 'enemy' ? shiftColor(def.accent, 20, -10) : def.accent;
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(px + w/2, py + h - 2, w/2 - 2, 8, 0, 0, Math.PI * 2);
ctx.fill();
// Under construction?
if (b.buildProgress < 1.0) {
ctx.globalAlpha = 0.5 + b.buildProgress * 0.5;
// scaffold
ctx.fillStyle = '#6a4a20';
ctx.fillRect(px + 2, py + 2, w - 4, h - 4);
}
// building body
ctx.fillStyle = baseColor;
ctx.fillRect(px + 2, py + 4, w - 4, h - 6);
// darker bottom
ctx.fillStyle = accentColor;
ctx.fillRect(px + 2, py + h - 12, w - 4, 8);
// top accent / roof
if (b.type === 'townhall') {
// tower on top
ctx.fillStyle = accentColor;
ctx.fillRect(px + w/2 - 10, py - 8, 20, 14);
ctx.fillStyle = '#d4b870';
ctx.fillRect(px + w/2 - 8, py - 6, 16, 4);
// flag
ctx.fillStyle = owner === 'enemy' ? '#d04040' : '#4f8fd0';
ctx.fillRect(px + w/2 - 1, py - 18, 2, 12);
ctx.fillRect(px + w/2 + 1, py - 18, 8, 5);
} else if (b.type === 'house') {
// pitched roof
ctx.fillStyle = accentColor;
ctx.beginPath();
ctx.moveTo(px + 2, py + 6);
ctx.lineTo(px + w/2, py - 4);
ctx.lineTo(px + w - 2, py + 6);
ctx.closePath();
ctx.fill();
} else if (b.type === 'barracks') {
// battlements
ctx.fillStyle = accentColor;
for (let i = 0; i < 3; i++) {
ctx.fillRect(px + 6 + i * (w/3), py - 4, 10, 8);
}
// crossed swords icon
ctx.strokeStyle = '#d4b870';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px + w/2 - 8, py + h/2 - 6);
ctx.lineTo(px + w/2 + 8, py + h/2 + 6);
ctx.moveTo(px + w/2 + 8, py + h/2 - 6);
ctx.lineTo(px + w/2 - 8, py + h/2 + 6);
ctx.stroke();
} else if (b.type === 'tower') {
// top battlements
ctx.fillStyle = accentColor;
ctx.fillRect(px + 2, py - 4, w - 4, 6);
ctx.fillStyle = '#d4b870';
ctx.beginPath();
ctx.arc(px + w/2, py + h/2, 4, 0, Math.PI * 2);
ctx.fill();
}
// door
if (b.type === 'townhall' || b.type === 'barracks') {
ctx.fillStyle = '#3a2810';
ctx.fillRect(px + w/2 - 6, py + h - 16, 12, 12);
}
// owner color band
ctx.fillStyle = owner === 'enemy' ? '#d04040' : '#4f8fd0';
ctx.fillRect(px + 2, py + 4, 4, h - 6);
ctx.fillRect(px + w - 6, py + 4, 4, h - 6);
ctx.globalAlpha = 1.0;
// HP bar
if (b.hp < b.maxHp) {
const bw = w - 8, bh = 4;
ctx.fillStyle = '#000';
ctx.fillRect(px + 4, py - 10, bw, bh);
ctx.fillStyle = owner === 'enemy' ? '#d05050' : '#4fff4f';
ctx.fillRect(px + 4, py - 10, bw * (b.hp / b.maxHp), bh);
}
// Build progress bar
if (b.buildProgress < 1.0) {
const bw = w - 8, bh = 5;
ctx.fillStyle = '#000';
ctx.fillRect(px + 4, py - 18, bw, bh);
ctx.fillStyle = '#d4b870';
ctx.fillRect(px + 4, py - 18, bw * b.buildProgress, bh);
}
// Training queue indicator
if (b.trainQueue && b.trainQueue.length > 0) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(px + 4, py + h, w - 8, 6);
ctx.fillStyle = '#5fb0e0';
const cur = UNITS[b.trainQueue[0]].buildTime;
ctx.fillRect(px + 4, py + h, (w - 8) * (1 - b.trainTimer / cur), 6);
}
// rally point
if (b.rallyX !== null && state.selection.includes(b)) {
ctx.strokeStyle = '#7fff7f';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(b.x - state.camera.x, b.y - state.camera.y);
ctx.lineTo(b.rallyX - state.camera.x, b.rallyY - state.camera.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#7fff7f';
ctx.beginPath();
ctx.arc(b.rallyX - state.camera.x, b.rallyY - state.camera.y, 4, 0, Math.PI * 2);
ctx.fill();
}
}
function drawUnit(u) {
const cx = u.x - state.camera.x;
const cy = u.y - state.camera.y;
const r = u.def.radius;
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(cx, cy + r - 1, r * 0.9, r * 0.4, 0, 0, Math.PI * 2);
ctx.fill();
// body
ctx.fillStyle = u.owner === 'enemy' ? shiftColor(u.def.color, -20, 30) : u.def.color;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// outline
ctx.strokeStyle = u.def.accent;
ctx.lineWidth = 1.5;
ctx.stroke();
// direction indicator
ctx.fillStyle = u.def.accent;
const fx = cx + Math.cos(u.facing) * r;
const fy = cy + Math.sin(u.facing) * r;
ctx.beginPath();
ctx.arc(fx, fy, 2.5, 0, Math.PI * 2);
ctx.fill();
// unit-specific accents
if (u.type === 'worker') {
// little hat
ctx.fillStyle = '#d4b870';
ctx.fillRect(cx - 4, cy - r - 2, 8, 3);
// carrying indicator
if (u.carrying > 0) {
ctx.fillStyle = u.carryType === 'gold' ? '#ffd700' : '#a06030';
ctx.beginPath();
ctx.arc(cx, cy - r - 5, 3, 0, Math.PI * 2);
ctx.fill();
}
} else if (u.type === 'soldier') {
// helmet
ctx.fillStyle = '#888';
ctx.beginPath();
ctx.arc(cx, cy - 2, r * 0.7, Math.PI, Math.PI * 2);
ctx.fill();
// sword
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(fx + Math.cos(u.facing) * 2, fy + Math.sin(u.facing) * 2);
ctx.lineTo(fx + Math.cos(u.facing) * 8, fy + Math.sin(u.facing) * 8);
ctx.stroke();
}
// HP bar (only if damaged)
if (u.hp < u.maxHp) {
const w = 20, h = 3;
ctx.fillStyle = '#000';
ctx.fillRect(cx - w/2, cy - r - 8, w, h);
ctx.fillStyle = u.owner === 'enemy' ? '#d05050' : '#4fff4f';
ctx.fillRect(cx - w/2, cy - r - 8, w * (u.hp / u.maxHp), h);
}
}
function drawGhost() {
const def = BUILDINGS[state.buildMode];
const tx = state.ghost.tx, ty = state.ghost.ty;
const px = tx * TILE - state.camera.x;
const py = ty * TILE - state.camera.y;
const w = def.w * TILE, h = def.h * TILE;
ctx.globalAlpha = 0.6;
ctx.fillStyle = state.ghost.valid ? '#7fff7f' : '#ff7f7f';
ctx.fillRect(px, py, w, h);
ctx.strokeStyle = state.ghost.valid ? '#4faf4f' : '#af4f4f';
ctx.lineWidth = 2;
ctx.strokeRect(px, py, w, h);
ctx.globalAlpha = 1.0;
// grid lines on building footprint
ctx.strokeStyle = state.ghost.valid ? 'rgba(127,255,127,0.4)' : 'rgba(255,127,127,0.4)';
ctx.lineWidth = 1;
for (let i = 1; i < def.w; i++) {
ctx.beginPath();
ctx.moveTo(px + i * TILE, py); ctx.lineTo(px + i * TILE, py + h);
ctx.stroke();
}
for (let i = 1; i < def.h; i++) {
ctx.beginPath();
ctx.moveTo(px, py + i * TILE); ctx.lineTo(px + w, py + i * TILE);
ctx.stroke();
}
}
function drawFogOverlay(startX, startY, endX, endY) {
for (let ty = startY; ty < endY; ty++) {
for (let tx = startX; tx < endX; tx++) {
if (!state.revealed[ty][tx]) {
// pitch black - unexplored
ctx.fillStyle = '#000';
ctx.fillRect(tx * TILE - state.camera.x, ty * TILE - state.camera.y, TILE, TILE);
} else if (!state.visible[ty][tx]) {
// explored but not visible - darken
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(tx * TILE - state.camera.x, ty * TILE - state.camera.y, TILE, TILE);
}
}
}
}
function shiftColor(hex, dr, dg) {
// simple shift: parse #rrggbb, shift r and g
const r = clamp(parseInt(hex.slice(1,3),16) + dr, 0, 255);
const g = clamp(parseInt(hex.slice(3,5),16) + dg, 0, 255);
const b = clamp(parseInt(hex.slice(5,7),16), 0, 255);
return `rgb(${r|0},${g|0},${b|0})`;
}
// ---------- MINIMAP ----------
function drawMinimap() {
const scale = MINIMAP_SIZE / MAP_W;
mctx.fillStyle = '#000';
mctx.fillRect(0, 0, MINIMAP_SIZE, MINIMAP_SIZE);
// terrain (sample one pixel per tile for speed)
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
if (!state.revealed[y][x]) continue;
const t = state.map[y][x];
let c = '#3a5a28';
if (t === T_WATER) c = '#2a6090';
else if (t === T_TREE) c = '#1f4018';
else if (t === T_GOLD) c = '#c89500';
else if (t === T_ROCK) c = '#6a6868';
mctx.fillStyle = c;
mctx.fillRect(x * scale, y * scale, scale + 0.5, scale + 0.5);
}
// buildings
for (const b of state.buildings) {
if (!state.visible[b.ty][b.tx]) continue;
mctx.fillStyle = '#4f8fd0';
mctx.fillRect(b.tx * scale, b.ty * scale, b.w * scale, b.h * scale);
}
for (const b of state.enemy.buildings) {
if (!state.visible[b.ty] || !state.visible[b.ty][b.tx]) continue;
mctx.fillStyle = '#d04040';
mctx.fillRect(b.tx * scale, b.ty * scale, b.w * scale, b.h * scale);
}
// units
for (const u of state.units) {
if (!state.visible[Math.floor(u.y/TILE)] || !state.visible[Math.floor(u.y/TILE)][Math.floor(u.x/TILE)]) continue;
mctx.fillStyle = '#7fc0ff';
mctx.fillRect(u.x / TILE * scale - 1, u.y / TILE * scale - 1, 2, 2);
}
for (const u of state.enemy.units) {
if (!state.visible[Math.floor(u.y/TILE)] || !state.visible[Math.floor(u.y/TILE)][Math.floor(u.x/TILE)]) continue;
mctx.fillStyle = '#ff6060';
mctx.fillRect(u.x / TILE * scale - 1, u.y / TILE * scale - 1, 2, 2);
}
// camera viewport
mctx.strokeStyle = '#ffd700';
mctx.lineWidth = 1;
mctx.strokeRect(state.camera.x / TILE * scale, state.camera.y / TILE * scale, canvas.width / TILE * scale, canvas.height / TILE * scale);
}
// ---------- INPUT ----------
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
if (e.button === 0) {
if (state.buildMode) {
tryPlaceBuilding(mx, my);
return;
}
state.mouse.down = true;
state.mouse.dragStart = { x: mx, y: my };
state.mouse.dragging = false;
} else if (e.button === 2) {
state.mouse.rdown = true;
handleRightClick(mx, my);
}
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
state.mouse.x = mx; state.mouse.y = my;
state.mouse.worldX = mx + state.camera.x;
state.mouse.worldY = my + state.camera.y;
state.mouse.tx = Math.floor(state.mouse.worldX / TILE);
state.mouse.ty = Math.floor(state.mouse.worldY / TILE);
if (state.mouse.down && state.mouse.dragStart) {
const dx = mx - state.mouse.dragStart.x, dy = my - state.mouse.dragStart.y;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) state.mouse.dragging = true;
}
if (state.buildMode) {
updateGhost();
}
// tooltip
updateTooltip(mx, my);
});
canvas.addEventListener('mouseup', (e) => {
if (e.button === 0) {
if (state.mouse.dragging) {
// box select
const x0 = Math.min(state.mouse.dragStart.x, state.mouse.x);
const y0 = Math.min(state.mouse.dragStart.y, state.mouse.y);
const x1 = Math.max(state.mouse.dragStart.x, state.mouse.x);
const y1 = Math.max(state.mouse.dragStart.y, state.mouse.y);
const sel = [];
for (const u of state.units) {
const sx = u.x - state.camera.x, sy = u.y - state.camera.y;
if (sx >= x0 && sx <= x1 && sy >= y0 && sy <= y1) sel.push(u);
}
state.selection = sel;
} else {
// single select
const wx = state.mouse.worldX, wy = state.mouse.worldY;
let found = null;
for (const u of state.units) {
if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { found = u; break; }
}
if (!found) {
for (const b of state.buildings) {
if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { found = b; break; }
}
}
if (!found) {
// enemy unit/building (just for inspection - can't command)
for (const u of state.enemy.units) {
if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { found = u; break; }
}
if (!found) for (const b of state.enemy.buildings) {
if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { found = b; break; }
}
}
state.selection = found ? [found] : [];
}
state.mouse.down = false;
state.mouse.dragging = false;
state.mouse.dragStart = null;
updateSelectionPanel();
} else if (e.button === 2) {
state.mouse.rdown = false;
}
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Mouse wheel: zoom? Let's keep it simple - no zoom. Use it for nothing.
canvas.addEventListener('wheel', (e) => { e.preventDefault(); }, { passive: false });
// Edge scrolling handled in update loop
// Keyboard
window.addEventListener('keydown', (e) => {
state.keys[e.key.toLowerCase()] = true;
if (e.key === 'Escape') {
state.buildMode = null;
state.selection = [];
updateSelectionPanel();
refreshBuildPanel();
}
// Hotkeys
if (e.key.toLowerCase() === 'b' && state.selection.length > 0) {
// toggle build menu if worker selected - handled by panel
}
});
window.addEventListener('keyup', (e) => {
state.keys[e.key.toLowerCase()] = false;
});
// Minimap click
minimap.addEventListener('mousedown', (e) => {
const rect = minimap.getBoundingClientRect();
const mx = (e.clientX - rect.left) / MINIMAP_SIZE * MAP_W * TILE;
const my = (e.clientY - rect.top) / MINIMAP_SIZE * MAP_H * TILE;
state.camera.x = mx - canvas.width / 2;
state.camera.y = my - canvas.height / 2;
clampCamera();
});
function handleRightClick(mx, my) {
if (state.buildMode) { state.buildMode = null; refreshBuildPanel(); return; }
const wx = mx + state.camera.x, wy = my + state.camera.y;
const selectedUnits = state.selection.filter(s => s.def && !s.w); // units only
const selectedBuildings = state.selection.filter(s => s.w);
if (selectedUnits.length === 0) {
// If a building selected, set rally point
if (selectedBuildings.length > 0) {
const b = selectedBuildings[0];
b.rallyX = wx; b.rallyY = wy;
}
return;
}
// Determine command: attack enemy, gather resource, or move
let targetEnemy = null;
for (const u of state.enemy.units) if (dist(u.x, u.y, wx, wy) < 16) { targetEnemy = u; break; }
if (!targetEnemy) for (const b of state.enemy.buildings) {
if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { targetEnemy = b; break; }
}
let resourceTile = null;
if (!targetEnemy) {
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
if (inBounds(tx, ty) && (state.map[ty][tx] === T_TREE || state.map[ty][tx] === T_GOLD)) {
const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty);
if (r) resourceTile = r;
}
}
for (const u of selectedUnits) {
u.path = null;
u.target = null;
u.targetResource = null;
u.buildTarget = null;
if (targetEnemy) {
u.target = targetEnemy;
u.state = 'attacking';
} else if (resourceTile && u.def.canGather) {
u.targetResource = resourceTile;
u.carryType = resourceTile.type;
u.state = 'gathering';
u.gatherTimer = u.def.gatherTime;
} else {
// move
u.state = 'moving';
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
u.path = findPath(u.tx, u.ty, tx, ty, u);
u.pathIndex = u.path ? 1 : 0;
}
}
// formation offset for groups
if (selectedUnits.length > 1 && !targetEnemy && !resourceTile) {
// simple cluster: offset each by small amounts
const cx = Math.floor(wx / TILE), cy = Math.floor(wy / TILE);
selectedUnits.forEach((u, i) => {
const ox = (i % 3) - 1, oy = Math.floor(i / 3) - 1;
u.path = findPath(u.tx, u.ty, cx + ox, cy + oy, u);
u.pathIndex = u.path ? 1 : 0;
});
}
// click ripple
state.effects.push({ type: 'puff', x: wx, y: wy, life: 0.3, max: 0.3 });
}
function tryPlaceBuilding(mx, my) {
const def = BUILDINGS[state.buildMode];
// check resources
if (state.gold < def.cost.g || state.wood < def.cost.w) {
showMessage('Not enough resources!', `Need ${def.cost.g}g, ${def.cost.w}w`, '');
setTimeout(() => document.getElementById('message').style.display = 'none', 1200);
return;
}
if (!state.ghost.valid) return;
const tx = state.ghost.tx, ty = state.ghost.ty;
// create building (under construction)
const b = makeBuilding(state.buildMode, tx, ty, 'player');
b.buildProgress = 0.0;
b.hp = b.maxHp * 0.1; // starts with little hp
state.buildings.push(b);
state.gold -= def.cost.g;
state.wood -= def.cost.w;
// assign nearest selected worker to build
const workers = state.selection.filter(u => u.def && u.def.canBuild);
if (workers.length > 0) {
let best = workers[0], bd = Infinity;
for (const w of workers) {
const d = dist(w.x, w.y, b.x, b.y);
if (d < bd) { bd = d; best = w; }
}
best.buildTarget = b;
best.state = 'building';
best.path = findPath(best.tx, best.ty, Math.floor(b.x / TILE), Math.floor(b.y / TILE), best);
best.pathIndex = best.path ? 1 : 0;
}
// stay in build mode if shift held
if (!state.keys['shift']) {
state.buildMode = null;
}
refreshBuildPanel();
}
function updateGhost() {
const def = BUILDINGS[state.buildMode];
const tx = state.mouse.tx, ty = state.mouse.ty;
// snap so footprint is at cursor center-ish
const sx = tx - Math.floor((def.w - 1) / 2);
const sy = ty - Math.floor((def.h - 1) / 2);
state.ghost.tx = sx; state.ghost.ty = sy;
// validate
let valid = true;
for (let y = sy; y < sy + def.h; y++) for (let x = sx; x < sx + def.w; x++) {
if (!inBounds(x, y)) { valid = false; break; }
if (state.map[y][x] !== T_GRASS) { valid = false; break; }
for (const b of allBuildings()) {
if (x >= b.tx && x < b.tx + b.w && y >= b.ty && y < b.ty + b.h) { valid = false; break; }
}
}
if (state.gold < def.cost.g || state.wood < def.cost.w) valid = false;
state.ghost.valid = valid;
}
function updateTooltip(mx, my) {
const tip = document.getElementById('tooltip');
const wx = state.mouse.worldX, wy = state.mouse.worldY;
let target = null, kind = null;
for (const u of state.units) {
if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { target = u; kind = 'unit'; break; }
}
if (!target) for (const u of state.enemy.units) {
if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { target = u; kind = 'unit'; break; }
}
if (!target) for (const b of state.buildings) {
if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { target = b; kind = 'building'; break; }
}
if (!target) for (const b of state.enemy.buildings) {
if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { target = b; kind = 'building'; break; }
}
if (!target) {
// resource tile
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
if (inBounds(tx, ty) && (state.map[ty][tx] === T_GOLD || state.map[ty][tx] === T_TREE)) {
const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty);
if (r) {
tip.style.display = 'block';
tip.style.left = (mx + 14) + 'px';
tip.style.top = (my + 14) + 'px';
tip.innerHTML = `<b>${r.type === 'gold' ? 'Gold Mine' : 'Forest'}</b><br>Remaining: ${Math.floor(r.amount)}`;
return;
}
}
tip.style.display = 'none';
return;
}
if (kind === 'unit') {
tip.style.display = 'block';
tip.style.left = (mx + 14) + 'px';
tip.style.top = (my + 14) + 'px';
tip.innerHTML = `<b style="color:${target.def.color}">${target.def.name}</b> (${target.owner})<br>HP: ${Math.ceil(target.hp)}/${target.maxHp}<br>DMG: ${target.def.damage} • SPD: ${target.def.speed}`;
} else {
tip.style.display = 'block';
tip.style.left = (mx + 14) + 'px';
tip.style.top = (my + 14) + 'px';
tip.innerHTML = `<b style="color:${target.def.color}">${target.def.name}</b> (${target.owner})<br>HP: ${Math.ceil(target.hp)}/${target.maxHp}<br>${target.def.desc || ''}`;
}
}
// ---------- UI ----------
function updateSelectionPanel() {
const p = document.getElementById('selectionPanel');
if (state.selection.length === 0) {
p.innerHTML = '<div class="empty">Click a unit or building to inspect it.</div>';
refreshBuildPanel();
return;
}
if (state.selection.length > 1) {
const counts = {};
for (const u of state.selection) {
const k = u.type;
counts[k] = (counts[k] || 0) + 1;
}
p.innerHTML = `<div class="name">${state.selection.length} units selected</div>` +
Object.entries(counts).map(([k, v]) => `<div class="stat">${UNITS[k] ? UNITS[k].name : k}: <b>${v}</b></div>`).join('');
refreshBuildPanel();
return;
}
const e = state.selection[0];
if (e.def && !e.w) {
// unit
p.innerHTML = `<div class="name">${e.def.name}</div>` +
`<span class="stat">HP: <b>${Math.ceil(e.hp)}/${e.maxHp}</b></span>` +
`<span class="stat">DMG: <b>${e.def.damage}</b></span>` +
`<span class="stat">State: <b>${e.state}</b></span>` +
(e.carrying > 0 ? `<span class="stat">Carrying: <b>${e.carrying} ${e.carryType}</b></span>` : '');
} else {
// building
p.innerHTML = `<div class="name">${e.def.name}</div>` +
`<span class="stat">HP: <b>${Math.ceil(e.hp)}/${e.maxHp}</b></span>` +
(e.buildProgress < 1.0 ? `<div class="progress"><div style="width:${e.buildProgress*100}%"></div></div>Under construction...` : '');
// show training queue info
if (e.trainQueue && e.trainQueue.length > 0) {
p.innerHTML += `<div>Training: ${e.trainQueue.map(t => UNITS[t].name).join(', ')}</div>`;
}
}
refreshBuildPanel();
}
function refreshBuildPanel() {
const panel = document.getElementById('buildPanel');
panel.innerHTML = '';
// Determine context: which buttons to show
const sel = state.selection;
const hasWorker = sel.some(s => s.def && s.def.canBuild);
const hasTownhall = sel.some(s => s.type === 'townhall' && s.owner === 'player' && s.buildProgress >= 1.0);
const hasBarracks = sel.some(s => s.type === 'barracks' && s.owner === 'player' && s.buildProgress >= 1.0);
if (hasWorker) {
for (const key of Object.keys(BUILDINGS)) {
const def = BUILDINGS[key];
const canAfford = state.gold >= def.cost.g && state.wood >= def.cost.w;
const btn = document.createElement('div');
btn.className = 'build-btn' + (state.buildMode === key ? ' active' : '') + (canAfford ? '' : ' disabled');
btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>${def.name}</div><div class="cost">${def.cost.g}g ${def.cost.w}w</div>`;
btn.onclick = () => {
if (state.buildMode === key) state.buildMode = null;
else state.buildMode = key;
refreshBuildPanel();
};
panel.appendChild(btn);
// draw icon
const ic = btn.querySelector('canvas');
drawBuildingIcon(ic, key);
}
}
if (hasTownhall) {
const btn = document.createElement('div');
btn.className = 'build-btn';
const def = UNITS.worker;
btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>Worker</div><div class="cost">${def.cost.g}g</div>`;
btn.onclick = () => trainUnit('worker');
panel.appendChild(btn);
drawUnitIcon(btn.querySelector('canvas'), 'worker');
}
if (hasBarracks) {
const btn = document.createElement('div');
btn.className = 'build-btn';
const def = UNITS.soldier;
btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>Soldier</div><div class="cost">${def.cost.g}g ${def.cost.w}w</div>`;
btn.onclick = () => trainUnit('soldier');
panel.appendChild(btn);
drawUnitIcon(btn.querySelector('canvas'), 'soldier');
}
// stop / cancel button when training queue exists
const bldWithQueue = sel.find(s => s.trainQueue && s.trainQueue.length > 0);
if (bldWithQueue) {
const btn = document.createElement('div');
btn.className = 'build-btn';
btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>Cancel</div><div class="cost">last in queue</div>`;
btn.onclick = () => {
const t = bldWithQueue.trainQueue.pop();
if (t) {
const def = UNITS[t];
state.gold += def.cost.g;
state.wood += def.cost.w;
recalcPop();
}
updateSelectionPanel();
};
panel.appendChild(btn);
const ic = btn.querySelector('canvas');
const c = ic.getContext('2d');
c.fillStyle = '#d05050'; c.font = 'bold 28px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText('✕', 18, 18);
}
}
function drawBuildingIcon(c, type) {
const ctx2 = c.getContext('2d');
ctx2.clearRect(0,0,36,36);
const def = BUILDINGS[type];
ctx2.fillStyle = def.color;
ctx2.fillRect(4, 8, 28, 24);
ctx2.fillStyle = def.accent;
ctx2.fillRect(4, 28, 28, 4);
if (type === 'townhall') {
ctx2.fillStyle = def.accent;
ctx2.fillRect(13, 2, 10, 8);
} else if (type === 'house') {
ctx2.fillStyle = def.accent;
ctx2.beginPath();
ctx2.moveTo(4, 10); ctx2.lineTo(18, 2); ctx2.lineTo(32, 10); ctx2.closePath(); ctx2.fill();
} else if (type === 'barracks') {
ctx2.fillStyle = def.accent;
ctx2.fillRect(6, 4, 6, 6); ctx2.fillRect(15, 4, 6, 6); ctx2.fillRect(24, 4, 6, 6);
} else if (type === 'tower') {
ctx2.fillStyle = def.accent;
ctx2.fillRect(4, 4, 28, 4);
}
}
function drawUnitIcon(c, type) {
const ctx2 = c.getContext('2d');
ctx2.clearRect(0,0,36,36);
const def = UNITS[type];
ctx2.fillStyle = def.color;
ctx2.beginPath(); ctx2.arc(18, 20, 10, 0, Math.PI * 2); ctx2.fill();
ctx2.strokeStyle = def.accent; ctx2.lineWidth = 2; ctx2.stroke();
if (type === 'worker') {
ctx2.fillStyle = '#d4b870';
ctx2.fillRect(13, 7, 10, 3);
} else {
ctx2.fillStyle = '#888';
ctx2.beginPath(); ctx2.arc(18, 18, 7, Math.PI, Math.PI * 2); ctx2.fill();
ctx2.strokeStyle = '#ddd'; ctx2.lineWidth = 1.5;
ctx2.beginPath(); ctx2.moveTo(28, 20); ctx2.lineTo(32, 16); ctx2.stroke();
}
}
function trainUnit(type) {
const def = UNITS[type];
// find selected building of correct type
let bld = null;
for (const s of state.selection) {
if (s.def && s.w && s.buildProgress >= 1.0) {
if (type === 'worker' && s.type === 'townhall') { bld = s; break; }
if (type === 'soldier' && s.type === 'barracks') { bld = s; break; }
}
}
if (!bld) return;
if (state.gold < def.cost.g || state.wood < def.cost.w) {
showMessage('Not enough resources!', `Need ${def.cost.g}g${def.cost.w ? ' and ' + def.cost.w + 'w' : ''}`, '');
setTimeout(() => document.getElementById('message').style.display = 'none', 1000);
return;
}
if (state.pop + def.pop > state.popMax) {
showMessage('Population cap reached!', 'Build more houses to increase it.', '');
setTimeout(() => document.getElementById('message').style.display = 'none', 1200);
return;
}
state.gold -= def.cost.g;
state.wood -= def.cost.w;
bld.trainQueue.push(type);
if (bld.trainQueue.length === 1) bld.trainTimer = def.buildTime;
updateSelectionPanel();
}
// ---------- MAIN LOOP ----------
let lastT = performance.now();
function loop(now) {
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
if (!state.paused) {
// Camera scrolling (keys + edge)
let dx = 0, dy = 0;
if (state.keys['a'] || state.keys['arrowleft']) dx -= 1;
if (state.keys['d'] || state.keys['arrowright']) dx += 1;
if (state.keys['w'] || state.keys['arrowup']) dy -= 1;
if (state.keys['s'] || state.keys['arrowdown']) dy += 1;
// edge scroll (only if mouse inside canvas)
if (state.mouse.x >= 0 && state.mouse.x < canvas.width && state.mouse.y >= 0 && state.mouse.y < canvas.height) {
if (state.mouse.x < EDGE_SCROLL) dx -= 1;
if (state.mouse.x > canvas.width - EDGE_SCROLL) dx += 1;
if (state.mouse.y < EDGE_SCROLL) dy -= 1;
if (state.mouse.y > canvas.height - EDGE_SCROLL) dy += 1;
}
if (dx !== 0 || dy !== 0) {
const len = Math.sqrt(dx*dx + dy*dy);
state.camera.x += (dx / len) * CAMERA_SPEED * dt;
state.camera.y += (dy / len) * CAMERA_SPEED * dt;
clampCamera();
}
update(dt);
}
render();
// Update HUD
document.getElementById('gold').textContent = Math.floor(state.gold);
document.getElementById('wood').textContent = Math.floor(state.wood);
document.getElementById('pop').textContent = state.pop;
document.getElementById('popMax').textContent = state.popMax;
const m = Math.floor(state.gameTime / 60);
const s = Math.floor(state.gameTime % 60);
document.getElementById('gameTime').textContent = (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
// Update selection panel live (for build progress etc.)
if (state.selection.length > 0) updateSelectionPanel();
requestAnimationFrame(loop);
}
// ---------- BOOT ----------
resize();
initGame();
updateSelectionPanel();
refreshBuildPanel();
requestAnimationFrame(loop);
// Welcome message
setTimeout(() => {
showMessage('⚔ MINI RTS', 'Build your base, gather gold & wood, train an army, then destroy the enemy stronghold in the far corner.<br><br><span style="font-size:13px;color:#8a8270">Click to dismiss</span>');
document.getElementById('message').onclick = () => document.getElementById('message').style.display = 'none';
}, 100);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment