Last active
May 16, 2026 21:00
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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