Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 20, 2026 07:12
Show Gist options
  • Select an option

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

Select an option

Save senko/125a7abfcee55be6ef193a22cfd0036b to your computer and use it in GitHub Desktop.
RTS game by Qwen 3.8 Max Preview
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tiny Realms — Mini 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='80' font-size='80'>⚔️</text></svg>">
<style>
:root{
--gold:#e8c66a; --gold-dim:#a8894a; --panel:#241c0e; --panel2:#33291a;
--border:#6b532f; --border-hi:#9a7a44; --text:#efe4c8; --dim:#b3a482;
--blue:#6fc3ff; --red:#ff6a5e; --green:#7ddf7d;
}
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%; overflow:hidden; background:#0d0b07; color:var(--text);
font-family:ui-sans-serif,system-ui,"Segoe UI",Roboto,sans-serif; user-select:none; -webkit-user-select:none;}
#app{display:flex; flex-direction:column; height:100%;}
/* ---------- top bar ---------- */
#topbar{height:44px; flex:0 0 44px; display:flex; align-items:center; gap:18px; padding:0 14px;
background:linear-gradient(#322814,#1b1408); border-bottom:2px solid var(--border);
box-shadow:0 2px 8px rgba(0,0,0,.6); z-index:5;}
#topbar .logo{font-family:Georgia,'Times New Roman',serif; font-weight:700; letter-spacing:1px;
color:var(--gold); font-size:17px; text-shadow:0 1px 0 #000, 0 0 12px rgba(232,198,106,.25); white-space:nowrap;}
.res{display:flex; align-items:center; gap:7px; font-size:15px; font-variant-numeric:tabular-nums; min-width:70px;}
.res b{font-weight:600; color:#fff;}
.res.warn b{color:var(--red);}
.ico{width:14px; height:14px; display:inline-block; flex:0 0 14px; box-shadow:0 1px 2px rgba(0,0,0,.6);}
.ico.gold{border-radius:50%; background:radial-gradient(circle at 35% 30%, #ffefad, #e3b45c 55%, #8a6a2a);}
.ico.wood{border-radius:3px; background:linear-gradient(160deg,#a9814d,#6a4a26 70%);}
.ico.food{border-radius:50% 50% 45% 45%; background:radial-gradient(circle at 40% 30%, #ffd9a0, #d0763a 70%);}
.ico.map{border-radius:3px; border:1px solid #7c93a8; background:linear-gradient(45deg,#2c3f50 50%,#3d576c 50%);}
#mapBar{width:64px; height:7px; background:#14100a; border:1px solid #4a3a20; border-radius:4px; overflow:hidden;}
#mapBar i{display:block; height:100%; width:0%; background:linear-gradient(90deg,#4f8f5a,#8fd67f); transition:width .3s;}
.spacer{flex:1;}
.tbtn{background:linear-gradient(#3d3122,#221a0c);
border:1px solid var(--border); color:var(--text); font-size:13px; padding:5px 12px; border-radius:6px;
cursor:pointer; box-shadow:inset 0 1px 0 rgba(255,255,255,.08);}
.tbtn:hover{border-color:var(--border-hi); color:#fff;}
.tbtn:active{transform:translateY(1px);}
/* ---------- stage ---------- */
#stage{flex:1; position:relative; overflow:hidden; background:#000;}
#cv{position:absolute; inset:0; display:block;}
#toast{position:absolute; left:50%; bottom:26px; transform:translateX(-50%); background:rgba(15,11,5,.92);
border:1px solid var(--border); color:var(--text); padding:8px 18px; border-radius:8px; font-size:14px;
pointer-events:none; opacity:0; transition:opacity .35s; max-width:80%; text-align:center;
box-shadow:0 4px 16px rgba(0,0,0,.6); z-index:6;}
#toast.show{opacity:1;}
#banner{position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(5,4,2,.55); z-index:8;}
#banner .card{background:linear-gradient(#322814,#1b1408); border:2px solid var(--gold-dim); border-radius:14px;
padding:34px 54px; text-align:center; box-shadow:0 10px 50px rgba(0,0,0,.8), 0 0 60px rgba(232,198,106,.15);}
#banner h1{font-family:Georgia,serif; color:var(--gold); font-size:34px; margin-bottom:10px; letter-spacing:1px;}
#banner p{color:var(--dim); margin-bottom:22px; font-size:15px;}
#banner button{font-size:15px; padding:9px 26px; border-radius:8px; border:1px solid var(--border-hi);
background:linear-gradient(#4a3a1e,#2a2010); color:#fff; cursor:pointer;}
#banner button:hover{border-color:var(--gold); color:var(--gold);}
.hidden{display:none !important;}
#help{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(5,4,2,.6); z-index:9;}
#help .card{background:linear-gradient(#2c2312,#17110a); border:2px solid var(--border); border-radius:14px;
padding:26px 34px; max-width:640px; width:92%; box-shadow:0 10px 50px rgba(0,0,0,.8);}
#help h2{font-family:Georgia,serif; color:var(--gold); margin-bottom:14px; font-size:22px;}
#help table{width:100%; border-collapse:collapse; font-size:13.5px; margin-bottom:14px;}
#help td{padding:4px 8px; border-bottom:1px solid rgba(107,83,47,.35); vertical-align:top;}
#help td:first-child{color:var(--gold); white-space:nowrap; width:150px; font-weight:600;}
#help .obj{color:var(--dim); font-size:13.5px; line-height:1.55;}
#help .obj b{color:var(--text);}
#help .close{margin-top:16px; text-align:right;}
/* ---------- bottom panel ---------- */
#bottom{height:182px; flex:0 0 182px; display:flex; gap:10px; padding:10px;
background:linear-gradient(#241c0e,#151007); border-top:2px solid var(--border); z-index:5;}
#miniWrap{position:relative; width:162px; height:162px; flex:0 0 162px; border:2px solid var(--border);
border-radius:6px; overflow:hidden; background:#0a1420; box-shadow:inset 0 0 12px rgba(0,0,0,.7);}
#mini{width:162px; height:162px; display:block;}
#info{flex:1; min-width:0; border:2px solid var(--border); border-radius:6px; background:rgba(10,8,4,.55);
padding:10px 14px; overflow:hidden; display:flex; flex-direction:column; gap:6px;}
#info .name{font-family:Georgia,serif; font-size:16px; color:var(--gold); letter-spacing:.5px;}
#info .sub{font-size:12.5px; color:var(--dim);}
.bar{height:9px; background:#14100a; border:1px solid #4a3a20; border-radius:5px; overflow:hidden; max-width:340px;}
.bar i{display:block; height:100%; background:linear-gradient(90deg,#3f9f4a,#7ddf7d);}
.bar.mid i{background:linear-gradient(90deg,#b98a2a,#e8c66a);}
.bar.low i{background:linear-gradient(90deg,#a33227,#ff6a5e);}
.bar.build i{background:linear-gradient(90deg,#2a6ab9,#6fc3ff);}
#info .row{display:flex; align-items:center; gap:10px; font-size:13px; flex-wrap:wrap;}
#info .cnt{display:inline-flex; align-items:center; gap:4px; background:rgba(255,255,255,.05);
border:1px solid rgba(107,83,47,.5); border-radius:5px; padding:2px 7px;}
#info .cnt canvas{width:20px; height:20px;}
#info .q{display:inline-flex; gap:5px; align-items:center;}
#info .q canvas{width:26px; height:26px; border:1px solid var(--border); border-radius:4px; background:rgba(0,0,0,.35);}
#info .q .prog{position:relative; width:26px; height:26px;}
#info .q .prog::after{content:''; position:absolute; left:0; right:0; bottom:0; background:rgba(111,195,255,.35);
height:var(--p,0%);}
#card{width:212px; flex:0 0 212px; display:grid; grid-template-columns:repeat(3,1fr); gap:6px; align-content:start;}
.btn{position:relative; height:52px; border:1px solid var(--border); border-radius:7px; cursor:pointer;
background:linear-gradient(#3a2f1c,#221a0c); box-shadow:inset 0 1px 0 rgba(255,255,255,.07), 0 2px 4px rgba(0,0,0,.5);
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px; padding:2px;}
.btn:hover{border-color:var(--gold); background:linear-gradient(#4a3c22,#2a2010);}
.btn:active{transform:translateY(1px);}
.btn canvas{width:30px; height:30px; pointer-events:none;}
.btn .lbl{font-size:9.5px; color:var(--dim); line-height:1; pointer-events:none; white-space:nowrap;}
.btn .cost{font-size:9.5px; line-height:1; pointer-events:none; font-variant-numeric:tabular-nums;}
.btn .cost .g{color:#ffd75e;} .btn .cost .w{color:#a5d67e;}
.btn .cost .no{color:var(--red);}
.btn .key{position:absolute; top:2px; left:4px; font-size:9px; color:#8a7a55; font-weight:700; pointer-events:none;}
.btn.dis{opacity:.45; filter:grayscale(.6);}
.btn.dis:hover{border-color:var(--border); background:linear-gradient(#3a2f1c,#221a0c);}
</style>
</head>
<body>
<div id="app">
<div id="topbar">
<div class="logo">⚔ TINY REALMS</div>
<div class="res" title="Gold — mined from gold mines by workers"><span class="ico gold"></span><b id="vGold">0</b></div>
<div class="res" title="Lumber — chopped from trees by workers"><span class="ico wood"></span><b id="vWood">0</b></div>
<div class="res" id="resFood" title="Food — build Farms to raise the limit"><span class="ico food"></span><b id="vFood">0/0</b></div>
<div class="res" title="Map explored — uncover 100% to win!"><span class="ico map"></span><b id="vMap">0%</b><div id="mapBar"><i></i></div></div>
<div class="spacer"></div>
<button class="tbtn" id="btnSpeed" title="Game speed (P)">▶ 1×</button>
<button class="tbtn" id="btnHelp" title="Help (F1)">?</button>
</div>
<div id="stage">
<canvas id="cv"></canvas>
<div id="toast"></div>
<div id="banner" class="hidden">
<div class="card">
<h1>🏆 The Realm is Revealed!</h1>
<p>Every corner of the map has been uncovered. Victory is yours —<br>the sandbox remains open: build, gather and conquer at leisure.</p>
<button id="btnContinue">Keep Playing</button>
</div>
</div>
<div id="help" class="hidden">
<div class="card">
<h2>How to Play</h2>
<table>
<tr><td>Left drag</td><td>box-select units</td></tr>
<tr><td>Left click</td><td>select unit / building · <b>Shift</b> adds · <b>double-click</b> selects all of that type</td></tr>
<tr><td>Right click</td><td>move · gather resource · attack beast · build / repair site · set rally point (on a training building)</td></tr>
<tr><td>Arrow keys / screen edges</td><td>scroll the map · <b>Space</b> centers on selection</td></tr>
<tr><td>B</td><td>build menu (workers selected)</td></tr>
<tr><td>A + click / S / H</td><td>attack-move / stop / hold position</td></tr>
<tr><td>P</td><td>game speed: pause → 1× → 2× → 4×</td></tr>
<tr><td>Esc</td><td>cancel placement / deselect</td></tr>
</table>
<div class="obj">
<b>Objective:</b> mine <b>gold</b>, chop <b>lumber</b>, expand your base and raise an army.
Uncover <b>100% of the map</b> to win. Beware the beasts that roam the realm — they attack
anyone who wanders close, but drop gold bounties when slain. Guard Towers shoot them on sight.
</div>
<div class="close"><button class="tbtn" id="btnHelpClose">Close (Esc)</button></div>
</div>
</div>
</div>
<div id="bottom">
<div id="miniWrap"><canvas id="mini" width="162" height="162"></canvas></div>
<div id="info"><div id="infoInner"></div></div>
<div id="card"></div>
</div>
</div>
<script>
"use strict";
/* ================================================================
Tiny Realms — a miniature real-time strategy game in one file.
================================================================ */
/* ---------------- polyfill ---------------- */
if(!CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
r=Math.min(r,w/2,h/2);
this.moveTo(x+r,y); this.arcTo(x+w,y,x+w,y+h,r); this.arcTo(x+w,y+h,x,y+h,r);
this.arcTo(x,y+h,x,y,r); this.arcTo(x,y,x+w,y,r); this.closePath(); return this;
};
}
/* ---------------- utils ---------------- */
const T=32, MAP_W=64, MAP_H=64, WORLD_W=MAP_W*T, WORLD_H=MAP_H*T, SQ2=Math.SQRT2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const dist=(ax,ay,bx,by)=>Math.hypot(ax-bx,ay-by);
const rand=(a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b));
const pick=a=>a[randi(0,a.length)];
function hash2(x,y){let h=(x*374761393+y*668265263)|0; h=(h^(h>>13))*1274126177|0; h^=h>>16; return (h>>>0)/4294967295;}
const tileX=px=>Math.floor(px/T), tileY=py=>Math.floor(py/T);
const tcx=tx=>(tx+0.5)*T, tcy=ty=>(ty+0.5)*T;
/* ---------------- defs ---------------- */
const UNITS={
worker: {name:'Worker', gold:75, wood:0, food:1, time:12, hp:60, speed:66, radius:8, sight:6, dmg:3, range:18, cd:1.2, canGather:true, canBuild:true},
footman:{name:'Footman', gold:60, wood:0, food:2, time:14, hp:130, speed:76, radius:9, sight:7, dmg:12, range:20, cd:1.0, military:true},
archer: {name:'Archer', gold:80, wood:20, food:2, time:16, hp:85, speed:80, radius:8, sight:8, dmg:13, range:150, cd:1.3, military:true, ranged:true, projSpeed:340},
knight: {name:'Knight', gold:160, wood:60, food:3, time:22, hp:280, speed:96, radius:11, sight:7, dmg:24, range:26, cd:1.1, military:true},
};
const BUILDS={
townhall:{name:'Town Hall', gold:400, wood:150, time:45, hp:1500, w:3, h:3, sight:9, food:10, trains:['worker'], dropoff:true},
farm: {name:'Farm', gold:80, wood:20, time:14, hp:400, w:2, h:2, sight:5, food:6},
barracks:{name:'Barracks', gold:180, wood:40, time:26, hp:900, w:3, h:2, sight:6, trains:['footman','archer','knight']},
tower: {name:'Guard Tower', gold:110, wood:70, time:22, hp:550, w:2, h:2, sight:9, dmg:11, range:220, cd:0.9},
};
const CREEPDEFS={
wolf:{name:'Dire Wolf', hp:70, speed:90, radius:9, sight:5, dmg:8, range:18, cd:1.0, bounty:12},
ogre:{name:'Ogre', hp:200, speed:56, radius:12, sight:5.5, dmg:18, range:24, cd:1.4, bounty:30},
};
const CARRY=20, TREE_AMOUNT=120, MINE_AMOUNT=25000, MINE_WORK_T=1.6, WOOD_WORK_T=2.0, FOOD_MAX=80;
/* ---------------- state ---------------- */
const S={
gold:500, wood:250, foodUsed:0, foodCap:0,
time:0, speed:1, paused:false,
explored:0, totalTiles:MAP_W*MAP_H, won:false,
sel:[], placing:null, buildMenu:false,
cam:{x:0,y:0}, attackMoveMode:false,
};
let units=[], creeps=[], buildings=[], trees=[], rocks=[], mines=[];
let projectiles=[], particles=[], floaters=[], corpses=[];
let nextId=1;
const GRASS=0, WATER=1;
const terrain=new Uint8Array(MAP_W*MAP_H);
const blocked=new Uint8Array(MAP_W*MAP_H);
const fog=new Uint8Array(MAP_W*MAP_H); // 0 unexplored, 1 explored, 2 visible
let visList=[];
const idx=(tx,ty)=>ty*MAP_W+tx;
function isBlocked(tx,ty){ if(tx<0||ty<0||tx>=MAP_W||ty>=MAP_H)return true; const i=idx(tx,ty); return blocked[i]===1||terrain[i]===WATER; }
function setBlock(tx,ty,v){ if(tx>=0&&ty>=0&&tx<MAP_W&&ty<MAP_H) blocked[idx(tx,ty)]=v?1:0; }
function setBlockRect(tx,ty,w,h,v){ for(let y=0;y<h;y++)for(let x=0;x<w;x++) setBlock(tx+x,ty+y,v); }
/* ---------------- map generation ---------------- */
let startTX=10, startTY=MAP_H-11;
function genWorld(){
terrain.fill(GRASS);
// water blobs
const blobs=randi(3,6);
for(let i=0;i<blobs;i++){
let x=randi(5,MAP_W-5), y=randi(5,MAP_H-5);
const n=randi(35,100);
for(let j=0;j<n;j++){
const r=randi(1,3);
for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
if(dx*dx+dy*dy>r*r+1)continue;
const tx=x+dx, ty=y+dy;
if(tx>1&&ty>1&&tx<MAP_W-2&&ty<MAP_H-2) terrain[idx(tx,ty)]=WATER;
}
if(Math.random()<0.6) x=clamp(x+randi(-1,2),2,MAP_W-3); else y=clamp(y+randi(-1,2),2,MAP_H-3);
}
}
// clear start area
for(let dy=-6;dy<=6;dy++)for(let dx=-6;dx<=6;dx++){
const tx=startTX+dx, ty=startTY+dy;
if(tx>=0&&ty>=0&&tx<MAP_W&&ty<MAP_H&&dx*dx+dy*dy<=38) terrain[idx(tx,ty)]=GRASS;
}
// town hall
const th=makeBuilding('townhall',startTX-1,startTY-1);
th.built=true; th.progress=th.def.time; th.hp=th.maxHp;
buildings.push(th); setBlockRect(th.tileX,th.tileY,th.w,th.h,1);
th.rally={x:th.x,y:(th.tileY+th.h)*T+18};
// mines
const mineSpots=[];
const addMine=(tx,ty)=>{
for(let dy=-1;dy<=2;dy++)for(let dx=-1;dx<=2;dx++){
const x=tx+dx,y=ty+dy;
if(x>=0&&y>=0&&x<MAP_W&&y<MAP_H) terrain[idx(x,y)]=GRASS;
}
const m=makeMine(tx,ty); mines.push(m); setBlockRect(tx,ty,2,2,1); mineSpots.push(m);
};
{ // guaranteed nearby mine
const a=rand(0,Math.PI*2), d=rand(6,8);
let tx=Math.round(startTX+Math.cos(a)*d), ty=Math.round(startTY+Math.sin(a)*d);
tx=clamp(tx,2,MAP_W-4); ty=clamp(ty,2,MAP_H-4);
addMine(tx,ty);
}
for(let i=0;i<4;i++){
for(let tries=0;tries<60;tries++){
const tx=randi(3,MAP_W-5), ty=randi(3,MAP_H-5);
if(terrain[idx(tx,ty)]!==GRASS)continue;
if(dist(tx,ty,startTX,startTY)<13)continue;
if(mineSpots.some(m=>dist(tx,ty,m.tx,m.ty)<15))continue;
addMine(tx,ty); break;
}
}
// forests
const addTree=(tx,ty)=>{
if(tx<1||ty<1||tx>=MAP_W-1||ty>=MAP_H-1)return false;
if(terrain[idx(tx,ty)]!==GRASS||blocked[idx(tx,ty)])return false;
if(dist(tx,ty,startTX,startTY)<5.5)return false;
if(mines.some(m=>tx>=m.tx-2&&tx<=m.tx+3&&ty>=m.ty-2&&ty<=m.ty+3))return false;
const t=makeTree(tx,ty); trees.push(t); setBlock(tx,ty,1); return true;
};
{ // guaranteed trees near start
const a=rand(0,Math.PI*2), cx=Math.round(startTX+Math.cos(a)*6.5), cy=Math.round(startTY+Math.sin(a)*6.5);
for(let j=0;j<14;j++) addTree(cx+randi(-2,3),cy+randi(-2,3));
}
const forests=randi(10,14);
for(let i=0;i<forests;i++){
let x=randi(3,MAP_W-3), y=randi(3,MAP_H-3);
if(dist(x,y,startTX,startTY)<8)continue;
const n=randi(8,26);
for(let j=0;j<n;j++){
addTree(x+randi(-1,2),y+randi(-1,2));
if(Math.random()<0.5)x=clamp(x+randi(-1,2),2,MAP_W-3); else y=clamp(y+randi(-1,2),2,MAP_H-3);
}
}
for(let i=0;i<26;i++) addTree(randi(2,MAP_W-2),randi(2,MAP_H-2));
// rocks
const addRock=(tx,ty)=>{
if(tx<1||ty<1||tx>=MAP_W-1||ty>=MAP_H-1)return;
if(terrain[idx(tx,ty)]!==GRASS||blocked[idx(tx,ty)])return;
if(dist(tx,ty,startTX,startTY)<6)return;
if(mines.some(m=>tx>=m.tx-2&&tx<=m.tx+3&&ty>=m.ty-2&&ty<=m.ty+3))return;
const r=makeRock(tx,ty); rocks.push(r); setBlock(tx,ty,1);
};
for(let i=0;i<5;i++){
const x=randi(3,MAP_W-3), y=randi(3,MAP_H-3);
const n=randi(2,6);
for(let j=0;j<n;j++) addRock(x+randi(-2,3),y+randi(-2,3));
}
for(let i=0;i<16;i++) addRock(randi(2,MAP_W-2),randi(2,MAP_H-2));
// creep camps
const camps=[];
for(let i=0;i<7;i++){
for(let tries=0;tries<80;tries++){
const tx=randi(4,MAP_W-4), ty=randi(4,MAP_H-4);
if(terrain[idx(tx,ty)]!==GRASS)continue;
if(dist(tx,ty,startTX,startTY)<15)continue;
if(camps.some(c=>dist(tx,ty,c.x,c.y)<11))continue;
camps.push({x:tx,y:ty});
terrain[idx(tx,ty)]=GRASS;
const nw=randi(2,4), ogre=Math.random()<(i<2?0.9:0.4);
for(let k=0;k<nw;k++) spawnCreep('wolf',tcx(tx+randi(-2,3)),tcy(ty+randi(-2,3)));
if(ogre) spawnCreep('ogre',tcx(tx+randi(-1,2)),tcy(ty+randi(-1,2)));
break;
}
}
// mine spots (adjacent free tiles)
for(const m of mines) computeMineSpots(m);
// starting workers
for(let i=0;i<5;i++){
const a=i/5*Math.PI*2, r=44;
const u=makeUnit('worker',tcx(startTX)+Math.cos(a)*r,tcy(startTY)+Math.sin(a)*r+20);
units.push(u);
}
recomputeFood();
S.foodUsed=units.reduce((s,u)=>s+u.def.food,0);
// camera + selection
centerOn(tcx(startTX),tcy(startTY));
setSelection(units.slice());
}
/* ---------------- entity factories ---------------- */
function makeUnit(type,x,y){
const d=UNITS[type];
return {id:nextId++, kind:'unit', type, def:d, team:'player', x, y, px:x, py:y,
hp:d.hp, maxHp:d.hp, face:1, order:{type:'idle'}, target:null, path:null,
cd:0, scanT:rand(0,0.3), repathT:0, carry:null, carryAmt:0,
walkPhase:rand(0,6), moving:false, attackAnim:0, swing:0, hitFlash:0, dead:false};
}
function spawnCreep(type,x,y){
const d=CREEPDEFS[type];
creeps.push({id:nextId++, kind:'creep', type, def:d, team:'neutral', x, y, px:x, py:y,
hp:d.hp, maxHp:d.hp, face:Math.random()<0.5?-1:1, order:{type:'idle'}, target:null, path:null,
cd:0, scanT:rand(0,0.3), repathT:0, walkPhase:rand(0,6), moving:false, attackAnim:0, hitFlash:0,
home:{x,y}, wanderT:rand(1,5), dead:false});
}
function makeBuilding(type,tx,ty){
const d=BUILDS[type];
return {id:nextId++, kind:'building', type, def:d, team:'player', tileX:tx, tileY:ty, w:d.w, h:d.h,
x:(tx+d.w/2)*T, y:(ty+d.h/2)*T, hp:Math.max(1,Math.floor(d.hp*0.05)), maxHp:d.hp,
progress:0, built:false, queue:[], trainT:0, cd:0, rally:null, target:null, flash:0, hitFlash:0, dead:false};
}
function makeTree(tx,ty){ return {id:nextId++, kind:'tree', tx, ty, x:tcx(tx), y:tcy(ty), amount:TREE_AMOUNT, v:hash2(tx,ty), dead:false}; }
function makeRock(tx,ty){ return {id:nextId++, kind:'rock', tx, ty, x:tcx(tx), y:tcy(ty), v:hash2(tx*3+7,ty*5+1), dead:false}; }
function makeMine(tx,ty){ return {id:nextId++, kind:'mine', tx, ty, x:(tx+1)*T, y:(ty+1)*T, amount:MINE_AMOUNT, v:hash2(tx*11+3,ty*7+5), spots:[], dead:false}; }
function computeMineSpots(m){
m.spots=[];
for(let ty=m.ty-1;ty<=m.ty+2;ty++)for(let tx=m.tx-1;tx<=m.tx+2;tx++){
if(tx>=m.tx&&tx<=m.tx+1&&ty>=m.ty&&ty<=m.ty+1)continue;
if(!isBlocked(tx,ty)) m.spots.push({x:tcx(tx),y:tcy(ty)});
}
}
/* ---------------- pathfinding ---------------- */
class Heap{
constructor(){this.a=[];}
get size(){return this.a.length;}
push(v,p){const a=this.a;let i=a.length;a.push({v,p});while(i>0){const j=(i-1)>>1;if(a[j].p<=p)break;const t=a[i];a[i]=a[j];a[j]=t;i=j;}}
pop(){const a=this.a;const top=a[0];const last=a.pop();if(a.length){a[0]=last;let i=0;const n=a.length;for(;;){let l=2*i+1,r=l+1,j=i;if(l<n&&a[l].p<a[j].p)j=l;if(r<n&&a[r].p<a[j].p)j=r;if(j===i)break;const t=a[i];a[i]=a[j];a[j]=t;i=j;}}return top?top.v:undefined;}
}
function nearestFree(tx,ty,maxR){
if(!isBlocked(tx,ty))return {x:tx,y:ty};
for(let r=1;r<=maxR;r++){
let best=null,bd=1e9;
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;
const x=tx+dx,y=ty+dy;
if(x<0||y<0||x>=MAP_W||y>=MAP_H)continue;
if(!isBlocked(x,y)){ const d=dx*dx+dy*dy; if(d<bd){bd=d;best={x,y};} }
}
if(best)return best;
}
return null;
}
function lineClearPx(x0,y0,x1,y1){
let tx0=tileX(x0),ty0=tileY(y0); const tx1=tileX(x1),ty1=tileY(y1);
const dx=Math.abs(tx1-tx0), dy=Math.abs(ty1-ty0);
const sx=tx0<tx1?1:-1, sy=ty0<ty1?1:-1;
let err=dx-dy;
for(let i=0;i<200;i++){
if(isBlocked(tx0,ty0))return false;
if(tx0===tx1&&ty0===ty1)return true;
const e2=2*err;
if(e2>-dy){err-=dy;tx0+=sx;}
if(e2<dx){err+=dx;ty0+=sy;}
}
return false;
}
const octile=(x,y,gx,gy)=>{const dx=Math.abs(x-gx),dy=Math.abs(y-gy);return (dx+dy)+(SQ2-2)*Math.min(dx,dy);};
function findPath(sx,sy,gx,gy){
sx=clamp(sx,0,MAP_W-1); sy=clamp(sy,0,MAP_H-1);
gx=clamp(gx,0,MAP_W-1); gy=clamp(gy,0,MAP_H-1);
if(isBlocked(gx,gy)){ const f=nearestFree(gx,gy,12); if(!f)return null; gx=f.x; gy=f.y; }
if(sx===gx&&sy===gy)return [];
const N=MAP_W*MAP_H;
const g=new Float32Array(N).fill(1e15);
const from=new Int32Array(N).fill(-1);
const closed=new Uint8Array(N);
const si=idx(sx,sy); g[si]=0;
const heap=new Heap(); heap.push(si,octile(sx,sy,gx,gy));
let guard=0;
while(heap.size&&guard++<8000){
const cur=heap.pop();
if(closed[cur])continue; closed[cur]=1;
const cx=cur%MAP_W, cy=(cur/MAP_W)|0;
if(cx===gx&&cy===gy){
const path=[]; let c=cur;
while(c!==si){ path.push(c); c=from[c]; }
path.reverse();
const pts=path.map(i=>({x:tcx(i%MAP_W),y:tcy((i/MAP_W)|0)}));
return smoothPath({x:tcx(sx),y:tcy(sy)},pts);
}
for(const [dx,dy,cost] of [[1,0,1],[-1,0,1],[0,1,1],[0,-1,1],[1,1,SQ2],[1,-1,SQ2],[-1,1,SQ2],[-1,-1,SQ2]]){
const nx=cx+dx, ny=cy+dy;
if(nx<0||ny<0||nx>=MAP_W||ny>=MAP_H)continue;
if(isBlocked(nx,ny))continue;
if(dx&&dy&&(isBlocked(cx+dx,cy)||isBlocked(cx,cy+dy)))continue;
const ni=idx(nx,ny);
if(closed[ni])continue;
const ng=g[cur]+cost;
if(ng<g[ni]){ g[ni]=ng; from[ni]=cur; heap.push(ni,ng+octile(nx,ny,gx,gy)); }
}
}
return null;
}
function smoothPath(start,pts){
const all=[start,...pts];
if(all.length<3)return pts;
const out=[];
let i=0;
while(i<all.length-1){
let j=all.length-1;
while(j>i+1&&!lineClearPx(all[i].x,all[i].y,all[j].x,all[j].y))j--;
if(j>0)out.push(all[j]);
i=j;
}
return out;
}
/* ---------------- economy helpers ---------------- */
function canAfford(c){ return S.gold>=(c.gold||0)&&S.wood>=(c.wood||0); }
function pay(c){ S.gold-=(c.gold||0); S.wood-=(c.wood||0); }
function refund(c,f=1){ S.gold+=Math.floor((c.gold||0)*f); S.wood+=Math.floor((c.wood||0)*f); }
function recomputeFood(){
let cap=0;
for(const b of buildings) if(!b.dead&&b.built&&b.def.food) cap+=b.def.food;
S.foodCap=Math.min(FOOD_MAX,cap);
}
function nearestDropoff(x,y){
let best=null,bd=1e9;
for(const b of buildings){
if(b.dead||!b.built||!b.def.dropoff)continue;
const d=dist(x,y,b.x,b.y);
if(d<bd){bd=d;best=b;}
}
return best;
}
function nearestTree(x,y,maxTiles){
let best=null,bd=(maxTiles*T)*(maxTiles*T);
for(const t of trees){
if(t.dead||t.amount<=0)continue;
const d=(t.x-x)*(t.x-x)+(t.y-y)*(t.y-y);
if(d<bd){bd=d;best=t;}
}
return best;
}
function nearestMine(x,y,maxTiles){
let best=null,bd=(maxTiles*T)*(maxTiles*T);
for(const m of mines){
if(m.dead||m.amount<=0)continue;
const d=(m.x-x)*(m.x-x)+(m.y-y)*(m.y-y);
if(d<bd){bd=d;best=m;}
}
return best;
}
/* ---------------- orders ---------------- */
function setOrderMove(u,x,y,type='move'){
u.order={type,x,y}; u.target=null;
u.path=findPath(tileX(u.x),tileY(u.y),tileX(x),tileY(y));
if(!u.path){ float(u.x,u.y-22,'No path','#ff9a8a'); u.order={type:'idle'}; }
}
function orderGather(u,res){ u.order={type:'gather',res,phase:'toRes',t:0}; u.path=null; u.target=null; }
function orderAttack(u,t){ u.order={type:'attack',target:t,resume:null,hold:null}; u.target=t; u.path=null; }
function startReturn(u,prevRes){
const th=nearestDropoff(u.x,u.y);
if(!th){ u.order={type:'idle'}; return; }
u.order={type:'return',drop:th,phase:'toDrop',t:0,prevRes:prevRes||null};
u.path=null;
}
function perimeterPoint(e,ux,uy){
// nearest free tile center adjacent to entity footprint
let tx,ty,w,h;
if(e.kind==='building'){ tx=e.tileX; ty=e.tileY; w=e.w; h=e.h; }
else if(e.kind==='mine'){ tx=e.tx; ty=e.ty; w=2; h=2; }
else { tx=e.tx; ty=e.ty; w=1; h=1; }
let best=null,bd=1e9;
for(let y=ty-1;y<=ty+h;y++)for(let x=tx-1;x<=tx+w;x++){
if(x>=tx&&x<tx+w&&y>=ty&&y<ty+h)continue;
if(isBlocked(x,y))continue;
const px=tcx(x),py=tcy(y);
const d=(px-ux)*(px-ux)+(py-uy)*(py-uy);
if(d<bd){bd=d;best={x:px,y:py};}
}
return best;
}
function formationOffset(i,n){
if(n<=1)return {x:0,y:0};
const a=i*2.4, r=16*Math.sqrt(i);
return {x:Math.cos(a)*r,y:Math.sin(a)*r};
}
/* ---------------- movement ---------------- */
function tileBlockedAtPx(px,py){ return isBlocked(tileX(px),tileY(py)); }
function moveAlong(u,dt){
if(!u.path||!u.path.length){ u.moving=false; return true; }
// skip waypoints reachable in a straight line
while(u.path.length>1&&lineClearPx(u.x,u.y,u.path[1].x,u.path[1].y)) u.path.shift();
const wp=u.path[0];
const dx=wp.x-u.x, dy=wp.y-u.y;
const d=Math.hypot(dx,dy);
const step=u.def.speed*dt;
u.moving=true;
if(d<=Math.max(4,step)){
u.x=wp.x; u.y=wp.y; u.path.shift();
if(!u.path.length)u.moving=false;
}else{
const nx=u.x+dx/d*step, ny=u.y+dy/d*step;
if(!tileBlockedAtPx(nx,ny)){ u.x=nx; u.y=ny; }
else if(!tileBlockedAtPx(nx,u.y)){ u.x=nx; }
else if(!tileBlockedAtPx(u.x,ny)){ u.y=ny; }
else { u.path=null; u.moving=false; return true; }
if(dx>0.5)u.face=1; else if(dx<-0.5)u.face=-1;
}
u.x=clamp(u.x,6,WORLD_W-6); u.y=clamp(u.y,6,WORLD_H-6);
u.walkPhase+=dt*(u.def.speed/9);
return !u.path||!u.path.length;
}
function separate(){
const mobs=units.concat(creeps);
for(let i=0;i<mobs.length;i++){
const a=mobs[i];
if(a.dead)continue;
for(let j=i+1;j<mobs.length;j++){
const b=mobs[j];
if(b.dead)continue;
const dx=b.x-a.x, dy=b.y-a.y;
const rr=a.def.radius+b.def.radius;
const d2=dx*dx+dy*dy;
if(d2>0.01&&d2<rr*rr){
const d=Math.sqrt(d2), push=(rr-d)/2*0.4;
const ux=dx/d, uy=dy/d;
a.x-=ux*push; a.y-=uy*push; b.x+=ux*push; b.y+=uy*push;
}
}
// push out of blocked tiles (e.g. building completed under unit)
if(tileBlockedAtPx(a.x,a.y)){
const f=nearestFree(tileX(a.x),tileY(a.y),6);
if(f){ a.x=tcx(f.x); a.y=tcy(f.y); }
}
}
}
/* ---------------- combat ---------------- */
function faceTo(u,t){ u.face=(t.x>=u.x)?1:-1; }
function tryAttack(u,t){
if(u.cd>0)return;
u.cd=u.def.cd; u.attackAnim=0.28; faceTo(u,t);
if(u.def.ranged){
projectiles.push({x:u.x+u.face*5,y:u.y-14,target:t,speed:u.def.projSpeed||340,dmg:u.def.dmg,color:'#ffe9a0'});
}else{
damage(t,u.def.dmg,u);
burst(t.x,t.y-t.def.radius,'#ffd27d',4,60);
}
}
function damage(t,dmg,from){
if(t.dead)return;
t.hp-=dmg; t.hitFlash=0.13;
if(t.hp<=0) kill(t,from);
}
function kill(t,from){
if(t.dead)return;
t.dead=true; t.hp=0;
corpses.push({x:t.x,y:t.y,r:t.def.radius+4,t:0,max:9,color:t.kind==='creep'?'rgba(122,60,60,':'rgba(90,50,50,'});
burst(t.x,t.y-8,'#c0564a',12,110);
if(t.kind==='creep'){
S.gold+=t.def.bounty;
float(t.x,t.y-18,'+'+t.def.bounty+' gold','#ffd75e');
}
if(t.kind==='unit'){ S.foodUsed=Math.max(0,S.foodUsed-t.def.food); }
if(t.kind==='building'){
setBlockRect(t.tileX,t.tileY,t.w,t.h,0);
recomputeFood();
corpses.push({x:t.x,y:t.y,r:t.w*T*0.4,t:0,max:14,color:'rgba(110,105,95,'});
}
if(S.sel.includes(t)){ S.sel=S.sel.filter(e=>e!==t); refreshCard(); refreshInfo(); }
}
function autoAcquire(u){
const R=u.def.sight*T;
let best=null,bd=R*R;
for(const c of creeps){
if(c.dead)continue;
const dx=c.x-u.x, dy=c.y-u.y, d2=dx*dx+dy*dy;
if(d2<bd){bd=d2;best=c;}
}
if(!best)return false;
if(u.order.type==='hold'&&Math.sqrt(bd)>u.def.range+best.def.radius+6)return false;
u.order={type:'attack',target:best,
resume:u.order.type==='attackmove'?{type:'attackmove',x:u.order.x,y:u.order.y}:null,
hold:u.order.type==='hold'?{x:u.x,y:u.y}:null};
u.target=best; u.path=null;
return true;
}
/* ---------------- unit update ---------------- */
function updateUnit(u,dt){
u.cd-=dt; u.hitFlash-=dt; u.attackAnim-=dt;
const o=u.order;
switch(o.type){
case 'idle':
u.moving=false;
u.scanT-=dt;
if(u.scanT<=0){ u.scanT=0.28; if(u.def.military)autoAcquire(u); }
break;
case 'hold':
u.moving=false;
u.scanT-=dt;
if(u.scanT<=0){ u.scanT=0.28; autoAcquire(u); }
break;
case 'move':
if(moveAlong(u,dt)) u.order={type:'idle'};
break;
case 'attackmove':
u.scanT-=dt;
if(u.scanT<=0){ u.scanT=0.28; if(autoAcquire(u))break; }
if(moveAlong(u,dt)) u.order={type:'idle'};
break;
case 'attack':{
const t=u.target=o.target;
if(!t||t.dead){
if(o.resume){ setOrderMove(u,o.resume.x,o.resume.y,'attackmove'); }
else if(o.hold){ u.order={type:'hold'}; }
else u.order={type:'idle'};
break;
}
const d=dist(u.x,u.y,t.x,t.y)-t.def.radius;
if(d<=u.def.range+3){
u.path=null; u.moving=false; tryAttack(u,t);
}else if(o.hold){
u.moving=false;
}else{
if(!u.path||!u.path.length||u.repathT<=0){
u.path=findPath(tileX(u.x),tileY(u.y),tileX(t.x),tileY(t.y));
u.repathT=0.35;
}
u.repathT-=dt;
moveAlong(u,dt);
}
break;
}
case 'gather': updateGather(u,dt); break;
case 'return': updateReturn(u,dt); break;
case 'build': updateBuild(u,dt); break;
}
if(u.moving)u.swing=0;
}
function updateGather(u,dt){
const o=u.order, res=o.res;
if(!res||res.dead||res.amount<=0){
if(u.carryAmt>0){ startReturn(u,null); return; }
const alt=res&&res.kind==='tree'?nearestTree(u.x,u.y,18):nearestMine(u.x,u.y,60);
if(alt){ orderGather(u,alt); return; }
u.order={type:'idle'};
float(u.x,u.y-22,'Depleted','#cfcfcf');
return;
}
if(o.phase==='toRes'){
const p=perimeterPoint(res,u.x,u.y);
if(!p){ u.order={type:'idle'}; return; }
if(!u.path||!u.path.length){
if(dist(u.x,u.y,p.x,p.y)<16){ o.phase='work'; o.t=res.kind==='mine'?MINE_WORK_T:WOOD_WORK_T; return; }
u.path=findPath(tileX(u.x),tileY(u.y),tileX(p.x),tileY(p.y));
if(!u.path){ u.order={type:'idle'}; float(u.x,u.y-22,'No path','#ff9a8a'); return; }
}
if(moveAlong(u,dt)||dist(u.x,u.y,p.x,p.y)<14){
u.path=null; u.moving=false; o.phase='work'; o.t=res.kind==='mine'?MINE_WORK_T:WOOD_WORK_T;
}
}else if(o.phase==='work'){
u.moving=false; u.swing+=dt;
faceTo(u,res);
o.t-=dt;
if(o.t<=0){
const amt=Math.min(CARRY,res.amount);
res.amount-=amt;
u.carryAmt=amt; u.carry=res.kind==='mine'?'gold':'wood';
burst(res.x,res.y-10,res.kind==='mine'?'#ffd75e':'#a5d67e',3,50);
if(res.kind==='tree'&&res.amount<=0) removeTree(res);
startReturn(u,res);
}
}
}
function updateReturn(u,dt){
const o=u.order;
let b=o.drop;
if(!b||b.dead){
b=nearestDropoff(u.x,u.y);
if(!b){ u.order={type:'idle'}; return; }
o.drop=b; u.path=null;
}
if(o.phase==='toDrop'){
const p=perimeterPoint(b,u.x,u.y);
if(!p){ u.order={type:'idle'}; return; }
if(!u.path||!u.path.length){
if(dist(u.x,u.y,p.x,p.y)<18){ o.phase='drop'; o.t=0.35; return; }
u.path=findPath(tileX(u.x),tileY(u.y),tileX(p.x),tileY(p.y));
if(!u.path){ u.order={type:'idle'}; return; }
}
if(moveAlong(u,dt)||dist(u.x,u.y,p.x,p.y)<16){ u.path=null; u.moving=false; o.phase='drop'; o.t=0.35; }
}else{
u.moving=false;
o.t-=dt;
if(o.t<=0){
if(u.carry==='gold')S.gold+=u.carryAmt; else S.wood+=u.carryAmt;
float(b.x,b.y-b.h*T/2-14,'+'+u.carryAmt,u.carry==='gold'?'#ffd75e':'#a5d67e');
u.carryAmt=0; u.carry=null;
const res=o.prevRes;
if(res&&!res.dead&&res.amount>0) orderGather(u,res);
else{
const alt=res&&res.kind==='tree'?nearestTree(u.x,u.y,18):nearestMine(u.x,u.y,60);
if(alt)orderGather(u,alt); else u.order={type:'idle'};
}
}
}
}
function updateBuild(u,dt){
const b=u.order.b;
if(!b||b.dead||b.built){ u.order={type:'idle'}; return; }
const p=perimeterPoint(b,u.x,u.y);
if(!p){ u.order={type:'idle'}; return; }
if(dist(u.x,u.y,p.x,p.y)>30){
if(!u.path||!u.path.length||u.repathT<=0){
u.path=findPath(tileX(u.x),tileY(u.y),tileX(p.x),tileY(p.y));
u.repathT=0.6;
}
u.repathT-=dt;
moveAlong(u,dt);
}else{
u.path=null; u.moving=false; u.swing+=dt; faceTo(u,b);
b.progress+=dt;
b.hp=Math.max(b.hp,Math.ceil(b.maxHp*Math.min(1,b.progress/b.def.time)));
if(Math.random()<dt*2.5)
particles.push({x:b.x+rand(-b.w*T/2,b.w*T/2),y:b.y+rand(-6,b.h*T/2),vx:rand(-8,8),vy:rand(-30,-12),t:0,max:0.7,size:rand(1.5,3),color:'#c9b18a'});
if(b.progress>=b.def.time) completeBuilding(b);
}
}
/* ---------------- buildings ---------------- */
function completeBuilding(b){
if(b.built)return;
b.built=true; b.progress=b.def.time; b.hp=b.maxHp;
recomputeFood();
if(!b.rally) b.rally={x:b.x,y:(b.tileY+b.h)*T+16};
float(b.x,b.y-b.h*T/2-10,'Construction complete','#9fe0ff');
burst(b.x,b.y,'#ffe9a0',14,130);
refreshInfo();
}
function enqueueTrain(b,type){
if(!b||b.dead)return;
if(!b.built){ toast('Building is still under construction'); return; }
const d=UNITS[type];
if(!canAfford(d)){ toast('Not enough resources'); return; }
if(S.foodUsed+d.food>S.foodCap){ toast('Not enough food — build a Farm'); return; }
if(b.queue.length>=5){ toast('Queue is full'); return; }
pay(d);
b.queue.push(type);
refreshInfo(); refreshCard();
}
function cancelLastQueued(b){
if(!b.queue.length)return;
const type=b.queue.pop();
refund(UNITS[type]);
refreshInfo(); refreshCard();
}
function updateTraining(b,dt){
if(!b.built||!b.queue.length)return;
b.trainT+=dt;
while(b.queue.length){
const d=UNITS[b.queue[0]];
if(b.trainT<d.time)break;
b.trainT-=d.time;
spawnTrained(b,b.queue.shift());
}
}
function spawnTrained(b,type){
const f=nearestFree(b.tileX+Math.floor(b.w/2),b.tileY+b.h,8);
const x=f?tcx(f.x):b.x, y=f?tcy(f.y):b.y;
const u=makeUnit(type,x,y);
units.push(u);
S.foodUsed+=u.def.food;
burst(u.x,u.y-8,'#9fe0ff',6,70);
const r=b.rally;
if(r){
if(r.ent&&!r.ent.dead){
if(r.ent.kind==='tree'||r.ent.kind==='mine') orderGather(u,r.ent);
else if(r.ent.kind==='creep') orderAttack(u,r.ent);
else setOrderMove(u,r.ent.x,r.ent.y);
}else setOrderMove(u,r.x!==undefined?r.x:b.x,r.y!==undefined?r.y:(b.tileY+b.h)*T+16);
}
refreshInfo();
}
function updateTower(b,dt){
if(!b.built||!b.def.dmg)return;
b.cd-=dt;
let best=null,bd=b.def.range*b.def.range;
for(const c of creeps){
if(c.dead)continue;
const dx=c.x-b.x, dy=c.y-b.y, d2=dx*dx+dy*dy;
if(d2<bd){bd=d2;best=c;}
}
b.target=best;
if(best&&b.cd<=0){
b.cd=b.def.cd; b.flash=0.16;
projectiles.push({x:b.x,y:b.y-b.h*T*0.55,target:best,speed:400,dmg:b.def.dmg,color:'#9fe0ff'});
}
}
function cancelConstruction(b){
refund(b.def,0.75);
b.dead=true;
setBlockRect(b.tileX,b.tileY,b.w,b.h,0);
for(const u of units) if(u.order.type==='build'&&u.order.b===b) u.order={type:'idle'};
float(b.x,b.y-20,'Cancelled (75% refund)','#cfcfcf');
S.sel=S.sel.filter(e=>e!==b);
refreshCard(); refreshInfo();
}
/* ---------------- creeps ---------------- */
function updateCreep(c,dt){
c.cd-=dt; c.hitFlash-=dt; c.attackAnim-=dt;
const o=c.order;
if(o.type==='idle'){
if(c.hp<c.maxHp)c.hp=Math.min(c.maxHp,c.hp+6*dt);
c.wanderT-=dt;
if(c.wanderT<=0){
c.wanderT=rand(3,7);
const a=rand(0,Math.PI*2), r=rand(0,2*T);
const tx=clamp(c.home.x+Math.cos(a)*r,8,WORLD_W-8), ty=clamp(c.home.y+Math.sin(a)*r,8,WORLD_H-8);
c.path=findPath(tileX(c.x),tileY(c.y),tileX(tx),tileY(ty));
}
moveAlong(c,dt);
c.scanT-=dt;
if(c.scanT<=0){
c.scanT=0.3;
const R=c.def.sight*T;
let best=null,bd=R*R;
for(const u of units){
if(u.dead)continue;
const dx=u.x-c.x, dy=u.y-c.y, d2=dx*dx+dy*dy;
if(d2<bd){bd=d2;best=u;}
}
if(best){ c.order={type:'attack',target:best}; c.path=null; }
}
}else if(o.type==='attack'){
const t=o.target;
if(!t||t.dead){ c.order={type:'return'}; c.path=null; return; }
if(dist(c.x,c.y,c.home.x,c.home.y)>9.5*T){ c.order={type:'return'}; c.path=null; return; }
const d=dist(c.x,c.y,t.x,t.y)-t.def.radius;
if(d<=c.def.range+3){ c.path=null; c.moving=false; tryAttack(c,t); }
else{
if(!c.path||!c.path.length||c.repathT<=0){
c.path=findPath(tileX(c.x),tileY(c.y),tileX(t.x),tileY(t.y));
c.repathT=0.4;
}
c.repathT-=dt;
moveAlong(c,dt);
}
}else if(o.type==='return'){
if(dist(c.x,c.y,c.home.x,c.home.y)<12){ c.order={type:'idle'}; c.path=null; return; }
if(!c.path||!c.path.length) c.path=findPath(tileX(c.x),tileY(c.y),tileX(c.home.x),tileY(c.home.y));
moveAlong(c,dt);
}
}
/* ---------------- trees / projectiles / effects ---------------- */
function removeTree(t){
if(t.dead)return;
t.dead=true;
setBlock(t.tx,t.ty,0);
corpses.push({x:t.x,y:t.y,r:8,t:0,max:12,color:'rgba(106,74,38,'});
burst(t.x,t.y-10,'#a5d67e',8,90);
}
function updateProjectiles(dt){
for(const p of projectiles){
const t=p.target;
if(!t||t.dead){ p.dead=true; burst(p.x,p.y,'#d8d8d8',3,40); continue; }
const dx=t.x-p.x, dy=(t.y-t.def.radius*0.6)-p.y;
const d=Math.hypot(dx,dy);
const step=p.speed*dt;
if(d<=step+6){
damage(t,p.dmg,null);
burst(t.x,t.y-t.def.radius,'#ffe9a0',4,60);
p.dead=true;
}else{
p.x+=dx/d*step; p.y+=dy/d*step;
p.ang=Math.atan2(dy,dx);
}
}
projectiles=projectiles.filter(p=>!p.dead);
}
function burst(x,y,color,n,spd){
for(let i=0;i<n;i++){
const a=rand(0,Math.PI*2), s=rand(spd*0.3,spd);
particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-20,t:0,max:rand(0.3,0.7),size:rand(1.5,3.2),color});
}
if(particles.length>500)particles.splice(0,particles.length-500);
}
function float(x,y,text,color){
floaters.push({x,y,text,color,t:0,max:1.1});
if(floaters.length>60)floaters.shift();
}
function updateEffects(dt){
for(const p of particles){ p.t+=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=140*dt; }
particles=particles.filter(p=>p.t<p.max);
for(const f of floaters){ f.t+=dt; f.y-=26*dt; }
floaters=floaters.filter(f=>f.t<f.max);
for(const c of corpses)c.t+=dt;
corpses=corpses.filter(c=>c.t<c.max);
}
/* ---------------- fog of war ---------------- */
function updateFog(){
for(const i of visList) if(fog[i]===2)fog[i]=1;
visList.length=0;
const stamp=(wx,wy,rTiles)=>{
const cx=Math.floor(wx/T), cy=Math.floor(wy/T), r=Math.ceil(rTiles), r2=rTiles*rTiles;
for(let dy=-r;dy<=r;dy++){
const ty=cy+dy;
if(ty<0||ty>=MAP_H)continue;
for(let dx=-r;dx<=r;dx++){
const tx=cx+dx;
if(tx<0||tx>=MAP_W)continue;
if(dx*dx+dy*dy>r2)continue;
const i=idx(tx,ty);
if(fog[i]!==2){
if(fog[i]===0)S.explored++;
fog[i]=2; visList.push(i);
}
}
}
};
for(const u of units) if(!u.dead) stamp(u.x,u.y,u.def.sight);
for(const b of buildings) if(!b.dead) stamp(b.x,b.y,b.def.sight);
if(!S.won&&S.explored>=S.totalTiles) win();
}
function win(){
S.won=true;
document.getElementById('banner').classList.remove('hidden');
}
/* ---------------- world update ---------------- */
function updateWorld(dt){
for(const u of units) if(!u.dead) updateUnit(u,dt);
for(const c of creeps) if(!c.dead) updateCreep(c,dt);
for(const b of buildings){
if(b.dead)continue;
b.hitFlash-=dt; b.flash-=dt;
updateTraining(b,dt);
updateTower(b,dt);
}
separate();
updateProjectiles(dt);
updateEffects(dt);
units=units.filter(u=>!u.dead);
creeps=creeps.filter(c=>!c.dead);
buildings=buildings.filter(b=>!b.dead);
trees=trees.filter(t=>!t.dead);
}
/* ================================================================
RENDERING
================================================================ */
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
let vw=800, vh=600, dpr=1;
function resize(){
const st=document.getElementById('stage');
vw=st.clientWidth; vh=st.clientHeight;
dpr=Math.min(2,window.devicePixelRatio||1);
cv.width=Math.round(vw*dpr); cv.height=Math.round(vh*dpr);
cv.style.width=vw+'px'; cv.style.height=vh+'px';
clampCam();
}
function clampCam(){
S.cam.x=clamp(S.cam.x,0,Math.max(0,WORLD_W-vw));
S.cam.y=clamp(S.cam.y,0,Math.max(0,WORLD_H-vh));
if(WORLD_W<vw)S.cam.x=(WORLD_W-vw)/2;
if(WORLD_H<vh)S.cam.y=(WORLD_H-vh)/2;
}
function centerOn(x,y){
S.cam.x=x-vw/2; S.cam.y=y-vh/2; clampCam();
}
/* ---- ground pre-render ---- */
const ground=document.createElement('canvas');
ground.width=WORLD_W; ground.height=WORLD_H;
function buildGround(){
const g=ground.getContext('2d');
const grassCols=['#5d9440','#578d3c','#639a45','#5a9040'];
for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){
const h=hash2(tx,ty);
g.fillStyle=terrain[idx(tx,ty)]===WATER?'#2e6bb0':grassCols[Math.floor(h*grassCols.length)%grassCols.length];
g.fillRect(tx*T,ty*T,T,T);
}
// soft large patches
for(let i=0;i<160;i++){
const x=rand(0,WORLD_W), y=rand(0,WORLD_H), r=rand(40,130);
g.fillStyle=Math.random()<0.5?'rgba(255,255,230,0.045)':'rgba(20,60,10,0.05)';
g.beginPath(); g.ellipse(x,y,r,r*0.6,rand(0,3),0,Math.PI*2); g.fill();
}
// water shading + edges
for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){
if(terrain[idx(tx,ty)]!==WATER)continue;
const x=tx*T,y=ty*T;
const gr=g.createLinearGradient(x,y,x,y+T);
gr.addColorStop(0,'#3574bd'); gr.addColorStop(1,'#2a5f9e');
g.fillStyle=gr; g.fillRect(x,y,T,T);
g.fillStyle='rgba(10,30,60,0.25)';
if(ty>0&&terrain[idx(tx,ty-1)]===WATER)g.fillRect(x,y,T,2);
g.fillStyle='rgba(220,240,255,0.35)';
if(ty>0&&terrain[idx(tx,ty-1)]!==WATER)g.fillRect(x,y,T,2);
if(tx>0&&terrain[idx(tx-1,ty)]!==WATER)g.fillRect(x,y,2,T);
}
// decals on grass
for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){
if(terrain[idx(tx,ty)]!==GRASS)continue;
const h=hash2(tx*5+1,ty*3+2), x=tx*T, y=ty*T;
if(h>0.93){ // flowers
const cols=['#f2f0da','#f0d473','#e8a5c0'];
for(let i=0;i<3;i++){
g.fillStyle=cols[i%3];
g.fillRect(x+6+i*9+(h*7|0),y+8+((h*97+i*31)%18),2.4,2.4);
}
}else if(h<0.05){ // pebbles
g.fillStyle='rgba(120,115,100,0.5)';
g.beginPath(); g.ellipse(x+10+h*100%12,y+14,3,2.2,0,0,Math.PI*2); g.fill();
}else if(h>0.55&&h<0.62){ // grass tufts
g.strokeStyle='rgba(40,80,30,0.5)'; g.lineWidth=1;
for(let i=0;i<3;i++){
g.beginPath();
g.moveTo(x+8+i*7,y+22);
g.lineTo(x+8+i*7+(i-1)*2,y+15);
g.stroke();
}
}
}
}
/* ---- minimap base ---- */
const miniBase=document.createElement('canvas');
miniBase.width=MAP_W; miniBase.height=MAP_H;
function buildMiniBase(){
const g=miniBase.getContext('2d');
for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){
const h=hash2(tx,ty);
g.fillStyle=terrain[idx(tx,ty)]===WATER?(h>0.5?'#2e6bb0':'#2a63a8'):(h>0.5?'#4f8436':'#4a7d33');
g.fillRect(tx,ty,1,1);
}
}
/* ---- sprites ---- */
function ellipse(ctx,x,y,rx,ry){ ctx.beginPath(); ctx.ellipse(x,y,rx,ry,0,0,Math.PI*2); ctx.fill(); }
function shadow(ctx,x,y,r){ ctx.fillStyle='rgba(0,0,0,0.28)'; ellipse(ctx,x,y+1,r,r*0.38); }
function drawHumanoid(ctx,x,y,o){
// o: {body,trim,pants,skin,phase,moving,face,attackAnim,swing,tool,carry,helm,hood,big}
const bob=o.moving?Math.sin(o.phase)*1.3:0;
const ls=o.moving?Math.sin(o.phase)*2.6:0;
ctx.save(); ctx.translate(x,y+bob); if(o.face<0)ctx.scale(-1,1);
// legs
ctx.fillStyle=o.pants||'#4a3a26';
ctx.fillRect(-4,-7-Math.max(0,ls)*0.4,3.4,7+Math.max(0,ls)*0.4);
ctx.fillRect(0.8,-7-Math.max(0,-ls)*0.4,3.4,7+Math.max(0,-ls)*0.4);
// body
ctx.fillStyle=o.body;
ctx.beginPath(); ctx.roundRect(-5.5,-17,11,11,3); ctx.fill();
ctx.fillStyle=o.trim||'rgba(0,0,0,0.25)';
ctx.fillRect(-5.5,-9.5,11,2.2);
// back arm
ctx.fillStyle=o.body;
ctx.fillRect(-7,-16,3,7);
// head
ctx.fillStyle=o.skin||'#eac08f';
ctx.beginPath(); ctx.arc(0,-21,4.2,0,Math.PI*2); ctx.fill();
if(o.helm){
ctx.fillStyle='#9aa3ad';
ctx.beginPath(); ctx.arc(0,-22,4.4,Math.PI,0); ctx.fill();
ctx.fillRect(-4.4,-22.5,8.8,2.4);
ctx.fillStyle=o.trim||'#4a86d8';
ctx.beginPath(); ctx.ellipse(0,-27,1.6,3.4,0,0,Math.PI*2); ctx.fill(); // plume
}else if(o.hood){
ctx.fillStyle=o.hood;
ctx.beginPath(); ctx.arc(0,-21.5,4.8,Math.PI*0.9,Math.PI*2.1); ctx.lineTo(4,-17) ; ctx.lineTo(-5,-17); ctx.fill();
}else if(o.hat){
ctx.fillStyle=o.hat;
ctx.beginPath(); ctx.arc(0,-22.5,4.5,Math.PI,0); ctx.fill();
ctx.fillRect(-5.5,-22.8,11,1.8);
}
// front arm + tool
const atk=o.attackAnim>0?o.attackAnim/0.28:0;
const sw=o.swing?Math.sin(o.swing*9)*0.7:0;
ctx.save();
ctx.translate(5,-14);
if(o.tool==='pick'){
ctx.rotate(-0.9+sw+(atk?atk*1.2:0));
ctx.fillStyle='#7a5a34'; ctx.fillRect(-1,0,2.2,11);
ctx.fillStyle='#b9b0a0';
ctx.beginPath(); ctx.roundRect(-5,9,10,3,1.5); ctx.fill();
}else if(o.tool==='axe'){
ctx.rotate(-0.8+sw);
ctx.fillStyle='#7a5a34'; ctx.fillRect(-1,0,2.2,11);
ctx.fillStyle='#c8cdd4';
ctx.beginPath(); ctx.roundRect(-1,8,6,5,1.5); ctx.fill();
}else if(o.tool==='sword'){
ctx.rotate(-1.2+atk*2.0+sw*0.3);
ctx.fillStyle='#d7dbe0'; ctx.fillRect(-1,-11,2.6,12);
ctx.fillStyle='#8a6a2a'; ctx.fillRect(-3,0,7,2.4);
}else if(o.tool==='bow'){
ctx.rotate(atk?-0.3:0.15);
ctx.strokeStyle='#8a6a3a'; ctx.lineWidth=1.8;
ctx.beginPath(); ctx.arc(2,0,8,-1.2,1.2); ctx.stroke();
ctx.strokeStyle='#e8e4d4'; ctx.lineWidth=0.8;
ctx.beginPath(); ctx.moveTo(2+8*Math.cos(-1.2),8*Math.sin(-1.2)); ctx.lineTo(2+8*Math.cos(1.2),8*Math.sin(1.2)); ctx.stroke();
if(atk>0.4){ ctx.strokeStyle='#d7dbe0'; ctx.lineWidth=1.2; ctx.beginPath(); ctx.moveTo(-4,0); ctx.lineTo(9,0); ctx.stroke(); }
}else if(o.tool==='lance'){
ctx.rotate(0.5);
ctx.fillStyle='#7a5a34'; ctx.fillRect(-1,-16,2.4,24);
ctx.fillStyle='#d7dbe0';
ctx.beginPath(); ctx.moveTo(-1.6,-16); ctx.lineTo(1.6,-16); ctx.lineTo(0,-22); ctx.fill();
}else{
ctx.fillStyle=o.body; ctx.fillRect(-1.5,0,3,6);
}
ctx.restore();
// carry sack
if(o.carry){
ctx.fillStyle=o.carry==='gold'?'#d9a94f':'#7a5a34';
ctx.beginPath(); ctx.arc(-6.5,-12,3.6,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.35)';
ctx.beginPath(); ctx.arc(-7.5,-13,1.2,0,Math.PI*2); ctx.fill();
}
ctx.restore();
}
function drawUnitSprite(ctx,u){
shadow(ctx,u.x,u.y,u.def.radius);
const o={phase:u.walkPhase,moving:u.moving,face:u.face,attackAnim:u.attackAnim,swing:u.swing,carry:u.carry};
if(u.type==='worker') drawHumanoid(ctx,u.x,u.y,{...o,body:'#8a6a3f',trim:'#4a86d8',pants:'#5a4632',hat:'#4a86d8',tool:(u.order.type==='gather'||u.order.type==='build')?'pick':null});
else if(u.type==='footman') drawHumanoid(ctx,u.x,u.y,{...o,body:'#8d97a3',trim:'#4a86d8',pants:'#5a5148',helm:true,tool:'sword'});
else if(u.type==='archer') drawHumanoid(ctx,u.x,u.y,{...o,body:'#5a7a4a',trim:'#3a5230',pants:'#4a4238',hood:'#3f6638',tool:'bow'});
else if(u.type==='knight'){
// horse
const ph=u.walkPhase;
ctx.save(); ctx.translate(u.x,u.y); if(u.face<0)ctx.scale(-1,1);
ctx.fillStyle='#6e5236';
const legY=Math.sin(ph)*2;
ctx.fillRect(-8,-6+legY,2.6,7); ctx.fillRect(-3,-6-legY,2.6,7);
ctx.fillRect(3,-6+legY*0.7,2.6,7); ctx.fillRect(7,-6-legY*0.7,2.6,7);
ctx.beginPath(); ctx.ellipse(0,-10,11,6.5,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#5d452c';
ctx.beginPath(); ctx.ellipse(10,-15,4,3.4,-0.5,0,Math.PI*2); ctx.fill(); // head
ctx.beginPath(); ctx.moveTo(8,-18); ctx.lineTo(10,-21); ctx.lineTo(11.5,-17.5); ctx.fill(); // ear
ctx.strokeStyle='#4a3620'; ctx.lineWidth=1.6;
ctx.beginPath(); ctx.moveTo(-10,-12); ctx.quadraticCurveTo(-15,-8,-13,-3); ctx.stroke(); // tail
ctx.restore();
drawHumanoid(ctx,u.x,u.y-8,{...o,moving:false,body:'#9aa3ad',trim:'#4a86d8',helm:true,tool:'lance'});
}
if(u.hitFlash>0){
ctx.fillStyle='rgba(255,255,255,'+Math.min(0.6,u.hitFlash*4)+')';
ellipse(ctx,u.x,u.y-12,u.def.radius,u.def.radius*1.2);
}
}
function drawCreepSprite(ctx,c){
shadow(ctx,c.x,c.y,c.def.radius);
if(c.type==='wolf'){
const ph=c.walkPhase, mv=c.moving;
ctx.save(); ctx.translate(c.x,c.y); if(c.face<0)ctx.scale(-1,1);
const l=mv?Math.sin(ph)*2.4:0;
ctx.strokeStyle='#6d727c'; ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(-6,-5); ctx.lineTo(-6-l,1);
ctx.moveTo(-2,-5); ctx.lineTo(-2+l,1);
ctx.moveTo(4,-5); ctx.lineTo(4-l,1);
ctx.moveTo(7,-5); ctx.lineTo(7+l,1);
ctx.stroke();
ctx.fillStyle='#8a8f98';
ctx.beginPath(); ctx.ellipse(0,-8,10,5,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#a6abb4';
ctx.beginPath(); ctx.ellipse(0,-6.5,7,2.6,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#8a8f98';
ctx.beginPath(); ctx.arc(9,-11,4.2,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#6d727c';
ctx.beginPath(); ctx.roundRect(11,-11,5,2.6,1); ctx.fill(); // snout
ctx.beginPath(); ctx.moveTo(6,-14); ctx.lineTo(8,-18); ctx.lineTo(9.5,-13.5); ctx.fill(); // ears
ctx.beginPath(); ctx.moveTo(9.5,-14); ctx.lineTo(11.5,-17.5); ctx.lineTo(12.5,-13); ctx.fill();
ctx.strokeStyle='#6d727c'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(-9,-9); ctx.quadraticCurveTo(-14,-12,-13,-16); ctx.stroke(); // tail
ctx.fillStyle=c.order.type==='attack'?'#ff5040':'#2a2a2a';
ctx.beginPath(); ctx.arc(8.5,-12,1.1,0,Math.PI*2); ctx.fill();
ctx.restore();
}else{ // ogre
const ph=c.walkPhase, mv=c.moving;
ctx.save(); ctx.translate(c.x,c.y); if(c.face<0)ctx.scale(-1,1);
const l=mv?Math.sin(ph)*2.6:0;
ctx.fillStyle='#6a5a3a';
ctx.fillRect(-7,-9+l*0.4,5.5,9); ctx.fillRect(2,-9-l*0.4,5.5,9); // legs
ctx.fillStyle='#8a7a4a';
ctx.beginPath(); ctx.ellipse(0,-17,10,10,0,0,Math.PI*2); ctx.fill(); // body
ctx.fillStyle='#a3925e';
ctx.beginPath(); ctx.ellipse(0,-14,6,6,0,0,Math.PI*2); ctx.fill(); // belly
ctx.fillStyle='#8a7a4a';
ctx.beginPath(); ctx.arc(0,-29,5.5,0,Math.PI*2); ctx.fill(); // head
ctx.fillStyle='#6a5a3a';
ctx.beginPath(); ctx.roundRect(-4,-27,8,3,1.5); ctx.fill(); // jaw
ctx.fillStyle='#ff5040';
ctx.beginPath(); ctx.arc(-2,-30.5,1,0,Math.PI*2); ctx.arc(2.5,-30.5,1,0,Math.PI*2); ctx.fill();
// arms + club
const atk=c.attackAnim>0?c.attackAnim/0.28:0;
ctx.save(); ctx.translate(8,-20); ctx.rotate(-0.6+atk*1.8);
ctx.fillStyle='#7d6e42'; ctx.beginPath(); ctx.roundRect(-2,0,5,10,2); ctx.fill();
ctx.fillStyle='#6e5236'; ctx.fillRect(0,8,3,12);
ctx.fillStyle='#9aa3ad';
ctx.beginPath(); ctx.roundRect(-3,17,9,7,2); ctx.fill();
ctx.restore();
ctx.fillStyle='#7d6e42';
ctx.beginPath(); ctx.roundRect(-11,-22,4.5,10,2); ctx.fill();
ctx.restore();
}
if(c.hitFlash>0){
ctx.fillStyle='rgba(255,255,255,'+Math.min(0.6,c.hitFlash*4)+')';
ellipse(ctx,c.x,c.y-c.def.radius,c.def.radius,c.def.radius);
}
}
function drawTree(ctx,t){
const x=t.x,y=t.y,v=t.v;
const k=0.55+0.45*(t.amount/TREE_AMOUNT);
ctx.fillStyle='rgba(0,0,0,0.25)'; ellipse(ctx,x,y+2,10,4);
ctx.fillStyle='#6a4a26';
ctx.fillRect(x-2.5,y-13,5,13);
ctx.fillStyle='#57391d';
ctx.fillRect(x-2.5,y-13,1.6,13);
const s=(0.8+v*0.45)*k;
ctx.fillStyle='#2c6e31';
ctx.beginPath(); ctx.arc(x,y-19*s,12*s,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#38873d';
ctx.beginPath(); ctx.arc(x-5*s,y-23*s,8.5*s,0,Math.PI*2); ctx.arc(x+6*s,y-21*s,7.5*s,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#4fa457';
ctx.beginPath(); ctx.arc(x-3*s,y-26*s,5.5*s,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,255,220,0.25)';
ctx.beginPath(); ctx.arc(x-5*s,y-27*s,2.4*s,0,Math.PI*2); ctx.fill();
}
function drawRock(ctx,r){
const x=r.x,y=r.y,v=r.v;
ctx.fillStyle='rgba(0,0,0,0.25)'; ellipse(ctx,x,y+2,11,4);
ctx.fillStyle='#8d8a80';
ctx.beginPath();
ctx.moveTo(x-11,y); ctx.lineTo(x-8,y-9-v*4); ctx.lineTo(x-1,y-13-v*3);
ctx.lineTo(x+7,y-10-v*2); ctx.lineTo(x+11,y-2); ctx.lineTo(x+8,y+1); ctx.closePath(); ctx.fill();
ctx.fillStyle='#a5a298';
ctx.beginPath();
ctx.moveTo(x-8,y-9-v*4); ctx.lineTo(x-1,y-13-v*3); ctx.lineTo(x+3,y-6); ctx.lineTo(x-4,y-4); ctx.closePath(); ctx.fill();
ctx.fillStyle='#6e6b62';
ctx.beginPath(); ctx.moveTo(x+7,y-10-v*2); ctx.lineTo(x+11,y-2); ctx.lineTo(x+4,y-3); ctx.closePath(); ctx.fill();
}
function drawMine(ctx,m){
const x=m.x,y=m.y;
ctx.fillStyle='rgba(0,0,0,0.28)'; ellipse(ctx,x,y+26,26,8);
// rock mound
ctx.fillStyle='#7b766c';
ctx.beginPath();
ctx.moveTo(x-27,y+24); ctx.lineTo(x-22,y-2); ctx.lineTo(x-8,y-16);
ctx.lineTo(x+8,y-14); ctx.lineTo(x+22,y+2); ctx.lineTo(x+27,y+24); ctx.closePath(); ctx.fill();
ctx.fillStyle='#948f84';
ctx.beginPath();
ctx.moveTo(x-22,y-2); ctx.lineTo(x-8,y-16); ctx.lineTo(x+2,y-4); ctx.lineTo(x-10,y+2); ctx.closePath(); ctx.fill();
ctx.fillStyle='#635f56';
ctx.beginPath();
ctx.moveTo(x+8,y-14); ctx.lineTo(x+22,y+2); ctx.lineTo(x+10,y+6); ctx.closePath(); ctx.fill();
// cave
ctx.fillStyle='#241f18';
ctx.beginPath(); ctx.roundRect(x-9,y+4,18,20,7); ctx.fill();
ctx.fillStyle='#6e5236';
ctx.fillRect(x-11,y+2,3,22); ctx.fillRect(x+8,y+2,3,22); ctx.fillRect(x-11,y+1,22,3.4);
// gold nuggets
const nug=[[x-17,y+8],[x-14,y+16],[x+15,y+10],[x+12,y+18],[x-4,y-8],[x+6,y-6]];
for(const [nx,ny] of nug){
ctx.fillStyle='#f6c545';
ctx.beginPath(); ctx.arc(nx,ny,3,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#ffe89a';
ctx.beginPath(); ctx.arc(nx-1,ny-1,1.1,0,Math.PI*2); ctx.fill();
}
// sparkle
const sp=Math.sin(S.time*3+m.v*9)*0.5+0.5;
ctx.fillStyle='rgba(255,244,200,'+(sp*0.9)+')';
ctx.beginPath(); ctx.arc(x-4,y-8,1.6+sp,0,Math.PI*2); ctx.fill();
}
/* ---- buildings ---- */
function drawTownHall(ctx,x,y){
// x,y top-left, 96x96
ctx.fillStyle='rgba(0,0,0,0.3)'; ellipse(ctx,x+48,y+88,44,10);
// stone base
ctx.fillStyle='#b6ad9c';
ctx.beginPath(); ctx.roundRect(x+10,y+42,76,44,3); ctx.fill();
ctx.strokeStyle='rgba(90,80,60,0.5)'; ctx.lineWidth=1;
for(let i=1;i<4;i++){ ctx.beginPath(); ctx.moveTo(x+10,y+42+i*11); ctx.lineTo(x+86,y+42+i*11); ctx.stroke(); }
for(let i=0;i<4;i++){ ctx.beginPath(); ctx.moveTo(x+10+19*i+((i%2)?9:0),y+42); ctx.lineTo(x+10+19*i+((i%2)?9:0),y+86); ctx.stroke(); }
// door + windows
ctx.fillStyle='#4a3620';
ctx.beginPath(); ctx.roundRect(x+40,y+60,16,26,6); ctx.fill();
ctx.fillStyle='#2e2214';
ctx.beginPath(); ctx.roundRect(x+42,y+62,12,24,5); ctx.fill();
ctx.fillStyle='#7fb2d9';
ctx.fillRect(x+20,y+52,10,10); ctx.fillRect(x+66,y+52,10,10);
ctx.strokeStyle='#4a3620'; ctx.strokeRect(x+20,y+52,10,10); ctx.strokeRect(x+66,y+52,10,10);
// upper wood floor
ctx.fillStyle='#8a6a3f';
ctx.fillRect(x+14,y+26,68,17);
ctx.fillStyle='#6e5236';
for(let i=0;i<5;i++)ctx.fillRect(x+14+i*14,y+26,2,17);
ctx.fillStyle='#7fb2d9';
ctx.fillRect(x+42,y+29,12,10);
ctx.strokeStyle='#4a3620'; ctx.strokeRect(x+42,y+29,12,10);
// roof
ctx.fillStyle='#3f66a8';
ctx.beginPath(); ctx.moveTo(x+4,y+28); ctx.lineTo(x+48,y-4); ctx.lineTo(x+92,y+28); ctx.closePath(); ctx.fill();
ctx.fillStyle='#35568c';
ctx.beginPath(); ctx.moveTo(x+48,y-4); ctx.lineTo(x+92,y+28); ctx.lineTo(x+70,y+28); ctx.closePath(); ctx.fill();
ctx.strokeStyle='#2a4470'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(x+4,y+28); ctx.lineTo(x+48,y-4); ctx.lineTo(x+92,y+28); ctx.stroke();
// flag
const wv=Math.sin(S.time*4)*2;
ctx.strokeStyle='#4a3620'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(x+48,y-4); ctx.lineTo(x+48,y-20); ctx.stroke();
ctx.fillStyle='#4a86d8';
ctx.beginPath(); ctx.moveTo(x+48,y-20); ctx.lineTo(x+64,y-16+wv); ctx.lineTo(x+48,y-11); ctx.closePath(); ctx.fill();
}
function drawFarm(ctx,x,y){
// 64x64
ctx.fillStyle='rgba(0,0,0,0.28)'; ellipse(ctx,x+32,y+58,28,7);
// wheat patch
ctx.fillStyle='#c9ab5e';
ctx.beginPath(); ctx.roundRect(x+4,y+46,20,14,3); ctx.fill();
ctx.strokeStyle='#a8894a'; ctx.lineWidth=1;
for(let i=0;i<5;i++){ ctx.beginPath(); ctx.moveTo(x+7+i*4,y+58); ctx.lineTo(x+7+i*4,y+50); ctx.stroke(); }
// house base
ctx.fillStyle='#9a7a4a';
ctx.beginPath(); ctx.roundRect(x+14,y+30,40,26,2); ctx.fill();
ctx.fillStyle='#6e5236';
ctx.fillRect(x+14,y+30,40,2); ctx.fillRect(x+14,y+42,40,2);
ctx.fillStyle='#4a3620';
ctx.beginPath(); ctx.roundRect(x+28,y+42,12,14,3); ctx.fill();
ctx.fillStyle='#7fb2d9';
ctx.fillRect(x+18,y+34,8,7); ctx.fillRect(x+42,y+34,8,7);
// thatched roof
ctx.fillStyle='#c9a55a';
ctx.beginPath(); ctx.moveTo(x+8,y+32); ctx.lineTo(x+34,y+10); ctx.lineTo(x+60,y+32); ctx.closePath(); ctx.fill();
ctx.fillStyle='#b3903f';
ctx.beginPath(); ctx.moveTo(x+34,y+10); ctx.lineTo(x+60,y+32); ctx.lineTo(x+46,y+32); ctx.closePath(); ctx.fill();
ctx.strokeStyle='#8a6a2a'; ctx.lineWidth=1;
for(let i=1;i<4;i++){
ctx.beginPath(); ctx.moveTo(x+8+i*4,y+32-i*1.2); ctx.lineTo(x+34,y+10+i*2); ctx.stroke();
}
}
function drawBarracks(ctx,x,y){
// 96x64
ctx.fillStyle='rgba(0,0,0,0.3)'; ellipse(ctx,x+48,y+58,44,8);
ctx.fillStyle='#6e5236';
ctx.beginPath(); ctx.roundRect(x+6,y+18,84,40,3); ctx.fill();
ctx.fillStyle='#5d452c';
for(let i=0;i<7;i++)ctx.fillRect(x+6+i*12,y+18,2,40);
// battlements
ctx.fillStyle='#7d5f3e';
for(let i=0;i<6;i++)ctx.fillRect(x+8+i*14,y+10,9,9);
// door
ctx.fillStyle='#2e2214';
ctx.beginPath(); ctx.roundRect(x+40,y+36,16,22,4); ctx.fill();
// banner
ctx.fillStyle='#a83232';
ctx.beginPath(); ctx.roundRect(x+14,y+24,16,22,2); ctx.fill();
ctx.strokeStyle='#e8d9b0'; ctx.lineWidth=1.8;
ctx.beginPath(); ctx.moveTo(x+18,y+28); ctx.lineTo(x+26,y+40); ctx.moveTo(x+26,y+28); ctx.lineTo(x+18,y+40); ctx.stroke();
// weapon rack
ctx.strokeStyle='#8d8a80'; ctx.lineWidth=1.6;
ctx.beginPath();
ctx.moveTo(x+70,y+52); ctx.lineTo(x+70,y+30);
ctx.moveTo(x+76,y+52); ctx.lineTo(x+76,y+28);
ctx.moveTo(x+82,y+52); ctx.lineTo(x+82,y+31);
ctx.stroke();
ctx.fillStyle='#8d8a80';
ctx.beginPath(); ctx.moveTo(x+68.5,y+30); ctx.lineTo(x+71.5,y+30); ctx.lineTo(x+70,y+25); ctx.fill();
}
function drawTower(ctx,x,y,b){
// 64x64
ctx.fillStyle='rgba(0,0,0,0.3)'; ellipse(ctx,x+32,y+58,22,7);
ctx.fillStyle='#a8a090';
ctx.beginPath(); ctx.roundRect(x+16,y+14,32,44,3); ctx.fill();
ctx.strokeStyle='rgba(90,80,60,0.5)'; ctx.lineWidth=1;
for(let i=1;i<4;i++){ ctx.beginPath(); ctx.moveTo(x+16,y+14+i*11); ctx.lineTo(x+48,y+14+i*11); ctx.stroke(); }
ctx.fillStyle='#8d8578';
ctx.fillRect(x+16,y+14,5,44);
// battlements
ctx.fillStyle='#b6ad9c';
for(let i=0;i<3;i++)ctx.fillRect(x+14+i*13,y+6,9,9);
// window slits
ctx.fillStyle='#241f18';
ctx.fillRect(x+30,y+24,4,10); ctx.fillRect(x+30,y+40,4,10);
// flag
const wv=Math.sin(S.time*4+2)*1.6;
ctx.strokeStyle='#4a3620'; ctx.lineWidth=1.6;
ctx.beginPath(); ctx.moveTo(x+32,y+6); ctx.lineTo(x+32,y-6); ctx.stroke();
ctx.fillStyle='#4a86d8';
ctx.beginPath(); ctx.moveTo(x+32,y-6); ctx.lineTo(x+44,y-3+wv); ctx.lineTo(x+32,y+0); ctx.closePath(); ctx.fill();
// firing flash
if(b&&b.flash>0){
ctx.fillStyle='rgba(159,224,255,'+(b.flash*4)+')';
ctx.beginPath(); ctx.arc(x+32,y+20,7,0,Math.PI*2); ctx.fill();
}
}
const BLD_DRAW={townhall:drawTownHall,farm:drawFarm,barracks:drawBarracks,tower:drawTower};
function drawBuilding(ctx,b){
const x=b.tileX*T, y=b.tileY*T;
const dw=b.w*T, dh=b.h*T;
if(!b.built){
// blueprint + rising build
const p=clamp(b.progress/b.def.time,0,1);
ctx.save();
ctx.globalAlpha=0.28+0.55*p;
BLD_DRAW[b.type](ctx,x,y,b);
ctx.restore();
// scaffold frame
ctx.strokeStyle='rgba(232,198,106,0.5)';
ctx.lineWidth=1;
ctx.setLineDash([5,4]);
ctx.strokeRect(x+2,y+2,dw-4,dh-4);
ctx.setLineDash([]);
// plank line at build height
const py=y+dh-4-(dh-8)*p;
ctx.strokeStyle='#8a6a3f'; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(x+3,py); ctx.lineTo(x+dw-3,py); ctx.stroke();
}else{
BLD_DRAW[b.type](ctx,x,y,b);
}
if(b.hitFlash>0){
ctx.fillStyle='rgba(255,255,255,'+Math.min(0.5,b.hitFlash*3)+')';
ctx.fillRect(x+4,y+4,dw-8,dh-8);
}
}
/* ---- bars / overlays ---- */
function drawBar(x,y,w,h,ratio,cls){
ctx.fillStyle='rgba(10,8,4,0.75)';
ctx.fillRect(x-1,y-1,w+2,h+2);
ctx.fillStyle=cls==='build'?'#4f9fd9':ratio>0.5?'#5fbf5f':ratio>0.25?'#d9a94f':'#d95f4f';
ctx.fillRect(x,y,w*ratio,h);
}
function onScreen(x,y,pad){
return x>S.cam.x-pad&&x<S.cam.x+vw+pad&&y>S.cam.y-pad&&y<S.cam.y+vh+pad;
}
/* ---- main render ---- */
const fogCv=document.createElement('canvas');
fogCv.width=MAP_W; fogCv.height=MAP_H;
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.fillStyle='#0a0805';
ctx.fillRect(0,0,vw,vh);
ctx.save();
ctx.translate(-Math.round(S.cam.x),-Math.round(S.cam.y));
// ground
ctx.imageSmoothingEnabled=false;
ctx.drawImage(ground,S.cam.x,S.cam.y,vw,vh,S.cam.x,S.cam.y,vw,vh);
// water shimmer
const tx0=Math.max(0,tileX(S.cam.x)), ty0=Math.max(0,tileY(S.cam.y));
const tx1=Math.min(MAP_W-1,tileX(S.cam.x+vw)), ty1=Math.min(MAP_H-1,tileY(S.cam.y+vh));
ctx.strokeStyle='rgba(230,245,255,0.35)';
ctx.lineWidth=1;
for(let ty=ty0;ty<=ty1;ty++)for(let tx=tx0;tx<=tx1;tx++){
if(terrain[idx(tx,ty)]!==WATER||fog[idx(tx,ty)]===0)continue;
const h=hash2(tx,ty);
if(h>0.55)continue;
const a=Math.sin(S.time*1.6+h*20)*0.5+0.5;
ctx.globalAlpha=a*0.3;
const wx=tx*T+6+h*10, wy=ty*T+8+((h*57)%14);
ctx.beginPath(); ctx.moveTo(wx,wy); ctx.lineTo(wx+9,wy); ctx.stroke();
}
ctx.globalAlpha=1;
// corpses / decals
for(const c of corpses){
if(!onScreen(c.x,c.y,40))continue;
const a=(1-c.t/c.max)*0.4;
ctx.fillStyle=c.color+a+')';
ellipse(ctx,c.x,c.y,c.r,c.r*0.4);
}
// selection circles (under sprites)
for(const e of S.sel){
if(e.dead)continue;
if(e.kind==='unit'||e.kind==='creep'){
ctx.strokeStyle=e.team==='player'?'rgba(127,224,255,0.9)':'rgba(255,106,94,0.9)';
ctx.lineWidth=1.6;
ctx.beginPath(); ctx.ellipse(e.x,e.y+1,e.def.radius+3,(e.def.radius+3)*0.42,0,0,Math.PI*2); ctx.stroke();
}else if(e.kind==='building'){
const x=e.tileX*T,y=e.tileY*T,dw=e.w*T,dh=e.h*T;
ctx.strokeStyle='rgba(127,224,255,0.9)'; ctx.lineWidth=2;
const L=10;
ctx.beginPath();
ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x+L,y);
ctx.moveTo(x+dw-L,y); ctx.lineTo(x+dw,y); ctx.lineTo(x+dw,y+L);
ctx.moveTo(x+dw,y+dh-L); ctx.lineTo(x+dw,y+dh); ctx.lineTo(x+dw-L,y+dh);
ctx.moveTo(x+L,y+dh); ctx.lineTo(x,y+dh); ctx.lineTo(x,y+dh-L);
ctx.stroke();
}
}
// rally points
for(const e of S.sel){
if(e.kind==='building'&&e.built&&e.rally&&e.def.trains){
const r=e.rally;
ctx.strokeStyle='rgba(255,255,255,0.25)';
ctx.setLineDash([4,5]); ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(e.x,(e.tileY+e.h)*T); ctx.lineTo(r.x,r.y); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle='rgba(232,198,106,'+(0.6+0.3*Math.sin(S.time*5))+')';
ctx.beginPath(); ctx.moveTo(r.x,r.y); ctx.lineTo(r.x,r.y-12); ctx.lineTo(r.x+9,r.y-9); ctx.lineTo(r.x,r.y-6); ctx.fill();
}
}
// gather visible drawables, sort by ground-y
const draws=[];
for(const t of trees){
if(!onScreen(t.x,t.y,60))continue;
if(fog[idx(t.tx,t.ty)]===0)continue;
draws.push({y:t.y+2,f:()=>drawTree(ctx,t)});
}
for(const r of rocks){
if(!onScreen(r.x,r.y,60))continue;
if(fog[idx(r.tx,r.ty)]===0)continue;
draws.push({y:r.y+2,f:()=>drawRock(ctx,r)});
}
for(const m of mines){
if(!onScreen(m.x,m.y,80))continue;
if(fog[idx(m.tx,m.ty)]===0&&fog[idx(m.tx+1,m.ty+1)]===0)continue;
draws.push({y:m.y+26,f:()=>drawMine(ctx,m)});
}
for(const b of buildings){
if(b.dead||!onScreen(b.x,b.y,140))continue;
draws.push({y:(b.tileY+b.h)*T,f:()=>drawBuilding(ctx,b)});
}
for(const u of units){
if(u.dead||!onScreen(u.x,u.y,60))continue;
draws.push({y:u.y,f:()=>drawUnitSprite(ctx,u)});
}
for(const c of creeps){
if(c.dead)continue;
if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue;
if(!onScreen(c.x,c.y,60))continue;
draws.push({y:c.y,f:()=>drawCreepSprite(ctx,c)});
}
draws.sort((a,b)=>a.y-b.y);
for(const d of draws)d.f();
// health / progress bars
for(const u of units){
if(u.dead||!onScreen(u.x,u.y,60))continue;
if(u.hp<u.maxHp||S.sel.includes(u))
drawBar(u.x-11,u.y-u.def.radius*2-14,22,3.4,u.hp/u.maxHp);
}
for(const c of creeps){
if(c.dead||!onScreen(c.x,c.y,60))continue;
if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue;
if(c.hp<c.maxHp) drawBar(c.x-11,c.y-c.def.radius*2-12,22,3.4,c.hp/c.maxHp);
}
for(const b of buildings){
if(b.dead||!onScreen(b.x,b.y,140))continue;
const w=Math.max(26,b.w*T*0.6), bx=b.x-w/2, by=b.tileY*T-8;
if(!b.built) drawBar(bx,by,w,4,b.progress/b.def.time,'build');
else if(b.hp<b.maxHp||S.sel.includes(b)) drawBar(bx,by,w,4,b.hp/b.maxHp);
}
// projectiles
for(const p of projectiles){
if(!onScreen(p.x,p.y,40))continue;
ctx.strokeStyle=p.color; ctx.lineWidth=2;
const a=p.ang||0;
ctx.beginPath(); ctx.moveTo(p.x-Math.cos(a)*7,p.y-Math.sin(a)*7); ctx.lineTo(p.x,p.y); ctx.stroke();
ctx.fillStyle='rgba(255,255,255,0.8)';
ctx.beginPath(); ctx.arc(p.x,p.y,1.6,0,Math.PI*2); ctx.fill();
}
// particles
for(const p of particles){
if(!onScreen(p.x,p.y,20))continue;
ctx.globalAlpha=1-p.t/p.max;
ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,Math.PI*2); ctx.fill();
}
ctx.globalAlpha=1;
// floating text
ctx.font='600 11.5px ui-sans-serif,system-ui';
ctx.textAlign='center';
for(const f of floaters){
ctx.globalAlpha=1-f.t/f.max;
ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=3;
ctx.strokeText(f.text,f.x,f.y);
ctx.fillStyle=f.color;
ctx.fillText(f.text,f.x,f.y);
}
ctx.globalAlpha=1;
// placement ghost
if(S.placing){
const d=BUILDS[S.placing.type];
const x=S.placing.tx*T, y=S.placing.ty*T, dw=d.w*T, dh=d.h*T;
ctx.fillStyle=S.placing.ok?'rgba(90,230,120,0.25)':'rgba(230,80,70,0.28)';
ctx.fillRect(x,y,dw,dh);
ctx.strokeStyle=S.placing.ok?'rgba(120,255,150,0.9)':'rgba(255,110,100,0.9)';
ctx.lineWidth=1.6;
ctx.strokeRect(x+1,y+1,dw-2,dh-2);
ctx.globalAlpha=0.55;
BLD_DRAW[S.placing.type](ctx,x,y,null);
ctx.globalAlpha=1;
ctx.strokeStyle='rgba(255,255,255,0.18)';
ctx.beginPath(); ctx.arc(x+dw/2,y+dh/2,d.sight*T,0,Math.PI*2); ctx.stroke();
}
// fog of war
const fctx=fogCv.getContext('2d');
const img=fctx.createImageData(MAP_W,MAP_H);
for(let i=0;i<MAP_W*MAP_H;i++){
const a=fog[i]===0?255:fog[i]===1?135:0;
img.data[i*4]=0; img.data[i*4+1]=0; img.data[i*4+2]=0; img.data[i*4+3]=a;
}
fctx.putImageData(img,0,0);
ctx.imageSmoothingEnabled=true;
ctx.drawImage(fogCv,0,0,MAP_W,MAP_H,0,0,WORLD_W,WORLD_H);
ctx.imageSmoothingEnabled=false;
ctx.restore();
// screen-space: selection box
if(drag.active&&drag.moved){
const x=Math.min(drag.sx,drag.cx), y=Math.min(drag.sy,drag.cy);
const w=Math.abs(drag.cx-drag.sx), h=Math.abs(drag.cy-drag.sy);
ctx.fillStyle='rgba(127,224,255,0.12)';
ctx.fillRect(x,y,w,h);
ctx.strokeStyle='rgba(127,224,255,0.8)';
ctx.lineWidth=1;
ctx.strokeRect(x+0.5,y+0.5,w,h);
}
}
/* ---- minimap ---- */
const mini=document.getElementById('mini'), mctx=mini.getContext('2d');
const MINI=162, MS=MINI/MAP_W;
const fogMini=document.createElement('canvas');
fogMini.width=MAP_W; fogMini.height=MAP_H;
function renderMini(){
mctx.imageSmoothingEnabled=false;
mctx.clearRect(0,0,MINI,MINI);
mctx.drawImage(miniBase,0,0,MAP_W,MAP_H,0,0,MINI,MINI);
// trees & rocks
mctx.fillStyle='#2c6e31';
for(const t of trees){
if(fog[idx(t.tx,t.ty)]===0)continue;
mctx.fillRect(t.tx*MS,t.ty*MS,MS,MS);
}
mctx.fillStyle='#8d8a80';
for(const r of rocks){
if(fog[idx(r.tx,r.ty)]===0)continue;
mctx.fillRect(r.tx*MS,r.ty*MS,MS,MS);
}
// mines
mctx.fillStyle='#f6c545';
for(const m of mines){
if(fog[idx(m.tx,m.ty)]===0)continue;
mctx.fillRect(m.tx*MS-1,m.ty*MS-1,MS*2+2,MS*2+2);
}
// buildings
for(const b of buildings){
if(b.dead)continue;
mctx.fillStyle=b.built?'#6fc3ff':'#3a6a8f';
mctx.fillRect(b.tileX*MS,b.tileY*MS,b.w*MS,b.h*MS);
}
// units
mctx.fillStyle='#bfe8ff';
for(const u of units){
if(u.dead)continue;
mctx.fillRect(u.x/T*MS-1,u.y/T*MS-1,2.4,2.4);
}
mctx.fillStyle='#ff6a5e';
for(const c of creeps){
if(c.dead)continue;
if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue;
mctx.fillRect(c.x/T*MS-1.2,c.y/T*MS-1.2,2.8,2.8);
}
// fog overlay
const fctx=fogMini.getContext('2d');
const img=fctx.createImageData(MAP_W,MAP_H);
for(let i=0;i<MAP_W*MAP_H;i++){
const a=fog[i]===0?235:fog[i]===1?110:0;
img.data[i*4]=2; img.data[i*4+1]=4; img.data[i*4+2]=8; img.data[i*4+3]=a;
}
fctx.putImageData(img,0,0);
mctx.drawImage(fogMini,0,0,MAP_W,MAP_H,0,0,MINI,MINI);
// camera rect
mctx.strokeStyle='rgba(255,255,255,0.85)';
mctx.lineWidth=1;
mctx.strokeRect(S.cam.x/T*MS+0.5,S.cam.y/T*MS+0.5,vw/T*MS,vh/T*MS);
}
/* ================================================================
UI
================================================================ */
const $=id=>document.getElementById(id);
let toastTimer=null;
function toast(msg,ms=2600){
const el=$('toast');
el.textContent=msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>el.classList.remove('show'),ms);
}
/* ---- icon cache ---- */
const iconCache={};
function iconCanvas(key,drawFn){
if(iconCache[key])return iconCache[key];
const c=document.createElement('canvas');
c.width=60; c.height=60;
const g=c.getContext('2d');
drawFn(g);
iconCache[key]=c;
return c;
}
function unitIcon(type){
return iconCanvas('u_'+type,g=>{
const o={phase:0,moving:false,face:1,attackAnim:0.15,swing:0.4,carry:null};
if(type==='knight'){
g.translate(30,52); g.scale(1.25,1.25);
g.fillStyle='#6e5236';
g.beginPath(); g.ellipse(0,-10,11,6.5,0,0,Math.PI*2); g.fill();
g.beginPath(); g.ellipse(10,-15,4,3.4,-0.5,0,Math.PI*2); g.fill();
drawHumanoid(g,0,-8,{...o,body:'#9aa3ad',trim:'#4a86d8',helm:true,tool:'lance'});
}else{
g.translate(30,50); g.scale(1.45,1.45);
if(type==='worker')drawHumanoid(g,0,0,{...o,body:'#8a6a3f',trim:'#4a86d8',pants:'#5a4632',hat:'#4a86d8',tool:'pick'});
else if(type==='footman')drawHumanoid(g,0,0,{...o,body:'#8d97a3',trim:'#4a86d8',pants:'#5a5148',helm:true,tool:'sword'});
else if(type==='archer')drawHumanoid(g,0,0,{...o,body:'#5a7a4a',trim:'#3a5230',pants:'#4a4238',hood:'#3f6638',tool:'bow'});
}
});
}
function bldIcon(type){
return iconCanvas('b_'+type,g=>{
const d=BUILDS[type];
const s=Math.min(54/(d.w*T),44/(d.h*T));
g.translate(30-d.w*T*s/2, 56-d.h*T*s);
g.scale(s,s);
BLD_DRAW[type](g,0,0,null);
});
}
function glyphIcon(key,drawFn){
return iconCanvas('g_'+key,g=>{
g.translate(30,30);
drawFn(g);
});
}
const ICONS={
build:()=>glyphIcon('build',g=>{ // hammer
g.rotate(-0.6);
g.fillStyle='#8a6a3f'; g.fillRect(-2,-2,4,20);
g.fillStyle='#b9b0a0'; g.beginPath(); g.roundRect(-9,-10,18,9,2); g.fill();
g.fillStyle='#d7dbe0'; g.fillRect(-9,-10,18,2.5);
}),
stop:()=>glyphIcon('stop',g=>{
g.fillStyle='#c04a3a';
g.beginPath();
const r=13;
for(let i=0;i<8;i++){const a=Math.PI/8+i*Math.PI/4; g.lineTo(Math.cos(a)*r,Math.sin(a)*r);}
g.closePath(); g.fill();
g.fillStyle='#fff'; g.fillRect(-2.5,-7,5,14);
}),
hold:()=>glyphIcon('hold',g=>{
g.fillStyle='#4a86d8';
g.beginPath(); g.moveTo(0,-14); g.lineTo(12,-9); g.lineTo(10,4); g.quadraticCurveTo(6,12,0,15);
g.quadraticCurveTo(-6,12,-10,4); g.lineTo(-12,-9); g.closePath(); g.fill();
g.fillStyle='#e8c66a';
g.beginPath(); g.arc(0,-1,4.5,0,Math.PI*2); g.fill();
}),
atkmove:()=>glyphIcon('atkmove',g=>{
g.strokeStyle='#e8c66a'; g.lineWidth=3.4; g.lineCap='round';
g.beginPath(); g.moveTo(-11,11); g.lineTo(9,-9); g.stroke();
g.beginPath(); g.moveTo(-9,-7); g.lineTo(11,9); g.stroke();
g.fillStyle='#e8c66a';
g.beginPath(); g.moveTo(9,-9); g.lineTo(1,-11); g.lineTo(11,-1); g.fill();
g.beginPath(); g.moveTo(-9,-7); g.lineTo(-11,1); g.lineTo(-1,-9); g.fill();
}),
cancel:()=>glyphIcon('cancel',g=>{
g.strokeStyle='#c8cdd4'; g.lineWidth=4.5; g.lineCap='round';
g.beginPath(); g.moveTo(-9,-9); g.lineTo(9,9); g.moveTo(9,-9); g.lineTo(-9,9); g.stroke();
}),
back:()=>glyphIcon('back',g=>{
g.fillStyle='#c8cdd4';
g.beginPath(); g.moveTo(8,-12); g.lineTo(-8,0); g.lineTo(8,12); g.lineTo(8,5); g.lineTo(2,5);
g.lineTo(2,-5); g.lineTo(8,-5); g.closePath(); g.fill();
}),
};
/* ---- command card ---- */
const cardEl=$('card');
let cardBtns=[];
function makeButton(cfg){
const el=document.createElement('div');
el.className='btn';
el.title=cfg.tip||cfg.label||'';
if(cfg.act)el.dataset.act=cfg.act;
if(cfg.key){
const k=document.createElement('span');
k.className='key'; k.textContent=cfg.key;
el.appendChild(k);
}
const ic=document.createElement('canvas');
ic.width=60; ic.height=60;
ic.getContext('2d').drawImage(cfg.icon,0,0);
el.appendChild(ic);
if(cfg.label){
const l=document.createElement('span');
l.className='lbl'; l.textContent=cfg.label;
el.appendChild(l);
}
if(cfg.cost&&(cfg.cost.gold||cfg.cost.wood)){
const c=document.createElement('span');
c.className='cost';
let html='';
if(cfg.cost.gold)html+='<span class="g">'+cfg.cost.gold+'</span>';
if(cfg.cost.gold&&cfg.cost.wood)html+=' ';
if(cfg.cost.wood)html+='<span class="w">'+cfg.cost.wood+'</span>';
c.innerHTML=html;
el.appendChild(c);
cfg._costEl=c;
}
el.addEventListener('click',()=>{
cfg.fn();
el.blur?.();
document.activeElement?.blur?.();
});
cfg._el=el;
return el;
}
function refreshCard(){
cardEl.innerHTML='';
cardBtns=[];
const list=cardForSelection();
for(const cfg of list){
if(!cfg)continue;
const el=makeButton(cfg);
cardEl.appendChild(el);
cardBtns.push(cfg);
}
updateCardStates();
}
function updateCardStates(){
for(const cfg of cardBtns){
if(!cfg._el)continue;
const en=cfg.enabled?cfg.enabled():true;
cfg._el.classList.toggle('dis',!en);
if(cfg._costEl&&cfg.cost){
const noG=cfg.cost.gold&&S.gold<cfg.cost.gold;
const noW=cfg.cost.wood&&S.wood<cfg.cost.wood;
cfg._costEl.querySelector('.g')?.classList.toggle('no',!!noG);
cfg._costEl.querySelector('.w')?.classList.toggle('no',!!noW);
}
}
}
function trainCfg(b,type,key){
const d=UNITS[type];
return {act:'train-'+type, icon:unitIcon(type), label:d.name, key, cost:{gold:d.gold,wood:d.wood},
tip:'Train '+d.name+' ('+d.hp+' hp, '+d.dmg+' dmg)',
fn:()=>enqueueTrain(b,type),
enabled:()=>!b.dead&&b.built&&b.queue.length<5};
}
function cardForSelection(){
const sel=S.sel.filter(e=>!e.dead);
if(S.placing){
return [{act:'cancel-place',icon:ICONS.cancel(),label:'Cancel (Esc)',key:'Esc',
fn:()=>{S.placing=null;refreshCard();}}];
}
if(S.buildMenu){
const out=[];
const bkeys={farm:'F',barracks:'B',tower:'T',townhall:'H'};
for(const type of ['farm','barracks','tower','townhall']){
const d=BUILDS[type];
out.push({act:'place-'+type,icon:bldIcon(type),label:d.name,key:bkeys[type],
cost:{gold:d.gold,wood:d.wood},tip:d.name+' — '+d.hp+' hp',
fn:()=>startPlacing(type),
enabled:()=>canAfford(d)});
}
out.push({act:'back',icon:ICONS.back(),label:'Back',key:'Esc',fn:()=>{S.buildMenu=false;refreshCard();}});
return out;
}
const workers=sel.filter(u=>u.kind==='unit'&&u.def.canGather);
const military=sel.filter(u=>u.kind==='unit'&&u.def.military);
const bld=sel.length===1&&sel[0].kind==='building'?sel[0]:null;
if(bld){
if(!bld.built){
return [{act:'cancel-build',icon:ICONS.cancel(),label:'Cancel (75%)',
fn:()=>cancelConstruction(bld)}];
}
const out=[];
if(bld.def.trains){
if(bld.type==='townhall') out.push(trainCfg(bld,'worker','W'));
if(bld.type==='barracks'){
out.push(trainCfg(bld,'footman','F'));
out.push(trainCfg(bld,'archer','A'));
out.push(trainCfg(bld,'knight','K'));
}
if(bld.queue.length)
out.push({act:'cancel-queue',icon:ICONS.cancel(),label:'Cancel last',key:'X',
fn:()=>cancelLastQueued(bld)});
}
return out;
}
if(workers.length){
return [
{act:'buildmenu',icon:ICONS.build(),label:'Build',key:'B',
fn:()=>{S.buildMenu=true;refreshCard();},
enabled:()=>workers.some(u=>!u.dead)},
{act:'stop',icon:ICONS.stop(),label:'Stop',key:'S',fn:cmdStop},
];
}
if(military.length||sel.some(u=>u.kind==='unit')){
return [
{act:'stop',icon:ICONS.stop(),label:'Stop',key:'S',fn:cmdStop},
{act:'hold',icon:ICONS.hold(),label:'Hold',key:'H',fn:cmdHold},
{act:'atkmove',icon:ICONS.atkmove(),label:'Attack-Move',key:'A',
fn:()=>{S.attackMoveMode=!S.attackMoveMode;
toast(S.attackMoveMode?'Attack-move: click a destination':'Attack-move cancelled',1400);}},
];
}
return [];
}
function cmdStop(){
for(const u of S.sel) if(u.kind==='unit'){ u.order={type:'idle'}; u.path=null; u.target=null; }
}
function cmdHold(){
for(const u of S.sel) if(u.kind==='unit'){ u.order={type:'hold'}; u.path=null; u.target=null; }
}
/* ---- placement ---- */
function startPlacing(type){
const d=BUILDS[type];
if(!canAfford(d)){ toast('Not enough resources'); return; }
S.buildMenu=false;
S.placing={type,tx:0,ty:0,ok:false};
updateGhost(mouse.x+S.cam.x,mouse.y+S.cam.y);
refreshCard();
}
function updateGhost(wx,wy){
if(!S.placing)return;
const d=BUILDS[S.placing.type];
const tx=Math.round(wx/T-d.w/2), ty=Math.round(wy/T-d.h/2);
S.placing.tx=tx; S.placing.ty=ty;
S.placing.ok=validPlacement(S.placing.type,tx,ty);
}
function validPlacement(type,tx,ty){
const d=BUILDS[type];
for(let y=0;y<d.h;y++)for(let x=0;x<d.w;x++){
const gx=tx+x, gy=ty+y;
if(gx<0||gy<0||gx>=MAP_W||gy>=MAP_H)return false;
if(terrain[idx(gx,gy)]===WATER)return false;
if(blocked[idx(gx,gy)])return false;
if(fog[idx(gx,gy)]===0)return false;
}
return true;
}
function placeBuilding(type,tx,ty){
const d=BUILDS[type];
if(!canAfford(d)){ toast('Not enough resources'); return false; }
if(!validPlacement(type,tx,ty))return false;
pay(d);
const b=makeBuilding(type,tx,ty);
buildings.push(b);
setBlockRect(tx,ty,d.w,d.h,1);
burst(b.x,b.y,'#ffe9a0',8,80);
let assigned=false;
for(const u of S.sel){
if(u.kind==='unit'&&u.def.canBuild){
u.order={type:'build',b}; u.path=null; u.target=null;
assigned=true;
}
}
if(!assigned) toast('Site placed — right-click it with a worker to build');
refreshInfo();
return true;
}
/* ---- info panel ---- */
function barHTML(ratio,cls=''){
const c=ratio>0.5?'':ratio>0.25?'mid':'low';
return '<div class="bar '+c+' '+cls+'"><i style="width:'+Math.round(clamp(ratio,0,1)*100)+'%"></i></div>';
}
function orderLabel(u){
switch(u.order.type){
case 'gather': return u.order.phase==='work'?(u.order.res?.kind==='mine'?'Mining gold':'Chopping lumber'):'Going to gather';
case 'return': return 'Returning resources';
case 'build': return 'Constructing '+ (u.order.b?u.order.b.def.name:'');
case 'attack': return 'Attacking '+(u.target?u.target.def.name:'');
case 'move': return 'Moving';
case 'attackmove': return 'Attack-moving';
case 'hold': return 'Holding position';
default: return 'Idle';
}
}
function refreshInfo(){
const el=$('infoInner');
const sel=S.sel.filter(e=>!e.dead);
if(!sel.length){
el.innerHTML='<div class="name">Tiny Realms</div><div class="sub">Select units by dragging a box. Right-click to command them.<br>Uncover 100% of the map to win — press <b>H</b> for help.</div>';
return;
}
if(sel.length===1){
const e=sel[0];
if(e.kind==='unit'){
el.innerHTML='<div class="name">'+e.def.name+'</div>'+barHTML(e.hp/e.maxHp)+
'<div class="row"><span class="sub">'+Math.ceil(e.hp)+'/'+e.maxHp+' hp · '+e.def.dmg+' dmg'+(e.def.range>40?' · ranged':'')+'</span></div>'+
'<div class="row"><span class="sub">'+orderLabel(e)+(e.carryAmt?' · carrying '+e.carryAmt+' '+(e.carry==='gold'?'gold':'lumber'):'')+'</span></div>';
return;
}
if(e.kind==='creep'){
el.innerHTML='<div class="name">'+e.def.name+' <span class="sub">(neutral)</span></div>'+barHTML(e.hp/e.maxHp)+
'<div class="row"><span class="sub">'+Math.ceil(e.hp)+'/'+e.maxHp+' hp · '+e.def.dmg+' dmg · bounty '+e.def.bounty+' gold</span></div>'+
'<div class="row"><span class="sub">Attacks units that wander close.</span></div>';
return;
}
if(e.kind==='building'){
let html='<div class="name">'+e.def.name+(e.built?'':' (under construction)')+'</div>';
if(!e.built){
html+=barHTML(e.progress/e.def.time,'build')+
'<div class="row"><span class="sub">Building… '+Math.floor(e.progress/e.def.time*100)+'%</span></div>';
}else{
html+=barHTML(e.hp/e.maxHp)+
'<div class="row"><span class="sub">'+Math.ceil(e.hp)+'/'+e.maxHp+' hp</span></div>';
if(e.def.dropoff)html+='<div class="row"><span class="sub">Resource drop-off point.</span></div>';
if(e.def.dmg)html+='<div class="row"><span class="sub">Attacks nearby beasts automatically ('+e.def.dmg+' dmg).</span></div>';
if(e.def.food)html+='<div class="row"><span class="sub">Provides '+e.def.food+' food.</span></div>';
if(e.queue.length){
html+='<div class="row"><span class="sub">Training:</span><span class="q">';
e.queue.forEach((t,i)=>{
const p=i===0?e.trainT/UNITS[t].time:0;
html+='<span class="prog" style="--p:'+Math.round(p*100)+'%"><canvas data-icon="u_'+t+'"></canvas></span>';
});
html+='</span></div>';
}else if(e.def.trains){
html+='<div class="row"><span class="sub">Right-click to set rally point.</span></div>';
}
}
el.innerHTML=html;
el.querySelectorAll('canvas[data-icon]').forEach(c=>{
const src=unitIcon(c.dataset.icon.slice(2));
c.width=52; c.height=52; c.getContext('2d').drawImage(src,0,0);
});
return;
}
}
// multiple
const counts={};
for(const e of sel){
if(e.kind!=='unit')continue;
counts[e.type]=(counts[e.type]||0)+1;
}
let html='<div class="name">'+sel.length+' units selected</div><div class="row">';
for(const t in counts){
html+='<span class="cnt"><canvas data-icon="u_'+t+'"></canvas>×'+counts[t]+'</span>';
}
html+='</div><div class="row"><span class="sub">Right-click: move / gather / attack.</span></div>';
el.innerHTML=html;
el.querySelectorAll('canvas[data-icon]').forEach(c=>{
const src=unitIcon(c.dataset.icon.slice(2));
c.width=40; c.height=40; c.getContext('2d').drawImage(src,0,0);
});
}
/* ---- top bar ---- */
function refreshTop(){
$('vGold').textContent=Math.floor(S.gold);
$('vWood').textContent=Math.floor(S.wood);
$('vFood').textContent=S.foodUsed+'/'+S.foodCap;
$('resFood').classList.toggle('warn',S.foodUsed>=S.foodCap);
const pct=Math.floor(S.explored/S.totalTiles*100);
$('vMap').textContent=pct+'%';
$('mapBar').firstElementChild.style.width=pct+'%';
$('btnSpeed').textContent=S.paused?'⏸ paused':('▶ '+S.speed+'×');
}
/* ================================================================
INPUT
================================================================ */
const stage=$('stage');
const mouse={x:0,y:0,over:false};
const drag={active:false,moved:false,sx:0,sy:0,cx:0,cy:0};
const keys={};
let lastClick={t:0,kind:null,type:null};
function canvasPos(e){
const r=cv.getBoundingClientRect();
return {x:e.clientX-r.left,y:e.clientY-r.top};
}
cv.addEventListener('contextmenu',e=>e.preventDefault());
cv.addEventListener('mousedown',e=>{
const p=canvasPos(e);
if(e.button===0){
if(S.placing){
if(S.placing.ok){
const ok=placeBuilding(S.placing.type,S.placing.tx,S.placing.ty);
if(ok&&!e.shiftKey){ S.placing=null; refreshCard(); }
}else toast('Can\'t build there');
return;
}
drag.active=true; drag.moved=false;
drag.sx=drag.cx=p.x; drag.sy=drag.cy=p.y;
}else if(e.button===2){
if(S.placing){ S.placing=null; refreshCard(); return; }
commandAt(p.x+S.cam.x,p.y+S.cam.y);
}
});
window.addEventListener('mousemove',e=>{
const p=canvasPos(e);
mouse.x=p.x; mouse.y=p.y;
const r=cv.getBoundingClientRect();
mouse.over=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom;
if(drag.active){
drag.cx=p.x; drag.cy=p.y;
if(Math.hypot(drag.cx-drag.sx,drag.cy-drag.sy)>5)drag.moved=true;
}
if(S.placing) updateGhost(p.x+S.cam.x,p.y+S.cam.y);
else updateCursor(p.x+S.cam.x,p.y+S.cam.y);
});
window.addEventListener('mouseup',e=>{
if(e.button!==0||!drag.active)return;
drag.active=false;
const p=canvasPos(e);
if(drag.moved){
boxSelect(Math.min(drag.sx,p.x)+S.cam.x,Math.min(drag.sy,p.y)+S.cam.y,
Math.abs(p.x-drag.sx),Math.abs(p.y-drag.sy),e.shiftKey);
}else{
clickSelect(p.x+S.cam.x,p.y+S.cam.y,e.shiftKey);
}
});
function updateCursor(wx,wy){
const pick=pickEntity(wx,wy);
let cur='default';
if(pick){
if(pick.kind==='creep')cur='crosshair';
else if((pick.kind==='tree'||pick.kind==='mine')&&S.sel.some(u=>u.kind==='unit'&&u.def.canGather))cur='pointer';
else if(pick.kind==='building'&&pick.ent.team==='player')cur='pointer';
}
cv.style.cursor=cur;
}
function pickEntity(wx,wy){
let best=null,bd=1e9;
for(const u of units){
if(u.dead)continue;
const d=dist(wx,wy,u.x,u.y-8);
if(d<u.def.radius+7&&d<bd){bd=d;best={ent:u,kind:'unit'};}
}
for(const c of creeps){
if(c.dead)continue;
if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue;
const d=dist(wx,wy,c.x,c.y-8);
if(d<c.def.radius+7&&d<bd){bd=d;best={ent:c,kind:'creep'};}
}
for(const b of buildings){
if(b.dead)continue;
if(wx>=b.tileX*T-4&&wx<=(b.tileX+b.w)*T+4&&wy>=b.tileY*T-4&&wy<=(b.tileY+b.h)*T+4){
const d=dist(wx,wy,b.x,b.y);
if(d<bd){bd=d;best={ent:b,kind:'building'};}
}
}
for(const t of trees){
if(t.dead)continue;
if(fog[idx(t.tx,t.ty)]===0)continue;
const d=dist(wx,wy,t.x,t.y-8);
if(d<15&&d<bd){bd=d;best={ent:t,kind:'tree'};}
}
for(const m of mines){
if(m.dead)continue;
if(wx>m.tx*T-8&&wx<(m.tx+2)*T+8&&wy>m.ty*T-12&&wy<(m.ty+2)*T+8){
const d=dist(wx,wy,m.x,m.y);
if(d<34&&d<bd){bd=d;best={ent:m,kind:'mine'};}
}
}
for(const r of rocks){
if(r.dead)continue;
const d=dist(wx,wy,r.x,r.y-6);
if(d<14&&d<bd){bd=d;best={ent:r,kind:'rock'};}
}
return best;
}
function clickSelect(wx,wy,shift){
const pick=pickEntity(wx,wy);
const now=performance.now();
if(pick&&(pick.kind==='unit'||pick.kind==='creep')){
const e=pick.ent;
if(now-lastClick.t<350&&lastClick.type===e.type&&e.kind==='unit'){
setSelection(units.filter(u=>u.type===e.type&&!u.dead));
lastClick.t=0;
return;
}
lastClick={t:now,kind:e.kind,type:e.type};
if(shift){
if(S.sel.includes(e)) setSelection(S.sel.filter(x=>x!==e));
else setSelection(S.sel.concat([e]));
}else setSelection([e]);
}else if(pick&&pick.kind==='building'){
if(shift){
if(S.sel.includes(pick.ent)) setSelection(S.sel.filter(x=>x!==pick.ent));
else setSelection(S.sel.concat([pick.ent]));
}else setSelection([pick.ent]);
}else if(!shift){
setSelection([]);
}
}
function boxSelect(x,y,w,h,shift){
const list=units.filter(u=>!u.dead&&u.x>=x&&u.x<=x+w&&u.y>=y-20&&u.y<=y+h);
if(list.length) setSelection(shift?[...new Set(S.sel.concat(list))]:list);
else if(!shift) setSelection([]);
}
function setSelection(list){
S.sel=list.filter(e=>!e.dead);
S.buildMenu=false;
if(!S.sel.some(u=>u.kind==='unit'&&u.def.military))S.attackMoveMode=false;
refreshCard();
refreshInfo();
}
function commandAt(wx,wy){
const pick=pickEntity(wx,wy);
const mobs=S.sel.filter(u=>u.kind==='unit'&&!u.dead);
if(!mobs.length){
const b=S.sel.length===1&&S.sel[0].kind==='building'?S.sel[0]:null;
if(b&&b.built&&b.def.trains){
if(pick&&(pick.kind==='tree'||pick.kind==='mine'||pick.kind==='creep')) b.rally={ent:pick.ent};
else b.rally={x:wx,y:wy};
burst(wx,wy,'#e8c66a',4,50);
}
return;
}
if(S.attackMoveMode){
mobs.forEach((u,i)=>{
if(!u.def.military){ setOrderMove(u,wx,wy); return; }
const off=formationOffset(i,mobs.length);
setOrderMove(u,wx+off.x,wy+off.y,'attackmove');
});
S.attackMoveMode=false;
refreshCard();
return;
}
mobs.forEach((u,i)=>{
if(pick){
const e=pick.ent;
if(pick.kind==='creep'){ orderAttack(u,e); return; }
if((pick.kind==='tree'||pick.kind==='mine')&&u.def.canGather){ orderGather(u,e); return; }
if(pick.kind==='building'&&e.team==='player'){
if(!e.built&&u.def.canBuild){ u.order={type:'build',b:e}; u.path=null; u.target=null; return; }
if(e.def.dropoff&&u.carryAmt>0){ startReturn(u,u.order.type==='gather'?u.order.res:null); return; }
}
}
const off=formationOffset(i,mobs.length);
setOrderMove(u,wx+off.x,wy+off.y);
});
}
/* ---- minimap input ---- */
function miniToWorld(e){
const r=mini.getBoundingClientRect();
const mx=(e.clientX-r.left)/r.width*MAP_W, my=(e.clientY-r.top)/r.height*MAP_H;
return {x:mx*T,y:my*T};
}
let miniDrag=false;
mini.addEventListener('contextmenu',e=>e.preventDefault());
mini.addEventListener('mousedown',e=>{
const w=miniToWorld(e);
if(e.button===0){ miniDrag=true; centerOn(w.x,w.y); }
else if(e.button===2){ commandAt(w.x,w.y); }
});
window.addEventListener('mousemove',e=>{
if(miniDrag&&e.buttons&1){ const w=miniToWorld(e); centerOn(w.x,w.y); }
});
window.addEventListener('mouseup',()=>miniDrag=false);
/* ---- keyboard ---- */
window.addEventListener('keydown',e=>{
if(e.target&&e.target.tagName==='INPUT')return;
const k=e.key.length===1?e.key.toLowerCase():e.key;
if(k==='Escape'){
if(!$('help').classList.contains('hidden')){ $('help').classList.add('hidden'); return; }
if(S.placing){ S.placing=null; refreshCard(); return; }
if(S.buildMenu){ S.buildMenu=false; refreshCard(); return; }
if(S.attackMoveMode){ S.attackMoveMode=false; toast('Attack-move cancelled',1200); return; }
setSelection([]);
return;
}
if(k==='F1'){
e.preventDefault();
$('help').classList.toggle('hidden');
return;
}
if(k==='p'){ cycleSpeed(); return; }
if(k===' '){
e.preventDefault();
const sel=S.sel.filter(x=>!x.dead);
if(sel.length){
const cx=sel.reduce((s,x)=>s+x.x,0)/sel.length;
const cy=sel.reduce((s,x)=>s+x.y,0)/sel.length;
centerOn(cx,cy);
}
return;
}
keys[e.code]=true;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code))e.preventDefault();
// card hotkeys
for(const cfg of cardBtns){
if(cfg.key&&cfg.key.toLowerCase()===k){
const en=cfg.enabled?cfg.enabled():true;
if(en)cfg.fn();
else toast('Can\'t do that right now');
return;
}
}
});
window.addEventListener('keyup',e=>{ keys[e.code]=false; });
window.addEventListener('blur',()=>{ for(const k in keys)keys[k]=false; });
/* ---- camera ---- */
function updateCamera(dt){
let vx=0,vy=0;
const sp=620;
if(keys.ArrowLeft)vx-=1; if(keys.ArrowRight)vx+=1;
if(keys.ArrowUp)vy-=1; if(keys.ArrowDown)vy+=1;
if(mouse.over){
const edge=22;
if(mouse.x<edge)vx-=1; else if(mouse.x>vw-edge)vx+=1;
if(mouse.y<edge)vy-=1; else if(mouse.y>vh-edge)vy+=1;
}
if(vx||vy){
const l=Math.hypot(vx,vy);
S.cam.x+=vx/l*sp*dt; S.cam.y+=vy/l*sp*dt;
clampCam();
}
}
/* ---- speed ---- */
function cycleSpeed(){
if(!S.paused&&S.speed===1){ S.speed=2; }
else if(!S.paused&&S.speed===2){ S.speed=4; }
else if(!S.paused&&S.speed===4){ S.paused=true; }
else { S.paused=false; S.speed=1; }
refreshTop();
}
$('btnSpeed').addEventListener('click',()=>{cycleSpeed();$('btnSpeed').blur();});
$('btnHelp').addEventListener('click',()=>{$('help').classList.remove('hidden');$('btnHelp').blur();});
$('btnHelpClose').addEventListener('click',()=>$('help').classList.add('hidden'));
$('btnContinue').addEventListener('click',()=>$('banner').classList.add('hidden'));
/* ================================================================
TEST / DEBUG API
================================================================ */
window.GAME={
version:1,
get state(){
return {gold:Math.floor(S.gold),wood:Math.floor(S.wood),foodUsed:S.foodUsed,foodCap:S.foodCap,
exploredPct:Math.floor(S.explored/S.totalTiles*100),units:units.length,
buildings:buildings.length,creeps:creeps.filter(c=>!c.dead).length,
speed:S.speed,paused:S.paused,time:Math.floor(S.time),won:S.won,
selected:S.sel.length};
},
selectWorkers(){ setSelection(units.filter(u=>u.def.canGather&&!u.dead)); return S.sel.length; },
selectMilitary(){ setSelection(units.filter(u=>u.def.military&&!u.dead)); return S.sel.length; },
selectBuildingOfType(type){ const b=buildings.find(b=>b.type===type&&!b.dead); if(b)setSelection([b]); return !!b; },
nearestMinePos(){
const th=buildings.find(b=>b.type==='townhall'&&!b.dead);
const m=nearestMine(th?th.x:WORLD_W/2,th?th.y:WORLD_H/2,999);
return m?{x:m.x,y:m.y,id:m.id}:null;
},
orderGatherMine(){
const sel=S.sel.filter(u=>u.kind==='unit'&&u.def.canGather&&!u.dead);
if(!sel.length)return 0;
const cx=sel.reduce((s,u)=>s+u.x,0)/sel.length, cy=sel.reduce((s,u)=>s+u.y,0)/sel.length;
const m=nearestMine(cx,cy,9999)||mines[0];
let n=0;
for(const u of sel){ orderGather(u,m); n++; }
return n;
},
rightClick(x,y){ commandAt(x,y); },
buildSpot(type){
// find a valid spot near the town hall
const th=buildings.find(b=>b.type==='townhall'&&!b.dead);
const cx=th?th.tileX:10, cy=th?th.tileY:10;
for(let r=3;r<20;r++)for(let a=0;a<16;a++){
const tx=cx+Math.round(Math.cos(a/16*Math.PI*2)*r);
const ty=cy+Math.round(Math.sin(a/16*Math.PI*2)*r);
if(validPlacement(type,tx,ty))return {tx,ty};
}
return null;
},
place(type,tx,ty){
if(!validPlacement(type,tx,ty))return false;
pay(BUILDS[type]);
const b=makeBuilding(type,tx,ty);
buildings.push(b); setBlockRect(tx,ty,b.w,b.h,1);
for(const u of units) if(u.def.canBuild&&!u.dead){ u.order={type:'build',b}; u.path=null; }
return true;
},
train(type){
const need=type==='worker'?'townhall':'barracks';
const b=buildings.find(b=>b.type===need&&b.built&&!b.dead);
if(!b)return false;
enqueueTrain(b,type);
return b.queue.length>0;
},
setSpeed(n){ S.paused=n===0; S.speed=Math.max(1,n); refreshTop(); },
revealAll(){
for(let i=0;i<fog.length;i++){ if(fog[i]===0)S.explored++; fog[i]=1; }
updateFog();
},
centerOn,
unitScreenPositions(){
return units.filter(u=>!u.dead).map(u=>({id:u.id,type:u.type,x:u.x-S.cam.x,y:u.y-S.cam.y}));
},
entityScreenPos(id){
const e=[...units,...buildings,...creeps].find(x=>x.id===id);
return e?{x:e.x-S.cam.x,y:e.y-S.cam.y}:null;
},
camHere(){ return {x:S.cam.x,y:S.cam.y}; },
debugGiveArmy(){
const th=buildings.find(b=>b.type==='townhall'&&!b.dead);
const x=th?th.x:WORLD_W/2, y=th?th.y+60:WORLD_H/2;
for(let i=0;i<3;i++){ const u=makeUnit('footman',x-40+i*26,y); units.push(u); S.foodUsed+=u.def.food; }
for(let i=0;i<2;i++){ const u=makeUnit('archer',x-25+i*30,y+30); units.push(u); S.foodUsed+=u.def.food; }
return 5;
},
debugSpawnWolf(){
const th=buildings.find(b=>b.type==='townhall'&&!b.dead);
const x=(th?th.x:WORLD_W/2)+80, y=(th?th.y:WORLD_H/2)+40;
spawnCreep('wolf',x,y);
return {x,y};
},
debugFastBuild(){
for(const b of buildings) if(!b.built&&!b.dead) completeBuilding(b);
},
selectionInfo(){
return S.sel.map(e=>({kind:e.kind,type:e.type,id:e.id}));
},
};
/* ================================================================
BOOT + LOOP
================================================================ */
buildGround();
buildMiniBase();
genWorld();
resize();
window.addEventListener('resize',resize);
refreshTop();
refreshCard();
refreshInfo();
updateFog();
// intro hints
setTimeout(()=>toast('Drag a box around your workers, then right-click the gold mine to gather gold.',6000),800);
setTimeout(()=>toast('Select a worker and press B to build — start with a Farm for food.',6000),8000);
setTimeout(()=>toast('Raise an army at the Barracks and uncover 100% of the map to win!',6000),15500);
let last=performance.now(), uiAcc=0;
function frame(now){
requestAnimationFrame(frame);
let dtR=(now-last)/1000;
last=now;
dtR=Math.min(dtR,0.05);
updateCamera(dtR);
if(S.placing&&mouse.over) updateGhost(mouse.x+S.cam.x,mouse.y+S.cam.y);
const dt=S.paused?0:dtR*S.speed;
if(dt>0){ S.time+=dt; updateWorld(dt); }
updateFog();
render();
renderMini();
uiAcc+=dtR;
if(uiAcc>0.12){
uiAcc=0;
refreshTop();
updateCardStates();
if(S.sel.some(e=>e.dead)) setSelection(S.sel.filter(e=>!e.dead));
else refreshInfo();
}
}
requestAnimationFrame(frame);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment