Skip to content

Instantly share code, notes, and snippets.

@senko
Created June 15, 2026 17:45
Show Gist options
  • Select an option

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

Select an option

Save senko/22eeea953f830cc6d148b275d605a482 to your computer and use it in GitHub Desktop.
index.html
<!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