Skip to content

Instantly share code, notes, and snippets.

@senko
Created April 29, 2026 13:43
Show Gist options
  • Select an option

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

Select an option

Save senko/ede62589a5c97603abd10bbe70a0263e to your computer and use it in GitHub Desktop.
RTS game by Kimi K2.6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTS Game</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; }
body { background: #0a0a0a; overflow: hidden; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #eee; }
#gameCanvas { display: block; cursor: crosshair; }
#uiLayer { position: absolute; inset: 0; pointer-events: none; display: flex; flex-direction: column; justify-content: space-between; }
#uiLayer > * { pointer-events: auto; }
#topBar {
height: 40px; background: linear-gradient(180deg, #2a2a2a, #1a1a1a); border-bottom: 2px solid #444;
display: flex; align-items: center; padding: 0 20px; gap: 28px; font-size: 14px; font-weight: 600;
}
.resource { display: flex; align-items: center; gap: 6px; }
.dot { width: 12px; height: 12px; border-radius: 2px; display: inline-block; border: 1px solid rgba(255,255,255,0.3); }
.gold { background: #ffd700; }
.wood { background: #8d6e63; }
.food { background: #ef5350; }
#hint { margin-left: auto; font-size: 11px; color: #999; font-weight: 400; }
#bottomPanel {
height: 190px; background: linear-gradient(0deg, #1a1a1a, #2a2a2a); border-top: 2px solid #444;
display: flex; gap: 0;
}
#infoColumn {
width: 220px; border-right: 2px solid #333; padding: 10px; display: flex; flex-direction: column; gap: 8px;
}
#portraitBox {
width: 64px; height: 64px; background: #111; border: 2px solid #555; display: flex; align-items: center; justify-content: center;
font-size: 28px;
}
#infoName { font-size: 14px; font-weight: 700; color: #fff; }
#infoStats { font-size: 11px; color: #bbb; line-height: 1.5; }
.hpBarWrap { width: 100%; height: 10px; background: #111; border: 1px solid #333; margin-top: 2px; }
.hpBar { height: 100%; background: #4caf50; width: 100%; transition: width 0.2s; }
#commandColumn {
flex: 1; padding: 10px; display: grid; grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(3, 1fr); gap: 6px;
align-content: center; justify-content: center;
}
.cmdBtn {
background: linear-gradient(180deg, #3a3a3a, #2a2a2a); border: 1px solid #555; color: #fff;
font-size: 12px; font-weight: 600; cursor: pointer; border-radius: 2px; padding: 6px; text-align: center;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
transition: filter 0.1s, border-color 0.1s;
}
.cmdBtn:hover { filter: brightness(1.15); border-color: #888; }
.cmdBtn:active { filter: brightness(0.9); }
.cmdBtn.disabled { opacity: 0.35; cursor: not-allowed; filter: grayscale(0.8); }
.cmdKey { font-size: 10px; color: #aaa; font-weight: 400; }
#minimapColumn {
width: 200px; border-left: 2px solid #333; background: #000; position: relative; display: flex; align-items: center; justify-content: center;
}
#minimap { width: 180px; height: 180px; image-rendering: pixelated; cursor: pointer; border: 1px solid #333; }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="uiLayer">
<div id="topBar">
<div class="resource"><span class="dot gold"></span> Gold: <span id="resGold">500</span></div>
<div class="resource"><span class="dot wood"></span> Wood: <span id="resWood">300</span></div>
<div class="resource"><span class="dot food"></span> Pop: <span id="resFood">4/10</span></div>
<div id="hint">Pan: Arrows/Edge · Select: Click/Drag · Move/Gather: Right-Click · Build: B then select</div>
</div>
<div id="bottomPanel">
<div id="infoColumn">
<div style="display:flex; gap:10px; align-items:flex-start;">
<div id="portraitBox">?</div>
<div>
<div id="infoName">None</div>
<div id="infoStats"></div>
</div>
</div>
<div class="hpBarWrap"><div id="infoHp" class="hpBar"></div></div>
</div>
<div id="commandColumn"></div>
<div id="minimapColumn">
<canvas id="minimap" width="80" height="80"></canvas>
</div>
</div>
</div>
<script>
/* ============================ CONFIG ============================ */
const TILE = 32;
const MAP_W = 80;
const MAP_H = 80;
const TICK = 1/60;
const COSTS = {
worker: { g: 50, w: 0, f: 1, time: 3 },
soldier: { g: 75, w: 25, f: 1, time: 5 },
townhall: { g: 400, w: 200, f: 0, sz: 3 },
barracks: { g: 150, w: 100, f: 0, sz: 3 },
farm: { g: 100, w: 50, f: 0, sz: 2 },
};
/* ============================ STATE ============================ */
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const mmCanvas = document.getElementById('minimap');
const mmCtx = mmCanvas.getContext('2d');
const game = {
cam: { x: 0, y: 0 },
tiles: [], // 0 grass, 1 water, 2 tree
revealed: [], // bool
occupied: [], // bool walkable false
units: [],
buildings: [],
mapRes: [], // trees / gold mines on map
particles: [],
selected: [],
player: { gold: 500, wood: 300, food: 10, used: 4 },
mouse: { x: 0, y: 0, down: false, rightDown: false, drag: null },
mode: 'normal', // normal | place
placeType: null,
time: 0,
ids: 1,
};
/* ============================ UTILS ============================ */
function rand(n) { return Math.floor(Math.random()*n); }
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function inBounds(x, y) { return x>=0 && y>=0 && x<MAP_W && y<MAP_H; }
function dist(a,b){ return Math.hypot(a.x-b.x, a.y-b.y); }
function snap(v){ return Math.floor(v/TILE)*TILE; }
function key(x,y){ return x+","+y; }
/* ============================ CLASSES ============================ */
class Unit {
constructor(x,y,type,team){
this.id=game.ids++; this.x=x; this.y=y; this.type=type; this.team=team;
this.hp=type==='worker'?40:60; this.maxHp=this.hp;
this.speed=type==='worker'?110:90; this.radius=10;
this.state='idle'; // idle move gather return build
this.path=[]; this.pi=0;
this.target=null; this.gatherT=null; this.returnT=null; this.buildT=null;
this.carry={type:null,amount:0,max:10};
this.timer=0; this.dead=false;
this.ox=0; this.oy=0;
this.onArriveState='idle';
}
update(dt){
if(this.dead) return;
// Separation
for(const u of game.units){
if(u===this||u.dead) continue;
const d=dist(this,u);
if(d<this.radius+u.radius && d>0){
const dx=(this.x-u.x)/d, dy=(this.y-u.y)/d;
this.x+=dx*20*dt; this.y+=dy*20*dt;
}
}
if(this.state==='move'){
if(this.pi>=this.path.length){ this.state=this.onArriveState||'idle'; return; }
const t=this.path[this.pi];
const a=Math.atan2(t.y-this.y, t.x-this.x);
this.x+=Math.cos(a)*this.speed*dt;
this.y+=Math.sin(a)*this.speed*dt;
if(Math.hypot(t.x-this.x,t.y-this.y)<4){ this.pi++; }
return;
}
if(this.state==='gather'){
if(!this.gatherT||this.gatherT.dead){ this.state='idle'; return; }
if(!this.adjacent(this.gatherT)){ this.moveTo(this.gatherT.x,this.gatherT.y,true,'gather'); return; }
this.timer-=dt;
if(this.timer<=0){
const amt=Math.min(10, this.carry.max-this.carry.amount, this.gatherT.amount);
this.gatherT.amount-=amt; this.carry.amount+=amt; this.carry.type=this.gatherT.resType;
if(this.gatherT.resType==='wood') this.ox=(Math.random()-0.5)*6;
if(this.gatherT.amount<=0){
this.gatherT.dead=true;
if(this.gatherT.tx!=null){ game.tiles[this.gatherT.ty][this.gatherT.tx]=0; rebuildOccupied(); }
}
this.timer=0.8;
spawnParticle(this.x,this.y-10, this.gatherT.resType==='gold'?'#ffeb3b':'#8d6e63');
if(this.carry.amount>=this.carry.max || this.gatherT.dead){
this.state='return';
this.returnT=findNearestBuilding(this.x,this.y,'townhall');
if(this.returnT) this.moveTo(this.returnT.x+this.returnT.w/2,this.returnT.y+this.returnT.h/2,true,'return');
else this.state='idle';
}
}
return;
}
if(this.state==='return'){
if(!this.returnT||this.returnT.dead){ this.returnT=findNearestBuilding(this.x,this.y,'townhall'); if(!this.returnT){this.state='idle';return;} }
if(this.adjacent(this.returnT)){
if(this.carry.type==='gold') game.player.gold+=this.carry.amount;
else if(this.carry.type==='wood') game.player.wood+=this.carry.amount;
this.carry.amount=0; this.carry.type=null; this.ox=0;
updateUI();
if(this.gatherT && !this.gatherT.dead){ this.state='gather'; this.timer=0.5; }
else this.state='idle';
} else {
this.moveTo(this.returnT.x+this.returnT.w/2,this.returnT.y+this.returnT.h/2,true,'return');
}
return;
}
if(this.state==='build'){
if(!this.buildT||this.buildT.dead){ this.state='idle'; return; }
if(!this.adjacent(this.buildT)){ this.moveTo(this.buildT.x+this.buildT.w/2,this.buildT.y+this.buildT.h/2,true,'build'); return; }
this.timer-=dt;
if(this.timer<=0){
this.buildT.buildProgress+=15;
this.timer=0.5;
spawnParticle(this.x,this.y-10,'#fff');
if(this.buildT.buildProgress>=this.buildT.buildMax){
this.buildT.built=true; this.buildT.hp=this.buildT.maxHp; this.state='idle'; this.buildT=null; rebuildOccupied();
}
}
return;
}
}
adjacent(ent){
const cx=ent.x+(ent.w||0)/2, cy=ent.y+(ent.h||0)/2;
const r=(ent.w||TILE)/2+TILE;
return Math.hypot(this.x-cx,this.y-cy)<=r+4;
}
moveTo(x,y,adj=false,onArrive='idle'){
let tx=Math.floor(x/TILE), ty=Math.floor(y/TILE);
if(adj){ const n=freeNeighbor(tx,ty); if(n){tx=n.x;ty=n.y;} }
this.path=findPath(this.x,this.y,tx*TILE+TILE/2,ty*TILE+TILE/2);
this.pi=0; this.state='move'; this.onArriveState=onArrive;
}
}
class Building {
constructor(x,y,type,team,built=false){
this.id=game.ids++; this.x=x; this.y=y; this.type=type; this.team=team; this.built=built;
this.w=COSTS[type].sz*TILE; this.h=COSTS[type].sz*TILE;
this.hp=built?500:1; this.maxHp=500;
this.buildProgress=built?100:0; this.buildMax=100;
this.queue=[]; this.qTimer=0; this.dead=false;
}
update(dt){
if(!this.built||this.dead) return;
if(this.queue.length>0){
this.qTimer-=dt;
if(this.qTimer<=0){
const uType=this.queue.shift();
spawnUnit(uType,this);
if(this.queue.length>0) this.qTimer=COSTS[this.queue[0]].time;
}
}
}
}
class MapRes {
constructor(x,y,type,amt){
this.id=game.ids++; this.x=x; this.y=y; this.resType=type; this.amount=amt; this.dead=false;
this.tx=Math.floor(x/TILE); this.ty=Math.floor(y/TILE);
this.radius=(type==='tree'?TILE*0.35:TILE*0.6);
}
}
class Particle {
constructor(x,y,color){
this.x=x; this.y=y; this.color=color; this.life=1; this.vx=(Math.random()-0.5)*60; this.vy=-30-Math.random()*40;
}
update(dt){ this.x+=this.vx*dt; this.y+=this.vy*dt; this.life-=dt*2; }
}
/* ============================ MAP ============================ */
function initMap(){
for(let y=0;y<MAP_H;y++){
game.tiles[y]=[]; game.revealed[y]=[]; game.occupied[y]=[];
for(let x=0;x<MAP_W;x++){
game.tiles[y][x]=0; game.revealed[y][x]=false; game.occupied[y][x]=false;
}
}
// Water blobs
for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++){
const n=Math.sin(x*0.08)+Math.sin(y*0.08)+Math.sin((x+y)*0.04);
if(n>1.6) game.tiles[y][x]=1;
}
// Trees
for(let i=0;i<50;i++){
const cx=rand(MAP_W), cy=rand(MAP_H);
if(nearStart(cx,cy)) continue;
for(let j=0;j<rand(6)+3;j++){
const x=cx+rand(5)-2, y=cy+rand(5)-2;
if(inBounds(x,y) && game.tiles[y][x]===0){
game.tiles[y][x]=2;
game.mapRes.push(new MapRes(x*TILE+TILE/2,y*TILE+TILE/2,'tree',100));
}
}
}
// Gold mines
for(let i=0;i<10;i++){
let x,y;
do{ x=rand(MAP_W-4)+2; y=rand(MAP_H-4)+2; }while(game.tiles[y][x]!==0 || nearStart(x,y));
game.mapRes.push(new MapRes(x*TILE+TILE/2,y*TILE+TILE/2,'gold',2000));
}
rebuildOccupied();
}
function nearStart(tx,ty){ return Math.hypot(tx-MAP_W/2,ty-MAP_H/2)<12; }
function rebuildOccupied(){
for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++){
game.occupied[y][x]=(game.tiles[y][x]===1);
}
for(const b of game.buildings){
const x0=Math.floor(b.x/TILE), y0=Math.floor(b.y/TILE);
const x1=x0+Math.floor(b.w/TILE), y1=y0+Math.floor(b.h/TILE);
for(let y=y0;y<y1;y++) for(let x=x0;x<x1;x++) if(inBounds(x,y)) game.occupied[y][x]=true;
}
for(const r of game.mapRes){
if(!r.dead){
const tx=Math.floor(r.x/TILE), ty=Math.floor(r.y/TILE);
if(inBounds(tx,ty)) game.occupied[ty][tx]=true;
}
}
}
function freeNeighbor(tx,ty){
for(const [dx,dy] of [[0,1],[1,0],[0,-1],[-1,0],[1,1],[-1,-1],[1,-1],[-1,1]]){
const x=tx+dx,y=ty+dy;
if(inBounds(x,y) && !game.occupied[y][x]) return {x,y};
}
return null;
}
function findNearestBuilding(x,y,type){
let best=null, bd=Infinity;
for(const b of game.buildings){
if(b.type!==type||b.dead) continue;
const d=Math.hypot(x-(b.x+b.w/2),y-(b.y+b.h/2));
if(d<bd){ bd=d; best=b; }
}
return best;
}
/* ============================ PATHFINDING ============================ */
function findPath(sx,sy,ex,ey){
const sxt=Math.floor(sx/TILE), syt=Math.floor(sy/TILE);
let ext=Math.floor(ex/TILE), eyt=Math.floor(ey/TILE);
if(game.occupied[eyt] && game.occupied[eyt][ext]){
const n=freeNeighbor(ext,eyt); if(n){ext=n.x; eyt=n.y;} else return [];
}
if(sxt===ext && syt===eyt) return [{x:ex,y:ey}];
const open=[]; const closed=new Set(); const from=new Map(); const g=new Map(); const f=new Map();
const sk=key(sxt,syt); g.set(sk,0); f.set(sk,Math.abs(ext-sxt)+Math.abs(eyt-syt)); open.push({x:sxt,y:syt,f:f.get(sk)});
while(open.length){
open.sort((a,b)=>a.f-b.f); const cur=open.shift(); const ck=key(cur.x,cur.y);
if(cur.x===ext && cur.y===eyt){
const path=[]; let node=ck;
while(from.has(node)){
const [px,py]=node.split(',').map(Number);
path.push({x:px*TILE+TILE/2,y:py*TILE+TILE/2});
node=from.get(node);
}
path.reverse(); path.push({x:ex,y:ey});
return path;
}
closed.add(ck);
for(const [dx,dy] of [[0,1],[1,0],[0,-1],[-1,0]]){
const nx=cur.x+dx, ny=cur.y+dy; const nk=key(nx,ny);
if(!inBounds(nx,ny)||game.occupied[ny][nx]||closed.has(nk)) continue;
const tg=(g.get(ck)||0)+1;
if(!g.has(nk)||tg<g.get(nk)){
from.set(nk,ck); g.set(nk,tg); f.set(nk,tg+Math.abs(ext-nx)+Math.abs(eyt-ny));
if(!open.find(o=>o.x===nx&&o.y===ny)) open.push({x:nx,y:ny,f:f.get(nk)});
}
}
}
return [];
}
/* ============================ SPAWNING ============================ */
function spawnStart(){
const cx=MAP_W/2*TILE, cy=MAP_H/2*TILE;
const th=new Building(cx-1.5*TILE, cy-1.5*TILE, 'townhall', 0, true);
game.buildings.push(th);
for(let i=0;i<3;i++) game.units.push(new Unit(cx-60+i*30, cy+60, 'worker', 0));
game.units.push(new Unit(cx, cy+80, 'soldier', 0));
game.player.used=4;
}
function spawnUnit(type, building){
const angle=Math.random()*Math.PI*2;
const d=building.w/2+20;
const x=building.x+building.w/2+Math.cos(angle)*d;
const y=building.y+building.h/2+Math.sin(angle)*d;
const u=new Unit(x,y,type,0);
game.units.push(u); game.player.used++;
updateUI();
}
function spawnParticle(x,y,color){ game.particles.push(new Particle(x,y,color)); }
/* ============================ INPUT ============================ */
function setupInput(){
window.addEventListener('resize', resize);
canvas.addEventListener('mousedown', e=>{
const rect=canvas.getBoundingClientRect();
game.mouse.x=e.clientX-rect.left; game.mouse.y=e.clientY-rect.top;
if(e.button===0){
game.mouse.down=true;
game.mouse.drag={x:game.mouse.x, y:game.mouse.y, sx:game.mouse.x+game.cam.x, sy:game.mouse.y+game.cam.y};
} else if(e.button===2){
game.mouse.rightDown=true; handleRightClick();
}
});
window.addEventListener('mousemove', e=>{
const rect=canvas.getBoundingClientRect();
game.mouse.x=e.clientX-rect.left; game.mouse.y=e.clientY-rect.top;
});
window.addEventListener('mouseup', e=>{
if(e.button===0){
if(game.mouse.down && game.mouse.drag){
const dx=game.mouse.x-game.mouse.drag.x; const dy=game.mouse.y-game.mouse.drag.y;
if(Math.hypot(dx,dy)<5) handleLeftClick();
else handleBoxSelect();
}
game.mouse.down=false; game.mouse.drag=null;
} else if(e.button===2) game.mouse.rightDown=false;
});
canvas.addEventListener('contextmenu', e=>e.preventDefault());
// Minimap click
mmCanvas.addEventListener('mousedown', e=>{
const rect=mmCanvas.getBoundingClientRect();
const mx=(e.clientX-rect.left)*(MAP_W/rect.width);
const my=(e.clientY-rect.top)*(MAP_H/rect.height);
game.cam.x=mx*TILE-canvas.width/2; game.cam.y=my*TILE-canvas.height/2; clampCam();
});
// Keys
const keys={};
window.addEventListener('keydown', e=>{
keys[e.key]=true;
if(e.key==='Escape'){ game.mode='normal'; game.placeType=null; }
if(e.key==='b'||e.key==='B'){ showBuildMenu(); }
});
window.addEventListener('keyup', e=>keys[e.key]=false);
// Edge pan + keys
setInterval(()=>{
const s=320*TICK;
if(keys['ArrowLeft']||game.mouse.x<20) game.cam.x-=s;
if(keys['ArrowRight']||game.mouse.x>canvas.width-20) game.cam.x+=s;
if(keys['ArrowUp']||game.mouse.y<20) game.cam.y-=s;
if(keys['ArrowDown']||game.mouse.y>canvas.height-20) game.cam.y+=s;
clampCam();
}, 1000/60);
}
function clampCam(){
game.cam.x=clamp(game.cam.x, 0, MAP_W*TILE-canvas.width);
game.cam.y=clamp(game.cam.y, 0, MAP_H*TILE-canvas.height);
}
function world(mx,my){ return {x:mx+game.cam.x, y:my+game.cam.y}; }
function handleLeftClick(){
const w=world(game.mouse.x,game.mouse.y);
// If placing building
if(game.mode==='place' && game.placeType){
const tx=Math.floor(w.x/TILE), ty=Math.floor(w.y/TILE);
const sz=COSTS[game.placeType].sz;
const c=canPlace(tx,ty,sz);
if(c.ok){
const cost=COSTS[game.placeType];
if(game.player.gold>=cost.g && game.player.wood>=cost.w){
game.player.gold-=cost.g; game.player.wood-=cost.w;
const b=new Building(tx*TILE,ty*TILE,game.placeType,0,false);
game.buildings.push(b); rebuildOccupied();
// Auto-assign selected workers to build
for(const u of game.selected) if(u instanceof Unit && u.type==='worker'){ u.buildT=b; u.state='build'; u.timer=0; }
updateUI();
}
}
game.mode='normal'; game.placeType=null;
return;
}
// Select entity
let hit=null;
// Units first
for(const u of game.units){
if(u.dead) continue;
if(Math.hypot(u.x-w.x,u.y-w.y)<u.radius+4){ hit=u; break; }
}
if(!hit){
for(const b of game.buildings){
if(b.dead) continue;
if(w.x>=b.x && w.x<=b.x+b.w && w.y>=b.y && w.y<=b.y+b.h){ hit=b; break; }
}
}
if(hit){ game.selected=[hit]; }
else { game.selected=[]; }
refreshUI();
}
function handleBoxSelect(){
if(game.mode!=='normal') return;
const w1=world(game.mouse.drag.x, game.mouse.drag.y);
const w2=world(game.mouse.x, game.mouse.y);
const minX=Math.min(w1.x,w2.x), maxX=Math.max(w1.x,w2.x);
const minY=Math.min(w1.y,w2.y), maxY=Math.max(w1.y,w2.y);
game.selected=[];
for(const u of game.units){
if(u.dead||u.team!==0) continue;
if(u.x>=minX && u.x<=maxX && u.y>=minY && u.y<=maxY) game.selected.push(u);
}
refreshUI();
}
function handleRightClick(){
const w=world(game.mouse.x,game.mouse.y);
// Find target resource
let res=null;
for(const r of game.mapRes){
if(r.dead) continue;
if(Math.hypot(r.x-w.x,r.y-w.y)<TILE*0.8){ res=r; break; }
}
for(const u of game.selected){
if(!(u instanceof Unit)) continue;
if(u.type==='worker' && res){
u.gatherT=res; u.state='gather'; u.timer=0.5;
} else {
u.moveTo(w.x,w.y,false);
}
}
}
function canPlace(tx,ty,sz){
let ok=true, allRevealed=true;
for(let y=ty;y<ty+sz;y++){
for(let x=tx;x<tx+sz;x++){
if(!inBounds(x,y)){ ok=false; }
else {
if(game.occupied[y][x]) ok=false;
if(!game.revealed[y][x]) allRevealed=false;
}
}
}
return {ok, allRevealed};
}
/* ============================ UI ============================ */
function updateUI(){
document.getElementById('resGold').textContent=game.player.gold;
document.getElementById('resWood').textContent=game.player.wood;
document.getElementById('resFood').textContent=game.player.used+'/'+game.player.food;
}
function refreshUI(){
const infoName=document.getElementById('infoName');
const infoStats=document.getElementById('infoStats');
const infoHp=document.getElementById('infoHp');
const portrait=document.getElementById('portraitBox');
const cmd=document.getElementById('commandColumn');
cmd.innerHTML='';
if(game.selected.length===0){
infoName.textContent='None';
infoStats.innerHTML='';
infoHp.style.width='0%';
portrait.textContent='?';
return;
}
const first=game.selected[0];
const multi=game.selected.length>1;
if(first instanceof Unit){
portrait.textContent=first.type==='worker'?'👷':'⚔️';
infoName.textContent=(multi?game.selected.length+' ':'')+(first.type==='worker'?'Worker':'Soldier');
infoStats.innerHTML=multi?'':'HP: '+Math.ceil(first.hp)+'/'+first.maxHp+'<br>State: '+first.state;
infoHp.style.width=multi?'0%':(first.hp/first.maxHp*100)+'%';
} else if(first instanceof Building){
portrait.textContent=first.type==='townhall'?'🏰':first.type==='barracks'?'🏛️':'🌾';
infoName.textContent=first.type==='townhall'?'Town Hall':first.type==='barracks'?'Barracks':'Farm';
infoStats.innerHTML='HP: '+Math.ceil(first.hp)+'/'+first.maxHp+(first.queue.length?'<br>Training: '+first.queue.length:'');
infoHp.style.width=(first.hp/first.maxHp*100)+'%';
}
// Command buttons
if(first instanceof Building && !multi){
if(first.type==='townhall'){
const cost=COSTS.worker;
const can=game.player.gold>=cost.g && game.player.wood>=cost.w && game.player.used<game.player.food;
addBtn(cmd,'Train Worker','50G',can,()=>{ queueUnit(first,'worker'); });
}
if(first.type==='barracks'){
const cost=COSTS.soldier;
const can=game.player.gold>=cost.g && game.player.wood>=cost.w && game.player.used<game.player.food;
addBtn(cmd,'Train Soldier','75G 25W',can,()=>{ queueUnit(first,'soldier'); });
}
}
if(game.selected.some(e=>e instanceof Unit && e.type==='worker')){
addBtn(cmd,'Build Town Hall','400G 200W',true,()=>{ startPlace('townhall'); });
addBtn(cmd,'Build Barracks','150G 100W',true,()=>{ startPlace('barracks'); });
addBtn(cmd,'Build Farm','100G 50W',true,()=>{ startPlace('farm'); });
}
}
function addBtn(parent,title,sub,enabled,onclick){
const b=document.createElement('div');
b.className='cmdBtn'+(enabled?'':' disabled');
b.innerHTML='<div>'+title+'</div><div class="cmdKey">'+sub+'</div>';
if(enabled) b.onclick=onclick;
parent.appendChild(b);
}
function showBuildMenu(){
// just a shortcut to trigger if workers selected
if(game.selected.some(e=>e instanceof Unit && e.type==='worker')){
// open first option? just set townhall for demo or show nothing (buttons already visible)
}
}
function startPlace(type){
game.mode='place'; game.placeType=type;
}
function queueUnit(building, type){
const cost=COSTS[type];
if(game.player.gold<cost.g || game.player.wood<cost.w || game.player.used>=game.player.food) return;
game.player.gold-=cost.g; game.player.wood-=cost.w;
building.queue.push(type);
if(building.queue.length===1) building.qTimer=cost.time;
updateUI(); refreshUI();
}
/* ============================ RENDER ============================ */
function resize(){
canvas.width=window.innerWidth; canvas.height=window.innerHeight;
}
function drawMap(){
const sx=Math.floor(game.cam.x/TILE), sy=Math.floor(game.cam.y/TILE);
const ex=sx+Math.ceil(canvas.width/TILE)+1, ey=sy+Math.ceil(canvas.height/TILE)+1;
for(let y=sy;y<=ey;y++){
for(let x=sx;x<=ex;x++){
if(!inBounds(x,y)) continue;
const rx=x*TILE, ry=y*TILE;
if(!game.revealed[y][x]){ ctx.fillStyle='#000'; ctx.fillRect(rx,ry,TILE,TILE); continue; }
const t=game.tiles[y][x];
if(t===1){ ctx.fillStyle='#2b65ec'; ctx.fillRect(rx,ry,TILE,TILE); }
else if(t===2){ ctx.fillStyle='#2e5d27'; ctx.fillRect(rx,ry,TILE,TILE); ctx.fillStyle='#43a047'; ctx.beginPath(); ctx.arc(rx+TILE/2,ry+TILE/2,TILE*0.35,0,Math.PI*2); ctx.fill(); }
else { ctx.fillStyle=((x+y)%2===0)?'#4caf50':'#43a047'; ctx.fillRect(rx,ry,TILE,TILE); }
}
}
}
function drawEntities(){
const list=[...game.mapRes.filter(r=>!r.dead), ...game.buildings.filter(b=>!b.dead), ...game.units.filter(u=>!u.dead)];
list.sort((a,b)=>a.y-b.y);
for(const e of list){
const tx=Math.floor(e.x/TILE), ty=Math.floor(e.y/TILE);
if(!inBounds(tx,ty) || !game.revealed[ty][tx]) continue;
if(e instanceof MapRes) drawRes(e);
else if(e instanceof Building) drawBuilding(e);
else if(e instanceof Unit) drawUnit(e);
}
}
function drawRes(r){
ctx.save(); ctx.translate(r.x,r.y);
if(r.resType==='gold'){
ctx.fillStyle='#fbc02d'; ctx.beginPath(); ctx.arc(0,0,r.radius,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#f9a825'; ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle='#000'; ctx.font='10px sans-serif'; ctx.textAlign='center'; ctx.fillText('Au',0,3);
} else {
ctx.fillStyle='#5d4037'; ctx.fillRect(-4,0,8,12);
ctx.fillStyle='#388e3c'; ctx.beginPath(); ctx.arc(0,-4,10,0,Math.PI*2); ctx.fill();
}
ctx.restore();
}
function drawBuilding(b){
ctx.save(); ctx.translate(b.x,b.y);
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(4,4,b.w,b.h);
let color='#8d6e63';
if(b.type==='townhall') color='#795548';
else if(b.type==='barracks') color='#6d4c41';
else if(b.type==='farm') color='#558b2f';
ctx.fillStyle=color; ctx.fillRect(0,0,b.w,b.h);
ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2; ctx.strokeRect(4,4,b.w-8,b.h-8);
ctx.fillStyle='#4caf50'; ctx.fillRect(0,0,b.w,4);
if(!b.built){
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(0,0,b.w,b.h);
ctx.fillStyle='#fff'; ctx.font='12px sans-serif'; ctx.textAlign='center'; ctx.fillText('Building...',b.w/2,b.h/2);
ctx.fillStyle='#ff9800'; ctx.fillRect(4,b.h-8,b.w-8,4);
ctx.fillStyle='#4caf50'; ctx.fillRect(4,b.h-8,(b.w-8)*(b.buildProgress/b.buildMax),4);
}
if(game.selected.includes(b)){
ctx.strokeStyle='#76ff03'; ctx.lineWidth=2; ctx.strokeRect(-2,-2,b.w+4,b.h+4);
}
if(b.queue.length>0){
ctx.fillStyle='#fff'; ctx.font='10px sans-serif'; ctx.textAlign='center';
ctx.fillText('Q:'+b.queue.length,b.w/2,-6);
}
ctx.restore();
}
function drawUnit(u){
ctx.save(); ctx.translate(u.x+u.ox,u.y+u.oy);
if(game.selected.includes(u)){
ctx.strokeStyle='#76ff03'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.ellipse(0,8,u.radius+3,u.radius*0.6,0,0,Math.PI*2); ctx.stroke();
}
ctx.fillStyle=u.type==='worker'?'#ff9800':'#e53935';
ctx.beginPath(); ctx.arc(0,0,u.radius,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#222'; ctx.lineWidth=1; ctx.stroke();
// facing
let fx=1,fy=0;
if(u.path.length>u.pi){ fx=u.path[u.pi].x-u.x; fy=u.path[u.pi].y-u.y; const d=Math.hypot(fx,fy)||1; fx/=d; fy/=d; }
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.moveTo(fx*u.radius,fy*u.radius); ctx.lineTo(fx*u.radius*0.4+fy*u.radius*0.6,fy*u.radius*0.4-fx*u.radius*0.6); ctx.lineTo(fx*u.radius*0.4-fy*u.radius*0.6,fy*u.radius*0.4+fx*u.radius*0.6); ctx.fill();
// carry
if(u.carry.amount>0){
ctx.fillStyle=u.carry.type==='gold'?'#ffeb3b':'#8d6e63';
ctx.fillRect(-5,-u.radius-7,10,5);
}
ctx.restore();
}
function drawOverlay(){
// Selection box
if(game.mouse.down && game.mouse.drag){
const w1=game.mouse.drag.x, w2=game.mouse.x;
const h1=game.mouse.drag.y, h2=game.mouse.y;
ctx.strokeStyle='#76ff03'; ctx.lineWidth=1; ctx.setLineDash([4,4]);
ctx.strokeRect(w1,h1,w2-w1,h2-h1); ctx.setLineDash([]);
}
// Building ghost
if(game.mode==='place' && game.placeType){
const w=world(game.mouse.x,game.mouse.y);
const tx=Math.floor(w.x/TILE), ty=Math.floor(w.y/TILE);
const sz=COSTS[game.placeType].sz;
const ok=canPlace(tx,ty,sz).ok;
ctx.save(); ctx.translate(tx*TILE,ty*TILE);
ctx.fillStyle=ok?'rgba(76,175,80,0.35)':'rgba(244,67,54,0.35)';
ctx.fillRect(0,0,sz*TILE,sz*TILE);
ctx.strokeStyle=ok?'#4caf50':'#f44336'; ctx.lineWidth=2; ctx.strokeRect(0,0,sz*TILE,sz*TILE);
ctx.restore();
}
}
function drawParticles(){
for(const p of game.particles){
ctx.globalAlpha=clamp(p.life,0,1);
ctx.fillStyle=p.color;
ctx.fillRect(p.x-2,p.y-2,4,4);
}
ctx.globalAlpha=1;
}
function renderMinimap(){
mmCtx.clearRect(0,0,MAP_W,MAP_H);
for(let y=0;y<MAP_H;y++){
for(let x=0;x<MAP_W;x++){
if(!game.revealed[y][x]){ mmCtx.fillStyle='#000'; }
else if(game.tiles[y][x]===1) mmCtx.fillStyle='#2b65ec';
else if(game.tiles[y][x]===2) mmCtx.fillStyle='#2e7d32';
else mmCtx.fillStyle='#66bb6a';
mmCtx.fillRect(x,y,1,1);
}
}
for(const b of game.buildings){
if(b.dead) continue;
mmCtx.fillStyle='#fff';
mmCtx.fillRect(Math.floor(b.x/TILE),Math.floor(b.y/TILE),Math.max(1,Math.floor(b.w/TILE)),Math.max(1,Math.floor(b.h/TILE)));
}
for(const u of game.units){ if(!u.dead) { mmCtx.fillStyle=u.team===0?'#0f0':'#f00'; mmCtx.fillRect(Math.floor(u.x/TILE),Math.floor(u.y/TILE),1,1); } }
mmCtx.strokeStyle='#ffeb3b'; mmCtx.lineWidth=1;
mmCtx.strokeRect(Math.floor(game.cam.x/TILE),Math.floor(game.cam.y/TILE),Math.ceil(canvas.width/TILE),Math.ceil(canvas.height/TILE));
}
/* ============================ MAIN LOOP ============================ */
function update(dt){
game.time+=dt;
// Reveal fog
for(const u of game.units){
if(u.dead) continue;
const tx=Math.floor(u.x/TILE), ty=Math.floor(u.y/TILE);
for(let y=ty-5;y<=ty+5;y++) for(let x=tx-5;x<=tx+5;x++){
if(inBounds(x,y) && Math.hypot(x-tx,y-ty)<=5) game.revealed[y][x]=true;
}
}
// Entities
for(const u of game.units) u.update(dt);
for(const b of game.buildings) b.update(dt);
game.particles=game.particles.filter(p=>p.life>0);
for(const p of game.particles) p.update(dt);
// Camera clamp
clampCam();
}
function render(){
ctx.fillStyle='#000'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.save();
ctx.translate(-Math.floor(game.cam.x), -Math.floor(game.cam.y));
drawMap();
drawEntities();
drawParticles();
ctx.restore();
drawOverlay();
renderMinimap();
}
let last=0;
function loop(ts){
const dt=Math.min((ts-last)/1000,0.1); last=ts;
update(dt); render();
requestAnimationFrame(loop);
}
/* ============================ START ============================ */
resize();
initMap();
spawnStart();
setupInput();
updateUI();
refreshUI();
requestAnimationFrame(loop);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment