Skip to content

Instantly share code, notes, and snippets.

@senko
Created April 29, 2026 13:44
Show Gist options
  • Select an option

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

Select an option

Save senko/8b8c52452c2283db65b29b62a48398cf to your computer and use it in GitHub Desktop.
RTS game by Qwen3.6 Max
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mini RTS</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #111; overflow: hidden; font-family: 'Segoe UI', Arial, sans-serif; user-select: none; }
canvas { display: block; cursor: crosshair; }
#ui-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
#top-bar {
position: absolute; top: 0; left: 0; right: 0; height: 36px;
background: linear-gradient(180deg, rgba(30,25,20,0.95), rgba(20,15,10,0.9));
border-bottom: 2px solid #8B7355; display: flex; align-items: center; padding: 0 15px; gap: 30px;
pointer-events: auto; z-index: 10;
}
.res-item { display: flex; align-items: center; gap: 6px; color: #ddd; font-size: 14px; font-weight: bold; }
.res-icon { width: 20px; height: 20px; border-radius: 3px; }
.res-icon.gold { background: linear-gradient(135deg, #FFD700, #DAA520); }
.res-icon.wood { background: linear-gradient(135deg, #228B22, #006400); }
.res-icon.food { background: linear-gradient(135deg, #FF6347, #CD5C5C); }
.res-val { color: #FFD700; min-width: 40px; }
.res-val.wood-val { color: #90EE90; }
.res-val.food-val { color: #FFA07A; }
#bottom-panel {
position: absolute; bottom: 0; left: 0; right: 0; height: 160px;
background: linear-gradient(0deg, rgba(30,25,20,0.97), rgba(25,20,15,0.93));
border-top: 2px solid #8B7355; display: flex; pointer-events: auto; z-index: 10;
}
#minimap-container { width: 160px; height: 160px; border-right: 2px solid #8B7355; flex-shrink: 0; }
#minimap { width: 160px; height: 160px; }
#info-panel { flex: 1; padding: 8px 15px; display: flex; gap: 15px; overflow: hidden; }
#unit-info { min-width: 180px; }
#unit-info .name { color: #FFD700; font-size: 16px; font-weight: bold; margin-bottom: 4px; }
#unit-info .stats { color: #bbb; font-size: 12px; line-height: 1.6; }
#unit-info .hp-bar { width: 150px; height: 10px; background: #333; border: 1px solid #555; border-radius: 2px; margin-top: 4px; }
#unit-info .hp-fill { height: 100%; background: linear-gradient(90deg, #ff3333, #33ff33); border-radius: 1px; transition: width 0.2s; }
#action-panel { flex: 1; display: flex; flex-wrap: wrap; gap: 4px; align-content: flex-start; padding: 4px; }
.action-btn {
width: 56px; height: 56px; background: linear-gradient(180deg, #4a3f35, #2a2520);
border: 2px solid #8B7355; border-radius: 4px; color: #ddd; font-size: 9px; text-align: center; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; transition: all 0.15s;
}
.action-btn:hover { background: linear-gradient(180deg, #6a5f55, #4a4540); border-color: #FFD700; }
.action-btn:active { transform: scale(0.95); }
.action-btn .btn-icon { font-size: 20px; line-height: 1; }
.action-btn .btn-label { font-size: 8px; line-height: 1; }
.action-btn .btn-cost { font-size: 7px; color: #FFD700; line-height: 1; }
.action-btn.disabled { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.5); }
.action-btn.disabled:hover { border-color: #8B7355; background: linear-gradient(180deg, #4a3f35, #2a2520); }
#build-menu {
position: absolute; bottom: 170px; right: 10px;
background: rgba(30,25,20,0.97); border: 2px solid #8B7355; border-radius: 6px; padding: 8px;
display: none; pointer-events: auto; z-index: 30;
}
#build-menu.show { display: flex; flex-wrap: wrap; gap: 4px; max-width: 250px; }
.build-btn {
width: 72px; height: 72px; background: linear-gradient(180deg, #4a3f35, #2a2520);
border: 2px solid #8B7355; border-radius: 4px; color: #ddd; font-size: 10px; text-align: center; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; transition: all 0.15s;
}
.build-btn:hover { background: linear-gradient(180deg, #6a5f55, #4a4540); border-color: #FFD700; }
.build-btn .btn-icon { font-size: 24px; }
.build-btn .btn-cost { font-size: 8px; color: #FFD700; }
.build-btn.disabled { opacity: 0.4; cursor: not-allowed; }
#notification {
position: absolute; top: 50px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.8); color: #FFD700; padding: 8px 20px; border-radius: 4px; font-size: 14px; font-weight: bold;
border: 1px solid #8B7355; display: none; z-index: 50; pointer-events: none;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="ui-overlay">
<div id="top-bar">
<div class="res-item"><div class="res-icon gold"></div><span>Gold:</span><span class="res-val" id="gold-val">500</span></div>
<div class="res-item"><div class="res-icon wood"></div><span>Wood:</span><span class="res-val wood-val" id="wood-val">300</span></div>
<div class="res-item"><div class="res-icon food"></div><span>Food:</span><span class="res-val food-val" id="food-val">0/10</span></div>
</div>
<div id="bottom-panel">
<div id="minimap-container"><canvas id="minimap" width="160" height="160"></canvas></div>
<div id="info-panel">
<div id="unit-info"></div>
<div id="action-panel"></div>
</div>
</div>
<div id="build-menu"></div>
<div id="notification"></div>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const minimapCanvas = document.getElementById('minimap');
const minimapCtx = minimapCanvas.getContext('2d');
const TILE = 32, MAP_W = 80, MAP_H = 80;
const T_GRASS = 0, T_TREE = 1, T_GOLD = 2, T_WATER = 3, T_DIRT = 4, T_STONE = 5;
const F_HIDDEN = 0, F_REVEALED = 1, F_VISIBLE = 2;
let game = {
gold: 500, wood: 300, food: 0, foodMax: 10,
map: [], fog: [], resourceHp: {},
units: [], buildings: [], selected: [],
camera: { x: 0, y: 0 }, buildMode: null,
tick: 0, dragging: false, dragStart: null, dragEnd: null,
keys: {}, mouse: { x: 0, y: 0, wx: 0, wy: 0 },
nextId: 1, notifTimer: 0
};
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
window.addEventListener('resize', resize);
resize();
// --- Map Gen ---
function generateMap() {
for (let y = 0; y < MAP_H; y++) {
game.map[y] = []; game.fog[y] = [];
for (let x = 0; x < MAP_W; x++) { game.map[y][x] = T_GRASS; game.fog[y][x] = F_HIDDEN; }
}
const fill = (cx, cy, r, type, prob=1) => {
for (let dy=-r; dy<=r; dy++) for (let dx=-r; dx<=r; dx++) {
if (dx*dx+dy*dy <= r*r && Math.random()<prob) {
let nx=Math.floor(cx+dx), ny=Math.floor(cy+dy);
if (nx>=0 && nx<MAP_W && ny>=0 && ny<MAP_H && game.map[ny][nx]===T_GRASS) {
game.map[ny][nx] = type;
if (type===T_TREE) game.resourceHp[nx+','+ny] = 30;
if (type===T_GOLD) game.resourceHp[nx+','+ny] = 100;
}
}
}
};
for(let i=0;i<15;i++) fill(10+Math.random()*60, 10+Math.random()*60, 2+Math.random()*4, T_DIRT);
for(let i=0;i<6;i++) fill(5+Math.random()*70, 5+Math.random()*70, 3+Math.random()*5, T_WATER, 0.8);
for(let i=0;i<25;i++) fill(3+Math.random()*74, 3+Math.random()*74, 2+Math.random()*5, T_TREE, 0.7);
for(let i=0;i<10;i++) fill(5+Math.random()*70, 5+Math.random()*70, 1, T_GOLD);
for(let i=0;i<8;i++) fill(5+Math.random()*70, 5+Math.random()*70, 1+Math.random()*3, T_STONE);
let sx=35, sy=35;
for(let dy=-5;dy<=5;dy++) for(let dx=-5;dx<=5;dx++) {
let nx=sx+dx, ny=sy+dy;
if(nx>=0&&nx<MAP_W&&ny>=0&&ny<MAP_H) { game.map[ny][nx]=T_GRASS; delete game.resourceHp[nx+','+ny]; }
}
placeBuilding('townhall', sx-1, sy-1, true);
for(let i=0;i<3;i++) placeUnit('worker', sx+2+i, sy+3);
revealFog(sx, sy, 12);
}
function revealFog(cx, cy, r) {
for(let dy=-r;dy<=r;dy++) for(let dx=-r;dx<=r;dx++) {
let nx=cx+dx, ny=cy+dy;
if(nx>=0&&nx<MAP_W&&ny>=0&&ny<MAP_H && dx*dx+dy*dy<=r*r) game.fog[ny][nx]=F_VISIBLE;
}
}
function updateFog() {
for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++) if(game.fog[y][x]===F_VISIBLE) game.fog[y][x]=F_REVEALED;
for(let e of [...game.units, ...game.buildings]) revealFog(Math.floor(e.x), Math.floor(e.y), e.sightRadius||5);
}
// --- Definitions ---
const UNIT_DEFS = {
worker: { name:'Worker', hp:40, speed:1.8, sight:4, cost:{gold:50}, buildTime:120, icon:'⛏️', attack:3, attackRange:1, attackCooldown:30 },
soldier: { name:'Soldier', hp:80, speed:1.5, sight:5, cost:{gold:75,wood:25}, buildTime:180, icon:'⚔️', attack:12, attackRange:1, attackCooldown:25 },
archer: { name:'Archer', hp:50, speed:1.6, sight:6, cost:{gold:60,wood:40}, buildTime:160, icon:'🏹', attack:8, attackRange:5, attackCooldown:30 },
cavalry: { name:'Cavalry', hp:120, speed:2.5, sight:6, cost:{gold:120,wood:30}, buildTime:240, icon:'🐴', attack:18, attackRange:1, attackCooldown:20 },
};
const BUILDING_DEFS = {
townhall: { name:'Town Hall', hp:500, sight:8, cost:{gold:400,wood:200}, buildTime:600, icon:'🏰', w:3, h:3, providesFood:0, canProduce:['worker'] },
barracks: { name:'Barracks', hp:300, sight:5, cost:{gold:200,wood:150}, buildTime:400, icon:'🏗️', w:2, h:2, providesFood:0, canProduce:['soldier','archer'] },
farm: { name:'Farm', hp:150, sight:3, cost:{gold:100,wood:50}, buildTime:200, icon:'🌾', w:2, h:2, providesFood:10, canProduce:[] },
stable: { name:'Stable', hp:250, sight:5, cost:{gold:250,wood:200}, buildTime:450, icon:'🏇', w:2, h:2, providesFood:0, canProduce:['cavalry'] },
tower: { name:'Watch Tower', hp:200, sight:10, cost:{gold:150,wood:100}, buildTime:300, icon:'🗼', w:1, h:1, providesFood:0, canProduce:[], attack:15, attackRange:6, attackCooldown:20 },
house: { name:'House', hp:100, sight:3, cost:{gold:80,wood:60}, buildTime:150, icon:'🏠', w:1, h:1, providesFood:5, canProduce:[] },
};
function placeUnit(type, x, y) {
let d = UNIT_DEFS[type];
let u = { id:game.nextId++, type, name:d.name, x, y, hp:d.hp, maxHp:d.hp, speed:d.speed, sightRadius:d.sight,
attack:d.attack, attackRange:d.attackRange, attackCooldown:d.attackCooldown, attackTimer:0, icon:d.icon,
targetX:null, targetY:null, targetEntity:null, state:'idle', carrying:null, carryCapacity:10,
gatherTarget:null, gatherTimer:0, buildTarget:null, radius:0.4, selected:false, path:[], animFrame:0, animTimer:0, direction:0 };
game.units.push(u); return u;
}
function placeBuilding(type, x, y, instant=false) {
let d = BUILDING_DEFS[type];
let b = { id:game.nextId++, type, name:d.name, x, y, hp:instant?d.hp:0, maxHp:d.hp, sightRadius:d.sight, icon:d.icon,
w:d.w, h:d.h, providesFood:d.providesFood, canProduce:[...(d.canProduce||[])], attack:d.attack||0, attackRange:d.attackRange||0,
attackCooldown:d.attackCooldown||0, attackTimer:0, state:instant?'complete':'building', buildProgress:instant?d.buildTime:0,
buildTime:d.buildTime, productionQueue:[], productionTimer:0, selected:false, rallyX:null, rallyY:null, radius:Math.max(d.w,d.h)*0.5 };
game.buildings.push(b); recalcFood(); return b;
}
function recalcFood() {
game.foodMax = 10;
for(let b of game.buildings) if(b.state==='complete') game.foodMax += b.providesFood;
game.food = game.units.length;
}
// --- Pathfinding & Helpers ---
function findPath(sx, sy, ex, ey, max=300) {
sx=Math.floor(sx); sy=Math.floor(sy); ex=Math.floor(ex); ey=Math.floor(ey);
if(sx===ex && sy===ey) return [];
if(ex<0||ex>=MAP_W||ey<0||ey>=MAP_H) return [];
let open=[{x:sx,y:sy,g:0,h:Math.abs(ex-sx)+Math.abs(ey-sy),parent:null}], closed=new Set(), key=(x,y)=>x*1000+y;
closed.add(key(sx,sy));
let dirs=[[0,-1],[1,0],[0,1],[-1,0],[1,-1],[1,1],[-1,1],[-1,-1]];
while(open.length && max-- > 0) {
let best=0; for(let i=1;i<open.length;i++) if(open[i].g+open[i].h < open[best].g+open[best].h) best=i;
let cur=open.splice(best,1)[0];
if(cur.x===ex && cur.y===ey) { let p=[],n=cur; while(n.parent){p.unshift({x:n.x,y:n.y});n=n.parent;} return p; }
for(let [dx,dy] of dirs) {
let nx=cur.x+dx, ny=cur.y+dy;
if(nx<0||nx>=MAP_W||ny<0||ny>=MAP_H || closed.has(key(nx,ny)) || (isBlocked(nx,ny) && !(nx===ex&&ny===ey))) continue;
closed.add(key(nx,ny)); open.push({x:nx,y:ny,g:cur.g+((dx&&dy)?1.414:1),h:Math.abs(ex-nx)+Math.abs(ey-ny),parent:cur});
}
}
return [];
}
function isBlocked(x,y) {
if(x<0||x>=MAP_W||y<0||y>=MAP_H) return true;
let t=game.map[y][x]; if(t===T_WATER||t===T_TREE) return true;
for(let b of game.buildings) if((b.state==='complete'||b.state==='building') && x>=b.x&&x<b.x+b.w&&y>=b.y&&y<b.y+b.h) return true;
return false;
}
function isBuildingBlocked(x,y,w,h) {
for(let dy=0;dy<h;dy++) for(let dx=0;dx<w;dx++) {
let nx=x+dx,ny=y+dy;
if(nx<0||nx>=MAP_W||ny<0||ny>=MAP_H) return true;
let t=game.map[ny][nx]; if(t===T_WATER||t===T_TREE||t===T_GOLD||t===T_STONE) return true;
for(let b of game.buildings) if((b.state==='complete'||b.state==='building')&&nx>=b.x&&nx<b.x+b.w&&ny>=b.y&&ny<b.y+b.h) return true;
}
return false;
}
function getResourceAt(x,y) {
if(y<0||y>=MAP_H||x<0||x>=MAP_W) return null;
let t=game.map[y][x]; return t===T_GOLD?'gold':t===T_TREE?'wood':null;
}
function depleteResource(x,y,amt) {
let k=x+','+y; if(!game.resourceHp[k]) game.resourceHp[k]=game.map[y][x]===T_GOLD?100:30;
game.resourceHp[k]-=amt;
if(game.resourceHp[k]<=0) { game.map[y][x]=game.map[y][x]===T_GOLD?T_DIRT:T_GRASS; delete game.resourceHp[k]; return true; }
return false;
}
function findNearestResource(ux,uy,type) {
let mapType=type==='gold'?T_GOLD:T_TREE, best=null, bestD=Infinity;
for(let r=1;r<40;r++) {
for(let dy=-r;dy<=r;dy++) for(let dx=-r;dx<=r;dx++) {
if(Math.abs(dx)!==r&&Math.abs(dy)!==r) continue;
let nx=Math.floor(ux)+dx, ny=Math.floor(uy)+dy;
if(nx>=0&&nx<MAP_W&&ny>=0&&ny<MAP_H&&game.map[ny][nx]===mapType) {
let d=Math.abs(dx)+Math.abs(dy); if(d<bestD){bestD=d;best={x:nx,y:ny,type};}
}
}
if(best) return best;
}
return null;
}
function findNearestTownHall(ux,uy) {
let best=null, minD=Infinity;
for(let b of game.buildings) if(b.type==='townhall'&&(b.state==='complete'||b.state==='building')) {
let d=Math.hypot(b.x+b.w/2-ux, b.y+b.h/2-uy); if(d<minD){minD=d;best=b;}
}
return best;
}
function canAfford(c) { return game.gold>=(c.gold||0) && game.wood>=(c.wood||0); }
function spendResources(c) { if(canAfford(c)){game.gold-=c.gold||0;game.wood-=c.wood||0;return true;} return false; }
function notify(m) { let e=document.getElementById('notification'); e.textContent=m; e.style.display='block'; game.notifTimer=150; }
// --- Selection ---
function clearSelection() { for(let u of game.units) u.selected=false; for(let b of game.buildings) b.selected=false; game.selected=[]; }
function selectEntity(e) { clearSelection(); e.selected=true; game.selected=[e]; }
function addToSelection(e) { if(!e.selected){e.selected=true;game.selected.push(e);} }
function selectInRect(x1,y1,x2,y2) {
clearSelection();
let mnX=Math.min(x1,x2),mxX=Math.max(x1,x2),mnY=Math.min(y1,y2),mxY=Math.max(y1,y2);
for(let u of game.units) if(u.x>=mnX&&u.x<=mxX&&u.y>=mnY&&u.y<=mxY){u.selected=true;game.selected.push(u);}
if(!game.selected.length) for(let b of game.buildings) if(b.x>=mnX&&b.x+b.w<=mxX+1&&b.y>=mnY&&b.y+b.h<=mxY+1){b.selected=true;game.selected.push(b);}
}
function getEntityAt(wx,wy) {
for(let u of game.units) if(Math.abs(u.x-wx)<0.6&&Math.abs(u.y-wy)<0.6) return u;
for(let b of game.buildings) if(wx>=b.x&&wx<b.x+b.w&&wy>=b.y&&wy<b.y+b.h) return b;
return null;
}
// --- Commands ---
function moveSelected(tx,ty) {
let units=game.selected.filter(e=>e.speed); if(!units.length) return;
let cols=Math.ceil(Math.sqrt(units.length)), sp=1.2;
units.forEach((u,i)=>{
let c=i%cols, r=Math.floor(i/cols);
u.targetX=tx+(c-(cols-1)/2)*sp; u.targetY=ty+(r-(Math.ceil(units.length/cols)-1)/2)*sp;
u.targetEntity=null; u.state='moving'; u.path=findPath(u.x,u.y,u.targetX,u.targetY); u.gatherTarget=null; u.buildTarget=null;
});
}
function gatherResource(u,tx,ty) {
let rt=getResourceAt(tx,ty); if(!rt) return;
u.gatherTarget={x:tx,y:ty,type:rt}; u.state='gathering'; u.targetX=tx+0.5; u.targetY=ty+0.5;
u.path=findPath(u.x,u.y,u.targetX,u.targetY); u.gatherTimer=0;
}
function returnResources(u) {
let th=findNearestTownHall(u.x,u.y);
if(th){u.state='returning';u.targetX=th.x+th.w/2;u.targetY=th.y+th.h+0.5;u.path=findPath(u.x,u.y,u.targetX,u.targetY);u.returnBuilding=th;}
else{u.state='idle';notify('No Town Hall!');}
}
function startProduction(b,type) {
let d=UNIT_DEFS[type]; if(!d) return;
if(!canAfford(d.cost)){notify('Not enough resources!');return;}
if(game.food>=game.foodMax){notify('Need more food!');return;}
if(b.productionQueue.length>=5){notify('Queue full!');return;}
spendResources(d.cost); b.productionQueue.push({type,progress:0,totalTime:d.buildTime}); notify('Training '+d.name+'...');
}
function showBuildMenu() {
let menu=document.getElementById('build-menu'), html='';
for(let bt of ['house','farm','barracks','stable','tower']) {
let d=BUILDING_DEFS[bt], cs=(d.cost.gold?d.cost.gold+'g ':'')+(d.cost.wood?d.cost.wood+'w':'');
html+=`<div class="build-btn ${canAfford(d.cost)?'':'disabled'}" data-buildtype="${bt}">
<span class="btn-icon">${d.icon}</span><span style="font-size:9px;">${d.name}</span><span class="btn-cost">${cs}</span></div>`;
}
menu.innerHTML=html; menu.classList.add('show');
menu.querySelectorAll('.build-btn').forEach(btn=>btn.addEventListener('click',e=>{
e.stopPropagation(); let bt=btn.dataset.buildtype, d=BUILDING_DEFS[bt];
if(!canAfford(d.cost)){notify('Not enough resources!');return;}
game.buildMode=bt; menu.classList.remove('show'); notify('Click to place '+d.name);
}));
}
function placeBuildingGhost(bx,by) {
if(!game.buildMode) return; let d=BUILDING_DEFS[game.buildMode];
if(!canAfford(d.cost)){notify('Not enough resources!');game.buildMode=null;return;}
if(isBuildingBlocked(bx,by,d.w,d.h)){notify('Cannot build here!');return;}
spendResources(d.cost); let b=placeBuilding(game.buildMode,bx,by);
let nearest=null,minD=Infinity;
for(let u of game.units) if(u.type==='worker'){let md=Math.hypot(u.x-(bx+d.w/2),u.y-(by+d.h/2));if(md<minD){minD=d;nearest=u;}}
if(nearest){nearest.buildTarget=b;nearest.state='building';nearest.targetX=bx+d.w/2;nearest.targetY=by+d.h+0.5;nearest.path=findPath(nearest.x,nearest.y,nearest.targetX,nearest.targetY);nearest.gatherTarget=null;}
game.buildMode=null; recalcFood(); notify('Constructing '+d.name+'...');
}
// --- Update ---
function update() {
game.tick++;
let cs=4;
if(game.keys['ArrowLeft']||game.keys['KeyA']||game.mouse.x<15) game.camera.x-=cs;
if(game.keys['ArrowRight']||game.keys['KeyD']||game.mouse.x>canvas.width-15) game.camera.x+=cs;
if(game.keys['ArrowUp']||game.keys['KeyW']||(game.mouse.y<51&&game.mouse.y>36)) game.camera.y-=cs;
if(game.keys['ArrowDown']||game.keys['KeyS']||game.mouse.y>canvas.height-175) game.camera.y+=cs;
game.camera.x=Math.max(0,Math.min(MAP_W*TILE-canvas.width,game.camera.x));
game.camera.y=Math.max(0,Math.min(MAP_H*TILE-canvas.height+160,game.camera.y));
game.mouse.wx=(game.mouse.x+game.camera.x)/TILE; game.mouse.wy=(game.mouse.y+game.camera.y-36)/TILE;
if(game.notifTimer>0){game.notifTimer--;if(game.notifTimer<=0)document.getElementById('notification').style.display='none';}
if(game.tick%10===0) updateFog();
// Clean dead from selection
game.selected = game.selected.filter(e => e.hp > 0);
for(let u of game.units) {
u.animTimer++; if(u.animTimer>=15){u.animTimer=0;u.animFrame=(u.animFrame+1)%4;}
if(u.attackTimer>0) u.attackTimer--;
switch(u.state) {
case 'moving': updateMovement(u); if(u.state==='moving'&&Math.hypot(u.x-u.targetX,u.y-u.targetY)<0.2){u.state='idle';u.targetX=u.targetY=null;} break;
case 'gathering': updateGathering(u); break;
case 'returning': updateReturning(u); break;
case 'building': updateConstruction(u); break;
case 'attacking': updateAttacking(u); break;
}
}
for(let b of game.buildings) {
if(b.state==='complete'&&b.productionQueue.length>0) {
let q=b.productionQueue[0]; q.progress++;
if(q.progress>=q.totalTime) {
let d=UNIT_DEFS[q.type], u=placeUnit(q.type, b.x+b.w/2+(Math.random()-0.5)*2, b.y+b.h+0.5+Math.random()*0.5);
if(b.rallyX!==null){u.targetX=b.rallyX;u.targetY=b.rallyY;u.state='moving';u.path=findPath(u.x,u.y,u.targetX,u.targetY);}
b.productionQueue.shift(); recalcFood(); notify(d.name+' ready!');
}
}
}
game.units=game.units.filter(u=>u.hp>0);
game.buildings=game.buildings.filter(b=>{if(b.hp<=0&&b.state!=='building'){recalcFood();return false;}return true;});
}
function updateMovement(u) {
let tx=u.targetX,ty=u.targetY; if(tx===null||ty===null){u.state='idle';return;}
let dist=Math.hypot(u.x-tx,u.y-ty); if(dist<0.15){u.x=tx;u.y=ty;return;}
if(u.path.length>0) {
let n=u.path[0], nx=n.x+0.5, ny=n.y+0.5, d=Math.hypot(u.x-nx,u.y-ny);
if(d<0.25) u.path.shift();
else { let dx=(nx-u.x)/d,dy=(ny-u.y)/d; u.direction=Math.atan2(dy,dx); let s=u.speed/30; u.x+=dx*s;u.y+=dy*s; }
} else {
let dx=(tx-u.x)/dist,dy=(ty-u.y)/dist; u.direction=Math.atan2(dy,dx); let s=u.speed/30;
let nx=u.x+dx*Math.min(s,dist), ny=u.y+dy*Math.min(s,dist);
if(!isBlocked(Math.floor(nx),Math.floor(ny))){u.x=nx;u.y=ny;}
else { u.path=findPath(u.x,u.y,tx,ty); if(!u.path.length) u.state='idle'; }
}
}
function updateGathering(u) {
if(!u.gatherTarget){u.state='idle';return;}
let gt=u.gatherTarget, cur=(gt.y>=0&&gt.y<MAP_H&&gt.x>=0&&gt.x<MAP_W)?game.map[gt.y][gt.x]:-1;
let exp=gt.type==='gold'?T_GOLD:T_TREE;
if(cur!==exp) {
let nr=findNearestResource(u.x,u.y,gt.type);
if(nr){u.gatherTarget=nr;u.targetX=nr.x+0.5;u.targetY=nr.y+0.5;u.path=findPath(u.x,u.y,u.targetX,u.targetY);u.gatherTimer=0;}
else {u.gatherTarget=null;u.state='idle';if(u.carrying&&u.carrying.amount>0)returnResources(u);}
return;
}
if(Math.hypot(u.x-(gt.x+0.5),u.y-(gt.y+0.5))>1.5){updateMovement(u);return;}
u.gatherTimer++;
if(u.gatherTimer>=60) {
u.gatherTimer=0; if(!u.carrying) u.carrying={type:gt.type,amount:0};
if(u.carrying.type===gt.type) {
let amt=Math.min(u.carryCapacity-u.carrying.amount, gt.type==='gold'?5:3);
u.carrying.amount+=amt;
if(depleteResource(gt.x,gt.y,amt)) {
let nr=findNearestResource(u.x,u.y,gt.type);
if(nr){u.gatherTarget=nr;u.targetX=nr.x+0.5;u.targetY=nr.y+0.5;u.path=findPath(u.x,u.y,u.targetX,u.targetY);}
else u.gatherTarget=null;
}
if(u.carrying.amount>=u.carryCapacity) returnResources(u);
}
}
}
function updateReturning(u) {
if(!u.carrying||u.carrying.amount<=0){u.state='idle';return;}
if(Math.hypot(u.x-u.targetX,u.y-u.targetY)>1.5){updateMovement(u);return;}
if(u.carrying.type==='gold') game.gold+=u.carrying.amount;
if(u.carrying.type==='wood') game.wood+=u.carrying.amount;
let ct=u.carrying.type; u.carrying=null;
if(u.gatherTarget) {
let gt=u.gatherTarget, cur=(gt.y>=0&&gt.y<MAP_H&&gt.x>=0&&gt.x<MAP_W)?game.map[gt.y][gt.x]:-1;
if(cur===(ct==='gold'?T_GOLD:T_TREE)) {
u.state='gathering'; u.targetX=gt.x+0.5; u.targetY=gt.y+0.5; u.path=findPath(u.x,u.y,u.targetX,u.targetY); u.gatherTimer=0;
} else {
let nr=findNearestResource(u.x,u.y,ct);
if(nr){u.gatherTarget=nr;u.state='gathering';u.targetX=nr.x+0.5;u.targetY=nr.y+0.5;u.path=findPath(u.x,u.y,u.targetX,u.targetY);u.gatherTimer=0;}
else {u.gatherTarget=null;u.state='idle';}
}
} else u.state='idle';
}
function updateConstruction(u) {
if(!u.buildTarget||u.buildTarget.hp<=0||u.buildTarget.state==='complete'){u.buildTarget=null;u.state='idle';return;}
let b=u.buildTarget; if(Math.hypot(u.x-(b.x+b.w/2),u.y-(b.y+b.h/2))>3){updateMovement(u);return;}
b.buildProgress+=2; b.hp=Math.min(b.maxHp,(b.buildProgress/b.buildTime)*b.maxHp);
if(b.buildProgress>=b.buildTime){b.state='complete';b.hp=b.maxHp;u.buildTarget=null;u.state='idle';recalcFood();notify(b.name+' complete!');}
}
function updateAttacking(u) {
if(!u.targetEntity||u.targetEntity.hp<=0){u.targetEntity=null;u.state='idle';return;}
let t=u.targetEntity, tcx=t.x+(t.w||0)/2, tcy=t.y+(t.h||0)/2, dist=Math.hypot(u.x-tcx,u.y-tcy);
let range=u.attackRange+Math.max((t.w||0),(t.h||0))*0.5;
if(dist<=range) {
u.targetX=u.x;u.targetY=u.y;
if(u.attackTimer<=0){t.hp-=u.attack;u.attackTimer=u.attackCooldown;u.direction=Math.atan2(tcy-u.y,tcx-u.x);if(t.hp<=0){destroyEntity(t);u.targetEntity=null;u.state='idle';}}
} else { u.targetX=tcx;u.targetY=tcy; updateMovement(u); }
}
function destroyEntity(e){e.hp=0;if(!e.speed)recalcFood();}
function getFogAt(x,y){let tx=Math.floor(x),ty=Math.floor(y);if(tx<0||tx>=MAP_W||ty<0||ty>=MAP_H)return F_HIDDEN;return game.fog[ty][tx];}
// --- Render ---
const TC={[T_GRASS]:['#4a8c3f','#3d7a34','#5a9c4f','#4b8b40'],[T_TREE]:['#2d5a1e','#1e4a12','#3a6a2e'],[T_GOLD]:['#c8a832','#b89828','#d8b842'],[T_WATER]:['#2a5a8a','#1e4a7a','#3a6a9a','#285888'],[T_DIRT]:['#8a7a5a','#7a6a4a','#9a8a6a'],[T_STONE]:['#6a6a6a','#5a5a5a','#7a7a7a']};
function drawTerrain() {
let sx=Math.max(0,Math.floor(game.camera.x/TILE)),sy=Math.max(0,Math.floor(game.camera.y/TILE));
let vx=Math.ceil(canvas.width/TILE),vy=Math.ceil((canvas.height-196)/TILE);
let ex=Math.min(MAP_W,sx+vx+2),ey=Math.min(MAP_H,sy+vy+2);
for(let y=sy;y<ey;y++) for(let x=sx;x<ex;x++) {
let f=game.fog[y][x], px=x*TILE-game.camera.x, py=y*TILE-game.camera.y+36;
if(f===F_HIDDEN){ctx.fillStyle='#111';ctx.fillRect(px,py,TILE+1,TILE+1);continue;}
let t=game.map[y][x]; ctx.fillStyle=(TC[t]||TC[T_GRASS])[(x*7+y*13)%(TC[t]||TC[T_GRASS]).length];
ctx.fillRect(px,py,TILE+1,TILE+1);
if(f===F_VISIBLE) {
let cx=px+TILE/2,cy=py+TILE/2;
if(t===T_TREE){ctx.fillStyle='#5a3a1a';ctx.fillRect(cx-2,cy+2,4,10);ctx.fillStyle='#2a6a1a';ctx.beginPath();ctx.arc(cx,cy-2,10,0,Math.PI*2);ctx.fill();ctx.fillStyle='#3a8a2a';ctx.beginPath();ctx.arc(cx-3,cy-5,7,0,Math.PI*2);ctx.fill();}
else if(t===T_GOLD){ctx.fillStyle='#8a7a2a';ctx.beginPath();ctx.arc(cx,cy+2,8,0,Math.PI*2);ctx.fill();ctx.fillStyle='#FFD700';ctx.beginPath();ctx.arc(cx-2,cy,5,0,Math.PI*2);ctx.fill();}
else if(t===T_WATER){ctx.fillStyle=`rgba(100,180,255,${0.1+Math.sin(game.tick*0.05+x*0.5+y*0.3)*0.1})`;ctx.fillRect(px,py,TILE,TILE);}
}
if(f===F_REVEALED){ctx.fillStyle='rgba(0,0,0,0.4)';ctx.fillRect(px,py,TILE+1,TILE+1);}
}
}
function drawBuildings() {
for(let b of game.buildings) {
let f=getFogAt(b.x+b.w/2,b.y+b.h/2); if(f===F_HIDDEN) continue;
let sx=b.x*TILE-game.camera.x, sy=b.y*TILE-game.camera.y+36, sw=b.w*TILE, sh=b.h*TILE;
if(f===F_REVEALED) ctx.globalAlpha=0.5;
ctx.fillStyle='rgba(0,0,0,0.3)';ctx.fillRect(sx+3,sy+3,sw,sh);
let bc,rc;
switch(b.type){case'townhall':bc='#8B7355';rc='#A0522D';break;case'barracks':bc='#6B4226';rc='#8B0000';break;case'farm':bc='#8FBC8F';rc='#DAA520';break;case'stable':bc='#8B6914';rc='#654321';break;case'tower':bc='#808080';rc='#4A4A4A';break;case'house':bc='#BC8F8F';rc='#CD853F';break;default:bc='#8B7355';rc='#A0522D';}
if(b.state==='building') ctx.globalAlpha*=0.4+0.6*(b.buildProgress/b.buildTime);
ctx.fillStyle=bc;ctx.fillRect(sx+2,sy+2,sw-4,sh-4);
ctx.fillStyle=rc;if(b.w>=2&&b.h>=2){ctx.beginPath();ctx.moveTo(sx,sy+sh*0.3);ctx.lineTo(sx+sw/2,sy);ctx.lineTo(sx+sw,sy+sh*0.3);ctx.closePath();ctx.fill();}else ctx.fillRect(sx+2,sy+2,sw-4,sh*0.3);
ctx.fillStyle='#3a2a1a';ctx.fillRect(sx+sw/2-4,sy+sh-12,8,10);
ctx.fillStyle='#FFE4B5';if(b.w>=2){ctx.fillRect(sx+8,sy+sh*0.4,6,6);ctx.fillRect(sx+sw-14,sy+sh*0.4,6,6);}
if(b.hp<b.maxHp){let p=b.hp/b.maxHp;ctx.fillStyle='#333';ctx.fillRect(sx+2,sy-6,sw-4,4);ctx.fillStyle=p>0.5?'#3c3':p>0.25?'#cc3':'#c33';ctx.fillRect(sx+2,sy-6,(sw-4)*p,4);}
if(b.selected){ctx.strokeStyle='#0f0';ctx.lineWidth=2;ctx.strokeRect(sx-2,sy-2,sw+4,sh+4);ctx.lineWidth=1;}
if(b.state==='complete'&&b.productionQueue.length>0){let q=b.productionQueue[0],p=q.progress/q.totalTime;ctx.fillStyle='#333';ctx.fillRect(sx+2,sy+sh+2,sw-4,4);ctx.fillStyle='#48f';ctx.fillRect(sx+2,sy+sh+2,(sw-4)*p,4);if(b.productionQueue.length>1){ctx.fillStyle='#aaa';ctx.font='10px sans-serif';ctx.fillText('+'+(b.productionQueue.length-1),sx+sw-14,sy+sh+12);}}
ctx.globalAlpha=1;
}
}
function drawUnits() {
for(let u of game.units) {
let f=getFogAt(u.x,u.y); if(f===F_HIDDEN) continue;
let sx=u.x*TILE-game.camera.x, sy=u.y*TILE-game.camera.y+36;
if(f===F_REVEALED) ctx.globalAlpha=0.5;
ctx.fillStyle='rgba(0,0,0,0.3)';ctx.beginPath();ctx.ellipse(sx+2,sy+10,6,3,0,0,Math.PI*2);ctx.fill();
let bc,ac; switch(u.type){case'worker':bc='#8B7355';ac='#DAA520';break;case'soldier':bc='#4169E1';ac='#FFD700';break;case'archer':bc='#228B22';ac='#FF8C00';break;case'cavalry':bc='#8B4513';ac='#D2691E';break;default:bc='#888';ac='#aaa';}
let bob=u.state!=='idle'?Math.sin(game.tick*0.15)*2:0;
ctx.fillStyle=bc;ctx.beginPath();ctx.arc(sx,sy+bob-4,7,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#FFDAB9';ctx.beginPath();ctx.arc(sx,sy+bob-12,4,0,Math.PI*2);ctx.fill();
ctx.fillStyle=ac;ctx.beginPath();ctx.arc(sx,sy+bob-14,4,Math.PI,0);ctx.fill();
if(u.type==='soldier'){ctx.strokeStyle='#C0C0C0';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(sx+6,sy+bob-16);ctx.lineTo(sx+6,sy+bob-2);ctx.stroke();ctx.lineWidth=1;}
else if(u.type==='archer'){ctx.strokeStyle='#8B4513';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(sx+8,sy+bob-8,6,-Math.PI*0.7,Math.PI*0.7);ctx.stroke();ctx.lineWidth=1;}
else if(u.type==='worker'){ctx.fillStyle='#888';ctx.fillRect(sx+5,sy+bob-14,2,8);}
if(u.carrying&&u.carrying.amount>0){ctx.fillStyle=u.carrying.type==='gold'?'#FFD700':'#228B22';ctx.beginPath();ctx.arc(sx,sy+bob+2,3,0,Math.PI*2);ctx.fill();}
if(u.type==='cavalry'){ctx.fillStyle='#8B4513';ctx.beginPath();ctx.ellipse(sx,sy+bob+2,10,5,0,0,Math.PI*2);ctx.fill();ctx.fillStyle='#654321';let lo=u.state!=='idle'?Math.sin(game.tick*0.2)*3:0;ctx.fillRect(sx-7,sy+bob+5,2,5+lo);ctx.fillRect(sx+5,sy+bob+5,2,5-lo);}
if(u.hp<u.maxHp){let p=u.hp/u.maxHp;ctx.fillStyle='#333';ctx.fillRect(sx-8,sy-20,16,3);ctx.fillStyle=p>0.5?'#3c3':p>0.25?'#cc3':'#c33';ctx.fillRect(sx-8,sy-20,16*p,3);}
if(u.selected){ctx.strokeStyle='#0f0';ctx.lineWidth=1.5;ctx.beginPath();ctx.ellipse(sx,sy+8,10,5,0,0,Math.PI*2);ctx.stroke();ctx.lineWidth=1;}
ctx.globalAlpha=1;
}
}
function drawBuildMode() {
if(!game.buildMode) return; let d=BUILDING_DEFS[game.buildMode], mx=Math.floor(game.mouse.wx), my=Math.floor(game.mouse.wy);
let sx=mx*TILE-game.camera.x, sy=my*TILE-game.camera.y+36, sw=d.w*TILE, sh=d.h*TILE, bl=isBuildingBlocked(mx,my,d.w,d.h);
ctx.fillStyle=bl?'rgba(255,0,0,0.3)':'rgba(0,255,0,0.3)';ctx.fillRect(sx,sy,sw,sh);
ctx.strokeStyle=bl?'#f00':'#0f0';ctx.lineWidth=2;ctx.strokeRect(sx,sy,sw,sh);ctx.lineWidth=1;
}
function drawSelectionBox() {
if(!game.dragging||!game.dragStart||!game.dragEnd) return;
ctx.strokeStyle='#0f0';ctx.lineWidth=1;ctx.setLineDash([4,4]);ctx.strokeRect(game.dragStart.x,game.dragStart.y,game.dragEnd.x-game.dragStart.x,game.dragEnd.y-game.dragStart.y);ctx.setLineDash([]);
ctx.fillStyle='rgba(0,255,0,0.1)';ctx.fillRect(game.dragStart.x,game.dragStart.y,game.dragEnd.x-game.dragStart.x,game.dragEnd.y-game.dragStart.y);
}
function drawMinimap() {
let mw=160,mh=160,sx=mw/MAP_W,sy=mh/MAP_H; minimapCtx.fillStyle='#111';minimapCtx.fillRect(0,0,mw,mh);
let tc={[T_GRASS]:'#3a6a2a',[T_TREE]:'#1a4a0a',[T_GOLD]:'#c8a832',[T_WATER]:'#2a5a8a',[T_DIRT]:'#7a6a4a',[T_STONE]:'#6a6a6a'};
for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++) {
if(game.fog[y][x]===F_HIDDEN) continue;
minimapCtx.fillStyle=tc[game.map[y][x]]||'#3a6a2a'; if(game.fog[y][x]===F_REVEALED) minimapCtx.globalAlpha=0.5;
minimapCtx.fillRect(x*sx,y*sy,sx+0.5,sy+0.5); minimapCtx.globalAlpha=1;
}
for(let b of game.buildings) if(getFogAt(b.x+b.w/2,b.y+b.h/2)!==F_HIDDEN){minimapCtx.fillStyle='#f44';minimapCtx.fillRect(b.x*sx,b.y*sy,Math.max(b.w*sx,2),Math.max(b.h*sy,2));}
for(let u of game.units) if(getFogAt(u.x,u.y)!==F_HIDDEN){minimapCtx.fillStyle='#4f4';minimapCtx.fillRect(u.x*sx-0.5,u.y*sy-0.5,2,2);}
minimapCtx.strokeStyle='#fff';minimapCtx.lineWidth=1;minimapCtx.strokeRect(game.camera.x/TILE*sx,game.camera.y/TILE*sy,(canvas.width/TILE)*sx,((canvas.height-196)/TILE)*sy);
}
// --- UI ---
let lastSelKey = '';
function updateUI() {
document.getElementById('gold-val').textContent = Math.floor(game.gold);
document.getElementById('wood-val').textContent = Math.floor(game.wood);
document.getElementById('food-val').textContent = game.food + '/' + game.foodMax;
let infoEl = document.getElementById('unit-info');
let selKey = game.selected.map(e => e.id).join(',');
let selChanged = selKey !== lastSelKey;
// Info panel (updates every frame for HP/progress)
if (game.selected.length === 0) {
infoEl.innerHTML = '<span style="color:#888;font-size:12px;">Select a unit or building</span>';
} else if (game.selected.length === 1) {
let e = game.selected[0];
let hpPct = (e.hp / e.maxHp * 100).toFixed(0);
let html = `<div class="name">${e.icon||''} ${e.name}</div><div class="stats">HP: ${Math.floor(e.hp)}/${e.maxHp}</div>`;
html += `<div class="hp-bar"><div class="hp-fill" style="width:${hpPct}%"></div></div>`;
if(e.speed) {
html += `<div class="stats" style="margin-top:4px;">Attack: ${e.attack} | Range: ${e.attackRange}</div>`;
if(e.carrying&&e.carrying.amount>0) html += `<div class="stats">Carrying: ${e.carrying.amount} ${e.carrying.type}</div>`;
html += `<div class="stats">State: ${e.state}</div>`;
}
if(e.productionQueue!==undefined) {
html += `<div class="stats">Queue: ${e.productionQueue.length}/5</div>`;
if(e.productionQueue.length>0) { let q=e.productionQueue[0]; html += `<div class="stats">Training: ${UNIT_DEFS[q.type].name} (${(q.progress/q.totalTime*100).toFixed(0)}%)</div>`; }
}
infoEl.innerHTML = html;
} else {
let counts = {}; for(let u of game.selected) counts[u.name]=(counts[u.name]||0)+1;
let html = `<div class="name">Selection (${game.selected.length})</div>`;
for(let [n,c] of Object.entries(counts)) html += `<div class="stats">${n}: ${c}</div>`;
infoEl.innerHTML = html;
}
// Action panel (only updates when selection changes)
if (selChanged) {
lastSelKey = selKey;
let actionEl = document.getElementById('action-panel');
let actions = '';
if (game.selected.length === 1) {
let e = game.selected[0];
if(e.type==='townhall'&&e.state==='complete') actions += makeActionBtn('worker','⛏️','Worker','50g');
if(e.type==='barracks'&&e.state==='complete') { actions += makeActionBtn('soldier','⚔️','Soldier','75g 25w'); actions += makeActionBtn('archer','🏹','Archer','60g 40w'); }
if(e.type==='stable'&&e.state==='complete') actions += makeActionBtn('cavalry','🐴','Cavalry','120g 30w');
if(e.type==='worker') actions += makeActionBtn('build','🔨','Build','');
if(e.speed) actions += makeActionBtn('stop','🛑','Stop','');
} else if (game.selected.length > 1) {
actions += makeActionBtn('stop','🛑','Stop','');
}
actionEl.innerHTML = actions;
}
}
function makeActionBtn(action, icon, label, cost) {
let disabled = '';
if(UNIT_DEFS[action] && !canAfford(UNIT_DEFS[action].cost)) disabled = 'disabled';
if(BUILDING_DEFS[action] && !canAfford(BUILDING_DEFS[action].cost)) disabled = 'disabled';
return `<div class="action-btn ${disabled}" data-action="${action}" title="${label}">
<span class="btn-icon">${icon}</span><span class="btn-label">${label}</span>${cost?'<span class="btn-cost">'+cost+'</span>':''}</div>`;
}
function handleAction(action) {
if(!action) return;
if(action === 'build') { showBuildMenu(); return; }
if(action === 'stop') {
for(let u of game.selected) if(u.speed){u.state='idle';u.targetX=u.targetY=u.targetEntity=u.gatherTarget=u.buildTarget=null;u.path=[];}
return;
}
if(game.selected.length===1 && game.selected[0].canProduce && game.selected[0].canProduce.includes(action)) {
startProduction(game.selected[0], action);
}
}
// Event Delegation for Action Buttons (runs once)
document.getElementById('action-panel').addEventListener('click', (e) => {
let btn = e.target.closest('.action-btn');
if (btn && !btn.classList.contains('disabled')) handleAction(btn.dataset.action);
});
// --- Input ---
canvas.addEventListener('mousedown', (e) => {
e.preventDefault(); let rect=canvas.getBoundingClientRect(), mx=e.clientX-rect.left, my=e.clientY-rect.top;
if(my<36||my>canvas.height-160) return;
if(e.button===0) {
if(game.buildMode){placeBuildingGhost(Math.floor(game.mouse.wx),Math.floor(game.mouse.wy));return;}
game.dragging=true; game.dragStart={x:mx,y:my}; game.dragEnd={x:mx,y:my};
} else if(e.button===2) {
if(game.buildMode){game.buildMode=null;return;}
let wx=game.mouse.wx, wy=game.mouse.wy, tx=Math.floor(wx), ty=Math.floor(wy), target=getEntityAt(wx,wy);
if(target && !game.selected.includes(target)) {
let rt=getResourceAt(tx,ty);
if(rt) { for(let u of game.selected) if(u.type==='worker') gatherResource(u,tx,ty); }
else { for(let u of game.selected) if(u.speed){u.targetEntity=target;u.state='attacking';u.targetX=target.x+(target.w||0)/2;u.targetY=target.y+(target.h||0)/2;u.path=findPath(u.x,u.y,u.targetX,u.targetY);} }
} else {
let movable=game.selected.filter(e=>e.speed);
if(movable.length) {
let rt=getResourceAt(tx,ty);
if(rt) { for(let u of movable) if(u.type==='worker') gatherResource(u,tx,ty); else {u.targetX=wx;u.targetY=wy;u.state='moving';u.path=findPath(u.x,u.y,wx,wy);} }
else moveSelected(wx,wy);
}
for(let b of game.selected) if(!b.speed){b.rallyX=wx;b.rallyY=wy;}
}
}
});
canvas.addEventListener('mousemove', (e) => { let r=canvas.getBoundingClientRect(); game.mouse.x=e.clientX-r.left; game.mouse.y=e.clientY-r.top; if(game.dragging) game.dragEnd={x:game.mouse.x,y:game.mouse.y}; });
canvas.addEventListener('mouseup', (e) => {
if(e.button===0 && game.dragging) {
if(game.dragStart&&game.dragEnd) {
let dx=Math.abs(game.dragEnd.x-game.dragStart.x), dy=Math.abs(game.dragEnd.y-game.dragStart.y);
if(dx<5&&dy<5) { let ent=getEntityAt(game.mouse.wx,game.mouse.wy); if(ent){if(e.shiftKey)addToSelection(ent);else selectEntity(ent);} else clearSelection(); }
else selectInRect((game.dragStart.x+game.camera.x)/TILE,(game.dragStart.y+game.camera.y-36)/TILE,(game.dragEnd.x+game.camera.x)/TILE,(game.dragEnd.y+game.camera.y-36)/TILE);
}
game.dragging=false; game.dragStart=game.dragEnd=null;
}
});
canvas.addEventListener('contextmenu', e=>e.preventDefault());
document.addEventListener('keydown', e=>{game.keys[e.code]=true;if(e.code==='Escape'){game.buildMode=null;document.getElementById('build-menu').classList.remove('show');clearSelection();}});
document.addEventListener('keyup', e=>{game.keys[e.code]=false;});
minimapCanvas.addEventListener('mousedown', e=>{e.preventDefault();let r=minimapCanvas.getBoundingClientRect();game.camera.x=(e.clientX-r.left)/160*MAP_W*TILE-canvas.width/2;game.camera.y=(e.clientY-r.top)/160*MAP_H*TILE-(canvas.height-196)/2;game.camera.x=Math.max(0,Math.min(MAP_W*TILE-canvas.width,game.camera.x));game.camera.y=Math.max(0,Math.min(MAP_H*TILE-canvas.height+160,game.camera.y));});
minimapCanvas.addEventListener('mousemove', e=>{if(e.buttons&1){let r=minimapCanvas.getBoundingClientRect();game.camera.x=(e.clientX-r.left)/160*MAP_W*TILE-canvas.width/2;game.camera.y=(e.clientY-r.top)/160*MAP_H*TILE-(canvas.height-196)/2;game.camera.x=Math.max(0,Math.min(MAP_W*TILE-canvas.width,game.camera.x));game.camera.y=Math.max(0,Math.min(MAP_H*TILE-canvas.height+160,game.camera.y));}});
// --- Loop ---
function render() { ctx.fillStyle='#111';ctx.fillRect(0,0,canvas.width,canvas.height); drawTerrain(); drawBuildings(); drawUnits(); drawBuildMode(); drawSelectionBox(); drawMinimap(); }
function loop() { update(); updateUI(); render(); requestAnimationFrame(loop); }
generateMap();
game.camera.x = 35*TILE - canvas.width/2;
game.camera.y = 35*TILE - (canvas.height-196)/2;
loop();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment