Created
June 15, 2026 17:46
-
-
Save senko/41d8a744805c6c3ecd7d4b3c712f3c25 to your computer and use it in GitHub Desktop.
RTS game by Kimi K2.7-code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>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 & Command & 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