Skip to content

Instantly share code, notes, and snippets.

@senko
Created April 16, 2026 15:32
Show Gist options
  • Select an option

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

Select an option

Save senko/ee6ca221f301b2f2562a910a617f083d to your computer and use it in GitHub Desktop.
RTS game by Opus 4.7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Iron Dominion — RTS</title>
<style>
:root {
--bg-0: #0a0d10;
--bg-1: #151a20;
--bg-2: #1e252d;
--panel: #1a2028;
--panel-edge: #3a4654;
--accent: #d4a94a;
--accent-bright: #f5cf6f;
--text: #d8dde3;
--text-dim: #8a96a4;
--danger: #c44;
--good: #6bc17a;
--blue: #5a9fd4;
--gold: #e8c34a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', 'Trebuchet MS', sans-serif;
background: var(--bg-0);
color: var(--text);
user-select: none;
-webkit-user-select: none;
}
#game {
position: absolute; inset: 0;
display: grid;
grid-template-rows: 36px 1fr 180px;
}
/* Top bar */
#topbar {
display: flex; align-items: center; gap: 24px;
background: linear-gradient(to bottom, #222a33, #11161b);
border-bottom: 2px solid #000;
padding: 0 16px;
font-size: 13px;
letter-spacing: 0.5px;
}
#topbar .logo {
font-family: 'Georgia', serif;
font-weight: bold;
color: var(--accent-bright);
text-shadow: 0 0 6px rgba(212,169,74,0.4);
font-size: 15px;
letter-spacing: 2px;
}
.res {
display: flex; align-items: center; gap: 6px;
font-weight: 600;
}
.res .icon {
width: 14px; height: 14px; border-radius: 2px;
display: inline-block;
}
.res.gold .icon { background: radial-gradient(circle at 30% 30%, #ffdd66, #b8860b); box-shadow: inset 0 0 3px #000; }
.res.wood .icon { background: linear-gradient(135deg, #8b5a2b, #5a3a1a); box-shadow: inset 0 0 3px #000; }
.res.food .icon { background: radial-gradient(circle at 30% 30%, #9ae29a, #3a7a3a); box-shadow: inset 0 0 3px #000; }
.res .val { color: var(--accent-bright); min-width: 30px; }
.res.food .val.full { color: var(--danger); }
#help {
margin-left: auto; color: var(--text-dim); font-size: 11px;
}
/* Main view */
#view {
position: relative;
background: #000;
overflow: hidden;
}
canvas#main {
display: block;
width: 100%; height: 100%;
cursor: crosshair;
image-rendering: pixelated;
}
#selBox {
position: absolute;
border: 1px solid #6bc17a;
background: rgba(107,193,122,0.1);
pointer-events: none;
display: none;
}
/* Bottom panel */
#bottom {
display: grid;
grid-template-columns: 200px 1fr 320px;
background: linear-gradient(to bottom, #1a2028, #0e1216);
border-top: 2px solid #000;
padding: 8px;
gap: 8px;
}
.panel {
background: var(--panel);
border: 1px solid var(--panel-edge);
border-radius: 3px;
padding: 8px;
overflow: hidden;
box-shadow: inset 0 0 12px rgba(0,0,0,0.4);
}
#minimap-wrap {
position: relative;
padding: 4px;
}
#minimap {
display: block;
width: 100%; height: 100%;
background: #050708;
border: 1px solid #000;
image-rendering: pixelated;
cursor: pointer;
}
#info {
display: flex; flex-direction: column;
font-size: 12px;
overflow: hidden;
}
#info .portrait-row {
display: flex; gap: 8px; align-items: flex-start;
margin-bottom: 6px;
}
#info .portrait {
width: 64px; height: 64px;
background: #0a0d10;
border: 1px solid var(--panel-edge);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
#info .details { flex: 1; }
#info .name {
color: var(--accent-bright);
font-weight: bold;
font-size: 14px;
letter-spacing: 1px;
margin-bottom: 4px;
}
#info .stat { color: var(--text-dim); margin: 2px 0; }
#info .stat b { color: var(--text); }
.hp-bar {
width: 100%; height: 8px; background: #000;
border: 1px solid var(--panel-edge);
position: relative;
margin-top: 4px;
}
.hp-bar > div {
height: 100%; background: linear-gradient(to right, var(--good), #3a9);
transition: width 0.2s;
}
#multi-list {
display: flex; flex-wrap: wrap; gap: 4px;
margin-top: 6px;
max-height: 80px; overflow: auto;
}
#multi-list .mini {
width: 28px; height: 28px;
background: #0a0d10;
border: 1px solid var(--panel-edge);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
position: relative;
}
#multi-list .mini .bar {
position: absolute; left: 1px; right: 1px; bottom: 1px;
height: 2px; background: var(--good);
}
#actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 56px;
gap: 4px;
align-content: start;
}
.btn {
background: linear-gradient(to bottom, #2a3440, #151b22);
border: 1px solid var(--panel-edge);
color: var(--text);
padding: 4px;
border-radius: 2px;
cursor: pointer;
font-size: 10px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center;
line-height: 1.1;
transition: all 0.1s;
position: relative;
overflow: hidden;
}
.btn:hover:not(.disabled) {
background: linear-gradient(to bottom, #3a4454, #1f2831);
border-color: var(--accent);
color: var(--accent-bright);
}
.btn:active:not(.disabled) {
transform: translateY(1px);
}
.btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn .ico {
font-size: 22px;
margin-bottom: 2px;
filter: drop-shadow(0 1px 1px #000);
}
.btn .lbl {
font-weight: 600;
letter-spacing: 0.3px;
}
.btn .cost {
font-size: 9px;
color: var(--accent);
margin-top: 1px;
}
.btn .cost .insuf { color: var(--danger); }
.btn.building-placing {
border-color: var(--accent-bright);
box-shadow: 0 0 8px rgba(212,169,74,0.4);
}
.build-progress {
position: absolute;
left: 0; bottom: 0;
height: 3px;
background: var(--good);
transition: width 0.2s;
}
/* Messages */
#msg {
position: absolute;
top: 50px; left: 50%;
transform: translateX(-50%);
background: rgba(20,26,32,0.9);
border: 1px solid var(--accent);
padding: 8px 16px;
font-size: 13px;
color: var(--accent-bright);
border-radius: 3px;
z-index: 100;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
#msg.show { opacity: 1; }
#placing-hint {
position: absolute;
top: 8px; left: 50%;
transform: translateX(-50%);
background: rgba(10,13,16,0.85);
border: 1px solid var(--accent);
padding: 4px 12px;
font-size: 12px;
color: var(--accent-bright);
border-radius: 3px;
z-index: 100;
pointer-events: none;
display: none;
}
/* Welcome screen */
#welcome {
position: absolute; inset: 0;
background: radial-gradient(ellipse at center, #1a2028 0%, #050708 100%);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
z-index: 200;
text-align: center;
padding: 20px;
}
#welcome h1 {
font-family: 'Georgia', serif;
font-size: 52px;
color: var(--accent-bright);
text-shadow: 0 0 20px rgba(212,169,74,0.5), 0 2px 0 #000;
letter-spacing: 6px;
margin-bottom: 8px;
}
#welcome .sub {
color: var(--text-dim);
font-style: italic;
margin-bottom: 32px;
letter-spacing: 2px;
}
#welcome .tips {
max-width: 540px;
background: rgba(20,26,32,0.8);
border: 1px solid var(--panel-edge);
padding: 20px 28px;
border-radius: 4px;
margin-bottom: 24px;
font-size: 13px;
line-height: 1.7;
text-align: left;
}
#welcome .tips b { color: var(--accent-bright); }
#welcome button {
background: linear-gradient(to bottom, #d4a94a, #8a6b20);
border: 1px solid #000;
color: #0a0d10;
padding: 12px 40px;
font-size: 16px;
font-weight: bold;
letter-spacing: 3px;
cursor: pointer;
border-radius: 3px;
box-shadow: 0 0 20px rgba(212,169,74,0.4);
transition: all 0.15s;
}
#welcome button:hover {
background: linear-gradient(to bottom, #f5cf6f, #b8862a);
transform: scale(1.03);
}
</style>
</head>
<body>
<div id="welcome">
<h1>IRON DOMINION</h1>
<div class="sub">— A Real-Time Strategy —</div>
<div class="tips">
<b>OBJECTIVE:</b> Expand your dominion, gather resources, and uncover the map.<br><br>
<b>CONTROLS:</b><br>
• <b>Left click</b> to select units / buildings<br>
• <b>Left click + drag</b> to box-select multiple units<br>
• <b>Right click</b> to move units or order workers to gather<br>
• <b>Arrow keys</b> or <b>screen edges</b> to scroll the map<br>
• <b>Click minimap</b> to jump to a location<br><br>
<b>TIP:</b> Start by training <b>Workers</b> from your Town Hall. Send them to <b>Gold Mines</b> (glittering piles) and <b>Forests</b> (trees) to gather resources. Build <b>Farms</b> to increase your food cap. Build a <b>Barracks</b> to train <b>Soldiers</b>.
</div>
<button onclick="startGame()">BEGIN CAMPAIGN</button>
</div>
<div id="game" style="display:none">
<div id="topbar">
<div class="logo">⚔ IRON DOMINION</div>
<div class="res gold"><span class="icon"></span>Gold <span class="val" id="r-gold">0</span></div>
<div class="res wood"><span class="icon"></span>Wood <span class="val" id="r-wood">0</span></div>
<div class="res food"><span class="icon"></span>Food <span class="val" id="r-food">0/0</span></div>
<div id="help">Right-click: move/gather • Drag: box-select • Arrows/Edges: scroll</div>
</div>
<div id="view">
<canvas id="main"></canvas>
<div id="selBox"></div>
<div id="placing-hint">Left-click to place • Right-click or ESC to cancel</div>
<div id="msg"></div>
</div>
<div id="bottom">
<div class="panel" id="minimap-wrap">
<canvas id="minimap"></canvas>
</div>
<div class="panel" id="info">
<div class="portrait-row">
<div class="portrait" id="portrait">—</div>
<div class="details">
<div class="name" id="sel-name">Nothing selected</div>
<div class="stat" id="sel-stat1"></div>
<div class="stat" id="sel-stat2"></div>
<div class="stat" id="sel-stat3"></div>
<div class="hp-bar" id="sel-hp" style="display:none"><div></div></div>
</div>
</div>
<div id="multi-list"></div>
</div>
<div class="panel" id="actions"></div>
</div>
</div>
<script>
// ============================================================
// IRON DOMINION — Real-Time Strategy
// ============================================================
const TILE = 32;
const MAP_W = 64; // tiles
const MAP_H = 64;
const WORLD_W = MAP_W * TILE;
const WORLD_H = MAP_H * TILE;
// -------- Map generation --------
// Tile types: 0 grass, 1 forest, 2 gold-mine, 3 water, 4 dirt
const TILE_GRASS = 0, TILE_FOREST = 1, TILE_GOLD = 2, TILE_WATER = 3, TILE_DIRT = 4;
let tiles = []; // 2D [y][x] -> type
let tileRes = []; // 2D [y][x] -> resource amount (for forest/gold)
let fog = []; // 2D [y][x] -> 0 unseen, 1 seen (explored), 2 visible
function seededRand(seed) {
let s = seed | 0;
return () => {
s = (s * 1664525 + 1013904223) | 0;
return ((s >>> 0) % 100000) / 100000;
};
}
const rand = seededRand(42);
function generateMap() {
tiles = Array.from({length: MAP_H}, () => new Array(MAP_W).fill(TILE_GRASS));
tileRes = Array.from({length: MAP_H}, () => new Array(MAP_W).fill(0));
fog = Array.from({length: MAP_H}, () => new Array(MAP_W).fill(0));
// Scatter dirt patches
for (let i = 0; i < 40; i++) {
const cx = Math.floor(rand()*MAP_W), cy = Math.floor(rand()*MAP_H);
const r = 2 + Math.floor(rand()*4);
for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) {
const tx = cx+x, ty = cy+y;
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue;
if (x*x+y*y <= r*r && rand() < 0.6) tiles[ty][tx] = TILE_DIRT;
}
}
// Forests (several clusters)
for (let i = 0; i < 14; i++) {
const cx = Math.floor(rand()*MAP_W), cy = Math.floor(rand()*MAP_H);
// Avoid starting area
if (Math.abs(cx-8) < 6 && Math.abs(cy-8) < 6) continue;
const r = 2 + Math.floor(rand()*4);
for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) {
const tx = cx+x, ty = cy+y;
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue;
if (x*x+y*y <= r*r && rand() < 0.7) {
tiles[ty][tx] = TILE_FOREST;
tileRes[ty][tx] = 100;
}
}
}
// Gold mines (scattered)
const goldSpots = [
[14, 10], [22, 22], [10, 30], [38, 14], [50, 28], [28, 48], [48, 50], [18, 54], [56, 44], [6, 44]
];
for (const [cx, cy] of goldSpots) {
// small cluster 2x2
for (let y = 0; y < 2; y++) for (let x = 0; x < 2; x++) {
const tx = cx+x, ty = cy+y;
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue;
tiles[ty][tx] = TILE_GOLD;
tileRes[ty][tx] = 1500;
}
}
// Water patches
for (let i = 0; i < 6; i++) {
const cx = Math.floor(rand()*MAP_W), cy = Math.floor(rand()*MAP_H);
if (Math.abs(cx-8) < 8 && Math.abs(cy-8) < 8) continue;
const r = 2 + Math.floor(rand()*3);
for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) {
const tx = cx+x, ty = cy+y;
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue;
if (x*x+y*y <= r*r && tiles[ty][tx] === TILE_GRASS) tiles[ty][tx] = TILE_WATER;
}
}
// Ensure starting area is clear grass
for (let y = 5; y < 13; y++) for (let x = 5; x < 13; x++) {
tiles[y][x] = TILE_GRASS;
tileRes[y][x] = 0;
}
}
function isPassable(tx, ty) {
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false;
const t = tiles[ty][tx];
if (t === TILE_WATER || t === TILE_GOLD || t === TILE_FOREST) return false;
// Check buildings
for (const b of buildings) {
if (!b.alive) continue;
const bx = Math.floor(b.x / TILE), by = Math.floor(b.y / TILE);
if (tx >= bx && tx < bx + b.def.tw && ty >= by && ty < by + b.def.th) return false;
}
return true;
}
// -------- Definitions --------
const UNIT_DEFS = {
worker: {
name: 'Worker', icon: '⛏', hp: 40, speed: 60, radius: 10, color: '#e0c080',
costGold: 50, costWood: 0, costFood: 1, buildTime: 8,
damage: 3, range: 16, attackCd: 1.0,
canBuild: true, canGather: true,
desc: 'Gathers gold and wood. Constructs buildings.'
},
soldier: {
name: 'Soldier', icon: '⚔', hp: 80, speed: 55, radius: 11, color: '#a82020',
costGold: 80, costWood: 20, costFood: 2, buildTime: 14,
damage: 12, range: 18, attackCd: 1.2,
canBuild: false, canGather: false,
desc: 'Melee combat unit with sword and shield.'
},
archer: {
name: 'Archer', icon: '🏹', hp: 55, speed: 58, radius: 10, color: '#2a7a2a',
costGold: 100, costWood: 50, costFood: 2, buildTime: 16,
damage: 10, range: 110, attackCd: 1.5,
canBuild: false, canGather: false,
desc: 'Ranged unit. Fires arrows at distant enemies.'
},
knight: {
name: 'Knight', icon: '♞', hp: 150, speed: 75, radius: 13, color: '#6a5acd',
costGold: 180, costWood: 60, costFood: 3, buildTime: 22,
damage: 22, range: 20, attackCd: 1.3,
canBuild: false, canGather: false,
desc: 'Heavy cavalry. Strong and fast.'
}
};
const BUILDING_DEFS = {
townhall: {
name: 'Town Hall', icon: '🏛', tw: 3, th: 3, hp: 1500,
costGold: 400, costWood: 300, buildTime: 45,
provides: 5, sight: 8, // tiles
dropoff: true,
trains: ['worker'],
desc: 'Heart of your civilization. Trains workers. Resource drop-off.'
},
farm: {
name: 'Farm', icon: '🌾', tw: 2, th: 2, hp: 300,
costGold: 0, costWood: 80, buildTime: 15,
provides: 4, sight: 4,
desc: 'Provides food for additional units.'
},
barracks: {
name: 'Barracks', icon: '⚔', tw: 3, th: 3, hp: 800,
costGold: 200, costWood: 150, buildTime: 30,
provides: 0, sight: 6,
trains: ['soldier', 'archer'],
desc: 'Trains soldiers and archers.'
},
stable: {
name: 'Stable', icon: '♞', tw: 3, th: 3, hp: 900,
costGold: 250, costWood: 200, buildTime: 35,
provides: 0, sight: 6,
trains: ['knight'],
desc: 'Trains knights — heavy cavalry.'
},
tower: {
name: 'Watchtower', icon: '🗼', tw: 2, th: 2, hp: 500,
costGold: 100, costWood: 100, buildTime: 20,
provides: 0, sight: 9,
damage: 15, range: 140, attackCd: 1.5,
desc: 'Defensive tower that shoots nearby enemies.'
}
};
// -------- Game state --------
const game = {
gold: 500,
wood: 300,
food: 0,
foodCap: 0,
camX: 4 * TILE,
camY: 4 * TILE,
viewW: 800,
viewH: 600,
selected: [], // array of units or single building
hovering: null,
lastTime: 0,
placingBuilding: null, // def key if placing
mouse: { x: 0, y: 0, screenX: 0, screenY: 0, down: false, dragStart: null },
time: 0,
};
const units = [];
const buildings = [];
const projectiles = [];
const effects = []; // damage flashes, sparkles
let nextId = 1;
// -------- Entity factories --------
function createUnit(type, x, y) {
const def = UNIT_DEFS[type];
const u = {
id: nextId++,
kind: 'unit',
type, def,
x, y,
hp: def.hp, maxHp: def.hp,
speed: def.speed,
radius: def.radius,
alive: true,
state: 'idle', // idle, moving, gathering, returning, attacking, building, dead
target: null,
targetPos: null,
gatherType: null, // 'gold' or 'wood'
carry: 0,
carryType: null,
gatherCd: 0,
attackCd: 0,
buildTarget: null,
facing: 0,
};
units.push(u);
return u;
}
function createBuilding(type, tx, ty, complete = false) {
const def = BUILDING_DEFS[type];
const b = {
id: nextId++,
kind: 'building',
type, def,
x: tx * TILE + (def.tw * TILE) / 2,
y: ty * TILE + (def.th * TILE) / 2,
tx, ty,
hp: complete ? def.hp : 1,
maxHp: def.hp,
alive: true,
complete,
buildProgress: complete ? def.buildTime : 0,
buildQueue: [],
trainProgress: 0,
rallyPoint: null,
attackCd: 0,
};
buildings.push(b);
if (complete && def.provides) game.foodCap += def.provides;
return b;
}
// -------- Initialize game --------
function initGame() {
generateMap();
// Starting units
const th = createBuilding('townhall', 7, 7, true);
// workers
createUnit('worker', th.x - 40, th.y + 60);
createUnit('worker', th.x + 40, th.y + 60);
createUnit('worker', th.x, th.y + 70);
// starting food units
game.food = 3;
updateFog();
centerOn(th.x, th.y);
}
function centerOn(x, y) {
game.camX = x - game.viewW / 2;
game.camY = y - game.viewH / 2;
clampCamera();
}
function clampCamera() {
game.camX = Math.max(0, Math.min(WORLD_W - game.viewW, game.camX));
game.camY = Math.max(0, Math.min(WORLD_H - game.viewH, game.camY));
}
// -------- Fog of war --------
function updateFog() {
// Decay: visible -> seen
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
if (fog[y][x] === 2) fog[y][x] = 1;
}
}
// Reveal around units and buildings
function reveal(wx, wy, sightTiles) {
const cx = Math.floor(wx / TILE), cy = Math.floor(wy / TILE);
const r = sightTiles;
for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) {
const tx = cx + x, ty = cy + y;
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue;
if (x*x + y*y <= r*r) fog[ty][tx] = 2;
}
}
for (const u of units) if (u.alive) reveal(u.x, u.y, 6);
for (const b of buildings) if (b.alive) reveal(b.x, b.y, b.def.sight || 4);
}
// -------- Pathing (simple) --------
function moveToward(u, tx, ty, dt) {
const dx = tx - u.x, dy = ty - u.y;
const d = Math.hypot(dx, dy);
if (d < 0.5) return true;
u.facing = Math.atan2(dy, dx);
const step = u.speed * dt;
let nx = u.x, ny = u.y;
if (step >= d) { nx = tx; ny = ty; }
else { nx = u.x + (dx / d) * step; ny = u.y + (dy / d) * step; }
// Try full move
if (canOccupy(nx, ny, u)) { u.x = nx; u.y = ny; return d <= step; }
// Try x only
if (canOccupy(nx, u.y, u)) { u.x = nx; return false; }
// Try y only
if (canOccupy(u.x, ny, u)) { u.y = ny; return false; }
// Try perpendicular slide
const slideAngles = [Math.PI/3, -Math.PI/3, Math.PI/2, -Math.PI/2];
for (const a of slideAngles) {
const sa = u.facing + a;
const sx = u.x + Math.cos(sa) * step;
const sy = u.y + Math.sin(sa) * step;
if (canOccupy(sx, sy, u)) { u.x = sx; u.y = sy; return false; }
}
return false;
}
function canOccupy(x, y, self) {
const tx = Math.floor(x / TILE), ty = Math.floor(y / TILE);
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false;
const t = tiles[ty][tx];
if (t === TILE_WATER) return false;
// Collide with buildings
for (const b of buildings) {
if (!b.alive || b === self) continue;
const bx0 = b.tx * TILE, by0 = b.ty * TILE;
const bx1 = bx0 + b.def.tw * TILE, by1 = by0 + b.def.th * TILE;
if (x > bx0 - 2 && x < bx1 + 2 && y > by0 - 2 && y < by1 + 2) return false;
}
// Don't walk into forest/gold tiles
if (t === TILE_FOREST || t === TILE_GOLD) return false;
return true;
}
// Find nearest tile of type around a world position, returns {tx,ty,wx,wy} or null
function findNearestTile(fromX, fromY, typeOrTypes) {
const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes];
const cx = Math.floor(fromX / TILE), cy = Math.floor(fromY / TILE);
const MAX_R = 30;
for (let r = 0; r <= MAX_R; r++) {
for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) {
if (Math.max(Math.abs(x), Math.abs(y)) !== r) continue;
const tx = cx+x, ty = cy+y;
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue;
if (types.includes(tiles[ty][tx]) && tileRes[ty][tx] > 0) {
return { tx, ty, wx: tx*TILE + TILE/2, wy: ty*TILE + TILE/2 };
}
}
}
return null;
}
// Find nearest dropoff building
function findNearestDropoff(fromX, fromY) {
let best = null, bd = Infinity;
for (const b of buildings) {
if (!b.alive || !b.complete) continue;
if (!b.def.dropoff) continue;
const d = Math.hypot(b.x - fromX, b.y - fromY);
if (d < bd) { bd = d; best = b; }
}
return best;
}
function isAdjacentToTile(wx, wy, tx, ty) {
// True if the world point lies within the 3x3 block of tiles centered on (tx,ty)
const cx = Math.floor(wx / TILE), cy = Math.floor(wy / TILE);
return Math.abs(cx - tx) <= 1 && Math.abs(cy - ty) <= 1;
}
// Approach target — get within range
function inRangeOf(u, target, range) {
if (target.kind === 'building') {
// distance to building edge
const bx0 = target.tx * TILE, by0 = target.ty * TILE;
const bx1 = bx0 + target.def.tw * TILE, by1 = by0 + target.def.th * TILE;
const cx = Math.max(bx0, Math.min(u.x, bx1));
const cy = Math.max(by0, Math.min(u.y, by1));
return Math.hypot(u.x - cx, u.y - cy) <= range;
} else if (target.kind === 'tile') {
const cx = target.tx * TILE + TILE/2;
const cy = target.ty * TILE + TILE/2;
return Math.hypot(u.x - cx, u.y - cy) <= range + TILE/2;
}
return Math.hypot(u.x - target.x, u.y - target.y) <= range;
}
function approachPoint(target) {
if (target.kind === 'building') {
// approach near edge
return { x: target.x, y: target.y };
} else if (target.kind === 'tile') {
return { x: target.tx*TILE + TILE/2, y: target.ty*TILE + TILE/2 };
}
return { x: target.x, y: target.y };
}
// -------- Unit AI --------
function updateUnit(u, dt) {
if (!u.alive) return;
if (u.attackCd > 0) u.attackCd -= dt;
if (u.gatherCd > 0) u.gatherCd -= dt;
if (u.state === 'moving') {
if (!u.targetPos) { u.state = 'idle'; return; }
if (moveToward(u, u.targetPos.x, u.targetPos.y, dt)) {
u.state = 'idle';
u.targetPos = null;
}
} else if (u.state === 'gathering') {
// target is a resource tile
if (!u.target || tileRes[u.target.ty][u.target.tx] <= 0) {
// find another
const near = findNearestTile(u.x, u.y, u.gatherType === 'gold' ? [TILE_GOLD] : [TILE_FOREST]);
if (near) { u.target = { kind: 'tile', tx: near.tx, ty: near.ty }; }
else { u.state = 'idle'; u.target = null; return; }
}
// Need to be adjacent (8-connected) to the resource tile: any point whose tile
// is a neighbor (orthogonal or diagonal) counts as "in range".
if (!isAdjacentToTile(u.x, u.y, u.target.tx, u.target.ty)) {
const aj = findAdjacentPassable(u.target.tx, u.target.ty, u.x, u.y);
if (aj) moveToward(u, aj.x, aj.y, dt);
else { u.state = 'idle'; return; }
} else {
// gather
if (u.gatherCd <= 0) {
const amt = Math.min(5, tileRes[u.target.ty][u.target.tx]);
u.carry += amt;
u.carryType = u.gatherType;
tileRes[u.target.ty][u.target.tx] -= amt;
u.gatherCd = 1.2;
if (tileRes[u.target.ty][u.target.tx] <= 0) {
// tile depleted, convert to dirt
tiles[u.target.ty][u.target.tx] = TILE_DIRT;
// Redraw the affected tile in the cached canvas so it visually updates
tctx.clearRect(u.target.tx * TILE, u.target.ty * TILE, TILE, TILE);
drawTile(tctx, u.target.tx, u.target.ty);
}
if (u.carry >= 10) {
// return to dropoff
u.state = 'returning';
}
}
}
} else if (u.state === 'returning') {
const drop = findNearestDropoff(u.x, u.y);
if (!drop) { u.state = 'idle'; u.carry = 0; return; }
// Adjacent to the building footprint (within 1 tile of any of its tiles)
const ucx = Math.floor(u.x / TILE), ucy = Math.floor(u.y / TILE);
const adjacent = ucx >= drop.tx - 1 && ucx <= drop.tx + drop.def.tw &&
ucy >= drop.ty - 1 && ucy <= drop.ty + drop.def.th;
if (adjacent) {
if (u.carryType === 'gold') game.gold += u.carry;
else if (u.carryType === 'wood') game.wood += u.carry;
u.carry = 0;
// return to resource
if (u.gatherType) {
const near = findNearestTile(u.x, u.y, u.gatherType === 'gold' ? [TILE_GOLD] : [TILE_FOREST]);
if (near) {
u.target = { kind: 'tile', tx: near.tx, ty: near.ty };
u.state = 'gathering';
} else {
u.state = 'idle';
}
} else {
u.state = 'idle';
}
} else {
// walk toward an adjacent tile of the dropoff, not its center
const aj = findAdjacentPassable(drop.tx, drop.ty, u.x, u.y, drop.def.tw, drop.def.th);
if (aj) moveToward(u, aj.x, aj.y, dt);
else moveToward(u, drop.x, drop.y, dt);
}
} else if (u.state === 'building') {
if (!u.buildTarget || !u.buildTarget.alive || u.buildTarget.complete) {
u.state = 'idle'; u.buildTarget = null; return;
}
const bt = u.buildTarget;
const ucx = Math.floor(u.x / TILE), ucy = Math.floor(u.y / TILE);
const adjB = ucx >= bt.tx - 1 && ucx <= bt.tx + bt.def.tw &&
ucy >= bt.ty - 1 && ucy <= bt.ty + bt.def.th;
if (!adjB) {
const aj = findAdjacentPassable(bt.tx, bt.ty, u.x, u.y, bt.def.tw, bt.def.th);
if (aj) moveToward(u, aj.x, aj.y, dt);
else { u.state = 'idle'; u.buildTarget = null; return; }
} else {
// construct
u.buildTarget.buildProgress += dt * 1.2;
u.buildTarget.hp = Math.min(u.buildTarget.maxHp,
(u.buildTarget.buildProgress / u.buildTarget.def.buildTime) * u.buildTarget.maxHp);
if (u.buildTarget.buildProgress >= u.buildTarget.def.buildTime) {
u.buildTarget.complete = true;
u.buildTarget.hp = u.buildTarget.maxHp;
if (u.buildTarget.def.provides) game.foodCap += u.buildTarget.def.provides;
showMsg(`${u.buildTarget.def.name} constructed`);
u.state = 'idle';
u.buildTarget = null;
}
}
} else if (u.state === 'attacking') {
if (!u.target || !u.target.alive) { u.state = 'idle'; u.target = null; return; }
if (!inRangeOf(u, u.target, u.def.range)) {
moveToward(u, u.target.x, u.target.y, dt);
} else {
if (u.attackCd <= 0) {
dealAttack(u, u.target);
u.attackCd = u.def.attackCd;
}
}
}
}
function findAdjacentPassable(tx, ty, fromX, fromY, tw = 1, th = 1) {
// return a world-space point adjacent to the tile/building area
const candidates = [];
// Pre-compute which tiles are occupied by buildings (for quick lookup)
const isBuildingTile = (cx, cy) => {
for (const b of buildings) {
if (!b.alive) continue;
// Skip the target area itself (if asking for adjacency to a building, don't filter itself)
if (cx >= b.tx && cx < b.tx + b.def.tw &&
cy >= b.ty && cy < b.ty + b.def.th &&
b.tx === tx && b.ty === ty) continue;
if (cx >= b.tx && cx < b.tx + b.def.tw &&
cy >= b.ty && cy < b.ty + b.def.th) return true;
}
return false;
};
for (let y = -1; y <= th; y++) {
for (let x = -1; x <= tw; x++) {
if (x >= 0 && x < tw && y >= 0 && y < th) continue;
const cx = tx + x, cy = ty + y;
if (cx < 0 || cy < 0 || cx >= MAP_W || cy >= MAP_H) continue;
const t = tiles[cy][cx];
if (t === TILE_WATER || t === TILE_GOLD || t === TILE_FOREST) continue;
if (isBuildingTile(cx, cy)) continue;
const wx = cx * TILE + TILE/2, wy = cy * TILE + TILE/2;
const d = Math.hypot(wx - fromX, wy - fromY);
candidates.push({ x: wx, y: wy, d });
}
}
candidates.sort((a,b) => a.d - b.d);
return candidates[0] || null;
}
function dealAttack(attacker, target) {
const def = attacker.def;
if (def.range > 50) {
// ranged projectile
projectiles.push({
x: attacker.x, y: attacker.y,
tx: target.x, ty: target.y,
target, damage: def.damage,
speed: 300,
alive: true,
color: '#ffe080'
});
} else {
// melee instant
target.hp -= def.damage;
effects.push({ x: target.x, y: target.y, type: 'hit', life: 0.3, maxLife: 0.3 });
if (target.hp <= 0) killEntity(target);
}
}
function killEntity(e) {
e.alive = false;
if (e.kind === 'unit') {
game.food = Math.max(0, game.food - e.def.costFood);
} else if (e.kind === 'building') {
if (e.complete && e.def.provides) game.foodCap -= e.def.provides;
}
// remove from selected
game.selected = game.selected.filter(s => s.alive);
}
// -------- Building logic --------
function updateBuilding(b, dt) {
if (!b.alive) return;
if (!b.complete) return;
// Tower attacks
if (b.def.damage) {
if (b.attackCd > 0) b.attackCd -= dt;
// no enemies in this game, skip
}
// Train queue
if (b.buildQueue.length > 0) {
const ut = b.buildQueue[0];
const udef = UNIT_DEFS[ut];
b.trainProgress += dt;
if (b.trainProgress >= udef.buildTime) {
// spawn
if (game.food + udef.costFood > game.foodCap) {
// wait
showMsg('Not enough food. Build farms.');
b.trainProgress = udef.buildTime;
} else {
b.trainProgress = 0;
b.buildQueue.shift();
const spawnPos = findSpawnPoint(b);
const u = createUnit(ut, spawnPos.x, spawnPos.y);
game.food += udef.costFood;
if (b.rallyPoint) {
u.targetPos = { x: b.rallyPoint.x, y: b.rallyPoint.y };
u.state = 'moving';
}
}
}
}
}
function findSpawnPoint(b) {
// find a passable tile adjacent to the building
const candidates = [];
for (let y = -1; y <= b.def.th; y++) {
for (let x = -1; x <= b.def.tw; x++) {
if (x >= 0 && x < b.def.tw && y >= 0 && y < b.def.th) continue;
const tx = b.tx + x, ty = b.ty + y;
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue;
const t = tiles[ty][tx];
if (t === TILE_WATER || t === TILE_GOLD || t === TILE_FOREST) continue;
candidates.push({ x: tx * TILE + TILE/2, y: ty * TILE + TILE/2 });
}
}
return candidates[Math.floor(Math.random() * candidates.length)] || { x: b.x, y: b.y + b.def.th * TILE };
}
// -------- Projectiles --------
function updateProjectiles(dt) {
for (const p of projectiles) {
if (!p.alive) continue;
if (p.target && p.target.alive) { p.tx = p.target.x; p.ty = p.target.y; }
const dx = p.tx - p.x, dy = p.ty - p.y;
const d = Math.hypot(dx, dy);
const step = p.speed * dt;
if (step >= d) {
// hit
if (p.target && p.target.alive) {
p.target.hp -= p.damage;
effects.push({ x: p.target.x, y: p.target.y, type: 'hit', life: 0.3, maxLife: 0.3 });
if (p.target.hp <= 0) killEntity(p.target);
}
p.alive = false;
} else {
p.x += (dx / d) * step;
p.y += (dy / d) * step;
}
}
for (let i = projectiles.length - 1; i >= 0; i--) if (!projectiles[i].alive) projectiles.splice(i, 1);
}
function updateEffects(dt) {
for (const e of effects) e.life -= dt;
for (let i = effects.length - 1; i >= 0; i--) if (effects[i].life <= 0) effects.splice(i, 1);
}
// -------- Rendering --------
const canvas = document.getElementById('main');
const ctx = canvas.getContext('2d');
const minimap = document.getElementById('minimap');
const mctx = minimap.getContext('2d');
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.floor(rect.width);
canvas.height = Math.floor(rect.height);
game.viewW = canvas.width;
game.viewH = canvas.height;
clampCamera();
const mr = minimap.getBoundingClientRect();
minimap.width = Math.floor(mr.width);
minimap.height = Math.floor(mr.height);
}
// Pre-render tile textures to offscreen for perf
const tileCanvas = document.createElement('canvas');
tileCanvas.width = WORLD_W;
tileCanvas.height = WORLD_H;
const tctx = tileCanvas.getContext('2d');
function renderTilesToCache() {
tctx.clearRect(0, 0, WORLD_W, WORLD_H);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
drawTile(tctx, x, y);
}
}
}
function drawTile(c, x, y) {
const t = tiles[y][x];
const px = x * TILE, py = y * TILE;
// Base
if (t === TILE_WATER) {
// water
const g = c.createLinearGradient(px, py, px, py + TILE);
g.addColorStop(0, '#1e4a6e');
g.addColorStop(1, '#0d2c44');
c.fillStyle = g;
c.fillRect(px, py, TILE, TILE);
// wave
c.strokeStyle = 'rgba(255,255,255,0.08)';
c.beginPath();
c.moveTo(px + 4, py + TILE/2);
c.quadraticCurveTo(px + TILE/2, py + TILE/2 - 2, px + TILE - 4, py + TILE/2);
c.stroke();
} else if (t === TILE_DIRT) {
// dirt
const h = ((x * 31 + y * 17) % 20) / 100;
c.fillStyle = `hsl(30, 30%, ${30 + h*100}%)`;
c.fillRect(px, py, TILE, TILE);
// speckle
c.fillStyle = 'rgba(0,0,0,0.15)';
const seed = (x*53+y*97)%9;
for (let i = 0; i < 4; i++) {
c.fillRect(px + ((seed+i)*7)%TILE, py + ((seed+i*3)*11)%TILE, 2, 2);
}
} else if (t === TILE_FOREST) {
// grass base
c.fillStyle = '#2d4a26';
c.fillRect(px, py, TILE, TILE);
// tree
const cx = px + TILE/2, cy = py + TILE/2;
// trunk
c.fillStyle = '#4a2c1a';
c.fillRect(cx - 2, cy + 2, 4, 10);
// foliage
c.fillStyle = '#1d5020';
c.beginPath();
c.arc(cx, cy + 2, TILE/2 - 3, 0, Math.PI * 2);
c.fill();
c.fillStyle = '#2a6a30';
c.beginPath();
c.arc(cx - 3, cy - 1, TILE/3, 0, Math.PI * 2);
c.fill();
c.fillStyle = '#378040';
c.beginPath();
c.arc(cx + 3, cy - 3, TILE/4, 0, Math.PI * 2);
c.fill();
// highlight
c.fillStyle = 'rgba(255,255,200,0.15)';
c.beginPath();
c.arc(cx + 2, cy - 4, 3, 0, Math.PI * 2);
c.fill();
} else if (t === TILE_GOLD) {
// rocky base
c.fillStyle = '#555';
c.fillRect(px, py, TILE, TILE);
c.fillStyle = '#444';
c.fillRect(px, py, TILE, TILE/2);
// gold nugget cluster
const cx = px + TILE/2, cy = py + TILE/2;
const grd = c.createRadialGradient(cx, cy, 2, cx, cy, TILE/2);
grd.addColorStop(0, '#ffe680');
grd.addColorStop(0.6, '#d4a94a');
grd.addColorStop(1, '#6a4a10');
c.fillStyle = grd;
c.beginPath();
c.arc(cx, cy, TILE/2 - 4, 0, Math.PI*2);
c.fill();
// sparkle
c.fillStyle = '#fff6c0';
c.fillRect(cx - 4, cy - 4, 2, 2);
c.fillRect(cx + 3, cy + 2, 2, 2);
} else {
// grass
const v = ((x * 41 + y * 23) % 30) - 15;
c.fillStyle = `hsl(105, 35%, ${26 + v * 0.3}%)`;
c.fillRect(px, py, TILE, TILE);
// tufts
const seed = (x*73+y*29)%5;
if (seed < 2) {
c.fillStyle = 'rgba(200,230,150,0.25)';
c.fillRect(px + (seed*11)%TILE, py + (seed*17+5)%TILE, 2, 2);
c.fillRect(px + (seed*19+8)%TILE, py + (seed*7+11)%TILE, 2, 1);
}
}
// grid line (subtle)
c.strokeStyle = 'rgba(0,0,0,0.06)';
c.lineWidth = 1;
c.strokeRect(px + 0.5, py + 0.5, TILE - 1, TILE - 1);
}
function render() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw cached tile map with camera offset
ctx.drawImage(
tileCanvas,
game.camX, game.camY, game.viewW, game.viewH,
0, 0, game.viewW, game.viewH
);
// Buildings (back to front by y)
const drawList = [];
for (const b of buildings) if (b.alive) drawList.push(b);
for (const u of units) if (u.alive) drawList.push(u);
drawList.sort((a, b) => (a.y + (a.kind==='building' ? a.def.th*TILE/2 : 0)) - (b.y + (b.kind==='building' ? b.def.th*TILE/2 : 0)));
for (const e of drawList) {
if (e.kind === 'building') drawBuilding(e);
else drawUnit(e);
}
// Projectiles
for (const p of projectiles) {
if (!p.alive) continue;
const sx = p.x - game.camX, sy = p.y - game.camY;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(sx, sy, 2, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,220,120,0.6)';
const dx = p.tx - p.x, dy = p.ty - p.y;
const d = Math.hypot(dx,dy) || 1;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx - (dx/d)*8, sy - (dy/d)*8);
ctx.stroke();
}
// Effects
for (const fx of effects) {
const sx = fx.x - game.camX, sy = fx.y - game.camY;
const a = fx.life / fx.maxLife;
if (fx.type === 'hit') {
ctx.strokeStyle = `rgba(255,60,60,${a})`;
ctx.lineWidth = 2;
const r = (1 - a) * 14 + 4;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI*2);
ctx.stroke();
} else if (fx.type === 'sparkle') {
ctx.fillStyle = `rgba(255,230,120,${a})`;
ctx.fillRect(sx-1, sy-1, 2, 2);
}
}
// Placing preview
if (game.placingBuilding) {
drawBuildingPreview();
}
// Selection rings
for (const s of game.selected) {
if (!s.alive) continue;
if (s.kind === 'unit') {
const sx = s.x - game.camX, sy = s.y - game.camY;
ctx.strokeStyle = '#6bc17a';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(sx, sy + s.radius * 0.3, s.radius + 1, 0.1, Math.PI - 0.1);
ctx.stroke();
} else {
const bx = s.tx * TILE - game.camX, by = s.ty * TILE - game.camY;
ctx.strokeStyle = '#6bc17a';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.strokeRect(bx, by, s.def.tw * TILE, s.def.th * TILE);
ctx.setLineDash([]);
}
}
// HP bars for selected and for damaged
for (const e of drawList) {
const dmg = e.hp < e.maxHp * 0.99;
const sel = game.selected.includes(e);
if (!dmg && !sel) continue;
drawHpBar(e);
}
// Fog of war
drawFog();
renderMinimap();
}
function drawUnit(u) {
const sx = u.x - game.camX, sy = u.y - game.camY;
const tx = Math.floor(u.x / TILE), ty = Math.floor(u.y / TILE);
// Only draw if in visible area of fog (still render but darker if not visible)
const visible = fog[ty] && fog[ty][tx] === 2;
if (!visible) return;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(sx, sy + u.radius * 0.6, u.radius * 0.9, u.radius * 0.35, 0, 0, Math.PI*2);
ctx.fill();
// Body: a simple "person"
const r = u.radius;
const bob = Math.sin(game.time * 6 + u.id) * (u.state === 'moving' ? 1.2 : 0);
// Legs
ctx.fillStyle = '#2a1f14';
ctx.fillRect(sx - 3, sy + 1, 2, 5);
ctx.fillRect(sx + 1, sy + 1, 2, 5);
// Body
ctx.fillStyle = u.def.color;
ctx.fillRect(sx - 5, sy - 5 + bob, 10, 8);
// Body shadow
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(sx + 2, sy - 5 + bob, 3, 8);
// Highlight
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(sx - 5, sy - 5 + bob, 2, 8);
// Head
ctx.fillStyle = '#e3b68a';
ctx.beginPath();
ctx.arc(sx, sy - 8 + bob, 3.5, 0, Math.PI*2);
ctx.fill();
// hair
ctx.fillStyle = '#3a2416';
ctx.fillRect(sx - 3, sy - 11 + bob, 6, 2);
// Weapon / tool
if (u.type === 'worker') {
// pickaxe on back
ctx.strokeStyle = '#6a3a20';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(sx + 3, sy - 3 + bob);
ctx.lineTo(sx + 7, sy + 2 + bob);
ctx.stroke();
ctx.fillStyle = '#888';
ctx.fillRect(sx + 6, sy - 1 + bob, 3, 2);
} else if (u.type === 'soldier') {
// sword
ctx.fillStyle = '#cfd4dc';
ctx.fillRect(sx + 5, sy - 10 + bob, 1, 8);
ctx.fillStyle = '#8a6b20';
ctx.fillRect(sx + 4, sy - 2 + bob, 3, 1);
// shield
ctx.fillStyle = '#6b4020';
ctx.fillRect(sx - 8, sy - 4 + bob, 3, 5);
ctx.fillStyle = '#d4a94a';
ctx.fillRect(sx - 7, sy - 3 + bob, 1, 3);
} else if (u.type === 'archer') {
// bow
ctx.strokeStyle = '#6a3a1a';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(sx + 6, sy - 2 + bob, 4, -Math.PI*0.6, Math.PI*0.6);
ctx.stroke();
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(sx + 6, sy - 6 + bob);
ctx.lineTo(sx + 6, sy + 2 + bob);
ctx.stroke();
} else if (u.type === 'knight') {
// helmet
ctx.fillStyle = '#888';
ctx.fillRect(sx - 3, sy - 11 + bob, 6, 4);
ctx.fillStyle = '#c0392b';
ctx.fillRect(sx - 1, sy - 13 + bob, 2, 3);
// lance
ctx.strokeStyle = '#a0a0a0';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(sx + 5, sy - 8 + bob);
ctx.lineTo(sx + 10, sy + 2 + bob);
ctx.stroke();
}
// Carrying indicator
if (u.carry > 0) {
ctx.fillStyle = u.carryType === 'gold' ? '#e8c34a' : '#8b5a2b';
ctx.fillRect(sx - 2, sy - 14 + bob, 4, 3);
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.5;
ctx.strokeRect(sx - 2, sy - 14 + bob, 4, 3);
}
}
function drawBuilding(b) {
const bx = b.tx * TILE - game.camX;
const by = b.ty * TILE - game.camY;
const w = b.def.tw * TILE, h = b.def.th * TILE;
const tx = Math.floor(b.x / TILE), ty = Math.floor(b.y / TILE);
const visible = fog[ty] && fog[ty][tx] === 2;
// If not visible but explored, show ghost
if (!visible && !fog[ty]?.[tx]) return;
// Ground shadow
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.fillRect(bx + 2, by + h - 4, w, 6);
if (!b.complete) {
// Scaffold look
ctx.fillStyle = '#3a2818';
ctx.fillRect(bx + 4, by + 4, w - 8, h - 8);
ctx.strokeStyle = '#8b6a3a';
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(bx + 4 + (w-8) * i / 3, by + 4);
ctx.lineTo(bx + 4 + (w-8) * i / 3, by + h - 4);
ctx.stroke();
}
// cross beams
ctx.beginPath();
ctx.moveTo(bx + 4, by + 4);
ctx.lineTo(bx + w - 4, by + h - 4);
ctx.moveTo(bx + w - 4, by + 4);
ctx.lineTo(bx + 4, by + h - 4);
ctx.stroke();
// progress bar
const prog = b.buildProgress / b.def.buildTime;
ctx.fillStyle = '#000';
ctx.fillRect(bx + 2, by - 6, w - 4, 4);
ctx.fillStyle = '#e8c34a';
ctx.fillRect(bx + 3, by - 5, (w - 6) * prog, 2);
return;
}
// Complete buildings
if (b.type === 'townhall') drawTownHall(bx, by, w, h);
else if (b.type === 'farm') drawFarm(bx, by, w, h);
else if (b.type === 'barracks') drawBarracks(bx, by, w, h);
else if (b.type === 'stable') drawStable(bx, by, w, h);
else if (b.type === 'tower') drawTower(bx, by, w, h);
}
function drawTownHall(x, y, w, h) {
// base stone
ctx.fillStyle = '#8a8680';
ctx.fillRect(x + 4, y + h * 0.35, w - 8, h * 0.55);
// shadow side
ctx.fillStyle = '#5a564f';
ctx.fillRect(x + w - 10, y + h*0.35, 6, h*0.55);
// stone blocks
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(x + 4, y + h*0.35 + (h*0.55)*i/4);
ctx.lineTo(x + w - 4, y + h*0.35 + (h*0.55)*i/4);
ctx.stroke();
}
// door
ctx.fillStyle = '#2a1a0a';
ctx.fillRect(x + w/2 - 5, y + h - 14, 10, 14);
ctx.fillStyle = '#6b3a1a';
ctx.fillRect(x + w/2 - 4, y + h - 13, 8, 12);
// roof (tapered trapezoid)
ctx.fillStyle = '#b04040';
ctx.beginPath();
ctx.moveTo(x + 2, y + h * 0.35);
ctx.lineTo(x + w - 2, y + h * 0.35);
ctx.lineTo(x + w - 12, y + 4);
ctx.lineTo(x + 12, y + 4);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#7a2020';
ctx.beginPath();
ctx.moveTo(x + w/2, y + 4);
ctx.lineTo(x + w - 2, y + h * 0.35);
ctx.lineTo(x + w - 12, y + 4);
ctx.closePath();
ctx.fill();
// flag
ctx.strokeStyle = '#222';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x + w/2, y + 4);
ctx.lineTo(x + w/2, y - 10);
ctx.stroke();
ctx.fillStyle = '#d4a94a';
ctx.beginPath();
ctx.moveTo(x + w/2, y - 10);
ctx.lineTo(x + w/2 + 8, y - 7);
ctx.lineTo(x + w/2, y - 4);
ctx.closePath();
ctx.fill();
// windows
ctx.fillStyle = '#2a1a0a';
ctx.fillRect(x + 12, y + h*0.5, 5, 6);
ctx.fillRect(x + w - 17, y + h*0.5, 5, 6);
ctx.fillStyle = '#ffe080';
ctx.fillRect(x + 13, y + h*0.51, 3, 4);
ctx.fillRect(x + w - 16, y + h*0.51, 3, 4);
}
function drawFarm(x, y, w, h) {
// field
ctx.fillStyle = '#8a6b20';
ctx.fillRect(x + 2, y + 4, w - 4, h - 4);
// rows
ctx.fillStyle = '#e8c34a';
for (let i = 0; i < 4; i++) {
ctx.fillRect(x + 4, y + 7 + i * ((h-10)/4), w - 8, 2);
}
// fence
ctx.strokeStyle = '#5a3a1a';
ctx.lineWidth = 1;
ctx.strokeRect(x + 2, y + 4, w - 4, h - 4);
// posts
ctx.fillStyle = '#3a2410';
ctx.fillRect(x + 2, y + 4, 2, 4);
ctx.fillRect(x + w - 4, y + 4, 2, 4);
ctx.fillRect(x + 2, y + h - 4, 2, 4);
ctx.fillRect(x + w - 4, y + h - 4, 2, 4);
}
function drawBarracks(x, y, w, h) {
// base stone/wood
ctx.fillStyle = '#6b4020';
ctx.fillRect(x + 3, y + h * 0.4, w - 6, h * 0.55);
// planks
ctx.strokeStyle = '#3a2410';
ctx.lineWidth = 1;
for (let i = 1; i < 5; i++) {
ctx.beginPath();
ctx.moveTo(x + 3, y + h * 0.4 + (h*0.55) * i / 5);
ctx.lineTo(x + w - 3, y + h * 0.4 + (h*0.55) * i / 5);
ctx.stroke();
}
// stone corners
ctx.fillStyle = '#8a8680';
ctx.fillRect(x + 3, y + h * 0.4, 5, h * 0.55);
ctx.fillRect(x + w - 8, y + h * 0.4, 5, h * 0.55);
// roof
ctx.fillStyle = '#4a2c1a';
ctx.beginPath();
ctx.moveTo(x + 1, y + h * 0.4);
ctx.lineTo(x + w - 1, y + h * 0.4);
ctx.lineTo(x + w - 6, y + 6);
ctx.lineTo(x + 6, y + 6);
ctx.closePath();
ctx.fill();
// door
ctx.fillStyle = '#1a0a04';
ctx.fillRect(x + w/2 - 4, y + h - 12, 8, 12);
// crossed swords emblem
ctx.strokeStyle = '#cfd4dc';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + w/2 - 5, y + h*0.5);
ctx.lineTo(x + w/2 + 5, y + h*0.5 + 8);
ctx.moveTo(x + w/2 + 5, y + h*0.5);
ctx.lineTo(x + w/2 - 5, y + h*0.5 + 8);
ctx.stroke();
}
function drawStable(x, y, w, h) {
// walls
ctx.fillStyle = '#5a3a1a';
ctx.fillRect(x + 3, y + h * 0.4, w - 6, h * 0.55);
// planks vertical
ctx.strokeStyle = '#2a1a0a';
ctx.lineWidth = 1;
for (let i = 1; i < 6; i++) {
ctx.beginPath();
ctx.moveTo(x + 3 + (w-6) * i / 6, y + h * 0.4);
ctx.lineTo(x + 3 + (w-6) * i / 6, y + h - 3);
ctx.stroke();
}
// roof (curved hay-style)
ctx.fillStyle = '#b08a40';
ctx.beginPath();
ctx.moveTo(x + 1, y + h * 0.4);
ctx.quadraticCurveTo(x + w/2, y - 4, x + w - 1, y + h * 0.4);
ctx.closePath();
ctx.fill();
// dark edge
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.moveTo(x + 1, y + h * 0.4);
ctx.quadraticCurveTo(x + w/2, y - 4, x + w - 1, y + h * 0.4);
ctx.lineTo(x + w - 1, y + h * 0.4 + 3);
ctx.quadraticCurveTo(x + w/2, y - 1, x + 1, y + h * 0.4 + 3);
ctx.closePath();
ctx.fill();
// door (arch)
ctx.fillStyle = '#1a0a04';
ctx.beginPath();
ctx.moveTo(x + w/2 - 6, y + h - 2);
ctx.lineTo(x + w/2 - 6, y + h - 10);
ctx.quadraticCurveTo(x + w/2, y + h - 16, x + w/2 + 6, y + h - 10);
ctx.lineTo(x + w/2 + 6, y + h - 2);
ctx.closePath();
ctx.fill();
// horseshoe emblem
ctx.strokeStyle = '#cfd4dc';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x + w/2, y + h * 0.45, 4, Math.PI*0.1, Math.PI*0.9);
ctx.stroke();
}
function drawTower(x, y, w, h) {
// base
ctx.fillStyle = '#6a6a6a';
ctx.fillRect(x + 4, y + h * 0.3, w - 8, h * 0.6);
ctx.fillStyle = '#4a4a4a';
ctx.fillRect(x + w - 10, y + h * 0.3, 6, h * 0.6);
// stone block lines
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 1;
for (let i = 1; i < 5; i++) {
ctx.beginPath();
ctx.moveTo(x + 4, y + h*0.3 + (h*0.6) * i / 5);
ctx.lineTo(x + w - 4, y + h*0.3 + (h*0.6) * i / 5);
ctx.stroke();
}
// battlements
ctx.fillStyle = '#6a6a6a';
ctx.fillRect(x + 3, y + h*0.2, w - 6, h*0.14);
ctx.fillStyle = '#0a0d10';
for (let i = 0; i < 4; i++) {
ctx.fillRect(x + 4 + i * ((w-8)/4) + 2, y + h*0.2, 3, 4);
}
// conical roof
ctx.fillStyle = '#2a4060';
ctx.beginPath();
ctx.moveTo(x + 2, y + h*0.2);
ctx.lineTo(x + w - 2, y + h*0.2);
ctx.lineTo(x + w/2, y - 4);
ctx.closePath();
ctx.fill();
// window slit
ctx.fillStyle = '#000';
ctx.fillRect(x + w/2 - 1, y + h*0.5, 2, 5);
}
function drawBuildingPreview() {
const def = BUILDING_DEFS[game.placingBuilding];
const mx = game.mouse.x, my = game.mouse.y;
const tx = Math.floor(mx / TILE), ty = Math.floor(my / TILE);
const canPlace = canPlaceBuilding(tx, ty, def);
const sx = tx * TILE - game.camX, sy = ty * TILE - game.camY;
const w = def.tw * TILE, h = def.th * TILE;
ctx.fillStyle = canPlace ? 'rgba(107,193,122,0.25)' : 'rgba(196,68,68,0.35)';
ctx.fillRect(sx, sy, w, h);
ctx.strokeStyle = canPlace ? '#6bc17a' : '#c44';
ctx.lineWidth = 2;
ctx.strokeRect(sx, sy, w, h);
// footprint grid
ctx.strokeStyle = canPlace ? 'rgba(107,193,122,0.5)' : 'rgba(196,68,68,0.5)';
ctx.lineWidth = 1;
for (let i = 1; i < def.tw; i++) {
ctx.beginPath();
ctx.moveTo(sx + i * TILE, sy);
ctx.lineTo(sx + i * TILE, sy + h);
ctx.stroke();
}
for (let i = 1; i < def.th; i++) {
ctx.beginPath();
ctx.moveTo(sx, sy + i * TILE);
ctx.lineTo(sx + w, sy + i * TILE);
ctx.stroke();
}
}
function canPlaceBuilding(tx, ty, def) {
if (tx < 0 || ty < 0 || tx + def.tw > MAP_W || ty + def.th > MAP_H) return false;
// Require explored tiles
for (let y = 0; y < def.th; y++) {
for (let x = 0; x < def.tw; x++) {
if (fog[ty+y][tx+x] === 0) return false;
const t = tiles[ty+y][tx+x];
if (t === TILE_WATER || t === TILE_FOREST || t === TILE_GOLD) return false;
// No overlap with buildings
for (const b of buildings) {
if (!b.alive) continue;
if (tx+x >= b.tx && tx+x < b.tx + b.def.tw &&
ty+y >= b.ty && ty+y < b.ty + b.def.th) return false;
}
// No overlap with units
for (const u of units) {
if (!u.alive) continue;
const ux = Math.floor(u.x / TILE), uy = Math.floor(u.y / TILE);
if (ux === tx+x && uy === ty+y) return false;
}
}
}
return true;
}
function drawHpBar(e) {
let cx, cy, w;
if (e.kind === 'unit') {
cx = e.x - game.camX; cy = e.y - game.camY - 14;
w = 18;
} else {
cx = e.tx * TILE + (e.def.tw * TILE) / 2 - game.camX;
cy = e.ty * TILE - game.camY - 6;
w = e.def.tw * TILE - 6;
}
const hpr = e.hp / e.maxHp;
ctx.fillStyle = 'rgba(0,0,0,0.8)';
ctx.fillRect(cx - w/2, cy, w, 3);
ctx.fillStyle = hpr > 0.5 ? '#6bc17a' : hpr > 0.25 ? '#e8c34a' : '#c44';
ctx.fillRect(cx - w/2, cy, w * hpr, 3);
}
function drawFog() {
// Build a fog image
const fogW = Math.ceil(game.viewW / 4);
const fogH = Math.ceil(game.viewH / 4);
const imgData = ctx.createImageData(fogW, fogH);
for (let py = 0; py < fogH; py++) {
for (let px = 0; px < fogW; px++) {
const wx = game.camX + px * 4;
const wy = game.camY + py * 4;
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
let a = 255;
if (tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H) {
const f = fog[ty][tx];
if (f === 2) a = 0;
else if (f === 1) a = 120;
else a = 255;
}
const idx = (py * fogW + px) * 4;
imgData.data[idx] = 0;
imgData.data[idx+1] = 0;
imgData.data[idx+2] = 0;
imgData.data[idx+3] = a;
}
}
// Draw via temp canvas scaled up
const tmp = document.createElement('canvas');
tmp.width = fogW; tmp.height = fogH;
tmp.getContext('2d').putImageData(imgData, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(tmp, 0, 0, fogW, fogH, 0, 0, game.viewW, game.viewH);
ctx.imageSmoothingEnabled = false;
}
function renderMinimap() {
mctx.fillStyle = '#050708';
mctx.fillRect(0, 0, minimap.width, minimap.height);
const scale = Math.min(minimap.width / MAP_W, minimap.height / MAP_H);
const offX = (minimap.width - MAP_W * scale) / 2;
const offY = (minimap.height - MAP_H * scale) / 2;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const f = fog[y][x];
if (f === 0) continue;
const t = tiles[y][x];
let col;
if (t === TILE_WATER) col = '#1e4a6e';
else if (t === TILE_FOREST) col = '#2a6a30';
else if (t === TILE_GOLD) col = '#d4a94a';
else if (t === TILE_DIRT) col = '#6a5030';
else col = '#3a6030';
if (f === 1) {
// darker
mctx.fillStyle = col;
mctx.globalAlpha = 0.5;
} else {
mctx.fillStyle = col;
mctx.globalAlpha = 1;
}
mctx.fillRect(offX + x * scale, offY + y * scale, Math.ceil(scale), Math.ceil(scale));
}
}
mctx.globalAlpha = 1;
// Buildings
for (const b of buildings) {
if (!b.alive) continue;
const tx = Math.floor(b.x / TILE), ty = Math.floor(b.y / TILE);
if (!fog[ty] || fog[ty][tx] === 0) continue;
mctx.fillStyle = '#6bc17a';
mctx.fillRect(offX + b.tx * scale, offY + b.ty * scale, Math.max(2, b.def.tw * scale), Math.max(2, b.def.th * scale));
}
// Units
for (const u of units) {
if (!u.alive) continue;
const tx = Math.floor(u.x / TILE), ty = Math.floor(u.y / TILE);
if (!fog[ty] || fog[ty][tx] === 0) continue;
mctx.fillStyle = '#6bc17a';
mctx.fillRect(offX + tx * scale, offY + ty * scale, Math.max(1, scale * 0.8), Math.max(1, scale * 0.8));
}
// Camera box
mctx.strokeStyle = '#fff';
mctx.lineWidth = 1;
mctx.strokeRect(
offX + (game.camX / TILE) * scale,
offY + (game.camY / TILE) * scale,
(game.viewW / TILE) * scale,
(game.viewH / TILE) * scale
);
}
// -------- Input handling --------
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
game.mouse.screenX = e.clientX - rect.left;
game.mouse.screenY = e.clientY - rect.top;
game.mouse.x = game.mouse.screenX + game.camX;
game.mouse.y = game.mouse.screenY + game.camY;
if (game.mouse.dragStart) {
const box = document.getElementById('selBox');
const x1 = Math.min(game.mouse.dragStart.sx, game.mouse.screenX);
const y1 = Math.min(game.mouse.dragStart.sy, game.mouse.screenY);
const x2 = Math.max(game.mouse.dragStart.sx, game.mouse.screenX);
const y2 = Math.max(game.mouse.dragStart.sy, game.mouse.screenY);
if (x2 - x1 > 4 || y2 - y1 > 4) {
box.style.display = 'block';
box.style.left = x1 + 'px';
box.style.top = y1 + 'px';
box.style.width = (x2 - x1) + 'px';
box.style.height = (y2 - y1) + 'px';
}
}
});
canvas.addEventListener('mousedown', e => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const wx = sx + game.camX;
const wy = sy + game.camY;
if (e.button === 2) {
// Right click
if (game.placingBuilding) {
cancelPlacement();
return;
}
issueRightClick(wx, wy);
return;
}
// Left click
if (game.placingBuilding) {
tryPlaceBuilding(wx, wy);
return;
}
game.mouse.dragStart = { sx, sy, wx, wy };
});
canvas.addEventListener('mouseup', e => {
if (e.button !== 0) return;
if (!game.mouse.dragStart) return;
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const wx = sx + game.camX;
const wy = sy + game.camY;
const dragDist = Math.hypot(sx - game.mouse.dragStart.sx, sy - game.mouse.dragStart.sy);
document.getElementById('selBox').style.display = 'none';
if (dragDist < 5) {
// Single click select
const clicked = entityAt(wx, wy);
if (clicked) {
if (e.shiftKey && clicked.kind === 'unit') {
if (game.selected.includes(clicked)) {
game.selected = game.selected.filter(s => s !== clicked);
} else if (game.selected[0]?.kind === 'unit') {
game.selected.push(clicked);
} else {
game.selected = [clicked];
}
} else {
game.selected = [clicked];
}
} else {
game.selected = [];
}
} else {
// Box select - only units
const x1 = Math.min(game.mouse.dragStart.wx, wx);
const y1 = Math.min(game.mouse.dragStart.wy, wy);
const x2 = Math.max(game.mouse.dragStart.wx, wx);
const y2 = Math.max(game.mouse.dragStart.wy, wy);
const picked = units.filter(u => u.alive && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2);
if (picked.length > 0) {
game.selected = picked;
} else {
game.selected = [];
}
}
game.mouse.dragStart = null;
updateUI();
});
function entityAt(wx, wy) {
// units first (closer)
let best = null, bd = Infinity;
for (const u of units) {
if (!u.alive) continue;
const d = Math.hypot(u.x - wx, u.y - wy);
if (d < u.radius + 2 && d < bd) { bd = d; best = u; }
}
if (best) return best;
// buildings
for (const b of buildings) {
if (!b.alive) continue;
const bx0 = b.tx * TILE, by0 = b.ty * TILE;
const bx1 = bx0 + b.def.tw * TILE, by1 = by0 + b.def.th * TILE;
if (wx >= bx0 && wx < bx1 && wy >= by0 && wy < by1) return b;
}
return null;
}
function issueRightClick(wx, wy) {
if (game.selected.length === 0) return;
// Check what's at the target
const target = entityAt(wx, wy);
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
const tileType = (tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H) ? tiles[ty][tx] : -1;
// Buildings as selected => set rally point (only for single building)
if (game.selected.length === 1 && game.selected[0].kind === 'building') {
const b = game.selected[0];
if (b.complete && b.def.trains) {
b.rallyPoint = { x: wx, y: wy };
showMsg('Rally point set');
}
return;
}
// Units
const selUnits = game.selected.filter(s => s.kind === 'unit' && s.alive);
if (selUnits.length === 0) return;
// If target is a resource tile and workers are selected, gather
if ((tileType === TILE_GOLD || tileType === TILE_FOREST) && tileRes[ty][tx] > 0) {
for (const u of selUnits) {
if (u.def.canGather) {
u.state = 'gathering';
u.target = { kind: 'tile', tx, ty };
u.gatherType = (tileType === TILE_GOLD) ? 'gold' : 'wood';
u.targetPos = null;
} else {
// soldiers etc just move
spreadMove(u, selUnits.indexOf(u), selUnits.length, wx, wy);
}
}
pulseCursor(wx, wy, '#e8c34a');
return;
}
// If target is a dropoff building and worker has carry, go there
if (target && target.kind === 'building' && target.def.dropoff && target.complete) {
for (const u of selUnits) {
if (u.def.canGather && u.carry > 0) {
u.state = 'returning';
} else {
spreadMove(u, selUnits.indexOf(u), selUnits.length, target.x, target.y);
}
}
pulseCursor(wx, wy, '#6bc17a');
return;
}
// If target is an incomplete building, workers help build
if (target && target.kind === 'building' && !target.complete) {
for (const u of selUnits) {
if (u.def.canBuild) {
u.state = 'building';
u.buildTarget = target;
} else {
spreadMove(u, selUnits.indexOf(u), selUnits.length, target.x, target.y);
}
}
pulseCursor(wx, wy, '#6bc17a');
return;
}
// Otherwise, spread-move to position
selUnits.forEach((u, i) => spreadMove(u, i, selUnits.length, wx, wy));
pulseCursor(wx, wy, '#6bc17a');
}
function spreadMove(u, index, total, wx, wy) {
// spread in a circle/grid around the target point
const cols = Math.ceil(Math.sqrt(total));
const spacing = 24;
const col = index % cols, row = Math.floor(index / cols);
const ox = (col - (cols - 1) / 2) * spacing;
const oy = (row - (cols - 1) / 2) * spacing;
u.state = 'moving';
u.targetPos = { x: wx + ox, y: wy + oy };
u.target = null;
u.buildTarget = null;
}
function pulseCursor(wx, wy, color) {
effects.push({ x: wx, y: wy, type: 'hit', life: 0.4, maxLife: 0.4, color });
}
// Minimap click
minimap.addEventListener('mousedown', e => {
const rect = minimap.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const scale = Math.min(minimap.width / MAP_W, minimap.height / MAP_H);
const offX = (minimap.width - MAP_W * scale) / 2;
const offY = (minimap.height - MAP_H * scale) / 2;
const tx = (mx - offX) / scale;
const ty = (my - offY) / scale;
centerOn(tx * TILE, ty * TILE);
});
// Keyboard
const keys = {};
window.addEventListener('keydown', e => {
keys[e.key] = true;
if (e.key === 'Escape') {
cancelPlacement();
game.selected = [];
updateUI();
}
});
window.addEventListener('keyup', e => { keys[e.key] = false; });
function handleCameraScroll(dt) {
const speed = 600;
if (keys['ArrowLeft'] || keys['a']) game.camX -= speed * dt;
if (keys['ArrowRight'] || keys['d']) game.camX += speed * dt;
if (keys['ArrowUp'] || keys['w']) game.camY -= speed * dt;
if (keys['ArrowDown'] || keys['s']) game.camY += speed * dt;
// Edge scrolling
const EDGE = 20;
const sx = game.mouse.screenX, sy = game.mouse.screenY;
if (sx >= 0 && sx < EDGE) game.camX -= speed * dt;
if (sx > game.viewW - EDGE && sx <= game.viewW) game.camX += speed * dt;
if (sy >= 0 && sy < EDGE) game.camY -= speed * dt;
if (sy > game.viewH - EDGE && sy <= game.viewH) game.camY += speed * dt;
clampCamera();
}
// -------- UI --------
function updateUI() {
document.getElementById('r-gold').textContent = Math.floor(game.gold);
document.getElementById('r-wood').textContent = Math.floor(game.wood);
const foodEl = document.getElementById('r-food');
foodEl.textContent = `${Math.floor(game.food)}/${game.foodCap}`;
foodEl.classList.toggle('full', game.food >= game.foodCap);
// Info panel
const portrait = document.getElementById('portrait');
const nameEl = document.getElementById('sel-name');
const s1 = document.getElementById('sel-stat1');
const s2 = document.getElementById('sel-stat2');
const s3 = document.getElementById('sel-stat3');
const hp = document.getElementById('sel-hp');
const multi = document.getElementById('multi-list');
multi.innerHTML = '';
if (game.selected.length === 0) {
portrait.textContent = '—';
portrait.style.fontSize = '24px';
nameEl.textContent = 'Nothing selected';
s1.textContent = 'Select a unit or building.';
s2.textContent = '';
s3.textContent = '';
hp.style.display = 'none';
} else if (game.selected.length === 1) {
const e = game.selected[0];
portrait.style.fontSize = '40px';
portrait.textContent = e.def.icon || '?';
nameEl.textContent = e.def.name.toUpperCase();
if (e.kind === 'unit') {
s1.innerHTML = `HP: <b>${Math.ceil(e.hp)} / ${e.maxHp}</b> • DMG: <b>${e.def.damage}</b>`;
s2.innerHTML = `State: <b>${e.state}</b>${e.carry > 0 ? ` • Carrying: <b>${e.carry} ${e.carryType}</b>` : ''}`;
s3.innerHTML = `<i>${e.def.desc}</i>`;
} else {
if (!e.complete) {
s1.innerHTML = `Constructing: <b>${Math.floor((e.buildProgress / e.def.buildTime) * 100)}%</b>`;
s2.innerHTML = `HP: <b>${Math.ceil(e.hp)} / ${e.maxHp}</b>`;
s3.innerHTML = `<i>${e.def.desc}</i>`;
} else {
s1.innerHTML = `HP: <b>${Math.ceil(e.hp)} / ${e.maxHp}</b>`;
let q = '';
if (e.buildQueue.length > 0) {
q = `Training: <b>${UNIT_DEFS[e.buildQueue[0]].name}</b> (${Math.floor((e.trainProgress / UNIT_DEFS[e.buildQueue[0]].buildTime) * 100)}%)${e.buildQueue.length > 1 ? ` +${e.buildQueue.length - 1}` : ''}`;
} else if (e.def.trains) {
q = 'Idle';
}
s2.innerHTML = q;
s3.innerHTML = `<i>${e.def.desc}</i>`;
}
}
hp.style.display = 'block';
hp.children[0].style.width = (e.hp / e.maxHp * 100) + '%';
} else {
portrait.style.fontSize = '24px';
portrait.textContent = game.selected.length;
nameEl.textContent = `${game.selected.length} UNITS`;
const counts = {};
for (const s of game.selected) {
counts[s.def.name] = (counts[s.def.name] || 0) + 1;
}
s1.textContent = Object.entries(counts).map(([k,v]) => `${v} × ${k}`).join(', ');
s2.textContent = '';
s3.innerHTML = '<i>Right-click to move, gather, or build.</i>';
hp.style.display = 'none';
// mini-list
for (const s of game.selected) {
const el = document.createElement('div');
el.className = 'mini';
el.textContent = s.def.icon;
el.style.fontSize = '16px';
const bar = document.createElement('div');
bar.className = 'bar';
bar.style.width = (s.hp / s.maxHp * 100) + '%';
el.appendChild(bar);
el.onclick = (ev) => {
if (ev.shiftKey) game.selected = game.selected.filter(x => x !== s);
else game.selected = [s];
updateUI();
};
multi.appendChild(el);
}
}
// Actions
renderActions();
}
function renderActions() {
const act = document.getElementById('actions');
// Mixed selection — only use first unit's context if all units, or show nothing
const first = game.selected[0];
const anyWorker = game.selected.some(s => s.kind === 'unit' && s.type === 'worker');
const haveWorker = units.some(u => u.alive && u.type === 'worker');
// Build a state signature so we only rebuild DOM when something meaningful changes.
let mode = 'empty';
if (game.selected.length === 0) {
mode = haveWorker ? 'workers-global' : 'empty';
} else if (first.kind === 'building' && game.selected.length === 1) {
mode = first.complete ? `building:${first.type}` : 'building-construction';
} else if (anyWorker) {
mode = 'workers';
} else {
mode = 'units';
}
const affordSig = `${Math.floor(game.gold/10)}|${Math.floor(game.wood/10)}|${game.placingBuilding}`;
const sig = `${mode}|${affordSig}|${game.selected.length}`;
if (act.dataset.sig === sig) return; // nothing changed visually
act.dataset.sig = sig;
act.innerHTML = '';
if (mode === 'empty') {
const hint = document.createElement('div');
hint.style.cssText = 'grid-column: 1 / -1; color: var(--text-dim); font-size: 11px; padding: 8px; font-style: italic;';
hint.textContent = 'Select a unit or building to see available commands.';
act.appendChild(hint);
return;
}
if (mode.startsWith('building:')) {
const b = first;
if (b.def.trains) {
for (const ut of b.def.trains) {
const def = UNIT_DEFS[ut];
addBtn(def.icon, def.name, def, () => trainUnit(b, ut));
}
}
return;
}
if (mode === 'building-construction') return;
// workers (selected) OR workers-global (none selected but workers exist)
if (mode === 'workers' || mode === 'workers-global') {
const builds = [
['townhall', BUILDING_DEFS.townhall],
['farm', BUILDING_DEFS.farm],
['barracks', BUILDING_DEFS.barracks],
['stable', BUILDING_DEFS.stable],
['tower', BUILDING_DEFS.tower],
];
for (const [key, def] of builds) {
addBtn(def.icon, 'Build ' + def.name, def, () => startPlacement(key), key === game.placingBuilding);
}
addBtn('✋', 'Stop', null, () => stopSelected());
if (mode === 'workers-global') {
const hint = document.createElement('div');
hint.style.cssText = 'grid-column: 1 / -1; color: var(--text-dim); font-size: 10px; padding: 4px 2px 0; font-style: italic; text-align:center;';
hint.textContent = 'The nearest idle Worker will respond.';
act.appendChild(hint);
}
return;
}
// non-worker units selected
addBtn('✋', 'Stop', null, () => stopSelected());
}
function addBtn(icon, label, def, onClick, active = false) {
const act = document.getElementById('actions');
const btn = document.createElement('div');
btn.className = 'btn';
if (active) btn.classList.add('building-placing');
// Cost check
let canAfford = true;
let costHtml = '';
if (def) {
const gold = def.costGold || 0;
const wood = def.costWood || 0;
const food = def.costFood || 0;
if (game.gold < gold || game.wood < wood) canAfford = false;
const parts = [];
if (gold) parts.push(`<span class="${game.gold < gold ? 'insuf' : ''}">${gold}g</span>`);
if (wood) parts.push(`<span class="${game.wood < wood ? 'insuf' : ''}">${wood}w</span>`);
if (food) parts.push(`${food}f`);
costHtml = `<div class="cost">${parts.join(' ')}</div>`;
}
btn.innerHTML = `<div class="ico">${icon}</div><div class="lbl">${label}</div>${costHtml}`;
btn.title = def ? def.desc || '' : '';
if (def && !canAfford) btn.classList.add('disabled');
btn.onclick = () => {
if (btn.classList.contains('disabled')) { showMsg('Not enough resources.'); return; }
onClick();
};
act.appendChild(btn);
}
function stopSelected() {
for (const s of game.selected) {
if (s.kind !== 'unit') continue;
s.state = 'idle';
s.target = null;
s.targetPos = null;
s.buildTarget = null;
}
}
function trainUnit(b, type) {
const def = UNIT_DEFS[type];
if (game.gold < def.costGold || game.wood < def.costWood) {
showMsg('Not enough resources.');
return;
}
if (game.food + def.costFood > game.foodCap) {
// Check all queued as well
let queuedFood = 0;
for (const q of b.buildQueue) queuedFood += UNIT_DEFS[q].costFood;
if (game.food + queuedFood + def.costFood > game.foodCap) {
showMsg('Not enough food. Build a farm.');
return;
}
}
game.gold -= def.costGold;
game.wood -= def.costWood;
b.buildQueue.push(type);
showMsg(`Training ${def.name}...`);
updateUI();
}
function startPlacement(key) {
const def = BUILDING_DEFS[key];
if (game.gold < def.costGold || game.wood < def.costWood) {
showMsg('Not enough resources.');
return;
}
game.placingBuilding = key;
const act = document.getElementById('actions');
if (act) act.dataset.sig = ''; // force re-render to show active highlight
document.getElementById('placing-hint').style.display = 'block';
document.getElementById('placing-hint').textContent =
`Placing ${def.name} — Left-click to build • Right-click or ESC to cancel`;
updateUI();
}
function cancelPlacement() {
if (!game.placingBuilding) return;
game.placingBuilding = null;
const act = document.getElementById('actions');
if (act) act.dataset.sig = '';
document.getElementById('placing-hint').style.display = 'none';
updateUI();
}
function tryPlaceBuilding(wx, wy) {
const key = game.placingBuilding;
const def = BUILDING_DEFS[key];
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
if (!canPlaceBuilding(tx, ty, def)) {
showMsg('Cannot build here.');
return;
}
if (game.gold < def.costGold || game.wood < def.costWood) {
showMsg('Not enough resources.');
return;
}
// Pick the worker BEFORE deducting cost, so we can fail gracefully
let workers = game.selected.filter(s => s.kind === 'unit' && s.alive && s.type === 'worker');
if (workers.length === 0) {
// Auto-pick: prefer idle workers, fall back to gathering ones, nearest wins
const all = units.filter(u => u.alive && u.type === 'worker' && u.state !== 'building');
if (all.length === 0) {
showMsg('No worker available.');
return;
}
const score = (u) => {
const d = Math.hypot(u.x - wx, u.y - wy);
const statePenalty = u.state === 'idle' ? 0 : 1000;
return d + statePenalty;
};
workers = [all.reduce((best, u) => score(u) < score(best) ? u : best)];
}
game.gold -= def.costGold;
game.wood -= def.costWood;
const b = createBuilding(key, tx, ty, false);
// Nearest of the candidates
let best = workers[0], bd = Infinity;
for (const w of workers) {
const d = Math.hypot(w.x - b.x, w.y - b.y);
if (d < bd) { bd = d; best = w; }
}
best.state = 'building';
best.buildTarget = b;
best.target = null;
best.targetPos = null;
showMsg(`${def.name} started. Worker en route.`);
cancelPlacement();
}
let msgTimer = 0;
function showMsg(text) {
const el = document.getElementById('msg');
el.textContent = text;
el.classList.add('show');
msgTimer = 2.5;
}
// -------- Game loop --------
function tick(now) {
const dt = Math.min(0.05, (now - game.lastTime) / 1000) || 0.016;
game.lastTime = now;
game.time += dt;
handleCameraScroll(dt);
for (const u of units) updateUnit(u, dt);
for (const b of buildings) updateBuilding(b, dt);
updateProjectiles(dt);
updateEffects(dt);
// periodic fog update
if (Math.floor(game.time * 4) !== Math.floor((game.time - dt) * 4)) {
updateFog();
}
// Cleanup selection
game.selected = game.selected.filter(s => s.alive);
// Msg fade
if (msgTimer > 0) {
msgTimer -= dt;
if (msgTimer <= 0) document.getElementById('msg').classList.remove('show');
}
render();
updateUI();
requestAnimationFrame(tick);
}
// -------- Start --------
function startGame() {
document.getElementById('welcome').style.display = 'none';
document.getElementById('game').style.display = 'grid';
requestAnimationFrame(() => {
resizeCanvas();
initGame();
renderTilesToCache();
game.lastTime = performance.now();
requestAnimationFrame(tick);
});
}
window.addEventListener('resize', () => {
resizeCanvas();
if (tiles.length > 0) {
// tile cache dimensions don't change (world size fixed) so no re-render needed
}
});
// Expose start
window.startGame = startGame;
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment