Created
February 5, 2026 19:17
-
-
Save senko/8751829c1aa9a4342383ef2fd4da7424 to your computer and use it in GitHub Desktop.
RTS game by Opus 4.6
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>Conquest RTS</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; } | |
| #game { display: block; cursor: default; } | |
| #minimap { position: fixed; left: 8px; bottom: 158px; border: 2px solid #556; background: #000; z-index: 10; cursor: pointer; } | |
| #topbar { position: fixed; top: 0; left: 0; right: 0; height: 36px; background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); border-bottom: 2px solid #334; display: flex; align-items: center; padding: 0 16px; gap: 24px; z-index: 10; user-select: none; } | |
| .res { display: flex; align-items: center; gap: 6px; color: #ddd; font-size: 14px; font-weight: 600; } | |
| .res-icon { width: 16px; height: 16px; border-radius: 3px; display: inline-block; } | |
| .gold-icon { background: linear-gradient(135deg, #ffd700, #daa520); } | |
| .wood-icon { background: linear-gradient(135deg, #8b6914, #6b4226); } | |
| .supply-icon { background: linear-gradient(135deg, #6a9eea, #3366cc); } | |
| #panel { position: fixed; bottom: 0; left: 0; right: 0; height: 150px; background: linear-gradient(0deg, #0d1117 0%, #161b22 100%); border-top: 2px solid #334; display: flex; z-index: 10; user-select: none; } | |
| #panel-info { flex: 1; padding: 10px 16px; display: flex; flex-direction: column; gap: 4px; color: #ccc; min-width: 0; } | |
| #panel-info .name { font-size: 16px; font-weight: 700; color: #fff; } | |
| #panel-info .detail { font-size: 12px; color: #aaa; } | |
| #panel-info .hp-bar-outer { width: 140px; height: 8px; background: #333; border-radius: 4px; margin-top: 4px; } | |
| #panel-info .hp-bar-inner { height: 100%; background: #4caf50; border-radius: 4px; transition: width 0.2s; } | |
| #panel-info .train-progress-outer { width: 140px; height: 8px; background: #333; border-radius: 4px; margin-top: 2px; } | |
| #panel-info .train-progress-inner { height: 100%; background: #42a5f5; border-radius: 4px; } | |
| #panel-actions { width: 320px; padding: 10px; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 6px; } | |
| .action-btn { background: #1e2a3a; border: 1px solid #3a4a5a; color: #ddd; padding: 6px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; min-width: 90px; text-align: center; transition: all 0.15s; } | |
| .action-btn:hover { background: #2a3a5a; border-color: #5a7aaa; color: #fff; } | |
| .action-btn:active { background: #3a4a6a; } | |
| .action-btn.disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; } | |
| .action-btn .cost { font-size: 10px; color: #999; display: block; } | |
| #msg { position: fixed; top: 44px; left: 50%; transform: translateX(-50%); color: #ffcc00; font-size: 14px; font-weight: 600; z-index: 20; pointer-events: none; opacity: 0; transition: opacity 0.3s; text-shadow: 0 0 8px rgba(0,0,0,0.8); } | |
| #msg.show { opacity: 1; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <canvas id="minimap" width="180" height="180"></canvas> | |
| <div id="topbar"> | |
| <div class="res"><span class="res-icon gold-icon"></span><span id="resGold">500</span></div> | |
| <div class="res"><span class="res-icon wood-icon"></span><span id="resWood">500</span></div> | |
| <div class="res"><span class="res-icon supply-icon"></span><span id="resSupply">0/10</span></div> | |
| </div> | |
| <div id="panel"> | |
| <div id="panel-info"><span class="detail" style="margin-top:40px;text-align:center;color:#667">Select a unit or building</span></div> | |
| <div id="panel-actions"></div> | |
| </div> | |
| <div id="msg"></div> | |
| <script> | |
| (() => { | |
| 'use strict'; | |
| // ======================== CONSTANTS ======================== | |
| const TILE = 32; | |
| const MAP_W = 80, MAP_H = 80; | |
| const WORLD_W = MAP_W * TILE, WORLD_H = MAP_H * TILE; | |
| const T_GRASS = 0, T_WATER = 1, T_TREE = 2, T_GOLD = 3; | |
| const FOG_HIDDEN = 0, FOG_EXPLORED = 1, FOG_VISIBLE = 2; | |
| const TEAM_COLOR = '#3b7ddd'; | |
| const EDGE_SCROLL = 20, SCROLL_SPEED = 8; | |
| const MINIMAP_SIZE = 180; | |
| const MINIMAP_SCALE = MINIMAP_SIZE / MAP_W; | |
| const BLDG_DEFS = { | |
| townhall: { name:'Town Hall', w:3, h:3, hp:1200, gCost:400, wCost:200, bTime:480, supply:10, sight:8, trains:['worker'] }, | |
| barracks: { name:'Barracks', w:3, h:3, hp:800, gCost:300, wCost:150, bTime:360, supply:0, sight:6, trains:['soldier'] }, | |
| farm: { name:'Farm', w:2, h:2, hp:400, gCost:100, wCost:50, bTime:200, supply:6, sight:4, trains:[] }, | |
| tower: { name:'Tower', w:2, h:2, hp:600, gCost:200, wCost:100, bTime:300, supply:0, sight:9, trains:[] }, | |
| }; | |
| const UNIT_DEFS = { | |
| worker: { name:'Worker', hp:60, speed:2.2, gCost:75, wCost:0, tTime:150, supply:1, sight:6, gatherAmt:10, gatherTime:80, atk:5, atkRange:1.2 }, | |
| soldier: { name:'Soldier', hp:150, speed:1.9, gCost:150, wCost:25, tTime:180, supply:1, sight:6, atk:18, atkRange:1.3 }, | |
| }; | |
| // ======================== GAME STATE ======================== | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const mmCanvas = document.getElementById('minimap'); | |
| const mmCtx = mmCanvas.getContext('2d'); | |
| let W, H; | |
| const map = []; | |
| const fog = []; | |
| const grassVar = []; | |
| let treeHealth = []; // HP left for each tree tile | |
| const goldMines = []; | |
| const units = []; | |
| const buildings = []; | |
| const camera = { x: 0, y: 0 }; | |
| const res = { gold: 500, wood: 500 }; | |
| let supplyUsed = 0, supplyMax = 0; | |
| let selectedUnits = []; | |
| let selectedBuilding = null; | |
| let placementType = null; | |
| let isDragging = false; | |
| let dragStartScreen = { x:0, y:0 }; | |
| let dragEndScreen = { x:0, y:0 }; | |
| let mouseScreen = { x:0, y:0 }; | |
| let mouseWorld = { x:0, y:0 }; | |
| let mouseTile = { x:0, y:0 }; | |
| let nextId = 1; | |
| let frameCount = 0; | |
| let keysDown = {}; | |
| let msgTimer = 0; | |
| const moveMarkers = []; | |
| const START_TX = 12, START_TY = 12; | |
| let rightClickHandled = false; | |
| // ======================== UTILITY ======================== | |
| function uid() { return nextId++; } | |
| function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } | |
| function dist(x1,y1,x2,y2) { return Math.sqrt((x2-x1)**2+(y2-y1)**2); } | |
| function tileDist(x1,y1,x2,y2) { return Math.abs(x2-x1)+Math.abs(y2-y1); } | |
| function hash(x, y) { let h = x * 374761393 + y * 668265263; h = (h ^ (h >> 13)) * 1274126177; return (h ^ (h >> 16)) & 0x7fffffff; } | |
| function showMsg(txt) { const el = document.getElementById('msg'); el.textContent = txt; el.classList.add('show'); msgTimer = 120; } | |
| function screenToWorld(sx, sy) { return { x: sx + camera.x, y: sy + camera.y }; } | |
| function worldToScreen(wx, wy) { return { x: wx - camera.x, y: wy - camera.y }; } | |
| function worldToTile(wx, wy) { return { x: Math.floor(wx / TILE), y: Math.floor(wy / TILE) }; } | |
| function tileCenter(tx, ty) { return { x: tx * TILE + TILE/2, y: ty * TILE + TILE/2 }; } | |
| function inBounds(tx, ty) { return tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H; } | |
| function isWalkable(tx, ty) { | |
| if (!inBounds(tx, ty)) return false; | |
| const t = map[ty][tx]; | |
| if (t === T_WATER || t === T_TREE || t === T_GOLD) return false; | |
| for (const b of buildings) { | |
| if (tx >= b.tileX && tx < b.tileX + b.w && ty >= b.tileY && ty < b.tileY + b.h) return false; | |
| } | |
| return true; | |
| } | |
| function canAfford(gCost, wCost) { return res.gold >= gCost && res.wood >= wCost; } | |
| function spend(gCost, wCost) { res.gold -= gCost; res.wood -= wCost; } | |
| function findNearestTownHall(wx, wy) { | |
| let best = null, bestD = Infinity; | |
| for (const b of buildings) { | |
| if (b.type === 'townhall' && b.built) { | |
| const cx = (b.tileX + b.w/2) * TILE; | |
| const cy = (b.tileY + b.h/2) * TILE; | |
| const d = dist(wx, wy, cx, cy); | |
| if (d < bestD) { bestD = d; best = b; } | |
| } | |
| } | |
| return best; | |
| } | |
| function findAdjacentWalkable(tileX, tileY, w, h, fromX, fromY) { | |
| const candidates = []; | |
| for (let dy = -1; dy <= h; dy++) { | |
| for (let dx = -1; dx <= w; dx++) { | |
| if (dx >= 0 && dx < w && dy >= 0 && dy < h) continue; | |
| const tx = tileX + dx, ty = tileY + dy; | |
| if (inBounds(tx, ty) && isWalkable(tx, ty)) { | |
| candidates.push({ x: tx, y: ty }); | |
| } | |
| } | |
| } | |
| const ftx = Math.floor(fromX / TILE), fty = Math.floor(fromY / TILE); | |
| candidates.sort((a, b) => tileDist(a.x, a.y, ftx, fty) - tileDist(b.x, b.y, ftx, fty)); | |
| return candidates[0] || null; | |
| } | |
| function findNearestWalkable(tx, ty) { | |
| for (let r = 1; r < 15; 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; | |
| const nx = tx + dx, ny = ty + dy; | |
| if (inBounds(nx, ny) && isWalkable(nx, ny)) return { x: nx, y: ny }; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function getGoldMineAt(tx, ty) { | |
| for (const m of goldMines) { | |
| if (tx >= m.tileX && tx < m.tileX + 2 && ty >= m.tileY && ty < m.tileY + 2 && m.remaining > 0) return m; | |
| } | |
| return null; | |
| } | |
| function getBuildingAt(tx, ty) { | |
| for (const b of buildings) { | |
| if (tx >= b.tileX && tx < b.tileX + b.w && ty >= b.tileY && ty < b.tileY + b.h) return b; | |
| } | |
| return null; | |
| } | |
| function getUnitAt(wx, wy) { | |
| let best = null, bestD = Infinity; | |
| for (const u of units) { | |
| const d = dist(wx, wy, u.x, u.y); | |
| if (d < 14 && d < bestD) { bestD = d; best = u; } | |
| } | |
| return best; | |
| } | |
| // ======================== MAP GENERATION ======================== | |
| function generateMap() { | |
| for (let y = 0; y < MAP_H; y++) { | |
| map[y] = []; fog[y] = []; grassVar[y] = []; treeHealth[y] = []; | |
| for (let x = 0; x < MAP_W; x++) { | |
| map[y][x] = T_GRASS; | |
| fog[y][x] = FOG_HIDDEN; | |
| grassVar[y][x] = hash(x, y) % 5; | |
| treeHealth[y][x] = 0; | |
| } | |
| } | |
| // Border trees | |
| for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) { | |
| if (x < 2 || x >= MAP_W-2 || y < 2 || y >= MAP_H-2) { map[y][x] = T_TREE; treeHealth[y][x] = 50; } | |
| } | |
| // Water bodies | |
| for (let i = 0; i < 3; i++) { | |
| const cx = 15 + Math.floor(Math.random() * (MAP_W - 30)); | |
| const cy = 15 + Math.floor(Math.random() * (MAP_H - 30)); | |
| createLake(cx, cy, 25 + Math.floor(Math.random() * 40)); | |
| } | |
| // Tree clusters | |
| for (let i = 0; i < 18; i++) { | |
| const cx = 4 + Math.floor(Math.random() * (MAP_W - 8)); | |
| const cy = 4 + Math.floor(Math.random() * (MAP_H - 8)); | |
| createForest(cx, cy, 15 + Math.floor(Math.random() * 35)); | |
| } | |
| // Gold mines | |
| const mineSpots = [ | |
| [START_TX + 8, START_TY - 3], | |
| [START_TX - 5, START_TY + 8], | |
| [5 + Math.floor(Math.random()*10), 40 + Math.floor(Math.random()*10)], | |
| [40 + Math.floor(Math.random()*10), 5 + Math.floor(Math.random()*10)], | |
| [55 + Math.floor(Math.random()*10), 25 + Math.floor(Math.random()*10)], | |
| [25 + Math.floor(Math.random()*10), 55 + Math.floor(Math.random()*10)], | |
| [60 + Math.floor(Math.random()*10), 55 + Math.floor(Math.random()*10)], | |
| [50 + Math.floor(Math.random()*10), 45 + Math.floor(Math.random()*10)], | |
| ]; | |
| for (const [mx, my] of mineSpots) { | |
| placeGoldMine(clamp(mx, 3, MAP_W-5), clamp(my, 3, MAP_H-5), 2000 + Math.floor(Math.random()*1000)); | |
| } | |
| // Clear starting area | |
| clearArea(START_TX + 1, START_TY + 1, 7); | |
| // Ensure trees near start for wood | |
| for (let i = 0; i < 20; i++) { | |
| const tx = START_TX - 4 + Math.floor(Math.random() * 3) - 1; | |
| const ty = START_TY + Math.floor(Math.random() * 6); | |
| if (inBounds(tx, ty) && map[ty][tx] === T_GRASS && dist(tx, ty, START_TX+1, START_TY+1) > 5) { | |
| map[ty][tx] = T_TREE; treeHealth[ty][tx] = 50; | |
| } | |
| } | |
| for (let i = 0; i < 15; i++) { | |
| const tx = START_TX + 5 + Math.floor(Math.random() * 4); | |
| const ty = START_TY + 4 + Math.floor(Math.random() * 4); | |
| if (inBounds(tx, ty) && map[ty][tx] === T_GRASS) { | |
| map[ty][tx] = T_TREE; treeHealth[ty][tx] = 50; | |
| } | |
| } | |
| } | |
| function createLake(cx, cy, size) { | |
| const pts = [{ x: cx, y: cy }]; | |
| if (inBounds(cx, cy)) map[cy][cx] = T_WATER; | |
| for (let i = 0; i < size; i++) { | |
| const p = pts[Math.floor(Math.random() * pts.length)]; | |
| const nx = p.x + Math.floor(Math.random()*3)-1; | |
| const ny = p.y + Math.floor(Math.random()*3)-1; | |
| if (inBounds(nx, ny) && map[ny][nx] !== T_GOLD) { | |
| map[ny][nx] = T_WATER; pts.push({ x:nx, y:ny }); | |
| } | |
| } | |
| } | |
| function createForest(cx, cy, size) { | |
| for (let i = 0; i < size; i++) { | |
| const tx = cx + Math.floor((Math.random()-0.5) * 10); | |
| const ty = cy + Math.floor((Math.random()-0.5) * 10); | |
| if (inBounds(tx, ty) && map[ty][tx] === T_GRASS) { | |
| map[ty][tx] = T_TREE; treeHealth[ty][tx] = 50; | |
| } | |
| } | |
| } | |
| function placeGoldMine(tx, ty, amount) { | |
| // Clear area for mine | |
| for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) { | |
| if (inBounds(tx+dx, ty+dy)) map[ty+dy][tx+dx] = T_GOLD; | |
| } | |
| goldMines.push({ tileX: tx, tileY: ty, remaining: amount, maxAmount: amount }); | |
| } | |
| function clearArea(cx, cy, radius) { | |
| for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) { | |
| if (dx*dx + dy*dy <= radius*radius) { | |
| const x = cx+dx, y = cy+dy; | |
| if (inBounds(x, y) && map[y][x] !== T_GOLD) map[y][x] = T_GRASS; | |
| } | |
| } | |
| } | |
| // ======================== CAMERA ======================== | |
| function updateCamera() { | |
| if (keysDown['ArrowLeft'] || keysDown['a']) camera.x -= SCROLL_SPEED; | |
| if (keysDown['ArrowRight'] || keysDown['d']) camera.x += SCROLL_SPEED; | |
| if (keysDown['ArrowUp'] || keysDown['w']) camera.y -= SCROLL_SPEED; | |
| if (keysDown['ArrowDown'] || keysDown['s']) camera.y += SCROLL_SPEED; | |
| // Edge scroll - only when mouse is in the game viewport (not over HUD) | |
| const mouseInGame = mouseScreen.y > 36 && mouseScreen.y < H - 150; | |
| if (mouseInGame) { | |
| if (mouseScreen.x < EDGE_SCROLL) camera.x -= SCROLL_SPEED; | |
| if (mouseScreen.x > W - EDGE_SCROLL) camera.x += SCROLL_SPEED; | |
| if (mouseScreen.y < 36 + EDGE_SCROLL) camera.y -= SCROLL_SPEED; | |
| if (mouseScreen.y > H - 150 - EDGE_SCROLL) camera.y += SCROLL_SPEED; | |
| } | |
| camera.x = clamp(camera.x, 0, WORLD_W - W); | |
| camera.y = clamp(camera.y, 0, WORLD_H - H); | |
| const wm = screenToWorld(mouseScreen.x, mouseScreen.y); | |
| mouseWorld.x = wm.x; mouseWorld.y = wm.y; | |
| const tm = worldToTile(wm.x, wm.y); | |
| mouseTile.x = tm.x; mouseTile.y = tm.y; | |
| } | |
| // ======================== FOG OF WAR ======================== | |
| function updateFog() { | |
| for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) { | |
| if (fog[y][x] === FOG_VISIBLE) fog[y][x] = FOG_EXPLORED; | |
| } | |
| for (const u of units) { | |
| const t = worldToTile(u.x, u.y); | |
| revealFog(t.x, t.y, UNIT_DEFS[u.type].sight); | |
| } | |
| for (const b of buildings) { | |
| if (b.buildProgress > 0) { | |
| const cx = b.tileX + Math.floor(b.w/2); | |
| const cy = b.tileY + Math.floor(b.h/2); | |
| revealFog(cx, cy, BLDG_DEFS[b.type].sight); | |
| } | |
| } | |
| } | |
| function revealFog(cx, cy, r) { | |
| const r2 = r * r; | |
| for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { | |
| if (dx*dx + dy*dy <= r2) { | |
| const tx = cx+dx, ty = cy+dy; | |
| if (inBounds(tx, ty)) fog[ty][tx] = FOG_VISIBLE; | |
| } | |
| } | |
| } | |
| // ======================== PATHFINDING ======================== | |
| function findPath(sx, sy, ex, ey) { | |
| let stx = clamp(Math.floor(sx/TILE), 0, MAP_W-1), sty = clamp(Math.floor(sy/TILE), 0, MAP_H-1); | |
| let etx = clamp(Math.floor(ex/TILE), 0, MAP_W-1), ety = clamp(Math.floor(ey/TILE), 0, MAP_H-1); | |
| if (!isWalkable(stx, sty)) { const a = findNearestWalkable(stx, sty); if (!a) return []; stx = a.x; sty = a.y; } | |
| if (!isWalkable(etx, ety)) { const a = findNearestWalkable(etx, ety); if (!a) return []; etx = a.x; ety = a.y; } | |
| if (stx === etx && sty === ety) return []; | |
| const key = (x,y) => y * MAP_W + x; | |
| const openMap = new Map(); | |
| const closed = new Set(); | |
| const cameFrom = new Map(); | |
| const gScore = new Map(); | |
| const sk = key(stx, sty); | |
| gScore.set(sk, 0); | |
| openMap.set(sk, { x: stx, y: sty, f: heuristic(stx,sty,etx,ety) }); | |
| const dirs = [{dx:-1,dy:0},{dx:1,dy:0},{dx:0,dy:-1},{dx:0,dy:1},{dx:-1,dy:-1},{dx:1,dy:-1},{dx:-1,dy:1},{dx:1,dy:1}]; | |
| let iter = 0; | |
| while (openMap.size > 0 && iter < 4000) { | |
| iter++; | |
| let bestK = null, bestF = Infinity; | |
| for (const [k, v] of openMap) { if (v.f < bestF) { bestF = v.f; bestK = k; } } | |
| const cur = openMap.get(bestK); | |
| openMap.delete(bestK); | |
| closed.add(bestK); | |
| if (cur.x === etx && cur.y === ety) { | |
| const path = []; | |
| let k = bestK; | |
| while (cameFrom.has(k)) { | |
| const tx = k % MAP_W, ty = Math.floor(k / MAP_W); | |
| path.unshift({ x: tx*TILE+TILE/2, y: ty*TILE+TILE/2 }); | |
| k = cameFrom.get(k); | |
| } | |
| return path; | |
| } | |
| for (const d of dirs) { | |
| const nx = cur.x+d.dx, ny = cur.y+d.dy; | |
| if (!inBounds(nx,ny) || !isWalkable(nx,ny)) continue; | |
| if (d.dx !== 0 && d.dy !== 0) { | |
| if (!isWalkable(cur.x+d.dx, cur.y) || !isWalkable(cur.x, cur.y+d.dy)) continue; | |
| } | |
| const nk = key(nx, ny); | |
| if (closed.has(nk)) continue; | |
| const tg = gScore.get(bestK) + (d.dx !== 0 && d.dy !== 0 ? 1.414 : 1); | |
| if (!gScore.has(nk) || tg < gScore.get(nk)) { | |
| cameFrom.set(nk, bestK); | |
| gScore.set(nk, tg); | |
| const f = tg + heuristic(nx, ny, etx, ety); | |
| openMap.set(nk, { x: nx, y: ny, f }); | |
| } | |
| } | |
| } | |
| return []; | |
| } | |
| function heuristic(x1,y1,x2,y2) { | |
| const dx = Math.abs(x1-x2), dy = Math.abs(y1-y2); | |
| return Math.max(dx,dy) + 0.414 * Math.min(dx,dy); | |
| } | |
| // ======================== ENTITIES ======================== | |
| function createUnit(type, wx, wy) { | |
| const def = UNIT_DEFS[type]; | |
| const u = { | |
| id: uid(), type, x: wx, y: wy, | |
| hp: def.hp, maxHp: def.hp, | |
| state: 'idle', path: [], | |
| gatherTarget: null, // { type:'gold'|'wood', mine?, tileX?, tileY? } | |
| gatherTimer: 0, | |
| carrying: null, // { type, amount } | |
| buildTarget: null, | |
| selected: false, | |
| }; | |
| units.push(u); | |
| supplyUsed += def.supply; | |
| return u; | |
| } | |
| function createBuilding(type, tx, ty, prebuilt) { | |
| const def = BLDG_DEFS[type]; | |
| const b = { | |
| id: uid(), type, tileX: tx, tileY: ty, w: def.w, h: def.h, | |
| hp: prebuilt ? def.hp : 1, maxHp: def.hp, | |
| built: !!prebuilt, buildProgress: prebuilt ? def.bTime : 0, buildTime: def.bTime, | |
| trainQueue: [], trainProgress: 0, | |
| rallyX: (tx + def.w/2) * TILE, rallyY: (ty + def.h + 1) * TILE, | |
| selected: false, | |
| }; | |
| buildings.push(b); | |
| if (prebuilt) supplyMax += def.supply; | |
| return b; | |
| } | |
| function removeUnit(u) { | |
| const idx = units.indexOf(u); | |
| if (idx >= 0) units.splice(idx, 1); | |
| supplyUsed -= UNIT_DEFS[u.type].supply; | |
| selectedUnits = selectedUnits.filter(s => s !== u); | |
| } | |
| function removeBuilding(b) { | |
| const idx = buildings.indexOf(b); | |
| if (idx >= 0) buildings.splice(idx, 1); | |
| if (b.built) supplyMax -= BLDG_DEFS[b.type].supply; | |
| if (selectedBuilding === b) selectedBuilding = null; | |
| } | |
| // ======================== COMMANDS ======================== | |
| function commandMove(unitList, wx, wy) { | |
| const count = unitList.length; | |
| unitList.forEach((u, i) => { | |
| u.state = 'moving'; u.gatherTarget = null; u.buildTarget = null; u.carrying = null; | |
| const offset = count > 1 ? spreadOffset(i, count) : {x:0,y:0}; | |
| u.path = findPath(u.x, u.y, wx + offset.x, wy + offset.y); | |
| if (u.path.length === 0) u.state = 'idle'; | |
| }); | |
| moveMarkers.push({ x: wx, y: wy, t: 30 }); | |
| } | |
| function spreadOffset(i, count) { | |
| const cols = Math.ceil(Math.sqrt(count)); | |
| const row = Math.floor(i / cols), col = i % cols; | |
| return { x: (col - cols/2) * TILE * 0.8, y: (row - cols/2) * TILE * 0.8 }; | |
| } | |
| function commandGatherGold(unitList, mine) { | |
| for (const u of unitList) { | |
| if (u.type !== 'worker') continue; | |
| u.gatherTarget = { type: 'gold', mine }; | |
| u.carrying = null; u.buildTarget = null; | |
| const adj = findAdjacentWalkable(mine.tileX, mine.tileY, 2, 2, u.x, u.y); | |
| if (adj) { | |
| u.path = findPath(u.x, u.y, adj.x * TILE + TILE/2, adj.y * TILE + TILE/2); | |
| u.state = u.path.length > 0 ? 'moving' : 'idle'; | |
| if (u.path.length === 0 && tileDist(Math.floor(u.x/TILE), Math.floor(u.y/TILE), adj.x, adj.y) <= 1) { | |
| u.state = 'gathering'; u.gatherTimer = 0; | |
| } | |
| } | |
| } | |
| } | |
| function commandGatherWood(unitList, tx, ty) { | |
| for (const u of unitList) { | |
| if (u.type !== 'worker') continue; | |
| u.gatherTarget = { type: 'wood', tileX: tx, tileY: ty }; | |
| u.carrying = null; u.buildTarget = null; | |
| const adj = findAdjacentWalkable(tx, ty, 1, 1, u.x, u.y); | |
| if (adj) { | |
| u.path = findPath(u.x, u.y, adj.x * TILE + TILE/2, adj.y * TILE + TILE/2); | |
| u.state = u.path.length > 0 ? 'moving' : 'idle'; | |
| if (u.path.length === 0 && tileDist(Math.floor(u.x/TILE), Math.floor(u.y/TILE), adj.x, adj.y) <= 1) { | |
| u.state = 'gathering'; u.gatherTimer = 0; | |
| } | |
| } | |
| } | |
| } | |
| function commandBuild(worker, bType, tx, ty) { | |
| const def = BLDG_DEFS[bType]; | |
| if (!canAfford(def.gCost, def.wCost)) { showMsg('Not enough resources'); return false; } | |
| // Validate placement | |
| for (let dy = 0; dy < def.h; dy++) for (let dx = 0; dx < def.w; dx++) { | |
| if (!isWalkable(tx+dx, ty+dy)) { showMsg('Cannot build here'); return false; } | |
| } | |
| spend(def.gCost, def.wCost); | |
| const b = createBuilding(bType, tx, ty, false); | |
| worker.buildTarget = b; | |
| worker.gatherTarget = null; worker.carrying = null; | |
| const adj = findAdjacentWalkable(tx, ty, def.w, def.h, worker.x, worker.y); | |
| if (adj) { | |
| worker.path = findPath(worker.x, worker.y, adj.x * TILE + TILE/2, adj.y * TILE + TILE/2); | |
| worker.state = worker.path.length > 0 ? 'moving' : 'building'; | |
| } else { | |
| worker.state = 'building'; | |
| } | |
| return true; | |
| } | |
| function commandTrain(building, uType) { | |
| const def = UNIT_DEFS[uType]; | |
| if (!canAfford(def.gCost, def.wCost)) { showMsg('Not enough resources'); return; } | |
| if (supplyUsed + def.supply > supplyMax) { showMsg('Need more farms'); return; } | |
| // Pre-reserve supply so we don't over-queue | |
| if (building.trainQueue.length > 0) { | |
| let queued = 0; | |
| for (const qt of building.trainQueue) queued += UNIT_DEFS[qt].supply; | |
| if (supplyUsed + queued + def.supply > supplyMax) { showMsg('Need more farms'); return; } | |
| } | |
| spend(def.gCost, def.wCost); | |
| building.trainQueue.push(uType); | |
| } | |
| // ======================== UPDATE LOGIC ======================== | |
| function update() { | |
| frameCount++; | |
| updateCamera(); | |
| updateFog(); | |
| updateUnits(); | |
| updateBuildings(); | |
| updateMarkers(); | |
| if (msgTimer > 0) { msgTimer--; if (msgTimer <= 0) document.getElementById('msg').classList.remove('show'); } | |
| } | |
| function updateUnits() { | |
| for (const u of units) { | |
| switch (u.state) { | |
| case 'idle': break; | |
| case 'moving': updateMoving(u); break; | |
| case 'gathering': updateGathering(u); break; | |
| case 'returning': updateReturning(u); break; | |
| case 'building': updateBuilding(u); break; | |
| } | |
| // Separation | |
| for (const o of units) { | |
| if (o === u) continue; | |
| const d = dist(u.x, u.y, o.x, o.y); | |
| if (d < 16 && d > 0.1) { | |
| u.x += ((u.x - o.x) / d) * 0.4; | |
| u.y += ((u.y - o.y) / d) * 0.4; | |
| } | |
| } | |
| // Keep in bounds | |
| u.x = clamp(u.x, TILE, WORLD_W - TILE); | |
| u.y = clamp(u.y, TILE, WORLD_H - TILE); | |
| } | |
| } | |
| function updateMoving(u) { | |
| if (u.path.length === 0) { | |
| // Arrived | |
| if (u.gatherTarget) { | |
| if (u.carrying) { | |
| // At town hall, deposit | |
| u.state = 'returning'; | |
| } else { | |
| u.state = 'gathering'; | |
| u.gatherTimer = 0; | |
| } | |
| } else if (u.buildTarget) { | |
| u.state = 'building'; | |
| } else { | |
| u.state = 'idle'; | |
| } | |
| return; | |
| } | |
| const target = u.path[0]; | |
| const dx = target.x - u.x, dy = target.y - u.y; | |
| const d = Math.sqrt(dx*dx + dy*dy); | |
| const spd = UNIT_DEFS[u.type].speed; | |
| if (d < spd + 1) { | |
| u.x = target.x; u.y = target.y; | |
| u.path.shift(); | |
| } else { | |
| u.x += (dx/d) * spd; | |
| u.y += (dy/d) * spd; | |
| } | |
| } | |
| function updateGathering(u) { | |
| if (!u.gatherTarget) { u.state = 'idle'; return; } | |
| const def = UNIT_DEFS[u.type]; | |
| u.gatherTimer++; | |
| if (u.gatherTimer >= def.gatherTime) { | |
| if (u.gatherTarget.type === 'gold') { | |
| const mine = u.gatherTarget.mine; | |
| if (mine.remaining <= 0) { u.gatherTarget = null; u.state = 'idle'; return; } | |
| const amt = Math.min(def.gatherAmt, mine.remaining); | |
| mine.remaining -= amt; | |
| u.carrying = { type: 'gold', amount: amt }; | |
| if (mine.remaining <= 0) { | |
| for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) { | |
| if (inBounds(mine.tileX+dx, mine.tileY+dy)) map[mine.tileY+dy][mine.tileX+dx] = T_GRASS; | |
| } | |
| } | |
| } else { | |
| const tx = u.gatherTarget.tileX, ty = u.gatherTarget.tileY; | |
| if (!inBounds(tx,ty) || map[ty][tx] !== T_TREE) { u.gatherTarget = null; u.state = 'idle'; return; } | |
| treeHealth[ty][tx] -= def.gatherAmt; | |
| if (treeHealth[ty][tx] <= 0) { | |
| map[ty][tx] = T_GRASS; | |
| u.carrying = { type: 'wood', amount: def.gatherAmt }; | |
| } else { | |
| u.carrying = { type: 'wood', amount: def.gatherAmt }; | |
| } | |
| } | |
| // Return to town hall | |
| u.state = 'returning'; | |
| u.gatherTimer = 0; | |
| const th = findNearestTownHall(u.x, u.y); | |
| if (!th) { u.state = 'idle'; return; } | |
| const adj = findAdjacentWalkable(th.tileX, th.tileY, th.w, th.h, u.x, u.y); | |
| if (adj) { | |
| u.path = findPath(u.x, u.y, adj.x*TILE+TILE/2, adj.y*TILE+TILE/2); | |
| } | |
| } | |
| } | |
| function updateReturning(u) { | |
| if (u.path.length > 0) { | |
| updateMoving(u); | |
| if (u.state === 'returning' && u.path.length > 0) return; | |
| } | |
| // Deposit | |
| if (u.carrying) { | |
| if (u.carrying.type === 'gold') res.gold += u.carrying.amount; | |
| else res.wood += u.carrying.amount; | |
| u.carrying = null; | |
| } | |
| // Go back to gather | |
| if (u.gatherTarget) { | |
| if (u.gatherTarget.type === 'gold') { | |
| if (u.gatherTarget.mine.remaining > 0) { | |
| commandGatherGold([u], u.gatherTarget.mine); | |
| } else { | |
| u.gatherTarget = null; u.state = 'idle'; | |
| } | |
| } else { | |
| let tx = u.gatherTarget.tileX, ty = u.gatherTarget.tileY; | |
| if (!inBounds(tx,ty) || map[ty][tx] !== T_TREE) { | |
| // Find nearest tree | |
| const nt = findNearestTree(tx, ty); | |
| if (nt) { | |
| commandGatherWood([u], nt.x, nt.y); | |
| } else { | |
| u.gatherTarget = null; u.state = 'idle'; | |
| } | |
| } else { | |
| commandGatherWood([u], tx, ty); | |
| } | |
| } | |
| } else { | |
| u.state = 'idle'; | |
| } | |
| } | |
| function findNearestTree(fromTX, fromTY) { | |
| let best = null, bestD = Infinity; | |
| for (let r = 0; r < 20; 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; | |
| const tx = fromTX+dx, ty = fromTY+dy; | |
| if (inBounds(tx,ty) && map[ty][tx] === T_TREE) { | |
| const d = Math.abs(dx)+Math.abs(dy); | |
| if (d < bestD) { bestD = d; best = {x:tx, y:ty}; } | |
| } | |
| } | |
| if (best) return best; | |
| } | |
| return null; | |
| } | |
| function updateBuilding(u) { | |
| if (!u.buildTarget || buildings.indexOf(u.buildTarget) < 0) { | |
| u.buildTarget = null; u.state = 'idle'; return; | |
| } | |
| const b = u.buildTarget; | |
| if (b.built) { u.buildTarget = null; u.state = 'idle'; return; } | |
| // Check if adjacent | |
| const utx = Math.floor(u.x / TILE), uty = Math.floor(u.y / TILE); | |
| let adjacent = false; | |
| for (let dy = -1; dy <= b.h; dy++) for (let dx = -1; dx <= b.w; dx++) { | |
| if (utx === b.tileX + dx && uty === b.tileY + dy) adjacent = true; | |
| } | |
| if (!adjacent) { | |
| // Move closer | |
| const adj = findAdjacentWalkable(b.tileX, b.tileY, b.w, b.h, u.x, u.y); | |
| if (adj) { | |
| u.path = findPath(u.x, u.y, adj.x*TILE+TILE/2, adj.y*TILE+TILE/2); | |
| if (u.path.length > 0) { u.state = 'moving'; return; } | |
| } | |
| } | |
| b.buildProgress++; | |
| b.hp = Math.floor((b.buildProgress / b.buildTime) * b.maxHp); | |
| if (b.buildProgress >= b.buildTime) { | |
| b.built = true; b.hp = b.maxHp; | |
| supplyMax += BLDG_DEFS[b.type].supply; | |
| u.buildTarget = null; u.state = 'idle'; | |
| } | |
| } | |
| function updateBuildings() { | |
| for (const b of buildings) { | |
| if (!b.built || b.trainQueue.length === 0) continue; | |
| const uType = b.trainQueue[0]; | |
| const def = UNIT_DEFS[uType]; | |
| b.trainProgress++; | |
| if (b.trainProgress >= def.tTime) { | |
| b.trainProgress = 0; | |
| b.trainQueue.shift(); | |
| // Spawn unit | |
| const sx = (b.tileX + b.w/2) * TILE; | |
| const sy = (b.tileY + b.h) * TILE + TILE; | |
| const u = createUnit(uType, sx, sy); | |
| // Rally | |
| if (b.rallyX && b.rallyY) { | |
| u.path = findPath(u.x, u.y, b.rallyX, b.rallyY); | |
| if (u.path.length > 0) u.state = 'moving'; | |
| } | |
| } | |
| } | |
| } | |
| function updateMarkers() { | |
| for (let i = moveMarkers.length - 1; i >= 0; i--) { | |
| moveMarkers[i].t--; | |
| if (moveMarkers[i].t <= 0) moveMarkers.splice(i, 1); | |
| } | |
| } | |
| // ======================== INPUT ======================== | |
| canvas.addEventListener('mousedown', onMouseDown); | |
| document.addEventListener('mousemove', onMouseMove); | |
| document.addEventListener('mouseup', onMouseUp); | |
| canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| document.addEventListener('contextmenu', e => { if (e.target === canvas) e.preventDefault(); }); | |
| document.addEventListener('keydown', e => { keysDown[e.key] = true; if (e.key === 'Escape') cancelAction(); }); | |
| document.addEventListener('keyup', e => { keysDown[e.key] = false; }); | |
| mmCanvas.addEventListener('mousedown', onMinimapClick); | |
| mmCanvas.addEventListener('mousemove', e => { if (e.buttons & 1) onMinimapClick(e); }); | |
| function onMinimapClick(e) { | |
| const rect = mmCanvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| camera.x = (mx / MINIMAP_SCALE) * TILE - W/2; | |
| camera.y = (my / MINIMAP_SCALE) * TILE - H/2; | |
| camera.x = clamp(camera.x, 0, WORLD_W - W); | |
| camera.y = clamp(camera.y, 0, WORLD_H - H); | |
| } | |
| function onMouseDown(e) { | |
| mouseScreen.x = e.clientX; mouseScreen.y = e.clientY; | |
| if (e.button === 0) { | |
| if (placementType) { | |
| handlePlacement(); | |
| } else { | |
| isDragging = true; | |
| dragStartScreen.x = e.clientX; dragStartScreen.y = e.clientY; | |
| dragEndScreen.x = e.clientX; dragEndScreen.y = e.clientY; | |
| } | |
| } else if (e.button === 2) { | |
| handleRightClick(); | |
| } | |
| } | |
| function onMouseMove(e) { | |
| mouseScreen.x = e.clientX; mouseScreen.y = e.clientY; | |
| if (isDragging) { | |
| dragEndScreen.x = e.clientX; dragEndScreen.y = e.clientY; | |
| } | |
| } | |
| function onMouseUp(e) { | |
| if (e.button === 0 && isDragging) { | |
| isDragging = false; | |
| const dx = Math.abs(dragEndScreen.x - dragStartScreen.x); | |
| const dy = Math.abs(dragEndScreen.y - dragStartScreen.y); | |
| if (dx > 5 || dy > 5) { | |
| boxSelect(); | |
| } else { | |
| clickSelect(e.clientX, e.clientY); | |
| } | |
| } | |
| } | |
| function clickSelect(sx, sy) { | |
| const w = screenToWorld(sx, sy); | |
| // Try to select a unit | |
| const u = getUnitAt(w.x, w.y); | |
| if (u) { | |
| deselectAll(); | |
| u.selected = true; | |
| selectedUnits = [u]; | |
| updateHUD(); | |
| return; | |
| } | |
| // Try to select a building | |
| const t = worldToTile(w.x, w.y); | |
| const b = getBuildingAt(t.x, t.y); | |
| if (b) { | |
| deselectAll(); | |
| b.selected = true; | |
| selectedBuilding = b; | |
| updateHUD(); | |
| return; | |
| } | |
| // Deselect | |
| deselectAll(); | |
| updateHUD(); | |
| } | |
| function boxSelect() { | |
| const w1 = screenToWorld(Math.min(dragStartScreen.x, dragEndScreen.x), Math.min(dragStartScreen.y, dragEndScreen.y)); | |
| const w2 = screenToWorld(Math.max(dragStartScreen.x, dragEndScreen.x), Math.max(dragStartScreen.y, dragEndScreen.y)); | |
| deselectAll(); | |
| for (const u of units) { | |
| if (u.x >= w1.x && u.x <= w2.x && u.y >= w1.y && u.y <= w2.y) { | |
| u.selected = true; | |
| selectedUnits.push(u); | |
| } | |
| } | |
| updateHUD(); | |
| } | |
| function deselectAll() { | |
| for (const u of units) u.selected = false; | |
| for (const b of buildings) b.selected = false; | |
| selectedUnits = []; | |
| selectedBuilding = null; | |
| } | |
| function handleRightClick() { | |
| const w = screenToWorld(mouseScreen.x, mouseScreen.y); | |
| const t = worldToTile(w.x, w.y); | |
| // If a building is selected, set rally point | |
| if (selectedBuilding && selectedBuilding.built) { | |
| selectedBuilding.rallyX = w.x; | |
| selectedBuilding.rallyY = w.y; | |
| moveMarkers.push({ x: w.x, y: w.y, t: 30 }); | |
| updateHUD(); | |
| return; | |
| } | |
| if (selectedUnits.length === 0) return; | |
| const workers = selectedUnits.filter(u => u.type === 'worker'); | |
| // Check gold mine | |
| const mine = getGoldMineAt(t.x, t.y); | |
| if (mine && workers.length > 0) { | |
| commandGatherGold(workers, mine); | |
| // Non-workers just move | |
| const nonWorkers = selectedUnits.filter(u => u.type !== 'worker'); | |
| if (nonWorkers.length > 0) commandMove(nonWorkers, w.x, w.y); | |
| return; | |
| } | |
| // Check tree | |
| if (inBounds(t.x, t.y) && map[t.y][t.x] === T_TREE && workers.length > 0) { | |
| commandGatherWood(workers, t.x, t.y); | |
| const nonWorkers = selectedUnits.filter(u => u.type !== 'worker'); | |
| if (nonWorkers.length > 0) commandMove(nonWorkers, w.x, w.y); | |
| return; | |
| } | |
| // Move command | |
| commandMove(selectedUnits, w.x, w.y); | |
| } | |
| function handlePlacement() { | |
| if (!placementType) return; | |
| const def = BLDG_DEFS[placementType]; | |
| const tx = mouseTile.x - Math.floor(def.w/2); | |
| const ty = mouseTile.y - Math.floor(def.h/2); | |
| // Find a selected worker | |
| const worker = selectedUnits.find(u => u.type === 'worker'); | |
| if (!worker) { showMsg('Select a worker first'); return; } | |
| if (commandBuild(worker, placementType, tx, ty)) { | |
| placementType = null; | |
| canvas.style.cursor = 'default'; | |
| } | |
| } | |
| function cancelAction() { | |
| if (placementType) { placementType = null; canvas.style.cursor = 'default'; return; } | |
| deselectAll(); | |
| updateHUD(); | |
| } | |
| // ======================== RENDERING ======================== | |
| const GRASS_COLORS = ['#3d8b37','#3f8f3a','#3a8533','#41923c','#378230']; | |
| const WATER_BASE = [40, 95, 165]; | |
| function render() { | |
| ctx.clearRect(0, 0, W, H); | |
| const stx = Math.floor(camera.x / TILE), sty = Math.floor(camera.y / TILE); | |
| const etx = Math.ceil((camera.x + W) / TILE), ety = Math.ceil((camera.y + H) / TILE); | |
| // Draw terrain | |
| for (let ty = sty; ty <= ety; ty++) for (let tx = stx; tx <= etx; tx++) { | |
| if (!inBounds(tx, ty)) continue; | |
| const sx = tx * TILE - camera.x, sy = ty * TILE - camera.y; | |
| if (fog[ty][tx] === FOG_HIDDEN) { ctx.fillStyle = '#0a0a12'; ctx.fillRect(sx, sy, TILE, TILE); continue; } | |
| const tile = map[ty][tx]; | |
| if (tile === T_GRASS) { | |
| ctx.fillStyle = GRASS_COLORS[grassVar[ty][tx]]; | |
| ctx.fillRect(sx, sy, TILE, TILE); | |
| } else if (tile === T_WATER) { | |
| const wave = Math.sin(tx * 0.7 + frameCount * 0.04) * 8; | |
| const r = WATER_BASE[0], g = WATER_BASE[1] + wave, b = WATER_BASE[2] + wave * 0.5; | |
| ctx.fillStyle = `rgb(${r},${Math.floor(g)},${Math.floor(b)})`; | |
| ctx.fillRect(sx, sy, TILE, TILE); | |
| // Water highlights | |
| const hx = sx + 8 + Math.sin(ty * 1.3 + frameCount * 0.06) * 6; | |
| const hy = sy + 8 + Math.cos(tx * 0.9 + frameCount * 0.05) * 6; | |
| ctx.fillStyle = 'rgba(120,180,230,0.3)'; | |
| ctx.fillRect(hx, hy, 6, 2); | |
| } else if (tile === T_TREE) { | |
| ctx.fillStyle = GRASS_COLORS[grassVar[ty][tx]]; | |
| ctx.fillRect(sx, sy, TILE, TILE); | |
| drawTree(sx, sy, tx, ty); | |
| } else if (tile === T_GOLD) { | |
| ctx.fillStyle = '#556'; | |
| ctx.fillRect(sx, sy, TILE, TILE); | |
| } | |
| } | |
| // Draw gold mines | |
| for (const m of goldMines) { | |
| if (m.remaining <= 0) continue; | |
| const sx = m.tileX * TILE - camera.x, sy = m.tileY * TILE - camera.y; | |
| if (sx > W + 64 || sy > H + 64 || sx < -64 || sy < -64) continue; | |
| if (!inBounds(m.tileX, m.tileY) || fog[m.tileY][m.tileX] === FOG_HIDDEN) continue; | |
| drawGoldMine(sx, sy, m); | |
| } | |
| // Draw buildings | |
| const sortedBuildings = [...buildings].sort((a,b) => (a.tileY+a.h) - (b.tileY+b.h)); | |
| for (const b of sortedBuildings) { | |
| const sx = b.tileX * TILE - camera.x, sy = b.tileY * TILE - camera.y; | |
| if (sx > W + 128 || sy > H + 128 || sx < -128 || sy < -128) continue; | |
| drawBuilding(b, sx, sy); | |
| } | |
| // Draw units sorted by Y | |
| const sortedUnits = [...units].sort((a,b) => a.y - b.y); | |
| for (const u of sortedUnits) { | |
| const s = worldToScreen(u.x, u.y); | |
| if (s.x > W + 32 || s.y > H + 32 || s.x < -32 || s.y < -32) continue; | |
| drawUnit(u, s.x, s.y); | |
| } | |
| // Draw fog overlay | |
| for (let ty = sty; ty <= ety; ty++) for (let tx = stx; tx <= etx; tx++) { | |
| if (!inBounds(tx, ty)) continue; | |
| if (fog[ty][tx] === FOG_EXPLORED) { | |
| const sx = tx * TILE - camera.x, sy = ty * TILE - camera.y; | |
| ctx.fillStyle = 'rgba(8,8,16,0.45)'; | |
| ctx.fillRect(sx, sy, TILE, TILE); | |
| } | |
| } | |
| // Move markers | |
| for (const m of moveMarkers) { | |
| const s = worldToScreen(m.x, m.y); | |
| ctx.strokeStyle = `rgba(0,255,100,${m.t / 30})`; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(s.x, s.y, 12 - (m.t/30)*4, 0, Math.PI*2); | |
| ctx.stroke(); | |
| } | |
| // Selection box | |
| if (isDragging) { | |
| const dx = Math.abs(dragEndScreen.x - dragStartScreen.x); | |
| const dy = Math.abs(dragEndScreen.y - dragStartScreen.y); | |
| if (dx > 5 || dy > 5) { | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4,4]); | |
| ctx.strokeRect( | |
| Math.min(dragStartScreen.x, dragEndScreen.x), | |
| Math.min(dragStartScreen.y, dragEndScreen.y), | |
| Math.abs(dragEndScreen.x - dragStartScreen.x), | |
| Math.abs(dragEndScreen.y - dragStartScreen.y) | |
| ); | |
| ctx.setLineDash([]); | |
| } | |
| } | |
| // Placement ghost | |
| if (placementType) { | |
| const def = BLDG_DEFS[placementType]; | |
| const tx = mouseTile.x - Math.floor(def.w/2); | |
| const ty = mouseTile.y - Math.floor(def.h/2); | |
| let valid = canAfford(def.gCost, def.wCost); | |
| for (let dy = 0; dy < def.h && valid; dy++) for (let dx = 0; dx < def.w && valid; dx++) { | |
| if (!isWalkable(tx+dx, ty+dy)) valid = false; | |
| } | |
| ctx.globalAlpha = 0.5; | |
| ctx.fillStyle = valid ? 'rgba(0,200,0,0.4)' : 'rgba(200,0,0,0.4)'; | |
| const sx = tx * TILE - camera.x, sy = ty * TILE - camera.y; | |
| ctx.fillRect(sx, sy, def.w * TILE, def.h * TILE); | |
| ctx.strokeStyle = valid ? '#0f0' : '#f00'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(sx, sy, def.w * TILE, def.h * TILE); | |
| ctx.globalAlpha = 1.0; | |
| // Draw building name | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '11px sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(def.name, sx + def.w * TILE / 2, sy - 6); | |
| } | |
| // Minimap | |
| renderMinimap(); | |
| } | |
| function drawTree(sx, sy, tx, ty) { | |
| const v = hash(tx, ty); | |
| const sz = 10 + (v % 4); | |
| // Shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.2)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx + TILE/2 + 2, sy + TILE - 4, sz - 2, 4, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Trunk | |
| ctx.fillStyle = '#5a3820'; | |
| ctx.fillRect(sx + TILE/2 - 2, sy + TILE - 12, 5, 12); | |
| // Canopy | |
| const g1 = 30 + (v % 20), g2 = 80 + (v % 40); | |
| ctx.fillStyle = `rgb(${20+v%15},${g2},${g1})`; | |
| ctx.beginPath(); | |
| ctx.arc(sx + TILE/2, sy + TILE/2 - 2, sz, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Highlight | |
| ctx.fillStyle = `rgba(100,200,80,0.3)`; | |
| ctx.beginPath(); | |
| ctx.arc(sx + TILE/2 - 3, sy + TILE/2 - 5, sz * 0.5, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| function drawGoldMine(sx, sy, mine) { | |
| const mw = 2 * TILE, mh = 2 * TILE; | |
| // Rock shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.25)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx + mw/2 + 3, sy + mh - 4, mw/2 - 4, 8, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Rock base | |
| ctx.fillStyle = '#6a6a72'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + 6, sy + mh - 2); | |
| ctx.lineTo(sx + mw - 6, sy + mh - 2); | |
| ctx.lineTo(sx + mw - 2, sy + mh * 0.35); | |
| ctx.lineTo(sx + mw * 0.65, sy + 4); | |
| ctx.lineTo(sx + mw * 0.35, sy + 4); | |
| ctx.lineTo(sx + 2, sy + mh * 0.35); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Rock dark edge | |
| ctx.fillStyle = '#55555e'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + 6, sy + mh - 2); | |
| ctx.lineTo(sx + mw - 6, sy + mh - 2); | |
| ctx.lineTo(sx + mw - 2, sy + mh * 0.35); | |
| ctx.lineTo(sx + mw * 0.5, sy + mh * 0.5); | |
| ctx.lineTo(sx + 2, sy + mh * 0.35); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Gold veins | |
| ctx.fillStyle = '#daa520'; | |
| ctx.beginPath(); ctx.arc(sx + 18, sy + 24, 7, 0, Math.PI*2); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(sx + 44, sy + 18, 5, 0, Math.PI*2); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(sx + 32, sy + 40, 6, 0, Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#ffd700'; | |
| ctx.beginPath(); ctx.arc(sx + 20, sy + 22, 3, 0, Math.PI*2); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(sx + 43, sy + 16, 2.5, 0, Math.PI*2); ctx.fill(); | |
| // Sparkle | |
| if (frameCount % 30 < 10) { | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); ctx.arc(sx + 22 + (frameCount%3)*4, sy + 20, 1.5, 0, Math.PI*2); ctx.fill(); | |
| } | |
| // Remaining | |
| const pct = mine.remaining / mine.maxAmount; | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = 'bold 10px sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(mine.remaining, sx + mw/2, sy + mh - 6); | |
| } | |
| function drawBuilding(b, sx, sy) { | |
| const bw = b.w * TILE, bh = b.h * TILE; | |
| const alpha = b.built ? 1.0 : 0.5 + (b.buildProgress / b.buildTime) * 0.5; | |
| ctx.globalAlpha = alpha; | |
| // Shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.2)'; | |
| ctx.fillRect(sx + 4, sy + 4, bw, bh); | |
| if (b.type === 'townhall') { | |
| // Walls | |
| ctx.fillStyle = '#8b7b65'; | |
| ctx.fillRect(sx, sy + bh * 0.3, bw, bh * 0.7); | |
| // Roof | |
| ctx.fillStyle = '#5a3a2a'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx - 4, sy + bh * 0.35); | |
| ctx.lineTo(sx + bw/2, sy + 2); | |
| ctx.lineTo(sx + bw + 4, sy + bh * 0.35); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Door | |
| ctx.fillStyle = '#3a2a1a'; | |
| ctx.fillRect(sx + bw/2 - 8, sy + bh - 22, 16, 22); | |
| // Windows | |
| ctx.fillStyle = '#ddc87a'; | |
| ctx.fillRect(sx + 12, sy + bh * 0.45, 10, 10); | |
| ctx.fillRect(sx + bw - 22, sy + bh * 0.45, 10, 10); | |
| // Banner | |
| ctx.fillStyle = TEAM_COLOR; | |
| ctx.fillRect(sx + bw/2 - 3, sy + 2, 6, 18); | |
| ctx.fillRect(sx + bw/2 - 10, sy + 2, 20, 8); | |
| } else if (b.type === 'barracks') { | |
| ctx.fillStyle = '#7a5040'; | |
| ctx.fillRect(sx, sy + bh * 0.2, bw, bh * 0.8); | |
| // Flat roof with crenellations | |
| ctx.fillStyle = '#5a3025'; | |
| ctx.fillRect(sx - 2, sy + bh * 0.15, bw + 4, bh * 0.15); | |
| for (let i = 0; i < 6; i++) { | |
| ctx.fillRect(sx - 2 + i * (bw+4)/6, sy + bh * 0.08, (bw+4)/12, bh * 0.1); | |
| } | |
| // Door | |
| ctx.fillStyle = '#3a1a10'; | |
| ctx.fillRect(sx + bw/2 - 10, sy + bh - 24, 20, 24); | |
| // Crossed swords icon | |
| ctx.strokeStyle = '#ccc'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(sx + bw/2 - 12, sy + bh * 0.35); ctx.lineTo(sx + bw/2 + 12, sy + bh * 0.6); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(sx + bw/2 + 12, sy + bh * 0.35); ctx.lineTo(sx + bw/2 - 12, sy + bh * 0.6); ctx.stroke(); | |
| // Team color trim | |
| ctx.fillStyle = TEAM_COLOR; | |
| ctx.fillRect(sx, sy + bh * 0.2, bw, 3); | |
| } else if (b.type === 'farm') { | |
| // Crop rows | |
| ctx.fillStyle = '#7a9a44'; | |
| ctx.fillRect(sx, sy, bw, bh); | |
| for (let i = 0; i < 4; i++) { | |
| ctx.fillStyle = i % 2 === 0 ? '#6a8a34' : '#8aaa54'; | |
| ctx.fillRect(sx + 4, sy + 6 + i * 14, bw - 8, 10); | |
| } | |
| // Small building | |
| ctx.fillStyle = '#9a8060'; | |
| ctx.fillRect(sx + bw/2 - 12, sy + bh/2 - 10, 24, 20); | |
| ctx.fillStyle = '#6a4a30'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + bw/2 - 14, sy + bh/2 - 8); | |
| ctx.lineTo(sx + bw/2, sy + bh/2 - 20); | |
| ctx.lineTo(sx + bw/2 + 14, sy + bh/2 - 8); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Fence | |
| ctx.strokeStyle = '#8a7050'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(sx + 2, sy + 2, bw - 4, bh - 4); | |
| } else if (b.type === 'tower') { | |
| // Tower base | |
| ctx.fillStyle = '#7a7a88'; | |
| ctx.fillRect(sx + 8, sy + bh * 0.3, bw - 16, bh * 0.7); | |
| // Tower top wider | |
| ctx.fillStyle = '#6a6a78'; | |
| ctx.fillRect(sx + 4, sy + bh * 0.15, bw - 8, bh * 0.2); | |
| // Crenellations | |
| ctx.fillStyle = '#8a8a98'; | |
| for (let i = 0; i < 4; i++) { | |
| ctx.fillRect(sx + 4 + i * (bw-8)/4, sy + bh * 0.08, (bw-8)/8, bh * 0.1); | |
| } | |
| // Arrow slit | |
| ctx.fillStyle = '#2a2a32'; | |
| ctx.fillRect(sx + bw/2 - 2, sy + bh * 0.4, 4, 14); | |
| ctx.fillRect(sx + bw/2 - 6, sy + bh * 0.45, 12, 4); | |
| // Team flag | |
| ctx.fillStyle = TEAM_COLOR; | |
| ctx.fillRect(sx + bw/2 - 1, sy, 2, bh * 0.15); | |
| ctx.fillRect(sx + bw/2 + 1, sy, 10, 6); | |
| } | |
| // Selection indicator | |
| if (b.selected) { | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(sx - 2, sy - 2, bw + 4, bh + 4); | |
| } | |
| // Health bar (if damaged) | |
| if (b.hp < b.maxHp) { | |
| const hbw = bw - 8, hbh = 4; | |
| const hbx = sx + 4, hby = sy - 8; | |
| ctx.fillStyle = '#300'; | |
| ctx.fillRect(hbx, hby, hbw, hbh); | |
| const pct = b.hp / b.maxHp; | |
| ctx.fillStyle = pct > 0.5 ? '#4caf50' : pct > 0.25 ? '#ff9800' : '#f44'; | |
| ctx.fillRect(hbx, hby, hbw * pct, hbh); | |
| } | |
| // Construction progress bar | |
| if (!b.built) { | |
| const pbw = bw - 8, pbh = 5; | |
| const pbx = sx + 4, pby = sy - 14; | |
| ctx.fillStyle = '#222'; | |
| ctx.fillRect(pbx, pby, pbw, pbh); | |
| ctx.fillStyle = '#42a5f5'; | |
| ctx.fillRect(pbx, pby, pbw * (b.buildProgress / b.buildTime), pbh); | |
| } | |
| // Rally point | |
| if (b.selected && b.built && b.rallyX) { | |
| const rs = worldToScreen(b.rallyX, b.rallyY); | |
| ctx.strokeStyle = TEAM_COLOR; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([3,3]); | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + bw/2, sy + bh/2); | |
| ctx.lineTo(rs.x, rs.y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Flag | |
| ctx.fillStyle = TEAM_COLOR; | |
| ctx.fillRect(rs.x, rs.y - 12, 2, 14); | |
| ctx.fillRect(rs.x + 2, rs.y - 12, 8, 6); | |
| } | |
| ctx.globalAlpha = 1.0; | |
| } | |
| function drawUnit(u, sx, sy) { | |
| const def = UNIT_DEFS[u.type]; | |
| // Selection circle | |
| if (u.selected) { | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx, sy + 6, 13, 6, 0, 0, Math.PI*2); | |
| ctx.stroke(); | |
| } | |
| // Shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.25)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx, sy + 6, 9, 4, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| if (u.type === 'worker') { | |
| // Body | |
| ctx.fillStyle = TEAM_COLOR; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 3, 7, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Lighter front | |
| ctx.fillStyle = '#5a9ae8'; | |
| ctx.beginPath(); | |
| ctx.arc(sx - 1, sy - 5, 3.5, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Head | |
| ctx.fillStyle = '#deb887'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 12, 5, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Hair | |
| ctx.fillStyle = '#6a4a2a'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 14, 4, Math.PI, Math.PI*2); | |
| ctx.fill(); | |
| // Tool | |
| if (u.state === 'gathering' || (u.gatherTarget && !u.carrying)) { | |
| // Pickaxe/axe animation | |
| const angle = Math.sin(frameCount * 0.15) * 0.5; | |
| ctx.save(); | |
| ctx.translate(sx + 8, sy - 8); | |
| ctx.rotate(angle - 0.3); | |
| ctx.strokeStyle = '#8b6914'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(10, -10); ctx.stroke(); | |
| ctx.fillStyle = '#888'; | |
| ctx.fillRect(8, -14, 6, 4); | |
| ctx.restore(); | |
| } else { | |
| ctx.strokeStyle = '#8b6914'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(sx + 7, sy - 6); ctx.lineTo(sx + 13, sy - 14); ctx.stroke(); | |
| } | |
| } else { | |
| // Soldier body (bigger, armored) | |
| ctx.fillStyle = TEAM_COLOR; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 4, 8, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Armor plate | |
| ctx.fillStyle = '#7a8aaa'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 5, 5, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Head | |
| ctx.fillStyle = '#deb887'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 14, 5, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Helmet | |
| ctx.fillStyle = '#888'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 16, 4.5, Math.PI, Math.PI*2); | |
| ctx.fill(); | |
| ctx.fillRect(sx - 5, sy - 15, 10, 2); | |
| // Sword | |
| ctx.strokeStyle = '#bbb'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + 9, sy - 2); | |
| ctx.lineTo(sx + 9, sy - 18); | |
| ctx.stroke(); | |
| // Hilt | |
| ctx.strokeStyle = '#8b6914'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + 5, sy - 6); | |
| ctx.lineTo(sx + 13, sy - 6); | |
| ctx.stroke(); | |
| // Shield | |
| ctx.fillStyle = TEAM_COLOR; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx - 8, sy - 5, 5, 7, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.strokeStyle = '#ddd'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx - 8, sy - 5, 5, 7, 0, 0, Math.PI*2); | |
| ctx.stroke(); | |
| } | |
| // Carrying indicator | |
| if (u.carrying) { | |
| ctx.fillStyle = u.carrying.type === 'gold' ? '#ffd700' : '#8b6914'; | |
| ctx.strokeStyle = '#333'; | |
| ctx.lineWidth = 1; | |
| ctx.fillRect(sx - 4, sy - 22, 8, 7); | |
| ctx.strokeRect(sx - 4, sy - 22, 8, 7); | |
| } | |
| // State indicator (small dot) | |
| if (u.state === 'gathering') { | |
| ctx.fillStyle = u.gatherTarget?.type === 'gold' ? '#ffd700' : '#6b4'; | |
| const pulse = Math.sin(frameCount * 0.1) * 2; | |
| ctx.beginPath(); ctx.arc(sx, sy - 24 + pulse, 2.5, 0, Math.PI*2); ctx.fill(); | |
| } else if (u.state === 'building') { | |
| ctx.fillStyle = '#42a5f5'; | |
| const pulse = Math.sin(frameCount * 0.1) * 2; | |
| ctx.beginPath(); ctx.arc(sx, sy - 24 + pulse, 2.5, 0, Math.PI*2); ctx.fill(); | |
| } | |
| // Health bar (if damaged) | |
| if (u.hp < u.maxHp) { | |
| const hbw = 20, hbh = 3; | |
| const hbx = sx - hbw/2, hby = sy - 26; | |
| ctx.fillStyle = '#300'; | |
| ctx.fillRect(hbx, hby, hbw, hbh); | |
| const pct = u.hp / u.maxHp; | |
| ctx.fillStyle = pct > 0.5 ? '#4caf50' : pct > 0.25 ? '#ff9800' : '#f44'; | |
| ctx.fillRect(hbx, hby, hbw * pct, hbh); | |
| } | |
| } | |
| // ======================== MINIMAP ======================== | |
| function renderMinimap() { | |
| mmCtx.fillStyle = '#0a0a12'; | |
| mmCtx.fillRect(0, 0, MINIMAP_SIZE, MINIMAP_SIZE); | |
| const ms = MINIMAP_SCALE; | |
| for (let ty = 0; ty < MAP_H; ty++) for (let tx = 0; tx < MAP_W; tx++) { | |
| if (fog[ty][tx] === FOG_HIDDEN) continue; | |
| const tile = map[ty][tx]; | |
| if (tile === T_GRASS) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#3a7a30' : '#253a20'; | |
| else if (tile === T_WATER) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#2858a0' : '#1a3060'; | |
| else if (tile === T_TREE) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#1a5a18' : '#0f3510'; | |
| else if (tile === T_GOLD) mmCtx.fillStyle = fog[ty][tx] === FOG_VISIBLE ? '#daa520' : '#8a6a15'; | |
| mmCtx.fillRect(tx * ms, ty * ms, Math.ceil(ms), Math.ceil(ms)); | |
| } | |
| // Buildings | |
| for (const b of buildings) { | |
| mmCtx.fillStyle = '#5a8aee'; | |
| mmCtx.fillRect(b.tileX * ms, b.tileY * ms, b.w * ms, b.h * ms); | |
| } | |
| // Units | |
| for (const u of units) { | |
| mmCtx.fillStyle = '#8abfff'; | |
| const tx = u.x / TILE * ms, ty = u.y / TILE * ms; | |
| mmCtx.fillRect(tx - 1, ty - 1, 3, 3); | |
| } | |
| // Camera viewport | |
| const cx = (camera.x / TILE) * ms, cy = (camera.y / TILE) * ms; | |
| const cw = (W / TILE) * ms, ch = (H / TILE) * ms; | |
| mmCtx.strokeStyle = 'rgba(255,255,255,0.7)'; | |
| mmCtx.lineWidth = 1; | |
| mmCtx.strokeRect(cx, cy, cw, ch); | |
| } | |
| // ======================== HUD ======================== | |
| function updateHUD() { | |
| document.getElementById('resGold').textContent = res.gold; | |
| document.getElementById('resWood').textContent = res.wood; | |
| document.getElementById('resSupply').textContent = `${supplyUsed}/${supplyMax}`; | |
| const info = document.getElementById('panel-info'); | |
| const actions = document.getElementById('panel-actions'); | |
| actions.innerHTML = ''; | |
| if (selectedBuilding) { | |
| const b = selectedBuilding; | |
| const def = BLDG_DEFS[b.type]; | |
| let html = `<div class="name">${def.name}</div>`; | |
| html += `<div class="detail">HP: ${b.hp}/${b.maxHp}</div>`; | |
| html += `<div class="hp-bar-outer"><div class="hp-bar-inner" style="width:${(b.hp/b.maxHp)*100}%"></div></div>`; | |
| if (!b.built) { | |
| html += `<div class="detail">Under construction: ${Math.floor((b.buildProgress/b.buildTime)*100)}%</div>`; | |
| } | |
| if (b.built && b.trainQueue.length > 0) { | |
| const cur = b.trainQueue[0]; | |
| const td = UNIT_DEFS[cur]; | |
| html += `<div class="detail">Training: ${td.name}</div>`; | |
| html += `<div class="train-progress-outer"><div class="train-progress-inner" style="width:${(b.trainProgress/td.tTime)*100}%"></div></div>`; | |
| if (b.trainQueue.length > 1) html += `<div class="detail">Queue: ${b.trainQueue.length - 1} more</div>`; | |
| } | |
| if (def.supply > 0) { | |
| html += `<div class="detail">Provides ${def.supply} supply</div>`; | |
| } | |
| info.innerHTML = html; | |
| // Train buttons | |
| if (b.built) { | |
| for (const uType of def.trains) { | |
| const ud = UNIT_DEFS[uType]; | |
| const btn = document.createElement('button'); | |
| btn.className = 'action-btn'; | |
| const afford = canAfford(ud.gCost, ud.wCost); | |
| const hasSupply = supplyUsed + ud.supply <= supplyMax; | |
| if (!afford || !hasSupply) btn.classList.add('disabled'); | |
| btn.innerHTML = `Train ${ud.name}<span class="cost">${ud.gCost}G ${ud.wCost > 0 ? ud.wCost + 'W' : ''}</span>`; | |
| btn.addEventListener('click', () => { commandTrain(b, uType); updateHUD(); }); | |
| actions.appendChild(btn); | |
| } | |
| } | |
| } else if (selectedUnits.length > 0) { | |
| if (selectedUnits.length === 1) { | |
| const u = selectedUnits[0]; | |
| const def = UNIT_DEFS[u.type]; | |
| let html = `<div class="name">${def.name}</div>`; | |
| html += `<div class="detail">HP: ${u.hp}/${u.maxHp}</div>`; | |
| html += `<div class="hp-bar-outer"><div class="hp-bar-inner" style="width:${(u.hp/u.maxHp)*100}%"></div></div>`; | |
| let stateText = u.state; | |
| if (u.state === 'gathering' && u.gatherTarget) stateText = `Gathering ${u.gatherTarget.type}`; | |
| if (u.state === 'returning') stateText = 'Returning resources'; | |
| if (u.state === 'building') stateText = 'Building'; | |
| if (u.carrying) stateText += ` (carrying ${u.carrying.amount} ${u.carrying.type})`; | |
| html += `<div class="detail">State: ${stateText}</div>`; | |
| info.innerHTML = html; | |
| } else { | |
| const workers = selectedUnits.filter(u => u.type === 'worker').length; | |
| const soldiers = selectedUnits.filter(u => u.type === 'soldier').length; | |
| let html = `<div class="name">${selectedUnits.length} Units Selected</div>`; | |
| if (workers > 0) html += `<div class="detail">${workers} Worker${workers>1?'s':''}</div>`; | |
| if (soldiers > 0) html += `<div class="detail">${soldiers} Soldier${soldiers>1?'s':''}</div>`; | |
| info.innerHTML = html; | |
| } | |
| // Build buttons for workers | |
| const hasWorker = selectedUnits.some(u => u.type === 'worker'); | |
| if (hasWorker) { | |
| if (placementType) { | |
| const pd = BLDG_DEFS[placementType]; | |
| const hint = document.createElement('div'); | |
| hint.style.cssText = 'color:#ffcc00;font-size:12px;padding:4px 8px;width:100%'; | |
| hint.textContent = `Placing ${pd.name} — click on map (ESC to cancel)`; | |
| actions.appendChild(hint); | |
| } | |
| for (const [bType, def] of Object.entries(BLDG_DEFS)) { | |
| const btn = document.createElement('button'); | |
| btn.className = 'action-btn'; | |
| if (!canAfford(def.gCost, def.wCost)) btn.classList.add('disabled'); | |
| if (placementType === bType) btn.style.borderColor = '#ffcc00'; | |
| btn.innerHTML = `Build ${def.name}<span class="cost">${def.gCost}G ${def.wCost}W</span>`; | |
| btn.addEventListener('click', () => { | |
| if (!canAfford(def.gCost, def.wCost)) { showMsg('Not enough resources'); return; } | |
| placementType = bType; | |
| canvas.style.cursor = 'crosshair'; | |
| updateHUD(); | |
| }); | |
| actions.appendChild(btn); | |
| } | |
| } | |
| } else { | |
| info.innerHTML = '<span class="detail" style="margin-top:40px;text-align:center;color:#667">Select a unit or building</span>'; | |
| } | |
| } | |
| // ======================== GAME LOOP ======================== | |
| function gameLoop() { | |
| update(); | |
| render(); | |
| // Update HUD resource display every 10 frames | |
| if (frameCount % 10 === 0) updateHUD(); | |
| requestAnimationFrame(gameLoop); | |
| } | |
| // ======================== INITIALIZATION ======================== | |
| function resize() { | |
| W = window.innerWidth; H = window.innerHeight; | |
| canvas.width = W; canvas.height = H; | |
| } | |
| function init() { | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| generateMap(); | |
| // Create starting entities | |
| const thx = START_TX, thy = START_TY; | |
| createBuilding('townhall', thx, thy, true); | |
| for (let i = 0; i < 4; i++) { | |
| const wx = (thx + 3) * TILE + TILE/2 + (i % 2) * TILE; | |
| const wy = (thy + 1) * TILE + TILE/2 + Math.floor(i / 2) * TILE; | |
| createUnit('worker', wx, wy); | |
| } | |
| // Center camera on base | |
| camera.x = (thx + 1.5) * TILE - W/2; | |
| camera.y = (thy + 1.5) * TILE - H/2; | |
| camera.x = clamp(camera.x, 0, WORLD_W - W); | |
| camera.y = clamp(camera.y, 0, WORLD_H - H); | |
| updateHUD(); | |
| gameLoop(); | |
| } | |
| init(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment