Created
July 16, 2026 17:51
-
-
Save senko/ea289747090ca5ecf328415ca5147688 to your computer and use it in GitHub Desktop.
index.html
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"/> | |
| <title>Little Kingdoms — Tiny RTS</title> | |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏰</text></svg>"> | |
| <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&display=swap" rel="stylesheet"> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html,body{width:100%;height:100%;overflow:hidden;background:#0b0f0b;font-family:'Nunito',sans-serif; | |
| user-select:none;-webkit-user-select:none;cursor:default} | |
| #game{position:fixed;inset:0;display:block} | |
| /* ---------- top bar ---------- */ | |
| #topbar{position:fixed;top:0;left:50%;transform:translateX(-50%);display:flex;gap:20px;align-items:center; | |
| background:rgba(12,16,12,.85);backdrop-filter:blur(6px);border:1px solid #4a4030;border-top:none; | |
| border-radius:0 0 14px 14px;padding:8px 22px;color:#f3e9c8;font-weight:800;z-index:20; | |
| box-shadow:0 4px 18px rgba(0,0,0,.5);font-size:15px;white-space:nowrap} | |
| .res{display:flex;gap:6px;align-items:center} | |
| .res b{min-width:34px;text-align:left;color:#ffe9a8} | |
| .tbtn{background:#232b1e;color:#ffe9a8;border:1px solid #6a5a2f;border-radius:8px;padding:4px 12px; | |
| font-family:inherit;font-weight:800;cursor:pointer;font-size:13px} | |
| .tbtn:hover{background:#31402a} | |
| /* ---------- bottom bar ---------- */ | |
| #bottombar{position:fixed;left:0;right:0;bottom:0;display:flex;justify-content:space-between; | |
| align-items:flex-end;padding:10px;pointer-events:none;z-index:20;gap:10px} | |
| #bottombar>*{pointer-events:auto} | |
| #minimap{width:180px;height:180px;border:2px solid #6a5a2f;border-radius:10px;background:#000; | |
| box-shadow:0 4px 18px rgba(0,0,0,.6);cursor:pointer;display:block} | |
| #panel{flex:0 1 auto;display:flex;gap:16px;background:rgba(14,18,14,.88);backdrop-filter:blur(8px); | |
| border:1px solid #4a4030;border-radius:14px;padding:12px 16px;min-width:540px;max-width:820px; | |
| min-height:156px;color:#e8e2c8;box-shadow:0 4px 18px rgba(0,0,0,.6)} | |
| #pinfo{width:230px;flex:none;border-right:1px solid #333a2c;padding-right:14px} | |
| #pactions{flex:1;display:flex;flex-wrap:wrap;gap:8px;align-content:flex-start;align-items:flex-start} | |
| .prow{display:flex;gap:12px;align-items:center} | |
| .portrait{width:62px;height:62px;flex:none;border-radius:10px;background:#1d241d;border:1px solid #5a5232; | |
| display:flex;align-items:center;justify-content:center;font-size:34px;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} | |
| .pname{font-weight:900;font-size:16px;color:#ffe9a8;margin-bottom:4px} | |
| .psub{font-size:12px;color:#b9b294;margin-top:4px;line-height:1.45} | |
| .bar{height:9px;background:#101410;border:1px solid #3a3a28;border-radius:5px;overflow:hidden;margin-top:4px;min-width:90px} | |
| .bar>i{display:block;height:100%;background:linear-gradient(90deg,#7be382,#46b450);transition:width .15s} | |
| .bar.mini{height:5px;margin-top:3px} | |
| .cmd{width:92px;height:78px;background:linear-gradient(180deg,#2a3324,#1c2318);border:1px solid #6a5a2f; | |
| border-radius:10px;color:#eee6c4;font-family:inherit;cursor:pointer;display:flex;flex-direction:column; | |
| align-items:center;justify-content:center;gap:2px;padding:4px} | |
| .cmd:hover{border-color:#e8c15a;box-shadow:0 0 10px rgba(232,193,90,.35);background:linear-gradient(180deg,#35402c,#232b1c)} | |
| .cmd.dis{opacity:.42;filter:grayscale(.7);cursor:not-allowed} | |
| .cmd .ic{font-size:24px;line-height:1} | |
| .cmd .nm{font-size:11px;font-weight:800} | |
| .cmd .cost{font-size:10px;color:#cbbf92} | |
| .cmd u{text-decoration:none;background:#3a3a28;border-radius:3px;padding:0 3px;font-size:9px;color:#ffe9a8} | |
| .queue{display:flex;gap:6px;align-items:center} | |
| .qitem{width:40px;height:46px;background:#1d241d;border:1px solid #5a5232;border-radius:8px;font-size:20px; | |
| display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;padding:2px} | |
| .qitem:hover{border-color:#ff8a6b} | |
| .hint{width:100%;font-size:11px;color:#8f8a70;margin-top:2px} | |
| .grid{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px} | |
| .cell{width:30px;height:30px;background:#1d241d;border:1px solid #4a4a32;border-radius:6px;font-size:16px; | |
| display:flex;align-items:center;justify-content:center} | |
| /* ---------- toasts ---------- */ | |
| #toasts{position:fixed;top:60px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column; | |
| gap:6px;z-index:40;pointer-events:none;align-items:center} | |
| .toast{background:rgba(20,24,20,.92);border:1px solid #6a5a2f;color:#ffe9a8;padding:6px 16px;border-radius:9px; | |
| font-weight:800;font-size:13px;animation:tin .25s ease-out;box-shadow:0 4px 14px rgba(0,0,0,.5)} | |
| @keyframes tin{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}} | |
| /* ---------- overlays ---------- */ | |
| .overlay{position:fixed;inset:0;background:rgba(0,0,0,.62);display:flex;align-items:center;justify-content:center;z-index:60} | |
| .card{background:linear-gradient(180deg,#1a2117,#12170f);border:1px solid #6a5a2f;border-radius:18px; | |
| padding:28px 34px;max-width:560px;color:#eee6c4;box-shadow:0 12px 50px rgba(0,0,0,.8)} | |
| .card h1{font-size:26px;color:#ffd75e;margin-bottom:4px} | |
| .card h2{font-size:15px;color:#b9b294;font-weight:700;margin-bottom:14px} | |
| .card ul{list-style:none;margin:0 0 18px} | |
| .card li{padding:4px 0;font-size:14px;line-height:1.5} | |
| .card li b{color:#ffe9a8} | |
| .kbd{background:#2a3324;border:1px solid #5a5232;border-radius:5px;padding:0 7px;font-size:12px;font-weight:800;color:#ffe9a8} | |
| .btn{background:linear-gradient(180deg,#8a6f2f,#6a5420);border:1px solid #e8c15a;color:#fff3d0;border-radius:10px; | |
| padding:10px 26px;font-family:inherit;font-weight:900;font-size:15px;cursor:pointer} | |
| .btn:hover{filter:brightness(1.15)} | |
| .stats{display:flex;gap:22px;margin:6px 0 18px;flex-wrap:wrap} | |
| .stat{text-align:center} | |
| .stat .v{font-size:24px;font-weight:900;color:#ffd75e} | |
| .stat .l{font-size:11px;color:#b9b294} | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <div id="topbar"> | |
| <span class="res">🪙 <b id="rGold">0</b></span> | |
| <span class="res">🪵 <b id="rWood">0</b></span> | |
| <span class="res">👥 <b id="rSup">0/0</b></span> | |
| <span class="res">🗺️ <b id="rExp">0%</b></span> | |
| <span class="res">⏱️ <b id="rTime">0:00</b></span> | |
| <button class="tbtn" id="helpBtn">❓ Help</button> | |
| </div> | |
| <div id="bottombar"> | |
| <canvas id="minimap"></canvas> | |
| <div id="panel"> | |
| <div id="pinfo"></div> | |
| <div id="pactions"></div> | |
| </div> | |
| </div> | |
| <div id="toasts"></div> | |
| <div id="help" class="overlay"> | |
| <div class="card"> | |
| <h1>🏰 Little Kingdoms</h1> | |
| <h2>A tiny real-time strategy game — explore 95% of the map to win!</h2> | |
| <ul> | |
| <li>🖱️ <b>Left-click</b> select · <b>drag</b> box-select · <b>double-click</b> select all of a type</li> | |
| <li>🖱️ <b>Right-click</b> move · gather (mine 🪙 / tree 🪵) · attack · set rally point</li> | |
| <li>👷 <b>Workers</b> gather gold & wood and construct buildings (drop off at a Town Hall)</li> | |
| <li>🌾 Build <b>Farms</b> for supply · 🛡️ <b>Barracks</b> train soldiers · 🏰 extra <b>Town Halls</b> speed up gathering</li> | |
| <li>🗺️ Camera: <span class="kbd">←↑↓→</span> · screen edges · minimap · <b>wheel</b> to zoom</li> | |
| <li>⌨️ Hotkeys: <span class="kbd">F</span> farm · <span class="kbd">R</span> barracks · <span class="kbd">T</span> town hall · <span class="kbd">W</span> worker · <span class="kbd">S</span> soldier · <span class="kbd">Esc</span> cancel</li> | |
| <li>👾 Wild slimes lurk in the fog — harmless, and worth <b>25🪙</b> bounty</li> | |
| </ul> | |
| <button class="btn" id="startBtn">⚔️ Start playing</button> | |
| </div> | |
| </div> | |
| <div id="victory" class="overlay" style="display:none"> | |
| <div class="card" style="text-align:center"> | |
| <h1>🎉 The realm is charted!</h1> | |
| <h2>You have explored the land. Victory!</h2> | |
| <div class="stats" style="justify-content:center"> | |
| <div class="stat"><div class="v" id="vTime">0:00</div><div class="l">Time</div></div> | |
| <div class="stat"><div class="v" id="vUnits">0</div><div class="l">Units trained</div></div> | |
| <div class="stat"><div class="v" id="vBuild">0</div><div class="l">Buildings built</div></div> | |
| <div class="stat"><div class="v" id="vSlain">0</div><div class="l">Slimes slain</div></div> | |
| </div> | |
| <button class="btn" id="keepBtn">Keep playing</button> | |
| </div> | |
| </div> | |
| <script> | |
| 'use strict'; | |
| /* ============================================================ | |
| Little Kingdoms — a tiny RTS in a single file | |
| ============================================================ */ | |
| // ---------------- utils ---------------- | |
| const clamp=(v,a,b)=>v<a?a:v>b?b:v; | |
| const rand=(a,b)=>a+Math.random()*(b-a); | |
| const randi=(a,b)=>Math.floor(rand(a,b+1)); | |
| const choice=a=>a[Math.floor(Math.random()*a.length)]; | |
| const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1); | |
| function hash2(x,y){const h=Math.sin(x*127.1+y*311.7)*43758.5453;return h-Math.floor(h);} | |
| const byId=(arr,id)=>arr.find(e=>e.id===id); | |
| const $=id=>document.getElementById(id); | |
| // ---------------- config ---------------- | |
| const TILE=32, MAP_W=80, MAP_H=80; | |
| const WORLD_W=MAP_W*TILE, WORLD_H=MAP_H*TILE; | |
| const COSTS={ | |
| worker: {gold:50, supply:1, time:6}, | |
| soldier: {gold:90, supply:2, time:9}, | |
| farm: {wood:80, time:6, hp:250}, | |
| barracks:{wood:160,gold:40, time:10, hp:450}, | |
| townhall:{gold:300,wood:150,time:15, hp:700}, | |
| }; | |
| const SUPPLY={townhall:12,farm:8}; | |
| const SIGHT={worker:5,soldier:6,townhall:9,farm:5,barracks:7}; | |
| const BUILD_SIZE={townhall:4,barracks:3,farm:2}; | |
| const NAMES={townhall:'Town Hall',farm:'Farm',barracks:'Barracks',worker:'Worker',soldier:'Footman'}; | |
| const GATHER_TIME=1.6, GOLD_TRIP=10, WOOD_TRIP=8; | |
| // ---------------- state ---------------- | |
| let gold=250, wood=150; | |
| let units=[], buildings=[], resources=[], monsters=[], particles=[], floats=[]; | |
| let nextId=1; | |
| let selection=[], selectedBuilding=null, selectedMonster=null; | |
| let placement=null; | |
| let cam={x:0,y:0}, zoom=1; | |
| let time=0, gameTime=0, won=false; | |
| let stats={trained:0,built:0,slain:0}; | |
| let mouse={x:0,y:0,in:false}; | |
| let keys={}; | |
| let selBox=null, mmDrag=false; | |
| // ---------------- canvas ---------------- | |
| const cv=$('game'), ctx=cv.getContext('2d'); | |
| const mm=$('minimap'), mmc=mm.getContext('2d'); | |
| const DPR=Math.min(2,window.devicePixelRatio||1); | |
| let cvW=innerWidth, cvH=innerHeight; | |
| function resize(){ | |
| cvW=innerWidth; cvH=innerHeight; | |
| cv.width=cvW*DPR; cv.height=cvH*DPR; | |
| cv.style.width=cvW+'px'; cv.style.height=cvH+'px'; | |
| } | |
| addEventListener('resize',resize); resize(); | |
| mm.width=180*DPR; mm.height=180*DPR; mmc.scale(DPR,DPR); | |
| // ---------------- map ---------------- | |
| const terrain=new Uint8Array(MAP_W*MAP_H); // 0 grass 1 water 2 sand 3 dirt | |
| const objBlock=new Uint8Array(MAP_W*MAP_H); // 1 = tree/mine/building | |
| const tIdx=(x,y)=>y*MAP_W+x; | |
| const inB=(x,y)=>x>=0&&y>=0&&x<MAP_W&&y<MAP_H; | |
| const walkable=(x,y)=>inB(x,y)&&terrain[tIdx(x,y)]!==1&&objBlock[tIdx(x,y)]===0; | |
| function markBlock(tx,ty,w,h,v){for(let y=ty;y<ty+h;y++)for(let x=tx;x<tx+w;x++)if(inB(x,y))objBlock[tIdx(x,y)]=v;} | |
| const corners=[[12,12],[MAP_W-14,12],[12,MAP_H-14],[MAP_W-14,MAP_H-14]]; | |
| const start=choice(corners); | |
| function paintBlob(cx,cy,r,type,minStartDist){ | |
| for(let y=Math.floor(cy-r-2);y<=cy+r+2;y++)for(let x=Math.floor(cx-r-2);x<=cx+r+2;x++){ | |
| if(!inB(x,y))continue; | |
| const d=dist(x,y,cx,cy)+(hash2(x,y)-0.5)*2.4; | |
| if(d<r&&dist(x,y,start[0],start[1])>minStartDist)terrain[tIdx(x,y)]=type; | |
| } | |
| } | |
| function genMap(){ | |
| // water lakes | |
| for(let i=0;i<8;i++)paintBlob(rand(6,MAP_W-6),rand(6,MAP_H-6),rand(2.5,5.5),1,13); | |
| // dirt patches (walkable decoration) | |
| for(let i=0;i<12;i++)paintBlob(rand(4,MAP_W-4),rand(4,MAP_H-4),rand(1.5,3.5),3,8); | |
| // sand ring around water | |
| for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){ | |
| if(terrain[tIdx(x,y)]!==1)continue; | |
| for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ | |
| const nx=x+dx,ny=y+dy; | |
| if(inB(nx,ny)&&terrain[tIdx(nx,ny)]!==1)terrain[tIdx(nx,ny)]=2; | |
| } | |
| } | |
| // clear start area | |
| for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++) | |
| if(dist(x,y,start[0],start[1])<9&&terrain[tIdx(x,y)]===1)terrain[tIdx(x,y)]=0; | |
| // forests | |
| let placed=0,guard=0; | |
| while(placed<300&&guard++<4000){ | |
| const cx=randi(3,MAP_W-4),cy=randi(3,MAP_H-4); | |
| if(dist(cx,cy,start[0],start[1])<10)continue; | |
| let x=cx,y=cy; | |
| const n=randi(6,16); | |
| for(let i=0;i<n;i++){ | |
| if(inB(x,y)&&terrain[tIdx(x,y)]!==1&&objBlock[tIdx(x,y)]===0&&dist(x,y,start[0],start[1])>9){ | |
| resources.push({id:nextId++,kind:'tree',tx:x,ty:y,w:1,h:1,amount:200,max:200}); | |
| objBlock[tIdx(x,y)]=1; placed++; | |
| } | |
| x+=randi(-1,1); y+=randi(-1,1); | |
| } | |
| } | |
| // gold mines (2x2) | |
| const canMine=(tx,ty)=>{ | |
| for(let y=ty;y<ty+2;y++)for(let x=tx;x<tx+2;x++) | |
| if(!inB(x,y)||terrain[tIdx(x,y)]===1||objBlock[tIdx(x,y)])return false; | |
| return true; | |
| }; | |
| const addMine=(tx,ty)=>{resources.push({id:nextId++,kind:'mine',tx,ty,w:2,h:2,amount:3000,max:3000});markBlock(tx,ty,2,2,1);}; | |
| // one mine near start | |
| let done=false; | |
| for(let a=0;a<40&&!done;a++){ | |
| const ang=rand(0,6.283),d=rand(6.5,9); | |
| const tx=Math.round(start[0]+Math.cos(ang)*d),ty=Math.round(start[1]+Math.sin(ang)*d); | |
| if(canMine(tx,ty)){addMine(tx,ty);done=true;} | |
| } | |
| // scattered mines | |
| guard=0;let m=0; | |
| while(m<5&&guard++<800){ | |
| const tx=randi(2,MAP_W-4),ty=randi(2,MAP_H-4); | |
| if(dist(tx,ty,start[0],start[1])<14)continue; | |
| if(!canMine(tx,ty))continue; | |
| addMine(tx,ty);m++; | |
| } | |
| // slimes | |
| guard=0;let s=0; | |
| while(s<10&&guard++<2000){ | |
| const x=randi(2,MAP_W-3),y=randi(2,MAP_H-3); | |
| if(!walkable(x,y)||dist(x,y,start[0],start[1])<20)continue; | |
| monsters.push({id:nextId++,x:(x+0.5)*TILE,y:(y+0.5)*TILE,hp:45,maxHp:45}); | |
| s++; | |
| } | |
| // starting base | |
| const th={id:nextId++,type:'townhall',tx:start[0]-2,ty:start[1]-2,w:4,h:4, | |
| hp:COSTS.townhall.hp,maxHp:COSTS.townhall.hp,state:'done',progress:1,queue:[],rally:null}; | |
| buildings.push(th); markBlock(th.tx,th.ty,4,4,1); | |
| for(let i=0;i<4;i++){ | |
| const a=i/4*6.283; | |
| units.push(makeUnit('worker',(start[0]+2.5+Math.cos(a)*2.2)*TILE,(start[1]+2.5+Math.sin(a)*2.2)*TILE)); | |
| } | |
| cam.x=(start[0]+2)*TILE-cvW/zoom/2; cam.y=(start[1]+2)*TILE-cvH/zoom/2; clampCam(); | |
| } | |
| // ---------------- fog of war ---------------- | |
| const fogE=new Uint8Array(MAP_W*MAP_H), fogV=new Uint8Array(MAP_W*MAP_H); | |
| let exploredCount=0; | |
| const fogCanvas=document.createElement('canvas'); fogCanvas.width=MAP_W; fogCanvas.height=MAP_H; | |
| const fogCtx=fogCanvas.getContext('2d'); | |
| const fogImg=fogCtx.createImageData(MAP_W,MAP_H); | |
| function updateFog(){ | |
| fogV.fill(0); | |
| const reveal=(cx,cy,r)=>{ | |
| const x0=Math.max(0,Math.floor(cx-r)),x1=Math.min(MAP_W-1,Math.ceil(cx+r)); | |
| const y0=Math.max(0,Math.floor(cy-r)),y1=Math.min(MAP_H-1,Math.ceil(cy+r)); | |
| for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++) | |
| if(dist(x+0.5,y+0.5,cx,cy)<=r)fogV[tIdx(x,y)]=1; | |
| }; | |
| for(const u of units)reveal(u.x/TILE,u.y/TILE,SIGHT[u.type]); | |
| for(const b of buildings)reveal(b.tx+b.w/2,b.ty+b.h/2,SIGHT[b.type]); | |
| const d=fogImg.data; | |
| for(let i=0;i<fogE.length;i++){ | |
| d[i*4]=6; d[i*4+1]=10; d[i*4+2]=14; | |
| if(fogV[i]){if(!fogE[i]){fogE[i]=1;exploredCount++;}d[i*4+3]=0;} | |
| else d[i*4+3]=fogE[i]?150:255; | |
| } | |
| fogCtx.putImageData(fogImg,0,0); | |
| } | |
| const tileVisible=(x,y)=>inB(x,y)&&fogV[tIdx(x,y)]; | |
| const tileExplored=(x,y)=>inB(x,y)&&fogE[tIdx(x,y)]; | |
| // ---------------- pathfinding (A*) ---------------- | |
| const gScore=new Float32Array(MAP_W*MAP_H); | |
| const fScore=new Float32Array(MAP_W*MAP_H); | |
| const cameFrom=new Int32Array(MAP_W*MAP_H); | |
| const closedA=new Uint8Array(MAP_W*MAP_H); | |
| const heap=[]; | |
| function hpush(i){heap.push(i);let c=heap.length-1;while(c>0){const p=(c-1)>>1;if(fScore[heap[p]]<=fScore[heap[c]])break;const t=heap[p];heap[p]=heap[c];heap[c]=t;c=p;}} | |
| function hpop(){const top=heap[0],last=heap.pop();if(heap.length){heap[0]=last;let p=0;for(;;){const l=2*p+1,r=l+1;let s=p;if(l<heap.length&&fScore[heap[l]]<fScore[heap[s]])s=l;if(r<heap.length&&fScore[heap[r]]<fScore[heap[s]])s=r;if(s===p)break;const t=heap[p];heap[p]=heap[s];heap[s]=t;p=s;}}return top;} | |
| const heur=(x0,y0,x1,y1)=>{const dx=Math.abs(x1-x0),dy=Math.abs(y1-y0);return Math.max(dx,dy)+0.4142*Math.min(dx,dy);}; | |
| function nearestWalkable(tx,ty,maxR=14){ | |
| tx=clamp(tx,0,MAP_W-1); ty=clamp(ty,0,MAP_H-1); | |
| if(walkable(tx,ty))return{x:tx,y:ty}; | |
| for(let r=1;r<=maxR;r++) | |
| for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){ | |
| if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue; | |
| if(walkable(tx+dx,ty+dy))return{x:tx+dx,y:ty+dy}; | |
| } | |
| return null; | |
| } | |
| const DIRS=[[1,0,1],[-1,0,1],[0,1,1],[0,-1,1],[1,1,1.4142],[1,-1,1.4142],[-1,1,1.4142],[-1,-1,1.4142]]; | |
| function findPath(sx,sy,tx,ty){ | |
| const s=nearestWalkable(sx,sy),t=nearestWalkable(tx,ty); | |
| if(!s||!t)return null; | |
| sx=s.x;sy=s.y;tx=t.x;ty=t.y; | |
| gScore.fill(Infinity);fScore.fill(Infinity);closedA.fill(0);cameFrom.fill(-1);heap.length=0; | |
| const si=tIdx(sx,sy),ti=tIdx(tx,ty); | |
| gScore[si]=0;fScore[si]=heur(sx,sy,tx,ty);hpush(si); | |
| let found=false,guard=0; | |
| while(heap.length&&guard++<30000){ | |
| const ci=hpop(); | |
| if(closedA[ci])continue; | |
| closedA[ci]=1; | |
| if(ci===ti){found=true;break;} | |
| const cx=ci%MAP_W,cy=(ci/MAP_W)|0; | |
| for(const d of DIRS){ | |
| const nx=cx+d[0],ny=cy+d[1]; | |
| if(!walkable(nx,ny))continue; | |
| if(d[0]&&d[1]&&(!walkable(cx+d[0],cy)||!walkable(cx,cy+d[1])))continue; | |
| const ni=tIdx(nx,ny); | |
| if(closedA[ni])continue; | |
| const g=gScore[ci]+d[2]; | |
| if(g<gScore[ni]){gScore[ni]=g;fScore[ni]=g+heur(nx,ny,tx,ty);cameFrom[ni]=ci;hpush(ni);} | |
| } | |
| } | |
| if(!found)return null; | |
| const path=[];let ci=ti; | |
| while(ci!==-1){path.push({x:ci%MAP_W,y:(ci/MAP_W)|0});ci=cameFrom[ci];} | |
| path.reverse(); | |
| return smoothPath(path); | |
| } | |
| function losTiles(a,b){ | |
| const d=Math.hypot(b.x-a.x,b.y-a.y),steps=Math.max(1,Math.ceil(d*3)); | |
| for(let k=1;k<steps;k++){ | |
| const x=Math.round(a.x+(b.x-a.x)*k/steps),y=Math.round(a.y+(b.y-a.y)*k/steps); | |
| if(!walkable(x,y))return false; | |
| } | |
| return true; | |
| } | |
| function smoothPath(p){ | |
| if(p.length<=2)return p; | |
| const out=[p[0]];let i=0; | |
| while(i<p.length-1){ | |
| let j=p.length-1; | |
| while(j>i+1&&!losTiles(p[i],p[j]))j--; | |
| out.push(p[j]);i=j; | |
| } | |
| return out; | |
| } | |
| function setPath(u,wx,wy){ | |
| const p=findPath(Math.floor(u.x/TILE),Math.floor(u.y/TILE),Math.floor(wx/TILE),Math.floor(wy/TILE)); | |
| if(!p||!p.length){u.path=null;u.pathi=0;return false;} | |
| u.path=p.map(t=>({x:(t.x+0.5)*TILE,y:(t.y+0.5)*TILE})); | |
| const lt=p[p.length-1]; | |
| if(walkable(Math.floor(wx/TILE),Math.floor(wy/TILE))&&dist((lt.x+0.5)*TILE,(lt.y+0.5)*TILE,wx,wy)<TILE) | |
| u.path[u.path.length-1]={x:wx,y:wy}; | |
| u.pathi=0;return true; | |
| } | |
| function followPath(u,dt){ | |
| if(!u.path||u.pathi>=u.path.length)return true; | |
| const wp=u.path[u.pathi]; | |
| const d=dist(u.x,u.y,wp.x,wp.y); | |
| if(d<6){u.pathi++;return u.pathi>=u.path.length;} | |
| u.dir=Math.atan2(wp.y-u.y,wp.x-u.x); | |
| const sp=Math.min(u.speed*dt,d); | |
| u.x+=Math.cos(u.dir)*sp; u.y+=Math.sin(u.dir)*sp; | |
| u.moving=true; | |
| // anti-stuck: if barely moving for a while (blocked by other units), skip waypoint | |
| if(u._sx===undefined){u._sx=u.x;u._sy=u.y;u._st=0;} | |
| u._st+=dt; | |
| if(u._st>1){ | |
| const moved=dist(u.x,u.y,u._sx,u._sy); | |
| u._sx=u.x;u._sy=u.y;u._st=0; | |
| if(moved<5){u.pathi++;return u.pathi>=u.path.length;} | |
| } | |
| return false; | |
| } | |
| function adjacentPoint(u,e){ | |
| let best=null,bd=Infinity; | |
| for(let y=e.ty-1;y<=e.ty+e.h;y++)for(let x=e.tx-1;x<=e.tx+e.w;x++){ | |
| const perim=x===e.tx-1||x===e.tx+e.w||y===e.ty-1||y===e.ty+e.h; | |
| if(!perim||!walkable(x,y))continue; | |
| const wx=(x+0.5)*TILE,wy=(y+0.5)*TILE; | |
| let d=(wx-u.x)**2+(wy-u.y)**2; | |
| // spread workers out: penalize tiles already crowded by other units | |
| for(const o of units)if(o!==u&&(o.x-wx)**2+(o.y-wy)**2<900)d+=2600; | |
| if(d<bd){bd=d;best={x:wx,y:wy};} | |
| } | |
| return best; | |
| } | |
| function pathToAdj(u,e){const p=adjacentPoint(u,e);if(p)setPath(u,p.x,p.y);} | |
| function nearEnt(u,e,pad){ | |
| const x0=e.tx*TILE,y0=e.ty*TILE,x1=(e.tx+e.w)*TILE,y1=(e.ty+e.h)*TILE; | |
| const cx=clamp(u.x,x0,x1),cy=clamp(u.y,y0,y1); | |
| return dist(u.x,u.y,cx,cy)<pad*TILE; | |
| } | |
| // ---------------- entities ---------------- | |
| function makeUnit(type,x,y){ | |
| return{id:nextId++,type,x,y,dir:0, | |
| hp:type==='worker'?40:90,maxHp:type==='worker'?40:90, | |
| speed:type==='worker'?72:88, | |
| state:'idle',path:null,pathi:0,cd:0,carry:null,phase:null,timer:0, | |
| gatherTarget:null,gatherKind:null,buildTarget:null,attackTarget:null,dropId:null,moving:false}; | |
| } | |
| function nearestResource(x,y,kind){ | |
| let best=null,bd=Infinity; | |
| for(const r of resources){ | |
| if(r.kind!==kind||r.amount<=0)continue; | |
| const d=dist(x,y,(r.tx+r.w/2)*TILE,(r.ty+r.h/2)*TILE); | |
| if(d<bd){bd=d;best=r;} | |
| } | |
| return best; | |
| } | |
| function nearestDropoff(x,y){ | |
| let best=null,bd=Infinity; | |
| for(const b of buildings){ | |
| if(b.type!=='townhall'||b.state!=='done')continue; | |
| const d=dist(x,y,(b.tx+b.w/2)*TILE,(b.ty+b.h/2)*TILE); | |
| if(d<bd){bd=d;best=b;} | |
| } | |
| return best; | |
| } | |
| function removeTree(r){ | |
| markBlock(r.tx,r.ty,1,1,0); | |
| resources.splice(resources.indexOf(r),1); | |
| burst((r.tx+0.5)*TILE,(r.ty+0.5)*TILE,'#4e7d33',10,60,0.7,300); | |
| } | |
| function killMonster(m){ | |
| monsters.splice(monsters.indexOf(m),1); | |
| if(selectedMonster===m)selectedMonster=null; | |
| gold+=25; stats.slain++; | |
| addFloat(m.x,m.y-16,'+25 🪙','#ffd75e'); | |
| burst(m.x,m.y,'#7de07a',16,90,0.8,200); | |
| } | |
| function spawnUnit(type,b){ | |
| const spot=nearestWalkable(b.tx+b.w,b.ty+((b.h/2)|0))||nearestWalkable(b.tx,b.ty+b.h); | |
| const u=makeUnit(type,(spot.x+0.5)*TILE,(spot.y+0.5)*TILE); | |
| units.push(u); stats.trained++; | |
| burst(u.x,u.y,'#ffe9a8',8,50,0.5,0); | |
| if(b.rally)orderMove(u,b.rally.x,b.rally.y); | |
| } | |
| // ---------------- economy ---------------- | |
| const canAfford=c=>gold>=(c.gold||0)&&wood>=(c.wood||0); | |
| function pay(c){gold-=c.gold||0;wood-=c.wood||0;} | |
| function refund(c){gold+=c.gold||0;wood+=c.wood||0;} | |
| function supplyUsed(){let s=0;for(const u of units)s+=COSTS[u.type].supply;for(const b of buildings)for(const q of b.queue)s+=COSTS[q.type].supply;return s;} | |
| function supplyCap(){let s=0;for(const b of buildings)if(b.state==='done')s+=SUPPLY[b.type]||0;return s;} | |
| // ---------------- orders ---------------- | |
| function clearWork(u){u.gatherTarget=null;u.buildTarget=null;u.attackTarget=null;u.phase=null;u.timer=0;} | |
| function orderMove(u,wx,wy){clearWork(u);u.state='move';setPath(u,clamp(wx,0,WORLD_W-1),clamp(wy,0,WORLD_H-1));} | |
| function orderGroupMove(sel,wx,wy){ | |
| const n=sel.length,cols=Math.ceil(Math.sqrt(n)),rows=Math.ceil(n/cols),sp=26; | |
| sel.forEach((u,i)=>{ | |
| const ox=(i%cols-(cols-1)/2)*sp,oy=(((i/cols)|0)-(rows-1)/2)*sp; | |
| orderMove(u,wx+ox,wy+oy); | |
| }); | |
| } | |
| function orderGather(u,r){ | |
| clearWork(u); | |
| u.state='gather';u.gatherTarget=r.id;u.gatherKind=r.kind; | |
| if(u.carry){ | |
| const th=nearestDropoff(u.x,u.y); | |
| if(th){u.phase='toDrop';u.dropId=th.id;pathToAdj(u,th);return;} | |
| u.carry=null; | |
| } | |
| u.phase='toSource';pathToAdj(u,r); | |
| } | |
| function orderBuild(u,b){clearWork(u);u.state='build';u.buildTarget=b.id;pathToAdj(u,b);} | |
| function orderAttack(u,m){clearWork(u);u.state='attack';u.attackTarget=m.id;u.path=null;} | |
| // ---------------- commands (UI) ---------------- | |
| function cmdBuild(type){ | |
| const w=selection.map(id=>byId(units,id)).find(u=>u&&u.type==='worker'); | |
| if(!w){toast('Select a worker first');return;} | |
| placement={type}; | |
| toast('Choose a location — right-click / Esc to cancel'); | |
| } | |
| function canPlaceAt(tx,ty,size){ | |
| for(let y=ty;y<ty+size;y++)for(let x=tx;x<tx+size;x++) | |
| if(!inB(x,y)||terrain[tIdx(x,y)]===1||objBlock[tIdx(x,y)]||!fogE[tIdx(x,y)])return false; | |
| return true; | |
| } | |
| function tryPlace(wx,wy){ | |
| const type=placement.type,size=BUILD_SIZE[type]; | |
| const tx=Math.floor(wx/TILE-size/2),ty=Math.floor(wy/TILE-size/2); | |
| if(!canPlaceAt(tx,ty,size)){toast("Can't build there");return;} | |
| const cost=COSTS[type]; | |
| if(!canAfford(cost)){toast('Not enough resources');placement=null;return;} | |
| pay(cost); | |
| const b={id:nextId++,type,tx,ty,w:size,h:size,hp:cost.hp*0.1,maxHp:cost.hp, | |
| state:'constructing',progress:0,queue:[],rally:null}; | |
| buildings.push(b);markBlock(tx,ty,size,size,1); | |
| // nudge units out of the footprint | |
| for(const u of units){ | |
| if(u.x>tx*TILE&&u.x<(tx+size)*TILE&&u.y>ty*TILE&&u.y<(ty+size)*TILE){ | |
| const p=nearestWalkable(Math.floor(u.x/TILE),Math.floor(u.y/TILE)); | |
| if(p){u.x=(p.x+0.5)*TILE;u.y=(p.y+0.5)*TILE;} | |
| } | |
| } | |
| const ws=selection.map(id=>byId(units,id)).filter(u=>u&&u.type==='worker'); | |
| ws.sort((a,b2)=>dist(a.x,a.y,wx,wy)-dist(b2.x,b2.y,wx,wy)); | |
| if(ws.length)orderBuild(ws[0],b); | |
| placement=null; | |
| } | |
| function cmdTrain(type){ | |
| const b=selectedBuilding; | |
| if(!b||b.state!=='done')return; | |
| if(type==='worker'&&b.type!=='townhall')return; | |
| if(type==='soldier'&&b.type!=='barracks')return; | |
| const c=COSTS[type]; | |
| if(supplyUsed()+c.supply>supplyCap()){toast('Not enough supply — build Farms 🌾');return;} | |
| if(!canAfford(c)){toast('Not enough resources');return;} | |
| pay(c);b.queue.push({type,t:c.time}); | |
| } | |
| function cancelTrain(i){ | |
| const b=selectedBuilding; | |
| if(!b||!b.queue[i])return; | |
| refund(COSTS[b.queue[i].type]);b.queue.splice(i,1); | |
| } | |
| // ---------------- selection & input ---------------- | |
| function screenToWorld(sx,sy){return{x:cam.x+sx/zoom,y:cam.y+sy/zoom};} | |
| function pickAt(wx,wy){ | |
| let best=null,bd=Infinity; | |
| for(const u of units){const d=dist(u.x,u.y,wx,wy);if(d<16&&d<bd){bd=d;best={kind:'unit',o:u};}} | |
| if(best)return best; | |
| for(const m of monsters){ | |
| if(dist(m.x,m.y,wx,wy)<18&&tileVisible(Math.floor(m.x/TILE),Math.floor(m.y/TILE))) | |
| return{kind:'monster',o:m}; | |
| } | |
| for(let i=buildings.length-1;i>=0;i--){ | |
| const b=buildings[i]; | |
| if(wx>=b.tx*TILE&&wx<(b.tx+b.w)*TILE&&wy>=b.ty*TILE&&wy<(b.ty+b.h)*TILE)return{kind:'building',o:b}; | |
| } | |
| return null; | |
| } | |
| function clearSelection(){selection=[];selectedBuilding=null;selectedMonster=null;} | |
| function selectSingle(shift){ | |
| const w=screenToWorld(mouse.x,mouse.y),p=pickAt(w.x,w.y); | |
| if(!p){if(!shift)clearSelection();return;} | |
| if(p.kind==='unit'){ | |
| selectedBuilding=null;selectedMonster=null; | |
| if(shift){const i=selection.indexOf(p.o.id);if(i>=0)selection.splice(i,1);else selection.push(p.o.id);} | |
| else selection=[p.o.id]; | |
| }else if(p.kind==='building'){selection=[];selectedMonster=null;selectedBuilding=p.o;} | |
| else{selection=[];selectedBuilding=null;selectedMonster=p.o;} | |
| } | |
| function boxSelect(x0,y0,x1,y1,shift){ | |
| const a=screenToWorld(Math.min(x0,x1),Math.min(y0,y1)),b=screenToWorld(Math.max(x0,x1),Math.max(y0,y1)); | |
| const ids=units.filter(u=>u.x>=a.x&&u.x<=b.x&&u.y>=a.y&&u.y<=b.y).map(u=>u.id); | |
| if(!shift){clearSelection();} | |
| selectedBuilding=null;selectedMonster=null; | |
| for(const id of ids)if(!selection.includes(id))selection.push(id); | |
| } | |
| cv.addEventListener('mousedown',e=>{ | |
| if(e.button===0){ | |
| if(placement){const w=screenToWorld(e.clientX,e.clientY);tryPlace(w.x,w.y);return;} | |
| selBox={x0:e.clientX,y0:e.clientY,x1:e.clientX,y1:e.clientY}; | |
| }else if(e.button===2){ | |
| if(placement){placement=null;return;} | |
| onRightClick(e.clientX,e.clientY); | |
| } | |
| }); | |
| addEventListener('mousemove',e=>{ | |
| mouse.x=e.clientX;mouse.y=e.clientY;mouse.in=true; | |
| if(selBox){selBox.x1=e.clientX;selBox.y1=e.clientY;} | |
| if(mmDrag)mmSeek(e); | |
| }); | |
| addEventListener('mouseup',e=>{ | |
| if(e.button===0&&selBox){ | |
| const w=Math.abs(selBox.x1-selBox.x0),h=Math.abs(selBox.y1-selBox.y0); | |
| if(w<8&&h<8)selectSingle(e.shiftKey); | |
| else boxSelect(selBox.x0,selBox.y0,selBox.x1,selBox.y1,e.shiftKey); | |
| selBox=null; | |
| } | |
| mmDrag=false; | |
| }); | |
| cv.addEventListener('dblclick',e=>{ | |
| const w=screenToWorld(e.clientX,e.clientY),p=pickAt(w.x,w.y); | |
| if(p&&p.kind==='unit'){ | |
| const vw=cvW/zoom,vh=cvH/zoom; | |
| selection=units.filter(u=>u.type===p.o.type&&u.x>cam.x&&u.x<cam.x+vw&&u.y>cam.y&&u.y<cam.y+vh).map(u=>u.id); | |
| selectedBuilding=null;selectedMonster=null; | |
| } | |
| }); | |
| cv.addEventListener('contextmenu',e=>e.preventDefault()); | |
| cv.addEventListener('wheel',e=>{ | |
| e.preventDefault(); | |
| const w0=screenToWorld(mouse.x,mouse.y); | |
| zoom=clamp(zoom*(e.deltaY>0?0.88:1.14),0.55,1.9); | |
| cam.x=w0.x-mouse.x/zoom;cam.y=w0.y-mouse.y/zoom;clampCam(); | |
| },{passive:false}); | |
| document.addEventListener('mouseleave',()=>{mouse.in=false;}); | |
| addEventListener('blur',()=>{keys={};}); | |
| addEventListener('keydown',e=>{ | |
| if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault(); | |
| keys[e.key]=true; | |
| if(e.key==='Escape'){ | |
| if(placement){placement=null;return;} | |
| if($('help').style.display!=='none'){$('help').style.display='none';return;} | |
| clearSelection();return; | |
| } | |
| const k=e.key.toLowerCase(); | |
| if(k==='f')cmdBuild('farm'); | |
| else if(k==='r')cmdBuild('barracks'); | |
| else if(k==='t')cmdBuild('townhall'); | |
| else if(k==='w')cmdTrain('worker'); | |
| else if(k==='s')cmdTrain('soldier'); | |
| }); | |
| addEventListener('keyup',e=>{keys[e.key]=false;}); | |
| function onRightClick(sx,sy){ | |
| const w=screenToWorld(sx,sy); | |
| if(selectedBuilding){ | |
| const b=selectedBuilding; | |
| if(b.state==='done'&&(b.type==='townhall'||b.type==='barracks')){ | |
| b.rally={x:w.x,y:w.y};ring(w.x,w.y,'#7cc4ff'); | |
| } | |
| return; | |
| } | |
| const sel=selection.map(id=>byId(units,id)).filter(Boolean); | |
| if(!sel.length)return; | |
| const m=monsters.find(m2=>dist(m2.x,m2.y,w.x,w.y)<20&&tileVisible(Math.floor(m2.x/TILE),Math.floor(m2.y/TILE))); | |
| if(m){sel.forEach(u=>orderAttack(u,m));ring(w.x,w.y,'#ff6b6b');return;} | |
| const r=resources.find(r2=>w.x>=r2.tx*TILE-8&&w.x<(r2.tx+r2.w)*TILE+8&&w.y>=r2.ty*TILE-8&&w.y<(r2.ty+r2.h)*TILE+8); | |
| if(r){ | |
| sel.forEach(u=>{ | |
| if(u.type==='worker')orderGather(u,r); | |
| else{const p=adjacentPoint(u,r);if(p)orderMove(u,p.x,p.y);} | |
| }); | |
| ring(w.x,w.y,'#ffd75e');return; | |
| } | |
| const b=buildings.find(b2=>w.x>=b2.tx*TILE&&w.x<(b2.tx+b2.w)*TILE&&w.y>=b2.ty*TILE&&w.y<(b2.ty+b2.h)*TILE); | |
| if(b&&b.state==='constructing'){ | |
| sel.filter(u=>u.type==='worker').forEach(u=>orderBuild(u,b)); | |
| ring(w.x,w.y,'#7dff9e');return; | |
| } | |
| orderGroupMove(sel,w.x,w.y);ring(w.x,w.y,'#7dff9e'); | |
| } | |
| // minimap input | |
| mm.addEventListener('mousedown',e=>{mmDrag=true;mmSeek(e);e.preventDefault();}); | |
| function mmSeek(e){ | |
| const r=mm.getBoundingClientRect(); | |
| const mx=(e.clientX-r.left)/r.width,my=(e.clientY-r.top)/r.height; | |
| cam.x=mx*WORLD_W-cvW/zoom/2;cam.y=my*WORLD_H-cvH/zoom/2;clampCam(); | |
| } | |
| function clampCam(){ | |
| const vw=cvW/zoom,vh=cvH/zoom; | |
| cam.x=vw>=WORLD_W?(WORLD_W-vw)/2:clamp(cam.x,0,WORLD_W-vw); | |
| cam.y=vh>=WORLD_H?(WORLD_H-vh)/2:clamp(cam.y,0,WORLD_H-vh); | |
| } | |
| // ---------------- particles & floats ---------------- | |
| function burst(x,y,color,n,spd,life,grav){ | |
| for(let i=0;i<n;i++)particles.push({kind:'dot',x,y,vx:rand(-spd,spd),vy:rand(-spd,spd*0.6)-spd*0.3, | |
| life:rand(life*0.5,life),maxLife:life,color,size:rand(1.5,3.2),grav:grav||0}); | |
| } | |
| function ring(x,y,color){particles.push({kind:'ring',x,y,life:0.45,maxLife:0.45,color});} | |
| function addFloat(x,y,txt,color){floats.push({x,y,txt,color,t:1.6});} | |
| // ---------------- update ---------------- | |
| function updateUnit(u,dt){ | |
| u.moving=false; | |
| switch(u.state){ | |
| case 'move': | |
| if(followPath(u,dt))u.state='idle'; | |
| break; | |
| case 'gather':{ | |
| if(u.phase==='toDrop'){ | |
| const th=byId(buildings,u.dropId); | |
| if(!th){u.state='idle';break;} | |
| if(nearEnt(u,th,1.8)){ | |
| u.path=null; | |
| if(u.carry){ | |
| if(u.carry.kind==='gold')gold+=u.carry.amt;else wood+=u.carry.amt; | |
| addFloat(u.x,u.y-20,'+'+u.carry.amt+(u.carry.kind==='gold'?' 🪙':' 🪵'),u.carry.kind==='gold'?'#ffd75e':'#d29a5b'); | |
| u.carry=null; | |
| } | |
| u.phase='toSource'; | |
| const r2=byId(resources,u.gatherTarget); | |
| if(r2&&r2.amount>0)pathToAdj(u,r2); | |
| }else if(followPath(u,dt))pathToAdj(u,th); | |
| break; | |
| } | |
| const r=byId(resources,u.gatherTarget); | |
| if(!r||r.amount<=0){ | |
| const nr=nearestResource(u.x,u.y,u.gatherKind||'tree'); | |
| if(nr){u.gatherTarget=nr.id;u.gatherKind=nr.kind;u.phase='toSource';pathToAdj(u,nr);} | |
| else{u.state='idle';toast('No more '+(u.gatherKind==='mine'?'gold':'wood')+' nearby');} | |
| break; | |
| } | |
| if(u.phase==='toSource'){ | |
| if(nearEnt(u,r,1.5)){u.path=null;u.phase='harvest';u.timer=GATHER_TIME;} | |
| else if(followPath(u,dt))pathToAdj(u,r); | |
| }else if(u.phase==='harvest'){ | |
| u.dir=Math.atan2((r.ty+r.h/2)*TILE-u.y,(r.tx+r.w/2)*TILE-u.x); | |
| u.timer-=dt; | |
| if(Math.random()<dt*3)burst(u.x+Math.cos(u.dir)*10,u.y+Math.sin(u.dir)*10,r.kind==='mine'?'#ffd75e':'#a5835a',2,40,0.4,200); | |
| if(u.timer<=0){ | |
| const take=Math.min(r.kind==='mine'?GOLD_TRIP:WOOD_TRIP,r.amount); | |
| r.amount-=take; | |
| u.carry={kind:r.kind==='mine'?'gold':'wood',amt:take}; | |
| if(r.amount<=0&&r.kind==='tree')removeTree(r); | |
| const th=nearestDropoff(u.x,u.y); | |
| if(!th){u.state='idle';break;} | |
| u.phase='toDrop';u.dropId=th.id;pathToAdj(u,th); | |
| } | |
| } | |
| break;} | |
| case 'build':{ | |
| const b=byId(buildings,u.buildTarget); | |
| if(!b||b.state!=='constructing'){u.state='idle';break;} | |
| if(!nearEnt(u,b,1.6)){ | |
| if(followPath(u,dt))pathToAdj(u,b); | |
| }else{ | |
| u.path=null; | |
| u.dir=Math.atan2((b.ty+b.h/2)*TILE-u.y,(b.tx+b.w/2)*TILE-u.x); | |
| b.progress+=dt/COSTS[b.type].time; | |
| b.hp=Math.min(b.maxHp,b.maxHp*(0.1+0.9*b.progress)); | |
| if(Math.random()<dt*4)burst(u.x+rand(-8,8),u.y+rand(-8,8),'#c9b18a',2,40,0.4,150); | |
| if(b.progress>=1){ | |
| b.progress=1;b.state='done';b.hp=b.maxHp;stats.built++; | |
| toast(NAMES[b.type]+' complete 🎉'); | |
| burst((b.tx+b.w/2)*TILE,(b.ty+b.h/2)*TILE,'#ffe9a8',14,80,0.7,100); | |
| u.state='idle'; | |
| } | |
| } | |
| break;} | |
| case 'attack':{ | |
| const m=byId(monsters,u.attackTarget); | |
| if(!m){u.state='idle';break;} | |
| const d=dist(u.x,u.y,m.x,m.y),range=(u.type==='soldier'?1.7:1.3)*TILE; | |
| if(d>range){ | |
| if(!u.path||u.pathi>=u.path.length)setPath(u,m.x,m.y); | |
| followPath(u,dt); | |
| }else{ | |
| u.path=null; | |
| u.dir=Math.atan2(m.y-u.y,m.x-u.x); | |
| u.cd-=dt; | |
| if(u.cd<=0){ | |
| u.cd=0.8; | |
| m.hp-=u.type==='soldier'?9:3; | |
| burst(m.x,m.y-4,'#ffffff',4,70,0.3,0); | |
| if(m.hp<=0)killMonster(m); | |
| } | |
| } | |
| break;} | |
| } | |
| } | |
| function separate(){ | |
| for(let i=0;i<units.length;i++)for(let j=i+1;j<units.length;j++){ | |
| const a=units[i],b=units[j]; | |
| let dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy); | |
| if(d>0&&d<16){ | |
| const push=(16-d)/2;dx/=d;dy/=d; | |
| tryPush(a,-dx*push,-dy*push);tryPush(b,dx*push,dy*push); | |
| } | |
| } | |
| for(const u of units)for(const m of monsters){ | |
| let dx=u.x-m.x,dy=u.y-m.y,d=Math.hypot(dx,dy); | |
| if(d>0&&d<18){tryPush(u,dx/d*(18-d),dy/d*(18-d));} | |
| } | |
| } | |
| function tryPush(u,dx,dy){ | |
| const nx=u.x+dx,ny=u.y+dy; | |
| if(walkable(Math.floor(nx/TILE),Math.floor(ny/TILE))){u.x=nx;u.y=ny;} | |
| } | |
| let fogTimer=0,mmTimer=0,uiTimer=0; | |
| function update(dt){ | |
| time+=dt;gameTime+=dt; | |
| // camera | |
| let kx=0,ky=0; | |
| if(keys.ArrowLeft)kx-=1;if(keys.ArrowRight)kx+=1; | |
| if(keys.ArrowUp)ky-=1;if(keys.ArrowDown)ky+=1; | |
| if(mouse.in){ | |
| if(mouse.x<18)kx-=1;if(mouse.x>cvW-18)kx+=1; | |
| if(mouse.y<18)ky-=1;if(mouse.y>cvH-18)ky+=1; | |
| } | |
| if(kx||ky){const l=Math.hypot(kx,ky),sp=720/Math.sqrt(zoom);cam.x+=kx/l*sp*dt;cam.y+=ky/l*sp*dt;clampCam();} | |
| // entities | |
| for(const u of units)updateUnit(u,dt); | |
| separate(); | |
| for(const b of buildings){ | |
| if(b.state!=='done'||!b.queue.length)continue; | |
| const q=b.queue[0];q.t-=dt; | |
| if(q.t<=0){b.queue.shift();spawnUnit(q.type,b);} | |
| } | |
| // particles / floats | |
| for(let i=particles.length-1;i>=0;i--){ | |
| const p=particles[i];p.life-=dt; | |
| if(p.life<=0){particles.splice(i,1);continue;} | |
| if(p.kind==='dot'){p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=p.grav*dt;} | |
| } | |
| for(let i=floats.length-1;i>=0;i--){const f=floats[i];f.t-=dt;f.y-=22*dt;if(f.t<=0)floats.splice(i,1);} | |
| // timers | |
| fogTimer-=dt;if(fogTimer<=0){updateFog();fogTimer=0.2;} | |
| mmTimer-=dt;if(mmTimer<=0){updateMinimap();updateTopbar();mmTimer=0.15;} | |
| uiTimer-=dt;if(uiTimer<=0){updatePanel();uiTimer=0.2;} | |
| // victory | |
| if(!won&&exploredCount/(MAP_W*MAP_H)>=0.95){ | |
| won=true; | |
| $('vTime').textContent=fmtTime(gameTime); | |
| $('vUnits').textContent=stats.trained; | |
| $('vBuild').textContent=stats.built; | |
| $('vSlain').textContent=stats.slain; | |
| $('victory').style.display='flex'; | |
| } | |
| } | |
| // ---------------- drawing helpers ---------------- | |
| function rr(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();} | |
| function circle(x,y,r){ctx.beginPath();ctx.arc(x,y,r,0,6.2832);} | |
| function ellip(x,y,rx,ry){ctx.beginPath();ctx.ellipse(x,y,rx,ry,0,0,6.2832);} | |
| function hpBar(x,y,w,frac){ | |
| ctx.fillStyle='rgba(0,0,0,.55)';ctx.fillRect(x-1,y-1,w+2,5); | |
| ctx.fillStyle=frac>0.5?'#5fd35f':frac>0.25?'#e8c15a':'#e85a5a'; | |
| ctx.fillRect(x,y,w*clamp(frac,0,1),3); | |
| } | |
| // ---------------- terrain prerender ---------------- | |
| const terrainCanvas=document.createElement('canvas'); | |
| function prerenderTerrain(){ | |
| terrainCanvas.width=WORLD_W;terrainCanvas.height=WORLD_H; | |
| const c=terrainCanvas.getContext('2d'); | |
| const grass=['#5da24e','#589a49','#62a854','#559347']; | |
| for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){ | |
| const t=terrain[tIdx(x,y)],px=x*TILE,py=y*TILE,h=hash2(x,y); | |
| if(t===1){ | |
| const g=c.createLinearGradient(px,py,px,py+TILE); | |
| g.addColorStop(0,'#2f74a8');g.addColorStop(1,'#235c8b'); | |
| c.fillStyle=g;c.fillRect(px,py,TILE,TILE); | |
| c.strokeStyle='rgba(255,255,'+Math.floor(200+h*55)+','+(0.06+h*0.08)+')'; | |
| c.lineWidth=1.5;c.beginPath(); | |
| c.moveTo(px+5,py+8+h*16);c.quadraticCurveTo(px+13,py+4+h*16,px+22,py+8+h*16);c.stroke(); | |
| }else if(t===2){ | |
| c.fillStyle=h<0.5?'#d3ba72':'#cbb26a';c.fillRect(px,py,TILE,TILE); | |
| c.fillStyle='rgba(120,95,50,.25)'; | |
| for(let k=0;k<3;k++)c.fillRect(px+hash2(x*5+k,y*9)*28,py+hash2(x*11,y*3+k)*28,2,2); | |
| }else if(t===3){ | |
| c.fillStyle=h<0.5?'#8a6f47':'#826843';c.fillRect(px,py,TILE,TILE); | |
| c.fillStyle='rgba(60,45,25,.25)'; | |
| for(let k=0;k<3;k++)c.fillRect(px+hash2(x*7+k,y*5)*28,py+hash2(x*3,y*13+k)*28,3,2); | |
| }else{ | |
| c.fillStyle=grass[Math.floor(h*4)];c.fillRect(px,py,TILE,TILE); | |
| c.strokeStyle='rgba(0,0,0,.09)';c.lineWidth=1; | |
| for(let k=0;k<3;k++){ | |
| const bx=px+hash2(x*7+k*3,y*13)*30,by=py+hash2(x*11,y*5+k*7)*30; | |
| c.beginPath();c.moveTo(bx,by);c.lineTo(bx+1.5,by-3);c.stroke(); | |
| } | |
| if(h>0.93){ | |
| c.fillStyle=['#e86a92','#f2d15c','#ffffff'][Math.floor(h*300)%3]; | |
| c.beginPath();c.arc(px+hash2(x*3,y*17)*26+3,py+hash2(x*19,y*2)*26+3,1.8,0,6.2832);c.fill(); | |
| } | |
| } | |
| } | |
| // shore foam | |
| c.strokeStyle='rgba(205,232,255,.4)';c.lineWidth=2; | |
| for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){ | |
| if(terrain[tIdx(x,y)]!==1)continue; | |
| const px=x*TILE,py=y*TILE; | |
| if(inB(x,y-1)&&terrain[tIdx(x,y-1)]!==1){c.beginPath();c.moveTo(px,py+1);c.lineTo(px+TILE,py+1);c.stroke();} | |
| if(inB(x,y+1)&&terrain[tIdx(x,y+1)]!==1){c.beginPath();c.moveTo(px,py+TILE-1);c.lineTo(px+TILE,py+TILE-1);c.stroke();} | |
| if(inB(x-1,y)&&terrain[tIdx(x-1,y)]!==1){c.beginPath();c.moveTo(px+1,py);c.lineTo(px+1,py+TILE);c.stroke();} | |
| if(inB(x+1,y)&&terrain[tIdx(x+1,y)]!==1){c.beginPath();c.moveTo(px+TILE-1,py);c.lineTo(px+TILE-1,py+TILE);c.stroke();} | |
| } | |
| } | |
| const mmTerrain=document.createElement('canvas'); | |
| function buildMinimapTerrain(){ | |
| mmTerrain.width=MAP_W;mmTerrain.height=MAP_H; | |
| const c=mmTerrain.getContext('2d'),img=c.createImageData(MAP_W,MAP_H); | |
| const cols={0:[88,150,70],1:[43,110,163],2:[203,178,106],3:[138,111,71]}; | |
| for(let i=0;i<MAP_W*MAP_H;i++){ | |
| const col=cols[terrain[i]]||cols[0]; | |
| img.data[i*4]=col[0];img.data[i*4+1]=col[1];img.data[i*4+2]=col[2];img.data[i*4+3]=255; | |
| } | |
| c.putImageData(img,0,0); | |
| c.fillStyle='#2f6b34'; | |
| for(const r of resources)if(r.kind==='tree')c.fillRect(r.tx,r.ty,1,1); | |
| c.fillStyle='#c8c8d0'; | |
| for(const r of resources)if(r.kind==='mine')c.fillRect(r.tx,r.ty,2,2); | |
| } | |
| // ---------------- entity drawing ---------------- | |
| function drawTree(r){ | |
| const x=(r.tx+0.5)*TILE,y=(r.ty+0.95)*TILE; | |
| const sway=Math.sin(time*1.3+r.tx*0.7+r.ty)*1.4; | |
| ctx.fillStyle='rgba(0,0,0,.25)';ellip(x,y,13,4.5);ctx.fill(); | |
| ctx.fillStyle='#6b4a2f';ctx.fillRect(x-2,y-14,4,13); | |
| ctx.fillStyle='#2e6b34';circle(x+sway*0.3,y-26,14);ctx.fill(); | |
| ctx.fillStyle='#3c7f3f';circle(x-6+sway*0.5,y-20,10.5);ctx.fill(); | |
| ctx.fillStyle='#4c9448';circle(x+6+sway*0.6,y-22,9.5);ctx.fill(); | |
| ctx.fillStyle='rgba(255,255,255,.12)';circle(x-3+sway*0.5,y-28,5);ctx.fill(); | |
| } | |
| function drawMine(r){ | |
| const x=r.tx*TILE,y=r.ty*TILE,w=r.w*TILE,h=r.h*TILE; | |
| const depleted=r.amount<=0; | |
| ctx.fillStyle='rgba(0,0,0,.28)';ellip(x+w/2,y+h-4,w/2-2,7);ctx.fill(); | |
| const rocks=[[0.25,0.55,13],[0.55,0.4,16],[0.78,0.62,11],[0.4,0.72,12],[0.68,0.78,9]]; | |
| for(const rk of rocks){ | |
| ctx.fillStyle=depleted?'#6f6f74':'#8d8d96'; | |
| circle(x+rk[0]*w,y+rk[1]*h,rk[2]);ctx.fill(); | |
| ctx.strokeStyle='#55555e';ctx.lineWidth=1.5;ctx.stroke(); | |
| } | |
| if(!depleted){ | |
| ctx.fillStyle='#f2c14e'; | |
| const tw=0.5+0.5*Math.sin(time*3+r.id); | |
| for(const g of[[0.35,0.45],[0.6,0.6],[0.48,0.3],[0.72,0.45]]){ | |
| circle(x+g[0]*w,y+g[1]*h,2.5+tw);ctx.fill(); | |
| } | |
| ctx.fillStyle='rgba(255,240,180,'+(0.4+0.4*tw)+')'; | |
| circle(x+0.55*w,y+0.35*h,2);ctx.fill(); | |
| } | |
| } | |
| function drawBuilding(b){ | |
| const x=b.tx*TILE,y=b.ty*TILE,w=b.w*TILE,h=b.h*TILE; | |
| const selB=selectedBuilding===b; | |
| if(b.state==='constructing'){ | |
| ctx.fillStyle='rgba(0,0,0,.25)';rr(x+3,y+4,w-4,h-3,5);ctx.fill(); | |
| ctx.fillStyle='#8a6f47';rr(x+3,y+3,w-6,h-6,5);ctx.fill(); | |
| ctx.setLineDash([5,4]);ctx.strokeStyle='#d8c49a';ctx.lineWidth=2;rr(x+5,y+5,w-10,h-10,4);ctx.stroke();ctx.setLineDash([]); | |
| ctx.fillStyle='#a5835a'; | |
| ctx.fillRect(x+w*0.2,y+h*0.3,w*0.5,5);ctx.fillRect(x+w*0.35,y+h*0.55,w*0.5,5); | |
| ctx.fillStyle='#7a5c38';ctx.fillRect(x+w*0.15,y+h*0.15,5,h*0.5);ctx.fillRect(x+w*0.75,y+h*0.35,5,h*0.5); | |
| hpBar(x+4,y-9,w-8,b.progress); | |
| if(selB)drawSelCorners(x,y,w,h); | |
| return; | |
| } | |
| ctx.fillStyle='rgba(0,0,0,.3)';rr(x+5,y+7,w-6,h-4,9);ctx.fill(); | |
| if(b.type==='townhall'){ | |
| const g=ctx.createLinearGradient(0,y,0,y+h);g.addColorStop(0,'#c9c2b2');g.addColorStop(1,'#948c7c'); | |
| ctx.fillStyle=g;rr(x+8,y+16,w-16,h-22,6);ctx.fill(); | |
| ctx.strokeStyle='#6e675a';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.strokeStyle='rgba(90,70,50,.45)';ctx.lineWidth=3; | |
| ctx.beginPath();ctx.moveTo(x+9,y+h*0.58);ctx.lineTo(x+w-9,y+h*0.58);ctx.stroke(); | |
| ctx.fillStyle='#a8493b'; | |
| ctx.beginPath();ctx.moveTo(x+1,y+24);ctx.lineTo(x+w/2,y-6);ctx.lineTo(x+w-1,y+24);ctx.closePath();ctx.fill(); | |
| ctx.strokeStyle='#7d3329';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.strokeStyle='rgba(255,255,255,.18)';ctx.beginPath();ctx.moveTo(x+w/2,y-4);ctx.lineTo(x+w-8,y+22);ctx.stroke(); | |
| ctx.fillStyle='#4a3826';rr(x+w/2-9,y+h-27,18,21,3);ctx.fill(); | |
| ctx.fillStyle='#ffd97a';rr(x+16,y+h-32,10,10,2);ctx.fill();rr(x+w-26,y+h-32,10,10,2);ctx.fill(); | |
| const fw=Math.sin(time*4)*2; | |
| ctx.strokeStyle='#3a3a3a';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x+w/2,y-6);ctx.lineTo(x+w/2,y-22);ctx.stroke(); | |
| ctx.fillStyle='#3b82f6';ctx.beginPath();ctx.moveTo(x+w/2,y-22);ctx.lineTo(x+w/2+15+fw,y-18);ctx.lineTo(x+w/2,y-13);ctx.closePath();ctx.fill(); | |
| }else if(b.type==='farm'){ | |
| ctx.fillStyle='#7b5a36';rr(x+2,y+2,w-4,h-4,6);ctx.fill(); | |
| ctx.strokeStyle='#5a4128';ctx.lineWidth=2;ctx.stroke(); | |
| for(let i=0;i<4;i++){ | |
| ctx.fillStyle=i%2?'#79b356':'#8fc765'; | |
| rr(x+6,y+6+i*((h-12)/4),w-12,(h-12)/4-2,2);ctx.fill(); | |
| } | |
| ctx.fillStyle='#4e7d33'; | |
| for(let i=0;i<4;i++)for(let k=0;k<4;k++) | |
| circle(x+10+k*((w-20)/3),y+8+i*((h-12)/4)+2,1.6),ctx.fill(); | |
| ctx.fillStyle='#5a4128'; | |
| ctx.fillRect(x+1,y+1,5,5);ctx.fillRect(x+w-6,y+1,5,5);ctx.fillRect(x+1,y+h-6,5,5);ctx.fillRect(x+w-6,y+h-6,5,5); | |
| }else if(b.type==='barracks'){ | |
| const g=ctx.createLinearGradient(0,y,0,y+h);g.addColorStop(0,'#b0aaa2');g.addColorStop(1,'#8a847c'); | |
| ctx.fillStyle=g;rr(x+6,y+12,w-12,h-18,5);ctx.fill(); | |
| ctx.strokeStyle='#66605a';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.fillStyle='#b0413e'; | |
| ctx.beginPath();ctx.moveTo(x+1,y+18);ctx.lineTo(x+w/2,y-4);ctx.lineTo(x+w-1,y+18);ctx.closePath();ctx.fill(); | |
| ctx.strokeStyle='#83302e';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.fillStyle='#4a3826';rr(x+w/2-8,y+h-24,16,18,3);ctx.fill(); | |
| // crossed swords emblem | |
| ctx.strokeStyle='#e8ecf2';ctx.lineWidth=2.5; | |
| ctx.beginPath();ctx.moveTo(x+14,y+h-30);ctx.lineTo(x+26,y+h-14);ctx.moveTo(x+26,y+h-30);ctx.lineTo(x+14,y+h-14);ctx.stroke(); | |
| ctx.fillStyle='#3b82f6';ctx.fillRect(x+w-16,y+h-34,8,14); | |
| } | |
| if(b.hp<b.maxHp)hpBar(x+4,y-9,w-8,b.hp/b.maxHp); | |
| if(selB)drawSelCorners(x,y,w,h); | |
| } | |
| function drawSelCorners(x,y,w,h){ | |
| ctx.strokeStyle='#7dff9e';ctx.lineWidth=2.5; | |
| const l=9,p=3; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+p,y+p+l);ctx.lineTo(x+p,y+p);ctx.lineTo(x+p+l,y+p); | |
| ctx.moveTo(x+w-p-l,y+p);ctx.lineTo(x+w-p,y+p);ctx.lineTo(x+w-p,y+p+l); | |
| ctx.moveTo(x+w-p,y+h-p-l);ctx.lineTo(x+w-p,y+h-p);ctx.lineTo(x+w-p-l,y+h-p); | |
| ctx.moveTo(x+p+l,y+h-p);ctx.lineTo(x+p,y+h-p);ctx.lineTo(x+p,y+h-p-l); | |
| ctx.stroke(); | |
| } | |
| function drawUnit(u){ | |
| const x=u.x,y=u.y,isSel=selection.includes(u.id); | |
| const bob=u.moving?Math.sin(time*12+u.id*1.7)*1.3:Math.sin(time*2.5+u.id)*0.6; | |
| ctx.fillStyle='rgba(0,0,0,.3)';ellip(x,y+7,8,3.5);ctx.fill(); | |
| if(isSel){ctx.strokeStyle='rgba(125,255,158,.95)';ctx.lineWidth=2;ellip(x,y+6,11,5.5);ctx.stroke();} | |
| const by=y-5+bob*0.3; | |
| ctx.fillStyle=u.type==='worker'?'#3f8ef7':'#2f6fe0'; | |
| circle(x,by,7.5);ctx.fill(); | |
| ctx.strokeStyle='#1d3f77';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.fillStyle='#f2c9a0';circle(x,by-9,4.6);ctx.fill(); | |
| ctx.strokeStyle='rgba(0,0,0,.3)';ctx.lineWidth=1;ctx.stroke(); | |
| if(u.type==='worker'){ | |
| ctx.fillStyle='#c9a44a';ellip(x,by-11,6.5,2.6);ctx.fill(); | |
| circle(x,by-12.5,3.1);ctx.fill(); | |
| if(u.carry){ | |
| if(u.carry.kind==='gold'){ctx.fillStyle='#ffd75e';circle(x+8,by-15,3.2);ctx.fill();ctx.strokeStyle='#a87f1f';ctx.lineWidth=1;ctx.stroke();} | |
| else{ctx.fillStyle='#8a5c30';ctx.fillRect(x+5,by-18,9,4);ctx.strokeStyle='#5a3c1c';ctx.lineWidth=1;ctx.strokeRect(x+5,by-18,9,4);} | |
| } | |
| }else{ | |
| ctx.fillStyle='#c7ccd4';ctx.beginPath();ctx.arc(x,by-9,5.4,Math.PI,0);ctx.fill(); | |
| ctx.strokeStyle='#7a828e';ctx.lineWidth=1.5;ctx.stroke(); | |
| const swing=u.cd>0.35?Math.sin((0.8-u.cd)*10)*0.7:0; | |
| const a=u.dir+swing; | |
| ctx.strokeStyle='#e8ecf2';ctx.lineWidth=2.5; | |
| ctx.beginPath();ctx.moveTo(x+Math.cos(a)*5,by+Math.sin(a)*5);ctx.lineTo(x+Math.cos(a)*16,by+Math.sin(a)*16);ctx.stroke(); | |
| ctx.strokeStyle='#8a6f3a';ctx.lineWidth=3; | |
| ctx.beginPath();ctx.moveTo(x+Math.cos(a+1.57)*3+Math.cos(a)*6,by+Math.sin(a+1.57)*3+Math.sin(a)*6); | |
| ctx.lineTo(x+Math.cos(a-1.57)*3+Math.cos(a)*6,by+Math.sin(a-1.57)*3+Math.sin(a)*6);ctx.stroke(); | |
| } | |
| if(u.hp<u.maxHp||isSel)hpBar(x-12,y-27,24,u.hp/u.maxHp); | |
| } | |
| function drawMonster(m){ | |
| const w=Math.sin(time*3+m.id*2.1); | |
| const isSel=selectedMonster===m; | |
| ctx.save();ctx.translate(m.x,m.y);ctx.scale(1+0.08*w,1-0.08*w); | |
| ctx.fillStyle='rgba(0,0,0,.28)';ellip(0,7,11,4);ctx.fill(); | |
| const g=ctx.createRadialGradient(-3,-6,2,0,-2,13); | |
| g.addColorStop(0,'#8ee88a');g.addColorStop(1,'#3f9e4d'); | |
| ctx.fillStyle=g;circle(0,-2,11);ctx.fill(); | |
| ctx.strokeStyle='#2c6e38';ctx.lineWidth=2;ctx.stroke(); | |
| ctx.fillStyle='#fff';circle(-4,-6,2.8);ctx.fill();circle(4,-6,2.8);ctx.fill(); | |
| ctx.fillStyle='#1a2e1a';circle(-3.6,-6,1.3);ctx.fill();circle(4.4,-6,1.3);ctx.fill(); | |
| ctx.strokeStyle='#2c6e38';ctx.lineWidth=1.5; | |
| ctx.beginPath();ctx.arc(0,-1,3.5,0.3,2.8);ctx.stroke(); | |
| ctx.restore(); | |
| if(isSel){ctx.strokeStyle='rgba(125,255,158,.95)';ctx.lineWidth=2;ellip(m.x,m.y+7,13,6);ctx.stroke();} | |
| if(m.hp<m.maxHp||isSel)hpBar(m.x-12,m.y-22,24,m.hp/m.maxHp); | |
| } | |
| // ---------------- render ---------------- | |
| function render(){ | |
| ctx.setTransform(1,0,0,1,0,0); | |
| ctx.fillStyle='#0c140c';ctx.fillRect(0,0,cv.width,cv.height); | |
| const z=zoom*DPR; | |
| ctx.setTransform(z,0,0,z,-cam.x*z,-cam.y*z); | |
| ctx.imageSmoothingEnabled=true; | |
| ctx.drawImage(terrainCanvas,0,0); | |
| // map border | |
| ctx.strokeStyle='rgba(0,0,0,.6)';ctx.lineWidth=6;ctx.strokeRect(0,0,WORLD_W,WORLD_H); | |
| // gather drawables, y-sorted | |
| const vx0=cam.x-60,vy0=cam.y-60,vx1=cam.x+cvW/zoom+60,vy1=cam.y+cvH/zoom+60; | |
| const draw=[]; | |
| for(const r of resources){ | |
| const cx=(r.tx+r.w/2)*TILE,cy=(r.ty+r.h/2)*TILE; | |
| if(cx<vx0||cx>vx1||cy<vy0||cy>vy1)continue; | |
| if(!tileExplored(r.tx,r.ty))continue; | |
| draw.push({y:(r.ty+r.h)*TILE,k:'r',o:r}); | |
| } | |
| for(const b of buildings){ | |
| const cx=(b.tx+b.w/2)*TILE,cy=(b.ty+b.h/2)*TILE; | |
| if(cx<vx0||cx>vx1||cy<vy0||cy>vy1)continue; | |
| draw.push({y:(b.ty+b.h)*TILE,k:'b',o:b}); | |
| } | |
| for(const u of units){ | |
| if(u.x<vx0||u.x>vx1||u.y<vy0||u.y>vy1)continue; | |
| draw.push({y:u.y,k:'u',o:u}); | |
| } | |
| for(const m of monsters){ | |
| if(m.x<vx0||m.x>vx1||m.y<vy0||m.y>vy1)continue; | |
| if(!tileVisible(Math.floor(m.x/TILE),Math.floor(m.y/TILE)))continue; | |
| draw.push({y:m.y,k:'m',o:m}); | |
| } | |
| draw.sort((a,b)=>a.y-b.y); | |
| for(const d of draw){ | |
| if(d.k==='r'){d.o.kind==='tree'?drawTree(d.o):drawMine(d.o);} | |
| else if(d.k==='b')drawBuilding(d.o); | |
| else if(d.k==='u')drawUnit(d.o); | |
| else drawMonster(d.o); | |
| } | |
| // particles | |
| for(const p of particles){ | |
| const a=clamp(p.life/p.maxLife,0,1); | |
| if(p.kind==='dot'){ | |
| ctx.globalAlpha=a;ctx.fillStyle=p.color;circle(p.x,p.y,p.size);ctx.fill();ctx.globalAlpha=1; | |
| }else{ | |
| ctx.globalAlpha=a;ctx.strokeStyle=p.color;ctx.lineWidth=2.5; | |
| circle(p.x,p.y,(1-a)*22+4);ctx.stroke();ctx.globalAlpha=1; | |
| } | |
| } | |
| // rally flag | |
| if(selectedBuilding&&selectedBuilding.rally){ | |
| const r=selectedBuilding.rally; | |
| ctx.strokeStyle='#2c4a6e';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(r.x,r.y);ctx.lineTo(r.x,r.y-18);ctx.stroke(); | |
| ctx.fillStyle='#7cc4ff';ctx.beginPath();ctx.moveTo(r.x,r.y-18);ctx.lineTo(r.x+12,r.y-14);ctx.lineTo(r.x,r.y-10);ctx.closePath();ctx.fill(); | |
| } | |
| // floating texts | |
| ctx.font='800 13px Nunito,sans-serif';ctx.textAlign='center'; | |
| for(const f of floats){ | |
| ctx.globalAlpha=clamp(f.t,0,1); | |
| ctx.lineWidth=3;ctx.strokeStyle='rgba(0,0,0,.7)';ctx.strokeText(f.txt,f.x,f.y); | |
| ctx.fillStyle=f.color;ctx.fillText(f.txt,f.x,f.y); | |
| ctx.globalAlpha=1; | |
| } | |
| // fog of war | |
| ctx.drawImage(fogCanvas,0,0,MAP_W,MAP_H,0,0,WORLD_W,WORLD_H); | |
| // placement ghost | |
| if(placement&&mouse.in){ | |
| const size=BUILD_SIZE[placement.type]; | |
| const w=screenToWorld(mouse.x,mouse.y); | |
| const tx=Math.floor(w.x/TILE-size/2),ty=Math.floor(w.y/TILE-size/2); | |
| const ok=canPlaceAt(tx,ty,size)&&canAfford(COSTS[placement.type]); | |
| ctx.fillStyle=ok?'rgba(110,255,150,.3)':'rgba(255,90,90,.35)'; | |
| ctx.fillRect(tx*TILE,ty*TILE,size*TILE,size*TILE); | |
| ctx.strokeStyle=ok?'#7dff9e':'#ff6b6b';ctx.lineWidth=2; | |
| ctx.strokeRect(tx*TILE,ty*TILE,size*TILE,size*TILE); | |
| } | |
| // selection box (screen space) | |
| if(selBox){ | |
| ctx.setTransform(DPR,0,0,DPR,0,0); | |
| const x=Math.min(selBox.x0,selBox.x1),y=Math.min(selBox.y0,selBox.y1); | |
| const w=Math.abs(selBox.x1-selBox.x0),h=Math.abs(selBox.y1-selBox.y0); | |
| ctx.fillStyle='rgba(110,255,150,.12)';ctx.fillRect(x,y,w,h); | |
| ctx.strokeStyle='rgba(125,255,158,.9)';ctx.lineWidth=1.5;ctx.strokeRect(x,y,w,h); | |
| } | |
| } | |
| // ---------------- minimap ---------------- | |
| function updateMinimap(){ | |
| const MM=180; | |
| mmc.fillStyle='#000';mmc.fillRect(0,0,MM,MM); | |
| mmc.drawImage(mmTerrain,0,0,MM,MM); | |
| mmc.drawImage(fogCanvas,0,0,MM,MM); | |
| const s=MM/WORLD_W; | |
| for(const b of buildings){ | |
| mmc.fillStyle=b.state==='done'?'#7cc4ff':'#c9b18a'; | |
| mmc.fillRect(b.tx*TILE*s-1.5,b.ty*TILE*s-1.5,4+b.w,4+b.h); | |
| } | |
| mmc.fillStyle='#4ade80'; | |
| for(const u of units)mmc.fillRect(u.x*s-1,u.y*s-1,2.5,2.5); | |
| mmc.fillStyle='#ef4444'; | |
| for(const m of monsters){ | |
| if(!tileVisible(Math.floor(m.x/TILE),Math.floor(m.y/TILE)))continue; | |
| mmc.fillRect(m.x*s-1.5,m.y*s-1.5,3,3); | |
| } | |
| mmc.strokeStyle='rgba(255,255,255,.85)';mmc.lineWidth=1; | |
| mmc.strokeRect(cam.x*s,cam.y*s,cvW/zoom*s,cvH/zoom*s); | |
| } | |
| // ---------------- UI ---------------- | |
| function toast(msg){ | |
| const box=$('toasts'); | |
| while(box.children.length>=4)box.removeChild(box.firstChild); | |
| const t=document.createElement('div'); | |
| t.className='toast';t.textContent=msg; | |
| box.appendChild(t); | |
| setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .4s';},2200); | |
| setTimeout(()=>t.remove(),2700); | |
| } | |
| function fmtTime(t){const m=Math.floor(t/60),s=Math.floor(t%60);return m+':'+(s<10?'0':'')+s;} | |
| function updateTopbar(){ | |
| $('rGold').textContent=Math.floor(gold); | |
| $('rWood').textContent=Math.floor(wood); | |
| $('rSup').textContent=supplyUsed()+'/'+supplyCap(); | |
| $('rExp').textContent=Math.floor(exploredCount/(MAP_W*MAP_H)*100)+'%'; | |
| $('rTime').textContent=fmtTime(gameTime); | |
| } | |
| function hpHtml(hp,max){ | |
| const pct=Math.round(hp/max*100); | |
| return `<div class="bar"><i style="width:${pct}%"></i></div><div class="psub">HP ${Math.ceil(hp)} / ${max}</div>`; | |
| } | |
| function buildBtnHtml(t){ | |
| const c=COSTS[t],ok=canAfford(c); | |
| const icons={farm:'🌾',barracks:'🛡️',townhall:'🏰'}; | |
| const hk={farm:'F',barracks:'R',townhall:'T'}; | |
| return `<button class="cmd ${ok?'':'dis'}" onmousedown="cmdBuild('${t}')"><span class="ic">${icons[t]}</span><span class="nm">${NAMES[t]}</span><span class="cost">${c.gold?c.gold+'🪙 ':''}${c.wood?c.wood+'🪵':''} <u>${hk[t]}</u></span></button>`; | |
| } | |
| function trainBtnHtml(t){ | |
| const c=COSTS[t]; | |
| const ok=canAfford(c)&&supplyUsed()+c.supply<=supplyCap(); | |
| const hk=t==='worker'?'W':'S'; | |
| return `<button class="cmd ${ok?'':'dis'}" onmousedown="cmdTrain('${t}')"><span class="ic">${t==='worker'?'👷':'🗡️'}</span><span class="nm">${NAMES[t]}</span><span class="cost">${c.gold}🪙 · ${c.supply}👥 <u>${hk}</u></span></button>`; | |
| } | |
| let lastSig=''; | |
| function updatePanel(){ | |
| let sig=''; | |
| if(selectedMonster)sig='M'+selectedMonster.id+'|'+selectedMonster.hp; | |
| else if(selectedBuilding){ | |
| const b=selectedBuilding; | |
| sig='B'+b.id+'|'+b.state+'|'+Math.round(b.progress*40)+'|'+b.queue.length+'|'+(b.queue[0]?Math.round(b.queue[0].t*3):'')+'|'+Math.floor(gold)+'|'+Math.floor(wood)+'|'+supplyUsed(); | |
| } | |
| else if(selection.length)sig='U'+selection.join(',')+'|'+Math.floor(gold)+'|'+Math.floor(wood); | |
| else sig='N'; | |
| if(sig===lastSig)return; | |
| lastSig=sig; | |
| renderPanel(); | |
| } | |
| function renderPanel(){ | |
| const info=$('pinfo'),act=$('pactions'); | |
| let ih='',ah=''; | |
| if(selectedMonster){ | |
| const m=selectedMonster; | |
| ih=`<div class="prow"><div class="portrait">👾</div><div><div class="pname">Wild Slime</div>${hpHtml(m.hp,m.maxHp)}<div class="psub">Bounty: 25🪙 · perfectly harmless</div></div></div>`; | |
| ah=`<div class="hint">Right-click it with a soldier (or worker) to attack.</div>`; | |
| }else if(selectedBuilding){ | |
| const b=selectedBuilding; | |
| const icons={townhall:'🏰',farm:'🌾',barracks:'🛡️'}; | |
| ih=`<div class="prow"><div class="portrait">${icons[b.type]}</div><div><div class="pname">${NAMES[b.type]}</div>`; | |
| if(b.state==='constructing') | |
| ih+=`<div class="bar"><i style="width:${Math.round(b.progress*100)}%"></i></div><div class="psub">Building… ${Math.floor(b.progress*100)}%</div>`; | |
| else ih+=hpHtml(b.hp,b.maxHp); | |
| if(b.type==='farm'&&b.state==='done')ih+=`<div class="psub">+8 supply</div>`; | |
| if(b.type==='townhall'&&b.state==='done')ih+=`<div class="psub">+12 supply · resource drop-off</div>`; | |
| ih+=`</div></div>`; | |
| if(b.state==='done'&&(b.type==='townhall'||b.type==='barracks')){ | |
| ah+=trainBtnHtml(b.type==='townhall'?'worker':'soldier'); | |
| if(b.queue.length){ | |
| ah+=`<div class="queue">`+b.queue.map((q,i)=> | |
| `<div class="qitem" onmousedown="cancelTrain(${i})" title="Click to cancel (refund)">${q.type==='worker'?'👷':'🗡️'}${i===0?`<div class="bar mini" style="width:30px"><i style="width:${Math.round((1-q.t/COSTS[q.type].time)*100)}%"></i></div>`:''}</div>` | |
| ).join('')+`</div>`; | |
| } | |
| ah+=`<div class="hint">Right-click on the map to set a rally point.</div>`; | |
| }else if(b.state==='constructing'){ | |
| ah=`<div class="hint">Under construction — right-click it with a worker to help finish.</div>`; | |
| } | |
| }else if(selection.length>1){ | |
| const us=selection.map(id=>byId(units,id)).filter(Boolean); | |
| const nw=us.filter(u=>u.type==='worker').length; | |
| ih=`<div class="pname">${us.length} units</div><div class="psub">${nw} 👷 · ${us.length-nw} 🗡️</div><div class="grid">`+ | |
| us.slice(0,18).map(u=>`<div class="cell">${u.type==='worker'?'👷':'🗡️'}</div>`).join('')+`</div>`; | |
| if(nw)ah+=buildBtnHtml('farm')+buildBtnHtml('barracks')+buildBtnHtml('townhall'); | |
| ah+=`<div class="hint">Right-click: move · gather · attack</div>`; | |
| }else if(selection.length===1){ | |
| const u=byId(units,selection[0]); | |
| if(u){ | |
| ih=`<div class="prow"><div class="portrait">${u.type==='worker'?'👷':'🗡️'}</div><div><div class="pname">${NAMES[u.type]}</div>${hpHtml(u.hp,u.maxHp)}<div class="psub">${ | |
| u.type==='worker'?(u.carry?'Carrying '+u.carry.amt+(u.carry.kind==='gold'?'🪙':'🪵'):'Gathers resources & builds'):'Combat unit'}</div></div></div>`; | |
| if(u.type==='worker'){ | |
| ah+=buildBtnHtml('farm')+buildBtnHtml('barracks')+buildBtnHtml('townhall'); | |
| ah+=`<div class="hint">Right-click a gold mine or tree to gather.</div>`; | |
| }else ah+=`<div class="hint">Right-click: move · attack slimes for bounty.</div>`; | |
| } | |
| }else{ | |
| ih=`<div class="pname">🗺️ Objective</div><div class="psub">Explore <b>95%</b> of the map to win.<br><br>Drag to select workers, then right-click a gold mine or tree to gather. Build 🌾 Farms for supply and 🛡️ Barracks for soldiers.</div>`; | |
| } | |
| info.innerHTML=ih;act.innerHTML=ah; | |
| } | |
| $('helpBtn').onclick=()=>{$('help').style.display='flex';}; | |
| $('startBtn').onclick=()=>{$('help').style.display='none';}; | |
| $('keepBtn').onclick=()=>{$('victory').style.display='none';}; | |
| // ---------------- boot ---------------- | |
| genMap(); | |
| prerenderTerrain(); | |
| buildMinimapTerrain(); | |
| updateFog(); | |
| updateTopbar(); | |
| renderPanel(); | |
| setTimeout(()=>toast('🗺️ Explore 95% of the map to win!'),600); | |
| setTimeout(()=>toast('👷 Select workers and right-click a mine or tree'),3200); | |
| let last=performance.now(); | |
| function loop(t){ | |
| const dt=Math.min(0.05,(t-last)/1000);last=t; | |
| update(dt);render(); | |
| requestAnimationFrame(loop); | |
| } | |
| requestAnimationFrame(loop); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment