Skip to content

Instantly share code, notes, and snippets.

@senko
Last active May 16, 2026 21:00
Show Gist options
  • Select an option

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

Select an option

Save senko/e52f639187070168a5006e3a0a675752 to your computer and use it in GitHub Desktop.
RTS game by DeepSeek V4-Flash quantized to 2-bits run via DwarfStar4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>⚔️ Mini RTS - Classic Strategy</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, sans-serif;
}
.game-container {
background: #16213e;
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.8);
border: 2px solid #2a3a5e;
display: flex;
flex-direction: column;
align-items: center;
}
/* Top bar */
.top-bar {
width: 100%;
background: #0f1a30;
border-radius: 10px 10px 0 0;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid #2a3a5e;
font-weight: bold;
color: #dfe6ed;
flex-wrap: wrap;
gap: 8px;
}
.resource {
display: flex;
align-items: center;
gap: 6px;
background: #1e2d45;
padding: 4px 14px;
border-radius: 20px;
font-size: 15px;
}
.resource-icon {
font-size: 18px;
}
.resource-value {
min-width: 40px;
text-align: center;
}
.resource-max {
color: #7a8baa;
}
/* Canvas area */
.canvas-wrapper {
background: #0a0f1f;
padding: 8px;
border-radius: 6px;
}
canvas {
display: block;
background: #1c2a3f;
border-radius: 4px;
cursor: default;
width: 640px;
height: 640px;
image-rendering: pixelated;
}
/* Bottom bar */
.bottom-bar {
width: 100%;
background: #0f1a30;
border-radius: 0 0 10px 10px;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 2px solid #2a3a5e;
flex-wrap: wrap;
gap: 10px;
min-height: 60px;
}
.action-group {
display: flex;
gap: 8px;
align-items: center;
}
.action-group-label {
color: #7a8baa;
font-size: 13px;
font-weight: 600;
margin-right: 4px;
}
.btn {
background: #2a3a5e;
border: 1px solid #3d5a80;
color: #dfe6ed;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: #3d5a80;
border-color: #5a7da0;
transform: scale(1.04);
}
.btn:active {
transform: scale(0.96);
}
.btn.primary {
background: #2d6b3e;
border-color: #4a8a5a;
}
.btn.primary:hover {
background: #3a7e4e;
}
.btn.build {
background: #6b4a2d;
border-color: #8a6a3e;
}
.btn.build:hover {
background: #7e5a3e;
}
.btn.train {
background: #4a2d6b;
border-color: #6a3e8a;
}
.btn.train:hover {
background: #5e3e7e;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.btn .hotkey {
color: #b0c4de;
font-size: 11px;
background: rgba(255, 255, 255, 0.1);
padding: 0 6px;
border-radius: 4px;
}
.selection-info {
color: #dfe6ed;
font-size: 14px;
padding: 4px 10px;
background: #1e2d45;
border-radius: 8px;
min-width: 120px;
min-height: 28px;
display: flex;
align-items: center;
gap: 6px;
}
/* Responsive tweaks */
@media (max-width: 720px) {
.game-container {
padding: 8px;
}
canvas {
width: 100%;
height: auto;
aspect-ratio: 1/1;
}
.top-bar,
.bottom-bar {
padding: 8px 12px;
font-size: 13px;
}
.btn {
padding: 4px 10px;
font-size: 12px;
}
.resource {
padding: 2px 10px;
font-size: 13px;
}
}
</style>
</head>
<body>
<div class="game-container">
<!-- Top Bar -->
<div class="top-bar">
<div style="display:flex; align-items:center; gap:12px;">
<span style="font-size:20px; font-weight:800; color:#d4b87a;">⚔️ RTS</span>
</div>
<div style="display:flex; gap:12px; flex-wrap:wrap;">
<div class="resource">
<span class="resource-icon">🪙</span>
<span class="resource-value" id="goldDisplay">500</span>
</div>
<div class="resource">
<span class="resource-icon">🍞</span>
<span class="resource-value" id="foodDisplay">10</span>
<span class="resource-max">/ <span id="foodCapDisplay">10</span></span>
</div>
<div class="resource">
<span class="resource-icon">👥</span>
<span class="resource-value" id="unitCountDisplay">0</span>
<span class="resource-max">/ <span id="unitCapDisplay">10</span></span>
</div>
</div>
</div>
<!-- Canvas -->
<div class="canvas-wrapper">
<canvas id="gameCanvas" width="640" height="640"></canvas>
</div>
<!-- Bottom Bar -->
<div class="bottom-bar">
<div class="selection-info" id="selectionInfo">
<span>🏛️</span>
<span>Click to select</span>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<div class="action-group">
<span class="action-group-label">🏗️</span>
<button class="btn build" id="btnTownHall">🏘️ TH</button>
<button class="btn build" id="btnBarracks">🏰 B</button>
<button class="btn build" id="btnFarm">🌾 F</button>
<button class="btn build" id="btnWall">🧱 W</button>
</div>
<div class="action-group">
<span class="action-group-label">🎓</span>
<button class="btn train" id="btnWorker">🔧 Worker</button>
<button class="btn train" id="btnSoldier">⚔️ Soldier</button>
</div>
</div>
</div>
</div>
<script>
// ─────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────
const TILE_SIZE = 20;
const MAP_SIZE = 32;
const CANVAS_SIZE = MAP_SIZE * TILE_SIZE; // 640
const TERRAIN = {
GRASS: 0,
TREES: 1,
MOUNTAIN: 2,
WATER: 3,
GOLD_MINE: 4
};
const TERRAIN_COLORS = {
[TERRAIN.GRASS]: ['#4a6b3a', '#507a40', '#3e5e30', '#567a45'],
[TERRAIN.TREES]: ['#2d4a1e', '#1e3614', '#3a5a28', '#24441a'],
[TERRAIN.MOUNTAIN]: ['#6a6a6a', '#7a7a7a', '#5e5e5e', '#878787'],
[TERRAIN.WATER]: ['#3a6a8a', '#2e5a7a', '#4a7a9a', '#3a6a8a'],
[TERRAIN.GOLD_MINE]: ['#c4a13a', '#d4b44a', '#b89430', '#c9a93e']
};
const ENTITY_TYPES = {
WORKER: 'worker',
SOLDIER: 'soldier',
TOWN_HALL: 'townhall',
BARRACKS: 'barracks',
FARM: 'farm',
WALL: 'wall'
};
const BUILD_COSTS = {
[ENTITY_TYPES.TOWN_HALL]: { gold: 200 },
[ENTITY_TYPES.BARRACKS]: { gold: 150 },
[ENTITY_TYPES.FARM]: { gold: 100 },
[ENTITY_TYPES.WALL]: { gold: 50 }
};
const TRAIN_COSTS = {
[ENTITY_TYPES.WORKER]: { gold: 50, food: 1 },
[ENTITY_TYPES.SOLDIER]: { gold: 100, food: 2 }
};
const TRAIN_TIMES = {
[ENTITY_TYPES.WORKER]: 4000,
[ENTITY_TYPES.SOLDIER]: 6000
};
const BUILD_TIMES = {
[ENTITY_TYPES.TOWN_HALL]: 8000,
[ENTITY_TYPES.BARRACKS]: 6000,
[ENTITY_TYPES.FARM]: 4000,
[ENTITY_TYPES.WALL]: 2000
};
const SIGHT_RANGES = {
[ENTITY_TYPES.WORKER]: 4,
[ENTITY_TYPES.SOLDIER]: 6,
[ENTITY_TYPES.TOWN_HALL]: 8,
[ENTITY_TYPES.BARRACKS]: 5,
[ENTITY_TYPES.FARM]: 4,
[ENTITY_TYPES.WALL]: 3
};
const SPEEDS = {
[ENTITY_TYPES.WORKER]: 40,
[ENTITY_TYPES.SOLDIER]: 55
};
const MAX_HEALTH = {
[ENTITY_TYPES.WORKER]: 60,
[ENTITY_TYPES.SOLDIER]: 100,
[ENTITY_TYPES.TOWN_HALL]: 400,
[ENTITY_TYPES.BARRACKS]: 300,
[ENTITY_TYPES.FARM]: 150,
[ENTITY_TYPES.WALL]: 200
};
const GATHER_RATE = 8; // gold per gather
const GATHER_INTERVAL = 2000; // ms between gathers
const FOOD_PER_FARM = 6;
const START_GOLD = 400;
const START_FOOD = 10;
const START_UNIT_CAP = 10;
// ─────────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────────
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// game state
let gold = START_GOLD;
let food = START_FOOD;
let unitCap = START_UNIT_CAP;
let units = [];
let buildings = [];
let map = [];
let explored = [];
let visible = [];
let selection = []; // selected entities
let hoverEntity = null;
let buildMode = null; // entity type string or null
let buildPreviewPos = null;
let moveTarget = null;
let nextId = 1;
let gameTime = 0;
// DOM refs
const goldEl = document.getElementById('goldDisplay');
const foodEl = document.getElementById('foodDisplay');
const foodCapEl = document.getElementById('foodCapDisplay');
const unitCountEl = document.getElementById('unitCountDisplay');
const unitCapEl = document.getElementById('unitCapDisplay');
const selectionInfo = document.getElementById('selectionInfo');
// ─────────────────────────────────────────────────────────
// MAP GENERATION
// ─────────────────────────────────────────────────────────
function generateMap() {
map = [];
explored = [];
visible = [];
for (let y = 0; y < MAP_SIZE; y++) {
const row = [];
const exRow = [];
const viRow = [];
for (let x = 0; x < MAP_SIZE; x++) {
row.push(TERRAIN.GRASS);
exRow.push(false);
viRow.push(false);
}
map.push(row);
explored.push(exRow);
visible.push(viRow);
}
// helper to place terrain clusters
function placeCluster(type, count, minDist = 1) {
for (let i = 0; i < count; i++) {
let x = Math.floor(Math.random() * MAP_SIZE);
let y = Math.floor(Math.random() * MAP_SIZE);
// ensure not water/mountain overlap for gold mines
if (type === TERRAIN.GOLD_MINE && map[y][x] !== TERRAIN.GRASS) continue;
if (type !== TERRAIN.GRASS && map[y][x] !== TERRAIN.GRASS) continue;
map[y][x] = type;
// spread a few neighbors
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (Math.random() < 0.5) {
const ny = y + dy,
nx = x + dx;
if (ny >= 0 && ny < MAP_SIZE && nx >= 0 && nx < MAP_SIZE && map[ny][nx] === TERRAIN
.GRASS) {
map[ny][nx] = type;
}
}
}
}
}
}
placeCluster(TERRAIN.TREES, 18, 2);
placeCluster(TERRAIN.MOUNTAIN, 10, 2);
placeCluster(TERRAIN.WATER, 8, 2);
placeCluster(TERRAIN.GOLD_MINE, 5, 3);
// ensure town hall placement area is clear
const cx = Math.floor(MAP_SIZE / 2);
const cy = Math.floor(MAP_SIZE / 2);
for (let y = cy - 2; y <= cy + 2; y++) {
for (let x = cx - 2; x <= cx + 2; x++) {
if (y >= 0 && y < MAP_SIZE && x >= 0 && x < MAP_SIZE) {
map[y][x] = TERRAIN.GRASS;
}
}
}
}
// ─────────────────────────────────────────────────────────
// ENTITIES
// ─────────────────────────────────────────────────────────
function createEntity(type, x, y, owner = 'player') {
const id = nextId++;
const health = MAX_HEALTH[type] || 100;
const maxHealth = health;
const ent = {
id,
type,
x,
y,
owner,
health,
maxHealth,
// movement
moveX: null,
moveY: null,
speed: SPEEDS[type] || 30,
// gathering
gathering: false,
gatherTarget: null,
gatherTimer: 0,
// building / training
buildingProgress: 0,
buildTarget: null,
trainingUnit: null,
trainingTimer: 0,
// extra
sight: SIGHT_RANGES[type] || 4,
// for buildings: size (tiles)
size: getBuildingSize(type),
// for walls: compact
isBuilding: isBuildingType(type),
isUnit: isUnitType(type),
// animation offset
animPhase: Math.random() * Math.PI * 2,
};
if (isBuildingType(type)) {
ent.buildingComplete = false;
ent.buildTime = BUILD_TIMES[type] || 5000;
ent.buildProgress = 0;
}
if (type === ENTITY_TYPES.WORKER || type === ENTITY_TYPES.SOLDIER) {
ent.foodCost = TRAIN_COSTS[type] ? TRAIN_COSTS[type].food : 1;
}
return ent;
}
function isBuildingType(type) {
return [ENTITY_TYPES.TOWN_HALL, ENTITY_TYPES.BARRACKS, ENTITY_TYPES.FARM, ENTITY_TYPES.WALL].includes(type);
}
function isUnitType(type) {
return [ENTITY_TYPES.WORKER, ENTITY_TYPES.SOLDIER].includes(type);
}
function getBuildingSize(type) {
switch (type) {
case ENTITY_TYPES.TOWN_HALL:
return 3;
case ENTITY_TYPES.BARRACKS:
return 2;
case ENTITY_TYPES.FARM:
return 2;
case ENTITY_TYPES.WALL:
return 1;
default:
return 1;
}
}
// ─────────────────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────────────────
function initGame() {
generateMap();
// Place Town Hall
const cx = Math.floor(MAP_SIZE / 2);
const cy = Math.floor(MAP_SIZE / 2);
const th = createEntity(ENTITY_TYPES.TOWN_HALL, cx, cy);
th.buildingComplete = true;
th.buildProgress = 1;
buildings.push(th);
// Place 4 workers nearby
for (let i = 0; i < 4; i++) {
const wx = cx + (i % 2 === 0 ? -1 : 1) * (1 + Math.floor(Math.random() * 2));
const wy = cy + (i < 2 ? -1 : 1) * (1 + Math.floor(Math.random() * 2));
const w = createEntity(ENTITY_TYPES.WORKER, wx, wy);
units.push(w);
}
// Place some gold mines on map (already in terrain)
// Place a few neutral soldiers? No — no AI.
updateUI();
updateFog();
}
// ─────────────────────────────────────────────────────────
// FOG OF WAR
// ─────────────────────────────────────────────────────────
function updateFog() {
// reset visibility
for (let y = 0; y < MAP_SIZE; y++) {
for (let x = 0; x < MAP_SIZE; x++) {
visible[y][x] = false;
}
}
// all buildings and units reveal
const allEntities = [...buildings, ...units];
for (const e of allEntities) {
if (!e.buildingComplete && e.isBuilding) continue;
const r = e.sight || 4;
const ex = e.x;
const ey = e.y;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy <= r * r) {
const ny = Math.floor(ey + dy);
const nx = Math.floor(ex + dx);
if (ny >= 0 && ny < MAP_SIZE && nx >= 0 && nx < MAP_SIZE) {
visible[ny][nx] = true;
explored[ny][nx] = true;
}
}
}
}
}
}
// ─────────────────────────────────────────────────────────
// UTILITY
// ─────────────────────────────────────────────────────────
function isTileWalkable(x, y, entityType) {
if (x < 0 || x >= MAP_SIZE || y < 0 || y >= MAP_SIZE) return false;
const t = map[y][x];
if (t === TERRAIN.WATER || t === TERRAIN.MOUNTAIN) return false;
// Check buildings blocking
for (const b of buildings) {
if (!b.buildingComplete) continue;
const s = b.size || 1;
const bx = Math.floor(b.x);
const by = Math.floor(b.y);
if (x >= bx && x < bx + s && y >= by && y < by + s) return false;
}
return true;
}
function isPositionValidBuild(x, y, size) {
const sx = Math.floor(x);
const sy = Math.floor(y);
for (let dy = 0; dy < size; dy++) {
for (let dx = 0; dx < size; dx++) {
const cx = sx + dx;
const cy = sy + dy;
if (cx < 0 || cx >= MAP_SIZE || cy < 0 || cy >= MAP_SIZE) return false;
const t = map[cy][cx];
if (t === TERRAIN.WATER || t === TERRAIN.MOUNTAIN || t === TERRAIN.GOLD_MINE) return false;
// check existing buildings
for (const b of buildings) {
const bs = b.size || 1;
const bx = Math.floor(b.x);
const by = Math.floor(b.y);
if (cx >= bx && cx < bx + bs && cy >= by && cy < by + bs) return false;
}
}
}
return true;
}
function dist(ax, ay, bx, by) {
return Math.sqrt((ax - bx) ** 2 + (ay - by) ** 2);
}
function entityAt(x, y) {
// returns unit or building at tile coords
for (const u of units) {
if (Math.floor(u.x) === Math.floor(x) && Math.floor(u.y) === Math.floor(y)) return u;
}
for (const b of buildings) {
const s = b.size || 1;
const bx = Math.floor(b.x);
const by = Math.floor(b.y);
if (x >= bx && x < bx + s && y >= by && y < by + s) return b;
}
return null;
}
function findNearestGoldMine(x, y) {
let best = null;
let bestDist = Infinity;
for (let gy = 0; gy < MAP_SIZE; gy++) {
for (let gx = 0; gx < MAP_SIZE; gx++) {
if (map[gy][gx] === TERRAIN.GOLD_MINE) {
const d = dist(x, y, gx, gy);
if (d < bestDist) {
bestDist = d;
best = { x: gx, y: gy };
}
}
}
}
return best;
}
// ─────────────────────────────────────────────────────────
// GAME UPDATE
// ─────────────────────────────────────────────────────────
function update(dt) {
gameTime += dt;
// ----- Movement -----
for (const u of units) {
if (u.moveX !== null && u.moveY !== null) {
const dx = u.moveX - u.x;
const dy = u.moveY - u.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d > 0.2) {
const speed = u.speed * dt / 1000;
const nx = u.x + (dx / d) * speed;
const ny = u.y + (dy / d) * speed;
// collision with terrain
const tx = Math.floor(nx);
const ty = Math.floor(ny);
if (isTileWalkable(tx, ty, u.type)) {
u.x = nx;
u.y = ny;
} else {
// try sliding
const altX = u.x + (dx / d) * speed;
const altY = u.y + (dy / d) * speed;
if (isTileWalkable(Math.floor(altX), Math.floor(u.y), u.type)) {
u.y = altY;
} else if (isTileWalkable(Math.floor(u.x), Math.floor(altY), u.type)) {
u.x = altX;
} else {
u.moveX = null;
u.moveY = null;
}
}
} else {
u.moveX = null;
u.moveY = null;
}
}
// clamp
u.x = Math.max(0, Math.min(MAP_SIZE - 0.5, u.x));
u.y = Math.max(0, Math.min(MAP_SIZE - 0.5, u.y));
}
// ----- Gathering -----
for (const u of units) {
if (u.type === ENTITY_TYPES.WORKER && u.gatherTarget) {
const gx = u.gatherTarget.x;
const gy = u.gatherTarget.y;
if (dist(u.x, u.y, gx, gy) < 1.5) {
u.gatherTimer += dt;
if (u.gatherTimer >= GATHER_INTERVAL) {
u.gatherTimer -= GATHER_INTERVAL;
// check gold mine still exists
if (map[gy] && map[gy][gx] === TERRAIN.GOLD_MINE) {
gold += GATHER_RATE;
// deplete? keep infinite for simplicity
updateUI();
}
}
} else {
// move to mine
u.moveX = gx;
u.moveY = gy;
u.gatherTimer = 0;
}
}
}
// ----- Building construction -----
for (const b of buildings) {
if (!b.buildingComplete) {
// find nearby workers building it
let buildingProgress = false;
for (const u of units) {
if (u.type === ENTITY_TYPES.WORKER && u.buildTarget && u.buildTarget.id === b.id && dist(u.x, u.y, b
.x, b.y) < 2.5) {
u.buildTimer = (u.buildTimer || 0) + dt;
if (u.buildTimer >= 1200) {
u.buildTimer -= 1200;
b.buildProgress += 0.05;
if (b.buildProgress >= 1) {
b.buildingComplete = true;
b.buildProgress = 1;
// apply effects
if (b.type === ENTITY_TYPES.FARM) {
unitCap += FOOD_PER_FARM;
updateUI();
}
}
updateUI();
}
buildingProgress = true;
break;
}
}
if (!buildingProgress) {
// assign a nearby worker if idle
for (const u of units) {
if (u.type === ENTITY_TYPES.WORKER && !u.buildTarget && !u.gatherTarget && u.moveX === null &&
u.moveY === null) {
u.buildTarget = b;
u.moveX = b.x + b.size / 2;
u.moveY = b.y + b.size / 2;
break;
}
}
}
}
}
// ----- Training -----
for (const b of buildings) {
if (b.buildingComplete && b.trainingUnit && b.trainingTimer > 0) {
b.trainingTimer -= dt;
if (b.trainingTimer <= 0) {
// spawn unit
const type = b.trainingUnit;
const cost = TRAIN_COSTS[type];
if (cost) {
gold -= cost.gold;
// food checked before
}
let spawnX = b.x + b.size / 2;
let spawnY = b.y + b.size + 1;
if (spawnY >= MAP_SIZE) spawnY = b.y - 1;
const nu = createEntity(type, spawnX, spawnY);
units.push(nu);
b.trainingUnit = null;
b.trainingTimer = 0;
updateUI();
}
}
}
// ----- Auto-explore: units reveal fog (already done per frame, but we update periodically) -----
// Update fog every 10 frames for performance
updateFog();
// ----- Cleanup dead entities (none, no combat yet) -----
}
// ─────────────────────────────────────────────────────────
// INPUT HANDLING
// ─────────────────────────────────────────────────────────
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
const tx = Math.floor(mx / TILE_SIZE);
const ty = Math.floor(my / TILE_SIZE);
if (buildMode && tx >= 0 && tx < MAP_SIZE && ty >= 0 && ty < MAP_SIZE) {
// Try to place building
const size = getBuildingSize(buildMode);
const cost = BUILD_COSTS[buildMode];
if (cost && gold >= cost.gold) {
if (isPositionValidBuild(tx, ty, size)) {
gold -= cost.gold;
const b = createEntity(buildMode, tx, ty);
b.buildingComplete = false;
b.buildProgress = 0;
buildings.push(b);
updateUI();
buildMode = null;
buildPreviewPos = null;
return;
}
}
return;
}
// ----- Selection -----
// Find entity under cursor
const entities = [...units, ...buildings.filter(b => b.buildingComplete)];
let clicked = null;
for (const e of entities) {
const ex = e.x * TILE_SIZE + TILE_SIZE / 2;
const ey = e.y * TILE_SIZE + TILE_SIZE / 2;
const s = e.size || 1;
const half = s * TILE_SIZE / 2;
if (mx >= ex - half && mx <= ex + half && my >= ey - half && my <= ey + half) {
clicked = e;
break;
}
}
if (clicked) {
selection = [clicked];
updateSelectionInfo();
return;
}
// Click on empty ground -> move selected units
if (selection.length > 0) {
const targetX = tx + 0.5;
const targetY = ty + 0.5;
for (const s of selection) {
if (s.isUnit) {
s.moveX = targetX;
s.moveY = targetY;
s.gatherTarget = null;
s.buildTarget = null;
}
}
// if worker, also could gather if clicking on gold mine
if (map[ty] && map[ty][tx] === TERRAIN.GOLD_MINE) {
for (const s of selection) {
if (s.type === ENTITY_TYPES.WORKER) {
s.gatherTarget = { x: tx, y: ty };
s.moveX = tx;
s.moveY = ty;
s.buildTarget = null;
}
}
}
}
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
const tx = Math.floor(mx / TILE_SIZE);
const ty = Math.floor(my / TILE_SIZE);
if (buildMode) {
buildPreviewPos = { x: tx, y: ty };
return;
}
// hover entity detection
const entities = [...units, ...buildings.filter(b => b.buildingComplete)];
let hover = null;
for (const e of entities) {
const ex = e.x * TILE_SIZE + TILE_SIZE / 2;
const ey = e.y * TILE_SIZE + TILE_SIZE / 2;
const s = e.size || 1;
const half = s * TILE_SIZE / 2;
if (mx >= ex - half && mx <= ex + half && my >= ey - half && my <= ey + half) {
hover = e;
break;
}
}
hoverEntity = hover;
canvas.style.cursor = (hover || buildMode) ? 'pointer' : 'default';
});
// ─────────────────────────────────────────────────────────
// UI BUTTONS
// ─────────────────────────────────────────────────────────
function setupButtons() {
const builds = [
['btnTownHall', ENTITY_TYPES.TOWN_HALL],
['btnBarracks', ENTITY_TYPES.BARRACKS],
['btnFarm', ENTITY_TYPES.FARM],
['btnWall', ENTITY_TYPES.WALL],
];
for (const [id, type] of builds) {
document.getElementById(id).addEventListener('click', () => {
if (buildMode === type) {
buildMode = null;
buildPreviewPos = null;
} else {
buildMode = type;
}
});
}
document.getElementById('btnWorker').addEventListener('click', () => {
tryTrain(ENTITY_TYPES.WORKER);
});
document.getElementById('btnSoldier').addEventListener('click', () => {
tryTrain(ENTITY_TYPES.SOLDIER);
});
}
function tryTrain(type) {
const cost = TRAIN_COSTS[type];
if (!cost) return;
// find selected building that can train (Town Hall trains workers, Barracks trains soldiers)
let building = null;
for (const s of selection) {
if (s.isBuilding && s.buildingComplete) {
if (type === ENTITY_TYPES.WORKER && s.type === ENTITY_TYPES.TOWN_HALL) building = s;
if (type === ENTITY_TYPES.SOLDIER && s.type === ENTITY_TYPES.BARRACKS) building = s;
}
}
if (!building) {
alert('Select a Town Hall to train Workers, or a Barracks to train Soldiers.');
return;
}
if (building.trainingUnit) {
alert('Building is already training a unit.');
return;
}
// check resources
let unitCount = units.length;
let foodUsed = units.reduce((s, u) => s + (u.foodCost || 0), 0);
if (foodUsed + cost.food > food) {
alert('Not enough food! Build farms to increase food supply.');
return;
}
if (unitCount >= unitCap) {
alert('Unit cap reached! Build more farms to increase cap.');
return;
}
if (gold < cost.gold) {
alert('Not enough gold!');
return;
}
// deduct gold (will deduct at completion)
// but we check now
building.trainingUnit = type;
building.trainingTimer = TRAIN_TIMES[type];
updateUI();
}
// ─────────────────────────────────────────────────────────
// UI UPDATE
// ─────────────────────────────────────────────────────────
function updateUI() {
goldEl.textContent = Math.floor(gold);
const foodUsed = units.reduce((s, u) => s + (u.foodCost || 0), 0);
foodEl.textContent = food - foodUsed;
foodCapEl.textContent = food;
unitCountEl.textContent = units.length;
unitCapEl.textContent = unitCap;
}
function updateSelectionInfo() {
if (selection.length === 0) {
selectionInfo.innerHTML = '<span>🏛️</span><span>Click to select</span>';
return;
}
const s = selection[0];
const name = s.type.charAt(0).toUpperCase() + s.type.slice(1);
const health = s.health;
const maxH = s.maxHealth;
const icon = s.isBuilding ? '🏛️' : (s.type === ENTITY_TYPES.WORKER ? '🔧' : '⚔️');
let extra = '';
if (s.isBuilding && !s.buildingComplete) {
extra = ` (${Math.floor(s.buildProgress * 100)}%)`;
}
if (s.isBuilding && s.trainingUnit) {
const pct = Math.floor((1 - s.trainingTimer / TRAIN_TIMES[s.trainingUnit]) * 100);
extra += ` training: ${pct}%`;
}
selectionInfo.innerHTML = `<span>${icon}</span><span>${name} ❤️${health}/${maxH}${extra}</span>`;
}
// ─────────────────────────────────────────────────────────
// RENDER
// ─────────────────────────────────────────────────────────
function render() {
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
// ---- Terrain + Fog ----
for (let y = 0; y < MAP_SIZE; y++) {
for (let x = 0; x < MAP_SIZE; x++) {
const t = map[y][x];
const colors = TERRAIN_COLORS[t] || TERRAIN_COLORS[TERRAIN.GRASS];
const col = colors[(x + y) % colors.length];
const px = x * TILE_SIZE;
const py = y * TILE_SIZE;
// If not explored at all -> black fog
if (!explored[y][x]) {
ctx.fillStyle = '#0a0a12';
ctx.fillRect(px, py, TILE_SIZE, TILE_SIZE);
continue;
}
// Draw terrain
ctx.fillStyle = col;
ctx.fillRect(px, py, TILE_SIZE, TILE_SIZE);
// Draw details for specific terrain
if (t === TERRAIN.TREES) {
ctx.fillStyle = '#1e321a';
for (let i = 0; i < 3; i++) {
const cx = px + 4 + Math.random() * 12;
const cy = py + 4 + Math.random() * 12;
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#3a5a2a';
for (let i = 0; i < 4; i++) {
const cx = px + 4 + Math.random() * 12;
const cy = py + 4 + Math.random() * 12;
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fill();
}
} else if (t === TERRAIN.MOUNTAIN) {
ctx.fillStyle = '#5a5a4a';
ctx.beginPath();
ctx.moveTo(px + 2, py + TILE_SIZE);
ctx.lineTo(px + TILE_SIZE / 2, py + 2);
ctx.lineTo(px + TILE_SIZE - 2, py + TILE_SIZE);
ctx.fill();
ctx.fillStyle = '#6a6a5a';
ctx.beginPath();
ctx.moveTo(px + 4, py + TILE_SIZE);
ctx.lineTo(px + TILE_SIZE / 2 + 2, py + 6);
ctx.lineTo(px + TILE_SIZE - 4, py + TILE_SIZE);
ctx.fill();
} else if (t === TERRAIN.WATER) {
ctx.fillStyle = '#4a7a9a';
ctx.fillRect(px + 2, py + 2, TILE_SIZE - 4, TILE_SIZE - 4);
ctx.fillStyle = '#5a8a9a';
for (let i = 0; i < 3; i++) {
const wx = px + 4 + Math.random() * 12;
const wy = py + 4 + Math.random() * 12;
ctx.beginPath();
ctx.arc(wx, wy, 2, 0, Math.PI * 2);
ctx.fill();
}
} else if (t === TERRAIN.GOLD_MINE) {
ctx.fillStyle = '#b89430';
ctx.fillRect(px + 2, py + 2, TILE_SIZE - 4, TILE_SIZE - 4);
ctx.fillStyle = '#d4b44a';
ctx.beginPath();
ctx.arc(px + TILE_SIZE / 2, py + TILE_SIZE / 2, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f0d060';
ctx.beginPath();
ctx.arc(px + TILE_SIZE / 2 - 2, py + TILE_SIZE / 2 - 2, 3, 0, Math.PI * 2);
ctx.fill();
// sparkle
const sp = Math.sin(gameTime / 300 + x * 7 + y * 13) * 0.5 + 0.5;
ctx.fillStyle = `rgba(255, 240, 180, ${sp * 0.6})`;
ctx.fillRect(px + 6, py + 6, 2, 2);
ctx.fillRect(px + 12, py + 10, 2, 2);
}
// Fog overlay if not visible but explored
if (!visible[y][x]) {
ctx.fillStyle = 'rgba(0, 0, 16, 0.55)';
ctx.fillRect(px, py, TILE_SIZE, TILE_SIZE);
}
}
}
// ---- Buildings (under construction first, then complete) ----
for (const b of buildings) {
const s = b.size || 1;
const px = b.x * TILE_SIZE;
const py = b.y * TILE_SIZE;
const w = s * TILE_SIZE;
// Visibility check — if any tile not visible, skip
let vis = false;
for (let dy = 0; dy < s; dy++) {
for (let dx = 0; dx < s; dx++) {
const vy = Math.floor(b.y + dy);
const vx = Math.floor(b.x + dx);
if (vy >= 0 && vy < MAP_SIZE && vx >= 0 && vx < MAP_SIZE && visible[vy][vx]) vis = true;
}
}
if (!vis) continue;
const progress = b.buildingComplete ? 1 : b.buildProgress;
const alpha = b.buildingComplete ? 1 : 0.6;
// Draw building shape
ctx.globalAlpha = alpha;
if (b.type === ENTITY_TYPES.TOWN_HALL) {
ctx.fillStyle = '#7a5a3a';
ctx.fillRect(px + 4, py + 10, w - 8, w - 14);
ctx.fillStyle = '#8b5e3c';
ctx.fillRect(px + 2, py + 4, w - 4, 10);
// roof
ctx.fillStyle = '#5a3a2a';
ctx.beginPath();
ctx.moveTo(px + 2, py + 4);
ctx.lineTo(px + w / 2, py - 4);
ctx.lineTo(px + w - 2, py + 4);
ctx.fill();
// door
ctx.fillStyle = '#3a2a1a';
ctx.fillRect(px + w / 2 - 4, py + w - 10, 8, 10);
// windows
ctx.fillStyle = '#a0c0d0';
ctx.fillRect(px + 6, py + 14, 6, 6);
ctx.fillRect(px + w - 12, py + 14, 6, 6);
} else if (b.type === ENTITY_TYPES.BARRACKS) {
ctx.fillStyle = '#5a3a2a';
ctx.fillRect(px + 4, py + 6, w - 8, w - 10);
ctx.fillStyle = '#4a2a1a';
ctx.fillRect(px + 2, py + 2, w - 4, 6);
// door
ctx.fillStyle = '#2a1a0a';
ctx.fillRect(px + w / 2 - 3, py + w - 8, 6, 8);
// windows
ctx.fillStyle = '#90a8b8';
ctx.fillRect(px + 5, py + 10, 4, 4);
ctx.fillRect(px + w - 9, py + 10, 4, 4);
// flag
ctx.fillStyle = '#8a2a2a';
ctx.fillRect(px + w / 2 - 1, py - 2, 2, 6);
ctx.fillStyle = '#c04040';
ctx.beginPath();
ctx.moveTo(px + w / 2, py - 2);
ctx.lineTo(px + w / 2 + 6, py - 4);
ctx.lineTo(px + w / 2, py - 6);
ctx.fill();
} else if (b.type === ENTITY_TYPES.FARM) {
ctx.fillStyle = '#6a5a3a';
ctx.fillRect(px + 3, py + 3, w - 6, w - 6);
// fence
ctx.strokeStyle = '#4a3a2a';
ctx.lineWidth = 2;
ctx.strokeRect(px + 2, py + 2, w - 4, w - 4);
// crops
ctx.fillStyle = '#6a8a3a';
for (let i = 0; i < 4; i++) {
const cx = px + 4 + Math.random() * (w - 8);
const cy = py + 4 + Math.random() * (w - 8);
ctx.fillRect(cx, cy, 3, 6);
}
} else if (b.type === ENTITY_TYPES.WALL) {
ctx.fillStyle = '#7a6a5a';
ctx.fillRect(px + 2, py + 2, w - 4, w - 4);
ctx.fillStyle = '#8a7a6a';
ctx.fillRect(px + 4, py + 4, w - 8, w - 8);
// battlements
ctx.fillStyle = '#6a5a4a';
for (let i = 0; i < 4; i++) {
const bx = px + 2 + i * 5;
ctx.fillRect(bx, py, 3, 3);
}
}
// Health bar
if (b.buildingComplete) {
const hp = b.health / b.maxHealth;
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(px + 2, py - 4, w - 4, 4);
ctx.fillStyle = hp > 0.5 ? '#4a8a4a' : hp > 0.25 ? '#b8a030' : '#b84040';
ctx.fillRect(px + 2, py - 4, (w - 4) * hp, 4);
} else {
// progress bar
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(px + 2, py - 4, w - 4, 4);
ctx.fillStyle = '#6080c0';
ctx.fillRect(px + 2, py - 4, (w - 4) * b.buildProgress, 4);
}
ctx.globalAlpha = 1;
}
// ---- Units ----
for (const u of units) {
const px = u.x * TILE_SIZE;
const py = u.y * TILE_SIZE;
const vis = visible[Math.floor(u.y)] && visible[Math.floor(u.y)][Math.floor(u.x)];
if (!vis) {
// draw silhouette if not visible
ctx.globalAlpha = 0.3;
}
const rad = 7;
// body
if (u.type === ENTITY_TYPES.WORKER) {
ctx.fillStyle = '#3a7a4a';
ctx.beginPath();
ctx.arc(px + TILE_SIZE / 2, py + TILE_SIZE / 2 + 2, rad, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#2a5a3a';
ctx.beginPath();
ctx.arc(px + TILE_SIZE / 2 - 2, py + TILE_SIZE / 2 - 4, 4, 0, Math.PI * 2);
ctx.fill();
// tool (pickaxe)
ctx.strokeStyle = '#8a7a5a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px + TILE_SIZE / 2 + 4, py + TILE_SIZE / 2 + 2);
ctx.lineTo(px + TILE_SIZE / 2 + 10, py + TILE_SIZE / 2 - 4);
ctx.stroke();
} else {
// soldier
ctx.fillStyle = '#4a5a8a';
ctx.beginPath();
ctx.arc(px + TILE_SIZE / 2, py + TILE_SIZE / 2 + 2, rad, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3a4a6a';
ctx.beginPath();
ctx.arc(px + TILE_SIZE / 2 - 2, py + TILE_SIZE / 2 - 4, 4, 0, Math.PI * 2);
ctx.fill();
// sword
ctx.strokeStyle = '#b0b0c0';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px + TILE_SIZE / 2 + 6, py + TILE_SIZE / 2);
ctx.lineTo(px + TILE_SIZE / 2 + 14, py + TILE_SIZE / 2 - 6);
ctx.stroke();
// shield
ctx.fillStyle = '#6a6a7a';
ctx.fillRect(px + TILE_SIZE / 2 - 8, py + TILE_SIZE / 2 - 2, 5, 8);
}
// Health bar
const hp = u.health / u.maxHealth;
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(px + 2, py - 6, 16, 3);
ctx.fillStyle = hp > 0.5 ? '#4a8a4a' : hp > 0.25 ? '#b8a030' : '#b84040';
ctx.fillRect(px + 2, py - 6, 16 * hp, 3);
// Selection highlight
if (selection.includes(u)) {
ctx.strokeStyle = '#70d070';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px + TILE_SIZE / 2, py + TILE_SIZE / 2 + 2, rad + 4, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// ---- Build preview ----
if (buildMode && buildPreviewPos) {
const s = getBuildingSize(buildMode);
const px = buildPreviewPos.x * TILE_SIZE;
const py = buildPreviewPos.y * TILE_SIZE;
const w = s * TILE_SIZE;
const valid = isPositionValidBuild(buildPreviewPos.x, buildPreviewPos.y, s);
ctx.fillStyle = valid ? 'rgba(80, 200, 80, 0.3)' : 'rgba(200, 80, 80, 0.3)';
ctx.fillRect(px, py, w, w);
ctx.strokeStyle = valid ? '#70d070' : '#d07070';
ctx.lineWidth = 2;
ctx.strokeRect(px, py, w, w);
}
// ---- Hover highlight ----
if (hoverEntity && !buildMode) {
const e = hoverEntity;
const s = e.size || 1;
const px = e.x * TILE_SIZE;
const py = e.y * TILE_SIZE;
const w = s * TILE_SIZE;
ctx.strokeStyle = 'rgba(255, 255, 200, 0.4)';
ctx.lineWidth = 2;
ctx.strokeRect(px, py, w, w);
}
}
// ─────────────────────────────────────────────────────────
// GAME LOOP
// ─────────────────────────────────────────────────────────
function gameLoop(timestamp) {
const dt = Math.min(50, (timestamp || 0) - (gameLoop.last || 0));
gameLoop.last = timestamp || 0;
update(dt);
render();
// update UI periodically
if (Math.floor(gameTime / 500) !== Math.floor((gameTime - dt) / 500)) {
updateUI();
updateSelectionInfo();
}
requestAnimationFrame(gameLoop);
}
gameLoop.last = 0;
// ─────────────────────────────────────────────────────────
// START
// ─────────────────────────────────────────────────────────
initGame();
setupButtons();
updateUI();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment