Skip to content

Instantly share code, notes, and snippets.

@senko
Created March 5, 2026 20:39
Show Gist options
  • Select an option

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

Select an option

Save senko/596a657b4c0bfd5c8d08f44e4e5347b8 to your computer and use it in GitHub Desktop.
RTS game by GPT-5.4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frontier Command RTS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700&family=Rajdhani:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--bg0: #08131a;
--bg1: #13262f;
--panel: rgba(10, 18, 24, 0.92);
--panel-border: rgba(130, 196, 218, 0.3);
--accent: #7ce0c4;
--accent-2: #ffcf68;
--danger: #ff7a6b;
--text: #edf7f5;
--muted: #a8c1bc;
--shadow: rgba(0, 0, 0, 0.4);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: 'Rajdhani', sans-serif;
color: var(--text);
background:
radial-gradient(circle at top, rgba(90, 165, 156, 0.15), transparent 35%),
linear-gradient(160deg, #071016 0%, #0d1d25 45%, #142632 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
}
.shell {
width: 100%;
height: calc(100vh - 16px);
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 16px;
align-items: stretch;
}
.game-panel,
.side-panel {
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: 0 20px 60px var(--shadow);
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
}
.game-panel::before,
.side-panel::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(124, 224, 196, 0.08), transparent 120px);
pointer-events: none;
}
.topbar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: linear-gradient(180deg, rgba(7, 14, 19, 0.92), rgba(7, 14, 19, 0.35));
border-bottom: 1px solid rgba(124, 224, 196, 0.16);
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: 'Orbitron', sans-serif;
font-size: 12px;
}
.resource-strip,
.objective-strip {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.chip {
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 5px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 999px;
white-space: nowrap;
}
.chip strong { color: var(--accent-2); }
canvas#game {
display: block;
width: 100%;
height: 100%;
background: #10222b;
cursor: crosshair;
}
.side-panel {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: auto;
}
.panel-title {
margin: 0;
font-family: 'Orbitron', sans-serif;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.subtle {
color: var(--muted);
margin: 0;
line-height: 1.3;
font-size: 15px;
}
.card {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
padding: 12px;
border-radius: 10px;
overflow: hidden;
}
.card h2 {
margin: 0 0 8px;
font-family: 'Orbitron', sans-serif;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
}
.selection-name {
font-size: 24px;
font-weight: 700;
line-height: 1;
margin-bottom: 8px;
}
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
font-size: 15px;
}
.stat {
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.progress-list,
.queue-list,
.hint-list {
display: grid;
gap: 8px;
font-size: 15px;
}
.progress-row {
display: grid;
gap: 5px;
}
.bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
}
.bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
#actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.action-btn {
border: 1px solid rgba(124, 224, 196, 0.28);
background: linear-gradient(180deg, rgba(124, 224, 196, 0.18), rgba(124, 224, 196, 0.05));
color: var(--text);
padding: 10px 10px 8px;
border-radius: 10px;
text-align: left;
cursor: pointer;
min-height: 68px;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
font: inherit;
}
.action-btn:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(255, 207, 104, 0.42);
background: linear-gradient(180deg, rgba(255, 207, 104, 0.16), rgba(124, 224, 196, 0.08));
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-btn strong {
display: block;
font-family: 'Orbitron', sans-serif;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 4px;
}
.action-btn span {
font-size: 14px;
color: var(--muted);
}
.mini {
width: 100%;
aspect-ratio: 1;
display: block;
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.status-line {
font-size: 15px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.06);
min-height: 48px;
display: flex;
align-items: center;
}
.status-line.alert {
border-color: rgba(255, 122, 107, 0.32);
color: #ffd8cf;
}
.overlay-message {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(3, 8, 11, 0.76);
z-index: 3;
padding: 24px;
text-align: center;
}
.overlay-message.visible { display: flex; }
.overlay-message .inner {
max-width: 520px;
border: 1px solid rgba(124, 224, 196, 0.25);
background: rgba(8, 17, 21, 0.92);
padding: 28px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.overlay-message h2 {
margin: 0 0 10px;
font-family: 'Orbitron', sans-serif;
font-size: 28px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.overlay-message p {
margin: 0;
color: var(--muted);
font-size: 17px;
line-height: 1.4;
}
@media (max-width: 1120px) {
body {
padding: 0;
}
.shell {
grid-template-columns: 1fr;
height: auto;
gap: 12px;
}
.side-panel {
min-height: 0;
}
canvas#game {
height: auto;
aspect-ratio: 16 / 10;
}
}
</style>
</head>
<body>
<div class="shell">
<div class="game-panel">
<div class="topbar">
<div class="resource-strip" id="resourceStrip"></div>
<div class="objective-strip" id="objectiveStrip"></div>
</div>
<canvas id="game" width="1040" height="650"></canvas>
<div class="overlay-message" id="victoryOverlay">
<div class="inner">
<h2>Map Secured</h2>
<p>You explored the entire frontier, established production, and locked down every sector. Press reload if you want a fresh map.</p>
</div>
</div>
</div>
<aside class="side-panel">
<div>
<h1 class="panel-title">Frontier Command</h1>
<p class="subtle">Single-player RTS skirmish. Build a base, train units, mine ore, cut lumber, and reveal the entire battlefield.</p>
</div>
<section class="card">
<h2>Selection</h2>
<div id="selectionInfo"></div>
</section>
<section class="card">
<h2>Actions</h2>
<div id="actions"></div>
</section>
<section class="card">
<h2>Production</h2>
<div class="queue-list" id="queueInfo"></div>
</section>
<section class="card">
<h2>Minimap</h2>
<canvas id="minimap" class="mini" width="260" height="260"></canvas>
</section>
<section class="card">
<h2>Orders</h2>
<div class="status-line" id="statusLine">Select a worker to build, or drag to box-select your units.</div>
</section>
<section class="card">
<h2>Controls</h2>
<div class="hint-list">
<div>Left click selects. Drag to box-select units.</div>
<div>Right click to move units, harvest a resource, or set a worker to build.</div>
<div>Use `WASD` or arrow keys to scroll the camera.</div>
<div>Ore funds industry, lumber unlocks faster expansion and advanced troops.</div>
</div>
</section>
</aside>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const minimap = document.getElementById('minimap');
const miniCtx = minimap.getContext('2d');
const resourceStrip = document.getElementById('resourceStrip');
const objectiveStrip = document.getElementById('objectiveStrip');
const selectionInfo = document.getElementById('selectionInfo');
const actionsEl = document.getElementById('actions');
const queueInfo = document.getElementById('queueInfo');
const statusLine = document.getElementById('statusLine');
const victoryOverlay = document.getElementById('victoryOverlay');
const VIEW_W = canvas.width;
const VIEW_H = canvas.height;
const MAP_W = 3200;
const MAP_H = 2200;
const GRID = 40;
const FOG_W = Math.ceil(MAP_W / GRID);
const FOG_H = Math.ceil(MAP_H / GRID);
const TAU = Math.PI * 2;
let nextId = 1;
let lastTime = 0;
const game = {
camera: { x: 0, y: 0, speed: 520 },
keys: new Set(),
mouse: { x: 0, y: 0, worldX: 0, worldY: 0 },
selection: [],
units: [],
buildings: [],
resources: [],
resourcesStock: { ore: 420, lumber: 240 },
supply: { used: 0, cap: 0 },
buildMode: null,
buildPreview: null,
drag: null,
explored: new Array(FOG_W * FOG_H).fill(false),
visible: new Array(FOG_W * FOG_H).fill(false),
message: 'Select a worker to build, or drag to box-select your units.',
alert: false,
victory: false,
terrainSeeds: [],
uiTimer: 0,
};
const UnitDefs = {
worker: {
name: 'Worker',
speed: 92,
radius: 11,
color: '#ffc56a',
sight: 175,
hp: 70,
cost: { ore: 50, lumber: 0, supply: 1 },
gatherRate: 12,
carryMax: 30,
buildOptions: ['barracks', 'depot'],
},
rifle: {
name: 'Rifleman',
speed: 84,
radius: 10,
color: '#7cccf6',
sight: 190,
hp: 90,
cost: { ore: 80, lumber: 20, supply: 1 },
},
scout: {
name: 'Scout',
speed: 124,
radius: 9,
color: '#7ce0c4',
sight: 245,
hp: 65,
cost: { ore: 60, lumber: 45, supply: 1 },
},
};
const BuildingDefs = {
hq: {
name: 'Command Core',
width: 110,
height: 110,
hp: 900,
sight: 210,
buildTime: 0,
color: '#c0d4d8',
production: ['worker'],
dropOff: true,
supply: 8,
},
barracks: {
name: 'Barracks',
width: 94,
height: 84,
hp: 560,
sight: 180,
buildTime: 14,
color: '#ec8d74',
production: ['rifle', 'scout'],
cost: { ore: 150, lumber: 90 },
},
depot: {
name: 'Field Depot',
width: 84,
height: 74,
hp: 380,
sight: 150,
buildTime: 11,
color: '#77ddcf',
dropOff: true,
supply: 6,
cost: { ore: 90, lumber: 60 },
},
};
const ProductionDefs = {
worker: { time: 7 },
rifle: { time: 8 },
scout: { time: 9 },
};
const uiActions = {
train_worker: {
label: 'Train Worker',
description: '50 ore',
run() {
const hq = getSingleSelectedBuilding('hq');
if (hq) queueProduction(hq, 'worker');
},
},
train_rifle: {
label: 'Train Rifleman',
description: '80 ore, 20 lumber',
run() {
const barracks = getSingleSelectedBuilding('barracks');
if (barracks) queueProduction(barracks, 'rifle');
},
},
train_scout: {
label: 'Train Scout',
description: '60 ore, 45 lumber',
run() {
const barracks = getSingleSelectedBuilding('barracks');
if (barracks) queueProduction(barracks, 'scout');
},
},
build_barracks: {
label: 'Place Barracks',
description: '150 ore, 90 lumber',
run() {
beginBuildMode('barracks');
},
},
build_depot: {
label: 'Place Depot',
description: '90 ore, 60 lumber',
run() {
beginBuildMode('depot');
},
},
cancel_mode: {
label: 'Cancel Order',
description: 'Exit placement mode',
run() {
game.buildMode = null;
game.buildPreview = null;
setMessage('Placement mode cancelled.');
},
},
};
function seededNoise(x, y) {
const v = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
return v - Math.floor(v);
}
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function dist(ax, ay, bx, by) { return Math.hypot(ax - bx, ay - by); }
function lerp(a, b, t) { return a + (b - a) * t; }
function worldToScreen(x, y) { return { x: x - game.camera.x, y: y - game.camera.y }; }
function screenToWorld(x, y) { return { x: x + game.camera.x, y: y + game.camera.y }; }
function fmtPercent(v) { return `${Math.round(v * 100)}%`; }
function hasSelectedWorker() { return game.selection.some(e => e.kind === 'unit' && e.type === 'worker'); }
function setMessage(text, alert = false) {
game.message = text;
game.alert = alert;
statusLine.textContent = text;
statusLine.className = `status-line${alert ? ' alert' : ''}`;
}
function createUnit(type, x, y) {
const def = UnitDefs[type];
const unit = {
id: nextId++,
kind: 'unit',
type,
x,
y,
radius: def.radius,
hp: def.hp,
maxHp: def.hp,
selected: false,
target: null,
facing: Math.random() * TAU,
state: 'idle',
harvestTargetId: null,
carry: { ore: 0, lumber: 0 },
gatherTimer: 0,
assignedSiteId: null,
};
game.units.push(unit);
game.supply.used += def.cost.supply;
return unit;
}
function createBuilding(type, x, y, opts = {}) {
const def = BuildingDefs[type];
const building = {
id: nextId++,
kind: 'building',
type,
x,
y,
width: def.width,
height: def.height,
hp: opts.underConstruction ? Math.max(40, def.hp * 0.15) : def.hp,
maxHp: def.hp,
sight: def.sight,
selected: false,
underConstruction: !!opts.underConstruction,
progress: opts.underConstruction ? 0.02 : 1,
queue: [],
assignedBuilderId: opts.assignedBuilderId || null,
};
game.buildings.push(building);
if (!opts.underConstruction && def.supply) {
game.supply.cap += def.supply;
}
return building;
}
function createResource(type, x, y, amount) {
const radius = type === 'ore' ? 28 : 34;
const resource = { id: nextId++, kind: 'resource', type, x, y, amount, radius };
game.resources.push(resource);
return resource;
}
function initWorld() {
const hq = createBuilding('hq', 440, 410);
createUnit('worker', 570, 470);
createUnit('worker', 610, 430);
createUnit('worker', 540, 370);
createUnit('scout', 650, 470);
const orePatches = [
[900, 540], [1180, 370], [1640, 780], [2050, 580], [2400, 900], [2860, 1600],
];
const lumberPatches = [
[780, 900], [1400, 520], [1840, 1280], [2250, 720], [2600, 1320], [2920, 500], [1100, 1650],
];
orePatches.forEach(([x, y], i) => {
for (let j = 0; j < 4; j++) {
createResource('ore', x + Math.cos(j * 1.7 + i) * 52, y + Math.sin(j * 1.7 + i) * 44, 400);
}
});
lumberPatches.forEach(([x, y], i) => {
for (let j = 0; j < 5; j++) {
createResource('lumber', x + Math.cos(j * 1.23 + i) * 48, y + Math.sin(j * 1.23 + i) * 52, 320);
}
});
game.camera.x = clamp(hq.x - VIEW_W * 0.4, 0, MAP_W - VIEW_W);
game.camera.y = clamp(hq.y - VIEW_H * 0.45, 0, MAP_H - VIEW_H);
setMessage('Base established. Mine ore, harvest lumber, and explore the frontier.');
}
function canAfford(cost) {
return game.resourcesStock.ore >= (cost.ore || 0) && game.resourcesStock.lumber >= (cost.lumber || 0);
}
function queuedSupply() {
let reserved = 0;
for (const building of game.buildings) {
for (const item of building.queue) {
reserved += UnitDefs[item.type].cost.supply || 0;
}
}
return reserved;
}
function spend(cost) {
game.resourcesStock.ore -= cost.ore || 0;
game.resourcesStock.lumber -= cost.lumber || 0;
}
function refund(cost) {
game.resourcesStock.ore += cost.ore || 0;
game.resourcesStock.lumber += cost.lumber || 0;
}
function getSingleSelectedBuilding(type = null) {
if (game.selection.length !== 1 || game.selection[0].kind !== 'building') return null;
const b = game.selection[0];
if (type && b.type !== type) return null;
return b;
}
function queueProduction(building, unitType) {
const unitDef = UnitDefs[unitType];
if (building.underConstruction) {
setMessage('Construction must finish before production begins.', true);
return;
}
if (!canAfford(unitDef.cost)) {
setMessage('Insufficient resources for that unit.', true);
return;
}
if (game.supply.used + queuedSupply() + unitDef.cost.supply > game.supply.cap) {
setMessage('Supply capped. Build a Field Depot first.', true);
return;
}
spend(unitDef.cost);
building.queue.push({ type: unitType, progress: 0, time: ProductionDefs[unitType].time });
setMessage(`${UnitDefs[unitType].name} added to ${BuildingDefs[building.type].name} queue.`);
updateUI();
}
function beginBuildMode(type) {
const def = BuildingDefs[type];
if (!hasSelectedWorker()) {
setMessage('Select at least one worker to place buildings.', true);
return;
}
if (!canAfford(def.cost)) {
setMessage(`Need ${def.cost.ore} ore and ${def.cost.lumber} lumber.`, true);
return;
}
game.buildMode = type;
setMessage(`Placement mode: click the battlefield to place a ${def.name}.`);
}
function issueMoveOrder(units, x, y) {
if (!units.length) return;
const cols = Math.ceil(Math.sqrt(units.length));
const spacing = 26;
const startX = x - ((cols - 1) * spacing) / 2;
const rows = Math.ceil(units.length / cols);
const startY = y - ((rows - 1) * spacing) / 2;
units.forEach((unit, i) => {
const row = Math.floor(i / cols);
const col = i % cols;
unit.target = {
type: 'move',
x: clamp(startX + col * spacing, unit.radius, MAP_W - unit.radius),
y: clamp(startY + row * spacing, unit.radius, MAP_H - unit.radius),
};
unit.state = 'moving';
unit.harvestTargetId = null;
unit.assignedSiteId = null;
});
setMessage(`Move order issued to ${units.length} unit${units.length > 1 ? 's' : ''}.`);
}
function issueHarvestOrder(workers, resource) {
workers.forEach(worker => {
worker.harvestTargetId = resource.id;
worker.target = { type: 'harvest', x: resource.x, y: resource.y };
worker.state = 'movingToHarvest';
worker.assignedSiteId = null;
});
setMessage(`Workers assigned to ${resource.type === 'ore' ? 'ore' : 'lumber'} harvesting.`);
}
function issueBuildOrder(buildingType, x, y) {
const workers = game.selection.filter(e => e.kind === 'unit' && e.type === 'worker');
if (!workers.length) {
setMessage('You need a selected worker to build.', true);
return;
}
const def = BuildingDefs[buildingType];
if (!canAfford(def.cost)) {
setMessage('Insufficient resources for that building.', true);
return;
}
const placement = { x, y, width: def.width, height: def.height };
if (!isBuildable(placement)) {
setMessage('Invalid construction site. Keep clear of resources and buildings.', true);
return;
}
spend(def.cost);
const builder = workers[0];
const site = createBuilding(buildingType, x, y, { underConstruction: true, assignedBuilderId: builder.id });
const approach = getApproachPoint(site, builder);
builder.assignedSiteId = site.id;
builder.harvestTargetId = null;
builder.target = { type: 'build', x: approach.x, y: approach.y };
builder.state = 'movingToBuild';
game.buildMode = null;
game.buildPreview = null;
setMessage(`${def.name} placement confirmed. Worker moving to construction site.`);
updateUI();
}
function isBuildable(rect) {
const left = rect.x - rect.width / 2;
const top = rect.y - rect.height / 2;
const right = rect.x + rect.width / 2;
const bottom = rect.y + rect.height / 2;
if (left < 40 || top < 40 || right > MAP_W - 40 || bottom > MAP_H - 40) return false;
for (const building of game.buildings) {
const pad = building.underConstruction ? 22 : 28;
if (Math.abs(building.x - rect.x) < (building.width + rect.width) / 2 + pad &&
Math.abs(building.y - rect.y) < (building.height + rect.height) / 2 + pad) {
return false;
}
}
for (const resource of game.resources) {
if (resource.amount <= 0) continue;
const cx = clamp(resource.x, left, right);
const cy = clamp(resource.y, top, bottom);
if (dist(resource.x, resource.y, cx, cy) < resource.radius + 20) return false;
}
return true;
}
function clearSelection() {
game.selection.forEach(entity => entity.selected = false);
game.selection = [];
}
function setSelection(entities) {
clearSelection();
entities.forEach(e => e.selected = true);
game.selection = entities;
updateUI();
}
function pickEntity(worldX, worldY) {
for (let i = game.units.length - 1; i >= 0; i--) {
const unit = game.units[i];
if (dist(worldX, worldY, unit.x, unit.y) <= unit.radius + 4) return unit;
}
for (let i = game.buildings.length - 1; i >= 0; i--) {
const building = game.buildings[i];
if (Math.abs(worldX - building.x) <= building.width / 2 && Math.abs(worldY - building.y) <= building.height / 2) return building;
}
return null;
}
function pickResource(worldX, worldY) {
for (let i = game.resources.length - 1; i >= 0; i--) {
const resource = game.resources[i];
if (resource.amount > 0 && dist(worldX, worldY, resource.x, resource.y) <= resource.radius + 5) return resource;
}
return null;
}
function selectedUnits() {
return game.selection.filter(e => e.kind === 'unit');
}
function nearestDropOff(x, y) {
let best = null;
let bestDist = Infinity;
for (const building of game.buildings) {
const def = BuildingDefs[building.type];
if (building.underConstruction || !def.dropOff) continue;
const d = dist(x, y, building.x, building.y);
if (d < bestDist) {
best = building;
bestDist = d;
}
}
return best;
}
function getApproachPoint(building, unit, padding = 18) {
const sidePad = padding + unit.radius;
const dx = unit.x - building.x;
const dy = unit.y - building.y;
if (Math.abs(dx) >= Math.abs(dy)) {
const side = dx >= 0 ? 1 : -1;
return {
x: clamp(building.x + side * (building.width / 2 + sidePad), unit.radius, MAP_W - unit.radius),
y: clamp(unit.y, unit.radius, MAP_H - unit.radius),
};
}
const side = dy >= 0 ? 1 : -1;
return {
x: clamp(unit.x, unit.radius, MAP_W - unit.radius),
y: clamp(building.y + side * (building.height / 2 + sidePad), unit.radius, MAP_H - unit.radius),
};
}
function spawnUnitNear(building, unitType) {
const def = UnitDefs[unitType];
const attempts = [
[building.width * 0.65, 0], [0, building.height * 0.75], [0, -building.height * 0.75],
[-building.width * 0.7, 0], [building.width * 0.7, building.height * 0.5], [building.width * 0.7, -building.height * 0.5],
];
let pos = { x: building.x + building.width * 0.65, y: building.y };
for (const [dx, dy] of attempts) {
const test = { x: clamp(building.x + dx, def.radius, MAP_W - def.radius), y: clamp(building.y + dy, def.radius, MAP_H - def.radius) };
if (!isPointBlocked(test.x, test.y, def.radius + 2)) {
pos = test;
break;
}
}
return createUnit(unitType, pos.x, pos.y);
}
function isPointBlocked(x, y, radius = 8) {
for (const building of game.buildings) {
if (Math.abs(x - building.x) < building.width / 2 + radius && Math.abs(y - building.y) < building.height / 2 + radius) {
return true;
}
}
return false;
}
function updateCamera(dt) {
let dx = 0;
let dy = 0;
if (game.keys.has('ArrowLeft') || game.keys.has('a')) dx -= 1;
if (game.keys.has('ArrowRight') || game.keys.has('d')) dx += 1;
if (game.keys.has('ArrowUp') || game.keys.has('w')) dy -= 1;
if (game.keys.has('ArrowDown') || game.keys.has('s')) dy += 1;
const edge = 18;
if (game.mouse.x < edge) dx -= 1;
if (game.mouse.x > VIEW_W - edge) dx += 1;
if (game.mouse.y < edge) dy -= 1;
if (game.mouse.y > VIEW_H - edge) dy += 1;
if (dx || dy) {
const len = Math.hypot(dx, dy) || 1;
game.camera.x = clamp(game.camera.x + (dx / len) * game.camera.speed * dt, 0, MAP_W - VIEW_W);
game.camera.y = clamp(game.camera.y + (dy / len) * game.camera.speed * dt, 0, MAP_H - VIEW_H);
}
}
function updateUnits(dt) {
for (const unit of game.units) {
const def = UnitDefs[unit.type];
if (unit.target) {
const dx = unit.target.x - unit.x;
const dy = unit.target.y - unit.y;
const distance = Math.hypot(dx, dy);
if (distance > 3) {
const step = Math.min(distance, def.speed * dt);
const nx = unit.x + dx / distance * step;
const ny = unit.y + dy / distance * step;
if (!isPointBlocked(nx, ny, unit.radius + 1)) {
unit.x = nx;
unit.y = ny;
}
unit.facing = Math.atan2(dy, dx);
} else {
resolveArrival(unit);
}
}
if (unit.type === 'worker') {
updateWorker(unit, dt);
}
}
}
function resolveArrival(unit) {
if (!unit.target) return;
if (unit.target.type === 'move') {
unit.state = 'idle';
unit.target = null;
} else if (unit.target.type === 'harvest') {
unit.target = null;
unit.state = 'harvesting';
} else if (unit.target.type === 'deliver') {
unit.target = null;
unit.state = 'delivering';
} else if (unit.target.type === 'build') {
unit.target = null;
unit.state = 'constructing';
}
}
function updateWorker(worker, dt) {
if (worker.state === 'harvesting') {
const node = game.resources.find(r => r.id === worker.harvestTargetId && r.amount > 0);
if (!node) {
worker.state = 'idle';
worker.harvestTargetId = null;
return;
}
if (dist(worker.x, worker.y, node.x, node.y) > node.radius + worker.radius + 8) {
worker.target = { type: 'harvest', x: node.x, y: node.y };
worker.state = 'movingToHarvest';
return;
}
worker.gatherTimer += dt;
if (worker.gatherTimer >= 0.75) {
worker.gatherTimer = 0;
const resourceType = node.type;
const remainingCap = UnitDefs.worker.carryMax - (worker.carry.ore + worker.carry.lumber);
const amount = Math.min(UnitDefs.worker.gatherRate, remainingCap, node.amount);
node.amount -= amount;
worker.carry[resourceType] += amount;
if (node.amount <= 0) {
worker.harvestTargetId = null;
}
if (worker.carry.ore + worker.carry.lumber >= UnitDefs.worker.carryMax || node.amount <= 0) {
const depot = nearestDropOff(worker.x, worker.y);
if (depot) {
const approach = getApproachPoint(depot, worker);
worker.target = { type: 'deliver', x: approach.x, y: approach.y, buildingId: depot.id };
worker.state = 'movingToDeliver';
}
}
}
} else if (worker.state === 'delivering') {
if (worker.carry.ore || worker.carry.lumber) {
game.resourcesStock.ore += worker.carry.ore;
game.resourcesStock.lumber += worker.carry.lumber;
worker.carry.ore = 0;
worker.carry.lumber = 0;
}
const node = game.resources.find(r => r.id === worker.harvestTargetId && r.amount > 0);
if (node) {
worker.target = { type: 'harvest', x: node.x, y: node.y };
worker.state = 'movingToHarvest';
} else {
worker.state = 'idle';
worker.target = null;
}
} else if (worker.state === 'constructing') {
const site = game.buildings.find(b => b.id === worker.assignedSiteId);
if (!site || !site.underConstruction) {
worker.assignedSiteId = null;
worker.state = 'idle';
return;
}
const approach = getApproachPoint(site, worker);
if (dist(worker.x, worker.y, approach.x, approach.y) > 10) {
worker.target = { type: 'build', x: approach.x, y: approach.y };
worker.state = 'movingToBuild';
return;
}
site.progress = Math.min(1, site.progress + dt / BuildingDefs[site.type].buildTime);
site.hp = lerp(site.maxHp * 0.2, site.maxHp, site.progress);
if (site.progress >= 1) {
site.underConstruction = false;
site.assignedBuilderId = null;
worker.assignedSiteId = null;
worker.state = 'idle';
if (BuildingDefs[site.type].supply) game.supply.cap += BuildingDefs[site.type].supply;
setMessage(`${BuildingDefs[site.type].name} construction complete.`);
}
}
}
function updateBuildings(dt) {
for (const building of game.buildings) {
if (building.underConstruction || !building.queue.length) continue;
const current = building.queue[0];
current.progress += dt / current.time;
if (current.progress >= 1) {
const unit = spawnUnitNear(building, current.type);
building.queue.shift();
setMessage(`${UnitDefs[unit.type].name} ready.`);
}
}
}
function updateFog() {
game.visible.fill(false);
const revealers = [...game.units, ...game.buildings.filter(b => !b.underConstruction)];
for (const entity of revealers) {
const sight = entity.kind === 'unit' ? UnitDefs[entity.type].sight : entity.sight;
const minX = clamp(Math.floor((entity.x - sight) / GRID), 0, FOG_W - 1);
const maxX = clamp(Math.ceil((entity.x + sight) / GRID), 0, FOG_W - 1);
const minY = clamp(Math.floor((entity.y - sight) / GRID), 0, FOG_H - 1);
const maxY = clamp(Math.ceil((entity.y + sight) / GRID), 0, FOG_H - 1);
const r2 = sight * sight;
for (let gy = minY; gy <= maxY; gy++) {
for (let gx = minX; gx <= maxX; gx++) {
const cx = gx * GRID + GRID / 2;
const cy = gy * GRID + GRID / 2;
if ((cx - entity.x) ** 2 + (cy - entity.y) ** 2 <= r2) {
const idx = gy * FOG_W + gx;
game.visible[idx] = true;
game.explored[idx] = true;
}
}
}
}
const exploredCount = game.explored.reduce((sum, v) => sum + (v ? 1 : 0), 0);
const pct = exploredCount / game.explored.length;
if (pct >= 0.999 && !game.victory) {
game.victory = true;
victoryOverlay.classList.add('visible');
setMessage('Entire map revealed. Mission complete.');
}
}
function updateBuildPreview() {
if (!game.buildMode) {
game.buildPreview = null;
return;
}
const def = BuildingDefs[game.buildMode];
const { worldX, worldY } = game.mouse;
const snapX = Math.round(worldX / 10) * 10;
const snapY = Math.round(worldY / 10) * 10;
game.buildPreview = {
type: game.buildMode,
x: snapX,
y: snapY,
width: def.width,
height: def.height,
};
}
function renderTerrain() {
const cell = 80;
const startX = Math.floor(game.camera.x / cell) * cell;
const startY = Math.floor(game.camera.y / cell) * cell;
for (let y = startY; y < game.camera.y + VIEW_H + cell; y += cell) {
for (let x = startX; x < game.camera.x + VIEW_W + cell; x += cell) {
const nx = Math.floor(x / cell);
const ny = Math.floor(y / cell);
const a = seededNoise(nx * 0.45, ny * 0.45);
const b = seededNoise(nx * 0.81 + 8, ny * 0.81 + 3);
const mix = a * 0.65 + b * 0.35;
const sx = x - game.camera.x;
const sy = y - game.camera.y;
ctx.fillStyle = mix > 0.56 ? '#284936' : mix > 0.43 ? '#314f3d' : '#3f5746';
ctx.fillRect(sx, sy, cell, cell);
ctx.fillStyle = `rgba(255,255,255,${0.015 + seededNoise(nx * 2.1, ny * 1.7) * 0.02})`;
ctx.fillRect(sx, sy, cell, cell);
}
}
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
ctx.lineWidth = 1;
for (let x = -((game.camera.x % 40)); x < VIEW_W; x += 40) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, VIEW_H); ctx.stroke();
}
for (let y = -((game.camera.y % 40)); y < VIEW_H; y += 40) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(VIEW_W, y); ctx.stroke();
}
const road = worldToScreen(500, 420);
const roadGrad = ctx.createLinearGradient(road.x - 130, road.y, road.x + 160, road.y + 80);
roadGrad.addColorStop(0, 'rgba(180, 165, 130, 0.26)');
roadGrad.addColorStop(1, 'rgba(120, 110, 90, 0.08)');
ctx.fillStyle = roadGrad;
ctx.beginPath();
ctx.ellipse(road.x, road.y + 30, 220, 120, 0.24, 0, TAU);
ctx.fill();
}
function drawResource(resource) {
const { x, y } = worldToScreen(resource.x, resource.y);
if (x < -80 || y < -80 || x > VIEW_W + 80 || y > VIEW_H + 80 || resource.amount <= 0) return;
const alpha = 0.72 + 0.18 * Math.sin(performance.now() * 0.003 + resource.id);
ctx.save();
ctx.translate(x, y);
if (resource.type === 'ore') {
ctx.shadowBlur = 18;
ctx.shadowColor = 'rgba(90, 210, 255, 0.45)';
for (let i = 0; i < 3; i++) {
ctx.fillStyle = `rgba(110, 220, 255, ${alpha - i * 0.12})`;
ctx.beginPath();
ctx.moveTo(-10 + i * 9, 22);
ctx.lineTo(-22 + i * 10, -8);
ctx.lineTo(-8 + i * 10, -30);
ctx.lineTo(12 + i * 9, -4);
ctx.lineTo(4 + i * 8, 20);
ctx.closePath();
ctx.fill();
}
} else {
for (let i = 0; i < 3; i++) {
ctx.fillStyle = ['#143823', '#1a4e2c', '#26693d'][i];
ctx.beginPath();
ctx.arc(-12 + i * 13, -6 - i * 2, 18, 0, TAU);
ctx.fill();
}
ctx.fillStyle = '#5f3d23';
ctx.fillRect(-4, 10, 8, 18);
}
ctx.restore();
}
function drawBuilding(building) {
const { x, y } = worldToScreen(building.x, building.y);
const w = building.width;
const h = building.height;
if (x < -w || y < -h || x > VIEW_W + w || y > VIEW_H + h) return;
ctx.save();
ctx.translate(x, y);
ctx.shadowBlur = 26;
ctx.shadowColor = 'rgba(0,0,0,0.28)';
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.beginPath();
ctx.ellipse(0, h * 0.34, w * 0.46, h * 0.2, 0, 0, TAU);
ctx.fill();
ctx.shadowBlur = 0;
if (building.type === 'hq') {
const grad = ctx.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2);
grad.addColorStop(0, '#cdd7d8');
grad.addColorStop(1, '#596e77');
ctx.fillStyle = grad;
roundRect(ctx, -w / 2, -h / 2, w, h, 14); ctx.fill();
ctx.fillStyle = '#233743';
roundRect(ctx, -w * 0.34, -h * 0.36, w * 0.68, h * 0.42, 10); ctx.fill();
ctx.fillStyle = '#ffcf68';
ctx.fillRect(-w * 0.13, -h * 0.42, w * 0.26, h * 0.17);
ctx.fillStyle = '#85e0ff';
ctx.fillRect(-w * 0.22, -2, w * 0.44, h * 0.15);
} else if (building.type === 'barracks') {
const grad = ctx.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2);
grad.addColorStop(0, '#ef9f7f');
grad.addColorStop(1, '#6f4545');
ctx.fillStyle = grad;
roundRect(ctx, -w / 2, -h / 2, w, h, 12); ctx.fill();
ctx.fillStyle = '#3a2025';
roundRect(ctx, -w * 0.4, -h * 0.2, w * 0.8, h * 0.44, 8); ctx.fill();
ctx.fillStyle = '#ffd5a8';
ctx.fillRect(-w * 0.14, -h * 0.42, w * 0.28, h * 0.16);
} else {
const grad = ctx.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2);
grad.addColorStop(0, '#87efd8');
grad.addColorStop(1, '#2a5557');
ctx.fillStyle = grad;
roundRect(ctx, -w / 2, -h / 2, w, h, 18); ctx.fill();
ctx.fillStyle = 'rgba(10, 30, 36, 0.7)';
ctx.beginPath();
ctx.ellipse(0, -8, w * 0.28, h * 0.24, 0, 0, TAU);
ctx.fill();
ctx.fillStyle = '#dffef4';
ctx.fillRect(-w * 0.1, h * 0.15, w * 0.2, h * 0.11);
}
if (building.underConstruction) {
ctx.strokeStyle = 'rgba(255, 210, 120, 0.8)';
ctx.lineWidth = 2;
for (let i = -2; i <= 2; i++) {
ctx.beginPath();
ctx.moveTo(-w / 2 + i * 18, h / 2);
ctx.lineTo(-w / 2 + i * 18 + 18, -h / 2);
ctx.stroke();
}
}
if (building.selected) {
ctx.strokeStyle = '#ffcf68';
ctx.lineWidth = 2;
ctx.strokeRect(-w / 2 - 5, -h / 2 - 5, w + 10, h + 10);
}
drawHealthBar(x, y - h / 2 - 14, w * 0.86, building.hp / building.maxHp, building.underConstruction ? '#ffcf68' : '#7ce0c4');
if (building.queue.length) {
drawHealthBar(x, y + h / 2 + 10, w * 0.74, building.queue[0].progress, '#7cccf6');
}
ctx.restore();
}
function drawUnit(unit) {
const { x, y } = worldToScreen(unit.x, unit.y);
if (x < -40 || y < -40 || x > VIEW_W + 40 || y > VIEW_H + 40) return;
const time = performance.now() * 0.003;
ctx.save();
ctx.translate(x, y);
ctx.rotate(unit.facing);
ctx.shadowBlur = 16;
ctx.shadowColor = 'rgba(0,0,0,0.28)';
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath();
ctx.ellipse(0, 11, unit.radius * 1.1, unit.radius * 0.55, 0, 0, TAU);
ctx.fill();
ctx.shadowBlur = 0;
if (unit.type === 'worker') {
ctx.fillStyle = '#52341b';
ctx.fillRect(-10, -7, 18, 14);
ctx.fillStyle = '#ffc56a';
ctx.fillRect(-8, -9, 16, 10);
ctx.fillStyle = '#fff2ca';
ctx.fillRect(2, -4, 8, 4);
} else if (unit.type === 'rifle') {
ctx.fillStyle = '#26485d';
ctx.beginPath();
ctx.arc(-1, 0, 11, 0, TAU);
ctx.fill();
ctx.fillStyle = '#8bd3ff';
ctx.fillRect(-3, -14, 8, 22);
ctx.fillStyle = '#d6f5ff';
ctx.fillRect(3, -2, 10, 3);
} else {
ctx.fillStyle = '#1a4f4d';
ctx.beginPath();
ctx.moveTo(12, 0);
ctx.lineTo(-10, -8);
ctx.lineTo(-6, 0);
ctx.lineTo(-10, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#7ce0c4';
ctx.beginPath();
ctx.moveTo(14, 0);
ctx.lineTo(-7, -6);
ctx.lineTo(-3, 0);
ctx.lineTo(-7, 6);
ctx.closePath();
ctx.fill();
ctx.fillStyle = `rgba(124, 224, 196, ${0.35 + Math.sin(time * 2 + unit.id) * 0.12})`;
ctx.fillRect(-10, 10, 18, 3);
}
if (unit.selected) {
ctx.strokeStyle = '#ffcf68';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, unit.radius + 6, 0, TAU);
ctx.stroke();
}
ctx.restore();
drawHealthBar(x, y - unit.radius - 12, 28, unit.hp / unit.maxHp, unit.type === 'worker' ? '#ffcf68' : '#7ce0c4');
if ((unit.carry.ore || unit.carry.lumber) && unit.type === 'worker') {
ctx.fillStyle = unit.carry.ore ? '#7cccf6' : '#6fd18c';
ctx.beginPath();
ctx.arc(x + 12, y - 10, 4, 0, TAU);
ctx.fill();
}
}
function drawHealthBar(x, y, width, ratio, color) {
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(x - width / 2, y, width, 5);
ctx.fillStyle = color;
ctx.fillRect(x - width / 2 + 1, y + 1, (width - 2) * clamp(ratio, 0, 1), 3);
ctx.restore();
}
function drawBuildPreview() {
if (!game.buildPreview) return;
const p = game.buildPreview;
const { x, y } = worldToScreen(p.x, p.y);
const valid = isBuildable(p);
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = valid ? 'rgba(124, 224, 196, 0.22)' : 'rgba(255, 122, 107, 0.25)';
ctx.strokeStyle = valid ? 'rgba(124, 224, 196, 0.9)' : 'rgba(255, 122, 107, 0.9)';
ctx.lineWidth = 2;
ctx.fillRect(-p.width / 2, -p.height / 2, p.width, p.height);
ctx.strokeRect(-p.width / 2, -p.height / 2, p.width, p.height);
ctx.restore();
}
function drawDragBox() {
if (!game.drag || !game.drag.active) return;
const { startX, startY, currentX, currentY } = game.drag;
ctx.save();
ctx.strokeStyle = 'rgba(255, 207, 104, 0.95)';
ctx.fillStyle = 'rgba(255, 207, 104, 0.12)';
ctx.lineWidth = 1.5;
ctx.strokeRect(startX, startY, currentX - startX, currentY - startY);
ctx.fillRect(startX, startY, currentX - startX, currentY - startY);
ctx.restore();
}
function drawFog() {
const startX = Math.floor(game.camera.x / GRID);
const endX = Math.ceil((game.camera.x + VIEW_W) / GRID);
const startY = Math.floor(game.camera.y / GRID);
const endY = Math.ceil((game.camera.y + VIEW_H) / GRID);
for (let gy = startY; gy < endY; gy++) {
for (let gx = startX; gx < endX; gx++) {
const idx = gy * FOG_W + gx;
const screenX = gx * GRID - game.camera.x;
const screenY = gy * GRID - game.camera.y;
if (!game.explored[idx]) {
ctx.fillStyle = 'rgba(2, 6, 9, 0.94)';
ctx.fillRect(screenX, screenY, GRID, GRID);
} else if (!game.visible[idx]) {
ctx.fillStyle = 'rgba(4, 10, 13, 0.58)';
ctx.fillRect(screenX, screenY, GRID, GRID);
}
}
}
}
function renderScene() {
ctx.clearRect(0, 0, VIEW_W, VIEW_H);
renderTerrain();
for (const resource of game.resources) drawResource(resource);
for (const building of game.buildings) drawBuilding(building);
for (const unit of game.units) drawUnit(unit);
drawBuildPreview();
drawFog();
drawDragBox();
if (game.buildMode) {
ctx.save();
ctx.fillStyle = 'rgba(7, 14, 19, 0.82)';
ctx.fillRect(14, VIEW_H - 44, 318, 28);
ctx.fillStyle = '#edf7f5';
ctx.font = '15px Rajdhani';
ctx.fillText(`Placement mode: ${BuildingDefs[game.buildMode].name} | Left click place, right click cancel`, 24, VIEW_H - 25);
ctx.restore();
}
}
function renderMinimap() {
miniCtx.clearRect(0, 0, minimap.width, minimap.height);
const sx = minimap.width / MAP_W;
const sy = minimap.height / MAP_H;
miniCtx.fillStyle = '#1b3529';
miniCtx.fillRect(0, 0, minimap.width, minimap.height);
for (const resource of game.resources) {
if (resource.amount <= 0) continue;
miniCtx.fillStyle = resource.type === 'ore' ? '#72d9ff' : '#6fd18c';
miniCtx.fillRect(resource.x * sx - 2, resource.y * sy - 2, 4, 4);
}
for (const building of game.buildings) {
miniCtx.fillStyle = building.type === 'hq' ? '#ffcf68' : building.type === 'barracks' ? '#ec8d74' : '#7ce0c4';
miniCtx.fillRect((building.x - building.width / 2) * sx, (building.y - building.height / 2) * sy, building.width * sx, building.height * sy);
}
for (const unit of game.units) {
miniCtx.fillStyle = unit.type === 'worker' ? '#ffc56a' : unit.type === 'rifle' ? '#7cccf6' : '#7ce0c4';
miniCtx.fillRect(unit.x * sx - 1, unit.y * sy - 1, 3, 3);
}
for (let gy = 0; gy < FOG_H; gy++) {
for (let gx = 0; gx < FOG_W; gx++) {
const idx = gy * FOG_W + gx;
if (!game.explored[idx]) {
miniCtx.fillStyle = 'rgba(2, 6, 9, 0.95)';
miniCtx.fillRect(gx * GRID * sx, gy * GRID * sy, GRID * sx + 0.4, GRID * sy + 0.4);
} else if (!game.visible[idx]) {
miniCtx.fillStyle = 'rgba(2, 6, 9, 0.45)';
miniCtx.fillRect(gx * GRID * sx, gy * GRID * sy, GRID * sx + 0.4, GRID * sy + 0.4);
}
}
}
miniCtx.strokeStyle = 'rgba(255,255,255,0.8)';
miniCtx.lineWidth = 1;
miniCtx.strokeRect(game.camera.x * sx, game.camera.y * sy, VIEW_W * sx, VIEW_H * sy);
}
function updateTopBar() {
const explored = Math.round(game.explored.reduce((sum, v) => sum + (v ? 1 : 0), 0) / game.explored.length * 100);
resourceStrip.innerHTML = [
`<div class="chip">Ore <strong>${Math.floor(game.resourcesStock.ore)}</strong></div>`,
`<div class="chip">Lumber <strong>${Math.floor(game.resourcesStock.lumber)}</strong></div>`,
`<div class="chip">Supply <strong>${game.supply.used}/${game.supply.cap}</strong></div>`,
].join('');
objectiveStrip.innerHTML = [
`<div class="chip">Explored <strong>${explored}%</strong></div>`,
`<div class="chip">Objective <strong>Reveal 100%</strong></div>`,
].join('');
}
function updateUI() {
updateTopBar();
if (!game.selection.length) {
selectionInfo.innerHTML = '<div class="selection-name">Nothing Selected</div><p class="subtle">Drag across units or click a structure to inspect it.</p>';
} else if (game.selection.length === 1) {
const entity = game.selection[0];
if (entity.kind === 'unit') {
const def = UnitDefs[entity.type];
const carrying = entity.type === 'worker' ? `${entity.carry.ore ? entity.carry.ore + ' ore' : ''}${entity.carry.ore && entity.carry.lumber ? ', ' : ''}${entity.carry.lumber ? entity.carry.lumber + ' lumber' : ''}` || 'Empty' : 'Combat ready';
selectionInfo.innerHTML = `
<div class="selection-name">${def.name}</div>
<div class="stats">
<div class="stat">HP ${Math.round(entity.hp)}/${entity.maxHp}</div>
<div class="stat">Sight ${def.sight}</div>
<div class="stat">State ${entity.state}</div>
<div class="stat">Cargo ${carrying}</div>
</div>
`;
} else {
const def = BuildingDefs[entity.type];
selectionInfo.innerHTML = `
<div class="selection-name">${def.name}</div>
<div class="stats">
<div class="stat">HP ${Math.round(entity.hp)}/${entity.maxHp}</div>
<div class="stat">Sight ${def.sight}</div>
<div class="stat">Status ${entity.underConstruction ? 'Constructing' : 'Online'}</div>
<div class="stat">Queue ${entity.queue.length}</div>
</div>
`;
}
} else {
const unitCounts = game.selection.reduce((acc, entity) => {
acc[entity.type] = (acc[entity.type] || 0) + 1;
return acc;
}, {});
selectionInfo.innerHTML = `
<div class="selection-name">${game.selection.length} Units</div>
<p class="subtle">${Object.entries(unitCounts).map(([type, count]) => `${count} ${UnitDefs[type].name}${count > 1 ? 's' : ''}`).join(' • ')}</p>
`;
}
const actionIds = [];
if (game.buildMode) actionIds.push('cancel_mode');
if (game.selection.length === 1) {
const entity = game.selection[0];
if (entity.kind === 'building' && entity.type === 'hq') actionIds.push('train_worker');
if (entity.kind === 'building' && entity.type === 'barracks') actionIds.push('train_rifle', 'train_scout');
}
if (game.selection.some(e => e.kind === 'unit' && e.type === 'worker')) {
actionIds.push('build_barracks', 'build_depot');
}
actionsEl.innerHTML = '';
if (!actionIds.length) {
actionsEl.innerHTML = '<p class="subtle">No context actions available for the current selection.</p>';
} else {
const seen = new Set();
actionIds.forEach(id => {
if (seen.has(id)) return;
seen.add(id);
const action = uiActions[id];
const button = document.createElement('button');
button.className = 'action-btn';
button.innerHTML = `<strong>${action.label}</strong><span>${action.description}</span>`;
button.disabled = (id === 'build_barracks' && !canAfford(BuildingDefs.barracks.cost)) ||
(id === 'build_depot' && !canAfford(BuildingDefs.depot.cost));
button.addEventListener('click', action.run);
actionsEl.appendChild(button);
});
}
const activeQueues = game.buildings.filter(b => b.queue.length || b.underConstruction);
queueInfo.innerHTML = '';
if (!activeQueues.length) {
queueInfo.innerHTML = '<p class="subtle">No active construction or production queues.</p>';
} else {
activeQueues.forEach(building => {
const def = BuildingDefs[building.type];
const row = document.createElement('div');
row.className = 'progress-row';
if (building.underConstruction) {
row.innerHTML = `
<div>${def.name} construction</div>
<div class="bar"><span style="width:${fmtPercent(building.progress)}"></span></div>
`;
} else {
const current = building.queue[0];
row.innerHTML = `
<div>${def.name}: ${UnitDefs[current.type].name} (${building.queue.length} queued)</div>
<div class="bar"><span style="width:${fmtPercent(current.progress)}"></span></div>
`;
}
queueInfo.appendChild(row);
});
}
}
function handleLeftDown(event) {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
const world = screenToWorld(x, y);
game.mouse.x = x; game.mouse.y = y; game.mouse.worldX = world.x; game.mouse.worldY = world.y;
if (game.buildMode) {
issueBuildOrder(game.buildMode, Math.round(world.x / 10) * 10, Math.round(world.y / 10) * 10);
return;
}
const entity = pickEntity(world.x, world.y);
if (entity) {
setSelection([entity]);
} else {
clearSelection();
updateUI();
game.drag = { active: true, startX: x, startY: y, currentX: x, currentY: y };
}
}
function handleLeftUp(event) {
if (!game.drag || !game.drag.active) return;
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
const minX = Math.min(game.drag.startX, x);
const maxX = Math.max(game.drag.startX, x);
const minY = Math.min(game.drag.startY, y);
const maxY = Math.max(game.drag.startY, y);
const selected = game.units.filter(unit => {
const p = worldToScreen(unit.x, unit.y);
return p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY;
});
setSelection(selected);
game.drag = null;
}
function handleRightClick(event) {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
const world = screenToWorld(x, y);
if (game.buildMode) {
game.buildMode = null;
game.buildPreview = null;
setMessage('Placement mode cancelled.');
updateUI();
return;
}
const units = selectedUnits();
if (!units.length) return;
const resource = pickResource(world.x, world.y);
const workers = units.filter(unit => unit.type === 'worker');
if (resource && workers.length) {
issueHarvestOrder(workers, resource);
return;
}
issueMoveOrder(units, world.x, world.y);
}
canvas.addEventListener('mousedown', event => {
if (event.button === 0) handleLeftDown(event);
});
window.addEventListener('mouseup', event => {
if (event.button === 0) handleLeftUp(event);
});
canvas.addEventListener('mousemove', event => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
const world = screenToWorld(x, y);
game.mouse.x = x;
game.mouse.y = y;
game.mouse.worldX = world.x;
game.mouse.worldY = world.y;
if (game.drag && game.drag.active) {
game.drag.currentX = x;
game.drag.currentY = y;
}
});
canvas.addEventListener('contextmenu', handleRightClick);
window.addEventListener('keydown', event => {
game.keys.add(event.key.toLowerCase());
if (event.key === 'Escape' && game.buildMode) {
game.buildMode = null;
game.buildPreview = null;
updateUI();
setMessage('Placement mode cancelled.');
}
});
window.addEventListener('keyup', event => game.keys.delete(event.key.toLowerCase()));
minimap.addEventListener('click', event => {
const rect = minimap.getBoundingClientRect();
const x = (event.clientX - rect.left) * (minimap.width / rect.width);
const y = (event.clientY - rect.top) * (minimap.height / rect.height);
game.camera.x = clamp(x / minimap.width * MAP_W - VIEW_W / 2, 0, MAP_W - VIEW_W);
game.camera.y = clamp(y / minimap.height * MAP_H - VIEW_H / 2, 0, MAP_H - VIEW_H);
});
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function loop(timestamp) {
const dt = Math.min(0.033, (timestamp - lastTime) / 1000 || 0.016);
lastTime = timestamp;
updateCamera(dt);
updateBuildPreview();
updateUnits(dt);
updateBuildings(dt);
updateFog();
renderScene();
renderMinimap();
updateTopBar();
game.uiTimer += dt;
if (game.uiTimer >= 0.15) {
game.uiTimer = 0;
updateUI();
}
requestAnimationFrame(loop);
}
initWorld();
updateFog();
updateUI();
requestAnimationFrame(loop);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment