Created
April 29, 2026 13:44
-
-
Save senko/8b8c52452c2283db65b29b62a48398cf to your computer and use it in GitHub Desktop.
RTS game by Qwen3.6 Max
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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&>.y<MAP_H&>.x>=0&>.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&>.y<MAP_H&>.x>=0&>.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