Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 1, 2026 14:10
Show Gist options
  • Select an option

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

Select an option

Save senko/0957ae90be6cc0307a91a5b4b3c48aa4 to your computer and use it in GitHub Desktop.
RTS game by Sonnet 5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ironhold — RTS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700&display=swap" rel="stylesheet">
<style>
:root{
--panel-bg: rgba(18,16,14,0.92);
--panel-border: #6b5636;
--gold: #e8c44a;
--wood: #b5895a;
--accent: #7fd858;
--danger: #e05a4e;
}
*{box-sizing:border-box;}
html,body{
margin:0; padding:0; width:100%; height:100%; overflow:hidden;
background:#0a0a0a; font-family:'Trebuchet MS', Verdana, sans-serif;
-webkit-user-select:none; user-select:none;
}
#gameCanvas{
position:fixed; top:0; left:0; display:block; background:#000; cursor:default;
}
#gameCanvas.placing{ cursor:crosshair; }
.panel{
position:fixed; background:var(--panel-bg); border:2px solid var(--panel-border);
border-radius:8px; color:#eee; font-size:13px; z-index:20;
box-shadow:0 4px 14px rgba(0,0,0,0.6);
}
#hudTop{
top:10px; left:50%; transform:translateX(-50%);
display:flex; align-items:center; gap:18px; padding:8px 18px;
}
#hudTop .stat{ display:flex; align-items:center; gap:6px; font-size:15px; font-weight:bold; white-space:nowrap;}
.icon{ width:16px; height:16px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,0.4); }
.icon.gold{ background:radial-gradient(circle at 35% 35%, #fff2b0, var(--gold)); }
.icon.wood{ background:radial-gradient(circle at 35% 35%, #d8b78e, var(--wood)); border-radius:3px;}
.icon.pop{ background:radial-gradient(circle at 35% 35%, #bfe8ff, #5a9bd8); }
.icon.explore{ background:radial-gradient(circle at 35% 35%, #d8d8d8, #888); }
#titleTag{ font-family:'Cinzel',serif; font-weight:700; color:var(--gold); font-size:16px; letter-spacing:1px; margin-right:6px;}
#helpBtn{
background:#332a1a; color:var(--gold); border:1px solid var(--panel-border); border-radius:50%;
width:24px; height:24px; cursor:pointer; font-weight:bold;
}
#commandPanel{
bottom:10px; left:10px; width:340px; min-height:150px; padding:10px 12px;
display:flex; flex-direction:column; gap:8px;
}
#selectionInfo{ font-size:13px; line-height:1.5; }
#selectionInfo .selTitle{ font-family:'Cinzel',serif; color:var(--gold); font-size:15px; font-weight:700; }
.hpbarMini{ height:6px; background:#3a1e1e; border-radius:3px; overflow:hidden; margin-top:2px; border:1px solid #000;}
.hpbarMini > div{ height:100%; background:var(--accent); }
#actionButtons{ display:flex; flex-wrap:wrap; gap:6px; }
.actBtn{
background:#26221a; border:1px solid var(--panel-border); color:#eee; border-radius:6px;
padding:6px 8px; font-size:12px; cursor:pointer; width:96px; text-align:left; line-height:1.35;
}
.actBtn b{ display:block; color:var(--gold); font-size:12px;}
.actBtn small{ color:#bbb; font-size:10px; }
.actBtn:hover{ background:#3a3324; border-color:var(--gold); }
.actBtn:disabled{ opacity:0.4; cursor:not-allowed; }
.actBtn:disabled:hover{ background:#26221a; border-color:var(--panel-border); }
.queueRow{ display:flex; align-items:center; gap:6px; font-size:11px; margin-top:2px;}
.queueBar{ flex:1; height:8px; background:#222; border-radius:4px; overflow:hidden; border:1px solid #000;}
.queueBar>div{ height:100%; background:#7fb0e0; }
#minimapWrap{
bottom:10px; right:10px; padding:6px; z-index:20;
}
#minimapCanvas{ display:block; image-rendering:pixelated; cursor:pointer; border-radius:4px;}
#helpOverlay{
top:50%; left:50%; transform:translate(-50%,-50%); width:420px; padding:18px 20px; z-index:40;
}
#helpOverlay h2{ font-family:'Cinzel',serif; color:var(--gold); margin:0 0 10px 0; font-size:18px;}
#helpOverlay ul{ margin:0; padding-left:18px; font-size:13px; line-height:1.7; }
#helpOverlay .closeBtn{
margin-top:14px; background:#332a1a; color:var(--gold); border:1px solid var(--panel-border);
border-radius:6px; padding:6px 14px; cursor:pointer; font-weight:bold;
}
.hidden{ display:none !important; }
#banner{
top:80px; left:50%; transform:translateX(-50%); z-index:30; padding:12px 24px;
font-family:'Cinzel',serif; color:var(--gold); font-size:18px; font-weight:700;
border-color:var(--gold); text-align:center; pointer-events:none;
}
#placementIndicator{
top:64px; left:50%; transform:translateX(-50%); z-index:25; padding:8px 18px;
color:var(--accent); border-color:var(--accent); font-weight:bold; font-size:13px;
pointer-events:none; text-align:center; white-space:nowrap;
}
#startOverlay{
position:fixed; inset:0; background:rgba(6,5,4,0.95); z-index:50;
display:flex; align-items:center; justify-content:center; flex-direction:column; color:#eee;
text-align:center; padding:20px;
}
#startOverlay h1{ font-family:'Cinzel',serif; color:var(--gold); font-size:38px; margin:0 0 6px 0; letter-spacing:2px;}
#startOverlay p{ max-width:520px; font-size:14px; line-height:1.6; color:#ccc; }
#startOverlay button{
margin-top:18px; background:#332a1a; color:var(--gold); border:2px solid var(--gold);
border-radius:8px; padding:12px 30px; font-size:16px; cursor:pointer; font-family:'Cinzel',serif; font-weight:700;
}
#startOverlay button:hover{ background:#4a3d24; }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hudTop" class="panel">
<span id="titleTag">IRONHOLD</span>
<div class="stat"><span class="icon gold"></span><span id="goldVal">0</span></div>
<div class="stat"><span class="icon wood"></span><span id="woodVal">0</span></div>
<div class="stat"><span class="icon pop"></span><span id="popVal">0/0</span></div>
<div class="stat"><span class="icon explore"></span><span id="exploreVal">0%</span></div>
<button id="helpBtn" title="Help">?</button>
</div>
<div id="commandPanel" class="panel">
<div id="selectionInfo"><i style="color:#999;">Nothing selected</i></div>
<div id="actionButtons"></div>
</div>
<div id="minimapWrap" class="panel">
<canvas id="minimapCanvas"></canvas>
</div>
<div id="banner" class="panel hidden"></div>
<div id="placementIndicator" class="panel hidden"></div>
<div id="helpOverlay" class="panel hidden">
<h2>How to Play</h2>
<ul>
<li><b>Left click</b> a unit/building to select it, or <b>drag</b> to box-select units.</li>
<li><b>Right click</b> ground to move, a tree/mine to gather, or an enemy to attack.</li>
<li>Select a <b>Worker</b> and click a Build button, then click on the map to place it.</li>
<li>Select a completed <b>building</b> to train units from it.</li>
<li>Scroll with <b>WASD / arrow keys</b> or by moving the mouse to the screen edge.</li>
<li>Click the <b>minimap</b> to jump the camera anywhere you've explored.</li>
<li>Explore the whole map, gather Gold &amp; Wood, and clear out hostile creeps!</li>
</ul>
<button class="closeBtn" id="helpCloseBtn">Got it</button>
</div>
<div id="startOverlay">
<h1>IRONHOLD</h1>
<p>Build your stronghold, gather gold and wood, train workers and soldiers, and explore
every corner of the map. Hostile creeps roam the wilds — build an army to deal with them.</p>
<button id="startBtn">Begin</button>
</div>
<script>
'use strict';
/* =========================================================================================
CONFIG
========================================================================================= */
const TILE = 32;
const MAP_W = 60, MAP_H = 42;
const TILE_GRASS = 0, TILE_WATER = 1, TILE_ROCK = 2;
const GATHER_TICK = 0.4; // seconds per harvest tick
const GATHER_PER_TICK = 2; // resource units per tick
const WORKER_CAPACITY = 10;
const BUILDING_DEFS = {
townhall: { name:'Town Hall', w:3,h:3, hp:1500, cost:{gold:400,wood:200}, buildTime:55, trains:['worker'], providesPop:10, vision:7, dropoff:true, buildable:true },
barracks: { name:'Barracks', w:3,h:2, hp:800, cost:{gold:120,wood:80}, buildTime:24, trains:['soldier','archer'], vision:5, buildable:true },
farm: { name:'Farm', w:2,h:2, hp:400, cost:{gold:30,wood:90}, buildTime:14, trains:[], providesPop:6, vision:4, buildable:true },
tower: { name:'Watch Tower', w:2,h:2, hp:550, cost:{gold:110,wood:40}, buildTime:20, trains:[], vision:7, attack:{damage:16,range:5,cooldown:0.9}, buildable:true },
};
// the starting town hall (index 0) is placed free, not counted as "buildable" cost-triggered
const UNIT_DEFS = {
worker: { name:'Worker', hp:40, cost:{gold:50,wood:0}, buildTime:8, speed:2.6, vision:4, radius:0.32, popCost:1, canGather:true, canBuild:true },
soldier: { name:'Soldier', hp:120, cost:{gold:80,wood:20}, buildTime:16, speed:2.2, vision:5, radius:0.38, popCost:2, attack:{damage:13,range:0.9,cooldown:0.85} },
archer: { name:'Archer', hp:70, cost:{gold:70,wood:40}, buildTime:16, speed:2.3, vision:6, radius:0.36, popCost:2, attack:{damage:9,range:4.2,cooldown:0.75} },
};
const CREEP_DEFS = {
wolf: { name:'Wolf', hp:60, speed:2.4, radius:0.36, attack:{damage:7,range:0.9,cooldown:0.9}, aggro:4.2, leash:9 },
bandit: { name:'Bandit', hp:95, speed:1.9, radius:0.4, attack:{damage:11,range:0.9,cooldown:1.0}, aggro:5, leash:10 },
};
const START_GOLD = 250, START_WOOD = 150;
/* =========================================================================================
GLOBAL STATE
========================================================================================= */
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const minimapCanvas = document.getElementById('minimapCanvas');
const mctx = minimapCanvas.getContext('2d');
let dpr = Math.max(1, window.devicePixelRatio || 1);
const world = {
tiles: [], // [ty][tx] = {type, shade}
resourceNodes: [], // gold / wood nodes
buildings: [],
units: [],
creeps: [],
particles: [],
};
const player = { gold: START_GOLD, wood: START_WOOD, popCurrent: 0, popMax: 0 };
let fog = null; // Uint8Array MAP_W*MAP_H 0 unexplored, 1 explored, 2 visible
let exploredPct = 0;
let bannerShown = false;
const camera = { x: 0, y: 0 };
const keys = {};
const mouse = { x:0, y:0, down:false, dragStart:null, dragging:false, button:null };
let selection = []; // array of unit/creep refs currently selected (units only, or a single creep)
let selectedBuilding = null;
let placement = null; // { key, def }
let idSeq = 1;
function nextId(){ return idSeq++; }
let lastTime = 0;
let gameStarted = false;
/* =========================================================================================
UTILITY
========================================================================================= */
function clamp(v,min,max){ return v<min?min:(v>max?max:v); }
function dist(a,b){ return Math.hypot(a.x-b.x, a.y-b.y); }
function inBounds(tx,ty){ return tx>=0 && ty>=0 && tx<MAP_W && ty<MAP_H; }
function tileIndex(tx,ty){ return ty*MAP_W+tx; }
function tileAt(tx,ty){ return inBounds(tx,ty) ? world.tiles[ty][tx] : null; }
function rand(min,max){ return min + Math.random()*(max-min); }
function randInt(min,max){ return Math.floor(rand(min,max+1)); }
function choice(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
/* =========================================================================================
MAP GENERATION
========================================================================================= */
const SPAWN_TX = 7, SPAWN_TY = 7;
function genTiles(){
for(let y=0;y<MAP_H;y++){
const row = [];
for(let x=0;x<MAP_W;x++){
row.push({ type: TILE_GRASS, shade: Math.random() });
}
world.tiles.push(row);
}
// lakes
const lakes = randInt(3,5);
for(let i=0;i<lakes;i++){
const cx = randInt(4, MAP_W-5), cy = randInt(4, MAP_H-5);
const r = rand(3.5,7);
stampBlob(cx,cy,r,TILE_WATER);
}
// rocky clusters
const rocks = randInt(4,6);
for(let i=0;i<rocks;i++){
const cx = randInt(4, MAP_W-5), cy = randInt(4, MAP_H-5);
const r = rand(2.5,5);
stampBlob(cx,cy,r,TILE_ROCK);
}
// clear spawn area
for(let y=SPAWN_TY-5;y<=SPAWN_TY+6;y++){
for(let x=SPAWN_TX-5;x<=SPAWN_TX+7;x++){
const t = tileAt(x,y);
if(t) t.type = TILE_GRASS;
}
}
}
function stampBlob(cx,cy,r,type){
const r2 = r*r;
for(let y=Math.floor(cy-r); y<=Math.ceil(cy+r); y++){
for(let x=Math.floor(cx-r); x<=Math.ceil(cx+r); x++){
if(!inBounds(x,y)) continue;
const dx=x-cx, dy=y-cy;
const d2 = dx*dx+dy*dy;
if(d2 <= r2 * rand(0.7,1.05)){
world.tiles[y][x].type = type;
}
}
}
}
function footprintFree(tx,ty,w,h){
for(let y=ty;y<ty+h;y++){
for(let x=tx;x<tx+w;x++){
if(!inBounds(x,y)) return false;
const t = world.tiles[y][x];
if(t.type !== TILE_GRASS) return false;
if(occupiedTile(x,y)) return false;
}
}
return true;
}
function occupiedTile(tx,ty){
for(const b of world.buildings){
if(tx>=b.tx && tx<b.tx+b.w && ty>=b.ty && ty<b.ty+b.h) return true;
}
for(const n of world.resourceNodes){
if(tx>=n.tx && tx<n.tx+n.w && ty>=n.ty && ty<n.ty+n.h) return true;
}
return false;
}
function addResourceNode(type, tx, ty, w, h, amount){
const node = {
id: nextId(), type, tx, ty, w, h,
x: tx+w/2, y: ty+h/2,
amount, max: amount, alive:true,
};
world.resourceNodes.push(node);
return node;
}
function placeGoldMine(minD, maxTries){
for(let i=0;i<maxTries;i++){
const tx = randInt(2, MAP_W-4), ty = randInt(2, MAP_H-4);
const d = Math.hypot(tx-SPAWN_TX, ty-SPAWN_TY);
if(d < minD) continue;
if(footprintFree(tx,ty,2,2)){
return addResourceNode('gold', tx, ty, 2, 2, randInt(3000,5000));
}
}
return null;
}
function placeForestPatch(cx, cy, count){
let placed = 0;
let tries = 0;
while(placed < count && tries < count*8){
tries++;
const tx = clamp(Math.round(cx + rand(-3,3)), 0, MAP_W-1);
const ty = clamp(Math.round(cy + rand(-3,3)), 0, MAP_H-1);
if(footprintFree(tx,ty,1,1)){
addResourceNode('wood', tx, ty, 1, 1, randInt(60,110));
placed++;
}
}
}
function genResources(){
// guaranteed nearby forest patches
placeForestPatch(SPAWN_TX+9, SPAWN_TY-2, 8);
placeForestPatch(SPAWN_TX-2, SPAWN_TY+10, 8);
// guaranteed nearby gold mine
placeGoldMine(11, 300) || placeGoldMine(6, 300);
// extra random mines spread further out
const extraMines = randInt(3,5);
for(let i=0;i<extraMines;i++) placeGoldMine(16, 300);
// extra random forest patches
const extraPatches = randInt(8,12);
for(let i=0;i<extraPatches;i++){
const cx = randInt(3, MAP_W-4), cy = randInt(3, MAP_H-4);
if(Math.hypot(cx-SPAWN_TX,cy-SPAWN_TY) < 5) continue;
placeForestPatch(cx, cy, randInt(4,9));
}
}
function genCreeps(){
const count = randInt(11,16);
let placed = 0, tries = 0;
while(placed < count && tries < count*15){
tries++;
const tx = randInt(2, MAP_W-3), ty = randInt(2, MAP_H-3);
if(Math.hypot(tx-SPAWN_TX,ty-SPAWN_TY) < 15) continue;
const t = tileAt(tx,ty);
if(!t || t.type !== TILE_GRASS) continue;
if(occupiedTile(tx,ty)) continue;
const kind = Math.random() < 0.6 ? 'wolf' : 'bandit';
spawnCreep(kind, tx+0.5, ty+0.5);
placed++;
}
}
/* =========================================================================================
ENTITY FACTORIES
========================================================================================= */
function placeBuildingInstant(key, tx, ty, constructing){
const def = BUILDING_DEFS[key];
const b = {
id: nextId(), key, owner:'player', tx, ty, w:def.w, h:def.h,
x: tx+def.w/2, y: ty+def.h/2,
hp: constructing ? Math.max(1, Math.round(def.hp*0.1)) : def.hp,
maxHp: def.hp,
constructing: !!constructing,
buildProgress: constructing ? 0 : 1,
buildTime: def.buildTime,
trainQueue: [],
rally: null,
builders: [],
atkCooldown: 0,
target: null,
};
world.buildings.push(b);
if(!constructing && def.providesPop) player.popMax += def.providesPop;
return b;
}
function spawnUnit(key, x, y){
const def = UNIT_DEFS[key];
const u = {
id: nextId(), key, owner:'player', x, y, hp: def.hp, maxHp: def.hp,
speed: def.speed, vision: def.vision, radius: def.radius, popCost: def.popCost,
canGather: !!def.canGather, canBuild: !!def.canBuild, attackDef: def.attack || null,
command: null, state: 'idle', target: null, path: null, pathIndex: 0,
carrying: 0, carryType: null, harvestTimer: 0, atkCooldown: 0, repathAt: 0,
facing: 0,
};
world.units.push(u);
return u;
}
function spawnCreep(key, x, y){
const def = CREEP_DEFS[key];
const c = {
id: nextId(), key, owner:'neutral', x, y, hp: def.hp, maxHp: def.hp,
speed: def.speed, radius: def.radius, attackDef: def.attack,
aggro: def.aggro, leash: def.leash, home: {x,y},
target: null, path: null, pathIndex: 0, atkCooldown: 0, repathAt: 0,
wanderTimer: rand(1,4), facing: 0,
};
world.creeps.push(c);
return c;
}
/* =========================================================================================
PATHFINDING (A*)
========================================================================================= */
class MinHeap{
constructor(){ this.a = []; }
get size(){ return this.a.length; }
push(item, pri){
this.a.push([pri,item]);
let i = this.a.length-1;
while(i>0){
const p = (i-1)>>1;
if(this.a[p][0] <= this.a[i][0]) break;
[this.a[p],this.a[i]] = [this.a[i],this.a[p]];
i = p;
}
}
pop(){
const top = this.a[0];
const last = this.a.pop();
if(this.a.length){
this.a[0] = last;
let i=0;
while(true){
let l=2*i+1, r=2*i+2, s=i;
if(l<this.a.length && this.a[l][0]<this.a[s][0]) s=l;
if(r<this.a.length && this.a[r][0]<this.a[s][0]) s=r;
if(s===i) break;
[this.a[s],this.a[i]]=[this.a[i],this.a[s]];
i=s;
}
}
return top[1];
}
}
function isWalkableTile(tx,ty){
if(!inBounds(tx,ty)) return false;
const t = world.tiles[ty][tx];
if(t.type !== TILE_GRASS) return false;
if(occupiedTile(tx,ty)) return false;
return true;
}
function findNearestWalkable(tx,ty,maxR){
if(isWalkableTile(tx,ty)) return {x:tx,y:ty};
for(let r=1;r<=maxR;r++){
for(let dx=-r;dx<=r;dx++){
for(let dy=-r;dy<=r;dy++){
if(Math.max(Math.abs(dx),Math.abs(dy)) !== r) continue;
const nx=tx+dx, ny=ty+dy;
if(isWalkableTile(nx,ny)) return {x:nx,y:ny};
}
}
}
return null;
}
function findAdjacentToFootprint(tx,ty,w,h,maxR){
const candidates = [];
for(let x=tx-1;x<=tx+w;x++){
candidates.push([x,ty-1]);
candidates.push([x,ty+h]);
}
for(let y=ty;y<ty+h;y++){
candidates.push([tx-1,y]);
candidates.push([tx+w,y]);
}
for(const [x,y] of candidates){
if(isWalkableTile(x,y)) return {x,y};
}
return findNearestWalkable(tx-1,ty-1,maxR||6);
}
function findPath(sx,sy,tx,ty){
const start = {x:Math.floor(sx), y:Math.floor(sy)};
const goal = {x:Math.floor(tx), y:Math.floor(ty)};
if(!inBounds(goal.x,goal.y)) return null;
if(start.x===goal.x && start.y===goal.y) return [{x:goal.x+0.5,y:goal.y+0.5}];
const open = new MinHeap();
const startIdx = tileIndex(start.x,start.y);
const gScore = new Map([[startIdx,0]]);
const cameFrom = new Map();
const closed = new Set();
open.push(start, heuristic(start,goal));
const inOpen = new Set([startIdx]);
let iterations = 0;
const MAX_ITER = 6000;
while(open.size && iterations < MAX_ITER){
iterations++;
const cur = open.pop();
const curIdx = tileIndex(cur.x,cur.y);
inOpen.delete(curIdx);
if(closed.has(curIdx)) continue;
closed.add(curIdx);
if(cur.x===goal.x && cur.y===goal.y){
return reconstructPath(cameFrom, cur);
}
for(const [dx,dy] of NEIGHBORS){
const nx = cur.x+dx, ny = cur.y+dy;
if(!inBounds(nx,ny)) continue;
const walkable = isWalkableTile(nx,ny) || (nx===goal.x && ny===goal.y);
if(!walkable) continue;
if(dx!==0 && dy!==0){
// prevent corner cutting
if(!isWalkableTile(cur.x+dx, cur.y) || !isWalkableTile(cur.x, cur.y+dy)) continue;
}
const nIdx = tileIndex(nx,ny);
if(closed.has(nIdx)) continue;
const cost = (dx!==0 && dy!==0) ? 1.4142 : 1;
const tentative = (gScore.get(curIdx)||0) + cost;
if(tentative < (gScore.has(nIdx) ? gScore.get(nIdx) : Infinity)){
gScore.set(nIdx, tentative);
cameFrom.set(nIdx, cur);
const f = tentative + heuristic({x:nx,y:ny}, goal);
open.push({x:nx,y:ny}, f);
inOpen.add(nIdx);
}
}
}
return null;
}
const NEIGHBORS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
function heuristic(a,b){ return Math.hypot(a.x-b.x, a.y-b.y); }
function reconstructPath(cameFrom, node){
const path = [{x:node.x+0.5, y:node.y+0.5}];
let idx = tileIndex(node.x,node.y);
let cur = node;
while(cameFrom.has(idx)){
cur = cameFrom.get(idx);
idx = tileIndex(cur.x,cur.y);
path.unshift({x:cur.x+0.5, y:cur.y+0.5});
}
return path;
}
function issueMove(entity, tx, ty){
const dest = findNearestWalkable(Math.floor(tx), Math.floor(ty), 8) || {x:Math.floor(tx),y:Math.floor(ty)};
const path = findPath(entity.x, entity.y, dest.x, dest.y);
entity.path = path; entity.pathIndex = 0;
return !!path;
}
/* =========================================================================================
MOVEMENT
========================================================================================= */
function moveAlongPath(entity, dt){
if(!entity.path || entity.pathIndex >= entity.path.length){
entity.path = null;
return true; // arrived / no path
}
const wp = entity.path[entity.pathIndex];
const dx = wp.x - entity.x, dy = wp.y - entity.y;
const d = Math.hypot(dx,dy);
const step = entity.speed * dt;
if(d <= step || d < 0.05){
entity.x = wp.x; entity.y = wp.y;
entity.pathIndex++;
if(entity.pathIndex >= entity.path.length){
entity.path = null;
return true;
}
} else {
entity.facing = Math.atan2(dy,dx);
entity.x += (dx/d) * step;
entity.y += (dy/d) * step;
}
return false;
}
/* =========================================================================================
COMBAT HELPERS
========================================================================================= */
function isAlive(e){ return e && e.hp > 0 && (e.alive !== false); }
function findNearestEnemy(entity, enemyList, radius){
let best = null, bestD = Infinity;
for(const e of enemyList){
if(!isAlive(e)) continue;
const d = dist(entity, e);
if(d <= radius && d < bestD){ best = e; bestD = d; }
}
return best;
}
function tryAttack(attacker, target, dt){
attacker.atkCooldown -= dt;
const rangeOk = dist(attacker, target) <= attacker.attackDef.range + (target.radius||0.5);
if(!rangeOk) return false;
attacker.facing = Math.atan2(target.y-attacker.y, target.x-attacker.x);
if(attacker.atkCooldown <= 0){
target.hp -= attacker.attackDef.damage;
attacker.atkCooldown = attacker.attackDef.cooldown;
spawnFloatText(target.x, target.y-0.3, '-'+attacker.attackDef.damage, '#ff8080');
if(target.hp <= 0) onDeath(target);
}
return true;
}
function chaseAndAttack(attacker, dt, now){
const t = attacker.target;
if(!isAlive(t)){ attacker.target = null; attacker.path = null; return; }
const d = dist(attacker, t);
if(d <= attacker.attackDef.range + (t.radius||0.5)){
attacker.path = null;
tryAttack(attacker, t, dt);
} else {
if(!attacker.path || now >= attacker.repathAt){
attacker.path = findPath(attacker.x, attacker.y, Math.floor(t.x), Math.floor(t.y));
attacker.pathIndex = 0;
attacker.repathAt = now + 0.9;
}
moveAlongPath(attacker, dt);
}
}
function onDeath(entity){
if(entity.hp > 0) entity.hp = 0;
if(world.units.includes(entity)){
player.popCurrent = Math.max(0, player.popCurrent - entity.popCost);
if(selection.includes(entity)) selection = selection.filter(u=>u!==entity);
} else if(world.creeps.includes(entity)){
player.gold += 5;
spawnFloatText(entity.x, entity.y-0.3, '+5', '#e8c44a');
if(selectedCreep===entity) selectedCreep = null;
} else if(world.buildings.includes(entity)){
const def = BUILDING_DEFS[entity.key];
if(!entity.constructing && def.providesPop) player.popMax = Math.max(0, player.popMax - def.providesPop);
if(selectedBuilding===entity) selectedBuilding = null;
}
}
/* =========================================================================================
UNIT UPDATE
========================================================================================= */
function nearestDropoff(x,y){
let best=null, bestD=Infinity;
for(const b of world.buildings){
if(b.owner!=='player' || b.constructing) continue;
if(!BUILDING_DEFS[b.key].dropoff) continue;
const d = Math.hypot(b.x-x,b.y-y);
if(d<bestD){ bestD=d; best=b; }
}
return best;
}
function nearestResourceOfType(type, x, y, excludeId){
let best=null, bestD=Infinity;
for(const n of world.resourceNodes){
if(!n.alive || n.type!==type || n.id===excludeId) continue;
const d = Math.hypot(n.x-x,n.y-y);
if(d<bestD){ bestD=d; best=n; }
}
return best;
}
function updateUnit(u, dt, now){
if(u.hp<=0) return;
if(u.command === 'move'){
if(moveAlongPath(u, dt)) u.command = null;
return;
}
if(u.command === 'gather'){
const node = u.target;
if(u.state === 'moving'){
if(!node || !node.alive){ u.command=null; u.state='idle'; return; }
if(moveAlongPath(u, dt)){ u.state = 'harvesting'; u.harvestTimer = 0; }
return;
}
if(u.state === 'harvesting'){
if(!node || !node.alive || node.amount<=0){
const repl = nearestResourceOfType(node?node.type:u.carryType, u.x, u.y, node?node.id:null);
if(repl){
u.target = repl; u.state='moving';
const adj = findAdjacentToFootprint(repl.tx,repl.ty,repl.w,repl.h,8);
if(adj) issueMove(u, adj.x+0.5, adj.y+0.5);
} else if(u.carrying>0){
const drop = nearestDropoff(u.x,u.y);
if(drop){ u.state='returning'; u.target=drop; const adj=findAdjacentToFootprint(drop.tx,drop.ty,drop.w,drop.h,8); if(adj) issueMove(u,adj.x+0.5,adj.y+0.5); }
else { u.command=null; u.state='idle'; }
} else { u.command=null; u.state='idle'; }
return;
}
u.harvestTimer += dt;
if(u.harvestTimer >= GATHER_TICK){
u.harvestTimer -= GATHER_TICK;
const amt = Math.min(GATHER_PER_TICK, node.amount, WORKER_CAPACITY-u.carrying);
node.amount -= amt; u.carrying += amt; u.carryType = node.type;
if(node.amount<=0) node.alive=false;
}
if(u.carrying >= WORKER_CAPACITY || !node.alive){
const drop = nearestDropoff(u.x,u.y);
if(drop){
u.state='returning'; u.pendingNode = node;
const adj = findAdjacentToFootprint(drop.tx,drop.ty,drop.w,drop.h,8);
if(adj) issueMove(u, adj.x+0.5, adj.y+0.5);
} else { u.command=null; u.state='idle'; }
}
return;
}
if(u.state === 'returning'){
if(moveAlongPath(u, dt)){
if(u.carrying>0){
if(u.carryType==='gold') player.gold += u.carrying; else player.wood += u.carrying;
spawnFloatText(u.x,u.y-0.3, '+'+u.carrying, u.carryType==='gold'?'#e8c44a':'#b5895a');
u.carrying = 0;
}
const backNode = (node && node.alive) ? node : nearestResourceOfType(u.carryType||node?.type, u.x, u.y, null);
if(backNode){
u.target = backNode; u.state='moving';
const adj = findAdjacentToFootprint(backNode.tx,backNode.ty,backNode.w,backNode.h,8);
if(adj) issueMove(u, adj.x+0.5, adj.y+0.5);
} else { u.command=null; u.state='idle'; }
}
return;
}
}
if(u.command === 'build'){
const site = u.target;
if(u.state === 'moving'){
if(!site || site.buildProgress>=1){ u.command=null; u.state='idle'; return; }
if(moveAlongPath(u, dt)){ u.state='building'; if(!site.builders.includes(u)) site.builders.push(u); }
return;
}
if(u.state === 'building'){
if(!site || site.hp<=0){ u.command=null; u.state='idle'; return; }
if(site.buildProgress >= 1){
finishConstruction(site);
u.command=null; u.state='idle';
return;
}
const builderCount = site.builders.filter(b=>isAlive(b)).length || 1;
site.buildProgress += (0.045 * builderCount * dt * (20/site.buildTime));
site.buildProgress = Math.min(1, site.buildProgress);
site.hp = Math.round(site.maxHp * (0.1 + 0.9*site.buildProgress));
if(site.buildProgress >= 1){
finishConstruction(site);
u.command=null; u.state='idle';
}
return;
}
}
if(u.command === 'attack'){
chaseAndAttack(u, dt, now);
if(!u.target) u.command = null;
return;
}
// idle: auto acquire nearby creep if this unit can fight
if(u.attackDef){
const enemy = findNearestEnemy(u, world.creeps, 3.6);
if(enemy){ u.command='attack'; u.target=enemy; }
}
}
function finishConstruction(site){
site.constructing = false;
site.buildProgress = 1;
site.hp = site.maxHp;
const def = BUILDING_DEFS[site.key];
if(def.providesPop) player.popMax += def.providesPop;
site.builders = [];
spawnFloatText(site.x, site.y-0.5, def.name+' complete', '#7fd858');
}
/* =========================================================================================
CREEP UPDATE
========================================================================================= */
function updateCreep(c, dt, now){
if(c.hp<=0) return;
if(c.target && isAlive(c.target)){
const distFromHome = Math.hypot(c.x-c.home.x, c.y-c.home.y);
if(distFromHome > c.leash){
c.target = null; c.path = null;
issueMove(c, c.home.x, c.home.y);
c.state = 'returning';
} else {
chaseAndAttack(c, dt, now);
return;
}
}
if(c.state === 'returning'){
if(moveAlongPath(c, dt)) c.state = 'idle';
// still check aggro while returning
}
const enemy = findNearestEnemy(c, [...world.units], c.aggro);
if(enemy){ c.target = enemy; c.state = 'chasing'; c.path=null; return; }
// wander near home
c.wanderTimer -= dt;
if(c.wanderTimer <= 0 && !c.path){
c.wanderTimer = rand(3,6);
const tx = clamp(Math.round(c.home.x + rand(-2.5,2.5)), 0, MAP_W-1);
const ty = clamp(Math.round(c.home.y + rand(-2.5,2.5)), 0, MAP_H-1);
if(isWalkableTile(tx,ty)) issueMove(c, tx, ty);
}
if(c.path) moveAlongPath(c, dt);
}
/* =========================================================================================
BUILDING UPDATE (training queues + tower attacks)
========================================================================================= */
function updateBuilding(b, dt, now){
if(b.hp<=0) return;
if(!b.constructing && b.trainQueue.length){
const q = b.trainQueue[0];
q.timeLeft -= dt;
if(q.timeLeft <= 0){
spawnTrainedUnit(b, q.key);
b.trainQueue.shift();
}
}
const def = BUILDING_DEFS[b.key];
if(!b.constructing && def.attack){
if(!isAlive(b.target)){
b.target = findNearestEnemy(b, world.creeps, def.attack.range);
}
if(b.target){
b.attackDef = def.attack;
tryAttack(b, b.target, dt);
}
}
}
function spawnTrainedUnit(building, key){
const spot = findAdjacentToFootprint(building.tx, building.ty, building.w, building.h, 6);
const pos = spot || {x:building.tx, y:building.ty+building.h};
const u = spawnUnit(key, pos.x+0.5, pos.y+0.5);
spawnFloatText(u.x, u.y-0.3, UNIT_DEFS[key].name+' ready', '#7fd858');
if(building.rally){
issueMove(u, building.rally.x, building.rally.y);
u.command = 'move';
}
}
/* =========================================================================================
FLOATING TEXT PARTICLES
========================================================================================= */
function spawnFloatText(x,y,text,color){
world.particles.push({ x, y, text, color, life: 1.1, maxLife: 1.1 });
}
function updateParticles(dt){
for(const p of world.particles){ p.life -= dt; p.y -= dt*0.35; }
world.particles = world.particles.filter(p=>p.life>0);
}
/* =========================================================================================
FOG OF WAR
========================================================================================= */
function initFog(){
fog = new Uint8Array(MAP_W*MAP_H);
}
function updateFog(){
const visibleNow = new Uint8Array(MAP_W*MAP_H);
const sources = [];
for(const u of world.units) if(u.hp>0) sources.push({x:u.x,y:u.y,r:UNIT_DEFS[u.key].vision});
for(const b of world.buildings) if(b.hp>0) sources.push({x:b.x,y:b.y,r:BUILDING_DEFS[b.key].vision});
for(const s of sources){
const r = s.r;
const minX = Math.max(0, Math.floor(s.x-r)), maxX = Math.min(MAP_W-1, Math.ceil(s.x+r));
const minY = Math.max(0, Math.floor(s.y-r)), maxY = Math.min(MAP_H-1, Math.ceil(s.y+r));
const r2 = r*r;
for(let y=minY;y<=maxY;y++){
for(let x=minX;x<=maxX;x++){
const dx=x+0.5-s.x, dy=y+0.5-s.y;
if(dx*dx+dy*dy <= r2) visibleNow[tileIndex(x,y)] = 1;
}
}
}
let exploredCount = 0;
for(let i=0;i<fog.length;i++){
if(visibleNow[i]) fog[i] = 2;
else if(fog[i]===2) fog[i] = 1;
if(fog[i] > 0) exploredCount++;
}
exploredPct = Math.round((exploredCount / fog.length) * 100);
if(exploredPct >= 100 && !bannerShown){
bannerShown = true;
showBanner('Map fully explored!');
}
}
function fogAt(tx,ty){ return inBounds(tx,ty) ? fog[tileIndex(tx,ty)] : 0; }
/* =========================================================================================
INPUT
========================================================================================= */
function screenToWorld(px,py){ return { x: px+camera.x, y: py+camera.y }; }
function worldToTileF(wx,wy){ return { x: wx/TILE, y: wy/TILE }; }
function hitTestUnit(wx,wy){
const tf = worldToTileF(wx,wy);
let best=null, bestD=Infinity;
for(const u of world.units){
if(u.hp<=0) continue;
const d = Math.hypot(u.x-tf.x,u.y-tf.y);
if(d <= u.radius+0.12 && d<bestD){ best=u; bestD=d; }
}
return best;
}
function hitTestCreep(wx,wy){
const tf = worldToTileF(wx,wy);
let best=null, bestD=Infinity;
for(const c of world.creeps){
if(c.hp<=0) continue;
if(fogAt(Math.floor(c.x),Math.floor(c.y))!==2) continue;
const d = Math.hypot(c.x-tf.x,c.y-tf.y);
if(d <= c.radius+0.12 && d<bestD){ best=c; bestD=d; }
}
return best;
}
function hitTestBuilding(wx,wy){
const tf = worldToTileF(wx,wy);
for(const b of world.buildings){
if(b.hp<=0) continue;
if(tf.x>=b.tx && tf.x<b.tx+b.w && tf.y>=b.ty && tf.y<b.ty+b.h) return b;
}
return null;
}
function hitTestResource(wx,wy){
const tf = worldToTileF(wx,wy);
for(const n of world.resourceNodes){
if(!n.alive) continue;
if(fogAt(Math.floor(n.x),Math.floor(n.y))!==2) continue;
if(tf.x>=n.tx && tf.x<n.tx+n.w && tf.y>=n.ty && tf.y<n.ty+n.h) return n;
}
return null;
}
canvas.addEventListener('contextmenu', e=>e.preventDefault());
// Tracks whether the *current* press-release gesture began on the canvas itself, as
// opposed to a UI button/panel. The window-level mouseup listener below needs this:
// mouseup bubbles to window regardless of where it originated, so without this guard,
// releasing a click on any command-panel button would also fire a spurious game-world
// click/command at that button's screen position (and could deselect the current
// selection / rebuild the panel out from under the button, silently eating the click).
let gameMouseActive = false;
canvas.addEventListener('mousedown', (e)=>{
if(!gameStarted) return;
const rect = canvas.getBoundingClientRect();
const px = e.clientX-rect.left, py = e.clientY-rect.top;
gameMouseActive = true;
mouse.down = true; mouse.button = e.button;
mouse.dragStart = {x:px,y:py};
mouse.dragging = false;
if(e.button === 0){
if(placement){
tryPlaceBuilding(px,py);
return;
}
}
});
window.addEventListener('mousemove', (e)=>{
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX-rect.left; mouse.y = e.clientY-rect.top;
if(mouse.down && mouse.button===0 && mouse.dragStart){
const d = Math.hypot(mouse.x-mouse.dragStart.x, mouse.y-mouse.dragStart.y);
if(d>4) mouse.dragging = true;
}
});
window.addEventListener('mouseup', (e)=>{
const wasGameMouse = gameMouseActive;
gameMouseActive = false;
if(!gameStarted){ mouse.down=false; return; }
if(!wasGameMouse){ mouse.down=false; mouse.dragging=false; mouse.dragStart=null; return; }
if(e.button===0 && !placement){
const rect = canvas.getBoundingClientRect();
const px = e.clientX-rect.left, py = e.clientY-rect.top;
if(mouse.dragging && mouse.dragStart){
doBoxSelect(mouse.dragStart.x, mouse.dragStart.y, px, py);
} else {
doClickSelect(px,py);
}
} else if(e.button===2){
if(placement){ placement=null; canvas.classList.remove('placing'); }
else {
const rect = canvas.getBoundingClientRect();
const px = e.clientX-rect.left, py = e.clientY-rect.top;
doRightClickCommand(px,py);
}
}
mouse.down=false; mouse.dragging=false; mouse.dragStart=null;
});
window.addEventListener('keydown', (e)=>{
keys[e.key.toLowerCase()] = true;
if(e.key === 'Escape'){
if(placement){ placement=null; canvas.classList.remove('placing'); }
else { selection=[]; selectedBuilding=null; refreshCommandPanel(); }
}
});
window.addEventListener('keyup', (e)=>{ keys[e.key.toLowerCase()] = false; });
function doClickSelect(px,py){
const w = screenToWorld(px,py);
const u = hitTestUnit(w.x,w.y);
if(u){ selection=[u]; selectedBuilding=null; refreshCommandPanel(); return; }
const c = hitTestCreep(w.x,w.y);
if(c){ selection=[]; selectedBuilding=null; selectedCreep=c; refreshCommandPanel(); return; }
selectedCreep = null;
const b = hitTestBuilding(w.x,w.y);
if(b && b.owner==='player'){ selection=[]; selectedBuilding=b; refreshCommandPanel(); return; }
selection=[]; selectedBuilding=null; refreshCommandPanel();
}
let selectedCreep = null;
function doBoxSelect(x1,y1,x2,y2){
const minX=Math.min(x1,x2), maxX=Math.max(x1,x2);
const minY=Math.min(y1,y2), maxY=Math.max(y1,y2);
const found = [];
for(const u of world.units){
if(u.hp<=0) continue;
const sx = u.x*TILE - camera.x, sy = u.y*TILE - camera.y;
if(sx>=minX && sx<=maxX && sy>=minY && sy<=maxY) found.push(u);
}
if(found.length){ selection=found; selectedBuilding=null; selectedCreep=null; }
refreshCommandPanel();
}
function doRightClickCommand(px,py){
const w = screenToWorld(px,py);
const tf = worldToTileF(w.x,w.y);
if(selectedBuilding && selectedBuilding.owner==='player'){
if(!hitTestUnit(w.x,w.y) && !hitTestCreep(w.x,w.y)){
selectedBuilding.rally = {x:tf.x, y:tf.y};
spawnFloatText(tf.x,tf.y,'Rally set','#7fd858');
return;
}
}
if(!selection.length) return;
const resNode = hitTestResource(w.x,w.y);
const creep = hitTestCreep(w.x,w.y);
if(resNode){
for(const u of selection){
if(!u.canGather) continue;
u.command='gather'; u.state='moving'; u.target=resNode;
const adj = findAdjacentToFootprint(resNode.tx,resNode.ty,resNode.w,resNode.h,8);
if(adj) issueMove(u, adj.x+0.5, adj.y+0.5);
}
return;
}
if(creep){
for(const u of selection){
if(!u.attackDef) continue;
u.command='attack'; u.target=creep; u.path=null;
}
return;
}
// plain move
for(const u of selection){
u.command='move'; u.state='idle'; u.target=null;
issueMove(u, tf.x, tf.y);
}
}
/* ---- Building placement ---- */
function startPlacement(key){
placement = { key, def: BUILDING_DEFS[key] };
canvas.classList.add('placing');
}
function tryPlaceBuilding(px,py){
const def = placement.def;
const w = screenToWorld(px,py);
const tx = Math.round(w.x/TILE - def.w/2);
const ty = Math.round(w.y/TILE - def.h/2);
if(!canPlaceAt(tx,ty,def)){
spawnFloatText(tx+def.w/2, ty+def.h/2, 'Cannot build here', '#e05a4e');
return;
}
if(player.gold < def.cost.gold || player.wood < def.cost.wood){
spawnFloatText(tx+def.w/2, ty+def.h/2, 'Not enough resources', '#e05a4e');
return;
}
player.gold -= def.cost.gold; player.wood -= def.cost.wood;
const site = placeBuildingInstant(placement.key, tx, ty, true);
const workers = selection.filter(u=>u.canBuild);
for(const wk of workers){
wk.command='build'; wk.state='moving'; wk.target=site;
const adj = findAdjacentToFootprint(tx,ty,def.w,def.h,8);
if(adj) issueMove(wk, adj.x+0.5, adj.y+0.5);
}
placement = null;
canvas.classList.remove('placing');
}
function canPlaceAt(tx,ty,def){
for(let y=ty;y<ty+def.h;y++){
for(let x=tx;x<tx+def.w;x++){
if(!inBounds(x,y)) return false;
if(fog[tileIndex(x,y)] === 0) return false;
const t = world.tiles[y][x];
if(t.type !== TILE_GRASS) return false;
if(occupiedTile(x,y)) return false;
}
}
return true;
}
/* =========================================================================================
UI: command panel + hud
========================================================================================= */
const selectionInfoEl = document.getElementById('selectionInfo');
const actionButtonsEl = document.getElementById('actionButtons');
// The command panel is rebuilt (innerHTML wiped + buttons recreated) ONLY when the
// selection actually changes shape (tracked via panelSignature). Between real changes,
// liveUpdatePanel() just mutates existing elements (text/width/disabled) in place.
// This matters: recreating a button while the mouse is mid-click on it makes the
// browser drop the click entirely, since mousedown/mouseup no longer target the same node.
let panelSignature = null;
let panelRefs = {};
function computePanelSignature(){
if(selectedBuilding) return 'b:'+selectedBuilding.id+':'+selectedBuilding.constructing;
if(selectedCreep) return 'c:'+selectedCreep.id;
if(selection.length) return 'u:'+selection.map(u=>u.id).join(',');
return 'none';
}
function refreshCommandPanel(){
const sig = computePanelSignature();
if(sig === panelSignature){
liveUpdatePanel();
return;
}
panelSignature = sig;
actionButtonsEl.innerHTML = '';
panelRefs = { mode: null };
if(selectedBuilding){
panelRefs.mode = 'building';
buildBuildingPanel(selectedBuilding);
} else if(selectedCreep){
panelRefs.mode = 'creep';
buildCreepPanel(selectedCreep);
} else if(selection.length){
panelRefs.mode = 'units';
buildUnitsPanel(selection);
} else {
selectionInfoEl.innerHTML = '<i style="color:#999;">Nothing selected</i>';
}
}
function liveUpdatePanel(){
if(panelRefs.mode==='building') liveUpdateBuildingPanel();
else if(panelRefs.mode==='units') liveUpdateUnitsPanel();
else if(panelRefs.mode==='creep') liveUpdateCreepPanel();
}
function buildBuildingPanel(b){
const def = BUILDING_DEFS[b.key];
selectionInfoEl.innerHTML = '';
const title = document.createElement('div');
title.className = 'selTitle';
title.textContent = def.name;
selectionInfoEl.appendChild(title);
const hpWrap = document.createElement('div'); hpWrap.className = 'hpbarMini';
const hpFill = document.createElement('div');
hpWrap.appendChild(hpFill);
selectionInfoEl.appendChild(hpWrap);
const hpText = document.createElement('div');
selectionInfoEl.appendChild(hpText);
panelRefs.building = b;
panelRefs.hpFill = hpFill;
panelRefs.hpText = hpText;
if(b.constructing){
const progText = document.createElement('div');
selectionInfoEl.appendChild(progText);
panelRefs.progText = progText;
liveUpdateBuildingPanel();
return;
}
const trainBtns = [];
for(const key of def.trains){
const udef = UNIT_DEFS[key];
const btn = document.createElement('button');
btn.className = 'actBtn';
btn.innerHTML = `<b>Train ${udef.name}</b><small>${udef.cost.gold}g ${udef.cost.wood}w · pop ${udef.popCost}</small>`;
btn.onclick = () => queueTraining(b, key);
actionButtonsEl.appendChild(btn);
trainBtns.push({ btn, udef });
}
panelRefs.trainBtns = trainBtns;
const queueWrap = document.createElement('div');
queueWrap.style.width = '100%';
queueWrap.style.display = 'none';
const queueRow = document.createElement('div'); queueRow.className = 'queueRow';
const queueLabel = document.createElement('span');
queueRow.appendChild(queueLabel);
const queueBar = document.createElement('div'); queueBar.className = 'queueBar';
const queueFill = document.createElement('div');
queueBar.appendChild(queueFill);
queueWrap.appendChild(queueRow);
queueWrap.appendChild(queueBar);
actionButtonsEl.appendChild(queueWrap);
panelRefs.queueWrap = queueWrap;
panelRefs.queueLabel = queueLabel;
panelRefs.queueFill = queueFill;
liveUpdateBuildingPanel();
}
function liveUpdateBuildingPanel(){
const b = panelRefs.building;
if(!b || b.hp<=0) return;
const pct = Math.max(0, b.hp/b.maxHp*100);
panelRefs.hpFill.style.width = pct+'%';
panelRefs.hpText.textContent = Math.max(0,Math.round(b.hp))+'/'+b.maxHp+' HP';
if(b.constructing){
if(panelRefs.progText) panelRefs.progText.textContent = 'Constructing… '+(b.buildProgress*100).toFixed(0)+'%';
return;
}
if(panelRefs.trainBtns){
for(const { btn, udef } of panelRefs.trainBtns){
const afford = player.gold>=udef.cost.gold && player.wood>=udef.cost.wood;
const popOk = player.popCurrent + udef.popCost <= player.popMax;
btn.disabled = !afford || !popOk || b.trainQueue.length>=5;
}
}
if(b.trainQueue.length){
const q = b.trainQueue[0];
const pct2 = 1 - (q.timeLeft/q.totalTime);
panelRefs.queueWrap.style.display = '';
panelRefs.queueLabel.textContent = `${UNIT_DEFS[q.key].name} (${b.trainQueue.length} queued)`;
panelRefs.queueFill.style.width = (pct2*100).toFixed(0)+'%';
} else {
panelRefs.queueWrap.style.display = 'none';
}
}
function buildCreepPanel(c){
selectionInfoEl.innerHTML = '';
const def = CREEP_DEFS[c.key];
const title = document.createElement('div');
title.className = 'selTitle';
title.innerHTML = `${def.name} <span style="color:#e05a4e;">(hostile)</span>`;
selectionInfoEl.appendChild(title);
const hpWrap = document.createElement('div'); hpWrap.className = 'hpbarMini';
const hpFill = document.createElement('div');
hpWrap.appendChild(hpFill);
selectionInfoEl.appendChild(hpWrap);
const hpText = document.createElement('div');
selectionInfoEl.appendChild(hpText);
panelRefs.creep = c; panelRefs.hpFill = hpFill; panelRefs.hpText = hpText;
liveUpdateCreepPanel();
}
function liveUpdateCreepPanel(){
const c = panelRefs.creep;
if(!c || c.hp<=0) return;
const pct = Math.max(0, c.hp/c.maxHp*100);
panelRefs.hpFill.style.width = pct+'%';
panelRefs.hpText.textContent = Math.max(0,Math.round(c.hp))+'/'+c.maxHp+' HP';
}
function buildUnitsPanel(units){
selectionInfoEl.innerHTML = '';
const title = document.createElement('div');
title.className = 'selTitle';
title.textContent = `${units.length} unit${units.length>1?'s':''} selected`;
selectionInfoEl.appendChild(title);
const counts = {};
for(const u of units) counts[u.key] = (counts[u.key]||0)+1;
const countsDiv = document.createElement('div');
countsDiv.innerHTML = Object.keys(counts).map(k=>`${UNIT_DEFS[k].name} × ${counts[k]}`).join('<br>');
selectionInfoEl.appendChild(countsDiv);
panelRefs.units = units;
panelRefs.hpFill = null; panelRefs.hpText = null;
if(units.length===1){
const hpWrap = document.createElement('div'); hpWrap.className = 'hpbarMini';
const hpFill = document.createElement('div');
hpWrap.appendChild(hpFill);
selectionInfoEl.appendChild(hpWrap);
const hpText = document.createElement('div');
selectionInfoEl.appendChild(hpText);
panelRefs.hpFill = hpFill; panelRefs.hpText = hpText;
}
const hasWorker = units.some(u=>u.canBuild);
const buildBtns = [];
if(hasWorker){
for(const key in BUILDING_DEFS){
const def = BUILDING_DEFS[key];
if(!def.buildable) continue;
const btn = document.createElement('button');
btn.className = 'actBtn';
btn.innerHTML = `<b>Build ${def.name}</b><small>${def.cost.gold}g ${def.cost.wood}w</small>`;
btn.onclick = () => startPlacement(key);
actionButtonsEl.appendChild(btn);
buildBtns.push({ btn, def });
}
}
panelRefs.buildBtns = buildBtns;
const stopBtn = document.createElement('button');
stopBtn.className = 'actBtn';
stopBtn.innerHTML = `<b>Stop</b>`;
stopBtn.onclick = () => { for(const u of units){ u.command=null; u.target=null; u.path=null; u.state='idle'; } };
actionButtonsEl.appendChild(stopBtn);
liveUpdateUnitsPanel();
}
function liveUpdateUnitsPanel(){
if(panelRefs.hpFill && panelRefs.units.length===1){
const u = panelRefs.units[0];
if(u.hp>0){
const pct = Math.max(0, u.hp/u.maxHp*100);
panelRefs.hpFill.style.width = pct+'%';
panelRefs.hpText.textContent = Math.max(0,Math.round(u.hp))+'/'+u.maxHp+' HP';
}
}
for(const { btn, def } of panelRefs.buildBtns){
const afford = player.gold>=def.cost.gold && player.wood>=def.cost.wood;
btn.disabled = !afford;
}
}
function queueTraining(building, key){
const udef = UNIT_DEFS[key];
if(player.gold<udef.cost.gold || player.wood<udef.cost.wood) return;
if(player.popCurrent + udef.popCost > player.popMax) return;
if(building.trainQueue.length>=5) return;
player.gold -= udef.cost.gold; player.wood -= udef.cost.wood;
player.popCurrent += udef.popCost;
building.trainQueue.push({ key, timeLeft: udef.buildTime, totalTime: udef.buildTime });
}
function updateHud(){
document.getElementById('goldVal').textContent = Math.floor(player.gold);
document.getElementById('woodVal').textContent = Math.floor(player.wood);
document.getElementById('popVal').textContent = player.popCurrent+'/'+player.popMax;
document.getElementById('exploreVal').textContent = exploredPct+'%';
}
function showBanner(text){
const el = document.getElementById('banner');
el.textContent = text;
el.classList.remove('hidden');
setTimeout(()=>{ el.classList.add('hidden'); }, 4000);
}
const placementIndicatorEl = document.getElementById('placementIndicator');
function updatePlacementIndicator(){
if(placement){
placementIndicatorEl.textContent = `Placing ${placement.def.name} — click on the map to build (right-click or Esc to cancel)`;
placementIndicatorEl.classList.remove('hidden');
} else {
placementIndicatorEl.classList.add('hidden');
}
}
document.getElementById('helpBtn').onclick = ()=> document.getElementById('helpOverlay').classList.remove('hidden');
document.getElementById('helpCloseBtn').onclick = ()=> document.getElementById('helpOverlay').classList.add('hidden');
document.getElementById('startBtn').onclick = ()=>{
document.getElementById('startOverlay').style.display='none';
gameStarted = true;
};
/* =========================================================================================
CAMERA
========================================================================================= */
function updateCamera(dt){
const speed = 620 * dt;
const edge = 18;
let dx=0, dy=0;
if(keys['arrowleft']||keys['a']) dx -= 1;
if(keys['arrowright']||keys['d']) dx += 1;
if(keys['arrowup']||keys['w']) dy -= 1;
if(keys['arrowdown']||keys['s']) dy += 1;
if(mouse.x <= edge) dx -= 1;
if(mouse.x >= canvas.clientWidth-edge) dx += 1;
if(mouse.y <= edge) dy -= 1;
if(mouse.y >= canvas.clientHeight-edge) dy += 1;
if(dx||dy){
const len = Math.hypot(dx,dy)||1;
camera.x += (dx/len)*speed;
camera.y += (dy/len)*speed;
}
const maxX = Math.max(0, MAP_W*TILE - canvas.clientWidth);
const maxY = Math.max(0, MAP_H*TILE - canvas.clientHeight);
camera.x = clamp(camera.x, 0, maxX);
camera.y = clamp(camera.y, 0, maxY);
}
/* =========================================================================================
MINIMAP
========================================================================================= */
let minimapTerrain = null; // offscreen canvas
const MM_SCALE = 3;
function buildMinimapTerrain(){
minimapTerrain = document.createElement('canvas');
minimapTerrain.width = MAP_W*MM_SCALE;
minimapTerrain.height = MAP_H*MM_SCALE;
const c2 = minimapTerrain.getContext('2d');
for(let y=0;y<MAP_H;y++){
for(let x=0;x<MAP_W;x++){
const t = world.tiles[y][x];
c2.fillStyle = t.type===TILE_WATER ? '#2b5f86' : (t.type===TILE_ROCK ? '#6b6b63' : '#3f6b34');
c2.fillRect(x*MM_SCALE,y*MM_SCALE,MM_SCALE,MM_SCALE);
}
}
for(const n of world.resourceNodes){
c2.fillStyle = n.type==='gold' ? '#e8c44a' : '#2f7a2f';
c2.fillRect(n.tx*MM_SCALE, n.ty*MM_SCALE, n.w*MM_SCALE, n.h*MM_SCALE);
}
}
function renderMinimap(){
minimapCanvas.width = MAP_W*MM_SCALE;
minimapCanvas.height = MAP_H*MM_SCALE;
mctx.drawImage(minimapTerrain,0,0);
// fog overlay
const img = mctx.getImageData(0,0,minimapCanvas.width,minimapCanvas.height);
for(let y=0;y<MAP_H;y++){
for(let x=0;x<MAP_W;x++){
const f = fog[tileIndex(x,y)];
if(f===2) continue;
const alpha = f===1 ? 0.55 : 1.0;
for(let yy=0;yy<MM_SCALE;yy++){
for(let xx=0;xx<MM_SCALE;xx++){
const px = (x*MM_SCALE+xx), py=(y*MM_SCALE+yy);
const idx = (py*img.width+px)*4;
img.data[idx] = img.data[idx]*(1-alpha);
img.data[idx+1] = img.data[idx+1]*(1-alpha);
img.data[idx+2] = img.data[idx+2]*(1-alpha);
}
}
}
}
mctx.putImageData(img,0,0);
for(const b of world.buildings){
if(b.hp<=0) continue;
mctx.fillStyle = '#7fc1ff';
mctx.fillRect(b.tx*MM_SCALE, b.ty*MM_SCALE, b.w*MM_SCALE, b.h*MM_SCALE);
}
for(const u of world.units){
if(u.hp<=0) continue;
mctx.fillStyle = '#bfe8ff';
mctx.fillRect(u.x*MM_SCALE-1, u.y*MM_SCALE-1, 2,2);
}
for(const c of world.creeps){
if(c.hp<=0) continue;
if(fogAt(Math.floor(c.x),Math.floor(c.y))!==2) continue;
mctx.fillStyle = '#ff5040';
mctx.fillRect(c.x*MM_SCALE-1, c.y*MM_SCALE-1, 2,2);
}
// camera rect
mctx.strokeStyle = '#fff';
mctx.lineWidth = 1;
mctx.strokeRect(
(camera.x/TILE)*MM_SCALE, (camera.y/TILE)*MM_SCALE,
(canvas.clientWidth/TILE)*MM_SCALE, (canvas.clientHeight/TILE)*MM_SCALE
);
}
function minimapToCamera(mx,my){
const tx = mx/MM_SCALE, ty = my/MM_SCALE;
camera.x = clamp(tx*TILE - canvas.clientWidth/2, 0, Math.max(0,MAP_W*TILE-canvas.clientWidth));
camera.y = clamp(ty*TILE - canvas.clientHeight/2, 0, Math.max(0,MAP_H*TILE-canvas.clientHeight));
}
let mmDragging=false;
minimapCanvas.addEventListener('mousedown',(e)=>{
mmDragging=true;
const r = minimapCanvas.getBoundingClientRect();
minimapToCamera((e.clientX-r.left)*(minimapCanvas.width/r.width), (e.clientY-r.top)*(minimapCanvas.height/r.height));
});
window.addEventListener('mousemove',(e)=>{
if(!mmDragging) return;
const r = minimapCanvas.getBoundingClientRect();
minimapToCamera((e.clientX-r.left)*(minimapCanvas.width/r.width), (e.clientY-r.top)*(minimapCanvas.height/r.height));
});
window.addEventListener('mouseup',()=>{ mmDragging=false; });
/* =========================================================================================
RENDERING
========================================================================================= */
function resizeCanvas(){
dpr = Math.max(1, window.devicePixelRatio || 1);
const w = window.innerWidth, h = window.innerHeight;
canvas.width = Math.floor(w*dpr);
canvas.height = Math.floor(h*dpr);
canvas.style.width = w+'px';
canvas.style.height = h+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
}
window.addEventListener('resize', resizeCanvas);
function render(){
const cw = canvas.clientWidth, ch = canvas.clientHeight;
ctx.clearRect(0,0,cw,ch);
ctx.fillStyle = '#000';
ctx.fillRect(0,0,cw,ch);
const minTx = Math.max(0, Math.floor(camera.x/TILE));
const minTy = Math.max(0, Math.floor(camera.y/TILE));
const maxTx = Math.min(MAP_W-1, Math.ceil((camera.x+cw)/TILE));
const maxTy = Math.min(MAP_H-1, Math.ceil((camera.y+ch)/TILE));
// terrain
for(let ty=minTy; ty<=maxTy; ty++){
for(let tx=minTx; tx<=maxTx; tx++){
const f = fog[tileIndex(tx,ty)];
if(f===0) continue;
const t = world.tiles[ty][tx];
const sx = tx*TILE-camera.x, sy = ty*TILE-camera.y;
drawTile(sx,sy,t,f);
}
}
// resource nodes
for(const n of world.resourceNodes){
if(!n.alive) continue;
const f = fog[tileIndex(n.tx,n.ty)];
if(f===0) continue;
if(n.tx+n.w < minTx || n.tx > maxTx || n.ty+n.h < minTy || n.ty > maxTy) continue;
drawResourceNode(n, f);
}
// buildings
for(const b of world.buildings){
if(b.hp<=0) continue;
drawBuilding(b);
}
// creeps
for(const c of world.creeps){
if(c.hp<=0) continue;
if(fogAt(Math.floor(c.x),Math.floor(c.y))!==2) continue;
drawCreep(c);
}
// units
for(const u of world.units){
if(u.hp<=0) continue;
drawUnit(u);
}
// rally lines
if(selectedBuilding && selectedBuilding.rally){
const r = selectedBuilding.rally;
ctx.strokeStyle='rgba(127,216,88,0.8)';
ctx.setLineDash([4,4]);
ctx.beginPath();
ctx.moveTo(selectedBuilding.x*TILE-camera.x, selectedBuilding.y*TILE-camera.y);
ctx.lineTo(r.x*TILE-camera.x, r.y*TILE-camera.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle='#7fd858';
ctx.beginPath(); ctx.arc(r.x*TILE-camera.x, r.y*TILE-camera.y, 4,0,Math.PI*2); ctx.fill();
}
// particles
for(const p of world.particles){
const sx = p.x*TILE-camera.x, sy = p.y*TILE-camera.y;
ctx.globalAlpha = clamp(p.life/p.maxLife,0,1);
ctx.fillStyle = p.color;
ctx.font = 'bold 12px sans-serif';
ctx.textAlign='center';
ctx.fillText(p.text, sx, sy);
ctx.globalAlpha = 1;
}
// placement ghost
if(placement){
const w = screenToWorld(mouse.x, mouse.y);
const def = placement.def;
const tx = Math.round(w.x/TILE - def.w/2);
const ty = Math.round(w.y/TILE - def.h/2);
const ok = canPlaceAt(tx,ty,def) && player.gold>=def.cost.gold && player.wood>=def.cost.wood;
ctx.fillStyle = ok ? 'rgba(127,216,88,0.35)' : 'rgba(224,90,78,0.35)';
ctx.strokeStyle = ok ? '#7fd858' : '#e05a4e';
ctx.lineWidth=2;
const sx = tx*TILE-camera.x, sy = ty*TILE-camera.y;
ctx.fillRect(sx,sy,def.w*TILE,def.h*TILE);
ctx.strokeRect(sx,sy,def.w*TILE,def.h*TILE);
}
// drag select box
if(mouse.down && mouse.dragging && mouse.dragStart && !placement){
ctx.strokeStyle = '#7fd858';
ctx.fillStyle = 'rgba(127,216,88,0.15)';
const x = Math.min(mouse.dragStart.x, mouse.x), y = Math.min(mouse.dragStart.y, mouse.y);
const w = Math.abs(mouse.x-mouse.dragStart.x), h = Math.abs(mouse.y-mouse.dragStart.y);
ctx.fillRect(x,y,w,h); ctx.strokeRect(x,y,w,h);
}
}
function drawTile(sx,sy,t,fogState){
let base;
if(t.type===TILE_WATER) base = `rgb(${40+t.shade*10},${95+t.shade*15},${140+t.shade*15})`;
else if(t.type===TILE_ROCK) base = `rgb(${100+t.shade*20},${98+t.shade*20},${92+t.shade*18})`;
else base = `rgb(${58+t.shade*22},${110+t.shade*28},${52+t.shade*20})`;
ctx.fillStyle = base;
ctx.fillRect(sx,sy,TILE,TILE);
if(fogState===1){
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(sx,sy,TILE,TILE);
}
}
function drawResourceNode(n, fogState){
const sx = n.tx*TILE - camera.x, sy = n.ty*TILE - camera.y;
const w = n.w*TILE, h = n.h*TILE;
ctx.save();
if(fogState===1) ctx.globalAlpha = 0.45;
if(n.type==='gold'){
ctx.fillStyle = '#8a7a5a';
ctx.beginPath();
ctx.moveTo(sx+w*0.1, sy+h*0.85); ctx.lineTo(sx+w*0.3, sy+h*0.25);
ctx.lineTo(sx+w*0.7, sy+h*0.15); ctx.lineTo(sx+w*0.9, sy+h*0.8); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#e8c44a';
for(let i=0;i<5;i++){
const rx = sx + w*(0.25+0.5*Math.random());
const ry = sy + h*(0.35+0.45*Math.random());
ctx.beginPath(); ctx.arc(rx,ry,2.2,0,Math.PI*2); ctx.fill();
}
} else {
ctx.fillStyle = '#5a3a20';
ctx.fillRect(sx+w/2-3, sy+h*0.55, 6, h*0.4);
ctx.fillStyle = '#2f6b2a';
ctx.beginPath(); ctx.arc(sx+w/2, sy+h*0.42, w*0.42, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#397a34';
ctx.beginPath(); ctx.arc(sx+w/2-4, sy+h*0.32, w*0.28, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
}
function drawBuilding(b){
const sx = b.tx*TILE - camera.x, sy = b.ty*TILE - camera.y;
const w = b.w*TILE, h = b.h*TILE;
if(sx+w<0 || sy+h<0 || sx>canvas.clientWidth || sy>canvas.clientHeight) return;
ctx.save();
if(b.constructing) ctx.globalAlpha = 0.65;
const wallColor = '#3a5a78';
const roofColor = '#274a63';
ctx.fillStyle = wallColor;
ctx.fillRect(sx+3, sy+h*0.35, w-6, h*0.6);
ctx.fillStyle = roofColor;
ctx.beginPath();
ctx.moveTo(sx, sy+h*0.4);
ctx.lineTo(sx+w/2, sy);
ctx.lineTo(sx+w, sy+h*0.4);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#1c2e3d';
ctx.fillRect(sx+w/2-6, sy+h*0.65, 12, h*0.3);
if(b.key==='tower'){
ctx.fillStyle = '#4a6a88';
ctx.fillRect(sx+w*0.3, sy+h*0.05, w*0.4, h*0.5);
}
if(b.key==='farm'){
ctx.strokeStyle = '#8a6a3a'; ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(sx+4,sy+4); ctx.lineTo(sx+w-4,sy+h-4);
ctx.moveTo(sx+w-4,sy+4); ctx.lineTo(sx+4,sy+h-4);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.strokeRect(sx+3, sy+h*0.35, w-6, h*0.6);
ctx.restore();
if(b.constructing){
drawProgressBar(sx+w/2, sy-8, w-8, b.buildProgress, '#7fb0e0');
} else if(b.hp < b.maxHp){
drawProgressBar(sx+w/2, sy-8, w-8, b.hp/b.maxHp, '#7fd858');
}
if(selectedBuilding===b){
ctx.strokeStyle = '#f5e05a';
ctx.lineWidth = 2;
ctx.strokeRect(sx, sy, w, h);
}
}
function drawProgressBar(cx, sy, w, pct, color){
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(cx-w/2, sy-4, w, 6);
ctx.fillStyle = color;
ctx.fillRect(cx-w/2, sy-4, w*clamp(pct,0,1), 6);
}
function drawUnit(u){
const sx = u.x*TILE - camera.x, sy = u.y*TILE - camera.y;
if(sx<-20||sy<-20||sx>canvas.clientWidth+20||sy>canvas.clientHeight+20) return;
const isSel = selection.includes(u);
if(isSel){
ctx.strokeStyle = '#f5e05a'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(sx, sy+u.radius*TILE*0.7, u.radius*TILE*1.15, u.radius*TILE*0.6, 0,0,Math.PI*2); ctx.stroke();
}
const r = u.radius*TILE;
let bodyColor = '#3a8ad8', accentColor = '#1c5a99';
if(u.key==='soldier'){ bodyColor='#4a8ad8'; accentColor='#c04a3a'; }
if(u.key==='archer'){ bodyColor='#5aa070'; accentColor='#2f6b2a'; }
ctx.fillStyle = accentColor;
ctx.beginPath(); ctx.ellipse(sx, sy+r*0.75, r*0.9, r*0.35, 0,0,Math.PI*2); ctx.fill();
ctx.fillStyle = bodyColor;
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth=1.2; ctx.stroke();
if(u.key==='soldier'){
ctx.strokeStyle = '#eee'; ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(sx + Math.cos(u.facing)*r*0.2, sy + Math.sin(u.facing)*r*0.2);
ctx.lineTo(sx + Math.cos(u.facing)*r*1.6, sy + Math.sin(u.facing)*r*1.6);
ctx.stroke();
} else if(u.key==='archer'){
ctx.strokeStyle = '#3a2a18'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.arc(sx + Math.cos(u.facing)*r*0.9, sy + Math.sin(u.facing)*r*0.9, r*0.55, u.facing-0.9, u.facing+0.9); ctx.stroke();
} else {
ctx.fillStyle = '#e8c44a';
if(u.carrying>0){
ctx.beginPath(); ctx.arc(sx, sy-r*1.3, 3.5, 0, Math.PI*2); ctx.fill();
}
}
if(u.hp < u.maxHp){
drawProgressBar(sx, sy-r*1.9, r*2.4, u.hp/u.maxHp, u.hp/u.maxHp>0.4?'#7fd858':'#e0a54e');
}
}
function drawCreep(c){
const sx = c.x*TILE - camera.x, sy = c.y*TILE - camera.y;
if(sx<-20||sy<-20||sx>canvas.clientWidth+20||sy>canvas.clientHeight+20) return;
const r = c.radius*TILE;
if(selectedCreep===c){
ctx.strokeStyle = '#f5e05a'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(sx, sy+r*0.7, r*1.15, r*0.6, 0,0,Math.PI*2); ctx.stroke();
}
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath(); ctx.ellipse(sx, sy+r*0.75, r*0.9, r*0.35, 0,0,Math.PI*2); ctx.fill();
if(c.key==='wolf'){
ctx.fillStyle = '#8a8078';
ctx.beginPath();
ctx.moveTo(sx-r, sy);
ctx.lineTo(sx, sy-r*0.7);
ctx.lineTo(sx+r, sy);
ctx.lineTo(sx, sy+r*0.7);
ctx.closePath(); ctx.fill();
} else {
ctx.fillStyle = '#8a2a24';
ctx.beginPath(); ctx.arc(sx,sy,r,0,Math.PI*2); ctx.fill();
ctx.fillStyle = '#3a1a14';
ctx.fillRect(sx-r*0.5, sy-r*0.15, r, r*0.3);
}
ctx.strokeStyle='rgba(0,0,0,0.5)'; ctx.lineWidth=1; ctx.stroke();
if(c.hp<c.maxHp){
drawProgressBar(sx, sy-r*1.9, r*2.4, c.hp/c.maxHp, '#e05a4e');
}
}
/* =========================================================================================
MAIN LOOP
========================================================================================= */
function tick(ts){
requestAnimationFrame(tick);
if(!lastTime) lastTime = ts;
let dt = (ts-lastTime)/1000;
lastTime = ts;
dt = Math.min(dt, 0.06);
if(!gameStarted){ return; }
const now = ts/1000;
updateCamera(dt);
for(const u of world.units) updateUnit(u, dt, now);
for(const c of world.creeps) updateCreep(c, dt, now);
for(const b of world.buildings) updateBuilding(b, dt, now);
world.units = world.units.filter(u=>u.hp>0);
world.creeps = world.creeps.filter(c=>c.hp>0);
world.buildings = world.buildings.filter(b=>b.hp>0);
updateParticles(dt);
updateFog();
updateHud();
render();
renderMinimap();
refreshCommandPanel();
updatePlacementIndicator();
}
/* =========================================================================================
INIT
========================================================================================= */
function init(){
resizeCanvas();
genTiles();
genResources();
initFog();
const th = placeBuildingInstant('townhall', SPAWN_TX, SPAWN_TY, false);
player.popMax = BUILDING_DEFS.townhall.providesPop;
const spawnSpots = [
{x:SPAWN_TX+3.5, y:SPAWN_TY+1.5},
{x:SPAWN_TX+3.5, y:SPAWN_TY+2.5},
{x:SPAWN_TX-0.5, y:SPAWN_TY+3.5},
];
for(const s of spawnSpots){
spawnUnit('worker', s.x, s.y);
player.popCurrent += UNIT_DEFS.worker.popCost;
}
genCreeps();
camera.x = clamp(th.x*TILE - canvas.clientWidth/2, 0, Math.max(0,MAP_W*TILE-canvas.clientWidth));
camera.y = clamp(th.y*TILE - canvas.clientHeight/2, 0, Math.max(0,MAP_H*TILE-canvas.clientHeight));
buildMinimapTerrain();
updateFog();
updateHud();
requestAnimationFrame(tick);
}
init();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment