Created
June 10, 2026 21:48
-
-
Save senko/a1b3a5a128742fd9674bd5d823d905f9 to your computer and use it in GitHub Desktop.
RTS game by Fable 5
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>Tiny Realm — a mini RTS</title> | |
| <style> | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; padding: 0; overflow: hidden; background: #0b0e14; | |
| font-family: "Segoe UI", "Trebuchet MS", Verdana, sans-serif; user-select: none; | |
| -webkit-user-select: none; } | |
| canvas { display: block; } | |
| #game { position: fixed; inset: 0; cursor: default; } | |
| /* ---------- top bar ---------- */ | |
| #topbar { | |
| position: fixed; top: 0; left: 0; right: 0; height: 42px; z-index: 10; | |
| display: flex; align-items: center; gap: 22px; padding: 0 16px; | |
| background: linear-gradient(#2b2418, #1a150d); | |
| border-bottom: 2px solid #6b5631; box-shadow: 0 2px 10px rgba(0,0,0,.6); | |
| color: #e8d9a8; font-size: 15px; font-weight: 600; | |
| } | |
| #topbar .res { display: flex; align-items: center; gap: 6px; min-width: 86px; } | |
| #topbar .res .ico { font-size: 17px; } | |
| #topbar .spacer { flex: 1; } | |
| #topbar .title { color: #c9b06a; font-size: 15px; letter-spacing: 2px; font-weight: 700; } | |
| #helpBtn { | |
| background: linear-gradient(#5a4a2a, #3d321c); border: 1px solid #8a7340; color: #ffe9b0; | |
| border-radius: 6px; padding: 4px 12px; cursor: pointer; font-weight: 700; font-size: 13px; | |
| } | |
| #helpBtn:hover { filter: brightness(1.2); } | |
| /* ---------- bottom bar ---------- */ | |
| #bottom { | |
| position: fixed; left: 0; right: 0; bottom: 0; height: 158px; z-index: 10; | |
| display: flex; gap: 10px; padding: 8px; | |
| background: linear-gradient(#2b2418, #15110a); | |
| border-top: 2px solid #6b5631; box-shadow: 0 -2px 12px rgba(0,0,0,.6); | |
| } | |
| .panel { | |
| background: rgba(0,0,0,.35); border: 1px solid #564626; border-radius: 8px; | |
| } | |
| #minimapWrap { width: 150px; height: 140px; padding: 3px; flex: none; } | |
| #minimap { width: 142px; height: 132px; image-rendering: pixelated; cursor: pointer; | |
| border-radius: 4px; } | |
| #info { flex: 1; padding: 10px 14px; color: #e8d9a8; overflow: hidden; min-width: 0; } | |
| #info h3 { margin: 0 0 6px 0; font-size: 16px; color: #ffe9b0; display:flex; align-items:center; gap:8px; } | |
| #info h3 .bigico { font-size: 24px; } | |
| #info .sub { font-size: 12px; color: #b3a274; margin-bottom: 6px; } | |
| .hpbarOuter { width: 220px; height: 10px; background: #2a2a2a; border: 1px solid #000; | |
| border-radius: 4px; overflow: hidden; margin: 4px 0; } | |
| .hpbarInner { height: 100%; background: linear-gradient(#7ce06b, #3f9e33); } | |
| .qrow { display: flex; gap: 6px; margin-top: 6px; align-items: center; } | |
| .qitem { width: 34px; height: 34px; border: 1px solid #6b5631; border-radius: 6px; | |
| background: #1e1a10; display: flex; align-items: center; justify-content: center; | |
| font-size: 18px; cursor: pointer; position: relative; overflow: hidden; } | |
| .qitem:hover { border-color: #d44; } | |
| .qitem .qprog { position: absolute; left: 0; bottom: 0; height: 4px; background: #f3c33c; } | |
| .qhint { font-size: 11px; color: #8d7d52; } | |
| #cmds { width: 332px; flex: none; padding: 8px; display: grid; | |
| grid-template-columns: repeat(3, 1fr); grid-auto-rows: 42px; gap: 6px; align-content: start; } | |
| .cmd { | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| background: linear-gradient(#4d4128, #2e2716); color: #ffe9b0; | |
| border: 1px solid #8a7340; border-radius: 6px; cursor: pointer; | |
| font-size: 12px; font-weight: 700; line-height: 1.1; padding: 2px; | |
| } | |
| .cmd small { font-weight: 400; font-size: 10px; color: #cdb877; } | |
| .cmd:hover:not(.off) { filter: brightness(1.25); } | |
| .cmd:active:not(.off) { transform: translateY(1px); } | |
| .cmd.off { opacity: .38; cursor: not-allowed; } | |
| .cmd.danger { border-color: #a33; color: #ffb9a8; } | |
| /* ---------- toast & overlays ---------- */ | |
| #toast { | |
| position: fixed; bottom: 172px; left: 50%; transform: translateX(-50%); z-index: 20; | |
| background: rgba(20,14,4,.92); border: 1px solid #8a7340; color: #ffe9b0; | |
| padding: 7px 18px; border-radius: 8px; font-size: 14px; pointer-events: none; | |
| opacity: 0; transition: opacity .25s; | |
| } | |
| .overlay { | |
| position: fixed; inset: 0; z-index: 50; background: rgba(5,7,12,.82); | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .card { | |
| width: 560px; max-width: 92vw; max-height: 86vh; overflow: auto; | |
| background: linear-gradient(#332a18, #1d160b); border: 2px solid #8a7340; | |
| border-radius: 14px; padding: 26px 32px; color: #e8d9a8; box-shadow: 0 10px 60px #000; | |
| } | |
| .card h1 { margin: 0 0 4px; color: #ffd87a; letter-spacing: 3px; font-size: 30px; text-align: center; } | |
| .card h2 { margin: 0 0 18px; color: #b3a274; font-size: 14px; font-weight: 400; text-align: center; } | |
| .card h4 { color: #ffd87a; margin: 14px 0 6px; } | |
| .card p, .card li { font-size: 13.5px; line-height: 1.55; } | |
| .card ul { margin: 4px 0; padding-left: 20px; } | |
| .card kbd { background: #11202b; border: 1px solid #46627a; border-radius: 4px; | |
| padding: 1px 6px; font-size: 12px; color: #cfe6ff; } | |
| .bigbtn { | |
| display: block; margin: 20px auto 0; padding: 10px 44px; font-size: 17px; font-weight: 700; | |
| background: linear-gradient(#7a9c3f, #4e6b23); color: #f4ffd9; border: 1px solid #aacc66; | |
| border-radius: 8px; cursor: pointer; letter-spacing: 1px; | |
| } | |
| .bigbtn:hover { filter: brightness(1.15); } | |
| .winstats { text-align:center; font-size: 15px; margin-top: 10px; color:#ffe9b0; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <div id="topbar"> | |
| <span class="title">⚜ TINY REALM</span> | |
| <span class="res"><span class="ico">🟡</span><span id="rGold">0</span></span> | |
| <span class="res"><span class="ico">🪵</span><span id="rWood">0</span></span> | |
| <span class="res"><span class="ico">👥</span><span id="rSupply">0/0</span></span> | |
| <span class="res"><span class="ico">🗺️</span><span id="rExplored">0%</span></span> | |
| <span class="spacer"></span> | |
| <span class="res" style="min-width:0"><span id="rTime">0:00</span></span> | |
| <button id="helpBtn">? Help</button> | |
| </div> | |
| <div id="bottom"> | |
| <div id="minimapWrap" class="panel"><canvas id="minimap" width="144" height="144"></canvas></div> | |
| <div id="info" class="panel"></div> | |
| <div id="cmds" class="panel"></div> | |
| </div> | |
| <div id="toast"></div> | |
| <div id="intro" class="overlay"> | |
| <div class="card"> | |
| <h1>⚜ TINY REALM</h1> | |
| <h2>a tiny real-time strategy game</h2> | |
| <p><b style="color:#ffd87a">Your goal:</b> build up your settlement and | |
| <b>uncover the entire map</b>. Gather <b>🟡 gold</b> from mines and <b>🪵 wood</b> | |
| from forests, raise buildings, train an army — and beware, giant spiders 🕷️ guard | |
| the richest mines.</p> | |
| <h4>Controls</h4> | |
| <ul> | |
| <li><b>Left-click / drag</b> — select your units & buildings</li> | |
| <li><b>Right-click</b> — move, gather, attack, or resume construction</li> | |
| <li><b>WASD / arrows / edge of screen / minimap</b> — scroll the map</li> | |
| <li><kbd>Esc</kbd> cancel & deselect · <kbd>S</kbd> stop · <kbd>Space</kbd> jump to Town Hall · <kbd>H</kbd> help</li> | |
| </ul> | |
| <h4>Tips</h4> | |
| <ul> | |
| <li>Workers build everything — select one and use the build buttons.</li> | |
| <li>Farms and Town Halls raise your supply cap.</li> | |
| <li>Watchtowers reveal a huge area — great for exploring.</li> | |
| <li>A building site paused? Right-click it with a worker to resume.</li> | |
| </ul> | |
| <button class="bigbtn" id="startBtn">⚔ PLAY ⚔</button> | |
| </div> | |
| </div> | |
| <div id="winOverlay" class="overlay" style="display:none"> | |
| <div class="card"> | |
| <h1>🎉 VICTORY</h1> | |
| <h2>The whole realm lies revealed before you!</h2> | |
| <div class="winstats" id="winStats"></div> | |
| <button class="bigbtn" id="continueBtn">Keep playing</button> | |
| </div> | |
| </div> | |
| <script> | |
| 'use strict'; | |
| /* ===================================================================== | |
| TINY REALM — a single-file mini RTS | |
| ===================================================================== */ | |
| // ------------------------------ constants --------------------------- | |
| const TILE = 32, MW = 72, MH = 72; | |
| const T = { GRASS: 0, WATER: 1, TREE: 2, STUMP: 3 }; | |
| const REACH = 27; // px, melee / interact reach to a building rect | |
| const TREE_REACH = 50; // px, reach to a tree tile centre | |
| const CARRY_MAX = 10; | |
| const BLD = { | |
| townhall: { name: 'Town Hall', w: 3, h: 3, hp: 1200, cost: { g: 350, w: 250 }, time: 35, sight: 9, supply: 10, icon: '🏰', desc: 'Resource drop-off. Trains workers. +10 supply.' }, | |
| farm: { name: 'Farm', w: 2, h: 2, hp: 350, cost: { g: 60, w: 40 }, time: 14, sight: 5, supply: 6, icon: '🌾', desc: '+6 supply.' }, | |
| barracks: { name: 'Barracks', w: 3, h: 3, hp: 800, cost: { g: 120, w: 80 }, time: 22, sight: 7, supply: 0, icon: '⚔️', desc: 'Trains soldiers and archers.' }, | |
| tower: { name: 'Watchtower', w: 2, h: 2, hp: 500, cost: { g: 40, w: 80 }, time: 12, sight: 13, supply: 0, icon: '🗼', desc: 'Reveals a large area.' }, | |
| }; | |
| const UDEF = { | |
| worker: { name: 'Worker', hp: 50, speed: 95, dmg: 4, range: 26, atkSpd: 1.1, sight: 7, cost: { g: 50, w: 0 }, time: 8, icon: '👷', desc: 'Gathers resources and builds.' }, | |
| soldier: { name: 'Soldier', hp: 120, dmg: 11, speed: 90, range: 28, atkSpd: 0.9, sight: 7, cost: { g: 90, w: 10 }, time: 10, icon: '🛡️', desc: 'Sturdy melee fighter.' }, | |
| archer: { name: 'Archer', hp: 70, dmg: 8, speed: 95, range: 160, atkSpd: 1.1, sight: 8, cost: { g: 70, w: 40 }, time: 11, icon: '🏹', desc: 'Ranged attacker.' }, | |
| spider: { name: 'Giant Spider', hp: 70, dmg: 7, speed: 100, range: 26, atkSpd: 1.0, sight: 5, cost: { g: 0, w: 0 }, time: 0, icon: '🕷️', desc: 'Guards the wild gold mines.' }, | |
| }; | |
| // ------------------------------ state ------------------------------- | |
| const terrain = new Uint8Array(MW * MH); | |
| const treeWood = new Uint16Array(MW * MH); | |
| const bgrid = new Array(MW * MH).fill(null); // building or mine occupying tile | |
| const explored = new Uint8Array(MW * MH); | |
| const visible = new Uint8Array(MW * MH); | |
| const treeVar = new Uint8Array(MW * MH); // cosmetic per-tile hash | |
| let units = [], buildings = [], mines = [], fx = []; | |
| let sel = []; // current selection (entities) | |
| let res = { g: 300, w: 250 }; | |
| let supplyMax = 0, supplyUsed = 0; | |
| let exploredCount = 0; | |
| let cam = { x: 0, y: 0 }; | |
| let gameTime = 0, started = false, won = false; | |
| let placing = null; // {kind} while placing a building | |
| let mouse = { x: 0, y: 0, inside: false, drag: null, mmPan: false }; | |
| let visTimer = 0, uiTimer = 0, selDirty = true; | |
| let now0 = performance.now(); | |
| const idx = (x, y) => y * MW + x; | |
| const inB = (x, y) => x >= 0 && y >= 0 && x < MW && y < MH; | |
| const dist = (ax, ay, bx, by) => Math.hypot(bx - ax, by - ay); | |
| const tOf = v => Math.floor(v / TILE); | |
| const clamp = (v, a, b) => v < a ? a : v > b ? b : v; | |
| const hash2 = (x, y) => { let h = (x * 374761393 + y * 668265263) | 0; h = (h ^ (h >> 13)) * 1274126177; return ((h ^ (h >> 16)) >>> 0); }; | |
| function isBlocked(x, y) { | |
| if (!inB(x, y)) return true; | |
| const t = terrain[idx(x, y)]; | |
| return t === T.WATER || t === T.TREE || bgrid[idx(x, y)] !== null; | |
| } | |
| // ------------------------------ map generation ---------------------- | |
| function genMap() { | |
| for (let tries = 0; tries < 40; tries++) { | |
| terrain.fill(T.GRASS); treeWood.fill(0); bgrid.fill(null); | |
| mines = []; units = []; buildings = []; | |
| for (let i = 0; i < MW * MH; i++) treeVar[i] = hash2(i % MW, (i / MW) | 0) & 255; | |
| const blob = (cx, cy, size, type) => { | |
| let frontier = [[cx, cy]], placed = 0; | |
| const seen = new Set([cx + ',' + cy]); | |
| while (frontier.length && placed < size) { | |
| const [x, y] = frontier.splice((Math.random() * frontier.length) | 0, 1)[0]; | |
| if (!inB(x, y)) continue; | |
| terrain[idx(x, y)] = type; | |
| if (type === T.TREE) treeWood[idx(x, y)] = 120; | |
| placed++; | |
| for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { | |
| const k = (x+dx) + ',' + (y+dy); | |
| if (!seen.has(k) && Math.random() < 0.75) { seen.add(k); frontier.push([x+dx, y+dy]); } | |
| } | |
| } | |
| }; | |
| // lakes | |
| for (let i = 0; i < 6; i++) | |
| blob(8 + (Math.random() * (MW - 16)) | 0, 8 + (Math.random() * (MH - 16)) | 0, 12 + Math.random() * 22, T.WATER); | |
| // forests | |
| for (let i = 0; i < 11; i++) | |
| blob((Math.random() * MW) | 0, (Math.random() * MH) | 0, 25 + Math.random() * 45, T.TREE); | |
| // home base area | |
| const hx = 9, hy = 9; | |
| for (let y = hy - 6; y <= hy + 8; y++) for (let x = hx - 6; x <= hx + 8; x++) | |
| if (inB(x, y)) { terrain[idx(x, y)] = T.GRASS; treeWood[idx(x, y)] = 0; } | |
| // a guaranteed starter forest | |
| blob(hx + 9, hy - 2, 30, T.TREE); | |
| // gold mines: one near home + 5 wild ones spread out | |
| const mineSpots = [{ x: hx - 2, y: hy + 7 }]; | |
| let guard = 0; | |
| while (mineSpots.length < 6 && guard++ < 4000) { | |
| const x = 4 + (Math.random() * (MW - 10)) | 0, y = 4 + (Math.random() * (MH - 10)) | 0; | |
| if (mineSpots.every(m => dist(m.x, m.y, x, y) > 17) && dist(x, y, hx, hy) > 20) mineSpots.push({ x, y }); | |
| } | |
| if (mineSpots.length < 6) continue; | |
| for (const s of mineSpots) { | |
| for (let y = s.y - 1; y <= s.y + 2; y++) for (let x = s.x - 1; x <= s.x + 2; x++) | |
| if (inB(x, y)) { terrain[idx(x, y)] = T.GRASS; treeWood[idx(x, y)] = 0; } | |
| const m = { kind: 'mine', tx: s.x, ty: s.y, w: 2, h: 2, gold: 3000, depleted: false }; | |
| mines.push(m); | |
| for (let y = s.y; y < s.y + 2; y++) for (let x = s.x; x < s.x + 2; x++) bgrid[idx(x, y)] = m; | |
| } | |
| // town hall | |
| const th = makeBuilding('townhall', hx, hy, true); | |
| // reachability: flood from base, trees count as passable (they can be chopped) | |
| const reach = new Uint8Array(MW * MH); | |
| const q = [[hx - 1, hy]]; reach[idx(hx - 1, hy)] = 1; | |
| while (q.length) { | |
| const [x, y] = q.pop(); | |
| for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { | |
| const nx = x + dx, ny = y + dy; | |
| if (!inB(nx, ny) || reach[idx(nx, ny)]) continue; | |
| const t = terrain[idx(nx, ny)]; | |
| if (t === T.WATER || bgrid[idx(nx, ny)]) continue; | |
| reach[idx(nx, ny)] = 1; q.push([nx, ny]); | |
| } | |
| } | |
| // every mine must be touchable, every tile must be visible from a reachable tile | |
| const mineOk = mines.every(m => { | |
| for (let y = m.ty - 1; y <= m.ty + m.h; y++) for (let x = m.tx - 1; x <= m.tx + m.w; x++) | |
| if (inB(x, y) && reach[idx(x, y)]) return true; | |
| return false; | |
| }); | |
| let coverOk = mineOk; | |
| if (coverOk) outer: | |
| for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) { | |
| let seen = false; | |
| for (let yy = Math.max(0, y - 5); yy <= Math.min(MH - 1, y + 5) && !seen; yy++) | |
| for (let xx = Math.max(0, x - 5); xx <= Math.min(MW - 1, x + 5); xx++) | |
| if (reach[idx(xx, yy)] && dist(x, y, xx, yy) <= 5.2) { seen = true; break; } | |
| if (!seen) { coverOk = false; break outer; } | |
| } | |
| if (!coverOk) continue; | |
| // starting workers | |
| for (let i = 0; i < 4; i++) spawnUnit('worker', 0, hx + 3 + i, hy + 3); | |
| // spiders guard the wild mines (not the home one) | |
| for (let mi = 1; mi < mines.length; mi++) { | |
| const m = mines[mi]; | |
| const n = 2 + (mi % 2); | |
| for (let i = 0; i < n; i++) { | |
| const t = freeTileNear(m.tx - 2 + ((i * 5) % 7), m.ty - 2 + ((i * 3) % 7)); | |
| if (t) { | |
| const u = spawnUnit('spider', 1, t.x, t.y); | |
| u.guard = { x: u.x, y: u.y }; | |
| } | |
| } | |
| } | |
| cam.x = clamp(th.tx * TILE + th.w * TILE / 2 - VW / 2, 0, Math.max(0, MW * TILE - VW)); | |
| cam.y = clamp(th.ty * TILE + th.h * TILE / 2 - VH / 2, 0, Math.max(0, MH * TILE - VH + 158)); | |
| return; | |
| } | |
| throw new Error('map generation failed'); | |
| } | |
| // ------------------------------ entities ---------------------------- | |
| function makeBuilding(kind, tx, ty, finished) { | |
| const d = BLD[kind]; | |
| const b = { kind, tx, ty, w: d.w, h: d.h, hp: d.hp, maxHp: d.hp, | |
| progress: finished ? d.time : 0, built: !!finished, | |
| queue: [], rally: null, dead: false }; | |
| buildings.push(b); | |
| for (let y = ty; y < ty + d.h; y++) for (let x = tx; x < tx + d.w; x++) bgrid[idx(x, y)] = b; | |
| recalcSupply(); visTimer = 99; | |
| return b; | |
| } | |
| function spawnUnit(kind, faction, tx, ty) { | |
| const d = UDEF[kind]; | |
| const u = { kind, faction, x: tx * TILE + 16, y: ty * TILE + 16, | |
| hp: d.hp, maxHp: d.hp, speed: d.speed, dmg: d.dmg, range: d.range, | |
| atkSpd: d.atkSpd, sight: d.sight, | |
| order: { kind: 'idle' }, path: null, pathI: 0, destT: null, | |
| carry: null, lastGather: null, work: 0, cd: 0, repath: 0, | |
| flash: 0, moving: false, dir: 1, bob: Math.random() * 7, dead: false }; | |
| units.push(u); | |
| if (faction === 0) recalcSupply(); | |
| return u; | |
| } | |
| function recalcSupply() { | |
| supplyUsed = units.filter(u => u.faction === 0 && !u.dead).length; | |
| supplyMax = Math.min(100, buildings.reduce((s, b) => s + (b.built && !b.dead ? (BLD[b.kind].supply || 0) : 0), 0)); | |
| } | |
| function kill(e) { | |
| e.dead = true; | |
| if (e.kind === 'spider') { | |
| res.g += 25; toast('🕷️ Spider slain! +25 gold'); | |
| fx.push({ type: 'splat', x: e.x, y: e.y, t: 0 }); | |
| } | |
| sel = sel.filter(s => s !== e); selDirty = true; | |
| recalcSupply(); | |
| } | |
| // ------------------------------ pathfinding (A*) -------------------- | |
| const PF_N = MW * MH; | |
| const pfG = new Float32Array(PF_N), pfF = new Float32Array(PF_N); | |
| const pfCame = new Int32Array(PF_N), pfGen = new Int32Array(PF_N), pfClosed = new Int32Array(PF_N); | |
| let pfCur = 0; | |
| function findPath(sx, sy, tx, ty) { | |
| if (sx === tx && sy === ty) return []; | |
| if (isBlocked(tx, ty)) return null; | |
| pfCur++; | |
| const open = []; // binary heap of node indices | |
| const push = i => { open.push(i); let c = open.length - 1; | |
| while (c > 0) { const p = (c - 1) >> 1; if (pfF[open[c]] < pfF[open[p]]) { [open[c], open[p]] = [open[p], open[c]]; c = p; } else break; } }; | |
| const pop = () => { const top = open[0], last = open.pop(); | |
| if (open.length) { open[0] = last; let i = 0; | |
| for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; | |
| if (l < open.length && pfF[open[l]] < pfF[open[m]]) m = l; | |
| if (r < open.length && pfF[open[r]] < pfF[open[m]]) m = r; | |
| if (m === i) break; [open[i], open[m]] = [open[m], open[i]]; i = m; } } | |
| return top; }; | |
| const h = (x, y) => Math.hypot(tx - x, ty - y); | |
| const s = idx(sx, sy); | |
| pfG[s] = 0; pfF[s] = h(sx, sy); pfCame[s] = -1; pfGen[s] = pfCur; | |
| push(s); | |
| let iter = 0; | |
| while (open.length && iter++ < 14000) { | |
| const cur = pop(); | |
| if (pfClosed[cur] === pfCur) continue; | |
| pfClosed[cur] = pfCur; | |
| const cx = cur % MW, cy = (cur / MW) | 0; | |
| if (cx === tx && cy === ty) { | |
| const path = []; | |
| for (let n = cur; n !== s; n = pfCame[n]) path.push({ x: n % MW, y: (n / MW) | 0 }); | |
| return path.reverse(); | |
| } | |
| for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { | |
| if (!dx && !dy) continue; | |
| const nx = cx + dx, ny = cy + dy; | |
| if (isBlocked(nx, ny)) continue; | |
| if (dx && dy && (isBlocked(cx + dx, cy) || isBlocked(cx, cy + dy))) continue; // no corner cutting | |
| const ni = idx(nx, ny); | |
| if (pfClosed[ni] === pfCur) continue; | |
| const g = pfG[cur] + (dx && dy ? 1.4142 : 1); | |
| if (pfGen[ni] !== pfCur || g < pfG[ni]) { | |
| pfGen[ni] = pfCur; pfG[ni] = g; pfF[ni] = g + h(nx, ny); pfCame[ni] = cur; | |
| push(ni); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function setPath(u, tx, ty) { | |
| const p = findPath(tOf(u.x), tOf(u.y), tx, ty); | |
| if (!p) return false; | |
| u.path = p; u.pathI = 0; u.destT = { x: tx, y: ty }; | |
| return true; | |
| } | |
| function followPath(u, dt) { | |
| if (!u.path) return false; | |
| if (u.pathI >= u.path.length) { u.path = null; return false; } | |
| const wp = u.path[u.pathI]; | |
| if (isBlocked(wp.x, wp.y)) { // something was built in the way | |
| if (!u.destT || !setPath(u, u.destT.x, u.destT.y)) { u.path = null; return false; } | |
| return true; | |
| } | |
| const px = wp.x * TILE + 16, py = wp.y * TILE + 16; | |
| const dx = px - u.x, dy = py - u.y, d = Math.hypot(dx, dy), step = u.speed * dt; | |
| if (d <= step) { u.x = px; u.y = py; u.pathI++; if (u.pathI >= u.path.length) { u.path = null; return false; } } | |
| else { u.x += dx / d * step; u.y += dy / d * step; if (Math.abs(dx) > 2) u.dir = dx > 0 ? 1 : -1; } | |
| u.moving = true; | |
| return true; | |
| } | |
| // ------------------------------ spatial helpers --------------------- | |
| function* spiral(cx, cy, maxR) { | |
| yield { x: cx, y: cy }; | |
| for (let r = 1; r <= maxR; r++) { | |
| for (let x = cx - r; x <= cx + r; x++) { yield { x, y: cy - r }; yield { x, y: cy + r }; } | |
| for (let y = cy - r + 1; y <= cy + r - 1; y++) { yield { x: cx - r, y }; yield { x: cx + r, y }; } | |
| } | |
| } | |
| function unitOn(tx, ty) { | |
| return units.some(u => !u.dead && tOf(u.x) === tx && tOf(u.y) === ty); | |
| } | |
| function freeTileNear(tx, ty, maxR = 8, noUnits = true) { | |
| for (const t of spiral(tx, ty, maxR)) | |
| if (inB(t.x, t.y) && !isBlocked(t.x, t.y) && (!noUnits || !unitOn(t.x, t.y))) return t; | |
| return null; | |
| } | |
| function nearestFreeAdj(u, tx, ty, w, h) { | |
| let best = null, bd = 1e9; | |
| for (let x = tx - 1; x <= tx + w; x++) for (let y = ty - 1; y <= ty + h; y++) { | |
| const onEdge = x === tx - 1 || x === tx + w || y === ty - 1 || y === ty + h; | |
| if (!onEdge || !inB(x, y) || isBlocked(x, y)) continue; | |
| const d = dist(u.x, u.y, x * TILE + 16, y * TILE + 16); | |
| if (d < bd) { bd = d; best = { x, y }; } | |
| } | |
| return best; | |
| } | |
| function distToRect(px, py, e) { | |
| const x1 = e.tx * TILE, y1 = e.ty * TILE, x2 = x1 + e.w * TILE, y2 = y1 + e.h * TILE; | |
| const dx = Math.max(x1 - px, 0, px - x2), dy = Math.max(y1 - py, 0, py - y2); | |
| return Math.hypot(dx, dy); | |
| } | |
| function findNearestTree(tx, ty, r) { | |
| let best = null, bd = 1e9; | |
| for (let y = Math.max(0, ty - r); y <= Math.min(MH - 1, ty + r); y++) | |
| for (let x = Math.max(0, tx - r); x <= Math.min(MW - 1, tx + r); x++) | |
| if (terrain[idx(x, y)] === T.TREE) { | |
| const d = dist(tx, ty, x, y); | |
| if (d < bd) { bd = d; best = { x, y }; } | |
| } | |
| return best; | |
| } | |
| function nearestDepot(u) { | |
| let best = null, bd = 1e9; | |
| for (const b of buildings) | |
| if (b.kind === 'townhall' && b.built && !b.dead) { | |
| const d = distToRect(u.x, u.y, b); | |
| if (d < bd) { bd = d; best = b; } | |
| } | |
| return best; | |
| } | |
| // ------------------------------ orders / unit AI -------------------- | |
| function orderMove(u, tx, ty) { u.order = { kind: 'move' }; u.work = 0; setPath(u, tx, ty); } | |
| function reachOrPath(u, dt, tx, ty, w, h, reachPx, ent) { | |
| // returns true once standing in reach of a rect; walks there otherwise | |
| const d = ent ? distToRect(u.x, u.y, ent) | |
| : dist(u.x, u.y, tx * TILE + 16, ty * TILE + 16); | |
| if (d <= reachPx) { u.path = null; return true; } | |
| if (!followPath(u, dt)) { | |
| const t = nearestFreeAdj(u, tx, ty, w, h); | |
| if (!t || !setPath(u, t.x, t.y)) u.order = { kind: 'idle' }; | |
| } | |
| return false; | |
| } | |
| function updateUnit(u, dt) { | |
| u.moving = false; | |
| if (u.flash > 0) u.flash -= dt; | |
| if (u.cd > 0) u.cd -= dt; | |
| u.repath -= dt; | |
| const o = u.order; | |
| if (u.kind === 'spider') { updateSpider(u, dt); return; } | |
| switch (o.kind) { | |
| case 'idle': break; | |
| case 'move': | |
| if (!followPath(u, dt)) u.order = { kind: 'idle' }; | |
| break; | |
| case 'chop': { | |
| if (u.carry && u.carry.type === 'wood' && u.carry.amt >= CARRY_MAX) { u.order = { kind: 'return' }; break; } | |
| let i = idx(o.tx, o.ty); | |
| if (terrain[i] !== T.TREE) { | |
| const nt = findNearestTree(o.tx, o.ty, 7); | |
| if (nt) { o.tx = nt.x; o.ty = nt.y; u.path = null; i = idx(o.tx, o.ty); } | |
| else { u.order = (u.carry && u.carry.amt) ? { kind: 'return' } : { kind: 'idle' }; break; } | |
| } | |
| if (dist(u.x, u.y, o.tx * TILE + 16, o.ty * TILE + 16) <= TREE_REACH) { | |
| u.path = null; | |
| u.dir = (o.tx * TILE + 16) > u.x ? 1 : -1; | |
| u.work += dt; u.anim = 'work'; | |
| if (u.work >= 0.45) { | |
| u.work = 0; | |
| if (!u.carry || u.carry.type !== 'wood') u.carry = { type: 'wood', amt: 0 }; | |
| const take = Math.min(2, treeWood[i], CARRY_MAX - u.carry.amt); | |
| u.carry.amt += take; treeWood[i] -= take; | |
| fx.push({ type: 'chip', x: o.tx * TILE + 16, y: o.ty * TILE + 10, t: 0 }); | |
| if (treeWood[i] <= 0) terrain[i] = T.STUMP; | |
| if (u.carry.amt >= CARRY_MAX) { u.lastGather = { ...o }; u.order = { kind: 'return' }; } | |
| } | |
| } else if (!followPath(u, dt)) { | |
| const t = nearestFreeAdj(u, o.tx, o.ty, 1, 1); | |
| if (!t || !setPath(u, t.x, t.y)) u.order = { kind: 'idle' }; | |
| } | |
| break; | |
| } | |
| case 'mine': { | |
| const m = o.mine; | |
| if (m.depleted) { | |
| const alt = mines.find(mm => !mm.depleted && distToRect(u.x, u.y, mm) < 12 * TILE); | |
| if (alt) { o.mine = alt; u.path = null; } | |
| else { u.order = (u.carry && u.carry.amt) ? { kind: 'return' } : { kind: 'idle' }; } | |
| break; | |
| } | |
| if (u.carry && u.carry.type === 'gold' && u.carry.amt >= CARRY_MAX) { u.lastGather = { ...o }; u.order = { kind: 'return' }; break; } | |
| if (reachOrPath(u, dt, m.tx, m.ty, m.w, m.h, REACH, m)) { | |
| u.dir = (m.tx * TILE + 32) > u.x ? 1 : -1; | |
| u.work += dt; u.anim = 'work'; | |
| if (u.work >= 0.5) { | |
| u.work = 0; | |
| if (!u.carry || u.carry.type !== 'gold') u.carry = { type: 'gold', amt: 0 }; | |
| const take = Math.min(2, m.gold, CARRY_MAX - u.carry.amt); | |
| u.carry.amt += take; m.gold -= take; | |
| if (m.gold <= 0) { m.depleted = true; toast('⛏️ A gold mine has collapsed — it is empty!'); } | |
| if (u.carry.amt >= CARRY_MAX) { u.lastGather = { ...o }; u.order = { kind: 'return' }; } | |
| } | |
| } | |
| break; | |
| } | |
| case 'return': { | |
| const d = nearestDepot(u); | |
| if (!d) { u.order = { kind: 'idle' }; break; } | |
| if (reachOrPath(u, dt, d.tx, d.ty, d.w, d.h, REACH, d)) { | |
| if (u.carry) { res[u.carry.type === 'gold' ? 'g' : 'w'] += u.carry.amt; u.carry = null; } | |
| if (u.lastGather) { u.order = { ...u.lastGather }; u.path = null; } | |
| else u.order = { kind: 'idle' }; | |
| } | |
| break; | |
| } | |
| case 'build': { | |
| const b = o.b; | |
| if (b.dead) { u.order = { kind: 'idle' }; break; } | |
| if (b.built) { u.order = { kind: 'idle' }; break; } | |
| if (reachOrPath(u, dt, b.tx, b.ty, b.w, b.h, REACH, b)) { | |
| u.anim = 'work'; u.work += dt; | |
| u.dir = (b.tx * TILE + b.w * 16) > u.x ? 1 : -1; | |
| b.progress += dt; | |
| if (b.progress >= BLD[b.kind].time) finishBuilding(b); | |
| } | |
| break; | |
| } | |
| case 'attack': { | |
| const t = o.target; | |
| if (!t || t.dead) { u.order = { kind: 'idle' }; break; } | |
| const d = dist(u.x, u.y, t.x, t.y); | |
| if (d <= u.range + 6) { | |
| u.path = null; | |
| u.dir = t.x > u.x ? 1 : -1; | |
| u.anim = 'fight'; | |
| if (u.cd <= 0) { | |
| u.cd = u.atkSpd; | |
| if (u.kind === 'archer') fx.push({ type: 'arrow', x: u.x, y: u.y - 8, tx: t.x, ty: t.y - 6, t: 0, d: dist(u.x, u.y, t.x, t.y) }); | |
| else fx.push({ type: 'hit', x: t.x + (Math.random() * 10 - 5), y: t.y - 8, t: 0 }); | |
| t.hp -= u.dmg; t.flash = 0.18; | |
| aggroSpider(t, u); | |
| if (t.hp <= 0) kill(t); | |
| } | |
| } else { | |
| if (!u.path && u.repath <= 0) { | |
| u.repath = 0.5; | |
| const tt = freeTileNear(tOf(t.x), tOf(t.y), 4); | |
| if (tt) setPath(u, tt.x, tt.y); | |
| } | |
| followPath(u, dt); | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| function aggroSpider(victim, attacker) { | |
| if (victim.kind === 'spider' && !victim.dead && victim.order.kind !== 'attack') | |
| victim.order = { kind: 'attack', target: attacker }; | |
| } | |
| function updateSpider(u, dt) { | |
| const o = u.order; | |
| if (o.kind === 'attack') { | |
| const t = o.target; | |
| const far = dist(u.x, u.y, u.guard.x, u.guard.y) > 9 * TILE; | |
| if (!t || t.dead || far) { u.order = { kind: 'home' }; u.path = null; return; } | |
| const d = dist(u.x, u.y, t.x, t.y); | |
| if (d <= u.range + 4) { | |
| u.path = null; u.dir = t.x > u.x ? 1 : -1; | |
| if (u.cd <= 0) { | |
| u.cd = u.atkSpd; t.hp -= u.dmg; t.flash = 0.18; | |
| fx.push({ type: 'hit', x: t.x, y: t.y - 8, t: 0 }); | |
| if (t.hp <= 0) kill(t); | |
| } | |
| } else { | |
| if (!u.path && u.repath <= 0) { | |
| u.repath = 0.4; | |
| const tt = freeTileNear(tOf(t.x), tOf(t.y), 3); | |
| if (tt) setPath(u, tt.x, tt.y); | |
| } | |
| followPath(u, dt); | |
| } | |
| return; | |
| } | |
| if (o.kind === 'home') { | |
| if (!u.path) { const t = freeTileNear(tOf(u.guard.x), tOf(u.guard.y), 3); if (t) setPath(u, t.x, t.y); } | |
| if (!followPath(u, dt)) { u.order = { kind: 'idle' }; u.hp = Math.min(u.maxHp, u.hp + 20); } | |
| return; | |
| } | |
| // idle: look for prey | |
| let best = null, bd = 4.2 * TILE; | |
| for (const e of units) | |
| if (e.faction === 0 && !e.dead) { | |
| const d = dist(u.x, u.y, e.x, e.y); | |
| if (d < bd) { bd = d; best = e; } | |
| } | |
| if (best) u.order = { kind: 'attack', target: best }; | |
| } | |
| // keep units from stacking on the same spot | |
| function separate() { | |
| for (let i = 0; i < units.length; i++) { | |
| const a = units[i]; if (a.dead) continue; | |
| for (let j = i + 1; j < units.length; j++) { | |
| const b = units[j]; if (b.dead) continue; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const d = Math.hypot(dx, dy); | |
| if (d > 18 || d === 0) continue; | |
| dx /= d; dy /= d; | |
| const push = (18 - d) / 2; | |
| const ax = a.x - dx * push, ay = a.y - dy * push; | |
| const bx2 = b.x + dx * push, by2 = b.y + dy * push; | |
| if (!isBlocked(tOf(ax), tOf(ay))) { a.x = ax; a.y = ay; } | |
| if (!isBlocked(tOf(bx2), tOf(by2))) { b.x = bx2; b.y = by2; } | |
| } | |
| } | |
| } | |
| // ------------------------------ buildings --------------------------- | |
| function finishBuilding(b) { | |
| b.built = true; b.progress = BLD[b.kind].time; b.hp = b.maxHp; | |
| recalcSupply(); visTimer = 99; selDirty = true; | |
| toast(`${BLD[b.kind].icon} ${BLD[b.kind].name} completed!`); | |
| } | |
| function canAfford(c) { return res.g >= c.g && res.w >= c.w; } | |
| function pay(c) { res.g -= c.g; res.w -= c.w; } | |
| function refund(c) { res.g += c.g; res.w += c.w; } | |
| function tryTrain(b, kind) { | |
| const d = UDEF[kind]; | |
| if (!canAfford(d.cost)) { toast('Not enough resources!'); return; } | |
| if (supplyUsed + b.queue.length >= supplyMax) { toast('Build more farms — supply cap reached!'); return; } | |
| if (b.queue.length >= 5) { toast('Queue is full.'); return; } | |
| pay(d.cost); | |
| b.queue.push({ kind, t: 0 }); | |
| selDirty = true; | |
| } | |
| function updateBuilding(b, dt) { | |
| if (!b.built || !b.queue.length) return; | |
| const q = b.queue[0]; | |
| q.t += dt; | |
| if (q.t >= UDEF[q.kind].time) { | |
| b.queue.shift(); | |
| const spot = freeTileNear(b.tx + (b.w >> 1), b.ty + b.h, 7) || freeTileNear(b.tx, b.ty - 1, 7); | |
| if (spot) { | |
| const u = spawnUnit(q.kind, 0, spot.x, spot.y); | |
| if (b.rally) orderMove(u, clamp(tOf(b.rally.x), 0, MW - 1), clamp(tOf(b.rally.y), 0, MH - 1)); | |
| } | |
| selDirty = true; | |
| } | |
| } | |
| // ------------------------------ placement --------------------------- | |
| function placementTiles() { | |
| const d = BLD[placing.kind]; | |
| const wx = cam.x + mouse.x, wy = cam.y + mouse.y; | |
| const tx = clamp(Math.round(wx / TILE - d.w / 2), 0, MW - d.w); | |
| const ty = clamp(Math.round(wy / TILE - d.h / 2), 0, MH - d.h); | |
| return { tx, ty, d }; | |
| } | |
| function placementValid(tx, ty, d) { | |
| for (let y = ty; y < ty + d.h; y++) for (let x = tx; x < tx + d.w; x++) { | |
| const t = terrain[idx(x, y)]; | |
| if ((t !== T.GRASS && t !== T.STUMP) || bgrid[idx(x, y)] || !explored[idx(x, y)] || unitOn(x, y)) return false; | |
| } | |
| return true; | |
| } | |
| function tryPlace() { | |
| const { tx, ty, d } = placementTiles(); | |
| if (!placementValid(tx, ty, d)) { toast('Cannot build there.'); return; } | |
| if (!canAfford(d.cost)) { toast('Not enough resources!'); return; } | |
| pay(d.cost); | |
| const b = makeBuilding(placing.kind, tx, ty, false); | |
| const workers = sel.filter(e => e.kind === 'worker' && !e.dead); | |
| for (const w of workers) { w.order = { kind: 'build', b }; w.path = null; } | |
| placing = null; selDirty = true; | |
| } | |
| // ------------------------------ vision / fog ------------------------ | |
| const fogCv = document.createElement('canvas'); fogCv.width = MW; fogCv.height = MH; | |
| const fogCtx = fogCv.getContext('2d'); | |
| const fogImg = fogCtx.createImageData(MW, MH); | |
| function markCircle(cx, cy, r) { | |
| const r2 = r * r; | |
| for (let y = Math.max(0, cy - r); y <= Math.min(MH - 1, cy + r); y++) | |
| for (let x = Math.max(0, cx - r); x <= Math.min(MW - 1, cx + r); x++) { | |
| const dx = x - cx, dy = y - cy; | |
| if (dx * dx + dy * dy <= r2 + r * 0.5) { | |
| const i = idx(x, y); | |
| visible[i] = 1; | |
| if (!explored[i]) { explored[i] = 1; exploredCount++; } | |
| } | |
| } | |
| } | |
| function recomputeVision() { | |
| visible.fill(0); | |
| for (const u of units) if (u.faction === 0 && !u.dead) markCircle(tOf(u.x), tOf(u.y), u.sight); | |
| for (const b of buildings) if (!b.dead) markCircle(b.tx + (b.w >> 1), b.ty + (b.h >> 1), b.built ? BLD[b.kind].sight : 4); | |
| const px = fogImg.data; | |
| for (let i = 0; i < MW * MH; i++) { | |
| const o = i * 4; | |
| px[o] = 8; px[o + 1] = 10; px[o + 2] = 18; | |
| px[o + 3] = explored[i] ? (visible[i] ? 0 : 115) : 255; | |
| } | |
| fogCtx.putImageData(fogImg, 0, 0); | |
| if (!won && exploredCount >= MW * MH) triggerWin(); | |
| } | |
| function triggerWin() { | |
| won = true; | |
| const m = Math.floor(gameTime / 60), s = Math.floor(gameTime % 60); | |
| document.getElementById('winStats').innerHTML = | |
| `Time: <b>${m}:${String(s).padStart(2, '0')}</b> · Units: <b>${supplyUsed}</b> · Buildings: <b>${buildings.filter(b => b.built).length}</b>`; | |
| document.getElementById('winOverlay').style.display = 'flex'; | |
| } | |
| // ------------------------------ rendering: terrain prerender -------- | |
| const terrCv = document.createElement('canvas'); | |
| terrCv.width = MW * TILE; terrCv.height = MH * TILE; | |
| function prerenderTerrain() { | |
| const c = terrCv.getContext('2d'); | |
| for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) { | |
| const i = idx(x, y), v = treeVar[i], px = x * TILE, py = y * TILE; | |
| if (terrain[i] === T.WATER) { | |
| c.fillStyle = (v & 1) ? '#27628f' : '#246089'; | |
| c.fillRect(px, py, TILE, TILE); | |
| if (v % 5 === 0) { c.fillStyle = 'rgba(255,255,255,.10)'; c.fillRect(px + 6 + (v % 13), py + 10 + (v % 7), 9, 2); } | |
| } else { | |
| const g = 64 + (v % 4) * 5; | |
| c.fillStyle = `rgb(${52 + (v % 3) * 4},${110 + (v % 4) * 6},${56 + (v % 3) * 4})`; | |
| c.fillRect(px, py, TILE, TILE); | |
| if (v % 7 === 0) { c.fillStyle = 'rgba(0,0,0,.07)'; c.fillRect(px + (v % 16), py + (v >> 4) % 16, 10, 10); } | |
| if (v % 31 === 0) { c.fillStyle = '#e8e26b'; c.fillRect(px + (v % 24) + 3, py + (v % 19) + 4, 3, 3); } | |
| if (v % 53 === 0) { c.fillStyle = '#e8f0ff'; c.fillRect(px + (v % 20) + 5, py + (v % 23) + 4, 3, 3); } | |
| } | |
| } | |
| // shoreline highlights | |
| c.fillStyle = 'rgba(180,220,255,.35)'; | |
| for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) { | |
| if (terrain[idx(x, y)] !== T.WATER) continue; | |
| const px = x * TILE, py = y * TILE; | |
| if (y > 0 && terrain[idx(x, y - 1)] !== T.WATER) c.fillRect(px, py, TILE, 3); | |
| if (y < MH - 1 && terrain[idx(x, y + 1)] !== T.WATER) c.fillRect(px, py + TILE - 3, TILE, 3); | |
| if (x > 0 && terrain[idx(x - 1, y)] !== T.WATER) c.fillRect(px, py, 3, TILE); | |
| if (x < MW - 1 && terrain[idx(x + 1, y)] !== T.WATER) c.fillRect(px + TILE - 3, py, 3, TILE); | |
| } | |
| } | |
| const miniTerr = document.createElement('canvas'); | |
| miniTerr.width = MW * 2; miniTerr.height = MH * 2; | |
| function prerenderMini() { | |
| const c = miniTerr.getContext('2d'); | |
| for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) { | |
| const t = terrain[idx(x, y)]; | |
| c.fillStyle = t === T.WATER ? '#246089' : '#3a7a3e'; | |
| c.fillRect(x * 2, y * 2, 2, 2); | |
| } | |
| } | |
| // ------------------------------ rendering: entities ----------------- | |
| const cv = document.getElementById('game'); | |
| const ctx = cv.getContext('2d'); | |
| let VW = 0, VH = 0, DPR = 1; | |
| function resize() { | |
| DPR = Math.min(2, window.devicePixelRatio || 1); | |
| VW = window.innerWidth; VH = window.innerHeight; | |
| cv.width = VW * DPR; cv.height = VH * DPR; | |
| cv.style.width = VW + 'px'; cv.style.height = VH + 'px'; | |
| } | |
| window.addEventListener('resize', resize); resize(); | |
| function drawTree(x, y, i, time) { | |
| const v = treeVar[i]; | |
| const sway = Math.sin(time * 0.9 + v) * 1.2; | |
| const s = 0.85 + (v % 5) * 0.06; | |
| ctx.fillStyle = 'rgba(0,0,0,.25)'; | |
| ctx.beginPath(); ctx.ellipse(x + 16, y + 27, 11 * s, 4, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#5d4225'; | |
| ctx.fillRect(x + 14, y + 16, 4, 11); | |
| const greens = ['#2e6b33', '#2f7a38', '#357f3c']; | |
| ctx.fillStyle = greens[v % 3]; | |
| ctx.beginPath(); ctx.ellipse(x + 16 + sway * 0.4, y + 14, 11 * s, 9 * s, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = greens[(v + 1) % 3]; | |
| ctx.beginPath(); ctx.ellipse(x + 16 + sway, y + 7, 8 * s, 7 * s, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = 'rgba(255,255,255,.13)'; | |
| ctx.beginPath(); ctx.ellipse(x + 13 + sway, y + 5, 3.5, 2.5, 0, 0, 7); ctx.fill(); | |
| } | |
| function drawStump(x, y) { | |
| ctx.fillStyle = '#6b4e2e'; ctx.beginPath(); ctx.ellipse(x + 16, y + 20, 6, 4, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#a9885a'; ctx.beginPath(); ctx.ellipse(x + 16, y + 18, 6, 4, 0, 0, 7); ctx.fill(); | |
| } | |
| function drawMine(m, time) { | |
| const x = m.tx * TILE, y = m.ty * TILE; | |
| ctx.fillStyle = 'rgba(0,0,0,.25)'; | |
| ctx.beginPath(); ctx.ellipse(x + 32, y + 56, 32, 9, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = m.depleted ? '#5d5a52' : '#7c7468'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 2, y + 58); ctx.quadraticCurveTo(x + 6, y + 14, x + 32, y + 8); | |
| ctx.quadraticCurveTo(x + 58, y + 14, x + 62, y + 58); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = m.depleted ? '#4a4741' : '#675f52'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 10, y + 58); ctx.quadraticCurveTo(x + 14, y + 24, x + 32, y + 20); | |
| ctx.quadraticCurveTo(x + 50, y + 24, x + 54, y + 58); ctx.closePath(); ctx.fill(); | |
| // entrance | |
| ctx.fillStyle = '#17120c'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 22, y + 58); ctx.quadraticCurveTo(x + 32, y + 30, x + 42, y + 58); ctx.closePath(); ctx.fill(); | |
| if (!m.depleted) { | |
| const tw = Math.sin(time * 4 + m.tx) > 0.3; | |
| ctx.fillStyle = '#ffd84d'; | |
| ctx.fillRect(x + 14, y + 34, 4, 4); ctx.fillRect(x + 46, y + 40, 4, 4); | |
| if (tw) ctx.fillRect(x + 30, y + 24, 4, 4); | |
| } else { | |
| ctx.fillStyle = '#3a372f'; | |
| ctx.fillRect(x + 24, y + 48, 16, 10); | |
| } | |
| } | |
| function teamFill(shade) { return shade ? '#2f5e96' : '#3b76bd'; } | |
| function drawBuilding(b, time) { | |
| const x = b.tx * TILE, y = b.ty * TILE, W = b.w * TILE, H = b.h * TILE; | |
| ctx.fillStyle = 'rgba(0,0,0,.28)'; | |
| ctx.beginPath(); ctx.ellipse(x + W / 2, y + H - 4, W / 2, 8, 0, 0, 7); ctx.fill(); | |
| if (!b.built) { // construction site | |
| const p = b.progress / BLD[b.kind].time; | |
| ctx.fillStyle = '#7a6038'; ctx.fillRect(x + 2, y + 2, W - 4, H - 4); | |
| ctx.fillStyle = '#5c4527'; ctx.fillRect(x + 4, y + 4, W - 8, H - 8); | |
| ctx.strokeStyle = '#a98850'; ctx.lineWidth = 3; | |
| ctx.strokeRect(x + 6, y + 6, W - 12, H - 12); | |
| ctx.beginPath(); ctx.moveTo(x + 6, y + 6); ctx.lineTo(x + W - 6, y + H - 6); | |
| ctx.moveTo(x + W - 6, y + 6); ctx.lineTo(x + 6, y + H - 6); ctx.stroke(); | |
| if (p > 0.45) { ctx.globalAlpha = 0.55; drawFinishedBuilding(b, x, y, W, H, time); ctx.globalAlpha = 1; } | |
| // progress bar | |
| ctx.fillStyle = '#111'; ctx.fillRect(x + 4, y - 9, W - 8, 6); | |
| ctx.fillStyle = '#f3c33c'; ctx.fillRect(x + 5, y - 8, (W - 10) * p, 4); | |
| return; | |
| } | |
| drawFinishedBuilding(b, x, y, W, H, time); | |
| } | |
| function drawFinishedBuilding(b, x, y, W, H, time) { | |
| if (b.kind === 'townhall') { | |
| ctx.fillStyle = '#8d8478'; ctx.fillRect(x + 6, y + 30, W - 12, H - 34); | |
| ctx.fillStyle = '#797062'; ctx.fillRect(x + 6, y + 30, W - 12, 6); | |
| ctx.fillStyle = '#9c2f2f'; | |
| ctx.beginPath(); ctx.moveTo(x, y + 34); ctx.lineTo(x + W / 2, y + 2); ctx.lineTo(x + W, y + 34); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#7e2424'; | |
| ctx.beginPath(); ctx.moveTo(x, y + 34); ctx.lineTo(x + W / 2, y + 8); ctx.lineTo(x + W / 2, y + 2); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#43301c'; ctx.fillRect(x + W / 2 - 9, y + H - 26, 18, 22); | |
| ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + W / 2 - 6, y + H - 22, 12, 18); | |
| ctx.fillStyle = '#d9c9a8'; | |
| ctx.fillRect(x + 14, y + 40, 10, 10); ctx.fillRect(x + W - 24, y + 40, 10, 10); | |
| // flag | |
| ctx.strokeStyle = '#3a2c18'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(x + W / 2, y + 2); ctx.lineTo(x + W / 2, y - 16); ctx.stroke(); | |
| const fw = Math.sin(time * 3) * 2; | |
| ctx.fillStyle = teamFill(); | |
| ctx.beginPath(); ctx.moveTo(x + W / 2, y - 16); ctx.lineTo(x + W / 2 + 16 + fw, y - 12); | |
| ctx.lineTo(x + W / 2, y - 7); ctx.closePath(); ctx.fill(); | |
| } else if (b.kind === 'farm') { | |
| ctx.fillStyle = '#b59a6a'; ctx.fillRect(x + 2, y + 18, W - 4, H - 20); | |
| ctx.fillStyle = '#caa874'; | |
| for (let r = 0; r < 4; r++) ctx.fillRect(x + 5, y + 22 + r * 9, W - 10, 4); | |
| ctx.fillStyle = '#7a4d28'; | |
| ctx.beginPath(); ctx.moveTo(x, y + 20); ctx.lineTo(x + 18, y + 2); ctx.lineTo(x + 36, y + 20); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#8d6038'; ctx.fillRect(x + 6, y + 20, 24, 14); | |
| ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + 14, y + 24, 8, 10); | |
| } else if (b.kind === 'barracks') { | |
| ctx.fillStyle = '#6e6a64'; ctx.fillRect(x + 4, y + 14, W - 8, H - 18); | |
| ctx.fillStyle = '#7d7972'; | |
| for (let i = 0; i < 5; i++) ctx.fillRect(x + 4 + i * ((W - 8) / 5) + 2, y + 8, (W - 8) / 5 - 6, 10); | |
| ctx.fillStyle = '#5b5751'; | |
| ctx.fillRect(x + 4, y + 34, W - 8, 4); | |
| ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + W / 2 - 10, y + H - 28, 20, 24); | |
| ctx.fillStyle = '#85817a'; | |
| ctx.fillRect(x + 14, y + 44, 10, 12); ctx.fillRect(x + W - 24, y + 44, 10, 12); | |
| ctx.fillStyle = teamFill(); | |
| ctx.beginPath(); ctx.moveTo(x + W / 2, y + 16); ctx.lineTo(x + W / 2 + 12, y + 20); ctx.lineTo(x + W / 2, y + 24); ctx.closePath(); ctx.fill(); | |
| } else if (b.kind === 'tower') { | |
| ctx.fillStyle = '#8d8478'; | |
| ctx.fillRect(x + 12, y - 18, W - 24, H + 12); | |
| ctx.fillStyle = '#797062'; ctx.fillRect(x + 12, y - 18, 6, H + 12); | |
| ctx.fillStyle = '#6e6a64'; ctx.fillRect(x + 6, y - 26, W - 12, 14); | |
| ctx.fillStyle = '#7d7972'; | |
| for (let i = 0; i < 4; i++) ctx.fillRect(x + 7 + i * ((W - 14) / 4) + 1, y - 32, (W - 14) / 4 - 4, 8); | |
| ctx.fillStyle = '#2a1d10'; ctx.fillRect(x + W / 2 - 5, y + H - 18, 10, 14); | |
| ctx.fillStyle = '#ffd84d'; | |
| const gl = 0.6 + Math.sin(time * 5) * 0.3; | |
| ctx.globalAlpha = gl; ctx.fillRect(x + W / 2 - 3, y - 24, 6, 8); ctx.globalAlpha = 1; | |
| } | |
| } | |
| function drawUnit(u, time) { | |
| const bob = u.moving ? Math.sin(time * 11 + u.bob) * 1.6 : 0; | |
| const x = u.x, y = u.y + bob; | |
| ctx.fillStyle = 'rgba(0,0,0,.3)'; | |
| ctx.beginPath(); ctx.ellipse(x, u.y + 9, 8, 3.4, 0, 0, 7); ctx.fill(); | |
| const flash = u.flash > 0; | |
| if (u.kind === 'spider') { | |
| ctx.strokeStyle = flash ? '#fff' : '#3b2a20'; ctx.lineWidth = 2; | |
| for (let i = 0; i < 4; i++) { | |
| const a = -0.7 + i * 0.46 + (u.moving ? Math.sin(time * 14 + i) * 0.18 : 0); | |
| ctx.beginPath(); ctx.moveTo(x, y); | |
| ctx.lineTo(x + Math.cos(a) * 12, y + 4 + Math.sin(a) * 5); | |
| ctx.moveTo(x, y); | |
| ctx.lineTo(x - Math.cos(a) * 12, y + 4 + Math.sin(a) * 5); | |
| ctx.stroke(); | |
| } | |
| ctx.fillStyle = flash ? '#fff' : '#54281e'; | |
| ctx.beginPath(); ctx.ellipse(x, y, 8.5, 6.5, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = flash ? '#fff' : '#6d3526'; | |
| ctx.beginPath(); ctx.ellipse(x + u.dir * 5, y - 2, 5, 4.4, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#ffd84d'; | |
| ctx.fillRect(x + u.dir * 6 - 2, y - 4, 2, 2); ctx.fillRect(x + u.dir * 6 + 1, y - 4, 2, 2); | |
| return; | |
| } | |
| const d = u.dir; | |
| const working = u.anim === 'work' && (u.order.kind === 'chop' || u.order.kind === 'mine' || u.order.kind === 'build'); | |
| const swing = working ? Math.sin(time * 9 + u.bob) : (u.order.kind === 'attack' ? Math.sin(time * 12) : 0); | |
| // body | |
| const bodyCol = flash ? '#ffffff' : (u.kind === 'worker' ? '#b07a3e' : u.kind === 'soldier' ? teamFill() : '#3f7a46'); | |
| ctx.fillStyle = bodyCol; | |
| ctx.beginPath(); ctx.ellipse(x, y, 6.6, 8, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = 'rgba(0,0,0,.18)'; | |
| ctx.beginPath(); ctx.ellipse(x - d * 2, y + 1, 4.5, 6, 0, 0, 7); ctx.fill(); | |
| // head | |
| ctx.fillStyle = flash ? '#fff' : '#e8b88a'; | |
| ctx.beginPath(); ctx.arc(x + d * 1, y - 9, 4.6, 0, 7); ctx.fill(); | |
| if (u.kind === 'worker') { | |
| ctx.fillStyle = flash ? '#fff' : '#d9a430'; // straw hat | |
| ctx.beginPath(); ctx.ellipse(x + d, y - 11.5, 6.4, 2.6, 0, 0, 7); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(x + d, y - 12.5, 3.4, Math.PI, 0); ctx.fill(); | |
| // tool | |
| ctx.strokeStyle = '#5d4225'; ctx.lineWidth = 2; | |
| const ta = d * (working ? (-0.9 + swing * 0.8) : -0.5); | |
| ctx.beginPath(); ctx.moveTo(x + d * 4, y - 2); | |
| ctx.lineTo(x + d * 4 + Math.cos(ta) * 11 * d, y - 2 - Math.abs(Math.sin(ta)) * 11); ctx.stroke(); | |
| ctx.fillStyle = '#9aa2ad'; | |
| ctx.fillRect(x + d * 4 + Math.cos(ta) * 11 * d - 2, y - 2 - Math.abs(Math.sin(ta)) * 11 - 3, 5, 4); | |
| if (u.carry && u.carry.amt > 0) { | |
| ctx.fillStyle = u.carry.type === 'gold' ? '#ffd84d' : '#8d6038'; | |
| ctx.beginPath(); ctx.arc(x - d * 6, y - 4, 3.6, 0, 7); ctx.fill(); | |
| } | |
| } else if (u.kind === 'soldier') { | |
| ctx.fillStyle = flash ? '#fff' : '#9aa2ad'; // helmet | |
| ctx.beginPath(); ctx.arc(x + d, y - 10, 4.4, Math.PI, 0); ctx.fill(); | |
| ctx.fillRect(x + d - 4.4, y - 10, 8.8, 2); | |
| // sword | |
| ctx.strokeStyle = '#cfd6df'; ctx.lineWidth = 2.4; | |
| const sa = -0.4 + swing * 0.9; | |
| ctx.beginPath(); ctx.moveTo(x + d * 5, y - 1); | |
| ctx.lineTo(x + d * 5 + d * Math.cos(sa) * 12, y - 1 - Math.sin(sa + 0.8) * 9); ctx.stroke(); | |
| // shield | |
| ctx.fillStyle = flash ? '#fff' : '#7e5a2e'; | |
| ctx.beginPath(); ctx.ellipse(x - d * 6, y - 1, 3.4, 5, 0, 0, 7); ctx.fill(); | |
| } else if (u.kind === 'archer') { | |
| ctx.fillStyle = flash ? '#fff' : '#2e5e35'; // hood | |
| ctx.beginPath(); ctx.moveTo(x + d - 5, y - 7); ctx.lineTo(x + d, y - 16); ctx.lineTo(x + d + 5, y - 7); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = '#5d4225'; ctx.lineWidth = 2; // bow | |
| ctx.beginPath(); ctx.arc(x + d * 7, y - 4, 7, d > 0 ? -1.1 : Math.PI - 1.1 + 2.2, d > 0 ? 1.1 : Math.PI + 1.1 + 2.2); ctx.stroke(); | |
| } | |
| } | |
| function drawHpBar(x, y, w, frac) { | |
| ctx.fillStyle = '#101010'; ctx.fillRect(x - w / 2, y, w, 4.5); | |
| ctx.fillStyle = frac > 0.5 ? '#52c234' : frac > 0.25 ? '#e0a92e' : '#d04a2a'; | |
| ctx.fillRect(x - w / 2 + 1, y + 1, (w - 2) * frac, 2.5); | |
| } | |
| // ------------------------------ main render ------------------------- | |
| function render(time) { | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| ctx.clearRect(0, 0, VW, VH); | |
| ctx.save(); | |
| ctx.translate(-Math.round(cam.x), -Math.round(cam.y)); | |
| // terrain | |
| ctx.drawImage(terrCv, cam.x, cam.y, VW, VH, cam.x, cam.y, VW, VH); | |
| const tx0 = Math.max(0, tOf(cam.x) - 1), ty0 = Math.max(0, tOf(cam.y) - 1); | |
| const tx1 = Math.min(MW - 1, tOf(cam.x + VW) + 1), ty1 = Math.min(MH - 1, tOf(cam.y + VH) + 1); | |
| // stumps first, then mines/buildings/units sorted by y, then trees overlapping | |
| for (let y = ty0; y <= ty1; y++) for (let x = tx0; x <= tx1; x++) | |
| if (terrain[idx(x, y)] === T.STUMP) drawStump(x * TILE, y * TILE); | |
| const drawables = []; | |
| for (const m of mines) | |
| if (m.tx + 2 >= tx0 && m.tx <= tx1 && m.ty + 2 >= ty0 && m.ty <= ty1) | |
| drawables.push({ y: (m.ty + m.h) * TILE, f: () => drawMine(m, time) }); | |
| for (const b of buildings) | |
| if (!b.dead && b.tx + b.w >= tx0 && b.tx <= tx1 && b.ty + b.h >= ty0 && b.ty <= ty1) | |
| drawables.push({ y: (b.ty + b.h) * TILE, f: () => drawBuilding(b, time) }); | |
| for (const u of units) | |
| if (!u.dead && u.x > cam.x - 40 && u.x < cam.x + VW + 40 && u.y > cam.y - 40 && u.y < cam.y + VH + 40) { | |
| if (u.faction === 1 && !visible[idx(tOf(u.x), tOf(u.y))]) continue; // hidden creeps | |
| drawables.push({ y: u.y + 10, f: () => { | |
| if (sel.includes(u)) { | |
| ctx.strokeStyle = '#7ce06b'; ctx.lineWidth = 1.6; | |
| ctx.beginPath(); ctx.ellipse(u.x, u.y + 9, 10, 4.6, 0, 0, 7); ctx.stroke(); | |
| } | |
| drawUnit(u, time); | |
| if (u.hp < u.maxHp || sel.includes(u)) drawHpBar(u.x, u.y - 20, 22, u.hp / u.maxHp); | |
| }}); | |
| } | |
| for (let y = ty0; y <= ty1; y++) for (let x = tx0; x <= tx1; x++) | |
| if (terrain[idx(x, y)] === T.TREE) | |
| drawables.push({ y: y * TILE + 27, f: () => drawTree(x * TILE, y * TILE, idx(x, y), time) }); | |
| drawables.sort((a, b) => a.y - b.y); | |
| for (const d of drawables) d.f(); | |
| // selected building decorations | |
| for (const e of sel) if (e.w && !e.dead) { | |
| const x = e.tx * TILE, y = e.ty * TILE, W = e.w * TILE, H = e.h * TILE; | |
| ctx.strokeStyle = '#7ce06b'; ctx.lineWidth = 2; | |
| const L = 10; | |
| for (const [cx, cy, dx, dy] of [[x, y, 1, 1], [x + W, y, -1, 1], [x, y + H, 1, -1], [x + W, y + H, -1, -1]]) { | |
| ctx.beginPath(); ctx.moveTo(cx + dx * L, cy); ctx.lineTo(cx, cy); ctx.lineTo(cx, cy + dy * L); ctx.stroke(); | |
| } | |
| if (e.rally) { | |
| ctx.strokeStyle = '#ffd84d'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(e.rally.x, e.rally.y + 4); ctx.lineTo(e.rally.x, e.rally.y - 14); ctx.stroke(); | |
| ctx.fillStyle = '#ffd84d'; | |
| ctx.beginPath(); ctx.moveTo(e.rally.x, e.rally.y - 14); ctx.lineTo(e.rally.x + 11, e.rally.y - 10); ctx.lineTo(e.rally.x, e.rally.y - 6); ctx.closePath(); ctx.fill(); | |
| } | |
| if (e.hp !== undefined && e.maxHp) drawHpBar(x + W / 2, y - (e.kind === 'tower' ? 40 : 16), W - 8, e.hp / e.maxHp); | |
| } | |
| // effects | |
| for (const f of fx) { | |
| if (f.type === 'hit') { | |
| ctx.strokeStyle = `rgba(255,230,120,${1 - f.t * 3})`; ctx.lineWidth = 2; | |
| const r = 3 + f.t * 26; | |
| ctx.beginPath(); | |
| for (let i = 0; i < 5; i++) { | |
| const a = i * 1.256 + f.t * 5; | |
| ctx.moveTo(f.x + Math.cos(a) * r * 0.4, f.y + Math.sin(a) * r * 0.4); | |
| ctx.lineTo(f.x + Math.cos(a) * r, f.y + Math.sin(a) * r); | |
| } | |
| ctx.stroke(); | |
| } else if (f.type === 'arrow') { | |
| const p = Math.min(1, f.t * 300 / Math.max(1, f.d)); | |
| const ax = f.x + (f.tx - f.x) * p, ay = f.y + (f.ty - f.y) * p - Math.sin(p * Math.PI) * 14; | |
| ctx.strokeStyle = '#e8d9a8'; ctx.lineWidth = 2; | |
| const ang = Math.atan2(f.ty - f.y, f.tx - f.x); | |
| ctx.beginPath(); ctx.moveTo(ax - Math.cos(ang) * 6, ay - Math.sin(ang) * 6); ctx.lineTo(ax + Math.cos(ang) * 4, ay + Math.sin(ang) * 4); ctx.stroke(); | |
| } else if (f.type === 'chip') { | |
| ctx.fillStyle = `rgba(170,130,80,${1 - f.t * 2.5})`; | |
| ctx.fillRect(f.x - 6 + f.t * 24, f.y - f.t * 30 + f.t * f.t * 70, 3, 3); | |
| ctx.fillRect(f.x + 4 - f.t * 20, f.y - f.t * 36 + f.t * f.t * 70, 3, 3); | |
| } else if (f.type === 'splat') { | |
| ctx.fillStyle = `rgba(90,40,30,${0.7 - f.t * 0.5})`; | |
| ctx.beginPath(); ctx.ellipse(f.x, f.y + 6, 10 + f.t * 6, 5 + f.t * 3, 0, 0, 7); ctx.fill(); | |
| } | |
| } | |
| // placement ghost | |
| if (placing) { | |
| const { tx, ty, d } = placementTiles(); | |
| const ok = placementValid(tx, ty, d); | |
| ctx.globalAlpha = 0.55; | |
| const tmp = { kind: placing.kind, tx, ty, w: d.w, h: d.h, built: true }; | |
| drawFinishedBuilding(tmp, tx * TILE, ty * TILE, d.w * TILE, d.h * TILE, time); | |
| ctx.globalAlpha = 1; | |
| ctx.fillStyle = ok ? 'rgba(110,220,110,.3)' : 'rgba(230,80,60,.4)'; | |
| ctx.fillRect(tx * TILE, ty * TILE, d.w * TILE, d.h * TILE); | |
| ctx.strokeStyle = ok ? '#7ce06b' : '#e0563c'; ctx.lineWidth = 2; | |
| ctx.strokeRect(tx * TILE + 1, ty * TILE + 1, d.w * TILE - 2, d.h * TILE - 2); | |
| } | |
| // fog of war (soft-scaled) | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.drawImage(fogCv, 0, 0, MW, MH, 0, 0, MW * TILE, MH * TILE); | |
| ctx.imageSmoothingEnabled = false; | |
| ctx.restore(); | |
| // selection drag box | |
| if (mouse.drag && mouse.drag.box) { | |
| const d = mouse.drag; | |
| ctx.strokeStyle = '#7ce06b'; ctx.lineWidth = 1.5; | |
| ctx.fillStyle = 'rgba(124,224,107,.12)'; | |
| const x = Math.min(d.sx, mouse.x), y = Math.min(d.sy, mouse.y); | |
| const w = Math.abs(mouse.x - d.sx), h = Math.abs(mouse.y - d.sy); | |
| ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); | |
| } | |
| renderMinimap(); | |
| } | |
| // ------------------------------ minimap ----------------------------- | |
| const mmCv = document.getElementById('minimap'); | |
| const mmCtx = mmCv.getContext('2d'); | |
| function renderMinimap() { | |
| const S = mmCv.width / MW; // 2 px per tile | |
| mmCtx.clearRect(0, 0, mmCv.width, mmCv.height); | |
| mmCtx.drawImage(miniTerr, 0, 0); | |
| for (const m of mines) { | |
| mmCtx.fillStyle = m.depleted ? '#777' : '#ffd84d'; | |
| mmCtx.fillRect(m.tx * S, m.ty * S, 4, 4); | |
| } | |
| for (const b of buildings) if (!b.dead) { | |
| mmCtx.fillStyle = '#4d9aef'; | |
| mmCtx.fillRect(b.tx * S, b.ty * S, b.w * S, b.h * S); | |
| } | |
| for (const u of units) if (!u.dead) { | |
| const i = idx(tOf(u.x), tOf(u.y)); | |
| if (u.faction === 1 && !visible[i]) continue; | |
| mmCtx.fillStyle = u.faction === 0 ? '#aee4ff' : '#e0563c'; | |
| mmCtx.fillRect(tOf(u.x) * S, tOf(u.y) * S, 2, 2); | |
| } | |
| mmCtx.imageSmoothingEnabled = false; | |
| mmCtx.drawImage(fogCv, 0, 0, MW, MH, 0, 0, mmCv.width, mmCv.height); | |
| mmCtx.strokeStyle = '#fff'; mmCtx.lineWidth = 1; | |
| mmCtx.strokeRect(cam.x / TILE * S, cam.y / TILE * S, VW / TILE * S, VH / TILE * S); | |
| } | |
| // ------------------------------ UI panels --------------------------- | |
| const elGold = document.getElementById('rGold'), elWood = document.getElementById('rWood'); | |
| const elSup = document.getElementById('rSupply'), elExp = document.getElementById('rExplored'); | |
| const elTime = document.getElementById('rTime'); | |
| const elInfo = document.getElementById('info'), elCmds = document.getElementById('cmds'); | |
| let toastTimer = null; | |
| function toast(msg) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; t.style.opacity = 1; | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => t.style.opacity = 0, 2400); | |
| } | |
| function fmtCost(c) { return `${c.g ? c.g + '🟡' : ''}${c.g && c.w ? ' ' : ''}${c.w ? c.w + '🪵' : ''}`; } | |
| function refreshTopbar() { | |
| elGold.textContent = res.g; elWood.textContent = res.w; | |
| elSup.textContent = supplyUsed + '/' + supplyMax; | |
| elSup.style.color = supplyUsed >= supplyMax ? '#ff9078' : ''; | |
| elExp.textContent = Math.floor(exploredCount / (MW * MH) * 100) + '%'; | |
| const m = Math.floor(gameTime / 60), s = Math.floor(gameTime % 60); | |
| elTime.textContent = m + ':' + String(s).padStart(2, '0'); | |
| } | |
| function cmdBtn(label, sub, cb, off, danger) { | |
| const b = document.createElement('button'); | |
| b.className = 'cmd' + (off ? ' off' : '') + (danger ? ' danger' : ''); | |
| b.innerHTML = `<span>${label}</span>` + (sub ? `<small>${sub}</small>` : ''); | |
| if (!off) b.onclick = cb; | |
| elCmds.appendChild(b); | |
| return b; | |
| } | |
| function rebuildPanels() { | |
| elInfo.innerHTML = ''; elCmds.innerHTML = ''; | |
| selDirty = false; | |
| if (!sel.length) { | |
| elInfo.innerHTML = `<h3><span class="bigico">⚜</span> Tiny Realm</h3> | |
| <div class="sub">Select units with left-click / drag. Explore the whole map to win!</div> | |
| <div class="sub">Explored: ${Math.floor(exploredCount / (MW * MH) * 100)}% of the realm</div>`; | |
| return; | |
| } | |
| const first = sel[0]; | |
| if (sel.length > 1) { | |
| const counts = {}; | |
| for (const u of sel) counts[u.kind] = (counts[u.kind] || 0) + 1; | |
| elInfo.innerHTML = `<h3><span class="bigico">⚑</span> ${sel.length} units selected</h3> | |
| <div class="sub">${Object.entries(counts).map(([k, n]) => `${UDEF[k].icon} ${UDEF[k].name} ×${n}`).join(' ')}</div>`; | |
| if (sel.some(u => u.kind === 'worker')) buildButtons(); | |
| cmdBtn('✋ Stop', 'S', stopSelected); | |
| return; | |
| } | |
| if (first.w) { // building or mine | |
| if (first.kind === 'mine') { | |
| elInfo.innerHTML = `<h3><span class="bigico">⛏️</span> Gold Mine</h3> | |
| <div class="sub">${first.depleted ? 'Depleted — nothing remains.' : 'Gold remaining: <b style="color:#ffd84d">' + first.gold + '</b>'}</div> | |
| <div class="sub">Send workers here to mine gold.</div>`; | |
| return; | |
| } | |
| const d = BLD[first.kind]; | |
| let html = `<h3><span class="bigico">${d.icon}</span> ${d.name}${first.built ? '' : ' (under construction)'}</h3> | |
| <div class="sub">${d.desc}</div>`; | |
| if (!first.built) { | |
| const p = Math.floor(first.progress / d.time * 100); | |
| html += `<div class="hpbarOuter"><div class="hpbarInner" style="width:${p}%;background:linear-gradient(#ffe084,#cf9d2e)"></div></div> | |
| <div class="sub">Construction: ${p}% — right-click with a worker to ${p > 0 ? 'resume' : 'start'} building</div>`; | |
| } else { | |
| html += `<div class="hpbarOuter"><div class="hpbarInner" style="width:${first.hp / first.maxHp * 100}%"></div></div> | |
| <div class="sub">HP ${first.hp}/${first.maxHp}</div>`; | |
| if (first.queue.length) { | |
| html += `<div class="qrow">` + first.queue.map((q, i) => | |
| `<div class="qitem" data-qi="${i}" title="Click to cancel">${UDEF[q.kind].icon} | |
| <div class="qprog" style="width:${i === 0 ? Math.floor(q.t / UDEF[q.kind].time * 100) : 0}%"></div></div>`).join('') + | |
| `<span class="qhint">click to cancel</span></div>`; | |
| } | |
| } | |
| elInfo.innerHTML = html; | |
| elInfo.querySelectorAll('.qitem').forEach(el => el.onclick = () => { | |
| const i = +el.dataset.qi; | |
| const q = first.queue.splice(i, 1)[0]; | |
| if (q) { refund(UDEF[q.kind].cost); selDirty = true; } | |
| }); | |
| if (first.built) { | |
| if (first.kind === 'townhall') | |
| cmdBtn(`${UDEF.worker.icon} Worker`, fmtCost(UDEF.worker.cost), () => tryTrain(first, 'worker')); | |
| if (first.kind === 'barracks') { | |
| cmdBtn(`${UDEF.soldier.icon} Soldier`, fmtCost(UDEF.soldier.cost), () => tryTrain(first, 'soldier')); | |
| cmdBtn(`${UDEF.archer.icon} Archer`, fmtCost(UDEF.archer.cost), () => tryTrain(first, 'archer')); | |
| } | |
| if (first.kind === 'townhall' || first.kind === 'barracks') { | |
| const hint = document.createElement('div'); | |
| hint.className = 'qhint'; hint.style.gridColumn = '1 / -1'; | |
| hint.textContent = 'Right-click on the map to set a rally point.'; | |
| elCmds.appendChild(hint); | |
| } | |
| } | |
| return; | |
| } | |
| // single unit | |
| const d = UDEF[first.kind]; | |
| let extra = ''; | |
| if (first.carry && first.carry.amt) extra = `Carrying ${first.carry.amt} ${first.carry.type === 'gold' ? '🟡 gold' : '🪵 wood'}`; | |
| else extra = { idle: 'Idle', move: 'Moving', chop: 'Chopping wood', mine: 'Mining gold', return: 'Delivering resources', build: 'Building', attack: 'Fighting!' }[first.order.kind] || ''; | |
| elInfo.innerHTML = `<h3><span class="bigico">${d.icon}</span> ${d.name}</h3> | |
| <div class="sub">${d.desc}</div> | |
| <div class="hpbarOuter"><div class="hpbarInner" style="width:${first.hp / first.maxHp * 100}%"></div></div> | |
| <div class="sub">HP ${Math.ceil(first.hp)}/${first.maxHp} ${extra}</div>`; | |
| if (first.kind === 'worker') buildButtons(); | |
| cmdBtn('✋ Stop', 'S', stopSelected); | |
| } | |
| function buildButtons() { | |
| for (const k of ['farm', 'barracks', 'tower', 'townhall']) { | |
| const d = BLD[k]; | |
| cmdBtn(`${d.icon} ${d.name}`, fmtCost(d.cost), () => { | |
| if (!canAfford(d.cost)) { toast('Not enough resources!'); return; } | |
| placing = { kind: k }; | |
| }, !canAfford(d.cost)); | |
| } | |
| } | |
| function stopSelected() { | |
| for (const u of sel) if (!u.w) { u.order = { kind: 'idle' }; u.path = null; } | |
| } | |
| // ------------------------------ input ------------------------------- | |
| const keys = {}; | |
| window.addEventListener('keydown', e => { | |
| if (e.repeat) return; | |
| keys[e.key.toLowerCase()] = true; | |
| if (e.key === 'Escape') { | |
| if (placing) placing = null; | |
| else { sel = []; selDirty = true; } | |
| } | |
| if (e.key.toLowerCase() === 's') stopSelected(); | |
| if (e.key.toLowerCase() === 'h') document.getElementById('intro').style.display = 'flex'; | |
| if (e.key === ' ') { | |
| const th = buildings.find(b => b.kind === 'townhall' && !b.dead); | |
| if (th) { cam.x = th.tx * TILE - VW / 2 + 48; cam.y = th.ty * TILE - VH / 2 + 48; } | |
| e.preventDefault(); | |
| } | |
| }); | |
| window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false); | |
| window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; }); | |
| cv.addEventListener('contextmenu', e => e.preventDefault()); | |
| cv.addEventListener('mousedown', e => { | |
| if (!started) return; | |
| if (e.button === 0) { | |
| if (placing) { tryPlace(); return; } | |
| mouse.drag = { sx: e.clientX, sy: e.clientY, box: false }; | |
| } else if (e.button === 1) { | |
| mouse.mmPan = { x: e.clientX, y: e.clientY }; e.preventDefault(); | |
| } else if (e.button === 2) { | |
| if (placing) { placing = null; return; } | |
| rightClick(cam.x + e.clientX, cam.y + e.clientY); | |
| } | |
| }); | |
| document.addEventListener('mouseleave', () => mouse.inside = false); | |
| window.addEventListener('mousemove', e => { | |
| mouse.x = e.clientX; mouse.y = e.clientY; mouse.inside = true; | |
| if (mouse.drag && !mouse.drag.box && | |
| Math.hypot(e.clientX - mouse.drag.sx, e.clientY - mouse.drag.sy) > 6) mouse.drag.box = true; | |
| if (mouse.mmPan) { | |
| cam.x -= e.clientX - mouse.mmPan.x; cam.y -= e.clientY - mouse.mmPan.y; | |
| mouse.mmPan = { x: e.clientX, y: e.clientY }; | |
| } | |
| }); | |
| window.addEventListener('mouseup', e => { | |
| if (e.button === 1) { mouse.mmPan = false; return; } | |
| if (e.button !== 0 || !mouse.drag) return; | |
| const d = mouse.drag; mouse.drag = null; | |
| if (!started) return; | |
| if (d.box) { | |
| const x1 = cam.x + Math.min(d.sx, e.clientX), x2 = cam.x + Math.max(d.sx, e.clientX); | |
| const y1 = cam.y + Math.min(d.sy, e.clientY), y2 = cam.y + Math.max(d.sy, e.clientY); | |
| const picked = units.filter(u => u.faction === 0 && !u.dead && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2); | |
| if (picked.length) { sel = picked; selDirty = true; return; } | |
| } | |
| // click select | |
| const wx = cam.x + e.clientX, wy = cam.y + e.clientY; | |
| let best = null, bd = 18; | |
| for (const u of units) { | |
| if (u.dead) continue; | |
| if (u.faction === 1 && !visible[idx(tOf(u.x), tOf(u.y))]) continue; | |
| const dd = dist(wx, wy, u.x, u.y - 4); | |
| if (dd < bd) { bd = dd; best = u; } | |
| } | |
| if (best && best.faction === 0) { sel = [best]; selDirty = true; return; } | |
| const tx = tOf(wx), ty = tOf(wy); | |
| const g = inB(tx, ty) ? bgrid[idx(tx, ty)] : null; | |
| if (g && explored[idx(tx, ty)]) { sel = [g]; selDirty = true; return; } | |
| sel = []; selDirty = true; | |
| }); | |
| function rightClick(wx, wy) { | |
| const tx = clamp(tOf(wx), 0, MW - 1), ty = clamp(tOf(wy), 0, MH - 1); | |
| const i = idx(tx, ty); | |
| // building selected → set rally | |
| if (sel.length === 1 && sel[0].w && sel[0].kind !== 'mine' && sel[0].built) { | |
| sel[0].rally = { x: wx, y: wy }; | |
| return; | |
| } | |
| const myUnits = sel.filter(e => !e.w && e.faction === 0 && !e.dead); | |
| if (!myUnits.length) return; | |
| // enemy creep? | |
| let creep = null, cd = 24; | |
| for (const u of units) if (u.faction === 1 && !u.dead && visible[idx(tOf(u.x), tOf(u.y))]) { | |
| const dd = dist(wx, wy, u.x, u.y); | |
| if (dd < cd) { cd = dd; creep = u; } | |
| } | |
| if (creep) { | |
| for (const u of myUnits) { u.order = { kind: 'attack', target: creep }; u.path = null; u.repath = 0; } | |
| return; | |
| } | |
| const g = bgrid[i]; | |
| if (g && explored[i]) { | |
| if (g.kind === 'mine' && !g.depleted) { | |
| for (const u of myUnits) | |
| if (u.kind === 'worker') { u.order = { kind: 'mine', mine: g }; u.lastGather = { kind: 'mine', mine: g }; u.path = null; } | |
| else moveGroup([u], tx, ty); | |
| return; | |
| } | |
| if (g.kind !== 'mine' && !g.built && !g.dead) { | |
| let any = false; | |
| for (const u of myUnits) | |
| if (u.kind === 'worker') { u.order = { kind: 'build', b: g }; u.path = null; any = true; } | |
| if (any) return; | |
| } | |
| moveGroup(myUnits, tx, ty); | |
| return; | |
| } | |
| if (terrain[i] === T.TREE && explored[i]) { | |
| let any = false; | |
| for (const u of myUnits) | |
| if (u.kind === 'worker') { u.order = { kind: 'chop', tx, ty }; u.lastGather = { kind: 'chop', tx, ty }; u.path = null; any = true; } | |
| const rest = myUnits.filter(u => u.kind !== 'worker'); | |
| if (rest.length) moveGroup(rest, tx, ty); | |
| if (any || rest.length) return; | |
| } | |
| moveGroup(myUnits, tx, ty); | |
| } | |
| function moveGroup(group, tx, ty) { | |
| const it = spiral(tx, ty, 7); | |
| for (const u of group) { | |
| let t = null; | |
| for (const c of it) if (inB(c.x, c.y) && !isBlocked(c.x, c.y)) { t = c; break; } | |
| if (!t) t = { x: tx, y: ty }; | |
| u.order = { kind: 'move' }; u.path = null; | |
| setPath(u, t.x, t.y); | |
| } | |
| fx.push({ type: 'hit', x: tx * TILE + 16, y: ty * TILE + 16, t: 0.12 }); | |
| } | |
| // minimap interaction | |
| function mmJump(e) { | |
| const r = mmCv.getBoundingClientRect(); | |
| const fx2 = (e.clientX - r.left) / r.width, fy = (e.clientY - r.top) / r.height; | |
| cam.x = fx2 * MW * TILE - VW / 2; cam.y = fy * MH * TILE - VH / 2; | |
| } | |
| mmCv.addEventListener('mousedown', e => { if (e.button === 0) { mmJump(e); mmCv.dataset.drag = '1'; } }); | |
| window.addEventListener('mouseup', () => delete mmCv.dataset.drag); | |
| mmCv.addEventListener('mousemove', e => { if (mmCv.dataset.drag) mmJump(e); }); | |
| // ------------------------------ camera ------------------------------ | |
| function updateCamera(dt) { | |
| const sp = 620 * dt; | |
| if (keys['arrowleft'] || keys['a']) cam.x -= sp; | |
| if (keys['arrowright'] || keys['d']) cam.x += sp; | |
| if (keys['arrowup'] || keys['w']) cam.y -= sp; | |
| if (keys['arrowdown']) cam.y += sp; // (S is the stop hotkey, so only arrow-down scrolls down) | |
| // edge scroll | |
| const M = 14; | |
| if (mouse.inside) { | |
| if (mouse.x < M) cam.x -= sp; | |
| if (mouse.x > VW - M) cam.x += sp; | |
| if (mouse.y < M + 42 && mouse.y >= 42) cam.y -= sp; | |
| if (mouse.y > VH - M) cam.y += sp; | |
| } | |
| cam.x = clamp(cam.x, 0, Math.max(0, MW * TILE - VW)); | |
| cam.y = clamp(cam.y, 0, Math.max(0, MH * TILE - VH + 158)); | |
| } | |
| // ------------------------------ main loop --------------------------- | |
| function tick(nowMs) { | |
| requestAnimationFrame(tick); | |
| const dt = Math.min(0.05, (nowMs - now0) / 1000); | |
| now0 = nowMs; | |
| const time = nowMs / 1000; | |
| if (started) { | |
| gameTime += dt; | |
| updateCamera(dt); | |
| for (const u of units) if (!u.dead) updateUnit(u, dt); | |
| for (const b of buildings) if (!b.dead) updateBuilding(b, dt); | |
| separate(); | |
| if (units.some(u => u.dead)) units = units.filter(u => !u.dead); | |
| visTimer += dt; | |
| if (visTimer > 0.15) { visTimer = 0; recomputeVision(); } | |
| for (const f of fx) f.t += dt; | |
| fx = fx.filter(f => f.t < (f.type === 'arrow' ? f.d / 300 : f.type === 'splat' ? 1.4 : 0.4)); | |
| uiTimer += dt; | |
| if (selDirty || uiTimer > 0.25) { uiTimer = 0; rebuildPanels(); } | |
| refreshTopbar(); | |
| } | |
| render(time); | |
| } | |
| // ------------------------------ boot -------------------------------- | |
| genMap(); | |
| prerenderTerrain(); | |
| prerenderMini(); | |
| recomputeVision(); | |
| rebuildPanels(); | |
| refreshTopbar(); | |
| document.getElementById('startBtn').onclick = () => { | |
| document.getElementById('intro').style.display = 'none'; | |
| if (!started) { started = true; toast('Gather resources and explore the realm! Right-click to command.'); } | |
| }; | |
| document.getElementById('helpBtn').onclick = () => document.getElementById('intro').style.display = 'flex'; | |
| document.getElementById('continueBtn').onclick = () => document.getElementById('winOverlay').style.display = 'none'; | |
| requestAnimationFrame(tick); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment