Created
April 29, 2026 13:43
-
-
Save senko/ede62589a5c97603abd10bbe70a0263e to your computer and use it in GitHub Desktop.
RTS game by Kimi K2.6
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>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