Skip to content

Instantly share code, notes, and snippets.

@senko
Created February 5, 2026 19:17
Show Gist options
  • Select an option

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

Select an option

Save senko/8751829c1aa9a4342383ef2fd4da7424 to your computer and use it in GitHub Desktop.
RTS game by Opus 4.6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conquest RTS</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; }
#game { display: block; cursor: default; }
#minimap { position: fixed; left: 8px; bottom: 158px; border: 2px solid #556; background: #000; z-index: 10; cursor: pointer; }
#topbar { position: fixed; top: 0; left: 0; right: 0; height: 36px; background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-bottom: 2px solid #334; display: flex; align-items: center; padding: 0 16px; gap: 24px; z-index: 10; user-select: none; }
.res { display: flex; align-items: center; gap: 6px; color: #ddd; font-size: 14px; font-weight: 600; }
.res-icon { width: 16px; height: 16px; border-radius: 3px; display: inline-block; }
.gold-icon { background: linear-gradient(135deg, #ffd700, #daa520); }
.wood-icon { background: linear-gradient(135deg, #8b6914, #6b4226); }
.supply-icon { background: linear-gradient(135deg, #6a9eea, #3366cc); }
#panel { position: fixed; bottom: 0; left: 0; right: 0; height: 150px; background: linear-gradient(0deg, #0d1117 0%, #161b22 100%); border-top: 2px solid #334; display: flex; z-index: 10; user-select: none; }
#panel-info { flex: 1; padding: 10px 16px; display: flex; flex-direction: column; gap: 4px; color: #ccc; min-width: 0; }
#panel-info .name { font-size: 16px; font-weight: 700; color: #fff; }
#panel-info .detail { font-size: 12px; color: #aaa; }
#panel-info .hp-bar-outer { width: 140px; height: 8px; background: #333; border-radius: 4px; margin-top: 4px; }
#panel-info .hp-bar-inner { height: 100%; background: #4caf50; border-radius: 4px; transition: width 0.2s; }
#panel-info .train-progress-outer { width: 140px; height: 8px; background: #333; border-radius: 4px; margin-top: 2px; }
#panel-info .train-progress-inner { height: 100%; background: #42a5f5; border-radius: 4px; }
#panel-actions { width: 320px; padding: 10px; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 6px; }
.action-btn { background: #1e2a3a; border: 1px solid #3a4a5a; color: #ddd; padding: 6px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; min-width: 90px; text-align: center; transition: all 0.15s; }
.action-btn:hover { background: #2a3a5a; border-color: #5a7aaa; color: #fff; }
.action-btn:active { background: #3a4a6a; }
.action-btn.disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.action-btn .cost { font-size: 10px; color: #999; display: block; }
#msg { position: fixed; top: 44px; left: 50%; transform: translateX(-50%); color: #ffcc00; font-size: 14px; font-weight: 600; z-index: 20; pointer-events: none; opacity: 0; transition: opacity 0.3s; text-shadow: 0 0 8px rgba(0,0,0,0.8); }
#msg.show { opacity: 1; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<canvas id="minimap" width="180" height="180"></canvas>
<div id="topbar">
<div class="res"><span class="res-icon gold-icon"></span><span id="resGold">500</span></div>
<div class="res"><span class="res-icon wood-icon"></span><span id="resWood">500</span></div>
<div class="res"><span class="res-icon supply-icon"></span><span id="resSupply">0/10</span></div>
</div>
<div id="panel">
<div id="panel-info"><span class="detail" style="margin-top:40px;text-align:center;color:#667">Select a unit or building</span></div>
<div id="panel-actions"></div>
</div>
<div id="msg"></div>
<script>
(() => {
'use strict';
// ======================== CONSTANTS ========================
const TILE = 32;
const MAP_W = 80, MAP_H = 80;
const WORLD_W = MAP_W * TILE, WORLD_H = MAP_H * TILE;
const T_GRASS = 0, T_WATER = 1, T_TREE = 2, T_GOLD = 3;
const FOG_HIDDEN = 0, FOG_EXPLORED = 1, FOG_VISIBLE = 2;
const TEAM_COLOR = '#3b7ddd';
const EDGE_SCROLL = 20, SCROLL_SPEED = 8;
const MINIMAP_SIZE = 180;
const MINIMAP_SCALE = MINIMAP_SIZE / MAP_W;
const BLDG_DEFS = {
townhall: { name:'Town Hall', w:3, h:3, hp:1200, gCost:400, wCost:200, bTime:480, supply:10, sight:8, trains:['worker'] },
barracks: { name:'Barracks', w:3, h:3, hp:800, gCost:300, wCost:150, bTime:360, supply:0, sight:6, trains:['soldier'] },
farm: { name:'Farm', w:2, h:2, hp:400, gCost:100, wCost:50, bTime:200, supply:6, sight:4, trains:[] },
tower: { name:'Tower', w:2, h:2, hp:600, gCost:200, wCost:100, bTime:300, supply:0, sight:9, trains:[] },
};
const UNIT_DEFS = {
worker: { name:'Worker', hp:60, speed:2.2, gCost:75, wCost:0, tTime:150, supply:1, sight:6, gatherAmt:10, gatherTime:80, atk:5, atkRange:1.2 },
soldier: { name:'Soldier', hp:150, speed:1.9, gCost:150, wCost:25, tTime:180, supply:1, sight:6, atk:18, atkRange:1.3 },
};
// ======================== GAME STATE ========================
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const mmCanvas = document.getElementById('minimap');
const mmCtx = mmCanvas.getContext('2d');
let W, H;
const map = [];
const fog = [];
const grassVar = [];
let treeHealth = []; // HP left for each tree tile
const goldMines = [];
const units = [];
const buildings = [];
const camera = { x: 0, y: 0 };
const res = { gold: 500, wood: 500 };
let supplyUsed = 0, supplyMax = 0;
let selectedUnits = [];
let selectedBuilding = null;
let placementType = null;
let isDragging = false;
let dragStartScreen = { x:0, y:0 };
let dragEndScreen = { x:0, y:0 };
let mouseScreen = { x:0, y:0 };
let mouseWorld = { x:0, y:0 };
let mouseTile = { x:0, y:0 };
let nextId = 1;
let frameCount = 0;
let keysDown = {};
let msgTimer = 0;
const moveMarkers = [];
const START_TX = 12, START_TY = 12;
let rightClickHandled = false;
// ======================== UTILITY ========================
function uid() { return nextId++; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function dist(x1,y1,x2,y2) { return Math.sqrt((x2-x1)**2+(y2-y1)**2); }
function tileDist(x1,y1,x2,y2) { return Math.abs(x2-x1)+Math.abs(y2-y1); }
function hash(x, y) { let h = x * 374761393 + y * 668265263; h = (h ^ (h >> 13)) * 1274126177; return (h ^ (h >> 16)) & 0x7fffffff; }
function showMsg(txt) { const el = document.getElementById('msg'); el.textContent = txt; el.classList.add('show'); msgTimer = 120; }
function screenToWorld(sx, sy) { return { x: sx + camera.x, y: sy + camera.y }; }
function worldToScreen(wx, wy) { return { x: wx - camera.x, y: wy - camera.y }; }
function worldToTile(wx, wy) { return { x: Math.floor(wx / TILE), y: Math.floor(wy / TILE) }; }
function tileCenter(tx, ty) { return { x: tx * TILE + TILE/2, y: ty * TILE + TILE/2 }; }
function inBounds(tx, ty) { return tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H; }
function isWalkable(tx, ty) {
if (!inBounds(tx, ty)) return false;
const t = map[ty][tx];
if (t === T_WATER || t === T_TREE || t === T_GOLD) return false;
for (const b of buildings) {
if (tx >= b.tileX && tx < b.tileX + b.w && ty >= b.tileY && ty < b.tileY + b.h) return false;
}
return true;
}
function canAfford(gCost, wCost) { return res.gold >= gCost && res.wood >= wCost; }
function spend(gCost, wCost) { res.gold -= gCost; res.wood -= wCost; }
function findNearestTownHall(wx, wy) {
let best = null, bestD = Infinity;
for (const b of buildings) {
if (b.type === 'townhall' && b.built) {
const cx = (b.tileX + b.w/2) * TILE;
const cy = (b.tileY + b.h/2) * TILE;
const d = dist(wx, wy, cx, cy);
if (d < bestD) { bestD = d; best = b; }
}
}
return best;
}
function findAdjacentWalkable(tileX, tileY, w, h, fromX, fromY) {
const candidates = [];
for (let dy = -1; dy <= h; dy++) {
for (let dx = -1; dx <= w; dx++) {
if (dx >= 0 && dx < w && dy >= 0 && dy < h) continue;
const tx = tileX + dx, ty = tileY + dy;
if (inBounds(tx, ty) && isWalkable(tx, ty)) {
candidates.push({ x: tx, y: ty });
}
}
}
const ftx = Math.floor(fromX / TILE), fty = Math.floor(fromY / TILE);
candidates.sort((a, b) => tileDist(a.x, a.y, ftx, fty) - tileDist(b.x, b.y, ftx, fty));
return candidates[0] || null;
}
function findNearestWalkable(tx, ty) {
for (let r = 1; r < 15; r++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
const nx = tx + dx, ny = ty + dy;
if (inBounds(nx, ny) && isWalkable(nx, ny)) return { x: nx, y: ny };
}
}
}
return null;
}
function getGoldMineAt(tx, ty) {
for (const m of goldMines) {
if (tx >= m.tileX && tx < m.tileX + 2 && ty >= m.tileY && ty < m.tileY + 2 && m.remaining > 0) return m;
}
return null;
}
function getBuildingAt(tx, ty) {
for (const b of buildings) {
if (tx >= b.tileX && tx < b.tileX + b.w && ty >= b.tileY && ty < b.tileY + b.h) return b;
}
return null;
}
function getUnitAt(wx, wy) {
let best = null, bestD = Infinity;
for (const u of units) {
const d = dist(wx, wy, u.x, u.y);
if (d < 14 && d < bestD) { bestD = d; best = u; }
}
return best;
}
// ======================== MAP GENERATION ========================
function generateMap() {
for (let y = 0; y < MAP_H; y++) {
map[y] = []; fog[y] = []; grassVar[y] = []; treeHealth[y] = [];
for (let x = 0; x < MAP_W; x++) {
map[y][x] = T_GRASS;
fog[y][x] = FOG_HIDDEN;
grassVar[y][x] = hash(x, y) % 5;
treeHealth[y][x] = 0;
}
}
// Border trees
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
if (x < 2 || x >= MAP_W-2 || y < 2 || y >= MAP_H-2) { map[y][x] = T_TREE; treeHealth[y][x] = 50; }
}
// Water bodies
for (let i = 0; i < 3; i++) {
const cx = 15 + Math.floor(Math.random() * (MAP_W - 30));
const cy = 15 + Math.floor(Math.random() * (MAP_H - 30));
createLake(cx, cy, 25 + Math.floor(Math.random() * 40));
}
// Tree clusters
for (let i = 0; i < 18; i++) {
const cx = 4 + Math.floor(Math.random() * (MAP_W - 8));
const cy = 4 + Math.floor(Math.random() * (MAP_H - 8));
createForest(cx, cy, 15 + Math.floor(Math.random() * 35));
}
// Gold mines
const mineSpots = [
[START_TX + 8, START_TY - 3],
[START_TX - 5, START_TY + 8],
[5 + Math.floor(Math.random()*10), 40 + Math.floor(Math.random()*10)],
[40 + Math.floor(Math.random()*10), 5 + Math.floor(Math.random()*10)],
[55 + Math.floor(Math.random()*10), 25 + Math.floor(Math.random()*10)],
[25 + Math.floor(Math.random()*10), 55 + Math.floor(Math.random()*10)],
[60 + Math.floor(Math.random()*10), 55 + Math.floor(Math.random()*10)],
[50 + Math.floor(Math.random()*10), 45 + Math.floor(Math.random()*10)],
];
for (const [mx, my] of mineSpots) {
placeGoldMine(clamp(mx, 3, MAP_W-5), clamp(my, 3, MAP_H-5), 2000 + Math.floor(Math.random()*1000));
}
// Clear starting area
clearArea(START_TX + 1, START_TY + 1, 7);
// Ensure trees near start for wood
for (let i = 0; i < 20; i++) {
const tx = START_TX - 4 + Math.floor(Math.random() * 3) - 1;
const ty = START_TY + Math.floor(Math.random() * 6);
if (inBounds(tx, ty) && map[ty][tx] === T_GRASS && dist(tx, ty, START_TX+1, START_TY+1) > 5) {
map[ty][tx] = T_TREE; treeHealth[ty][tx] = 50;
}
}
for (let i = 0; i < 15; i++) {
const tx = START_TX + 5 + Math.floor(Math.random() * 4);
const ty = START_TY + 4 + Math.floor(Math.random() * 4);
if (inBounds(tx, ty) && map[ty][tx] === T_GRASS) {
map[ty][tx] = T_TREE; treeHealth[ty][tx] = 50;
}
}
}
function createLake(cx, cy, size) {
const pts = [{ x: cx, y: cy }];
if (inBounds(cx, cy)) map[cy][cx] = T_WATER;
for (let i = 0; i < size; i++) {
const p = pts[Math.floor(Math.random() * pts.length)];
const nx = p.x + Math.floor(Math.random()*3)-1;
const ny = p.y + Math.floor(Math.random()*3)-1;
if (inBounds(nx, ny) && map[ny][nx] !== T_GOLD) {
map[ny][nx] = T_WATER; pts.push({ x:nx, y:ny });
}
}
}
function createForest(cx, cy, size) {
for (let i = 0; i < size; i++) {
const tx = cx + Math.floor((Math.random()-0.5) * 10);
const ty = cy + Math.floor((Math.random()-0.5) * 10);
if (inBounds(tx, ty) && map[ty][tx] === T_GRASS) {
map[ty][tx] = T_TREE; treeHealth[ty][tx] = 50;
}
}
}
function placeGoldMine(tx, ty, amount) {
// Clear area for mine
for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) {
if (inBounds(tx+dx, ty+dy)) map[ty+dy][tx+dx] = T_GOLD;
}
goldMines.push({ tileX: tx, tileY: ty, remaining: amount, maxAmount: amount });
}
function clearArea(cx, cy, radius) {
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
if (dx*dx + dy*dy <= radius*radius) {
const x = cx+dx, y = cy+dy;
if (inBounds(x, y) && map[y][x] !== T_GOLD) map[y][x] = T_GRASS;
}
}
}
// ======================== CAMERA ========================
function updateCamera() {
if (keysDown['ArrowLeft'] || keysDown['a']) camera.x -= SCROLL_SPEED;
if (keysDown['ArrowRight'] || keysDown['d']) camera.x += SCROLL_SPEED;
if (keysDown['ArrowUp'] || keysDown['w']) camera.y -= SCROLL_SPEED;
if (keysDown['ArrowDown'] || keysDown['s']) camera.y += SCROLL_SPEED;
// Edge scroll - only when mouse is in the game viewport (not over HUD)
const mouseInGame = mouseScreen.y > 36 && mouseScreen.y < H - 150;
if (mouseInGame) {
if (mouseScreen.x < EDGE_SCROLL) camera.x -= SCROLL_SPEED;
if (mouseScreen.x > W - EDGE_SCROLL) camera.x += SCROLL_SPEED;
if (mouseScreen.y < 36 + EDGE_SCROLL) camera.y -= SCROLL_SPEED;
if (mouseScreen.y > H - 150 - EDGE_SCROLL) camera.y += SCROLL_SPEED;
}
camera.x = clamp(camera.x, 0, WORLD_W - W);
camera.y = clamp(camera.y, 0, WORLD_H - H);
const wm = screenToWorld(mouseScreen.x, mouseScreen.y);
mouseWorld.x = wm.x; mouseWorld.y = wm.y;
const tm = worldToTile(wm.x, wm.y);
mouseTile.x = tm.x; mouseTile.y = tm.y;
}
// ======================== FOG OF WAR ========================
function updateFog() {
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
if (fog[y][x] === FOG_VISIBLE) fog[y][x] = FOG_EXPLORED;
}
for (const u of units) {
const t = worldToTile(u.x, u.y);
revealFog(t.x, t.y, UNIT_DEFS[u.type].sight);
}
for (const b of buildings) {
if (b.buildProgress > 0) {
const cx = b.tileX + Math.floor(b.w/2);
const cy = b.tileY + Math.floor(b.h/2);
revealFog(cx, cy, BLDG_DEFS[b.type].sight);
}
}
}
function revealFog(cx, cy, r) {
const r2 = r * r;
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (dx*dx + dy*dy <= r2) {
const tx = cx+dx, ty = cy+dy;
if (inBounds(tx, ty)) fog[ty][tx] = FOG_VISIBLE;
}
}
}
// ======================== PATHFINDING ========================
function findPath(sx, sy, ex, ey) {
let stx = clamp(Math.floor(sx/TILE), 0, MAP_W-1), sty = clamp(Math.floor(sy/TILE), 0, MAP_H-1);
let etx = clamp(Math.floor(ex/TILE), 0, MAP_W-1), ety = clamp(Math.floor(ey/TILE), 0, MAP_H-1);
if (!isWalkable(stx, sty)) { const a = findNearestWalkable(stx, sty); if (!a) return []; stx = a.x; sty = a.y; }
if (!isWalkable(etx, ety)) { const a = findNearestWalkable(etx, ety); if (!a) return []; etx = a.x; ety = a.y; }
if (stx === etx && sty === ety) return [];
const key = (x,y) => y * MAP_W + x;
const openMap = new Map();
const closed = new Set();
const cameFrom = new Map();
const gScore = new Map();
const sk = key(stx, sty);
gScore.set(sk, 0);
openMap.set(sk, { x: stx, y: sty, f: heuristic(stx,sty,etx,ety) });
const dirs = [{dx:-1,dy:0},{dx:1,dy:0},{dx:0,dy:-1},{dx:0,dy:1},{dx:-1,dy:-1},{dx:1,dy:-1},{dx:-1,dy:1},{dx:1,dy:1}];
let iter = 0;
while (openMap.size > 0 && iter < 4000) {
iter++;
let bestK = null, bestF = Infinity;
for (const [k, v] of openMap) { if (v.f < bestF) { bestF = v.f; bestK = k; } }
const cur = openMap.get(bestK);
openMap.delete(bestK);
closed.add(bestK);
if (cur.x === etx && cur.y === ety) {
const path = [];
let k = bestK;
while (cameFrom.has(k)) {
const tx = k % MAP_W, ty = Math.floor(k / MAP_W);
path.unshift({ x: tx*TILE+TILE/2, y: ty*TILE+TILE/2 });
k = cameFrom.get(k);
}
return path;
}
for (const d of dirs) {
const nx = cur.x+d.dx, ny = cur.y+d.dy;
if (!inBounds(nx,ny) || !isWalkable(nx,ny)) continue;
if (d.dx !== 0 && d.dy !== 0) {
if (!isWalkable(cur.x+d.dx, cur.y) || !isWalkable(cur.x, cur.y+d.dy)) continue;
}
const nk = key(nx, ny);
if (closed.has(nk)) continue;
const tg = gScore.get(bestK) + (d.dx !== 0 && d.dy !== 0 ? 1.414 : 1);
if (!gScore.has(nk) || tg < gScore.get(nk)) {
cameFrom.set(nk, bestK);
gScore.set(nk, tg);
const f = tg + heuristic(nx, ny, etx, ety);
openMap.set(nk, { x: nx, y: ny, f });
}
}
}
return [];
}
function heuristic(x1,y1,x2,y2) {
const dx = Math.abs(x1-x2), dy = Math.abs(y1-y2);
return Math.max(dx,dy) + 0.414 * Math.min(dx,dy);
}
// ======================== ENTITIES ========================
function createUnit(type, wx, wy) {
const def = UNIT_DEFS[type];
const u = {
id: uid(), type, x: wx, y: wy,
hp: def.hp, maxHp: def.hp,
state: 'idle', path: [],
gatherTarget: null, // { type:'gold'|'wood', mine?, tileX?, tileY? }
gatherTimer: 0,
carrying: null, // { type, amount }
buildTarget: null,
selected: false,
};
units.push(u);
supplyUsed += def.supply;
return u;
}
function createBuilding(type, tx, ty, prebuilt) {
const def = BLDG_DEFS[type];
const b = {
id: uid(), type, tileX: tx, tileY: ty, w: def.w, h: def.h,
hp: prebuilt ? def.hp : 1, maxHp: def.hp,
built: !!prebuilt, buildProgress: prebuilt ? def.bTime : 0, buildTime: def.bTime,
trainQueue: [], trainProgress: 0,
rallyX: (tx + def.w/2) * TILE, rallyY: (ty + def.h + 1) * TILE,
selected: false,
};
buildings.push(b);
if (prebuilt) supplyMax += def.supply;
return b;
}
function removeUnit(u) {
const idx = units.indexOf(u);
if (idx >= 0) units.splice(idx, 1);
supplyUsed -= UNIT_DEFS[u.type].supply;
selectedUnits = selectedUnits.filter(s => s !== u);
}
function removeBuilding(b) {
const idx = buildings.indexOf(b);
if (idx >= 0) buildings.splice(idx, 1);
if (b.built) supplyMax -= BLDG_DEFS[b.type].supply;
if (selectedBuilding === b) selectedBuilding = null;
}
// ======================== COMMANDS ========================
function commandMove(unitList, wx, wy) {
const count = unitList.length;
unitList.forEach((u, i) => {
u.state = 'moving'; u.gatherTarget = null; u.buildTarget = null; u.carrying = null;
const offset = count > 1 ? spreadOffset(i, count) : {x:0,y:0};
u.path = findPath(u.x, u.y, wx + offset.x, wy + offset.y);
if (u.path.length === 0) u.state = 'idle';
});
moveMarkers.push({ x: wx, y: wy, t: 30 });
}
function spreadOffset(i, count) {
const cols = Math.ceil(Math.sqrt(count));
const row = Math.floor(i / cols), col = i % cols;
return { x: (col - cols/2) * TILE * 0.8, y: (row - cols/2) * TILE * 0.8 };
}
function commandGatherGold(unitList, mine) {
for (const u of unitList) {
if (u.type !== 'worker') continue;
u.gatherTarget = { type: 'gold', mine };
u.carrying = null; u.buildTarget = null;
const adj = findAdjacentWalkable(mine.tileX, mine.tileY, 2, 2, u.x, u.y);
if (adj) {
u.path = findPath(u.x, u.y, adj.x * TILE + TILE/2, adj.y * TILE + TILE/2);
u.state = u.path.length > 0 ? 'moving' : 'idle';
if (u.path.length === 0 && tileDist(Math.floor(u.x/TILE), Math.floor(u.y/TILE), adj.x, adj.y) <= 1) {
u.state = 'gathering'; u.gatherTimer = 0;
}
}
}
}
function commandGatherWood(unitList, tx, ty) {
for (const u of unitList) {
if (u.type !== 'worker') continue;
u.gatherTarget = { type: 'wood', tileX: tx, tileY: ty };
u.carrying = null; u.buildTarget = null;
const adj = findAdjacentWalkable(tx, ty, 1, 1, u.x, u.y);
if (adj) {
u.path = findPath(u.x, u.y, adj.x * TILE + TILE/2, adj.y * TILE + TILE/2);
u.state = u.path.length > 0 ? 'moving' : 'idle';
if (u.path.length === 0 && tileDist(Math.floor(u.x/TILE), Math.floor(u.y/TILE), adj.x, adj.y) <= 1) {
u.state = 'gathering'; u.gatherTimer = 0;
}
}
}
}
function commandBuild(worker, bType, tx, ty) {
const def = BLDG_DEFS[bType];
if (!canAfford(def.gCost, def.wCost)) { showMsg('Not enough resources'); return false; }
// Validate placement
for (let dy = 0; dy < def.h; dy++) for (let dx = 0; dx < def.w; dx++) {
if (!isWalkable(tx+dx, ty+dy)) { showMsg('Cannot build here'); return false; }
}
spend(def.gCost, def.wCost);
const b = createBuilding(bType, tx, ty, false);
worker.buildTarget = b;
worker.gatherTarget = null; worker.carrying = null;
const adj = findAdjacentWalkable(tx, ty, def.w, def.h, worker.x, worker.y);
if (adj) {
worker.path = findPath(worker.x, worker.y, adj.x * TILE + TILE/2, adj.y * TILE + TILE/2);
worker.state = worker.path.length > 0 ? 'moving' : 'building';
} else {
worker.state = 'building';
}
return true;
}
function commandTrain(building, uType) {
const def = UNIT_DEFS[uType];
if (!canAfford(def.gCost, def.wCost)) { showMsg('Not enough resources'); return; }
if (supplyUsed + def.supply > supplyMax) { showMsg('Need more farms'); return; }
// Pre-reserve supply so we don't over-queue
if (building.trainQueue.length > 0) {
let queued = 0;
for (const qt of building.trainQueue) queued += UNIT_DEFS[qt].supply;
if (supplyUsed + queued + def.supply > supplyMax) { showMsg('Need more farms'); return; }
}
spend(def.gCost, def.wCost);
building.trainQueue.push(uType);
}
// ======================== UPDATE LOGIC ========================
function update() {
frameCount++;
updateCamera();
updateFog();
updateUnits();
updateBuildings();
updateMarkers();
if (msgTimer > 0) { msgTimer--; if (msgTimer <= 0) document.getElementById('msg').classList.remove('show'); }
}
function updateUnits() {
for (const u of units) {
switch (u.state) {
case 'idle': break;
case 'moving': updateMoving(u); break;
case 'gathering': updateGathering(u); break;
case 'returning': updateReturning(u); break;
case 'building': updateBuilding(u); break;
}
// Separation
for (const o of units) {
if (o === u) continue;
const d = dist(u.x, u.y, o.x, o.y);
if (d < 16 && d > 0.1) {
u.x += ((u.x - o.x) / d) * 0.4;
u.y += ((u.y - o.y) / d) * 0.4;
}
}
// Keep in bounds
u.x = clamp(u.x, TILE, WORLD_W - TILE);
u.y = clamp(u.y, TILE, WORLD_H - TILE);
}
}
function updateMoving(u) {
if (u.path.length === 0) {
// Arrived
if (u.gatherTarget) {
if (u.carrying) {
// At town hall, deposit
u.state = 'returning';
} else {
u.state = 'gathering';
u.gatherTimer = 0;
}
} else if (u.buildTarget) {
u.state = 'building';
} else {
u.state = 'idle';
}
return;
}
const target = u.path[0];
const dx = target.x - u.x, dy = target.y - u.y;
const d = Math.sqrt(dx*dx + dy*dy);
const spd = UNIT_DEFS[u.type].speed;
if (d < spd + 1) {
u.x = target.x; u.y = target.y;
u.path.shift();
} else {
u.x += (dx/d) * spd;
u.y += (dy/d) * spd;
}
}
function updateGathering(u) {
if (!u.gatherTarget) { u.state = 'idle'; return; }
const def = UNIT_DEFS[u.type];
u.gatherTimer++;
if (u.gatherTimer >= def.gatherTime) {
if (u.gatherTarget.type === 'gold') {
const mine = u.gatherTarget.mine;
if (mine.remaining <= 0) { u.gatherTarget = null; u.state = 'idle'; return; }
const amt = Math.min(def.gatherAmt, mine.remaining);
mine.remaining -= amt;
u.carrying = { type: 'gold', amount: amt };
if (mine.remaining <= 0) {
for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) {
if (inBounds(mine.tileX+dx, mine.tileY+dy)) map[mine.tileY+dy][mine.tileX+dx] = T_GRASS;
}
}
} else {
const tx = u.gatherTarget.tileX, ty = u.gatherTarget.tileY;
if (!inBounds(tx,ty) || map[ty][tx] !== T_TREE) { u.gatherTarget = null; u.state = 'idle'; return; }
treeHealth[ty][tx] -= def.gatherAmt;
if (treeHealth[ty][tx] <= 0) {
map[ty][tx] = T_GRASS;
u.carrying = { type: 'wood', amount: def.gatherAmt };
} else {
u.carrying = { type: 'wood', amount: def.gatherAmt };
}
}
// Return to town hall
u.state = 'returning';
u.gatherTimer = 0;
const th = findNearestTownHall(u.x, u.y);
if (!th) { u.state = 'idle'; return; }
const adj = findAdjacentWalkable(th.tileX, th.tileY, th.w, th.h, u.x, u.y);
if (adj) {
u.path = findPath(u.x, u.y, adj.x*TILE+TILE/2, adj.y*TILE+TILE/2);
}
}
}
function updateReturning(u) {
if (u.path.length > 0) {
updateMoving(u);
if (u.state === 'returning' && u.path.length > 0) return;
}
// Deposit
if (u.carrying) {
if (u.carrying.type === 'gold') res.gold += u.carrying.amount;
else res.wood += u.carrying.amount;
u.carrying = null;
}
// Go back to gather
if (u.gatherTarget) {
if (u.gatherTarget.type === 'gold') {
if (u.gatherTarget.mine.remaining > 0) {
commandGatherGold([u], u.gatherTarget.mine);
} else {
u.gatherTarget = null; u.state = 'idle';
}
} else {
let tx = u.gatherTarget.tileX, ty = u.gatherTarget.tileY;
if (!inBounds(tx,ty) || map[ty][tx] !== T_TREE) {
// Find nearest tree
const nt = findNearestTree(tx, ty);
if (nt) {
commandGatherWood([u], nt.x, nt.y);
} else {
u.gatherTarget = null; u.state = 'idle';
}
} else {
commandGatherWood([u], tx, ty);
}
}
} else {
u.state = 'idle';
}
}
function findNearestTree(fromTX, fromTY) {
let best = null, bestD = Infinity;
for (let r = 0; r < 20; r++) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
const tx = fromTX+dx, ty = fromTY+dy;
if (inBounds(tx,ty) && map[ty][tx] === T_TREE) {
const d = Math.abs(dx)+Math.abs(dy);
if (d < bestD) { bestD = d; best = {x:tx, y:ty}; }
}
}
if (best) return best;
}
return null;
}
function updateBuilding(u) {
if (!u.buildTarget || buildings.indexOf(u.buildTarget) < 0) {
u.buildTarget = null; u.state = 'idle'; return;
}
const b = u.buildTarget;
if (b.built) { u.buildTarget = null; u.state = 'idle'; return; }
// Check if adjacent
const utx = Math.floor(u.x / TILE), uty = Math.floor(u.y / TILE);
let adjacent = false;
for (let dy = -1; dy <= b.h; dy++) for (let dx = -1; dx <= b.w; dx++) {
if (utx === b.tileX + dx && uty === b.tileY + dy) adjacent = true;
}
if (!adjacent) {
// Move closer
const adj = findAdjacentWalkable(b.tileX, b.tileY, b.w, b.h, u.x, u.y);
if (adj) {
u.path = findPath(u.x, u.y, adj.x*TILE+TILE/2, adj.y*TILE+TILE/2);
if (u.path.length > 0) { u.state = 'moving'; return; }
}
}
b.buildProgress++;
b.hp = Math.floor((b.buildProgress / b.buildTime) * b.maxHp);
if (b.buildProgress >= b.buildTime) {
b.built = true; b.hp = b.maxHp;
supplyMax += BLDG_DEFS[b.type].supply;
u.buildTarget = null; u.state = 'idle';
}
}
function updateBuildings() {
for (const b of buildings) {
if (!b.built || b.trainQueue.length === 0) continue;
const uType = b.trainQueue[0];
const def = UNIT_DEFS[uType];
b.trainProgress++;
if (b.trainProgress >= def.tTime) {
b.trainProgress = 0;
b.trainQueue.shift();
// Spawn unit
const sx = (b.tileX + b.w/2) * TILE;
const sy = (b.tileY + b.h) * TILE + TILE;
const u = createUnit(uType, sx, sy);
// Rally
if (b.rallyX && b.rallyY) {
u.path = findPath(u.x, u.y, b.rallyX, b.rallyY);
if (u.path.length > 0) u.state = 'moving';
}
}
}
}
function updateMarkers() {
for (let i = moveMarkers.length - 1; i >= 0; i--) {
moveMarkers[i].t--;
if (moveMarkers[i].t <= 0) moveMarkers.splice(i, 1);
}
}
// ======================== INPUT ========================
canvas.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('contextmenu', e => { if (e.target === canvas) e.preventDefault(); });
document.addEventListener('keydown', e => { keysDown[e.key] = true; if (e.key === 'Escape') cancelAction(); });
document.addEventListener('keyup', e => { keysDown[e.key] = false; });
mmCanvas.addEventListener('mousedown', onMinimapClick);
mmCanvas.addEventListener('mousemove', e => { if (e.buttons & 1) onMinimapClick(e); });
function onMinimapClick(e) {
const rect = mmCanvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
camera.x = (mx / MINIMAP_SCALE) * TILE - W/2;
camera.y = (my / MINIMAP_SCALE) * TILE - H/2;
camera.x = clamp(camera.x, 0, WORLD_W - W);
camera.y = clamp(camera.y, 0, WORLD_H - H);
}
function onMouseDown(e) {
mouseScreen.x = e.clientX; mouseScreen.y = e.clientY;
if (e.button === 0) {
if (placementType) {
handlePlacement();
} else {
isDragging = true;
dragStartScreen.x = e.clientX; dragStartScreen.y = e.clientY;
dragEndScreen.x = e.clientX; dragEndScreen.y = e.clientY;
}
} else if (e.button === 2) {
handleRightClick();
}
}
function onMouseMove(e) {
mouseScreen.x = e.clientX; mouseScreen.y = e.clientY;
if (isDragging) {
dragEndScreen.x = e.clientX; dragEndScreen.y = e.clientY;
}
}
function onMouseUp(e) {
if (e.button === 0 && isDragging) {
isDragging = false;
const dx = Math.abs(dragEndScreen.x - dragStartScreen.x);
const dy = Math.abs(dragEndScreen.y - dragStartScreen.y);
if (dx > 5 || dy > 5) {
boxSelect();
} else {
clickSelect(e.clientX, e.clientY);
}
}
}
function clickSelect(sx, sy) {
const w = screenToWorld(sx, sy);
// Try to select a unit
const u = getUnitAt(w.x, w.y);
if (u) {
deselectAll();
u.selected = true;
selectedUnits = [u];
updateHUD();
return;
}
// Try to select a building
const t = worldToTile(w.x, w.y);
const b = getBuildingAt(t.x, t.y);
if (b) {
deselectAll();
b.selected = true;
selectedBuilding = b;
updateHUD();
return;
}
// Deselect
deselectAll();
updateHUD();
}
function boxSelect() {
const w1 = screenToWorld(Math.min(dragStartScreen.x, dragEndScreen.x), Math.min(dragStartScreen.y, dragEndScreen.y));
const w2 = screenToWorld(Math.max(dragStartScreen.x, dragEndScreen.x), Math.max(dragStartScreen.y, dragEndScreen.y));
deselectAll();
for (const u of units) {
if (u.x >= w1.x && u.x <= w2.x && u.y >= w1.y && u.y <= w2.y) {
u.selected = true;
selectedUnits.push(u);
}
}
updateHUD();
}
function deselectAll() {
for (const u of units) u.selected = false;
for (const b of buildings) b.selected = false;
selectedUnits = [];
selectedBuilding = null;
}
function handleRightClick() {
const w = screenToWorld(mouseScreen.x, mouseScreen.y);
const t = worldToTile(w.x, w.y);
// If a building is selected, set rally point
if (selectedBuilding && selectedBuilding.built) {
selectedBuilding.rallyX = w.x;
selectedBuilding.rallyY = w.y;
moveMarkers.push({ x: w.x, y: w.y, t: 30 });
updateHUD();
return;
}
if (selectedUnits.length === 0) return;
const workers = selectedUnits.filter(u => u.type === 'worker');
// Check gold mine
const mine = getGoldMineAt(t.x, t.y);
if (mine && workers.length > 0) {
commandGatherGold(workers, mine);
// Non-workers just move
const nonWorkers = selectedUnits.filter(u => u.type !== 'worker');
if (nonWorkers.length > 0) commandMove(nonWorkers, w.x, w.y);
return;
}
// Check tree
if (inBounds(t.x, t.y) && map[t.y][t.x] === T_TREE && workers.length > 0) {
commandGatherWood(workers, t.x, t.y);
const nonWorkers = selectedUnits.filter(u => u.type !== 'worker');
if (nonWorkers.length > 0) commandMove(nonWorkers, w.x, w.y);
return;
}
// Move command
commandMove(selectedUnits, w.x, w.y);
}
function handlePlacement() {
if (!placementType) return;
const def = BLDG_DEFS[placementType];
const tx = mouseTile.x - Math.floor(def.w/2);
const ty = mouseTile.y - Math.floor(def.h/2);
// Find a selected worker
const worker = selectedUnits.find(u => u.type === 'worker');
if (!worker) { showMsg('Select a worker first'); return; }
if (commandBuild(worker, placementType, tx, ty)) {
placementType = null;
canvas.style.cursor = 'default';
}
}
function cancelAction() {
if (placementType) { placementType = null; canvas.style.cursor = 'default'; return; }
deselectAll();
updateHUD();
}
// ======================== RENDERING ========================
const GRASS_COLORS = ['#3d8b37','#3f8f3a','#3a8533','#41923c','#378230'];
const WATER_BASE = [40, 95, 165];
function render() {
ctx.clearRect(0, 0, W, H);
const stx = Math.floor(camera.x / TILE), sty = Math.floor(camera.y / TILE);
const etx = Math.ceil((camera.x + W) / TILE), ety = Math.ceil((camera.y + H) / TILE);
// Draw terrain
for (let ty = sty; ty <= ety; ty++) for (let tx = stx; tx <= etx; tx++) {
if (!inBounds(tx, ty)) continue;
const sx = tx * TILE - camera.x, sy = ty * TILE - camera.y;
if (fog[ty][tx] === FOG_HIDDEN) { ctx.fillStyle = '#0a0a12'; ctx.fillRect(sx, sy, TILE, TILE); continue; }
const tile = map[ty][tx];
if (tile === T_GRASS) {
ctx.fillStyle = GRASS_COLORS[grassVar[ty][tx]];
ctx.fillRect(sx, sy, TILE, TILE);
} else if (tile === T_WATER) {
const wave = Math.sin(tx * 0.7 + frameCount * 0.04) * 8;
const r = WATER_BASE[0], g = WATER_BASE[1] + wave, b = WATER_BASE[2] + wave * 0.5;
ctx.fillStyle = `rgb(${r},${Math.floor(g)},${Math.floor(b)})`;
ctx.fillRect(sx, sy, TILE, TILE);
// Water highlights
const hx = sx + 8 + Math.sin(ty * 1.3 + frameCount * 0.06) * 6;
const hy = sy + 8 + Math.cos(tx * 0.9 + frameCount * 0.05) * 6;
ctx.fillStyle = 'rgba(120,180,230,0.3)';
ctx.fillRect(hx, hy, 6, 2);
} else if (tile === T_TREE) {
ctx.fillStyle = GRASS_COLORS[grassVar[ty][tx]];
ctx.fillRect(sx, sy, TILE, TILE);
drawTree(sx, sy, tx, ty);
} else if (tile === T_GOLD) {
ctx.fillStyle = '#556';
ctx.fillRect(sx, sy, TILE, TILE);
}
}
// Draw gold mines
for (const m of goldMines) {
if (m.remaining <= 0) continue;
const sx = m.tileX * TILE - camera.x, sy = m.tileY * TILE - camera.y;
if (sx > W + 64 || sy > H + 64 || sx < -64 || sy < -64) continue;
if (!inBounds(m.tileX, m.tileY) || fog[m.tileY][m.tileX] === FOG_HIDDEN) continue;
drawGoldMine(sx, sy, m);
}
// Draw buildings
const sortedBuildings = [...buildings].sort((a,b) => (a.tileY+a.h) - (b.tileY+b.h));
for (const b of sortedBuildings) {
const sx = b.tileX * TILE - camera.x, sy = b.tileY * TILE - camera.y;
if (sx > W + 128 || sy > H + 128 || sx < -128 || sy < -128) continue;
drawBuilding(b, sx, sy);
}
// Draw units sorted by Y
const sortedUnits = [...units].sort((a,b) => a.y - b.y);
for (const u of sortedUnits) {
const s = worldToScreen(u.x, u.y);
if (s.x > W + 32 || s.y > H + 32 || s.x < -32 || s.y < -32) continue;
drawUnit(u, s.x, s.y);
}
// Draw fog overlay
for (let ty = sty; ty <= ety; ty++) for (let tx = stx; tx <= etx; tx++) {
if (!inBounds(tx, ty)) continue;
if (fog[ty][tx] === FOG_EXPLORED) {
const sx = tx * TILE - camera.x, sy = ty * TILE - camera.y;
ctx.fillStyle = 'rgba(8,8,16,0.45)';
ctx.fillRect(sx, sy, TILE, TILE);
}
}
// Move markers
for (const m of moveMarkers) {
const s = worldToScreen(m.x, m.y);
ctx.strokeStyle = `rgba(0,255,100,${m.t / 30})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(s.x, s.y, 12 - (m.t/30)*4, 0, Math.PI*2);
ctx.stroke();
}
// Selection box
if (isDragging) {
const dx = Math.abs(dragEndScreen.x - dragStartScreen.x);
const dy = Math.abs(dragEndScreen.y - dragStartScreen.y);
if (dx > 5 || dy > 5) {
ctx.strokeStyle = '#0f0';
ctx.lineWidth = 1;
ctx.setLineDash([4,4]);
ctx.strokeRect(
Math.min(dragStartScreen.x, dragEndScreen.x),
Math.min(dragStartScreen.y, dragEndScreen.y),
Math.abs(dragEndScreen.x - dragStartScreen.x),
Math.abs(dragEndScreen.y - dragStartScreen.y)
);
ctx.setLineDash([]);
}
}
// Placement ghost
if (placementType) {
const def = BLDG_DEFS[placementType];
const tx = mouseTile.x - Math.floor(def.w/2);
const ty = mouseTile.y - Math.floor(def.h/2);
let valid = canAfford(def.gCost, def.wCost);
for (let dy = 0; dy < def.h && valid; dy++) for (let dx = 0; dx < def.w && valid; dx++) {
if (!isWalkable(tx+dx, ty+dy)) valid = false;
}
ctx.globalAlpha = 0.5;
ctx.fillStyle = valid ? 'rgba(0,200,0,0.4)' : 'rgba(200,0,0,0.4)';
const sx = tx * TILE - camera.x, sy = ty * TILE - camera.y;
ctx.fillRect(sx, sy, def.w * TILE, def.h * TILE);
ctx.strokeStyle = valid ? '#0f0' : '#f00';
ctx.lineWidth = 2;
ctx.strokeRect(sx, sy, def.w * TILE, def.h * TILE);
ctx.globalAlpha = 1.0;
// Draw building name
ctx.fillStyle = '#fff';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(def.name, sx + def.w * TILE / 2, sy - 6);
}
// Minimap
renderMinimap();
}
function drawTree(sx, sy, tx, ty) {
const v = hash(tx, ty);
const sz = 10 + (v % 4);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.ellipse(sx + TILE/2 + 2, sy + TILE - 4, sz - 2, 4, 0, 0, Math.PI*2);
ctx.fill();
// Trunk
ctx.fillStyle = '#5a3820';
ctx.fillRect(sx + TILE/2 - 2, sy + TILE - 12, 5, 12);
// Canopy
const g1 = 30 + (v % 20), g2 = 80 + (v % 40);
ctx.fillStyle = `rgb(${20+v%15},${g2},${g1})`;
ctx.beginPath();
ctx.arc(sx + TILE/2, sy + TILE/2 - 2, sz, 0, Math.PI*2);
ctx.fill();
// Highlight
ctx.fillStyle = `rgba(100,200,80,0.3)`;
ctx.beginPath();
ctx.arc(sx + TILE/2 - 3, sy + TILE/2 - 5, sz * 0.5, 0, Math.PI*2);
ctx.fill();
}
function drawGoldMine(sx, sy, mine) {
const mw = 2 * TILE, mh = 2 * TILE;
// Rock shadow
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath();
ctx.ellipse(sx + mw/2 + 3, sy + mh - 4, mw/2 - 4, 8, 0, 0, Math.PI*2);
ctx.fill();
// Rock base
ctx.fillStyle = '#6a6a72';
ctx.beginPath();
ctx.moveTo(sx + 6, sy + mh - 2);
ctx.lineTo(sx + mw - 6, sy + mh - 2);
ctx.lineTo(sx + mw - 2, sy + mh * 0.35);
ctx.lineTo(sx + mw * 0.65, sy + 4);
ctx.lineTo(sx + mw * 0.35, sy + 4);
ctx.lineTo(sx + 2, sy + mh * 0.35);
ctx.closePath();
ctx.fill();
// Rock dark edge
ctx.fillStyle = '#55555e';
ctx.beginPath();
ctx.moveTo(sx + 6, sy + mh - 2);
ctx.lineTo(sx + mw - 6, sy + mh - 2);
ctx.lineTo(sx + mw - 2, sy + mh * 0.35);
ctx.lineTo(sx + mw * 0.5, sy + mh * 0.5);
ctx.lineTo(sx + 2, sy + mh * 0.35);
ctx.closePath();
ctx.fill();
// Gold veins
ctx.fillStyle = '#daa520';
ctx.beginPath(); ctx.arc(sx + 18, sy + 24, 7, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + 44, sy + 18, 5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + 32, sy + 40, 6, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#ffd700';
ctx.beginPath(); ctx.arc(sx + 20, sy + 22, 3, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + 43, sy + 16, 2.5, 0, Math.PI*2); ctx.fill();
// Sparkle
if (frameCount % 30 < 10) {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(sx + 22 + (frameCount%3)*4, sy + 20, 1.5, 0, Math.PI*2); ctx.fill();
}
// Remaining
const pct = mine.remaining / mine.maxAmount;
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(mine.remaining, sx + mw/2, sy + mh - 6);
}
function drawBuilding(b, sx, sy) {
const bw = b.w * TILE, bh = b.h * TILE;
const alpha = b.built ? 1.0 : 0.5 + (b.buildProgress / b.buildTime) * 0.5;
ctx.globalAlpha = alpha;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(sx + 4, sy + 4, bw, bh);
if (b.type === 'townhall') {
// Walls
ctx.fillStyle = '#8b7b65';
ctx.fillRect(sx, sy + bh * 0.3, bw, bh * 0.7);
// Roof
ctx.fillStyle = '#5a3a2a';
ctx.beginPath();
ctx.moveTo(sx - 4, sy + bh * 0.35);
ctx.lineTo(sx + bw/2, sy + 2);
ctx.lineTo(sx + bw + 4, sy + bh * 0.35);
ctx.closePath();
ctx.fill();
// Door
ctx.fillStyle = '#3a2a1a';
ctx.fillRect(sx + bw/2 - 8, sy + bh - 22, 16, 22);
// Windows
ctx.fillStyle = '#ddc87a';
ctx.fillRect(sx + 12, sy + bh * 0.45, 10, 10);
ctx.fillRect(sx + bw - 22, sy + bh * 0.45, 10, 10);
// Banner
ctx.fillStyle = TEAM_COLOR;
ctx.fillRect(sx + bw/2 - 3, sy + 2, 6, 18);
ctx.fillRect(sx + bw/2 - 10, sy + 2, 20, 8);
} else if (b.type === 'barracks') {
ctx.fillStyle = '#7a5040';
ctx.fillRect(sx, sy + bh * 0.2, bw, bh * 0.8);
// Flat roof with crenellations
ctx.fillStyle = '#5a3025';
ctx.fillRect(sx - 2, sy + bh * 0.15, bw + 4, bh * 0.15);
for (let i = 0; i < 6; i++) {
ctx.fillRect(sx - 2 + i * (bw+4)/6, sy + bh * 0.08, (bw+4)/12, bh * 0.1);
}
// Door
ctx.fillStyle = '#3a1a10';
ctx.fillRect(sx + bw/2 - 10, sy + bh - 24, 20, 24);
// Crossed swords icon
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx + bw/2 - 12, sy + bh * 0.35); ctx.lineTo(sx + bw/2 + 12, sy + bh * 0.6); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + bw/2 + 12, sy + bh * 0.35); ctx.lineTo(sx + bw/2 - 12, sy + bh * 0.6); ctx.stroke();
// Team color trim
ctx.fillStyle = TEAM_COLOR;
ctx.fillRect(sx, sy + bh * 0.2, bw, 3);
} else if (b.type === 'farm') {
// Crop rows
ctx.fillStyle = '#7a9a44';
ctx.fillRect(sx, sy, bw, bh);
for (let i = 0; i < 4; i++) {
ctx.fillStyle = i % 2 === 0 ? '#6a8a34' : '#8aaa54';
ctx.fillRect(sx + 4, sy + 6 + i * 14, bw - 8, 10);
}
// Small building
ctx.fillStyle = '#9a8060';
ctx.fillRect(sx + bw/2 - 12, sy + bh/2 - 10, 24, 20);
ctx.fillStyle = '#6a4a30';
ctx.beginPath();
ctx.moveTo(sx + bw/2 - 14, sy + bh/2 - 8);
ctx.lineTo(sx + bw/2, sy + bh/2 - 20);
ctx.lineTo(sx + bw/2 + 14, sy + bh/2 - 8);
ctx.closePath();
ctx.fill();
// Fence
ctx.strokeStyle = '#8a7050';
ctx.lineWidth = 1;
ctx.strokeRect(sx + 2, sy + 2, bw - 4, bh - 4);
} else if (b.type === 'tower') {
// Tower base
ctx.fillStyle = '#7a7a88';
ctx.fillRect(sx + 8, sy + bh * 0.3, bw - 16, bh * 0.7);
// Tower top wider
ctx.fillStyle = '#6a6a78';
ctx.fillRect(sx + 4, sy + bh * 0.15, bw - 8, bh * 0.2);
// Crenellations
ctx.fillStyle = '#8a8a98';
for (let i = 0; i < 4; i++) {
ctx.fillRect(sx + 4 + i * (bw-8)/4, sy + bh * 0.08, (bw-8)/8, bh * 0.1);
}
// Arrow slit
ctx.fillStyle = '#2a2a32';
ctx.fillRect(sx + bw/2 - 2, sy + bh * 0.4, 4, 14);
ctx.fillRect(sx + bw/2 - 6, sy + bh * 0.45, 12, 4);
// Team flag
ctx.fillStyle = TEAM_COLOR;
ctx.fillRect(sx + bw/2 - 1, sy, 2, bh * 0.15);
ctx.fillRect(sx + bw/2 + 1, sy, 10, 6);
}
// Selection indicator
if (b.selected) {
ctx.strokeStyle = '#0f0';
ctx.lineWidth = 2;
ctx.strokeRect(sx - 2, sy - 2, bw + 4, bh + 4);
}
// Health bar (if damaged)
if (b.hp < b.maxHp) {
const hbw = bw - 8, hbh = 4;
const hbx = sx + 4, hby = sy - 8;
ctx.fillStyle = '#300';
ctx.fillRect(hbx, hby, hbw, hbh);
const pct = b.hp / b.maxHp;
ctx.fillStyle = pct > 0.5 ? '#4caf50' : pct > 0.25 ? '#ff9800' : '#f44';
ctx.fillRect(hbx, hby, hbw * pct, hbh);
}
// Construction progress bar
if (!b.built) {
const pbw = bw - 8, pbh = 5;
const pbx = sx + 4, pby = sy - 14;
ctx.fillStyle = '#222';
ctx.fillRect(pbx, pby, pbw, pbh);
ctx.fillStyle = '#42a5f5';
ctx.fillRect(pbx, pby, pbw * (b.buildProgress / b.buildTime), pbh);
}
// Rally point
if (b.selected && b.built && b.rallyX) {
const rs = worldToScreen(b.rallyX, b.rallyY);
ctx.strokeStyle = TEAM_COLOR;
ctx.lineWidth = 1;
ctx.setLineDash([3,3]);
ctx.beginPath();
ctx.moveTo(sx + bw/2, sy + bh/2);
ctx.lineTo(rs.x, rs.y);
ctx.stroke();
ctx.setLineDash([]);
// Flag
ctx.fillStyle = TEAM_COLOR;
ctx.fillRect(rs.x, rs.y - 12, 2, 14);
ctx.fillRect(rs.x + 2, rs.y - 12, 8, 6);
}
ctx.globalAlpha = 1.0;
}
function drawUnit(u, sx, sy) {
const def = UNIT_DEFS[u.type];
// Selection circle
if (u.selected) {
ctx.strokeStyle = '#0f0';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(sx, sy + 6, 13, 6, 0, 0, Math.PI*2);
ctx.stroke();
}
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath();
ctx.ellipse(sx, sy + 6, 9, 4, 0, 0, Math.PI*2);
ctx.fill();
if (u.type === 'worker') {
// Body
ctx.fillStyle = TEAM_COLOR;
ctx.beginPath();
ctx.arc(sx, sy - 3, 7, 0, Math.PI*2);
ctx.fill();
// Lighter front
ctx.fillStyle = '#5a9ae8';
ctx.beginPath();
ctx.arc(sx - 1, sy - 5, 3.5, 0, Math.PI*2);
ctx.fill();
// Head
ctx.fillStyle = '#deb887';
ctx.beginPath();
ctx.arc(sx, sy - 12, 5, 0, Math.PI*2);
ctx.fill();
// Hair
ctx.fillStyle = '#6a4a2a';
ctx.beginPath();
ctx.arc(sx, sy - 14, 4, Math.PI, Math.PI*2);
ctx.fill();
// Tool
if (u.state === 'gathering' || (u.gatherTarget && !u.carrying)) {
// Pickaxe/axe animation
const angle = Math.sin(frameCount * 0.15) * 0.5;
ctx.save();
ctx.translate(sx + 8, sy - 8);
ctx.rotate(angle - 0.3);
ctx.strokeStyle = '#8b6914';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(10, -10); ctx.stroke();
ctx.fillStyle = '#888';
ctx.fillRect(8, -14, 6, 4);
ctx.restore();
} else {
ctx.strokeStyle = '#8b6914';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx + 7, sy - 6); ctx.lineTo(sx + 13, sy - 14); ctx.stroke();
}
} else {
// Soldier body (bigger, armored)
ctx.fillStyle = TEAM_COLOR;
ctx.beginPath();
ctx.arc(sx, sy - 4, 8, 0, Math.PI*2);
ctx.fill();
// Armor plate
ctx.fillStyle = '#7a8aaa';
ctx.beginPath();
ctx.arc(sx, sy - 5, 5, 0, Math.PI*2);
ctx.fill();
// Head
ctx.fillStyle = '#deb887';
ctx.beginPath();
ctx.arc(sx, sy - 14, 5, 0, Math.PI*2);
ctx.fill();
// Helmet
ctx.fillStyle = '#888';
ctx.beginPath();
ctx.arc(sx, sy - 16, 4.5, Math.PI, Math.PI*2);
ctx.fill();
ctx.fillRect(sx - 5, sy - 15, 10, 2);
// Sword
ctx.strokeStyle = '#bbb';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx + 9, sy - 2);
ctx.lineTo(sx + 9, sy - 18);
ctx.stroke();
// Hilt
ctx.strokeStyle = '#8b6914';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx + 5, sy - 6);
ctx.lineTo(sx + 13, sy - 6);
ctx.stroke();
// Shield
ctx.fillStyle = TEAM_COLOR;
ctx.beginPath();
ctx.ellipse(sx - 8, sy - 5, 5, 7, 0, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(sx - 8, sy - 5, 5, 7, 0, 0, Math.PI*2);
ctx.stroke();
}
// Carrying indicator
if (u.carrying) {
ctx.fillStyle = u.carrying.type === 'gold' ? '#ffd700' : '#8b6914';
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.fillRect(sx - 4, sy - 22, 8, 7);
ctx.strokeRect(sx - 4, sy - 22, 8, 7);
}
// State indicator (small dot)
if (u.state === 'gathering') {
ctx.fillStyle = u.gatherTarget?.type === 'gold' ? '#ffd700' : '#6b4';
const pulse = Math.sin(frameCount * 0.1) * 2;
ctx.beginPath(); ctx.arc(sx, sy - 24 + pulse, 2.5, 0, Math.PI*2); ctx.fill();
} else if (u.state === 'building') {
ctx.fillStyle = '#42a5f5';
const pulse = Math.sin(frameCount * 0.1) * 2;
ctx.beginPath(); ctx.arc(sx, sy - 24 + pulse, 2.5, 0, Math.PI*2); ctx.fill();
}
// Health bar (if damaged)
if (u.hp < u.maxHp) {
const hbw = 20, hbh = 3;
const hbx = sx - hbw/2, hby = sy - 26;
ctx.fillStyle = '#300';
ctx.fillRect(hbx, hby, hbw, hbh);
const pct = u.hp / u.maxHp;
ctx.fillStyle = pct > 0.5 ? '#4caf50' : pct > 0.25 ? '#ff9800' : '#f44';
ctx.fillRect(hbx, hby, hbw * pct, hbh);
}
}
// ======================== MINIMAP ========================
function renderMinimap() {
mmCtx.fillStyle = '#0a0a12';
mmCtx.fillRect(0, 0, MINIMAP_SIZE, MINIMAP_SIZE);
const ms = MINIMAP_SCALE;
for (let ty = 0; ty < MAP_H; ty++) for (let tx = 0; tx < MAP_W; tx++) {
if (fog[ty][tx] === FOG_HIDDEN) continue;
const tile = map[ty][tx];
if (tile === T_GRASS) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#3a7a30' : '#253a20';
else if (tile === T_WATER) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#2858a0' : '#1a3060';
else if (tile === T_TREE) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#1a5a18' : '#0f3510';
else if (tile === T_GOLD) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#daa520' : '#8a6a15';
mmCtx.fillRect(tx * ms, ty * ms, Math.ceil(ms), Math.ceil(ms));
}
// Buildings
for (const b of buildings) {
mmCtx.fillStyle = '#5a8aee';
mmCtx.fillRect(b.tileX * ms, b.tileY * ms, b.w * ms, b.h * ms);
}
// Units
for (const u of units) {
mmCtx.fillStyle = '#8abfff';
const tx = u.x / TILE * ms, ty = u.y / TILE * ms;
mmCtx.fillRect(tx - 1, ty - 1, 3, 3);
}
// Camera viewport
const cx = (camera.x / TILE) * ms, cy = (camera.y / TILE) * ms;
const cw = (W / TILE) * ms, ch = (H / TILE) * ms;
mmCtx.strokeStyle = 'rgba(255,255,255,0.7)';
mmCtx.lineWidth = 1;
mmCtx.strokeRect(cx, cy, cw, ch);
}
// ======================== HUD ========================
function updateHUD() {
document.getElementById('resGold').textContent = res.gold;
document.getElementById('resWood').textContent = res.wood;
document.getElementById('resSupply').textContent = `${supplyUsed}/${supplyMax}`;
const info = document.getElementById('panel-info');
const actions = document.getElementById('panel-actions');
actions.innerHTML = '';
if (selectedBuilding) {
const b = selectedBuilding;
const def = BLDG_DEFS[b.type];
let html = `<div class="name">${def.name}</div>`;
html += `<div class="detail">HP: ${b.hp}/${b.maxHp}</div>`;
html += `<div class="hp-bar-outer"><div class="hp-bar-inner" style="width:${(b.hp/b.maxHp)*100}%"></div></div>`;
if (!b.built) {
html += `<div class="detail">Under construction: ${Math.floor((b.buildProgress/b.buildTime)*100)}%</div>`;
}
if (b.built && b.trainQueue.length > 0) {
const cur = b.trainQueue[0];
const td = UNIT_DEFS[cur];
html += `<div class="detail">Training: ${td.name}</div>`;
html += `<div class="train-progress-outer"><div class="train-progress-inner" style="width:${(b.trainProgress/td.tTime)*100}%"></div></div>`;
if (b.trainQueue.length > 1) html += `<div class="detail">Queue: ${b.trainQueue.length - 1} more</div>`;
}
if (def.supply > 0) {
html += `<div class="detail">Provides ${def.supply} supply</div>`;
}
info.innerHTML = html;
// Train buttons
if (b.built) {
for (const uType of def.trains) {
const ud = UNIT_DEFS[uType];
const btn = document.createElement('button');
btn.className = 'action-btn';
const afford = canAfford(ud.gCost, ud.wCost);
const hasSupply = supplyUsed + ud.supply <= supplyMax;
if (!afford || !hasSupply) btn.classList.add('disabled');
btn.innerHTML = `Train ${ud.name}<span class="cost">${ud.gCost}G ${ud.wCost > 0 ? ud.wCost + 'W' : ''}</span>`;
btn.addEventListener('click', () => { commandTrain(b, uType); updateHUD(); });
actions.appendChild(btn);
}
}
} else if (selectedUnits.length > 0) {
if (selectedUnits.length === 1) {
const u = selectedUnits[0];
const def = UNIT_DEFS[u.type];
let html = `<div class="name">${def.name}</div>`;
html += `<div class="detail">HP: ${u.hp}/${u.maxHp}</div>`;
html += `<div class="hp-bar-outer"><div class="hp-bar-inner" style="width:${(u.hp/u.maxHp)*100}%"></div></div>`;
let stateText = u.state;
if (u.state === 'gathering' && u.gatherTarget) stateText = `Gathering ${u.gatherTarget.type}`;
if (u.state === 'returning') stateText = 'Returning resources';
if (u.state === 'building') stateText = 'Building';
if (u.carrying) stateText += ` (carrying ${u.carrying.amount} ${u.carrying.type})`;
html += `<div class="detail">State: ${stateText}</div>`;
info.innerHTML = html;
} else {
const workers = selectedUnits.filter(u => u.type === 'worker').length;
const soldiers = selectedUnits.filter(u => u.type === 'soldier').length;
let html = `<div class="name">${selectedUnits.length} Units Selected</div>`;
if (workers > 0) html += `<div class="detail">${workers} Worker${workers>1?'s':''}</div>`;
if (soldiers > 0) html += `<div class="detail">${soldiers} Soldier${soldiers>1?'s':''}</div>`;
info.innerHTML = html;
}
// Build buttons for workers
const hasWorker = selectedUnits.some(u => u.type === 'worker');
if (hasWorker) {
if (placementType) {
const pd = BLDG_DEFS[placementType];
const hint = document.createElement('div');
hint.style.cssText = 'color:#ffcc00;font-size:12px;padding:4px 8px;width:100%';
hint.textContent = `Placing ${pd.name} — click on map (ESC to cancel)`;
actions.appendChild(hint);
}
for (const [bType, def] of Object.entries(BLDG_DEFS)) {
const btn = document.createElement('button');
btn.className = 'action-btn';
if (!canAfford(def.gCost, def.wCost)) btn.classList.add('disabled');
if (placementType === bType) btn.style.borderColor = '#ffcc00';
btn.innerHTML = `Build ${def.name}<span class="cost">${def.gCost}G ${def.wCost}W</span>`;
btn.addEventListener('click', () => {
if (!canAfford(def.gCost, def.wCost)) { showMsg('Not enough resources'); return; }
placementType = bType;
canvas.style.cursor = 'crosshair';
updateHUD();
});
actions.appendChild(btn);
}
}
} else {
info.innerHTML = '<span class="detail" style="margin-top:40px;text-align:center;color:#667">Select a unit or building</span>';
}
}
// ======================== GAME LOOP ========================
function gameLoop() {
update();
render();
// Update HUD resource display every 10 frames
if (frameCount % 10 === 0) updateHUD();
requestAnimationFrame(gameLoop);
}
// ======================== INITIALIZATION ========================
function resize() {
W = window.innerWidth; H = window.innerHeight;
canvas.width = W; canvas.height = H;
}
function init() {
resize();
window.addEventListener('resize', resize);
generateMap();
// Create starting entities
const thx = START_TX, thy = START_TY;
createBuilding('townhall', thx, thy, true);
for (let i = 0; i < 4; i++) {
const wx = (thx + 3) * TILE + TILE/2 + (i % 2) * TILE;
const wy = (thy + 1) * TILE + TILE/2 + Math.floor(i / 2) * TILE;
createUnit('worker', wx, wy);
}
// Center camera on base
camera.x = (thx + 1.5) * TILE - W/2;
camera.y = (thy + 1.5) * TILE - H/2;
camera.x = clamp(camera.x, 0, WORLD_W - W);
camera.y = clamp(camera.y, 0, WORLD_H - H);
updateHUD();
gameLoop();
}
init();
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment