Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 9, 2026 19:28
Show Gist options
  • Select an option

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

Select an option

Save senko/cee877674a9e7060add155fec7ae72f1 to your computer and use it in GitHub Desktop.
RTS game by GPT-5.6 Terra
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frontier Command</title>
<link rel="icon" href="data:,">
<style>
:root {
color-scheme: dark;
--ink: #0d171b;
--panel: #142126;
--panel-2: #1c2d33;
--line: #38525a;
--text: #e7f1ec;
--muted: #91a8aa;
--accent: #64d7b3;
--amber: #f0b75b;
--danger: #e46b57;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 760px;
height: 100vh;
overflow: hidden;
background: #0a1114;
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
#game-shell {
height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr) 290px;
grid-template-rows: 58px minmax(0, 1fr);
background: #0a1114;
}
header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 24px;
padding: 0 18px;
border-bottom: 1px solid #30454c;
background: #132025;
box-shadow: 0 2px 12px #05090b88;
z-index: 3;
}
.brand {
min-width: 178px;
font-weight: 800;
font-size: 15px;
line-height: 1;
color: #f2f6e9;
text-transform: uppercase;
letter-spacing: 1.2px;
}
.brand small { display: block; margin-top: 5px; color: var(--accent); font-size: 9px; letter-spacing: 2.3px; }
#resources { display: flex; align-items: stretch; height: 100%; }
.resource {
display: flex;
align-items: center;
gap: 9px;
min-width: 106px;
padding: 0 16px;
border-left: 1px solid #294148;
color: var(--text);
font-variant-numeric: tabular-nums;
font-weight: 750;
}
.resource:last-child { border-right: 1px solid #294148; }
.resource-icon { width: 14px; height: 14px; display: inline-block; }
.wood { background: #5a9b69; border-radius: 2px; transform: rotate(45deg); }
.ore { background: #66b9d6; clip-path: polygon(50% 0, 95% 35%, 76% 100%, 23% 100%, 4% 35%); }
.people { border: 2px solid var(--amber); border-radius: 50%; position: relative; }
.people::after { content: ""; position: absolute; width: 18px; height: 7px; border: 2px solid var(--amber); border-bottom: 0; left: -4px; top: 10px; border-radius: 12px 12px 0 0; }
#status { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#field { grid-column: 1; grid-row: 2; position: relative; overflow: hidden; min-width: 0; min-height: 0; background: #182f2a; }
#game { width: 100%; height: 100%; display: block; cursor: crosshair; }
#selection-box { display: none; position: absolute; border: 1px solid #a8f5db; background: #6ce6c528; pointer-events: none; z-index: 2; }
aside {
grid-column: 2;
grid-row: 2;
border-left: 1px solid #30454c;
background: #101c21;
display: flex;
flex-direction: column;
min-height: 0;
z-index: 2;
}
.panel { padding: 15px; border-bottom: 1px solid #2d454c; }
.panel-title { margin: 0 0 11px; color: #a9c1bd; font-size: 10px; font-weight: 800; letter-spacing: 1.4px; text-transform: uppercase; }
#selection-info { min-height: 94px; }
.selection-name { font-size: 17px; font-weight: 800; margin-bottom: 5px; }
.selection-subtitle { color: var(--muted); font-size: 12px; line-height: 1.45; }
.meter { height: 6px; background: #091114; border: 1px solid #263b40; margin-top: 11px; overflow: hidden; }
.meter > span { display: block; height: 100%; background: #65cda8; }
.build-grid, .action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
button {
position: relative;
border: 1px solid #355057;
border-radius: 4px;
background: #1a2a30;
color: #e6eeeb;
min-height: 56px;
padding: 8px;
cursor: pointer;
text-align: left;
font: inherit;
transition: background .14s ease, border-color .14s ease, transform .14s ease;
}
button:hover:not(:disabled) { background: #243a3f; border-color: #62bca5; }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { cursor: not-allowed; opacity: .42; }
button.armed { border-color: #eebe62; background: #3a3020; box-shadow: inset 0 0 0 1px #eebe6244; }
.cmd-icon { float: left; display: grid; place-items: center; width: 26px; height: 26px; margin-right: 7px; color: var(--accent); font-weight: 900; font-size: 18px; }
.cmd-label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 750; }
.cmd-cost { display: block; margin-top: 4px; color: #9ab2b3; font-size: 10px; font-variant-numeric: tabular-nums; }
#action-panel { display: none; }
#action-panel.visible { display: block; }
#minimap-panel { margin-top: auto; padding-bottom: 14px; }
#minimap { width: 100%; aspect-ratio: 1.46; display: block; border: 1px solid #39545a; background: #132624; cursor: crosshair; }
.minimap-caption { display: flex; justify-content: space-between; margin-top: 8px; color: #7e9897; font-size: 10px; text-transform: uppercase; letter-spacing: .9px; }
#toast { position: absolute; left: 15px; bottom: 15px; max-width: 430px; padding: 9px 11px; border-left: 3px solid var(--accent); background: #0b1519e8; color: #d5e4de; font-size: 12px; opacity: 0; transform: translateY(5px); transition: opacity .18s, transform .18s; pointer-events: none; }
#toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 850px) {
body { min-width: 0; }
#game-shell { grid-template-columns: minmax(0, 1fr) 235px; }
header { gap: 8px; padding: 0 10px; }
.brand { min-width: 115px; font-size: 12px; }
.resource { min-width: 72px; padding: 0 9px; }
#status { display: none; }
aside .panel { padding: 11px; }
}
</style>
</head>
<body>
<main id="game-shell">
<header>
<div class="brand">Frontier Command<small>Sector L-17</small></div>
<div id="resources">
<div class="resource"><i class="resource-icon wood"></i><span id="wood-count">0</span></div>
<div class="resource"><i class="resource-icon ore"></i><span id="ore-count">0</span></div>
<div class="resource"><i class="resource-icon people"></i><span id="pop-count">0 / 0</span></div>
</div>
<div id="status">Establish the frontier.</div>
</header>
<section id="field" aria-label="Game map">
<canvas id="game"></canvas>
<div id="selection-box"></div>
<div id="toast"></div>
</section>
<aside>
<section class="panel" id="selection-info">
<h2 class="panel-title">Selection</h2>
<div id="selection-content">
<div class="selection-name">Command Nexus</div>
<div class="selection-subtitle">Coordinate production and expand the frontier.</div>
<div class="meter"><span style="width:100%"></span></div>
</div>
</section>
<section class="panel">
<h2 class="panel-title">Construction</h2>
<div class="build-grid" id="build-grid">
<button data-build="outpost" title="Build Outpost"><span class="cmd-icon">⌂</span><span class="cmd-label">Outpost</span><span class="cmd-cost">90 timber · 30 ore</span></button>
<button data-build="lumberyard" title="Build Lumberyard"><span class="cmd-icon">⌁</span><span class="cmd-label">Lumberyard</span><span class="cmd-cost">120 timber · 35 ore</span></button>
<button data-build="refinery" title="Build Refinery"><span class="cmd-icon">◈</span><span class="cmd-label">Refinery</span><span class="cmd-cost">90 timber · 95 ore</span></button>
<button data-build="barracks" title="Build Barracks"><span class="cmd-icon">⚑</span><span class="cmd-label">Barracks</span><span class="cmd-cost">160 timber · 75 ore</span></button>
<button data-build="beacon" title="Build Survey Beacon"><span class="cmd-icon">✦</span><span class="cmd-label">Survey Beacon</span><span class="cmd-cost">80 timber · 130 ore</span></button>
</div>
</section>
<section class="panel" id="action-panel">
<h2 class="panel-title" id="action-title">Production</h2>
<div class="action-grid" id="action-grid"></div>
</section>
<section class="panel" id="minimap-panel">
<canvas id="minimap" width="246" height="168" aria-label="Minimap"></canvas>
<div class="minimap-caption"><span>Exploration map</span><span id="map-progress">0%</span></div>
</section>
</aside>
</main>
<script>
(() => {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const mini = document.getElementById('minimap');
const mctx = mini.getContext('2d');
const field = document.getElementById('field');
const selectionBox = document.getElementById('selection-box');
const toastEl = document.getElementById('toast');
const statusEl = document.getElementById('status');
const actionPanel = document.getElementById('action-panel');
const actionGrid = document.getElementById('action-grid');
const selectionContent = document.getElementById('selection-content');
const buildGrid = document.getElementById('build-grid');
const TILE = 48;
const MAP_W = 34;
const MAP_H = 24;
const WORLD_W = MAP_W * TILE;
const WORLD_H = MAP_H * TILE;
const resources = { wood: 520, ore: 390 };
let population = 0;
let populationCap = 0;
let nextId = 1;
let time = 0;
let last = performance.now();
let camera = { x: 0, y: 0 };
let viewport = { w: 1, h: 1, dpr: 1 };
let selected = [];
let selectedBuilding = null;
let buildMode = null;
let mouse = { x: 0, y: 0, worldX: 0, worldY: 0, down: false, startX: 0, startY: 0 };
let toastTimer = 0;
const keys = new Set();
const BUILDINGS = {
nexus: { name: 'Command Nexus', w: 4, h: 3, hp: 900, vision: 5, supply: 8, color: '#62cfaa', production: ['worker', 'scout'] },
outpost: { name: 'Outpost', w: 2, h: 2, hp: 420, vision: 4, supply: 4, color: '#e9b85e' },
lumberyard: { name: 'Lumberyard', w: 3, h: 2, hp: 520, vision: 3, color: '#8fd36e' },
refinery: { name: 'Refinery', w: 3, h: 2, hp: 540, vision: 3, color: '#66b8d4' },
barracks: { name: 'Barracks', w: 3, h: 3, hp: 700, vision: 3, color: '#df7859', production: ['guard', 'scout'] },
beacon: { name: 'Survey Beacon', w: 2, h: 2, hp: 360, vision: 6, color: '#b77bd8', scan: true }
};
const BUILD_COSTS = {
outpost: { wood: 90, ore: 30 }, lumberyard: { wood: 120, ore: 35 }, refinery: { wood: 90, ore: 95 },
barracks: { wood: 160, ore: 75 }, beacon: { wood: 80, ore: 130 }
};
const UNIT_TYPES = {
worker: { name: 'Field Engineer', hp: 80, speed: 77, vision: 3, cost: { wood: 35, ore: 10 }, time: 2.6, color: '#72d4b1', role: 'worker' },
scout: { name: 'Pathfinder', hp: 65, speed: 126, vision: 5, cost: { wood: 30, ore: 25 }, time: 2.1, color: '#e5c45f', role: 'scout' },
guard: { name: 'Vanguard', hp: 135, speed: 68, vision: 3.5, cost: { wood: 55, ore: 65 }, time: 3.2, color: '#dd785b', role: 'guard' }
};
const terrain = [];
const revealed = Array.from({ length: MAP_H }, () => Array(MAP_W).fill(false));
const nodes = [];
const buildings = [];
const units = [];
function hash(x, y) {
let n = Math.imul(x + 163, 374761393) + Math.imul(y + 41, 668265263);
n = (n ^ (n >>> 13)) * 1274126177;
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
}
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function worldPoint(e) {
const rect = canvas.getBoundingClientRect();
return { x: (e.clientX - rect.left) + camera.x, y: (e.clientY - rect.top) + camera.y };
}
function centerOf(b) { return { x: (b.x + b.w * TILE / 2), y: (b.y + b.h * TILE / 2) }; }
function status(text) { statusEl.textContent = text; }
function toast(text) {
toastEl.textContent = text;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2100);
}
function hasCost(cost) { return resources.wood >= cost.wood && resources.ore >= cost.ore; }
function pay(cost) { resources.wood -= cost.wood; resources.ore -= cost.ore; }
function makeTerrain() {
for (let y = 0; y < MAP_H; y++) {
terrain[y] = [];
for (let x = 0; x < MAP_W; x++) {
const r = hash(x, y);
terrain[y][x] = r > .81 ? 2 : r > .56 ? 1 : 0;
}
}
addNode('wood', 8, 5, 6); addNode('wood', 12, 16, 7); addNode('wood', 26, 8, 7); addNode('wood', 29, 19, 8); addNode('wood', 5, 19, 5);
addNode('ore', 18, 5, 5); addNode('ore', 20, 17, 6); addNode('ore', 31, 13, 5); addNode('ore', 9, 11, 4); addNode('ore', 3, 15, 4);
}
function addNode(type, tx, ty, size) {
nodes.push({ id: nextId++, type, x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, radius: 20 + size * 2, amount: 900 + size * 110 });
}
function addBuilding(type, tx, ty, complete = true) {
const d = BUILDINGS[type];
const b = { id: nextId++, type, x: tx * TILE, y: ty * TILE, w: d.w, h: d.h, hp: d.hp, maxHp: d.hp, progress: complete ? 1 : 0, queue: [], production: 0 };
buildings.push(b);
if (complete && d.supply) populationCap += d.supply;
return b;
}
function addUnit(type, x, y) {
const d = UNIT_TYPES[type];
const u = { id: nextId++, type, x, y, hp: d.hp, maxHp: d.hp, target: null, task: null, cargo: 0, cargoType: null, harvestTick: 0, selected: false };
units.push(u);
population++;
return u;
}
function initialState() {
makeTerrain();
addBuilding('nexus', 3, 3);
addBuilding('outpost', 7, 9);
addUnit('worker', 354, 240);
addUnit('worker', 365, 273);
addUnit('worker', 334, 310);
addUnit('scout', 389, 245);
selected = [units[0]];
units[0].selected = true;
camera.x = 0; camera.y = 0;
}
function resize() {
const rect = field.getBoundingClientRect();
viewport.w = Math.max(1, rect.width);
viewport.h = Math.max(1, rect.height);
viewport.dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(viewport.w * viewport.dpr);
canvas.height = Math.round(viewport.h * viewport.dpr);
ctx.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
constrainCamera();
}
function constrainCamera() {
camera.x = clamp(camera.x, 0, Math.max(0, WORLD_W - viewport.w));
camera.y = clamp(camera.y, 0, Math.max(0, WORLD_H - viewport.h));
}
function setSelected(items) {
for (const unit of units) unit.selected = false;
selected = items;
selectedBuilding = null;
selected.forEach(u => u.selected = true);
updatePanels();
}
function selectBuilding(b) {
for (const unit of units) unit.selected = false;
selected = [];
selectedBuilding = b;
updatePanels();
}
function updateResources() {
document.getElementById('wood-count').textContent = Math.floor(resources.wood);
document.getElementById('ore-count').textContent = Math.floor(resources.ore);
document.getElementById('pop-count').textContent = `${population} / ${populationCap}`;
for (const button of buildGrid.querySelectorAll('button')) {
const cost = BUILD_COSTS[button.dataset.build];
button.disabled = !hasCost(cost);
}
}
function updatePanels() {
const selectedType = selected.length === 1 ? UNIT_TYPES[selected[0].type] : null;
if (selectedBuilding) {
const d = BUILDINGS[selectedBuilding.type];
selectionContent.innerHTML = `<div class="selection-name">${d.name}</div><div class="selection-subtitle">${selectedBuilding.queue.length ? `Production queue: ${selectedBuilding.queue.length}` : (selectedBuilding.progress < 1 ? 'Construction in progress' : 'Standing by')}</div><div class="meter"><span style="width:${Math.round(selectedBuilding.progress * 100)}%"></span></div>`;
renderActions(selectedBuilding);
} else if (selected.length > 1) {
const full = selected.reduce((a, u) => a + u.hp / u.maxHp, 0) / selected.length;
selectionContent.innerHTML = `<div class="selection-name">Field group</div><div class="selection-subtitle">${selected.length} units selected</div><div class="meter"><span style="width:${Math.round(full * 100)}%"></span></div>`;
actionPanel.classList.remove('visible');
} else if (selectedType) {
const unit = selected[0];
let job = selectedType.role === 'worker' ? 'Ready for harvest or construction.' : 'Ready for movement orders.';
if (unit.task === 'harvest') job = unit.cargo ? `Carrying ${unit.cargo} ${unit.cargoType}.` : 'Harvesting resource node.';
if (unit.task === 'build') job = 'Constructing field structure.';
selectionContent.innerHTML = `<div class="selection-name">${selectedType.name}</div><div class="selection-subtitle">${job}</div><div class="meter"><span style="width:${Math.round(unit.hp / unit.maxHp * 100)}%"></span></div>`;
actionPanel.classList.remove('visible');
} else {
selectionContent.innerHTML = '<div class="selection-name">No selection</div><div class="selection-subtitle">Select a unit or structure on the map.</div>';
actionPanel.classList.remove('visible');
}
}
function renderActions(b) {
const d = BUILDINGS[b.type];
actionPanel.classList.toggle('visible', Boolean(d.production));
if (!d.production) return;
actionGrid.innerHTML = d.production.map(type => {
const u = UNIT_TYPES[type];
return `<button data-produce="${type}" ${(!hasCost(u.cost) || population >= populationCap || b.progress < 1) ? 'disabled' : ''} title="Train ${u.name}"><span class="cmd-icon">${type === 'worker' ? '◉' : type === 'scout' ? '➤' : '✚'}</span><span class="cmd-label">${u.name}</span><span class="cmd-cost">${u.cost.wood} timber · ${u.cost.ore} ore</span></button>`;
}).join('');
}
function setBuildMode(type) {
if (!hasCost(BUILD_COSTS[type])) { toast('Insufficient materials.'); return; }
buildMode = type;
buildGrid.querySelectorAll('button').forEach(b => b.classList.toggle('armed', b.dataset.build === type));
status(`Place ${BUILDINGS[type].name} on cleared terrain.`);
canvas.style.cursor = 'cell';
}
function clearBuildMode() {
buildMode = null;
buildGrid.querySelectorAll('button').forEach(b => b.classList.remove('armed'));
canvas.style.cursor = 'crosshair';
}
function tileVisible(tx, ty) { return tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H && revealed[ty][tx]; }
function buildingAt(x, y) { return buildings.find(b => x >= b.x && x <= b.x + b.w * TILE && y >= b.y && y <= b.y + b.h * TILE); }
function nodeAt(x, y) { return nodes.find(n => Math.hypot(x - n.x, y - n.y) < n.radius + 8); }
function unitAt(x, y) {
return [...units].reverse().find(u => Math.hypot(x - u.x, y - u.y) < 17);
}
function validPlacement(type, tx, ty) {
const d = BUILDINGS[type];
if (tx < 0 || ty < 0 || tx + d.w > MAP_W || ty + d.h > MAP_H) return false;
for (let y = ty; y < ty + d.h; y++) for (let x = tx; x < tx + d.w; x++) if (!revealed[y][x]) return false;
const x = tx * TILE, y = ty * TILE, w = d.w * TILE, h = d.h * TILE;
if (buildings.some(b => x < b.x + b.w * TILE + 12 && x + w + 12 > b.x && y < b.y + b.h * TILE + 12 && y + h + 12 > b.y)) return false;
return true;
}
function placementClear(type, tx, ty) {
const d = BUILDINGS[type];
const x = tx * TILE, y = ty * TILE, w = d.w * TILE, h = d.h * TILE;
if (!validPlacement(type, tx, ty)) return false;
return !nodes.some(n => n.x > x - n.radius && n.x < x + w + n.radius && n.y > y - n.radius && n.y < y + h + n.radius);
}
function beginConstruction(type, wx, wy) {
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
if (!placementClear(type, tx, ty)) { toast('Build site blocked or hidden.'); return; }
const worker = selected.find(u => UNIT_TYPES[u.type].role === 'worker') || units.find(u => UNIT_TYPES[u.type].role === 'worker' && !u.task);
if (!worker) { toast('Select a free Field Engineer.'); return; }
const cost = BUILD_COSTS[type];
if (!hasCost(cost)) { toast('Insufficient materials.'); clearBuildMode(); return; }
pay(cost);
const b = addBuilding(type, tx, ty, false);
worker.task = 'build';
worker.target = centerOf(b);
worker.building = b;
selectedBuilding = b;
clearBuildMode();
status(`${BUILDINGS[type].name} construction deployed.`);
toast('Construction crew dispatched.');
updatePanels(); updateResources();
}
function commandMove(target) {
if (!selected.length) return;
selected.forEach((u, i) => {
u.task = 'move';
u.target = { x: clamp(target.x + (i % 3 - 1) * 26, 16, WORLD_W - 16), y: clamp(target.y + (Math.floor(i / 3) - 1) * 26, 16, WORLD_H - 16) };
u.cargo = 0; u.cargoType = null;
});
status('Movement order acknowledged.');
updatePanels();
}
function commandHarvest(node) {
const workers = selected.filter(u => UNIT_TYPES[u.type].role === 'worker');
if (!workers.length) { commandMove(node); return; }
workers.forEach((u, i) => {
u.task = 'harvest';
u.target = { x: node.x + Math.cos(i * 2.1) * (node.radius - 8), y: node.y + Math.sin(i * 2.1) * (node.radius - 8) };
u.resource = node;
u.cargo = 0; u.cargoType = null;
});
status(`Harvesting ${node.type === 'wood' ? 'timber' : 'ore'} deposit.`);
updatePanels();
}
function enqueue(b, type) {
const d = UNIT_TYPES[type];
if (!hasCost(d.cost) || population >= populationCap) { toast(population >= populationCap ? 'Build an Outpost for more capacity.' : 'Insufficient materials.'); return; }
pay(d.cost);
b.queue.push(type);
status(`${d.name} queued.`);
updateResources(); updatePanels();
}
function moveUnit(u, dt) {
if (!u.target) return true;
const dx = u.target.x - u.x, dy = u.target.y - u.y;
const len = Math.hypot(dx, dy);
if (len < 3) { u.x = u.target.x; u.y = u.target.y; return true; }
const amount = Math.min(len, UNIT_TYPES[u.type].speed * dt);
u.x += dx / len * amount;
u.y += dy / len * amount;
return amount === len;
}
function nearbyDepot(u) {
const candidates = buildings.filter(b => b.progress >= 1 && (b.type === 'nexus' || b.type === 'lumberyard' || b.type === 'refinery'));
return candidates.sort((a, b) => distance(u, centerOf(a)) - distance(u, centerOf(b)))[0];
}
function updateUnit(u, dt) {
if (u.task === 'build') {
if (!u.building || !buildings.includes(u.building)) { u.task = null; return; }
if (!moveUnit(u, dt)) return;
u.building.progress = Math.min(1, u.building.progress + dt / 4.5);
if (u.building.progress >= 1) {
const d = BUILDINGS[u.building.type];
if (d.supply) populationCap += d.supply;
if (d.scan) {
for (const row of revealed) row.fill(true);
toast('Survey sweep complete: sector fully mapped.');
}
status(`${d.name} complete.`);
u.task = null; u.target = null; u.building = null;
updatePanels(); updateResources();
}
return;
}
if (u.task === 'harvest') {
if (u.cargo > 0) {
if (!u.target || !u.deposit) { u.deposit = nearbyDepot(u); u.target = centerOf(u.deposit); }
if (moveUnit(u, dt)) {
resources[u.cargoType] += u.cargo;
u.cargo = 0; u.cargoType = null; u.deposit = null;
u.target = { x: u.resource.x, y: u.resource.y };
updateResources();
}
return;
}
if (!moveUnit(u, dt)) return;
u.harvestTick += dt;
if (u.harvestTick > .62) {
u.harvestTick = 0;
const amount = Math.min(12, u.resource.amount);
if (!amount) { u.task = null; u.target = null; return; }
u.resource.amount -= amount;
u.cargo = amount;
u.cargoType = u.resource.type;
u.deposit = nearbyDepot(u);
u.target = centerOf(u.deposit);
}
return;
}
if (u.target && moveUnit(u, dt)) { u.target = null; u.task = null; }
}
function updateBuildings(dt) {
for (const b of buildings) {
if (!b.queue.length || b.progress < 1) continue;
b.production += dt;
const type = b.queue[0];
if (b.production >= UNIT_TYPES[type].time) {
b.production = 0;
b.queue.shift();
const spawn = { x: b.x + b.w * TILE + 22, y: b.y + b.h * TILE / 2 + (Math.random() - .5) * 30 };
addUnit(type, clamp(spawn.x, 18, WORLD_W - 18), clamp(spawn.y, 18, WORLD_H - 18));
status(`${UNIT_TYPES[type].name} is ready.`);
updateResources(); updatePanels();
}
}
}
function updateVision() {
const sources = [
...units.map(u => ({ x: u.x, y: u.y, r: UNIT_TYPES[u.type].vision })),
...buildings.filter(b => b.progress > 0).map(b => ({ ...centerOf(b), r: BUILDINGS[b.type].vision * b.progress }))
];
for (const source of sources) {
const tx = Math.floor(source.x / TILE), ty = Math.floor(source.y / TILE), r = Math.ceil(source.r);
for (let y = ty - r; y <= ty + r; y++) for (let x = tx - r; x <= tx + r; x++) {
if (x >= 0 && y >= 0 && x < MAP_W && y < MAP_H && Math.hypot(x - tx, y - ty) <= source.r) revealed[y][x] = true;
}
}
}
function updateCamera(dt) {
const speed = 550;
if (keys.has('arrowleft') || keys.has('a')) camera.x -= speed * dt;
if (keys.has('arrowright') || keys.has('d')) camera.x += speed * dt;
if (keys.has('arrowup') || keys.has('w')) camera.y -= speed * dt;
if (keys.has('arrowdown') || keys.has('s')) camera.y += speed * dt;
const edge = 18;
if (mouse.x < edge) camera.x -= speed * dt;
if (mouse.x > viewport.w - edge) camera.x += speed * dt;
if (mouse.y < edge) camera.y -= speed * dt;
if (mouse.y > viewport.h - edge) camera.y += speed * dt;
constrainCamera();
}
function drawTerrain() {
const sx = Math.floor(camera.x / TILE), ex = Math.ceil((camera.x + viewport.w) / TILE);
const sy = Math.floor(camera.y / TILE), ey = Math.ceil((camera.y + viewport.h) / TILE);
ctx.fillStyle = '#1d4439';
ctx.fillRect(0, 0, viewport.w, viewport.h);
for (let y = sy; y <= ey; y++) for (let x = sx; x <= ex; x++) {
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue;
const px = x * TILE - camera.x, py = y * TILE - camera.y;
const t = terrain[y][x];
ctx.fillStyle = t === 2 ? '#265646' : t === 1 ? '#234c40' : '#204638';
ctx.fillRect(px, py, TILE + 1, TILE + 1);
const s = hash(x * 3, y * 9);
ctx.fillStyle = s > .56 ? '#34634c' : '#173a32';
ctx.globalAlpha = .34;
ctx.fillRect(px + 7 + s * 14, py + 9 + hash(y, x) * 16, 2, 2);
ctx.fillRect(px + 31 - s * 9, py + 24 + hash(x, y + 5) * 11, 2, 2);
ctx.globalAlpha = 1;
if ((x + y) % 5 === 0) { ctx.strokeStyle = '#5f92763b'; ctx.lineWidth = 1; ctx.strokeRect(px, py, TILE, TILE); }
}
}
function drawNodes() {
for (const n of nodes) {
if (n.amount <= 0) continue;
const x = n.x - camera.x, y = n.y - camera.y;
ctx.save();
ctx.translate(x, y);
if (n.type === 'wood') {
for (let i = 0; i < 6; i++) {
const a = i * 1.047 + .2;
const px = Math.cos(a) * (9 + (i % 2) * 10), py = Math.sin(a) * (9 + (i % 3) * 7);
ctx.fillStyle = '#132c25'; ctx.beginPath(); ctx.ellipse(px + 2, py + 8, 11, 8, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = i % 2 ? '#477f50' : '#5e9560'; ctx.beginPath(); ctx.arc(px, py, 10, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#84b963'; ctx.beginPath(); ctx.arc(px - 3, py - 4, 4, 0, Math.PI * 2); ctx.fill();
}
} else {
ctx.shadowColor = '#58b9e0'; ctx.shadowBlur = 10;
for (let i = 0; i < 6; i++) {
const a = i * 1.047 + .3, r = 6 + (i % 3) * 8;
const px = Math.cos(a) * r, py = Math.sin(a) * r;
ctx.fillStyle = i % 2 ? '#5aaaca' : '#78d1dd';
ctx.beginPath(); ctx.moveTo(px, py - 13); ctx.lineTo(px + 7, py + 3); ctx.lineTo(px - 5, py + 10); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#b9f5e8'; ctx.lineWidth = 1; ctx.stroke();
}
ctx.shadowBlur = 0;
}
ctx.restore();
}
}
function drawBuilding(b) {
const d = BUILDINGS[b.type], x = b.x - camera.x, y = b.y - camera.y, w = b.w * TILE, h = b.h * TILE;
ctx.save();
ctx.globalAlpha = .62 + b.progress * .38;
ctx.fillStyle = '#0b171a'; ctx.fillRect(x + 5, y + 8, w, h);
ctx.fillStyle = '#263d3f'; ctx.fillRect(x, y, w - 7, h - 8);
ctx.strokeStyle = d.color; ctx.lineWidth = 2; ctx.strokeRect(x + 1, y + 1, w - 9, h - 10);
ctx.fillStyle = '#182b30'; ctx.fillRect(x + 8, y + 9, w - 23, h - 25);
ctx.fillStyle = d.color; ctx.globalAlpha *= .9;
if (b.type === 'nexus') {
ctx.beginPath(); ctx.moveTo(x + w / 2, y + 13); ctx.lineTo(x + w - 23, y + h - 20); ctx.lineTo(x + 22, y + h - 20); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#ccf8e4'; ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2 + 4, 12, 0, Math.PI * 2); ctx.fill();
} else if (b.type === 'beacon') {
ctx.fillRect(x + w / 2 - 4, y + 15, 8, h - 29); ctx.beginPath(); ctx.arc(x + w / 2, y + 17, 11, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#e7ccff'; ctx.beginPath(); ctx.arc(x + w / 2, y + 17, 18 + Math.sin(time * 2) * 2, 0, Math.PI * 2); ctx.stroke();
} else if (b.type === 'barracks') {
ctx.fillRect(x + 17, y + 18, w - 42, h - 42); ctx.fillStyle = '#f0ac84'; ctx.fillRect(x + w / 2 - 5, y + 10, 10, h - 25);
} else if (b.type === 'refinery') {
ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, Math.min(w, h) * .25, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#c0f0e8'; ctx.fillRect(x + 12, y + 13, w - 35, 6);
} else {
ctx.fillRect(x + 14, y + h / 2 - 8, w - 35, 17); ctx.fillStyle = '#dce9b0'; ctx.fillRect(x + w / 2 - 4, y + 13, 8, h - 28);
}
ctx.globalAlpha = 1;
if (b.progress < 1) {
ctx.fillStyle = '#081114cc'; ctx.fillRect(x, y + h - 8, w - 7, 8);
ctx.fillStyle = '#efbd64'; ctx.fillRect(x, y + h - 8, (w - 7) * b.progress, 8);
}
if (selectedBuilding === b) {
ctx.strokeStyle = '#e7f4cf'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.strokeRect(x - 4, y - 4, w + 1, h); ctx.setLineDash([]);
}
ctx.restore();
}
function drawUnits() {
for (const u of units) {
const d = UNIT_TYPES[u.type], x = u.x - camera.x, y = u.y - camera.y;
ctx.save(); ctx.translate(x, y);
ctx.fillStyle = '#0a1517aa'; ctx.beginPath(); ctx.ellipse(3, 8, 13, 6, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = d.color;
if (u.type === 'scout') {
ctx.beginPath(); ctx.moveTo(0, -11); ctx.lineTo(13, 9); ctx.lineTo(-13, 9); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#e3f3cf'; ctx.fillRect(-2, -4, 4, 8);
} else {
ctx.beginPath(); ctx.arc(0, -4, 7, Math.PI, 0); ctx.lineTo(10, 9); ctx.lineTo(-10, 9); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#e7f2ce'; ctx.fillRect(-4, -9, 8, 6);
if (u.type === 'guard') { ctx.strokeStyle = '#f1dca6'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(5, -1); ctx.lineTo(15, -8); ctx.stroke(); }
if (u.type === 'worker') { ctx.strokeStyle = '#d7f4d5'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-6, 1); ctx.lineTo(-13, -7); ctx.stroke(); }
}
if (u.cargo) { ctx.fillStyle = u.cargoType === 'wood' ? '#79b867' : '#78d8e2'; ctx.fillRect(-12, 7, 5, 5); }
if (u.selected) { ctx.strokeStyle = '#e7f4cf'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(0, 8, 16, 8, 0, 0, Math.PI * 2); ctx.stroke(); }
ctx.restore();
}
}
function drawOrders() {
for (const u of selected) if (u.target) {
const x = u.target.x - camera.x, y = u.target.y - camera.y;
ctx.strokeStyle = '#baf2d4aa'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(u.x - camera.x, u.y - camera.y); ctx.lineTo(x, y); ctx.stroke(); ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke();
}
}
function drawFog() {
const sx = Math.floor(camera.x / TILE), ex = Math.ceil((camera.x + viewport.w) / TILE);
const sy = Math.floor(camera.y / TILE), ey = Math.ceil((camera.y + viewport.h) / TILE);
for (let y = sy; y <= ey; y++) for (let x = sx; x <= ex; x++) {
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || revealed[y][x]) continue;
const px = x * TILE - camera.x, py = y * TILE - camera.y;
ctx.fillStyle = '#071014e8'; ctx.fillRect(px, py, TILE + 1, TILE + 1);
ctx.strokeStyle = '#2b4b4f75'; ctx.lineWidth = 1; ctx.strokeRect(px, py, TILE, TILE);
}
}
function drawPlacementGhost() {
if (!buildMode) return;
const tx = Math.floor(mouse.worldX / TILE), ty = Math.floor(mouse.worldY / TILE), d = BUILDINGS[buildMode];
const ok = placementClear(buildMode, tx, ty);
ctx.save(); ctx.globalAlpha = .55; ctx.fillStyle = ok ? '#65d6ac' : '#e36755';
ctx.fillRect(tx * TILE - camera.x, ty * TILE - camera.y, d.w * TILE, d.h * TILE);
ctx.strokeStyle = ok ? '#d7ffe8' : '#ffd6cb'; ctx.lineWidth = 2; ctx.strokeRect(tx * TILE - camera.x + 1, ty * TILE - camera.y + 1, d.w * TILE - 2, d.h * TILE - 2);
ctx.restore();
}
function renderMini() {
const w = mini.width, h = mini.height, sx = w / WORLD_W, sy = h / WORLD_H;
mctx.fillStyle = '#1c4038'; mctx.fillRect(0, 0, w, h);
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
const px = x * TILE * sx, py = y * TILE * sy;
mctx.fillStyle = revealed[y][x] ? (terrain[y][x] === 2 ? '#397451' : '#31664a') : '#0c171b';
mctx.fillRect(px, py, Math.ceil(TILE * sx), Math.ceil(TILE * sy));
}
for (const n of nodes) { mctx.fillStyle = n.type === 'wood' ? '#7baa59' : '#65b9d6'; mctx.fillRect(n.x * sx - 2, n.y * sy - 2, 4, 4); }
for (const b of buildings) { mctx.fillStyle = BUILDINGS[b.type].color; mctx.fillRect(b.x * sx, b.y * sy, b.w * TILE * sx, b.h * TILE * sy); }
for (const u of units) { mctx.fillStyle = '#eaf6d2'; mctx.fillRect(u.x * sx - 1, u.y * sy - 1, 3, 3); }
mctx.strokeStyle = '#e8f2dc'; mctx.lineWidth = 1; mctx.strokeRect(camera.x * sx, camera.y * sy, viewport.w * sx, viewport.h * sy);
const seen = revealed.flat().filter(Boolean).length;
document.getElementById('map-progress').textContent = `${Math.round(seen / (MAP_W * MAP_H) * 100)}%`;
}
function render() {
ctx.clearRect(0, 0, viewport.w, viewport.h);
drawTerrain(); drawNodes(); buildings.forEach(drawBuilding); drawOrders(); drawUnits(); drawFog(); drawPlacementGhost(); renderMini();
}
function tick(now) {
const dt = Math.min(.05, (now - last) / 1000); last = now; time += dt;
updateCamera(dt);
units.forEach(u => updateUnit(u, dt));
updateBuildings(dt);
updateVision();
render();
requestAnimationFrame(tick);
}
function clickMap(world) {
if (buildMode) { beginConstruction(buildMode, world.x, world.y); return; }
const b = buildingAt(world.x, world.y);
const u = unitAt(world.x, world.y);
const n = nodeAt(world.x, world.y);
if (b) { selectBuilding(b); status(`${BUILDINGS[b.type].name} selected.`); return; }
if (u) { setSelected([u]); status(`${UNIT_TYPES[u.type].name} selected.`); return; }
if (n) { commandHarvest(n); return; }
commandMove(world);
}
function finishDrag() {
const x1 = Math.min(mouse.startX, mouse.x), x2 = Math.max(mouse.startX, mouse.x), y1 = Math.min(mouse.startY, mouse.y), y2 = Math.max(mouse.startY, mouse.y);
const items = units.filter(u => {
const x = u.x - camera.x, y = u.y - camera.y;
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
});
if (items.length) { setSelected(items); status(`${items.length} units selected.`); }
}
canvas.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const rect = canvas.getBoundingClientRect();
const p = worldPoint(e);
mouse.down = true; mouse.startX = e.clientX - rect.left; mouse.startY = e.clientY - rect.top;
mouse.x = mouse.startX; mouse.y = mouse.startY; mouse.worldX = p.x; mouse.worldY = p.y;
});
window.addEventListener('mouseup', e => {
if (!mouse.down || e.button !== 0) return;
mouse.down = false; selectionBox.style.display = 'none';
const p = worldPoint(e);
if (Math.hypot(mouse.x - mouse.startX, mouse.y - mouse.startY) > 9 && !buildMode) finishDrag(); else clickMap(p);
});
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect(), p = worldPoint(e);
mouse.x = e.clientX - rect.left; mouse.y = e.clientY - rect.top; mouse.worldX = p.x; mouse.worldY = p.y;
if (mouse.down && !buildMode) {
const left = Math.min(mouse.startX, mouse.x), top = Math.min(mouse.startY, mouse.y);
selectionBox.style.display = 'block'; selectionBox.style.left = `${left}px`; selectionBox.style.top = `${top}px`;
selectionBox.style.width = `${Math.abs(mouse.x - mouse.startX)}px`; selectionBox.style.height = `${Math.abs(mouse.y - mouse.startY)}px`;
}
});
canvas.addEventListener('mouseleave', () => { mouse.x = -100; mouse.y = -100; });
canvas.addEventListener('contextmenu', e => { e.preventDefault(); if (buildMode) { clearBuildMode(); status('Construction order cancelled.'); return; } commandMove(worldPoint(e)); });
window.addEventListener('keydown', e => {
const key = e.key.toLowerCase();
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'w', 'a', 's', 'd'].includes(key)) { keys.add(key); e.preventDefault(); }
if (key === 'escape') { clearBuildMode(); setSelected([]); status('Selection cleared.'); }
});
window.addEventListener('keyup', e => keys.delete(e.key.toLowerCase()));
buildGrid.addEventListener('click', e => { const button = e.target.closest('[data-build]'); if (button) setBuildMode(button.dataset.build); });
actionGrid.addEventListener('click', e => { const button = e.target.closest('[data-produce]'); if (button && selectedBuilding) enqueue(selectedBuilding, button.dataset.produce); });
mini.addEventListener('click', e => {
const rect = mini.getBoundingClientRect();
const target = { x: (e.clientX - rect.left) / rect.width * WORLD_W, y: (e.clientY - rect.top) / rect.height * WORLD_H };
if (selected.length) commandMove(target); else { camera.x = target.x - viewport.w / 2; camera.y = target.y - viewport.h / 2; constrainCamera(); }
});
window.addEventListener('resize', resize);
initialState(); resize(); updateResources(); updatePanels(); updateVision();
status('Expand the frontier and map the sector.');
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment