Created
June 15, 2026 17:45
-
-
Save senko/22eeea953f830cc6d148b275d605a482 to your computer and use it in GitHub Desktop.
index.html
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>Realm Conquest - RTS Game</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: #0a0a0a; overflow: hidden; font-family: 'Segoe UI', Arial, sans-serif; user-select: none; } | |
| #gameCanvas { display: block; cursor: default; } | |
| #ui-overlay { | |
| position: absolute; top: 0; left: 0; width: 100%; height: 100%; | |
| pointer-events: none; | |
| } | |
| #resource-bar { | |
| position: absolute; top: 0; left: 0; right: 0; height: 40px; | |
| background: linear-gradient(180deg, #1a1a2e 0%, #0f0f1a 100%); | |
| border-bottom: 2px solid #3a3a5c; | |
| display: flex; align-items: center; padding: 0 16px; gap: 32px; | |
| pointer-events: auto; z-index: 10; | |
| } | |
| .resource-item { | |
| display: flex; align-items: center; gap: 8px; color: #e0e0e0; font-size: 14px; font-weight: 600; | |
| } | |
| .resource-item .icon { width: 20px; height: 20px; border-radius: 3px; } | |
| .resource-item .value { min-width: 40px; } | |
| #panel { | |
| position: absolute; bottom: 0; left: 0; width: 280px; height: 220px; | |
| background: linear-gradient(180deg, #1a1a2e 0%, #0f0f1a 100%); | |
| border-top: 2px solid #3a3a5c; border-right: 2px solid #3a3a5c; | |
| pointer-events: auto; z-index: 10; padding: 8px; overflow-y: auto; | |
| } | |
| #panel h3 { color: #ffd700; font-size: 13px; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 1px; } | |
| #panel .info { color: #b0b0c0; font-size: 12px; line-height: 1.6; } | |
| #panel .info .hp-bar { width: 100%; height: 8px; background: #333; border-radius: 4px; margin: 4px 0; overflow: hidden; } | |
| #panel .info .hp-fill { height: 100%; border-radius: 4px; transition: width 0.2s; } | |
| #action-panel { | |
| position: absolute; bottom: 0; left: 280px; right: 200px; height: 220px; | |
| background: linear-gradient(180deg, #1a1a2e 0%, #0f0f1a 100%); | |
| border-top: 2px solid #3a3a5c; | |
| pointer-events: auto; z-index: 10; padding: 8px; | |
| display: flex; flex-wrap: wrap; align-content: flex-start; gap: 4px; | |
| } | |
| .action-btn { | |
| width: 60px; height: 60px; background: linear-gradient(180deg, #2a2a4a 0%, #1a1a2e 100%); | |
| border: 2px solid #3a3a5c; border-radius: 6px; cursor: pointer; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| transition: all 0.15s; position: relative; | |
| } | |
| .action-btn:hover { border-color: #ffd700; background: linear-gradient(180deg, #3a3a5a 0%, #2a2a3e 100%); transform: scale(1.05); } | |
| .action-btn.active { border-color: #ffd700; box-shadow: 0 0 10px rgba(255,215,0,0.4); } | |
| .action-btn .btn-icon { font-size: 22px; margin-bottom: 2px; } | |
| .action-btn .btn-label { font-size: 9px; color: #b0b0c0; text-align: center; line-height: 1.1; } | |
| .action-btn .btn-cost { font-size: 8px; color: #ffa500; margin-top: 1px; } | |
| .action-btn .hotkey { | |
| position: absolute; top: 2px; right: 4px; font-size: 9px; color: #666; | |
| font-weight: bold; | |
| } | |
| #minimap-container { | |
| position: absolute; bottom: 0; right: 0; width: 200px; height: 200px; | |
| background: #0a0a0a; border-top: 2px solid #3a3a5c; border-left: 2px solid #3a3a5c; | |
| pointer-events: auto; z-index: 10; padding: 2px; | |
| } | |
| #minimapCanvas { width: 100%; height: 100%; border-radius: 2px; cursor: pointer; } | |
| #message-area { | |
| position: absolute; top: 48px; left: 50%; transform: translateX(-50%); | |
| pointer-events: none; z-index: 20; text-align: center; | |
| } | |
| .message { | |
| background: rgba(0,0,0,0.8); color: #ffd700; padding: 6px 18px; | |
| border-radius: 4px; margin-bottom: 4px; font-size: 13px; | |
| animation: fadeOut 3s forwards; | |
| } | |
| @keyframes fadeOut { 0%{opacity:1} 70%{opacity:1} 100%{opacity:0} } | |
| #build-ghost { position: absolute; pointer-events: none; z-index: 5; opacity: 0.6; } | |
| .tooltip { | |
| position: absolute; background: rgba(10,10,20,0.95); color: #e0e0e0; | |
| padding: 6px 10px; border-radius: 4px; font-size: 11px; z-index: 30; | |
| border: 1px solid #3a3a5c; pointer-events: none; max-width: 200px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="gameCanvas"></canvas> | |
| <div id="ui-overlay"> | |
| <div id="resource-bar"> | |
| <div class="resource-item"><div class="icon" style="background:#ffd700"></div>Gold: <span class="value" id="res-gold">500</span></div> | |
| <div class="resource-item"><div class="icon" style="background:#8B4513"></div>Wood: <span class="value" id="res-wood">300</span></div> | |
| <div class="resource-item"><div class="icon" style="background:#ff6b6b"></div>Food: <span class="value" id="res-food">0</span></div> | |
| <div class="resource-item"><div class="icon" style="background:#4ecdc4"></div>Supply: <span class="value" id="res-supply">0/0</span></div> | |
| <div class="resource-item" style="margin-left:auto;color:#888;font-size:12px" id="pop-text">Population: 0</div> | |
| </div> | |
| <div id="panel"><h3>Realm Conquest</h3><div class="info">Select a unit or building to begin.<br><br>Controls:<br>Left Click: Select<br>Right Click: Command<br>Drag: Box Select<br>WASD/Arrows: Scroll<br>1-9: Hotkeys<br>Esc: Cancel</div></div> | |
| <div id="action-panel"></div> | |
| <div id="minimap-container"><canvas id="minimapCanvas"></canvas></div> | |
| <div id="message-area"></div> | |
| </div> | |
| <script> | |
| // ==================== CONSTANTS ==================== | |
| const TILE_SIZE = 32; | |
| const MAP_W = 80, MAP_H = 80; | |
| const MINIMAP_W = 196, MINIMAP_H = 196; | |
| const TILE_GRASS = 0, TILE_FOREST = 1, TILE_GOLD = 2, TILE_WATER = 3, TILE_ROCK = 4, TILE_DIRT = 5; | |
| const TEAM_PLAYER = 0, TEAM_ENEMY = 1; | |
| // ==================== ENTITY DEFINITIONS ==================== | |
| const UNIT_DEFS = { | |
| worker: { | |
| name: 'Worker', hp: 60, speed: 1.8, attack: 5, attackRange: 1, attackSpeed: 1, | |
| cost: { gold: 50, wood: 0, food: 1 }, buildTime: 90, supply: 1, | |
| canBuild: true, canGather: true, size: 10, color: '#4ecdc4' | |
| }, | |
| soldier: { | |
| name: 'Soldier', hp: 120, speed: 1.5, attack: 15, attackRange: 1, attackSpeed: 0.8, | |
| cost: { gold: 100, wood: 30, food: 1 }, buildTime: 120, supply: 1, | |
| canBuild: false, canGather: false, size: 11, color: '#ff6b6b' | |
| }, | |
| archer: { | |
| name: 'Archer', hp: 70, speed: 1.6, attack: 12, attackRange: 5, attackSpeed: 0.6, | |
| cost: { gold: 80, wood: 50, food: 1 }, buildTime: 110, supply: 1, | |
| canBuild: false, canGather: false, size: 10, color: '#a8e6cf' | |
| }, | |
| knight: { | |
| name: 'Knight', hp: 200, speed: 1.3, attack: 25, attackRange: 1, attackSpeed: 0.7, | |
| cost: { gold: 200, wood: 50, food: 2 }, buildTime: 180, supply: 2, | |
| canBuild: false, canGather: false, size: 13, color: '#dcedc1' | |
| } | |
| }; | |
| const BUILDING_DEFS = { | |
| townhall: { | |
| name: 'Town Hall', hp: 800, size: 3, cost: { gold: 400, wood: 200, food: 0 }, | |
| buildTime: 300, providesSupply: 10, canTrain: ['worker'], color: '#5c6bc0', | |
| description: 'Main building. Trains workers.' | |
| }, | |
| barracks: { | |
| name: 'Barracks', hp: 500, size: 2, cost: { gold: 150, wood: 100, food: 0 }, | |
| buildTime: 180, providesSupply: 0, canTrain: ['soldier', 'archer', 'knight'], color: '#ef5350', | |
| description: 'Trains military units.' | |
| }, | |
| farm: { | |
| name: 'Farm', hp: 300, size: 2, cost: { gold: 60, wood: 80, food: 0 }, | |
| buildTime: 100, providesSupply: 5, canTrain: [], color: '#66bb6a', | |
| description: 'Provides food supply for units.' | |
| }, | |
| tower: { | |
| name: 'Watch Tower', hp: 400, size: 1, cost: { gold: 120, wood: 60, food: 0 }, | |
| buildTime: 120, providesSupply: 0, canTrain: [], color: '#ffa726', | |
| attack: 18, attackRange: 6, attackSpeed: 0.5, isDefensive: true, | |
| description: 'Defensive tower. Attacks enemies.' | |
| }, | |
| lumbermill: { | |
| name: 'Lumber Mill', hp: 400, size: 2, cost: { gold: 100, wood: 150, food: 0 }, | |
| buildTime: 140, providesSupply: 0, canTrain: [], color: '#8d6e63', | |
| description: 'Workers return wood faster (+50%).' | |
| }, | |
| blacksmith: { | |
| name: 'Blacksmith', hp: 450, size: 2, cost: { gold: 200, wood: 100, food: 0 }, | |
| buildTime: 160, providesSupply: 0, canTrain: [], color: '#78909c', | |
| description: 'Upgrades unit attack (+3).' | |
| } | |
| }; | |
| // ==================== GAME STATE ==================== | |
| let canvas, ctx, miniCanvas, miniCtx; | |
| let gameState = { | |
| map: [], resources: { gold: 500, wood: 300, food: 0 }, | |
| units: [], buildings: [], particles: [], projectiles: [], | |
| selection: { units: [], building: null }, | |
| camera: { x: 0, y: 0 }, | |
| keys: {}, mouse: { x: 0, y: 0, worldX: 0, worldY: 0 }, | |
| buildMode: null, buildGhost: null, | |
| dragSelect: false, dragStart: null, dragEnd: null, | |
| time: 0, gameOver: false, upgradeAttack: 0, woodBonus: false, | |
| rallyPoint: null | |
| }; | |
| // ==================== MAP GENERATION ==================== | |
| function generateMap() { | |
| const map = []; | |
| for (let y = 0; y < MAP_H; y++) { | |
| map[y] = []; | |
| for (let x = 0; x < MAP_W; x++) { | |
| map[y][x] = { type: TILE_GRASS, resource: 0, occupied: false }; | |
| } | |
| } | |
| // Place water bodies | |
| for (let i = 0; i < 4; i++) { | |
| let cx = 15 + Math.random() * (MAP_W - 30); | |
| let cy = 15 + Math.random() * (MAP_H - 30); | |
| // Keep away from starting area | |
| if (cx < 15 && cy < 15) continue; | |
| let rx = 3 + Math.random() * 4, ry = 2 + Math.random() * 3; | |
| for (let y = Math.floor(cy-ry-1); y <= Math.ceil(cy+ry+1); y++) { | |
| for (let x = Math.floor(cx-rx-1); x <= Math.ceil(cx+rx+1); x++) { | |
| if (x < 0 || x >= MAP_W || y < 0 || y >= MAP_H) continue; | |
| let dx = (x-cx)/rx, dy = (y-cy)/ry; | |
| if (dx*dx + dy*dy < 1 + Math.random()*0.3) map[y][x].type = TILE_WATER; | |
| } | |
| } | |
| } | |
| // Place forests | |
| for (let i = 0; i < 18; i++) { | |
| let cx = Math.random() * MAP_W, cy = Math.random() * MAP_H; | |
| let r = 2 + Math.random() * 4; | |
| for (let y = Math.floor(cy-r); y <= Math.ceil(cy+r); y++) { | |
| for (let x = Math.floor(cx-r); x <= Math.ceil(cx+r); x++) { | |
| if (x < 0 || x >= MAP_W || y < 0 || y >= MAP_H) continue; | |
| if (map[y][x].type !== TILE_GRASS) continue; | |
| let dx = x-cx, dy = y-cy; | |
| if (dx*dx + dy*dy < r*r * (0.5 + Math.random()*0.5)) { | |
| map[y][x].type = TILE_FOREST; | |
| map[y][x].resource = 100 + Math.floor(Math.random() * 100); | |
| } | |
| } | |
| } | |
| } | |
| // Place gold mines | |
| for (let i = 0; i < 10; i++) { | |
| let cx = 5 + Math.random() * (MAP_W - 10); | |
| let cy = 5 + Math.random() * (MAP_H - 10); | |
| let r = 1 + Math.random(); | |
| for (let y = Math.floor(cy-r); y <= Math.ceil(cy+r); y++) { | |
| for (let x = Math.floor(cx-r); x <= Math.ceil(cx+r); x++) { | |
| if (x < 0 || x >= MAP_W || y < 0 || y >= MAP_H) continue; | |
| if (map[y][x].type !== TILE_GRASS) continue; | |
| let dx = x-cx, dy = y-cy; | |
| if (dx*dx + dy*dy < r*r) { | |
| map[y][x].type = TILE_GOLD; | |
| map[y][x].resource = 800 + Math.floor(Math.random() * 400); | |
| } | |
| } | |
| } | |
| } | |
| // Place rocks | |
| for (let i = 0; i < 12; i++) { | |
| let cx = Math.random() * MAP_W, cy = Math.random() * MAP_H; | |
| let r = 1 + Math.random() * 2; | |
| for (let y = Math.floor(cy-r); y <= Math.ceil(cy+r); y++) { | |
| for (let x = Math.floor(cx-r); x <= Math.ceil(cx+r); x++) { | |
| if (x < 0 || x >= MAP_W || y < 0 || y >= MAP_H) continue; | |
| if (map[y][x].type !== TILE_GRASS) continue; | |
| let dx = x-cx, dy = y-cy; | |
| if (dx*dx + dy*dy < r*r * (0.4 + Math.random()*0.5)) { | |
| map[y][x].type = TILE_ROCK; | |
| } | |
| } | |
| } | |
| } | |
| // Ensure starting area is clear | |
| for (let y = 2; y < 10; y++) { | |
| for (let x = 2; x < 10; x++) { | |
| map[y][x].type = TILE_GRASS; | |
| map[y][x].resource = 0; | |
| } | |
| } | |
| // Place some forest and gold near start | |
| for (let x = 10; x < 15; x++) { | |
| map[4][x].type = TILE_FOREST; map[4][x].resource = 150; | |
| map[5][x].type = TILE_FOREST; map[5][x].resource = 150; | |
| } | |
| for (let x = 4; x < 7; x++) { | |
| map[10][x].type = TILE_GOLD; map[10][x].resource = 1500; | |
| map[11][x].type = TILE_GOLD; map[11][x].resource = 1500; | |
| } | |
| return map; | |
| } | |
| // ==================== ENTITY CLASSES ==================== | |
| class Unit { | |
| constructor(type, x, y, team) { | |
| this.type = type; | |
| this.def = UNIT_DEFS[type]; | |
| this.x = x; this.y = y; | |
| this.team = team; | |
| this.hp = this.def.hp; this.maxHp = this.def.hp; | |
| this.state = 'idle'; // idle, moving, gathering, building, attacking | |
| this.targetX = x; this.targetY = y; | |
| this.path = []; | |
| this.gatherTarget = null; | |
| this.gatherTimer = 0; | |
| this.carrying = 0; this.carryType = null; | |
| this.attackTarget = null; | |
| this.attackTimer = 0; | |
| this.buildTarget = null; | |
| this.selected = false; | |
| this.animFrame = 0; this.animTimer = 0; | |
| this.facing = 1; // 1=right, -1=left | |
| this.id = Math.random().toString(36).substr(2, 9); | |
| } | |
| getAttack() { return this.def.attack + (gameState.upgradeAttack || 0); } | |
| } | |
| class Building { | |
| constructor(type, x, y, team) { | |
| this.type = type; | |
| this.def = BUILDING_DEFS[type]; | |
| this.tileX = x; this.tileY = y; | |
| this.team = team; | |
| this.hp = this.def.hp; this.maxHp = this.def.hp; | |
| this.built = false; | |
| this.buildProgress = 0; | |
| this.buildMax = this.def.buildTime; | |
| this.trainingQueue = []; | |
| this.trainingProgress = 0; | |
| this.rallyX = x * TILE_SIZE + this.def.size * TILE_SIZE / 2; | |
| this.rallyY = y * TILE_SIZE + this.def.size * TILE_SIZE / 2; | |
| this.selected = false; | |
| this.attackTimer = 0; | |
| this.id = Math.random().toString(36).substr(2, 9); | |
| // Occupy tiles | |
| for (let dy = 0; dy < this.def.size; dy++) { | |
| for (let dx = 0; dx < this.def.size; dx++) { | |
| let tx = x + dx, ty = y + dy; | |
| if (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H) { | |
| gameState.map[ty][tx].occupied = true; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| class Particle { | |
| constructor(x, y, color, life, vx, vy, size) { | |
| this.x = x; this.y = y; this.color = color; | |
| this.life = life; this.maxLife = life; | |
| this.vx = vx || (Math.random()-0.5)*2; | |
| this.vy = vy || (Math.random()-0.5)*2; | |
| this.size = size || 3; | |
| } | |
| } | |
| class Projectile { | |
| constructor(x, y, tx, ty, damage, team) { | |
| this.x = x; this.y = y; | |
| this.tx = tx; this.ty = ty; | |
| this.damage = damage; this.team = team; | |
| this.speed = 4; this.life = 60; | |
| } | |
| } | |
| // ==================== PATHFINDING (A*) ==================== | |
| function isWalkable(tx, ty) { | |
| if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return false; | |
| let tile = gameState.map[ty][tx]; | |
| if (tile.type === TILE_WATER || tile.type === TILE_ROCK) return false; | |
| if (tile.occupied) return false; | |
| return true; | |
| } | |
| function findPath(sx, sy, ex, ey) { | |
| let stx = Math.floor(sx / TILE_SIZE), sty = Math.floor(sy / TILE_SIZE); | |
| let etx = Math.floor(ex / TILE_SIZE), ety = Math.floor(ey / TILE_SIZE); | |
| // Clamp end to walkable | |
| if (!isWalkable(etx, ety)) { | |
| // Find nearest walkable tile | |
| let best = null, bestDist = Infinity; | |
| for (let r = 1; r < 5; r++) { | |
| for (let dy = -r; dy <= r; dy++) { | |
| for (let dx = -r; dx <= r; dx++) { | |
| let nx = etx+dx, ny = ety+dy; | |
| if (isWalkable(nx, ny)) { | |
| let d = dx*dx + dy*dy; | |
| if (d < bestDist) { bestDist = d; best = {x:nx, y:ny}; } | |
| } | |
| } | |
| } | |
| if (best) break; | |
| } | |
| if (best) { etx = best.x; ety = best.y; } | |
| else return []; | |
| } | |
| if (stx === etx && sty === ety) return [{x: ex, y: ey}]; | |
| const open = [], closed = new Set(); | |
| const cameFrom = {}, gScore = {}, fScore = {}; | |
| const key = (x,y) => x+','+y; | |
| const h = (x,y) => Math.abs(x-etx) + Math.abs(y-ety); | |
| gScore[key(stx,sty)] = 0; | |
| fScore[key(stx,sty)] = h(stx,sty); | |
| open.push({x:stx, y:sty, f: fScore[key(stx,sty)]}); | |
| let iterations = 0; | |
| while (open.length > 0 && iterations < 2000) { | |
| iterations++; | |
| open.sort((a,b) => a.f - b.f); | |
| let current = open.shift(); | |
| let ck = key(current.x, current.y); | |
| if (current.x === etx && current.y === ety) { | |
| let path = []; | |
| let k = ck; | |
| while (k) { | |
| let [px,py] = k.split(',').map(Number); | |
| path.unshift({x: px * TILE_SIZE + TILE_SIZE/2, y: py * TILE_SIZE + TILE_SIZE/2}); | |
| k = cameFrom[k]; | |
| } | |
| path.push({x: ex, y: ey}); | |
| return path; | |
| } | |
| closed.add(ck); | |
| const dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[-1,1],[1,-1],[1,1]]; | |
| for (let [dx,dy] of dirs) { | |
| let nx = current.x+dx, ny = current.y+dy; | |
| if (nx<0||nx>=MAP_W||ny<0||ny>=MAP_H) continue; | |
| if (!isWalkable(nx,ny)) continue; | |
| let nk = key(nx,ny); | |
| if (closed.has(nk)) continue; | |
| let moveCost = (dx!==0 && dy!==0) ? 1.414 : 1; | |
| let ng = gScore[ck] + moveCost; | |
| if (gScore[nk] === undefined || ng < gScore[nk]) { | |
| cameFrom[nk] = ck; | |
| gScore[nk] = ng; | |
| fScore[nk] = ng + h(nx,ny); | |
| if (!open.find(o => o.x===nx && o.y===ny)) { | |
| open.push({x:nx, y:ny, f: fScore[nk]}); | |
| } | |
| } | |
| } | |
| } | |
| return []; | |
| } | |
| // ==================== INITIALIZATION ==================== | |
| function init() { | |
| canvas = document.getElementById('gameCanvas'); | |
| ctx = canvas.getContext('2d'); | |
| miniCanvas = document.getElementById('minimapCanvas'); | |
| miniCtx = miniCanvas.getContext('2d'); | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| gameState.map = generateMap(); | |
| // Place Town Hall | |
| let th = new Building('townhall', 4, 4, TEAM_PLAYER); | |
| th.built = true; th.buildProgress = th.buildMax; | |
| gameState.buildings.push(th); | |
| // Place initial workers | |
| for (let i = 0; i < 3; i++) { | |
| let wx = (6 + i) * TILE_SIZE + TILE_SIZE/2; | |
| let wy = 7 * TILE_SIZE + TILE_SIZE/2; | |
| gameState.units.push(new Unit('worker', wx, wy, TEAM_PLAYER)); | |
| } | |
| // Place enemy base | |
| let eth = new Building('townhall', MAP_W - 10, MAP_H - 10, TEAM_ENEMY); | |
| eth.built = true; eth.buildProgress = eth.buildMax; | |
| gameState.buildings.push(eth); | |
| for (let i = 0; i < 2; i++) { | |
| let wx = (MAP_W - 8 + i) * TILE_SIZE + TILE_SIZE/2; | |
| let wy = (MAP_H - 7) * TILE_SIZE + TILE_SIZE/2; | |
| gameState.units.push(new Unit('soldier', wx, wy, TEAM_ENEMY)); | |
| } | |
| // Center camera on town hall | |
| gameState.camera.x = 4 * TILE_SIZE - canvas.width/2 + TILE_SIZE*1.5; | |
| gameState.camera.y = 4 * TILE_SIZE - canvas.height/2 + TILE_SIZE*1.5; | |
| setupInput(); | |
| showMessage('Welcome to Realm Conquest! Select your workers and start building.', 5000); | |
| requestAnimationFrame(gameLoop); | |
| } | |
| function resize() { | |
| canvas.width = window.innerWidth; | |
| canvas.height = window.innerHeight; | |
| miniCanvas.width = MINIMAP_W; | |
| miniCanvas.height = MINIMAP_H; | |
| } | |
| // ==================== INPUT ==================== | |
| function setupInput() { | |
| canvas.addEventListener('mousedown', onMouseDown); | |
| canvas.addEventListener('mousemove', onMouseMove); | |
| canvas.addEventListener('mouseup', onMouseUp); | |
| canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| window.addEventListener('keydown', e => { | |
| gameState.keys[e.key.toLowerCase()] = true; | |
| handleHotkey(e.key.toLowerCase()); | |
| }); | |
| window.addEventListener('keyup', e => gameState.keys[e.key.toLowerCase()] = false); | |
| // Minimap click | |
| miniCanvas.addEventListener('mousedown', (e) => { | |
| let rect = miniCanvas.getBoundingClientRect(); | |
| let mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| gameState.camera.x = (mx / MINIMAP_W) * MAP_W * TILE_SIZE - canvas.width/2; | |
| gameState.camera.y = (my / MINIMAP_H) * MAP_H * TILE_SIZE - canvas.height/2; | |
| }); | |
| } | |
| function screenToWorld(sx, sy) { | |
| return { x: sx + gameState.camera.x, y: sy + gameState.camera.y }; | |
| } | |
| function onMouseDown(e) { | |
| if (gameState.gameOver) return; | |
| let rect = canvas.getBoundingClientRect(); | |
| let sx = e.clientX - rect.left, sy = e.clientY - rect.top; | |
| let world = screenToWorld(sx, sy); | |
| if (e.button === 0) { // Left click | |
| if (gameState.buildMode) { | |
| placeBuild(sx, sy); | |
| return; | |
| } | |
| // Check if clicking on UI | |
| if (sy >= canvas.height - 220 || sy <= 40) return; | |
| // Try select unit or building | |
| let clickedUnit = null; | |
| let clickedBuilding = null; | |
| for (let u of gameState.units) { | |
| if (u.team !== TEAM_PLAYER) continue; | |
| let dx = world.x - u.x, dy = world.y - u.y; | |
| if (dx*dx + dy*dy < (u.def.size+4)*(u.def.size+4)) { | |
| clickedUnit = u; | |
| break; | |
| } | |
| } | |
| if (!clickedUnit) { | |
| for (let b of gameState.buildings) { | |
| if (b.team !== TEAM_PLAYER) continue; | |
| let bx = b.tileX * TILE_SIZE, by = b.tileY * TILE_SIZE; | |
| let bw = b.def.size * TILE_SIZE, bh = b.def.size * TILE_SIZE; | |
| if (world.x >= bx && world.x <= bx+bw && world.y >= by && world.y <= by+bh) { | |
| clickedBuilding = b; | |
| break; | |
| } | |
| } | |
| } | |
| if (clickedUnit) { | |
| if (!e.shiftKey) deselectAll(); | |
| clickedUnit.selected = true; | |
| gameState.selection.units.push(clickedUnit); | |
| gameState.selection.building = null; | |
| updatePanel(); | |
| updateActionPanel(); | |
| gameState.dragSelect = false; | |
| } else if (clickedBuilding) { | |
| deselectAll(); | |
| clickedBuilding.selected = true; | |
| gameState.selection.building = clickedBuilding; | |
| updatePanel(); | |
| updateActionPanel(); | |
| gameState.dragSelect = false; | |
| } else { | |
| // Start drag select | |
| gameState.dragSelect = true; | |
| gameState.dragStart = {x: sx, y: sy}; | |
| gameState.dragEnd = {x: sx, y: sy}; | |
| } | |
| } else if (e.button === 2) { // Right click | |
| if (gameState.buildMode) { | |
| gameState.buildMode = null; | |
| gameState.buildGhost = null; | |
| updateActionPanel(); | |
| return; | |
| } | |
| let sel = gameState.selection.units; | |
| if (sel.length === 0) return; | |
| // Check what we clicked on | |
| let targetUnit = null, targetBuilding = null; | |
| for (let u of gameState.units) { | |
| let dx = world.x - u.x, dy = world.y - u.y; | |
| if (dx*dx + dy*dy < (u.def.size+4)*(u.def.size+4)) { | |
| targetUnit = u; break; | |
| } | |
| } | |
| if (!targetUnit) { | |
| for (let b of gameState.buildings) { | |
| let bx = b.tileX * TILE_SIZE, by = b.tileY * TILE_SIZE; | |
| let bw = b.def.size * TILE_SIZE, bh = b.def.size * TILE_SIZE; | |
| if (world.x >= bx && world.x <= bx+bw && world.y >= by && world.y <= by+bh) { | |
| targetBuilding = b; break; | |
| } | |
| } | |
| } | |
| let tx = Math.floor(world.x / TILE_SIZE), ty = Math.floor(world.y / TILE_SIZE); | |
| let tile = (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H) ? gameState.map[ty][tx] : null; | |
| for (let u of sel) { | |
| if (u.team !== TEAM_PLAYER) continue; | |
| // Attack enemy | |
| if (targetUnit && targetUnit.team !== TEAM_PLAYER) { | |
| u.state = 'attacking'; | |
| u.attackTarget = targetUnit; | |
| u.path = []; | |
| addParticle(u.x, u.y, '#ff4444', 20); | |
| continue; | |
| } | |
| if (targetBuilding && targetBuilding.team !== TEAM_PLAYER) { | |
| u.state = 'attacking'; | |
| u.attackTarget = targetBuilding; | |
| u.path = []; | |
| addParticle(u.x, u.y, '#ff4444', 20); | |
| continue; | |
| } | |
| // Gather resources | |
| if (u.def.canGather && tile && (tile.type === TILE_GOLD || tile.type === TILE_FOREST)) { | |
| u.state = 'gathering'; | |
| u.gatherTarget = {x: tx, y: ty, type: tile.type}; | |
| u.targetX = tx * TILE_SIZE + TILE_SIZE/2; | |
| u.targetY = ty * TILE_SIZE + TILE_SIZE/2; | |
| u.path = findPath(u.x, u.y, u.targetX, u.targetY); | |
| u.carrying = 0; | |
| addParticle(u.x, u.y, '#ffd700', 20); | |
| continue; | |
| } | |
| // Build unfinished building | |
| if (u.def.canBuild && targetBuilding && targetBuilding.team === TEAM_PLAYER && !targetBuilding.built) { | |
| u.state = 'building'; | |
| u.buildTarget = targetBuilding; | |
| let bx = targetBuilding.tileX * TILE_SIZE + targetBuilding.def.size * TILE_SIZE/2; | |
| let by = targetBuilding.tileY * TILE_SIZE + targetBuilding.def.size * TILE_SIZE/2; | |
| u.path = findPath(u.x, u.y, bx, by); | |
| addParticle(u.x, u.y, '#44ff44', 20); | |
| continue; | |
| } | |
| // Move | |
| u.state = 'moving'; | |
| u.targetX = world.x; | |
| u.targetY = world.y; | |
| u.path = findPath(u.x, u.y, world.x, world.y); | |
| u.attackTarget = null; | |
| u.gatherTarget = null; | |
| addParticle(world.x, world.y, '#4488ff', 15); | |
| } | |
| } | |
| } | |
| function onMouseMove(e) { | |
| let rect = canvas.getBoundingClientRect(); | |
| let sx = e.clientX - rect.left, sy = e.clientY - rect.top; | |
| gameState.mouse.x = sx; | |
| gameState.mouse.y = sy; | |
| let world = screenToWorld(sx, sy); | |
| gameState.mouse.worldX = world.x; | |
| gameState.mouse.worldY = world.y; | |
| if (gameState.dragSelect) { | |
| gameState.dragEnd = {x: sx, y: sy}; | |
| } | |
| if (gameState.buildMode) { | |
| let tx = Math.floor(world.x / TILE_SIZE); | |
| let ty = Math.floor(world.y / TILE_SIZE); | |
| gameState.buildGhost = {tx, ty, type: gameState.buildMode}; | |
| } | |
| } | |
| function onMouseUp(e) { | |
| if (e.button === 0 && gameState.dragSelect) { | |
| gameState.dragSelect = false; | |
| let sx1 = Math.min(gameState.dragStart.x, gameState.dragEnd.x); | |
| let sy1 = Math.min(gameState.dragStart.y, gameState.dragEnd.y); | |
| let sx2 = Math.max(gameState.dragStart.x, gameState.dragEnd.x); | |
| let sy2 = Math.max(gameState.dragStart.y, gameState.dragEnd.y); | |
| if (sx2 - sx1 > 5 || sy2 - sy1 > 5) { | |
| deselectAll(); | |
| for (let u of gameState.units) { | |
| if (u.team !== TEAM_PLAYER) continue; | |
| let screenX = u.x - gameState.camera.x; | |
| let screenY = u.y - gameState.camera.y; | |
| if (screenX >= sx1 && screenX <= sx2 && screenY >= sy1 && screenY <= sy2) { | |
| u.selected = true; | |
| gameState.selection.units.push(u); | |
| } | |
| } | |
| updatePanel(); | |
| updateActionPanel(); | |
| } | |
| gameState.dragStart = null; | |
| gameState.dragEnd = null; | |
| } | |
| } | |
| function deselectAll() { | |
| for (let u of gameState.units) u.selected = false; | |
| for (let b of gameState.buildings) b.selected = false; | |
| gameState.selection.units = []; | |
| gameState.selection.building = null; | |
| } | |
| function handleHotkey(key) { | |
| if (key === 'escape') { | |
| gameState.buildMode = null; | |
| gameState.buildGhost = null; | |
| deselectAll(); | |
| updatePanel(); | |
| updateActionPanel(); | |
| return; | |
| } | |
| // Hotkeys based on action panel buttons | |
| const btns = document.querySelectorAll('.action-btn'); | |
| let idx = parseInt(key) - 1; | |
| if (idx >= 0 && idx < btns.length) { | |
| btns[idx].click(); | |
| } | |
| } | |
| // ==================== BUILD PLACEMENT ==================== | |
| function startBuild(type) { | |
| let cost = BUILDING_DEFS[type].cost; | |
| if (gameState.resources.gold < cost.gold || gameState.resources.wood < cost.wood) { | |
| showMessage('Not enough resources!'); | |
| return; | |
| } | |
| gameState.buildMode = type; | |
| gameState.buildGhost = null; | |
| showMessage('Click to place ' + BUILDING_DEFS[type].name + '. Right-click to cancel.'); | |
| } | |
| function placeBuild(sx, sy) { | |
| if (!gameState.buildMode) return; | |
| let ghost = gameState.buildGhost; | |
| if (!ghost) return; | |
| let def = BUILDING_DEFS[ghost.type]; | |
| let cost = def.cost; | |
| // Check if all tiles are clear | |
| for (let dy = 0; dy < def.size; dy++) { | |
| for (let dx = 0; dx < def.size; dx++) { | |
| let tx = ghost.tx + dx, ty = ghost.ty + dy; | |
| if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) { showMessage('Cannot build here!'); return; } | |
| let tile = gameState.map[ty][tx]; | |
| if (tile.type !== TILE_GRASS && tile.type !== TILE_DIRT) { showMessage('Cannot build here!'); return; } | |
| if (tile.occupied) { showMessage('Area occupied!'); return; } | |
| } | |
| } | |
| if (gameState.resources.gold < cost.gold || gameState.resources.wood < cost.wood) { | |
| showMessage('Not enough resources!'); return; | |
| } | |
| gameState.resources.gold -= cost.gold; | |
| gameState.resources.wood -= cost.wood; | |
| let b = new Building(ghost.type, ghost.tx, ghost.ty, TEAM_PLAYER); | |
| b.built = false; | |
| b.buildProgress = 0; | |
| gameState.buildings.push(b); | |
| // Send nearby workers to build | |
| for (let u of gameState.units) { | |
| if (u.team === TEAM_PLAYER && u.def.canBuild && u.state === 'idle') { | |
| u.state = 'building'; | |
| u.buildTarget = b; | |
| let bx = b.tileX * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let by = b.tileY * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| u.path = findPath(u.x, u.y, bx, by); | |
| } | |
| } | |
| gameState.buildMode = null; | |
| gameState.buildGhost = null; | |
| updateActionPanel(); | |
| showMessage(def.name + ' placement started. Workers are heading to build.'); | |
| } | |
| function canPlaceBuild(type, tx, ty) { | |
| let def = BUILDING_DEFS[type]; | |
| for (let dy = 0; dy < def.size; dy++) { | |
| for (let dx = 0; dx < def.size; dx++) { | |
| let cx = tx + dx, cy = ty + dy; | |
| if (cx < 0 || cx >= MAP_W || cy < 0 || cy >= MAP_H) return false; | |
| let tile = gameState.map[cy][cx]; | |
| if (tile.type !== TILE_GRASS && tile.type !== TILE_DIRT) return false; | |
| if (tile.occupied) return false; | |
| } | |
| } | |
| return true; | |
| } | |
| // ==================== UI UPDATES ==================== | |
| function updatePanel() { | |
| let panel = document.getElementById('panel'); | |
| let sel = gameState.selection; | |
| if (sel.units.length > 0) { | |
| if (sel.units.length === 1) { | |
| let u = sel.units[0]; | |
| let hpPct = Math.max(0, u.hp / u.maxHp * 100); | |
| let hpColor = hpPct > 60 ? '#4caf50' : hpPct > 30 ? '#ff9800' : '#f44336'; | |
| let stateText = u.state; | |
| if (u.state === 'gathering') stateText = 'Gathering ' + (u.carryType === 'gold' ? 'Gold' : 'Wood') + (u.carrying > 0 ? ' (' + u.carrying + ')' : ''); | |
| if (u.state === 'building') stateText = 'Building'; | |
| panel.innerHTML = `<h3>${u.def.name}</h3> | |
| <div class="info"> | |
| HP: ${Math.ceil(u.hp)}/${u.maxHp} | |
| <div class="hp-bar"><div class="hp-fill" style="width:${hpPct}%;background:${hpColor}"></div></div> | |
| Attack: ${u.getAttack()} | Speed: ${u.def.speed.toFixed(1)}<br> | |
| State: ${stateText}<br> | |
| ${u.def.canGather && u.carrying > 0 ? 'Carrying: ' + u.carrying + ' ' + u.carryType : ''} | |
| </div>`; | |
| } else { | |
| let counts = {}; | |
| sel.units.forEach(u => counts[u.type] = (counts[u.type]||0)+1); | |
| let html = '<h3>Multiple Units (' + sel.units.length + ')</h3><div class="info">'; | |
| for (let [k,v] of Object.entries(counts)) { | |
| html += UNIT_DEFS[k].name + ': ' + v + '<br>'; | |
| } | |
| html += '</div>'; | |
| panel.innerHTML = html; | |
| } | |
| } else if (sel.building) { | |
| let b = sel.building; | |
| let hpPct = Math.max(0, b.hp / b.maxHp * 100); | |
| let hpColor = hpPct > 60 ? '#4caf50' : hpPct > 30 ? '#ff9800' : '#f44336'; | |
| let progress = b.built ? '' : `<br>Build: ${Math.floor(b.buildProgress/b.buildMax*100)}%`; | |
| let training = ''; | |
| if (b.trainingQueue.length > 0) { | |
| training = '<br>Training: ' + b.trainingQueue.map(t => UNIT_DEFS[t].name).join(', '); | |
| training += `<br>Progress: ${Math.floor(b.trainingProgress)}%`; | |
| } | |
| panel.innerHTML = `<h3>${b.def.name}</h3> | |
| <div class="info"> | |
| HP: ${Math.ceil(b.hp)}/${b.maxHp} | |
| <div class="hp-bar"><div class="hp-fill" style="width:${hpPct}%;background:${hpColor}"></div></div> | |
| ${b.def.description}${progress}${training} | |
| </div>`; | |
| } else { | |
| panel.innerHTML = '<h3>Realm Conquest</h3><div class="info">Select a unit or building to begin.<br><br>Controls:<br>Left Click: Select<br>Right Click: Command<br>Drag: Box Select<br>WASD/Arrows: Scroll<br>1-9: Hotkeys<br>Esc: Cancel</div>'; | |
| } | |
| } | |
| function updateActionPanel() { | |
| let panel = document.getElementById('action-panel'); | |
| let sel = gameState.selection; | |
| let btns = ''; | |
| let idx = 1; | |
| if (sel.units.length > 0) { | |
| let hasWorker = sel.units.some(u => u.def.canBuild); | |
| let hasGatherer = sel.units.some(u => u.def.canGather); | |
| if (hasWorker) { | |
| for (let [key, def] of Object.entries(BUILDING_DEFS)) { | |
| let canAfford = gameState.resources.gold >= def.cost.gold && gameState.resources.wood >= def.cost.wood; | |
| btns += `<div class="action-btn ${canAfford?'':'" style="opacity:0.5'}" onclick="startBuild('${key}')" title="${def.name}: ${def.cost.gold}g ${def.cost.wood}w"> | |
| <span class="hotkey">${idx}</span> | |
| <span class="btn-icon">${getBuildingIcon(key)}</span> | |
| <span class="btn-label">${def.name}</span> | |
| <span class="btn-cost">${def.cost.gold}g ${def.cost.wood}w</span> | |
| </div>`; | |
| idx++; | |
| } | |
| } | |
| // Stop button | |
| btns += `<div class="action-btn" onclick="stopSelected()" title="Stop"> | |
| <span class="hotkey">${idx}</span> | |
| <span class="btn-icon">⏹</span> | |
| <span class="btn-label">Stop</span> | |
| </div>`; | |
| } else if (sel.building && sel.building.team === TEAM_PLAYER) { | |
| let b = sel.building; | |
| if (b.built && b.def.canTrain.length > 0) { | |
| for (let unitType of b.def.canTrain) { | |
| let def = UNIT_DEFS[unitType]; | |
| let canAfford = gameState.resources.gold >= def.cost.gold && gameState.resources.wood >= def.cost.wood && gameState.resources.food >= def.cost.food; | |
| btns += `<div class="action-btn ${canAfford?'':'" style="opacity:0.5'}" onclick="trainUnit('${unitType}')" title="${def.name}: ${def.cost.gold}g ${def.cost.wood}w ${def.cost.food}f"> | |
| <span class="hotkey">${idx}</span> | |
| <span class="btn-icon">${getUnitIcon(unitType)}</span> | |
| <span class="btn-label">${def.name}</span> | |
| <span class="btn-cost">${def.cost.gold}g ${def.cost.wood}w</span> | |
| </div>`; | |
| idx++; | |
| } | |
| } | |
| // Blacksmith upgrade | |
| if (b.built && b.type === 'blacksmith') { | |
| let cost = 150; | |
| let canAfford = gameState.resources.gold >= cost; | |
| btns += `<div class="action-btn ${canAfford?'':'" style="opacity:0.5'}" onclick="upgradeAttack()" title="Upgrade Attack +3 (150g)"> | |
| <span class="hotkey">${idx}</span> | |
| <span class="btn-icon">⚔</span> | |
| <span class="btn-label">Atk Up</span> | |
| <span class="btn-cost">${cost}g</span> | |
| </div>`; | |
| } | |
| } | |
| panel.innerHTML = btns; | |
| } | |
| function getBuildingIcon(type) { | |
| const icons = {townhall:'🏰',barracks:'⚔',farm:'🌾',tower:'🗼',lumbermill:'🪵',blacksmith:'🔨'}; | |
| return icons[type] || '🏠'; | |
| } | |
| function getUnitIcon(type) { | |
| const icons = {worker:'👷',soldier:'🗡',archer:'🏹',knight:'🛡'}; | |
| return icons[type] || '👤'; | |
| } | |
| function stopSelected() { | |
| for (let u of gameState.selection.units) { | |
| u.state = 'idle'; | |
| u.path = []; | |
| u.attackTarget = null; | |
| u.gatherTarget = null; | |
| u.buildTarget = null; | |
| } | |
| } | |
| function trainUnit(type) { | |
| let b = gameState.selection.building; | |
| if (!b || !b.built) return; | |
| let def = UNIT_DEFS[type]; | |
| if (gameState.resources.gold < def.cost.gold || gameState.resources.wood < def.cost.wood) { | |
| showMessage('Not enough resources!'); return; | |
| } | |
| let supply = getSupplyUsed(); | |
| let supplyMax = getSupplyMax(); | |
| if (supply + def.supply > supplyMax) { | |
| showMessage('Not enough supply! Build more Farms or Town Halls.'); | |
| return; | |
| } | |
| gameState.resources.gold -= def.cost.gold; | |
| gameState.resources.wood -= def.cost.wood; | |
| b.trainingQueue.push(type); | |
| if (b.trainingQueue.length === 1) b.trainingProgress = 0; | |
| updateActionPanel(); | |
| } | |
| function upgradeAttack() { | |
| if (gameState.resources.gold < 150) { showMessage('Not enough gold!'); return; } | |
| gameState.resources.gold -= 150; | |
| gameState.upgradeAttack += 3; | |
| showMessage('Attack upgraded! All units +3 attack.'); | |
| updateActionPanel(); | |
| } | |
| function getSupplyUsed() { | |
| return gameState.units.filter(u => u.team === TEAM_PLAYER).reduce((sum, u) => sum + u.def.supply, 0); | |
| } | |
| function getSupplyMax() { | |
| return gameState.buildings.filter(b => b.team === TEAM_PLAYER && b.built).reduce((sum, b) => sum + b.def.providesSupply, 0); | |
| } | |
| function addParticle(x, y, color, life) { | |
| for (let i = 0; i < 5; i++) { | |
| gameState.particles.push(new Particle(x, y, color, life || 30)); | |
| } | |
| } | |
| function showMessage(text, duration) { | |
| let area = document.getElementById('message-area'); | |
| let msg = document.createElement('div'); | |
| msg.className = 'message'; | |
| msg.textContent = text; | |
| msg.style.animationDuration = (duration || 3000) / 1000 + 's'; | |
| area.appendChild(msg); | |
| setTimeout(() => msg.remove(), duration || 3000); | |
| } | |
| // ==================== GAME LOGIC UPDATE ==================== | |
| function update() { | |
| if (gameState.gameOver) return; | |
| gameState.time++; | |
| let dt = 1/60; | |
| // Camera scrolling | |
| let scrollSpeed = 6; | |
| if (gameState.keys['w'] || gameState.keys['arrowup']) gameState.camera.y -= scrollSpeed; | |
| if (gameState.keys['s'] || gameState.keys['arrowdown']) gameState.camera.y += scrollSpeed; | |
| if (gameState.keys['a'] || gameState.keys['arrowleft']) gameState.camera.x -= scrollSpeed; | |
| if (gameState.keys['d'] || gameState.keys['arrowright']) gameState.camera.x += scrollSpeed; | |
| // Edge scrolling | |
| let edgeSize = 20; | |
| if (gameState.mouse.x < edgeSize) gameState.camera.x -= scrollSpeed; | |
| if (gameState.mouse.x > canvas.width - edgeSize) gameState.camera.x += scrollSpeed; | |
| if (gameState.mouse.y < 40 + edgeSize && gameState.mouse.y > 40) gameState.camera.y -= scrollSpeed; | |
| if (gameState.mouse.y > canvas.height - edgeSize) gameState.camera.y += scrollSpeed; | |
| // Clamp camera | |
| gameState.camera.x = Math.max(0, Math.min(MAP_W * TILE_SIZE - canvas.width, gameState.camera.x)); | |
| gameState.camera.y = Math.max(0, Math.min(MAP_H * TILE_SIZE - canvas.height, gameState.camera.y)); | |
| // Update units | |
| for (let u of gameState.units) { | |
| u.animTimer++; | |
| if (u.animTimer > 8) { u.animTimer = 0; u.animFrame = (u.animFrame + 1) % 4; } | |
| if (u.state === 'moving') { | |
| moveAlongPath(u); | |
| } else if (u.state === 'gathering') { | |
| updateGathering(u); | |
| } else if (u.state === 'attacking') { | |
| updateAttacking(u); | |
| } else if (u.state === 'building') { | |
| updateBuilding(u); | |
| } | |
| // Auto-attack nearby enemies when idle | |
| if (u.state === 'idle' && u.team === TEAM_PLAYER) { | |
| let nearest = findNearestEnemy(u, u.def.attackRange * TILE_SIZE * 1.5); | |
| if (nearest) { | |
| u.attackTarget = nearest; | |
| u.state = 'attacking'; | |
| } | |
| } | |
| } | |
| // Update buildings | |
| for (let b of gameState.buildings) { | |
| // Training | |
| if (b.built && b.trainingQueue.length > 0) { | |
| let trainType = b.trainingQueue[0]; | |
| let trainDef = UNIT_DEFS[trainType]; | |
| b.trainingProgress += 100 / trainDef.buildTime; | |
| if (b.trainingProgress >= 100) { | |
| b.trainingQueue.shift(); | |
| b.trainingProgress = 0; | |
| // Spawn unit | |
| let spawnX = b.rallyX || (b.tileX * TILE_SIZE + b.def.size * TILE_SIZE / 2); | |
| let spawnY = b.rallyY || (b.tileY * TILE_SIZE + b.def.size * TILE_SIZE + TILE_SIZE); | |
| // Find walkable spot near rally | |
| let stx = Math.floor(spawnX / TILE_SIZE), sty = Math.floor(spawnY / TILE_SIZE); | |
| for (let r = 0; r < 5; r++) { | |
| for (let dy = -r; dy <= r; dy++) { | |
| for (let dx = -r; dx <= r; dx++) { | |
| if (isWalkable(stx+dx, sty+dy)) { | |
| spawnX = (stx+dx) * TILE_SIZE + TILE_SIZE/2; | |
| spawnY = (sty+dy) * TILE_SIZE + TILE_SIZE/2; | |
| r = 5; dy = r+1; dx = r+1; break; | |
| } | |
| } | |
| } | |
| } | |
| let newUnit = new Unit(trainType, spawnX, spawnY, b.team); | |
| gameState.units.push(newUnit); | |
| if (b.team === TEAM_PLAYER) showMessage(trainDef.name + ' trained!'); | |
| } | |
| } | |
| // Defensive towers attack | |
| if (b.built && b.def.isDefensive && b.team === TEAM_PLAYER) { | |
| b.attackTimer = (b.attackTimer || 0) + dt; | |
| if (b.attackTimer >= 1 / b.def.attackSpeed) { | |
| b.attackTimer = 0; | |
| let range = b.def.attackRange * TILE_SIZE; | |
| let target = findNearestEnemyBuilding(b, range); | |
| if (target) { | |
| let bx = b.tileX * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let by = b.tileY * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| gameState.projectiles.push(new Projectile(bx, by, target.x || target.tileX*TILE_SIZE+TILE_SIZE, target.y || target.tileY*TILE_SIZE+TILE_SIZE, b.def.attack, b.team)); | |
| target.hp -= b.def.attack; | |
| addParticle(target.x || target.tileX*TILE_SIZE+TILE_SIZE, target.y || target.tileY*TILE_SIZE+TILE_SIZE, '#ffa500', 15); | |
| } | |
| } | |
| } | |
| } | |
| // Update projectiles | |
| for (let i = gameState.projectiles.length - 1; i >= 0; i--) { | |
| let p = gameState.projectiles[i]; | |
| let dx = p.tx - p.x, dy = p.ty - p.y; | |
| let dist = Math.sqrt(dx*dx + dy*dy); | |
| if (dist < p.speed * 2) { | |
| gameState.projectiles.splice(i, 1); | |
| continue; | |
| } | |
| p.x += (dx/dist) * p.speed; | |
| p.y += (dy/dist) * p.speed; | |
| p.life--; | |
| if (p.life <= 0) gameState.projectiles.splice(i, 1); | |
| } | |
| // Update particles | |
| for (let i = gameState.particles.length - 1; i >= 0; i--) { | |
| let p = gameState.particles[i]; | |
| p.x += p.vx; p.y += p.vy; | |
| p.vy += 0.05; | |
| p.life--; | |
| if (p.life <= 0) gameState.particles.splice(i, 1); | |
| } | |
| // Remove dead entities | |
| for (let i = gameState.units.length - 1; i >= 0; i--) { | |
| if (gameState.units[i].hp <= 0) { | |
| let u = gameState.units[i]; | |
| addParticle(u.x, u.y, '#ff0000', 30); | |
| // Free tiles if needed | |
| gameState.units.splice(i, 1); | |
| } | |
| } | |
| for (let i = gameState.buildings.length - 1; i >= 0; i--) { | |
| if (gameState.buildings[i].hp <= 0) { | |
| let b = gameState.buildings[i]; | |
| // Free tiles | |
| for (let dy = 0; dy < b.def.size; dy++) { | |
| for (let dx = 0; dx < b.def.size; dx++) { | |
| let tx = b.tileX + dx, ty = b.tileY + dy; | |
| if (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H) { | |
| gameState.map[ty][tx].occupied = false; | |
| } | |
| } | |
| } | |
| addParticle(b.tileX*TILE_SIZE+TILE_SIZE, b.tileY*TILE_SIZE+TILE_SIZE, '#ff4400', 40); | |
| gameState.buildings.splice(i, 1); | |
| } | |
| } | |
| // Update selection (remove dead) | |
| gameState.selection.units = gameState.selection.units.filter(u => u.hp > 0); | |
| if (gameState.selection.building && gameState.selection.building.hp <= 0) { | |
| gameState.selection.building = null; | |
| } | |
| // Check win/lose | |
| let playerTH = gameState.buildings.find(b => b.team === TEAM_PLAYER && b.type === 'townhall'); | |
| let enemyTH = gameState.buildings.find(b => b.team === TEAM_ENEMY && b.type === 'townhall'); | |
| if (!playerTH && !gameState.gameOver) { | |
| gameState.gameOver = true; | |
| showMessage('DEFEAT - Your town hall has been destroyed!', 10000); | |
| } | |
| if (!enemyTH && !gameState.gameOver) { | |
| gameState.gameOver = true; | |
| showMessage('VICTORY - Enemy town hall destroyed!', 10000); | |
| } | |
| // Enemy AI (simple) | |
| updateEnemyAI(); | |
| // Update resource display | |
| document.getElementById('res-gold').textContent = Math.floor(gameState.resources.gold); | |
| document.getElementById('res-wood').textContent = Math.floor(gameState.resources.wood); | |
| document.getElementById('res-food').textContent = Math.floor(gameState.resources.food); | |
| document.getElementById('res-supply').textContent = getSupplyUsed() + '/' + getSupplyMax(); | |
| document.getElementById('pop-text').textContent = 'Units: ' + gameState.units.filter(u=>u.team===TEAM_PLAYER).length; | |
| // Update panel periodically | |
| if (gameState.time % 30 === 0) { | |
| updatePanel(); | |
| updateActionPanel(); | |
| } | |
| } | |
| function moveAlongPath(u) { | |
| if (u.path.length === 0) { | |
| u.state = 'idle'; | |
| return; | |
| } | |
| let target = u.path[0]; | |
| let dx = target.x - u.x, dy = target.y - u.y; | |
| let dist = Math.sqrt(dx*dx + dy*dy); | |
| if (dist < u.def.speed * 2) { | |
| u.path.shift(); | |
| if (u.path.length === 0) u.state = 'idle'; | |
| return; | |
| } | |
| let moveX = (dx/dist) * u.def.speed; | |
| let moveY = (dy/dist) * u.def.speed; | |
| u.x += moveX; | |
| u.y += moveY; | |
| if (dx !== 0) u.facing = dx > 0 ? 1 : -1; | |
| } | |
| function updateGathering(u) { | |
| if (!u.gatherTarget) { u.state = 'idle'; return; } | |
| let gt = u.gatherTarget; | |
| let tx = gt.x * TILE_SIZE + TILE_SIZE/2; | |
| let ty = gt.y * TILE_SIZE + TILE_SIZE/2; | |
| // If carrying, return to nearest town hall / lumber mill | |
| if (u.carrying > 0) { | |
| let returnBuilding = findNearestReturnBuilding(u, u.carryType); | |
| if (!returnBuilding) { u.state = 'idle'; return; } | |
| let bx = returnBuilding.tileX * TILE_SIZE + returnBuilding.def.size * TILE_SIZE/2; | |
| let by = returnBuilding.tileY * TILE_SIZE + returnBuilding.def.size * TILE_SIZE/2; | |
| let dx = bx - u.x, dy = by - u.y; | |
| let dist = Math.sqrt(dx*dx + dy*dy); | |
| if (dist < TILE_SIZE * 1.5) { | |
| // Deposit | |
| let amount = u.carrying; | |
| if (u.carryType === 'gold') gameState.resources.gold += amount; | |
| else if (u.carryType === 'wood') gameState.resources.wood += amount; | |
| u.carrying = 0; | |
| u.carryType = null; | |
| // Go back to gather | |
| u.path = findPath(u.x, u.y, tx, ty); | |
| u.gatherTimer = 0; | |
| } else { | |
| if (u.path.length === 0 || u.state !== 'gathering') { | |
| u.path = findPath(u.x, u.y, bx, by); | |
| } | |
| moveAlongPath(u); | |
| u.state = 'gathering'; | |
| } | |
| return; | |
| } | |
| // Move to resource | |
| let dx = tx - u.x, dy = ty - u.y; | |
| let dist = Math.sqrt(dx*dx + dy*dy); | |
| if (dist > TILE_SIZE * 1.5) { | |
| if (u.path.length === 0) { | |
| u.path = findPath(u.x, u.y, tx, ty); | |
| } | |
| moveAlongPath(u); | |
| u.state = 'gathering'; | |
| return; | |
| } | |
| // Gather | |
| u.gatherTimer++; | |
| if (u.gatherTimer >= 40) { // ~0.67 seconds per gather | |
| u.gatherTimer = 0; | |
| let tile = gameState.map[gt.y] && gameState.map[gt.y][gt.x]; | |
| if (!tile || (tile.type !== TILE_GOLD && tile.type !== TILE_FOREST) || tile.resource <= 0) { | |
| // Find another resource nearby | |
| let found = findNearbyResource(u.x, u.y, gt.type); | |
| if (found) { | |
| u.gatherTarget = found; | |
| u.path = findPath(u.x, u.y, found.x * TILE_SIZE + TILE_SIZE/2, found.y * TILE_SIZE + TILE_SIZE/2); | |
| } else { | |
| u.state = 'idle'; | |
| u.gatherTarget = null; | |
| } | |
| return; | |
| } | |
| let gatherAmount = 8; | |
| tile.resource -= gatherAmount; | |
| if (tile.resource <= 0) { | |
| tile.type = TILE_DIRT; | |
| tile.resource = 0; | |
| } | |
| u.carrying = gatherAmount; | |
| u.carryType = gt.type === TILE_GOLD ? 'gold' : 'wood'; | |
| addParticle(u.x, u.y, u.carryType === 'gold' ? '#ffd700' : '#8B4513', 15); | |
| } | |
| } | |
| function findNearbyResource(x, y, type) { | |
| let tx = Math.floor(x / TILE_SIZE), ty = Math.floor(y / TILE_SIZE); | |
| let best = null, bestDist = Infinity; | |
| for (let r = 0; r < 15; r++) { | |
| for (let dy = -r; dy <= r; dy++) { | |
| for (let dx = -r; dx <= r; dx++) { | |
| let nx = tx+dx, ny = ty+dy; | |
| if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H) continue; | |
| let tile = gameState.map[ny][nx]; | |
| if (tile.type === type && tile.resource > 0) { | |
| let d = dx*dx + dy*dy; | |
| if (d < bestDist) { bestDist = d; best = {x:nx, y:ny, type: tile.type}; } | |
| } | |
| } | |
| } | |
| if (best) break; | |
| } | |
| return best; | |
| } | |
| function findNearestReturnBuilding(u, carryType) { | |
| let best = null, bestDist = Infinity; | |
| for (let b of gameState.buildings) { | |
| if (b.team !== TEAM_PLAYER || !b.built) continue; | |
| if (b.type === 'townhall') { | |
| let bx = b.tileX * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let by = b.tileY * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let d = Math.hypot(bx-u.x, by-u.y); | |
| if (d < bestDist) { bestDist = d; best = b; } | |
| } | |
| if (carryType === 'wood' && b.type === 'lumbermill') { | |
| let bx = b.tileX * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let by = b.tileY * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let d = Math.hypot(bx-u.x, by-u.y); | |
| if (d < bestDist) { bestDist = d; best = b; } | |
| } | |
| } | |
| return best; | |
| } | |
| function updateAttacking(u) { | |
| let target = u.attackTarget; | |
| if (!target || target.hp <= 0) { | |
| u.attackTarget = null; | |
| u.state = 'idle'; | |
| return; | |
| } | |
| let tx, ty; | |
| if (target instanceof Unit) { tx = target.x; ty = target.y; } | |
| else { tx = target.tileX * TILE_SIZE + target.def.size * TILE_SIZE/2; ty = target.tileY * TILE_SIZE + target.def.size * TILE_SIZE/2; } | |
| let dx = tx - u.x, dy = ty - u.y; | |
| let dist = Math.sqrt(dx*dx + dy*dy); | |
| let range = u.def.attackRange * TILE_SIZE; | |
| if (dist > range) { | |
| // Move toward target | |
| if (u.path.length === 0) { | |
| u.path = findPath(u.x, u.y, tx, ty); | |
| } | |
| moveAlongPath(u); | |
| u.state = 'attacking'; | |
| u.attackTarget = target; | |
| } else { | |
| // Attack | |
| u.attackTimer += 1/60; | |
| if (u.attackTimer >= 1 / u.def.attackSpeed) { | |
| u.attackTimer = 0; | |
| target.hp -= u.getAttack(); | |
| addParticle(tx, ty, '#ff4444', 10); | |
| if (u.def.attackRange > 1) { | |
| gameState.projectiles.push(new Projectile(u.x, u.y, tx, ty, u.getAttack(), u.team)); | |
| } | |
| } | |
| } | |
| } | |
| function updateBuilding(u) { | |
| if (!u.buildTarget || u.buildTarget.hp <= 0) { | |
| u.buildTarget = null; | |
| u.state = 'idle'; | |
| return; | |
| } | |
| let b = u.buildTarget; | |
| let bx = b.tileX * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let by = b.tileY * TILE_SIZE + b.def.size * TILE_SIZE/2; | |
| let dx = bx - u.x, dy = by - u.y; | |
| let dist = Math.sqrt(dx*dx + dy*dy); | |
| if (dist > TILE_SIZE * 2) { | |
| if (u.path.length === 0) { | |
| u.path = findPath(u.x, u.y, bx, by); | |
| } | |
| moveAlongPath(u); | |
| u.state = 'building'; | |
| } else { | |
| // Build | |
| if (!b.built) { | |
| b.buildProgress += 1; | |
| if (b.buildProgress >= b.buildMax) { | |
| b.built = true; | |
| if (b.team === TEAM_PLAYER) showMessage(b.def.name + ' completed!'); | |
| addParticle(bx, by, '#44ff44', 25); | |
| } | |
| } | |
| } | |
| } | |
| function findNearestEnemy(u, range) { | |
| let best = null, bestDist = range * range; | |
| for (let e of gameState.units) { | |
| if (e.team === u.team) continue; | |
| let d = Math.hypot(e.x-u.x, e.y-u.y); | |
| if (d < bestDist) { bestDist = d; best = e; } | |
| } | |
| for (let b of gameState.buildings) { | |
| if (b.team === u.team) continue; | |
| let bx = b.tileX*TILE_SIZE + b.def.size*TILE_SIZE/2; | |
| let by = b.tileY*TILE_SIZE + b.def.size*TILE_SIZE/2; | |
| let d = Math.hypot(bx-u.x, by-u.y); | |
| if (d < bestDist) { bestDist = d; best = b; } | |
| } | |
| return best; | |
| } | |
| function findNearestEnemyBuilding(b, range) { | |
| let best = null, bestDist = range * range; | |
| let bx = b.tileX*TILE_SIZE + b.def.size*TILE_SIZE/2; | |
| let by = b.tileY*TILE_SIZE + b.def.size*TILE_SIZE/2; | |
| for (let e of gameState.units) { | |
| if (e.team === b.team) continue; | |
| let d = Math.hypot(e.x-bx, e.y-by); | |
| if (d < bestDist) { bestDist = d; best = e; } | |
| } | |
| return best; | |
| } | |
| // ==================== ENEMY AI (Simple) ==================== | |
| let enemyTimer = 0; | |
| function updateEnemyAI() { | |
| enemyTimer++; | |
| if (enemyTimer % 600 !== 0) return; // Every ~10 seconds | |
| let enemyUnits = gameState.units.filter(u => u.team === TEAM_ENEMY); | |
| let enemyBuildings = gameState.buildings.filter(b => b.team === TEAM_ENEMY); | |
| if (enemyUnits.length < 8) { | |
| // Try to train from enemy barracks | |
| let enemyTH = enemyBuildings.find(b => b.type === 'townhall' && b.built); | |
| if (enemyTH && enemyTH.trainingQueue.length < 2) { | |
| let types = ['soldier', 'soldier', 'archer']; | |
| let type = types[Math.floor(Math.random() * types.length)]; | |
| enemyTH.trainingQueue.push(type); | |
| if (enemyTH.trainingQueue.length === 1) enemyTH.trainingProgress = 0; | |
| } | |
| } | |
| // Send idle enemy units to attack occasionally | |
| if (enemyTimer % 1800 === 0 && enemyUnits.length >= 3) { | |
| let playerBuildings = gameState.buildings.filter(b => b.team === TEAM_PLAYER); | |
| if (playerBuildings.length > 0) { | |
| let target = playerBuildings[Math.floor(Math.random() * playerBuildings.length)]; | |
| let tx = target.tileX * TILE_SIZE + target.def.size * TILE_SIZE/2; | |
| let ty = target.tileY * TILE_SIZE + target.def.size * TILE_SIZE/2; | |
| for (let u of enemyUnits) { | |
| if (u.state === 'idle') { | |
| u.state = 'attacking'; | |
| u.attackTarget = target; | |
| u.path = findPath(u.x, u.y, tx, ty); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ==================== RENDERING ==================== | |
| const TILE_COLORS = { | |
| [TILE_GRASS]: ['#4a8c3f', '#4e9443', '#468a3b', '#529a47'], | |
| [TILE_FOREST]: ['#2d5a1e', '#2b5519', '#336020', '#2a5018'], | |
| [TILE_GOLD]: ['#c4a035', '#b89830', '#d4aa3a', '#c8a238'], | |
| [TILE_WATER]: ['#2980b9', '#2573a7', '#2e8cc5', '#2170a0'], | |
| [TILE_ROCK]: ['#6b6b6b', '#5f5f5f', '#757575', '#585858'], | |
| [TILE_DIRT]: ['#8B7355', '#7d6848', '#937c5e', '#7a6345'] | |
| }; | |
| function render() { | |
| ctx.fillStyle = '#1a1a2e'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| let cam = gameState.camera; | |
| let startTX = Math.floor(cam.x / TILE_SIZE); | |
| let startTY = Math.floor(cam.y / TILE_SIZE); | |
| let endTX = Math.ceil((cam.x + canvas.width) / TILE_SIZE); | |
| let endTY = Math.ceil((cam.y + canvas.height) / TILE_SIZE); | |
| // Draw tiles | |
| for (let ty = startTY; ty <= endTY; ty++) { | |
| for (let tx = startTX; tx <= endTX; tx++) { | |
| if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) continue; | |
| let tile = gameState.map[ty][tx]; | |
| let sx = tx * TILE_SIZE - cam.x; | |
| let sy = ty * TILE_SIZE - cam.y; | |
| let colors = TILE_COLORS[tile.type] || TILE_COLORS[TILE_GRASS]; | |
| let ci = (tx * 7 + ty * 13) % colors.length; | |
| ctx.fillStyle = colors[ci]; | |
| ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE); | |
| // Tile details | |
| if (tile.type === TILE_FOREST) { | |
| drawTree(sx + TILE_SIZE/2, sy + TILE_SIZE/2, tile.resource); | |
| } else if (tile.type === TILE_GOLD) { | |
| drawGoldMine(sx + TILE_SIZE/2, sy + TILE_SIZE/2, tile.resource); | |
| } else if (tile.type === TILE_WATER) { | |
| // Wave animation | |
| ctx.fillStyle = 'rgba(255,255,255,0.08)'; | |
| let wave = Math.sin(gameState.time * 0.03 + tx * 0.5 + ty * 0.3) * 3; | |
| ctx.fillRect(sx + 4 + wave, sy + 12, 12, 3); | |
| ctx.fillRect(sx + 12 - wave, sy + 22, 10, 2); | |
| } else if (tile.type === TILE_ROCK) { | |
| drawRock(sx + TILE_SIZE/2, sy + TILE_SIZE/2); | |
| } else if (tile.type === TILE_GRASS) { | |
| // Occasional grass detail | |
| if ((tx * 3 + ty * 7) % 11 === 0) { | |
| ctx.fillStyle = 'rgba(80,140,60,0.5)'; | |
| ctx.fillRect(sx + 8, sy + 14, 2, 6); | |
| ctx.fillRect(sx + 20, sy + 10, 2, 8); | |
| } | |
| } | |
| // Grid lines (subtle) | |
| ctx.strokeStyle = 'rgba(0,0,0,0.08)'; | |
| ctx.strokeRect(sx, sy, TILE_SIZE, TILE_SIZE); | |
| } | |
| } | |
| // Draw build ghost | |
| if (gameState.buildGhost && gameState.buildMode) { | |
| let ghost = gameState.buildGhost; | |
| let def = BUILDING_DEFS[ghost.type]; | |
| let canPlace = canPlaceBuild(ghost.type, ghost.tx, ghost.ty); | |
| let sx = ghost.tx * TILE_SIZE - cam.x; | |
| let sy = ghost.ty * TILE_SIZE - cam.y; | |
| ctx.fillStyle = canPlace ? 'rgba(0,200,0,0.3)' : 'rgba(200,0,0,0.3)'; | |
| ctx.fillRect(sx, sy, def.size * TILE_SIZE, def.size * TILE_SIZE); | |
| ctx.strokeStyle = canPlace ? '#0f0' : '#f00'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(sx, sy, def.size * TILE_SIZE, def.size * TILE_SIZE); | |
| ctx.lineWidth = 1; | |
| // Draw building preview | |
| drawBuildingAt(ghost.type, sx, sy, def.size * TILE_SIZE, 0.4); | |
| } | |
| // Draw buildings | |
| for (let b of gameState.buildings) { | |
| let sx = b.tileX * TILE_SIZE - cam.x; | |
| let sy = b.tileY * TILE_SIZE - cam.y; | |
| let bw = b.def.size * TILE_SIZE; | |
| // Skip if off screen | |
| if (sx + bw < 0 || sy + bw < 0 || sx > canvas.width || sy > canvas.height) continue; | |
| if (!b.built) { | |
| // Scaffold effect | |
| ctx.globalAlpha = 0.5 + 0.3 * (b.buildProgress / b.buildMax); | |
| } | |
| drawBuildingAt(b.type, sx, sy, bw, 1); | |
| if (!b.built) { | |
| ctx.globalAlpha = 1; | |
| // Build progress bar | |
| let pct = b.buildProgress / b.buildMax; | |
| ctx.fillStyle = 'rgba(0,0,0,0.7)'; | |
| ctx.fillRect(sx, sy - 8, bw, 6); | |
| ctx.fillStyle = '#4caf50'; | |
| ctx.fillRect(sx, sy - 8, bw * pct, 6); | |
| ctx.strokeStyle = '#333'; | |
| ctx.strokeRect(sx, sy - 8, bw, 6); | |
| } | |
| // Training progress | |
| if (b.trainingQueue.length > 0) { | |
| let pct = b.trainingProgress / 100; | |
| ctx.fillStyle = 'rgba(0,0,0,0.7)'; | |
| ctx.fillRect(sx, sy - (b.built ? 8 : 16), bw, 5); | |
| ctx.fillStyle = '#2196f3'; | |
| ctx.fillRect(sx, sy - (b.built ? 8 : 16), bw * pct, 5); | |
| } | |
| // Selection highlight | |
| if (b.selected) { | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(sx - 2, sy - 2, bw + 4, bw + 4); | |
| ctx.lineWidth = 1; | |
| // Rally point | |
| if (b.rallyX && b.rallyY) { | |
| let rx = b.rallyX - cam.x, ry = b.rallyY - cam.y; | |
| ctx.fillStyle = '#0f0'; | |
| ctx.beginPath(); | |
| ctx.moveTo(rx, ry-6); | |
| ctx.lineTo(rx+5, ry+4); | |
| ctx.lineTo(rx-5, ry+4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| } | |
| // Team color indicator | |
| if (b.team === TEAM_ENEMY) { | |
| ctx.fillStyle = 'rgba(255,0,0,0.4)'; | |
| ctx.fillRect(sx + 2, sy + 2, 8, 8); | |
| } | |
| // HP bar (when damaged) | |
| if (b.hp < b.maxHp) { | |
| let pct = b.hp / b.maxHp; | |
| ctx.fillStyle = 'rgba(0,0,0,0.7)'; | |
| ctx.fillRect(sx, sy + bw + 2, bw, 4); | |
| ctx.fillStyle = pct > 0.6 ? '#4caf50' : pct > 0.3 ? '#ff9800' : '#f44336'; | |
| ctx.fillRect(sx, sy + bw + 2, bw * pct, 4); | |
| } | |
| } | |
| // Draw units | |
| for (let u of gameState.units) { | |
| let sx = u.x - cam.x; | |
| let sy = u.y - cam.y; | |
| if (sx < -30 || sy < -30 || sx > canvas.width + 30 || sy > canvas.height + 30) continue; | |
| drawUnit(u, sx, sy); | |
| } | |
| // Draw projectiles | |
| for (let p of gameState.projectiles) { | |
| let sx = p.x - cam.x, sy = p.y - cam.y; | |
| ctx.fillStyle = '#ffa500'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#ff0'; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy, 1.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| // Draw particles | |
| for (let p of gameState.particles) { | |
| let sx = p.x - cam.x, sy = p.y - cam.y; | |
| let alpha = p.life / p.maxLife; | |
| ctx.globalAlpha = alpha; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy, p.size * alpha, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| // Drag select box | |
| if (gameState.dragSelect && gameState.dragStart && gameState.dragEnd) { | |
| let x1 = Math.min(gameState.dragStart.x, gameState.dragEnd.x); | |
| let y1 = Math.min(gameState.dragStart.y, gameState.dragEnd.y); | |
| let x2 = Math.max(gameState.dragStart.x, gameState.dragEnd.x); | |
| let y2 = Math.max(gameState.dragStart.y, gameState.dragEnd.y); | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x1, y1, x2-x1, y2-y1); | |
| ctx.fillStyle = 'rgba(0,255,0,0.1)'; | |
| ctx.fillRect(x1, y1, x2-x1, y2-y1); | |
| } | |
| // Draw minimap | |
| renderMinimap(); | |
| } | |
| function drawTree(x, y, resource) { | |
| let health = resource / 200; | |
| let h = 10 + health * 8; | |
| // Trunk | |
| ctx.fillStyle = '#5D3A1A'; | |
| ctx.fillRect(x - 2, y - 2, 4, 8); | |
| // Canopy | |
| ctx.fillStyle = '#1B5E20'; | |
| ctx.beginPath(); | |
| ctx.arc(x, y - h/2 - 2, 7 + health * 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#2E7D32'; | |
| ctx.beginPath(); | |
| ctx.arc(x - 2, y - h/2, 5 + health * 2, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| function drawGoldMine(x, y, resource) { | |
| let health = resource / 1500; | |
| // Base | |
| ctx.fillStyle = '#8B6914'; | |
| ctx.beginPath(); | |
| ctx.arc(x, y + 2, 10, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Gold pile | |
| ctx.fillStyle = '#FFD700'; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 7 * health + 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Shine | |
| ctx.fillStyle = '#FFF176'; | |
| ctx.beginPath(); | |
| ctx.arc(x - 2, y - 2, 3 * health, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Sparkle | |
| if (gameState.time % 60 < 20) { | |
| ctx.fillStyle = '#FFF'; | |
| ctx.beginPath(); | |
| ctx.arc(x + 3, y - 3, 1.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawRock(x, y) { | |
| ctx.fillStyle = '#6b6b6b'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x - 10, y + 6); | |
| ctx.lineTo(x - 4, y - 10); | |
| ctx.lineTo(x + 8, y - 8); | |
| ctx.lineTo(x + 12, y + 4); | |
| ctx.lineTo(x + 2, y + 10); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.fillStyle = '#7a7a7a'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x - 2, y - 8); | |
| ctx.lineTo(x + 6, y - 6); | |
| ctx.lineTo(x + 4, y + 2); | |
| ctx.lineTo(x - 4, y + 0); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| function drawBuildingAt(type, sx, sy, size, alpha) { | |
| ctx.globalAlpha = alpha || 1; | |
| let def = BUILDING_DEFS[type]; | |
| let padding = 4; | |
| // Shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.3)'; | |
| ctx.fillRect(sx + 4, sy + 4, size - padding, size - padding); | |
| switch(type) { | |
| case 'townhall': | |
| // Base | |
| ctx.fillStyle = '#3F51B5'; | |
| ctx.fillRect(sx + padding, sy + padding, size - padding*2, size - padding*2); | |
| // Walls | |
| ctx.fillStyle = '#5C6BC0'; | |
| ctx.fillRect(sx + padding + 4, sy + padding + 4, size - padding*2 - 8, size - padding*2 - 8); | |
| // Roof | |
| ctx.fillStyle = '#1A237E'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + padding, sy + padding + 8); | |
| ctx.lineTo(sx + size/2, sy - 4); | |
| ctx.lineTo(sx + size - padding, sy + padding + 8); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Door | |
| ctx.fillStyle = '#8D6E63'; | |
| ctx.fillRect(sx + size/2 - 6, sy + size - padding - 16, 12, 16); | |
| // Flag | |
| ctx.fillStyle = '#F44336'; | |
| ctx.fillRect(sx + size/2, sy - 12, 12, 8); | |
| ctx.fillStyle = '#333'; | |
| ctx.fillRect(sx + size/2 - 1, sy - 16, 2, 16); | |
| // Windows | |
| ctx.fillStyle = '#FFEB3B'; | |
| ctx.fillRect(sx + padding + 10, sy + padding + 14, 8, 8); | |
| ctx.fillRect(sx + size - padding - 18, sy + padding + 14, 8, 8); | |
| break; | |
| case 'barracks': | |
| // Base | |
| ctx.fillStyle = '#B71C1C'; | |
| ctx.fillRect(sx + padding, sy + padding, size - padding*2, size - padding*2); | |
| // Walls | |
| ctx.fillStyle = '#E53935'; | |
| ctx.fillRect(sx + padding + 3, sy + padding + 3, size - padding*2 - 6, size - padding*2 - 6); | |
| // Roof line | |
| ctx.fillStyle = '#880E4F'; | |
| ctx.fillRect(sx + padding, sy + padding, size - padding*2, 6); | |
| // Door | |
| ctx.fillStyle = '#4E342E'; | |
| ctx.fillRect(sx + size/2 - 5, sy + size - padding - 12, 10, 12); | |
| // Weapons rack | |
| ctx.fillStyle = '#9E9E9E'; | |
| ctx.fillRect(sx + padding + 8, sy + padding + 12, 2, 16); | |
| ctx.fillRect(sx + padding + 14, sy + padding + 12, 2, 16); | |
| // Shield | |
| ctx.fillStyle = '#FF9800'; | |
| ctx.beginPath(); | |
| ctx.arc(sx + size - padding - 14, sy + padding + 18, 7, 0, Math.PI*2); | |
| ctx.fill(); | |
| break; | |
| case 'farm': | |
| // Field | |
| ctx.fillStyle = '#33691E'; | |
| ctx.fillRect(sx + padding, sy + padding, size - padding*2, size - padding*2); | |
| // Rows | |
| ctx.fillStyle = '#558B2F'; | |
| for (let i = 0; i < 4; i++) { | |
| ctx.fillRect(sx + padding + 4, sy + padding + 6 + i * 12, size - padding*2 - 8, 6); | |
| } | |
| // Barn | |
| ctx.fillStyle = '#D84315'; | |
| ctx.fillRect(sx + size - padding - 20, sy + padding + 2, 18, 18); | |
| // Barn roof | |
| ctx.fillStyle = '#BF360C'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + size - padding - 22, sy + padding + 4); | |
| ctx.lineTo(sx + size - padding - 11, sy + padding - 6); | |
| ctx.lineTo(sx + size - padding, sy + padding + 4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| break; | |
| case 'tower': | |
| // Base | |
| ctx.fillStyle = '#E65100'; | |
| ctx.fillRect(sx + 6, sy + 6, size - 12, size - 12); | |
| // Tower body | |
| ctx.fillStyle = '#FF8F00'; | |
| ctx.fillRect(sx + 8, sy + 2, size - 16, size - 10); | |
| // Battlements | |
| ctx.fillStyle = '#E65100'; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.fillRect(sx + 6 + i * 8, sy, 5, 6); | |
| } | |
| // Arrow slit | |
| ctx.fillStyle = '#1a1a1a'; | |
| ctx.fillRect(sx + size/2 - 1, sy + 10, 3, 10); | |
| // Base stones | |
| ctx.fillStyle = '#795548'; | |
| ctx.fillRect(sx + 4, sy + size - 8, size - 8, 4); | |
| break; | |
| case 'lumbermill': | |
| // Base | |
| ctx.fillStyle = '#4E342E'; | |
| ctx.fillRect(sx + padding, sy + padding, size - padding*2, size - padding*2); | |
| // Wood planks | |
| ctx.fillStyle = '#6D4C41'; | |
| ctx.fillRect(sx + padding + 3, sy + padding + 3, size - padding*2 - 6, size - padding*2 - 6); | |
| // Saw blade | |
| ctx.fillStyle = '#9E9E9E'; | |
| ctx.beginPath(); | |
| ctx.arc(sx + size/2, sy + size/2, 8, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#616161'; | |
| ctx.beginPath(); | |
| ctx.arc(sx + size/2, sy + size/2, 4, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Logs | |
| ctx.fillStyle = '#8D6E63'; | |
| ctx.fillRect(sx + padding + 6, sy + size - padding - 10, 16, 6); | |
| ctx.fillRect(sx + padding + 8, sy + size - padding - 16, 14, 6); | |
| break; | |
| case 'blacksmith': | |
| // Base | |
| ctx.fillStyle = '#37474F'; | |
| ctx.fillRect(sx + padding, sy + padding, size - padding*2, size - padding*2); | |
| // Interior | |
| ctx.fillStyle = '#546E7A'; | |
| ctx.fillRect(sx + padding + 3, sy + padding + 3, size - padding*2 - 6, size - padding*2 - 6); | |
| // Anvil | |
| ctx.fillStyle = '#78909C'; | |
| ctx.fillRect(sx + size/2 - 4, sy + size/2, 8, 10); | |
| ctx.fillRect(sx + size/2 - 6, sy + size/2 + 8, 12, 4); | |
| // Fire glow | |
| ctx.fillStyle = '#FF6F00'; | |
| ctx.beginPath(); | |
| ctx.arc(sx + padding + 14, sy + padding + 14, 6 + Math.sin(gameState.time*0.1)*2, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#FFAB00'; | |
| ctx.beginPath(); | |
| ctx.arc(sx + padding + 14, sy + padding + 14, 3, 0, Math.PI*2); | |
| ctx.fill(); | |
| break; | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| function drawUnit(u, sx, sy) { | |
| let bobY = 0; | |
| if (u.state === 'moving' || u.state === 'gathering' || u.state === 'attacking') { | |
| bobY = Math.sin(u.animFrame * Math.PI / 2) * 2; | |
| } | |
| // Shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.3)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx, sy + 6, u.def.size, u.def.size * 0.5, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Selection circle | |
| if (u.selected) { | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx, sy + 4, u.def.size + 4, (u.def.size + 4) * 0.5, 0, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| ctx.lineWidth = 1; | |
| } | |
| // Team color ring | |
| ctx.strokeStyle = u.team === TEAM_PLAYER ? '#2196F3' : '#F44336'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.ellipse(sx, sy + 4, u.def.size + 1, (u.def.size + 1) * 0.5, 0, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| ctx.lineWidth = 1; | |
| // Unit body | |
| let bx = sx, by = sy - 4 + bobY; | |
| if (u.type === 'worker') { | |
| // Body | |
| ctx.fillStyle = '#26A69A'; | |
| ctx.fillRect(bx - 5, by - 4, 10, 10); | |
| // Head | |
| ctx.fillStyle = '#FFCC80'; | |
| ctx.beginPath(); | |
| ctx.arc(bx, by - 8, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Hat | |
| ctx.fillStyle = '#795548'; | |
| ctx.fillRect(bx - 5, by - 13, 10, 4); | |
| ctx.fillRect(bx - 3, by - 15, 6, 3); | |
| // Tool | |
| if (u.state === 'gathering' || u.carrying > 0) { | |
| ctx.fillStyle = u.carryType === 'gold' ? '#FFD700' : u.carryType === 'wood' ? '#8D6E63' : '#9E9E9E'; | |
| ctx.fillRect(bx + 6, by - 4, 4, 4); | |
| } else { | |
| ctx.fillStyle = '#9E9E9E'; | |
| ctx.fillRect(bx + 5 * u.facing, by - 6, 2, 10); | |
| ctx.fillRect(bx + 5 * u.facing - 2, by - 6, 6, 2); | |
| } | |
| } else if (u.type === 'soldier') { | |
| // Body | |
| ctx.fillStyle = '#C62828'; | |
| ctx.fillRect(bx - 6, by - 4, 12, 12); | |
| // Head | |
| ctx.fillStyle = '#FFCC80'; | |
| ctx.beginPath(); | |
| ctx.arc(bx, by - 8, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Helmet | |
| ctx.fillStyle = '#757575'; | |
| ctx.beginPath(); | |
| ctx.arc(bx, by - 10, 6, Math.PI, 0); | |
| ctx.fill(); | |
| // Sword | |
| ctx.fillStyle = '#BDBDBD'; | |
| let swordX = bx + 8 * u.facing; | |
| ctx.fillRect(swordX, by - 10, 2, 16); | |
| ctx.fillStyle = '#795548'; | |
| ctx.fillRect(swordX - 2, by, 6, 3); | |
| // Shield | |
| if (u.facing === 1) { | |
| ctx.fillStyle = '#1565C0'; | |
| ctx.beginPath(); | |
| ctx.ellipse(bx - 7, by, 5, 6, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } else if (u.type === 'archer') { | |
| // Body | |
| ctx.fillStyle = '#2E7D32'; | |
| ctx.fillRect(bx - 5, by - 4, 10, 10); | |
| // Head | |
| ctx.fillStyle = '#FFCC80'; | |
| ctx.beginPath(); | |
| ctx.arc(bx, by - 8, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Hood | |
| ctx.fillStyle = '#1B5E20'; | |
| ctx.beginPath(); | |
| ctx.arc(bx, by - 9, 6, Math.PI * 1.2, Math.PI * 1.8); | |
| ctx.fill(); | |
| // Bow | |
| ctx.strokeStyle = '#8D6E63'; | |
| ctx.lineWidth = 2; | |
| let bowX = bx + 8 * u.facing; | |
| ctx.beginPath(); | |
| ctx.arc(bowX, by - 2, 8, -0.8, 0.8); | |
| ctx.stroke(); | |
| ctx.lineWidth = 1; | |
| // Bowstring | |
| ctx.strokeStyle = '#BCAAA4'; | |
| ctx.beginPath(); | |
| ctx.moveTo(bowX + 6 * u.facing, by - 9); | |
| ctx.lineTo(bowX + 6 * u.facing, by + 5); | |
| ctx.stroke(); | |
| } else if (u.type === 'knight') { | |
| // Body (larger) | |
| ctx.fillStyle = '#4527A0'; | |
| ctx.fillRect(bx - 7, by - 6, 14, 14); | |
| // Armor plates | |
| ctx.fillStyle = '#7E57C2'; | |
| ctx.fillRect(bx - 5, by - 4, 10, 10); | |
| // Head | |
| ctx.fillStyle = '#757575'; | |
| ctx.beginPath(); | |
| ctx.arc(bx, by - 10, 6, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Visor | |
| ctx.fillStyle = '#424242'; | |
| ctx.fillRect(bx - 3, by - 10, 6, 3); | |
| // Sword | |
| ctx.fillStyle = '#E0E0E0'; | |
| let swordX = bx + 10 * u.facing; | |
| ctx.fillRect(swordX, by - 12, 3, 20); | |
| ctx.fillStyle = '#FFB300'; | |
| ctx.fillRect(swordX - 2, by, 7, 3); | |
| // Shield | |
| ctx.fillStyle = '#283593'; | |
| ctx.beginPath(); | |
| ctx.ellipse(bx - 8 * u.facing, by, 6, 7, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#FFC107'; | |
| ctx.beginPath(); | |
| ctx.arc(bx - 8 * u.facing, by, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| // HP bar (when damaged) | |
| if (u.hp < u.maxHp) { | |
| let pct = u.hp / u.maxHp; | |
| let barW = u.def.size * 2 + 6; | |
| ctx.fillStyle = 'rgba(0,0,0,0.7)'; | |
| ctx.fillRect(sx - barW/2, sy - 20, barW, 4); | |
| ctx.fillStyle = pct > 0.6 ? '#4caf50' : pct > 0.3 ? '#ff9800' : '#f44336'; | |
| ctx.fillRect(sx - barW/2, sy - 20, barW * pct, 4); | |
| } | |
| // Gathering indicator | |
| if (u.state === 'gathering' && u.gatherTimer > 0) { | |
| ctx.fillStyle = 'rgba(255,215,0,0.5)'; | |
| let prog = u.gatherTimer / 40; | |
| ctx.beginPath(); | |
| ctx.arc(sx, sy - 24, 5, -Math.PI/2, -Math.PI/2 + Math.PI * 2 * prog); | |
| ctx.stroke(); | |
| } | |
| } | |
| function renderMinimap() { | |
| let mCtx = miniCtx; | |
| // Draw map | |
| let scale = MINIMAP_W / MAP_W; | |
| for (let ty = 0; ty < MAP_H; ty++) { | |
| for (let tx = 0; tx < MAP_W; tx++) { | |
| let tile = gameState.map[ty][tx]; | |
| let colors = { | |
| [TILE_GRASS]: '#4a8c3f', | |
| [TILE_FOREST]: '#1B5E20', | |
| [TILE_GOLD]: '#FFD700', | |
| [TILE_WATER]: '#1565C0', | |
| [TILE_ROCK]: '#616161', | |
| [TILE_DIRT]: '#795548' | |
| }; | |
| mCtx.fillStyle = colors[tile.type] || '#4a8c3f'; | |
| mCtx.fillRect(tx * scale, ty * scale, scale + 0.5, scale + 0.5); | |
| } | |
| } | |
| // Draw buildings on minimap | |
| for (let b of gameState.buildings) { | |
| mCtx.fillStyle = b.team === TEAM_PLAYER ? '#2196F3' : '#F44336'; | |
| mCtx.fillRect(b.tileX * scale, b.tileY * scale, b.def.size * scale + 1, b.def.size * scale + 1); | |
| } | |
| // Draw units on minimap | |
| for (let u of gameState.units) { | |
| mCtx.fillStyle = u.team === TEAM_PLAYER ? '#4FC3F7' : '#EF5350'; | |
| let ux = (u.x / TILE_SIZE) * scale; | |
| let uy = (u.y / TILE_SIZE) * scale; | |
| mCtx.fillRect(ux - 1, uy - 1, 3, 3); | |
| } | |
| // Draw camera viewport | |
| let cam = gameState.camera; | |
| let vx = (cam.x / TILE_SIZE) * scale; | |
| let vy = (cam.y / TILE_SIZE) * scale; | |
| let vw = (canvas.width / TILE_SIZE) * scale; | |
| let vh = (canvas.height / TILE_SIZE) * scale; | |
| mCtx.strokeStyle = '#fff'; | |
| mCtx.lineWidth = 1; | |
| mCtx.strokeRect(vx, vy, vw, vh); | |
| } | |
| // ==================== GAME LOOP ==================== | |
| function gameLoop() { | |
| update(); | |
| render(); | |
| requestAnimationFrame(gameLoop); | |
| } | |
| // Start the game | |
| window.addEventListener('load', init); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment