Created
April 16, 2026 15:32
-
-
Save senko/ee6ca221f301b2f2562a910a617f083d to your computer and use it in GitHub Desktop.
RTS game by Opus 4.7
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"> | |
| <title>Iron Dominion — RTS</title> | |
| <style> | |
| :root { | |
| --bg-0: #0a0d10; | |
| --bg-1: #151a20; | |
| --bg-2: #1e252d; | |
| --panel: #1a2028; | |
| --panel-edge: #3a4654; | |
| --accent: #d4a94a; | |
| --accent-bright: #f5cf6f; | |
| --text: #d8dde3; | |
| --text-dim: #8a96a4; | |
| --danger: #c44; | |
| --good: #6bc17a; | |
| --blue: #5a9fd4; | |
| --gold: #e8c34a; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { | |
| height: 100%; | |
| overflow: hidden; | |
| font-family: 'Segoe UI', 'Trebuchet MS', sans-serif; | |
| background: var(--bg-0); | |
| color: var(--text); | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| #game { | |
| position: absolute; inset: 0; | |
| display: grid; | |
| grid-template-rows: 36px 1fr 180px; | |
| } | |
| /* Top bar */ | |
| #topbar { | |
| display: flex; align-items: center; gap: 24px; | |
| background: linear-gradient(to bottom, #222a33, #11161b); | |
| border-bottom: 2px solid #000; | |
| padding: 0 16px; | |
| font-size: 13px; | |
| letter-spacing: 0.5px; | |
| } | |
| #topbar .logo { | |
| font-family: 'Georgia', serif; | |
| font-weight: bold; | |
| color: var(--accent-bright); | |
| text-shadow: 0 0 6px rgba(212,169,74,0.4); | |
| font-size: 15px; | |
| letter-spacing: 2px; | |
| } | |
| .res { | |
| display: flex; align-items: center; gap: 6px; | |
| font-weight: 600; | |
| } | |
| .res .icon { | |
| width: 14px; height: 14px; border-radius: 2px; | |
| display: inline-block; | |
| } | |
| .res.gold .icon { background: radial-gradient(circle at 30% 30%, #ffdd66, #b8860b); box-shadow: inset 0 0 3px #000; } | |
| .res.wood .icon { background: linear-gradient(135deg, #8b5a2b, #5a3a1a); box-shadow: inset 0 0 3px #000; } | |
| .res.food .icon { background: radial-gradient(circle at 30% 30%, #9ae29a, #3a7a3a); box-shadow: inset 0 0 3px #000; } | |
| .res .val { color: var(--accent-bright); min-width: 30px; } | |
| .res.food .val.full { color: var(--danger); } | |
| #help { | |
| margin-left: auto; color: var(--text-dim); font-size: 11px; | |
| } | |
| /* Main view */ | |
| #view { | |
| position: relative; | |
| background: #000; | |
| overflow: hidden; | |
| } | |
| canvas#main { | |
| display: block; | |
| width: 100%; height: 100%; | |
| cursor: crosshair; | |
| image-rendering: pixelated; | |
| } | |
| #selBox { | |
| position: absolute; | |
| border: 1px solid #6bc17a; | |
| background: rgba(107,193,122,0.1); | |
| pointer-events: none; | |
| display: none; | |
| } | |
| /* Bottom panel */ | |
| #bottom { | |
| display: grid; | |
| grid-template-columns: 200px 1fr 320px; | |
| background: linear-gradient(to bottom, #1a2028, #0e1216); | |
| border-top: 2px solid #000; | |
| padding: 8px; | |
| gap: 8px; | |
| } | |
| .panel { | |
| background: var(--panel); | |
| border: 1px solid var(--panel-edge); | |
| border-radius: 3px; | |
| padding: 8px; | |
| overflow: hidden; | |
| box-shadow: inset 0 0 12px rgba(0,0,0,0.4); | |
| } | |
| #minimap-wrap { | |
| position: relative; | |
| padding: 4px; | |
| } | |
| #minimap { | |
| display: block; | |
| width: 100%; height: 100%; | |
| background: #050708; | |
| border: 1px solid #000; | |
| image-rendering: pixelated; | |
| cursor: pointer; | |
| } | |
| #info { | |
| display: flex; flex-direction: column; | |
| font-size: 12px; | |
| overflow: hidden; | |
| } | |
| #info .portrait-row { | |
| display: flex; gap: 8px; align-items: flex-start; | |
| margin-bottom: 6px; | |
| } | |
| #info .portrait { | |
| width: 64px; height: 64px; | |
| background: #0a0d10; | |
| border: 1px solid var(--panel-edge); | |
| display: flex; align-items: center; justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| #info .details { flex: 1; } | |
| #info .name { | |
| color: var(--accent-bright); | |
| font-weight: bold; | |
| font-size: 14px; | |
| letter-spacing: 1px; | |
| margin-bottom: 4px; | |
| } | |
| #info .stat { color: var(--text-dim); margin: 2px 0; } | |
| #info .stat b { color: var(--text); } | |
| .hp-bar { | |
| width: 100%; height: 8px; background: #000; | |
| border: 1px solid var(--panel-edge); | |
| position: relative; | |
| margin-top: 4px; | |
| } | |
| .hp-bar > div { | |
| height: 100%; background: linear-gradient(to right, var(--good), #3a9); | |
| transition: width 0.2s; | |
| } | |
| #multi-list { | |
| display: flex; flex-wrap: wrap; gap: 4px; | |
| margin-top: 6px; | |
| max-height: 80px; overflow: auto; | |
| } | |
| #multi-list .mini { | |
| width: 28px; height: 28px; | |
| background: #0a0d10; | |
| border: 1px solid var(--panel-edge); | |
| display: flex; align-items: center; justify-content: center; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| #multi-list .mini .bar { | |
| position: absolute; left: 1px; right: 1px; bottom: 1px; | |
| height: 2px; background: var(--good); | |
| } | |
| #actions { | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| grid-auto-rows: 56px; | |
| gap: 4px; | |
| align-content: start; | |
| } | |
| .btn { | |
| background: linear-gradient(to bottom, #2a3440, #151b22); | |
| border: 1px solid var(--panel-edge); | |
| color: var(--text); | |
| padding: 4px; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| font-size: 10px; | |
| display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; | |
| text-align: center; | |
| line-height: 1.1; | |
| transition: all 0.1s; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .btn:hover:not(.disabled) { | |
| background: linear-gradient(to bottom, #3a4454, #1f2831); | |
| border-color: var(--accent); | |
| color: var(--accent-bright); | |
| } | |
| .btn:active:not(.disabled) { | |
| transform: translateY(1px); | |
| } | |
| .btn.disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| .btn .ico { | |
| font-size: 22px; | |
| margin-bottom: 2px; | |
| filter: drop-shadow(0 1px 1px #000); | |
| } | |
| .btn .lbl { | |
| font-weight: 600; | |
| letter-spacing: 0.3px; | |
| } | |
| .btn .cost { | |
| font-size: 9px; | |
| color: var(--accent); | |
| margin-top: 1px; | |
| } | |
| .btn .cost .insuf { color: var(--danger); } | |
| .btn.building-placing { | |
| border-color: var(--accent-bright); | |
| box-shadow: 0 0 8px rgba(212,169,74,0.4); | |
| } | |
| .build-progress { | |
| position: absolute; | |
| left: 0; bottom: 0; | |
| height: 3px; | |
| background: var(--good); | |
| transition: width 0.2s; | |
| } | |
| /* Messages */ | |
| #msg { | |
| position: absolute; | |
| top: 50px; left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(20,26,32,0.9); | |
| border: 1px solid var(--accent); | |
| padding: 8px 16px; | |
| font-size: 13px; | |
| color: var(--accent-bright); | |
| border-radius: 3px; | |
| z-index: 100; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| #msg.show { opacity: 1; } | |
| #placing-hint { | |
| position: absolute; | |
| top: 8px; left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(10,13,16,0.85); | |
| border: 1px solid var(--accent); | |
| padding: 4px 12px; | |
| font-size: 12px; | |
| color: var(--accent-bright); | |
| border-radius: 3px; | |
| z-index: 100; | |
| pointer-events: none; | |
| display: none; | |
| } | |
| /* Welcome screen */ | |
| #welcome { | |
| position: absolute; inset: 0; | |
| background: radial-gradient(ellipse at center, #1a2028 0%, #050708 100%); | |
| display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; | |
| z-index: 200; | |
| text-align: center; | |
| padding: 20px; | |
| } | |
| #welcome h1 { | |
| font-family: 'Georgia', serif; | |
| font-size: 52px; | |
| color: var(--accent-bright); | |
| text-shadow: 0 0 20px rgba(212,169,74,0.5), 0 2px 0 #000; | |
| letter-spacing: 6px; | |
| margin-bottom: 8px; | |
| } | |
| #welcome .sub { | |
| color: var(--text-dim); | |
| font-style: italic; | |
| margin-bottom: 32px; | |
| letter-spacing: 2px; | |
| } | |
| #welcome .tips { | |
| max-width: 540px; | |
| background: rgba(20,26,32,0.8); | |
| border: 1px solid var(--panel-edge); | |
| padding: 20px 28px; | |
| border-radius: 4px; | |
| margin-bottom: 24px; | |
| font-size: 13px; | |
| line-height: 1.7; | |
| text-align: left; | |
| } | |
| #welcome .tips b { color: var(--accent-bright); } | |
| #welcome button { | |
| background: linear-gradient(to bottom, #d4a94a, #8a6b20); | |
| border: 1px solid #000; | |
| color: #0a0d10; | |
| padding: 12px 40px; | |
| font-size: 16px; | |
| font-weight: bold; | |
| letter-spacing: 3px; | |
| cursor: pointer; | |
| border-radius: 3px; | |
| box-shadow: 0 0 20px rgba(212,169,74,0.4); | |
| transition: all 0.15s; | |
| } | |
| #welcome button:hover { | |
| background: linear-gradient(to bottom, #f5cf6f, #b8862a); | |
| transform: scale(1.03); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="welcome"> | |
| <h1>IRON DOMINION</h1> | |
| <div class="sub">— A Real-Time Strategy —</div> | |
| <div class="tips"> | |
| <b>OBJECTIVE:</b> Expand your dominion, gather resources, and uncover the map.<br><br> | |
| <b>CONTROLS:</b><br> | |
| • <b>Left click</b> to select units / buildings<br> | |
| • <b>Left click + drag</b> to box-select multiple units<br> | |
| • <b>Right click</b> to move units or order workers to gather<br> | |
| • <b>Arrow keys</b> or <b>screen edges</b> to scroll the map<br> | |
| • <b>Click minimap</b> to jump to a location<br><br> | |
| <b>TIP:</b> Start by training <b>Workers</b> from your Town Hall. Send them to <b>Gold Mines</b> (glittering piles) and <b>Forests</b> (trees) to gather resources. Build <b>Farms</b> to increase your food cap. Build a <b>Barracks</b> to train <b>Soldiers</b>. | |
| </div> | |
| <button onclick="startGame()">BEGIN CAMPAIGN</button> | |
| </div> | |
| <div id="game" style="display:none"> | |
| <div id="topbar"> | |
| <div class="logo">⚔ IRON DOMINION</div> | |
| <div class="res gold"><span class="icon"></span>Gold <span class="val" id="r-gold">0</span></div> | |
| <div class="res wood"><span class="icon"></span>Wood <span class="val" id="r-wood">0</span></div> | |
| <div class="res food"><span class="icon"></span>Food <span class="val" id="r-food">0/0</span></div> | |
| <div id="help">Right-click: move/gather • Drag: box-select • Arrows/Edges: scroll</div> | |
| </div> | |
| <div id="view"> | |
| <canvas id="main"></canvas> | |
| <div id="selBox"></div> | |
| <div id="placing-hint">Left-click to place • Right-click or ESC to cancel</div> | |
| <div id="msg"></div> | |
| </div> | |
| <div id="bottom"> | |
| <div class="panel" id="minimap-wrap"> | |
| <canvas id="minimap"></canvas> | |
| </div> | |
| <div class="panel" id="info"> | |
| <div class="portrait-row"> | |
| <div class="portrait" id="portrait">—</div> | |
| <div class="details"> | |
| <div class="name" id="sel-name">Nothing selected</div> | |
| <div class="stat" id="sel-stat1"></div> | |
| <div class="stat" id="sel-stat2"></div> | |
| <div class="stat" id="sel-stat3"></div> | |
| <div class="hp-bar" id="sel-hp" style="display:none"><div></div></div> | |
| </div> | |
| </div> | |
| <div id="multi-list"></div> | |
| </div> | |
| <div class="panel" id="actions"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================================ | |
| // IRON DOMINION — Real-Time Strategy | |
| // ============================================================ | |
| const TILE = 32; | |
| const MAP_W = 64; // tiles | |
| const MAP_H = 64; | |
| const WORLD_W = MAP_W * TILE; | |
| const WORLD_H = MAP_H * TILE; | |
| // -------- Map generation -------- | |
| // Tile types: 0 grass, 1 forest, 2 gold-mine, 3 water, 4 dirt | |
| const TILE_GRASS = 0, TILE_FOREST = 1, TILE_GOLD = 2, TILE_WATER = 3, TILE_DIRT = 4; | |
| let tiles = []; // 2D [y][x] -> type | |
| let tileRes = []; // 2D [y][x] -> resource amount (for forest/gold) | |
| let fog = []; // 2D [y][x] -> 0 unseen, 1 seen (explored), 2 visible | |
| function seededRand(seed) { | |
| let s = seed | 0; | |
| return () => { | |
| s = (s * 1664525 + 1013904223) | 0; | |
| return ((s >>> 0) % 100000) / 100000; | |
| }; | |
| } | |
| const rand = seededRand(42); | |
| function generateMap() { | |
| tiles = Array.from({length: MAP_H}, () => new Array(MAP_W).fill(TILE_GRASS)); | |
| tileRes = Array.from({length: MAP_H}, () => new Array(MAP_W).fill(0)); | |
| fog = Array.from({length: MAP_H}, () => new Array(MAP_W).fill(0)); | |
| // Scatter dirt patches | |
| for (let i = 0; i < 40; i++) { | |
| const cx = Math.floor(rand()*MAP_W), cy = Math.floor(rand()*MAP_H); | |
| const r = 2 + Math.floor(rand()*4); | |
| for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) { | |
| const tx = cx+x, ty = cy+y; | |
| if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue; | |
| if (x*x+y*y <= r*r && rand() < 0.6) tiles[ty][tx] = TILE_DIRT; | |
| } | |
| } | |
| // Forests (several clusters) | |
| for (let i = 0; i < 14; i++) { | |
| const cx = Math.floor(rand()*MAP_W), cy = Math.floor(rand()*MAP_H); | |
| // Avoid starting area | |
| if (Math.abs(cx-8) < 6 && Math.abs(cy-8) < 6) continue; | |
| const r = 2 + Math.floor(rand()*4); | |
| for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) { | |
| const tx = cx+x, ty = cy+y; | |
| if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue; | |
| if (x*x+y*y <= r*r && rand() < 0.7) { | |
| tiles[ty][tx] = TILE_FOREST; | |
| tileRes[ty][tx] = 100; | |
| } | |
| } | |
| } | |
| // Gold mines (scattered) | |
| const goldSpots = [ | |
| [14, 10], [22, 22], [10, 30], [38, 14], [50, 28], [28, 48], [48, 50], [18, 54], [56, 44], [6, 44] | |
| ]; | |
| for (const [cx, cy] of goldSpots) { | |
| // small cluster 2x2 | |
| for (let y = 0; y < 2; y++) for (let x = 0; x < 2; x++) { | |
| const tx = cx+x, ty = cy+y; | |
| if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue; | |
| tiles[ty][tx] = TILE_GOLD; | |
| tileRes[ty][tx] = 1500; | |
| } | |
| } | |
| // Water patches | |
| for (let i = 0; i < 6; i++) { | |
| const cx = Math.floor(rand()*MAP_W), cy = Math.floor(rand()*MAP_H); | |
| if (Math.abs(cx-8) < 8 && Math.abs(cy-8) < 8) continue; | |
| const r = 2 + Math.floor(rand()*3); | |
| for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) { | |
| const tx = cx+x, ty = cy+y; | |
| if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue; | |
| if (x*x+y*y <= r*r && tiles[ty][tx] === TILE_GRASS) tiles[ty][tx] = TILE_WATER; | |
| } | |
| } | |
| // Ensure starting area is clear grass | |
| for (let y = 5; y < 13; y++) for (let x = 5; x < 13; x++) { | |
| tiles[y][x] = TILE_GRASS; | |
| tileRes[y][x] = 0; | |
| } | |
| } | |
| function isPassable(tx, ty) { | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false; | |
| const t = tiles[ty][tx]; | |
| if (t === TILE_WATER || t === TILE_GOLD || t === TILE_FOREST) return false; | |
| // Check buildings | |
| for (const b of buildings) { | |
| if (!b.alive) continue; | |
| const bx = Math.floor(b.x / TILE), by = Math.floor(b.y / TILE); | |
| if (tx >= bx && tx < bx + b.def.tw && ty >= by && ty < by + b.def.th) return false; | |
| } | |
| return true; | |
| } | |
| // -------- Definitions -------- | |
| const UNIT_DEFS = { | |
| worker: { | |
| name: 'Worker', icon: '⛏', hp: 40, speed: 60, radius: 10, color: '#e0c080', | |
| costGold: 50, costWood: 0, costFood: 1, buildTime: 8, | |
| damage: 3, range: 16, attackCd: 1.0, | |
| canBuild: true, canGather: true, | |
| desc: 'Gathers gold and wood. Constructs buildings.' | |
| }, | |
| soldier: { | |
| name: 'Soldier', icon: '⚔', hp: 80, speed: 55, radius: 11, color: '#a82020', | |
| costGold: 80, costWood: 20, costFood: 2, buildTime: 14, | |
| damage: 12, range: 18, attackCd: 1.2, | |
| canBuild: false, canGather: false, | |
| desc: 'Melee combat unit with sword and shield.' | |
| }, | |
| archer: { | |
| name: 'Archer', icon: '🏹', hp: 55, speed: 58, radius: 10, color: '#2a7a2a', | |
| costGold: 100, costWood: 50, costFood: 2, buildTime: 16, | |
| damage: 10, range: 110, attackCd: 1.5, | |
| canBuild: false, canGather: false, | |
| desc: 'Ranged unit. Fires arrows at distant enemies.' | |
| }, | |
| knight: { | |
| name: 'Knight', icon: '♞', hp: 150, speed: 75, radius: 13, color: '#6a5acd', | |
| costGold: 180, costWood: 60, costFood: 3, buildTime: 22, | |
| damage: 22, range: 20, attackCd: 1.3, | |
| canBuild: false, canGather: false, | |
| desc: 'Heavy cavalry. Strong and fast.' | |
| } | |
| }; | |
| const BUILDING_DEFS = { | |
| townhall: { | |
| name: 'Town Hall', icon: '🏛', tw: 3, th: 3, hp: 1500, | |
| costGold: 400, costWood: 300, buildTime: 45, | |
| provides: 5, sight: 8, // tiles | |
| dropoff: true, | |
| trains: ['worker'], | |
| desc: 'Heart of your civilization. Trains workers. Resource drop-off.' | |
| }, | |
| farm: { | |
| name: 'Farm', icon: '🌾', tw: 2, th: 2, hp: 300, | |
| costGold: 0, costWood: 80, buildTime: 15, | |
| provides: 4, sight: 4, | |
| desc: 'Provides food for additional units.' | |
| }, | |
| barracks: { | |
| name: 'Barracks', icon: '⚔', tw: 3, th: 3, hp: 800, | |
| costGold: 200, costWood: 150, buildTime: 30, | |
| provides: 0, sight: 6, | |
| trains: ['soldier', 'archer'], | |
| desc: 'Trains soldiers and archers.' | |
| }, | |
| stable: { | |
| name: 'Stable', icon: '♞', tw: 3, th: 3, hp: 900, | |
| costGold: 250, costWood: 200, buildTime: 35, | |
| provides: 0, sight: 6, | |
| trains: ['knight'], | |
| desc: 'Trains knights — heavy cavalry.' | |
| }, | |
| tower: { | |
| name: 'Watchtower', icon: '🗼', tw: 2, th: 2, hp: 500, | |
| costGold: 100, costWood: 100, buildTime: 20, | |
| provides: 0, sight: 9, | |
| damage: 15, range: 140, attackCd: 1.5, | |
| desc: 'Defensive tower that shoots nearby enemies.' | |
| } | |
| }; | |
| // -------- Game state -------- | |
| const game = { | |
| gold: 500, | |
| wood: 300, | |
| food: 0, | |
| foodCap: 0, | |
| camX: 4 * TILE, | |
| camY: 4 * TILE, | |
| viewW: 800, | |
| viewH: 600, | |
| selected: [], // array of units or single building | |
| hovering: null, | |
| lastTime: 0, | |
| placingBuilding: null, // def key if placing | |
| mouse: { x: 0, y: 0, screenX: 0, screenY: 0, down: false, dragStart: null }, | |
| time: 0, | |
| }; | |
| const units = []; | |
| const buildings = []; | |
| const projectiles = []; | |
| const effects = []; // damage flashes, sparkles | |
| let nextId = 1; | |
| // -------- Entity factories -------- | |
| function createUnit(type, x, y) { | |
| const def = UNIT_DEFS[type]; | |
| const u = { | |
| id: nextId++, | |
| kind: 'unit', | |
| type, def, | |
| x, y, | |
| hp: def.hp, maxHp: def.hp, | |
| speed: def.speed, | |
| radius: def.radius, | |
| alive: true, | |
| state: 'idle', // idle, moving, gathering, returning, attacking, building, dead | |
| target: null, | |
| targetPos: null, | |
| gatherType: null, // 'gold' or 'wood' | |
| carry: 0, | |
| carryType: null, | |
| gatherCd: 0, | |
| attackCd: 0, | |
| buildTarget: null, | |
| facing: 0, | |
| }; | |
| units.push(u); | |
| return u; | |
| } | |
| function createBuilding(type, tx, ty, complete = false) { | |
| const def = BUILDING_DEFS[type]; | |
| const b = { | |
| id: nextId++, | |
| kind: 'building', | |
| type, def, | |
| x: tx * TILE + (def.tw * TILE) / 2, | |
| y: ty * TILE + (def.th * TILE) / 2, | |
| tx, ty, | |
| hp: complete ? def.hp : 1, | |
| maxHp: def.hp, | |
| alive: true, | |
| complete, | |
| buildProgress: complete ? def.buildTime : 0, | |
| buildQueue: [], | |
| trainProgress: 0, | |
| rallyPoint: null, | |
| attackCd: 0, | |
| }; | |
| buildings.push(b); | |
| if (complete && def.provides) game.foodCap += def.provides; | |
| return b; | |
| } | |
| // -------- Initialize game -------- | |
| function initGame() { | |
| generateMap(); | |
| // Starting units | |
| const th = createBuilding('townhall', 7, 7, true); | |
| // workers | |
| createUnit('worker', th.x - 40, th.y + 60); | |
| createUnit('worker', th.x + 40, th.y + 60); | |
| createUnit('worker', th.x, th.y + 70); | |
| // starting food units | |
| game.food = 3; | |
| updateFog(); | |
| centerOn(th.x, th.y); | |
| } | |
| function centerOn(x, y) { | |
| game.camX = x - game.viewW / 2; | |
| game.camY = y - game.viewH / 2; | |
| clampCamera(); | |
| } | |
| function clampCamera() { | |
| game.camX = Math.max(0, Math.min(WORLD_W - game.viewW, game.camX)); | |
| game.camY = Math.max(0, Math.min(WORLD_H - game.viewH, game.camY)); | |
| } | |
| // -------- Fog of war -------- | |
| function updateFog() { | |
| // Decay: visible -> seen | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| if (fog[y][x] === 2) fog[y][x] = 1; | |
| } | |
| } | |
| // Reveal around units and buildings | |
| function reveal(wx, wy, sightTiles) { | |
| const cx = Math.floor(wx / TILE), cy = Math.floor(wy / TILE); | |
| const r = sightTiles; | |
| for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) { | |
| const tx = cx + x, ty = cy + y; | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue; | |
| if (x*x + y*y <= r*r) fog[ty][tx] = 2; | |
| } | |
| } | |
| for (const u of units) if (u.alive) reveal(u.x, u.y, 6); | |
| for (const b of buildings) if (b.alive) reveal(b.x, b.y, b.def.sight || 4); | |
| } | |
| // -------- Pathing (simple) -------- | |
| function moveToward(u, tx, ty, dt) { | |
| const dx = tx - u.x, dy = ty - u.y; | |
| const d = Math.hypot(dx, dy); | |
| if (d < 0.5) return true; | |
| u.facing = Math.atan2(dy, dx); | |
| const step = u.speed * dt; | |
| let nx = u.x, ny = u.y; | |
| if (step >= d) { nx = tx; ny = ty; } | |
| else { nx = u.x + (dx / d) * step; ny = u.y + (dy / d) * step; } | |
| // Try full move | |
| if (canOccupy(nx, ny, u)) { u.x = nx; u.y = ny; return d <= step; } | |
| // Try x only | |
| if (canOccupy(nx, u.y, u)) { u.x = nx; return false; } | |
| // Try y only | |
| if (canOccupy(u.x, ny, u)) { u.y = ny; return false; } | |
| // Try perpendicular slide | |
| const slideAngles = [Math.PI/3, -Math.PI/3, Math.PI/2, -Math.PI/2]; | |
| for (const a of slideAngles) { | |
| const sa = u.facing + a; | |
| const sx = u.x + Math.cos(sa) * step; | |
| const sy = u.y + Math.sin(sa) * step; | |
| if (canOccupy(sx, sy, u)) { u.x = sx; u.y = sy; return false; } | |
| } | |
| return false; | |
| } | |
| function canOccupy(x, y, self) { | |
| const tx = Math.floor(x / TILE), ty = Math.floor(y / TILE); | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false; | |
| const t = tiles[ty][tx]; | |
| if (t === TILE_WATER) return false; | |
| // Collide with buildings | |
| for (const b of buildings) { | |
| if (!b.alive || b === self) continue; | |
| const bx0 = b.tx * TILE, by0 = b.ty * TILE; | |
| const bx1 = bx0 + b.def.tw * TILE, by1 = by0 + b.def.th * TILE; | |
| if (x > bx0 - 2 && x < bx1 + 2 && y > by0 - 2 && y < by1 + 2) return false; | |
| } | |
| // Don't walk into forest/gold tiles | |
| if (t === TILE_FOREST || t === TILE_GOLD) return false; | |
| return true; | |
| } | |
| // Find nearest tile of type around a world position, returns {tx,ty,wx,wy} or null | |
| function findNearestTile(fromX, fromY, typeOrTypes) { | |
| const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; | |
| const cx = Math.floor(fromX / TILE), cy = Math.floor(fromY / TILE); | |
| const MAX_R = 30; | |
| for (let r = 0; r <= MAX_R; r++) { | |
| for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) { | |
| if (Math.max(Math.abs(x), Math.abs(y)) !== r) continue; | |
| const tx = cx+x, ty = cy+y; | |
| if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue; | |
| if (types.includes(tiles[ty][tx]) && tileRes[ty][tx] > 0) { | |
| return { tx, ty, wx: tx*TILE + TILE/2, wy: ty*TILE + TILE/2 }; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| // Find nearest dropoff building | |
| function findNearestDropoff(fromX, fromY) { | |
| let best = null, bd = Infinity; | |
| for (const b of buildings) { | |
| if (!b.alive || !b.complete) continue; | |
| if (!b.def.dropoff) continue; | |
| const d = Math.hypot(b.x - fromX, b.y - fromY); | |
| if (d < bd) { bd = d; best = b; } | |
| } | |
| return best; | |
| } | |
| function isAdjacentToTile(wx, wy, tx, ty) { | |
| // True if the world point lies within the 3x3 block of tiles centered on (tx,ty) | |
| const cx = Math.floor(wx / TILE), cy = Math.floor(wy / TILE); | |
| return Math.abs(cx - tx) <= 1 && Math.abs(cy - ty) <= 1; | |
| } | |
| // Approach target — get within range | |
| function inRangeOf(u, target, range) { | |
| if (target.kind === 'building') { | |
| // distance to building edge | |
| const bx0 = target.tx * TILE, by0 = target.ty * TILE; | |
| const bx1 = bx0 + target.def.tw * TILE, by1 = by0 + target.def.th * TILE; | |
| const cx = Math.max(bx0, Math.min(u.x, bx1)); | |
| const cy = Math.max(by0, Math.min(u.y, by1)); | |
| return Math.hypot(u.x - cx, u.y - cy) <= range; | |
| } else if (target.kind === 'tile') { | |
| const cx = target.tx * TILE + TILE/2; | |
| const cy = target.ty * TILE + TILE/2; | |
| return Math.hypot(u.x - cx, u.y - cy) <= range + TILE/2; | |
| } | |
| return Math.hypot(u.x - target.x, u.y - target.y) <= range; | |
| } | |
| function approachPoint(target) { | |
| if (target.kind === 'building') { | |
| // approach near edge | |
| return { x: target.x, y: target.y }; | |
| } else if (target.kind === 'tile') { | |
| return { x: target.tx*TILE + TILE/2, y: target.ty*TILE + TILE/2 }; | |
| } | |
| return { x: target.x, y: target.y }; | |
| } | |
| // -------- Unit AI -------- | |
| function updateUnit(u, dt) { | |
| if (!u.alive) return; | |
| if (u.attackCd > 0) u.attackCd -= dt; | |
| if (u.gatherCd > 0) u.gatherCd -= dt; | |
| if (u.state === 'moving') { | |
| if (!u.targetPos) { u.state = 'idle'; return; } | |
| if (moveToward(u, u.targetPos.x, u.targetPos.y, dt)) { | |
| u.state = 'idle'; | |
| u.targetPos = null; | |
| } | |
| } else if (u.state === 'gathering') { | |
| // target is a resource tile | |
| if (!u.target || tileRes[u.target.ty][u.target.tx] <= 0) { | |
| // find another | |
| const near = findNearestTile(u.x, u.y, u.gatherType === 'gold' ? [TILE_GOLD] : [TILE_FOREST]); | |
| if (near) { u.target = { kind: 'tile', tx: near.tx, ty: near.ty }; } | |
| else { u.state = 'idle'; u.target = null; return; } | |
| } | |
| // Need to be adjacent (8-connected) to the resource tile: any point whose tile | |
| // is a neighbor (orthogonal or diagonal) counts as "in range". | |
| if (!isAdjacentToTile(u.x, u.y, u.target.tx, u.target.ty)) { | |
| const aj = findAdjacentPassable(u.target.tx, u.target.ty, u.x, u.y); | |
| if (aj) moveToward(u, aj.x, aj.y, dt); | |
| else { u.state = 'idle'; return; } | |
| } else { | |
| // gather | |
| if (u.gatherCd <= 0) { | |
| const amt = Math.min(5, tileRes[u.target.ty][u.target.tx]); | |
| u.carry += amt; | |
| u.carryType = u.gatherType; | |
| tileRes[u.target.ty][u.target.tx] -= amt; | |
| u.gatherCd = 1.2; | |
| if (tileRes[u.target.ty][u.target.tx] <= 0) { | |
| // tile depleted, convert to dirt | |
| tiles[u.target.ty][u.target.tx] = TILE_DIRT; | |
| // Redraw the affected tile in the cached canvas so it visually updates | |
| tctx.clearRect(u.target.tx * TILE, u.target.ty * TILE, TILE, TILE); | |
| drawTile(tctx, u.target.tx, u.target.ty); | |
| } | |
| if (u.carry >= 10) { | |
| // return to dropoff | |
| u.state = 'returning'; | |
| } | |
| } | |
| } | |
| } else if (u.state === 'returning') { | |
| const drop = findNearestDropoff(u.x, u.y); | |
| if (!drop) { u.state = 'idle'; u.carry = 0; return; } | |
| // Adjacent to the building footprint (within 1 tile of any of its tiles) | |
| const ucx = Math.floor(u.x / TILE), ucy = Math.floor(u.y / TILE); | |
| const adjacent = ucx >= drop.tx - 1 && ucx <= drop.tx + drop.def.tw && | |
| ucy >= drop.ty - 1 && ucy <= drop.ty + drop.def.th; | |
| if (adjacent) { | |
| if (u.carryType === 'gold') game.gold += u.carry; | |
| else if (u.carryType === 'wood') game.wood += u.carry; | |
| u.carry = 0; | |
| // return to resource | |
| if (u.gatherType) { | |
| const near = findNearestTile(u.x, u.y, u.gatherType === 'gold' ? [TILE_GOLD] : [TILE_FOREST]); | |
| if (near) { | |
| u.target = { kind: 'tile', tx: near.tx, ty: near.ty }; | |
| u.state = 'gathering'; | |
| } else { | |
| u.state = 'idle'; | |
| } | |
| } else { | |
| u.state = 'idle'; | |
| } | |
| } else { | |
| // walk toward an adjacent tile of the dropoff, not its center | |
| const aj = findAdjacentPassable(drop.tx, drop.ty, u.x, u.y, drop.def.tw, drop.def.th); | |
| if (aj) moveToward(u, aj.x, aj.y, dt); | |
| else moveToward(u, drop.x, drop.y, dt); | |
| } | |
| } else if (u.state === 'building') { | |
| if (!u.buildTarget || !u.buildTarget.alive || u.buildTarget.complete) { | |
| u.state = 'idle'; u.buildTarget = null; return; | |
| } | |
| const bt = u.buildTarget; | |
| const ucx = Math.floor(u.x / TILE), ucy = Math.floor(u.y / TILE); | |
| const adjB = ucx >= bt.tx - 1 && ucx <= bt.tx + bt.def.tw && | |
| ucy >= bt.ty - 1 && ucy <= bt.ty + bt.def.th; | |
| if (!adjB) { | |
| const aj = findAdjacentPassable(bt.tx, bt.ty, u.x, u.y, bt.def.tw, bt.def.th); | |
| if (aj) moveToward(u, aj.x, aj.y, dt); | |
| else { u.state = 'idle'; u.buildTarget = null; return; } | |
| } else { | |
| // construct | |
| u.buildTarget.buildProgress += dt * 1.2; | |
| u.buildTarget.hp = Math.min(u.buildTarget.maxHp, | |
| (u.buildTarget.buildProgress / u.buildTarget.def.buildTime) * u.buildTarget.maxHp); | |
| if (u.buildTarget.buildProgress >= u.buildTarget.def.buildTime) { | |
| u.buildTarget.complete = true; | |
| u.buildTarget.hp = u.buildTarget.maxHp; | |
| if (u.buildTarget.def.provides) game.foodCap += u.buildTarget.def.provides; | |
| showMsg(`${u.buildTarget.def.name} constructed`); | |
| u.state = 'idle'; | |
| u.buildTarget = null; | |
| } | |
| } | |
| } else if (u.state === 'attacking') { | |
| if (!u.target || !u.target.alive) { u.state = 'idle'; u.target = null; return; } | |
| if (!inRangeOf(u, u.target, u.def.range)) { | |
| moveToward(u, u.target.x, u.target.y, dt); | |
| } else { | |
| if (u.attackCd <= 0) { | |
| dealAttack(u, u.target); | |
| u.attackCd = u.def.attackCd; | |
| } | |
| } | |
| } | |
| } | |
| function findAdjacentPassable(tx, ty, fromX, fromY, tw = 1, th = 1) { | |
| // return a world-space point adjacent to the tile/building area | |
| const candidates = []; | |
| // Pre-compute which tiles are occupied by buildings (for quick lookup) | |
| const isBuildingTile = (cx, cy) => { | |
| for (const b of buildings) { | |
| if (!b.alive) continue; | |
| // Skip the target area itself (if asking for adjacency to a building, don't filter itself) | |
| if (cx >= b.tx && cx < b.tx + b.def.tw && | |
| cy >= b.ty && cy < b.ty + b.def.th && | |
| b.tx === tx && b.ty === ty) continue; | |
| if (cx >= b.tx && cx < b.tx + b.def.tw && | |
| cy >= b.ty && cy < b.ty + b.def.th) return true; | |
| } | |
| return false; | |
| }; | |
| for (let y = -1; y <= th; y++) { | |
| for (let x = -1; x <= tw; x++) { | |
| if (x >= 0 && x < tw && y >= 0 && y < th) continue; | |
| const cx = tx + x, cy = ty + y; | |
| if (cx < 0 || cy < 0 || cx >= MAP_W || cy >= MAP_H) continue; | |
| const t = tiles[cy][cx]; | |
| if (t === TILE_WATER || t === TILE_GOLD || t === TILE_FOREST) continue; | |
| if (isBuildingTile(cx, cy)) continue; | |
| const wx = cx * TILE + TILE/2, wy = cy * TILE + TILE/2; | |
| const d = Math.hypot(wx - fromX, wy - fromY); | |
| candidates.push({ x: wx, y: wy, d }); | |
| } | |
| } | |
| candidates.sort((a,b) => a.d - b.d); | |
| return candidates[0] || null; | |
| } | |
| function dealAttack(attacker, target) { | |
| const def = attacker.def; | |
| if (def.range > 50) { | |
| // ranged projectile | |
| projectiles.push({ | |
| x: attacker.x, y: attacker.y, | |
| tx: target.x, ty: target.y, | |
| target, damage: def.damage, | |
| speed: 300, | |
| alive: true, | |
| color: '#ffe080' | |
| }); | |
| } else { | |
| // melee instant | |
| target.hp -= def.damage; | |
| effects.push({ x: target.x, y: target.y, type: 'hit', life: 0.3, maxLife: 0.3 }); | |
| if (target.hp <= 0) killEntity(target); | |
| } | |
| } | |
| function killEntity(e) { | |
| e.alive = false; | |
| if (e.kind === 'unit') { | |
| game.food = Math.max(0, game.food - e.def.costFood); | |
| } else if (e.kind === 'building') { | |
| if (e.complete && e.def.provides) game.foodCap -= e.def.provides; | |
| } | |
| // remove from selected | |
| game.selected = game.selected.filter(s => s.alive); | |
| } | |
| // -------- Building logic -------- | |
| function updateBuilding(b, dt) { | |
| if (!b.alive) return; | |
| if (!b.complete) return; | |
| // Tower attacks | |
| if (b.def.damage) { | |
| if (b.attackCd > 0) b.attackCd -= dt; | |
| // no enemies in this game, skip | |
| } | |
| // Train queue | |
| if (b.buildQueue.length > 0) { | |
| const ut = b.buildQueue[0]; | |
| const udef = UNIT_DEFS[ut]; | |
| b.trainProgress += dt; | |
| if (b.trainProgress >= udef.buildTime) { | |
| // spawn | |
| if (game.food + udef.costFood > game.foodCap) { | |
| // wait | |
| showMsg('Not enough food. Build farms.'); | |
| b.trainProgress = udef.buildTime; | |
| } else { | |
| b.trainProgress = 0; | |
| b.buildQueue.shift(); | |
| const spawnPos = findSpawnPoint(b); | |
| const u = createUnit(ut, spawnPos.x, spawnPos.y); | |
| game.food += udef.costFood; | |
| if (b.rallyPoint) { | |
| u.targetPos = { x: b.rallyPoint.x, y: b.rallyPoint.y }; | |
| u.state = 'moving'; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function findSpawnPoint(b) { | |
| // find a passable tile adjacent to the building | |
| const candidates = []; | |
| for (let y = -1; y <= b.def.th; y++) { | |
| for (let x = -1; x <= b.def.tw; x++) { | |
| if (x >= 0 && x < b.def.tw && y >= 0 && y < b.def.th) continue; | |
| const tx = b.tx + x, ty = b.ty + y; | |
| if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue; | |
| const t = tiles[ty][tx]; | |
| if (t === TILE_WATER || t === TILE_GOLD || t === TILE_FOREST) continue; | |
| candidates.push({ x: tx * TILE + TILE/2, y: ty * TILE + TILE/2 }); | |
| } | |
| } | |
| return candidates[Math.floor(Math.random() * candidates.length)] || { x: b.x, y: b.y + b.def.th * TILE }; | |
| } | |
| // -------- Projectiles -------- | |
| function updateProjectiles(dt) { | |
| for (const p of projectiles) { | |
| if (!p.alive) continue; | |
| if (p.target && p.target.alive) { p.tx = p.target.x; p.ty = p.target.y; } | |
| const dx = p.tx - p.x, dy = p.ty - p.y; | |
| const d = Math.hypot(dx, dy); | |
| const step = p.speed * dt; | |
| if (step >= d) { | |
| // hit | |
| if (p.target && p.target.alive) { | |
| p.target.hp -= p.damage; | |
| effects.push({ x: p.target.x, y: p.target.y, type: 'hit', life: 0.3, maxLife: 0.3 }); | |
| if (p.target.hp <= 0) killEntity(p.target); | |
| } | |
| p.alive = false; | |
| } else { | |
| p.x += (dx / d) * step; | |
| p.y += (dy / d) * step; | |
| } | |
| } | |
| for (let i = projectiles.length - 1; i >= 0; i--) if (!projectiles[i].alive) projectiles.splice(i, 1); | |
| } | |
| function updateEffects(dt) { | |
| for (const e of effects) e.life -= dt; | |
| for (let i = effects.length - 1; i >= 0; i--) if (effects[i].life <= 0) effects.splice(i, 1); | |
| } | |
| // -------- Rendering -------- | |
| const canvas = document.getElementById('main'); | |
| const ctx = canvas.getContext('2d'); | |
| const minimap = document.getElementById('minimap'); | |
| const mctx = minimap.getContext('2d'); | |
| function resizeCanvas() { | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = Math.floor(rect.width); | |
| canvas.height = Math.floor(rect.height); | |
| game.viewW = canvas.width; | |
| game.viewH = canvas.height; | |
| clampCamera(); | |
| const mr = minimap.getBoundingClientRect(); | |
| minimap.width = Math.floor(mr.width); | |
| minimap.height = Math.floor(mr.height); | |
| } | |
| // Pre-render tile textures to offscreen for perf | |
| const tileCanvas = document.createElement('canvas'); | |
| tileCanvas.width = WORLD_W; | |
| tileCanvas.height = WORLD_H; | |
| const tctx = tileCanvas.getContext('2d'); | |
| function renderTilesToCache() { | |
| tctx.clearRect(0, 0, WORLD_W, WORLD_H); | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| drawTile(tctx, x, y); | |
| } | |
| } | |
| } | |
| function drawTile(c, x, y) { | |
| const t = tiles[y][x]; | |
| const px = x * TILE, py = y * TILE; | |
| // Base | |
| if (t === TILE_WATER) { | |
| // water | |
| const g = c.createLinearGradient(px, py, px, py + TILE); | |
| g.addColorStop(0, '#1e4a6e'); | |
| g.addColorStop(1, '#0d2c44'); | |
| c.fillStyle = g; | |
| c.fillRect(px, py, TILE, TILE); | |
| // wave | |
| c.strokeStyle = 'rgba(255,255,255,0.08)'; | |
| c.beginPath(); | |
| c.moveTo(px + 4, py + TILE/2); | |
| c.quadraticCurveTo(px + TILE/2, py + TILE/2 - 2, px + TILE - 4, py + TILE/2); | |
| c.stroke(); | |
| } else if (t === TILE_DIRT) { | |
| // dirt | |
| const h = ((x * 31 + y * 17) % 20) / 100; | |
| c.fillStyle = `hsl(30, 30%, ${30 + h*100}%)`; | |
| c.fillRect(px, py, TILE, TILE); | |
| // speckle | |
| c.fillStyle = 'rgba(0,0,0,0.15)'; | |
| const seed = (x*53+y*97)%9; | |
| for (let i = 0; i < 4; i++) { | |
| c.fillRect(px + ((seed+i)*7)%TILE, py + ((seed+i*3)*11)%TILE, 2, 2); | |
| } | |
| } else if (t === TILE_FOREST) { | |
| // grass base | |
| c.fillStyle = '#2d4a26'; | |
| c.fillRect(px, py, TILE, TILE); | |
| // tree | |
| const cx = px + TILE/2, cy = py + TILE/2; | |
| // trunk | |
| c.fillStyle = '#4a2c1a'; | |
| c.fillRect(cx - 2, cy + 2, 4, 10); | |
| // foliage | |
| c.fillStyle = '#1d5020'; | |
| c.beginPath(); | |
| c.arc(cx, cy + 2, TILE/2 - 3, 0, Math.PI * 2); | |
| c.fill(); | |
| c.fillStyle = '#2a6a30'; | |
| c.beginPath(); | |
| c.arc(cx - 3, cy - 1, TILE/3, 0, Math.PI * 2); | |
| c.fill(); | |
| c.fillStyle = '#378040'; | |
| c.beginPath(); | |
| c.arc(cx + 3, cy - 3, TILE/4, 0, Math.PI * 2); | |
| c.fill(); | |
| // highlight | |
| c.fillStyle = 'rgba(255,255,200,0.15)'; | |
| c.beginPath(); | |
| c.arc(cx + 2, cy - 4, 3, 0, Math.PI * 2); | |
| c.fill(); | |
| } else if (t === TILE_GOLD) { | |
| // rocky base | |
| c.fillStyle = '#555'; | |
| c.fillRect(px, py, TILE, TILE); | |
| c.fillStyle = '#444'; | |
| c.fillRect(px, py, TILE, TILE/2); | |
| // gold nugget cluster | |
| const cx = px + TILE/2, cy = py + TILE/2; | |
| const grd = c.createRadialGradient(cx, cy, 2, cx, cy, TILE/2); | |
| grd.addColorStop(0, '#ffe680'); | |
| grd.addColorStop(0.6, '#d4a94a'); | |
| grd.addColorStop(1, '#6a4a10'); | |
| c.fillStyle = grd; | |
| c.beginPath(); | |
| c.arc(cx, cy, TILE/2 - 4, 0, Math.PI*2); | |
| c.fill(); | |
| // sparkle | |
| c.fillStyle = '#fff6c0'; | |
| c.fillRect(cx - 4, cy - 4, 2, 2); | |
| c.fillRect(cx + 3, cy + 2, 2, 2); | |
| } else { | |
| // grass | |
| const v = ((x * 41 + y * 23) % 30) - 15; | |
| c.fillStyle = `hsl(105, 35%, ${26 + v * 0.3}%)`; | |
| c.fillRect(px, py, TILE, TILE); | |
| // tufts | |
| const seed = (x*73+y*29)%5; | |
| if (seed < 2) { | |
| c.fillStyle = 'rgba(200,230,150,0.25)'; | |
| c.fillRect(px + (seed*11)%TILE, py + (seed*17+5)%TILE, 2, 2); | |
| c.fillRect(px + (seed*19+8)%TILE, py + (seed*7+11)%TILE, 2, 1); | |
| } | |
| } | |
| // grid line (subtle) | |
| c.strokeStyle = 'rgba(0,0,0,0.06)'; | |
| c.lineWidth = 1; | |
| c.strokeRect(px + 0.5, py + 0.5, TILE - 1, TILE - 1); | |
| } | |
| function render() { | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Draw cached tile map with camera offset | |
| ctx.drawImage( | |
| tileCanvas, | |
| game.camX, game.camY, game.viewW, game.viewH, | |
| 0, 0, game.viewW, game.viewH | |
| ); | |
| // Buildings (back to front by y) | |
| const drawList = []; | |
| for (const b of buildings) if (b.alive) drawList.push(b); | |
| for (const u of units) if (u.alive) drawList.push(u); | |
| drawList.sort((a, b) => (a.y + (a.kind==='building' ? a.def.th*TILE/2 : 0)) - (b.y + (b.kind==='building' ? b.def.th*TILE/2 : 0))); | |
| for (const e of drawList) { | |
| if (e.kind === 'building') drawBuilding(e); | |
| else drawUnit(e); | |
| } | |
| // Projectiles | |
| for (const p of projectiles) { | |
| if (!p.alive) continue; | |
| const sx = p.x - game.camX, sy = p.y - game.camY; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy, 2, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.strokeStyle = 'rgba(255,220,120,0.6)'; | |
| const dx = p.tx - p.x, dy = p.ty - p.y; | |
| const d = Math.hypot(dx,dy) || 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx, sy); | |
| ctx.lineTo(sx - (dx/d)*8, sy - (dy/d)*8); | |
| ctx.stroke(); | |
| } | |
| // Effects | |
| for (const fx of effects) { | |
| const sx = fx.x - game.camX, sy = fx.y - game.camY; | |
| const a = fx.life / fx.maxLife; | |
| if (fx.type === 'hit') { | |
| ctx.strokeStyle = `rgba(255,60,60,${a})`; | |
| ctx.lineWidth = 2; | |
| const r = (1 - a) * 14 + 4; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy, r, 0, Math.PI*2); | |
| ctx.stroke(); | |
| } else if (fx.type === 'sparkle') { | |
| ctx.fillStyle = `rgba(255,230,120,${a})`; | |
| ctx.fillRect(sx-1, sy-1, 2, 2); | |
| } | |
| } | |
| // Placing preview | |
| if (game.placingBuilding) { | |
| drawBuildingPreview(); | |
| } | |
| // Selection rings | |
| for (const s of game.selected) { | |
| if (!s.alive) continue; | |
| if (s.kind === 'unit') { | |
| const sx = s.x - game.camX, sy = s.y - game.camY; | |
| ctx.strokeStyle = '#6bc17a'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy + s.radius * 0.3, s.radius + 1, 0.1, Math.PI - 0.1); | |
| ctx.stroke(); | |
| } else { | |
| const bx = s.tx * TILE - game.camX, by = s.ty * TILE - game.camY; | |
| ctx.strokeStyle = '#6bc17a'; | |
| ctx.lineWidth = 1.5; | |
| ctx.setLineDash([4, 3]); | |
| ctx.strokeRect(bx, by, s.def.tw * TILE, s.def.th * TILE); | |
| ctx.setLineDash([]); | |
| } | |
| } | |
| // HP bars for selected and for damaged | |
| for (const e of drawList) { | |
| const dmg = e.hp < e.maxHp * 0.99; | |
| const sel = game.selected.includes(e); | |
| if (!dmg && !sel) continue; | |
| drawHpBar(e); | |
| } | |
| // Fog of war | |
| drawFog(); | |
| renderMinimap(); | |
| } | |
| function drawUnit(u) { | |
| const sx = u.x - game.camX, sy = u.y - game.camY; | |
| const tx = Math.floor(u.x / TILE), ty = Math.floor(u.y / TILE); | |
| // Only draw if in visible area of fog (still render but darker if not visible) | |
| const visible = fog[ty] && fog[ty][tx] === 2; | |
| if (!visible) return; | |
| // Shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.35)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx, sy + u.radius * 0.6, u.radius * 0.9, u.radius * 0.35, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Body: a simple "person" | |
| const r = u.radius; | |
| const bob = Math.sin(game.time * 6 + u.id) * (u.state === 'moving' ? 1.2 : 0); | |
| // Legs | |
| ctx.fillStyle = '#2a1f14'; | |
| ctx.fillRect(sx - 3, sy + 1, 2, 5); | |
| ctx.fillRect(sx + 1, sy + 1, 2, 5); | |
| // Body | |
| ctx.fillStyle = u.def.color; | |
| ctx.fillRect(sx - 5, sy - 5 + bob, 10, 8); | |
| // Body shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.25)'; | |
| ctx.fillRect(sx + 2, sy - 5 + bob, 3, 8); | |
| // Highlight | |
| ctx.fillStyle = 'rgba(255,255,255,0.18)'; | |
| ctx.fillRect(sx - 5, sy - 5 + bob, 2, 8); | |
| // Head | |
| ctx.fillStyle = '#e3b68a'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 8 + bob, 3.5, 0, Math.PI*2); | |
| ctx.fill(); | |
| // hair | |
| ctx.fillStyle = '#3a2416'; | |
| ctx.fillRect(sx - 3, sy - 11 + bob, 6, 2); | |
| // Weapon / tool | |
| if (u.type === 'worker') { | |
| // pickaxe on back | |
| ctx.strokeStyle = '#6a3a20'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + 3, sy - 3 + bob); | |
| ctx.lineTo(sx + 7, sy + 2 + bob); | |
| ctx.stroke(); | |
| ctx.fillStyle = '#888'; | |
| ctx.fillRect(sx + 6, sy - 1 + bob, 3, 2); | |
| } else if (u.type === 'soldier') { | |
| // sword | |
| ctx.fillStyle = '#cfd4dc'; | |
| ctx.fillRect(sx + 5, sy - 10 + bob, 1, 8); | |
| ctx.fillStyle = '#8a6b20'; | |
| ctx.fillRect(sx + 4, sy - 2 + bob, 3, 1); | |
| // shield | |
| ctx.fillStyle = '#6b4020'; | |
| ctx.fillRect(sx - 8, sy - 4 + bob, 3, 5); | |
| ctx.fillStyle = '#d4a94a'; | |
| ctx.fillRect(sx - 7, sy - 3 + bob, 1, 3); | |
| } else if (u.type === 'archer') { | |
| // bow | |
| ctx.strokeStyle = '#6a3a1a'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.arc(sx + 6, sy - 2 + bob, 4, -Math.PI*0.6, Math.PI*0.6); | |
| ctx.stroke(); | |
| ctx.strokeStyle = '#ddd'; | |
| ctx.lineWidth = 0.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + 6, sy - 6 + bob); | |
| ctx.lineTo(sx + 6, sy + 2 + bob); | |
| ctx.stroke(); | |
| } else if (u.type === 'knight') { | |
| // helmet | |
| ctx.fillStyle = '#888'; | |
| ctx.fillRect(sx - 3, sy - 11 + bob, 6, 4); | |
| ctx.fillStyle = '#c0392b'; | |
| ctx.fillRect(sx - 1, sy - 13 + bob, 2, 3); | |
| // lance | |
| ctx.strokeStyle = '#a0a0a0'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + 5, sy - 8 + bob); | |
| ctx.lineTo(sx + 10, sy + 2 + bob); | |
| ctx.stroke(); | |
| } | |
| // Carrying indicator | |
| if (u.carry > 0) { | |
| ctx.fillStyle = u.carryType === 'gold' ? '#e8c34a' : '#8b5a2b'; | |
| ctx.fillRect(sx - 2, sy - 14 + bob, 4, 3); | |
| ctx.strokeStyle = '#000'; | |
| ctx.lineWidth = 0.5; | |
| ctx.strokeRect(sx - 2, sy - 14 + bob, 4, 3); | |
| } | |
| } | |
| function drawBuilding(b) { | |
| const bx = b.tx * TILE - game.camX; | |
| const by = b.ty * TILE - game.camY; | |
| const w = b.def.tw * TILE, h = b.def.th * TILE; | |
| const tx = Math.floor(b.x / TILE), ty = Math.floor(b.y / TILE); | |
| const visible = fog[ty] && fog[ty][tx] === 2; | |
| // If not visible but explored, show ghost | |
| if (!visible && !fog[ty]?.[tx]) return; | |
| // Ground shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.4)'; | |
| ctx.fillRect(bx + 2, by + h - 4, w, 6); | |
| if (!b.complete) { | |
| // Scaffold look | |
| ctx.fillStyle = '#3a2818'; | |
| ctx.fillRect(bx + 4, by + 4, w - 8, h - 8); | |
| ctx.strokeStyle = '#8b6a3a'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i < 4; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(bx + 4 + (w-8) * i / 3, by + 4); | |
| ctx.lineTo(bx + 4 + (w-8) * i / 3, by + h - 4); | |
| ctx.stroke(); | |
| } | |
| // cross beams | |
| ctx.beginPath(); | |
| ctx.moveTo(bx + 4, by + 4); | |
| ctx.lineTo(bx + w - 4, by + h - 4); | |
| ctx.moveTo(bx + w - 4, by + 4); | |
| ctx.lineTo(bx + 4, by + h - 4); | |
| ctx.stroke(); | |
| // progress bar | |
| const prog = b.buildProgress / b.def.buildTime; | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(bx + 2, by - 6, w - 4, 4); | |
| ctx.fillStyle = '#e8c34a'; | |
| ctx.fillRect(bx + 3, by - 5, (w - 6) * prog, 2); | |
| return; | |
| } | |
| // Complete buildings | |
| if (b.type === 'townhall') drawTownHall(bx, by, w, h); | |
| else if (b.type === 'farm') drawFarm(bx, by, w, h); | |
| else if (b.type === 'barracks') drawBarracks(bx, by, w, h); | |
| else if (b.type === 'stable') drawStable(bx, by, w, h); | |
| else if (b.type === 'tower') drawTower(bx, by, w, h); | |
| } | |
| function drawTownHall(x, y, w, h) { | |
| // base stone | |
| ctx.fillStyle = '#8a8680'; | |
| ctx.fillRect(x + 4, y + h * 0.35, w - 8, h * 0.55); | |
| // shadow side | |
| ctx.fillStyle = '#5a564f'; | |
| ctx.fillRect(x + w - 10, y + h*0.35, 6, h*0.55); | |
| // stone blocks | |
| ctx.strokeStyle = 'rgba(0,0,0,0.3)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < 4; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 4, y + h*0.35 + (h*0.55)*i/4); | |
| ctx.lineTo(x + w - 4, y + h*0.35 + (h*0.55)*i/4); | |
| ctx.stroke(); | |
| } | |
| // door | |
| ctx.fillStyle = '#2a1a0a'; | |
| ctx.fillRect(x + w/2 - 5, y + h - 14, 10, 14); | |
| ctx.fillStyle = '#6b3a1a'; | |
| ctx.fillRect(x + w/2 - 4, y + h - 13, 8, 12); | |
| // roof (tapered trapezoid) | |
| ctx.fillStyle = '#b04040'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 2, y + h * 0.35); | |
| ctx.lineTo(x + w - 2, y + h * 0.35); | |
| ctx.lineTo(x + w - 12, y + 4); | |
| ctx.lineTo(x + 12, y + 4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.fillStyle = '#7a2020'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w/2, y + 4); | |
| ctx.lineTo(x + w - 2, y + h * 0.35); | |
| ctx.lineTo(x + w - 12, y + 4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // flag | |
| ctx.strokeStyle = '#222'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w/2, y + 4); | |
| ctx.lineTo(x + w/2, y - 10); | |
| ctx.stroke(); | |
| ctx.fillStyle = '#d4a94a'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w/2, y - 10); | |
| ctx.lineTo(x + w/2 + 8, y - 7); | |
| ctx.lineTo(x + w/2, y - 4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // windows | |
| ctx.fillStyle = '#2a1a0a'; | |
| ctx.fillRect(x + 12, y + h*0.5, 5, 6); | |
| ctx.fillRect(x + w - 17, y + h*0.5, 5, 6); | |
| ctx.fillStyle = '#ffe080'; | |
| ctx.fillRect(x + 13, y + h*0.51, 3, 4); | |
| ctx.fillRect(x + w - 16, y + h*0.51, 3, 4); | |
| } | |
| function drawFarm(x, y, w, h) { | |
| // field | |
| ctx.fillStyle = '#8a6b20'; | |
| ctx.fillRect(x + 2, y + 4, w - 4, h - 4); | |
| // rows | |
| ctx.fillStyle = '#e8c34a'; | |
| for (let i = 0; i < 4; i++) { | |
| ctx.fillRect(x + 4, y + 7 + i * ((h-10)/4), w - 8, 2); | |
| } | |
| // fence | |
| ctx.strokeStyle = '#5a3a1a'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x + 2, y + 4, w - 4, h - 4); | |
| // posts | |
| ctx.fillStyle = '#3a2410'; | |
| ctx.fillRect(x + 2, y + 4, 2, 4); | |
| ctx.fillRect(x + w - 4, y + 4, 2, 4); | |
| ctx.fillRect(x + 2, y + h - 4, 2, 4); | |
| ctx.fillRect(x + w - 4, y + h - 4, 2, 4); | |
| } | |
| function drawBarracks(x, y, w, h) { | |
| // base stone/wood | |
| ctx.fillStyle = '#6b4020'; | |
| ctx.fillRect(x + 3, y + h * 0.4, w - 6, h * 0.55); | |
| // planks | |
| ctx.strokeStyle = '#3a2410'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < 5; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 3, y + h * 0.4 + (h*0.55) * i / 5); | |
| ctx.lineTo(x + w - 3, y + h * 0.4 + (h*0.55) * i / 5); | |
| ctx.stroke(); | |
| } | |
| // stone corners | |
| ctx.fillStyle = '#8a8680'; | |
| ctx.fillRect(x + 3, y + h * 0.4, 5, h * 0.55); | |
| ctx.fillRect(x + w - 8, y + h * 0.4, 5, h * 0.55); | |
| // roof | |
| ctx.fillStyle = '#4a2c1a'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 1, y + h * 0.4); | |
| ctx.lineTo(x + w - 1, y + h * 0.4); | |
| ctx.lineTo(x + w - 6, y + 6); | |
| ctx.lineTo(x + 6, y + 6); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // door | |
| ctx.fillStyle = '#1a0a04'; | |
| ctx.fillRect(x + w/2 - 4, y + h - 12, 8, 12); | |
| // crossed swords emblem | |
| ctx.strokeStyle = '#cfd4dc'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w/2 - 5, y + h*0.5); | |
| ctx.lineTo(x + w/2 + 5, y + h*0.5 + 8); | |
| ctx.moveTo(x + w/2 + 5, y + h*0.5); | |
| ctx.lineTo(x + w/2 - 5, y + h*0.5 + 8); | |
| ctx.stroke(); | |
| } | |
| function drawStable(x, y, w, h) { | |
| // walls | |
| ctx.fillStyle = '#5a3a1a'; | |
| ctx.fillRect(x + 3, y + h * 0.4, w - 6, h * 0.55); | |
| // planks vertical | |
| ctx.strokeStyle = '#2a1a0a'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < 6; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 3 + (w-6) * i / 6, y + h * 0.4); | |
| ctx.lineTo(x + 3 + (w-6) * i / 6, y + h - 3); | |
| ctx.stroke(); | |
| } | |
| // roof (curved hay-style) | |
| ctx.fillStyle = '#b08a40'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 1, y + h * 0.4); | |
| ctx.quadraticCurveTo(x + w/2, y - 4, x + w - 1, y + h * 0.4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // dark edge | |
| ctx.fillStyle = 'rgba(0,0,0,0.3)'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 1, y + h * 0.4); | |
| ctx.quadraticCurveTo(x + w/2, y - 4, x + w - 1, y + h * 0.4); | |
| ctx.lineTo(x + w - 1, y + h * 0.4 + 3); | |
| ctx.quadraticCurveTo(x + w/2, y - 1, x + 1, y + h * 0.4 + 3); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // door (arch) | |
| ctx.fillStyle = '#1a0a04'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w/2 - 6, y + h - 2); | |
| ctx.lineTo(x + w/2 - 6, y + h - 10); | |
| ctx.quadraticCurveTo(x + w/2, y + h - 16, x + w/2 + 6, y + h - 10); | |
| ctx.lineTo(x + w/2 + 6, y + h - 2); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // horseshoe emblem | |
| ctx.strokeStyle = '#cfd4dc'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(x + w/2, y + h * 0.45, 4, Math.PI*0.1, Math.PI*0.9); | |
| ctx.stroke(); | |
| } | |
| function drawTower(x, y, w, h) { | |
| // base | |
| ctx.fillStyle = '#6a6a6a'; | |
| ctx.fillRect(x + 4, y + h * 0.3, w - 8, h * 0.6); | |
| ctx.fillStyle = '#4a4a4a'; | |
| ctx.fillRect(x + w - 10, y + h * 0.3, 6, h * 0.6); | |
| // stone block lines | |
| ctx.strokeStyle = 'rgba(0,0,0,0.3)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < 5; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 4, y + h*0.3 + (h*0.6) * i / 5); | |
| ctx.lineTo(x + w - 4, y + h*0.3 + (h*0.6) * i / 5); | |
| ctx.stroke(); | |
| } | |
| // battlements | |
| ctx.fillStyle = '#6a6a6a'; | |
| ctx.fillRect(x + 3, y + h*0.2, w - 6, h*0.14); | |
| ctx.fillStyle = '#0a0d10'; | |
| for (let i = 0; i < 4; i++) { | |
| ctx.fillRect(x + 4 + i * ((w-8)/4) + 2, y + h*0.2, 3, 4); | |
| } | |
| // conical roof | |
| ctx.fillStyle = '#2a4060'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + 2, y + h*0.2); | |
| ctx.lineTo(x + w - 2, y + h*0.2); | |
| ctx.lineTo(x + w/2, y - 4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // window slit | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(x + w/2 - 1, y + h*0.5, 2, 5); | |
| } | |
| function drawBuildingPreview() { | |
| const def = BUILDING_DEFS[game.placingBuilding]; | |
| const mx = game.mouse.x, my = game.mouse.y; | |
| const tx = Math.floor(mx / TILE), ty = Math.floor(my / TILE); | |
| const canPlace = canPlaceBuilding(tx, ty, def); | |
| const sx = tx * TILE - game.camX, sy = ty * TILE - game.camY; | |
| const w = def.tw * TILE, h = def.th * TILE; | |
| ctx.fillStyle = canPlace ? 'rgba(107,193,122,0.25)' : 'rgba(196,68,68,0.35)'; | |
| ctx.fillRect(sx, sy, w, h); | |
| ctx.strokeStyle = canPlace ? '#6bc17a' : '#c44'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(sx, sy, w, h); | |
| // footprint grid | |
| ctx.strokeStyle = canPlace ? 'rgba(107,193,122,0.5)' : 'rgba(196,68,68,0.5)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < def.tw; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + i * TILE, sy); | |
| ctx.lineTo(sx + i * TILE, sy + h); | |
| ctx.stroke(); | |
| } | |
| for (let i = 1; i < def.th; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(sx, sy + i * TILE); | |
| ctx.lineTo(sx + w, sy + i * TILE); | |
| ctx.stroke(); | |
| } | |
| } | |
| function canPlaceBuilding(tx, ty, def) { | |
| if (tx < 0 || ty < 0 || tx + def.tw > MAP_W || ty + def.th > MAP_H) return false; | |
| // Require explored tiles | |
| for (let y = 0; y < def.th; y++) { | |
| for (let x = 0; x < def.tw; x++) { | |
| if (fog[ty+y][tx+x] === 0) return false; | |
| const t = tiles[ty+y][tx+x]; | |
| if (t === TILE_WATER || t === TILE_FOREST || t === TILE_GOLD) return false; | |
| // No overlap with buildings | |
| for (const b of buildings) { | |
| if (!b.alive) continue; | |
| if (tx+x >= b.tx && tx+x < b.tx + b.def.tw && | |
| ty+y >= b.ty && ty+y < b.ty + b.def.th) return false; | |
| } | |
| // No overlap with units | |
| for (const u of units) { | |
| if (!u.alive) continue; | |
| const ux = Math.floor(u.x / TILE), uy = Math.floor(u.y / TILE); | |
| if (ux === tx+x && uy === ty+y) return false; | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| function drawHpBar(e) { | |
| let cx, cy, w; | |
| if (e.kind === 'unit') { | |
| cx = e.x - game.camX; cy = e.y - game.camY - 14; | |
| w = 18; | |
| } else { | |
| cx = e.tx * TILE + (e.def.tw * TILE) / 2 - game.camX; | |
| cy = e.ty * TILE - game.camY - 6; | |
| w = e.def.tw * TILE - 6; | |
| } | |
| const hpr = e.hp / e.maxHp; | |
| ctx.fillStyle = 'rgba(0,0,0,0.8)'; | |
| ctx.fillRect(cx - w/2, cy, w, 3); | |
| ctx.fillStyle = hpr > 0.5 ? '#6bc17a' : hpr > 0.25 ? '#e8c34a' : '#c44'; | |
| ctx.fillRect(cx - w/2, cy, w * hpr, 3); | |
| } | |
| function drawFog() { | |
| // Build a fog image | |
| const fogW = Math.ceil(game.viewW / 4); | |
| const fogH = Math.ceil(game.viewH / 4); | |
| const imgData = ctx.createImageData(fogW, fogH); | |
| for (let py = 0; py < fogH; py++) { | |
| for (let px = 0; px < fogW; px++) { | |
| const wx = game.camX + px * 4; | |
| const wy = game.camY + py * 4; | |
| const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); | |
| let a = 255; | |
| if (tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H) { | |
| const f = fog[ty][tx]; | |
| if (f === 2) a = 0; | |
| else if (f === 1) a = 120; | |
| else a = 255; | |
| } | |
| const idx = (py * fogW + px) * 4; | |
| imgData.data[idx] = 0; | |
| imgData.data[idx+1] = 0; | |
| imgData.data[idx+2] = 0; | |
| imgData.data[idx+3] = a; | |
| } | |
| } | |
| // Draw via temp canvas scaled up | |
| const tmp = document.createElement('canvas'); | |
| tmp.width = fogW; tmp.height = fogH; | |
| tmp.getContext('2d').putImageData(imgData, 0, 0); | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.drawImage(tmp, 0, 0, fogW, fogH, 0, 0, game.viewW, game.viewH); | |
| ctx.imageSmoothingEnabled = false; | |
| } | |
| function renderMinimap() { | |
| mctx.fillStyle = '#050708'; | |
| mctx.fillRect(0, 0, minimap.width, minimap.height); | |
| const scale = Math.min(minimap.width / MAP_W, minimap.height / MAP_H); | |
| const offX = (minimap.width - MAP_W * scale) / 2; | |
| const offY = (minimap.height - MAP_H * scale) / 2; | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| const f = fog[y][x]; | |
| if (f === 0) continue; | |
| const t = tiles[y][x]; | |
| let col; | |
| if (t === TILE_WATER) col = '#1e4a6e'; | |
| else if (t === TILE_FOREST) col = '#2a6a30'; | |
| else if (t === TILE_GOLD) col = '#d4a94a'; | |
| else if (t === TILE_DIRT) col = '#6a5030'; | |
| else col = '#3a6030'; | |
| if (f === 1) { | |
| // darker | |
| mctx.fillStyle = col; | |
| mctx.globalAlpha = 0.5; | |
| } else { | |
| mctx.fillStyle = col; | |
| mctx.globalAlpha = 1; | |
| } | |
| mctx.fillRect(offX + x * scale, offY + y * scale, Math.ceil(scale), Math.ceil(scale)); | |
| } | |
| } | |
| mctx.globalAlpha = 1; | |
| // Buildings | |
| for (const b of buildings) { | |
| if (!b.alive) continue; | |
| const tx = Math.floor(b.x / TILE), ty = Math.floor(b.y / TILE); | |
| if (!fog[ty] || fog[ty][tx] === 0) continue; | |
| mctx.fillStyle = '#6bc17a'; | |
| mctx.fillRect(offX + b.tx * scale, offY + b.ty * scale, Math.max(2, b.def.tw * scale), Math.max(2, b.def.th * scale)); | |
| } | |
| // Units | |
| for (const u of units) { | |
| if (!u.alive) continue; | |
| const tx = Math.floor(u.x / TILE), ty = Math.floor(u.y / TILE); | |
| if (!fog[ty] || fog[ty][tx] === 0) continue; | |
| mctx.fillStyle = '#6bc17a'; | |
| mctx.fillRect(offX + tx * scale, offY + ty * scale, Math.max(1, scale * 0.8), Math.max(1, scale * 0.8)); | |
| } | |
| // Camera box | |
| mctx.strokeStyle = '#fff'; | |
| mctx.lineWidth = 1; | |
| mctx.strokeRect( | |
| offX + (game.camX / TILE) * scale, | |
| offY + (game.camY / TILE) * scale, | |
| (game.viewW / TILE) * scale, | |
| (game.viewH / TILE) * scale | |
| ); | |
| } | |
| // -------- Input handling -------- | |
| canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| canvas.addEventListener('mousemove', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| game.mouse.screenX = e.clientX - rect.left; | |
| game.mouse.screenY = e.clientY - rect.top; | |
| game.mouse.x = game.mouse.screenX + game.camX; | |
| game.mouse.y = game.mouse.screenY + game.camY; | |
| if (game.mouse.dragStart) { | |
| const box = document.getElementById('selBox'); | |
| const x1 = Math.min(game.mouse.dragStart.sx, game.mouse.screenX); | |
| const y1 = Math.min(game.mouse.dragStart.sy, game.mouse.screenY); | |
| const x2 = Math.max(game.mouse.dragStart.sx, game.mouse.screenX); | |
| const y2 = Math.max(game.mouse.dragStart.sy, game.mouse.screenY); | |
| if (x2 - x1 > 4 || y2 - y1 > 4) { | |
| box.style.display = 'block'; | |
| box.style.left = x1 + 'px'; | |
| box.style.top = y1 + 'px'; | |
| box.style.width = (x2 - x1) + 'px'; | |
| box.style.height = (y2 - y1) + 'px'; | |
| } | |
| } | |
| }); | |
| canvas.addEventListener('mousedown', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const sx = e.clientX - rect.left; | |
| const sy = e.clientY - rect.top; | |
| const wx = sx + game.camX; | |
| const wy = sy + game.camY; | |
| if (e.button === 2) { | |
| // Right click | |
| if (game.placingBuilding) { | |
| cancelPlacement(); | |
| return; | |
| } | |
| issueRightClick(wx, wy); | |
| return; | |
| } | |
| // Left click | |
| if (game.placingBuilding) { | |
| tryPlaceBuilding(wx, wy); | |
| return; | |
| } | |
| game.mouse.dragStart = { sx, sy, wx, wy }; | |
| }); | |
| canvas.addEventListener('mouseup', e => { | |
| if (e.button !== 0) return; | |
| if (!game.mouse.dragStart) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const sx = e.clientX - rect.left; | |
| const sy = e.clientY - rect.top; | |
| const wx = sx + game.camX; | |
| const wy = sy + game.camY; | |
| const dragDist = Math.hypot(sx - game.mouse.dragStart.sx, sy - game.mouse.dragStart.sy); | |
| document.getElementById('selBox').style.display = 'none'; | |
| if (dragDist < 5) { | |
| // Single click select | |
| const clicked = entityAt(wx, wy); | |
| if (clicked) { | |
| if (e.shiftKey && clicked.kind === 'unit') { | |
| if (game.selected.includes(clicked)) { | |
| game.selected = game.selected.filter(s => s !== clicked); | |
| } else if (game.selected[0]?.kind === 'unit') { | |
| game.selected.push(clicked); | |
| } else { | |
| game.selected = [clicked]; | |
| } | |
| } else { | |
| game.selected = [clicked]; | |
| } | |
| } else { | |
| game.selected = []; | |
| } | |
| } else { | |
| // Box select - only units | |
| const x1 = Math.min(game.mouse.dragStart.wx, wx); | |
| const y1 = Math.min(game.mouse.dragStart.wy, wy); | |
| const x2 = Math.max(game.mouse.dragStart.wx, wx); | |
| const y2 = Math.max(game.mouse.dragStart.wy, wy); | |
| const picked = units.filter(u => u.alive && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2); | |
| if (picked.length > 0) { | |
| game.selected = picked; | |
| } else { | |
| game.selected = []; | |
| } | |
| } | |
| game.mouse.dragStart = null; | |
| updateUI(); | |
| }); | |
| function entityAt(wx, wy) { | |
| // units first (closer) | |
| let best = null, bd = Infinity; | |
| for (const u of units) { | |
| if (!u.alive) continue; | |
| const d = Math.hypot(u.x - wx, u.y - wy); | |
| if (d < u.radius + 2 && d < bd) { bd = d; best = u; } | |
| } | |
| if (best) return best; | |
| // buildings | |
| for (const b of buildings) { | |
| if (!b.alive) continue; | |
| const bx0 = b.tx * TILE, by0 = b.ty * TILE; | |
| const bx1 = bx0 + b.def.tw * TILE, by1 = by0 + b.def.th * TILE; | |
| if (wx >= bx0 && wx < bx1 && wy >= by0 && wy < by1) return b; | |
| } | |
| return null; | |
| } | |
| function issueRightClick(wx, wy) { | |
| if (game.selected.length === 0) return; | |
| // Check what's at the target | |
| const target = entityAt(wx, wy); | |
| const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); | |
| const tileType = (tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H) ? tiles[ty][tx] : -1; | |
| // Buildings as selected => set rally point (only for single building) | |
| if (game.selected.length === 1 && game.selected[0].kind === 'building') { | |
| const b = game.selected[0]; | |
| if (b.complete && b.def.trains) { | |
| b.rallyPoint = { x: wx, y: wy }; | |
| showMsg('Rally point set'); | |
| } | |
| return; | |
| } | |
| // Units | |
| const selUnits = game.selected.filter(s => s.kind === 'unit' && s.alive); | |
| if (selUnits.length === 0) return; | |
| // If target is a resource tile and workers are selected, gather | |
| if ((tileType === TILE_GOLD || tileType === TILE_FOREST) && tileRes[ty][tx] > 0) { | |
| for (const u of selUnits) { | |
| if (u.def.canGather) { | |
| u.state = 'gathering'; | |
| u.target = { kind: 'tile', tx, ty }; | |
| u.gatherType = (tileType === TILE_GOLD) ? 'gold' : 'wood'; | |
| u.targetPos = null; | |
| } else { | |
| // soldiers etc just move | |
| spreadMove(u, selUnits.indexOf(u), selUnits.length, wx, wy); | |
| } | |
| } | |
| pulseCursor(wx, wy, '#e8c34a'); | |
| return; | |
| } | |
| // If target is a dropoff building and worker has carry, go there | |
| if (target && target.kind === 'building' && target.def.dropoff && target.complete) { | |
| for (const u of selUnits) { | |
| if (u.def.canGather && u.carry > 0) { | |
| u.state = 'returning'; | |
| } else { | |
| spreadMove(u, selUnits.indexOf(u), selUnits.length, target.x, target.y); | |
| } | |
| } | |
| pulseCursor(wx, wy, '#6bc17a'); | |
| return; | |
| } | |
| // If target is an incomplete building, workers help build | |
| if (target && target.kind === 'building' && !target.complete) { | |
| for (const u of selUnits) { | |
| if (u.def.canBuild) { | |
| u.state = 'building'; | |
| u.buildTarget = target; | |
| } else { | |
| spreadMove(u, selUnits.indexOf(u), selUnits.length, target.x, target.y); | |
| } | |
| } | |
| pulseCursor(wx, wy, '#6bc17a'); | |
| return; | |
| } | |
| // Otherwise, spread-move to position | |
| selUnits.forEach((u, i) => spreadMove(u, i, selUnits.length, wx, wy)); | |
| pulseCursor(wx, wy, '#6bc17a'); | |
| } | |
| function spreadMove(u, index, total, wx, wy) { | |
| // spread in a circle/grid around the target point | |
| const cols = Math.ceil(Math.sqrt(total)); | |
| const spacing = 24; | |
| const col = index % cols, row = Math.floor(index / cols); | |
| const ox = (col - (cols - 1) / 2) * spacing; | |
| const oy = (row - (cols - 1) / 2) * spacing; | |
| u.state = 'moving'; | |
| u.targetPos = { x: wx + ox, y: wy + oy }; | |
| u.target = null; | |
| u.buildTarget = null; | |
| } | |
| function pulseCursor(wx, wy, color) { | |
| effects.push({ x: wx, y: wy, type: 'hit', life: 0.4, maxLife: 0.4, color }); | |
| } | |
| // Minimap click | |
| minimap.addEventListener('mousedown', e => { | |
| const rect = minimap.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left; | |
| const my = e.clientY - rect.top; | |
| const scale = Math.min(minimap.width / MAP_W, minimap.height / MAP_H); | |
| const offX = (minimap.width - MAP_W * scale) / 2; | |
| const offY = (minimap.height - MAP_H * scale) / 2; | |
| const tx = (mx - offX) / scale; | |
| const ty = (my - offY) / scale; | |
| centerOn(tx * TILE, ty * TILE); | |
| }); | |
| // Keyboard | |
| const keys = {}; | |
| window.addEventListener('keydown', e => { | |
| keys[e.key] = true; | |
| if (e.key === 'Escape') { | |
| cancelPlacement(); | |
| game.selected = []; | |
| updateUI(); | |
| } | |
| }); | |
| window.addEventListener('keyup', e => { keys[e.key] = false; }); | |
| function handleCameraScroll(dt) { | |
| const speed = 600; | |
| if (keys['ArrowLeft'] || keys['a']) game.camX -= speed * dt; | |
| if (keys['ArrowRight'] || keys['d']) game.camX += speed * dt; | |
| if (keys['ArrowUp'] || keys['w']) game.camY -= speed * dt; | |
| if (keys['ArrowDown'] || keys['s']) game.camY += speed * dt; | |
| // Edge scrolling | |
| const EDGE = 20; | |
| const sx = game.mouse.screenX, sy = game.mouse.screenY; | |
| if (sx >= 0 && sx < EDGE) game.camX -= speed * dt; | |
| if (sx > game.viewW - EDGE && sx <= game.viewW) game.camX += speed * dt; | |
| if (sy >= 0 && sy < EDGE) game.camY -= speed * dt; | |
| if (sy > game.viewH - EDGE && sy <= game.viewH) game.camY += speed * dt; | |
| clampCamera(); | |
| } | |
| // -------- UI -------- | |
| function updateUI() { | |
| document.getElementById('r-gold').textContent = Math.floor(game.gold); | |
| document.getElementById('r-wood').textContent = Math.floor(game.wood); | |
| const foodEl = document.getElementById('r-food'); | |
| foodEl.textContent = `${Math.floor(game.food)}/${game.foodCap}`; | |
| foodEl.classList.toggle('full', game.food >= game.foodCap); | |
| // Info panel | |
| const portrait = document.getElementById('portrait'); | |
| const nameEl = document.getElementById('sel-name'); | |
| const s1 = document.getElementById('sel-stat1'); | |
| const s2 = document.getElementById('sel-stat2'); | |
| const s3 = document.getElementById('sel-stat3'); | |
| const hp = document.getElementById('sel-hp'); | |
| const multi = document.getElementById('multi-list'); | |
| multi.innerHTML = ''; | |
| if (game.selected.length === 0) { | |
| portrait.textContent = '—'; | |
| portrait.style.fontSize = '24px'; | |
| nameEl.textContent = 'Nothing selected'; | |
| s1.textContent = 'Select a unit or building.'; | |
| s2.textContent = ''; | |
| s3.textContent = ''; | |
| hp.style.display = 'none'; | |
| } else if (game.selected.length === 1) { | |
| const e = game.selected[0]; | |
| portrait.style.fontSize = '40px'; | |
| portrait.textContent = e.def.icon || '?'; | |
| nameEl.textContent = e.def.name.toUpperCase(); | |
| if (e.kind === 'unit') { | |
| s1.innerHTML = `HP: <b>${Math.ceil(e.hp)} / ${e.maxHp}</b> • DMG: <b>${e.def.damage}</b>`; | |
| s2.innerHTML = `State: <b>${e.state}</b>${e.carry > 0 ? ` • Carrying: <b>${e.carry} ${e.carryType}</b>` : ''}`; | |
| s3.innerHTML = `<i>${e.def.desc}</i>`; | |
| } else { | |
| if (!e.complete) { | |
| s1.innerHTML = `Constructing: <b>${Math.floor((e.buildProgress / e.def.buildTime) * 100)}%</b>`; | |
| s2.innerHTML = `HP: <b>${Math.ceil(e.hp)} / ${e.maxHp}</b>`; | |
| s3.innerHTML = `<i>${e.def.desc}</i>`; | |
| } else { | |
| s1.innerHTML = `HP: <b>${Math.ceil(e.hp)} / ${e.maxHp}</b>`; | |
| let q = ''; | |
| if (e.buildQueue.length > 0) { | |
| q = `Training: <b>${UNIT_DEFS[e.buildQueue[0]].name}</b> (${Math.floor((e.trainProgress / UNIT_DEFS[e.buildQueue[0]].buildTime) * 100)}%)${e.buildQueue.length > 1 ? ` +${e.buildQueue.length - 1}` : ''}`; | |
| } else if (e.def.trains) { | |
| q = 'Idle'; | |
| } | |
| s2.innerHTML = q; | |
| s3.innerHTML = `<i>${e.def.desc}</i>`; | |
| } | |
| } | |
| hp.style.display = 'block'; | |
| hp.children[0].style.width = (e.hp / e.maxHp * 100) + '%'; | |
| } else { | |
| portrait.style.fontSize = '24px'; | |
| portrait.textContent = game.selected.length; | |
| nameEl.textContent = `${game.selected.length} UNITS`; | |
| const counts = {}; | |
| for (const s of game.selected) { | |
| counts[s.def.name] = (counts[s.def.name] || 0) + 1; | |
| } | |
| s1.textContent = Object.entries(counts).map(([k,v]) => `${v} × ${k}`).join(', '); | |
| s2.textContent = ''; | |
| s3.innerHTML = '<i>Right-click to move, gather, or build.</i>'; | |
| hp.style.display = 'none'; | |
| // mini-list | |
| for (const s of game.selected) { | |
| const el = document.createElement('div'); | |
| el.className = 'mini'; | |
| el.textContent = s.def.icon; | |
| el.style.fontSize = '16px'; | |
| const bar = document.createElement('div'); | |
| bar.className = 'bar'; | |
| bar.style.width = (s.hp / s.maxHp * 100) + '%'; | |
| el.appendChild(bar); | |
| el.onclick = (ev) => { | |
| if (ev.shiftKey) game.selected = game.selected.filter(x => x !== s); | |
| else game.selected = [s]; | |
| updateUI(); | |
| }; | |
| multi.appendChild(el); | |
| } | |
| } | |
| // Actions | |
| renderActions(); | |
| } | |
| function renderActions() { | |
| const act = document.getElementById('actions'); | |
| // Mixed selection — only use first unit's context if all units, or show nothing | |
| const first = game.selected[0]; | |
| const anyWorker = game.selected.some(s => s.kind === 'unit' && s.type === 'worker'); | |
| const haveWorker = units.some(u => u.alive && u.type === 'worker'); | |
| // Build a state signature so we only rebuild DOM when something meaningful changes. | |
| let mode = 'empty'; | |
| if (game.selected.length === 0) { | |
| mode = haveWorker ? 'workers-global' : 'empty'; | |
| } else if (first.kind === 'building' && game.selected.length === 1) { | |
| mode = first.complete ? `building:${first.type}` : 'building-construction'; | |
| } else if (anyWorker) { | |
| mode = 'workers'; | |
| } else { | |
| mode = 'units'; | |
| } | |
| const affordSig = `${Math.floor(game.gold/10)}|${Math.floor(game.wood/10)}|${game.placingBuilding}`; | |
| const sig = `${mode}|${affordSig}|${game.selected.length}`; | |
| if (act.dataset.sig === sig) return; // nothing changed visually | |
| act.dataset.sig = sig; | |
| act.innerHTML = ''; | |
| if (mode === 'empty') { | |
| const hint = document.createElement('div'); | |
| hint.style.cssText = 'grid-column: 1 / -1; color: var(--text-dim); font-size: 11px; padding: 8px; font-style: italic;'; | |
| hint.textContent = 'Select a unit or building to see available commands.'; | |
| act.appendChild(hint); | |
| return; | |
| } | |
| if (mode.startsWith('building:')) { | |
| const b = first; | |
| if (b.def.trains) { | |
| for (const ut of b.def.trains) { | |
| const def = UNIT_DEFS[ut]; | |
| addBtn(def.icon, def.name, def, () => trainUnit(b, ut)); | |
| } | |
| } | |
| return; | |
| } | |
| if (mode === 'building-construction') return; | |
| // workers (selected) OR workers-global (none selected but workers exist) | |
| if (mode === 'workers' || mode === 'workers-global') { | |
| const builds = [ | |
| ['townhall', BUILDING_DEFS.townhall], | |
| ['farm', BUILDING_DEFS.farm], | |
| ['barracks', BUILDING_DEFS.barracks], | |
| ['stable', BUILDING_DEFS.stable], | |
| ['tower', BUILDING_DEFS.tower], | |
| ]; | |
| for (const [key, def] of builds) { | |
| addBtn(def.icon, 'Build ' + def.name, def, () => startPlacement(key), key === game.placingBuilding); | |
| } | |
| addBtn('✋', 'Stop', null, () => stopSelected()); | |
| if (mode === 'workers-global') { | |
| const hint = document.createElement('div'); | |
| hint.style.cssText = 'grid-column: 1 / -1; color: var(--text-dim); font-size: 10px; padding: 4px 2px 0; font-style: italic; text-align:center;'; | |
| hint.textContent = 'The nearest idle Worker will respond.'; | |
| act.appendChild(hint); | |
| } | |
| return; | |
| } | |
| // non-worker units selected | |
| addBtn('✋', 'Stop', null, () => stopSelected()); | |
| } | |
| function addBtn(icon, label, def, onClick, active = false) { | |
| const act = document.getElementById('actions'); | |
| const btn = document.createElement('div'); | |
| btn.className = 'btn'; | |
| if (active) btn.classList.add('building-placing'); | |
| // Cost check | |
| let canAfford = true; | |
| let costHtml = ''; | |
| if (def) { | |
| const gold = def.costGold || 0; | |
| const wood = def.costWood || 0; | |
| const food = def.costFood || 0; | |
| if (game.gold < gold || game.wood < wood) canAfford = false; | |
| const parts = []; | |
| if (gold) parts.push(`<span class="${game.gold < gold ? 'insuf' : ''}">${gold}g</span>`); | |
| if (wood) parts.push(`<span class="${game.wood < wood ? 'insuf' : ''}">${wood}w</span>`); | |
| if (food) parts.push(`${food}f`); | |
| costHtml = `<div class="cost">${parts.join(' ')}</div>`; | |
| } | |
| btn.innerHTML = `<div class="ico">${icon}</div><div class="lbl">${label}</div>${costHtml}`; | |
| btn.title = def ? def.desc || '' : ''; | |
| if (def && !canAfford) btn.classList.add('disabled'); | |
| btn.onclick = () => { | |
| if (btn.classList.contains('disabled')) { showMsg('Not enough resources.'); return; } | |
| onClick(); | |
| }; | |
| act.appendChild(btn); | |
| } | |
| function stopSelected() { | |
| for (const s of game.selected) { | |
| if (s.kind !== 'unit') continue; | |
| s.state = 'idle'; | |
| s.target = null; | |
| s.targetPos = null; | |
| s.buildTarget = null; | |
| } | |
| } | |
| function trainUnit(b, type) { | |
| const def = UNIT_DEFS[type]; | |
| if (game.gold < def.costGold || game.wood < def.costWood) { | |
| showMsg('Not enough resources.'); | |
| return; | |
| } | |
| if (game.food + def.costFood > game.foodCap) { | |
| // Check all queued as well | |
| let queuedFood = 0; | |
| for (const q of b.buildQueue) queuedFood += UNIT_DEFS[q].costFood; | |
| if (game.food + queuedFood + def.costFood > game.foodCap) { | |
| showMsg('Not enough food. Build a farm.'); | |
| return; | |
| } | |
| } | |
| game.gold -= def.costGold; | |
| game.wood -= def.costWood; | |
| b.buildQueue.push(type); | |
| showMsg(`Training ${def.name}...`); | |
| updateUI(); | |
| } | |
| function startPlacement(key) { | |
| const def = BUILDING_DEFS[key]; | |
| if (game.gold < def.costGold || game.wood < def.costWood) { | |
| showMsg('Not enough resources.'); | |
| return; | |
| } | |
| game.placingBuilding = key; | |
| const act = document.getElementById('actions'); | |
| if (act) act.dataset.sig = ''; // force re-render to show active highlight | |
| document.getElementById('placing-hint').style.display = 'block'; | |
| document.getElementById('placing-hint').textContent = | |
| `Placing ${def.name} — Left-click to build • Right-click or ESC to cancel`; | |
| updateUI(); | |
| } | |
| function cancelPlacement() { | |
| if (!game.placingBuilding) return; | |
| game.placingBuilding = null; | |
| const act = document.getElementById('actions'); | |
| if (act) act.dataset.sig = ''; | |
| document.getElementById('placing-hint').style.display = 'none'; | |
| updateUI(); | |
| } | |
| function tryPlaceBuilding(wx, wy) { | |
| const key = game.placingBuilding; | |
| const def = BUILDING_DEFS[key]; | |
| const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); | |
| if (!canPlaceBuilding(tx, ty, def)) { | |
| showMsg('Cannot build here.'); | |
| return; | |
| } | |
| if (game.gold < def.costGold || game.wood < def.costWood) { | |
| showMsg('Not enough resources.'); | |
| return; | |
| } | |
| // Pick the worker BEFORE deducting cost, so we can fail gracefully | |
| let workers = game.selected.filter(s => s.kind === 'unit' && s.alive && s.type === 'worker'); | |
| if (workers.length === 0) { | |
| // Auto-pick: prefer idle workers, fall back to gathering ones, nearest wins | |
| const all = units.filter(u => u.alive && u.type === 'worker' && u.state !== 'building'); | |
| if (all.length === 0) { | |
| showMsg('No worker available.'); | |
| return; | |
| } | |
| const score = (u) => { | |
| const d = Math.hypot(u.x - wx, u.y - wy); | |
| const statePenalty = u.state === 'idle' ? 0 : 1000; | |
| return d + statePenalty; | |
| }; | |
| workers = [all.reduce((best, u) => score(u) < score(best) ? u : best)]; | |
| } | |
| game.gold -= def.costGold; | |
| game.wood -= def.costWood; | |
| const b = createBuilding(key, tx, ty, false); | |
| // Nearest of the candidates | |
| let best = workers[0], bd = Infinity; | |
| for (const w of workers) { | |
| const d = Math.hypot(w.x - b.x, w.y - b.y); | |
| if (d < bd) { bd = d; best = w; } | |
| } | |
| best.state = 'building'; | |
| best.buildTarget = b; | |
| best.target = null; | |
| best.targetPos = null; | |
| showMsg(`${def.name} started. Worker en route.`); | |
| cancelPlacement(); | |
| } | |
| let msgTimer = 0; | |
| function showMsg(text) { | |
| const el = document.getElementById('msg'); | |
| el.textContent = text; | |
| el.classList.add('show'); | |
| msgTimer = 2.5; | |
| } | |
| // -------- Game loop -------- | |
| function tick(now) { | |
| const dt = Math.min(0.05, (now - game.lastTime) / 1000) || 0.016; | |
| game.lastTime = now; | |
| game.time += dt; | |
| handleCameraScroll(dt); | |
| for (const u of units) updateUnit(u, dt); | |
| for (const b of buildings) updateBuilding(b, dt); | |
| updateProjectiles(dt); | |
| updateEffects(dt); | |
| // periodic fog update | |
| if (Math.floor(game.time * 4) !== Math.floor((game.time - dt) * 4)) { | |
| updateFog(); | |
| } | |
| // Cleanup selection | |
| game.selected = game.selected.filter(s => s.alive); | |
| // Msg fade | |
| if (msgTimer > 0) { | |
| msgTimer -= dt; | |
| if (msgTimer <= 0) document.getElementById('msg').classList.remove('show'); | |
| } | |
| render(); | |
| updateUI(); | |
| requestAnimationFrame(tick); | |
| } | |
| // -------- Start -------- | |
| function startGame() { | |
| document.getElementById('welcome').style.display = 'none'; | |
| document.getElementById('game').style.display = 'grid'; | |
| requestAnimationFrame(() => { | |
| resizeCanvas(); | |
| initGame(); | |
| renderTilesToCache(); | |
| game.lastTime = performance.now(); | |
| requestAnimationFrame(tick); | |
| }); | |
| } | |
| window.addEventListener('resize', () => { | |
| resizeCanvas(); | |
| if (tiles.length > 0) { | |
| // tile cache dimensions don't change (world size fixed) so no re-render needed | |
| } | |
| }); | |
| // Expose start | |
| window.startGame = startGame; | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment