Created
July 25, 2026 09:26
-
-
Save senko/c7004d97f7cfa61bd05e1bdf8c26031d to your computer and use it in GitHub Desktop.
RTS game by Opus 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"> | |
| <title>Ironhold — Realtime Strategy</title> | |
| <style> | |
| :root{ | |
| --panel:#1b1f2a; | |
| --panel2:#232937; | |
| --edge:#39415a; | |
| --gold:#ffd166; | |
| --wood:#b9834b; | |
| --text:#d9e0f0; | |
| --dim:#8b95ad; | |
| --accent:#6fd3ff; | |
| --green:#7bd47b; | |
| --red:#ff6b6b; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| html,body{height:100%;overflow:hidden;background:#0b0d13;color:var(--text); | |
| font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;user-select:none;-webkit-user-select:none} | |
| #app{position:fixed;inset:0;display:flex;flex-direction:column} | |
| /* ---------- top bar ---------- */ | |
| #topbar{height:38px;flex:0 0 38px;display:flex;align-items:center;gap:18px;padding:0 14px; | |
| background:linear-gradient(#2a3145,#171b26);border-bottom:2px solid #0a0c11; | |
| box-shadow:0 2px 8px rgba(0,0,0,.6);z-index:5;font-size:14px} | |
| .res{display:flex;align-items:center;gap:7px;font-weight:600;letter-spacing:.4px} | |
| .res .gem{width:15px;height:15px;border-radius:3px;transform:rotate(45deg);box-shadow:inset 0 0 0 1px rgba(255,255,255,.25)} | |
| .gem.g{background:linear-gradient(135deg,#ffe9a8,#e0a020)} | |
| .gem.w{background:linear-gradient(135deg,#d8a778,#7a4f28);border-radius:2px;transform:none} | |
| .gem.s{background:linear-gradient(135deg,#a8d8ff,#3a7bbd);border-radius:50%;transform:none} | |
| .gem.e{background:linear-gradient(135deg,#c9b4ff,#6a4bbd);border-radius:50%;transform:none} | |
| #title{margin-left:auto;font-weight:700;letter-spacing:3px;color:#8c98b8;font-size:12px} | |
| .btn-min{background:#2c3245;border:1px solid #454e69;color:#c8d2e8;border-radius:4px;padding:3px 9px; | |
| font-size:12px;cursor:pointer} | |
| .btn-min:hover{background:#3a4260;color:#fff} | |
| /* ---------- viewport ---------- */ | |
| #viewport{position:relative;flex:1 1 auto;overflow:hidden;background:#05070b} | |
| #game{position:absolute;inset:0;display:block;cursor:default} | |
| #alerts{position:absolute;top:10px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column; | |
| gap:6px;align-items:center;pointer-events:none;z-index:4} | |
| .alert{background:rgba(20,24,34,.9);border:1px solid #4a5573;border-left:3px solid var(--red); | |
| padding:6px 14px;border-radius:4px;font-size:13px;animation:fade 3.4s forwards} | |
| @keyframes fade{0%{opacity:0;transform:translateY(-6px)}8%{opacity:1;transform:none}75%{opacity:1}100%{opacity:0}} | |
| #banner{position:absolute;top:34%;left:50%;transform:translate(-50%,-50%);text-align:center; | |
| pointer-events:none;opacity:0;transition:opacity .8s;z-index:6} | |
| #banner.show{opacity:1} | |
| #banner h1{font-size:44px;letter-spacing:6px;color:#ffe6a0;text-shadow:0 0 26px rgba(255,200,80,.55),0 3px 0 #4a3a10} | |
| #banner p{color:#cfd8ee;margin-top:8px;letter-spacing:1px} | |
| /* ---------- bottom hud ---------- */ | |
| #hud{flex:0 0 176px;height:176px;display:flex;gap:8px;padding:8px; | |
| background:linear-gradient(#232a3b,#141822);border-top:2px solid #0a0c11; | |
| box-shadow:0 -3px 10px rgba(0,0,0,.6);z-index:5} | |
| .frame{background:var(--panel);border:1px solid #414b68;border-radius:6px; | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 2px 6px rgba(0,0,0,.4)} | |
| #minimapWrap{flex:0 0 160px;width:160px;height:160px;position:relative;padding:4px} | |
| #minimap{width:100%;height:100%;display:block;border-radius:3px;background:#000;cursor:crosshair} | |
| #infoPanel{flex:1 1 auto;padding:8px 10px;overflow:hidden;position:relative;min-width:0} | |
| #cardPanel{flex:0 0 268px;width:268px;padding:6px;display:grid;grid-template-columns:repeat(4,1fr); | |
| grid-template-rows:repeat(3,1fr);gap:5px} | |
| .cmd{position:relative;background:linear-gradient(#39415c,#252b3d);border:1px solid #525d80;border-radius:5px; | |
| cursor:pointer;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0} | |
| .cmd:hover{border-color:#8fa4d8;background:linear-gradient(#48527a,#2c3448)} | |
| .cmd:active{transform:translateY(1px)} | |
| .cmd.disabled{filter:grayscale(.85) brightness(.55);cursor:not-allowed} | |
| .cmd .hk{position:absolute;top:1px;left:3px;font-size:10px;color:#ffd166;font-weight:700;text-shadow:0 1px 2px #000} | |
| .cmd canvas{width:100%;height:100%;display:block} | |
| .cmd.empty{background:#191d28;border:1px solid #2a3145;cursor:default;pointer-events:none;box-shadow:none} | |
| #tip{position:absolute;bottom:184px;right:8px;width:262px;background:rgba(16,19,27,.97); | |
| border:1px solid #5a668c;border-radius:6px;padding:9px 11px;font-size:12.5px;display:none;z-index:20; | |
| box-shadow:0 6px 18px rgba(0,0,0,.6)} | |
| #tip .tn{font-weight:700;color:#fff;margin-bottom:3px;font-size:13.5px} | |
| #tip .tc{color:var(--dim);margin-bottom:4px} | |
| #tip .td{color:#b9c4dc;line-height:1.35} | |
| #tip .cost{display:inline-flex;gap:4px;align-items:center;margin-right:9px} | |
| .selRow{display:flex;flex-wrap:wrap;gap:4px;align-content:flex-start;height:100%;overflow:hidden} | |
| .portrait{width:44px;height:44px;position:relative;background:#2a3145;border:1px solid #4c577a;border-radius:4px; | |
| cursor:pointer;overflow:hidden} | |
| .portrait canvas{width:100%;height:100%} | |
| .portrait .hpb{position:absolute;left:2px;right:2px;bottom:2px;height:3px;background:#111;border-radius:2px} | |
| .portrait .hpf{height:100%;background:var(--green);border-radius:2px} | |
| .portrait.hurt .hpf{background:#e8c04a} | |
| .portrait.crit .hpf{background:var(--red)} | |
| .single{display:flex;gap:12px;height:100%} | |
| .single .big{flex:0 0 88px;width:88px;height:88px;border:1px solid #4c577a;border-radius:6px; | |
| overflow:hidden;background:radial-gradient(circle at 50% 35%,#3c465f,#1a1f2b)} | |
| .single .big canvas{width:100%;height:100%} | |
| .single .meta{flex:1 1 auto;min-width:0} | |
| .single h3{font-size:16px;margin-bottom:2px;color:#fff;letter-spacing:.5px} | |
| .single .sub{font-size:12px;color:var(--dim);margin-bottom:6px} | |
| .stat{font-size:12px;color:#b9c4dc;margin-bottom:2px} | |
| .stat b{color:#fff;font-weight:600} | |
| .bar{height:9px;background:#0d1018;border:1px solid #3d465f;border-radius:5px;overflow:hidden;margin:3px 0 6px;max-width:230px} | |
| .bar > div{height:100%;background:linear-gradient(#8fe08f,#3f9f3f)} | |
| .bar.prog > div{background:linear-gradient(#7fd0ff,#2a7fc0)} | |
| .queue{display:flex;gap:4px;margin-top:6px} | |
| .qslot{width:30px;height:30px;background:#232a3b;border:1px solid #48527a;border-radius:3px;overflow:hidden; | |
| position:relative;cursor:pointer} | |
| .qslot canvas{width:100%;height:100%} | |
| .qslot:hover{border-color:#ff8f8f} | |
| .hint{color:var(--dim);font-size:12px;line-height:1.5} | |
| .hint b{color:#cfd8ee} | |
| kbd{background:#2c3245;border:1px solid #4c577a;border-bottom-width:2px;border-radius:3px;padding:0 4px; | |
| font-size:11px;font-family:inherit;color:#e6ecfa} | |
| /* ---------- help overlay ---------- */ | |
| @media (max-height:780px){ | |
| #hud{flex:0 0 146px;height:146px} | |
| #minimapWrap{flex:0 0 134px;width:134px;height:134px} | |
| #cardPanel{flex:0 0 232px;width:232px} | |
| #tip{bottom:154px} | |
| } | |
| #help{position:absolute;inset:0;background:rgba(6,8,13,.88);z-index:30;display:none; | |
| align-items:center;justify-content:center} | |
| #help.show{display:flex} | |
| #helpBox{width:min(760px,92%);max-height:86%;overflow:auto;background:var(--panel);border:1px solid #4c577a; | |
| border-radius:10px;padding:22px 26px;box-shadow:0 20px 60px rgba(0,0,0,.7)} | |
| #helpBox h2{color:#ffe6a0;letter-spacing:3px;margin-bottom:4px;font-size:22px} | |
| #helpBox h4{margin:16px 0 6px;color:var(--accent);font-size:13px;letter-spacing:2px;text-transform:uppercase} | |
| #helpBox p,#helpBox li{font-size:13.5px;line-height:1.6;color:#c3cde2} | |
| #helpBox ul{list-style:none} | |
| #helpBox li{display:flex;gap:10px;margin-bottom:3px} | |
| #helpBox li span:first-child{flex:0 0 168px;color:#9fb0d0} | |
| .cols{display:grid;grid-template-columns:1fr 1fr;gap:0 26px} | |
| #closeHelp{margin-top:18px;background:#39456a;border:1px solid #5d6b96;color:#fff;padding:7px 18px; | |
| border-radius:5px;cursor:pointer;font-size:13px} | |
| #closeHelp:hover{background:#4a5a8c} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <div id="topbar"> | |
| <div class="res"><div class="gem g"></div><span id="rGold">0</span></div> | |
| <div class="res"><div class="gem w"></div><span id="rWood">0</span></div> | |
| <div class="res"><div class="gem s"></div><span id="rSupply">0/0</span></div> | |
| <div class="res"><div class="gem e"></div><span id="rExplored">0%</span></div> | |
| <div class="res" style="color:#8b95ad;font-weight:400"><span id="rClock">0:00</span></div> | |
| <div id="title">IRONHOLD</div> | |
| <button class="btn-min" id="newBtn">New Map</button> | |
| <button class="btn-min" id="helpBtn">Help (H)</button> | |
| </div> | |
| <div id="viewport"> | |
| <canvas id="game"></canvas> | |
| <div id="alerts"></div> | |
| <div id="banner"><h1>THE MAP IS YOURS</h1><p>Every corner of the land has been charted.</p></div> | |
| <div id="banner2" style="position:absolute;top:34%;left:50%;transform:translate(-50%,-50%);text-align:center; | |
| pointer-events:none;opacity:0;transition:opacity .8s;z-index:6"> | |
| <h1 style="font-size:42px;letter-spacing:6px;color:#ff9b8f;text-shadow:0 0 26px rgba(255,80,60,.5),0 3px 0 #3a1010">YOUR HOLDING HAS FALLEN</h1> | |
| <p style="color:#cfd8ee;margin-top:8px;letter-spacing:1px">Nothing remains. Start a new map to try again.</p> | |
| </div> | |
| <div id="help"> | |
| <div id="helpBox"> | |
| <h2>IRONHOLD</h2> | |
| <p style="color:#8b95ad">Build a base, harvest gold and lumber, raise an army — and chart every last tile of the map.</p> | |
| <div class="cols"> | |
| <div> | |
| <h4>Camera</h4> | |
| <ul> | |
| <li><span>Arrow keys</span><span>Scroll</span></li> | |
| <li><span>Mouse to screen edge</span><span>Scroll</span></li> | |
| <li><span>Middle-drag</span><span>Pan</span></li> | |
| <li><span>Mouse wheel</span><span>Zoom</span></li> | |
| <li><span>Click minimap</span><span>Jump there</span></li> | |
| <li><span><kbd>Space</kbd></span><span>Centre on base</span></li> | |
| <li><span><kbd>N</kbd></span><span>Find uncharted land</span></li> | |
| </ul> | |
| <h4>Selection</h4> | |
| <ul> | |
| <li><span>Left click / drag</span><span>Select</span></li> | |
| <li><span><kbd>Shift</kbd>+click</span><span>Add to selection</span></li> | |
| <li><span>Double click</span><span>All of that type</span></li> | |
| <li><span><kbd>Ctrl</kbd>+<kbd>1..9</kbd></span><span>Make control group</span></li> | |
| <li><span><kbd>1..9</kbd></span><span>Recall group</span></li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h4>Orders</h4> | |
| <ul> | |
| <li><span>Right click</span><span>Move / harvest / attack</span></li> | |
| <li><span><kbd>B</kbd></span><span>Build menu (workers)</span></li> | |
| <li><span><kbd>A</kbd> + click</span><span>Attack-move</span></li> | |
| <li><span><kbd>S</kbd></span><span>Stop</span></li> | |
| <li><span><kbd>Esc</kbd></span><span>Cancel / deselect</span></li> | |
| </ul> | |
| <h4>Economy</h4> | |
| <ul> | |
| <li><span>Workers</span><span>Mine gold, chop trees, build</span></li> | |
| <li><span>Farms</span><span>Raise the supply cap</span></li> | |
| <li><span>Lumber Mill / Hall</span><span>Resource drop-off points</span></li> | |
| <li><span>Scouts</span><span>Fast, huge sight — explore!</span></li> | |
| </ul> | |
| </div> | |
| </div> | |
| <p style="margin-top:14px;color:#8b95ad;font-size:12.5px">Wild beasts roam the wilderness. They will not hunt you, but they defend their ground — and drop gold when slain.</p> | |
| <button id="closeHelp">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="hud"> | |
| <div id="minimapWrap" class="frame"><canvas id="minimap"></canvas></div> | |
| <div id="infoPanel" class="frame"></div> | |
| <div id="cardPanel" class="frame"></div> | |
| </div> | |
| <div id="tip"></div> | |
| </div> | |
| <script> | |
| "use strict"; | |
| /* ============================================================================ | |
| IRONHOLD — a small realtime strategy game | |
| ========================================================================== */ | |
| /* ---------------------------------------------------------------- utilities */ | |
| const TAU = Math.PI * 2; | |
| const clamp = (v, a, b) => v < a ? a : v > b ? b : v; | |
| const lerp = (a, b, t) => a + (b - a) * t; | |
| const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }; | |
| const dist = (ax, ay, bx, by) => Math.sqrt(dist2(ax, ay, bx, by)); | |
| function mulberry32(a) { | |
| return function () { | |
| a |= 0; a = a + 0x6D2B79F5 | 0; | |
| let t = Math.imul(a ^ a >>> 15, 1 | a); | |
| t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; | |
| return ((t ^ t >>> 14) >>> 0) / 4294967296; | |
| }; | |
| } | |
| /* ------------------------------------------------------------------ config */ | |
| const TS = 32; // tile size in world pixels | |
| const MAP_W = 72, MAP_H = 72; // map size in tiles | |
| const MAP_PX_W = MAP_W * TS, MAP_PX_H = MAP_H * TS; | |
| const T_DEEP = 0, T_WATER = 1, T_SAND = 2, T_GRASS = 3, T_DIRT = 4, T_ROCK = 5; | |
| const PASSABLE = [false, false, true, true, true, false]; | |
| const UNIT_DEFS = { | |
| worker: { | |
| name: "Peasant", hp: 45, speed: 74, sight: 6, dmg: 4, range: 20, cd: 1.1, supply: 1, | |
| gold: 50, wood: 0, time: 8, carry: 10, build: 1, from: "hall", hotkey: "Q", | |
| desc: "Harvests gold and lumber, and raises every building in your domain." | |
| }, | |
| soldier: { | |
| name: "Footman", hp: 110, speed: 66, sight: 7, dmg: 11, range: 22, cd: 1.0, supply: 2, | |
| gold: 80, wood: 10, time: 11, armor: 2, from: "barracks", hotkey: "W", | |
| desc: "Sturdy melee infantry. The backbone of any warband." | |
| }, | |
| archer: { | |
| name: "Archer", hp: 70, speed: 70, sight: 9, dmg: 9, range: 145, cd: 1.25, supply: 2, | |
| gold: 70, wood: 45, time: 12, ranged: true, from: "barracks", req: "mill", hotkey: "E", | |
| desc: "Strikes from afar. Requires a Lumber Mill for seasoned bowstaves." | |
| }, | |
| scout: { | |
| name: "Outrider", hp: 65, speed: 130, sight: 13, dmg: 6, range: 20, cd: 1.2, supply: 1, | |
| gold: 60, wood: 20, time: 8, from: "barracks", hotkey: "R", | |
| desc: "Swift rider with exceptional vision. The best tool for charting the map." | |
| } | |
| }; | |
| const BUILD_DEFS = { | |
| hall: { | |
| name: "Town Hall", w: 3, h: 3, hp: 900, sight: 10, gold: 350, wood: 120, time: 40, | |
| supply: 10, dropoff: true, trains: ["worker"], hotkey: "T", | |
| desc: "Trains peasants, accepts harvested resources and provides 10 supply." | |
| }, | |
| farm: { | |
| name: "Farm", w: 2, h: 2, hp: 340, sight: 5, gold: 60, wood: 20, time: 14, | |
| supply: 8, hotkey: "F", | |
| desc: "Feeds your people. Provides 8 supply." | |
| }, | |
| barracks: { | |
| name: "Barracks", w: 3, h: 3, hp: 700, sight: 7, gold: 150, wood: 60, time: 24, | |
| trains: ["soldier", "archer", "scout"], hotkey: "B", | |
| desc: "Trains footmen, archers and outriders." | |
| }, | |
| mill: { | |
| name: "Lumber Mill", w: 2, h: 2, hp: 480, sight: 6, gold: 120, wood: 30, time: 18, | |
| dropoff: true, hotkey: "L", | |
| desc: "A drop-off point for gold and lumber. Unlocks the Archer." | |
| }, | |
| tower: { | |
| name: "Guard Tower", w: 2, h: 2, hp: 500, sight: 10, gold: 90, wood: 70, time: 16, | |
| dmg: 16, range: 190, cd: 1.4, hotkey: "G", | |
| desc: "Fires upon hostile creatures that stray too close." | |
| } | |
| }; | |
| const GOLD_PER_MINE = 1800; | |
| const WOOD_PER_TREE = 120; | |
| /* ------------------------------------------------------------------- state */ | |
| const G = { | |
| gold: 550, wood: 300, supplyUsed: 0, supplyCap: 0, | |
| time: 0, running: true, won: false, lost: false, | |
| units: [], buildings: [], resources: [], projectiles: [], effects: [], | |
| selection: [], groups: {}, nextId: 1, | |
| placing: null, // {type} while placing a building | |
| attackMove: false, // 'A' pressed, awaiting click | |
| buildMenu: false, | |
| exploredCount: 0, explorableTotal: 1 | |
| }; | |
| /* map arrays */ | |
| let terrain, blocked, resAt, explored, visible, explorable; | |
| /* camera */ | |
| const cam = { x: 0, y: 0, zoom: 1, minZoom: 0.55, maxZoom: 1.8 }; | |
| /* ---------------------------------------------------------------- map gen */ | |
| function makeNoise(rng, size) { | |
| const g = new Float32Array(size * size); | |
| for (let i = 0; i < g.length; i++) g[i] = rng(); | |
| const sm = t => t * t * (3 - 2 * t); | |
| return (x, y) => { | |
| const fx = x * (size - 1), fy = y * (size - 1); | |
| const x0 = Math.floor(fx), y0 = Math.floor(fy); | |
| const x1 = Math.min(x0 + 1, size - 1), y1 = Math.min(y0 + 1, size - 1); | |
| const tx = sm(fx - x0), ty = sm(fy - y0); | |
| const a = g[y0 * size + x0], b = g[y0 * size + x1]; | |
| const c = g[y1 * size + x0], d = g[y1 * size + x1]; | |
| return lerp(lerp(a, b, tx), lerp(c, d, tx), ty); | |
| }; | |
| } | |
| let rng; | |
| let startTile = { x: 34, y: 34 }; | |
| function generateMap(seed) { | |
| rng = mulberry32(seed); | |
| terrain = new Uint8Array(MAP_W * MAP_H); | |
| blocked = new Uint8Array(MAP_W * MAP_H); | |
| resAt = new Int32Array(MAP_W * MAP_H).fill(-1); | |
| explored = new Uint8Array(MAP_W * MAP_H); | |
| visible = new Uint8Array(MAP_W * MAP_H); | |
| explorable = new Uint8Array(MAP_W * MAP_H); | |
| const n1 = makeNoise(rng, 6), n2 = makeNoise(rng, 13), n3 = makeNoise(rng, 27); | |
| const m1 = makeNoise(rng, 8), m2 = makeNoise(rng, 19); | |
| const height = (x, y) => 0.56 * n1(x, y) + 0.30 * n2(x, y) + 0.14 * n3(x, y); | |
| const moist = (x, y) => 0.65 * m1(x, y) + 0.35 * m2(x, y); | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| const u = x / (MAP_W - 1), v = y / (MAP_H - 1); | |
| // push the borders down so the land is ringed by ocean | |
| const edge = Math.min(u, v, 1 - u, 1 - v); | |
| const falloff = clamp(edge / 0.11, 0, 1); | |
| let h = height(u, v) * (0.35 + 0.65 * falloff) + 0.06 * falloff; | |
| let t; | |
| if (h < 0.20) t = T_DEEP; | |
| else if (h < 0.27) t = T_WATER; | |
| else if (h < 0.30) t = T_SAND; | |
| else if (h > 0.675) t = T_ROCK; | |
| else t = (moist(u, v) < 0.40 && h > 0.42) ? T_DIRT : T_GRASS; | |
| terrain[y * MAP_W + x] = t; | |
| } | |
| } | |
| // sand only belongs on a shoreline; inland patches become dry earth | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| const i = y * MAP_W + x; | |
| if (terrain[i] !== T_SAND) continue; | |
| let coastal = false; | |
| for (let dy = -2; dy <= 2 && !coastal; dy++) { | |
| for (let dx = -2; dx <= 2; dx++) { | |
| const nx = x + dx, ny = y + dy; | |
| if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; | |
| const nt = terrain[ny * MAP_W + nx]; | |
| if (nt === T_WATER || nt === T_DEEP) { coastal = true; break; } | |
| } | |
| } | |
| if (!coastal) terrain[i] = T_DIRT; | |
| } | |
| } | |
| // ---- pick a start location: open ground near the centre | |
| let best = null, bestScore = -1; | |
| for (let i = 0; i < 900; i++) { | |
| const x = 8 + Math.floor(rng() * (MAP_W - 16)); | |
| const y = 8 + Math.floor(rng() * (MAP_H - 16)); | |
| let open = 0; | |
| for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) { | |
| const t = terrain[(y + dy) * MAP_W + (x + dx)]; | |
| if (t === T_GRASS || t === T_DIRT) open++; | |
| } | |
| const centre = 1 - dist(x, y, MAP_W / 2, MAP_H / 2) / (MAP_W * 0.6); | |
| const score = open / 81 + centre * 0.55; | |
| if (score > bestScore) { bestScore = score; best = { x, y }; } | |
| } | |
| startTile = best; | |
| // flatten the home valley | |
| for (let dy = -5; dy <= 5; dy++) for (let dx = -5; dx <= 5; dx++) { | |
| const x = startTile.x + dx, y = startTile.y + dy; | |
| if (x < 1 || y < 1 || x >= MAP_W - 1 || y >= MAP_H - 1) continue; | |
| if (dx * dx + dy * dy <= 30) terrain[y * MAP_W + x] = T_GRASS; | |
| } | |
| // ---- reachability & explorability pass ------------------------------ | |
| // Anything the player could never see is turned into deep ocean, so that | |
| // 100% exploration is always attainable. | |
| const reach = floodReach(startTile.x, startTile.y); | |
| const R = 6; // <= the smallest unit sight radius, so 100% is always reachable | |
| const offs = []; | |
| for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++) | |
| if (dx * dx + dy * dy <= R * R + 1) offs.push([dx, dy]); | |
| for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) { | |
| if (!reach[y * MAP_W + x]) continue; | |
| for (const [dx, dy] of offs) { | |
| const nx = x + dx, ny = y + dy; | |
| if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; | |
| explorable[ny * MAP_W + nx] = 1; | |
| } | |
| } | |
| for (let i = 0; i < terrain.length; i++) if (!explorable[i]) terrain[i] = T_DEEP; | |
| G.explorableTotal = explorable.reduce((a, b) => a + b, 0); | |
| // ---- forests -------------------------------------------------------- | |
| const forest = makeNoise(rng, 15), forest2 = makeNoise(rng, 31); | |
| for (let y = 1; y < MAP_H - 1; y++) { | |
| for (let x = 1; x < MAP_W - 1; x++) { | |
| const i = y * MAP_W + x; | |
| if (terrain[i] !== T_GRASS) continue; | |
| if (dist(x, y, startTile.x, startTile.y) < 5.5) continue; | |
| const f = forest(x / MAP_W, y / MAP_H) * 0.72 + forest2(x / MAP_W, y / MAP_H) * 0.28; | |
| if (f > 0.60 && rng() < 0.86) addResource("tree", x, y); | |
| } | |
| } | |
| // a guaranteed grove beside the start | |
| seedGrove(startTile.x + 6, startTile.y - 4, 4); | |
| seedGrove(startTile.x - 6, startTile.y + 5, 4); | |
| // ---- gold mines ------------------------------------------------------ | |
| const mines = []; | |
| const tryMine = (x, y, force) => { | |
| if (x < 2 || y < 2 || x >= MAP_W - 3 || y >= MAP_H - 3) return false; | |
| for (const m of mines) if (dist(x, y, m.x, m.y) < 11) return false; | |
| for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) { | |
| const i = (y + dy) * MAP_W + (x + dx); | |
| if (!PASSABLE[terrain[i]] && !force) return false; | |
| if (blocked[i]) return false; | |
| } | |
| // must have some open ground around it | |
| let open = 0; | |
| for (let dy = -2; dy <= 3; dy++) for (let dx = -2; dx <= 3; dx++) { | |
| const nx = x + dx, ny = y + dy; | |
| if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; | |
| if (PASSABLE[terrain[ny * MAP_W + nx]] && resAt[ny * MAP_W + nx] < 0) open++; | |
| } | |
| if (open < 14) return false; | |
| for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) { | |
| terrain[(y + dy) * MAP_W + (x + dx)] = T_DIRT; | |
| const ri = resAt[(y + dy) * MAP_W + (x + dx)]; | |
| if (ri >= 0) removeResourceIndex(ri); | |
| } | |
| addResource("gold", x, y, 2, 2); | |
| mines.push({ x, y }); | |
| return true; | |
| }; | |
| // home mine | |
| for (const [dx, dy] of [[6, 4], [-7, -3], [4, -7], [-4, 7], [8, -6], [-8, 6]]) { | |
| if (tryMine(startTile.x + dx, startTile.y + dy, true)) break; | |
| } | |
| let guard = 0; | |
| while (mines.length < 11 && guard++ < 4000) { | |
| tryMine(2 + Math.floor(rng() * (MAP_W - 5)), 2 + Math.floor(rng() * (MAP_H - 5)), false); | |
| } | |
| // ---- what actually counts towards 100% ------------------------------- | |
| // Forests and mines block the ground they sit on, so re-derive the goal | |
| // from tiles a unit can genuinely stand on. Anything else the player | |
| // happens to reveal is a bonus rather than a requirement. | |
| { | |
| const stand = new Uint8Array(MAP_W * MAP_H); | |
| for (let i = 0; i < stand.length; i++) if (reach[i] && !blocked[i]) stand[i] = 1; | |
| const countable = new Uint8Array(MAP_W * MAP_H); | |
| for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) { | |
| if (!stand[y * MAP_W + x]) continue; | |
| for (const [dx, dy] of offs) { | |
| const nx = x + dx, ny = y + dy; | |
| if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; | |
| countable[ny * MAP_W + nx] = 1; | |
| } | |
| } | |
| explorable = countable; | |
| G.explorableTotal = countable.reduce((a, b) => a + b, 0); | |
| } | |
| // ---- wildlife -------------------------------------------------------- | |
| let packs = 0, guard2 = 0; | |
| while (packs < 15 && guard2++ < 4000) { | |
| const x = 3 + Math.floor(rng() * (MAP_W - 6)), y = 3 + Math.floor(rng() * (MAP_H - 6)); | |
| const i = y * MAP_W + x; | |
| if (!PASSABLE[terrain[i]] || blocked[i]) continue; | |
| if (dist(x, y, startTile.x, startTile.y) < 13) continue; | |
| const n = 2 + Math.floor(rng() * 3); | |
| for (let k = 0; k < n; k++) { | |
| const u = spawnUnit("beast", x * TS + TS / 2 + (rng() - .5) * 60, y * TS + TS / 2 + (rng() - .5) * 60, "neutral"); | |
| u.home = { x: u.x, y: u.y }; | |
| } | |
| packs++; | |
| } | |
| } | |
| function seedGrove(cx, cy, r) { | |
| for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { | |
| const x = cx + dx, y = cy + dy; | |
| if (x < 1 || y < 1 || x >= MAP_W - 1 || y >= MAP_H - 1) continue; | |
| if (dx * dx + dy * dy > r * r) continue; | |
| const i = y * MAP_W + x; | |
| if (terrain[i] !== T_GRASS || blocked[i]) continue; | |
| if (dist(x, y, startTile.x, startTile.y) < 5.5) continue; | |
| if (rng() < 0.75) addResource("tree", x, y); | |
| } | |
| } | |
| function floodReach(sx, sy) { | |
| const seen = new Uint8Array(MAP_W * MAP_H); | |
| const q = [sy * MAP_W + sx]; seen[sy * MAP_W + sx] = 1; | |
| let head = 0; | |
| while (head < q.length) { | |
| const i = q[head++]; const x = i % MAP_W, y = (i / MAP_W) | 0; | |
| for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { | |
| const nx = x + dx, ny = y + dy; | |
| if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; | |
| const ni = ny * MAP_W + nx; | |
| if (seen[ni] || !PASSABLE[terrain[ni]]) continue; | |
| seen[ni] = 1; q.push(ni); | |
| } | |
| } | |
| return seen; | |
| } | |
| /* -------------------------------------------------------------- resources */ | |
| function addResource(type, tx, ty, w = 1, h = 1) { | |
| const r = { | |
| id: G.nextId++, type, tx, ty, w, h, | |
| amount: type === "gold" ? GOLD_PER_MINE : WOOD_PER_TREE, | |
| max: type === "gold" ? GOLD_PER_MINE : WOOD_PER_TREE, | |
| variant: Math.floor(rng() * 4), dead: false, | |
| // purely cosmetic jitter so forests don't line up on the tile grid | |
| jx: type === "tree" ? (rng() - .5) * 15 : 0, | |
| jy: type === "tree" ? (rng() - .5) * 11 : 0, | |
| x: (tx + w / 2) * TS, y: (ty + h / 2) * TS | |
| }; | |
| G.resources.push(r); | |
| const idx = G.resources.length - 1; | |
| for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) { | |
| const i = (ty + dy) * MAP_W + (tx + dx); | |
| resAt[i] = idx; blocked[i] = 1; | |
| } | |
| return r; | |
| } | |
| function removeResourceIndex(idx) { | |
| const r = G.resources[idx]; | |
| if (!r || r.dead) return; | |
| r.dead = true; | |
| for (let dy = 0; dy < r.h; dy++) for (let dx = 0; dx < r.w; dx++) { | |
| const i = (r.ty + dy) * MAP_W + (r.tx + dx); | |
| if (resAt[i] === idx) { resAt[i] = -1; blocked[i] = 0; } | |
| } | |
| } | |
| function resourceAtTile(tx, ty) { | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return null; | |
| const idx = resAt[ty * MAP_W + tx]; | |
| if (idx < 0) return null; | |
| const r = G.resources[idx]; | |
| return (r && !r.dead) ? r : null; | |
| } | |
| /* ------------------------------------------------------------- pathfinding */ | |
| const N = MAP_W * MAP_H; | |
| const pfG = new Float32Array(N), pfF = new Float32Array(N); | |
| const pfFrom = new Int32Array(N), pfMark = new Int32Array(N), pfClosed = new Int32Array(N); | |
| let pfStamp = 0; | |
| /* a node may be re-pushed once per incoming direction, so leave room for it */ | |
| const heapArr = new Int32Array(N * 8); let heapLen = 0; | |
| function hPush(id) { | |
| if (heapLen >= heapArr.length) return; | |
| let i = heapLen++; heapArr[i] = id; | |
| while (i > 0) { | |
| const p = (i - 1) >> 1; | |
| if (pfF[heapArr[p]] <= pfF[heapArr[i]]) break; | |
| const t = heapArr[p]; heapArr[p] = heapArr[i]; heapArr[i] = t; i = p; | |
| } | |
| } | |
| function hPop() { | |
| const top = heapArr[0]; heapArr[0] = heapArr[--heapLen]; | |
| let i = 0; | |
| for (;;) { | |
| const l = i * 2 + 1, r = l + 1; let s = i; | |
| if (l < heapLen && pfF[heapArr[l]] < pfF[heapArr[s]]) s = l; | |
| if (r < heapLen && pfF[heapArr[r]] < pfF[heapArr[s]]) s = r; | |
| if (s === i) break; | |
| const t = heapArr[s]; heapArr[s] = heapArr[i]; heapArr[i] = t; i = s; | |
| } | |
| return top; | |
| } | |
| const walkable = i => PASSABLE[terrain[i]] && !blocked[i]; | |
| function findPath(sx, sy, gx, gy, limit = 24000) { | |
| if (sx === gx && sy === gy) return []; | |
| let goal = gy * MAP_W + gx; | |
| if (!walkable(goal)) { | |
| const alt = nearestWalkable(gx, gy, 8); | |
| if (!alt) return null; | |
| goal = alt.y * MAP_W + alt.x; gx = alt.x; gy = alt.y; | |
| } | |
| const start = sy * MAP_W + sx; | |
| pfStamp++; heapLen = 0; | |
| pfG[start] = 0; pfF[start] = octile(sx, sy, gx, gy); | |
| pfMark[start] = pfStamp; pfFrom[start] = -1; | |
| hPush(start); | |
| let expanded = 0; | |
| let bestNode = start, bestH = pfF[start]; | |
| while (heapLen > 0) { | |
| const cur = hPop(); | |
| if (pfClosed[cur] === pfStamp) continue; | |
| pfClosed[cur] = pfStamp; | |
| if (cur === goal) return buildPath(cur); | |
| if (++expanded > limit) break; | |
| const cx = cur % MAP_W, cy = (cur / MAP_W) | 0; | |
| for (let d = 0; d < 8; d++) { | |
| const dx = DIRS[d][0], dy = DIRS[d][1]; | |
| const nx = cx + dx, ny = cy + dy; | |
| if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; | |
| const ni = ny * MAP_W + nx; | |
| if (!walkable(ni)) continue; | |
| if (dx && dy) { // no corner cutting | |
| if (!walkable(cy * MAP_W + nx) || !walkable(ny * MAP_W + cx)) continue; | |
| } | |
| const step = (dx && dy) ? 1.4142 : 1; | |
| const ng = pfG[cur] + step; | |
| if (pfMark[ni] === pfStamp && ng >= pfG[ni]) continue; | |
| pfMark[ni] = pfStamp; pfG[ni] = ng; pfFrom[ni] = cur; | |
| const h = octile(nx, ny, gx, gy); | |
| pfF[ni] = ng + h * 1.05; | |
| if (h < bestH) { bestH = h; bestNode = ni; } | |
| hPush(ni); | |
| } | |
| } | |
| if (bestNode !== start) return buildPath(bestNode); | |
| return null; | |
| } | |
| const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; | |
| function octile(ax, ay, bx, by) { | |
| const dx = Math.abs(ax - bx), dy = Math.abs(ay - by); | |
| return (dx + dy) + (1.4142 - 2) * Math.min(dx, dy); | |
| } | |
| function buildPath(end) { | |
| const out = []; | |
| let c = end; | |
| while (c !== -1) { out.push({ x: c % MAP_W, y: (c / MAP_W) | 0 }); c = pfFrom[c]; } | |
| out.reverse(); // out[0] is the tile the unit stands on | |
| return smoothPath(out); | |
| } | |
| /* string-pulling: keep only the waypoints we cannot see past */ | |
| function smoothPath(p) { | |
| if (p.length < 3) return p.slice(1); | |
| const out = []; | |
| let i = 0; | |
| while (i < p.length - 1) { | |
| let j = p.length - 1; | |
| for (; j > i + 1; j--) if (lineClear(p[i].x, p[i].y, p[j].x, p[j].y)) break; | |
| out.push(p[j]); i = j; | |
| } | |
| return out; | |
| } | |
| function lineClear(x0, y0, x1, y1) { | |
| const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); | |
| const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; | |
| let err = dx - dy, x = x0, y = y0; | |
| for (let guard = 0; guard < 400; guard++) { | |
| if (!walkable(y * MAP_W + x)) return false; | |
| if (x === x1 && y === y1) return true; | |
| const e2 = err * 2; | |
| if (e2 > -dy) { err -= dy; x += sx; } | |
| if (e2 < dx) { err += dx; y += sy; } | |
| if (!walkable(y * MAP_W + x)) return false; | |
| } | |
| return false; | |
| } | |
| function nearestWalkable(tx, ty, maxR) { | |
| for (let r = 0; r <= maxR; r++) { | |
| for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { | |
| if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; | |
| const x = tx + dx, y = ty + dy; | |
| if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue; | |
| if (walkable(y * MAP_W + x)) return { x, y }; | |
| } | |
| } | |
| return null; | |
| } | |
| /* free tile adjacent to a rectangle, closest to (fx,fy) in tiles */ | |
| function adjacentTile(tx, ty, w, h, fx, fy) { | |
| let best = null, bd = Infinity; | |
| for (let x = tx - 1; x <= tx + w; x++) { | |
| for (let y = ty - 1; y <= ty + h; y++) { | |
| const inside = x >= tx && x < tx + w && y >= ty && y < ty + h; | |
| if (inside) continue; | |
| if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue; | |
| if (!walkable(y * MAP_W + x)) continue; | |
| const d = dist2(x, y, fx, fy); | |
| if (d < bd) { bd = d; best = { x, y }; } | |
| } | |
| } | |
| return best; | |
| } | |
| /* ------------------------------------------------------------- entity ctor */ | |
| function spawnUnit(type, x, y, team = "player") { | |
| const def = type === "beast" ? BEAST_DEF : UNIT_DEFS[type]; | |
| const u = { | |
| id: G.nextId++, kind: "unit", type, team, def, | |
| x, y, hp: def.hp, maxHp: def.hp, facing: rng ? rng() * TAU : 0, | |
| path: null, pi: 0, tx: x, ty: y, | |
| state: "idle", order: null, target: null, resource: null, | |
| carrying: 0, carryType: null, harvestT: 0, cd: 0, anim: rng ? rng() * 10 : 0, | |
| vx: 0, vy: 0, selected: false, home: null, attackMove: false, repathCd: 0 | |
| }; | |
| G.units.push(u); | |
| return u; | |
| } | |
| const BEAST_DEF = { | |
| name: "Wild Beast", hp: 85, speed: 60, sight: 6, dmg: 8, range: 22, cd: 1.1, | |
| supply: 0, bounty: 25, desc: "A territorial creature of the wilds." | |
| }; | |
| function placeBuilding(type, tx, ty, complete = false) { | |
| const def = BUILD_DEFS[type]; | |
| const b = { | |
| id: G.nextId++, kind: "building", type, def, tx, ty, w: def.w, h: def.h, | |
| x: (tx + def.w / 2) * TS, y: (ty + def.h / 2) * TS, | |
| hp: complete ? def.hp : Math.round(def.hp * 0.12), maxHp: def.hp, | |
| done: complete, progress: complete ? 1 : 0, builders: 0, | |
| queue: [], trainT: 0, rally: null, selected: false, cd: 0, flash: 0, | |
| team: "player" | |
| }; | |
| G.buildings.push(b); | |
| for (let dy = 0; dy < b.h; dy++) for (let dx = 0; dx < b.w; dx++) | |
| blocked[(ty + dy) * MAP_W + (tx + dx)] = 1; | |
| recalcSupply(); | |
| return b; | |
| } | |
| function destroyBuilding(b) { | |
| b.dead = true; | |
| for (let dy = 0; dy < b.h; dy++) for (let dx = 0; dx < b.w; dx++) | |
| blocked[(b.ty + dy) * MAP_W + (b.tx + dx)] = 0; | |
| for (const u of G.units) if (u.target === b) { u.target = null; u.state = "idle"; } | |
| recalcSupply(); | |
| } | |
| function recalcSupply() { | |
| let cap = 0; | |
| for (const b of G.buildings) if (!b.dead && b.done && b.def.supply) cap += b.def.supply; | |
| G.supplyCap = Math.min(cap, 120); | |
| let used = 0; | |
| for (const u of G.units) if (u.team === "player") used += u.def.supply || 0; | |
| for (const b of G.buildings) if (!b.dead) for (const q of b.queue) used += UNIT_DEFS[q].supply; | |
| G.supplyUsed = used; | |
| } | |
| function canPlaceAt(type, tx, ty) { | |
| const def = BUILD_DEFS[type]; | |
| if (tx < 0 || ty < 0 || tx + def.w > MAP_W || ty + def.h > MAP_H) return false; | |
| for (let dy = 0; dy < def.h; dy++) for (let dx = 0; dx < def.w; dx++) { | |
| const i = (ty + dy) * MAP_W + (tx + dx); | |
| if (!PASSABLE[terrain[i]] || blocked[i] || !explored[i]) return false; | |
| } | |
| // don't wall yourself in on top of units? units can walk out, allow it. | |
| return true; | |
| } | |
| function hasBuilding(type) { | |
| return G.buildings.some(b => !b.dead && b.done && b.type === type); | |
| } | |
| /* ------------------------------------------------------------------- costs */ | |
| function canAfford(def) { return G.gold >= (def.gold || 0) && G.wood >= (def.wood || 0); } | |
| function pay(def) { G.gold -= def.gold || 0; G.wood -= def.wood || 0; } | |
| function refund(def) { G.gold += def.gold || 0; G.wood += def.wood || 0; } | |
| /* ------------------------------------------------------------------- fog */ | |
| const sightOffsets = {}; | |
| function offsetsFor(r) { | |
| if (sightOffsets[r]) return sightOffsets[r]; | |
| const a = []; | |
| const rr = r * r + r * 0.6; | |
| for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) | |
| if (dx * dx + dy * dy <= rr) a.push(dy * MAP_W + dx); | |
| sightOffsets[r] = a; | |
| return a; | |
| } | |
| let fogTimer = 0; | |
| function updateFog() { | |
| visible.fill(0); | |
| const stamp = (px, py, r) => { | |
| const tx = (px / TS) | 0, ty = (py / TS) | 0; | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return; | |
| const base = ty * MAP_W + tx; | |
| const offs = offsetsFor(r); | |
| for (let k = 0; k < offs.length; k++) { | |
| const i = base + offs[k]; | |
| if (i < 0 || i >= N) continue; | |
| // guard against horizontal wrap | |
| const ox = (i % MAP_W) - tx; | |
| if (ox > r || ox < -r) continue; | |
| visible[i] = 1; | |
| if (!explored[i]) { explored[i] = 1; if (explorable[i]) G.exploredCount++; } | |
| } | |
| }; | |
| for (const u of G.units) if (u.team === "player" && !u.dead) stamp(u.x, u.y, u.def.sight); | |
| for (const b of G.buildings) if (!b.dead) stamp(b.x, b.y, b.done ? b.def.sight : 4); | |
| if (!G.won && G.exploredCount >= G.explorableTotal) { | |
| G.won = true; | |
| const el = document.getElementById("banner"); | |
| el.style.opacity = 1; | |
| setTimeout(() => { el.style.opacity = 0; }, 7000); | |
| } | |
| if (!G.lost && !G.buildings.some(b => !b.dead) && !G.units.some(u => !u.dead && u.team === "player")) { | |
| G.lost = true; | |
| document.getElementById("banner2").style.opacity = 1; | |
| } | |
| } | |
| const tileVisible = (px, py) => { | |
| const tx = (px / TS) | 0, ty = (py / TS) | 0; | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false; | |
| return visible[ty * MAP_W + tx] === 1; | |
| }; | |
| const tileExplored = (px, py) => { | |
| const tx = (px / TS) | 0, ty = (py / TS) | 0; | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false; | |
| return explored[ty * MAP_W + tx] === 1; | |
| }; | |
| /* ======================================================================== */ | |
| /* SPRITES */ | |
| /* ======================================================================== */ | |
| function mkCanvas(w, h) { | |
| const c = document.createElement("canvas"); | |
| c.width = w; c.height = h; | |
| return c; | |
| } | |
| function shade(ctx, x, y, rx, ry, a = .3) { | |
| ctx.fillStyle = `rgba(0,0,0,${a})`; | |
| ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, TAU); ctx.fill(); | |
| } | |
| function roundRect(ctx, x, y, w, h, r) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + r, y); | |
| ctx.arcTo(x + w, y, x + w, y + h, r); | |
| ctx.arcTo(x + w, y + h, x, y + h, r); | |
| ctx.arcTo(x, y + h, x, y, r); | |
| ctx.arcTo(x, y, x + w, y, r); | |
| ctx.closePath(); | |
| } | |
| /* ---- unit sprites: drawn centred on (0,0), roughly 26px tall ------------ */ | |
| const TEAM = { body: "#3f7fd6", light: "#79b3f5", dark: "#22497f" }; | |
| const NEUTRAL = { body: "#8a5a3a", light: "#b78455", dark: "#4d2f1c" }; | |
| function drawUnitShape(ctx, type, t, facing, team) { | |
| const col = team === "player" ? TEAM : NEUTRAL; | |
| const bob = Math.sin(t * 9) * 0.9; | |
| const swing = Math.sin(t * 9) * 3; | |
| const fx = Math.cos(facing), fy = Math.sin(facing); | |
| shade(ctx, 0, 8, 8, 3.5, .32); | |
| ctx.save(); | |
| ctx.translate(0, bob); | |
| if (type === "beast") { | |
| // four-legged creature | |
| ctx.fillStyle = col.dark; | |
| ctx.beginPath(); ctx.ellipse(-2, 4, 3, 2.4, 0, 0, TAU); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(5, 4, 3, 2.4, 0, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = col.body; | |
| ctx.beginPath(); ctx.ellipse(0, 0, 9, 6, 0, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = col.light; | |
| ctx.beginPath(); ctx.ellipse(-1, -1.5, 7, 3.6, 0, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = col.body; | |
| ctx.beginPath(); ctx.ellipse(fx * 8, fy * 6 - 1, 4.6, 4, 0, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = "#ffcf6a"; | |
| ctx.beginPath(); ctx.arc(fx * 9.5 - fy * 1.8, fy * 7 + fx * 1.8 - 1.5, 1, 0, TAU); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(fx * 9.5 + fy * 1.8, fy * 7 - fx * 1.8 - 1.5, 1, 0, TAU); ctx.fill(); | |
| // tail | |
| ctx.strokeStyle = col.dark; ctx.lineWidth = 2; ctx.lineCap = "round"; | |
| ctx.beginPath(); ctx.moveTo(-fx * 8, -fy * 6); | |
| ctx.lineTo(-fx * 14 + swing * .4, -fy * 10 + swing * .4); ctx.stroke(); | |
| ctx.restore(); | |
| return; | |
| } | |
| // legs | |
| ctx.strokeStyle = "#33302c"; ctx.lineWidth = 2.6; ctx.lineCap = "round"; | |
| ctx.beginPath(); ctx.moveTo(-2.4, 3); ctx.lineTo(-2.4 + swing * .35, 8); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(2.4, 3); ctx.lineTo(2.4 - swing * .35, 8); ctx.stroke(); | |
| if (type === "scout") { | |
| // horse under the rider | |
| ctx.fillStyle = "#6b5138"; | |
| ctx.beginPath(); ctx.ellipse(0, 3, 9.5, 5, 0, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = "#7d603f"; | |
| ctx.beginPath(); ctx.ellipse(fx * 7, fy * 5 + 1, 4, 3.4, 0, 0, TAU); ctx.fill(); | |
| } | |
| // torso | |
| const ty = type === "scout" ? -4 : 0; | |
| ctx.fillStyle = col.body; | |
| roundRect(ctx, -4.6, -5 + ty, 9.2, 10, 3.4); ctx.fill(); | |
| ctx.fillStyle = col.light; | |
| roundRect(ctx, -4.6, -5 + ty, 9.2, 4.4, 3); ctx.fill(); | |
| ctx.strokeStyle = "rgba(0,0,0,.45)"; ctx.lineWidth = 1; | |
| roundRect(ctx, -4.6, -5 + ty, 9.2, 10, 3.4); ctx.stroke(); | |
| // head | |
| ctx.fillStyle = "#e8c39a"; | |
| ctx.beginPath(); ctx.arc(0, -8.5 + ty, 3.6, 0, TAU); ctx.fill(); | |
| ctx.strokeStyle = "rgba(0,0,0,.35)"; ctx.stroke(); | |
| if (type === "worker") { | |
| ctx.fillStyle = "#b3721f"; // straw hat | |
| ctx.beginPath(); ctx.ellipse(0, -10.5 + ty, 5.6, 2.4, 0, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = "#d18f2e"; | |
| ctx.beginPath(); ctx.arc(0, -11.4 + ty, 2.8, Math.PI, TAU); ctx.fill(); | |
| // pickaxe | |
| ctx.save(); ctx.translate(fx * 5.5, fy * 4 - 3 + ty); ctx.rotate(facing + swing * .05); | |
| ctx.strokeStyle = "#6b4a2a"; ctx.lineWidth = 1.8; | |
| ctx.beginPath(); ctx.moveTo(-3, 3); ctx.lineTo(3, -4); ctx.stroke(); | |
| ctx.strokeStyle = "#c9d3e3"; ctx.lineWidth = 2.2; | |
| ctx.beginPath(); ctx.arc(3.2, -4.6, 2.6, -0.4, 2.4); ctx.stroke(); | |
| ctx.restore(); | |
| } else if (type === "soldier") { | |
| ctx.fillStyle = "#b9c4d8"; // helm | |
| ctx.beginPath(); ctx.arc(0, -9.2 + ty, 3.9, Math.PI * 1.05, TAU * 1.02); ctx.fill(); | |
| ctx.fillStyle = "#8f9cb5"; | |
| ctx.fillRect(-4, -9.4 + ty, 8, 1.6); | |
| // sword | |
| ctx.save(); ctx.translate(fx * 6, fy * 4 - 2 + ty); ctx.rotate(facing - 0.6 + swing * .04); | |
| ctx.fillStyle = "#dce4f2"; ctx.fillRect(-0.9, -9, 1.8, 10); | |
| ctx.fillStyle = "#8b6a3a"; ctx.fillRect(-2.6, 0.4, 5.2, 1.6); | |
| ctx.restore(); | |
| // shield | |
| ctx.save(); ctx.translate(-fx * 5.5, -fy * 4 + ty); | |
| ctx.fillStyle = col.dark; roundRect(ctx, -3, -4, 6, 8, 2); ctx.fill(); | |
| ctx.fillStyle = "#e4c56a"; ctx.fillRect(-0.8, -3, 1.6, 6); | |
| ctx.restore(); | |
| } else if (type === "archer") { | |
| ctx.fillStyle = "#4a7c3f"; // hood | |
| ctx.beginPath(); ctx.arc(0, -9 + ty, 4, Math.PI, TAU); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(-4, -9 + ty); ctx.lineTo(-6.5, -4.5 + ty); ctx.lineTo(-1, -8 + ty); | |
| ctx.closePath(); ctx.fill(); | |
| // bow | |
| ctx.save(); ctx.translate(fx * 6, fy * 5 - 2 + ty); ctx.rotate(facing); | |
| ctx.strokeStyle = "#8a5a2c"; ctx.lineWidth = 1.6; | |
| ctx.beginPath(); ctx.arc(0, 0, 5.4, -1.25, 1.25); ctx.stroke(); | |
| ctx.strokeStyle = "rgba(240,240,240,.75)"; ctx.lineWidth = .7; | |
| ctx.beginPath(); ctx.moveTo(1.7, -5.1); ctx.lineTo(1.7, 5.1); ctx.stroke(); | |
| ctx.restore(); | |
| } else if (type === "scout") { | |
| ctx.fillStyle = "#c9563f"; // cape | |
| ctx.beginPath(); | |
| ctx.moveTo(-fx * 4, -fy * 3 - 4 + ty); | |
| ctx.lineTo(-fx * 11 - fy * 4, -fy * 8 + fx * 4 + 2 + ty); | |
| ctx.lineTo(-fx * 11 + fy * 4, -fy * 8 - fx * 4 + 2 + ty); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = "#3d4a63"; | |
| ctx.beginPath(); ctx.ellipse(0, -10.6 + ty, 5, 2, 0, 0, TAU); ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| /* ---- baked unit sprites ------------------------------------------------- | |
| Vector-drawing every unit every frame is the single most expensive thing on | |
| screen, so each (type, facing, animation frame) is rendered once and reused. */ | |
| const SPR = 48, SPR_OX = 24, SPR_OY = 30, DIR_STEPS = 16, ANIM_FRAMES = 4; | |
| const unitSpriteCache = new Map(); | |
| function unitSprite(type, team, d, f) { | |
| const key = type + "|" + team + "|" + d + "|" + f; | |
| let c = unitSpriteCache.get(key); | |
| if (c) return c; | |
| c = mkCanvas(SPR, SPR); | |
| const g = c.getContext("2d"); | |
| g.translate(SPR_OX, SPR_OY); | |
| drawUnitShape(g, type, (f / ANIM_FRAMES) * (TAU / 9), d / DIR_STEPS * TAU, team); | |
| unitSpriteCache.set(key, c); | |
| return c; | |
| } | |
| function drawUnitCached(ctx, u) { | |
| const d = ((Math.round(u.facing / TAU * DIR_STEPS) % DIR_STEPS) + DIR_STEPS) % DIR_STEPS; | |
| const f = Math.floor(u.anim * 9 / TAU * ANIM_FRAMES) % ANIM_FRAMES; | |
| ctx.drawImage(unitSprite(u.type, u.team, d, f), u.x - SPR_OX, u.y - SPR_OY); | |
| } | |
| /* ---- resource sprites -------------------------------------------------- */ | |
| const treeSprites = [], goldSprite = mkCanvas(TS * 2 + 16, TS * 2 + 20); | |
| function buildResourceSprites() { | |
| const r2 = mulberry32(9911); | |
| for (let v = 0; v < 4; v++) { | |
| const c = mkCanvas(TS + 16, TS + 22), g = c.getContext("2d"); | |
| g.translate((TS + 16) / 2, TS + 12); | |
| shade(g, 0, 0, 11, 5, .3); | |
| const trunk = "#5a3f24"; | |
| g.fillStyle = trunk; | |
| g.fillRect(-2.4, -12, 4.8, 12); | |
| g.fillStyle = "#4a3320"; g.fillRect(-2.4, -12, 1.6, 12); | |
| const layers = [ | |
| { y: -14, r: 12, c: "#2f5c2c" }, | |
| { y: -19, r: 10, c: "#3a7136" }, | |
| { y: -24, r: 7.5, c: "#4b8a41" } | |
| ]; | |
| for (const L of layers) { | |
| g.fillStyle = L.c; | |
| g.beginPath(); | |
| const pts = 9; | |
| for (let i = 0; i <= pts; i++) { | |
| const a = (i / pts) * TAU; | |
| const rr = L.r * (0.82 + 0.28 * r2()); | |
| const px = Math.cos(a) * rr, py = L.y + Math.sin(a) * rr * 0.62; | |
| i ? g.lineTo(px, py) : g.moveTo(px, py); | |
| } | |
| g.closePath(); g.fill(); | |
| } | |
| g.fillStyle = "rgba(180,235,150,.30)"; | |
| g.beginPath(); g.ellipse(-3, -25, 4.5, 3, -0.4, 0, TAU); g.fill(); | |
| treeSprites.push(c); | |
| } | |
| // gold mine (2x2) | |
| const g = goldSprite.getContext("2d"); | |
| g.translate(goldSprite.width / 2, goldSprite.height - 14); | |
| shade(g, 0, 2, 28, 11, .32); | |
| g.fillStyle = "#6a6f7a"; | |
| g.beginPath(); | |
| g.moveTo(-30, 4); g.lineTo(-22, -18); g.lineTo(-6, -26); g.lineTo(12, -24); | |
| g.lineTo(28, -12); g.lineTo(30, 4); g.closePath(); g.fill(); | |
| g.fillStyle = "#7d838f"; | |
| g.beginPath(); | |
| g.moveTo(-22, -4); g.lineTo(-15, -20); g.lineTo(-2, -25); g.lineTo(6, -18); | |
| g.lineTo(2, -2); g.closePath(); g.fill(); | |
| g.fillStyle = "#3a3f49"; | |
| g.beginPath(); g.ellipse(4, -4, 10, 8, 0, 0, TAU); g.fill(); | |
| const crystals = [[-16, -6, 7], [-8, -12, 9], [16, -8, 8], [22, -2, 6], [8, -18, 6]]; | |
| for (const [cx, cy, s] of crystals) { | |
| g.fillStyle = "#f5c542"; | |
| g.beginPath(); g.moveTo(cx, cy - s); g.lineTo(cx + s * .55, cy); g.lineTo(cx, cy + s * .45); | |
| g.lineTo(cx - s * .55, cy); g.closePath(); g.fill(); | |
| g.fillStyle = "#ffe9a0"; | |
| g.beginPath(); g.moveTo(cx, cy - s); g.lineTo(cx + s * .55, cy); g.lineTo(cx, cy); g.closePath(); g.fill(); | |
| g.strokeStyle = "rgba(90,60,0,.5)"; g.lineWidth = 1; g.stroke(); | |
| } | |
| } | |
| /* ---- building drawing -------------------------------------------------- | |
| Everything is drawn in a fake 3/4 view: a ground footprint, a body that | |
| rises above it, and a roof that overhangs. Local origin is the centre of | |
| the footprint. */ | |
| function crenellations(ctx, x, y, w, hgt, n, fill) { | |
| ctx.fillStyle = fill; | |
| const bw = w / (n * 2 - 1); | |
| for (let i = 0; i < n; i++) ctx.fillRect(x + i * bw * 2, y, bw, hgt); | |
| } | |
| function stoneCourses(ctx, x, y, w, hgt, rows) { | |
| ctx.save(); | |
| ctx.beginPath(); ctx.rect(x, y, w, hgt); ctx.clip(); | |
| ctx.strokeStyle = "rgba(56,58,68,.30)"; ctx.lineWidth = 1; | |
| for (let r = 1; r < rows; r++) { | |
| const yy = Math.round(y + hgt * r / rows) + .5; | |
| ctx.beginPath(); ctx.moveTo(x, yy); ctx.lineTo(x + w, yy); ctx.stroke(); | |
| const off = (r % 2) ? 0 : w / 8; | |
| for (let c = 0; c <= 4; c++) { | |
| const xx = Math.round(x + off + c * w / 4) + .5; | |
| if (xx <= x || xx >= x + w) continue; | |
| ctx.beginPath(); ctx.moveTo(xx, yy); ctx.lineTo(xx, yy - hgt / rows); ctx.stroke(); | |
| } | |
| } | |
| ctx.restore(); | |
| } | |
| function gableRoof(ctx, cx, apexY, left, right, eaveY, dark, light) { | |
| ctx.fillStyle = dark; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, apexY); ctx.lineTo(right, eaveY); ctx.lineTo(left, eaveY); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = light; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, apexY); ctx.lineTo(cx, eaveY); ctx.lineTo(left, eaveY); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = "rgba(0,0,0,.28)"; ctx.lineWidth = 1.2; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, apexY); ctx.lineTo(right, eaveY); ctx.lineTo(left, eaveY); | |
| ctx.closePath(); ctx.stroke(); | |
| } | |
| function drawBuildingShape(ctx, type, w, h, progress, t) { | |
| const W = w * TS, H = h * TS; | |
| const px = -W / 2, py = -H / 2; | |
| const groundY = py + H * .96; | |
| // ground shadow | |
| ctx.fillStyle = "rgba(0,0,0,.32)"; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, groundY - H * .10, W * .46, H * .20, 0, 0, TAU); ctx.fill(); | |
| if (progress < 1) { | |
| // foundation + scaffolding | |
| ctx.fillStyle = "#5c5344"; | |
| roundRect(ctx, px + 4, py + H * .34, W - 8, H * .58, 4); ctx.fill(); | |
| ctx.fillStyle = "rgba(120,190,255,.16)"; | |
| const fh = H * .58 * progress; | |
| ctx.fillRect(px + 4, py + H * .92 - fh, W - 8, fh); | |
| ctx.strokeStyle = "#93794b"; ctx.lineWidth = 2.5; ctx.lineCap = "round"; | |
| for (let i = 0; i <= w * 2; i++) { | |
| const x = px + 5 + (W - 10) * i / (w * 2); | |
| ctx.beginPath(); ctx.moveTo(x, py + H * .28); ctx.lineTo(x, py + H * .94); ctx.stroke(); | |
| } | |
| for (let i = 0; i < 2; i++) { | |
| const y = py + H * (.44 + i * .28); | |
| ctx.beginPath(); ctx.moveTo(px + 3, y); ctx.lineTo(px + W - 3, y); ctx.stroke(); | |
| } | |
| ctx.strokeStyle = "rgba(255,255,255,.18)"; ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.moveTo(px + 5, py + H * .94); ctx.lineTo(px + W - 5, py + H * .30); ctx.stroke(); | |
| return; | |
| } | |
| if (type === "hall") { | |
| const bodyTop = py + H * .30, bodyBot = py + H * .92; | |
| // corner turrets (back pair drawn first) | |
| for (const sx of [-1, 1]) { | |
| const cx = sx * (W * .40); | |
| ctx.fillStyle = "#7e808b"; | |
| roundRect(ctx, cx - 9, bodyTop - H * .10, 18, bodyBot - bodyTop + H * .06, 3); ctx.fill(); | |
| ctx.fillStyle = "#94969f"; | |
| roundRect(ctx, cx - 9, bodyTop - H * .10, 9, bodyBot - bodyTop + H * .06, 3); ctx.fill(); | |
| crenellations(ctx, cx - 10, bodyTop - H * .16, 20, 8, 3, "#8a8c96"); | |
| ctx.fillStyle = "#2f3038"; | |
| ctx.fillRect(cx - 2.5, bodyTop + 6, 5, 9); | |
| } | |
| // main body | |
| ctx.fillStyle = "#8d8f99"; | |
| roundRect(ctx, px + 10, bodyTop, W - 20, bodyBot - bodyTop, 3); ctx.fill(); | |
| ctx.fillStyle = "#a2a4ad"; | |
| roundRect(ctx, px + 10, bodyTop, (W - 20) * .42, bodyBot - bodyTop, 3); ctx.fill(); | |
| stoneCourses(ctx, px + 10, bodyTop, W - 20, bodyBot - bodyTop, 5); | |
| // big roof | |
| gableRoof(ctx, 0, py + H * .02, px + 4, px + W - 4, bodyTop + 3, "#8e4038", "#b25a4c"); | |
| ctx.strokeStyle = "rgba(255,220,200,.22)"; ctx.lineWidth = 1; | |
| for (let i = 1; i < 5; i++) { | |
| const f = i / 5; | |
| ctx.beginPath(); | |
| ctx.moveTo(lerp(0, px + 4, f), lerp(py + H * .02, bodyTop + 3, f)); | |
| ctx.lineTo(lerp(0, px + W - 4, f), lerp(py + H * .02, bodyTop + 3, f)); | |
| ctx.stroke(); | |
| } | |
| // door and steps | |
| ctx.fillStyle = "#c8c6bd"; | |
| roundRect(ctx, -14, bodyBot - 6, 28, 8, 2); ctx.fill(); | |
| ctx.fillStyle = "#3f2c19"; | |
| ctx.beginPath(); | |
| ctx.moveTo(-9, bodyBot - 4); ctx.lineTo(-9, bodyBot - 20); | |
| ctx.arc(0, bodyBot - 20, 9, Math.PI, 0); ctx.lineTo(9, bodyBot - 4); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = "#5c4327"; | |
| ctx.fillRect(-1, bodyBot - 22, 2, 18); | |
| // windows | |
| ctx.fillStyle = "#ffdf9a"; | |
| for (const wx of [-W * .22, W * .22]) ctx.fillRect(wx - 3, bodyTop + H * .22, 6, 9); | |
| // banner on the ridge | |
| ctx.strokeStyle = "#6a5a3a"; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(0, py + H * .02); ctx.lineTo(0, py - H * .16); ctx.stroke(); | |
| ctx.fillStyle = TEAM.body; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, py - H * .16); ctx.lineTo(W * .20, py - H * .11); | |
| ctx.lineTo(0, py - H * .06); ctx.closePath(); ctx.fill(); | |
| } else if (type === "barracks") { | |
| const bodyTop = py + H * .34, bodyBot = py + H * .92; | |
| ctx.fillStyle = "#7f8189"; | |
| roundRect(ctx, px + 5, bodyTop, W - 10, bodyBot - bodyTop, 3); ctx.fill(); | |
| ctx.fillStyle = "#93959d"; | |
| roundRect(ctx, px + 5, bodyTop, (W - 10) * .40, bodyBot - bodyTop, 3); ctx.fill(); | |
| stoneCourses(ctx, px + 5, bodyTop, W - 10, bodyBot - bodyTop, 5); | |
| // flat crenellated roof | |
| ctx.fillStyle = "#6a6c76"; | |
| roundRect(ctx, px + 2, py + H * .22, W - 4, H * .14, 3); ctx.fill(); | |
| ctx.fillStyle = "#7c7e88"; | |
| roundRect(ctx, px + 2, py + H * .22, W - 4, H * .06, 3); ctx.fill(); | |
| crenellations(ctx, px + 3, py + H * .14, W - 6, 12, 5, "#767884"); | |
| // door | |
| ctx.fillStyle = "#3f2c19"; | |
| roundRect(ctx, -11, bodyBot - 24, 22, 24, 3); ctx.fill(); | |
| ctx.fillStyle = "#5c4327"; ctx.fillRect(-1, bodyBot - 24, 2, 24); | |
| // crossed swords sign | |
| ctx.strokeStyle = "#e2e7f2"; ctx.lineWidth = 2.6; ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(-W * .30, bodyTop + 10); ctx.lineTo(-W * .30 + 16, bodyTop + 26); | |
| ctx.moveTo(-W * .30 + 16, bodyTop + 10); ctx.lineTo(-W * .30, bodyTop + 26); | |
| ctx.stroke(); | |
| ctx.strokeStyle = "#8b6a3a"; ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(W * .22, bodyBot - 4); ctx.lineTo(W * .26, bodyTop + 8); | |
| ctx.moveTo(W * .30, bodyBot - 4); ctx.lineTo(W * .26, bodyTop + 8); ctx.stroke(); | |
| ctx.fillStyle = "#c9d3e3"; | |
| ctx.beginPath(); | |
| ctx.moveTo(W * .26, bodyTop + 2); ctx.lineTo(W * .30, bodyTop + 12); | |
| ctx.lineTo(W * .22, bodyTop + 12); ctx.closePath(); ctx.fill(); | |
| } else if (type === "farm") { | |
| // fenced field on the lower half | |
| ctx.fillStyle = "#7d6338"; | |
| roundRect(ctx, px + 3, py + H * .58, W - 6, H * .36, 3); ctx.fill(); | |
| ctx.strokeStyle = "#c9a94e"; ctx.lineWidth = 1.6; | |
| for (let i = 0; i < 7; i++) { | |
| const wx = px + 8 + i * (W - 16) / 6; | |
| const s = Math.sin(t * 1.8 + i) * 1.6; | |
| ctx.beginPath(); ctx.moveTo(wx, py + H * .92); ctx.lineTo(wx + s, py + H * .62); ctx.stroke(); | |
| } | |
| ctx.strokeStyle = "#6b5230"; ctx.lineWidth = 1.6; | |
| ctx.strokeRect(px + 3.5, py + H * .58, W - 7, H * .36); | |
| // cottage | |
| const bodyTop = py + H * .30, bodyBot = py + H * .62; | |
| ctx.fillStyle = "#a08560"; | |
| roundRect(ctx, px + 5, bodyTop, W - 10, bodyBot - bodyTop, 2); ctx.fill(); | |
| ctx.fillStyle = "#c2a67c"; | |
| roundRect(ctx, px + 5, bodyTop, (W - 10) * .45, bodyBot - bodyTop, 2); ctx.fill(); | |
| ctx.strokeStyle = "rgba(90,66,40,.55)"; ctx.lineWidth = 1.4; | |
| ctx.beginPath(); | |
| ctx.moveTo(px + 6, bodyTop + 2); ctx.lineTo(px + W - 6, bodyBot - 2); | |
| ctx.moveTo(px + W - 6, bodyTop + 2); ctx.lineTo(px + 6, bodyBot - 2); ctx.stroke(); | |
| gableRoof(ctx, 0, py + H * .04, px + 1, px + W - 1, bodyTop + 2, "#8a6a30", "#b08c42"); | |
| ctx.fillStyle = "#3f2c19"; | |
| roundRect(ctx, -6, bodyBot - 14, 12, 14, 2); ctx.fill(); | |
| } else if (type === "mill") { | |
| const bodyTop = py + H * .32, bodyBot = py + H * .90; | |
| ctx.fillStyle = "#7a5a34"; | |
| roundRect(ctx, px + 4, bodyTop, W - 8, bodyBot - bodyTop, 3); ctx.fill(); | |
| ctx.fillStyle = "#96703f"; | |
| for (let i = 0; i < 5; i++) | |
| ctx.fillRect(px + 6, bodyTop + 3 + i * ((bodyBot - bodyTop - 4) / 5), W - 12, 4); | |
| gableRoof(ctx, 0, py + H * .04, px + 1, px + W - 1, bodyTop + 2, "#4e5866", "#68748a"); | |
| // spinning saw | |
| ctx.save(); | |
| ctx.translate(W * .22, py + H * .70); | |
| ctx.rotate(t * 2.2); | |
| ctx.fillStyle = "#8c96a8"; | |
| for (let i = 0; i < 9; i++) { | |
| const a = i / 9 * TAU; | |
| ctx.beginPath(); | |
| ctx.moveTo(Math.cos(a) * 8, Math.sin(a) * 8); | |
| ctx.lineTo(Math.cos(a + .26) * 11, Math.sin(a + .26) * 11); | |
| ctx.lineTo(Math.cos(a + .52) * 8, Math.sin(a + .52) * 8); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| ctx.fillStyle = "#c3ccdb"; ctx.beginPath(); ctx.arc(0, 0, 8, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = "#5b6474"; ctx.beginPath(); ctx.arc(0, 0, 2.4, 0, TAU); ctx.fill(); | |
| ctx.restore(); | |
| // log pile | |
| for (let i = 0; i < 3; i++) { | |
| ctx.fillStyle = "#6b4a2a"; | |
| ctx.beginPath(); ctx.ellipse(px + 12 + i * 8, py + H * .86 - (i === 1 ? 5 : 0), 5.5, 3.4, 0, 0, TAU); ctx.fill(); | |
| ctx.fillStyle = "#a5773f"; | |
| ctx.beginPath(); ctx.ellipse(px + 12 + i * 8, py + H * .86 - (i === 1 ? 5 : 0), 3, 1.8, 0, 0, TAU); ctx.fill(); | |
| } | |
| } else if (type === "tower") { | |
| const bodyTop = py + H * .26, bodyBot = py + H * .92; | |
| ctx.fillStyle = "#7c7e88"; | |
| roundRect(ctx, px + 11, bodyTop, W - 22, bodyBot - bodyTop, 3); ctx.fill(); | |
| ctx.fillStyle = "#91939d"; | |
| roundRect(ctx, px + 11, bodyTop, (W - 22) * .42, bodyBot - bodyTop, 3); ctx.fill(); | |
| stoneCourses(ctx, px + 11, bodyTop, W - 22, bodyBot - bodyTop, 6); | |
| // overhanging battlement platform | |
| ctx.fillStyle = "#63656f"; | |
| roundRect(ctx, px + 4, py + H * .14, W - 8, H * .14, 3); ctx.fill(); | |
| ctx.fillStyle = "#787a85"; | |
| roundRect(ctx, px + 4, py + H * .14, W - 8, H * .06, 3); ctx.fill(); | |
| crenellations(ctx, px + 5, py + H * .06, W - 10, 11, 4, "#8a8c96"); | |
| ctx.fillStyle = "#2a2b33"; | |
| ctx.fillRect(-3, py + H * .46, 6, 12); | |
| ctx.fillStyle = "#c95a3a"; | |
| ctx.fillRect(px + 6, py + H * .04, 3, 8); | |
| } | |
| } | |
| /* ---- icon cache for the command card ----------------------------------- */ | |
| const iconCache = {}; | |
| function iconFor(key) { | |
| if (iconCache[key]) return iconCache[key]; | |
| const c = mkCanvas(64, 64), g = c.getContext("2d"); | |
| const grd = g.createLinearGradient(0, 0, 0, 64); | |
| grd.addColorStop(0, "#41506e"); grd.addColorStop(1, "#1a2030"); | |
| g.fillStyle = grd; g.fillRect(0, 0, 64, 64); | |
| if (UNIT_DEFS[key] || key === "beast") { | |
| g.save(); g.translate(32, 40); g.scale(2.05, 2.05); | |
| drawUnitShape(g, key, 0.35, -Math.PI / 2 + 0.35, "player"); | |
| g.restore(); | |
| } else if (BUILD_DEFS[key]) { | |
| const d = BUILD_DEFS[key]; | |
| const s = 1.5 / Math.max(d.w, d.h) * 1.35; | |
| g.save(); g.translate(32, 38); g.scale(s, s); | |
| drawBuildingShape(g, key, d.w, d.h, 1, 0); | |
| g.restore(); | |
| } else { | |
| g.save(); g.translate(32, 32); | |
| g.lineCap = "round"; g.lineJoin = "round"; | |
| if (key === "stop") { | |
| g.fillStyle = "#e05a5a"; roundRect(g, -13, -13, 26, 26, 5); g.fill(); | |
| g.fillStyle = "#fff"; g.font = "bold 20px sans-serif"; g.textAlign = "center"; | |
| g.fillText("S", 0, 7); | |
| } else if (key === "move") { | |
| g.strokeStyle = "#8fe08f"; g.lineWidth = 4; | |
| g.beginPath(); g.moveTo(-14, 0); g.lineTo(12, 0); g.stroke(); | |
| g.beginPath(); g.moveTo(4, -8); g.lineTo(14, 0); g.lineTo(4, 8); g.closePath(); | |
| g.fillStyle = "#8fe08f"; g.fill(); | |
| } else if (key === "attack") { | |
| g.strokeStyle = "#ff8f6b"; g.lineWidth = 5; | |
| g.beginPath(); g.moveTo(-11, -11); g.lineTo(11, 11); g.moveTo(11, -11); g.lineTo(-11, 11); | |
| g.stroke(); | |
| } else if (key === "build") { | |
| g.strokeStyle = "#e8c56a"; g.lineWidth = 4; | |
| g.beginPath(); g.moveTo(-10, 8); g.lineTo(6, -8); g.stroke(); | |
| g.strokeStyle = "#c9d3e3"; g.lineWidth = 6; | |
| g.beginPath(); g.arc(8, -9, 6, -0.5, 2.3); g.stroke(); | |
| } else if (key === "back") { | |
| g.strokeStyle = "#c9d3e3"; g.lineWidth = 4; | |
| g.beginPath(); g.moveTo(12, 0); g.lineTo(-12, 0); g.stroke(); | |
| g.beginPath(); g.moveTo(-4, -8); g.lineTo(-14, 0); g.lineTo(-4, 8); g.closePath(); | |
| g.fillStyle = "#c9d3e3"; g.fill(); | |
| } else if (key === "rally") { | |
| g.strokeStyle = "#7fd0ff"; g.lineWidth = 3; | |
| g.beginPath(); g.moveTo(-6, 13); g.lineTo(-6, -13); g.stroke(); | |
| g.fillStyle = "#7fd0ff"; | |
| g.beginPath(); g.moveTo(-6, -13); g.lineTo(12, -8); g.lineTo(-6, -3); g.closePath(); g.fill(); | |
| } | |
| g.restore(); | |
| } | |
| g.strokeStyle = "rgba(255,255,255,.10)"; g.lineWidth = 2; g.strokeRect(1, 1, 62, 62); | |
| iconCache[key] = c; | |
| return c; | |
| } | |
| /* ======================================================================== */ | |
| /* TERRAIN RENDER */ | |
| /* ======================================================================== */ | |
| let terrainCanvas, miniTerrain; | |
| const TERRAIN_RGB = { | |
| [T_DEEP]: [21, 52, 84], [T_WATER]: [34, 92, 142], [T_SAND]: [206, 188, 138], | |
| [T_GRASS]: [78, 121, 57], [T_DIRT]: [138, 113, 71], [T_ROCK]: [110, 113, 122] | |
| }; | |
| function bakeTerrain() { | |
| terrainCanvas = mkCanvas(MAP_PX_W, MAP_PX_H); | |
| const g = terrainCanvas.getContext("2d"); | |
| const r2 = mulberry32(4242); | |
| // smooth, low-frequency tinting keeps the ground from looking like a chequerboard | |
| const tintA = makeNoise(r2, 22), tintB = makeNoise(r2, 47); | |
| const rgbStr = (c, f) => "rgb(" + | |
| clamp(c[0] * (1 + f), 0, 255).toFixed(0) + "," + | |
| clamp(c[1] * (1 + f), 0, 255).toFixed(0) + "," + | |
| clamp(c[2] * (1 + f), 0, 255).toFixed(0) + ")"; | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| const t = terrain[y * MAP_W + x]; | |
| const px = x * TS, py = y * TS; | |
| const u = x / MAP_W, v = y / MAP_H; | |
| const n = (tintA(u, v) * 0.68 + tintB(u, v) * 0.32 - 0.5) * 2; // -1 .. 1 | |
| const amp = (t === T_GRASS || t === T_ROCK) ? 0.11 : 0.08; | |
| g.fillStyle = rgbStr(TERRAIN_RGB[t], n * amp); | |
| g.fillRect(px, py, TS, TS); | |
| if (t === T_GRASS) { | |
| for (let k = 0; k < 4; k++) { | |
| g.fillStyle = r2() < .5 ? "rgba(126,178,94,.16)" : "rgba(52,88,42,.16)"; | |
| g.fillRect(px + r2() * TS, py + r2() * TS, 2 + r2() * 4, 2); | |
| } | |
| if (r2() < .09) { | |
| g.fillStyle = r2() < .5 ? "rgba(232,226,150,.5)" : "rgba(206,206,216,.3)"; | |
| g.beginPath(); g.arc(px + r2() * TS, py + r2() * TS, 1.5, 0, TAU); g.fill(); | |
| } | |
| } else if (t === T_DIRT) { | |
| g.fillStyle = "rgba(60,45,25,.12)"; | |
| for (let k = 0; k < 4; k++) | |
| g.fillRect(px + r2() * TS, py + r2() * TS, 2 + r2() * 4, 2); | |
| } else if (t === T_SAND) { | |
| g.fillStyle = "rgba(255,246,210,.2)"; | |
| for (let k = 0; k < 3; k++) | |
| g.fillRect(px + r2() * TS, py + r2() * TS, 2 + r2() * 3, 2); | |
| } else if (t === T_ROCK) { | |
| for (let k = 0; k < 2; k++) { | |
| const a = px + 5 + r2() * (TS - 14), b = py + 5 + r2() * (TS - 14); | |
| const rr = 5 + r2() * 5; | |
| g.fillStyle = "rgba(30,33,40,.30)"; | |
| g.beginPath(); g.ellipse(a, b + 2, rr, rr * .72, 0, 0, TAU); g.fill(); | |
| g.fillStyle = "rgba(150,155,168,.45)"; | |
| g.beginPath(); g.ellipse(a, b, rr * .92, rr * .66, 0, 0, TAU); g.fill(); | |
| g.fillStyle = "rgba(210,216,228,.28)"; | |
| g.beginPath(); g.ellipse(a - rr * .2, b - rr * .2, rr * .5, rr * .3, 0, 0, TAU); g.fill(); | |
| } | |
| } | |
| } | |
| } | |
| // organic blotches to break up the tile grid entirely | |
| g.save(); | |
| for (let k = 0; k < 5200; k++) { | |
| const px = r2() * MAP_PX_W, py = r2() * MAP_PX_H; | |
| const t = terrain[clamp((py / TS) | 0, 0, MAP_H - 1) * MAP_W + clamp((px / TS) | 0, 0, MAP_W - 1)]; | |
| if (t === T_WATER || t === T_DEEP) continue; | |
| const rr = 12 + r2() * 46; | |
| const dark = r2() < .5; | |
| g.fillStyle = t === T_ROCK | |
| ? (dark ? "rgba(40,44,54,.055)" : "rgba(190,196,210,.045)") | |
| : (dark ? "rgba(30,52,22,.055)" : "rgba(150,196,110,.045)"); | |
| g.beginPath(); g.ellipse(px, py, rr, rr * (.6 + r2() * .5), r2() * 3, 0, TAU); g.fill(); | |
| } | |
| g.restore(); | |
| // soften transitions: draw a lighter edge on water tiles adjacent to land | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| const t = terrain[y * MAP_W + x]; | |
| const px = x * TS, py = y * TS; | |
| const nb = (dx, dy) => { | |
| const nx = x + dx, ny = y + dy; | |
| if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return t; | |
| return terrain[ny * MAP_W + nx]; | |
| }; | |
| if (t === T_WATER || t === T_DEEP) { | |
| // ragged shoreline: let the land spill a little way into the water tile | |
| for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { | |
| const nt = nb(dx, dy); | |
| if (nt !== T_SAND && nt !== T_GRASS && nt !== T_DIRT) continue; | |
| g.fillStyle = rgbStr(TERRAIN_RGB[nt], (r2() - .5) * .1); | |
| for (let k = 0; k < 5; k++) { | |
| const along = r2() * TS, into = r2() * 8; | |
| const cx2 = px + (dx ? (dx > 0 ? TS - into : into) : along); | |
| const cy2 = py + (dy ? (dy > 0 ? TS - into : into) : along); | |
| g.beginPath(); | |
| g.ellipse(cx2, cy2, 2 + r2() * 4, 2 + r2() * 3, r2() * 3, 0, TAU); | |
| g.fill(); | |
| } | |
| } | |
| g.fillStyle = t === T_WATER ? "rgba(160,205,235,.28)" : "rgba(70,135,185,.24)"; | |
| if (nb(0, -1) >= T_SAND) g.fillRect(px, py, TS, 3); | |
| if (nb(0, 1) >= T_SAND) g.fillRect(px, py + TS - 3, TS, 3); | |
| if (nb(-1, 0) >= T_SAND) g.fillRect(px, py, 3, TS); | |
| if (nb(1, 0) >= T_SAND) g.fillRect(px + TS - 3, py, 3, TS); | |
| } | |
| if (t === T_ROCK) { | |
| // cliff rim: dark on the shaded sides, bright lip on top | |
| g.fillStyle = "rgba(18,20,26,.34)"; | |
| if (nb(0, 1) !== T_ROCK) g.fillRect(px, py + TS - 6, TS, 6); | |
| if (nb(1, 0) !== T_ROCK) g.fillRect(px + TS - 5, py, 5, TS); | |
| g.fillStyle = "rgba(18,20,26,.20)"; | |
| if (nb(-1, 0) !== T_ROCK) g.fillRect(px, py, 4, TS); | |
| if (nb(0, -1) !== T_ROCK) { | |
| g.fillStyle = "rgba(226,232,244,.20)"; | |
| g.fillRect(px, py, TS, 4); | |
| } | |
| } | |
| // ragged dithering along land-to-land seams hides the square tiles | |
| // (rock keeps its hard edge — those are cliffs) | |
| const isLand = q => q === T_GRASS || q === T_DIRT || q === T_SAND; | |
| if (isLand(t)) { | |
| for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { | |
| const nt = nb(dx, dy); | |
| if (!isLand(nt) || nt === t) continue; | |
| // sand seams get a lighter touch — big pale dots read as noise | |
| const soft = (t === T_SAND || nt === T_SAND); | |
| const dots = soft ? 3 : 6, rad = soft ? 2.4 : 4; | |
| g.fillStyle = rgbStr(TERRAIN_RGB[nt], (r2() - .5) * .12); | |
| for (let k = 0; k < dots; k++) { | |
| const along = r2() * TS, into = r2() * (soft ? 6 : 9); | |
| const cx2 = px + (dx ? (dx > 0 ? TS - into : into) : along); | |
| const cy2 = py + (dy ? (dy > 0 ? TS - into : into) : along); | |
| g.beginPath(); | |
| g.ellipse(cx2, cy2, 1.6 + r2() * rad, 1.6 + r2() * rad * .8, r2() * 3, 0, TAU); | |
| g.fill(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // minimap terrain | |
| miniTerrain = mkCanvas(MAP_W, MAP_H); | |
| const mg = miniTerrain.getContext("2d"); | |
| const img = mg.createImageData(MAP_W, MAP_H); | |
| for (let i = 0; i < N; i++) { | |
| const c = TERRAIN_RGB[terrain[i]]; | |
| img.data[i * 4] = c[0]; img.data[i * 4 + 1] = c[1]; | |
| img.data[i * 4 + 2] = c[2]; img.data[i * 4 + 3] = 255; | |
| } | |
| mg.putImageData(img, 0, 0); | |
| miniFull = mkCanvas(MAP_W, MAP_H); | |
| bakeMiniFull(); | |
| } | |
| /* terrain + living resources, repainted only when something changes */ | |
| let miniFull; | |
| function bakeMiniFull() { | |
| const g = miniFull.getContext("2d"); | |
| g.clearRect(0, 0, MAP_W, MAP_H); | |
| g.drawImage(miniTerrain, 0, 0); | |
| for (const r of G.resources) { | |
| if (r.dead) continue; | |
| g.fillStyle = r.type === "gold" ? "#f5c542" : "#2c5c2c"; | |
| g.fillRect(r.tx, r.ty, r.w, r.h); | |
| } | |
| minimapDirty = false; | |
| } | |
| /* ======================================================================== */ | |
| /* GAME LOGIC */ | |
| /* ======================================================================== */ | |
| function nearestDropoff(x, y) { | |
| let best = null, bd = Infinity; | |
| for (const b of G.buildings) { | |
| if (b.dead || !b.done || !b.def.dropoff) continue; | |
| const d = dist2(x, y, b.x, b.y); | |
| if (d < bd) { bd = d; best = b; } | |
| } | |
| return best; | |
| } | |
| function nearestResource(x, y, type, exclude) { | |
| let best = null, bd = Infinity; | |
| for (const r of G.resources) { | |
| if (r.dead || r.type !== type || r === exclude) continue; | |
| if (!explored[r.ty * MAP_W + r.tx]) continue; | |
| const d = dist2(x, y, r.x, r.y); | |
| if (d < bd) { bd = d; best = r; } | |
| } | |
| return best; | |
| } | |
| function issueMove(u, wx, wy, attackMove = false) { | |
| u.order = "move"; u.state = "move"; u.attackMove = attackMove; | |
| u.resource = null; u.target = null; u.buildTarget = null; u.resumeMove = null; | |
| if (!setDestination(u, wx, wy)) { u.state = "idle"; u.attackMove = false; } | |
| } | |
| function setDestination(u, wx, wy) { | |
| const sx = clamp((u.x / TS) | 0, 0, MAP_W - 1), sy = clamp((u.y / TS) | 0, 0, MAP_H - 1); | |
| const gx = clamp((wx / TS) | 0, 0, MAP_W - 1), gy = clamp((wy / TS) | 0, 0, MAP_H - 1); | |
| const p = findPath(sx, sy, gx, gy); | |
| if (!p) { u.path = null; u.state = "idle"; return false; } | |
| u.path = p; u.pi = 0; | |
| u.finalX = wx; u.finalY = wy; | |
| return true; | |
| } | |
| function moveToTile(u, tx, ty) { | |
| return setDestination(u, tx * TS + TS / 2, ty * TS + TS / 2); | |
| } | |
| function issueHarvest(u, res) { | |
| if (u.type !== "worker") { issueMove(u, res.x, res.y); return; } | |
| u.order = "harvest"; u.resource = res; u.target = null; u.buildTarget = null; | |
| if (u.carrying > 0 && u.carryType !== res.type) { u.carrying = 0; u.carryType = null; } | |
| u.state = u.carrying >= u.def.carry ? "return" : "toResource"; | |
| routeWorker(u); | |
| } | |
| function issueAttack(u, target) { | |
| u.order = "attack"; u.target = target; u.resource = null; u.buildTarget = null; | |
| u.state = "attack"; u.attackMove = false; | |
| } | |
| function issueBuild(u, b) { | |
| u.order = "build"; u.buildTarget = b; u.resource = null; u.target = null; | |
| u.state = "toBuild"; | |
| const t = adjacentTile(b.tx, b.ty, b.w, b.h, (u.x / TS) | 0, (u.y / TS) | 0); | |
| if (t) moveToTile(u, t.x, t.y); else u.path = null; | |
| } | |
| function stopUnit(u) { | |
| u.order = null; u.state = "idle"; u.path = null; u.target = null; | |
| u.resource = null; u.buildTarget = null; u.attackMove = false; u.resumeMove = null; | |
| } | |
| function routeWorker(u) { | |
| if (u.state === "toResource") { | |
| const r = u.resource; | |
| if (!r || r.dead) { u.resource = null; u.state = "idle"; return; } | |
| const t = adjacentTile(r.tx, r.ty, r.w, r.h, (u.x / TS) | 0, (u.y / TS) | 0); | |
| if (t) moveToTile(u, t.x, t.y); | |
| else u.path = null; | |
| } else if (u.state === "return") { | |
| const d = nearestDropoff(u.x, u.y); | |
| if (!d) { u.state = "idle"; return; } | |
| u.dropTarget = d; | |
| const t = adjacentTile(d.tx, d.ty, d.w, d.h, (u.x / TS) | 0, (u.y / TS) | 0); | |
| if (t) moveToTile(u, t.x, t.y); | |
| else u.path = null; | |
| } | |
| } | |
| function nearRect(u, tx, ty, w, h, pad = 6) { | |
| const cx = clamp(u.x, tx * TS, (tx + w) * TS); | |
| const cy = clamp(u.y, ty * TS, (ty + h) * TS); | |
| return dist2(u.x, u.y, cx, cy) <= (TS * 0.75 + pad) * (TS * 0.75 + pad); | |
| } | |
| /* --------------------------------------------------------- unit update */ | |
| function updateUnit(u, dt) { | |
| u.anim += dt; | |
| if (u.cd > 0) u.cd -= dt; | |
| if (u.repathCd > 0) u.repathCd -= dt; | |
| if (u.team === "neutral") { updateBeast(u, dt); return; } | |
| switch (u.state) { | |
| case "move": { | |
| if (!advance(u, dt)) { | |
| u.state = "idle"; u.path = null; u.attackMove = false; | |
| } | |
| if (u.attackMove) acquireTarget(u); | |
| break; | |
| } | |
| case "attack": { | |
| const t = u.target; | |
| if (!t || t.dead || (t.hp !== undefined && t.hp <= 0)) { | |
| u.target = null; | |
| if (u.resumeMove) { // carry on with the attack-move | |
| const r = u.resumeMove; u.resumeMove = null; | |
| issueMove(u, r.x, r.y, true); | |
| } else u.state = "idle"; | |
| break; | |
| } | |
| const range = u.def.range + (t.kind === "building" ? Math.max(t.w, t.h) * TS * .5 : 8); | |
| const d = dist(u.x, u.y, t.x, t.y); | |
| if (d <= range) { | |
| u.path = null; | |
| u.facing = Math.atan2(t.y - u.y, t.x - u.x); | |
| if (u.cd <= 0) { | |
| u.cd = u.def.cd; | |
| if (u.def.ranged) spawnProjectile(u, t); | |
| else { damage(t, u.def.dmg, u); addEffect("slash", u.x + Math.cos(u.facing) * 14, u.y + Math.sin(u.facing) * 14, u.facing); } | |
| } | |
| } else { | |
| if (u.repathCd <= 0 && (!u.path || dist2(u.finalX || 0, u.finalY || 0, t.x, t.y) > 900)) { | |
| u.repathCd = 0.5; | |
| setDestination(u, t.x, t.y); | |
| } | |
| if (!advance(u, dt)) { | |
| if (d > u.def.sight * TS * 1.6) { // lost it — give up the chase | |
| u.target = null; | |
| if (u.resumeMove) { const r = u.resumeMove; u.resumeMove = null; issueMove(u, r.x, r.y, true); } | |
| else u.state = "idle"; | |
| } else u.repathCd = 0; | |
| } | |
| } | |
| break; | |
| } | |
| case "toResource": { | |
| const r = u.resource; | |
| if (!r || r.dead) { | |
| const alt = r ? nearestResource(u.x, u.y, r.type, r) : null; | |
| if (alt) { u.resource = alt; routeWorker(u); } | |
| else { u.state = "idle"; noMoreResource(r && r.type); u.resource = null; } | |
| break; | |
| } | |
| if (nearRect(u, r.tx, r.ty, r.w, r.h)) { | |
| u.path = null; u.state = "harvest"; | |
| u.harvestT = r.type === "gold" ? 1.1 : 1.5; | |
| u.facing = Math.atan2(r.y - u.y, r.x - u.x); | |
| } else if (!advance(u, dt)) { | |
| if (u.repathCd <= 0) { u.repathCd = 0.7; routeWorker(u); } | |
| } | |
| break; | |
| } | |
| case "harvest": { | |
| const r = u.resource; | |
| if (!r || r.dead) { u.state = "idle"; u.resource = null; break; } | |
| u.harvestT -= dt; | |
| if (u.harvestT <= 0) { | |
| const take = Math.min(u.def.carry - u.carrying, r.amount); | |
| r.amount -= take; u.carrying += take; u.carryType = r.type; | |
| addEffect("chip", r.x + (Math.random() - .5) * 16, r.y - 8, 0, r.type); | |
| if (r.amount <= 0) { | |
| removeResourceIndex(G.resources.indexOf(r)); | |
| markMinimapDirty(); | |
| } | |
| if (u.carrying >= u.def.carry || !r || r.dead) { | |
| u.state = "return"; routeWorker(u); | |
| } else { | |
| u.harvestT = r.type === "gold" ? 1.1 : 1.5; | |
| } | |
| } | |
| break; | |
| } | |
| case "return": { | |
| const d = u.dropTarget; | |
| if (!d || d.dead || !d.done) { u.dropTarget = null; routeWorker(u); if (!u.dropTarget) u.state = "idle"; break; } | |
| if (nearRect(u, d.tx, d.ty, d.w, d.h)) { | |
| if (u.carryType === "gold") G.gold += u.carrying; else G.wood += u.carrying; | |
| addFloater(d.x, d.y - 20, "+" + u.carrying, u.carryType === "gold" ? "#ffd166" : "#c39b6a"); | |
| u.carrying = 0; | |
| if (u.resource && !u.resource.dead) { u.state = "toResource"; routeWorker(u); } | |
| else { | |
| const want = u.carryType || "gold"; | |
| const alt = nearestResource(u.x, u.y, want); | |
| if (alt) { u.resource = alt; u.state = "toResource"; routeWorker(u); } | |
| else { u.state = "idle"; u.resource = null; noMoreResource(want); } | |
| } | |
| } else if (!advance(u, dt)) { | |
| if (u.repathCd <= 0) { u.repathCd = 0.7; routeWorker(u); } | |
| } | |
| break; | |
| } | |
| case "toBuild": { | |
| const b = u.buildTarget; | |
| if (!b || b.dead || b.done) { u.buildTarget = null; u.state = "idle"; break; } | |
| if (nearRect(u, b.tx, b.ty, b.w, b.h)) { u.path = null; u.state = "building"; } | |
| else if (!advance(u, dt)) { | |
| if (u.repathCd <= 0) { | |
| u.repathCd = 0.8; | |
| const t = adjacentTile(b.tx, b.ty, b.w, b.h, (u.x / TS) | 0, (u.y / TS) | 0); | |
| if (t) moveToTile(u, t.x, t.y); else { u.state = "idle"; u.buildTarget = null; } | |
| } | |
| } | |
| break; | |
| } | |
| case "building": { | |
| const b = u.buildTarget; | |
| if (!b || b.dead || b.done) { | |
| const nb = b && b.done ? b : null; | |
| u.buildTarget = null; u.state = "idle"; | |
| if (nb) { // after finishing, go back to work if there is anything nearby | |
| const alt = nearestResource(u.x, u.y, "gold"); | |
| if (alt && dist(u.x, u.y, alt.x, alt.y) < TS * 12) issueHarvest(u, alt); | |
| } | |
| break; | |
| } | |
| if (!nearRect(u, b.tx, b.ty, b.w, b.h)) { u.state = "toBuild"; break; } | |
| b.builders++; | |
| u.facing = Math.atan2(b.y - u.y, b.x - u.x); | |
| if (Math.random() < dt * 8) addEffect("chip", u.x + Math.cos(u.facing) * 12, u.y + Math.sin(u.facing) * 12 - 6, 0, "wood"); | |
| break; | |
| } | |
| default: { // idle | |
| if (u.def.dmg && u.type !== "worker") acquireTarget(u); | |
| break; | |
| } | |
| } | |
| separate(u, dt); | |
| } | |
| function acquireTarget(u) { | |
| if (u.target && !u.target.dead && u.target.hp > 0) return; | |
| let best = null, bd = (u.def.sight * TS) * (u.def.sight * TS); | |
| for (const e of G.units) { | |
| if (e.dead || e.team === u.team) continue; | |
| // wildlife minds its own business — only pick a fight on an explicit | |
| // attack-move, or with a beast that is already attacking someone | |
| if (!u.attackMove && e.team === "neutral" && !e.target) continue; | |
| const d = dist2(u.x, u.y, e.x, e.y); | |
| if (d < bd) { bd = d; best = e; } | |
| } | |
| if (best) { | |
| const keepPath = u.state === "move" && u.attackMove; | |
| u.target = best; | |
| u.state = "attack"; | |
| if (keepPath) u.resumeMove = { x: u.finalX, y: u.finalY }; | |
| } | |
| } | |
| function updateBeast(u, dt) { | |
| if (u.target && (u.target.dead || u.target.hp <= 0)) u.target = null; | |
| if (u.target) { | |
| const t = u.target; | |
| const d = dist(u.x, u.y, t.x, t.y); | |
| if (d > u.def.sight * TS * 2.2 || dist(u.x, u.y, u.home.x, u.home.y) > TS * 12) { | |
| u.target = null; u.state = "wander"; u.path = null; return; | |
| } | |
| if (d <= u.def.range + 8) { | |
| u.path = null; | |
| u.facing = Math.atan2(t.y - u.y, t.x - u.x); | |
| if (u.cd <= 0) { | |
| u.cd = u.def.cd; damage(t, u.def.dmg, u); | |
| addEffect("slash", u.x + Math.cos(u.facing) * 12, u.y + Math.sin(u.facing) * 12, u.facing); | |
| } | |
| } else { | |
| if (u.repathCd <= 0) { u.repathCd = .6; setDestination(u, t.x, t.y); } | |
| advance(u, dt); | |
| } | |
| return; | |
| } | |
| // lazy wander around home | |
| u.wanderT = (u.wanderT || 0) - dt; | |
| if (u.wanderT <= 0) { | |
| u.wanderT = 3 + Math.random() * 5; | |
| if (Math.random() < .6) { | |
| const a = Math.random() * TAU, r = Math.random() * TS * 3.5; | |
| setDestination(u, u.home.x + Math.cos(a) * r, u.home.y + Math.sin(a) * r); | |
| } else u.path = null; | |
| } | |
| advance(u, dt); | |
| separate(u, dt); | |
| } | |
| /* moves the unit along its path; returns false when there is nothing to do */ | |
| function advance(u, dt) { | |
| if (!u.path || u.pi >= u.path.length) return false; | |
| const wp = u.path[u.pi]; | |
| const tx = wp.x * TS + TS / 2, ty = wp.y * TS + TS / 2; | |
| const dx = tx - u.x, dy = ty - u.y; | |
| const d = Math.hypot(dx, dy); | |
| const step = u.def.speed * dt; | |
| if (d <= step + 1) { | |
| u.x = tx; u.y = ty; u.pi++; | |
| if (u.pi >= u.path.length) { u.path = null; return false; } | |
| return true; | |
| } | |
| u.facing = Math.atan2(dy, dx); | |
| u.x += dx / d * step; u.y += dy / d * step; | |
| return true; | |
| } | |
| /* soft collision so units don't stack in a single point */ | |
| let hashGrid = new Map(); | |
| function rebuildHash() { | |
| hashGrid.clear(); | |
| for (const u of G.units) { | |
| if (u.dead) continue; | |
| const k = ((u.x / 48) | 0) + "," + ((u.y / 48) | 0); | |
| let a = hashGrid.get(k); | |
| if (!a) { a = []; hashGrid.set(k, a); } | |
| a.push(u); | |
| } | |
| } | |
| function separate(u, dt) { | |
| const gx = (u.x / 48) | 0, gy = (u.y / 48) | 0; | |
| let px = 0, py = 0; | |
| for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++) { | |
| const a = hashGrid.get((gx + ox) + "," + (gy + oy)); | |
| if (!a) continue; | |
| for (const o of a) { | |
| if (o === u || o.dead) continue; | |
| const dx = u.x - o.x, dy = u.y - o.y; | |
| const d2 = dx * dx + dy * dy; | |
| const R = 15; | |
| if (d2 > R * R || d2 < 0.0001) continue; | |
| const d = Math.sqrt(d2); | |
| const f = (R - d) / R; | |
| px += dx / d * f; py += dy / d * f; | |
| } | |
| } | |
| if (px || py) { | |
| const sp = 46 * dt; | |
| let nx = u.x + px * sp, ny = u.y + py * sp; | |
| if (walkableAtPx(nx, u.y)) u.x = nx; | |
| if (walkableAtPx(u.x, ny)) u.y = ny; | |
| } | |
| // never end up inside terrain / buildings | |
| if (!walkableAtPx(u.x, u.y)) { | |
| const t = nearestWalkable((u.x / TS) | 0, (u.y / TS) | 0, 6); | |
| if (t) { | |
| const cx = t.x * TS + TS / 2, cy = t.y * TS + TS / 2; | |
| const a = Math.atan2(cy - u.y, cx - u.x); | |
| u.x += Math.cos(a) * 70 * dt; u.y += Math.sin(a) * 70 * dt; | |
| } | |
| } | |
| u.x = clamp(u.x, 4, MAP_PX_W - 4); u.y = clamp(u.y, 4, MAP_PX_H - 4); | |
| } | |
| function walkableAtPx(px, py) { | |
| const tx = (px / TS) | 0, ty = (py / TS) | 0; | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false; | |
| return walkable(ty * MAP_W + tx); | |
| } | |
| /* ------------------------------------------------------------ combat bits */ | |
| function spawnProjectile(u, t) { | |
| G.projectiles.push({ | |
| x: u.x, y: u.y - 8, tx: t.x, ty: t.y - 6, target: t, | |
| speed: 340, dmg: u.def.dmg, src: u, life: 2 | |
| }); | |
| } | |
| function damage(t, amount, src) { | |
| if (!t || t.dead) return; | |
| const armor = t.def && t.def.armor ? t.def.armor : 0; | |
| t.hp -= Math.max(1, amount - armor); | |
| t.flash = 0.15; | |
| if (t.kind === "unit" && t.team === "neutral" && !t.target && src) t.target = src; | |
| if (t.kind === "unit" && t.team === "player" && t.type !== "worker" && !t.target && src) t.target = src; | |
| if (t.hp <= 0) { | |
| if (t.kind === "unit") { | |
| t.dead = true; | |
| if (t.team === "neutral") { | |
| G.gold += t.def.bounty || 0; | |
| addFloater(t.x, t.y - 16, "+" + (t.def.bounty || 0), "#ffd166"); | |
| } else if (G.time - lastLossMsg > 6) { | |
| lastLossMsg = G.time; | |
| alertMsg(t.def.name + " lost!"); | |
| } | |
| addEffect("puff", t.x, t.y, 0); | |
| for (const o of G.units) if (o.target === t) { o.target = null; if (o.state === "attack") o.state = "idle"; } | |
| recalcSupply(); | |
| } else { | |
| destroyBuilding(t); | |
| addEffect("puff", t.x, t.y, 0); | |
| alertMsg(t.def.name + " destroyed!"); | |
| } | |
| } | |
| } | |
| function addEffect(type, x, y, a, extra) { | |
| G.effects.push({ type, x, y, a, life: type === "puff" ? .6 : .3, max: type === "puff" ? .6 : .3, extra }); | |
| } | |
| function addFloater(x, y, text, color) { | |
| G.effects.push({ type: "float", x, y, text, color, life: 1.3, max: 1.3 }); | |
| } | |
| let lastLossMsg = -99; | |
| const lastOutOf = { gold: -99, tree: -99 }; | |
| function noMoreResource(kind) { | |
| if (!kind || G.time - lastOutOf[kind] < 25) return; | |
| lastOutOf[kind] = G.time; | |
| alertMsg(kind === "gold" | |
| ? "No gold mine within your explored lands — scout for more" | |
| : "No forest within your explored lands — scout for more"); | |
| } | |
| /* send an unemployed worker to the closest known resource */ | |
| function autoAssignWorker(u) { | |
| const gold = nearestResource(u.x, u.y, "gold"); | |
| const tree = nearestResource(u.x, u.y, "tree"); | |
| let pick = gold; | |
| if (!pick) pick = tree; | |
| else if (tree && G.wood < G.gold * 0.4) pick = tree; | |
| if (pick) issueHarvest(u, pick); | |
| } | |
| function alertMsg(text) { | |
| const d = document.createElement("div"); | |
| d.className = "alert"; d.textContent = text; | |
| const box = document.getElementById("alerts"); | |
| box.appendChild(d); | |
| setTimeout(() => d.remove(), 3600); | |
| } | |
| /* -------------------------------------------------------- building update */ | |
| function updateBuilding(b, dt) { | |
| if (b.flash > 0) b.flash -= dt; | |
| if (!b.done) { | |
| b.workers = b.builders; // stable copy for the UI / renderer | |
| if (b.builders > 0) { | |
| const rate = (1 / b.def.time) * (1 + (b.builders - 1) * 0.55); | |
| b.progress += rate * dt; | |
| b.hp = Math.min(b.maxHp, b.def.hp * (0.12 + 0.88 * b.progress)); | |
| if (b.progress >= 1) { | |
| b.progress = 1; b.done = true; b.hp = b.maxHp; | |
| recalcSupply(); markMinimapDirty(); | |
| addEffect("puff", b.x, b.y, 0); | |
| alertMsg(b.def.name + " complete"); | |
| refreshCard(); | |
| } | |
| } | |
| b.builders = 0; | |
| return; | |
| } | |
| // training | |
| if (b.queue.length) { | |
| const type = b.queue[0]; | |
| b.trainT += dt; | |
| const need = UNIT_DEFS[type].time; | |
| if (b.trainT >= need) { | |
| // supply was reserved when the unit was queued, so it can always pop | |
| b.queue.shift(); b.trainT = 0; | |
| const spot = adjacentTile(b.tx, b.ty, b.w, b.h, b.tx + b.w / 2, b.ty + b.h + 1) || | |
| nearestWalkable((b.tx + b.w / 2) | 0, (b.ty + b.h) | 0, 8); | |
| const u = spawnUnit(type, (spot ? spot.x : b.tx) * TS + TS / 2, (spot ? spot.y : b.ty + b.h) * TS + TS / 2); | |
| recalcSupply(); | |
| if (b.rally) { | |
| const r = resourceAtTile((b.rally.x / TS) | 0, (b.rally.y / TS) | 0); | |
| if (r && type === "worker") issueHarvest(u, r); | |
| else issueMove(u, b.rally.x, b.rally.y); | |
| } else if (type === "worker") { | |
| autoAssignWorker(u); // fresh peasants get straight to work | |
| } | |
| alertMsg(UNIT_DEFS[type].name + " ready"); | |
| refreshCard(); | |
| } | |
| } | |
| // tower attack | |
| if (b.type === "tower") { | |
| if (b.cd > 0) b.cd -= dt; | |
| if (b.cd <= 0) { | |
| let best = null, bd = b.def.range * b.def.range; | |
| for (const e of G.units) { | |
| if (e.dead || e.team === "player") continue; | |
| const d = dist2(b.x, b.y, e.x, e.y); | |
| if (d < bd) { bd = d; best = e; } | |
| } | |
| if (best) { | |
| b.cd = b.def.cd; | |
| G.projectiles.push({ x: b.x, y: b.y - 30, tx: best.x, ty: best.y, target: best, speed: 380, dmg: b.def.dmg, src: b, life: 2 }); | |
| } | |
| } | |
| } | |
| } | |
| /* ======================================================================== */ | |
| /* INPUT */ | |
| /* ======================================================================== */ | |
| const cv = document.getElementById("game"); | |
| const ctx = cv.getContext("2d"); | |
| const mm = document.getElementById("minimap"); | |
| const mmCtx = mm.getContext("2d"); | |
| let DPR = Math.min(window.devicePixelRatio || 1, 2); | |
| let VW = 800, VH = 600; | |
| function resize() { | |
| const r = document.getElementById("viewport").getBoundingClientRect(); | |
| VW = Math.floor(r.width); VH = Math.floor(r.height); | |
| DPR = Math.min(window.devicePixelRatio || 1, 2); | |
| cv.width = Math.floor(VW * DPR); cv.height = Math.floor(VH * DPR); | |
| cv.style.width = VW + "px"; cv.style.height = VH + "px"; | |
| const mr = mm.getBoundingClientRect(); | |
| mm.width = Math.floor(mr.width * DPR); mm.height = Math.floor(mr.height * DPR); | |
| clampCam(); | |
| } | |
| window.addEventListener("resize", resize); | |
| function clampCam() { | |
| const w = VW / cam.zoom, h = VH / cam.zoom; | |
| cam.x = clamp(cam.x, 0, Math.max(0, MAP_PX_W - w)); | |
| cam.y = clamp(cam.y, 0, Math.max(0, MAP_PX_H - h)); | |
| } | |
| const screenToWorld = (sx, sy) => ({ x: cam.x + sx / cam.zoom, y: cam.y + sy / cam.zoom }); | |
| const worldToScreen = (wx, wy) => ({ x: (wx - cam.x) * cam.zoom, y: (wy - cam.y) * cam.zoom }); | |
| function centerOn(wx, wy) { | |
| cam.x = wx - VW / (2 * cam.zoom); cam.y = wy - VH / (2 * cam.zoom); clampCam(); | |
| } | |
| const mouse = { x: 0, y: 0, wx: 0, wy: 0, down: false, dragging: false, sx: 0, sy: 0, inside: false, mid: false }; | |
| const keys = {}; | |
| cv.addEventListener("contextmenu", e => e.preventDefault()); | |
| cv.addEventListener("mousemove", e => { | |
| const r = cv.getBoundingClientRect(); | |
| mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; mouse.inside = true; | |
| const w = screenToWorld(mouse.x, mouse.y); | |
| mouse.wx = w.x; mouse.wy = w.y; | |
| if (mouse.mid) { | |
| cam.x -= e.movementX / cam.zoom; cam.y -= e.movementY / cam.zoom; clampCam(); | |
| } | |
| if (mouse.down && !mouse.dragging) { | |
| if (Math.abs(mouse.x - mouse.sx) > 5 || Math.abs(mouse.y - mouse.sy) > 5) mouse.dragging = true; | |
| } | |
| }); | |
| cv.addEventListener("mouseleave", () => { mouse.inside = false; }); | |
| cv.addEventListener("mousedown", e => { | |
| const r = cv.getBoundingClientRect(); | |
| mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; | |
| const w = screenToWorld(mouse.x, mouse.y); | |
| mouse.wx = w.x; mouse.wy = w.y; | |
| if (e.button === 1) { mouse.mid = true; e.preventDefault(); return; } | |
| if (e.button === 0) { | |
| if (G.placing) { tryPlace(); return; } | |
| if (G.attackMove) { commandAttackMove(w.x, w.y); G.attackMove = false; cv.style.cursor = "default"; return; } | |
| mouse.down = true; mouse.dragging = false; mouse.sx = mouse.x; mouse.sy = mouse.y; | |
| } else if (e.button === 2) { | |
| if (G.placing) { cancelPlacing(); return; } | |
| if (G.attackMove) { G.attackMove = false; cv.style.cursor = "default"; return; } | |
| rightClick(w.x, w.y); | |
| } | |
| }); | |
| window.addEventListener("mouseup", e => { | |
| if (e.button === 1) { mouse.mid = false; return; } | |
| if (e.button !== 0 || !mouse.down) return; | |
| mouse.down = false; | |
| const r = cv.getBoundingClientRect(); | |
| const mx = e.clientX - r.left, my = e.clientY - r.top; | |
| if (mouse.dragging) { | |
| const a = screenToWorld(Math.min(mouse.sx, mx), Math.min(mouse.sy, my)); | |
| const b = screenToWorld(Math.max(mouse.sx, mx), Math.max(mouse.sy, my)); | |
| boxSelect(a.x, a.y, b.x, b.y, e.shiftKey); | |
| } else { | |
| clickSelect(mouse.wx, mouse.wy, e.shiftKey, e.detail >= 2); | |
| } | |
| mouse.dragging = false; | |
| }); | |
| cv.addEventListener("wheel", e => { | |
| e.preventDefault(); | |
| const before = screenToWorld(mouse.x, mouse.y); | |
| cam.zoom = clamp(cam.zoom * (e.deltaY < 0 ? 1.12 : 0.89), cam.minZoom, cam.maxZoom); | |
| const after = screenToWorld(mouse.x, mouse.y); | |
| cam.x += before.x - after.x; cam.y += before.y - after.y; | |
| clampCam(); | |
| }, { passive: false }); | |
| /* ---- selection --------------------------------------------------------- */ | |
| function clearSelection() { | |
| for (const e of G.selection) e.selected = false; | |
| G.selection = []; | |
| } | |
| function setSelection(list) { | |
| clearSelection(); | |
| G.selection = list; | |
| for (const e of list) e.selected = true; | |
| G.buildMenu = false; | |
| refreshCard(); refreshInfo(); | |
| } | |
| function unitAt(wx, wy) { | |
| let best = null, bd = 20 * 20; | |
| for (const u of G.units) { | |
| if (u.dead) continue; | |
| if (u.team !== "player" && !tileVisible(u.x, u.y)) continue; | |
| const d = dist2(wx, wy, u.x, u.y - 6); | |
| if (d < bd) { bd = d; best = u; } | |
| } | |
| return best; | |
| } | |
| function buildingAt(wx, wy) { | |
| const tx = (wx / TS) | 0, ty = (wy / TS) | 0; | |
| for (const b of G.buildings) { | |
| if (b.dead) continue; | |
| if (tx >= b.tx && tx < b.tx + b.w && ty >= b.ty && ty < b.ty + b.h) return b; | |
| } | |
| return null; | |
| } | |
| function clickSelect(wx, wy, add, dbl) { | |
| const u = unitAt(wx, wy); | |
| if (u) { | |
| if (dbl && u.team === "player") { | |
| const a = screenToWorld(0, 0), b = screenToWorld(VW, VH); | |
| const list = G.units.filter(o => !o.dead && o.team === "player" && o.type === u.type && | |
| o.x >= a.x && o.x <= b.x && o.y >= a.y && o.y <= b.y); | |
| setSelection(list); return; | |
| } | |
| if (add && u.team === "player") { | |
| if (u.selected) { u.selected = false; G.selection = G.selection.filter(o => o !== u); } | |
| else { u.selected = true; G.selection.push(u); } | |
| refreshCard(); refreshInfo(); return; | |
| } | |
| setSelection([u]); return; | |
| } | |
| const b = buildingAt(wx, wy); | |
| if (b && tileExplored(b.x, b.y)) { | |
| if (add && b.team === "player") { | |
| if (!b.selected) { b.selected = true; G.selection.push(b); } | |
| } else setSelection([b]); | |
| refreshCard(); refreshInfo(); return; | |
| } | |
| const r = resourceAtTile((wx / TS) | 0, (wy / TS) | 0); | |
| if (r && tileExplored(r.x, r.y)) { setSelection([r]); return; } | |
| if (!add) setSelection([]); | |
| } | |
| function boxSelect(x0, y0, x1, y1, add) { | |
| const list = G.units.filter(u => !u.dead && u.team === "player" && | |
| u.x >= x0 && u.x <= x1 && u.y >= y0 - 10 && u.y <= y1 + 10); | |
| if (list.length === 0) { | |
| const bl = G.buildings.filter(b => !b.dead && b.x >= x0 && b.x <= x1 && b.y >= y0 && b.y <= y1); | |
| if (bl.length) { setSelection(add ? [...new Set([...G.selection, ...bl])] : [bl[0]]); return; } | |
| if (!add) setSelection([]); | |
| return; | |
| } | |
| setSelection(add ? [...new Set([...G.selection.filter(e => e.kind === "unit"), ...list])] : list); | |
| } | |
| /* ---- orders ------------------------------------------------------------ */ | |
| function selectedUnits() { return G.selection.filter(e => e.kind === "unit" && e.team === "player" && !e.dead); } | |
| function formationOffsets(n) { | |
| const out = []; | |
| const cols = Math.ceil(Math.sqrt(n)); | |
| for (let i = 0; i < n; i++) { | |
| const cx = i % cols, cy = (i / cols) | 0; | |
| out.push({ x: (cx - (cols - 1) / 2) * 22, y: (cy - (Math.ceil(n / cols) - 1) / 2) * 22 }); | |
| } | |
| return out; | |
| } | |
| function rightClick(wx, wy) { | |
| const units = selectedUnits(); | |
| const sel = G.selection; | |
| // rally point for buildings | |
| const bsel = sel.filter(e => e.kind === "building" && e.done); | |
| if (bsel.length && !units.length) { | |
| for (const b of bsel) b.rally = { x: wx, y: wy }; | |
| addEffect("rally", wx, wy, 0); | |
| return; | |
| } | |
| if (!units.length) return; | |
| const target = unitAt(wx, wy); | |
| if (target && target.team !== "player") { | |
| for (const u of units) if (u.def.dmg) issueAttack(u, target); | |
| addEffect("attackPing", wx, wy, 0); | |
| return; | |
| } | |
| const bld = buildingAt(wx, wy); | |
| if (bld && !bld.done) { | |
| let any = false; | |
| for (const u of units) if (u.type === "worker") { issueBuild(u, bld); any = true; } | |
| if (any) { addEffect("ping", bld.x, bld.y, 0); return; } | |
| } | |
| const res = resourceAtTile((wx / TS) | 0, (wy / TS) | 0); | |
| if (res && tileExplored(res.x, res.y)) { | |
| let any = false; | |
| for (const u of units) if (u.type === "worker") { issueHarvest(u, res); any = true; } | |
| if (any) { addEffect("ping", res.x, res.y, 0); return; } | |
| } | |
| if (bld && bld.done && bld.def.dropoff) { | |
| let any = false; | |
| for (const u of units) if (u.type === "worker" && u.carrying > 0) { | |
| u.dropTarget = bld; u.state = "return"; routeWorker(u); any = true; | |
| } | |
| if (any) { addEffect("ping", bld.x, bld.y, 0); return; } | |
| } | |
| const offs = formationOffsets(units.length); | |
| units.forEach((u, i) => issueMove(u, wx + offs[i].x, wy + offs[i].y, false)); | |
| addEffect("ping", wx, wy, 0); | |
| } | |
| function commandAttackMove(wx, wy) { | |
| const units = selectedUnits(); | |
| const offs = formationOffsets(units.length); | |
| units.forEach((u, i) => { if (u.def.dmg) issueMove(u, wx + offs[i].x, wy + offs[i].y, true); }); | |
| addEffect("attackPing", wx, wy, 0); | |
| } | |
| /* ---- building placement ------------------------------------------------ */ | |
| function startPlacing(type) { | |
| const def = BUILD_DEFS[type]; | |
| if (!canAfford(def)) { alertMsg("Not enough resources"); return; } | |
| G.placing = { type }; | |
| cv.style.cursor = "crosshair"; | |
| } | |
| function cancelPlacing() { G.placing = null; cv.style.cursor = "default"; refreshCard(); } | |
| function tryPlace() { | |
| const type = G.placing.type, def = BUILD_DEFS[type]; | |
| const tx = Math.floor(mouse.wx / TS - (def.w - 1) / 2); | |
| const ty = Math.floor(mouse.wy / TS - (def.h - 1) / 2); | |
| if (!canPlaceAt(type, tx, ty)) { alertMsg("Cannot build there"); return; } | |
| if (!canAfford(def)) { alertMsg("Not enough resources"); cancelPlacing(); return; } | |
| pay(def); | |
| const b = placeBuilding(type, tx, ty, false); | |
| markMinimapDirty(); | |
| const workers = selectedUnits().filter(u => u.type === "worker"); | |
| if (workers.length) { | |
| for (const w of workers.slice(0, 5)) issueBuild(w, b); | |
| } else { | |
| // grab the closest idle worker | |
| let best = null, bd = Infinity; | |
| for (const u of G.units) { | |
| if (u.dead || u.type !== "worker" || u.team !== "player") continue; | |
| const d = dist2(u.x, u.y, b.x, b.y); | |
| if (d < bd) { bd = d; best = u; } | |
| } | |
| if (best) issueBuild(best, b); | |
| } | |
| if (!keys.shift) cancelPlacing(); | |
| refreshCard(); | |
| } | |
| /* ---- keyboard ---------------------------------------------------------- */ | |
| let lastGroupKey = null, lastGroupTime = 0; | |
| window.addEventListener("keydown", e => { | |
| const k = e.key.toLowerCase(); | |
| keys[k] = true; keys.shift = e.shiftKey; | |
| if (k === "escape") { | |
| if (G.placing) cancelPlacing(); | |
| else if (G.buildMenu) { G.buildMenu = false; refreshCard(); } | |
| else if (G.attackMove) { G.attackMove = false; cv.style.cursor = "default"; } | |
| else setSelection([]); | |
| document.getElementById("help").classList.remove("show"); | |
| return; | |
| } | |
| if (k === "h") { document.getElementById("help").classList.toggle("show"); return; } | |
| if (e.target && e.target.tagName === "INPUT") return; | |
| if (e.ctrlKey && k >= "1" && k <= "9") { | |
| G.groups[k] = G.selection.filter(x => !x.dead).slice(); | |
| alertMsg("Group " + k + " set (" + G.groups[k].length + ")"); | |
| e.preventDefault(); return; | |
| } | |
| if (!e.ctrlKey && k >= "1" && k <= "9") { | |
| const g = (G.groups[k] || []).filter(x => !x.dead); | |
| if (g.length) { | |
| const now = performance.now(); | |
| if (lastGroupKey === k && now - lastGroupTime < 400) centerOn(g[0].x, g[0].y); | |
| lastGroupKey = k; lastGroupTime = now; | |
| setSelection(g); | |
| } | |
| return; | |
| } | |
| if (k === " ") { | |
| const hall = G.buildings.find(b => !b.dead && b.type === "hall"); | |
| if (hall) centerOn(hall.x, hall.y); | |
| e.preventDefault(); return; | |
| } | |
| if (k === "n") { jumpToUnexplored(); return; } | |
| // command-card hotkeys | |
| const cmds = currentCommands(); | |
| for (const c of cmds) { | |
| if (c && c.hotkey && c.hotkey.toLowerCase() === k && !c.disabled) { c.action(); return; } | |
| } | |
| }); | |
| window.addEventListener("keyup", e => { keys[e.key.toLowerCase()] = false; keys.shift = e.shiftKey; }); | |
| /* centres the view on the nearest patch of land you have never laid eyes on */ | |
| function jumpToUnexplored() { | |
| const cx = cam.x + VW / (2 * cam.zoom), cy = cam.y + VH / (2 * cam.zoom); | |
| let best = null, bd = Infinity; | |
| for (let i = 0; i < N; i++) { | |
| if (explored[i] || !explorable[i]) continue; | |
| const x = (i % MAP_W) * TS + TS / 2, y = ((i / MAP_W) | 0) * TS + TS / 2; | |
| const d = dist2(x, y, cx, cy); | |
| if (d < bd) { bd = d; best = { x, y }; } | |
| } | |
| if (!best) { alertMsg("Every tile has been charted"); return; } | |
| centerOn(best.x, best.y); | |
| addEffect("ping", best.x, best.y, 0); | |
| addEffect("ping", best.x, best.y, 0); | |
| alertMsg("Uncharted land ahead"); | |
| } | |
| /* ---- minimap input ----------------------------------------------------- */ | |
| let mmDown = false; | |
| function mmToWorld(e) { | |
| const r = mm.getBoundingClientRect(); | |
| const x = clamp((e.clientX - r.left) / r.width, 0, 1); | |
| const y = clamp((e.clientY - r.top) / r.height, 0, 1); | |
| return { x: x * MAP_PX_W, y: y * MAP_PX_H }; | |
| } | |
| mm.addEventListener("mousedown", e => { | |
| e.preventDefault(); | |
| const w = mmToWorld(e); | |
| if (e.button === 2) { | |
| rightClick(w.x, w.y); | |
| } else { mmDown = true; centerOn(w.x, w.y); } | |
| }); | |
| mm.addEventListener("contextmenu", e => e.preventDefault()); | |
| window.addEventListener("mousemove", e => { if (mmDown) { const w = mmToWorld(e); centerOn(w.x, w.y); } }); | |
| window.addEventListener("mouseup", () => { mmDown = false; }); | |
| /* ======================================================================== */ | |
| /* UI */ | |
| /* ======================================================================== */ | |
| const cardPanel = document.getElementById("cardPanel"); | |
| const infoPanel = document.getElementById("infoPanel"); | |
| const tip = document.getElementById("tip"); | |
| function costHTML(def) { | |
| let s = ""; | |
| if (def.gold) s += `<span class="cost"><span class="gem g" style="width:10px;height:10px"></span>${def.gold}</span>`; | |
| if (def.wood) s += `<span class="cost"><span class="gem w" style="width:10px;height:10px"></span>${def.wood}</span>`; | |
| if (def.supply) s += `<span class="cost"><span class="gem s" style="width:10px;height:10px"></span>${def.supply}</span>`; | |
| if (def.time) s += `<span class="cost" style="color:#8b95ad">${def.time}s</span>`; | |
| return s; | |
| } | |
| function currentCommands() { | |
| const cmds = new Array(12).fill(null); | |
| const sel = G.selection; | |
| const units = selectedUnits(); | |
| if (!sel.length) return cmds; | |
| if (units.length) { | |
| const hasWorker = units.some(u => u.type === "worker"); | |
| const hasCombat = units.some(u => u.def.dmg && u.type !== "worker"); | |
| if (G.buildMenu && hasWorker) { | |
| let i = 0; | |
| for (const key of ["hall", "farm", "barracks", "mill", "tower"]) { | |
| const d = BUILD_DEFS[key]; | |
| cmds[i++] = { | |
| icon: key, hotkey: d.hotkey, name: d.name, def: d, desc: d.desc, | |
| disabled: !canAfford(d), | |
| action: () => { startPlacing(key); } | |
| }; | |
| } | |
| cmds[11] = { icon: "back", hotkey: "Esc", name: "Cancel", desc: "Return to the previous menu.", | |
| action: () => { G.buildMenu = false; refreshCard(); } }; | |
| return cmds; | |
| } | |
| cmds[0] = { icon: "move", hotkey: "M", name: "Move", desc: "Right-click the ground to move.", | |
| action: () => alertMsg("Right-click to move") }; | |
| cmds[1] = { icon: "stop", hotkey: "S", name: "Stop", desc: "Halt all current orders.", | |
| action: () => { for (const u of units) stopUnit(u); } }; | |
| if (hasCombat || hasWorker) { | |
| cmds[2] = { icon: "attack", hotkey: "A", name: "Attack", desc: "Attack-move: engage anything on the way.", | |
| action: () => { G.attackMove = true; cv.style.cursor = "crosshair"; } }; | |
| } | |
| if (hasWorker) { | |
| cmds[4] = { icon: "build", hotkey: "B", name: "Build", desc: "Raise a structure.", | |
| action: () => { G.buildMenu = true; refreshCard(); } }; | |
| } | |
| return cmds; | |
| } | |
| const b = sel.find(e => e.kind === "building"); | |
| if (b && b.team === "player") { | |
| if (!b.done) { | |
| cmds[11] = { icon: "stop", hotkey: "X", name: "Cancel construction", desc: "Refund half of the cost.", | |
| action: () => { G.gold += Math.round(b.def.gold / 2); G.wood += Math.round(b.def.wood / 2); destroyBuilding(b); setSelection([]); markMinimapDirty(); } }; | |
| return cmds; | |
| } | |
| let i = 0; | |
| if (b.def.trains) { | |
| for (const t of b.def.trains) { | |
| const d = UNIT_DEFS[t]; | |
| const missingReq = d.req && !hasBuilding(d.req); | |
| cmds[i++] = { | |
| icon: t, hotkey: d.hotkey, name: d.name, def: d, | |
| desc: d.desc + (missingReq ? ` (Requires ${BUILD_DEFS[d.req].name})` : ""), | |
| disabled: !canAfford(d) || missingReq, | |
| action: () => trainUnit(b, t) | |
| }; | |
| } | |
| cmds[8] = { icon: "rally", hotkey: "Y", name: "Set rally point", | |
| desc: "Right-click anywhere to set where new units gather.", | |
| action: () => alertMsg("Right-click to place the rally point") }; | |
| } | |
| return cmds; | |
| } | |
| return cmds; | |
| } | |
| function trainUnit(b, type) { | |
| const d = UNIT_DEFS[type]; | |
| if (d.req && !hasBuilding(d.req)) { alertMsg("Requires " + BUILD_DEFS[d.req].name); return; } | |
| if (!canAfford(d)) { alertMsg("Not enough resources"); return; } | |
| if (G.supplyUsed + d.supply > G.supplyCap) { alertMsg("Not enough supply — build a Farm"); return; } | |
| if (b.queue.length >= 6) { alertMsg("Queue is full"); return; } | |
| pay(d); b.queue.push(type); recalcSupply(); refreshCard(); refreshInfo(); | |
| } | |
| let cardSig = ""; | |
| function refreshCard(force) { | |
| const cmds = currentCommands(); | |
| const sig = cmds.map(c => c ? c.icon + (c.disabled ? "0" : "1") : "-").join("|") + "/" + G.selection.length; | |
| if (sig === cardSig && !force) return; | |
| cardSig = sig; | |
| hideTip(); | |
| cardPanel.innerHTML = ""; | |
| for (let i = 0; i < 12; i++) { | |
| const c = cmds[i]; | |
| const el = document.createElement("button"); | |
| if (!c) { el.className = "cmd empty"; cardPanel.appendChild(el); continue; } | |
| el.className = "cmd" + (c.disabled ? " disabled" : ""); | |
| const ic = iconFor(c.icon); | |
| const cc = mkCanvas(64, 64); cc.getContext("2d").drawImage(ic, 0, 0); | |
| el.appendChild(cc); | |
| const hk = document.createElement("div"); hk.className = "hk"; hk.textContent = c.hotkey || ""; | |
| el.appendChild(hk); | |
| el.onclick = () => { if (!c.disabled) c.action(); }; | |
| el.onmouseenter = () => showTip(c); | |
| el.onmouseleave = hideTip; | |
| cardPanel.appendChild(el); | |
| } | |
| } | |
| function showTip(c) { | |
| tip.innerHTML = `<div class="tn">${c.name}${c.hotkey ? ` <span style="color:#ffd166">[${c.hotkey}]</span>` : ""}</div>` + | |
| (c.def ? `<div class="tc">${costHTML(c.def)}</div>` : "") + | |
| `<div class="td">${c.desc || ""}</div>`; | |
| tip.style.display = "block"; | |
| } | |
| function hideTip() { tip.style.display = "none"; } | |
| cardPanel.addEventListener("mouseleave", hideTip); | |
| let infoSig = ""; | |
| let infoLive = null; // callbacks that refresh the numbers without rebuilding | |
| function refreshInfo() { | |
| const sel = G.selection.filter(e => !e.dead); | |
| if (sel.length !== G.selection.length) G.selection = sel; | |
| if (!sel.length) { | |
| if (infoSig !== "none") { | |
| infoSig = "none"; infoLive = null; | |
| infoPanel.innerHTML = `<div class="hint"> | |
| <b>No selection.</b><br> | |
| Left-click or drag to select. Right-click to order.<br> | |
| <kbd>B</kbd> build · <kbd>A</kbd> attack-move · <kbd>S</kbd> stop<br> | |
| <kbd>Space</kbd> base · <kbd>N</kbd> uncharted land · <kbd>H</kbd> help<br> | |
| <span style="color:#7fa8d8">Goal: reveal every tile of the map.</span> | |
| </div>`; | |
| } | |
| return; | |
| } | |
| if (sel.length === 1) { | |
| const e = sel[0]; | |
| const sig = "single" + e.id + (e.kind === "building" | |
| ? "|" + e.queue.join(",") + (e.done ? "1" : "0w" + (e.workers | 0)) | |
| : "|" + e.state); | |
| if (sig === infoSig) { if (infoLive) infoLive(); return; } | |
| infoSig = sig; infoLive = null; | |
| infoPanel.innerHTML = ""; | |
| const wrap = document.createElement("div"); wrap.className = "single"; | |
| const big = document.createElement("div"); big.className = "big"; | |
| const bc = mkCanvas(88, 88); const g = bc.getContext("2d"); | |
| if (e.kind === "unit") { g.save(); g.translate(44, 56); g.scale(2.7, 2.7); drawUnitShape(g, e.type, .3, -Math.PI / 2 + .4, e.team); g.restore(); } | |
| else if (e.kind === "building") { const s = 2.0 / Math.max(e.w, e.h); g.save(); g.translate(44, 52); g.scale(s, s); drawBuildingShape(g, e.type, e.w, e.h, 1, 0); g.restore(); } | |
| else { // resource | |
| g.save(); g.translate(44, 60); | |
| if (e.type === "gold") { g.scale(.85, .85); g.drawImage(goldSprite, -goldSprite.width / 2, -goldSprite.height + 14); } | |
| else { g.drawImage(treeSprites[e.variant], -treeSprites[0].width / 2, -treeSprites[0].height + 12); } | |
| g.restore(); | |
| } | |
| big.appendChild(bc); wrap.appendChild(big); | |
| const meta = document.createElement("div"); meta.className = "meta"; | |
| if (e.kind === "unit") { | |
| meta.innerHTML = `<h3>${e.def.name}</h3><div class="sub">${e.team === "player" ? "Your unit" : "Wild creature"} · ${stateLabel(e)}</div> | |
| <div class="bar"><div data-role="hpbar" style="width:${(e.hp / e.maxHp * 100).toFixed(0)}%"></div></div> | |
| <div class="stat">Health <b data-role="hptext">${Math.max(0, Math.ceil(e.hp))} / ${e.maxHp}</b></div> | |
| <div class="stat">Damage <b>${e.def.dmg}</b> · Range <b>${e.def.range > 40 ? "ranged" : "melee"}</b> · Sight <b>${e.def.sight}</b></div> | |
| ${e.type === "worker" ? `<div class="stat">Carrying <b data-role="cargo">${e.carrying ? e.carrying + " " + e.carryType : "nothing"}</b></div>` : ""}`; | |
| const bar = meta.querySelector('[data-role="hpbar"]'); | |
| const txt = meta.querySelector('[data-role="hptext"]'); | |
| const cargo = meta.querySelector('[data-role="cargo"]'); | |
| infoLive = () => { | |
| bar.style.width = clamp(e.hp / e.maxHp * 100, 0, 100).toFixed(0) + "%"; | |
| txt.textContent = Math.max(0, Math.ceil(e.hp)) + " / " + e.maxHp; | |
| if (cargo) cargo.textContent = e.carrying ? e.carrying + " " + e.carryType : "nothing"; | |
| }; | |
| } else if (e.kind === "building") { | |
| let q = ""; | |
| if (e.done && e.queue.length) { | |
| const d = UNIT_DEFS[e.queue[0]]; | |
| q = `<div class="stat" style="margin-top:4px">Training <b>${d.name}</b></div> | |
| <div class="bar prog"><div data-role="trainbar" style="width:${(e.trainT / d.time * 100).toFixed(0)}%"></div></div>`; | |
| } | |
| meta.innerHTML = `<h3>${e.def.name}</h3><div class="sub">${e.done ? "Operational" | |
| : (e.workers ? "Under construction · " + e.workers + " peasant" + (e.workers > 1 ? "s" : "") | |
| : "<span style='color:#ffb46b'>Waiting for a peasant</span>")}${e.def.supply ? " · +" + e.def.supply + " supply" : ""}</div> | |
| <div class="bar"><div data-role="hpbar" style="width:${(e.hp / e.maxHp * 100).toFixed(0)}%"></div></div> | |
| <div class="stat">Health <b data-role="hptext">${Math.ceil(e.hp)} / ${e.maxHp}</b></div> | |
| ${!e.done ? `<div class="stat">Construction <b data-role="progtext">${(e.progress * 100).toFixed(0)}%</b></div> | |
| <div class="bar prog"><div data-role="progbar" style="width:${(e.progress * 100).toFixed(0)}%"></div></div>` : ""} | |
| ${q}`; | |
| const bar = meta.querySelector('[data-role="hpbar"]'); | |
| const txt = meta.querySelector('[data-role="hptext"]'); | |
| const pb = meta.querySelector('[data-role="progbar"]'); | |
| const pt = meta.querySelector('[data-role="progtext"]'); | |
| const tb = meta.querySelector('[data-role="trainbar"]'); | |
| infoLive = () => { | |
| bar.style.width = clamp(e.hp / e.maxHp * 100, 0, 100).toFixed(0) + "%"; | |
| txt.textContent = Math.ceil(e.hp) + " / " + e.maxHp; | |
| if (pb) { pb.style.width = (e.progress * 100).toFixed(0) + "%"; pt.textContent = (e.progress * 100).toFixed(0) + "%"; } | |
| if (tb && e.queue.length) tb.style.width = clamp(e.trainT / UNIT_DEFS[e.queue[0]].time * 100, 0, 100).toFixed(0) + "%"; | |
| }; | |
| if (e.done && e.queue.length) { | |
| const qd = document.createElement("div"); qd.className = "queue"; | |
| e.queue.forEach((t, i) => { | |
| const s = document.createElement("div"); s.className = "qslot"; | |
| const c = mkCanvas(64, 64); c.getContext("2d").drawImage(iconFor(t), 0, 0); | |
| s.appendChild(c); s.title = "Cancel " + UNIT_DEFS[t].name; | |
| s.onclick = () => { refund(UNIT_DEFS[t]); e.queue.splice(i, 1); if (i === 0) e.trainT = 0; recalcSupply(); infoSig = ""; refreshInfo(); refreshCard(true); }; | |
| qd.appendChild(s); | |
| }); | |
| meta.appendChild(qd); | |
| } | |
| } else { | |
| meta.innerHTML = `<h3>${e.type === "gold" ? "Gold Mine" : "Forest"}</h3> | |
| <div class="sub">${e.type === "gold" ? "Rich in ore" : "Timber"}</div> | |
| <div class="bar"><div data-role="hpbar" style="width:${(e.amount / e.max * 100).toFixed(0)}%;background:${e.type === "gold" ? "linear-gradient(#ffe9a0,#e0a020)" : "linear-gradient(#8fd08f,#3f8f3f)"}"></div></div> | |
| <div class="stat">Remaining <b data-role="hptext">${e.amount}</b></div> | |
| <div class="stat" style="color:#8b95ad">Right-click with a peasant selected to harvest.</div>`; | |
| const bar = meta.querySelector('[data-role="hpbar"]'); | |
| const txt = meta.querySelector('[data-role="hptext"]'); | |
| infoLive = () => { | |
| bar.style.width = (e.amount / e.max * 100).toFixed(0) + "%"; | |
| txt.textContent = e.amount; | |
| }; | |
| } | |
| wrap.appendChild(meta); | |
| infoPanel.appendChild(wrap); | |
| return; | |
| } | |
| // multiple — rebuild only when the roster itself changes | |
| const shown = sel.slice(0, 32); | |
| const sig = "multi:" + shown.map(e => e.id).join(","); | |
| if (sig === infoSig) { if (infoLive) infoLive(); return; } | |
| infoSig = sig; | |
| infoPanel.innerHTML = ""; | |
| const row = document.createElement("div"); row.className = "selRow"; | |
| const live = []; | |
| shown.forEach(e => { | |
| const p = document.createElement("div"); | |
| p.className = "portrait"; | |
| const c = mkCanvas(64, 64); | |
| c.getContext("2d").drawImage(iconFor(e.type), 0, 0); | |
| p.appendChild(c); | |
| const hb = document.createElement("div"); hb.className = "hpb"; | |
| const hf = document.createElement("div"); hf.className = "hpf"; | |
| hb.appendChild(hf); p.appendChild(hb); | |
| p.title = (e.def && e.def.name) || e.type; | |
| p.onclick = ev => { ev.stopPropagation(); setSelection([e]); }; | |
| row.appendChild(p); | |
| live.push({ e, p, hf }); | |
| }); | |
| infoPanel.appendChild(row); | |
| if (sel.length > shown.length) { | |
| const more = document.createElement("div"); | |
| more.className = "hint"; more.style.marginTop = "4px"; | |
| more.textContent = "+" + (sel.length - shown.length) + " more"; | |
| infoPanel.appendChild(more); | |
| } | |
| infoLive = () => { | |
| for (const L of live) { | |
| const frac = clamp(L.e.hp / L.e.maxHp, 0, 1); | |
| L.hf.style.width = (frac * 100) + "%"; | |
| const cls = "portrait" + (frac < .35 ? " crit" : frac < .7 ? " hurt" : ""); | |
| if (L.p.className !== cls) L.p.className = cls; | |
| } | |
| }; | |
| infoLive(); | |
| } | |
| function stateLabel(u) { | |
| switch (u.state) { | |
| case "idle": return "Standing by"; | |
| case "move": return u.attackMove ? "Attack-moving" : "Moving"; | |
| case "attack": return "Fighting"; | |
| case "toResource": return "Going to harvest"; | |
| case "harvest": return u.resource && u.resource.type === "gold" ? "Mining gold" : "Chopping wood"; | |
| case "return": return "Returning cargo"; | |
| case "toBuild": case "building": return "Building"; | |
| case "wander": return "Roaming"; | |
| default: return "—"; | |
| } | |
| } | |
| /* ======================================================================== */ | |
| /* RENDER */ | |
| /* ======================================================================== */ | |
| const fogCanvas = mkCanvas(MAP_W, MAP_H); | |
| const fogCtx = fogCanvas.getContext("2d"); | |
| const fogImg = fogCtx.createImageData(MAP_W, MAP_H); | |
| function drawFog() { | |
| const d = fogImg.data; | |
| for (let i = 0; i < N; i++) { | |
| const e = explored[i], v = visible[i]; | |
| d[i * 4] = 0; d[i * 4 + 1] = 0; d[i * 4 + 2] = 2; | |
| d[i * 4 + 3] = e ? (v ? 0 : 122) : 255; | |
| } | |
| fogCtx.putImageData(fogImg, 0, 0); | |
| } | |
| let minimapDirty = true; | |
| function markMinimapDirty() { minimapDirty = true; } | |
| function render(t) { | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| ctx.clearRect(0, 0, VW, VH); | |
| ctx.imageSmoothingEnabled = false; | |
| const z = cam.zoom; | |
| ctx.save(); | |
| ctx.scale(z, z); | |
| ctx.translate(-cam.x, -cam.y); | |
| const vx0 = cam.x, vy0 = cam.y, vx1 = cam.x + VW / z, vy1 = cam.y + VH / z; | |
| // terrain | |
| ctx.drawImage(terrainCanvas, | |
| Math.max(0, vx0), Math.max(0, vy0), | |
| Math.min(MAP_PX_W, vx1) - Math.max(0, vx0), Math.min(MAP_PX_H, vy1) - Math.max(0, vy0), | |
| Math.max(0, vx0), Math.max(0, vy0), | |
| Math.min(MAP_PX_W, vx1) - Math.max(0, vx0), Math.min(MAP_PX_H, vy1) - Math.max(0, vy0)); | |
| ctx.imageSmoothingEnabled = true; | |
| // water shimmer | |
| ctx.save(); | |
| ctx.globalAlpha = .10; | |
| ctx.strokeStyle = "#cfeaff"; ctx.lineWidth = 1.5; | |
| const t0 = ((vy0 / TS) | 0), t1 = Math.min(MAP_H - 1, (vy1 / TS) | 0); | |
| const s0 = ((vx0 / TS) | 0), s1 = Math.min(MAP_W - 1, (vx1 / TS) | 0); | |
| for (let y = Math.max(0, t0); y <= t1; y++) { | |
| for (let x = Math.max(0, s0); x <= s1; x++) { | |
| const tt = terrain[y * MAP_W + x]; | |
| if (tt !== T_WATER && tt !== T_DEEP) continue; | |
| if (((x * 7 + y * 13) % 5) !== 0) continue; | |
| const ph = t * 1.5 + x * .7 + y * .5; | |
| ctx.beginPath(); | |
| ctx.moveTo(x * TS + 6, y * TS + 16 + Math.sin(ph) * 2); | |
| ctx.lineTo(x * TS + 26, y * TS + 16 + Math.sin(ph + 1) * 2); | |
| ctx.stroke(); | |
| } | |
| } | |
| ctx.restore(); | |
| // resources (remembered once explored) | |
| for (const r of G.resources) { | |
| if (r.dead) continue; | |
| if (r.x < vx0 - 60 || r.x > vx1 + 60 || r.y < vy0 - 60 || r.y > vy1 + 60) continue; | |
| if (!explored[r.ty * MAP_W + r.tx]) continue; | |
| if (r.type === "tree") { | |
| const s = treeSprites[r.variant]; | |
| ctx.drawImage(s, r.x - s.width / 2 + r.jx, r.y + TS / 2 - s.height + 10 + r.jy); | |
| } else { | |
| ctx.drawImage(goldSprite, r.x - goldSprite.width / 2, r.y + TS - goldSprite.height + 4); | |
| // depletion tint | |
| if (r.amount < r.max * .34) { | |
| ctx.save(); ctx.globalAlpha = .35; ctx.fillStyle = "#000"; | |
| ctx.beginPath(); ctx.ellipse(r.x, r.y + 4, 30, 16, 0, 0, TAU); ctx.fill(); ctx.restore(); | |
| } | |
| } | |
| if (r.selected) drawSelRing(r.x, r.y + 6, Math.max(r.w, r.h) * 18, "#ffd166"); | |
| } | |
| // buildings | |
| const sorted = G.buildings.filter(b => !b.dead && explored[b.ty * MAP_W + b.tx]).sort((a, b) => a.y - b.y); | |
| for (const b of sorted) { | |
| if (b.x < vx0 - 120 || b.x > vx1 + 120 || b.y < vy0 - 120 || b.y > vy1 + 120) continue; | |
| ctx.save(); | |
| ctx.translate(b.x, b.y); | |
| if (b.flash > 0) { ctx.filter = "brightness(1.9)"; } | |
| drawBuildingShape(ctx, b.type, b.w, b.h, b.done ? 1 : b.progress, t); | |
| ctx.restore(); | |
| if (b.selected) drawSelRing(b.x, b.y + b.h * TS * .3, Math.max(b.w, b.h) * TS * .55, "#8fe08f"); | |
| if (b.hp < b.maxHp || !b.done) drawBar(b.x, b.y - b.h * TS * .55, 34, b.hp / b.maxHp, "#5ad35a"); | |
| if (!b.done) { | |
| drawBar(b.x, b.y + b.h * TS * .5 + 4, 34, b.progress, b.workers ? "#67b8ff" : "#e0a02a"); | |
| if (!b.workers) { // nobody is working on this site | |
| const pulse = .55 + .45 * Math.sin(t * 5); | |
| ctx.save(); | |
| ctx.globalAlpha = pulse; | |
| ctx.fillStyle = "#ffc061"; | |
| ctx.strokeStyle = "rgba(0,0,0,.6)"; ctx.lineWidth = 2.5; | |
| ctx.font = "bold 20px Segoe UI, sans-serif"; ctx.textAlign = "center"; | |
| const yy = b.y - b.h * TS * .55 - 8; | |
| ctx.strokeText("!", b.x, yy); ctx.fillText("!", b.x, yy); | |
| ctx.restore(); | |
| } | |
| } | |
| if (b.rally && b.selected) { | |
| ctx.strokeStyle = "rgba(127,208,255,.65)"; ctx.lineWidth = 2; ctx.setLineDash([6, 5]); | |
| ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.rally.x, b.rally.y); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = "#7fd0ff"; | |
| ctx.beginPath(); ctx.arc(b.rally.x, b.rally.y, 4, 0, TAU); ctx.fill(); | |
| } | |
| } | |
| // units | |
| const vis = G.units.filter(u => !u.dead && | |
| u.x > vx0 - 40 && u.x < vx1 + 40 && u.y > vy0 - 40 && u.y < vy1 + 40 && | |
| (u.team === "player" || tileVisible(u.x, u.y))).sort((a, b) => a.y - b.y); | |
| for (const u of vis) { | |
| if (u.selected) drawSelRing(u.x, u.y + 7, 12, u.team === "player" ? "#8fe08f" : "#ff8f6b"); | |
| if (u.flash > 0) { | |
| u.flash -= 1 / 60; | |
| ctx.save(); ctx.filter = "brightness(2.1)"; drawUnitCached(ctx, u); ctx.restore(); | |
| } else drawUnitCached(ctx, u); | |
| if (u.carrying > 0) { | |
| ctx.fillStyle = u.carryType === "gold" ? "#f5c542" : "#8a5a2c"; | |
| ctx.strokeStyle = "rgba(0,0,0,.4)"; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.arc(u.x + 7, u.y - 11, 3.4, 0, TAU); ctx.fill(); ctx.stroke(); | |
| } | |
| if (u.hp < u.maxHp) drawBar(u.x, u.y - 16, 20, u.hp / u.maxHp, u.team === "player" ? "#5ad35a" : "#e05a5a"); | |
| } | |
| // projectiles | |
| for (const p of G.projectiles) { | |
| const a = Math.atan2(p.ty - p.y, p.tx - p.x); | |
| ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(a); | |
| ctx.strokeStyle = "#f0e6c8"; ctx.lineWidth = 2; ctx.lineCap = "round"; | |
| ctx.beginPath(); ctx.moveTo(-7, 0); ctx.lineTo(6, 0); ctx.stroke(); | |
| ctx.strokeStyle = "#c0c8d8"; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(-7, 0); ctx.lineTo(-10, -2.5); ctx.moveTo(-7, 0); ctx.lineTo(-10, 2.5); ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| // effects | |
| for (const e of G.effects) { | |
| const k = e.life / e.max; | |
| ctx.save(); | |
| if (e.type === "ping" || e.type === "attackPing" || e.type === "rally") { | |
| ctx.strokeStyle = e.type === "attackPing" ? "#ff8f6b" : e.type === "rally" ? "#7fd0ff" : "#8fe08f"; | |
| ctx.lineWidth = 2.4; ctx.globalAlpha = k; | |
| ctx.beginPath(); ctx.arc(e.x, e.y, (1 - k) * 22 + 4, 0, TAU); ctx.stroke(); | |
| } else if (e.type === "slash") { | |
| ctx.globalAlpha = k * 1.5; | |
| ctx.strokeStyle = "#fff6d8"; ctx.lineWidth = 3; ctx.lineCap = "round"; | |
| ctx.beginPath(); ctx.arc(e.x, e.y, 9, e.a - .9, e.a + .9); ctx.stroke(); | |
| } else if (e.type === "chip") { | |
| ctx.globalAlpha = k; | |
| ctx.fillStyle = e.extra === "gold" ? "#ffd166" : "#a9743f"; | |
| for (let i = 0; i < 3; i++) { | |
| const a = i * 2.1 + (1 - k) * 3; | |
| ctx.beginPath(); | |
| ctx.arc(e.x + Math.cos(a) * (1 - k) * 14, e.y + Math.sin(a) * (1 - k) * 10 - (1 - k) * 6, 2, 0, TAU); | |
| ctx.fill(); | |
| } | |
| } else if (e.type === "puff") { | |
| ctx.globalAlpha = k * .7; | |
| ctx.fillStyle = "#d9d2c4"; | |
| for (let i = 0; i < 6; i++) { | |
| const a = i * 1.05; | |
| ctx.beginPath(); | |
| ctx.arc(e.x + Math.cos(a) * (1 - k) * 24, e.y + Math.sin(a) * (1 - k) * 16, 7 * k + 2, 0, TAU); | |
| ctx.fill(); | |
| } | |
| } else if (e.type === "float") { | |
| ctx.globalAlpha = Math.min(1, k * 1.6); | |
| ctx.fillStyle = e.color; ctx.font = "bold 13px Segoe UI, sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.strokeStyle = "rgba(0,0,0,.65)"; ctx.lineWidth = 3; | |
| ctx.strokeText(e.text, e.x, e.y - (1 - k) * 22); | |
| ctx.fillText(e.text, e.x, e.y - (1 - k) * 22); | |
| } | |
| ctx.restore(); | |
| } | |
| // placement ghost | |
| if (G.placing) { | |
| const def = BUILD_DEFS[G.placing.type]; | |
| const tx = Math.floor(mouse.wx / TS - (def.w - 1) / 2); | |
| const ty = Math.floor(mouse.wy / TS - (def.h - 1) / 2); | |
| const ok = canPlaceAt(G.placing.type, tx, ty) && canAfford(def); | |
| ctx.save(); | |
| ctx.globalAlpha = .55; | |
| ctx.translate((tx + def.w / 2) * TS, (ty + def.h / 2) * TS); | |
| drawBuildingShape(ctx, G.placing.type, def.w, def.h, 1, t); | |
| ctx.restore(); | |
| for (let dy = 0; dy < def.h; dy++) for (let dx = 0; dx < def.w; dx++) { | |
| const x = tx + dx, y = ty + dy; | |
| let good = x >= 0 && y >= 0 && x < MAP_W && y < MAP_H && | |
| PASSABLE[terrain[y * MAP_W + x]] && !blocked[y * MAP_W + x] && explored[y * MAP_W + x]; | |
| ctx.fillStyle = good ? "rgba(110,230,120,.28)" : "rgba(235,90,90,.34)"; | |
| ctx.fillRect(x * TS + 1, y * TS + 1, TS - 2, TS - 2); | |
| ctx.strokeStyle = good ? "rgba(140,255,150,.5)" : "rgba(255,120,120,.6)"; | |
| ctx.lineWidth = 1; ctx.strokeRect(x * TS + 1, y * TS + 1, TS - 2, TS - 2); | |
| } | |
| if (!ok) { /* colours already communicate it */ } | |
| } | |
| // fog of war — upscaled with smoothing so the edges feather softly | |
| drawFog(); | |
| ctx.imageSmoothingEnabled = true; | |
| { | |
| const fx0 = clamp(Math.floor(vx0 / TS) - 1, 0, MAP_W); | |
| const fy0 = clamp(Math.floor(vy0 / TS) - 1, 0, MAP_H); | |
| const fx1 = clamp(Math.ceil(vx1 / TS) + 1, 0, MAP_W); | |
| const fy1 = clamp(Math.ceil(vy1 / TS) + 1, 0, MAP_H); | |
| if (fx1 > fx0 && fy1 > fy0) { | |
| ctx.drawImage(fogCanvas, fx0, fy0, fx1 - fx0, fy1 - fy0, | |
| fx0 * TS, fy0 * TS, (fx1 - fx0) * TS, (fy1 - fy0) * TS); | |
| } | |
| } | |
| // beyond the map edge everything is black | |
| ctx.fillStyle = "#05070b"; | |
| if (vx0 < 0) ctx.fillRect(vx0 - 4, vy0 - 4, -vx0 + 4, vy1 - vy0 + 8); | |
| if (vy0 < 0) ctx.fillRect(vx0 - 4, vy0 - 4, vx1 - vx0 + 8, -vy0 + 4); | |
| if (vx1 > MAP_PX_W) ctx.fillRect(MAP_PX_W, vy0 - 4, vx1 - MAP_PX_W + 4, vy1 - vy0 + 8); | |
| if (vy1 > MAP_PX_H) ctx.fillRect(vx0 - 4, MAP_PX_H, vx1 - vx0 + 8, vy1 - MAP_PX_H + 4); | |
| ctx.restore(); | |
| // ---- screen space ---- | |
| if (mouse.down && mouse.dragging) { | |
| const x = Math.min(mouse.sx, mouse.x), y = Math.min(mouse.sy, mouse.y); | |
| const w = Math.abs(mouse.x - mouse.sx), h = Math.abs(mouse.y - mouse.sy); | |
| ctx.fillStyle = "rgba(120,220,140,.12)"; | |
| ctx.fillRect(x, y, w, h); | |
| ctx.strokeStyle = "rgba(150,240,170,.9)"; ctx.lineWidth = 1.5; | |
| ctx.strokeRect(x + .5, y + .5, w, h); | |
| } | |
| if (G.attackMove) { | |
| ctx.strokeStyle = "#ff8f6b"; ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(mouse.x - 12, mouse.y); ctx.lineTo(mouse.x + 12, mouse.y); | |
| ctx.moveTo(mouse.x, mouse.y - 12); ctx.lineTo(mouse.x, mouse.y + 12); | |
| ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(mouse.x, mouse.y, 7, 0, TAU); ctx.stroke(); | |
| } | |
| } | |
| function drawSelRing(x, y, r, color) { | |
| ctx.save(); | |
| ctx.strokeStyle = color; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.ellipse(x, y, r, r * .5, 0, 0, TAU); ctx.stroke(); | |
| ctx.globalAlpha = .18; ctx.fillStyle = color; ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawBar(cx, y, w, frac, color) { | |
| ctx.fillStyle = "rgba(0,0,0,.65)"; | |
| ctx.fillRect(cx - w / 2 - 1, y - 1, w + 2, 5); | |
| ctx.fillStyle = color; | |
| ctx.fillRect(cx - w / 2, y, w * clamp(frac, 0, 1), 3); | |
| } | |
| /* ---- minimap ----------------------------------------------------------- */ | |
| function renderMinimap() { | |
| const W = mm.width, H = mm.height; | |
| mmCtx.setTransform(1, 0, 0, 1, 0, 0); | |
| mmCtx.imageSmoothingEnabled = false; | |
| mmCtx.fillStyle = "#05070b"; mmCtx.fillRect(0, 0, W, H); | |
| if (minimapDirty) bakeMiniFull(); | |
| mmCtx.drawImage(miniFull, 0, 0, MAP_W, MAP_H, 0, 0, W, H); | |
| const sx = W / MAP_W, sy = H / MAP_H; | |
| // fog | |
| mmCtx.drawImage(fogCanvas, 0, 0, MAP_W, MAP_H, 0, 0, W, H); | |
| // entities | |
| for (const b of G.buildings) { | |
| if (b.dead || !explored[b.ty * MAP_W + b.tx]) continue; | |
| mmCtx.fillStyle = b.done ? "#6fb6ff" : "#4a7fb0"; | |
| mmCtx.fillRect(b.tx * sx, b.ty * sy, b.w * sx, b.h * sy); | |
| } | |
| for (const u of G.units) { | |
| if (u.dead) continue; | |
| if (u.team !== "player" && !tileVisible(u.x, u.y)) continue; | |
| mmCtx.fillStyle = u.team === "player" ? (u.selected ? "#ffffff" : "#7fe0ff") : "#ff6b6b"; | |
| const s = Math.max(2, sx * .9); | |
| mmCtx.fillRect(u.x / TS * sx - s / 2, u.y / TS * sy - s / 2, s, s); | |
| } | |
| // viewport rect | |
| mmCtx.strokeStyle = "rgba(255,255,255,.85)"; mmCtx.lineWidth = 1.5; | |
| mmCtx.strokeRect(cam.x / TS * sx, cam.y / TS * sy, (VW / cam.zoom) / TS * sx, (VH / cam.zoom) / TS * sy); | |
| } | |
| /* ======================================================================== */ | |
| /* MAIN LOOP */ | |
| /* ======================================================================== */ | |
| let last = performance.now(); | |
| let uiTimer = 0; | |
| function tick(now) { | |
| let dt = (now - last) / 1000; | |
| last = now; | |
| dt = Math.min(dt, 0.05); | |
| G.time += dt; | |
| // camera scrolling (letters are reserved for command hotkeys) | |
| const sp = 620 / cam.zoom * dt; | |
| if (keys.arrowup) cam.y -= sp; | |
| if (keys.arrowdown) cam.y += sp; | |
| if (keys.arrowleft) cam.x -= sp; | |
| if (keys.arrowright) cam.x += sp; | |
| if (mouse.inside && !mouse.mid) { | |
| const M = 14; | |
| if (mouse.x < M) cam.x -= sp; | |
| if (mouse.x > VW - M) cam.x += sp; | |
| if (mouse.y < M) cam.y -= sp; | |
| if (mouse.y > VH - M) cam.y += sp; | |
| } | |
| clampCam(); | |
| // simulation | |
| rebuildHash(); | |
| for (const b of G.buildings) if (!b.dead) updateBuilding(b, dt); | |
| for (const u of G.units) if (!u.dead) updateUnit(u, dt); | |
| // projectiles | |
| for (const p of G.projectiles) { | |
| if (p.target && !p.target.dead) { p.tx = p.target.x; p.ty = p.target.y - 6; } | |
| const dx = p.tx - p.x, dy = p.ty - p.y, d = Math.hypot(dx, dy); | |
| const st = p.speed * dt; | |
| p.life -= dt; | |
| if (d <= st || p.life <= 0) { | |
| if (p.target && !p.target.dead && d <= st + 6) damage(p.target, p.dmg, p.src); | |
| p.done = true; | |
| } else { p.x += dx / d * st; p.y += dy / d * st; } | |
| } | |
| G.projectiles = G.projectiles.filter(p => !p.done); | |
| for (const e of G.effects) e.life -= dt; | |
| G.effects = G.effects.filter(e => e.life > 0); | |
| // cull dead | |
| if (G.units.some(u => u.dead)) { | |
| G.units = G.units.filter(u => !u.dead); | |
| G.selection = G.selection.filter(e => !e.dead); | |
| infoSig = ""; cardSig = ""; | |
| } | |
| if (G.buildings.some(b => b.dead)) { | |
| G.buildings = G.buildings.filter(b => !b.dead); | |
| G.selection = G.selection.filter(e => !e.dead); | |
| infoSig = ""; cardSig = ""; | |
| } | |
| fogTimer -= dt; | |
| if (fogTimer <= 0) { fogTimer = 0.1; updateFog(); } | |
| render(G.time); | |
| renderMinimap(); | |
| uiTimer -= dt; | |
| if (uiTimer <= 0) { | |
| uiTimer = 0.12; | |
| document.getElementById("rGold").textContent = Math.floor(G.gold); | |
| document.getElementById("rWood").textContent = Math.floor(G.wood); | |
| const supEl = document.getElementById("rSupply"); | |
| supEl.textContent = G.supplyUsed + "/" + G.supplyCap; | |
| supEl.style.color = G.supplyUsed >= G.supplyCap ? "#ff9b6b" : ""; | |
| const pct = G.exploredCount / G.explorableTotal * 100; | |
| document.getElementById("rExplored").textContent = | |
| (pct >= 95 && pct < 100 ? pct.toFixed(1) : Math.floor(pct)) + "%"; | |
| const m = Math.floor(G.time / 60), s = Math.floor(G.time % 60); | |
| document.getElementById("rClock").textContent = m + ":" + (s < 10 ? "0" : "") + s; | |
| refreshCard(); refreshInfo(); | |
| } | |
| requestAnimationFrame(tick); | |
| } | |
| /* ======================================================================== */ | |
| /* BOOT */ | |
| /* ======================================================================== */ | |
| function boot() { | |
| const seed = (Math.random() * 1e9) | 0; | |
| generateMap(seed); | |
| buildResourceSprites(); | |
| bakeTerrain(); | |
| // starting base | |
| const hall = placeBuilding("hall", startTile.x - 1, startTile.y - 1, true); | |
| const spots = []; | |
| for (let r = 3; r < 7 && spots.length < 5; r++) { | |
| for (let a = 0; a < 16 && spots.length < 5; a++) { | |
| const ang = a / 16 * TAU; | |
| const x = Math.round(startTile.x + Math.cos(ang) * r); | |
| const y = Math.round(startTile.y + Math.sin(ang) * r); | |
| if (x < 1 || y < 1 || x >= MAP_W - 1 || y >= MAP_H - 1) continue; | |
| if (!walkable(y * MAP_W + x)) continue; | |
| if (spots.some(s => s.x === x && s.y === y)) continue; | |
| spots.push({ x, y }); | |
| } | |
| } | |
| const mine = nearestResourceAny(hall.x, hall.y, "gold"); | |
| spots.slice(0, 5).forEach((s, i) => { | |
| const u = spawnUnit("worker", s.x * TS + TS / 2, s.y * TS + TS / 2); | |
| if (mine && i < 3) issueHarvest(u, mine); | |
| else { | |
| const tree = nearestResourceAny(hall.x, hall.y, "tree"); | |
| if (tree) issueHarvest(u, tree); | |
| } | |
| }); | |
| recalcSupply(); | |
| resize(); | |
| updateFog(); | |
| centerOn(hall.x, hall.y); | |
| refreshCard(true); refreshInfo(); | |
| document.getElementById("help").classList.add("show"); | |
| requestAnimationFrame(tick); | |
| } | |
| function nearestResourceAny(x, y, type) { | |
| let best = null, bd = Infinity; | |
| for (const r of G.resources) { | |
| if (r.dead || r.type !== type) continue; | |
| const d = dist2(x, y, r.x, r.y); | |
| if (d < bd) { bd = d; best = r; } | |
| } | |
| return best; | |
| } | |
| document.getElementById("helpBtn").onclick = () => document.getElementById("help").classList.toggle("show"); | |
| document.getElementById("newBtn").onclick = () => location.reload(); | |
| document.getElementById("closeHelp").onclick = () => document.getElementById("help").classList.remove("show"); | |
| document.getElementById("help").onclick = e => { | |
| if (e.target.id === "help") document.getElementById("help").classList.remove("show"); | |
| }; | |
| boot(); | |
| </script> | |
| </body> | |
| </html> |
Author
Sadly I don't have such a table, previously I wasn't capturing the time and cost, only started keeping track recently.
I might start recording that in the list above, but as you said, it is a really noisy test and is more useful to give you yet another data-point for relative strenghts of models in an intuitive/visual/silly way, than to provide hard data.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks @senko for running this !
Is there a table somewhere with time, cost, and html file size? I think it would be interesting to compare (while of course there's lots of noise between re-runs)