Created
April 29, 2026 13:45
-
-
Save senko/816194af33efd3947fe4ec2026f7ad7b to your computer and use it in GitHub Desktop.
RTS game by OpenAI GPT-5.5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Frontier Command - Single File RTS</title> | |
| <style> | |
| :root { | |
| --ink: #e8f4dc; | |
| --muted: #9eb5a6; | |
| --panel: rgba(10, 22, 22, 0.88); | |
| --panel-strong: rgba(6, 13, 14, 0.94); | |
| --line: rgba(170, 221, 177, 0.22); | |
| --good: #9be86a; | |
| --warn: #ffd166; | |
| --bad: #ff6b6b; | |
| --teal: #4fd1c5; | |
| --amber: #e8c547; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| height: 100%; | |
| overflow: hidden; | |
| color: var(--ink); | |
| background: | |
| radial-gradient(circle at 15% 8%, rgba(70, 132, 93, 0.35), transparent 28rem), | |
| radial-gradient(circle at 90% 85%, rgba(57, 90, 111, 0.28), transparent 26rem), | |
| #071111; | |
| font-family: "Trebuchet MS", "Segoe UI", sans-serif; | |
| user-select: none; | |
| } | |
| #game { | |
| display: block; | |
| width: 100vw; | |
| height: 100vh; | |
| cursor: crosshair; | |
| } | |
| .topbar { | |
| position: fixed; | |
| top: 16px; | |
| left: 16px; | |
| right: 336px; | |
| min-height: 54px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 10px 14px; | |
| border: 1px solid var(--line); | |
| border-radius: 16px; | |
| background: linear-gradient(135deg, rgba(8, 20, 19, 0.92), rgba(17, 33, 27, 0.78)); | |
| box-shadow: 0 16px 50px rgba(0, 0, 0, 0.28), inset 0 1px rgba(255, 255, 255, 0.08); | |
| pointer-events: none; | |
| backdrop-filter: blur(10px); | |
| } | |
| .brand { | |
| display: grid; | |
| gap: 1px; | |
| min-width: 170px; | |
| padding-right: 10px; | |
| border-right: 1px solid var(--line); | |
| letter-spacing: 0.05em; | |
| text-transform: uppercase; | |
| } | |
| .brand strong { | |
| font-size: 15px; | |
| color: #f2ffe5; | |
| } | |
| .brand span { | |
| font-size: 10px; | |
| color: var(--muted); | |
| } | |
| .stat { | |
| display: grid; | |
| gap: 1px; | |
| min-width: 96px; | |
| } | |
| .stat small { | |
| color: var(--muted); | |
| font-size: 10px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .stat b { | |
| font-size: 17px; | |
| color: #fff6cb; | |
| } | |
| .progress-shell { | |
| width: min(260px, 24vw); | |
| height: 10px; | |
| overflow: hidden; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| border-radius: 999px; | |
| background: rgba(255, 255, 255, 0.07); | |
| } | |
| .progress-fill { | |
| width: 0%; | |
| height: 100%; | |
| border-radius: inherit; | |
| background: linear-gradient(90deg, #88e06a, #d6f78c); | |
| box-shadow: 0 0 16px rgba(155, 232, 106, 0.45); | |
| transition: width 0.18s ease; | |
| } | |
| .side-panel { | |
| position: fixed; | |
| top: 16px; | |
| right: 16px; | |
| width: 304px; | |
| max-height: calc(100vh - 32px); | |
| overflow: auto; | |
| padding: 14px; | |
| border: 1px solid var(--line); | |
| border-radius: 18px; | |
| background: linear-gradient(180deg, var(--panel), var(--panel-strong)); | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.38), inset 0 1px rgba(255, 255, 255, 0.06); | |
| backdrop-filter: blur(12px); | |
| } | |
| .side-panel h2, | |
| .side-panel h3 { | |
| margin: 0 0 8px; | |
| letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| } | |
| .side-panel h2 { | |
| font-size: 18px; | |
| } | |
| .side-panel h3 { | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| .card { | |
| margin-top: 12px; | |
| padding: 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.09); | |
| border-radius: 14px; | |
| background: rgba(255, 255, 255, 0.045); | |
| } | |
| .hint { | |
| margin: 7px 0; | |
| color: var(--muted); | |
| font-size: 12px; | |
| line-height: 1.45; | |
| } | |
| .row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| margin: 8px 0; | |
| font-size: 13px; | |
| color: var(--muted); | |
| } | |
| .row b { | |
| color: var(--ink); | |
| font-size: 14px; | |
| } | |
| .button-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 8px; | |
| margin-top: 10px; | |
| } | |
| button { | |
| min-height: 40px; | |
| color: #f5ffe9; | |
| border: 1px solid rgba(255, 255, 255, 0.14); | |
| border-radius: 12px; | |
| background: | |
| linear-gradient(180deg, rgba(78, 117, 84, 0.85), rgba(34, 58, 49, 0.92)); | |
| font: inherit; | |
| font-size: 12px; | |
| font-weight: 700; | |
| letter-spacing: 0.03em; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| box-shadow: inset 0 1px rgba(255, 255, 255, 0.12), 0 7px 18px rgba(0, 0, 0, 0.18); | |
| } | |
| button:hover { | |
| border-color: rgba(214, 247, 140, 0.42); | |
| filter: brightness(1.08); | |
| } | |
| button:active { | |
| transform: translateY(1px); | |
| } | |
| button.danger { | |
| background: linear-gradient(180deg, rgba(119, 75, 57, 0.85), rgba(72, 37, 33, 0.92)); | |
| } | |
| button:disabled { | |
| color: rgba(255, 255, 255, 0.4); | |
| cursor: not-allowed; | |
| filter: grayscale(0.8) brightness(0.7); | |
| } | |
| .mini-wrap { | |
| position: fixed; | |
| left: 16px; | |
| bottom: 16px; | |
| width: 224px; | |
| padding: 10px; | |
| border: 1px solid var(--line); | |
| border-radius: 16px; | |
| background: rgba(8, 17, 18, 0.86); | |
| box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32); | |
| backdrop-filter: blur(8px); | |
| } | |
| .mini-wrap canvas { | |
| display: block; | |
| width: 204px; | |
| height: 153px; | |
| border-radius: 10px; | |
| background: #050a0b; | |
| cursor: pointer; | |
| } | |
| .mini-label { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 7px; | |
| color: var(--muted); | |
| font-size: 10px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .toast { | |
| position: fixed; | |
| left: 260px; | |
| bottom: 22px; | |
| width: min(520px, calc(100vw - 600px)); | |
| pointer-events: none; | |
| } | |
| .toast div { | |
| margin-top: 8px; | |
| padding: 9px 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| border-radius: 12px; | |
| color: #f8ffe9; | |
| background: rgba(8, 18, 18, 0.82); | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); | |
| font-size: 12px; | |
| } | |
| kbd { | |
| display: inline-block; | |
| min-width: 18px; | |
| padding: 1px 5px; | |
| border: 1px solid rgba(255, 255, 255, 0.22); | |
| border-radius: 5px; | |
| color: #fff9cf; | |
| background: rgba(255, 255, 255, 0.08); | |
| font-size: 11px; | |
| text-align: center; | |
| } | |
| @media (max-width: 900px) { | |
| .topbar { | |
| right: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .side-panel { | |
| top: auto; | |
| bottom: 16px; | |
| width: min(360px, calc(100vw - 32px)); | |
| max-height: 42vh; | |
| } | |
| .mini-wrap, | |
| .toast { | |
| display: none; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game" aria-label="Frontier Command game canvas"></canvas> | |
| <div class="topbar"> | |
| <div class="brand"> | |
| <strong>Frontier Command</strong> | |
| <span>single file RTS</span> | |
| </div> | |
| <div class="stat"> | |
| <small>Crystals</small> | |
| <b id="crystalStat">0</b> | |
| </div> | |
| <div class="stat"> | |
| <small>Units</small> | |
| <b id="unitStat">0</b> | |
| </div> | |
| <div class="stat"> | |
| <small>Surveyed</small> | |
| <b id="surveyStat">0%</b> | |
| </div> | |
| <div class="progress-shell" title="Explore the whole map"> | |
| <div id="surveyFill" class="progress-fill"></div> | |
| </div> | |
| </div> | |
| <aside class="side-panel"> | |
| <h2>Command Console</h2> | |
| <div id="panelBody"></div> | |
| </aside> | |
| <div class="mini-wrap"> | |
| <div class="mini-label"> | |
| <span>Tactical Map</span> | |
| <span>Click to pan</span> | |
| </div> | |
| <canvas id="minimap" width="204" height="153"></canvas> | |
| </div> | |
| <div id="toast" class="toast"></div> | |
| <script> | |
| (() => { | |
| "use strict"; | |
| const canvas = document.getElementById("game"); | |
| const ctx = canvas.getContext("2d"); | |
| const minimap = document.getElementById("minimap"); | |
| const mctx = minimap.getContext("2d"); | |
| const panelBody = document.getElementById("panelBody"); | |
| const toastEl = document.getElementById("toast"); | |
| const crystalStat = document.getElementById("crystalStat"); | |
| const unitStat = document.getElementById("unitStat"); | |
| const surveyStat = document.getElementById("surveyStat"); | |
| const surveyFill = document.getElementById("surveyFill"); | |
| const TILE = 32; | |
| const MAP_W = 96; | |
| const MAP_H = 72; | |
| const MAP_PIX_W = MAP_W * TILE; | |
| const MAP_PIX_H = MAP_H * TILE; | |
| const MAX_QUEUE = 5; | |
| const TERRAIN = { | |
| GRASS: 0, | |
| MOSS: 1, | |
| DIRT: 2, | |
| SCRUB: 3, | |
| CLAY: 4 | |
| }; | |
| const TERRAIN_PALETTE = [ | |
| ["#35633c", "#2e5735"], | |
| ["#2e5749", "#274a3e"], | |
| ["#6a5938", "#58492f"], | |
| ["#3f6731", "#35572a"], | |
| ["#74533c", "#634630"] | |
| ]; | |
| const UNIT_DEFS = { | |
| worker: { | |
| name: "Worker", | |
| cost: 65, | |
| buildTime: 8, | |
| hp: 70, | |
| speed: 92, | |
| sight: 7, | |
| radius: 11, | |
| capacity: 25, | |
| role: "Harvests crystals and constructs buildings." | |
| }, | |
| ranger: { | |
| name: "Ranger", | |
| cost: 95, | |
| buildTime: 10, | |
| hp: 95, | |
| speed: 82, | |
| sight: 8, | |
| radius: 12, | |
| capacity: 0, | |
| role: "Fast scout infantry for uncovering the map." | |
| } | |
| }; | |
| const BUILDING_DEFS = { | |
| command: { | |
| name: "Command Hall", | |
| cost: 0, | |
| buildTime: 0, | |
| hp: 850, | |
| w: 4, | |
| h: 4, | |
| sight: 11, | |
| deposit: true, | |
| produces: ["worker"], | |
| role: "Drop-off point and worker training center." | |
| }, | |
| barracks: { | |
| name: "Barracks", | |
| cost: 230, | |
| buildTime: 18, | |
| hp: 540, | |
| w: 3, | |
| h: 3, | |
| sight: 8, | |
| deposit: false, | |
| produces: ["ranger"], | |
| role: "Trains ranger infantry for exploration." | |
| }, | |
| depot: { | |
| name: "Relay Depot", | |
| cost: 140, | |
| buildTime: 14, | |
| hp: 420, | |
| w: 3, | |
| h: 2, | |
| sight: 8, | |
| deposit: true, | |
| produces: [], | |
| role: "Extra crystal drop-off point for distant fields." | |
| }, | |
| tower: { | |
| name: "Signal Tower", | |
| cost: 125, | |
| buildTime: 12, | |
| hp: 360, | |
| w: 2, | |
| h: 2, | |
| sight: 16, | |
| deposit: false, | |
| produces: [], | |
| role: "Large vision radius for permanent map coverage." | |
| } | |
| }; | |
| const state = { | |
| crystals: 420, | |
| terrain: new Uint8Array(MAP_W * MAP_H), | |
| explored: new Uint8Array(MAP_W * MAP_H), | |
| visible: new Uint8Array(MAP_W * MAP_H), | |
| exploredCount: 0, | |
| units: [], | |
| buildings: [], | |
| nodes: [], | |
| selected: [], | |
| buildMode: null, | |
| camera: { x: 0, y: 0 }, | |
| view: { w: window.innerWidth, h: window.innerHeight, dpr: 1 }, | |
| mouse: { | |
| x: 0, | |
| y: 0, | |
| worldX: 0, | |
| worldY: 0, | |
| down: false, | |
| drag: false, | |
| startX: 0, | |
| startY: 0, | |
| button: 0 | |
| }, | |
| keys: new Set(), | |
| messages: [], | |
| lastPanelText: "", | |
| uiTimer: 0, | |
| won: false, | |
| lastTime: performance.now(), | |
| nextId: 1 | |
| }; | |
| function idx(x, y) { | |
| return y * MAP_W + x; | |
| } | |
| function inMap(x, y) { | |
| return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H; | |
| } | |
| function clamp(value, min, max) { | |
| return Math.max(min, Math.min(max, value)); | |
| } | |
| function tileCenter(tile) { | |
| return { | |
| x: (tile.x + 0.5) * TILE, | |
| y: (tile.y + 0.5) * TILE | |
| }; | |
| } | |
| function worldToTile(x, y) { | |
| return { | |
| x: clamp(Math.floor(x / TILE), 0, MAP_W - 1), | |
| y: clamp(Math.floor(y / TILE), 0, MAP_H - 1) | |
| }; | |
| } | |
| function id(prefix) { | |
| const value = `${prefix}${state.nextId}`; | |
| state.nextId += 1; | |
| return value; | |
| } | |
| function rng(seed) { | |
| let a = seed >>> 0; | |
| return function next() { | |
| a += 0x6d2b79f5; | |
| let t = a; | |
| t = Math.imul(t ^ (t >>> 15), t | 1); | |
| t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| } | |
| const rand = rng(74829); | |
| function createMap() { | |
| for (let y = 0; y < MAP_H; y += 1) { | |
| for (let x = 0; x < MAP_W; x += 1) { | |
| const n = rand(); | |
| let type = TERRAIN.GRASS; | |
| if (n > 0.82) type = TERRAIN.MOSS; | |
| if (n < 0.14) type = TERRAIN.SCRUB; | |
| if ((x + y * 2) % 17 === 0 && rand() > 0.48) type = TERRAIN.DIRT; | |
| state.terrain[idx(x, y)] = type; | |
| } | |
| } | |
| for (let patch = 0; patch < 58; patch += 1) { | |
| const cx = Math.floor(rand() * MAP_W); | |
| const cy = Math.floor(rand() * MAP_H); | |
| const radius = 2 + Math.floor(rand() * 5); | |
| const type = rand() > 0.58 ? TERRAIN.DIRT : TERRAIN.CLAY; | |
| for (let y = cy - radius; y <= cy + radius; y += 1) { | |
| for (let x = cx - radius; x <= cx + radius; x += 1) { | |
| if (!inMap(x, y)) continue; | |
| const d = Math.hypot(x - cx, y - cy); | |
| if (d <= radius * (0.55 + rand() * 0.45)) { | |
| state.terrain[idx(x, y)] = type; | |
| } | |
| } | |
| } | |
| } | |
| clearTerrainRect(5, 6, 18, 15); | |
| clearTerrainRect(17, 8, 10, 8); | |
| addCrystalCluster(20, 12, 10, 360); | |
| addCrystalCluster(45, 18, 10, 460); | |
| addCrystalCluster(72, 15, 9, 430); | |
| addCrystalCluster(34, 42, 12, 500); | |
| addCrystalCluster(78, 46, 12, 520); | |
| addCrystalCluster(56, 61, 10, 480); | |
| addCrystalCluster(15, 59, 9, 420); | |
| addCrystalCluster(88, 65, 7, 390); | |
| } | |
| function clearTerrainRect(x, y, w, h) { | |
| for (let ty = y; ty < y + h; ty += 1) { | |
| for (let tx = x; tx < x + w; tx += 1) { | |
| if (inMap(tx, ty)) state.terrain[idx(tx, ty)] = TERRAIN.GRASS; | |
| } | |
| } | |
| } | |
| function addCrystalCluster(cx, cy, count, amount) { | |
| const used = new Set(); | |
| let placed = 0; | |
| let attempts = 0; | |
| while (placed < count && attempts < count * 30) { | |
| attempts += 1; | |
| const angle = rand() * Math.PI * 2; | |
| const dist = Math.sqrt(rand()) * 4.1; | |
| const x = clamp(Math.round(cx + Math.cos(angle) * dist), 1, MAP_W - 2); | |
| const y = clamp(Math.round(cy + Math.sin(angle) * dist), 1, MAP_H - 2); | |
| const key = `${x},${y}`; | |
| if (used.has(key) || buildingAtTile(x, y) || resourceAtTile(x, y)) continue; | |
| used.add(key); | |
| state.nodes.push({ | |
| id: id("r"), | |
| kind: "resource", | |
| type: "crystal", | |
| x, | |
| y, | |
| amount: Math.floor(amount * (0.75 + rand() * 0.5)), | |
| maxAmount: amount | |
| }); | |
| placed += 1; | |
| } | |
| } | |
| function createUnit(type, tileX, tileY) { | |
| const def = UNIT_DEFS[type]; | |
| const center = tileCenter({ x: tileX, y: tileY }); | |
| return { | |
| id: id("u"), | |
| kind: "unit", | |
| type, | |
| x: center.x, | |
| y: center.y, | |
| hp: def.hp, | |
| maxHp: def.hp, | |
| path: [], | |
| task: null, | |
| carrying: 0, | |
| harvestTimer: 0, | |
| facing: 0 | |
| }; | |
| } | |
| function createBuilding(type, tileX, tileY, complete) { | |
| const def = BUILDING_DEFS[type]; | |
| return { | |
| id: id("b"), | |
| kind: "building", | |
| type, | |
| x: tileX, | |
| y: tileY, | |
| w: def.w, | |
| h: def.h, | |
| hp: def.hp, | |
| maxHp: def.hp, | |
| complete, | |
| progress: complete ? def.buildTime : 0, | |
| queue: [], | |
| rally: { | |
| x: clamp(tileX + def.w + 2, 0, MAP_W - 1), | |
| y: clamp(tileY + Math.floor(def.h / 2), 0, MAP_H - 1) | |
| } | |
| }; | |
| } | |
| function setupGame() { | |
| createMap(); | |
| const command = createBuilding("command", 8, 9, true); | |
| state.buildings.push(command); | |
| state.units.push(createUnit("worker", 13, 14)); | |
| state.units.push(createUnit("worker", 14, 12)); | |
| state.units.push(createUnit("worker", 13, 10)); | |
| selectEntities([state.units[0]]); | |
| centerCameraOn(command.x * TILE + command.w * TILE / 2, command.y * TILE + command.h * TILE / 2); | |
| updateFog(); | |
| addMessage("Objective: gather crystals, build a base, train scouts, and uncover 100% of the map."); | |
| } | |
| function terrainPassable(x, y) { | |
| return inMap(x, y); | |
| } | |
| function resourceAtTile(x, y) { | |
| return state.nodes.find((node) => node.amount > 0 && node.x === x && node.y === y) || null; | |
| } | |
| function buildingAtTile(x, y, ignoreId) { | |
| return state.buildings.find((building) => { | |
| if (building.id === ignoreId) return false; | |
| return x >= building.x && y >= building.y && x < building.x + building.w && y < building.y + building.h; | |
| }) || null; | |
| } | |
| function isTileWalkable(x, y) { | |
| if (!terrainPassable(x, y)) return false; | |
| if (buildingAtTile(x, y)) return false; | |
| if (resourceAtTile(x, y)) return false; | |
| return true; | |
| } | |
| function unitTile(unit) { | |
| return worldToTile(unit.x, unit.y); | |
| } | |
| function selectedEntities() { | |
| const alive = new Set([ | |
| ...state.units.map((unit) => unit.id), | |
| ...state.buildings.map((building) => building.id), | |
| ...state.nodes.filter((node) => node.amount > 0).map((node) => node.id) | |
| ]); | |
| state.selected = state.selected.filter((entity) => alive.has(entity.id)); | |
| return state.selected; | |
| } | |
| function selectEntities(entities, append = false) { | |
| if (!append) state.selected = []; | |
| for (const entity of entities) { | |
| if (!entity) continue; | |
| if (!state.selected.some((item) => item.id === entity.id)) { | |
| state.selected.push(entity); | |
| } | |
| } | |
| state.lastPanelText = ""; | |
| updatePanel(true); | |
| } | |
| function clearSelection() { | |
| state.selected = []; | |
| state.lastPanelText = ""; | |
| updatePanel(true); | |
| } | |
| class BinaryHeap { | |
| constructor() { | |
| this.items = []; | |
| } | |
| push(item) { | |
| this.items.push(item); | |
| this.bubble(this.items.length - 1); | |
| } | |
| pop() { | |
| if (this.items.length === 1) return this.items.pop(); | |
| const top = this.items[0]; | |
| this.items[0] = this.items.pop(); | |
| this.sink(0); | |
| return top; | |
| } | |
| get length() { | |
| return this.items.length; | |
| } | |
| bubble(index) { | |
| while (index > 0) { | |
| const parent = Math.floor((index - 1) / 2); | |
| if (this.items[parent].f <= this.items[index].f) break; | |
| [this.items[parent], this.items[index]] = [this.items[index], this.items[parent]]; | |
| index = parent; | |
| } | |
| } | |
| sink(index) { | |
| while (true) { | |
| const left = index * 2 + 1; | |
| const right = left + 1; | |
| let smallest = index; | |
| if (left < this.items.length && this.items[left].f < this.items[smallest].f) smallest = left; | |
| if (right < this.items.length && this.items[right].f < this.items[smallest].f) smallest = right; | |
| if (smallest === index) break; | |
| [this.items[smallest], this.items[index]] = [this.items[index], this.items[smallest]]; | |
| index = smallest; | |
| } | |
| } | |
| } | |
| function findPath(startX, startY, goalX, goalY) { | |
| if (!inMap(startX, startY) || !inMap(goalX, goalY)) return []; | |
| const target = isTileWalkable(goalX, goalY) | |
| ? { x: goalX, y: goalY } | |
| : nearestWalkable(goalX, goalY, startX, startY, 9); | |
| if (!target) return []; | |
| const goalIndex = idx(target.x, target.y); | |
| const startIndex = idx(startX, startY); | |
| if (startIndex === goalIndex) return [{ x: startX, y: startY }]; | |
| const came = new Int32Array(MAP_W * MAP_H); | |
| const score = new Float32Array(MAP_W * MAP_H); | |
| const closed = new Uint8Array(MAP_W * MAP_H); | |
| came.fill(-1); | |
| score.fill(Infinity); | |
| score[startIndex] = 0; | |
| const open = new BinaryHeap(); | |
| open.push({ x: startX, y: startY, i: startIndex, f: 0 }); | |
| const dirs = [ | |
| [1, 0, 1], | |
| [-1, 0, 1], | |
| [0, 1, 1], | |
| [0, -1, 1], | |
| [1, 1, 1.42], | |
| [1, -1, 1.42], | |
| [-1, 1, 1.42], | |
| [-1, -1, 1.42] | |
| ]; | |
| let limit = 0; | |
| while (open.length && limit < 8000) { | |
| limit += 1; | |
| const current = open.pop(); | |
| if (closed[current.i]) continue; | |
| if (current.i === goalIndex) return rebuildPath(came, current.i); | |
| closed[current.i] = 1; | |
| for (const [dx, dy, cost] of dirs) { | |
| const nx = current.x + dx; | |
| const ny = current.y + dy; | |
| if (!inMap(nx, ny) || !isTileWalkable(nx, ny)) continue; | |
| if (dx !== 0 && dy !== 0 && (!isTileWalkable(current.x + dx, current.y) || !isTileWalkable(current.x, current.y + dy))) { | |
| continue; | |
| } | |
| const ni = idx(nx, ny); | |
| if (closed[ni]) continue; | |
| const newScore = score[current.i] + cost; | |
| if (newScore < score[ni]) { | |
| came[ni] = current.i; | |
| score[ni] = newScore; | |
| const h = Math.hypot(target.x - nx, target.y - ny); | |
| open.push({ x: nx, y: ny, i: ni, f: newScore + h }); | |
| } | |
| } | |
| } | |
| return []; | |
| } | |
| function rebuildPath(came, currentIndex) { | |
| const path = []; | |
| while (currentIndex !== -1) { | |
| path.push({ x: currentIndex % MAP_W, y: Math.floor(currentIndex / MAP_W) }); | |
| currentIndex = came[currentIndex]; | |
| } | |
| path.reverse(); | |
| return path; | |
| } | |
| function nearestWalkable(x, y, fromX, fromY, maxRadius) { | |
| let best = null; | |
| let bestScore = Infinity; | |
| for (let r = 0; r <= maxRadius; r += 1) { | |
| for (let ty = y - r; ty <= y + r; ty += 1) { | |
| for (let tx = x - r; tx <= x + r; tx += 1) { | |
| if (!inMap(tx, ty) || !isTileWalkable(tx, ty)) continue; | |
| const edge = Math.max(Math.abs(tx - x), Math.abs(ty - y)); | |
| if (edge !== r) continue; | |
| const score = Math.hypot(tx - x, ty - y) + Math.hypot(tx - fromX, ty - fromY) * 0.12; | |
| if (score < bestScore) { | |
| best = { x: tx, y: ty }; | |
| bestScore = score; | |
| } | |
| } | |
| } | |
| if (best) return best; | |
| } | |
| return null; | |
| } | |
| function adjacentTileForRect(rect, fromTile) { | |
| let best = null; | |
| let bestScore = Infinity; | |
| for (let y = rect.y - 1; y <= rect.y + rect.h; y += 1) { | |
| for (let x = rect.x - 1; x <= rect.x + rect.w; x += 1) { | |
| const onEdge = x === rect.x - 1 || x === rect.x + rect.w || y === rect.y - 1 || y === rect.y + rect.h; | |
| if (!onEdge || !inMap(x, y) || !isTileWalkable(x, y)) continue; | |
| const score = Math.hypot(x - fromTile.x, y - fromTile.y); | |
| if (score < bestScore) { | |
| best = { x, y }; | |
| bestScore = score; | |
| } | |
| } | |
| } | |
| return best; | |
| } | |
| function setUnitDestination(unit, targetX, targetY) { | |
| const start = unitTile(unit); | |
| const target = nearestWalkable(targetX, targetY, start.x, start.y, 10); | |
| if (!target) { | |
| unit.path = []; | |
| return false; | |
| } | |
| const path = findPath(start.x, start.y, target.x, target.y); | |
| if (!path.length) { | |
| unit.path = []; | |
| return false; | |
| } | |
| unit.path = path.slice(1); | |
| return true; | |
| } | |
| function setPathToRect(unit, rect) { | |
| const start = unitTile(unit); | |
| const tile = adjacentTileForRect(rect, start); | |
| if (!tile) return false; | |
| const path = findPath(start.x, start.y, tile.x, tile.y); | |
| unit.path = path.slice(1); | |
| return path.length > 0; | |
| } | |
| function update(dt) { | |
| updateCamera(dt); | |
| updateProduction(dt); | |
| for (const unit of state.units) { | |
| updateUnitMovement(unit, dt); | |
| } | |
| for (const unit of state.units) { | |
| updateUnitTask(unit, dt); | |
| } | |
| state.nodes = state.nodes.filter((node) => node.amount > 0); | |
| updateFog(); | |
| updateWinState(); | |
| state.uiTimer -= dt; | |
| if (state.uiTimer <= 0) { | |
| state.uiTimer = 0.18; | |
| updatePanel(); | |
| updateStats(); | |
| updateToasts(); | |
| } | |
| } | |
| function updateCamera(dt) { | |
| let dx = 0; | |
| let dy = 0; | |
| if (state.keys.has("arrowleft") || state.keys.has("a")) dx -= 1; | |
| if (state.keys.has("arrowright") || state.keys.has("d")) dx += 1; | |
| if (state.keys.has("arrowup") || state.keys.has("w")) dy -= 1; | |
| if (state.keys.has("arrowdown") || state.keys.has("s")) dy += 1; | |
| const edge = 16; | |
| if (state.mouse.x <= edge) dx -= 0.9; | |
| if (state.mouse.x >= state.view.w - edge) dx += 0.9; | |
| if (state.mouse.y <= edge) dy -= 0.9; | |
| if (state.mouse.y >= state.view.h - edge) dy += 0.9; | |
| if (dx || dy) { | |
| const len = Math.hypot(dx, dy) || 1; | |
| const speed = (state.keys.has("shift") ? 850 : 520) * dt; | |
| state.camera.x += (dx / len) * speed; | |
| state.camera.y += (dy / len) * speed; | |
| clampCamera(); | |
| } | |
| } | |
| function updateProduction(dt) { | |
| for (const building of state.buildings) { | |
| if (!building.complete || !building.queue.length) continue; | |
| const item = building.queue[0]; | |
| item.elapsed += dt; | |
| if (item.elapsed >= item.total) { | |
| const spawn = spawnTileForBuilding(building); | |
| if (!spawn) { | |
| item.elapsed = item.total - 0.25; | |
| continue; | |
| } | |
| const unit = createUnit(item.type, spawn.x, spawn.y); | |
| state.units.push(unit); | |
| building.queue.shift(); | |
| if (building.rally && (building.rally.x !== spawn.x || building.rally.y !== spawn.y)) { | |
| unit.task = { type: "move" }; | |
| setUnitDestination(unit, building.rally.x, building.rally.y); | |
| } | |
| addMessage(`${UNIT_DEFS[item.type].name} ready.`); | |
| } | |
| } | |
| } | |
| function updateUnitMovement(unit, dt) { | |
| if (!unit.path.length) return; | |
| const next = unit.path[0]; | |
| const center = tileCenter(next); | |
| const dx = center.x - unit.x; | |
| const dy = center.y - unit.y; | |
| const dist = Math.hypot(dx, dy); | |
| if (dist < 2) { | |
| unit.x = center.x; | |
| unit.y = center.y; | |
| unit.path.shift(); | |
| return; | |
| } | |
| const def = UNIT_DEFS[unit.type]; | |
| const step = Math.min(dist, def.speed * dt); | |
| unit.x += (dx / dist) * step; | |
| unit.y += (dy / dist) * step; | |
| unit.facing = Math.atan2(dy, dx); | |
| } | |
| function updateUnitTask(unit, dt) { | |
| if (!unit.task) return; | |
| if (unit.type === "worker" && unit.task.type === "gather") { | |
| updateGatherTask(unit, dt); | |
| } else if (unit.type === "worker" && unit.task.type === "build") { | |
| updateBuildTask(unit, dt); | |
| } else if (unit.task.type === "move" && !unit.path.length) { | |
| unit.task = null; | |
| } | |
| } | |
| function updateGatherTask(unit, dt) { | |
| const task = unit.task; | |
| const node = state.nodes.find((item) => item.id === task.nodeId && item.amount > 0); | |
| if (!node && unit.carrying <= 0) { | |
| unit.task = null; | |
| addMessage("Crystal field depleted."); | |
| return; | |
| } | |
| if (unit.carrying >= UNIT_DEFS.worker.capacity || (!node && unit.carrying > 0)) { | |
| const depot = nearestDepot(unit); | |
| if (!depot) { | |
| unit.task = null; | |
| addMessage("No completed depot available."); | |
| return; | |
| } | |
| if (!nearRect(unit, depot, 1.45)) { | |
| if (!unit.path.length || task.phase !== "toDepot") { | |
| setPathToRect(unit, depot); | |
| task.phase = "toDepot"; | |
| } | |
| return; | |
| } | |
| state.crystals += unit.carrying; | |
| addFloatText(`+${unit.carrying}`, unit.x, unit.y - 14, "#ffe680"); | |
| unit.carrying = 0; | |
| unit.harvestTimer = 0; | |
| if (!node) { | |
| unit.task = null; | |
| return; | |
| } | |
| } | |
| if (!node) return; | |
| if (!nearRect(unit, { x: node.x, y: node.y, w: 1, h: 1 }, 1.2)) { | |
| if (!unit.path.length || task.phase !== "toNode") { | |
| setPathToRect(unit, { x: node.x, y: node.y, w: 1, h: 1 }); | |
| task.phase = "toNode"; | |
| } | |
| return; | |
| } | |
| unit.path = []; | |
| task.phase = "harvest"; | |
| unit.harvestTimer += dt; | |
| if (unit.harvestTimer >= 0.95) { | |
| unit.harvestTimer = 0; | |
| const capacity = UNIT_DEFS.worker.capacity - unit.carrying; | |
| const mined = Math.min(10, capacity, node.amount); | |
| node.amount -= mined; | |
| unit.carrying += mined; | |
| addFloatText("mine", unit.x, unit.y - 18, "#9be8ff"); | |
| if (unit.carrying >= UNIT_DEFS.worker.capacity || node.amount <= 0) { | |
| task.phase = "toDepot"; | |
| const depot = nearestDepot(unit); | |
| if (depot) setPathToRect(unit, depot); | |
| } | |
| } | |
| } | |
| function updateBuildTask(unit, dt) { | |
| const building = state.buildings.find((item) => item.id === unit.task.buildingId); | |
| if (!building || building.complete) { | |
| unit.task = null; | |
| return; | |
| } | |
| if (!nearRect(unit, building, 1.35)) { | |
| if (!unit.path.length || unit.task.phase !== "toSite") { | |
| setPathToRect(unit, building); | |
| unit.task.phase = "toSite"; | |
| } | |
| return; | |
| } | |
| unit.path = []; | |
| unit.task.phase = "building"; | |
| building.progress += dt; | |
| addSpark(unit.x, unit.y - 12); | |
| const total = BUILDING_DEFS[building.type].buildTime; | |
| if (building.progress >= total) { | |
| building.progress = total; | |
| building.complete = true; | |
| building.hp = building.maxHp; | |
| unit.task = null; | |
| addMessage(`${BUILDING_DEFS[building.type].name} complete.`); | |
| } | |
| } | |
| function nearestDepot(unit) { | |
| let best = null; | |
| let bestDist = Infinity; | |
| for (const building of state.buildings) { | |
| const def = BUILDING_DEFS[building.type]; | |
| if (!building.complete || !def.deposit) continue; | |
| const cx = (building.x + building.w / 2) * TILE; | |
| const cy = (building.y + building.h / 2) * TILE; | |
| const dist = Math.hypot(unit.x - cx, unit.y - cy); | |
| if (dist < bestDist) { | |
| best = building; | |
| bestDist = dist; | |
| } | |
| } | |
| return best; | |
| } | |
| function nearRect(unit, rect, rangeTiles) { | |
| const ux = unit.x / TILE; | |
| const uy = unit.y / TILE; | |
| const closestX = clamp(ux, rect.x, rect.x + rect.w); | |
| const closestY = clamp(uy, rect.y, rect.y + rect.h); | |
| return Math.hypot(ux - closestX, uy - closestY) <= rangeTiles; | |
| } | |
| function updateFog() { | |
| state.visible.fill(0); | |
| for (const unit of state.units) { | |
| const tile = unitTile(unit); | |
| reveal(tile.x, tile.y, UNIT_DEFS[unit.type].sight); | |
| } | |
| for (const building of state.buildings) { | |
| const def = BUILDING_DEFS[building.type]; | |
| const radius = building.complete ? def.sight : 4; | |
| reveal(building.x + Math.floor(building.w / 2), building.y + Math.floor(building.h / 2), radius); | |
| } | |
| } | |
| function reveal(cx, cy, radius) { | |
| const r2 = radius * radius; | |
| for (let y = cy - radius; y <= cy + radius; y += 1) { | |
| for (let x = cx - radius; x <= cx + radius; x += 1) { | |
| if (!inMap(x, y)) continue; | |
| const d2 = (x - cx) * (x - cx) + (y - cy) * (y - cy); | |
| if (d2 > r2) continue; | |
| const i = idx(x, y); | |
| state.visible[i] = 1; | |
| if (!state.explored[i]) { | |
| state.explored[i] = 1; | |
| state.exploredCount += 1; | |
| } | |
| } | |
| } | |
| } | |
| function updateWinState() { | |
| if (state.won) return; | |
| if (state.exploredCount >= MAP_W * MAP_H) { | |
| state.won = true; | |
| addMessage("Survey complete. The entire frontier map is uncovered."); | |
| } | |
| } | |
| function draw() { | |
| ctx.setTransform(state.view.dpr, 0, 0, state.view.dpr, 0, 0); | |
| ctx.clearRect(0, 0, state.view.w, state.view.h); | |
| drawTerrain(); | |
| drawResources(); | |
| drawBuildings(); | |
| drawUnits(); | |
| drawRallyLines(); | |
| drawFog(); | |
| drawBuildGhost(); | |
| drawSelectionBox(); | |
| drawEffects(); | |
| drawMinimap(); | |
| } | |
| function drawTerrain() { | |
| const sx = Math.max(0, Math.floor(state.camera.x / TILE) - 1); | |
| const sy = Math.max(0, Math.floor(state.camera.y / TILE) - 1); | |
| const ex = Math.min(MAP_W, Math.ceil((state.camera.x + state.view.w) / TILE) + 1); | |
| const ey = Math.min(MAP_H, Math.ceil((state.camera.y + state.view.h) / TILE) + 1); | |
| for (let y = sy; y < ey; y += 1) { | |
| for (let x = sx; x < ex; x += 1) { | |
| const palette = TERRAIN_PALETTE[state.terrain[idx(x, y)]]; | |
| const px = x * TILE - state.camera.x; | |
| const py = y * TILE - state.camera.y; | |
| ctx.fillStyle = (x + y) % 2 ? palette[0] : palette[1]; | |
| ctx.fillRect(px, py, TILE, TILE); | |
| ctx.fillStyle = "rgba(255,255,255,0.025)"; | |
| if ((x * 13 + y * 7) % 9 === 0) ctx.fillRect(px + 4, py + 7, 3, 2); | |
| if ((x * 5 + y * 11) % 13 === 0) ctx.fillRect(px + 22, py + 20, 4, 2); | |
| } | |
| } | |
| } | |
| function drawResources() { | |
| for (const node of state.nodes) { | |
| if (!state.explored[idx(node.x, node.y)]) continue; | |
| const px = node.x * TILE - state.camera.x; | |
| const py = node.y * TILE - state.camera.y; | |
| if (!onScreen(px, py, TILE, TILE)) continue; | |
| const glow = ctx.createRadialGradient(px + 16, py + 18, 2, px + 16, py + 18, 24); | |
| glow.addColorStop(0, "rgba(121, 241, 255, 0.5)"); | |
| glow.addColorStop(1, "rgba(121, 241, 255, 0)"); | |
| ctx.fillStyle = glow; | |
| ctx.fillRect(px - 12, py - 10, 56, 56); | |
| ctx.save(); | |
| ctx.translate(px + 16, py + 17); | |
| const pct = clamp(node.amount / node.maxAmount, 0.25, 1); | |
| ctx.scale(0.78 + pct * 0.25, 0.78 + pct * 0.25); | |
| drawCrystalShape("#80f7ff", "#2ea7b5"); | |
| ctx.restore(); | |
| if (isSelected(node)) drawTileSelection(px, py, 1, 1, "#9df6ff"); | |
| } | |
| } | |
| function drawCrystalShape(top, side) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -14); | |
| ctx.lineTo(11, -2); | |
| ctx.lineTo(6, 13); | |
| ctx.lineTo(-7, 13); | |
| ctx.lineTo(-12, -2); | |
| ctx.closePath(); | |
| ctx.fillStyle = side; | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -14); | |
| ctx.lineTo(11, -2); | |
| ctx.lineTo(0, 2); | |
| ctx.lineTo(-12, -2); | |
| ctx.closePath(); | |
| ctx.fillStyle = top; | |
| ctx.fill(); | |
| ctx.strokeStyle = "rgba(255,255,255,0.62)"; | |
| ctx.lineWidth = 1.4; | |
| ctx.stroke(); | |
| } | |
| function drawBuildings() { | |
| for (const building of state.buildings) { | |
| const explored = rectExplored(building); | |
| if (!explored) continue; | |
| drawBuilding(building); | |
| } | |
| } | |
| function drawBuilding(building) { | |
| const def = BUILDING_DEFS[building.type]; | |
| const px = building.x * TILE - state.camera.x; | |
| const py = building.y * TILE - state.camera.y; | |
| const w = building.w * TILE; | |
| const h = building.h * TILE; | |
| if (!onScreen(px, py, w, h)) return; | |
| ctx.save(); | |
| ctx.translate(px, py); | |
| ctx.fillStyle = "rgba(0, 0, 0, 0.32)"; | |
| roundedRect(7, h - 8, w - 3, 18, 10); | |
| ctx.fill(); | |
| const body = ctx.createLinearGradient(0, 0, 0, h); | |
| if (building.type === "command") { | |
| body.addColorStop(0, "#b6d5b0"); | |
| body.addColorStop(1, "#46634f"); | |
| } else if (building.type === "barracks") { | |
| body.addColorStop(0, "#c5b06c"); | |
| body.addColorStop(1, "#6f5532"); | |
| } else if (building.type === "depot") { | |
| body.addColorStop(0, "#9ecfc7"); | |
| body.addColorStop(1, "#406966"); | |
| } else { | |
| body.addColorStop(0, "#d5e7ee"); | |
| body.addColorStop(1, "#557284"); | |
| } | |
| ctx.fillStyle = building.complete ? body : "rgba(119, 141, 126, 0.68)"; | |
| roundedRect(4, 8, w - 8, h - 12, 10); | |
| ctx.fill(); | |
| ctx.strokeStyle = "rgba(12, 20, 18, 0.7)"; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.fillStyle = "rgba(255, 255, 255, 0.14)"; | |
| ctx.beginPath(); | |
| ctx.moveTo(10, 10); | |
| ctx.lineTo(w - 14, 10); | |
| ctx.lineTo(w - 24, 24); | |
| ctx.lineTo(18, 24); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| if (building.type === "command") { | |
| ctx.fillStyle = "#192e2d"; | |
| roundedRect(w * 0.36, h * 0.32, w * 0.28, h * 0.3, 6); | |
| ctx.fill(); | |
| ctx.fillStyle = "#fff0a6"; | |
| roundedRect(w * 0.42, h * 0.37, w * 0.16, h * 0.12, 4); | |
| ctx.fill(); | |
| } else if (building.type === "barracks") { | |
| ctx.fillStyle = "#27312a"; | |
| for (let i = 0; i < 3; i += 1) { | |
| roundedRect(17 + i * 24, h * 0.43, 13, 18, 3); | |
| ctx.fill(); | |
| } | |
| } else if (building.type === "depot") { | |
| ctx.fillStyle = "rgba(255, 244, 150, 0.82)"; | |
| for (let i = 0; i < 4; i += 1) { | |
| ctx.fillRect(16 + i * 21, h * 0.46, 11, 18); | |
| } | |
| } else if (building.type === "tower") { | |
| ctx.strokeStyle = "#e8f4dc"; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(w / 2, h - 13); | |
| ctx.lineTo(w / 2, 12); | |
| ctx.moveTo(w / 2 - 16, h - 12); | |
| ctx.lineTo(w / 2, 12); | |
| ctx.moveTo(w / 2 + 16, h - 12); | |
| ctx.lineTo(w / 2, 12); | |
| ctx.stroke(); | |
| ctx.fillStyle = "#9df6ff"; | |
| ctx.beginPath(); | |
| ctx.arc(w / 2, 12, 8, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| if (!building.complete) { | |
| ctx.fillStyle = "rgba(255, 214, 102, 0.13)"; | |
| ctx.fillRect(8, 12, w - 16, h - 20); | |
| drawProgress(8, h - 10, w - 16, clamp(building.progress / def.buildTime, 0, 1), "#ffd166"); | |
| } else if (building.queue.length) { | |
| const item = building.queue[0]; | |
| drawProgress(8, h - 10, w - 16, item.elapsed / item.total, "#9be86a"); | |
| } | |
| if (isSelected(building)) { | |
| drawTileSelection(0, 0, building.w, building.h, "#d6f78c", true); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawUnits() { | |
| const units = [...state.units].sort((a, b) => a.y - b.y); | |
| for (const unit of units) { | |
| drawUnit(unit); | |
| } | |
| } | |
| function drawUnit(unit) { | |
| const def = UNIT_DEFS[unit.type]; | |
| const px = unit.x - state.camera.x; | |
| const py = unit.y - state.camera.y; | |
| if (!onScreen(px - 20, py - 24, 40, 48)) return; | |
| ctx.save(); | |
| ctx.translate(px, py); | |
| ctx.rotate(unit.facing); | |
| ctx.fillStyle = "rgba(0, 0, 0, 0.32)"; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, 8, def.radius + 4, 7, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| if (unit.type === "worker") { | |
| ctx.fillStyle = "#2a4d48"; | |
| roundedRect(-12, -8, 24, 17, 6); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#99f0d1"; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.fillStyle = "#ffe680"; | |
| roundedRect(1, -5, 9, 10, 3); | |
| ctx.fill(); | |
| ctx.fillStyle = "#172724"; | |
| ctx.fillRect(-14, -7, 5, 14); | |
| ctx.fillRect(9, -7, 5, 14); | |
| } else { | |
| ctx.fillStyle = "#31435c"; | |
| ctx.beginPath(); | |
| ctx.moveTo(15, 0); | |
| ctx.lineTo(1, -12); | |
| ctx.lineTo(-13, -7); | |
| ctx.lineTo(-12, 8); | |
| ctx.lineTo(3, 12); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#a6cbff"; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.strokeStyle = "#fff0a6"; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(7, 0); | |
| ctx.lineTo(20, 0); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| if (unit.type === "worker" && unit.carrying > 0) { | |
| ctx.fillStyle = "#9df6ff"; | |
| ctx.beginPath(); | |
| ctx.arc(px + 9, py - 17, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| if (isSelected(unit)) { | |
| ctx.strokeStyle = "#d6f78c"; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.ellipse(px, py + 8, def.radius + 8, 8, 0, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| if (unit.path.length) { | |
| ctx.strokeStyle = "rgba(214, 247, 140, 0.45)"; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(px, py); | |
| for (const step of unit.path) { | |
| const c = tileCenter(step); | |
| ctx.lineTo(c.x - state.camera.x, c.y - state.camera.y); | |
| } | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| function drawRallyLines() { | |
| const selected = selectedEntities().filter((entity) => entity.kind === "building" && BUILDING_DEFS[entity.type].produces.length); | |
| for (const building of selected) { | |
| if (!building.rally) continue; | |
| const sx = (building.x + building.w / 2) * TILE - state.camera.x; | |
| const sy = (building.y + building.h / 2) * TILE - state.camera.y; | |
| const target = tileCenter(building.rally); | |
| const tx = target.x - state.camera.x; | |
| const ty = target.y - state.camera.y; | |
| ctx.strokeStyle = "rgba(255, 240, 166, 0.6)"; | |
| ctx.lineWidth = 2; | |
| ctx.setLineDash([6, 6]); | |
| ctx.beginPath(); | |
| ctx.moveTo(sx, sy); | |
| ctx.lineTo(tx, ty); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = "#fff0a6"; | |
| ctx.beginPath(); | |
| ctx.arc(tx, ty, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawFog() { | |
| const sx = Math.max(0, Math.floor(state.camera.x / TILE) - 1); | |
| const sy = Math.max(0, Math.floor(state.camera.y / TILE) - 1); | |
| const ex = Math.min(MAP_W, Math.ceil((state.camera.x + state.view.w) / TILE) + 1); | |
| const ey = Math.min(MAP_H, Math.ceil((state.camera.y + state.view.h) / TILE) + 1); | |
| for (let y = sy; y < ey; y += 1) { | |
| for (let x = sx; x < ex; x += 1) { | |
| const i = idx(x, y); | |
| if (state.visible[i]) continue; | |
| const px = x * TILE - state.camera.x; | |
| const py = y * TILE - state.camera.y; | |
| ctx.fillStyle = state.explored[i] ? "rgba(2, 7, 8, 0.56)" : "rgba(1, 4, 5, 0.96)"; | |
| ctx.fillRect(px, py, TILE + 1, TILE + 1); | |
| if (!state.explored[i]) { | |
| ctx.fillStyle = "rgba(255,255,255,0.02)"; | |
| ctx.fillRect(px + 11, py + 11, 10, 10); | |
| } | |
| } | |
| } | |
| } | |
| function drawBuildGhost() { | |
| if (!state.buildMode) return; | |
| const def = BUILDING_DEFS[state.buildMode]; | |
| const tile = worldToTile(state.mouse.worldX, state.mouse.worldY); | |
| const tx = clamp(tile.x - Math.floor(def.w / 2), 0, MAP_W - def.w); | |
| const ty = clamp(tile.y - Math.floor(def.h / 2), 0, MAP_H - def.h); | |
| const info = canPlaceBuilding(state.buildMode, tx, ty); | |
| const px = tx * TILE - state.camera.x; | |
| const py = ty * TILE - state.camera.y; | |
| ctx.save(); | |
| ctx.globalAlpha = 0.78; | |
| ctx.fillStyle = info.ok ? "rgba(155, 232, 106, 0.28)" : "rgba(255, 107, 107, 0.28)"; | |
| ctx.fillRect(px, py, def.w * TILE, def.h * TILE); | |
| ctx.strokeStyle = info.ok ? "#9be86a" : "#ff6b6b"; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(px + 1, py + 1, def.w * TILE - 2, def.h * TILE - 2); | |
| ctx.restore(); | |
| ctx.fillStyle = info.ok ? "#eaffd3" : "#ffd1d1"; | |
| ctx.font = "12px Trebuchet MS"; | |
| ctx.fillText(info.ok ? "Place building" : info.reason, px, py - 8); | |
| } | |
| function drawSelectionBox() { | |
| if (!state.mouse.drag || !state.mouse.down || state.buildMode) return; | |
| const x = Math.min(state.mouse.startX, state.mouse.x); | |
| const y = Math.min(state.mouse.startY, state.mouse.y); | |
| const w = Math.abs(state.mouse.x - state.mouse.startX); | |
| const h = Math.abs(state.mouse.y - state.mouse.startY); | |
| ctx.fillStyle = "rgba(155, 232, 106, 0.12)"; | |
| ctx.fillRect(x, y, w, h); | |
| ctx.strokeStyle = "rgba(214, 247, 140, 0.85)"; | |
| ctx.lineWidth = 1.5; | |
| ctx.strokeRect(x, y, w, h); | |
| } | |
| const effects = []; | |
| function addSpark(x, y) { | |
| if (Math.random() > 0.24) return; | |
| effects.push({ | |
| type: "spark", | |
| x: x + (Math.random() - 0.5) * 22, | |
| y: y + (Math.random() - 0.5) * 12, | |
| life: 0.35, | |
| max: 0.35, | |
| color: "#ffd166" | |
| }); | |
| } | |
| function addFloatText(text, x, y, color) { | |
| effects.push({ | |
| type: "text", | |
| text, | |
| x, | |
| y, | |
| life: 0.8, | |
| max: 0.8, | |
| color | |
| }); | |
| } | |
| function drawEffects() { | |
| for (let i = effects.length - 1; i >= 0; i -= 1) { | |
| const effect = effects[i]; | |
| effect.life -= 1 / 60; | |
| if (effect.life <= 0) { | |
| effects.splice(i, 1); | |
| continue; | |
| } | |
| const alpha = clamp(effect.life / effect.max, 0, 1); | |
| const px = effect.x - state.camera.x; | |
| const py = effect.y - state.camera.y - (1 - alpha) * 14; | |
| ctx.globalAlpha = alpha; | |
| if (effect.type === "spark") { | |
| ctx.fillStyle = effect.color; | |
| ctx.fillRect(px - 2, py - 2, 4, 4); | |
| } else { | |
| ctx.fillStyle = effect.color; | |
| ctx.font = "bold 11px Trebuchet MS"; | |
| ctx.fillText(effect.text, px, py); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| function drawMinimap() { | |
| const w = minimap.width; | |
| const h = minimap.height; | |
| const sx = w / MAP_W; | |
| const sy = h / MAP_H; | |
| mctx.clearRect(0, 0, w, h); | |
| for (let y = 0; y < MAP_H; y += 1) { | |
| for (let x = 0; x < MAP_W; x += 1) { | |
| const i = idx(x, y); | |
| if (!state.explored[i]) { | |
| mctx.fillStyle = "#010405"; | |
| } else { | |
| mctx.fillStyle = state.visible[i] ? TERRAIN_PALETTE[state.terrain[i]][0] : "#162222"; | |
| } | |
| mctx.fillRect(Math.floor(x * sx), Math.floor(y * sy), Math.ceil(sx), Math.ceil(sy)); | |
| } | |
| } | |
| mctx.fillStyle = "#78f2ff"; | |
| for (const node of state.nodes) { | |
| if (state.explored[idx(node.x, node.y)]) { | |
| mctx.fillRect(node.x * sx - 1, node.y * sy - 1, 3, 3); | |
| } | |
| } | |
| for (const building of state.buildings) { | |
| mctx.fillStyle = building.complete ? "#fff0a6" : "#ffd166"; | |
| mctx.fillRect(building.x * sx, building.y * sy, Math.max(2, building.w * sx), Math.max(2, building.h * sy)); | |
| } | |
| mctx.fillStyle = "#d6f78c"; | |
| for (const unit of state.units) { | |
| const tile = unitTile(unit); | |
| mctx.fillRect(tile.x * sx - 1, tile.y * sy - 1, 2, 2); | |
| } | |
| mctx.strokeStyle = "#ffffff"; | |
| mctx.lineWidth = 1; | |
| mctx.strokeRect( | |
| state.camera.x / MAP_PIX_W * w, | |
| state.camera.y / MAP_PIX_H * h, | |
| state.view.w / MAP_PIX_W * w, | |
| state.view.h / MAP_PIX_H * h | |
| ); | |
| } | |
| function roundedRect(x, y, w, h, r) { | |
| const radius = Math.min(r, w / 2, h / 2); | |
| ctx.beginPath(); | |
| ctx.moveTo(x + radius, y); | |
| ctx.lineTo(x + w - radius, y); | |
| ctx.quadraticCurveTo(x + w, y, x + w, y + radius); | |
| ctx.lineTo(x + w, y + h - radius); | |
| ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); | |
| ctx.lineTo(x + radius, y + h); | |
| ctx.quadraticCurveTo(x, y + h, x, y + h - radius); | |
| ctx.lineTo(x, y + radius); | |
| ctx.quadraticCurveTo(x, y, x + radius, y); | |
| ctx.closePath(); | |
| } | |
| function drawProgress(x, y, w, pct, color) { | |
| ctx.fillStyle = "rgba(0, 0, 0, 0.38)"; | |
| roundedRect(x, y, w, 6, 4); | |
| ctx.fill(); | |
| ctx.fillStyle = color; | |
| roundedRect(x, y, Math.max(3, w * clamp(pct, 0, 1)), 6, 4); | |
| ctx.fill(); | |
| } | |
| function drawTileSelection(px, py, wTiles, hTiles, color, local = false) { | |
| const x = local ? px : px; | |
| const y = local ? py : py; | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2; | |
| ctx.setLineDash([8, 5]); | |
| ctx.strokeRect(x + 2, y + 2, wTiles * TILE - 4, hTiles * TILE - 4); | |
| ctx.setLineDash([]); | |
| } | |
| function onScreen(x, y, w, h) { | |
| return x + w > -80 && y + h > -80 && x < state.view.w + 80 && y < state.view.h + 80; | |
| } | |
| function rectExplored(rect) { | |
| for (let y = rect.y; y < rect.y + rect.h; y += 1) { | |
| for (let x = rect.x; x < rect.x + rect.w; x += 1) { | |
| if (inMap(x, y) && state.explored[idx(x, y)]) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function isSelected(entity) { | |
| return state.selected.some((item) => item.id === entity.id); | |
| } | |
| function queueUnit(unitType) { | |
| const building = selectedEntities().find((entity) => { | |
| if (entity.kind !== "building" || !entity.complete) return false; | |
| return BUILDING_DEFS[entity.type].produces.includes(unitType); | |
| }); | |
| if (!building) { | |
| addMessage("Select a completed production building first."); | |
| return; | |
| } | |
| if (building.queue.length >= MAX_QUEUE) { | |
| addMessage("Training queue is full."); | |
| return; | |
| } | |
| const def = UNIT_DEFS[unitType]; | |
| if (state.crystals < def.cost) { | |
| addMessage(`Need ${def.cost} crystals.`); | |
| return; | |
| } | |
| state.crystals -= def.cost; | |
| building.queue.push({ type: unitType, elapsed: 0, total: def.buildTime }); | |
| addMessage(`Training ${def.name}.`); | |
| updatePanel(true); | |
| } | |
| function beginBuild(type) { | |
| const worker = selectedEntities().find((entity) => entity.kind === "unit" && entity.type === "worker"); | |
| if (!worker) { | |
| addMessage("Select a worker to construct buildings."); | |
| return; | |
| } | |
| const def = BUILDING_DEFS[type]; | |
| if (state.crystals < def.cost) { | |
| addMessage(`Need ${def.cost} crystals.`); | |
| return; | |
| } | |
| state.buildMode = type; | |
| addMessage(`Placing ${def.name}. Left-click valid ground, Esc cancels.`); | |
| } | |
| function canPlaceBuilding(type, x, y) { | |
| const def = BUILDING_DEFS[type]; | |
| if (x < 0 || y < 0 || x + def.w > MAP_W || y + def.h > MAP_H) { | |
| return { ok: false, reason: "Out of map" }; | |
| } | |
| const workers = selectedEntities().filter((entity) => entity.kind === "unit" && entity.type === "worker"); | |
| if (!workers.length) return { ok: false, reason: "Need worker" }; | |
| const center = { x: x + def.w / 2, y: y + def.h / 2 }; | |
| const closeWorker = workers.some((worker) => { | |
| const tile = unitTile(worker); | |
| return Math.hypot(tile.x - center.x, tile.y - center.y) <= 12; | |
| }); | |
| if (!closeWorker) return { ok: false, reason: "Too far" }; | |
| if (state.crystals < def.cost) return { ok: false, reason: "Need crystals" }; | |
| for (let ty = y; ty < y + def.h; ty += 1) { | |
| for (let tx = x; tx < x + def.w; tx += 1) { | |
| if (!terrainPassable(tx, ty)) return { ok: false, reason: "Blocked" }; | |
| if (buildingAtTile(tx, ty)) return { ok: false, reason: "Blocked" }; | |
| if (resourceAtTile(tx, ty)) return { ok: false, reason: "Crystals here" }; | |
| if (!state.visible[idx(tx, ty)]) return { ok: false, reason: "Need vision" }; | |
| } | |
| } | |
| for (const unit of state.units) { | |
| const tile = unitTile(unit); | |
| if (tile.x >= x && tile.y >= y && tile.x < x + def.w && tile.y < y + def.h) { | |
| return { ok: false, reason: "Unit in way" }; | |
| } | |
| } | |
| return { ok: true, reason: "" }; | |
| } | |
| function placeBuilding(type) { | |
| const def = BUILDING_DEFS[type]; | |
| const tile = worldToTile(state.mouse.worldX, state.mouse.worldY); | |
| const x = clamp(tile.x - Math.floor(def.w / 2), 0, MAP_W - def.w); | |
| const y = clamp(tile.y - Math.floor(def.h / 2), 0, MAP_H - def.h); | |
| const info = canPlaceBuilding(type, x, y); | |
| if (!info.ok) { | |
| addMessage(info.reason); | |
| return; | |
| } | |
| state.crystals -= def.cost; | |
| const building = createBuilding(type, x, y, false); | |
| state.buildings.push(building); | |
| const workers = selectedEntities().filter((entity) => entity.kind === "unit" && entity.type === "worker"); | |
| for (const worker of workers) { | |
| worker.task = { type: "build", buildingId: building.id, phase: "toSite" }; | |
| worker.harvestTimer = 0; | |
| setPathToRect(worker, building); | |
| } | |
| state.buildMode = null; | |
| selectEntities([building]); | |
| addMessage(`${def.name} started.`); | |
| } | |
| function spawnTileForBuilding(building) { | |
| const center = { x: building.x + building.w / 2, y: building.y + building.h / 2 }; | |
| let best = null; | |
| let bestScore = Infinity; | |
| for (let y = building.y - 1; y <= building.y + building.h; y += 1) { | |
| for (let x = building.x - 1; x <= building.x + building.w; x += 1) { | |
| const edge = x === building.x - 1 || x === building.x + building.w || y === building.y - 1 || y === building.y + building.h; | |
| if (!edge || !inMap(x, y) || !isTileWalkable(x, y)) continue; | |
| const score = Math.hypot(x - center.x, y - center.y); | |
| if (score < bestScore) { | |
| best = { x, y }; | |
| bestScore = score; | |
| } | |
| } | |
| } | |
| return best; | |
| } | |
| function issueMove(units, tileX, tileY) { | |
| const count = units.length; | |
| const side = Math.ceil(Math.sqrt(count)); | |
| units.forEach((unit, index) => { | |
| const ox = (index % side) - Math.floor(side / 2); | |
| const oy = Math.floor(index / side) - Math.floor(side / 2); | |
| unit.task = { type: "move" }; | |
| unit.harvestTimer = 0; | |
| setUnitDestination(unit, clamp(tileX + ox, 0, MAP_W - 1), clamp(tileY + oy, 0, MAP_H - 1)); | |
| }); | |
| } | |
| function issueGather(workers, node) { | |
| for (const worker of workers) { | |
| worker.task = { type: "gather", nodeId: node.id, phase: "toNode" }; | |
| worker.harvestTimer = 0; | |
| setPathToRect(worker, { x: node.x, y: node.y, w: 1, h: 1 }); | |
| } | |
| addMessage(`Gathering crystals (${node.amount} left).`); | |
| } | |
| function issueBuildRepair(workers, building) { | |
| if (building.complete) return false; | |
| for (const worker of workers) { | |
| worker.task = { type: "build", buildingId: building.id, phase: "toSite" }; | |
| worker.harvestTimer = 0; | |
| setPathToRect(worker, building); | |
| } | |
| addMessage(`Workers assigned to ${BUILDING_DEFS[building.type].name}.`); | |
| return true; | |
| } | |
| function stopSelectedUnits() { | |
| for (const entity of selectedEntities()) { | |
| if (entity.kind !== "unit") continue; | |
| entity.path = []; | |
| entity.task = null; | |
| entity.harvestTimer = 0; | |
| } | |
| addMessage("Units stopped."); | |
| } | |
| function updateStats() { | |
| crystalStat.textContent = Math.floor(state.crystals); | |
| unitStat.textContent = String(state.units.length); | |
| const pct = Math.floor((state.exploredCount / (MAP_W * MAP_H)) * 100); | |
| surveyStat.textContent = `${pct}%`; | |
| surveyFill.style.width = `${pct}%`; | |
| } | |
| function updatePanel(force = false) { | |
| const html = buildPanelHtml(); | |
| if (!force && html === state.lastPanelText) return; | |
| state.lastPanelText = html; | |
| panelBody.innerHTML = html; | |
| } | |
| function buildPanelHtml() { | |
| const selected = selectedEntities(); | |
| if (!selected.length) { | |
| return ` | |
| <div class="card"> | |
| <h3>How to play</h3> | |
| <p class="hint">Left-click to select. Drag a box to select units. Right-click terrain to move, right-click crystals with workers to harvest.</p> | |
| <p class="hint"><kbd>WASD</kbd> or arrow keys pan the camera. <kbd>H</kbd> jumps home. <kbd>Space</kbd> centers selection. <kbd>Esc</kbd> cancels placement.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="row"><span>Objective</span><b>Uncover 100%</b></div> | |
| <div class="row"><span>Economy</span><b>Workers harvest</b></div> | |
| <div class="row"><span>Tech</span><b>Build barracks</b></div> | |
| </div> | |
| `; | |
| } | |
| if (selected.length === 1 && selected[0].kind === "resource") { | |
| const node = selected[0]; | |
| return ` | |
| <div class="card"> | |
| <h3>Resource Field</h3> | |
| <h2>Crystal Outcrop</h2> | |
| <div class="row"><span>Remaining</span><b>${node.amount}</b></div> | |
| <p class="hint">Select workers, then right-click this field to gather. Workers automatically return to a Command Hall or Relay Depot.</p> | |
| </div> | |
| `; | |
| } | |
| const units = selected.filter((entity) => entity.kind === "unit"); | |
| const buildings = selected.filter((entity) => entity.kind === "building"); | |
| if (units.length) { | |
| const workers = units.filter((unit) => unit.type === "worker"); | |
| const rangers = units.filter((unit) => unit.type === "ranger"); | |
| const carrying = workers.reduce((sum, unit) => sum + unit.carrying, 0); | |
| return ` | |
| <div class="card"> | |
| <h3>Unit Group</h3> | |
| <h2>${units.length} selected</h2> | |
| <div class="row"><span>Workers</span><b>${workers.length}</b></div> | |
| <div class="row"><span>Rangers</span><b>${rangers.length}</b></div> | |
| ${workers.length ? `<div class="row"><span>Carrying</span><b>${carrying}</b></div>` : ""} | |
| <p class="hint">Right-click ground to move. Workers can right-click crystals to gather or unfinished buildings to construct.</p> | |
| <div class="button-grid"> | |
| <button data-action="stop">Stop</button> | |
| ${workers.length ? `<button data-action="build:barracks">Barracks ${BUILDING_DEFS.barracks.cost}</button>` : ""} | |
| ${workers.length ? `<button data-action="build:depot">Relay Depot ${BUILDING_DEFS.depot.cost}</button>` : ""} | |
| ${workers.length ? `<button data-action="build:tower">Signal Tower ${BUILDING_DEFS.tower.cost}</button>` : ""} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| if (buildings.length === 1) { | |
| const building = buildings[0]; | |
| const def = BUILDING_DEFS[building.type]; | |
| const progress = def.buildTime ? Math.floor((building.progress / def.buildTime) * 100) : 100; | |
| const queueRows = building.queue.map((item, index) => { | |
| const pct = Math.floor((item.elapsed / item.total) * 100); | |
| return `<div class="row"><span>${index === 0 ? "Training" : "Queued"}</span><b>${UNIT_DEFS[item.type].name} ${pct}%</b></div>`; | |
| }).join(""); | |
| const trainButtons = def.produces.map((unitType) => { | |
| const unit = UNIT_DEFS[unitType]; | |
| return `<button data-action="train:${unitType}">${unit.name} ${unit.cost}</button>`; | |
| }).join(""); | |
| return ` | |
| <div class="card"> | |
| <h3>${building.complete ? "Building" : "Construction"}</h3> | |
| <h2>${def.name}</h2> | |
| <div class="row"><span>Status</span><b>${building.complete ? "Online" : `${progress}%`}</b></div> | |
| <div class="row"><span>Integrity</span><b>${building.hp}/${building.maxHp}</b></div> | |
| <div class="row"><span>Vision</span><b>${def.sight}</b></div> | |
| <p class="hint">${def.role}</p> | |
| ${queueRows} | |
| ${building.complete && trainButtons ? `<div class="button-grid">${trainButtons}</div><p class="hint">Right-click the map while this building is selected to set its rally point.</p>` : ""} | |
| ${!building.complete ? `<p class="hint">Select workers and right-click this site to speed construction.</p>` : ""} | |
| </div> | |
| `; | |
| } | |
| return ` | |
| <div class="card"> | |
| <h3>Selection</h3> | |
| <h2>${selected.length} objects</h2> | |
| <p class="hint">Mixed selections are allowed, but actions apply to selected units first.</p> | |
| </div> | |
| `; | |
| } | |
| function updateToasts() { | |
| const now = performance.now(); | |
| state.messages = state.messages.filter((item) => item.until > now); | |
| toastEl.innerHTML = state.messages.slice(-3).map((item) => `<div>${item.text}</div>`).join(""); | |
| } | |
| function addMessage(text) { | |
| state.messages.push({ text, until: performance.now() + 4200 }); | |
| updateToasts(); | |
| } | |
| function entityAtWorld(wx, wy) { | |
| for (let i = state.units.length - 1; i >= 0; i -= 1) { | |
| const unit = state.units[i]; | |
| const def = UNIT_DEFS[unit.type]; | |
| if (Math.hypot(unit.x - wx, unit.y - wy) <= def.radius + 8) return unit; | |
| } | |
| const tile = worldToTile(wx, wy); | |
| for (let i = state.buildings.length - 1; i >= 0; i -= 1) { | |
| const building = state.buildings[i]; | |
| if (!rectExplored(building)) continue; | |
| if (tile.x >= building.x && tile.y >= building.y && tile.x < building.x + building.w && tile.y < building.y + building.h) { | |
| return building; | |
| } | |
| } | |
| const node = resourceAtTile(tile.x, tile.y); | |
| if (node && state.explored[idx(node.x, node.y)]) return node; | |
| return null; | |
| } | |
| function handleLeftClick(event) { | |
| if (state.buildMode) { | |
| placeBuilding(state.buildMode); | |
| return; | |
| } | |
| const target = entityAtWorld(state.mouse.worldX, state.mouse.worldY); | |
| const append = event.shiftKey; | |
| if (target) { | |
| selectEntities([target], append); | |
| } else if (!append) { | |
| clearSelection(); | |
| } | |
| } | |
| function handleDragSelect(event) { | |
| const minX = Math.min(state.mouse.startX, state.mouse.x); | |
| const minY = Math.min(state.mouse.startY, state.mouse.y); | |
| const maxX = Math.max(state.mouse.startX, state.mouse.x); | |
| const maxY = Math.max(state.mouse.startY, state.mouse.y); | |
| const selected = []; | |
| for (const unit of state.units) { | |
| const sx = unit.x - state.camera.x; | |
| const sy = unit.y - state.camera.y; | |
| if (sx >= minX && sy >= minY && sx <= maxX && sy <= maxY) selected.push(unit); | |
| } | |
| selectEntities(selected, event.shiftKey); | |
| } | |
| function handleRightClick(event) { | |
| event.preventDefault(); | |
| if (state.buildMode) { | |
| state.buildMode = null; | |
| addMessage("Building placement cancelled."); | |
| return; | |
| } | |
| const selected = selectedEntities(); | |
| const units = selected.filter((entity) => entity.kind === "unit"); | |
| const buildings = selected.filter((entity) => entity.kind === "building"); | |
| const tile = worldToTile(state.mouse.worldX, state.mouse.worldY); | |
| const target = entityAtWorld(state.mouse.worldX, state.mouse.worldY); | |
| if (units.length) { | |
| const workers = units.filter((unit) => unit.type === "worker"); | |
| if (target && target.kind === "resource" && workers.length) { | |
| issueGather(workers, target); | |
| const others = units.filter((unit) => unit.type !== "worker"); | |
| if (others.length) issueMove(others, tile.x, tile.y); | |
| return; | |
| } | |
| if (target && target.kind === "building" && workers.length && issueBuildRepair(workers, target)) { | |
| return; | |
| } | |
| issueMove(units, tile.x, tile.y); | |
| return; | |
| } | |
| if (buildings.length) { | |
| for (const building of buildings) { | |
| if (!BUILDING_DEFS[building.type].produces.length || !building.complete) continue; | |
| const rally = nearestWalkable(tile.x, tile.y, building.x, building.y, 10); | |
| if (rally) building.rally = rally; | |
| } | |
| addMessage("Rally point set."); | |
| } | |
| } | |
| panelBody.addEventListener("click", (event) => { | |
| const button = event.target.closest("button[data-action]"); | |
| if (!button) return; | |
| const [action, value] = button.dataset.action.split(":"); | |
| if (action === "train") queueUnit(value); | |
| if (action === "build") beginBuild(value); | |
| if (action === "stop") stopSelectedUnits(); | |
| }); | |
| canvas.addEventListener("mousedown", (event) => { | |
| updateMouseFromEvent(event); | |
| state.mouse.down = true; | |
| state.mouse.drag = false; | |
| state.mouse.startX = state.mouse.x; | |
| state.mouse.startY = state.mouse.y; | |
| state.mouse.button = event.button; | |
| if (event.button === 2) handleRightClick(event); | |
| }); | |
| window.addEventListener("mousemove", (event) => { | |
| updateMouseFromEvent(event); | |
| if (state.mouse.down && state.mouse.button === 0) { | |
| const dist = Math.hypot(state.mouse.x - state.mouse.startX, state.mouse.y - state.mouse.startY); | |
| if (dist > 5) state.mouse.drag = true; | |
| } | |
| }); | |
| window.addEventListener("mouseup", (event) => { | |
| updateMouseFromEvent(event); | |
| if (!state.mouse.down) return; | |
| if (state.mouse.button === 0) { | |
| if (state.mouse.drag && !state.buildMode) { | |
| handleDragSelect(event); | |
| } else { | |
| handleLeftClick(event); | |
| } | |
| } | |
| state.mouse.down = false; | |
| state.mouse.drag = false; | |
| }); | |
| canvas.addEventListener("contextmenu", (event) => { | |
| event.preventDefault(); | |
| }); | |
| minimap.addEventListener("mousedown", (event) => { | |
| const rect = minimap.getBoundingClientRect(); | |
| const mx = (event.clientX - rect.left) / rect.width; | |
| const my = (event.clientY - rect.top) / rect.height; | |
| state.camera.x = mx * MAP_PIX_W - state.view.w / 2; | |
| state.camera.y = my * MAP_PIX_H - state.view.h / 2; | |
| clampCamera(); | |
| }); | |
| window.addEventListener("keydown", (event) => { | |
| const key = event.key.toLowerCase(); | |
| state.keys.add(key); | |
| if (["arrowleft", "arrowright", "arrowup", "arrowdown", " "].includes(event.key.toLowerCase())) { | |
| event.preventDefault(); | |
| } | |
| if (key === "escape") { | |
| if (state.buildMode) { | |
| state.buildMode = null; | |
| addMessage("Building placement cancelled."); | |
| } else { | |
| clearSelection(); | |
| } | |
| } | |
| if (key === "h") { | |
| const home = state.buildings.find((building) => building.type === "command"); | |
| if (home) centerCameraOn((home.x + home.w / 2) * TILE, (home.y + home.h / 2) * TILE); | |
| } | |
| if (key === " ") { | |
| event.preventDefault(); | |
| centerOnSelection(); | |
| } | |
| }); | |
| window.addEventListener("keyup", (event) => { | |
| state.keys.delete(event.key.toLowerCase()); | |
| }); | |
| window.addEventListener("resize", resize); | |
| function updateMouseFromEvent(event) { | |
| const rect = canvas.getBoundingClientRect(); | |
| state.mouse.x = clamp(event.clientX - rect.left, 0, state.view.w); | |
| state.mouse.y = clamp(event.clientY - rect.top, 0, state.view.h); | |
| state.mouse.worldX = state.mouse.x + state.camera.x; | |
| state.mouse.worldY = state.mouse.y + state.camera.y; | |
| } | |
| function centerCameraOn(x, y) { | |
| state.camera.x = x - state.view.w / 2; | |
| state.camera.y = y - state.view.h / 2; | |
| clampCamera(); | |
| } | |
| function centerOnSelection() { | |
| const selected = selectedEntities(); | |
| if (!selected.length) return; | |
| let x = 0; | |
| let y = 0; | |
| for (const entity of selected) { | |
| if (entity.kind === "unit") { | |
| x += entity.x; | |
| y += entity.y; | |
| } else if (entity.kind === "building") { | |
| x += (entity.x + entity.w / 2) * TILE; | |
| y += (entity.y + entity.h / 2) * TILE; | |
| } else { | |
| x += (entity.x + 0.5) * TILE; | |
| y += (entity.y + 0.5) * TILE; | |
| } | |
| } | |
| centerCameraOn(x / selected.length, y / selected.length); | |
| } | |
| function clampCamera() { | |
| state.camera.x = clamp(state.camera.x, 0, Math.max(0, MAP_PIX_W - state.view.w)); | |
| state.camera.y = clamp(state.camera.y, 0, Math.max(0, MAP_PIX_H - state.view.h)); | |
| state.mouse.worldX = state.mouse.x + state.camera.x; | |
| state.mouse.worldY = state.mouse.y + state.camera.y; | |
| } | |
| function resize() { | |
| const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); | |
| state.view = { | |
| w: window.innerWidth, | |
| h: window.innerHeight, | |
| dpr | |
| }; | |
| canvas.width = Math.floor(state.view.w * dpr); | |
| canvas.height = Math.floor(state.view.h * dpr); | |
| canvas.style.width = `${state.view.w}px`; | |
| canvas.style.height = `${state.view.h}px`; | |
| clampCamera(); | |
| } | |
| function loop(now) { | |
| const dt = Math.min(0.05, (now - state.lastTime) / 1000 || 0); | |
| state.lastTime = now; | |
| update(dt); | |
| draw(); | |
| requestAnimationFrame(loop); | |
| } | |
| resize(); | |
| setupGame(); | |
| updateStats(); | |
| updatePanel(true); | |
| requestAnimationFrame(loop); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment