Skip to content

Instantly share code, notes, and snippets.

@senko
Created February 5, 2026 19:16
Show Gist options
  • Select an option

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

Select an option

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