Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save senko/41d8a744805c6c3ecd7d4b3c712f3c25 to your computer and use it in GitHub Desktop.
RTS game by Kimi K2.7-code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Micro Kingdom RTS</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #1a1a1a; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #fff; user-select: none; -webkit-user-select: none; }
#game { display: flex; flex-direction: column; height: 100vh; }
#topbar { display: flex; align-items: center; gap: 24px; padding: 8px 16px; background: rgba(0,0,0,0.75); border-bottom: 2px solid #5b4a38; z-index: 10; }
.res { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 14px; }
.dot { width: 12px; height: 12px; border-radius: 2px; }
.wood { background: #9ad16b; }
.gold { background: #ffd95e; }
.food { background: #ff8b6a; }
.pop { background: #8ac7ff; }
#canvas-wrap { position: relative; flex: 1; overflow: hidden; background: #2b3a29; cursor: crosshair; }
canvas { display: block; }
#ui-bottom { display: grid; grid-template-columns: 320px 1fr; gap: 10px; padding: 8px 12px; background: rgba(0,0,0,0.8); border-top: 2px solid #5b4a38; min-height: 120px; }
#selection { background: rgba(40,30,20,0.6); border: 1px solid #5b4a38; padding: 8px; border-radius: 6px; font-size: 13px; overflow: auto; }
#commands { display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start; }
.btn { padding: 8px 12px; border: 1px solid #5b4a38; background: linear-gradient(#5c4a37,#3d3022); color: #fff; border-radius: 5px; cursor: pointer; font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 6px; }
.btn:hover { filter: brightness(1.15); }
.btn:active { transform: translateY(1px); }
.btn.disabled { opacity: 0.45; pointer-events: none; }
.key { font-size: 11px; opacity: 0.75; border: 1px solid rgba(255,255,255,0.3); padding: 1px 4px; border-radius: 3px; }
#toast { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 8px 16px; border-radius: 6px; border: 1px solid #6a5a45; pointer-events: none; opacity: 0; transition: opacity .2s; z-index: 20; font-size: 14px; }
#overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.65); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 30; text-align: center; }
#overlay h1 { margin: 0 0 10px; font-size: 44px; text-shadow: 2px 2px 0 #000; }
#overlay p { margin: 6px 0; max-width: 620px; line-height: 1.45; }
#overlay .btn { margin-top: 16px; font-size: 16px; padding: 10px 18px; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="game">
<div id="topbar">
<div class="res"><span class="dot wood"></span> Wood: <span id="wood">0</span></div>
<div class="res"><span class="dot gold"></span> Gold: <span id="gold">0</span></div>
<div class="res"><span class="dot food"></span> Food: <span id="food">0</span>/<span id="foodCap">0</span></div>
<div class="res"><span class="dot pop"></span> Units: <span id="pop">0</span>/<span id="popCap">0</span></div>
<div style="flex:1"></div>
<div class="res" style="font-weight:400; opacity:.8">Map uncovered: <span id="uncovered">0</span>%</div>
</div>
<div id="canvas-wrap">
<canvas id="c"></canvas>
<div id="toast"></div>
<div id="overlay">
<h1>Micro Kingdom</h1>
<p>A tiny RTS inspired by classic WarCraft &amp; Command &amp; Conquer.</p>
<p>Drag to select workers. Right-click trees or gold to gather, ground to move, buildings to attack. Build Farms for food, a Barracks for soldiers, and explore the whole map.</p>
<p>Hotkeys: <b>B</b> Build menu · <b>W</b> Worker · <b>F</b> Farm · <b>R</b> Barracks · <b>S</b> Soldier · <b>ESC</b> cancel</p>
<button class="btn" id="startBtn">Start Game</button>
</div>
</div>
<div id="ui-bottom">
<div id="selection">Select a unit or building.</div>
<div id="commands"></div>
</div>
</div>
<script>
/* ===== CONFIG ===== */
const WORLD_W = 2400, WORLD_H = 1600;
const TILE = 16;
const COLS = Math.ceil(WORLD_W/TILE), ROWS = Math.ceil(WORLD_H/TILE);
const START_RES = { wood: 350, gold: 150, food: 0 };
const COSTS = {
farm: { wood: 100, gold: 0 },
barracks: { wood: 150, gold: 100 },
worker: { wood: 50, gold: 0, food: 1 },
soldier: { wood: 60, gold: 40, food: 1 }
};
const TIMES = { farm: 120, barracks: 200, worker: 90, soldier: 160 };
/* ===== STATE ===== */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const ui = {
wood: document.getElementById('wood'),
gold: document.getElementById('gold'),
food: document.getElementById('food'),
foodCap: document.getElementById('foodCap'),
pop: document.getElementById('pop'),
popCap: document.getElementById('popCap'),
selection: document.getElementById('selection'),
commands: document.getElementById('commands'),
toast: document.getElementById('toast'),
uncovered: document.getElementById('uncovered'),
overlay: document.getElementById('overlay'),
};
let camera = { x: WORLD_W/2 - window.innerWidth/2, y: WORLD_H/2 - window.innerHeight/2 };
let mouse = { x: 0, y: 0, screenX: 0, screenY: 0 };
let keys = {};
let ents = [];
let particles = [];
let projectiles = [];
let selected = [];
let dragStart = null;
let isDragging = false;
let buildGhost = null; // {type:'farm'|'barracks'}
let lastTime = 0;
let gameRunning = false;
let fog = new Uint8Array(COLS*ROWS); // 0 hidden, 1 explored, 2 visible
/* ===== CLASSES ===== */
class Entity {
constructor(x, y, type, owner='player') {
this.id = Math.random().toString(36).slice(2);
this.x = x; this.y = y;
this.type = type;
this.owner = owner;
this.w = 28; this.h = 28;
this.hp = 100; this.maxHp = 100;
this.dead = false;
this.vision = 160;
this.rally = null;
ents.push(this);
}
worldRect() { return { x: this.x - this.w/2, y: this.y - this.h/2, w: this.w, h: this.h }; }
draw(ctx){}
update(dt){}
dist(o){ const dx=o.x-this.x, dy=o.y-this.y; return Math.hypot(dx,dy); }
}
class Unit extends Entity {
constructor(x,y,type,owner='player'){
super(x,y,type,owner);
this.target = null;
this.task = 'idle'; // idle move gather attack build return
this.carry = null; // 'wood'|'gold'
this.carryAmount = 0;
this.speed = type==='worker'?1.3:1.05;
this.atkRange = type==='worker'?10:70;
this.atkCooldown = 0;
this.buildTarget = null;
this.gatherTick = 0;
this.hitFlash = 0;
}
update(dt){
if(this.hitFlash>0) this.hitFlash-=dt;
if(this.atkCooldown>0) this.atkCooldown-=dt;
// movement & actions
if(this.task==='move' && this.target){
this.moveTo(this.target.x, this.target.y);
if(this.dist(this.target)<6){ this.task='idle'; this.target=null; }
} else if(this.task==='gather' && this.target && !this.target.dead){
if(this.dist(this.target)<22){
this.gatherTick++;
if(this.gatherTick>50){
this.carry = this.target.resType; this.carryAmount = 10;
this.gatherTick=0;
// find nearest dropoff
const drop = findNearestDropoff(this);
if(drop){ this.task='return'; this.target=drop; }
else { toast('No Town Hall to deposit resources!'); this.task='idle'; this.target=null; }
}
} else this.moveTo(this.target.x, this.target.y);
} else if(this.task==='return' && this.target){
if(this.dist(this.target)<40){
addResource(this.carry, this.carryAmount);
this.carry=null; this.carryAmount=0;
// auto return to nearest resource of same type
const res = findNearestResource(this, this.carry==='wood'?'tree':'gold');
if(res){ this.task='gather'; this.target=res; }
else this.task='idle';
} else this.moveTo(this.target.x, this.target.y);
} else if(this.task==='attack' && this.target && !this.target.dead){
if(this.dist(this.target) > this.atkRange) this.moveTo(this.target.x, this.target.y);
else if(this.atkCooldown<=0){ this.attack(this.target); this.atkCooldown=40; }
} else if(this.task==='build' && this.buildTarget){
if(this.dist({x:this.buildTarget.x,y:this.buildTarget.y})<30){
this.buildTarget.buildProgress++;
if(this.buildTarget.buildProgress>=this.buildTarget.buildTime){
this.buildTarget.finished=true;
this.buildTarget=null; this.task='idle';
}
} else this.moveTo(this.buildTarget.x, this.buildTarget.y);
}
}
moveTo(tx,ty){
const dx=tx-this.x, dy=ty-this.y, d=Math.hypot(dx,dy)||1;
this.x += (dx/d)*this.speed; this.y += (dy/d)*this.speed;
}
attack(target){
projectiles.push({ x:this.x, y:this.y-8, tx:target.x, ty:target.y-8, speed:6, dmg:this.type==='worker'?3:10, target, t:0 });
}
draw(ctx){
const x=this.x, y=this.y, w=this.w, h=this.h;
ctx.save();
ctx.translate(x,y);
// shadow
ctx.fillStyle='rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(0,10,w/2.2,h/4,0,0,Math.PI*2); ctx.fill();
// body
if(this.owner==='player'){
ctx.fillStyle = this.type==='worker' ? '#6ec3ff' : '#5a8cff';
ctx.strokeStyle='#c9e6ff';
} else {
ctx.fillStyle = this.type==='worker' ? '#ff9f6e' : '#ff5a5a';
ctx.strokeStyle='#ffe0c9';
}
if(this.hitFlash>0) ctx.fillStyle='#fff';
if(this.type==='soldier'){
ctx.beginPath(); ctx.moveTo(0,-h/2); ctx.lineTo(w/2,h/2); ctx.lineTo(-w/2,h/2); ctx.closePath(); ctx.fill(); ctx.stroke();
// spear
ctx.strokeStyle='#ccc'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(6,4); ctx.lineTo(14,-18); ctx.stroke();
} else {
ctx.beginPath(); ctx.arc(0,0,w/2.2,0,Math.PI*2); ctx.fill(); ctx.stroke();
// backpack
ctx.fillStyle=this.carry==='wood'?'#7bc452':this.carry==='gold'?'#ffd95e':'#444';
ctx.fillRect(-w/2-2,-4,5,10);
}
ctx.restore();
// hp bar
if(this.hp<this.maxHp) drawHpBar(ctx,x,y-22,w,5,this.hp,this.maxHp);
}
}
class Building extends Entity {
constructor(x,y,type,owner='player'){
super(x,y,type,owner);
this.finished = false;
this.buildProgress = 0;
this.buildTime = type==='townhall'?0:TIMES[type];
this.rally = { x:x+60, y:y+40 };
if(type==='farm'){ this.w=60; this.h=54; this.maxHp=250; this.hp=250; this.vision=120; }
else if(type==='barracks'){ this.w=80; this.h=72; this.maxHp=500; this.hp=500; this.vision=140; }
else if(type==='townhall'){ this.w=110; this.h=90; this.maxHp=1000; this.hp=1000; this.vision=200; this.finished=true; }
}
update(dt){}
draw(ctx){
const r=this.worldRect();
ctx.save();
// shadow
ctx.fillStyle='rgba(0,0,0,0.25)';
ctx.fillRect(r.x+4,r.y+r.h-6,r.w,r.h/3);
// unfinished silhouette
if(!this.finished){
ctx.globalAlpha=0.45;
}
if(this.type==='townhall'){
ctx.fillStyle='#8f6e4e'; ctx.fillRect(r.x,r.y,r.w,r.h);
ctx.fillStyle='#6a4e35'; ctx.fillRect(r.x+10,r.y+10,r.w-20,r.h-20);
ctx.fillStyle='#4a3725'; ctx.beginPath(); ctx.moveTo(r.x-10,r.y); ctx.lineTo(r.x+r.w/2,r.y-40); ctx.lineTo(r.x+r.w+10,r.y); ctx.fill();
ctx.strokeStyle='#c4a47c'; ctx.lineWidth=2; ctx.strokeRect(r.x,r.y,r.w,r.h);
} else if(this.type==='farm'){
ctx.fillStyle='#a86'; ctx.fillRect(r.x+10,r.y+20,r.w-20,r.h-20);
ctx.fillStyle='#d64'; ctx.beginPath(); ctx.moveTo(r.x,r.y+20); ctx.lineTo(r.x+r.w/2,r.y-10); ctx.lineTo(r.x+r.w,r.y+20); ctx.fill();
ctx.strokeStyle='#eaa'; ctx.lineWidth=2; ctx.strokeRect(r.x+10,r.y+20,r.w-20,r.h-20);
} else if(this.type==='barracks'){
ctx.fillStyle='#6b6e7a'; ctx.fillRect(r.x,r.y,r.w,r.h);
ctx.fillStyle='#444'; ctx.fillRect(r.x+15,r.y+15,25,35); ctx.fillRect(r.x+55,r.y+15,25,35);
ctx.strokeStyle='#aab'; ctx.lineWidth=2; ctx.strokeRect(r.x,r.y,r.w,r.h);
}
ctx.restore();
if(!this.finished){
ctx.fillStyle='#fff'; ctx.font='12px sans-serif'; ctx.textAlign='center';
ctx.fillText('Building...', this.x, this.y-this.h/2-8);
ctx.fillStyle='rgba(255,255,255,0.4)'; ctx.fillRect(r.x, r.y-14, r.w, 5);
ctx.fillStyle='#9f6'; ctx.fillRect(r.x, r.y-14, r.w*(this.buildProgress/this.buildTime), 5);
}
drawHpBar(ctx,this.x,this.y-this.h/2-8-(this.finished?0:14),this.w,6,this.hp,this.maxHp);
// rally flag
if(this.finished && this.rally){
ctx.strokeStyle='rgba(255,255,255,0.6)'; ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.moveTo(this.x,this.y); ctx.lineTo(this.rally.x,this.rally.y); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(this.rally.x,this.rally.y,4,0,Math.PI*2); ctx.fill();
}
}
}
class Resource extends Entity {
constructor(x,y,type){
super(x,y,type,'neutral');
this.resType = type;
this.amount = type==='tree'?100:1200;
}
draw(ctx){
if(this.type==='tree'){
ctx.fillStyle='#4a3'; ctx.beginPath(); ctx.arc(this.x,this.y,14,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#2d5a1e'; ctx.beginPath(); ctx.arc(this.x-3,this.y-3,10,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#5c3a1e'; ctx.fillRect(this.x-3,this.y+6,6,10);
} else {
ctx.fillStyle='#b8860b'; ctx.beginPath(); ctx.arc(this.x,this.y,18,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#ffd95e'; ctx.beginPath(); ctx.arc(this.x-4,this.y-4,10,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#8b6508'; ctx.font='10px sans-serif'; ctx.textAlign='center'; ctx.fillText('Au',this.x,this.y+3);
}
}
}
class Particle {
constructor(x,y,color){
this.x=x; this.y=y; this.vx=(Math.random()-.5)*3; this.vy=(Math.random()-.5)*3;
this.life=30; this.color=color;
particles.push(this);
}
update(){ this.x+=this.vx; this.y+=this.vy; this.vy+=0.1; this.life--; }
draw(ctx){ ctx.globalAlpha=this.life/30; ctx.fillStyle=this.color; ctx.fillRect(this.x,this.y,3,3); ctx.globalAlpha=1; }
}
/* ===== HELPERS ===== */
function drawHpBar(ctx,x,y,w,h,hp,max){ if(hp>=max) return; const pct=Math.max(0,hp/max); ctx.fillStyle='#300'; ctx.fillRect(x-w/2,y,w,h); ctx.fillStyle='#3d3'; ctx.fillRect(x-w/2,y,w*pct,h); ctx.strokeStyle='#000'; ctx.strokeRect(x-w/2,y,w,h); }
function addResource(type, amount){ state[type]+=amount; updateHud(); }
function canAfford(c){ return state.wood>=c.wood && state.gold>=c.gold && state.food+1<=state.foodCap; }
function spend(c){ state.wood-=c.wood||0; state.gold-=c.gold||0; state.food+=c.food||0; updateHud(); }
function refund(c){ state.wood+=c.wood||0; state.gold+=c.gold||0; state.food-=c.food||0; updateHud(); }
function findNearestDropoff(u){ return ents.filter(e=>e.type==='townhall' && e.finished && e.owner===u.owner).sort((a,b)=>u.dist(a)-u.dist(b))[0]; }
function findNearestResource(u,type){ return ents.filter(e=>e.type===type && e.amount>0).sort((a,b)=>u.dist(a)-u.dist(b))[0]; }
function countUnits(owner){ return ents.filter(e=>e instanceof Unit && e.owner===owner).length; }
function countFoodCap(owner){ return ents.filter(e=>e instanceof Building && e.type==='farm' && e.finished && e.owner===owner).length*5 + 10; }
function updateHud(){
ui.wood.textContent = Math.floor(state.wood);
ui.gold.textContent = Math.floor(state.gold);
state.foodCap = countFoodCap('player');
state.popCap = state.foodCap;
ui.food.textContent = state.food;
ui.foodCap.textContent = state.foodCap;
ui.pop.textContent = countUnits('player');
ui.popCap.textContent = state.popCap;
ui.uncovered.textContent = uncoveredPct();
}
function uncoveredPct(){
let visible=0;
for(let i=0;i<fog.length;i++) if(fog[i]) visible++;
return Math.round(visible/fog.length*100);
}
function toast(msg){ ui.toast.textContent=msg; ui.toast.style.opacity=1; setTimeout(()=>ui.toast.style.opacity=0,2200); }
/* ===== STATE ===== */
let state = { wood:0, gold:0, food:0, foodCap:0, popCap:0 };
/* ===== WORLD GEN ===== */
function generateWorld(){
ents=[]; particles=[]; projectiles=[]; fog.fill(0);
// trees in clusters
for(let i=0;i<70;i++){
const cx=80+Math.random()*(WORLD_W-160), cy=80+Math.random()*(WORLD_H-160);
for(let j=0;j<5+Math.random()*8;j++){
const x=cx+(Math.random()-.5)*160, y=cy+(Math.random()-.5)*160;
if(x>80&&x<WORLD_W-80 && y>80&&y<WORLD_H-80) new Resource(x,y,'tree');
}
}
// gold mines
for(let i=0;i<10;i++){
const x=120+Math.random()*(WORLD_W-240), y=120+Math.random()*(WORLD_H-240);
new Resource(x,y,'gold');
}
// player base
const thx=WORLD_W/2, thy=WORLD_H/2;
new Building(thx,thy,'townhall','player');
for(let i=0;i<3;i++) new Unit(thx-100+i*40, thy+100, 'worker','player');
// enemy base
const ex=WORLD_W-250, ey=250;
new Building(ex,ey,'townhall','enemy');
for(let i=0;i<3;i++) new Unit(ex-60+i*40, ey+90, 'worker','enemy');
for(let i=0;i<2;i++) new Unit(ex+30+i*40, ey+120, 'soldier','enemy');
// clear some fog around start
revealAround(thx,thy,260);
}
/* ===== FOG ===== */
function revealAround(x,y,r){
const cx=Math.floor(x/TILE), cy=Math.floor(y/TILE), rad=Math.ceil(r/TILE);
for(let dy=-rad;dy<=rad;dy++){
for(let dx=-rad;dx<=rad;dx++){
if(dx*dx+dy*dy>rad*rad) continue;
const nx=cx+dx, ny=cy+dy;
if(nx>=0&&nx<COLS&&ny>=0&&ny<ROWS) fog[ny*COLS+nx]=2;
}
}
}
function processFog(){
// mark old visible as explored
for(let i=0;i<fog.length;i++) if(fog[i]===2) fog[i]=1;
// reveal
for(const e of ents) if(e.owner==='player' || (e instanceof Building && e.owner==='player')) revealAround(e.x,e.y,e.vision);
for(const u of selected) if(u instanceof Unit) revealAround(u.x,u.y,u.vision);
}
/* ===== INPUT ===== */
function resize(){ const wrap=document.getElementById('canvas-wrap'); canvas.width=wrap.clientWidth; canvas.height=wrap.clientHeight; }
window.addEventListener('resize', resize);
function screenToWorld(sx,sy){ return { x: sx+camera.x, y: sy+camera.y }; }
function worldToScreen(wx,wy){ return { x: wx-camera.x, y: wy-camera.y }; }
canvas.addEventListener('mousedown', e=>{
if(e.button===0){
dragStart={ x:e.offsetX, y:e.offsetY, world: screenToWorld(e.offsetX,e.offsetY) };
isDragging=false;
} else if(e.button===2){
handleRightClick(e);
}
});
canvas.addEventListener('mousemove', e=>{
mouse.screenX=e.offsetX; mouse.screenY=e.offsetY;
const w=screenToWorld(e.offsetX,e.offsetY); mouse.x=w.x; mouse.y=w.y;
if(dragStart && Math.hypot(e.offsetX-dragStart.x,e.offsetY-dragStart.y)>5) isDragging=true;
});
window.addEventListener('mouseup', e=>{
if(e.button===0 && dragStart){
if(isDragging){
const w1=dragStart.world, w2=screenToWorld(e.offsetX,e.offsetY);
const rect={ x:Math.min(w1.x,w2.x), y:Math.min(w1.y,w2.y), w:Math.abs(w2.x-w1.x), h:Math.abs(w2.y-w1.y) };
selected = ents.filter(e=>e.owner==='player' && e instanceof Unit &&
e.x>rect.x && e.x<rect.x+rect.w && e.y>rect.y && e.y<rect.y+rect.h);
} else {
// single click select
const clicked = ents.filter(e=>e.owner==='player' &&
mouse.x>e.x-e.w/2 && mouse.x<e.x+e.w/2 && mouse.y>e.y-e.h/2 && mouse.y<e.y+e.h/2)
.sort((a,b)=> (b instanceof Unit)-(a instanceof Unit))[0];
selected = clicked ? [clicked] : [];
}
dragStart=null; isDragging=false; updateSelectionUI();
}
});
canvas.addEventListener('contextmenu', e=>e.preventDefault());
function handleRightClick(e){
if(selected.length===0) return;
const w=screenToWorld(e.offsetX,e.offsetY);
const target = ents.find(en=> en!==selected[0] && en.owner!=='player' &&
w.x>en.x-en.w/2 && w.x<en.x+en.w/2 && w.y>en.y-en.h/2 && w.y<en.y+en.h/2);
for(const u of selected){
if(!(u instanceof Unit)) continue;
if(target){
if(target instanceof Resource){
if(target.type==='tree' && u.type==='worker'){ u.task='gather'; u.target=target; }
else if(target.type==='gold' && u.type==='worker'){ u.task='gather'; u.target=target; }
else { u.task='attack'; u.target=target; }
} else {
u.task='attack'; u.target=target;
}
} else if(buildGhost){
// placing building
} else {
u.task='move'; u.target={x:w.x,y:w.y};
}
}
}
window.addEventListener('keydown', e=>{
keys[e.key]=true;
if(e.key==='Escape'){ buildGhost=null; updateSelectionUI(); }
if(e.key.toLowerCase()==='b' && selected.some(e=>e.type==='townhall'||e.type==='worker')) openBuildMenu();
if(buildGhost){
if(e.key.toLowerCase()==='f') startBuild('farm');
if(e.key.toLowerCase()==='r') startBuild('barracks');
}
if(selected.length){
if(e.key.toLowerCase()==='w') queueUnit('worker');
if(e.key.toLowerCase()==='s') queueUnit('soldier');
}
});
window.addEventListener('keyup', e=>keys[e.key]=false);
/* ===== UI ACTIONS ===== */
function updateSelectionUI(){
ui.commands.innerHTML='';
if(selected.length===0){ ui.selection.textContent='Select a unit or building.'; return; }
const first=selected[0];
// multiple units
if(selected.length>1){
ui.selection.innerHTML = `<b>${selected.length} units selected</b>`;
return;
}
// single
ui.selection.innerHTML = `<b>${cap(first.type)}</b> (${first.owner})<br>HP: ${Math.ceil(first.hp)}/${first.maxHp}`;
if(first instanceof Unit){
if(first.type==='worker'){
ui.selection.innerHTML += '<br>Can gather wood/gold and build.';
} else {
ui.selection.innerHTML += '<br>Military unit.';
}
}
// command buttons
if(first instanceof Building && first.finished && first.owner==='player'){
if(first.type==='townhall'){
addBtn('Worker '+costStr(COSTS.worker), 'W', canAfford(COSTS.worker), ()=>queueUnit('worker'));
}
if(first.type==='barracks'){
addBtn('Soldier '+costStr(COSTS.soldier), 'S', canAfford(COSTS.soldier), ()=>queueUnit('soldier'));
}
if(first.type==='townhall' || first.type==='worker') addBtn('Build...', 'B', true, openBuildMenu);
}
if(first instanceof Unit && first.type==='worker'){
addBtn('Build...', 'B', true, openBuildMenu);
}
}
function openBuildMenu(){
ui.commands.innerHTML='';
addBtn('Farm '+costStr(COSTS.farm), 'F', canAfford(COSTS.farm), ()=>startBuild('farm'));
addBtn('Barracks '+costStr(COSTS.barracks), 'R', canAfford(COSTS.barracks), ()=>startBuild('barracks'));
addBtn('Cancel', 'ESC', true, ()=>{ buildGhost=null; updateSelectionUI(); });
}
function startBuild(type){
if(!canAfford(COSTS[type])){ toast('Not enough resources!'); return; }
buildGhost=type;
ui.selection.innerHTML = `<b>Place ${cap(type)}</b><br>Left click to place.<br>Right-click or ESC to cancel.`;
}
function queueUnit(type){
const producer = selected.find(e=> e instanceof Building && e.finished && (type==='worker'?e.type==='townhall':e.type==='barracks'));
if(!producer) return;
if(!canAfford(COSTS[type])){ toast('Not enough resources!'); return; }
if(countUnits('player')>=state.popCap){ toast('Population cap reached! Build Farms.'); return; }
spend(COSTS[type]);
// spawn at rally or near building
const rx=producer.rally?producer.rally.x:producer.x+60;
const ry=producer.rally?producer.rally.y:producer.y+50;
const u = new Unit(rx, ry, type, 'player');
u.task='idle';
// training delay animation: unit starts at producer then pops out
u.x=producer.x; u.y=producer.y+producer.h/2+20;
setTimeout(()=>{ u.x=rx; u.y=ry; }, 200);
toast(`${cap(type)} trained`);
updateSelectionUI(); updateHud();
}
function addBtn(label, key, enabled, cb){
const b=document.createElement('button'); b.className='btn'+(enabled?'':' disabled');
b.innerHTML = `${label} <span class="key">${key}</span>`;
b.onclick=cb;
ui.commands.appendChild(b);
}
function cap(s){ return s[0].toUpperCase()+s.slice(1); }
function costStr(c){ const parts=[]; if(c.wood) parts.push(`${c.wood}W`); if(c.gold) parts.push(`${c.gold}G`); if(c.food) parts.push(`${c.food}F`); return `(${parts.join(' ')})`; }
/* ===== GAME LOOP ===== */
function update(dt){
// camera pan
if(keys['ArrowLeft']||keys['a']) camera.x-=6;
if(keys['ArrowRight']||keys['d']) camera.x+=6;
if(keys['ArrowUp']||keys['w']) camera.y-=6;
if(keys['ArrowDown']||keys['s']) camera.y+=6;
camera.x = Math.max(0, Math.min(WORLD_W-canvas.width, camera.x));
camera.y = Math.max(0, Math.min(WORLD_H-canvas.height, camera.y));
processFog();
// build ghost placement click
if(buildGhost && mouse.downLeft){
const c=COSTS[buildGhost];
if(canAfford(c)){
const b = new Building(mouse.x, mouse.y, buildGhost, 'player');
b.hp=10; b.finished=false; b.buildProgress=0;
spend(c);
// assign nearby idle workers to build
const worker = ents.find(e=>e instanceof Unit && e.type==='worker' && e.owner==='player' && e.task==='idle');
if(worker){ worker.task='build'; worker.buildTarget=b; }
buildGhost=null;
selected=[b]; updateSelectionUI();
toast('Construction started');
} else { toast('Not enough resources!'); buildGhost=null; }
}
for(const e of ents){
e.update(dt);
if(e instanceof Building && !e.finished && e.hp<=10){
// auto-built if no worker assigned? workers build it
}
}
// cleanup dead
for(let i=ents.length-1;i>=0;i--){ if(ents[i].dead) ents.splice(i,1); }
// projectiles
for(let i=projectiles.length-1;i>=0;i--){
const p=projectiles[i];
const dx=p.tx-p.x, dy=p.ty-p.y, d=Math.hypot(dx,dy)||1;
p.x += (dx/d)*p.speed; p.y += (dy/d)*p.speed; p.t++;
if(p.t>40 || d<8){
if(p.target && !p.target.dead){
p.target.hp-=p.dmg; p.target.hitFlash=8;
if(p.target.hp<=0){ p.target.dead=true; for(let k=0;k<8;k++) new Particle(p.target.x,p.target.y,'#ff8b6a'); }
}
projectiles.splice(i,1);
}
}
// particles
for(let i=particles.length-1;i>=0;i--){ particles[i].update(); if(particles[i].life<=0) particles.splice(i,1); }
updateHud();
}
function draw(){
ctx.fillStyle='#2b3a29'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.save();
ctx.translate(-camera.x, -camera.y);
// ground texture
ctx.fillStyle='rgba(255,255,255,0.03)';
for(let y=0;y<WORLD_H;y+=64) for(let x=0;x<WORLD_W;x+=64) if((x+y)%128===0) ctx.fillRect(x,y,2,2);
// draw entities
for(const e of ents) e.draw(ctx);
// selection ring
for(const s of selected){
ctx.strokeStyle='#9f6'; ctx.lineWidth=2; ctx.setLineDash([4,3]);
ctx.strokeRect(s.x-s.w/2-4, s.y-s.h/2-4, s.w+8, s.h+8);
ctx.setLineDash([]);
}
// drag box
if(isDragging && dragStart){
const w1=dragStart.world, w2=screenToWorld(mouse.screenX,mouse.screenY);
ctx.fillStyle='rgba(100,255,100,0.15)'; ctx.strokeStyle='#9f6';
ctx.fillRect(Math.min(w1.x,w2.x), Math.min(w1.y,w2.y), Math.abs(w2.x-w1.x), Math.abs(w2.y-w1.y));
ctx.strokeRect(Math.min(w1.x,w2.x), Math.min(w1.y,w2.y), Math.abs(w2.x-w1.x), Math.abs(w2.y-w1.y));
}
// build ghost
if(buildGhost){
const size = buildGhost==='farm'?{w:60,h:54}:{w:80,h:72};
ctx.globalAlpha=0.7; ctx.fillStyle=canAfford(COSTS[buildGhost])?'#9f6':'#f66';
ctx.fillRect(mouse.x-size.w/2, mouse.y-size.h/2, size.w, size.h);
ctx.globalAlpha=1;
}
// projectiles
ctx.fillStyle='#ffd95e';
for(const p of projectiles){ ctx.beginPath(); ctx.arc(p.x,p.y,3,0,Math.PI*2); ctx.fill(); }
ctx.restore();
// fog overlay
drawFog();
}
function drawFog(){
const off = ctx.getImageData(0,0,canvas.width,canvas.height); // too slow; use canvas rects
// draw per tile visible on screen
const startCol=Math.floor(camera.x/TILE), startRow=Math.floor(camera.y/TILE);
const endCol=Math.ceil((camera.x+canvas.width)/TILE), endRow=Math.ceil((camera.y+canvas.height)/TILE);
for(let r=Math.max(0,startRow); r<Math.min(ROWS,endRow); r++){
for(let c=Math.max(0,startCol); c<Math.min(COLS,endCol); c++){
const v=fog[r*COLS+c];
if(v===2) continue; // visible
const sx=c*TILE-camera.x, sy=r*TILE-camera.y;
if(v===0){ ctx.fillStyle='rgba(10,12,10,1)'; ctx.fillRect(sx,sy,TILE,TILE); }
else { ctx.fillStyle='rgba(10,12,10,0.55)'; ctx.fillRect(sx,sy,TILE,TILE); }
}
}
}
function loop(t){
if(!gameRunning) return;
const dt = t-lastTime; lastTime=t;
update(dt);
draw();
requestAnimationFrame(loop);
}
/* ===== START ===== */
document.getElementById('startBtn').addEventListener('click', ()=>{
ui.overlay.classList.add('hidden');
state={ ...START_RES, foodCap:10, popCap:10 };
resize(); generateWorld(); updateHud(); updateSelectionUI();
gameRunning=true; lastTime=performance.now();
requestAnimationFrame(loop);
});
// track left mouse globally for build placement
window.addEventListener('mousedown', e=>{ if(e.button===0) mouse.downLeft=true; });
window.addEventListener('mouseup', e=>{ if(e.button===0) mouse.downLeft=false; });
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment