Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 16, 2026 17:51
Show Gist options
  • Select an option

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

Select an option

Save senko/ea289747090ca5ecf328415ca5147688 to your computer and use it in GitHub Desktop.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Little Kingdoms — Tiny RTS</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏰</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#0b0f0b;font-family:'Nunito',sans-serif;
user-select:none;-webkit-user-select:none;cursor:default}
#game{position:fixed;inset:0;display:block}
/* ---------- top bar ---------- */
#topbar{position:fixed;top:0;left:50%;transform:translateX(-50%);display:flex;gap:20px;align-items:center;
background:rgba(12,16,12,.85);backdrop-filter:blur(6px);border:1px solid #4a4030;border-top:none;
border-radius:0 0 14px 14px;padding:8px 22px;color:#f3e9c8;font-weight:800;z-index:20;
box-shadow:0 4px 18px rgba(0,0,0,.5);font-size:15px;white-space:nowrap}
.res{display:flex;gap:6px;align-items:center}
.res b{min-width:34px;text-align:left;color:#ffe9a8}
.tbtn{background:#232b1e;color:#ffe9a8;border:1px solid #6a5a2f;border-radius:8px;padding:4px 12px;
font-family:inherit;font-weight:800;cursor:pointer;font-size:13px}
.tbtn:hover{background:#31402a}
/* ---------- bottom bar ---------- */
#bottombar{position:fixed;left:0;right:0;bottom:0;display:flex;justify-content:space-between;
align-items:flex-end;padding:10px;pointer-events:none;z-index:20;gap:10px}
#bottombar>*{pointer-events:auto}
#minimap{width:180px;height:180px;border:2px solid #6a5a2f;border-radius:10px;background:#000;
box-shadow:0 4px 18px rgba(0,0,0,.6);cursor:pointer;display:block}
#panel{flex:0 1 auto;display:flex;gap:16px;background:rgba(14,18,14,.88);backdrop-filter:blur(8px);
border:1px solid #4a4030;border-radius:14px;padding:12px 16px;min-width:540px;max-width:820px;
min-height:156px;color:#e8e2c8;box-shadow:0 4px 18px rgba(0,0,0,.6)}
#pinfo{width:230px;flex:none;border-right:1px solid #333a2c;padding-right:14px}
#pactions{flex:1;display:flex;flex-wrap:wrap;gap:8px;align-content:flex-start;align-items:flex-start}
.prow{display:flex;gap:12px;align-items:center}
.portrait{width:62px;height:62px;flex:none;border-radius:10px;background:#1d241d;border:1px solid #5a5232;
display:flex;align-items:center;justify-content:center;font-size:34px;box-shadow:inset 0 0 12px rgba(0,0,0,.6)}
.pname{font-weight:900;font-size:16px;color:#ffe9a8;margin-bottom:4px}
.psub{font-size:12px;color:#b9b294;margin-top:4px;line-height:1.45}
.bar{height:9px;background:#101410;border:1px solid #3a3a28;border-radius:5px;overflow:hidden;margin-top:4px;min-width:90px}
.bar>i{display:block;height:100%;background:linear-gradient(90deg,#7be382,#46b450);transition:width .15s}
.bar.mini{height:5px;margin-top:3px}
.cmd{width:92px;height:78px;background:linear-gradient(180deg,#2a3324,#1c2318);border:1px solid #6a5a2f;
border-radius:10px;color:#eee6c4;font-family:inherit;cursor:pointer;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2px;padding:4px}
.cmd:hover{border-color:#e8c15a;box-shadow:0 0 10px rgba(232,193,90,.35);background:linear-gradient(180deg,#35402c,#232b1c)}
.cmd.dis{opacity:.42;filter:grayscale(.7);cursor:not-allowed}
.cmd .ic{font-size:24px;line-height:1}
.cmd .nm{font-size:11px;font-weight:800}
.cmd .cost{font-size:10px;color:#cbbf92}
.cmd u{text-decoration:none;background:#3a3a28;border-radius:3px;padding:0 3px;font-size:9px;color:#ffe9a8}
.queue{display:flex;gap:6px;align-items:center}
.qitem{width:40px;height:46px;background:#1d241d;border:1px solid #5a5232;border-radius:8px;font-size:20px;
display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;padding:2px}
.qitem:hover{border-color:#ff8a6b}
.hint{width:100%;font-size:11px;color:#8f8a70;margin-top:2px}
.grid{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.cell{width:30px;height:30px;background:#1d241d;border:1px solid #4a4a32;border-radius:6px;font-size:16px;
display:flex;align-items:center;justify-content:center}
/* ---------- toasts ---------- */
#toasts{position:fixed;top:60px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;
gap:6px;z-index:40;pointer-events:none;align-items:center}
.toast{background:rgba(20,24,20,.92);border:1px solid #6a5a2f;color:#ffe9a8;padding:6px 16px;border-radius:9px;
font-weight:800;font-size:13px;animation:tin .25s ease-out;box-shadow:0 4px 14px rgba(0,0,0,.5)}
@keyframes tin{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
/* ---------- overlays ---------- */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.62);display:flex;align-items:center;justify-content:center;z-index:60}
.card{background:linear-gradient(180deg,#1a2117,#12170f);border:1px solid #6a5a2f;border-radius:18px;
padding:28px 34px;max-width:560px;color:#eee6c4;box-shadow:0 12px 50px rgba(0,0,0,.8)}
.card h1{font-size:26px;color:#ffd75e;margin-bottom:4px}
.card h2{font-size:15px;color:#b9b294;font-weight:700;margin-bottom:14px}
.card ul{list-style:none;margin:0 0 18px}
.card li{padding:4px 0;font-size:14px;line-height:1.5}
.card li b{color:#ffe9a8}
.kbd{background:#2a3324;border:1px solid #5a5232;border-radius:5px;padding:0 7px;font-size:12px;font-weight:800;color:#ffe9a8}
.btn{background:linear-gradient(180deg,#8a6f2f,#6a5420);border:1px solid #e8c15a;color:#fff3d0;border-radius:10px;
padding:10px 26px;font-family:inherit;font-weight:900;font-size:15px;cursor:pointer}
.btn:hover{filter:brightness(1.15)}
.stats{display:flex;gap:22px;margin:6px 0 18px;flex-wrap:wrap}
.stat{text-align:center}
.stat .v{font-size:24px;font-weight:900;color:#ffd75e}
.stat .l{font-size:11px;color:#b9b294}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="topbar">
<span class="res">🪙 <b id="rGold">0</b></span>
<span class="res">🪵 <b id="rWood">0</b></span>
<span class="res">👥 <b id="rSup">0/0</b></span>
<span class="res">🗺️ <b id="rExp">0%</b></span>
<span class="res">⏱️ <b id="rTime">0:00</b></span>
<button class="tbtn" id="helpBtn">❓ Help</button>
</div>
<div id="bottombar">
<canvas id="minimap"></canvas>
<div id="panel">
<div id="pinfo"></div>
<div id="pactions"></div>
</div>
</div>
<div id="toasts"></div>
<div id="help" class="overlay">
<div class="card">
<h1>🏰 Little Kingdoms</h1>
<h2>A tiny real-time strategy game — explore 95% of the map to win!</h2>
<ul>
<li>🖱️ <b>Left-click</b> select · <b>drag</b> box-select · <b>double-click</b> select all of a type</li>
<li>🖱️ <b>Right-click</b> move · gather (mine 🪙 / tree 🪵) · attack · set rally point</li>
<li>👷 <b>Workers</b> gather gold &amp; wood and construct buildings (drop off at a Town Hall)</li>
<li>🌾 Build <b>Farms</b> for supply · 🛡️ <b>Barracks</b> train soldiers · 🏰 extra <b>Town Halls</b> speed up gathering</li>
<li>🗺️ Camera: <span class="kbd">←↑↓→</span> · screen edges · minimap · <b>wheel</b> to zoom</li>
<li>⌨️ Hotkeys: <span class="kbd">F</span> farm · <span class="kbd">R</span> barracks · <span class="kbd">T</span> town hall · <span class="kbd">W</span> worker · <span class="kbd">S</span> soldier · <span class="kbd">Esc</span> cancel</li>
<li>👾 Wild slimes lurk in the fog — harmless, and worth <b>25🪙</b> bounty</li>
</ul>
<button class="btn" id="startBtn">⚔️ Start playing</button>
</div>
</div>
<div id="victory" class="overlay" style="display:none">
<div class="card" style="text-align:center">
<h1>🎉 The realm is charted!</h1>
<h2>You have explored the land. Victory!</h2>
<div class="stats" style="justify-content:center">
<div class="stat"><div class="v" id="vTime">0:00</div><div class="l">Time</div></div>
<div class="stat"><div class="v" id="vUnits">0</div><div class="l">Units trained</div></div>
<div class="stat"><div class="v" id="vBuild">0</div><div class="l">Buildings built</div></div>
<div class="stat"><div class="v" id="vSlain">0</div><div class="l">Slimes slain</div></div>
</div>
<button class="btn" id="keepBtn">Keep playing</button>
</div>
</div>
<script>
'use strict';
/* ============================================================
Little Kingdoms — a tiny RTS in a single file
============================================================ */
// ---------------- utils ----------------
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const choice=a=>a[Math.floor(Math.random()*a.length)];
const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1);
function hash2(x,y){const h=Math.sin(x*127.1+y*311.7)*43758.5453;return h-Math.floor(h);}
const byId=(arr,id)=>arr.find(e=>e.id===id);
const $=id=>document.getElementById(id);
// ---------------- config ----------------
const TILE=32, MAP_W=80, MAP_H=80;
const WORLD_W=MAP_W*TILE, WORLD_H=MAP_H*TILE;
const COSTS={
worker: {gold:50, supply:1, time:6},
soldier: {gold:90, supply:2, time:9},
farm: {wood:80, time:6, hp:250},
barracks:{wood:160,gold:40, time:10, hp:450},
townhall:{gold:300,wood:150,time:15, hp:700},
};
const SUPPLY={townhall:12,farm:8};
const SIGHT={worker:5,soldier:6,townhall:9,farm:5,barracks:7};
const BUILD_SIZE={townhall:4,barracks:3,farm:2};
const NAMES={townhall:'Town Hall',farm:'Farm',barracks:'Barracks',worker:'Worker',soldier:'Footman'};
const GATHER_TIME=1.6, GOLD_TRIP=10, WOOD_TRIP=8;
// ---------------- state ----------------
let gold=250, wood=150;
let units=[], buildings=[], resources=[], monsters=[], particles=[], floats=[];
let nextId=1;
let selection=[], selectedBuilding=null, selectedMonster=null;
let placement=null;
let cam={x:0,y:0}, zoom=1;
let time=0, gameTime=0, won=false;
let stats={trained:0,built:0,slain:0};
let mouse={x:0,y:0,in:false};
let keys={};
let selBox=null, mmDrag=false;
// ---------------- canvas ----------------
const cv=$('game'), ctx=cv.getContext('2d');
const mm=$('minimap'), mmc=mm.getContext('2d');
const DPR=Math.min(2,window.devicePixelRatio||1);
let cvW=innerWidth, cvH=innerHeight;
function resize(){
cvW=innerWidth; cvH=innerHeight;
cv.width=cvW*DPR; cv.height=cvH*DPR;
cv.style.width=cvW+'px'; cv.style.height=cvH+'px';
}
addEventListener('resize',resize); resize();
mm.width=180*DPR; mm.height=180*DPR; mmc.scale(DPR,DPR);
// ---------------- map ----------------
const terrain=new Uint8Array(MAP_W*MAP_H); // 0 grass 1 water 2 sand 3 dirt
const objBlock=new Uint8Array(MAP_W*MAP_H); // 1 = tree/mine/building
const tIdx=(x,y)=>y*MAP_W+x;
const inB=(x,y)=>x>=0&&y>=0&&x<MAP_W&&y<MAP_H;
const walkable=(x,y)=>inB(x,y)&&terrain[tIdx(x,y)]!==1&&objBlock[tIdx(x,y)]===0;
function markBlock(tx,ty,w,h,v){for(let y=ty;y<ty+h;y++)for(let x=tx;x<tx+w;x++)if(inB(x,y))objBlock[tIdx(x,y)]=v;}
const corners=[[12,12],[MAP_W-14,12],[12,MAP_H-14],[MAP_W-14,MAP_H-14]];
const start=choice(corners);
function paintBlob(cx,cy,r,type,minStartDist){
for(let y=Math.floor(cy-r-2);y<=cy+r+2;y++)for(let x=Math.floor(cx-r-2);x<=cx+r+2;x++){
if(!inB(x,y))continue;
const d=dist(x,y,cx,cy)+(hash2(x,y)-0.5)*2.4;
if(d<r&&dist(x,y,start[0],start[1])>minStartDist)terrain[tIdx(x,y)]=type;
}
}
function genMap(){
// water lakes
for(let i=0;i<8;i++)paintBlob(rand(6,MAP_W-6),rand(6,MAP_H-6),rand(2.5,5.5),1,13);
// dirt patches (walkable decoration)
for(let i=0;i<12;i++)paintBlob(rand(4,MAP_W-4),rand(4,MAP_H-4),rand(1.5,3.5),3,8);
// sand ring around water
for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){
if(terrain[tIdx(x,y)]!==1)continue;
for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){
const nx=x+dx,ny=y+dy;
if(inB(nx,ny)&&terrain[tIdx(nx,ny)]!==1)terrain[tIdx(nx,ny)]=2;
}
}
// clear start area
for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++)
if(dist(x,y,start[0],start[1])<9&&terrain[tIdx(x,y)]===1)terrain[tIdx(x,y)]=0;
// forests
let placed=0,guard=0;
while(placed<300&&guard++<4000){
const cx=randi(3,MAP_W-4),cy=randi(3,MAP_H-4);
if(dist(cx,cy,start[0],start[1])<10)continue;
let x=cx,y=cy;
const n=randi(6,16);
for(let i=0;i<n;i++){
if(inB(x,y)&&terrain[tIdx(x,y)]!==1&&objBlock[tIdx(x,y)]===0&&dist(x,y,start[0],start[1])>9){
resources.push({id:nextId++,kind:'tree',tx:x,ty:y,w:1,h:1,amount:200,max:200});
objBlock[tIdx(x,y)]=1; placed++;
}
x+=randi(-1,1); y+=randi(-1,1);
}
}
// gold mines (2x2)
const canMine=(tx,ty)=>{
for(let y=ty;y<ty+2;y++)for(let x=tx;x<tx+2;x++)
if(!inB(x,y)||terrain[tIdx(x,y)]===1||objBlock[tIdx(x,y)])return false;
return true;
};
const addMine=(tx,ty)=>{resources.push({id:nextId++,kind:'mine',tx,ty,w:2,h:2,amount:3000,max:3000});markBlock(tx,ty,2,2,1);};
// one mine near start
let done=false;
for(let a=0;a<40&&!done;a++){
const ang=rand(0,6.283),d=rand(6.5,9);
const tx=Math.round(start[0]+Math.cos(ang)*d),ty=Math.round(start[1]+Math.sin(ang)*d);
if(canMine(tx,ty)){addMine(tx,ty);done=true;}
}
// scattered mines
guard=0;let m=0;
while(m<5&&guard++<800){
const tx=randi(2,MAP_W-4),ty=randi(2,MAP_H-4);
if(dist(tx,ty,start[0],start[1])<14)continue;
if(!canMine(tx,ty))continue;
addMine(tx,ty);m++;
}
// slimes
guard=0;let s=0;
while(s<10&&guard++<2000){
const x=randi(2,MAP_W-3),y=randi(2,MAP_H-3);
if(!walkable(x,y)||dist(x,y,start[0],start[1])<20)continue;
monsters.push({id:nextId++,x:(x+0.5)*TILE,y:(y+0.5)*TILE,hp:45,maxHp:45});
s++;
}
// starting base
const th={id:nextId++,type:'townhall',tx:start[0]-2,ty:start[1]-2,w:4,h:4,
hp:COSTS.townhall.hp,maxHp:COSTS.townhall.hp,state:'done',progress:1,queue:[],rally:null};
buildings.push(th); markBlock(th.tx,th.ty,4,4,1);
for(let i=0;i<4;i++){
const a=i/4*6.283;
units.push(makeUnit('worker',(start[0]+2.5+Math.cos(a)*2.2)*TILE,(start[1]+2.5+Math.sin(a)*2.2)*TILE));
}
cam.x=(start[0]+2)*TILE-cvW/zoom/2; cam.y=(start[1]+2)*TILE-cvH/zoom/2; clampCam();
}
// ---------------- fog of war ----------------
const fogE=new Uint8Array(MAP_W*MAP_H), fogV=new Uint8Array(MAP_W*MAP_H);
let exploredCount=0;
const fogCanvas=document.createElement('canvas'); fogCanvas.width=MAP_W; fogCanvas.height=MAP_H;
const fogCtx=fogCanvas.getContext('2d');
const fogImg=fogCtx.createImageData(MAP_W,MAP_H);
function updateFog(){
fogV.fill(0);
const reveal=(cx,cy,r)=>{
const x0=Math.max(0,Math.floor(cx-r)),x1=Math.min(MAP_W-1,Math.ceil(cx+r));
const y0=Math.max(0,Math.floor(cy-r)),y1=Math.min(MAP_H-1,Math.ceil(cy+r));
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)
if(dist(x+0.5,y+0.5,cx,cy)<=r)fogV[tIdx(x,y)]=1;
};
for(const u of units)reveal(u.x/TILE,u.y/TILE,SIGHT[u.type]);
for(const b of buildings)reveal(b.tx+b.w/2,b.ty+b.h/2,SIGHT[b.type]);
const d=fogImg.data;
for(let i=0;i<fogE.length;i++){
d[i*4]=6; d[i*4+1]=10; d[i*4+2]=14;
if(fogV[i]){if(!fogE[i]){fogE[i]=1;exploredCount++;}d[i*4+3]=0;}
else d[i*4+3]=fogE[i]?150:255;
}
fogCtx.putImageData(fogImg,0,0);
}
const tileVisible=(x,y)=>inB(x,y)&&fogV[tIdx(x,y)];
const tileExplored=(x,y)=>inB(x,y)&&fogE[tIdx(x,y)];
// ---------------- pathfinding (A*) ----------------
const gScore=new Float32Array(MAP_W*MAP_H);
const fScore=new Float32Array(MAP_W*MAP_H);
const cameFrom=new Int32Array(MAP_W*MAP_H);
const closedA=new Uint8Array(MAP_W*MAP_H);
const heap=[];
function hpush(i){heap.push(i);let c=heap.length-1;while(c>0){const p=(c-1)>>1;if(fScore[heap[p]]<=fScore[heap[c]])break;const t=heap[p];heap[p]=heap[c];heap[c]=t;c=p;}}
function hpop(){const top=heap[0],last=heap.pop();if(heap.length){heap[0]=last;let p=0;for(;;){const l=2*p+1,r=l+1;let s=p;if(l<heap.length&&fScore[heap[l]]<fScore[heap[s]])s=l;if(r<heap.length&&fScore[heap[r]]<fScore[heap[s]])s=r;if(s===p)break;const t=heap[p];heap[p]=heap[s];heap[s]=t;p=s;}}return top;}
const heur=(x0,y0,x1,y1)=>{const dx=Math.abs(x1-x0),dy=Math.abs(y1-y0);return Math.max(dx,dy)+0.4142*Math.min(dx,dy);};
function nearestWalkable(tx,ty,maxR=14){
tx=clamp(tx,0,MAP_W-1); ty=clamp(ty,0,MAP_H-1);
if(walkable(tx,ty))return{x:tx,y:ty};
for(let r=1;r<=maxR;r++)
for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue;
if(walkable(tx+dx,ty+dy))return{x:tx+dx,y:ty+dy};
}
return null;
}
const DIRS=[[1,0,1],[-1,0,1],[0,1,1],[0,-1,1],[1,1,1.4142],[1,-1,1.4142],[-1,1,1.4142],[-1,-1,1.4142]];
function findPath(sx,sy,tx,ty){
const s=nearestWalkable(sx,sy),t=nearestWalkable(tx,ty);
if(!s||!t)return null;
sx=s.x;sy=s.y;tx=t.x;ty=t.y;
gScore.fill(Infinity);fScore.fill(Infinity);closedA.fill(0);cameFrom.fill(-1);heap.length=0;
const si=tIdx(sx,sy),ti=tIdx(tx,ty);
gScore[si]=0;fScore[si]=heur(sx,sy,tx,ty);hpush(si);
let found=false,guard=0;
while(heap.length&&guard++<30000){
const ci=hpop();
if(closedA[ci])continue;
closedA[ci]=1;
if(ci===ti){found=true;break;}
const cx=ci%MAP_W,cy=(ci/MAP_W)|0;
for(const d of DIRS){
const nx=cx+d[0],ny=cy+d[1];
if(!walkable(nx,ny))continue;
if(d[0]&&d[1]&&(!walkable(cx+d[0],cy)||!walkable(cx,cy+d[1])))continue;
const ni=tIdx(nx,ny);
if(closedA[ni])continue;
const g=gScore[ci]+d[2];
if(g<gScore[ni]){gScore[ni]=g;fScore[ni]=g+heur(nx,ny,tx,ty);cameFrom[ni]=ci;hpush(ni);}
}
}
if(!found)return null;
const path=[];let ci=ti;
while(ci!==-1){path.push({x:ci%MAP_W,y:(ci/MAP_W)|0});ci=cameFrom[ci];}
path.reverse();
return smoothPath(path);
}
function losTiles(a,b){
const d=Math.hypot(b.x-a.x,b.y-a.y),steps=Math.max(1,Math.ceil(d*3));
for(let k=1;k<steps;k++){
const x=Math.round(a.x+(b.x-a.x)*k/steps),y=Math.round(a.y+(b.y-a.y)*k/steps);
if(!walkable(x,y))return false;
}
return true;
}
function smoothPath(p){
if(p.length<=2)return p;
const out=[p[0]];let i=0;
while(i<p.length-1){
let j=p.length-1;
while(j>i+1&&!losTiles(p[i],p[j]))j--;
out.push(p[j]);i=j;
}
return out;
}
function setPath(u,wx,wy){
const p=findPath(Math.floor(u.x/TILE),Math.floor(u.y/TILE),Math.floor(wx/TILE),Math.floor(wy/TILE));
if(!p||!p.length){u.path=null;u.pathi=0;return false;}
u.path=p.map(t=>({x:(t.x+0.5)*TILE,y:(t.y+0.5)*TILE}));
const lt=p[p.length-1];
if(walkable(Math.floor(wx/TILE),Math.floor(wy/TILE))&&dist((lt.x+0.5)*TILE,(lt.y+0.5)*TILE,wx,wy)<TILE)
u.path[u.path.length-1]={x:wx,y:wy};
u.pathi=0;return true;
}
function followPath(u,dt){
if(!u.path||u.pathi>=u.path.length)return true;
const wp=u.path[u.pathi];
const d=dist(u.x,u.y,wp.x,wp.y);
if(d<6){u.pathi++;return u.pathi>=u.path.length;}
u.dir=Math.atan2(wp.y-u.y,wp.x-u.x);
const sp=Math.min(u.speed*dt,d);
u.x+=Math.cos(u.dir)*sp; u.y+=Math.sin(u.dir)*sp;
u.moving=true;
// anti-stuck: if barely moving for a while (blocked by other units), skip waypoint
if(u._sx===undefined){u._sx=u.x;u._sy=u.y;u._st=0;}
u._st+=dt;
if(u._st>1){
const moved=dist(u.x,u.y,u._sx,u._sy);
u._sx=u.x;u._sy=u.y;u._st=0;
if(moved<5){u.pathi++;return u.pathi>=u.path.length;}
}
return false;
}
function adjacentPoint(u,e){
let best=null,bd=Infinity;
for(let y=e.ty-1;y<=e.ty+e.h;y++)for(let x=e.tx-1;x<=e.tx+e.w;x++){
const perim=x===e.tx-1||x===e.tx+e.w||y===e.ty-1||y===e.ty+e.h;
if(!perim||!walkable(x,y))continue;
const wx=(x+0.5)*TILE,wy=(y+0.5)*TILE;
let d=(wx-u.x)**2+(wy-u.y)**2;
// spread workers out: penalize tiles already crowded by other units
for(const o of units)if(o!==u&&(o.x-wx)**2+(o.y-wy)**2<900)d+=2600;
if(d<bd){bd=d;best={x:wx,y:wy};}
}
return best;
}
function pathToAdj(u,e){const p=adjacentPoint(u,e);if(p)setPath(u,p.x,p.y);}
function nearEnt(u,e,pad){
const x0=e.tx*TILE,y0=e.ty*TILE,x1=(e.tx+e.w)*TILE,y1=(e.ty+e.h)*TILE;
const cx=clamp(u.x,x0,x1),cy=clamp(u.y,y0,y1);
return dist(u.x,u.y,cx,cy)<pad*TILE;
}
// ---------------- entities ----------------
function makeUnit(type,x,y){
return{id:nextId++,type,x,y,dir:0,
hp:type==='worker'?40:90,maxHp:type==='worker'?40:90,
speed:type==='worker'?72:88,
state:'idle',path:null,pathi:0,cd:0,carry:null,phase:null,timer:0,
gatherTarget:null,gatherKind:null,buildTarget:null,attackTarget:null,dropId:null,moving:false};
}
function nearestResource(x,y,kind){
let best=null,bd=Infinity;
for(const r of resources){
if(r.kind!==kind||r.amount<=0)continue;
const d=dist(x,y,(r.tx+r.w/2)*TILE,(r.ty+r.h/2)*TILE);
if(d<bd){bd=d;best=r;}
}
return best;
}
function nearestDropoff(x,y){
let best=null,bd=Infinity;
for(const b of buildings){
if(b.type!=='townhall'||b.state!=='done')continue;
const d=dist(x,y,(b.tx+b.w/2)*TILE,(b.ty+b.h/2)*TILE);
if(d<bd){bd=d;best=b;}
}
return best;
}
function removeTree(r){
markBlock(r.tx,r.ty,1,1,0);
resources.splice(resources.indexOf(r),1);
burst((r.tx+0.5)*TILE,(r.ty+0.5)*TILE,'#4e7d33',10,60,0.7,300);
}
function killMonster(m){
monsters.splice(monsters.indexOf(m),1);
if(selectedMonster===m)selectedMonster=null;
gold+=25; stats.slain++;
addFloat(m.x,m.y-16,'+25 🪙','#ffd75e');
burst(m.x,m.y,'#7de07a',16,90,0.8,200);
}
function spawnUnit(type,b){
const spot=nearestWalkable(b.tx+b.w,b.ty+((b.h/2)|0))||nearestWalkable(b.tx,b.ty+b.h);
const u=makeUnit(type,(spot.x+0.5)*TILE,(spot.y+0.5)*TILE);
units.push(u); stats.trained++;
burst(u.x,u.y,'#ffe9a8',8,50,0.5,0);
if(b.rally)orderMove(u,b.rally.x,b.rally.y);
}
// ---------------- economy ----------------
const canAfford=c=>gold>=(c.gold||0)&&wood>=(c.wood||0);
function pay(c){gold-=c.gold||0;wood-=c.wood||0;}
function refund(c){gold+=c.gold||0;wood+=c.wood||0;}
function supplyUsed(){let s=0;for(const u of units)s+=COSTS[u.type].supply;for(const b of buildings)for(const q of b.queue)s+=COSTS[q.type].supply;return s;}
function supplyCap(){let s=0;for(const b of buildings)if(b.state==='done')s+=SUPPLY[b.type]||0;return s;}
// ---------------- orders ----------------
function clearWork(u){u.gatherTarget=null;u.buildTarget=null;u.attackTarget=null;u.phase=null;u.timer=0;}
function orderMove(u,wx,wy){clearWork(u);u.state='move';setPath(u,clamp(wx,0,WORLD_W-1),clamp(wy,0,WORLD_H-1));}
function orderGroupMove(sel,wx,wy){
const n=sel.length,cols=Math.ceil(Math.sqrt(n)),rows=Math.ceil(n/cols),sp=26;
sel.forEach((u,i)=>{
const ox=(i%cols-(cols-1)/2)*sp,oy=(((i/cols)|0)-(rows-1)/2)*sp;
orderMove(u,wx+ox,wy+oy);
});
}
function orderGather(u,r){
clearWork(u);
u.state='gather';u.gatherTarget=r.id;u.gatherKind=r.kind;
if(u.carry){
const th=nearestDropoff(u.x,u.y);
if(th){u.phase='toDrop';u.dropId=th.id;pathToAdj(u,th);return;}
u.carry=null;
}
u.phase='toSource';pathToAdj(u,r);
}
function orderBuild(u,b){clearWork(u);u.state='build';u.buildTarget=b.id;pathToAdj(u,b);}
function orderAttack(u,m){clearWork(u);u.state='attack';u.attackTarget=m.id;u.path=null;}
// ---------------- commands (UI) ----------------
function cmdBuild(type){
const w=selection.map(id=>byId(units,id)).find(u=>u&&u.type==='worker');
if(!w){toast('Select a worker first');return;}
placement={type};
toast('Choose a location — right-click / Esc to cancel');
}
function canPlaceAt(tx,ty,size){
for(let y=ty;y<ty+size;y++)for(let x=tx;x<tx+size;x++)
if(!inB(x,y)||terrain[tIdx(x,y)]===1||objBlock[tIdx(x,y)]||!fogE[tIdx(x,y)])return false;
return true;
}
function tryPlace(wx,wy){
const type=placement.type,size=BUILD_SIZE[type];
const tx=Math.floor(wx/TILE-size/2),ty=Math.floor(wy/TILE-size/2);
if(!canPlaceAt(tx,ty,size)){toast("Can't build there");return;}
const cost=COSTS[type];
if(!canAfford(cost)){toast('Not enough resources');placement=null;return;}
pay(cost);
const b={id:nextId++,type,tx,ty,w:size,h:size,hp:cost.hp*0.1,maxHp:cost.hp,
state:'constructing',progress:0,queue:[],rally:null};
buildings.push(b);markBlock(tx,ty,size,size,1);
// nudge units out of the footprint
for(const u of units){
if(u.x>tx*TILE&&u.x<(tx+size)*TILE&&u.y>ty*TILE&&u.y<(ty+size)*TILE){
const p=nearestWalkable(Math.floor(u.x/TILE),Math.floor(u.y/TILE));
if(p){u.x=(p.x+0.5)*TILE;u.y=(p.y+0.5)*TILE;}
}
}
const ws=selection.map(id=>byId(units,id)).filter(u=>u&&u.type==='worker');
ws.sort((a,b2)=>dist(a.x,a.y,wx,wy)-dist(b2.x,b2.y,wx,wy));
if(ws.length)orderBuild(ws[0],b);
placement=null;
}
function cmdTrain(type){
const b=selectedBuilding;
if(!b||b.state!=='done')return;
if(type==='worker'&&b.type!=='townhall')return;
if(type==='soldier'&&b.type!=='barracks')return;
const c=COSTS[type];
if(supplyUsed()+c.supply>supplyCap()){toast('Not enough supply — build Farms 🌾');return;}
if(!canAfford(c)){toast('Not enough resources');return;}
pay(c);b.queue.push({type,t:c.time});
}
function cancelTrain(i){
const b=selectedBuilding;
if(!b||!b.queue[i])return;
refund(COSTS[b.queue[i].type]);b.queue.splice(i,1);
}
// ---------------- selection & input ----------------
function screenToWorld(sx,sy){return{x:cam.x+sx/zoom,y:cam.y+sy/zoom};}
function pickAt(wx,wy){
let best=null,bd=Infinity;
for(const u of units){const d=dist(u.x,u.y,wx,wy);if(d<16&&d<bd){bd=d;best={kind:'unit',o:u};}}
if(best)return best;
for(const m of monsters){
if(dist(m.x,m.y,wx,wy)<18&&tileVisible(Math.floor(m.x/TILE),Math.floor(m.y/TILE)))
return{kind:'monster',o:m};
}
for(let i=buildings.length-1;i>=0;i--){
const b=buildings[i];
if(wx>=b.tx*TILE&&wx<(b.tx+b.w)*TILE&&wy>=b.ty*TILE&&wy<(b.ty+b.h)*TILE)return{kind:'building',o:b};
}
return null;
}
function clearSelection(){selection=[];selectedBuilding=null;selectedMonster=null;}
function selectSingle(shift){
const w=screenToWorld(mouse.x,mouse.y),p=pickAt(w.x,w.y);
if(!p){if(!shift)clearSelection();return;}
if(p.kind==='unit'){
selectedBuilding=null;selectedMonster=null;
if(shift){const i=selection.indexOf(p.o.id);if(i>=0)selection.splice(i,1);else selection.push(p.o.id);}
else selection=[p.o.id];
}else if(p.kind==='building'){selection=[];selectedMonster=null;selectedBuilding=p.o;}
else{selection=[];selectedBuilding=null;selectedMonster=p.o;}
}
function boxSelect(x0,y0,x1,y1,shift){
const a=screenToWorld(Math.min(x0,x1),Math.min(y0,y1)),b=screenToWorld(Math.max(x0,x1),Math.max(y0,y1));
const ids=units.filter(u=>u.x>=a.x&&u.x<=b.x&&u.y>=a.y&&u.y<=b.y).map(u=>u.id);
if(!shift){clearSelection();}
selectedBuilding=null;selectedMonster=null;
for(const id of ids)if(!selection.includes(id))selection.push(id);
}
cv.addEventListener('mousedown',e=>{
if(e.button===0){
if(placement){const w=screenToWorld(e.clientX,e.clientY);tryPlace(w.x,w.y);return;}
selBox={x0:e.clientX,y0:e.clientY,x1:e.clientX,y1:e.clientY};
}else if(e.button===2){
if(placement){placement=null;return;}
onRightClick(e.clientX,e.clientY);
}
});
addEventListener('mousemove',e=>{
mouse.x=e.clientX;mouse.y=e.clientY;mouse.in=true;
if(selBox){selBox.x1=e.clientX;selBox.y1=e.clientY;}
if(mmDrag)mmSeek(e);
});
addEventListener('mouseup',e=>{
if(e.button===0&&selBox){
const w=Math.abs(selBox.x1-selBox.x0),h=Math.abs(selBox.y1-selBox.y0);
if(w<8&&h<8)selectSingle(e.shiftKey);
else boxSelect(selBox.x0,selBox.y0,selBox.x1,selBox.y1,e.shiftKey);
selBox=null;
}
mmDrag=false;
});
cv.addEventListener('dblclick',e=>{
const w=screenToWorld(e.clientX,e.clientY),p=pickAt(w.x,w.y);
if(p&&p.kind==='unit'){
const vw=cvW/zoom,vh=cvH/zoom;
selection=units.filter(u=>u.type===p.o.type&&u.x>cam.x&&u.x<cam.x+vw&&u.y>cam.y&&u.y<cam.y+vh).map(u=>u.id);
selectedBuilding=null;selectedMonster=null;
}
});
cv.addEventListener('contextmenu',e=>e.preventDefault());
cv.addEventListener('wheel',e=>{
e.preventDefault();
const w0=screenToWorld(mouse.x,mouse.y);
zoom=clamp(zoom*(e.deltaY>0?0.88:1.14),0.55,1.9);
cam.x=w0.x-mouse.x/zoom;cam.y=w0.y-mouse.y/zoom;clampCam();
},{passive:false});
document.addEventListener('mouseleave',()=>{mouse.in=false;});
addEventListener('blur',()=>{keys={};});
addEventListener('keydown',e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();
keys[e.key]=true;
if(e.key==='Escape'){
if(placement){placement=null;return;}
if($('help').style.display!=='none'){$('help').style.display='none';return;}
clearSelection();return;
}
const k=e.key.toLowerCase();
if(k==='f')cmdBuild('farm');
else if(k==='r')cmdBuild('barracks');
else if(k==='t')cmdBuild('townhall');
else if(k==='w')cmdTrain('worker');
else if(k==='s')cmdTrain('soldier');
});
addEventListener('keyup',e=>{keys[e.key]=false;});
function onRightClick(sx,sy){
const w=screenToWorld(sx,sy);
if(selectedBuilding){
const b=selectedBuilding;
if(b.state==='done'&&(b.type==='townhall'||b.type==='barracks')){
b.rally={x:w.x,y:w.y};ring(w.x,w.y,'#7cc4ff');
}
return;
}
const sel=selection.map(id=>byId(units,id)).filter(Boolean);
if(!sel.length)return;
const m=monsters.find(m2=>dist(m2.x,m2.y,w.x,w.y)<20&&tileVisible(Math.floor(m2.x/TILE),Math.floor(m2.y/TILE)));
if(m){sel.forEach(u=>orderAttack(u,m));ring(w.x,w.y,'#ff6b6b');return;}
const r=resources.find(r2=>w.x>=r2.tx*TILE-8&&w.x<(r2.tx+r2.w)*TILE+8&&w.y>=r2.ty*TILE-8&&w.y<(r2.ty+r2.h)*TILE+8);
if(r){
sel.forEach(u=>{
if(u.type==='worker')orderGather(u,r);
else{const p=adjacentPoint(u,r);if(p)orderMove(u,p.x,p.y);}
});
ring(w.x,w.y,'#ffd75e');return;
}
const b=buildings.find(b2=>w.x>=b2.tx*TILE&&w.x<(b2.tx+b2.w)*TILE&&w.y>=b2.ty*TILE&&w.y<(b2.ty+b2.h)*TILE);
if(b&&b.state==='constructing'){
sel.filter(u=>u.type==='worker').forEach(u=>orderBuild(u,b));
ring(w.x,w.y,'#7dff9e');return;
}
orderGroupMove(sel,w.x,w.y);ring(w.x,w.y,'#7dff9e');
}
// minimap input
mm.addEventListener('mousedown',e=>{mmDrag=true;mmSeek(e);e.preventDefault();});
function mmSeek(e){
const r=mm.getBoundingClientRect();
const mx=(e.clientX-r.left)/r.width,my=(e.clientY-r.top)/r.height;
cam.x=mx*WORLD_W-cvW/zoom/2;cam.y=my*WORLD_H-cvH/zoom/2;clampCam();
}
function clampCam(){
const vw=cvW/zoom,vh=cvH/zoom;
cam.x=vw>=WORLD_W?(WORLD_W-vw)/2:clamp(cam.x,0,WORLD_W-vw);
cam.y=vh>=WORLD_H?(WORLD_H-vh)/2:clamp(cam.y,0,WORLD_H-vh);
}
// ---------------- particles & floats ----------------
function burst(x,y,color,n,spd,life,grav){
for(let i=0;i<n;i++)particles.push({kind:'dot',x,y,vx:rand(-spd,spd),vy:rand(-spd,spd*0.6)-spd*0.3,
life:rand(life*0.5,life),maxLife:life,color,size:rand(1.5,3.2),grav:grav||0});
}
function ring(x,y,color){particles.push({kind:'ring',x,y,life:0.45,maxLife:0.45,color});}
function addFloat(x,y,txt,color){floats.push({x,y,txt,color,t:1.6});}
// ---------------- update ----------------
function updateUnit(u,dt){
u.moving=false;
switch(u.state){
case 'move':
if(followPath(u,dt))u.state='idle';
break;
case 'gather':{
if(u.phase==='toDrop'){
const th=byId(buildings,u.dropId);
if(!th){u.state='idle';break;}
if(nearEnt(u,th,1.8)){
u.path=null;
if(u.carry){
if(u.carry.kind==='gold')gold+=u.carry.amt;else wood+=u.carry.amt;
addFloat(u.x,u.y-20,'+'+u.carry.amt+(u.carry.kind==='gold'?' 🪙':' 🪵'),u.carry.kind==='gold'?'#ffd75e':'#d29a5b');
u.carry=null;
}
u.phase='toSource';
const r2=byId(resources,u.gatherTarget);
if(r2&&r2.amount>0)pathToAdj(u,r2);
}else if(followPath(u,dt))pathToAdj(u,th);
break;
}
const r=byId(resources,u.gatherTarget);
if(!r||r.amount<=0){
const nr=nearestResource(u.x,u.y,u.gatherKind||'tree');
if(nr){u.gatherTarget=nr.id;u.gatherKind=nr.kind;u.phase='toSource';pathToAdj(u,nr);}
else{u.state='idle';toast('No more '+(u.gatherKind==='mine'?'gold':'wood')+' nearby');}
break;
}
if(u.phase==='toSource'){
if(nearEnt(u,r,1.5)){u.path=null;u.phase='harvest';u.timer=GATHER_TIME;}
else if(followPath(u,dt))pathToAdj(u,r);
}else if(u.phase==='harvest'){
u.dir=Math.atan2((r.ty+r.h/2)*TILE-u.y,(r.tx+r.w/2)*TILE-u.x);
u.timer-=dt;
if(Math.random()<dt*3)burst(u.x+Math.cos(u.dir)*10,u.y+Math.sin(u.dir)*10,r.kind==='mine'?'#ffd75e':'#a5835a',2,40,0.4,200);
if(u.timer<=0){
const take=Math.min(r.kind==='mine'?GOLD_TRIP:WOOD_TRIP,r.amount);
r.amount-=take;
u.carry={kind:r.kind==='mine'?'gold':'wood',amt:take};
if(r.amount<=0&&r.kind==='tree')removeTree(r);
const th=nearestDropoff(u.x,u.y);
if(!th){u.state='idle';break;}
u.phase='toDrop';u.dropId=th.id;pathToAdj(u,th);
}
}
break;}
case 'build':{
const b=byId(buildings,u.buildTarget);
if(!b||b.state!=='constructing'){u.state='idle';break;}
if(!nearEnt(u,b,1.6)){
if(followPath(u,dt))pathToAdj(u,b);
}else{
u.path=null;
u.dir=Math.atan2((b.ty+b.h/2)*TILE-u.y,(b.tx+b.w/2)*TILE-u.x);
b.progress+=dt/COSTS[b.type].time;
b.hp=Math.min(b.maxHp,b.maxHp*(0.1+0.9*b.progress));
if(Math.random()<dt*4)burst(u.x+rand(-8,8),u.y+rand(-8,8),'#c9b18a',2,40,0.4,150);
if(b.progress>=1){
b.progress=1;b.state='done';b.hp=b.maxHp;stats.built++;
toast(NAMES[b.type]+' complete 🎉');
burst((b.tx+b.w/2)*TILE,(b.ty+b.h/2)*TILE,'#ffe9a8',14,80,0.7,100);
u.state='idle';
}
}
break;}
case 'attack':{
const m=byId(monsters,u.attackTarget);
if(!m){u.state='idle';break;}
const d=dist(u.x,u.y,m.x,m.y),range=(u.type==='soldier'?1.7:1.3)*TILE;
if(d>range){
if(!u.path||u.pathi>=u.path.length)setPath(u,m.x,m.y);
followPath(u,dt);
}else{
u.path=null;
u.dir=Math.atan2(m.y-u.y,m.x-u.x);
u.cd-=dt;
if(u.cd<=0){
u.cd=0.8;
m.hp-=u.type==='soldier'?9:3;
burst(m.x,m.y-4,'#ffffff',4,70,0.3,0);
if(m.hp<=0)killMonster(m);
}
}
break;}
}
}
function separate(){
for(let i=0;i<units.length;i++)for(let j=i+1;j<units.length;j++){
const a=units[i],b=units[j];
let dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy);
if(d>0&&d<16){
const push=(16-d)/2;dx/=d;dy/=d;
tryPush(a,-dx*push,-dy*push);tryPush(b,dx*push,dy*push);
}
}
for(const u of units)for(const m of monsters){
let dx=u.x-m.x,dy=u.y-m.y,d=Math.hypot(dx,dy);
if(d>0&&d<18){tryPush(u,dx/d*(18-d),dy/d*(18-d));}
}
}
function tryPush(u,dx,dy){
const nx=u.x+dx,ny=u.y+dy;
if(walkable(Math.floor(nx/TILE),Math.floor(ny/TILE))){u.x=nx;u.y=ny;}
}
let fogTimer=0,mmTimer=0,uiTimer=0;
function update(dt){
time+=dt;gameTime+=dt;
// camera
let kx=0,ky=0;
if(keys.ArrowLeft)kx-=1;if(keys.ArrowRight)kx+=1;
if(keys.ArrowUp)ky-=1;if(keys.ArrowDown)ky+=1;
if(mouse.in){
if(mouse.x<18)kx-=1;if(mouse.x>cvW-18)kx+=1;
if(mouse.y<18)ky-=1;if(mouse.y>cvH-18)ky+=1;
}
if(kx||ky){const l=Math.hypot(kx,ky),sp=720/Math.sqrt(zoom);cam.x+=kx/l*sp*dt;cam.y+=ky/l*sp*dt;clampCam();}
// entities
for(const u of units)updateUnit(u,dt);
separate();
for(const b of buildings){
if(b.state!=='done'||!b.queue.length)continue;
const q=b.queue[0];q.t-=dt;
if(q.t<=0){b.queue.shift();spawnUnit(q.type,b);}
}
// particles / floats
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];p.life-=dt;
if(p.life<=0){particles.splice(i,1);continue;}
if(p.kind==='dot'){p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=p.grav*dt;}
}
for(let i=floats.length-1;i>=0;i--){const f=floats[i];f.t-=dt;f.y-=22*dt;if(f.t<=0)floats.splice(i,1);}
// timers
fogTimer-=dt;if(fogTimer<=0){updateFog();fogTimer=0.2;}
mmTimer-=dt;if(mmTimer<=0){updateMinimap();updateTopbar();mmTimer=0.15;}
uiTimer-=dt;if(uiTimer<=0){updatePanel();uiTimer=0.2;}
// victory
if(!won&&exploredCount/(MAP_W*MAP_H)>=0.95){
won=true;
$('vTime').textContent=fmtTime(gameTime);
$('vUnits').textContent=stats.trained;
$('vBuild').textContent=stats.built;
$('vSlain').textContent=stats.slain;
$('victory').style.display='flex';
}
}
// ---------------- drawing helpers ----------------
function rr(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
function circle(x,y,r){ctx.beginPath();ctx.arc(x,y,r,0,6.2832);}
function ellip(x,y,rx,ry){ctx.beginPath();ctx.ellipse(x,y,rx,ry,0,0,6.2832);}
function hpBar(x,y,w,frac){
ctx.fillStyle='rgba(0,0,0,.55)';ctx.fillRect(x-1,y-1,w+2,5);
ctx.fillStyle=frac>0.5?'#5fd35f':frac>0.25?'#e8c15a':'#e85a5a';
ctx.fillRect(x,y,w*clamp(frac,0,1),3);
}
// ---------------- terrain prerender ----------------
const terrainCanvas=document.createElement('canvas');
function prerenderTerrain(){
terrainCanvas.width=WORLD_W;terrainCanvas.height=WORLD_H;
const c=terrainCanvas.getContext('2d');
const grass=['#5da24e','#589a49','#62a854','#559347'];
for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){
const t=terrain[tIdx(x,y)],px=x*TILE,py=y*TILE,h=hash2(x,y);
if(t===1){
const g=c.createLinearGradient(px,py,px,py+TILE);
g.addColorStop(0,'#2f74a8');g.addColorStop(1,'#235c8b');
c.fillStyle=g;c.fillRect(px,py,TILE,TILE);
c.strokeStyle='rgba(255,255,'+Math.floor(200+h*55)+','+(0.06+h*0.08)+')';
c.lineWidth=1.5;c.beginPath();
c.moveTo(px+5,py+8+h*16);c.quadraticCurveTo(px+13,py+4+h*16,px+22,py+8+h*16);c.stroke();
}else if(t===2){
c.fillStyle=h<0.5?'#d3ba72':'#cbb26a';c.fillRect(px,py,TILE,TILE);
c.fillStyle='rgba(120,95,50,.25)';
for(let k=0;k<3;k++)c.fillRect(px+hash2(x*5+k,y*9)*28,py+hash2(x*11,y*3+k)*28,2,2);
}else if(t===3){
c.fillStyle=h<0.5?'#8a6f47':'#826843';c.fillRect(px,py,TILE,TILE);
c.fillStyle='rgba(60,45,25,.25)';
for(let k=0;k<3;k++)c.fillRect(px+hash2(x*7+k,y*5)*28,py+hash2(x*3,y*13+k)*28,3,2);
}else{
c.fillStyle=grass[Math.floor(h*4)];c.fillRect(px,py,TILE,TILE);
c.strokeStyle='rgba(0,0,0,.09)';c.lineWidth=1;
for(let k=0;k<3;k++){
const bx=px+hash2(x*7+k*3,y*13)*30,by=py+hash2(x*11,y*5+k*7)*30;
c.beginPath();c.moveTo(bx,by);c.lineTo(bx+1.5,by-3);c.stroke();
}
if(h>0.93){
c.fillStyle=['#e86a92','#f2d15c','#ffffff'][Math.floor(h*300)%3];
c.beginPath();c.arc(px+hash2(x*3,y*17)*26+3,py+hash2(x*19,y*2)*26+3,1.8,0,6.2832);c.fill();
}
}
}
// shore foam
c.strokeStyle='rgba(205,232,255,.4)';c.lineWidth=2;
for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){
if(terrain[tIdx(x,y)]!==1)continue;
const px=x*TILE,py=y*TILE;
if(inB(x,y-1)&&terrain[tIdx(x,y-1)]!==1){c.beginPath();c.moveTo(px,py+1);c.lineTo(px+TILE,py+1);c.stroke();}
if(inB(x,y+1)&&terrain[tIdx(x,y+1)]!==1){c.beginPath();c.moveTo(px,py+TILE-1);c.lineTo(px+TILE,py+TILE-1);c.stroke();}
if(inB(x-1,y)&&terrain[tIdx(x-1,y)]!==1){c.beginPath();c.moveTo(px+1,py);c.lineTo(px+1,py+TILE);c.stroke();}
if(inB(x+1,y)&&terrain[tIdx(x+1,y)]!==1){c.beginPath();c.moveTo(px+TILE-1,py);c.lineTo(px+TILE-1,py+TILE);c.stroke();}
}
}
const mmTerrain=document.createElement('canvas');
function buildMinimapTerrain(){
mmTerrain.width=MAP_W;mmTerrain.height=MAP_H;
const c=mmTerrain.getContext('2d'),img=c.createImageData(MAP_W,MAP_H);
const cols={0:[88,150,70],1:[43,110,163],2:[203,178,106],3:[138,111,71]};
for(let i=0;i<MAP_W*MAP_H;i++){
const col=cols[terrain[i]]||cols[0];
img.data[i*4]=col[0];img.data[i*4+1]=col[1];img.data[i*4+2]=col[2];img.data[i*4+3]=255;
}
c.putImageData(img,0,0);
c.fillStyle='#2f6b34';
for(const r of resources)if(r.kind==='tree')c.fillRect(r.tx,r.ty,1,1);
c.fillStyle='#c8c8d0';
for(const r of resources)if(r.kind==='mine')c.fillRect(r.tx,r.ty,2,2);
}
// ---------------- entity drawing ----------------
function drawTree(r){
const x=(r.tx+0.5)*TILE,y=(r.ty+0.95)*TILE;
const sway=Math.sin(time*1.3+r.tx*0.7+r.ty)*1.4;
ctx.fillStyle='rgba(0,0,0,.25)';ellip(x,y,13,4.5);ctx.fill();
ctx.fillStyle='#6b4a2f';ctx.fillRect(x-2,y-14,4,13);
ctx.fillStyle='#2e6b34';circle(x+sway*0.3,y-26,14);ctx.fill();
ctx.fillStyle='#3c7f3f';circle(x-6+sway*0.5,y-20,10.5);ctx.fill();
ctx.fillStyle='#4c9448';circle(x+6+sway*0.6,y-22,9.5);ctx.fill();
ctx.fillStyle='rgba(255,255,255,.12)';circle(x-3+sway*0.5,y-28,5);ctx.fill();
}
function drawMine(r){
const x=r.tx*TILE,y=r.ty*TILE,w=r.w*TILE,h=r.h*TILE;
const depleted=r.amount<=0;
ctx.fillStyle='rgba(0,0,0,.28)';ellip(x+w/2,y+h-4,w/2-2,7);ctx.fill();
const rocks=[[0.25,0.55,13],[0.55,0.4,16],[0.78,0.62,11],[0.4,0.72,12],[0.68,0.78,9]];
for(const rk of rocks){
ctx.fillStyle=depleted?'#6f6f74':'#8d8d96';
circle(x+rk[0]*w,y+rk[1]*h,rk[2]);ctx.fill();
ctx.strokeStyle='#55555e';ctx.lineWidth=1.5;ctx.stroke();
}
if(!depleted){
ctx.fillStyle='#f2c14e';
const tw=0.5+0.5*Math.sin(time*3+r.id);
for(const g of[[0.35,0.45],[0.6,0.6],[0.48,0.3],[0.72,0.45]]){
circle(x+g[0]*w,y+g[1]*h,2.5+tw);ctx.fill();
}
ctx.fillStyle='rgba(255,240,180,'+(0.4+0.4*tw)+')';
circle(x+0.55*w,y+0.35*h,2);ctx.fill();
}
}
function drawBuilding(b){
const x=b.tx*TILE,y=b.ty*TILE,w=b.w*TILE,h=b.h*TILE;
const selB=selectedBuilding===b;
if(b.state==='constructing'){
ctx.fillStyle='rgba(0,0,0,.25)';rr(x+3,y+4,w-4,h-3,5);ctx.fill();
ctx.fillStyle='#8a6f47';rr(x+3,y+3,w-6,h-6,5);ctx.fill();
ctx.setLineDash([5,4]);ctx.strokeStyle='#d8c49a';ctx.lineWidth=2;rr(x+5,y+5,w-10,h-10,4);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle='#a5835a';
ctx.fillRect(x+w*0.2,y+h*0.3,w*0.5,5);ctx.fillRect(x+w*0.35,y+h*0.55,w*0.5,5);
ctx.fillStyle='#7a5c38';ctx.fillRect(x+w*0.15,y+h*0.15,5,h*0.5);ctx.fillRect(x+w*0.75,y+h*0.35,5,h*0.5);
hpBar(x+4,y-9,w-8,b.progress);
if(selB)drawSelCorners(x,y,w,h);
return;
}
ctx.fillStyle='rgba(0,0,0,.3)';rr(x+5,y+7,w-6,h-4,9);ctx.fill();
if(b.type==='townhall'){
const g=ctx.createLinearGradient(0,y,0,y+h);g.addColorStop(0,'#c9c2b2');g.addColorStop(1,'#948c7c');
ctx.fillStyle=g;rr(x+8,y+16,w-16,h-22,6);ctx.fill();
ctx.strokeStyle='#6e675a';ctx.lineWidth=2;ctx.stroke();
ctx.strokeStyle='rgba(90,70,50,.45)';ctx.lineWidth=3;
ctx.beginPath();ctx.moveTo(x+9,y+h*0.58);ctx.lineTo(x+w-9,y+h*0.58);ctx.stroke();
ctx.fillStyle='#a8493b';
ctx.beginPath();ctx.moveTo(x+1,y+24);ctx.lineTo(x+w/2,y-6);ctx.lineTo(x+w-1,y+24);ctx.closePath();ctx.fill();
ctx.strokeStyle='#7d3329';ctx.lineWidth=2;ctx.stroke();
ctx.strokeStyle='rgba(255,255,255,.18)';ctx.beginPath();ctx.moveTo(x+w/2,y-4);ctx.lineTo(x+w-8,y+22);ctx.stroke();
ctx.fillStyle='#4a3826';rr(x+w/2-9,y+h-27,18,21,3);ctx.fill();
ctx.fillStyle='#ffd97a';rr(x+16,y+h-32,10,10,2);ctx.fill();rr(x+w-26,y+h-32,10,10,2);ctx.fill();
const fw=Math.sin(time*4)*2;
ctx.strokeStyle='#3a3a3a';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x+w/2,y-6);ctx.lineTo(x+w/2,y-22);ctx.stroke();
ctx.fillStyle='#3b82f6';ctx.beginPath();ctx.moveTo(x+w/2,y-22);ctx.lineTo(x+w/2+15+fw,y-18);ctx.lineTo(x+w/2,y-13);ctx.closePath();ctx.fill();
}else if(b.type==='farm'){
ctx.fillStyle='#7b5a36';rr(x+2,y+2,w-4,h-4,6);ctx.fill();
ctx.strokeStyle='#5a4128';ctx.lineWidth=2;ctx.stroke();
for(let i=0;i<4;i++){
ctx.fillStyle=i%2?'#79b356':'#8fc765';
rr(x+6,y+6+i*((h-12)/4),w-12,(h-12)/4-2,2);ctx.fill();
}
ctx.fillStyle='#4e7d33';
for(let i=0;i<4;i++)for(let k=0;k<4;k++)
circle(x+10+k*((w-20)/3),y+8+i*((h-12)/4)+2,1.6),ctx.fill();
ctx.fillStyle='#5a4128';
ctx.fillRect(x+1,y+1,5,5);ctx.fillRect(x+w-6,y+1,5,5);ctx.fillRect(x+1,y+h-6,5,5);ctx.fillRect(x+w-6,y+h-6,5,5);
}else if(b.type==='barracks'){
const g=ctx.createLinearGradient(0,y,0,y+h);g.addColorStop(0,'#b0aaa2');g.addColorStop(1,'#8a847c');
ctx.fillStyle=g;rr(x+6,y+12,w-12,h-18,5);ctx.fill();
ctx.strokeStyle='#66605a';ctx.lineWidth=2;ctx.stroke();
ctx.fillStyle='#b0413e';
ctx.beginPath();ctx.moveTo(x+1,y+18);ctx.lineTo(x+w/2,y-4);ctx.lineTo(x+w-1,y+18);ctx.closePath();ctx.fill();
ctx.strokeStyle='#83302e';ctx.lineWidth=2;ctx.stroke();
ctx.fillStyle='#4a3826';rr(x+w/2-8,y+h-24,16,18,3);ctx.fill();
// crossed swords emblem
ctx.strokeStyle='#e8ecf2';ctx.lineWidth=2.5;
ctx.beginPath();ctx.moveTo(x+14,y+h-30);ctx.lineTo(x+26,y+h-14);ctx.moveTo(x+26,y+h-30);ctx.lineTo(x+14,y+h-14);ctx.stroke();
ctx.fillStyle='#3b82f6';ctx.fillRect(x+w-16,y+h-34,8,14);
}
if(b.hp<b.maxHp)hpBar(x+4,y-9,w-8,b.hp/b.maxHp);
if(selB)drawSelCorners(x,y,w,h);
}
function drawSelCorners(x,y,w,h){
ctx.strokeStyle='#7dff9e';ctx.lineWidth=2.5;
const l=9,p=3;
ctx.beginPath();
ctx.moveTo(x+p,y+p+l);ctx.lineTo(x+p,y+p);ctx.lineTo(x+p+l,y+p);
ctx.moveTo(x+w-p-l,y+p);ctx.lineTo(x+w-p,y+p);ctx.lineTo(x+w-p,y+p+l);
ctx.moveTo(x+w-p,y+h-p-l);ctx.lineTo(x+w-p,y+h-p);ctx.lineTo(x+w-p-l,y+h-p);
ctx.moveTo(x+p+l,y+h-p);ctx.lineTo(x+p,y+h-p);ctx.lineTo(x+p,y+h-p-l);
ctx.stroke();
}
function drawUnit(u){
const x=u.x,y=u.y,isSel=selection.includes(u.id);
const bob=u.moving?Math.sin(time*12+u.id*1.7)*1.3:Math.sin(time*2.5+u.id)*0.6;
ctx.fillStyle='rgba(0,0,0,.3)';ellip(x,y+7,8,3.5);ctx.fill();
if(isSel){ctx.strokeStyle='rgba(125,255,158,.95)';ctx.lineWidth=2;ellip(x,y+6,11,5.5);ctx.stroke();}
const by=y-5+bob*0.3;
ctx.fillStyle=u.type==='worker'?'#3f8ef7':'#2f6fe0';
circle(x,by,7.5);ctx.fill();
ctx.strokeStyle='#1d3f77';ctx.lineWidth=2;ctx.stroke();
ctx.fillStyle='#f2c9a0';circle(x,by-9,4.6);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.3)';ctx.lineWidth=1;ctx.stroke();
if(u.type==='worker'){
ctx.fillStyle='#c9a44a';ellip(x,by-11,6.5,2.6);ctx.fill();
circle(x,by-12.5,3.1);ctx.fill();
if(u.carry){
if(u.carry.kind==='gold'){ctx.fillStyle='#ffd75e';circle(x+8,by-15,3.2);ctx.fill();ctx.strokeStyle='#a87f1f';ctx.lineWidth=1;ctx.stroke();}
else{ctx.fillStyle='#8a5c30';ctx.fillRect(x+5,by-18,9,4);ctx.strokeStyle='#5a3c1c';ctx.lineWidth=1;ctx.strokeRect(x+5,by-18,9,4);}
}
}else{
ctx.fillStyle='#c7ccd4';ctx.beginPath();ctx.arc(x,by-9,5.4,Math.PI,0);ctx.fill();
ctx.strokeStyle='#7a828e';ctx.lineWidth=1.5;ctx.stroke();
const swing=u.cd>0.35?Math.sin((0.8-u.cd)*10)*0.7:0;
const a=u.dir+swing;
ctx.strokeStyle='#e8ecf2';ctx.lineWidth=2.5;
ctx.beginPath();ctx.moveTo(x+Math.cos(a)*5,by+Math.sin(a)*5);ctx.lineTo(x+Math.cos(a)*16,by+Math.sin(a)*16);ctx.stroke();
ctx.strokeStyle='#8a6f3a';ctx.lineWidth=3;
ctx.beginPath();ctx.moveTo(x+Math.cos(a+1.57)*3+Math.cos(a)*6,by+Math.sin(a+1.57)*3+Math.sin(a)*6);
ctx.lineTo(x+Math.cos(a-1.57)*3+Math.cos(a)*6,by+Math.sin(a-1.57)*3+Math.sin(a)*6);ctx.stroke();
}
if(u.hp<u.maxHp||isSel)hpBar(x-12,y-27,24,u.hp/u.maxHp);
}
function drawMonster(m){
const w=Math.sin(time*3+m.id*2.1);
const isSel=selectedMonster===m;
ctx.save();ctx.translate(m.x,m.y);ctx.scale(1+0.08*w,1-0.08*w);
ctx.fillStyle='rgba(0,0,0,.28)';ellip(0,7,11,4);ctx.fill();
const g=ctx.createRadialGradient(-3,-6,2,0,-2,13);
g.addColorStop(0,'#8ee88a');g.addColorStop(1,'#3f9e4d');
ctx.fillStyle=g;circle(0,-2,11);ctx.fill();
ctx.strokeStyle='#2c6e38';ctx.lineWidth=2;ctx.stroke();
ctx.fillStyle='#fff';circle(-4,-6,2.8);ctx.fill();circle(4,-6,2.8);ctx.fill();
ctx.fillStyle='#1a2e1a';circle(-3.6,-6,1.3);ctx.fill();circle(4.4,-6,1.3);ctx.fill();
ctx.strokeStyle='#2c6e38';ctx.lineWidth=1.5;
ctx.beginPath();ctx.arc(0,-1,3.5,0.3,2.8);ctx.stroke();
ctx.restore();
if(isSel){ctx.strokeStyle='rgba(125,255,158,.95)';ctx.lineWidth=2;ellip(m.x,m.y+7,13,6);ctx.stroke();}
if(m.hp<m.maxHp||isSel)hpBar(m.x-12,m.y-22,24,m.hp/m.maxHp);
}
// ---------------- render ----------------
function render(){
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle='#0c140c';ctx.fillRect(0,0,cv.width,cv.height);
const z=zoom*DPR;
ctx.setTransform(z,0,0,z,-cam.x*z,-cam.y*z);
ctx.imageSmoothingEnabled=true;
ctx.drawImage(terrainCanvas,0,0);
// map border
ctx.strokeStyle='rgba(0,0,0,.6)';ctx.lineWidth=6;ctx.strokeRect(0,0,WORLD_W,WORLD_H);
// gather drawables, y-sorted
const vx0=cam.x-60,vy0=cam.y-60,vx1=cam.x+cvW/zoom+60,vy1=cam.y+cvH/zoom+60;
const draw=[];
for(const r of resources){
const cx=(r.tx+r.w/2)*TILE,cy=(r.ty+r.h/2)*TILE;
if(cx<vx0||cx>vx1||cy<vy0||cy>vy1)continue;
if(!tileExplored(r.tx,r.ty))continue;
draw.push({y:(r.ty+r.h)*TILE,k:'r',o:r});
}
for(const b of buildings){
const cx=(b.tx+b.w/2)*TILE,cy=(b.ty+b.h/2)*TILE;
if(cx<vx0||cx>vx1||cy<vy0||cy>vy1)continue;
draw.push({y:(b.ty+b.h)*TILE,k:'b',o:b});
}
for(const u of units){
if(u.x<vx0||u.x>vx1||u.y<vy0||u.y>vy1)continue;
draw.push({y:u.y,k:'u',o:u});
}
for(const m of monsters){
if(m.x<vx0||m.x>vx1||m.y<vy0||m.y>vy1)continue;
if(!tileVisible(Math.floor(m.x/TILE),Math.floor(m.y/TILE)))continue;
draw.push({y:m.y,k:'m',o:m});
}
draw.sort((a,b)=>a.y-b.y);
for(const d of draw){
if(d.k==='r'){d.o.kind==='tree'?drawTree(d.o):drawMine(d.o);}
else if(d.k==='b')drawBuilding(d.o);
else if(d.k==='u')drawUnit(d.o);
else drawMonster(d.o);
}
// particles
for(const p of particles){
const a=clamp(p.life/p.maxLife,0,1);
if(p.kind==='dot'){
ctx.globalAlpha=a;ctx.fillStyle=p.color;circle(p.x,p.y,p.size);ctx.fill();ctx.globalAlpha=1;
}else{
ctx.globalAlpha=a;ctx.strokeStyle=p.color;ctx.lineWidth=2.5;
circle(p.x,p.y,(1-a)*22+4);ctx.stroke();ctx.globalAlpha=1;
}
}
// rally flag
if(selectedBuilding&&selectedBuilding.rally){
const r=selectedBuilding.rally;
ctx.strokeStyle='#2c4a6e';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(r.x,r.y);ctx.lineTo(r.x,r.y-18);ctx.stroke();
ctx.fillStyle='#7cc4ff';ctx.beginPath();ctx.moveTo(r.x,r.y-18);ctx.lineTo(r.x+12,r.y-14);ctx.lineTo(r.x,r.y-10);ctx.closePath();ctx.fill();
}
// floating texts
ctx.font='800 13px Nunito,sans-serif';ctx.textAlign='center';
for(const f of floats){
ctx.globalAlpha=clamp(f.t,0,1);
ctx.lineWidth=3;ctx.strokeStyle='rgba(0,0,0,.7)';ctx.strokeText(f.txt,f.x,f.y);
ctx.fillStyle=f.color;ctx.fillText(f.txt,f.x,f.y);
ctx.globalAlpha=1;
}
// fog of war
ctx.drawImage(fogCanvas,0,0,MAP_W,MAP_H,0,0,WORLD_W,WORLD_H);
// placement ghost
if(placement&&mouse.in){
const size=BUILD_SIZE[placement.type];
const w=screenToWorld(mouse.x,mouse.y);
const tx=Math.floor(w.x/TILE-size/2),ty=Math.floor(w.y/TILE-size/2);
const ok=canPlaceAt(tx,ty,size)&&canAfford(COSTS[placement.type]);
ctx.fillStyle=ok?'rgba(110,255,150,.3)':'rgba(255,90,90,.35)';
ctx.fillRect(tx*TILE,ty*TILE,size*TILE,size*TILE);
ctx.strokeStyle=ok?'#7dff9e':'#ff6b6b';ctx.lineWidth=2;
ctx.strokeRect(tx*TILE,ty*TILE,size*TILE,size*TILE);
}
// selection box (screen space)
if(selBox){
ctx.setTransform(DPR,0,0,DPR,0,0);
const x=Math.min(selBox.x0,selBox.x1),y=Math.min(selBox.y0,selBox.y1);
const w=Math.abs(selBox.x1-selBox.x0),h=Math.abs(selBox.y1-selBox.y0);
ctx.fillStyle='rgba(110,255,150,.12)';ctx.fillRect(x,y,w,h);
ctx.strokeStyle='rgba(125,255,158,.9)';ctx.lineWidth=1.5;ctx.strokeRect(x,y,w,h);
}
}
// ---------------- minimap ----------------
function updateMinimap(){
const MM=180;
mmc.fillStyle='#000';mmc.fillRect(0,0,MM,MM);
mmc.drawImage(mmTerrain,0,0,MM,MM);
mmc.drawImage(fogCanvas,0,0,MM,MM);
const s=MM/WORLD_W;
for(const b of buildings){
mmc.fillStyle=b.state==='done'?'#7cc4ff':'#c9b18a';
mmc.fillRect(b.tx*TILE*s-1.5,b.ty*TILE*s-1.5,4+b.w,4+b.h);
}
mmc.fillStyle='#4ade80';
for(const u of units)mmc.fillRect(u.x*s-1,u.y*s-1,2.5,2.5);
mmc.fillStyle='#ef4444';
for(const m of monsters){
if(!tileVisible(Math.floor(m.x/TILE),Math.floor(m.y/TILE)))continue;
mmc.fillRect(m.x*s-1.5,m.y*s-1.5,3,3);
}
mmc.strokeStyle='rgba(255,255,255,.85)';mmc.lineWidth=1;
mmc.strokeRect(cam.x*s,cam.y*s,cvW/zoom*s,cvH/zoom*s);
}
// ---------------- UI ----------------
function toast(msg){
const box=$('toasts');
while(box.children.length>=4)box.removeChild(box.firstChild);
const t=document.createElement('div');
t.className='toast';t.textContent=msg;
box.appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .4s';},2200);
setTimeout(()=>t.remove(),2700);
}
function fmtTime(t){const m=Math.floor(t/60),s=Math.floor(t%60);return m+':'+(s<10?'0':'')+s;}
function updateTopbar(){
$('rGold').textContent=Math.floor(gold);
$('rWood').textContent=Math.floor(wood);
$('rSup').textContent=supplyUsed()+'/'+supplyCap();
$('rExp').textContent=Math.floor(exploredCount/(MAP_W*MAP_H)*100)+'%';
$('rTime').textContent=fmtTime(gameTime);
}
function hpHtml(hp,max){
const pct=Math.round(hp/max*100);
return `<div class="bar"><i style="width:${pct}%"></i></div><div class="psub">HP ${Math.ceil(hp)} / ${max}</div>`;
}
function buildBtnHtml(t){
const c=COSTS[t],ok=canAfford(c);
const icons={farm:'🌾',barracks:'🛡️',townhall:'🏰'};
const hk={farm:'F',barracks:'R',townhall:'T'};
return `<button class="cmd ${ok?'':'dis'}" onmousedown="cmdBuild('${t}')"><span class="ic">${icons[t]}</span><span class="nm">${NAMES[t]}</span><span class="cost">${c.gold?c.gold+'🪙 ':''}${c.wood?c.wood+'🪵':''} <u>${hk[t]}</u></span></button>`;
}
function trainBtnHtml(t){
const c=COSTS[t];
const ok=canAfford(c)&&supplyUsed()+c.supply<=supplyCap();
const hk=t==='worker'?'W':'S';
return `<button class="cmd ${ok?'':'dis'}" onmousedown="cmdTrain('${t}')"><span class="ic">${t==='worker'?'👷':'🗡️'}</span><span class="nm">${NAMES[t]}</span><span class="cost">${c.gold}🪙 · ${c.supply}👥 <u>${hk}</u></span></button>`;
}
let lastSig='';
function updatePanel(){
let sig='';
if(selectedMonster)sig='M'+selectedMonster.id+'|'+selectedMonster.hp;
else if(selectedBuilding){
const b=selectedBuilding;
sig='B'+b.id+'|'+b.state+'|'+Math.round(b.progress*40)+'|'+b.queue.length+'|'+(b.queue[0]?Math.round(b.queue[0].t*3):'')+'|'+Math.floor(gold)+'|'+Math.floor(wood)+'|'+supplyUsed();
}
else if(selection.length)sig='U'+selection.join(',')+'|'+Math.floor(gold)+'|'+Math.floor(wood);
else sig='N';
if(sig===lastSig)return;
lastSig=sig;
renderPanel();
}
function renderPanel(){
const info=$('pinfo'),act=$('pactions');
let ih='',ah='';
if(selectedMonster){
const m=selectedMonster;
ih=`<div class="prow"><div class="portrait">👾</div><div><div class="pname">Wild Slime</div>${hpHtml(m.hp,m.maxHp)}<div class="psub">Bounty: 25🪙 · perfectly harmless</div></div></div>`;
ah=`<div class="hint">Right-click it with a soldier (or worker) to attack.</div>`;
}else if(selectedBuilding){
const b=selectedBuilding;
const icons={townhall:'🏰',farm:'🌾',barracks:'🛡️'};
ih=`<div class="prow"><div class="portrait">${icons[b.type]}</div><div><div class="pname">${NAMES[b.type]}</div>`;
if(b.state==='constructing')
ih+=`<div class="bar"><i style="width:${Math.round(b.progress*100)}%"></i></div><div class="psub">Building… ${Math.floor(b.progress*100)}%</div>`;
else ih+=hpHtml(b.hp,b.maxHp);
if(b.type==='farm'&&b.state==='done')ih+=`<div class="psub">+8 supply</div>`;
if(b.type==='townhall'&&b.state==='done')ih+=`<div class="psub">+12 supply · resource drop-off</div>`;
ih+=`</div></div>`;
if(b.state==='done'&&(b.type==='townhall'||b.type==='barracks')){
ah+=trainBtnHtml(b.type==='townhall'?'worker':'soldier');
if(b.queue.length){
ah+=`<div class="queue">`+b.queue.map((q,i)=>
`<div class="qitem" onmousedown="cancelTrain(${i})" title="Click to cancel (refund)">${q.type==='worker'?'👷':'🗡️'}${i===0?`<div class="bar mini" style="width:30px"><i style="width:${Math.round((1-q.t/COSTS[q.type].time)*100)}%"></i></div>`:''}</div>`
).join('')+`</div>`;
}
ah+=`<div class="hint">Right-click on the map to set a rally point.</div>`;
}else if(b.state==='constructing'){
ah=`<div class="hint">Under construction — right-click it with a worker to help finish.</div>`;
}
}else if(selection.length>1){
const us=selection.map(id=>byId(units,id)).filter(Boolean);
const nw=us.filter(u=>u.type==='worker').length;
ih=`<div class="pname">${us.length} units</div><div class="psub">${nw} 👷 · ${us.length-nw} 🗡️</div><div class="grid">`+
us.slice(0,18).map(u=>`<div class="cell">${u.type==='worker'?'👷':'🗡️'}</div>`).join('')+`</div>`;
if(nw)ah+=buildBtnHtml('farm')+buildBtnHtml('barracks')+buildBtnHtml('townhall');
ah+=`<div class="hint">Right-click: move · gather · attack</div>`;
}else if(selection.length===1){
const u=byId(units,selection[0]);
if(u){
ih=`<div class="prow"><div class="portrait">${u.type==='worker'?'👷':'🗡️'}</div><div><div class="pname">${NAMES[u.type]}</div>${hpHtml(u.hp,u.maxHp)}<div class="psub">${
u.type==='worker'?(u.carry?'Carrying '+u.carry.amt+(u.carry.kind==='gold'?'🪙':'🪵'):'Gathers resources & builds'):'Combat unit'}</div></div></div>`;
if(u.type==='worker'){
ah+=buildBtnHtml('farm')+buildBtnHtml('barracks')+buildBtnHtml('townhall');
ah+=`<div class="hint">Right-click a gold mine or tree to gather.</div>`;
}else ah+=`<div class="hint">Right-click: move · attack slimes for bounty.</div>`;
}
}else{
ih=`<div class="pname">🗺️ Objective</div><div class="psub">Explore <b>95%</b> of the map to win.<br><br>Drag to select workers, then right-click a gold mine or tree to gather. Build 🌾 Farms for supply and 🛡️ Barracks for soldiers.</div>`;
}
info.innerHTML=ih;act.innerHTML=ah;
}
$('helpBtn').onclick=()=>{$('help').style.display='flex';};
$('startBtn').onclick=()=>{$('help').style.display='none';};
$('keepBtn').onclick=()=>{$('victory').style.display='none';};
// ---------------- boot ----------------
genMap();
prerenderTerrain();
buildMinimapTerrain();
updateFog();
updateTopbar();
renderPanel();
setTimeout(()=>toast('🗺️ Explore 95% of the map to win!'),600);
setTimeout(()=>toast('👷 Select workers and right-click a mine or tree'),3200);
let last=performance.now();
function loop(t){
const dt=Math.min(0.05,(t-last)/1000);last=t;
update(dt);render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment