-
-
Save senko/c7004d97f7cfa61bd05e1bdf8c26031d to your computer and use it in GitHub Desktop.
| <!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> |
Yes. Here's the entire prompt:
Create a simple but functional real time strategy (RTS) game similar to old WarCraft, StarCraft or Command & Conquer games. The player should be able to build buildings, create units, gather resources and should uncover the whole map. No AI or multiplayer needed. Use simple but nice-looking graphics. No sound. Implement everything in HTML/CSS/JS, everything in a single file (you can use 3rd-party js or css libraries/frameworks via CDN).
It had playwright-cli for local testing which it used.
Ran for ~1h (41min of which was API time), token cost ~$21.
Compare with Fable and others here.
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)
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.
was this zero-shot?