Created
June 17, 2026 20:59
-
-
Save senko/d3d356c3e2c677216bf761a471b8f5f8 to your computer and use it in GitHub Desktop.
RTS game by GLM-5.2
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 — Build, Gather, Conquer</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { | |
| background: #14181c; | |
| color: #e8e2d4; | |
| font-family: 'Segoe UI', Tahoma, sans-serif; | |
| overflow: hidden; | |
| user-select: none; | |
| height: 100vh; | |
| width: 100vw; | |
| } | |
| #game { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| /* Top bar */ | |
| #topbar { | |
| background: linear-gradient(to bottom, #2b3138, #1a1d22); | |
| border-bottom: 2px solid #0a0c0f; | |
| padding: 8px 16px; | |
| display: flex; | |
| gap: 14px; | |
| align-items: center; | |
| font-size: 14px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.4); | |
| z-index: 10; | |
| } | |
| .resource { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 14px; | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 6px; | |
| font-weight: 600; | |
| min-width: 110px; | |
| } | |
| .resource-icon { | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| box-shadow: inset 0 -2px 4px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.5); | |
| } | |
| .gold-icon { background: radial-gradient(circle at 30% 30%, #ffe680, #c89500); } | |
| .wood-icon { background: radial-gradient(circle at 30% 30%, #a06030, #5a3010); } | |
| .pop-icon { background: radial-gradient(circle at 30% 30%, #6cb0f0, #2a5080); } | |
| #gameTime { | |
| font-family: 'Consolas', monospace; | |
| color: #8aa; | |
| font-size: 13px; | |
| padding: 4px 10px; | |
| background: rgba(0,0,0,0.3); | |
| border-radius: 4px; | |
| } | |
| #title { | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| color: #d4b870; | |
| text-shadow: 0 1px 2px rgba(0,0,0,0.6); | |
| margin-right: 12px; | |
| } | |
| /* Main area */ | |
| #mainArea { | |
| flex: 1; | |
| position: relative; | |
| overflow: hidden; | |
| background: #0a0c0f; | |
| } | |
| #gameCanvas { | |
| display: block; | |
| cursor: crosshair; | |
| } | |
| #minimap { | |
| position: absolute; | |
| top: 10px; | |
| right: 10px; | |
| border: 2px solid #0a0c0f; | |
| box-shadow: 0 0 0 1px #5a5040, 0 4px 12px rgba(0,0,0,0.6); | |
| background: #000; | |
| image-rendering: pixelated; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| /* Bottom bar */ | |
| #bottombar { | |
| background: linear-gradient(to bottom, #1a1d22, #2b3138); | |
| border-top: 2px solid #0a0c0f; | |
| padding: 10px 14px; | |
| display: flex; | |
| gap: 14px; | |
| min-height: 130px; | |
| box-shadow: 0 -2px 8px rgba(0,0,0,0.4); | |
| z-index: 10; | |
| } | |
| #selectionPanel { | |
| flex: 1; | |
| background: rgba(0,0,0,0.35); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 6px; | |
| padding: 10px 14px; | |
| overflow-y: auto; | |
| max-height: 110px; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| } | |
| #selectionPanel .empty { | |
| color: #6a6258; | |
| font-style: italic; | |
| } | |
| #selectionPanel .name { | |
| color: #d4b870; | |
| font-weight: 700; | |
| font-size: 15px; | |
| margin-bottom: 4px; | |
| } | |
| #selectionPanel .stat { | |
| color: #b8b0a0; | |
| margin-right: 14px; | |
| display: inline-block; | |
| } | |
| #selectionPanel .stat b { color: #e8e2d4; } | |
| #selectionPanel .progress { | |
| margin-top: 6px; | |
| background: #0a0c0f; | |
| height: 8px; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| border: 1px solid #333; | |
| } | |
| #selectionPanel .progress > div { | |
| height: 100%; | |
| background: linear-gradient(to right, #d4b870, #f0d090); | |
| transition: width 0.1s linear; | |
| } | |
| #buildPanel { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, 88px); | |
| gap: 6px; | |
| align-content: start; | |
| max-width: 540px; | |
| } | |
| .build-btn { | |
| width: 88px; | |
| height: 88px; | |
| background: linear-gradient(to bottom, #3a4048, #252a30); | |
| border: 1px solid #0a0c0f; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| color: #d8d0c0; | |
| font-size: 11px; | |
| padding: 4px; | |
| transition: all 0.12s; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); | |
| } | |
| .build-btn:hover:not(.disabled) { | |
| background: linear-gradient(to bottom, #4a5058, #353a40); | |
| border-color: #888; | |
| transform: translateY(-1px); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), 0 2px 6px rgba(0,0,0,0.4); | |
| } | |
| .build-btn.active { | |
| border-color: #ffd700; | |
| background: linear-gradient(to bottom, #4a4030, #352f20); | |
| box-shadow: 0 0 0 1px #ffd700, inset 0 1px 0 rgba(255,255,255,0.12); | |
| } | |
| .build-btn.disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| .build-btn .icon { | |
| width: 36px; | |
| height: 36px; | |
| margin-bottom: 3px; | |
| } | |
| .build-btn .cost { | |
| font-size: 9px; | |
| color: #8a8270; | |
| line-height: 1.2; | |
| text-align: center; | |
| } | |
| /* Overlay */ | |
| #message { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| background: rgba(0,0,0,0.85); | |
| padding: 28px 48px; | |
| border-radius: 10px; | |
| font-size: 28px; | |
| display: none; | |
| text-align: center; | |
| border: 1px solid #5a5040; | |
| box-shadow: 0 0 40px rgba(0,0,0,0.8); | |
| z-index: 20; | |
| } | |
| #message .sub { | |
| font-size: 14px; | |
| color: #8a8270; | |
| margin-top: 10px; | |
| } | |
| #message button { | |
| margin-top: 18px; | |
| padding: 8px 22px; | |
| background: linear-gradient(to bottom, #4a4030, #352f20); | |
| border: 1px solid #d4b870; | |
| color: #d4b870; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| font-weight: 600; | |
| } | |
| #message button:hover { background: #5a4830; } | |
| .help-text { | |
| position: absolute; | |
| bottom: 12px; | |
| left: 12px; | |
| background: rgba(0,0,0,0.6); | |
| padding: 6px 12px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| color: #9a9288; | |
| pointer-events: none; | |
| border: 1px solid rgba(255,255,255,0.05); | |
| } | |
| .help-text b { color: #d4b870; } | |
| #tooltip { | |
| position: absolute; | |
| background: rgba(0,0,0,0.92); | |
| border: 1px solid #5a5040; | |
| padding: 6px 10px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| pointer-events: none; | |
| display: none; | |
| z-index: 15; | |
| max-width: 220px; | |
| color: #d8d0c0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="game"> | |
| <div id="topbar"> | |
| <div id="title">⚔ MINI RTS</div> | |
| <div class="resource"><div class="resource-icon gold-icon"></div><span>Gold: <b id="gold">0</b></span></div> | |
| <div class="resource"><div class="resource-icon wood-icon"></div><span>Wood: <b id="wood">0</b></span></div> | |
| <div class="resource"><div class="resource-icon pop-icon"></div><span>Pop: <b id="pop">0</b>/<b id="popMax">0</b></span></div> | |
| <div style="flex:1"></div> | |
| <div id="gameTime">00:00</div> | |
| </div> | |
| <div id="mainArea"> | |
| <canvas id="gameCanvas"></canvas> | |
| <canvas id="minimap" width="180" height="180"></canvas> | |
| <div class="help-text"> | |
| <b>WASD/Arrows</b> pan | <b>Left-click</b> select | <b>Drag</b> box-select | <b>Right-click</b> move/attack/gather | <b>Esc</b> cancel | |
| </div> | |
| <div id="tooltip"></div> | |
| <div id="message"></div> | |
| </div> | |
| <div id="bottombar"> | |
| <div id="selectionPanel"> | |
| <div class="empty">Click a unit or building to inspect it.</div> | |
| </div> | |
| <div id="buildPanel"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================================ | |
| // MINI RTS — single-file real-time strategy game | |
| // ============================================================ | |
| 'use strict'; | |
| // ---------- CONFIG ---------- | |
| const TILE = 32; | |
| const MAP_W = 64; | |
| const MAP_H = 64; | |
| const WORLD_W = MAP_W * TILE; | |
| const WORLD_H = MAP_H * TILE; | |
| const SIGHT_RANGE = 6; // tiles a unit reveals | |
| const BUILDING_SIGHT = 5; | |
| const CAMERA_SPEED = 600; // px/sec | |
| const EDGE_SCROLL = 24; // px from edge to scroll | |
| const MINIMAP_SIZE = 180; | |
| // Tile types | |
| const T_GRASS = 0, T_WATER = 1, T_TREE = 2, T_GOLD = 3, T_ROCK = 4, T_DIRT = 5; | |
| const BLOCKING_TILES = new Set([T_WATER, T_TREE, T_GOLD, T_ROCK]); | |
| // ---------- BUILDING DEFS ---------- | |
| const BUILDINGS = { | |
| townhall: { | |
| name: 'Town Hall', w: 3, h: 3, hp: 1200, | |
| cost: { g: 200, w: 100 }, pop: 5, color: '#5a8ad8', accent: '#2a4a8a', | |
| sight: 6, desc: 'Trains workers, drops off resources' | |
| }, | |
| house: { | |
| name: 'House', w: 2, h: 2, hp: 400, | |
| cost: { g: 50, w: 30 }, pop: 5, color: '#b08850', accent: '#604020', | |
| sight: 4, desc: 'Increases population cap by 5' | |
| }, | |
| barracks: { | |
| name: 'Barracks', w: 3, h: 3, hp: 800, | |
| cost: { g: 150, w: 80 }, pop: 0, color: '#a05050', accent: '#502020', | |
| sight: 5, desc: 'Trains soldiers' | |
| }, | |
| tower: { | |
| name: 'Tower', w: 1, h: 1, hp: 500, | |
| cost: { g: 100, w: 50 }, pop: 0, color: '#787878', accent: '#383838', | |
| sight: 7, range: 6 * TILE, damage: 14, attackCD: 1.0, | |
| desc: 'Auto-attacks nearby enemies' | |
| }, | |
| }; | |
| // ---------- UNIT DEFS ---------- | |
| const UNITS = { | |
| worker: { | |
| name: 'Worker', hp: 40, cost: { g: 50, w: 0 }, pop: 1, | |
| speed: 70, color: '#5fb0e0', accent: '#2a6080', | |
| buildTime: 4, damage: 3, range: 1.0 * TILE, attackCD: 1.0, | |
| canBuild: true, canGather: true, carryCap: 10, gatherTime: 1.2, | |
| sight: 5, radius: 8 | |
| }, | |
| soldier: { | |
| name: 'Soldier', hp: 70, cost: { g: 60, w: 20 }, pop: 1, | |
| speed: 65, color: '#d05050', accent: '#702020', | |
| buildTime: 7, damage: 9, range: 1.1 * TILE, attackCD: 0.9, | |
| sight: 6, radius: 9 | |
| }, | |
| }; | |
| // ---------- STATE ---------- | |
| const state = { | |
| map: [], // 2D array of tile types | |
| resourceTiles: [], // {tx, ty, type, amount} | |
| units: [], | |
| buildings: [], | |
| projectiles: [], | |
| effects: [], // visual fx (hit sparks, build puffs) | |
| gold: 500, | |
| wood: 300, | |
| pop: 0, | |
| popMax: 0, | |
| selection: [], | |
| buildMode: null, // building type string or null | |
| ghost: { tx: 0, ty: 0, valid: false }, | |
| camera: { x: 0, y: 0 }, | |
| mouse: { x: 0, y: 0, worldX: 0, worldY: 0, tx: 0, ty: 0, down: false, rdown: false, dragStart: null, dragging: false }, | |
| keys: {}, | |
| revealed: [], // 2D bool array (fog of war) | |
| visible: [], // 2D bool array (currently visible) | |
| gameTime: 0, | |
| enemy: { units: [], buildings: [] }, // simple enemies (defensive only) | |
| running: true, | |
| paused: false, | |
| }; | |
| // ---------- CANVAS ---------- | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const minimap = document.getElementById('minimap'); | |
| const mctx = minimap.getContext('2d'); | |
| function resize() { | |
| const main = document.getElementById('mainArea'); | |
| canvas.width = main.clientWidth; | |
| canvas.height = main.clientHeight; | |
| } | |
| window.addEventListener('resize', resize); | |
| // ---------- UTIL ---------- | |
| function rand(a, b) { return a + Math.random() * (b - a); } | |
| function randi(a, b) { return Math.floor(rand(a, b + 1)); } | |
| function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.sqrt(dx*dx + dy*dy); } | |
| function dist2(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return dx*dx + dy*dy; } | |
| function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } | |
| function tileX(tx) { return tx * TILE + TILE/2; } | |
| function tileY(ty) { return ty * TILE + TILE/2; } | |
| function inBounds(tx, ty) { return tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H; } | |
| function showMessage(text, sub, button) { | |
| const m = document.getElementById('message'); | |
| m.innerHTML = text + (sub ? `<div class="sub">${sub}</div>` : '') + (button ? `<br><button onclick="document.getElementById('message').style.display='none';location.reload()">${button}</button>` : ''); | |
| m.style.display = 'block'; | |
| } | |
| // ---------- MAP GENERATION ---------- | |
| function generateMap() { | |
| state.map = []; | |
| state.revealed = []; | |
| state.visible = []; | |
| state.resourceTiles = []; | |
| for (let y = 0; y < MAP_H; y++) { | |
| const row = [], revRow = [], visRow = []; | |
| for (let x = 0; x < MAP_W; x++) { | |
| row.push(T_GRASS); revRow.push(false); visRow.push(false); | |
| } | |
| state.map.push(row); state.revealed.push(revRow); state.visible.push(visRow); | |
| } | |
| // Generate noise-ish terrain: water lakes | |
| const numWaters = 4; | |
| for (let i = 0; i < numWaters; i++) { | |
| const cx = randi(8, MAP_W - 8), cy = randi(8, MAP_H - 8); | |
| const r = randi(3, 6); | |
| for (let y = cy - r; y <= cy + r; y++) for (let x = cx - r; x <= cx + r; x++) { | |
| if (!inBounds(x, y)) continue; | |
| const d = dist(x, y, cx, cy); | |
| if (d <= r + rand(-0.5, 0.5)) state.map[y][x] = T_WATER; | |
| } | |
| } | |
| // Forests (clusters of trees) | |
| const numForests = 14; | |
| for (let i = 0; i < numForests; i++) { | |
| const cx = randi(3, MAP_W - 3), cy = randi(3, MAP_H - 3); | |
| const count = randi(8, 20); | |
| for (let j = 0; j < count; j++) { | |
| const x = cx + randi(-3, 3), y = cy + randi(-3, 3); | |
| if (!inBounds(x, y)) continue; | |
| if (state.map[y][x] === T_GRASS && Math.random() < 0.7) { | |
| state.map[y][x] = T_TREE; | |
| state.resourceTiles.push({ tx: x, ty: y, type: 'wood', amount: 80 }); | |
| } | |
| } | |
| } | |
| // Gold mines (clusters of gold rocks) | |
| const numGold = 6; | |
| for (let i = 0; i < numGold; i++) { | |
| let attempts = 30; | |
| while (attempts-- > 0) { | |
| const cx = randi(4, MAP_W - 4), cy = randi(4, MAP_H - 4); | |
| // require clear area | |
| let ok = true; | |
| for (let y = cy - 1; y <= cy + 1; y++) for (let x = cx - 1; x <= cx + 1; x++) { | |
| if (!inBounds(x, y) || state.map[y][x] !== T_GRASS) { ok = false; break; } | |
| } | |
| if (!ok) continue; | |
| // place gold mine (single 1x1 gold rock with high amount) | |
| state.map[cy][cx] = T_GOLD; | |
| state.resourceTiles.push({ tx: cx, ty: cy, type: 'gold', amount: 1500 }); | |
| // Optionally add small rocks around for decoration | |
| for (let y = cy - 1; y <= cy + 1; y++) for (let x = cx - 1; x <= cx + 1; x++) { | |
| if (x === cx && y === cy) continue; | |
| if (Math.random() < 0.35 && state.map[y][x] === T_GRASS) state.map[y][x] = T_ROCK; | |
| } | |
| break; | |
| } | |
| } | |
| // Scatter a few decorative rocks | |
| for (let i = 0; i < 30; i++) { | |
| const x = randi(0, MAP_W - 1), y = randi(0, MAP_H - 1); | |
| if (state.map[y][x] === T_GRASS) state.map[y][x] = T_ROCK; | |
| } | |
| // Make sure starting area is clear (player start at tile 4,4) | |
| for (let y = 3; y <= 8; y++) for (let x = 3; x <= 8; x++) { | |
| if (state.map[y][x] !== T_WATER) state.map[y][x] = T_GRASS; | |
| } | |
| } | |
| // ---------- PATHFINDING (A* on tile grid) ---------- | |
| function isTileBlocked(tx, ty, ignoreEntity) { | |
| if (!inBounds(tx, ty)) return true; | |
| if (BLOCKING_TILES.has(state.map[ty][tx])) return true; | |
| // Check buildings | |
| for (const b of state.buildings) { | |
| if (b === ignoreEntity) continue; | |
| if (tx >= b.tx && tx < b.tx + b.w && ty >= b.ty && ty < b.ty + b.h) return true; | |
| } | |
| for (const b of state.enemy.buildings) { | |
| if (b === ignoreEntity) continue; | |
| if (tx >= b.tx && tx < b.tx + b.w && ty >= b.ty && ty < b.ty + b.h) return true; | |
| } | |
| return false; | |
| } | |
| function findPath(sx, sy, ex, ey, ignoreEntity) { | |
| // A* on tile grid. sx,sy,ex,ey are tile coords. | |
| if (!inBounds(ex, ey)) return null; | |
| if (isTileBlocked(ex, ey, ignoreEntity)) { | |
| // find nearest walkable tile near ex,ey | |
| let found = null; | |
| for (let r = 1; r <= 4 && !found; r++) { | |
| for (let dy = -r; dy <= r && !found; dy++) for (let dx = -r; dx <= r && !found; dx++) { | |
| const nx = ex + dx, ny = ey + dy; | |
| if (inBounds(nx, ny) && !isTileBlocked(nx, ny, ignoreEntity)) { | |
| found = [nx, ny]; | |
| } | |
| } | |
| } | |
| if (!found) return null; | |
| ex = found[0]; ey = found[1]; | |
| } | |
| const open = []; | |
| const came = new Map(); | |
| const g = new Map(); | |
| const f = new Map(); | |
| const startKey = sx + ',' + sy; | |
| const endKey = ex + ',' + ey; | |
| g.set(startKey, 0); | |
| f.set(startKey, dist(sx, sy, ex, ey)); | |
| open.push({ x: sx, y: sy, f: f.get(startKey) }); | |
| const closed = new Set(); | |
| let iter = 0; | |
| const maxIter = 4000; | |
| while (open.length > 0 && iter++ < maxIter) { | |
| // pop lowest f | |
| let bi = 0; | |
| for (let i = 1; i < open.length; i++) if (open[i].f < open[bi].f) bi = i; | |
| const cur = open.splice(bi, 1)[0]; | |
| const ck = cur.x + ',' + cur.y; | |
| if (ck === endKey) { | |
| // reconstruct | |
| const path = [{ x: cur.x, y: cur.y }]; | |
| let k = ck; | |
| while (came.has(k)) { | |
| const p = came.get(k); | |
| path.unshift({ x: p.x, y: p.y }); | |
| k = p.x + ',' + p.y; | |
| } | |
| return path; | |
| } | |
| closed.add(ck); | |
| const neighbors = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; | |
| for (const [dx, dy] of neighbors) { | |
| const nx = cur.x + dx, ny = cur.y + dy; | |
| if (!inBounds(nx, ny)) continue; | |
| const nk = nx + ',' + ny; | |
| if (closed.has(nk)) continue; | |
| // Block diagonal through corners | |
| if (dx !== 0 && dy !== 0) { | |
| if (isTileBlocked(cur.x + dx, cur.y, ignoreEntity) || isTileBlocked(cur.x, cur.y + dy, ignoreEntity)) continue; | |
| } | |
| if (isTileBlocked(nx, ny, ignoreEntity) && nk !== endKey) continue; | |
| const moveCost = (dx !== 0 && dy !== 0) ? 1.414 : 1; | |
| const tg = g.get(ck) + moveCost; | |
| if (!g.has(nk) || tg < g.get(nk)) { | |
| came.set(nk, { x: cur.x, y: cur.y }); | |
| g.set(nk, tg); | |
| const h = dist(nx, ny, ex, ey); | |
| f.set(nk, tg + h); | |
| open.push({ x: nx, y: ny, f: tg + h }); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| // ---------- ENTITY FACTORIES ---------- | |
| let nextId = 1; | |
| function makeUnit(type, x, y, owner) { | |
| const def = UNITS[type]; | |
| return { | |
| id: nextId++, type, owner, x, y, def, | |
| hp: def.hp, maxHp: def.hp, | |
| tx: Math.floor(x / TILE), ty: Math.floor(y / TILE), | |
| path: null, pathIndex: 0, | |
| target: null, // entity to attack/gather from | |
| targetResource: null, // {tx,ty,type} | |
| carrying: 0, carryType: null, | |
| state: 'idle', // idle | moving | gathering | returning | attacking | building | |
| facing: 0, // radians | |
| attackCD: 0, | |
| gatherTimer: 0, | |
| buildTarget: null, | |
| repathTimer: 0, | |
| }; | |
| } | |
| function makeBuilding(type, tx, ty, owner) { | |
| const def = BUILDINGS[type]; | |
| return { | |
| id: nextId++, type, owner, tx, ty, w: def.w, h: def.h, def, | |
| x: tx * TILE + (def.w * TILE) / 2, | |
| y: ty * TILE + (def.h * TILE) / 2, | |
| hp: def.hp, maxHp: def.hp, | |
| buildProgress: 1.0, // 1.0 = complete (for player-start buildings) | |
| rallyX: null, rallyY: null, | |
| trainQueue: [], trainTimer: 0, | |
| attackCD: 0, | |
| }; | |
| } | |
| // ---------- INIT ---------- | |
| function initGame() { | |
| generateMap(); | |
| // Player start: town hall at (4,4), 3 workers around it | |
| const th = makeBuilding('townhall', 4, 4, 'player'); | |
| state.buildings.push(th); | |
| // workers | |
| for (let i = 0; i < 3; i++) { | |
| const u = makeUnit('worker', th.x + rand(-40, 40), th.y + 60 + i * 20, 'player'); | |
| state.units.push(u); | |
| } | |
| // Enemy: a small base far away (top-right corner) — passive defenders only | |
| const eth = makeBuilding('townhall', MAP_W - 8, MAP_H - 8, 'enemy'); | |
| eth.hp = eth.maxHp = 800; | |
| state.enemy.buildings.push(eth); | |
| // a couple enemy soldiers standing guard | |
| for (let i = 0; i < 2; i++) { | |
| state.enemy.units.push(makeUnit('soldier', eth.x + rand(-30, 30), eth.y + 70 + i * 25, 'enemy')); | |
| } | |
| // a couple more enemy soldiers scattered | |
| state.enemy.units.push(makeUnit('soldier', MAP_W * TILE * 0.7, MAP_H * TILE * 0.3, 'enemy')); | |
| state.enemy.units.push(makeUnit('soldier', MAP_W * TILE * 0.3, MAP_H * TILE * 0.7, 'enemy')); | |
| // Stats | |
| recalcPop(); | |
| // Camera at town hall | |
| state.camera.x = th.x - canvas.width / 2; | |
| state.camera.y = th.y - canvas.height / 2; | |
| clampCamera(); | |
| } | |
| function recalcPop() { | |
| let pop = 0, popMax = 0; | |
| for (const b of state.buildings) { | |
| if (b.owner === 'player' && b.buildProgress >= 1.0) popMax += b.def.pop; | |
| } | |
| for (const u of state.units) if (u.owner === 'player') pop += 1; | |
| state.pop = pop; state.popMax = popMax; | |
| } | |
| function clampCamera() { | |
| state.camera.x = clamp(state.camera.x, 0, Math.max(0, WORLD_W - canvas.width)); | |
| state.camera.y = clamp(state.camera.y, 0, Math.max(0, WORLD_H - canvas.height)); | |
| } | |
| // ---------- ENEMY ENTITIES (helpers) ---------- | |
| function allBuildings() { return state.buildings.concat(state.enemy.buildings); } | |
| function allUnits() { return state.units.concat(state.enemy.units); } | |
| // ---------- GATHER LOGIC ---------- | |
| function findNearestResourceTile(unit, type) { | |
| let best = null, bestD = Infinity; | |
| for (const r of state.resourceTiles) { | |
| if (r.amount <= 0 || r.type !== type) continue; | |
| const d = dist2(unit.x, unit.y, tileX(r.tx), tileY(r.ty)); | |
| if (d < bestD) { bestD = d; best = r; } | |
| } | |
| return best; | |
| } | |
| function findNearestDropoff(unit) { | |
| let best = null, bestD = Infinity; | |
| for (const b of state.buildings) { | |
| if (b.owner !== unit.owner) continue; | |
| if (b.type !== 'townhall') continue; | |
| if (b.buildProgress < 1.0) continue; | |
| const d = dist2(unit.x, unit.y, b.x, b.y); | |
| if (d < bestD) { bestD = d; best = b; } | |
| } | |
| return best; | |
| } | |
| // ---------- COMBAT ---------- | |
| function findEnemyTarget(unit, range) { | |
| let best = null, bestD = range * range; | |
| const enemies = unit.owner === 'player' ? state.enemy.units : state.units; | |
| for (const e of enemies) { | |
| if (e.hp <= 0) continue; | |
| const d = dist2(unit.x, unit.y, e.x, e.y); | |
| if (d < bestD) { bestD = d; best = e; } | |
| } | |
| // also check enemy buildings | |
| const enemyBuildings = unit.owner === 'player' ? state.enemy.buildings : state.buildings; | |
| for (const b of enemyBuildings) { | |
| if (b.hp <= 0) continue; | |
| // approximate center distance | |
| const d = dist2(unit.x, unit.y, b.x, b.y); | |
| if (d < bestD + (b.w * TILE / 2) * (b.w * TILE / 2)) { | |
| // closer check: edge distance | |
| const ex = clamp(unit.x, b.tx * TILE, (b.tx + b.w) * TILE); | |
| const ey = clamp(unit.y, b.ty * TILE, (b.ty + b.h) * TILE); | |
| const ed = dist2(unit.x, unit.y, ex, ey); | |
| if (ed < bestD) { bestD = ed; best = b; } | |
| } | |
| } | |
| return best; | |
| } | |
| function dealDamage(target, dmg) { | |
| target.hp -= dmg; | |
| // spawn hit spark | |
| state.effects.push({ type: 'spark', x: target.x, y: target.y, life: 0.25, max: 0.25 }); | |
| if (target.hp <= 0) { | |
| state.effects.push({ type: 'death', x: target.x, y: target.y, life: 0.5, max: 0.5 }); | |
| } | |
| } | |
| // ---------- UPDATE ---------- | |
| function update(dt) { | |
| if (!state.running) return; | |
| state.gameTime += dt; | |
| // Update player units | |
| for (const u of state.units) updateUnit(u, dt); | |
| // Update enemy units (passive: attack nearby player units in range, else idle) | |
| for (const u of state.enemy.units) updateEnemyUnit(u, dt); | |
| // Update buildings | |
| for (const b of state.buildings) updateBuilding(b, dt); | |
| for (const b of state.enemy.buildings) updateEnemyBuilding(b, dt); | |
| // Projectiles | |
| for (const p of state.projectiles) { | |
| p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; | |
| if (p.life <= 0) { if (p.target && p.target.hp > 0) dealDamage(p.target, p.damage); } | |
| } | |
| state.projectiles = state.projectiles.filter(p => p.life > 0); | |
| // Effects | |
| for (const e of state.effects) e.life -= dt; | |
| state.effects = state.effects.filter(e => e.life > 0); | |
| // Remove dead | |
| state.units = state.units.filter(u => u.hp > 0); | |
| state.enemy.units = state.enemy.units.filter(u => u.hp > 0); | |
| state.buildings = state.buildings.filter(b => b.hp > 0); | |
| state.enemy.buildings = state.enemy.buildings.filter(b => b.hp > 0); | |
| // Remove depleted resource tiles | |
| state.resourceTiles = state.resourceTiles.filter(r => r.amount > 0); | |
| for (const r of state.resourceTiles) { | |
| if (r.amount <= 0 && inBounds(r.tx, r.ty) && (state.map[r.ty][r.tx] === T_GOLD || state.map[r.ty][r.tx] === T_TREE)) { | |
| state.map[r.ty][r.tx] = T_GRASS; | |
| } | |
| } | |
| recalcPop(); | |
| updateFog(); | |
| checkVictory(); | |
| } | |
| function updateUnit(u, dt) { | |
| if (u.hp <= 0) return; | |
| u.attackCD = Math.max(0, u.attackCD - dt); | |
| u.repathTimer = Math.max(0, u.repathTimer - dt); | |
| u.tx = Math.floor(u.x / TILE); u.ty = Math.floor(u.y / TILE); | |
| // State machine | |
| switch (u.state) { | |
| case 'moving': { | |
| if (!u.path || u.pathIndex >= u.path.length) { u.state = 'idle'; u.path = null; break; } | |
| followPath(u, dt); | |
| break; | |
| } | |
| case 'gathering': { | |
| if (!u.targetResource || u.targetResource.amount <= 0) { | |
| // try to find new resource of same type nearby | |
| const r = findNearestResourceTile(u, u.carryType || 'wood'); | |
| if (r) { u.targetResource = r; } else { u.state = 'idle'; break; } | |
| } | |
| // Move adjacent to resource if far | |
| const r = u.targetResource; | |
| const d = dist(u.x, u.y, tileX(r.tx), tileY(r.ty)); | |
| if (d > TILE * 1.2) { | |
| if (!u.path || u.pathIndex >= u.path.length) { | |
| if (u.repathTimer <= 0) { | |
| u.path = findPath(u.tx, u.ty, r.tx, r.ty, u); | |
| u.pathIndex = u.path ? 1 : 0; // skip current tile | |
| u.repathTimer = 0.5; | |
| } | |
| } | |
| if (u.path) followPath(u, dt); | |
| } else { | |
| // gather | |
| u.gatherTimer -= dt; | |
| u.facing = Math.atan2(tileY(r.ty) - u.y, tileX(r.tx) - u.x); | |
| if (u.gatherTimer <= 0) { | |
| const take = Math.min(u.def.carryCap, r.amount); | |
| r.amount -= take; | |
| u.carrying = take; | |
| u.carryType = r.type; | |
| u.gatherTimer = u.def.gatherTime; | |
| // return to nearest dropoff | |
| const d2 = findNearestDropoff(u); | |
| if (d2) { | |
| u.state = 'returning'; | |
| u.target = d2; | |
| u.path = findPath(u.tx, u.ty, Math.floor(d2.x / TILE), Math.floor(d2.y / TILE), u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| } else { | |
| u.state = 'idle'; u.carrying = 0; u.carryType = null; | |
| } | |
| } | |
| } | |
| break; | |
| } | |
| case 'returning': { | |
| const t = u.target; | |
| if (!t || t.hp <= 0) { u.state = 'idle'; u.target = null; u.path = null; break; } | |
| // Use edge distance for buildings (so worker can deposit from adjacent tile) | |
| const ex = clamp(u.x, t.tx * TILE, (t.tx + t.w) * TILE); | |
| const ey = clamp(u.y, t.ty * TILE, (t.ty + t.h) * TILE); | |
| const d = dist(u.x, u.y, ex, ey); | |
| if (d > TILE * 1.2) { | |
| if (!u.path || u.pathIndex >= u.path.length) { | |
| if (u.repathTimer <= 0) { | |
| u.path = findPath(u.tx, u.ty, Math.floor(t.x / TILE), Math.floor(t.y / TILE), u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| u.repathTimer = 0.5; | |
| } | |
| } | |
| if (u.path) followPath(u, dt); | |
| } else { | |
| // deposit | |
| if (u.carryType === 'gold') state.gold += u.carrying; | |
| else if (u.carryType === 'wood') state.wood += u.carrying; | |
| const prevType = u.carryType; | |
| u.carrying = 0; u.carryType = null; | |
| // find more of same resource | |
| const r = findNearestResourceTile(u, prevType); | |
| if (r) { | |
| u.targetResource = r; | |
| u.state = 'gathering'; | |
| u.path = findPath(u.tx, u.ty, r.tx, r.ty, u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| u.gatherTimer = u.def.gatherTime; | |
| } else { | |
| u.state = 'idle'; | |
| } | |
| } | |
| break; | |
| } | |
| case 'attacking': { | |
| const t = u.target; | |
| if (!t || t.hp <= 0) { u.target = null; u.state = 'idle'; break; } | |
| const tx = t.x !== undefined ? t.x : (t.tx ? t.tx * TILE + TILE/2 : 0); | |
| const ty = t.y !== undefined ? t.y : (t.ty ? t.ty * TILE + TILE/2 : 0); | |
| // Use edge distance: if target is a building, compute edge distance | |
| let d; | |
| if (t.w) { | |
| const aex = clamp(u.x, t.tx * TILE, (t.tx + t.w) * TILE); | |
| const aey = clamp(u.y, t.ty * TILE, (t.ty + t.h) * TILE); | |
| d = dist(u.x, u.y, aex, aey); | |
| } else { | |
| d = dist(u.x, u.y, tx, ty) - (t.def.radius || 0); | |
| } | |
| const range = u.def.range; | |
| if (d > range) { | |
| // move closer | |
| if (!u.path || u.pathIndex >= u.path.length) { | |
| if (u.repathTimer <= 0) { | |
| u.path = findPath(u.tx, u.ty, Math.floor(tx / TILE), Math.floor(ty / TILE), u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| u.repathTimer = 0.4; | |
| } | |
| } | |
| if (u.path) followPath(u, dt); | |
| } else { | |
| u.facing = Math.atan2(ty - u.y, tx - u.x); | |
| if (u.attackCD <= 0) { | |
| u.attackCD = u.def.attackCD; | |
| dealDamage(t, u.def.damage); | |
| } | |
| } | |
| break; | |
| } | |
| case 'building': { | |
| const b = u.buildTarget; | |
| if (!b || b.hp <= 0 || b.buildProgress >= 1.0) { | |
| u.buildTarget = null; u.state = 'idle'; break; | |
| } | |
| // Use edge distance for buildings | |
| const bex = clamp(u.x, b.tx * TILE, (b.tx + b.w) * TILE); | |
| const bey = clamp(u.y, b.ty * TILE, (b.ty + b.h) * TILE); | |
| const bd = dist(u.x, u.y, bex, bey); | |
| if (bd > TILE * 1.2) { | |
| if (!u.path || u.pathIndex >= u.path.length) { | |
| if (u.repathTimer <= 0) { | |
| u.path = findPath(u.tx, u.ty, Math.floor(b.x / TILE), Math.floor(b.y / TILE), u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| u.repathTimer = 0.4; | |
| } | |
| } | |
| if (u.path) followPath(u, dt); | |
| } else { | |
| u.facing = Math.atan2(b.y - u.y, b.x - u.x); | |
| b.buildProgress = Math.min(1.0, b.buildProgress + dt / 8); // 8 sec to build | |
| // HP scales with build progress (from 10% to 100%) | |
| b.hp = Math.min(b.maxHp, b.maxHp * (0.1 + 0.9 * b.buildProgress)); | |
| if (b.buildProgress >= 1.0) { | |
| state.effects.push({ type: 'puff', x: b.x, y: b.y, life: 0.4, max: 0.4 }); | |
| u.buildTarget = null; u.state = 'idle'; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| function followPath(u, dt) { | |
| if (!u.path) return; | |
| // target tile center | |
| const target = u.path[u.pathIndex]; | |
| if (!target) { u.path = null; return; } | |
| const tx = target.x * TILE + TILE / 2; | |
| const ty = target.y * TILE + TILE / 2; | |
| const dx = tx - u.x, dy = ty - u.y; | |
| const d = Math.sqrt(dx*dx + dy*dy); | |
| if (d < 4) { | |
| u.pathIndex++; | |
| if (u.pathIndex >= u.path.length) { u.path = null; } | |
| return; | |
| } | |
| const speed = u.def.speed; | |
| const step = Math.min(speed * dt, d); | |
| let nx = u.x + (dx / d) * step; | |
| let ny = u.y + (dy / d) * step; | |
| // simple separation: avoid stacking on other units | |
| const ntx = Math.floor(nx / TILE), nty = Math.floor(ny / TILE); | |
| // Check building collision | |
| let blocked = false; | |
| for (const b of allBuildings()) { | |
| if (nx >= b.tx * TILE && nx < (b.tx + b.w) * TILE && ny >= b.ty * TILE && ny < (b.ty + b.h) * TILE) { | |
| blocked = true; break; | |
| } | |
| } | |
| if (!blocked && inBounds(ntx, nty) && BLOCKING_TILES.has(state.map[nty][ntx])) blocked = true; | |
| if (blocked) { | |
| // try repath | |
| if (u.repathTimer <= 0) { | |
| const finalTarget = u.path[u.path.length - 1]; | |
| u.path = findPath(u.tx, u.ty, finalTarget.x, finalTarget.y, u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| u.repathTimer = 0.5; | |
| } | |
| } else { | |
| u.x = nx; u.y = ny; | |
| u.facing = Math.atan2(dy, dx); | |
| } | |
| } | |
| function updateEnemyUnit(u, dt) { | |
| if (u.hp <= 0) return; | |
| u.attackCD = Math.max(0, u.attackCD - dt); | |
| u.repathTimer = Math.max(0, u.repathTimer - dt); | |
| u.tx = Math.floor(u.x / TILE); u.ty = Math.floor(u.y / TILE); | |
| // Defensive: attack nearby player units within aggro range (slightly larger than attack range) | |
| const aggroRange = u.def.range + TILE * 3; | |
| const t = findEnemyTarget(u, aggroRange); | |
| if (t) { | |
| // Use edge distance for buildings | |
| let d; | |
| if (t.w) { | |
| const aex = clamp(u.x, t.tx * TILE, (t.tx + t.w) * TILE); | |
| const aey = clamp(u.y, t.ty * TILE, (t.ty + t.h) * TILE); | |
| d = dist(u.x, u.y, aex, aey); | |
| } else { | |
| d = dist(u.x, u.y, t.x, t.y) - (t.def.radius || 0); | |
| } | |
| const range = u.def.range; | |
| if (d > range) { | |
| // approach | |
| if (!u.path || u.pathIndex >= u.path.length) { | |
| if (u.repathTimer <= 0) { | |
| u.path = findPath(u.tx, u.ty, Math.floor(t.x / TILE), Math.floor(t.y / TILE), u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| u.repathTimer = 0.5; | |
| } | |
| } | |
| if (u.path) followPath(u, dt); | |
| } else { | |
| u.facing = Math.atan2(t.y - u.y, t.x - u.x); | |
| if (u.attackCD <= 0) { | |
| u.attackCD = u.def.attackCD; | |
| dealDamage(t, u.def.damage); | |
| } | |
| } | |
| } | |
| } | |
| function updateBuilding(b, dt) { | |
| if (b.buildProgress < 1.0) return; // under construction | |
| b.attackCD = Math.max(0, b.attackCD - dt); | |
| // Training queue | |
| if (b.trainQueue.length > 0) { | |
| b.trainTimer -= dt; | |
| if (b.trainTimer <= 0) { | |
| const unitType = b.trainQueue.shift(); | |
| const def = UNITS[unitType]; | |
| const nu = makeUnit(unitType, b.x + rand(-20, 20), b.y + b.h * TILE / 2 + 20, 'player'); | |
| state.units.push(nu); | |
| // Set rally point | |
| if (b.rallyX !== null) { | |
| nu.path = findPath(nu.tx, nu.ty, Math.floor(b.rallyX / TILE), Math.floor(b.rallyY / TILE), nu); | |
| nu.pathIndex = nu.path ? 1 : 0; | |
| nu.state = 'moving'; | |
| } | |
| b.trainTimer = b.trainQueue.length > 0 ? UNITS[b.trainQueue[0]].buildTime : 0; | |
| } | |
| } | |
| // Tower attack | |
| if (b.type === 'tower') { | |
| const t = findEnemyTarget({ owner: 'player', x: b.x, y: b.y }, b.def.range); | |
| if (t && b.attackCD <= 0) { | |
| b.attackCD = b.def.attackCD; | |
| // spawn projectile | |
| const dx = t.x - b.x, dy = t.y - b.y; | |
| const d = Math.sqrt(dx*dx + dy*dy); | |
| const speed = 400; | |
| state.projectiles.push({ | |
| x: b.x, y: b.y, vx: dx/d * speed, vy: dy/d * speed, | |
| life: d / speed + 0.05, target: t, damage: b.def.damage | |
| }); | |
| } | |
| } | |
| } | |
| function updateEnemyBuilding(b, dt) { | |
| b.attackCD = Math.max(0, b.attackCD - dt); | |
| if (b.type === 'tower') { | |
| const t = findEnemyTarget({ owner: 'enemy', x: b.x, y: b.y }, b.def.range); | |
| if (t && b.attackCD <= 0) { | |
| b.attackCD = b.def.attackCD; | |
| const dx = t.x - b.x, dy = t.y - b.y; | |
| const d = Math.sqrt(dx*dx + dy*dy); | |
| const speed = 400; | |
| state.projectiles.push({ | |
| x: b.x, y: b.y, vx: dx/d * speed, vy: dy/d * speed, | |
| life: d / speed + 0.05, target: t, damage: b.def.damage | |
| }); | |
| } | |
| } | |
| } | |
| // ---------- FOG OF WAR ---------- | |
| function updateFog() { | |
| // Reset current visibility | |
| for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) state.visible[y][x] = false; | |
| // Reveal around each player unit & building | |
| const sources = []; | |
| for (const u of state.units) if (u.owner === 'player') sources.push({ x: u.x, y: u.y, r: u.def.sight }); | |
| for (const b of state.buildings) if (b.owner === 'player' && b.buildProgress >= 1.0) sources.push({ x: b.x, y: b.y, r: b.def.sight }); | |
| for (const s of sources) { | |
| const ctx0 = Math.floor(s.x / TILE), cty = Math.floor(s.y / TILE); | |
| const r = Math.ceil(s.r) + 1; | |
| for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { | |
| const tx = ctx0 + dx, ty = cty + dy; | |
| if (!inBounds(tx, ty)) continue; | |
| if (dx*dx + dy*dy <= s.r * s.r) { | |
| state.revealed[ty][tx] = true; | |
| state.visible[ty][tx] = true; | |
| } | |
| } | |
| } | |
| } | |
| function checkVictory() { | |
| if (state.enemy.buildings.length === 0 && state.enemy.units.length === 0) { | |
| state.running = false; | |
| showMessage('🏆 Victory!', 'You have wiped out the enemy stronghold.', 'Play Again'); | |
| } | |
| // Player loses if all buildings + units destroyed | |
| const playerBuildings = state.buildings.filter(b => b.owner === 'player'); | |
| if (playerBuildings.length === 0 && state.units.length === 0) { | |
| state.running = false; | |
| showMessage('💀 Defeat', 'Your forces have been wiped out.', 'Play Again'); | |
| } | |
| } | |
| // ---------- RENDER ---------- | |
| function render() { | |
| ctx.fillStyle = '#0a0c0f'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| const cx = state.camera.x, cy = state.camera.y; | |
| const startX = Math.max(0, Math.floor(cx / TILE)); | |
| const startY = Math.max(0, Math.floor(cy / TILE)); | |
| const endX = Math.min(MAP_W, Math.ceil((cx + canvas.width) / TILE) + 1); | |
| const endY = Math.min(MAP_H, Math.ceil((cy + canvas.height) / TILE) + 1); | |
| // Tiles | |
| for (let ty = startY; ty < endY; ty++) { | |
| for (let tx = startX; tx < endX; tx++) { | |
| drawTile(tx, ty); | |
| } | |
| } | |
| // Resource amounts (numbers on gold mines / trees when visible) | |
| // Buildings (under construction + complete) | |
| for (const b of state.buildings) drawBuilding(b, 'player'); | |
| for (const b of state.enemy.buildings) drawBuilding(b, 'enemy'); | |
| // Units | |
| for (const u of state.units) drawUnit(u); | |
| for (const u of state.enemy.units) drawUnit(u); | |
| // Projectiles | |
| for (const p of state.projectiles) { | |
| ctx.fillStyle = '#ffe080'; | |
| ctx.beginPath(); | |
| ctx.arc(p.x - cx, p.y - cy, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| // Effects | |
| for (const e of state.effects) { | |
| const t = 1 - e.life / e.max; | |
| if (e.type === 'spark') { | |
| ctx.fillStyle = `rgba(255, 220, 80, ${1 - t})`; | |
| ctx.beginPath(); | |
| ctx.arc(e.x - cx, e.y - cy, 6 + t * 8, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } else if (e.type === 'death') { | |
| ctx.strokeStyle = `rgba(200, 50, 50, ${1 - t})`; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(e.x - cx, e.y - cy, 4 + t * 18, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } else if (e.type === 'puff') { | |
| ctx.fillStyle = `rgba(220, 220, 220, ${(1 - t) * 0.7})`; | |
| ctx.beginPath(); | |
| ctx.arc(e.x - cx, e.y - cy, 8 + t * 30, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| // Selection indicators | |
| for (const u of state.selection) { | |
| if (!u || u.hp <= 0) continue; | |
| ctx.strokeStyle = '#7fff7f'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.ellipse(u.x - cx, u.y - cy + 4, 12, 6, 0, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| // hp bar | |
| if (u.hp < u.maxHp) { | |
| const w = 24, h = 4; | |
| ctx.fillStyle = '#000'; ctx.fillRect(u.x - cx - w/2, u.y - cy - 14, w, h); | |
| ctx.fillStyle = '#4fff4f'; ctx.fillRect(u.x - cx - w/2, u.y - cy - 14, w * (u.hp / u.maxHp), h); | |
| } | |
| } | |
| // Building selection indicator | |
| if (state.selection.length === 1 && state.selection[0] && state.selection[0].w) { | |
| const b = state.selection[0]; | |
| ctx.strokeStyle = '#7fff7f'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(b.tx * TILE - cx, b.ty * TILE - cy, b.w * TILE, b.h * TILE); | |
| } | |
| // Build ghost | |
| if (state.buildMode) { | |
| drawGhost(); | |
| } | |
| // Selection box | |
| if (state.mouse.dragging && state.mouse.dragStart) { | |
| const x0 = state.mouse.dragStart.x, y0 = state.mouse.dragStart.y; | |
| const x1 = state.mouse.x, y1 = state.mouse.y; | |
| ctx.strokeStyle = '#7fff7f'; | |
| ctx.fillStyle = 'rgba(127,255,127,0.12)'; | |
| ctx.lineWidth = 1; | |
| ctx.fillRect(Math.min(x0,x1), Math.min(y0,y1), Math.abs(x1-x0), Math.abs(y1-y0)); | |
| ctx.strokeRect(Math.min(x0,x1), Math.min(y0,y1), Math.abs(x1-x0), Math.abs(y1-y0)); | |
| } | |
| // Fog overlay (only dim tiles that are revealed but not currently visible) | |
| drawFogOverlay(startX, startY, endX, endY); | |
| // Minimap | |
| drawMinimap(); | |
| } | |
| function drawTile(tx, ty) { | |
| const t = state.map[ty][tx]; | |
| const px = tx * TILE - state.camera.x; | |
| const py = ty * TILE - state.camera.y; | |
| // base | |
| switch (t) { | |
| case T_GRASS: { | |
| // subtle variation | |
| const seed = (tx * 31 + ty * 17) % 7; | |
| const base = (seed < 2) ? '#5a8a3a' : (seed < 5) ? '#6a9542' : '#557f38'; | |
| ctx.fillStyle = base; | |
| ctx.fillRect(px, py, TILE, TILE); | |
| // small grass details | |
| if (seed === 0) { | |
| ctx.fillStyle = 'rgba(80,120,50,0.6)'; | |
| ctx.fillRect(px+6, py+8, 2, 2); | |
| ctx.fillRect(px+22, py+18, 2, 2); | |
| } | |
| break; | |
| } | |
| case T_DIRT: { | |
| ctx.fillStyle = '#7a6042'; | |
| ctx.fillRect(px, py, TILE, TILE); | |
| break; | |
| } | |
| case T_WATER: { | |
| const wave = Math.sin((state.gameTime * 1.5 + tx * 0.4 + ty * 0.3)) * 8; | |
| ctx.fillStyle = '#2a6090'; | |
| ctx.fillRect(px, py, TILE, TILE); | |
| ctx.fillStyle = '#3a78a8'; | |
| ctx.fillRect(px, py + 10 + wave, TILE, 2); | |
| ctx.fillRect(px, py + 22 + wave, TILE, 2); | |
| break; | |
| } | |
| case T_TREE: { | |
| // grass base | |
| ctx.fillStyle = '#557f38'; | |
| ctx.fillRect(px, py, TILE, TILE); | |
| // tree trunk + canopy | |
| ctx.fillStyle = '#4a3010'; | |
| ctx.fillRect(px + 14, py + 18, 4, 10); | |
| ctx.fillStyle = '#2a5a28'; | |
| ctx.beginPath(); | |
| ctx.arc(px + 16, py + 14, 11, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#3a7038'; | |
| ctx.beginPath(); | |
| ctx.arc(px + 13, py + 12, 7, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // resource amount indicator | |
| const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty); | |
| if (r) { | |
| ctx.fillStyle = 'rgba(0,0,0,0.6)'; | |
| ctx.fillRect(px + 2, py + 2, 18, 8); | |
| ctx.fillStyle = '#b0e070'; | |
| ctx.font = '8px monospace'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(Math.floor(r.amount), px + 4, py + 9); | |
| } | |
| break; | |
| } | |
| case T_GOLD: { | |
| ctx.fillStyle = '#7a6042'; | |
| ctx.fillRect(px, py, TILE, TILE); | |
| // gold rocks | |
| ctx.fillStyle = '#c89500'; | |
| ctx.beginPath(); | |
| ctx.arc(px + 10, py + 18, 6, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#e8b830'; | |
| ctx.beginPath(); | |
| ctx.arc(px + 20, py + 14, 7, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#ffe070'; | |
| ctx.beginPath(); | |
| ctx.arc(px + 14, py + 22, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // sparkles | |
| ctx.fillStyle = 'rgba(255,255,200,0.7)'; | |
| ctx.fillRect(px + 22, py + 10, 1, 1); | |
| ctx.fillRect(px + 8, py + 24, 1, 1); | |
| // amount | |
| const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty); | |
| if (r) { | |
| ctx.fillStyle = 'rgba(0,0,0,0.7)'; | |
| ctx.fillRect(px + 1, py + 1, 28, 9); | |
| ctx.fillStyle = '#ffe070'; | |
| ctx.font = '8px monospace'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('⛏ ' + Math.floor(r.amount), px + 3, py + 8); | |
| } | |
| break; | |
| } | |
| case T_ROCK: { | |
| ctx.fillStyle = '#557f38'; | |
| ctx.fillRect(px, py, TILE, TILE); | |
| ctx.fillStyle = '#6a6868'; | |
| ctx.beginPath(); | |
| ctx.arc(px + 16, py + 18, 9, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#8a8888'; | |
| ctx.beginPath(); | |
| ctx.arc(px + 13, py + 15, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| break; | |
| } | |
| } | |
| } | |
| function drawBuilding(b, owner) { | |
| const def = b.def; | |
| const px = b.tx * TILE - state.camera.x; | |
| const py = b.ty * TILE - state.camera.y; | |
| const w = b.w * TILE, h = b.h * TILE; | |
| const baseColor = owner === 'enemy' ? shiftColor(def.color, -30, 20) : def.color; | |
| const accentColor = owner === 'enemy' ? shiftColor(def.accent, 20, -10) : def.accent; | |
| // shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.3)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(px + w/2, py + h - 2, w/2 - 2, 8, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Under construction? | |
| if (b.buildProgress < 1.0) { | |
| ctx.globalAlpha = 0.5 + b.buildProgress * 0.5; | |
| // scaffold | |
| ctx.fillStyle = '#6a4a20'; | |
| ctx.fillRect(px + 2, py + 2, w - 4, h - 4); | |
| } | |
| // building body | |
| ctx.fillStyle = baseColor; | |
| ctx.fillRect(px + 2, py + 4, w - 4, h - 6); | |
| // darker bottom | |
| ctx.fillStyle = accentColor; | |
| ctx.fillRect(px + 2, py + h - 12, w - 4, 8); | |
| // top accent / roof | |
| if (b.type === 'townhall') { | |
| // tower on top | |
| ctx.fillStyle = accentColor; | |
| ctx.fillRect(px + w/2 - 10, py - 8, 20, 14); | |
| ctx.fillStyle = '#d4b870'; | |
| ctx.fillRect(px + w/2 - 8, py - 6, 16, 4); | |
| // flag | |
| ctx.fillStyle = owner === 'enemy' ? '#d04040' : '#4f8fd0'; | |
| ctx.fillRect(px + w/2 - 1, py - 18, 2, 12); | |
| ctx.fillRect(px + w/2 + 1, py - 18, 8, 5); | |
| } else if (b.type === 'house') { | |
| // pitched roof | |
| ctx.fillStyle = accentColor; | |
| ctx.beginPath(); | |
| ctx.moveTo(px + 2, py + 6); | |
| ctx.lineTo(px + w/2, py - 4); | |
| ctx.lineTo(px + w - 2, py + 6); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } else if (b.type === 'barracks') { | |
| // battlements | |
| ctx.fillStyle = accentColor; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.fillRect(px + 6 + i * (w/3), py - 4, 10, 8); | |
| } | |
| // crossed swords icon | |
| ctx.strokeStyle = '#d4b870'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(px + w/2 - 8, py + h/2 - 6); | |
| ctx.lineTo(px + w/2 + 8, py + h/2 + 6); | |
| ctx.moveTo(px + w/2 + 8, py + h/2 - 6); | |
| ctx.lineTo(px + w/2 - 8, py + h/2 + 6); | |
| ctx.stroke(); | |
| } else if (b.type === 'tower') { | |
| // top battlements | |
| ctx.fillStyle = accentColor; | |
| ctx.fillRect(px + 2, py - 4, w - 4, 6); | |
| ctx.fillStyle = '#d4b870'; | |
| ctx.beginPath(); | |
| ctx.arc(px + w/2, py + h/2, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| // door | |
| if (b.type === 'townhall' || b.type === 'barracks') { | |
| ctx.fillStyle = '#3a2810'; | |
| ctx.fillRect(px + w/2 - 6, py + h - 16, 12, 12); | |
| } | |
| // owner color band | |
| ctx.fillStyle = owner === 'enemy' ? '#d04040' : '#4f8fd0'; | |
| ctx.fillRect(px + 2, py + 4, 4, h - 6); | |
| ctx.fillRect(px + w - 6, py + 4, 4, h - 6); | |
| ctx.globalAlpha = 1.0; | |
| // HP bar | |
| if (b.hp < b.maxHp) { | |
| const bw = w - 8, bh = 4; | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(px + 4, py - 10, bw, bh); | |
| ctx.fillStyle = owner === 'enemy' ? '#d05050' : '#4fff4f'; | |
| ctx.fillRect(px + 4, py - 10, bw * (b.hp / b.maxHp), bh); | |
| } | |
| // Build progress bar | |
| if (b.buildProgress < 1.0) { | |
| const bw = w - 8, bh = 5; | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(px + 4, py - 18, bw, bh); | |
| ctx.fillStyle = '#d4b870'; | |
| ctx.fillRect(px + 4, py - 18, bw * b.buildProgress, bh); | |
| } | |
| // Training queue indicator | |
| if (b.trainQueue && b.trainQueue.length > 0) { | |
| ctx.fillStyle = 'rgba(0,0,0,0.7)'; | |
| ctx.fillRect(px + 4, py + h, w - 8, 6); | |
| ctx.fillStyle = '#5fb0e0'; | |
| const cur = UNITS[b.trainQueue[0]].buildTime; | |
| ctx.fillRect(px + 4, py + h, (w - 8) * (1 - b.trainTimer / cur), 6); | |
| } | |
| // rally point | |
| if (b.rallyX !== null && state.selection.includes(b)) { | |
| ctx.strokeStyle = '#7fff7f'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4, 3]); | |
| ctx.beginPath(); | |
| ctx.moveTo(b.x - state.camera.x, b.y - state.camera.y); | |
| ctx.lineTo(b.rallyX - state.camera.x, b.rallyY - state.camera.y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = '#7fff7f'; | |
| ctx.beginPath(); | |
| ctx.arc(b.rallyX - state.camera.x, b.rallyY - state.camera.y, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawUnit(u) { | |
| const cx = u.x - state.camera.x; | |
| const cy = u.y - state.camera.y; | |
| const r = u.def.radius; | |
| // shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.3)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(cx, cy + r - 1, r * 0.9, r * 0.4, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // body | |
| ctx.fillStyle = u.owner === 'enemy' ? shiftColor(u.def.color, -20, 30) : u.def.color; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // outline | |
| ctx.strokeStyle = u.def.accent; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| // direction indicator | |
| ctx.fillStyle = u.def.accent; | |
| const fx = cx + Math.cos(u.facing) * r; | |
| const fy = cy + Math.sin(u.facing) * r; | |
| ctx.beginPath(); | |
| ctx.arc(fx, fy, 2.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // unit-specific accents | |
| if (u.type === 'worker') { | |
| // little hat | |
| ctx.fillStyle = '#d4b870'; | |
| ctx.fillRect(cx - 4, cy - r - 2, 8, 3); | |
| // carrying indicator | |
| if (u.carrying > 0) { | |
| ctx.fillStyle = u.carryType === 'gold' ? '#ffd700' : '#a06030'; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy - r - 5, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } else if (u.type === 'soldier') { | |
| // helmet | |
| ctx.fillStyle = '#888'; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy - 2, r * 0.7, Math.PI, Math.PI * 2); | |
| ctx.fill(); | |
| // sword | |
| ctx.strokeStyle = '#ddd'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(fx + Math.cos(u.facing) * 2, fy + Math.sin(u.facing) * 2); | |
| ctx.lineTo(fx + Math.cos(u.facing) * 8, fy + Math.sin(u.facing) * 8); | |
| ctx.stroke(); | |
| } | |
| // HP bar (only if damaged) | |
| if (u.hp < u.maxHp) { | |
| const w = 20, h = 3; | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(cx - w/2, cy - r - 8, w, h); | |
| ctx.fillStyle = u.owner === 'enemy' ? '#d05050' : '#4fff4f'; | |
| ctx.fillRect(cx - w/2, cy - r - 8, w * (u.hp / u.maxHp), h); | |
| } | |
| } | |
| function drawGhost() { | |
| const def = BUILDINGS[state.buildMode]; | |
| const tx = state.ghost.tx, ty = state.ghost.ty; | |
| const px = tx * TILE - state.camera.x; | |
| const py = ty * TILE - state.camera.y; | |
| const w = def.w * TILE, h = def.h * TILE; | |
| ctx.globalAlpha = 0.6; | |
| ctx.fillStyle = state.ghost.valid ? '#7fff7f' : '#ff7f7f'; | |
| ctx.fillRect(px, py, w, h); | |
| ctx.strokeStyle = state.ghost.valid ? '#4faf4f' : '#af4f4f'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(px, py, w, h); | |
| ctx.globalAlpha = 1.0; | |
| // grid lines on building footprint | |
| ctx.strokeStyle = state.ghost.valid ? 'rgba(127,255,127,0.4)' : 'rgba(255,127,127,0.4)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < def.w; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(px + i * TILE, py); ctx.lineTo(px + i * TILE, py + h); | |
| ctx.stroke(); | |
| } | |
| for (let i = 1; i < def.h; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(px, py + i * TILE); ctx.lineTo(px + w, py + i * TILE); | |
| ctx.stroke(); | |
| } | |
| } | |
| function drawFogOverlay(startX, startY, endX, endY) { | |
| for (let ty = startY; ty < endY; ty++) { | |
| for (let tx = startX; tx < endX; tx++) { | |
| if (!state.revealed[ty][tx]) { | |
| // pitch black - unexplored | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(tx * TILE - state.camera.x, ty * TILE - state.camera.y, TILE, TILE); | |
| } else if (!state.visible[ty][tx]) { | |
| // explored but not visible - darken | |
| ctx.fillStyle = 'rgba(0,0,0,0.45)'; | |
| ctx.fillRect(tx * TILE - state.camera.x, ty * TILE - state.camera.y, TILE, TILE); | |
| } | |
| } | |
| } | |
| } | |
| function shiftColor(hex, dr, dg) { | |
| // simple shift: parse #rrggbb, shift r and g | |
| const r = clamp(parseInt(hex.slice(1,3),16) + dr, 0, 255); | |
| const g = clamp(parseInt(hex.slice(3,5),16) + dg, 0, 255); | |
| const b = clamp(parseInt(hex.slice(5,7),16), 0, 255); | |
| return `rgb(${r|0},${g|0},${b|0})`; | |
| } | |
| // ---------- MINIMAP ---------- | |
| function drawMinimap() { | |
| const scale = MINIMAP_SIZE / MAP_W; | |
| mctx.fillStyle = '#000'; | |
| mctx.fillRect(0, 0, MINIMAP_SIZE, MINIMAP_SIZE); | |
| // terrain (sample one pixel per tile for speed) | |
| for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) { | |
| if (!state.revealed[y][x]) continue; | |
| const t = state.map[y][x]; | |
| let c = '#3a5a28'; | |
| if (t === T_WATER) c = '#2a6090'; | |
| else if (t === T_TREE) c = '#1f4018'; | |
| else if (t === T_GOLD) c = '#c89500'; | |
| else if (t === T_ROCK) c = '#6a6868'; | |
| mctx.fillStyle = c; | |
| mctx.fillRect(x * scale, y * scale, scale + 0.5, scale + 0.5); | |
| } | |
| // buildings | |
| for (const b of state.buildings) { | |
| if (!state.visible[b.ty][b.tx]) continue; | |
| mctx.fillStyle = '#4f8fd0'; | |
| mctx.fillRect(b.tx * scale, b.ty * scale, b.w * scale, b.h * scale); | |
| } | |
| for (const b of state.enemy.buildings) { | |
| if (!state.visible[b.ty] || !state.visible[b.ty][b.tx]) continue; | |
| mctx.fillStyle = '#d04040'; | |
| mctx.fillRect(b.tx * scale, b.ty * scale, b.w * scale, b.h * scale); | |
| } | |
| // units | |
| for (const u of state.units) { | |
| if (!state.visible[Math.floor(u.y/TILE)] || !state.visible[Math.floor(u.y/TILE)][Math.floor(u.x/TILE)]) continue; | |
| mctx.fillStyle = '#7fc0ff'; | |
| mctx.fillRect(u.x / TILE * scale - 1, u.y / TILE * scale - 1, 2, 2); | |
| } | |
| for (const u of state.enemy.units) { | |
| if (!state.visible[Math.floor(u.y/TILE)] || !state.visible[Math.floor(u.y/TILE)][Math.floor(u.x/TILE)]) continue; | |
| mctx.fillStyle = '#ff6060'; | |
| mctx.fillRect(u.x / TILE * scale - 1, u.y / TILE * scale - 1, 2, 2); | |
| } | |
| // camera viewport | |
| mctx.strokeStyle = '#ffd700'; | |
| mctx.lineWidth = 1; | |
| mctx.strokeRect(state.camera.x / TILE * scale, state.camera.y / TILE * scale, canvas.width / TILE * scale, canvas.height / TILE * scale); | |
| } | |
| // ---------- INPUT ---------- | |
| canvas.addEventListener('mousedown', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| if (e.button === 0) { | |
| if (state.buildMode) { | |
| tryPlaceBuilding(mx, my); | |
| return; | |
| } | |
| state.mouse.down = true; | |
| state.mouse.dragStart = { x: mx, y: my }; | |
| state.mouse.dragging = false; | |
| } else if (e.button === 2) { | |
| state.mouse.rdown = true; | |
| handleRightClick(mx, my); | |
| } | |
| }); | |
| canvas.addEventListener('mousemove', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| state.mouse.x = mx; state.mouse.y = my; | |
| state.mouse.worldX = mx + state.camera.x; | |
| state.mouse.worldY = my + state.camera.y; | |
| state.mouse.tx = Math.floor(state.mouse.worldX / TILE); | |
| state.mouse.ty = Math.floor(state.mouse.worldY / TILE); | |
| if (state.mouse.down && state.mouse.dragStart) { | |
| const dx = mx - state.mouse.dragStart.x, dy = my - state.mouse.dragStart.y; | |
| if (Math.abs(dx) > 4 || Math.abs(dy) > 4) state.mouse.dragging = true; | |
| } | |
| if (state.buildMode) { | |
| updateGhost(); | |
| } | |
| // tooltip | |
| updateTooltip(mx, my); | |
| }); | |
| canvas.addEventListener('mouseup', (e) => { | |
| if (e.button === 0) { | |
| if (state.mouse.dragging) { | |
| // box select | |
| const x0 = Math.min(state.mouse.dragStart.x, state.mouse.x); | |
| const y0 = Math.min(state.mouse.dragStart.y, state.mouse.y); | |
| const x1 = Math.max(state.mouse.dragStart.x, state.mouse.x); | |
| const y1 = Math.max(state.mouse.dragStart.y, state.mouse.y); | |
| const sel = []; | |
| for (const u of state.units) { | |
| const sx = u.x - state.camera.x, sy = u.y - state.camera.y; | |
| if (sx >= x0 && sx <= x1 && sy >= y0 && sy <= y1) sel.push(u); | |
| } | |
| state.selection = sel; | |
| } else { | |
| // single select | |
| const wx = state.mouse.worldX, wy = state.mouse.worldY; | |
| let found = null; | |
| for (const u of state.units) { | |
| if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { found = u; break; } | |
| } | |
| if (!found) { | |
| for (const b of state.buildings) { | |
| if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { found = b; break; } | |
| } | |
| } | |
| if (!found) { | |
| // enemy unit/building (just for inspection - can't command) | |
| for (const u of state.enemy.units) { | |
| if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { found = u; break; } | |
| } | |
| if (!found) for (const b of state.enemy.buildings) { | |
| if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { found = b; break; } | |
| } | |
| } | |
| state.selection = found ? [found] : []; | |
| } | |
| state.mouse.down = false; | |
| state.mouse.dragging = false; | |
| state.mouse.dragStart = null; | |
| updateSelectionPanel(); | |
| } else if (e.button === 2) { | |
| state.mouse.rdown = false; | |
| } | |
| }); | |
| canvas.addEventListener('contextmenu', (e) => e.preventDefault()); | |
| // Mouse wheel: zoom? Let's keep it simple - no zoom. Use it for nothing. | |
| canvas.addEventListener('wheel', (e) => { e.preventDefault(); }, { passive: false }); | |
| // Edge scrolling handled in update loop | |
| // Keyboard | |
| window.addEventListener('keydown', (e) => { | |
| state.keys[e.key.toLowerCase()] = true; | |
| if (e.key === 'Escape') { | |
| state.buildMode = null; | |
| state.selection = []; | |
| updateSelectionPanel(); | |
| refreshBuildPanel(); | |
| } | |
| // Hotkeys | |
| if (e.key.toLowerCase() === 'b' && state.selection.length > 0) { | |
| // toggle build menu if worker selected - handled by panel | |
| } | |
| }); | |
| window.addEventListener('keyup', (e) => { | |
| state.keys[e.key.toLowerCase()] = false; | |
| }); | |
| // Minimap click | |
| minimap.addEventListener('mousedown', (e) => { | |
| const rect = minimap.getBoundingClientRect(); | |
| const mx = (e.clientX - rect.left) / MINIMAP_SIZE * MAP_W * TILE; | |
| const my = (e.clientY - rect.top) / MINIMAP_SIZE * MAP_H * TILE; | |
| state.camera.x = mx - canvas.width / 2; | |
| state.camera.y = my - canvas.height / 2; | |
| clampCamera(); | |
| }); | |
| function handleRightClick(mx, my) { | |
| if (state.buildMode) { state.buildMode = null; refreshBuildPanel(); return; } | |
| const wx = mx + state.camera.x, wy = my + state.camera.y; | |
| const selectedUnits = state.selection.filter(s => s.def && !s.w); // units only | |
| const selectedBuildings = state.selection.filter(s => s.w); | |
| if (selectedUnits.length === 0) { | |
| // If a building selected, set rally point | |
| if (selectedBuildings.length > 0) { | |
| const b = selectedBuildings[0]; | |
| b.rallyX = wx; b.rallyY = wy; | |
| } | |
| return; | |
| } | |
| // Determine command: attack enemy, gather resource, or move | |
| let targetEnemy = null; | |
| for (const u of state.enemy.units) if (dist(u.x, u.y, wx, wy) < 16) { targetEnemy = u; break; } | |
| if (!targetEnemy) for (const b of state.enemy.buildings) { | |
| if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { targetEnemy = b; break; } | |
| } | |
| let resourceTile = null; | |
| if (!targetEnemy) { | |
| const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); | |
| if (inBounds(tx, ty) && (state.map[ty][tx] === T_TREE || state.map[ty][tx] === T_GOLD)) { | |
| const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty); | |
| if (r) resourceTile = r; | |
| } | |
| } | |
| for (const u of selectedUnits) { | |
| u.path = null; | |
| u.target = null; | |
| u.targetResource = null; | |
| u.buildTarget = null; | |
| if (targetEnemy) { | |
| u.target = targetEnemy; | |
| u.state = 'attacking'; | |
| } else if (resourceTile && u.def.canGather) { | |
| u.targetResource = resourceTile; | |
| u.carryType = resourceTile.type; | |
| u.state = 'gathering'; | |
| u.gatherTimer = u.def.gatherTime; | |
| } else { | |
| // move | |
| u.state = 'moving'; | |
| const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); | |
| u.path = findPath(u.tx, u.ty, tx, ty, u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| } | |
| } | |
| // formation offset for groups | |
| if (selectedUnits.length > 1 && !targetEnemy && !resourceTile) { | |
| // simple cluster: offset each by small amounts | |
| const cx = Math.floor(wx / TILE), cy = Math.floor(wy / TILE); | |
| selectedUnits.forEach((u, i) => { | |
| const ox = (i % 3) - 1, oy = Math.floor(i / 3) - 1; | |
| u.path = findPath(u.tx, u.ty, cx + ox, cy + oy, u); | |
| u.pathIndex = u.path ? 1 : 0; | |
| }); | |
| } | |
| // click ripple | |
| state.effects.push({ type: 'puff', x: wx, y: wy, life: 0.3, max: 0.3 }); | |
| } | |
| function tryPlaceBuilding(mx, my) { | |
| const def = BUILDINGS[state.buildMode]; | |
| // check resources | |
| if (state.gold < def.cost.g || state.wood < def.cost.w) { | |
| showMessage('Not enough resources!', `Need ${def.cost.g}g, ${def.cost.w}w`, ''); | |
| setTimeout(() => document.getElementById('message').style.display = 'none', 1200); | |
| return; | |
| } | |
| if (!state.ghost.valid) return; | |
| const tx = state.ghost.tx, ty = state.ghost.ty; | |
| // create building (under construction) | |
| const b = makeBuilding(state.buildMode, tx, ty, 'player'); | |
| b.buildProgress = 0.0; | |
| b.hp = b.maxHp * 0.1; // starts with little hp | |
| state.buildings.push(b); | |
| state.gold -= def.cost.g; | |
| state.wood -= def.cost.w; | |
| // assign nearest selected worker to build | |
| const workers = state.selection.filter(u => u.def && u.def.canBuild); | |
| if (workers.length > 0) { | |
| let best = workers[0], bd = Infinity; | |
| for (const w of workers) { | |
| const d = dist(w.x, w.y, b.x, b.y); | |
| if (d < bd) { bd = d; best = w; } | |
| } | |
| best.buildTarget = b; | |
| best.state = 'building'; | |
| best.path = findPath(best.tx, best.ty, Math.floor(b.x / TILE), Math.floor(b.y / TILE), best); | |
| best.pathIndex = best.path ? 1 : 0; | |
| } | |
| // stay in build mode if shift held | |
| if (!state.keys['shift']) { | |
| state.buildMode = null; | |
| } | |
| refreshBuildPanel(); | |
| } | |
| function updateGhost() { | |
| const def = BUILDINGS[state.buildMode]; | |
| const tx = state.mouse.tx, ty = state.mouse.ty; | |
| // snap so footprint is at cursor center-ish | |
| const sx = tx - Math.floor((def.w - 1) / 2); | |
| const sy = ty - Math.floor((def.h - 1) / 2); | |
| state.ghost.tx = sx; state.ghost.ty = sy; | |
| // validate | |
| let valid = true; | |
| for (let y = sy; y < sy + def.h; y++) for (let x = sx; x < sx + def.w; x++) { | |
| if (!inBounds(x, y)) { valid = false; break; } | |
| if (state.map[y][x] !== T_GRASS) { valid = false; break; } | |
| for (const b of allBuildings()) { | |
| if (x >= b.tx && x < b.tx + b.w && y >= b.ty && y < b.ty + b.h) { valid = false; break; } | |
| } | |
| } | |
| if (state.gold < def.cost.g || state.wood < def.cost.w) valid = false; | |
| state.ghost.valid = valid; | |
| } | |
| function updateTooltip(mx, my) { | |
| const tip = document.getElementById('tooltip'); | |
| const wx = state.mouse.worldX, wy = state.mouse.worldY; | |
| let target = null, kind = null; | |
| for (const u of state.units) { | |
| if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { target = u; kind = 'unit'; break; } | |
| } | |
| if (!target) for (const u of state.enemy.units) { | |
| if (dist(u.x, u.y, wx, wy) <= u.def.radius + 2) { target = u; kind = 'unit'; break; } | |
| } | |
| if (!target) for (const b of state.buildings) { | |
| if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { target = b; kind = 'building'; break; } | |
| } | |
| if (!target) for (const b of state.enemy.buildings) { | |
| if (wx >= b.tx * TILE && wx < (b.tx + b.w) * TILE && wy >= b.ty * TILE && wy < (b.ty + b.h) * TILE) { target = b; kind = 'building'; break; } | |
| } | |
| if (!target) { | |
| // resource tile | |
| const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); | |
| if (inBounds(tx, ty) && (state.map[ty][tx] === T_GOLD || state.map[ty][tx] === T_TREE)) { | |
| const r = state.resourceTiles.find(r => r.tx === tx && r.ty === ty); | |
| if (r) { | |
| tip.style.display = 'block'; | |
| tip.style.left = (mx + 14) + 'px'; | |
| tip.style.top = (my + 14) + 'px'; | |
| tip.innerHTML = `<b>${r.type === 'gold' ? 'Gold Mine' : 'Forest'}</b><br>Remaining: ${Math.floor(r.amount)}`; | |
| return; | |
| } | |
| } | |
| tip.style.display = 'none'; | |
| return; | |
| } | |
| if (kind === 'unit') { | |
| tip.style.display = 'block'; | |
| tip.style.left = (mx + 14) + 'px'; | |
| tip.style.top = (my + 14) + 'px'; | |
| tip.innerHTML = `<b style="color:${target.def.color}">${target.def.name}</b> (${target.owner})<br>HP: ${Math.ceil(target.hp)}/${target.maxHp}<br>DMG: ${target.def.damage} • SPD: ${target.def.speed}`; | |
| } else { | |
| tip.style.display = 'block'; | |
| tip.style.left = (mx + 14) + 'px'; | |
| tip.style.top = (my + 14) + 'px'; | |
| tip.innerHTML = `<b style="color:${target.def.color}">${target.def.name}</b> (${target.owner})<br>HP: ${Math.ceil(target.hp)}/${target.maxHp}<br>${target.def.desc || ''}`; | |
| } | |
| } | |
| // ---------- UI ---------- | |
| function updateSelectionPanel() { | |
| const p = document.getElementById('selectionPanel'); | |
| if (state.selection.length === 0) { | |
| p.innerHTML = '<div class="empty">Click a unit or building to inspect it.</div>'; | |
| refreshBuildPanel(); | |
| return; | |
| } | |
| if (state.selection.length > 1) { | |
| const counts = {}; | |
| for (const u of state.selection) { | |
| const k = u.type; | |
| counts[k] = (counts[k] || 0) + 1; | |
| } | |
| p.innerHTML = `<div class="name">${state.selection.length} units selected</div>` + | |
| Object.entries(counts).map(([k, v]) => `<div class="stat">${UNITS[k] ? UNITS[k].name : k}: <b>${v}</b></div>`).join(''); | |
| refreshBuildPanel(); | |
| return; | |
| } | |
| const e = state.selection[0]; | |
| if (e.def && !e.w) { | |
| // unit | |
| p.innerHTML = `<div class="name">${e.def.name}</div>` + | |
| `<span class="stat">HP: <b>${Math.ceil(e.hp)}/${e.maxHp}</b></span>` + | |
| `<span class="stat">DMG: <b>${e.def.damage}</b></span>` + | |
| `<span class="stat">State: <b>${e.state}</b></span>` + | |
| (e.carrying > 0 ? `<span class="stat">Carrying: <b>${e.carrying} ${e.carryType}</b></span>` : ''); | |
| } else { | |
| // building | |
| p.innerHTML = `<div class="name">${e.def.name}</div>` + | |
| `<span class="stat">HP: <b>${Math.ceil(e.hp)}/${e.maxHp}</b></span>` + | |
| (e.buildProgress < 1.0 ? `<div class="progress"><div style="width:${e.buildProgress*100}%"></div></div>Under construction...` : ''); | |
| // show training queue info | |
| if (e.trainQueue && e.trainQueue.length > 0) { | |
| p.innerHTML += `<div>Training: ${e.trainQueue.map(t => UNITS[t].name).join(', ')}</div>`; | |
| } | |
| } | |
| refreshBuildPanel(); | |
| } | |
| function refreshBuildPanel() { | |
| const panel = document.getElementById('buildPanel'); | |
| panel.innerHTML = ''; | |
| // Determine context: which buttons to show | |
| const sel = state.selection; | |
| const hasWorker = sel.some(s => s.def && s.def.canBuild); | |
| const hasTownhall = sel.some(s => s.type === 'townhall' && s.owner === 'player' && s.buildProgress >= 1.0); | |
| const hasBarracks = sel.some(s => s.type === 'barracks' && s.owner === 'player' && s.buildProgress >= 1.0); | |
| if (hasWorker) { | |
| for (const key of Object.keys(BUILDINGS)) { | |
| const def = BUILDINGS[key]; | |
| const canAfford = state.gold >= def.cost.g && state.wood >= def.cost.w; | |
| const btn = document.createElement('div'); | |
| btn.className = 'build-btn' + (state.buildMode === key ? ' active' : '') + (canAfford ? '' : ' disabled'); | |
| btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>${def.name}</div><div class="cost">${def.cost.g}g ${def.cost.w}w</div>`; | |
| btn.onclick = () => { | |
| if (state.buildMode === key) state.buildMode = null; | |
| else state.buildMode = key; | |
| refreshBuildPanel(); | |
| }; | |
| panel.appendChild(btn); | |
| // draw icon | |
| const ic = btn.querySelector('canvas'); | |
| drawBuildingIcon(ic, key); | |
| } | |
| } | |
| if (hasTownhall) { | |
| const btn = document.createElement('div'); | |
| btn.className = 'build-btn'; | |
| const def = UNITS.worker; | |
| btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>Worker</div><div class="cost">${def.cost.g}g</div>`; | |
| btn.onclick = () => trainUnit('worker'); | |
| panel.appendChild(btn); | |
| drawUnitIcon(btn.querySelector('canvas'), 'worker'); | |
| } | |
| if (hasBarracks) { | |
| const btn = document.createElement('div'); | |
| btn.className = 'build-btn'; | |
| const def = UNITS.soldier; | |
| btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>Soldier</div><div class="cost">${def.cost.g}g ${def.cost.w}w</div>`; | |
| btn.onclick = () => trainUnit('soldier'); | |
| panel.appendChild(btn); | |
| drawUnitIcon(btn.querySelector('canvas'), 'soldier'); | |
| } | |
| // stop / cancel button when training queue exists | |
| const bldWithQueue = sel.find(s => s.trainQueue && s.trainQueue.length > 0); | |
| if (bldWithQueue) { | |
| const btn = document.createElement('div'); | |
| btn.className = 'build-btn'; | |
| btn.innerHTML = `<canvas class="icon" width="36" height="36"></canvas><div>Cancel</div><div class="cost">last in queue</div>`; | |
| btn.onclick = () => { | |
| const t = bldWithQueue.trainQueue.pop(); | |
| if (t) { | |
| const def = UNITS[t]; | |
| state.gold += def.cost.g; | |
| state.wood += def.cost.w; | |
| recalcPop(); | |
| } | |
| updateSelectionPanel(); | |
| }; | |
| panel.appendChild(btn); | |
| const ic = btn.querySelector('canvas'); | |
| const c = ic.getContext('2d'); | |
| c.fillStyle = '#d05050'; c.font = 'bold 28px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; | |
| c.fillText('✕', 18, 18); | |
| } | |
| } | |
| function drawBuildingIcon(c, type) { | |
| const ctx2 = c.getContext('2d'); | |
| ctx2.clearRect(0,0,36,36); | |
| const def = BUILDINGS[type]; | |
| ctx2.fillStyle = def.color; | |
| ctx2.fillRect(4, 8, 28, 24); | |
| ctx2.fillStyle = def.accent; | |
| ctx2.fillRect(4, 28, 28, 4); | |
| if (type === 'townhall') { | |
| ctx2.fillStyle = def.accent; | |
| ctx2.fillRect(13, 2, 10, 8); | |
| } else if (type === 'house') { | |
| ctx2.fillStyle = def.accent; | |
| ctx2.beginPath(); | |
| ctx2.moveTo(4, 10); ctx2.lineTo(18, 2); ctx2.lineTo(32, 10); ctx2.closePath(); ctx2.fill(); | |
| } else if (type === 'barracks') { | |
| ctx2.fillStyle = def.accent; | |
| ctx2.fillRect(6, 4, 6, 6); ctx2.fillRect(15, 4, 6, 6); ctx2.fillRect(24, 4, 6, 6); | |
| } else if (type === 'tower') { | |
| ctx2.fillStyle = def.accent; | |
| ctx2.fillRect(4, 4, 28, 4); | |
| } | |
| } | |
| function drawUnitIcon(c, type) { | |
| const ctx2 = c.getContext('2d'); | |
| ctx2.clearRect(0,0,36,36); | |
| const def = UNITS[type]; | |
| ctx2.fillStyle = def.color; | |
| ctx2.beginPath(); ctx2.arc(18, 20, 10, 0, Math.PI * 2); ctx2.fill(); | |
| ctx2.strokeStyle = def.accent; ctx2.lineWidth = 2; ctx2.stroke(); | |
| if (type === 'worker') { | |
| ctx2.fillStyle = '#d4b870'; | |
| ctx2.fillRect(13, 7, 10, 3); | |
| } else { | |
| ctx2.fillStyle = '#888'; | |
| ctx2.beginPath(); ctx2.arc(18, 18, 7, Math.PI, Math.PI * 2); ctx2.fill(); | |
| ctx2.strokeStyle = '#ddd'; ctx2.lineWidth = 1.5; | |
| ctx2.beginPath(); ctx2.moveTo(28, 20); ctx2.lineTo(32, 16); ctx2.stroke(); | |
| } | |
| } | |
| function trainUnit(type) { | |
| const def = UNITS[type]; | |
| // find selected building of correct type | |
| let bld = null; | |
| for (const s of state.selection) { | |
| if (s.def && s.w && s.buildProgress >= 1.0) { | |
| if (type === 'worker' && s.type === 'townhall') { bld = s; break; } | |
| if (type === 'soldier' && s.type === 'barracks') { bld = s; break; } | |
| } | |
| } | |
| if (!bld) return; | |
| if (state.gold < def.cost.g || state.wood < def.cost.w) { | |
| showMessage('Not enough resources!', `Need ${def.cost.g}g${def.cost.w ? ' and ' + def.cost.w + 'w' : ''}`, ''); | |
| setTimeout(() => document.getElementById('message').style.display = 'none', 1000); | |
| return; | |
| } | |
| if (state.pop + def.pop > state.popMax) { | |
| showMessage('Population cap reached!', 'Build more houses to increase it.', ''); | |
| setTimeout(() => document.getElementById('message').style.display = 'none', 1200); | |
| return; | |
| } | |
| state.gold -= def.cost.g; | |
| state.wood -= def.cost.w; | |
| bld.trainQueue.push(type); | |
| if (bld.trainQueue.length === 1) bld.trainTimer = def.buildTime; | |
| updateSelectionPanel(); | |
| } | |
| // ---------- MAIN LOOP ---------- | |
| let lastT = performance.now(); | |
| function loop(now) { | |
| const dt = Math.min(0.05, (now - lastT) / 1000); | |
| lastT = now; | |
| if (!state.paused) { | |
| // Camera scrolling (keys + edge) | |
| let dx = 0, dy = 0; | |
| if (state.keys['a'] || state.keys['arrowleft']) dx -= 1; | |
| if (state.keys['d'] || state.keys['arrowright']) dx += 1; | |
| if (state.keys['w'] || state.keys['arrowup']) dy -= 1; | |
| if (state.keys['s'] || state.keys['arrowdown']) dy += 1; | |
| // edge scroll (only if mouse inside canvas) | |
| if (state.mouse.x >= 0 && state.mouse.x < canvas.width && state.mouse.y >= 0 && state.mouse.y < canvas.height) { | |
| if (state.mouse.x < EDGE_SCROLL) dx -= 1; | |
| if (state.mouse.x > canvas.width - EDGE_SCROLL) dx += 1; | |
| if (state.mouse.y < EDGE_SCROLL) dy -= 1; | |
| if (state.mouse.y > canvas.height - EDGE_SCROLL) dy += 1; | |
| } | |
| if (dx !== 0 || dy !== 0) { | |
| const len = Math.sqrt(dx*dx + dy*dy); | |
| state.camera.x += (dx / len) * CAMERA_SPEED * dt; | |
| state.camera.y += (dy / len) * CAMERA_SPEED * dt; | |
| clampCamera(); | |
| } | |
| update(dt); | |
| } | |
| render(); | |
| // Update HUD | |
| document.getElementById('gold').textContent = Math.floor(state.gold); | |
| document.getElementById('wood').textContent = Math.floor(state.wood); | |
| document.getElementById('pop').textContent = state.pop; | |
| document.getElementById('popMax').textContent = state.popMax; | |
| const m = Math.floor(state.gameTime / 60); | |
| const s = Math.floor(state.gameTime % 60); | |
| document.getElementById('gameTime').textContent = (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; | |
| // Update selection panel live (for build progress etc.) | |
| if (state.selection.length > 0) updateSelectionPanel(); | |
| requestAnimationFrame(loop); | |
| } | |
| // ---------- BOOT ---------- | |
| resize(); | |
| initGame(); | |
| updateSelectionPanel(); | |
| refreshBuildPanel(); | |
| requestAnimationFrame(loop); | |
| // Welcome message | |
| setTimeout(() => { | |
| showMessage('⚔ MINI RTS', 'Build your base, gather gold & wood, train an army, then destroy the enemy stronghold in the far corner.<br><br><span style="font-size:13px;color:#8a8270">Click to dismiss</span>'); | |
| document.getElementById('message').onclick = () => document.getElementById('message').style.display = 'none'; | |
| }, 100); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment