Created
July 10, 2026 06:11
-
-
Save senko/0b5b2c89260f87b5bd5261ac92609283 to your computer and use it in GitHub Desktop.
RTS game by GPT-5.6 Sol
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> | |
| <link rel="icon" href="data:," /> | |
| <title>Frontier Command</title> | |
| <style> | |
| :root { | |
| --ink: #dcecf2; | |
| --muted: #7894a0; | |
| --cyan: #57e6e6; | |
| --amber: #ffbd56; | |
| --red: #f26b63; | |
| --panel: rgba(8, 18, 25, .92); | |
| --line: rgba(129, 187, 201, .18); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #071014; color: var(--ink); } | |
| body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; } | |
| button { font: inherit; } | |
| #game { position: fixed; inset: 0; width: 100%; height: 100%; cursor: crosshair; background: #14231d; } | |
| #vignette { position: fixed; inset: 0; pointer-events: none; box-shadow: inset 0 0 130px rgba(0,0,0,.52); } | |
| .topbar { | |
| position: fixed; left: 0; right: 0; top: 0; height: 68px; z-index: 5; | |
| display: flex; align-items: stretch; background: linear-gradient(180deg, rgba(6,16,23,.98), rgba(6,16,23,.82)); | |
| border-bottom: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0,0,0,.22); | |
| } | |
| .brand { width: 280px; padding: 13px 22px 0; border-right: 1px solid var(--line); letter-spacing: .13em; } | |
| .brand b { display: block; color: #fff; font-size: 17px; line-height: 1; } | |
| .brand span { color: var(--cyan); font-size: 9px; letter-spacing: .25em; } | |
| .resources { display: flex; align-items: center; gap: 32px; padding: 0 26px; } | |
| .metric { display: grid; grid-template-columns: 22px auto; grid-template-rows: 18px 14px; column-gap: 9px; min-width: 90px; } | |
| .metric .glyph { grid-row: 1 / 3; align-self: center; color: var(--cyan); font-size: 18px; text-shadow: 0 0 12px rgba(87,230,230,.45); } | |
| .metric strong { font-size: 15px; font-weight: 800; line-height: 18px; font-variant-numeric: tabular-nums; } | |
| .metric small { color: var(--muted); text-transform: uppercase; font-size: 8px; letter-spacing: .18em; } | |
| .metric:nth-child(2) .glyph { color: var(--amber); } | |
| .top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; padding: 0 18px; } | |
| .icon-btn { width: 37px; height: 37px; border: 1px solid var(--line); border-radius: 7px; background: rgba(255,255,255,.035); color: #a9c3cc; cursor: pointer; } | |
| .icon-btn:hover { color: #fff; border-color: rgba(87,230,230,.42); background: rgba(87,230,230,.08); } | |
| .mission { | |
| position: fixed; z-index: 4; left: 18px; top: 86px; width: 278px; padding: 16px 17px; | |
| background: linear-gradient(145deg, rgba(8,20,27,.94), rgba(9,25,31,.82)); border: 1px solid var(--line); border-radius: 9px; | |
| box-shadow: 0 14px 34px rgba(0,0,0,.25); backdrop-filter: blur(8px); | |
| } | |
| .eyebrow { color: var(--cyan); font-size: 8px; font-weight: 800; letter-spacing: .22em; text-transform: uppercase; } | |
| .mission h2 { margin: 5px 0 8px; font-size: 15px; letter-spacing: .02em; } | |
| .mission p { margin: 0 0 12px; color: #8aa3ad; font-size: 11px; line-height: 1.5; } | |
| .progress-track { height: 5px; background: rgba(255,255,255,.07); overflow: hidden; border-radius: 9px; } | |
| #exploreBar { width: 0%; height: 100%; background: linear-gradient(90deg, #1d8c99, var(--cyan)); box-shadow: 0 0 12px var(--cyan); transition: width .25s; } | |
| .progress-label { display: flex; justify-content: space-between; margin-top: 7px; color: #86a1ac; font-size: 9px; text-transform: uppercase; letter-spacing: .12em; } | |
| #eventLog { margin-top: 13px; padding-top: 11px; border-top: 1px solid var(--line); min-height: 16px; color: #afc1c8; font-size: 10px; } | |
| #eventLog::before { content: "●"; color: var(--cyan); margin-right: 7px; } | |
| .command-panel { | |
| position: fixed; z-index: 5; right: 18px; top: 86px; width: 286px; max-height: calc(100vh - 320px); min-height: 220px; | |
| background: var(--panel); border: 1px solid var(--line); border-radius: 9px; box-shadow: 0 16px 40px rgba(0,0,0,.32); overflow: hidden; | |
| backdrop-filter: blur(9px); | |
| } | |
| .panel-head { display: flex; align-items: center; justify-content: space-between; height: 43px; padding: 0 14px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.025); } | |
| .panel-head span { color: #99b2bb; font-size: 9px; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; } | |
| .panel-head kbd { color: #52717d; border: 1px solid #28404a; background: transparent; border-radius: 4px; font-size: 8px; padding: 3px 5px; } | |
| #selectionInfo { padding: 14px; } | |
| .selection-title { display: flex; gap: 12px; align-items: center; margin-bottom: 13px; } | |
| .portrait { width: 48px; height: 48px; flex: 0 0 48px; display: grid; place-items: center; border-radius: 7px; border: 1px solid rgba(87,230,230,.28); background: radial-gradient(circle at 50% 35%, rgba(87,230,230,.18), rgba(87,230,230,.03)); color: var(--cyan); font-size: 23px; } | |
| .selection-title h3 { margin: 0 0 4px; font-size: 14px; } | |
| .selection-title p { margin: 0; color: var(--muted); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; } | |
| .health { height: 4px; margin-top: 7px; width: 150px; overflow: hidden; border-radius: 4px; background: rgba(255,255,255,.08); } | |
| .health i { display: block; width: 100%; height: 100%; background: #53d399; } | |
| .queue { margin: 2px 0 12px; padding: 9px 10px; background: rgba(255,255,255,.03); border-radius: 6px; color: #91aab4; font-size: 9px; } | |
| .queue .progress-track { margin-top: 6px; height: 3px; } | |
| .queue .progress-track i { display: block; height: 100%; background: var(--amber); } | |
| .commands { display: grid; grid-template-columns: repeat(2, 1fr); gap: 7px; } | |
| .command { | |
| position: relative; min-height: 65px; padding: 9px 8px 8px; text-align: left; color: #cfe0e5; cursor: pointer; | |
| border: 1px solid rgba(130,178,191,.18); border-radius: 6px; background: linear-gradient(145deg, rgba(255,255,255,.05), rgba(255,255,255,.018)); | |
| } | |
| .command:hover:not(:disabled) { border-color: rgba(87,230,230,.55); background: rgba(87,230,230,.085); transform: translateY(-1px); } | |
| .command:disabled { cursor: not-allowed; opacity: .35; filter: grayscale(.5); } | |
| .command .cmd-icon { display: block; margin-bottom: 5px; color: var(--cyan); font-size: 17px; } | |
| .command b { display: block; font-size: 9px; letter-spacing: .05em; } | |
| .command small { color: #6f8b96; font-size: 8px; } | |
| .command .hotkey { position: absolute; top: 6px; right: 7px; color: #4f6b75; font-size: 8px; } | |
| .empty-help { color: #77919b; font-size: 11px; line-height: 1.6; } | |
| .empty-help b { color: #b8ccd3; } | |
| .minimap-wrap { position: fixed; z-index: 6; right: 18px; bottom: 18px; width: 286px; height: 184px; padding: 12px; border-radius: 9px; border: 1px solid var(--line); background: rgba(5,15,21,.94); box-shadow: 0 15px 35px rgba(0,0,0,.35); } | |
| .minimap-head { display: flex; justify-content: space-between; height: 20px; color: #6f8c97; font-size: 8px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; } | |
| .minimap-head span:last-child { color: var(--cyan); } | |
| #minimap { width: 260px; height: 140px; display: block; border: 1px solid rgba(137,188,200,.16); background: #050b0e; cursor: pointer; } | |
| .selection-strip { position: fixed; z-index: 4; left: 50%; bottom: 18px; transform: translateX(-50%); display: flex; align-items: center; min-width: 300px; height: 62px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: rgba(7,17,23,.92); box-shadow: 0 14px 34px rgba(0,0,0,.3); } | |
| #selectionCards { display: flex; gap: 6px; } | |
| .unit-card { width: 43px; height: 43px; display: grid; place-items: center; border-radius: 5px; border: 1px solid rgba(128,182,195,.18); color: #80abb6; background: rgba(255,255,255,.035); font-size: 16px; } | |
| .unit-card.active { color: var(--cyan); border-color: rgba(87,230,230,.4); } | |
| .strip-hint { margin-left: 14px; padding-left: 14px; border-left: 1px solid var(--line); color: #627d87; font-size: 9px; line-height: 1.45; } | |
| #toast { position: fixed; z-index: 12; left: 50%; top: 86px; transform: translate(-50%, -12px); padding: 10px 15px; border-radius: 6px; border: 1px solid rgba(87,230,230,.3); background: rgba(4,15,20,.94); color: #c8e3e8; font-size: 10px; letter-spacing: .06em; opacity: 0; pointer-events: none; transition: .22s; } | |
| #toast.show { opacity: 1; transform: translate(-50%, 0); } | |
| #toast.error { border-color: rgba(242,107,99,.45); color: #ffc0bb; } | |
| .overlay { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; background: radial-gradient(circle at 50% 45%, rgba(14,37,43,.82), rgba(2,7,10,.97)); transition: opacity .5s; } | |
| .overlay.hidden { opacity: 0; pointer-events: none; } | |
| .briefing { width: min(620px, calc(100vw - 36px)); padding: 38px 42px; border: 1px solid rgba(103,194,206,.25); border-radius: 12px; background: linear-gradient(145deg, rgba(11,27,35,.96), rgba(5,15,20,.98)); box-shadow: 0 35px 100px #000; } | |
| .briefing .logo { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 23px; border: 1px solid rgba(87,230,230,.4); border-radius: 50%; color: var(--cyan); font-size: 25px; box-shadow: inset 0 0 25px rgba(87,230,230,.09), 0 0 30px rgba(87,230,230,.08); } | |
| .briefing h1 { margin: 5px 0 12px; color: white; font-size: clamp(28px, 5vw, 48px); line-height: .96; letter-spacing: -.035em; } | |
| .briefing .lead { color: #8ea7b0; max-width: 500px; font-size: 13px; line-height: 1.65; } | |
| .brief-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 12px; margin: 25px 0; } | |
| .brief-item { padding: 13px; min-height: 86px; border: 1px solid var(--line); border-radius: 7px; background: rgba(255,255,255,.025); } | |
| .brief-item b { display: block; margin-bottom: 6px; color: #d8e8ec; font-size: 10px; letter-spacing: .06em; } | |
| .brief-item span { color: #6f8b95; font-size: 9px; line-height: 1.45; } | |
| .primary { min-width: 190px; padding: 13px 19px; border: 1px solid #5ee8e8; border-radius: 6px; background: var(--cyan); color: #062025; font-weight: 900; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; cursor: pointer; box-shadow: 0 0 30px rgba(87,230,230,.15); } | |
| .primary:hover { background: #83ffff; } | |
| .pause-card { text-align: center; width: 380px; } | |
| .pause-card h2 { margin: 4px 0 10px; font-size: 30px; } | |
| @media (max-width: 900px) { | |
| .brand { width: 190px; } | |
| .resources { gap: 12px; padding: 0 12px; } | |
| .metric { min-width: 72px; } | |
| .mission { width: 230px; } | |
| .command-panel { width: 240px; } | |
| .minimap-wrap { width: 240px; height: 160px; } | |
| #minimap { width: 214px; height: 116px; } | |
| .brief-grid { grid-template-columns: 1fr; } | |
| .brief-item { min-height: 0; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <div id="vignette"></div> | |
| <header class="topbar"> | |
| <div class="brand"><b>FRONTIER COMMAND</b><span>SECTOR K-42</span></div> | |
| <div class="resources"> | |
| <div class="metric"><span class="glyph">◆</span><strong id="credits">450</strong><small>Aether</small></div> | |
| <div class="metric"><span class="glyph">◈</span><strong id="supply">1 / 8</strong><small>Supply</small></div> | |
| <div class="metric"><span class="glyph">⌁</span><strong id="workers">1</strong><small>Collectors</small></div> | |
| </div> | |
| <div class="top-actions"> | |
| <button class="icon-btn" id="centerBtn" title="Center headquarters (H)">⌖</button> | |
| <button class="icon-btn" id="pauseBtn" title="Pause (P)">Ⅱ</button> | |
| <button class="icon-btn" id="helpBtn" title="Mission briefing">?</button> | |
| </div> | |
| </header> | |
| <section class="mission"> | |
| <div class="eyebrow">Primary directive</div> | |
| <h2>Survey the frontier</h2> | |
| <p>Build a mobile force and reveal 90% of this uncharted sector.</p> | |
| <div class="progress-track"><div id="exploreBar"></div></div> | |
| <div class="progress-label"><span>Map data</span><span id="exploreText">0%</span></div> | |
| <div id="eventLog">Command link established.</div> | |
| </section> | |
| <aside class="command-panel"> | |
| <div class="panel-head"><span>Command matrix</span><kbd>ESC CANCEL</kbd></div> | |
| <div id="selectionInfo"></div> | |
| </aside> | |
| <div class="minimap-wrap"> | |
| <div class="minimap-head"><span>Tactical overview</span><span>Live</span></div> | |
| <canvas id="minimap" width="520" height="280"></canvas> | |
| </div> | |
| <div class="selection-strip"> | |
| <div id="selectionCards"></div> | |
| <div class="strip-hint"><b>LEFT</b> SELECT / DRAG<br><b>RIGHT</b> MOVE OR HARVEST</div> | |
| </div> | |
| <div id="toast"></div> | |
| <div class="overlay" id="briefing"> | |
| <div class="briefing"> | |
| <div class="logo">⌁</div> | |
| <div class="eyebrow">New expedition // 07:40 local</div> | |
| <h1>Claim the<br>silent frontier.</h1> | |
| <p class="lead">Your command core has landed in an unmapped sector rich in Aether crystal. Establish an outpost, grow your force, and chart the wilderness.</p> | |
| <div class="brief-grid"> | |
| <div class="brief-item"><b>01 · GATHER</b><span>Select your Collector, then right-click glowing crystal fields.</span></div> | |
| <div class="brief-item"><b>02 · EXPAND</b><span>Build relays for supply, then barracks and factories.</span></div> | |
| <div class="brief-item"><b>03 · EXPLORE</b><span>Send units outward or construct sensor outposts.</span></div> | |
| </div> | |
| <button class="primary" id="startBtn">Begin operation</button> | |
| </div> | |
| </div> | |
| <div class="overlay hidden" id="pauseOverlay"> | |
| <div class="briefing pause-card"> | |
| <div class="eyebrow">Command channel</div> | |
| <h2>Operation paused</h2> | |
| <p class="lead">The simulation is standing by.</p> | |
| <button class="primary" id="resumeBtn">Resume</button> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| 'use strict'; | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const mini = document.getElementById('minimap'); | |
| const mctx = mini.getContext('2d'); | |
| const $ = id => document.getElementById(id); | |
| const WORLD = { w: 2688, h: 1824, tile: 48 }; | |
| const FOG = 48; | |
| const fogCols = Math.ceil(WORLD.w / FOG); | |
| const fogRows = Math.ceil(WORLD.h / FOG); | |
| const discovered = new Uint8Array(fogCols * fogRows); | |
| const visible = new Uint8Array(fogCols * fogRows); | |
| const TYPES = { | |
| worker: { name:'Collector', icon:'⌁', hp:80, speed:78, sight:190, cost:80, time:7, pop:1, radius:13, desc:'Harvests Aether and constructs buildings.' }, | |
| ranger: { name:'Pathfinder', icon:'⌖', hp:120, speed:104, sight:270, cost:110, time:10, pop:1, radius:12, desc:'Fast scout with an expanded sensor radius.' }, | |
| tank: { name:'Bulwark', icon:'▰', hp:330, speed:58, sight:220, cost:260, time:17, pop:3, radius:20, desc:'Heavy expedition vehicle.' }, | |
| hq: { name:'Command Core', icon:'⬡', hp:1200, w:128, h:112, sight:285, desc:'Primary base and Aether drop-off.' }, | |
| barracks:{ name:'Field Barracks', icon:'▥', hp:650, w:106, h:90, sight:180, cost:180, time:15, desc:'Trains Pathfinders.' }, | |
| factory: { name:'Vehicle Foundry', icon:'▣', hp:900, w:132, h:100, sight:190, cost:320, time:23, desc:'Constructs Bulwark vehicles.' }, | |
| relay: { name:'Supply Relay', icon:'◇', hp:420, w:76, h:76, sight:155, cost:120, time:11, desc:'Adds 8 supply capacity.' }, | |
| outpost: { name:'Sensor Outpost', icon:'⌾', hp:440, w:70, h:70, sight:460, cost:150, time:13, desc:'Reveals a large area of the map.' }, | |
| refinery:{ name:'Aether Depot', icon:'◆', hp:700, w:96, h:86, sight:175, cost:210, time:16, desc:'Remote resource drop-off with a 20% yield bonus.' } | |
| }; | |
| const state = { | |
| credits: 450, | |
| units: [], | |
| buildings: [], | |
| nodes: [], | |
| selected: [], | |
| pendingBuild: null, | |
| camera: { x: 250, y: 230, zoom: 1 }, | |
| keys: new Set(), | |
| mouse: { x: 0, y: 0, wx: 0, wy: 0, down: false, startX: 0, startY: 0, startWX: 0, startWY: 0, dragging: false }, | |
| paused: true, | |
| started: false, | |
| lastTime: performance.now(), | |
| nextId: 1, | |
| fogTimer: 0, | |
| uiTimer: 0, | |
| explored: 0, | |
| victory: false | |
| }; | |
| let toastTimer = 0; | |
| let uiSignature = ''; | |
| let seed = 731927; | |
| const rng = () => { | |
| seed |= 0; seed = seed + 0x6D2B79F5 | 0; | |
| let t = Math.imul(seed ^ seed >>> 15, 1 | seed); | |
| t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; | |
| return ((t ^ t >>> 14) >>> 0) / 4294967296; | |
| }; | |
| const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); | |
| const dist = (a,b) => Math.hypot(a.x-b.x, a.y-b.y); | |
| const hash = (x,y) => { | |
| let n = Math.imul(x + 17, 374761393) + Math.imul(y + 31, 668265263); | |
| n = Math.imul(n ^ n >>> 13, 1274126177); | |
| return ((n ^ n >>> 16) >>> 0) / 4294967295; | |
| }; | |
| const decorations = []; | |
| for (let i=0; i<330; i++) decorations.push({ | |
| x: 40 + rng()*(WORLD.w-80), y: 40 + rng()*(WORLD.h-80), | |
| type: rng() < .62 ? 'grass' : rng() < .75 ? 'rock' : 'flower', | |
| s: .55+rng()*1.2, a: rng()*Math.PI*2 | |
| }); | |
| function addUnit(type, x, y) { | |
| const t = TYPES[type]; | |
| const u = { id: state.nextId++, kind:'unit', type, x, y, tx:x, ty:y, angle:0, hp:t.hp, maxHp:t.hp, task:null, carry:0, maxCarry:type==='worker'?80:0, harvestTimer:0, selected:false }; | |
| state.units.push(u); return u; | |
| } | |
| function addBuilding(type, x, y, complete=true) { | |
| const t = TYPES[type]; | |
| const b = { id: state.nextId++, kind:'building', type, x, y, hp:t.hp, maxHp:t.hp, complete, progress:complete?1:0, queue:[], selected:false }; | |
| state.buildings.push(b); return b; | |
| } | |
| function addNode(x,y,amount=850) { | |
| state.nodes.push({ id:state.nextId++, kind:'node', x,y, amount, maxAmount:amount, radius:32, phase:rng()*9 }); | |
| } | |
| const hq = addBuilding('hq', 510, 480, true); | |
| const starter = addUnit('worker', 610, 515); | |
| addNode(300, 355, 1000); addNode(330, 650, 900); addNode(790, 340, 850); | |
| const nodeSpots = [ | |
| [1130,260],[1500,230],[2080,330],[2450,250],[950,720],[1360,610],[1800,650],[2290,770], | |
| [360,1010],[730,1160],[1160,1040],[1510,990],[1960,1120],[2420,1010], | |
| [260,1500],[720,1570],[1260,1470],[1670,1570],[2110,1450],[2460,1580] | |
| ]; | |
| nodeSpots.forEach((p,i) => addNode(p[0]+(rng()-.5)*80,p[1]+(rng()-.5)*70,650+Math.floor(rng()*650))); | |
| function screenToWorld(x,y) { return { x: state.camera.x + x/state.camera.zoom, y: state.camera.y + y/state.camera.zoom }; } | |
| function resize() { | |
| const dpr = Math.min(devicePixelRatio || 1, 2); | |
| canvas.width = Math.floor(innerWidth*dpr); canvas.height = Math.floor(innerHeight*dpr); | |
| canvas.style.width = innerWidth+'px'; canvas.style.height = innerHeight+'px'; | |
| ctx.setTransform(dpr,0,0,dpr,0,0); | |
| clampCamera(); | |
| } | |
| function clampCamera() { | |
| const viewW = innerWidth/state.camera.zoom, viewH = innerHeight/state.camera.zoom; | |
| state.camera.x = clamp(state.camera.x, 0, Math.max(0,WORLD.w-viewW)); | |
| state.camera.y = clamp(state.camera.y, 0, Math.max(0,WORLD.h-viewH)); | |
| } | |
| function centerOn(x,y) { | |
| state.camera.x = x-innerWidth/(2*state.camera.zoom); | |
| state.camera.y = y-innerHeight/(2*state.camera.zoom); | |
| clampCamera(); | |
| } | |
| function setEvent(text) { $('eventLog').textContent = text; } | |
| function toast(text, error=false) { | |
| clearTimeout(toastTimer); const el=$('toast'); el.textContent=text; el.className=error?'show error':'show'; | |
| toastTimer=setTimeout(()=>el.className='',2100); | |
| } | |
| function supply() { | |
| let used = state.units.reduce((n,u)=>n+TYPES[u.type].pop,0); | |
| for (const b of state.buildings) for (const q of b.queue) used += TYPES[q.type].pop || 0; | |
| let max = 8 + state.buildings.filter(b=>b.type==='relay'&&b.complete).length*8; | |
| return {used,max}; | |
| } | |
| function hasBuilding(type) { return state.buildings.some(b=>b.type===type&&b.complete); } | |
| function train(building,type) { | |
| const t=TYPES[type], sp=supply(); | |
| if (!building.complete) return toast('Construction is not complete.',true); | |
| if (state.credits<t.cost) return toast(`Need ${t.cost-state.credits} more Aether.`,true); | |
| if (sp.used+t.pop>sp.max) return toast('Build a Supply Relay first.',true); | |
| if (building.queue.length>=4) return toast('Production queue is full.',true); | |
| state.credits-=t.cost; building.queue.push({type, progress:0}); | |
| setEvent(`${t.name} added to the production queue.`); updateUI(true); | |
| } | |
| function beginBuild(type) { | |
| const t=TYPES[type]; | |
| if (state.credits<t.cost) return toast(`Need ${t.cost-state.credits} more Aether.`,true); | |
| if (type==='factory'&&!hasBuilding('barracks')) return toast('A Field Barracks is required.',true); | |
| state.pendingBuild=type; canvas.style.cursor='copy'; toast(`Place ${t.name} · right-click to cancel`); updateUI(true); | |
| } | |
| function validBuild(type,x,y) { | |
| const t=TYPES[type]; | |
| if (x-t.w/2<20||y-t.h/2<80||x+t.w/2>WORLD.w-20||y+t.h/2>WORLD.h-20) return false; | |
| for (const b of state.buildings) { | |
| const o=TYPES[b.type]; | |
| if (Math.abs(x-b.x)<(t.w+o.w)/2+28 && Math.abs(y-b.y)<(t.h+o.h)/2+28) return false; | |
| } | |
| for (const n of state.nodes) if (n.amount>0 && Math.hypot(x-n.x,y-n.y)<Math.max(t.w,t.h)/2+n.radius+24) return false; | |
| return true; | |
| } | |
| function placeBuild(x,y) { | |
| const type=state.pendingBuild, t=TYPES[type]; | |
| if (!validBuild(type,x,y)) return toast('Cannot build at this location.',true); | |
| if (state.credits<t.cost) { cancelBuild(); return toast('Insufficient Aether.',true); } | |
| state.credits-=t.cost; | |
| const b=addBuilding(type,x,y,false); | |
| const workers=state.selected.filter(e=>e.kind==='unit'&&e.type==='worker'); | |
| workers.forEach(u=>u.task={kind:'build',buildingId:b.id}); | |
| state.pendingBuild=null; canvas.style.cursor='crosshair'; | |
| setEvent(`${t.name} construction started.`); select([b]); updateUI(true); | |
| } | |
| function cancelBuild() { state.pendingBuild=null; canvas.style.cursor='crosshair'; updateUI(true); } | |
| function nearestDropoff(u) { | |
| const options=state.buildings.filter(b=>b.complete&&(b.type==='hq'||b.type==='refinery')); | |
| return options.sort((a,b)=>dist(u,a)-dist(u,b))[0] || hq; | |
| } | |
| function orderGather(u,node) { | |
| if (u.type!=='worker') return; | |
| u.task={kind:'gather',nodeId:node.id,phase:u.carry>0?'return':'toNode'}; | |
| } | |
| function orderMove(units,x,y) { | |
| const cols=Math.ceil(Math.sqrt(units.length)); | |
| units.forEach((u,i)=>{ | |
| const ox=((i%cols)-(cols-1)/2)*35, oy=(Math.floor(i/cols)-(Math.ceil(units.length/cols)-1)/2)*35; | |
| u.tx=clamp(x+ox,15,WORLD.w-15); u.ty=clamp(y+oy,75,WORLD.h-15); u.task={kind:'move'}; | |
| }); | |
| } | |
| function moveToward(u,x,y,dt,range=3) { | |
| const dx=x-u.x, dy=y-u.y, d=Math.hypot(dx,dy); | |
| if (d<=range) return true; | |
| const speed=TYPES[u.type].speed, step=Math.min(d-range,speed*dt); | |
| u.x+=dx/d*step; u.y+=dy/d*step; u.angle=Math.atan2(dy,dx); return d-step<=range+.1; | |
| } | |
| function updateUnit(u,dt) { | |
| if (!u.task) return; | |
| if (u.task.kind==='move') { | |
| if (moveToward(u,u.tx,u.ty,dt,3)) u.task=null; | |
| } else if (u.task.kind==='gather') { | |
| const node=state.nodes.find(n=>n.id===u.task.nodeId); | |
| if (!node||node.amount<=0) { u.task=null; if(node) setEvent('Aether field depleted.'); return; } | |
| if (u.task.phase==='toNode') { | |
| if (moveToward(u,node.x,node.y,dt,node.radius+10)) { u.task.phase='harvest'; u.harvestTimer=0; } | |
| } else if (u.task.phase==='harvest') { | |
| u.harvestTimer+=dt; | |
| if (u.harvestTimer>=.32) { | |
| const take=Math.min(5,node.amount,u.maxCarry-u.carry); node.amount-=take; u.carry+=take; u.harvestTimer=0; | |
| if (u.carry>=u.maxCarry||node.amount<=0) u.task.phase='return'; | |
| } | |
| } else { | |
| const drop=nearestDropoff(u), t=TYPES[drop.type]; | |
| if (moveToward(u,drop.x,drop.y,dt,Math.max(t.w,t.h)/2+13)) { | |
| const bonus=drop.type==='refinery'?1.2:1; | |
| const delivered=Math.round(u.carry*bonus); state.credits+=delivered; u.carry=0; | |
| setEvent(`Collector delivered ${delivered} Aether${bonus>1?' with depot bonus':''}.`); | |
| u.task.phase=node.amount>0?'toNode':'return'; if(node.amount<=0)u.task=null; | |
| } | |
| } | |
| } else if (u.task.kind==='build') { | |
| const b=state.buildings.find(b=>b.id===u.task.buildingId); | |
| if (!b||b.complete) { u.task=null; return; } | |
| const t=TYPES[b.type]; | |
| if (moveToward(u,b.x,b.y,dt,Math.max(t.w,t.h)/2+19)) { | |
| b.progress+=dt/t.time; | |
| b.hp=Math.max(1,Math.floor(b.maxHp*b.progress)); | |
| if (b.progress>=1) { b.progress=1; b.hp=b.maxHp; b.complete=true; u.task=null; setEvent(`${t.name} is operational.`); toast(`${t.name} complete`); updateUI(true); } | |
| } | |
| } | |
| } | |
| function updateBuilding(b,dt) { | |
| if (!b.complete||!b.queue.length) return; | |
| const q=b.queue[0], t=TYPES[q.type]; q.progress+=dt/t.time; | |
| if (q.progress>=1) { | |
| const bt=TYPES[b.type], angle=rng()*Math.PI*2; | |
| const u=addUnit(q.type,b.x+Math.cos(angle)*(bt.w/2+34),b.y+Math.sin(angle)*(bt.h/2+34)); | |
| u.tx=u.x+Math.cos(angle)*32; u.ty=u.y+Math.sin(angle)*32; u.task={kind:'move'}; | |
| b.queue.shift(); setEvent(`${t.name} deployment complete.`); toast(`${t.name} ready`); updateUI(true); | |
| } | |
| } | |
| function updateFog() { | |
| visible.fill(0); | |
| const reveal=(x,y,r)=>{ | |
| const minX=clamp(Math.floor((x-r)/FOG),0,fogCols-1), maxX=clamp(Math.floor((x+r)/FOG),0,fogCols-1); | |
| const minY=clamp(Math.floor((y-r)/FOG),0,fogRows-1), maxY=clamp(Math.floor((y+r)/FOG),0,fogRows-1); | |
| for(let gy=minY;gy<=maxY;gy++) for(let gx=minX;gx<=maxX;gx++) { | |
| const cx=gx*FOG+FOG/2,cy=gy*FOG+FOG/2; | |
| if(Math.hypot(cx-x,cy-y)<=r+FOG*.65){const i=gy*fogCols+gx;visible[i]=1;discovered[i]=1;} | |
| } | |
| }; | |
| state.units.forEach(u=>reveal(u.x,u.y,TYPES[u.type].sight)); | |
| state.buildings.filter(b=>b.complete).forEach(b=>reveal(b.x,b.y,TYPES[b.type].sight)); | |
| let count=0; for(const n of discovered)count+=n; | |
| state.explored=count/discovered.length; | |
| if(!state.victory&&state.explored>=.9){state.victory=true;toast('SECTOR SURVEY COMPLETE');setEvent('Mission complete. The frontier is mapped.');} | |
| } | |
| function update(dt) { | |
| let pan=420/state.camera.zoom; | |
| if(state.keys.has('shift'))pan*=1.7; | |
| if(state.keys.has('w')||state.keys.has('arrowup'))state.camera.y-=pan*dt; | |
| if(state.keys.has('s')||state.keys.has('arrowdown'))state.camera.y+=pan*dt; | |
| if(state.keys.has('a')||state.keys.has('arrowleft'))state.camera.x-=pan*dt; | |
| if(state.keys.has('d')||state.keys.has('arrowright'))state.camera.x+=pan*dt; | |
| clampCamera(); | |
| state.units.forEach(u=>updateUnit(u,dt)); | |
| state.buildings.forEach(b=>updateBuilding(b,dt)); | |
| state.fogTimer+=dt; state.uiTimer+=dt; | |
| if(state.fogTimer>.22){updateFog();state.fogTimer=0;} | |
| if(state.uiTimer>.2){updateUI();state.uiTimer=0;} | |
| } | |
| function roundedRect(c,x,y,w,h,r) { | |
| r=Math.min(r,w/2,h/2); c.beginPath(); c.moveTo(x+r,y); c.arcTo(x+w,y,x+w,y+h,r); c.arcTo(x+w,y+h,x,y+h,r); c.arcTo(x,y+h,x,y,r); c.arcTo(x,y,x+w,y,r); c.closePath(); | |
| } | |
| function drawTerrain() { | |
| const cam=state.camera,z=cam.zoom; | |
| const startX=Math.max(0,Math.floor(cam.x/WORLD.tile)), endX=Math.min(Math.ceil(WORLD.w/WORLD.tile),Math.ceil((cam.x+innerWidth/z)/WORLD.tile)); | |
| const startY=Math.max(0,Math.floor(cam.y/WORLD.tile)), endY=Math.min(Math.ceil(WORLD.h/WORLD.tile),Math.ceil((cam.y+innerHeight/z)/WORLD.tile)); | |
| for(let y=startY;y<endY;y++)for(let x=startX;x<endX;x++){ | |
| const h=hash(x,y),h2=hash(Math.floor(x/3),Math.floor(y/3)); | |
| ctx.fillStyle=h2>.7?(h>.5?'#1d3529':'#1b3227'):h>.5?'#1a3026':'#182d24'; | |
| ctx.fillRect(x*WORLD.tile,y*WORLD.tile,WORLD.tile+1,WORLD.tile+1); | |
| if(h>.87){ctx.fillStyle='rgba(123,157,107,.055)';ctx.beginPath();ctx.arc(x*WORLD.tile+13+h*21,y*WORLD.tile+18+h2*17,11,0,7);ctx.fill();} | |
| } | |
| ctx.strokeStyle='rgba(145,186,151,.032)';ctx.lineWidth=1/z;ctx.beginPath(); | |
| for(let x=startX;x<=endX;x++){ctx.moveTo(x*WORLD.tile,startY*WORLD.tile);ctx.lineTo(x*WORLD.tile,endY*WORLD.tile);} | |
| for(let y=startY;y<=endY;y++){ctx.moveTo(startX*WORLD.tile,y*WORLD.tile);ctx.lineTo(endX*WORLD.tile,y*WORLD.tile);}ctx.stroke(); | |
| // Landing scar and old routes. | |
| ctx.lineCap='round';ctx.strokeStyle='rgba(113,139,119,.08)';ctx.lineWidth=25; | |
| ctx.beginPath();ctx.moveTo(450,490);ctx.bezierCurveTo(870,570,1040,880,1500,930);ctx.bezierCurveTo(1900,980,2180,850,2650,920);ctx.stroke(); | |
| ctx.strokeStyle='rgba(10,20,17,.15)';ctx.lineWidth=2;ctx.setLineDash([8,16]);ctx.stroke();ctx.setLineDash([]); | |
| for(const d of decorations){ | |
| if(d.x<cam.x-20||d.y<cam.y-20||d.x>cam.x+innerWidth/z+20||d.y>cam.y+innerHeight/z+20)continue; | |
| ctx.save();ctx.translate(d.x,d.y);ctx.rotate(d.a);ctx.scale(d.s,d.s); | |
| if(d.type==='grass'){ctx.strokeStyle='rgba(114,157,116,.32)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,4);ctx.quadraticCurveTo(-4,-2,-3,-8);ctx.moveTo(0,4);ctx.quadraticCurveTo(3,-3,5,-7);ctx.moveTo(0,4);ctx.lineTo(0,-10);ctx.stroke();} | |
| else if(d.type==='rock'){ctx.fillStyle='rgba(77,95,82,.42)';ctx.beginPath();ctx.moveTo(-6,3);ctx.lineTo(-4,-4);ctx.lineTo(2,-7);ctx.lineTo(7,0);ctx.lineTo(4,5);ctx.closePath();ctx.fill();ctx.strokeStyle='rgba(164,179,154,.12)';ctx.stroke();} | |
| else{ctx.fillStyle='rgba(93,190,168,.35)';ctx.beginPath();ctx.arc(0,0,2.2,0,7);ctx.fill();} | |
| ctx.restore(); | |
| } | |
| } | |
| function drawNode(n,time) { | |
| if(n.amount<=0)return; | |
| const ratio=n.amount/n.maxAmount, count=Math.max(3,Math.ceil(8*ratio)); | |
| ctx.save();ctx.translate(n.x,n.y); | |
| const glow=ctx.createRadialGradient(0,5,2,0,5,45);glow.addColorStop(0,'rgba(74,229,239,.18)');glow.addColorStop(1,'rgba(74,229,239,0)');ctx.fillStyle=glow;ctx.fillRect(-50,-45,100,100); | |
| ctx.fillStyle='rgba(4,15,18,.35)';ctx.beginPath();ctx.ellipse(0,17,37,15,0,0,Math.PI*2);ctx.fill(); | |
| for(let i=0;i<count;i++){ | |
| const a=n.phase+i*2.399,rad=8+(i%3)*8, x=Math.cos(a)*rad, y=Math.sin(a)*rad*.52; | |
| const h=20+(i%4)*7, w=6+(i%3)*2, pulse=.8+Math.sin(time*2+n.phase+i)*.12; | |
| ctx.save();ctx.translate(x,y);ctx.scale(pulse,1); | |
| ctx.fillStyle=i%3===0?'#74f4ee':i%3===1?'#36bbc7':'#4dd5d5'; | |
| ctx.beginPath();ctx.moveTo(0,-h);ctx.lineTo(w,-h*.22);ctx.lineTo(w*.7,h*.3);ctx.lineTo(0,h*.48);ctx.lineTo(-w*.75,h*.25);ctx.lineTo(-w,-h*.2);ctx.closePath();ctx.fill(); | |
| ctx.strokeStyle='rgba(202,255,255,.6)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,-h);ctx.lineTo(0,h*.42);ctx.stroke();ctx.restore(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawBuilding(b,time) { | |
| const t=TYPES[b.type],w=t.w,h=t.h; | |
| ctx.save();ctx.translate(b.x,b.y); | |
| ctx.fillStyle='rgba(0,0,0,.28)';ctx.beginPath();ctx.ellipse(5,h*.43,w*.52,h*.23,0,0,7);ctx.fill(); | |
| if(!b.complete)ctx.globalAlpha=.5+.12*Math.sin(time*4); | |
| if(b.type==='hq'){ | |
| ctx.fillStyle='#263940';roundedRect(ctx,-w/2,-h/2,w,h,13);ctx.fill();ctx.strokeStyle='#48626a';ctx.lineWidth=3;ctx.stroke(); | |
| ctx.fillStyle='#18282e';roundedRect(ctx,-45,-40,90,59,9);ctx.fill(); | |
| ctx.fillStyle='#3c5259';ctx.fillRect(-58,24,116,17);ctx.fillStyle='#f0a94f';ctx.fillRect(-43,28,22,4);ctx.fillRect(-13,28,22,4);ctx.fillRect(17,28,22,4); | |
| ctx.fillStyle='rgba(87,230,230,.18)';ctx.beginPath();ctx.arc(0,-10,28,0,7);ctx.fill();ctx.strokeStyle='#57e6e6';ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,-10,17,0,7);ctx.stroke(); | |
| ctx.fillStyle='#9afcfa';ctx.beginPath();ctx.arc(0,-10,6+Math.sin(time*3)*1.2,0,7);ctx.fill(); | |
| ctx.strokeStyle='#66818a';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(-46,-47);ctx.lineTo(-52,-70);ctx.moveTo(46,-47);ctx.lineTo(54,-69);ctx.stroke();ctx.fillStyle='#57e6e6';ctx.fillRect(-55,-72,6,5);ctx.fillRect(51,-72,6,5); | |
| } else if(b.type==='barracks'){ | |
| ctx.fillStyle='#2b393c';roundedRect(ctx,-w/2,-h/2,w,h,8);ctx.fill();ctx.strokeStyle='#53656a';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.fillStyle='#17262b';ctx.fillRect(-39,-30,78,37);ctx.fillStyle='#354c52';ctx.fillRect(-45,15,90,20); | |
| ctx.fillStyle='#d7824c';for(let i=-38;i<38;i+=16){ctx.save();ctx.translate(i,25);ctx.rotate(-.65);ctx.fillRect(-2,-10,4,20);ctx.restore();} | |
| ctx.fillStyle='#72d3d2';ctx.fillRect(-31,-20,17,6);ctx.fillRect(-7,-20,17,6);ctx.fillRect(17,-20,17,6); | |
| } else if(b.type==='factory'){ | |
| ctx.fillStyle='#303c3d';roundedRect(ctx,-w/2,-h/2,w,h,8);ctx.fill();ctx.strokeStyle='#637074';ctx.lineWidth=3;ctx.stroke(); | |
| ctx.fillStyle='#16242a';roundedRect(ctx,-48,-34,96,58,5);ctx.fill();ctx.strokeStyle='#435b61';ctx.lineWidth=2;ctx.stroke(); | |
| for(let i=-38;i<=38;i+=19){ctx.strokeStyle='rgba(114,149,157,.35)';ctx.beginPath();ctx.moveTo(i,-28);ctx.lineTo(i,18);ctx.stroke();} | |
| ctx.fillStyle='#ecb354';ctx.fillRect(-52,31,104,7);ctx.fillStyle='#57e6e6';ctx.fillRect(-52,-45,30,4); | |
| ctx.fillStyle='#1d2b2f';ctx.beginPath();ctx.arc(47,-38,14,0,7);ctx.fill();ctx.strokeStyle='#61757a';ctx.stroke(); | |
| } else if(b.type==='relay'){ | |
| ctx.fillStyle='#26383d';ctx.beginPath();for(let i=0;i<8;i++){const a=Math.PI/8+i*Math.PI/4;ctx.lineTo(Math.cos(a)*w*.48,Math.sin(a)*h*.48);}ctx.closePath();ctx.fill();ctx.strokeStyle='#567079';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.strokeStyle='rgba(87,230,230,.7)';ctx.lineWidth=3;ctx.beginPath();ctx.arc(0,0,20+Math.sin(time*2)*2,0,7);ctx.stroke();ctx.fillStyle='#57e6e6';ctx.beginPath();ctx.arc(0,0,7,0,7);ctx.fill(); | |
| } else if(b.type==='outpost'){ | |
| ctx.fillStyle='#26363b';ctx.beginPath();ctx.arc(0,8,33,0,7);ctx.fill();ctx.strokeStyle='#5a747c';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#17272c';ctx.beginPath();ctx.arc(0,8,21,0,7);ctx.fill(); | |
| ctx.strokeStyle='#79edeb';ctx.lineWidth=2;ctx.save();ctx.rotate(time*.55);ctx.beginPath();ctx.moveTo(0,8);ctx.lineTo(34,8);ctx.arc(0,8,34,0,.55);ctx.stroke();ctx.restore(); | |
| ctx.fillStyle='#80f5f1';ctx.beginPath();ctx.arc(0,8,5,0,7);ctx.fill();ctx.strokeStyle='#677e84';ctx.lineWidth=4;ctx.beginPath();ctx.moveTo(0,-18);ctx.lineTo(0,-55);ctx.stroke();ctx.fillStyle='#57e6e6';ctx.fillRect(-4,-59,8,7); | |
| } else if(b.type==='refinery'){ | |
| ctx.fillStyle='#2a3b40';roundedRect(ctx,-w/2,-h/2,w,h,10);ctx.fill();ctx.strokeStyle='#526d75';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#13272d';ctx.beginPath();ctx.arc(-20,-4,23,0,7);ctx.fill();ctx.strokeStyle='#39c6ce';ctx.stroke(); | |
| ctx.fillStyle='#56dde0';ctx.beginPath();ctx.moveTo(-20,-21);ctx.lineTo(-9,-2);ctx.lineTo(-20,16);ctx.lineTo(-31,-2);ctx.closePath();ctx.fill();ctx.fillStyle='#3d5459';ctx.fillRect(14,-25,21,50);ctx.fillStyle='#e4a551';ctx.fillRect(18,-18,13,5);ctx.fillRect(18,-7,13,5);ctx.fillRect(18,4,13,5); | |
| } | |
| if(!b.complete){ | |
| ctx.globalAlpha=1;ctx.strokeStyle='rgba(255,189,86,.75)';ctx.lineWidth=2;ctx.setLineDash([7,5]);ctx.strokeRect(-w/2-5,-h/2-5,w+10,h+10);ctx.setLineDash([]); | |
| ctx.fillStyle='rgba(5,15,19,.85)';ctx.fillRect(-w/2,h/2+9,w,6);ctx.fillStyle='#ffbd56';ctx.fillRect(-w/2,h/2+9,w*clamp(b.progress,0,1),6); | |
| } | |
| if(b.selected){ctx.globalAlpha=1;ctx.strokeStyle='#65f0e9';ctx.lineWidth=2;ctx.setLineDash([5,5]);roundedRect(ctx,-w/2-8,-h/2-8,w+16,h+16,12);ctx.stroke();ctx.setLineDash([]);} | |
| ctx.restore(); | |
| } | |
| function drawUnit(u,time) { | |
| const t=TYPES[u.type];ctx.save();ctx.translate(u.x,u.y);ctx.rotate(u.angle); | |
| ctx.fillStyle='rgba(0,0,0,.34)';ctx.beginPath();ctx.ellipse(2,5,t.radius*1.15,t.radius*.67,0,0,7);ctx.fill(); | |
| if(u.type==='worker'){ | |
| ctx.fillStyle='#15272d';roundedRect(ctx,-14,-10,28,20,6);ctx.fill();ctx.strokeStyle='#54717a';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#e1a44e';ctx.fillRect(-11,-6,9,12);ctx.fillStyle='#314b52';ctx.fillRect(4,-6,6,12);ctx.fillStyle='#67e6e3';ctx.fillRect(6,-3,3,6); | |
| ctx.strokeStyle='#94acaf';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(10,-7);ctx.lineTo(18,-12);ctx.lineTo(21,-8);ctx.stroke(); | |
| } else if(u.type==='ranger'){ | |
| ctx.fillStyle='#28474e';ctx.beginPath();ctx.arc(0,1,11,0,7);ctx.fill();ctx.strokeStyle='#6e969e';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#d3b18d';ctx.beginPath();ctx.arc(2,-4,5,0,7);ctx.fill();ctx.fillStyle='#57d8d7';ctx.fillRect(-10,3,8,5);ctx.strokeStyle='#aec0c0';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(7,1);ctx.lineTo(20,1);ctx.stroke();ctx.fillStyle='#39484b';ctx.fillRect(15,-2,8,5); | |
| } else { | |
| ctx.fillStyle='#182529';roundedRect(ctx,-22,-15,44,30,7);ctx.fill();ctx.fillStyle='#41545a';ctx.fillRect(-17,-18,34,36);ctx.fillStyle='#202f34';ctx.beginPath();ctx.arc(3,0,11,0,7);ctx.fill();ctx.strokeStyle='#647b80';ctx.lineWidth=2;ctx.stroke();ctx.strokeStyle='#9faeae';ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(7,0);ctx.lineTo(29,0);ctx.stroke();ctx.fillStyle='#59e4e1';ctx.fillRect(-12,-10,8,3);ctx.fillRect(-12,7,8,3); | |
| } | |
| ctx.restore(); | |
| if(u.selected){ctx.strokeStyle='#69f0e9';ctx.lineWidth=2;ctx.beginPath();ctx.ellipse(u.x,u.y+t.radius*.72,t.radius*1.45,t.radius*.57,0,0,7);ctx.stroke();} | |
| if(u.carry>0){ctx.fillStyle='rgba(4,14,18,.8)';ctx.fillRect(u.x-15,u.y-t.radius-11,30,4);ctx.fillStyle='#56e5e1';ctx.fillRect(u.x-15,u.y-t.radius-11,30*(u.carry/u.maxCarry),4);} | |
| if(u.task&&u.task.kind==='gather'&&u.task.phase==='harvest'){ctx.fillStyle=`rgba(87,230,230,${.5+Math.sin(time*8)*.25})`;ctx.beginPath();ctx.arc(u.x,u.y-18,2,0,7);ctx.fill();} | |
| } | |
| function drawFog() { | |
| const cam=state.camera,z=cam.zoom; | |
| const minX=clamp(Math.floor(cam.x/FOG),0,fogCols-1), maxX=clamp(Math.ceil((cam.x+innerWidth/z)/FOG),0,fogCols-1); | |
| const minY=clamp(Math.floor(cam.y/FOG),0,fogRows-1), maxY=clamp(Math.ceil((cam.y+innerHeight/z)/FOG),0,fogRows-1); | |
| for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){ | |
| const i=y*fogCols+x;if(visible[i])continue; | |
| ctx.fillStyle=discovered[i]?'rgba(2,8,11,.58)':'rgba(1,4,6,.965)';ctx.fillRect(x*FOG-.5,y*FOG-.5,FOG+1,FOG+1); | |
| } | |
| } | |
| function render(time) { | |
| ctx.clearRect(0,0,innerWidth,innerHeight); | |
| ctx.save();ctx.scale(state.camera.zoom,state.camera.zoom);ctx.translate(-state.camera.x,-state.camera.y); | |
| drawTerrain(); | |
| state.nodes.forEach(n=>drawNode(n,time)); | |
| state.buildings.forEach(b=>drawBuilding(b,time)); | |
| state.units.slice().sort((a,b)=>a.y-b.y).forEach(u=>drawUnit(u,time)); | |
| if(state.pendingBuild){const t=TYPES[state.pendingBuild],ok=validBuild(state.pendingBuild,state.mouse.wx,state.mouse.wy);ctx.globalAlpha=.65;ctx.fillStyle=ok?'rgba(87,230,230,.26)':'rgba(242,107,99,.3)';ctx.strokeStyle=ok?'#57e6e6':'#f26b63';ctx.lineWidth=2/state.camera.zoom;ctx.fillRect(state.mouse.wx-t.w/2,state.mouse.wy-t.h/2,t.w,t.h);ctx.strokeRect(state.mouse.wx-t.w/2,state.mouse.wy-t.h/2,t.w,t.h);ctx.globalAlpha=1;} | |
| drawFog(); | |
| if(state.mouse.down&&state.mouse.dragging&&!state.pendingBuild){const x=Math.min(state.mouse.startWX,state.mouse.wx),y=Math.min(state.mouse.startWY,state.mouse.wy),w=Math.abs(state.mouse.wx-state.mouse.startWX),h=Math.abs(state.mouse.wy-state.mouse.startWY);ctx.fillStyle='rgba(87,230,230,.08)';ctx.fillRect(x,y,w,h);ctx.strokeStyle='#6be7e4';ctx.lineWidth=1/state.camera.zoom;ctx.strokeRect(x,y,w,h);} | |
| ctx.restore(); | |
| drawMinimap(); | |
| } | |
| function drawMinimap() { | |
| const w=mini.width,h=mini.height,sx=w/WORLD.w,sy=h/WORLD.h; | |
| mctx.fillStyle='#07100f';mctx.fillRect(0,0,w,h); | |
| for(let y=0;y<fogRows;y++)for(let x=0;x<fogCols;x++){ | |
| const i=y*fogCols+x;if(!discovered[i])continue; | |
| mctx.fillStyle=visible[i]?'#274436':'#172922';mctx.fillRect(x*FOG*sx,y*FOG*sy,FOG*sx+1,FOG*sy+1); | |
| } | |
| for(const n of state.nodes){if(n.amount<=0)continue;const gx=clamp(Math.floor(n.x/FOG),0,fogCols-1),gy=clamp(Math.floor(n.y/FOG),0,fogRows-1);if(discovered[gy*fogCols+gx]){mctx.fillStyle='#4fe5df';mctx.beginPath();mctx.arc(n.x*sx,n.y*sy,4,0,7);mctx.fill();}} | |
| mctx.fillStyle='#ffbb58';for(const b of state.buildings){if(b.complete)mctx.fillRect(b.x*sx-3,b.y*sy-3,6,6);} | |
| mctx.fillStyle='#d7ffff';for(const u of state.units){mctx.fillRect(u.x*sx-1.5,u.y*sy-1.5,3,3);} | |
| mctx.strokeStyle='rgba(255,255,255,.7)';mctx.lineWidth=2;const z=state.camera.zoom; | |
| mctx.strokeRect(state.camera.x*sx,state.camera.y*sy,innerWidth/z*sx,innerHeight/z*sy); | |
| } | |
| function select(items,add=false) { | |
| if(!add){state.selected.forEach(e=>e.selected=false);state.selected=[];} | |
| for(const e of items){if(!state.selected.includes(e)){e.selected=true;state.selected.push(e);}} | |
| updateUI(true); | |
| } | |
| function entityAt(x,y) { | |
| for(let i=state.units.length-1;i>=0;i--){const u=state.units[i];if(Math.hypot(x-u.x,y-u.y)<=TYPES[u.type].radius+7)return u;} | |
| for(let i=state.buildings.length-1;i>=0;i--){const b=state.buildings[i],t=TYPES[b.type];if(Math.abs(x-b.x)<=t.w/2&&Math.abs(y-b.y)<=t.h/2)return b;} | |
| return null; | |
| } | |
| function nodeAt(x,y) { return state.nodes.find(n=>n.amount>0&&Math.hypot(x-n.x,y-n.y)<=n.radius+16); } | |
| function cmdButton(icon,name,cost,action,hotkey='',disabled=false) { | |
| return `<button class="command" data-action="${action}" ${disabled?'disabled':''}><span class="cmd-icon">${icon}</span><b>${name}</b><small>${cost}</small>${hotkey?`<span class="hotkey">${hotkey}</span>`:''}</button>`; | |
| } | |
| function updateUI(force=false) { | |
| const sp=supply(); | |
| $('credits').textContent=Math.floor(state.credits);$('supply').textContent=`${sp.used} / ${sp.max}`;$('workers').textContent=state.units.filter(u=>u.type==='worker').length; | |
| $('exploreBar').style.width=(state.explored*100).toFixed(1)+'%';$('exploreText').textContent=Math.floor(state.explored*100)+'%'; | |
| const ids=state.selected.map(e=>e.id).join(','),queues=state.buildings.map(b=>b.queue.map(q=>q.type+q.progress.toFixed(1)).join()).join('|'); | |
| const sig=`${ids}/${state.pendingBuild}/${Math.floor(state.credits/10)}/${sp.used}/${sp.max}/${queues}/${state.buildings.map(b=>b.complete?'1':'0').join('')}`; | |
| if(!force&&sig===uiSignature)return;uiSignature=sig; | |
| const cards=$('selectionCards'); cards.innerHTML=''; | |
| state.selected.slice(0,8).forEach((e,i)=>{const d=document.createElement('div');d.className='unit-card '+(i===0?'active':'');d.textContent=TYPES[e.type].icon;d.title=TYPES[e.type].name;d.onclick=()=>select([e]);cards.appendChild(d);}); | |
| if(!state.selected.length){$('selectionInfo').innerHTML=`<div class="empty-help"><b>No units selected.</b><br><br>Drag a box around units, or left-click a building to access production. Use <b>WASD</b> to pan and the mouse wheel to zoom.</div>`;return;} | |
| const primary=state.selected[0],t=TYPES[primary.type]; | |
| let subtitle=primary.kind==='unit'?(primary.task?primary.task.kind.toUpperCase():'READY'):(primary.complete?'OPERATIONAL':'CONSTRUCTING'); | |
| let html=`<div class="selection-title"><div class="portrait">${t.icon}</div><div><h3>${state.selected.length>1?state.selected.length+' units selected':t.name}</h3><p>${subtitle}</p><div class="health"><i style="width:${primary.hp/primary.maxHp*100}%"></i></div></div></div>`; | |
| if(primary.kind==='building'&&primary.queue.length){const q=primary.queue[0];html+=`<div class="queue">Producing ${TYPES[q.type].name} · ${primary.queue.length} queued<div class="progress-track"><i style="width:${q.progress*100}%"></i></div></div>`;} | |
| html+='<div class="commands">'; | |
| const selectedWorkers=state.selected.filter(e=>e.kind==='unit'&&e.type==='worker'); | |
| if(selectedWorkers.length){ | |
| html+=cmdButton('▥','Barracks','180 Aether','build-barracks','B',state.credits<180); | |
| html+=cmdButton('▣','Foundry','320 Aether','build-factory','F',state.credits<320||!hasBuilding('barracks')); | |
| html+=cmdButton('◇','Supply Relay','120 Aether','build-relay','R',state.credits<120); | |
| html+=cmdButton('⌾','Sensor Post','150 Aether','build-outpost','O',state.credits<150); | |
| html+=cmdButton('◆','Aether Depot','210 Aether','build-refinery','E',state.credits<210); | |
| html+=cmdButton('■','Stop','Cancel order','stop','X'); | |
| } else if(primary.kind==='building'){ | |
| if(primary.type==='hq')html+=cmdButton('⌁','Collector','80 · 1 supply','train-worker','1',state.credits<80||sp.used+1>sp.max); | |
| if(primary.type==='barracks')html+=cmdButton('⌖','Pathfinder','110 · 1 supply','train-ranger','1',state.credits<110||sp.used+1>sp.max||!primary.complete); | |
| if(primary.type==='factory')html+=cmdButton('▰','Bulwark','260 · 3 supply','train-tank','1',state.credits<260||sp.used+3>sp.max||!primary.complete); | |
| if(!['hq','barracks','factory'].includes(primary.type))html+=`<div class="empty-help">${t.desc}</div>`; | |
| } else { | |
| html+=cmdButton('■','Stop','Cancel order','stop','X'); | |
| html+=cmdButton('⌖','Center view','Selected group','center-selection','C'); | |
| } | |
| html+='</div>';$('selectionInfo').innerHTML=html; | |
| document.querySelectorAll('[data-action]').forEach(btn=>btn.addEventListener('click',()=>handleAction(btn.dataset.action))); | |
| } | |
| function handleAction(a) { | |
| const p=state.selected[0]; | |
| if(a.startsWith('build-'))beginBuild(a.slice(6)); | |
| else if(a.startsWith('train-')&&p&&p.kind==='building')train(p,a.slice(6)); | |
| else if(a==='stop'){state.selected.filter(e=>e.kind==='unit').forEach(u=>{u.task=null;u.tx=u.x;u.ty=u.y;});setEvent('Selected units standing by.');updateUI(true);} | |
| else if(a==='center-selection'&&p)centerOn(p.x,p.y); | |
| } | |
| function pointerPos(e) { const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}; } | |
| canvas.addEventListener('pointerdown',e=>{ | |
| if(e.button!==0||state.paused)return;const p=pointerPos(e),w=screenToWorld(p.x,p.y); | |
| state.mouse.down=true;state.mouse.dragging=false;state.mouse.startX=p.x;state.mouse.startY=p.y;state.mouse.startWX=w.x;state.mouse.startWY=w.y; | |
| canvas.setPointerCapture(e.pointerId); | |
| }); | |
| canvas.addEventListener('pointermove',e=>{ | |
| const p=pointerPos(e),w=screenToWorld(p.x,p.y);Object.assign(state.mouse,{x:p.x,y:p.y,wx:w.x,wy:w.y}); | |
| if(state.mouse.down&&Math.hypot(p.x-state.mouse.startX,p.y-state.mouse.startY)>7)state.mouse.dragging=true; | |
| }); | |
| canvas.addEventListener('pointerup',e=>{ | |
| if(e.button!==0||!state.mouse.down)return;state.mouse.down=false; | |
| if(state.pendingBuild){placeBuild(state.mouse.wx,state.mouse.wy);return;} | |
| if(state.mouse.dragging){const x1=Math.min(state.mouse.startWX,state.mouse.wx),x2=Math.max(state.mouse.startWX,state.mouse.wx),y1=Math.min(state.mouse.startWY,state.mouse.wy),y2=Math.max(state.mouse.startWY,state.mouse.wy);select(state.units.filter(u=>u.x>=x1&&u.x<=x2&&u.y>=y1&&u.y<=y2),e.shiftKey);} | |
| else{const ent=entityAt(state.mouse.wx,state.mouse.wy);select(ent?[ent]:[],e.shiftKey);} | |
| state.mouse.dragging=false; | |
| }); | |
| canvas.addEventListener('contextmenu',e=>{ | |
| e.preventDefault();if(state.paused)return; | |
| if(state.pendingBuild){cancelBuild();toast('Construction placement cancelled.');return;} | |
| const p=pointerPos(e),w=screenToWorld(p.x,p.y),node=nodeAt(w.x,w.y),units=state.selected.filter(e=>e.kind==='unit'); | |
| if(node){const workers=units.filter(u=>u.type==='worker');workers.forEach(u=>orderGather(u,node));if(workers.length){setEvent(`${workers.length} Collector${workers.length>1?'s':''} assigned to Aether field.`);return;}} | |
| if(units.length){orderMove(units,w.x,w.y);setEvent(`${units.length} unit${units.length>1?'s':''} moving to waypoint.`);} | |
| }); | |
| canvas.addEventListener('wheel',e=>{ | |
| e.preventDefault();if(state.paused)return;const p=pointerPos(e),before=screenToWorld(p.x,p.y),factor=e.deltaY>0?.9:1.1; | |
| state.camera.zoom=clamp(state.camera.zoom*factor,.58,1.55);state.camera.x=before.x-p.x/state.camera.zoom;state.camera.y=before.y-p.y/state.camera.zoom;clampCamera(); | |
| },{passive:false}); | |
| mini.addEventListener('click',e=>{const r=mini.getBoundingClientRect(),x=(e.clientX-r.left)/r.width*WORLD.w,y=(e.clientY-r.top)/r.height*WORLD.h;centerOn(x,y);}); | |
| window.addEventListener('keydown',e=>{ | |
| const k=e.key.toLowerCase();state.keys.add(k);if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k))e.preventDefault(); | |
| if(e.repeat)return; | |
| if(k==='escape'){if(state.pendingBuild)cancelBuild();else select([]);} | |
| if(k==='p')togglePause();if(k==='h')centerOn(hq.x,hq.y); | |
| if(k==='x'&&state.selected.some(e=>e.kind==='unit'))handleAction('stop'); | |
| const primary=state.selected[0], workers=state.selected.some(e=>e.kind==='unit'&&e.type==='worker'); | |
| if(workers&&['b','f','r','o','e'].includes(k)){ | |
| const buildKey={b:'barracks',f:'factory',r:'relay',o:'outpost',e:'refinery'}[k]; | |
| if(!state.pendingBuild)beginBuild(buildKey); | |
| } | |
| if(k==='1'&&primary&&primary.kind==='building'){ | |
| const product={hq:'worker',barracks:'ranger',factory:'tank'}[primary.type]; | |
| if(product)train(primary,product); | |
| } | |
| if(k==='c'&&primary)centerOn(primary.x,primary.y); | |
| }); | |
| window.addEventListener('keyup',e=>state.keys.delete(e.key.toLowerCase())); | |
| window.addEventListener('resize',resize); | |
| function togglePause(force) { | |
| if(!state.started)return;state.paused=force!==undefined?force:!state.paused; | |
| $('pauseOverlay').classList.toggle('hidden',!state.paused);$('pauseBtn').textContent=state.paused?'▶':'Ⅱ';state.lastTime=performance.now(); | |
| } | |
| $('startBtn').onclick=()=>{state.started=true;state.paused=false;$('briefing').classList.add('hidden');select([starter]);updateFog();centerOn(560,490);setEvent('Select the Collector and harvest a nearby crystal field.');}; | |
| $('resumeBtn').onclick=()=>togglePause(false);$('pauseBtn').onclick=()=>togglePause();$('centerBtn').onclick=()=>centerOn(hq.x,hq.y); | |
| $('helpBtn').onclick=()=>{if(state.started)togglePause(true);$('briefing').classList.remove('hidden');}; | |
| $('briefing').addEventListener('click',e=>{if(e.target.id==='briefing'&&state.started){$('briefing').classList.add('hidden');togglePause(false);}}); | |
| function loop(now) { | |
| const dt=Math.min(.05,(now-state.lastTime)/1000);state.lastTime=now;if(!state.paused)update(dt);render(now/1000);requestAnimationFrame(loop); | |
| } | |
| resize();updateFog();updateUI(true);requestAnimationFrame(loop); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment