Created
February 5, 2026 19:16
-
-
Save senko/d87b1a311b300accdbf3de8992e53ca3 to your computer and use it in GitHub Desktop.
RTS game by Codex 5.3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Mini RTS</title> | |
| <style> | |
| :root { | |
| --bg: #0f151d; | |
| --panel: #1c2733; | |
| --panel2: #223344; | |
| --text: #d9e9f7; | |
| --accent: #66d9ff; | |
| --warn: #ffb84d; | |
| --good: #6cff95; | |
| --danger: #ff6b6b; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| height: 100%; | |
| overflow: hidden; | |
| background: radial-gradient(circle at 20% 10%, #1a2633, #0a1016 70%); | |
| color: var(--text); | |
| font-family: "Trebuchet MS", "Segoe UI", sans-serif; | |
| } | |
| #layout { | |
| display: grid; | |
| grid-template-rows: 1fr 138px; | |
| height: 100vh; | |
| width: 100vw; | |
| } | |
| #canvasWrap { | |
| position: relative; | |
| overflow: hidden; | |
| border-bottom: 1px solid #304154; | |
| } | |
| #gameCanvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| cursor: crosshair; | |
| background: #101820; | |
| } | |
| #hud { | |
| display: grid; | |
| grid-template-columns: 1.1fr 1fr 1.4fr; | |
| gap: 10px; | |
| padding: 10px; | |
| background: linear-gradient(180deg, var(--panel2), var(--panel)); | |
| border-top: 1px solid #3a5168; | |
| } | |
| .card { | |
| background: linear-gradient(180deg, #27384a, #1a2734); | |
| border: 1px solid #3c556b; | |
| border-radius: 10px; | |
| padding: 10px; | |
| min-height: 0; | |
| box-shadow: inset 0 0 0 1px #111a22; | |
| } | |
| #stats { | |
| display: flex; | |
| gap: 18px; | |
| align-items: center; | |
| font-size: 17px; | |
| flex-wrap: wrap; | |
| } | |
| #stats strong { | |
| color: var(--accent); | |
| letter-spacing: 0.4px; | |
| margin-right: 4px; | |
| } | |
| #selectionInfo { | |
| font-size: 14px; | |
| line-height: 1.35; | |
| white-space: pre-line; | |
| } | |
| #actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| align-content: flex-start; | |
| overflow: auto; | |
| max-height: 115px; | |
| padding-right: 4px; | |
| } | |
| .actionBtn { | |
| border: 1px solid #4d6a81; | |
| background: linear-gradient(180deg, #39526b, #2a3f53); | |
| color: #e9f6ff; | |
| border-radius: 8px; | |
| padding: 8px 10px; | |
| min-width: 116px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: transform 0.06s ease, border-color 0.12s ease; | |
| } | |
| .actionBtn:hover { | |
| border-color: #75b8df; | |
| transform: translateY(-1px); | |
| } | |
| .actionBtn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| #banner { | |
| position: absolute; | |
| top: 16px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| padding: 8px 14px; | |
| border-radius: 8px; | |
| border: 1px solid #57728a; | |
| background: rgba(21, 34, 45, 0.82); | |
| font-size: 13px; | |
| letter-spacing: 0.3px; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.25s ease; | |
| } | |
| #banner.show { | |
| opacity: 1; | |
| } | |
| .hint { | |
| font-size: 12px; | |
| opacity: 0.88; | |
| margin-top: 8px; | |
| line-height: 1.35; | |
| } | |
| .warn { | |
| color: var(--warn); | |
| } | |
| @media (max-width: 980px) { | |
| #hud { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: auto auto auto; | |
| height: 220px; | |
| } | |
| #layout { | |
| grid-template-rows: 1fr 220px; | |
| } | |
| #actions { | |
| max-height: 80px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="layout"> | |
| <div id="canvasWrap"> | |
| <canvas id="gameCanvas"></canvas> | |
| <div id="banner"></div> | |
| </div> | |
| <div id="hud"> | |
| <div class="card"> | |
| <div id="stats"></div> | |
| <div class="hint"> | |
| Controls: <strong>LMB</strong> select, <strong>drag</strong> box select, <strong>RMB</strong> move/harvest/rally, | |
| <strong>WASD / arrows</strong> pan camera, <strong>minimap</strong> click to jump. | |
| </div> | |
| </div> | |
| <div class="card" id="selectionInfo">Selection: none</div> | |
| <div class="card"> | |
| <div id="actions"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| const canvas = document.getElementById("gameCanvas"); | |
| const ctx = canvas.getContext("2d"); | |
| const statsEl = document.getElementById("stats"); | |
| const actionsEl = document.getElementById("actions"); | |
| const selectionEl = document.getElementById("selectionInfo"); | |
| const bannerEl = document.getElementById("banner"); | |
| const TILE = 32; | |
| const MAP_W = 72; | |
| const MAP_H = 72; | |
| const WORLD_W = MAP_W * TILE; | |
| const WORLD_H = MAP_H * TILE; | |
| const minimap = { | |
| x: 0, | |
| y: 14, | |
| w: 190, | |
| h: 190, | |
| }; | |
| const player = { | |
| minerals: 450, | |
| }; | |
| const state = { | |
| camX: 0, | |
| camY: 0, | |
| mouseX: 0, | |
| mouseY: 0, | |
| mouseWorldX: 0, | |
| mouseWorldY: 0, | |
| mouseInCanvas: false, | |
| selectedUnits: new Set(), | |
| selectedBuildings: new Set(), | |
| dragStart: null, | |
| dragCurrent: null, | |
| keys: new Set(), | |
| buildMode: null, | |
| time: 0, | |
| winAnnounced: false, | |
| }; | |
| let width = 1; | |
| let height = 1; | |
| let lastTime = performance.now(); | |
| let nextId = 1; | |
| const terrain = []; | |
| const resources = []; | |
| const units = []; | |
| const buildings = []; | |
| const visible = new Uint8Array(MAP_W * MAP_H); | |
| const explored = new Uint8Array(MAP_W * MAP_H); | |
| const UNIT_DEF = { | |
| worker: { | |
| radius: 10, | |
| speed: 76, | |
| maxHp: 50, | |
| color: "#ffe081", | |
| vision: 5, | |
| cost: 50, | |
| trainTime: 7, | |
| carryCap: 25, | |
| }, | |
| soldier: { | |
| radius: 11, | |
| speed: 88, | |
| maxHp: 75, | |
| color: "#86d2ff", | |
| vision: 6, | |
| cost: 70, | |
| trainTime: 8, | |
| carryCap: 0, | |
| }, | |
| scout: { | |
| radius: 9, | |
| speed: 116, | |
| maxHp: 40, | |
| color: "#8dffb7", | |
| vision: 7, | |
| cost: 0, | |
| trainTime: 0, | |
| carryCap: 0, | |
| }, | |
| }; | |
| const BUILD_DEF = { | |
| hq: { | |
| w: 3, | |
| h: 3, | |
| maxHp: 420, | |
| color: "#98b4cc", | |
| canTrain: ["worker"], | |
| buildTime: 0, | |
| cost: 0, | |
| vision: 7, | |
| name: "Command Hub", | |
| isDropoff: true, | |
| }, | |
| barracks: { | |
| w: 3, | |
| h: 2, | |
| maxHp: 300, | |
| color: "#85a3bd", | |
| canTrain: ["soldier"], | |
| buildTime: 20, | |
| cost: 140, | |
| vision: 6, | |
| name: "Barracks", | |
| isDropoff: false, | |
| }, | |
| refinery: { | |
| w: 2, | |
| h: 2, | |
| maxHp: 240, | |
| color: "#7da8b1", | |
| canTrain: [], | |
| buildTime: 14, | |
| cost: 90, | |
| vision: 5, | |
| name: "Refinery", | |
| isDropoff: true, | |
| }, | |
| outpost: { | |
| w: 2, | |
| h: 2, | |
| maxHp: 180, | |
| color: "#c0a777", | |
| canTrain: [], | |
| buildTime: 12, | |
| cost: 100, | |
| vision: 8, | |
| name: "Outpost", | |
| isDropoff: false, | |
| }, | |
| }; | |
| function showBanner(msg, ms = 1400) { | |
| bannerEl.textContent = msg; | |
| bannerEl.classList.add("show"); | |
| clearTimeout(showBanner.timer); | |
| showBanner.timer = setTimeout(() => bannerEl.classList.remove("show"), ms); | |
| } | |
| function clamp(v, min, max) { | |
| return Math.max(min, Math.min(max, v)); | |
| } | |
| function dist2(ax, ay, bx, by) { | |
| const dx = bx - ax; | |
| const dy = by - ay; | |
| return dx * dx + dy * dy; | |
| } | |
| function makeId() { | |
| return nextId++; | |
| } | |
| function tileIndex(tx, ty) { | |
| return ty * MAP_W + tx; | |
| } | |
| function worldToTile(x, y) { | |
| return { | |
| tx: clamp(Math.floor(x / TILE), 0, MAP_W - 1), | |
| ty: clamp(Math.floor(y / TILE), 0, MAP_H - 1), | |
| }; | |
| } | |
| function tileCenter(tx, ty) { | |
| return { | |
| x: tx * TILE + TILE * 0.5, | |
| y: ty * TILE + TILE * 0.5, | |
| }; | |
| } | |
| function spawnUnit(type, x, y) { | |
| const def = UNIT_DEF[type]; | |
| const unit = { | |
| id: makeId(), | |
| type, | |
| x, | |
| y, | |
| hp: def.maxHp, | |
| maxHp: def.maxHp, | |
| radius: def.radius, | |
| speed: def.speed, | |
| vision: def.vision, | |
| selected: false, | |
| order: { type: "idle" }, | |
| carrying: 0, | |
| carryCap: def.carryCap, | |
| gatherTick: 0, | |
| }; | |
| units.push(unit); | |
| return unit; | |
| } | |
| function spawnBuilding(type, tx, ty, complete = true) { | |
| const def = BUILD_DEF[type]; | |
| const b = { | |
| id: makeId(), | |
| type, | |
| tx, | |
| ty, | |
| w: def.w, | |
| h: def.h, | |
| hp: complete ? def.maxHp : Math.max(40, Math.floor(def.maxHp * 0.22)), | |
| maxHp: def.maxHp, | |
| selected: false, | |
| completed: complete, | |
| buildProgress: complete ? 1 : 0, | |
| queue: [], | |
| queueProgress: 0, | |
| rallyX: (tx + def.w + 0.7) * TILE, | |
| rallyY: (ty + def.h * 0.5) * TILE, | |
| vision: def.vision, | |
| isDropoff: def.isDropoff, | |
| constructedBy: null, | |
| }; | |
| buildings.push(b); | |
| return b; | |
| } | |
| function placeResources() { | |
| let idCounter = 1; | |
| for (let i = 0; i < 22; i++) { | |
| const margin = 4; | |
| const tx = Math.floor(Math.random() * (MAP_W - margin * 2)) + margin; | |
| const ty = Math.floor(Math.random() * (MAP_H - margin * 2)) + margin; | |
| const amount = 380 + Math.floor(Math.random() * 460); | |
| resources.push({ | |
| id: idCounter++, | |
| x: tx * TILE + TILE / 2, | |
| y: ty * TILE + TILE / 2, | |
| radius: 13, | |
| amount, | |
| }); | |
| } | |
| } | |
| function initTerrain() { | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| const n = Math.random(); | |
| let kind = 0; | |
| if (n < 0.09) kind = 2; | |
| else if (n < 0.24) kind = 1; | |
| terrain.push(kind); | |
| } | |
| } | |
| } | |
| function initGame() { | |
| initTerrain(); | |
| placeResources(); | |
| const startTx = 6; | |
| const startTy = 7; | |
| const hq = spawnBuilding("hq", startTx, startTy, true); | |
| spawnUnit("worker", (startTx + 3.8) * TILE, (startTy + 0.9) * TILE); | |
| spawnUnit("worker", (startTx + 3.8) * TILE, (startTy + 1.6) * TILE); | |
| spawnUnit("worker", (startTx + 3.8) * TILE, (startTy + 2.2) * TILE); | |
| spawnUnit("scout", (startTx + 4.6) * TILE, (startTy + 1.5) * TILE); | |
| state.camX = hq.tx * TILE - 140; | |
| state.camY = hq.ty * TILE - 120; | |
| clampCamera(); | |
| } | |
| function resize() { | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| width = rect.width; | |
| height = rect.height; | |
| canvas.width = Math.floor(width * devicePixelRatio); | |
| canvas.height = Math.floor(height * devicePixelRatio); | |
| canvas.style.width = width + "px"; | |
| canvas.style.height = height + "px"; | |
| ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); | |
| } | |
| function clampCamera() { | |
| state.camX = clamp(state.camX, 0, Math.max(0, WORLD_W - width)); | |
| state.camY = clamp(state.camY, 0, Math.max(0, WORLD_H - height)); | |
| } | |
| function clearSelection() { | |
| state.selectedUnits.clear(); | |
| state.selectedBuildings.clear(); | |
| } | |
| function selectSingle(unit, building, additive) { | |
| if (!additive) clearSelection(); | |
| if (unit) state.selectedUnits.add(unit.id); | |
| if (building) state.selectedBuildings.add(building.id); | |
| } | |
| function getUnitById(id) { | |
| return units.find((u) => u.id === id); | |
| } | |
| function getBuildingById(id) { | |
| return buildings.find((b) => b.id === id); | |
| } | |
| function getSelectedUnits() { | |
| return [...state.selectedUnits] | |
| .map((id) => getUnitById(id)) | |
| .filter(Boolean); | |
| } | |
| function getSelectedBuildings() { | |
| return [...state.selectedBuildings] | |
| .map((id) => getBuildingById(id)) | |
| .filter(Boolean); | |
| } | |
| function cameraToWorld(mx, my) { | |
| return { x: mx + state.camX, y: my + state.camY }; | |
| } | |
| function isPointInBuilding(wx, wy, b) { | |
| const x0 = b.tx * TILE; | |
| const y0 = b.ty * TILE; | |
| return wx >= x0 && wx <= x0 + b.w * TILE && wy >= y0 && wy <= y0 + b.h * TILE; | |
| } | |
| function findEntityAt(wx, wy) { | |
| for (let i = units.length - 1; i >= 0; i--) { | |
| const u = units[i]; | |
| if (dist2(wx, wy, u.x, u.y) <= u.radius * u.radius) { | |
| return { unit: u, building: null }; | |
| } | |
| } | |
| for (let i = buildings.length - 1; i >= 0; i--) { | |
| const b = buildings[i]; | |
| if (isPointInBuilding(wx, wy, b)) { | |
| return { unit: null, building: b }; | |
| } | |
| } | |
| return { unit: null, building: null }; | |
| } | |
| function findResourceAt(wx, wy) { | |
| for (const r of resources) { | |
| if (r.amount <= 0) continue; | |
| if (dist2(wx, wy, r.x, r.y) <= (r.radius + 8) * (r.radius + 8)) return r; | |
| } | |
| return null; | |
| } | |
| function issueMoveCommand(unitsToMove, wx, wy) { | |
| const cols = Math.ceil(Math.sqrt(unitsToMove.length)); | |
| const spacing = 22; | |
| const startX = wx - ((cols - 1) * spacing) / 2; | |
| const startY = wy - ((cols - 1) * spacing) / 2; | |
| unitsToMove.forEach((u, i) => { | |
| const row = Math.floor(i / cols); | |
| const col = i % cols; | |
| u.order = { | |
| type: "move", | |
| x: clamp(startX + col * spacing, 10, WORLD_W - 10), | |
| y: clamp(startY + row * spacing, 10, WORLD_H - 10), | |
| }; | |
| }); | |
| } | |
| function nearestDropoff(wx, wy) { | |
| let best = null; | |
| let bestD2 = Infinity; | |
| for (const b of buildings) { | |
| if (!b.completed || !b.isDropoff) continue; | |
| const bx = (b.tx + b.w * 0.5) * TILE; | |
| const by = (b.ty + b.h * 0.5) * TILE; | |
| const d = dist2(wx, wy, bx, by); | |
| if (d < bestD2) { | |
| bestD2 = d; | |
| best = b; | |
| } | |
| } | |
| return best; | |
| } | |
| function issueHarvestCommand(workers, node) { | |
| for (const w of workers) { | |
| if (w.type !== "worker") continue; | |
| w.order = { | |
| type: "harvest", | |
| nodeId: node.id, | |
| phase: "toNode", | |
| targetDropoffId: null, | |
| }; | |
| w.gatherTick = 0; | |
| } | |
| } | |
| function issueBuildCommand(worker, type, tx, ty) { | |
| const def = BUILD_DEF[type]; | |
| if (player.minerals < def.cost) { | |
| showBanner("Not enough minerals"); | |
| return false; | |
| } | |
| if (!canPlaceBuilding(type, tx, ty)) { | |
| showBanner("Cannot place building here"); | |
| return false; | |
| } | |
| player.minerals -= def.cost; | |
| const b = spawnBuilding(type, tx, ty, false); | |
| b.constructedBy = worker.id; | |
| const targetX = (tx + def.w * 0.5) * TILE; | |
| const targetY = (ty + def.h * 0.5) * TILE; | |
| worker.order = { | |
| type: "build", | |
| buildingId: b.id, | |
| x: targetX, | |
| y: targetY, | |
| }; | |
| showBanner(`${def.name} started`); | |
| return true; | |
| } | |
| function canPlaceBuilding(type, tx, ty) { | |
| const def = BUILD_DEF[type]; | |
| if (tx < 0 || ty < 0 || tx + def.w > MAP_W || ty + def.h > MAP_H) return false; | |
| for (const b of buildings) { | |
| const overlap = | |
| tx < b.tx + b.w && | |
| tx + def.w > b.tx && | |
| ty < b.ty + b.h && | |
| ty + def.h > b.ty; | |
| if (overlap) return false; | |
| } | |
| for (const r of resources) { | |
| if (r.amount <= 0) continue; | |
| const rtx = Math.floor(r.x / TILE); | |
| const rty = Math.floor(r.y / TILE); | |
| if (rtx >= tx && rtx < tx + def.w && rty >= ty && rty < ty + def.h) return false; | |
| } | |
| return true; | |
| } | |
| function enqueueTrain(building, unitType) { | |
| const unitDef = UNIT_DEF[unitType]; | |
| if (!unitDef) return; | |
| if (player.minerals < unitDef.cost) { | |
| showBanner("Not enough minerals"); | |
| return; | |
| } | |
| if (building.queue.length >= 6) { | |
| showBanner("Queue full"); | |
| return; | |
| } | |
| player.minerals -= unitDef.cost; | |
| building.queue.push(unitType); | |
| showBanner(`${unitType} queued`, 900); | |
| } | |
| function updateBuildingProduction(dt) { | |
| for (const b of buildings) { | |
| if (!b.completed || b.queue.length === 0) continue; | |
| const first = b.queue[0]; | |
| const def = UNIT_DEF[first]; | |
| b.queueProgress += dt; | |
| if (b.queueProgress >= def.trainTime) { | |
| b.queueProgress = 0; | |
| b.queue.shift(); | |
| const sx = (b.tx + b.w + 0.35) * TILE; | |
| const sy = (b.ty + b.h * 0.5) * TILE + (Math.random() * 24 - 12); | |
| const u = spawnUnit(first, clamp(sx, 12, WORLD_W - 12), clamp(sy, 12, WORLD_H - 12)); | |
| u.order = { type: "move", x: b.rallyX, y: b.rallyY }; | |
| } | |
| } | |
| } | |
| function updateConstruction(dt) { | |
| for (const b of buildings) { | |
| if (b.completed) continue; | |
| let builderNearby = false; | |
| if (b.constructedBy != null) { | |
| const w = getUnitById(b.constructedBy); | |
| if (w && w.type === "worker") { | |
| const cx = (b.tx + b.w * 0.5) * TILE; | |
| const cy = (b.ty + b.h * 0.5) * TILE; | |
| if (dist2(w.x, w.y, cx, cy) < (TILE * 1.5) * (TILE * 1.5)) { | |
| builderNearby = true; | |
| } | |
| } | |
| } | |
| if (builderNearby) { | |
| b.buildProgress += dt / BUILD_DEF[b.type].buildTime; | |
| b.hp = Math.floor(b.maxHp * clamp(b.buildProgress, 0, 1)); | |
| if (b.buildProgress >= 1) { | |
| b.completed = true; | |
| b.hp = b.maxHp; | |
| showBanner(`${BUILD_DEF[b.type].name} completed`, 1200); | |
| } | |
| } | |
| } | |
| } | |
| function moveUnitTowards(u, tx, ty, dt) { | |
| const dx = tx - u.x; | |
| const dy = ty - u.y; | |
| const d = Math.hypot(dx, dy); | |
| if (d < 1) return true; | |
| const step = u.speed * dt; | |
| if (d <= step) { | |
| u.x = tx; | |
| u.y = ty; | |
| return true; | |
| } | |
| u.x += (dx / d) * step; | |
| u.y += (dy / d) * step; | |
| return false; | |
| } | |
| function updateUnitOrders(dt) { | |
| for (const u of units) { | |
| if (u.order.type === "move") { | |
| const reached = moveUnitTowards(u, u.order.x, u.order.y, dt); | |
| if (reached) u.order = { type: "idle" }; | |
| } else if (u.order.type === "build") { | |
| const b = getBuildingById(u.order.buildingId); | |
| if (!b || b.completed) { | |
| u.order = { type: "idle" }; | |
| continue; | |
| } | |
| const reached = moveUnitTowards(u, u.order.x, u.order.y, dt); | |
| if (reached) { | |
| // Stay near the construction site until done. | |
| u.order = { | |
| type: "assistBuild", | |
| buildingId: b.id, | |
| }; | |
| } | |
| } else if (u.order.type === "assistBuild") { | |
| const b = getBuildingById(u.order.buildingId); | |
| if (!b || b.completed) { | |
| u.order = { type: "idle" }; | |
| } | |
| } else if (u.order.type === "harvest") { | |
| const node = resources.find((r) => r.id === u.order.nodeId); | |
| if (!node || node.amount <= 0) { | |
| u.order = { type: "idle" }; | |
| continue; | |
| } | |
| if (u.order.phase === "toNode") { | |
| const reached = moveUnitTowards(u, node.x, node.y, dt); | |
| if (reached || dist2(u.x, u.y, node.x, node.y) < (node.radius + 9) * (node.radius + 9)) { | |
| u.order.phase = "gather"; | |
| u.gatherTick = 0; | |
| } | |
| } else if (u.order.phase === "gather") { | |
| u.gatherTick += dt; | |
| if (u.gatherTick >= 1.1) { | |
| u.gatherTick = 0; | |
| const mined = Math.min(u.carryCap, node.amount); | |
| u.carrying = mined; | |
| node.amount -= mined; | |
| const drop = nearestDropoff(u.x, u.y); | |
| if (!drop) { | |
| u.order = { type: "idle" }; | |
| } else { | |
| u.order.phase = "toDropoff"; | |
| u.order.targetDropoffId = drop.id; | |
| } | |
| } | |
| } else if (u.order.phase === "toDropoff") { | |
| const b = getBuildingById(u.order.targetDropoffId) || nearestDropoff(u.x, u.y); | |
| if (!b) { | |
| u.order = { type: "idle" }; | |
| continue; | |
| } | |
| const bx = (b.tx + b.w * 0.5) * TILE; | |
| const by = (b.ty + b.h * 0.5) * TILE; | |
| const reached = moveUnitTowards(u, bx, by, dt); | |
| if (reached || dist2(u.x, u.y, bx, by) < (TILE * 1.2) * (TILE * 1.2)) { | |
| player.minerals += u.carrying; | |
| u.carrying = 0; | |
| if (node.amount > 0) { | |
| u.order.phase = "toNode"; | |
| } else { | |
| u.order = { type: "idle" }; | |
| } | |
| } | |
| } | |
| } | |
| u.x = clamp(u.x, 4, WORLD_W - 4); | |
| u.y = clamp(u.y, 4, WORLD_H - 4); | |
| } | |
| } | |
| function updateFog() { | |
| visible.fill(0); | |
| const reveal = (wx, wy, rad) => { | |
| const center = worldToTile(wx, wy); | |
| const r = Math.ceil(rad); | |
| for (let y = center.ty - r; y <= center.ty + r; y++) { | |
| if (y < 0 || y >= MAP_H) continue; | |
| for (let x = center.tx - r; x <= center.tx + r; x++) { | |
| if (x < 0 || x >= MAP_W) continue; | |
| const dx = x - center.tx; | |
| const dy = y - center.ty; | |
| if (dx * dx + dy * dy <= rad * rad) { | |
| const idx = tileIndex(x, y); | |
| visible[idx] = 1; | |
| explored[idx] = 1; | |
| } | |
| } | |
| } | |
| }; | |
| for (const u of units) reveal(u.x, u.y, u.vision); | |
| for (const b of buildings) reveal((b.tx + b.w * 0.5) * TILE, (b.ty + b.h * 0.5) * TILE, b.vision); | |
| } | |
| function explorationPercent() { | |
| let c = 0; | |
| for (let i = 0; i < explored.length; i++) c += explored[i]; | |
| return (c / explored.length) * 100; | |
| } | |
| function isVisibleWorld(wx, wy) { | |
| const { tx, ty } = worldToTile(wx, wy); | |
| return visible[tileIndex(tx, ty)] === 1; | |
| } | |
| function updateCamera(dt) { | |
| const speed = 410; | |
| if (state.keys.has("w") || state.keys.has("arrowup")) state.camY -= speed * dt; | |
| if (state.keys.has("s") || state.keys.has("arrowdown")) state.camY += speed * dt; | |
| if (state.keys.has("a") || state.keys.has("arrowleft")) state.camX -= speed * dt; | |
| if (state.keys.has("d") || state.keys.has("arrowright")) state.camX += speed * dt; | |
| if (state.mouseInCanvas) { | |
| const edge = 18; | |
| if (state.mouseX < edge) state.camX -= speed * 0.8 * dt; | |
| if (state.mouseX > width - edge) state.camX += speed * 0.8 * dt; | |
| if (state.mouseY < edge) state.camY -= speed * 0.8 * dt; | |
| if (state.mouseY > height - edge) state.camY += speed * 0.8 * dt; | |
| } | |
| clampCamera(); | |
| } | |
| function drawTerrain() { | |
| const startX = Math.floor(state.camX / TILE); | |
| const startY = Math.floor(state.camY / TILE); | |
| const endX = Math.ceil((state.camX + width) / TILE); | |
| const endY = Math.ceil((state.camY + height) / TILE); | |
| for (let y = startY; y <= endY; y++) { | |
| if (y < 0 || y >= MAP_H) continue; | |
| for (let x = startX; x <= endX; x++) { | |
| if (x < 0 || x >= MAP_W) continue; | |
| const idx = tileIndex(x, y); | |
| const kind = terrain[idx]; | |
| let color = "#203125"; | |
| if (kind === 1) color = "#253932"; | |
| else if (kind === 2) color = "#2c3e34"; | |
| const px = x * TILE - state.camX; | |
| const py = y * TILE - state.camY; | |
| ctx.fillStyle = color; | |
| ctx.fillRect(px, py, TILE + 1, TILE + 1); | |
| ctx.strokeStyle = "rgba(15,24,20,0.17)"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(px + 0.5, py + 0.5, TILE, TILE); | |
| } | |
| } | |
| } | |
| function drawResources() { | |
| for (const r of resources) { | |
| if (r.amount <= 0) continue; | |
| if (!isVisibleWorld(r.x, r.y)) continue; | |
| const x = r.x - state.camX; | |
| const y = r.y - state.camY; | |
| const grd = ctx.createRadialGradient(x - 4, y - 5, 2, x, y, r.radius + 2); | |
| grd.addColorStop(0, "#b0f2ff"); | |
| grd.addColorStop(1, "#4f95be"); | |
| ctx.fillStyle = grd; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r.radius, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = "rgba(197, 236, 255, 0.75)"; | |
| ctx.lineWidth = 1.2; | |
| ctx.stroke(); | |
| ctx.fillStyle = "rgba(20,40,55,0.9)"; | |
| ctx.fillRect(x - 14, y + r.radius + 4, 28, 5); | |
| ctx.fillStyle = "#87d4ff"; | |
| const ratio = clamp(r.amount / 850, 0, 1); | |
| ctx.fillRect(x - 14, y + r.radius + 4, 28 * ratio, 5); | |
| } | |
| } | |
| function drawBuilding(b) { | |
| if (!isVisibleWorld((b.tx + 0.5) * TILE, (b.ty + 0.5) * TILE)) return; | |
| const px = b.tx * TILE - state.camX; | |
| const py = b.ty * TILE - state.camY; | |
| const w = b.w * TILE; | |
| const h = b.h * TILE; | |
| const base = BUILD_DEF[b.type].color; | |
| ctx.fillStyle = base; | |
| ctx.fillRect(px, py, w, h); | |
| ctx.fillStyle = "rgba(255,255,255,0.07)"; | |
| ctx.fillRect(px + 4, py + 4, w - 8, 8); | |
| if (!b.completed) { | |
| ctx.fillStyle = "rgba(20, 20, 20, 0.52)"; | |
| ctx.fillRect(px, py, w, h); | |
| ctx.fillStyle = "#ffcf70"; | |
| ctx.fillRect(px + 6, py + h - 10, (w - 12) * clamp(b.buildProgress, 0, 1), 4); | |
| } | |
| const isSelected = state.selectedBuildings.has(b.id); | |
| ctx.strokeStyle = isSelected ? "#ffd166" : "rgba(20, 35, 50, 0.8)"; | |
| ctx.lineWidth = isSelected ? 2.2 : 1.2; | |
| ctx.strokeRect(px + 0.5, py + 0.5, w - 1, h - 1); | |
| const hpRatio = clamp(b.hp / b.maxHp, 0, 1); | |
| ctx.fillStyle = "rgba(10,16,22,0.8)"; | |
| ctx.fillRect(px + 5, py - 8, w - 10, 4); | |
| ctx.fillStyle = hpRatio > 0.45 ? "#83ec8d" : "#ff8c7a"; | |
| ctx.fillRect(px + 5, py - 8, (w - 10) * hpRatio, 4); | |
| if (state.selectedBuildings.has(b.id)) { | |
| const rx = b.rallyX - state.camX; | |
| const ry = b.rallyY - state.camY; | |
| ctx.strokeStyle = "rgba(140, 212, 255, 0.6)"; | |
| ctx.lineWidth = 1.2; | |
| ctx.beginPath(); | |
| ctx.moveTo(px + w * 0.5, py + h * 0.5); | |
| ctx.lineTo(rx, ry); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.arc(rx, ry, 7, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } | |
| } | |
| function drawUnit(u) { | |
| if (!isVisibleWorld(u.x, u.y)) return; | |
| const x = u.x - state.camX; | |
| const y = u.y - state.camY; | |
| const c = UNIT_DEF[u.type].color; | |
| ctx.beginPath(); | |
| if (u.type === "worker") { | |
| ctx.fillStyle = c; | |
| ctx.arc(x, y, u.radius, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = "rgba(255,255,255,0.62)"; | |
| ctx.fillRect(x - 3, y - 2, 6, 4); | |
| } else if (u.type === "scout") { | |
| ctx.fillStyle = c; | |
| ctx.moveTo(x, y - u.radius - 2); | |
| ctx.lineTo(x + u.radius, y + u.radius); | |
| ctx.lineTo(x - u.radius, y + u.radius); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } else { | |
| ctx.fillStyle = c; | |
| ctx.rect(x - u.radius, y - u.radius, u.radius * 2, u.radius * 2); | |
| ctx.fill(); | |
| } | |
| if (u.carrying > 0) { | |
| ctx.fillStyle = "#8fd8ff"; | |
| ctx.fillRect(x - 8, y - u.radius - 9, 16 * clamp(u.carrying / u.carryCap, 0, 1), 3); | |
| } | |
| ctx.beginPath(); | |
| ctx.arc(x, y, u.radius + 2.6, 0, Math.PI * 2); | |
| ctx.strokeStyle = state.selectedUnits.has(u.id) ? "#ffe28a" : "rgba(40, 70, 80, 0.9)"; | |
| ctx.lineWidth = state.selectedUnits.has(u.id) ? 2 : 1; | |
| ctx.stroke(); | |
| const hpRatio = clamp(u.hp / u.maxHp, 0, 1); | |
| ctx.fillStyle = "rgba(10,16,22,0.82)"; | |
| ctx.fillRect(x - 10, y + u.radius + 4, 20, 3); | |
| ctx.fillStyle = hpRatio > 0.5 ? "#7bf08c" : "#ff8c78"; | |
| ctx.fillRect(x - 10, y + u.radius + 4, 20 * hpRatio, 3); | |
| } | |
| function drawBuildGhost() { | |
| if (!state.buildMode) return; | |
| const def = BUILD_DEF[state.buildMode.type]; | |
| const tx = Math.floor(state.mouseWorldX / TILE); | |
| const ty = Math.floor(state.mouseWorldY / TILE); | |
| const canPlace = canPlaceBuilding(state.buildMode.type, tx, ty); | |
| const px = tx * TILE - state.camX; | |
| const py = ty * TILE - state.camY; | |
| ctx.fillStyle = canPlace ? "rgba(115, 232, 152, 0.3)" : "rgba(255, 110, 110, 0.3)"; | |
| ctx.fillRect(px, py, def.w * TILE, def.h * TILE); | |
| ctx.strokeStyle = canPlace ? "#67e59a" : "#ff6e6e"; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(px + 0.5, py + 0.5, def.w * TILE - 1, def.h * TILE - 1); | |
| } | |
| function drawSelectionBox() { | |
| if (!state.dragStart || !state.dragCurrent) return; | |
| const x = Math.min(state.dragStart.x, state.dragCurrent.x); | |
| const y = Math.min(state.dragStart.y, state.dragCurrent.y); | |
| const w = Math.abs(state.dragStart.x - state.dragCurrent.x); | |
| const h = Math.abs(state.dragStart.y - state.dragCurrent.y); | |
| ctx.fillStyle = "rgba(110,190,255,0.15)"; | |
| ctx.fillRect(x, y, w, h); | |
| ctx.strokeStyle = "rgba(160,219,255,0.75)"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x + 0.5, y + 0.5, w, h); | |
| } | |
| function drawFog() { | |
| const startX = Math.floor(state.camX / TILE); | |
| const startY = Math.floor(state.camY / TILE); | |
| const endX = Math.ceil((state.camX + width) / TILE); | |
| const endY = Math.ceil((state.camY + height) / TILE); | |
| for (let y = startY; y <= endY; y++) { | |
| if (y < 0 || y >= MAP_H) continue; | |
| for (let x = startX; x <= endX; x++) { | |
| if (x < 0 || x >= MAP_W) continue; | |
| const idx = tileIndex(x, y); | |
| let alpha = 0; | |
| if (!explored[idx]) alpha = 0.93; | |
| else if (!visible[idx]) alpha = 0.44; | |
| if (alpha <= 0) continue; | |
| const px = x * TILE - state.camX; | |
| const py = y * TILE - state.camY; | |
| ctx.fillStyle = `rgba(8, 10, 14, ${alpha})`; | |
| ctx.fillRect(px, py, TILE + 1, TILE + 1); | |
| } | |
| } | |
| } | |
| function drawMinimap() { | |
| minimap.x = width - minimap.w - 14; | |
| ctx.fillStyle = "rgba(10, 16, 24, 0.8)"; | |
| ctx.fillRect(minimap.x - 2, minimap.y - 2, minimap.w + 4, minimap.h + 4); | |
| ctx.fillStyle = "#101a24"; | |
| ctx.fillRect(minimap.x, minimap.y, minimap.w, minimap.h); | |
| const sx = minimap.w / WORLD_W; | |
| const sy = minimap.h / WORLD_H; | |
| const tW = minimap.w / MAP_W; | |
| const tH = minimap.h / MAP_H; | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| const idx = tileIndex(x, y); | |
| if (!explored[idx]) { | |
| ctx.fillStyle = "#05080b"; | |
| } else { | |
| const kind = terrain[idx]; | |
| ctx.fillStyle = kind === 2 ? "#2f4a3f" : kind === 1 ? "#2c443a" : "#273c33"; | |
| if (!visible[idx]) ctx.fillStyle = "#1c2a24"; | |
| } | |
| ctx.fillRect(minimap.x + x * tW, minimap.y + y * tH, tW + 0.5, tH + 0.5); | |
| } | |
| } | |
| for (const r of resources) { | |
| if (r.amount <= 0 || !explored[tileIndex(Math.floor(r.x / TILE), Math.floor(r.y / TILE))]) continue; | |
| ctx.fillStyle = "#6ec9f2"; | |
| ctx.fillRect(minimap.x + r.x * sx - 1, minimap.y + r.y * sy - 1, 2, 2); | |
| } | |
| for (const b of buildings) { | |
| if (!explored[tileIndex(b.tx, b.ty)]) continue; | |
| ctx.fillStyle = b.completed ? "#d6ecff" : "#d9b870"; | |
| ctx.fillRect(minimap.x + b.tx * TILE * sx, minimap.y + b.ty * TILE * sy, Math.max(2, b.w * TILE * sx), Math.max(2, b.h * TILE * sy)); | |
| } | |
| for (const u of units) { | |
| if (!explored[tileIndex(Math.floor(u.x / TILE), Math.floor(u.y / TILE))]) continue; | |
| ctx.fillStyle = u.type === "worker" ? "#ffe18b" : u.type === "scout" ? "#95ffc0" : "#95d7ff"; | |
| ctx.fillRect(minimap.x + u.x * sx - 1, minimap.y + u.y * sy - 1, 2, 2); | |
| } | |
| ctx.strokeStyle = "#98ceef"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(minimap.x, minimap.y, minimap.w, minimap.h); | |
| const vx = minimap.x + state.camX * sx; | |
| const vy = minimap.y + state.camY * sy; | |
| const vw = width * sx; | |
| const vh = height * sy; | |
| ctx.strokeStyle = "#f0fcff"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(vx, vy, vw, vh); | |
| } | |
| function render() { | |
| ctx.clearRect(0, 0, width, height); | |
| drawTerrain(); | |
| drawResources(); | |
| for (const b of buildings) drawBuilding(b); | |
| for (const u of units) drawUnit(u); | |
| drawBuildGhost(); | |
| drawFog(); | |
| drawSelectionBox(); | |
| drawMinimap(); | |
| if (state.buildMode) { | |
| ctx.fillStyle = "rgba(15,24,32,0.7)"; | |
| ctx.fillRect(14, height - 36, 380, 24); | |
| ctx.fillStyle = "#f0f8ff"; | |
| ctx.font = "13px Trebuchet MS"; | |
| ctx.fillText(`Placing ${BUILD_DEF[state.buildMode.type].name}: left click to place, right click to cancel`, 22, height - 20); | |
| } | |
| } | |
| function updateHUD() { | |
| const exploredPct = explorationPercent(); | |
| const idleWorkers = units.filter((u) => u.type === "worker" && u.order.type === "idle").length; | |
| statsEl.innerHTML = | |
| `<div><strong>Minerals</strong>${Math.floor(player.minerals)}</div>` + | |
| `<div><strong>Units</strong>${units.length}</div>` + | |
| `<div><strong>Buildings</strong>${buildings.length}</div>` + | |
| `<div><strong>Idle Workers</strong>${idleWorkers}</div>` + | |
| `<div><strong>Explored</strong>${exploredPct.toFixed(1)}%</div>`; | |
| const selectedUnits = getSelectedUnits(); | |
| const selectedBuildings = getSelectedBuildings(); | |
| if (selectedUnits.length === 0 && selectedBuildings.length === 0) { | |
| selectionEl.textContent = "Selection: none"; | |
| } else if (selectedUnits.length > 0 && selectedBuildings.length === 0) { | |
| const counts = {}; | |
| for (const u of selectedUnits) counts[u.type] = (counts[u.type] || 0) + 1; | |
| const lines = [ | |
| `Units selected: ${selectedUnits.length}`, | |
| Object.entries(counts) | |
| .map(([k, v]) => `${k}: ${v}`) | |
| .join(" "), | |
| ]; | |
| const one = selectedUnits[0]; | |
| if (selectedUnits.length === 1) { | |
| lines.push(`HP: ${Math.round(one.hp)}/${one.maxHp}`); | |
| lines.push(`Order: ${one.order.type}` + (one.carrying ? ` carrying: ${one.carrying}` : "")); | |
| } | |
| selectionEl.textContent = lines.join("\n"); | |
| } else if (selectedBuildings.length > 0 && selectedUnits.length === 0) { | |
| if (selectedBuildings.length === 1) { | |
| const b = selectedBuildings[0]; | |
| const def = BUILD_DEF[b.type]; | |
| selectionEl.textContent = | |
| `${def.name}\n` + | |
| `HP: ${Math.round(b.hp)}/${b.maxHp}\n` + | |
| `${b.completed ? "Completed" : "Under Construction"}\n` + | |
| `Queue: ${b.queue.length > 0 ? b.queue.join(", ") : "empty"}`; | |
| } else { | |
| selectionEl.textContent = `Buildings selected: ${selectedBuildings.length}`; | |
| } | |
| } else { | |
| selectionEl.textContent = `Mixed selection: ${selectedUnits.length} units + ${selectedBuildings.length} buildings`; | |
| } | |
| actionsEl.innerHTML = ""; | |
| if (selectedUnits.length > 0 && selectedBuildings.length === 0) { | |
| const allWorkers = selectedUnits.every((u) => u.type === "worker"); | |
| if (allWorkers && selectedUnits.length > 0) { | |
| addActionButton("Build Barracks (140)", () => setBuildMode("barracks"), player.minerals >= BUILD_DEF.barracks.cost); | |
| addActionButton("Build Refinery (90)", () => setBuildMode("refinery"), player.minerals >= BUILD_DEF.refinery.cost); | |
| addActionButton("Build Outpost (100)", () => setBuildMode("outpost"), player.minerals >= BUILD_DEF.outpost.cost); | |
| } | |
| addActionButton("Stop", () => { | |
| for (const u of selectedUnits) u.order = { type: "idle" }; | |
| }, true); | |
| } | |
| if (selectedBuildings.length === 1 && selectedUnits.length === 0) { | |
| const b = selectedBuildings[0]; | |
| const def = BUILD_DEF[b.type]; | |
| if (b.completed && def.canTrain.length) { | |
| for (const t of def.canTrain) { | |
| const uDef = UNIT_DEF[t]; | |
| addActionButton( | |
| `Train ${t} (${uDef.cost})`, | |
| () => enqueueTrain(b, t), | |
| player.minerals >= uDef.cost && b.queue.length < 6 | |
| ); | |
| } | |
| } | |
| if (b.completed) { | |
| addActionButton("Set Rally (RMB)", () => { | |
| showBanner("Right-click on terrain to set rally point", 1300); | |
| }, true); | |
| } | |
| } | |
| if (state.buildMode) { | |
| addActionButton("Cancel Build", () => { | |
| state.buildMode = null; | |
| }, true); | |
| } | |
| } | |
| function addActionButton(label, onClick, enabled) { | |
| const btn = document.createElement("button"); | |
| btn.className = "actionBtn"; | |
| btn.textContent = label; | |
| btn.disabled = !enabled; | |
| btn.addEventListener("pointerdown", (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (!btn.disabled) onClick(); | |
| }); | |
| btn.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| }); | |
| actionsEl.appendChild(btn); | |
| } | |
| function setBuildMode(type) { | |
| const selectedUnits = getSelectedUnits(); | |
| const worker = selectedUnits.find((u) => u.type === "worker"); | |
| if (!worker) { | |
| showBanner("Select at least one worker"); | |
| return; | |
| } | |
| state.buildMode = { type, workerId: worker.id }; | |
| showBanner(`Placing ${BUILD_DEF[type].name}`); | |
| } | |
| function handleLeftDown(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left; | |
| const my = e.clientY - rect.top; | |
| state.mouseX = mx; | |
| state.mouseY = my; | |
| const world = cameraToWorld(mx, my); | |
| state.mouseWorldX = world.x; | |
| state.mouseWorldY = world.y; | |
| if (inMinimap(mx, my)) { | |
| jumpCameraFromMinimap(mx, my); | |
| return; | |
| } | |
| if (state.buildMode) { | |
| const tx = Math.floor(world.x / TILE); | |
| const ty = Math.floor(world.y / TILE); | |
| const worker = getUnitById(state.buildMode.workerId); | |
| if (!worker) { | |
| state.buildMode = null; | |
| return; | |
| } | |
| if (issueBuildCommand(worker, state.buildMode.type, tx, ty)) { | |
| state.buildMode = null; | |
| } | |
| return; | |
| } | |
| state.dragStart = { x: mx, y: my }; | |
| state.dragCurrent = { x: mx, y: my }; | |
| } | |
| function handleLeftUp(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left; | |
| const my = e.clientY - rect.top; | |
| const world = cameraToWorld(mx, my); | |
| if (!state.dragStart) return; | |
| const dragDx = Math.abs(mx - state.dragStart.x); | |
| const dragDy = Math.abs(my - state.dragStart.y); | |
| const additive = e.shiftKey; | |
| if (dragDx > 6 || dragDy > 6) { | |
| if (!additive) clearSelection(); | |
| const x0 = Math.min(state.dragStart.x, mx) + state.camX; | |
| const x1 = Math.max(state.dragStart.x, mx) + state.camX; | |
| const y0 = Math.min(state.dragStart.y, my) + state.camY; | |
| const y1 = Math.max(state.dragStart.y, my) + state.camY; | |
| for (const u of units) { | |
| if (u.x >= x0 && u.x <= x1 && u.y >= y0 && u.y <= y1) { | |
| state.selectedUnits.add(u.id); | |
| } | |
| } | |
| } else { | |
| const hit = findEntityAt(world.x, world.y); | |
| if (hit.unit || hit.building) { | |
| selectSingle(hit.unit, hit.building, additive); | |
| } else if (!additive) { | |
| clearSelection(); | |
| } | |
| } | |
| state.dragStart = null; | |
| state.dragCurrent = null; | |
| } | |
| function handleRightDown(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left; | |
| const my = e.clientY - rect.top; | |
| const world = cameraToWorld(mx, my); | |
| if (state.buildMode) { | |
| state.buildMode = null; | |
| return; | |
| } | |
| const selectedUnits = getSelectedUnits(); | |
| const selectedBuildings = getSelectedBuildings(); | |
| if (selectedBuildings.length > 0 && selectedUnits.length === 0) { | |
| for (const b of selectedBuildings) { | |
| b.rallyX = clamp(world.x, 8, WORLD_W - 8); | |
| b.rallyY = clamp(world.y, 8, WORLD_H - 8); | |
| } | |
| return; | |
| } | |
| if (selectedUnits.length === 0) return; | |
| const resource = findResourceAt(world.x, world.y); | |
| const hasWorkers = selectedUnits.some((u) => u.type === "worker"); | |
| if (resource && hasWorkers) { | |
| issueHarvestCommand(selectedUnits, resource); | |
| } else { | |
| issueMoveCommand(selectedUnits, world.x, world.y); | |
| } | |
| } | |
| function onMouseMove(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| state.mouseX = e.clientX - rect.left; | |
| state.mouseY = e.clientY - rect.top; | |
| state.mouseInCanvas = true; | |
| const world = cameraToWorld(state.mouseX, state.mouseY); | |
| state.mouseWorldX = world.x; | |
| state.mouseWorldY = world.y; | |
| if (state.dragStart) { | |
| state.dragCurrent = { x: state.mouseX, y: state.mouseY }; | |
| } | |
| } | |
| function inMinimap(mx, my) { | |
| return mx >= minimap.x && mx <= minimap.x + minimap.w && my >= minimap.y && my <= minimap.y + minimap.h; | |
| } | |
| function jumpCameraFromMinimap(mx, my) { | |
| const rx = (mx - minimap.x) / minimap.w; | |
| const ry = (my - minimap.y) / minimap.h; | |
| state.camX = clamp(rx * WORLD_W - width / 2, 0, WORLD_W - width); | |
| state.camY = clamp(ry * WORLD_H - height / 2, 0, WORLD_H - height); | |
| } | |
| function step(now) { | |
| const dt = Math.min(0.05, (now - lastTime) / 1000); | |
| lastTime = now; | |
| state.time += dt; | |
| updateCamera(dt); | |
| updateUnitOrders(dt); | |
| updateConstruction(dt); | |
| updateBuildingProduction(dt); | |
| updateFog(); | |
| updateHUD(); | |
| const exploredPct = explorationPercent(); | |
| if (!state.winAnnounced && exploredPct >= 99.8) { | |
| state.winAnnounced = true; | |
| showBanner("Map fully explored", 2600); | |
| } | |
| render(); | |
| requestAnimationFrame(step); | |
| } | |
| function hookInput() { | |
| canvas.addEventListener("contextmenu", (e) => e.preventDefault()); | |
| canvas.addEventListener("mouseenter", () => { | |
| state.mouseInCanvas = true; | |
| }); | |
| canvas.addEventListener("mouseleave", () => { | |
| state.mouseInCanvas = false; | |
| if (state.dragStart) { | |
| state.dragStart = null; | |
| state.dragCurrent = null; | |
| } | |
| }); | |
| canvas.addEventListener("mousedown", (e) => { | |
| if (e.button === 0) handleLeftDown(e); | |
| else if (e.button === 2) handleRightDown(e); | |
| }); | |
| canvas.addEventListener("mouseup", (e) => { | |
| if (e.button === 0) handleLeftUp(e); | |
| }); | |
| canvas.addEventListener("mousemove", onMouseMove); | |
| window.addEventListener("keydown", (e) => { | |
| state.keys.add(e.key.toLowerCase()); | |
| }); | |
| window.addEventListener("keyup", (e) => { | |
| state.keys.delete(e.key.toLowerCase()); | |
| }); | |
| window.addEventListener("blur", () => { | |
| state.keys.clear(); | |
| state.mouseInCanvas = false; | |
| state.dragStart = null; | |
| state.dragCurrent = null; | |
| }); | |
| window.addEventListener("resize", resize); | |
| } | |
| initGame(); | |
| hookInput(); | |
| resize(); | |
| updateFog(); | |
| updateHUD(); | |
| requestAnimationFrame((t) => { | |
| lastTime = t; | |
| requestAnimationFrame(step); | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment