Skip to content

Instantly share code, notes, and snippets.

@senko
Created April 29, 2026 13:45
Show Gist options
  • Select an option

  • Save senko/816194af33efd3947fe4ec2026f7ad7b to your computer and use it in GitHub Desktop.

Select an option

Save senko/816194af33efd3947fe4ec2026f7ad7b to your computer and use it in GitHub Desktop.
RTS game by OpenAI GPT-5.5
<!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