Created
September 6, 2026 08:50
-
-
Save senko/2a51dfb52f5bb4bbca65ad628a0cb9d2 to your computer and use it in GitHub Desktop.
RTS game by Fable 5.1
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>Fable Craft</title> | |
| <style> | |
| html,body{margin:0;height:100%;overflow:hidden;background:#000;font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#efe3c4;user-select:none;-webkit-user-select:none} | |
| #view{position:absolute;left:0;top:36px;bottom:164px;width:100%;height:calc(100% - 200px);display:block;cursor:default;background:#000} | |
| #view.target{cursor:crosshair} | |
| #view.place{cursor:cell} | |
| /* top bar */ | |
| #top{position:absolute;left:0;top:0;right:0;height:36px;background:linear-gradient(#4a3a26,#2a1f13);border-bottom:2px solid #a07c3a;box-sizing:border-box;display:flex;align-items:center;gap:18px;padding:0 14px;font-size:15px;box-shadow:0 2px 10px rgba(0,0,0,.6);z-index:5} | |
| .res{display:flex;align-items:center;gap:7px;white-space:nowrap} | |
| .res b{font-weight:600;min-width:34px} | |
| .ico{display:inline-block;width:15px;height:15px;border-radius:50%;box-shadow:inset -2px -2px 3px rgba(0,0,0,.35)} | |
| .ico.gold{background:radial-gradient(circle at 35% 35%,#fff0a0,#d9a520 60%,#8a6210)} | |
| .ico.wood{border-radius:3px;background:linear-gradient(90deg,#6e4320,#a86a32 50%,#6e4320)} | |
| .ico.sup{border-radius:3px;background:linear-gradient(#d8e8ff,#6a9be0)} | |
| #expwrap{display:flex;align-items:center;gap:8px} | |
| #expbar{width:130px;height:9px;background:#1a130b;border:1px solid #7a5f2c;border-radius:4px;overflow:hidden} | |
| #expfill{height:100%;width:0;background:linear-gradient(90deg,#3c8bd8,#8fd0ff)} | |
| #top .sp{flex:1} | |
| #idle{cursor:pointer;padding:3px 9px;border-radius:4px;background:#5a3f1b;border:1px solid #b08a45;display:none} | |
| #idle:hover{background:#76562a} | |
| button.ui{background:linear-gradient(#6a5030,#3d2c17);color:#f4e6c0;border:1px solid #b08a45;border-radius:4px;padding:4px 10px;font-size:13px;cursor:pointer;font-family:inherit} | |
| button.ui:hover{background:linear-gradient(#83653f,#4d381e)} | |
| /* bottom panel */ | |
| #bottom{position:absolute;left:0;right:0;bottom:0;height:164px;background:linear-gradient(#3b2d1c,#211810);border-top:3px solid #a07c3a;box-sizing:border-box;display:flex;z-index:5;box-shadow:0 -3px 12px rgba(0,0,0,.7)} | |
| #mmwrap{width:176px;padding:6px;box-sizing:border-box;border-right:2px solid #6b5228} | |
| #minimap{width:164px;height:148px;display:block;border:1px solid #8a6a2f;background:#000;cursor:pointer;image-rendering:auto} | |
| #info{flex:1;padding:8px 14px;display:flex;gap:14px;overflow:hidden;min-width:0} | |
| #cmds{width:246px;box-sizing:border-box;border-left:2px solid #6b5228;padding:8px;display:grid;grid-template-columns:repeat(4,54px);grid-auto-rows:46px;gap:4px;align-content:start} | |
| .cmd{position:relative;background:linear-gradient(#5c4527,#33261a);border:1px solid #8a6a2f;border-radius:5px;cursor:pointer;padding:0;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.08)} | |
| .cmd:hover{border-color:#e8c46a;background:linear-gradient(#6d5330,#3d2c1f)} | |
| .cmd.active{border-color:#8fd0ff;box-shadow:0 0 6px #8fd0ff inset} | |
| .cmd.dis{opacity:.45;cursor:not-allowed} | |
| .cmd canvas{display:block;width:52px;height:38px} | |
| .cmd .key{position:absolute;right:2px;bottom:1px;font-size:10px;color:#ffe08a;font-weight:700;text-shadow:0 0 2px #000} | |
| .port{width:72px;height:72px;background:#16100a;border:1px solid #8a6a2f;border-radius:5px;flex:none} | |
| .port canvas{width:72px;height:72px;display:block} | |
| .det{flex:1;min-width:0;font-size:13px;line-height:1.45} | |
| .det .name{font-size:17px;font-weight:600;color:#ffe8a8;margin-bottom:2px} | |
| .hpbar{width:180px;height:8px;background:#1a130b;border:1px solid #6b5228;border-radius:3px;overflow:hidden;display:inline-block;vertical-align:middle;margin-right:6px} | |
| .hpbar div{height:100%;background:linear-gradient(#7ee37a,#2a9d2a)} | |
| .hpbar.low div{background:linear-gradient(#f7c15a,#d17a12)} | |
| .hpbar.crit div{background:linear-gradient(#f58a7a,#b4271a)} | |
| .stat{color:#c9b98f} | |
| .task{color:#9fd3ff;font-style:italic} | |
| .queue{display:flex;gap:4px;margin-top:4px;align-items:flex-end} | |
| .qitem{width:40px;height:34px;background:#16100a;border:1px solid #8a6a2f;border-radius:4px;cursor:pointer;position:relative;overflow:hidden} | |
| .qitem canvas{width:40px;height:30px;display:block} | |
| .qitem .qp{position:absolute;left:0;bottom:0;height:4px;background:#6ac0ff} | |
| .qitem:hover{border-color:#ff8f7a} | |
| .multi{display:flex;flex-wrap:wrap;gap:4px;align-content:flex-start} | |
| .mini{width:46px;height:42px;background:#16100a;border:1px solid #8a6a2f;border-radius:4px;cursor:pointer;position:relative;overflow:hidden} | |
| .mini canvas{width:46px;height:36px;display:block} | |
| .mini .mh{position:absolute;left:2px;right:2px;bottom:2px;height:3px;background:#3a1a1a} | |
| .mini .mh div{height:100%;background:#4fce4a} | |
| .mini:hover{border-color:#ffe08a} | |
| .cbar{width:180px;height:8px;background:#1a130b;border:1px solid #6b5228;border-radius:3px;overflow:hidden;display:inline-block;vertical-align:middle;margin-right:6px} | |
| .cbar div{height:100%;background:linear-gradient(#9ad8ff,#3a8fd6)} | |
| #tooltip{position:absolute;display:none;background:rgba(20,14,8,.95);border:1px solid #b08a45;border-radius:5px;padding:7px 10px;font-size:13px;max-width:260px;z-index:9;pointer-events:none;box-shadow:0 4px 14px rgba(0,0,0,.6)} | |
| #tooltip .tn{font-weight:600;color:#ffe8a8} | |
| #tooltip .tc{color:#ffd76a} | |
| #tooltip .td{color:#c9b98f;margin-top:3px} | |
| #overlay{position:absolute;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:20} | |
| #overlay.hidden{display:none} | |
| #obox{background:linear-gradient(#3b2d1c,#211810);border:2px solid #b08a45;border-radius:8px;padding:22px 28px;max-width:560px;box-shadow:0 10px 40px #000;font-size:14px;line-height:1.5} | |
| #obox h2{margin:0 0 10px;color:#ffe08a;font-size:24px} | |
| #obox h3{margin:14px 0 4px;color:#ffd76a;font-size:15px} | |
| #obox kbd{background:#16100a;border:1px solid #8a6a2f;border-radius:3px;padding:0 5px;font-family:inherit;color:#ffe8a8} | |
| #obox td{padding:1px 10px 1px 0;vertical-align:top} | |
| #obox .btns{margin-top:16px;display:flex;gap:10px;justify-content:flex-end} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="top"> | |
| <div class="res"><i class="ico gold"></i><b id="gold">0</b></div> | |
| <div class="res"><i class="ico wood"></i><b id="wood">0</b></div> | |
| <div class="res" title="Food used / available"><i class="ico sup"></i><b id="supply">0/0</b></div> | |
| <div id="expwrap" title="Map explored"><span>Explored</span><div id="expbar"><div id="expfill"></div></div><b id="explored">0%</b></div> | |
| <div id="idle" title="Select an idle peasant (also: comma key)">Idle peasants: <b id="idlen">0</b></div> | |
| <div class="sp"></div> | |
| <span id="clock">00:00</span> | |
| <button class="ui" id="btnHelp">Help (F1)</button> | |
| <button class="ui" id="btnNew">New map</button> | |
| </div> | |
| <canvas id="view"></canvas> | |
| <div id="bottom"> | |
| <div id="mmwrap"><canvas id="minimap" width="164" height="148"></canvas></div> | |
| <div id="info"></div> | |
| <div id="cmds"></div> | |
| </div> | |
| <div id="tooltip"></div> | |
| <div id="overlay" class="hidden"><div id="obox"></div></div> | |
| <script> | |
| (function(){ | |
| 'use strict'; | |
| // ============================================================ CONFIG | |
| const T=32, MW=64, MH=64, WPX=MW*T, HPX=MH*T; | |
| const TILE={GRASS:0,WATER:1,TREE:2,DIRT:3}; | |
| const PLAYER='player', NEUTRAL='neutral'; | |
| const TEAM={player:'#2f6fd6', neutral:'#b83a2f'}; | |
| const DX=[1,-1,0,0,1,1,-1,-1], DY=[0,0,1,-1,1,-1,1,-1]; | |
| const SUPPLY_CAP=60; | |
| const UNITS={ | |
| worker:{name:'Peasant',hp:40,speed:2.4,sight:5,cost:{gold:50,wood:0},supply:1,time:10,dmg:3,range:1,cd:1.0,r:9,key:'W',desc:'Gathers gold and wood, constructs buildings. Right-click a tree or gold mine to gather.'}, | |
| footman:{name:'Footman',hp:100,speed:2.2,sight:5,cost:{gold:100,wood:20},supply:1,time:14,dmg:9,range:1,cd:1.0,r:10,key:'F',desc:'Sturdy melee fighter with sword and shield.'}, | |
| archer:{name:'Archer',hp:55,speed:2.4,sight:6,cost:{gold:90,wood:50},supply:1,time:14,dmg:7,range:5,cd:1.3,r:9,key:'A',desc:'Ranged attacker. Fragile but shoots from afar.'}, | |
| scout:{name:'Scout Rider',hp:85,speed:4.2,sight:9,cost:{gold:110,wood:30},supply:2,time:16,dmg:8,range:1,cd:0.9,r:12,key:'S',desc:'Fast rider with long sight. Ideal for exploring the map.'}, | |
| wolf:{name:'Wolf',hp:45,speed:3.0,sight:5,dmg:6,range:1,cd:0.9,r:9,neutral:true,desc:'Hostile beast. Hunts in packs.'}, | |
| ogre:{name:'Ogre',hp:260,speed:1.7,sight:4,dmg:18,range:1,cd:1.6,r:14,neutral:true,desc:'Big, slow, hits very hard.'}, | |
| }; | |
| const BUILDINGS={ | |
| hall:{name:'Town Hall',w:3,h:3,hp:1200,cost:{gold:350,wood:250},time:70,supply:6,sight:6,trains:['worker'],dropoff:true,key:'H',desc:'Trains peasants. Peasants deliver resources here. Provides 6 food.'}, | |
| farm:{name:'Farm',w:2,h:2,hp:250,cost:{gold:60,wood:40},time:22,supply:4,sight:3,key:'F',desc:'Provides food for 4 units.'}, | |
| barracks:{name:'Barracks',w:3,h:3,hp:700,cost:{gold:140,wood:110},time:45,supply:0,sight:4,trains:['footman','archer','scout'],key:'B',desc:'Trains footmen, archers and scout riders.'}, | |
| tower:{name:'Watchtower',w:2,h:2,hp:450,cost:{gold:70,wood:120},time:35,supply:0,sight:11,dmg:12,range:7,cd:1.2,key:'T',desc:'Very long sight range. Shoots arrows at nearby enemies.'}, | |
| }; | |
| const BUILD_ORDER=['farm','barracks','tower','hall']; | |
| // ============================================================ UTILS | |
| const clamp=(v,a,b)=>v<a?a:v>b?b:v; | |
| const dist=(ax,ay,bx,by)=>Math.hypot(ax-bx,ay-by); | |
| const idx=(x,y)=>y*MW+x; | |
| const inMap=(x,y)=>x>=0&&y>=0&&x<MW&&y<MH; | |
| const tileOf=v=>Math.floor(v/T); | |
| const $=id=>document.getElementById(id); | |
| function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};} | |
| let rng=Math.random; | |
| const rand=()=>rng(); | |
| const randInt=(a,b)=>a+Math.floor(rand()*(b-a+1)); | |
| function makeNoise(size){ | |
| const g=new Float32Array(size*size); for(let i=0;i<g.length;i++) g[i]=rand(); | |
| return function(x,y){ | |
| const x0=Math.floor(x),y0=Math.floor(y),fx=x-x0,fy=y-y0,sx=fx*fx*(3-2*fx),sy=fy*fy*(3-2*fy); | |
| const ix0=((x0%size)+size)%size,iy0=((y0%size)+size)%size,ix1=(ix0+1)%size,iy1=(iy0+1)%size; | |
| const a=g[iy0*size+ix0],b=g[iy0*size+ix1],c=g[iy1*size+ix0],d=g[iy1*size+ix1]; | |
| return (a*(1-sx)+b*sx)*(1-sy)+(c*(1-sx)+d*sx)*sy; | |
| }; | |
| } | |
| function fbm(n,x,y,oct){let v=0,amp=1,f=1,sum=0;for(let i=0;i<oct;i++){v+=n(x*f,y*f)*amp;sum+=amp;amp*=0.5;f*=2;}return v/sum;} | |
| function distToRect(px,py,rx,ry,rw,rh){const dx=Math.max(rx-px,0,px-(rx+rw)),dy=Math.max(ry-py,0,py-(ry+rh));return Math.hypot(dx,dy);} | |
| function fmtTime(s){s=Math.floor(s);return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');} | |
| function fmtCost(c){const p=[];if(c.gold)p.push(c.gold+' gold');if(c.wood)p.push(c.wood+' wood');return p.join(', ')||'free';} | |
| function canAfford(c){return G.res.gold>=(c.gold||0)&&G.res.wood>=(c.wood||0);} | |
| function pay(c){G.res.gold-=(c.gold||0);G.res.wood-=(c.wood||0);} | |
| function refund(c,f){G.res.gold+=Math.floor((c.gold||0)*f);G.res.wood+=Math.floor((c.wood||0)*f);} | |
| // ============================================================ STATE | |
| let G=null; | |
| const view=$('view'), vctx=view.getContext('2d'); | |
| let ctx=vctx; // current drawing context (swapped for portraits) | |
| let VW=0,VH=0; | |
| const cam={x:0,y:0}; | |
| const terrainCv=document.createElement('canvas'); terrainCv.width=WPX; terrainCv.height=HPX; const tctx=terrainCv.getContext('2d'); | |
| const fogCv=document.createElement('canvas'); fogCv.width=MW; fogCv.height=MH; const fctx=fogCv.getContext('2d'); | |
| const mmTer=document.createElement('canvas'); mmTer.width=MW; mmTer.height=MH; const mmtctx=mmTer.getContext('2d'); | |
| const mm=$('minimap'), mmctx=mm.getContext('2d'); | |
| const mouse={x:0,y:0,wx:0,wy:0,inside:false,down:false,drag:null,lastClick:0,lastClickX:0,lastClickY:0}; | |
| const keys={}; | |
| let fogDirty=true, fogT=0, hudT=0, panelT=0, checkT=0; | |
| let paused=false; | |
| function resize(){VW=view.width=view.clientWidth;VH=view.height=view.clientHeight;clampCam();} | |
| function clampCam(){cam.x=clamp(cam.x,0,Math.max(0,WPX-VW));cam.y=clamp(cam.y,0,Math.max(0,HPX-VH));} | |
| function centerOn(x,y){cam.x=x-VW/2;cam.y=y-VH/2;clampCam();} | |
| // ============================================================ MAP GENERATION | |
| function isLand(x,y){return !inMap(x,y)||G.tiles[idx(x,y)]!==TILE.WATER;} | |
| function generateMap(seed){ | |
| rng=mulberry32(seed); | |
| const tiles=G.tiles=new Uint8Array(MW*MH), tw=G.treeWood=new Uint16Array(MW*MH), variant=G.variant=new Uint8Array(MW*MH); | |
| const nE=makeNoise(64), nF=makeNoise(64), nD=makeNoise(64); | |
| for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){ | |
| const i=idx(x,y); | |
| const e=fbm(nE,x/13+0.37,y/13+0.11,3), f=fbm(nF,x/7,y/7,2), d=nD(x/3.5,y/3.5); | |
| let t=TILE.GRASS; | |
| if(e<0.385) t=TILE.WATER; | |
| else if(f>0.555&&e>0.42) t=TILE.TREE; | |
| else if(d>0.72) t=TILE.DIRT; | |
| tiles[i]=t; variant[i]=(rand()*256)|0; | |
| if(t===TILE.TREE) tw[i]=60+((rand()*60)|0); | |
| } | |
| const corner=(rand()*4)|0; | |
| const sx=(corner&1)?MW-12:11, sy=(corner&2)?MH-12:11; | |
| G.start={x:sx,y:sy}; | |
| for(let y=sy-7;y<=sy+7;y++)for(let x=sx-7;x<=sx+7;x++) if(inMap(x,y)&&dist(x,y,sx,sy)<=6.5){const i=idx(x,y); if(tiles[i]!==TILE.DIRT) tiles[i]=TILE.GRASS; tw[i]=0;} | |
| const fx=sx-6, fy=sy+3; | |
| // start mine | |
| addMine(sx+5,sy-4,4000); | |
| // more mines | |
| let tries=0; | |
| while(G.mines.length<10&&tries<4000){ | |
| tries++; | |
| const x=randInt(2,MW-4), y=randInt(2,MH-4); | |
| if(dist(x,y,sx,sy)<15) continue; | |
| if(G.mines.some(m=>dist(m.x,m.y,x,y)<11)) continue; | |
| let ok=true; | |
| for(let yy=y-1;yy<=y+2&&ok;yy++)for(let xx=x-1;xx<=x+2;xx++) if(!inMap(xx,yy)||tiles[idx(xx,yy)]===TILE.WATER){ok=false;break;} | |
| if(!ok) continue; | |
| addMine(x,y,2000+randInt(0,8)*250); | |
| } | |
| // hall | |
| const hall=addBuilding('hall',sx-1,sy-1,PLAYER,true); | |
| ensureReachable(sx,sy+2); | |
| placeForest(fx,fy); | |
| ensureReachable(sx,sy+2); | |
| rebuildBlocked(); | |
| // workers | |
| const spots=freeSpotsAroundRect(hall.tx,hall.ty,hall.w,hall.h,5,{x:sx*T+T/2,y:(sy+3)*T}); | |
| for(const s of spots) spawnUnitAt('worker',PLAYER,s.x*T+T/2,s.y*T+T/2); | |
| // creeps | |
| placeCreeps(sx,sy); | |
| } | |
| function placeForest(fx,fy){ | |
| for(let y=fy-3;y<=fy+3;y++)for(let x=fx-3;x<=fx+3;x++) if(inMap(x,y)&&dist(x,y,fx,fy)<=3.2){const i=idx(x,y); if(G.tiles[i]===TILE.WATER) continue; G.tiles[i]=TILE.TREE; G.treeWood[i]=80+((rand()*40)|0);} | |
| } | |
| function addMine(x,y,gold){ | |
| for(let yy=y-1;yy<=y+2;yy++)for(let xx=x-1;xx<=x+2;xx++) if(inMap(xx,yy)){const i=idx(xx,yy); G.tiles[i]=(xx>=x&&xx<x+2&&yy>=y&&yy<y+2)?TILE.GRASS:TILE.DIRT; G.treeWood[i]=0;} | |
| const m={x,y,w:2,h:2,gold,maxGold:gold,isMine:true,id:G.nextId++}; | |
| G.mines.push(m); return m; | |
| } | |
| function rebuildBlocked(){ | |
| const b=G.blocked, t=G.tiles; | |
| for(let i=0;i<b.length;i++) b[i]=(t[i]===TILE.WATER||t[i]===TILE.TREE)?1:0; | |
| for(const m of G.mines) markRect(m.x,m.y,m.w,m.h,1); | |
| for(const bd of G.buildings) if(!bd.dead) markRect(bd.tx,bd.ty,bd.w,bd.h,1); | |
| } | |
| function markRect(x,y,w,h,v){for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++) if(inMap(xx,yy)) G.blocked[idx(xx,yy)]=v;} | |
| function ensureReachable(sx,sy){ | |
| const N=MW*MH, FAR=3; | |
| const carve=i=>{G.tiles[i]=TILE.GRASS;G.treeWood[i]=0;}; | |
| for(let iter=0;iter<60;iter++){ | |
| rebuildBlocked(); | |
| const hard=new Uint8Array(N); | |
| for(const m of G.mines) for(let yy=m.y;yy<m.y+2;yy++)for(let xx=m.x;xx<m.x+2;xx++) hard[idx(xx,yy)]=1; | |
| for(const b of G.buildings) for(let yy=b.ty;yy<b.ty+b.h;yy++)for(let xx=b.tx;xx<b.tx+b.w;xx++) if(inMap(xx,yy)) hard[idx(xx,yy)]=1; | |
| const d1=reachMap(sx,sy); | |
| const d2=new Int16Array(N).fill(-1), par=new Int32Array(N).fill(-1); const q2=[]; | |
| for(let i=0;i<N;i++) if(d1[i]>=0){d2[i]=0;q2.push(i);} | |
| for(let h=0;h<q2.length;h++){const c=q2[h],cx=c%MW,cy=(c/MW)|0; for(let d=0;d<8;d++){const nx=cx+DX[d],ny=cy+DY[d]; if(!inMap(nx,ny)) continue; const ni=idx(nx,ny); if(d2[ni]>=0||hard[ni]) continue; d2[ni]=d2[c]+1; par[ni]=c; q2.push(ni);}} | |
| const comp=new Uint8Array(N); let carved=false; | |
| for(let i=0;i<N;i++){ | |
| if(d2[i]<=FAR||comp[i]) continue; | |
| let best=i; const st=[i]; comp[i]=1; | |
| while(st.length){const c=st.pop(); if(d2[c]<d2[best]) best=c; const cx=c%MW,cy=(c/MW)|0; for(let d=0;d<8;d++){const nx=cx+DX[d],ny=cy+DY[d]; if(!inMap(nx,ny)) continue; const ni=idx(nx,ny); if(comp[ni]||d2[ni]<=FAR) continue; comp[ni]=1; st.push(ni);}} | |
| let p=best; | |
| while(p>=0&&d1[p]<0){ | |
| carve(p); const q=par[p]; if(q<0) break; | |
| const px=p%MW,py=(p/MW)|0,qx=q%MW,qy=(q/MW)|0; | |
| if(px!==qx&&py!==qy){let mid=idx(qx,py); if(hard[mid]) mid=idx(px,qy); if(d1[mid]<0&&!hard[mid]) carve(mid);} | |
| p=q; | |
| } | |
| carved=true; | |
| } | |
| if(!carved) break; | |
| } | |
| } | |
| function placeCreeps(sx,sy){ | |
| const d1=reachMap(sx,sy+2); | |
| const spawnPack=(cx,cy,pack)=>{ | |
| for(const type of pack){ | |
| for(let k=0;k<30;k++){ | |
| const x=cx+randInt(-3,3), y=cy+randInt(-3,3); | |
| if(!inMap(x,y)||d1[idx(x,y)]<0) continue; | |
| const u=spawnUnitAt(type,NEUTRAL,x*T+T/2,y*T+T/2); u.home={x:u.x,y:u.y}; break; | |
| } | |
| } | |
| }; | |
| for(const m of G.mines){ | |
| if(dist(m.x,m.y,sx,sy)<12) continue; | |
| const big=rand()<0.45; | |
| spawnPack(m.x+1,m.y+1,big?['ogre','wolf']:['wolf','wolf','wolf']); | |
| } | |
| const packs=[]; | |
| let tries=0; | |
| while(packs.length<7&&tries<2000){ | |
| tries++; | |
| const x=randInt(2,MW-3), y=randInt(2,MH-3); | |
| if(d1[idx(x,y)]<0||dist(x,y,sx,sy)<20) continue; | |
| if(packs.some(p=>dist(p.x,p.y,x,y)<9)||G.mines.some(m=>dist(m.x,m.y,x,y)<7)) continue; | |
| packs.push({x,y}); | |
| spawnPack(x,y,rand()<0.6?['wolf','wolf']:['ogre']); | |
| } | |
| } | |
| function reachMap(sx,sy){ | |
| const N=MW*MH; const d1=new Int16Array(N).fill(-1); const q=[idx(sx,sy)]; d1[q[0]]=0; | |
| for(let h=0;h<q.length;h++){const c=q[h],cx=c%MW,cy=(c/MW)|0; for(let d=0;d<4;d++){const nx=cx+DX[d],ny=cy+DY[d]; if(!inMap(nx,ny)) continue; const ni=idx(nx,ny); if(d1[ni]>=0||G.blocked[ni]) continue; d1[ni]=d1[c]+1; q.push(ni);}} | |
| return d1; | |
| } | |
| // ============================================================ PATHFINDING | |
| const AS={g:new Float32Array(MW*MH),f:new Float32Array(MW*MH),par:new Int32Array(MW*MH),closed:new Uint8Array(MW*MH),gen:new Uint32Array(MW*MH),cur:0}; | |
| function findPath(sx,sy,isGoal,hx,hy,maxIter){ | |
| maxIter=maxIter||7000; | |
| AS.cur++; const gen=AS.cur, F=AS.f, heap=[]; | |
| const push=i=>{heap.push(i);let k=heap.length-1;while(k>0){const p=(k-1)>>1;if(F[heap[p]]<=F[heap[k]])break;const t=heap[p];heap[p]=heap[k];heap[k]=t;k=p;}}; | |
| const pop=()=>{const top=heap[0],last=heap.pop();if(heap.length){heap[0]=last;let k=0;for(;;){const l=2*k+1,r=l+1;let m=k;if(l<heap.length&&F[heap[l]]<F[heap[m]])m=l;if(r<heap.length&&F[heap[r]]<F[heap[m]])m=r;if(m===k)break;const t=heap[m];heap[m]=heap[k];heap[k]=t;k=m;}}return top;}; | |
| const h=(x,y)=>{const dx=Math.abs(x-hx),dy=Math.abs(y-hy);return Math.max(dx,dy)+0.4142*Math.min(dx,dy);}; | |
| const s=idx(sx,sy); AS.gen[s]=gen; AS.g[s]=0; F[s]=h(sx,sy); AS.par[s]=-1; AS.closed[s]=0; push(s); | |
| let best=s,bestH=h(sx,sy),iter=0,found=-1; const blocked=G.blocked; | |
| while(heap.length&&iter++<maxIter){ | |
| const cur=pop(); if(AS.closed[cur]) continue; AS.closed[cur]=1; | |
| const cx=cur%MW,cy=(cur/MW)|0; | |
| if(isGoal(cx,cy)){found=cur;break;} | |
| const hh=h(cx,cy); if(hh<bestH){bestH=hh;best=cur;} | |
| for(let d=0;d<8;d++){ | |
| const nx=cx+DX[d],ny=cy+DY[d]; if(!inMap(nx,ny)) continue; | |
| const ni=idx(nx,ny); if(blocked[ni]) continue; | |
| if(d>=4&&(blocked[idx(cx+DX[d],cy)]||blocked[idx(cx,cy+DY[d])])) continue; | |
| if(AS.gen[ni]!==gen){AS.gen[ni]=gen;AS.g[ni]=Infinity;AS.closed[ni]=0;} | |
| if(AS.closed[ni]) continue; | |
| const ng=AS.g[cur]+(d>=4?1.4142:1); | |
| if(ng<AS.g[ni]){AS.g[ni]=ng;F[ni]=ng+h(nx,ny);AS.par[ni]=cur;push(ni);} | |
| } | |
| } | |
| const end=found>=0?found:best; const path=[]; let p=end; | |
| while(p!==s&&p>=0){path.push({x:p%MW,y:(p/MW)|0});p=AS.par[p];} | |
| path.reverse(); return {path,reached:found>=0}; | |
| } | |
| function los(x0,y0,x1,y1){ | |
| const d=dist(x0,y0,x1,y1), steps=Math.ceil(d/6); | |
| for(let i=1;i<=steps;i++){ | |
| const t=i/steps,x=x0+(x1-x0)*t,y=y0+(y1-y0)*t; | |
| for(let k=0;k<4;k++){const ox=(k&1)?7:-7,oy=(k&2)?7:-7;const tx=tileOf(x+ox),ty=tileOf(y+oy);if(!inMap(tx,ty)||G.blocked[idx(tx,ty)])return false;} | |
| } | |
| return true; | |
| } | |
| function smoothPath(sx,sy,pts){ | |
| if(pts.length<=1) return pts; | |
| const out=[]; let cx=sx,cy=sy,i=0; | |
| while(i<pts.length){let j=i;while(j+1<pts.length&&los(cx,cy,pts[j+1].x,pts[j+1].y))j++;out.push(pts[j]);cx=pts[j].x;cy=pts[j].y;i=j+1;} | |
| return out; | |
| } | |
| function setPath(u,goal,hx,hy){ | |
| const tx=clamp(tileOf(u.x),0,MW-1),ty=clamp(tileOf(u.y),0,MH-1); | |
| const r=findPath(tx,ty,goal,hx,hy); | |
| const pts=r.path.map(p=>({x:p.x*T+T/2,y:p.y*T+T/2})); | |
| u.path=smoothPath(u.x,u.y,pts); u.pathReq={goal,hx,hy}; u.stuckT=0; | |
| return r.reached; | |
| } | |
| function pathToPoint(u,px,py){ | |
| const tx=clamp(tileOf(px),0,MW-1),ty=clamp(tileOf(py),0,MH-1); | |
| const ok=setPath(u,(x,y)=>x===tx&&y===ty,tx,ty); | |
| if(ok&&u.path.length) {u.path[u.path.length-1]={x:px,y:py};} | |
| return ok; | |
| } | |
| function pathToRect(u,rx,ry,rw,rh){ | |
| const goal=(x,y)=>x>=rx-1&&x<=rx+rw&&y>=ry-1&&y<=ry+rh&&!(x>=rx&&x<rx+rw&&y>=ry&&y<ry+rh); | |
| return setPath(u,goal,rx+rw/2-0.5,ry+rh/2-0.5); | |
| } | |
| function moveAlong(u,dt){ | |
| if(!u.path||u.path.length===0) return true; | |
| const wp=u.path[0]; const dx=wp.x-u.x,dy=wp.y-u.y,d=Math.hypot(dx,dy); const step=u.speed*T*dt; | |
| let nx,ny; | |
| if(d<=step){nx=wp.x;ny=wp.y;} else {nx=u.x+dx/d*step;ny=u.y+dy/d*step;} | |
| const ti=idx(clamp(tileOf(nx),0,MW-1),clamp(tileOf(ny),0,MH-1)); | |
| if(G.blocked[ti]&&u.pathReq){ // path got blocked (new building?) -> repath | |
| u.stuckT+=dt; | |
| if(u.stuckT>0.3){const r=u.pathReq; setPath(u,r.goal,r.hx,r.hy); if(!u.path.length) return true;} | |
| return false; | |
| } | |
| u.x=nx;u.y=ny; if(Math.abs(dx)>0.5) u.facing=dx<0?-1:1; u.moving=true; u.anim+=dt; | |
| if(d<=step){u.path.shift();return u.path.length===0;} | |
| return false; | |
| } | |
| function freeSpotsAroundRect(rx,ry,rw,rh,n,pref){ | |
| const out=[]; const cand=[]; | |
| const want=Math.max(1,n); | |
| for(let r=0;r<=12;r++){ | |
| cand.length=0; | |
| for(let y=ry-r;y<ry+rh+r;y++)for(let x=rx-r;x<rx+rw+r;x++){ | |
| const inner=x>=rx-r+1&&x<rx+rw+r-1&&y>=ry-r+1&&y<ry+rh+r-1; | |
| if(r>0&&inner) continue; | |
| if(r===0&&!(x>=rx&&x<rx+rw&&y>=ry&&y<ry+rh)) continue; | |
| if(inMap(x,y)&&!G.blocked[idx(x,y)]) cand.push({x,y}); | |
| } | |
| if(pref) cand.sort((a,b)=>dist(a.x*T+T/2,a.y*T+T/2,pref.x,pref.y)-dist(b.x*T+T/2,b.y*T+T/2,pref.x,pref.y)); | |
| for(const c of cand){out.push(c);if(out.length>=want)return out;} | |
| } | |
| return out; | |
| } | |
| // ============================================================ ENTITIES | |
| function spawnUnitAt(type,owner,x,y){ | |
| const ut=UNITS[type]; | |
| const u={id:G.nextId++,type,owner,x,y,r:ut.r,hp:ut.hp,maxHp:ut.hp,speed:ut.speed,task:{kind:'idle'},path:null,pathReq:null,cd:0,facing:rand()<0.5?-1:1,anim:rand()*10,carry:null,home:{x,y},dead:false,isUnit:true,idleT:0,acqT:rand()*0.3,repathT:0,leash:0,working:false,attackAnim:0,stuckT:0,moving:false}; | |
| G.units.push(u); return u; | |
| } | |
| function addBuilding(type,tx,ty,owner,built){ | |
| const bt=BUILDINGS[type]; | |
| const b={id:G.nextId++,type,owner,tx,ty,w:bt.w,h:bt.h,hp:built?bt.hp:Math.max(1,bt.hp*0.1),maxHp:bt.hp,built:!!built,progress:built?1:0,queue:[],cd:0,rally:null,dead:false,isBuilding:true,sight:bt.sight}; | |
| G.buildings.push(b); markRect(tx,ty,b.w,b.h,1); | |
| return b; | |
| } | |
| const bRect=b=>({x:b.tx*T,y:b.ty*T,w:b.w*T,h:b.h*T}); | |
| const bCenter=b=>({x:(b.tx+b.w/2)*T,y:(b.ty+b.h/2)*T}); | |
| function entCenter(e){ if(e.isUnit) return {x:e.x,y:e.y}; if(e.isBuilding) return bCenter(e); return {x:(e.x+1)*T,y:(e.y+1)*T}; } | |
| function killUnit(u){ | |
| if(u.dead) return; u.dead=true; | |
| G.units.splice(G.units.indexOf(u),1); | |
| if(G.selection.includes(u)){G.selection=G.selection.filter(s=>s!==u);selectionChanged();} | |
| for(const k in G.groups) G.groups[k]=G.groups[k].filter(s=>s!==u); | |
| G.effects.push({kind:'death',x:u.x,y:u.y,r:u.r,t:0,dur:1.2,col:u.owner===NEUTRAL?'#7a2a20':'#3a3a4a'}); | |
| if(u.owner===NEUTRAL) G.stats.kills++; else G.stats.lost++; | |
| } | |
| function destroyBuilding(b){ | |
| if(b.dead) return; b.dead=true; | |
| G.buildings.splice(G.buildings.indexOf(b),1); markRect(b.tx,b.ty,b.w,b.h,0); | |
| if(G.selection.includes(b)){G.selection=G.selection.filter(s=>s!==b);selectionChanged();} | |
| const c=bCenter(b); | |
| for(let k=0;k<6;k++) G.effects.push({kind:'smoke',x:c.x+(rand()-0.5)*b.w*T*0.8,y:c.y+(rand()-0.5)*b.h*T*0.8,t:-rand()*0.5,dur:1.6+rand(),r:10+rand()*14}); | |
| } | |
| function removeMine(m){ | |
| G.mines.splice(G.mines.indexOf(m),1); markRect(m.x,m.y,m.w,m.h,0); | |
| if(G.selection.includes(m)){G.selection=[];selectionChanged();} | |
| for(let yy=m.y;yy<m.y+2;yy++)for(let xx=m.x;xx<m.x+2;xx++){G.tiles[idx(xx,yy)]=TILE.DIRT;redrawTile(xx,yy);} | |
| msg('A gold mine has collapsed.'); | |
| } | |
| function supplyUsed(){let s=0;for(const u of G.units)if(u.owner===PLAYER)s+=UNITS[u.type].supply;for(const b of G.buildings)for(const q of b.queue)s+=UNITS[q.type].supply;return s;} | |
| function supplyMax(){let s=0;for(const b of G.buildings)if(b.built)s+=BUILDINGS[b.type].supply;return Math.min(SUPPLY_CAP,s);} | |
| // ============================================================ QUERIES | |
| function enemiesOf(owner){ | |
| if(owner===PLAYER) return G.units.filter(u=>u.owner===NEUTRAL); | |
| return G.units.filter(u=>u.owner===PLAYER).concat(G.buildings); | |
| } | |
| function gapTo(x,y,r,tg){ | |
| if(tg.isUnit) return dist(x,y,tg.x,tg.y)-tg.r-r; | |
| const rc=bRect(tg); return distToRect(x,y,rc.x,rc.y,rc.w,rc.h)-r; | |
| } | |
| function findEnemyNear(x,y,r,rangePx,owner,unitsOnly){ | |
| let best=null,bd=Infinity; | |
| for(const e of enemiesOf(owner)){ | |
| if(e.dead) continue; if(unitsOnly&&!e.isUnit) continue; | |
| if(owner===PLAYER){const i=idx(clamp(tileOf(e.x),0,MW-1),clamp(tileOf(e.y),0,MH-1)); if(G.fog[i]!==2) continue;} | |
| const g=gapTo(x,y,r,e); if(g<=rangePx&&g<bd){bd=g;best=e;} | |
| } | |
| return best; | |
| } | |
| function nearestDropoff(u){ | |
| let best=null,bd=Infinity; | |
| for(const b of G.buildings){if(!b.built||!BUILDINGS[b.type].dropoff) continue;const c=bCenter(b);const d=dist(u.x,u.y,c.x,c.y);if(d<bd){bd=d;best=b;}} | |
| return best; | |
| } | |
| function mineAt(tx,ty){return G.mines.find(m=>tx>=m.x&&tx<m.x+2&&ty>=m.y&&ty<m.y+2)||null;} | |
| function buildingAt(tx,ty){return G.buildings.find(b=>tx>=b.tx&&tx<b.tx+b.w&&ty>=b.ty&&ty<b.ty+b.h)||null;} | |
| function unitAt(wx,wy){ | |
| let best=null,bd=Infinity; | |
| for(const u of G.units){ | |
| if(u.owner===NEUTRAL){const i=idx(clamp(tileOf(u.x),0,MW-1),clamp(tileOf(u.y),0,MH-1)); if(G.fog[i]!==2) continue;} | |
| const d=dist(wx,wy,u.x,u.y-u.r*0.5); if(d<=u.r+6&&d<bd){bd=d;best=u;} | |
| } | |
| return best; | |
| } | |
| function resAlive(res){ if(res.type==='wood') return G.tiles[idx(res.x,res.y)]===TILE.TREE; return !res.mine.dead&&G.mines.includes(res.mine); } | |
| function resRect(res){ if(res.type==='wood') return {x:res.x,y:res.y,w:1,h:1}; return {x:res.mine.x,y:res.mine.y,w:2,h:2}; } | |
| function treeHarvestable(x,y){ | |
| if(!inMap(x,y)||G.tiles[idx(x,y)]!==TILE.TREE) return false; | |
| for(let d=0;d<8;d++){const nx=x+DX[d],ny=y+DY[d]; if(inMap(nx,ny)&&!G.blocked[idx(nx,ny)]) return true;} | |
| return false; | |
| } | |
| function findResourceNear(res,fromX,fromY,exclude){ | |
| if(res.type==='wood'){ | |
| let best=null,bd=Infinity; const R=9; | |
| for(let y=res.y-R;y<=res.y+R;y++)for(let x=res.x-R;x<=res.x+R;x++){ | |
| if(!treeHarvestable(x,y)) continue; if(exclude&&exclude.has(idx(x,y))) continue; | |
| const d=dist(x,y,res.x,res.y)+dist(x*T,y*T,fromX,fromY)/T*0.3; if(d<bd){bd=d;best={type:'wood',x,y};} | |
| } | |
| return best; | |
| } else { | |
| let best=null,bd=Infinity; | |
| for(const m of G.mines){ if(exclude&&exclude.has(m.id)) continue; const d=dist(m.x+1,m.y+1,fromX/T,fromY/T); if(d<28&&d<bd){bd=d;best={type:'gold',mine:m};} } | |
| return best; | |
| } | |
| } | |
| function inRangeOfRect(u,rt){const d=distToRect(u.x,u.y,rt.x*T,rt.y*T,rt.w*T,rt.h*T);return d<=34;} | |
| function approachRect(u,rt){ | |
| const x=clamp(u.x,rt.x*T-12,(rt.x+rt.w)*T+12), y=clamp(u.y,rt.y*T-12,(rt.y+rt.h)*T+12); | |
| u.path=[{x,y}]; | |
| } | |
| // ============================================================ UNIT LOGIC | |
| function updateUnit(u,dt){ | |
| u.cd=Math.max(0,u.cd-dt); u.moving=false; u.working=false; u.attackAnim=Math.max(0,u.attackAnim-dt); | |
| if(u.owner===NEUTRAL) return updateCreep(u,dt); | |
| const t=u.task, ut=UNITS[u.type]; | |
| switch(t.kind){ | |
| case 'idle': | |
| u.idleT+=dt; | |
| if(u.type!=='worker'&&u.idleT>0.35){u.idleT=0;const e=findEnemyNear(u.x,u.y,u.r,ut.sight*T,PLAYER,true);if(e)u.task={kind:'attack',target:e,resume:null};} | |
| break; | |
| case 'move': if(moveAlong(u,dt)){u.task={kind:'idle'};u.path=null;} break; | |
| case 'amove': | |
| u.acqT-=dt; | |
| if(u.acqT<=0){u.acqT=0.3;const e=findEnemyNear(u.x,u.y,u.r,ut.sight*T,PLAYER,true);if(e){u.task={kind:'attack',target:e,resume:t};u.path=null;break;}} | |
| if(moveAlong(u,dt)){u.task={kind:'idle'};u.path=null;} | |
| break; | |
| case 'attack': updateAttack(u,dt); break; | |
| case 'gather': updateGather(u,dt); break; | |
| case 'build': updateBuild(u,dt); break; | |
| } | |
| } | |
| function updateAttack(u,dt){ | |
| const t=u.task,tg=t.target,ut=UNITS[u.type]; | |
| if(!tg||tg.dead){u.path=null;if(t.resume){u.task=t.resume;const r=t.resume;if(r.dest)pathToPoint(u,r.dest.x,r.dest.y);}else u.task={kind:'idle'};return;} | |
| const gap=gapTo(u.x,u.y,u.r,tg); | |
| const reach=ut.range<=1?10:ut.range*T; | |
| const tp=entCenter(tg); | |
| if(gap<=reach){ | |
| u.path=null; if(Math.abs(tp.x-u.x)>2) u.facing=tp.x<u.x?-1:1; | |
| if(u.cd<=0){u.cd=ut.cd;u.attackAnim=0.3;dealDamage(u,tg);} | |
| } else { | |
| u.repathT-=dt; | |
| if(!u.path||u.path.length===0||u.repathT<=0||(u.lastTp&&dist(tp.x,tp.y,u.lastTp.x,u.lastTp.y)>T*1.5)){ | |
| u.repathT=0.6; u.lastTp=tp; | |
| let ok; if(tg.isBuilding) ok=pathToRect(u,tg.tx,tg.ty,tg.w,tg.h); else ok=pathToPoint(u,tp.x,tp.y); | |
| if(!ok&&u.path.length===0){u.task=t.resume||{kind:'idle'};return;} | |
| } | |
| moveAlong(u,dt); | |
| } | |
| } | |
| function dealDamage(src,tg){ | |
| const st=src.isUnit?UNITS[src.type]:BUILDINGS[src.type]; | |
| const dmg=Math.max(1,Math.round(st.dmg*(0.85+rand()*0.3))); | |
| const tp=entCenter(tg); | |
| if(st.range>1){ | |
| const sp=src.isUnit?{x:src.x,y:src.y-8}:{x:bCenter(src).x,y:bCenter(src).y-40}; | |
| G.effects.push({kind:'arrow',x0:sp.x,y0:sp.y,x1:tp.x+(rand()-0.5)*8,y1:tp.y+(rand()-0.5)*8,t:0,dur:Math.max(0.12,dist(sp.x,sp.y,tp.x,tp.y)/600)}); | |
| } else { | |
| G.effects.push({kind:'hit',x:tp.x+(rand()-0.5)*10,y:tp.y-6+(rand()-0.5)*10,t:0,dur:0.25}); | |
| } | |
| tg.hp-=dmg; | |
| if(tg.hp<=0){ if(tg.isUnit) killUnit(tg); else destroyBuilding(tg); return; } | |
| // retaliation | |
| if(tg.isUnit){ | |
| if(tg.task.kind==='idle'&&(tg.owner===NEUTRAL||tg.type!=='worker')){tg.task={kind:'attack',target:src,resume:null};tg.path=null;} | |
| if(tg.owner===NEUTRAL){ | |
| for(const o of G.units) if(o.owner===NEUTRAL&&o!==tg&&o.task.kind==='idle'&&dist(o.x,o.y,tg.x,tg.y)<5*T){o.task={kind:'attack',target:src,resume:null};o.path=null;} | |
| } | |
| } else if(tg.owner===PLAYER){ | |
| // nearby idle soldiers defend | |
| const c=bCenter(tg); | |
| for(const o of G.units) if(o.owner===PLAYER&&o.type!=='worker'&&o.task.kind==='idle'&&dist(o.x,o.y,c.x,c.y)<8*T){o.task={kind:'attack',target:src,resume:null};o.path=null;} | |
| } | |
| } | |
| function updateGather(u,dt){ | |
| const t=u.task; | |
| if(t.phase==='toRes'){ | |
| if(!resAlive(t.res)){const nr=findResourceNear(t.res,u.x,u.y,t.tried);if(nr){t.res=nr;u.path=null;}else if(u.carry){t.phase='toDrop';u.path=null;}else{u.task={kind:'idle'};u.path=null;}return;} | |
| const rt=resRect(t.res); | |
| if(inRangeOfRect(u,rt)){u.path=null;t.phase='harvest';t.timer=0;return;} | |
| if(!u.path){ | |
| const ok=pathToRect(u,rt.x,rt.y,rt.w,rt.h); | |
| if(!ok){ t.tried=t.tried||new Set(); t.tried.add(t.res.type==='wood'?idx(t.res.x,t.res.y):t.res.mine.id); const nr=findResourceNear(t.res,u.x,u.y,t.tried); if(nr){t.res=nr;u.path=null;}else{u.task={kind:'idle'};u.path=null;msg('Peasant cannot reach that resource.');} return; } | |
| if(u.path.length===0){approachRect(u,rt);} | |
| } | |
| if(moveAlong(u,dt)){ if(inRangeOfRect(u,rt)){t.phase='harvest';t.timer=0;} else {t.fail=(t.fail||0)+1;u.path=null;if(t.fail>4){u.task={kind:'idle'};}} } | |
| } else if(t.phase==='harvest'){ | |
| if(!resAlive(t.res)){t.phase='toRes';u.path=null;return;} | |
| t.timer+=dt; u.working=true; u.anim+=dt; | |
| const rt=resRect(t.res); const cx=(rt.x+rt.w/2)*T; if(Math.abs(cx-u.x)>2) u.facing=cx<u.x?-1:1; | |
| const need=t.res.type==='wood'?5:6; | |
| if(t.timer>=need){ | |
| let amt=10; | |
| if(t.res.type==='wood'){const i=idx(t.res.x,t.res.y);amt=Math.min(10,G.treeWood[i]);G.treeWood[i]-=amt;if(G.treeWood[i]<=0){G.tiles[i]=TILE.GRASS;G.blocked[i]=0;redrawTile(t.res.x,t.res.y);}} | |
| else{amt=Math.min(10,t.res.mine.gold);t.res.mine.gold-=amt;if(t.res.mine.gold<=0)removeMine(t.res.mine);} | |
| u.carry={type:t.res.type,amount:amt}; t.phase='toDrop'; u.path=null; t.fail=0; | |
| } | |
| } else if(t.phase==='toDrop'){ | |
| const b=nearestDropoff(u); | |
| if(!b){u.task={kind:'idle'};u.path=null;msg('No Town Hall to deliver resources to.');return;} | |
| if(t.drop!==b){t.drop=b;u.path=null;} | |
| const rt={x:b.tx,y:b.ty,w:b.w,h:b.h}; | |
| if(inRangeOfRect(u,rt)){ | |
| if(u.carry){G.res[u.carry.type]+=u.carry.amount;G.stats.gathered[u.carry.type]+=u.carry.amount;G.effects.push({kind:'text',x:u.x,y:u.y-20,t:0,dur:1.1,text:'+'+u.carry.amount,col:u.carry.type==='gold'?'#ffd84a':'#c98a4a'});u.carry=null;} | |
| t.phase='toRes'; u.path=null; t.fail=0; return; | |
| } | |
| if(!u.path){const ok=pathToRect(u,rt.x,rt.y,rt.w,rt.h);if(!ok){u.task={kind:'idle'};u.path=null;return;}if(u.path.length===0)approachRect(u,rt);} | |
| if(moveAlong(u,dt)){ if(!inRangeOfRect(u,rt)){t.fail=(t.fail||0)+1;u.path=null;if(t.fail>4)u.task={kind:'idle'};} } | |
| } | |
| } | |
| function updateBuild(u,dt){ | |
| const b=u.task.b; | |
| if(!b||b.dead||b.built){u.task={kind:'idle'};u.path=null;return;} | |
| const rt={x:b.tx,y:b.ty,w:b.w,h:b.h}; | |
| if(inRangeOfRect(u,rt)){ | |
| u.path=null; u.working=true; u.anim+=dt; | |
| const cx=(b.tx+b.w/2)*T; if(Math.abs(cx-u.x)>2) u.facing=cx<u.x?-1:1; | |
| const bt=BUILDINGS[b.type]; | |
| b.progress+=dt/bt.time; b.hp=Math.min(bt.hp,b.hp+dt/bt.time*bt.hp*0.9); | |
| if(b.progress>=1) completeBuilding(b); | |
| return; | |
| } | |
| if(!u.path){const ok=pathToRect(u,rt.x,rt.y,rt.w,rt.h);if(!ok){u.task={kind:'idle'};u.path=null;msg('Peasant cannot reach the construction site.');return;}if(u.path.length===0)approachRect(u,rt);} | |
| if(moveAlong(u,dt)){ if(!inRangeOfRect(u,rt)){u.task.fail=(u.task.fail||0)+1;u.path=null;if(u.task.fail>4)u.task={kind:'idle'};} } | |
| } | |
| function completeBuilding(b){ | |
| b.built=true;b.progress=1;b.hp=b.maxHp;G.stats.built++; | |
| msg(BUILDINGS[b.type].name+' completed.'); | |
| for(const u of G.units) if(u.task.kind==='build'&&u.task.b===b){u.task={kind:'idle'};u.path=null;} | |
| if(G.selection.includes(b)) selectionChanged(); | |
| } | |
| function updateCreep(u,dt){ | |
| const t=u.task, ut=UNITS[u.type]; | |
| u.leash=Math.max(0,u.leash-dt); | |
| if(t.kind==='attack'){ | |
| const tg=t.target; | |
| if(!tg||tg.dead){u.task={kind:'return'};u.path=null;return;} | |
| if(dist(u.x,u.y,u.home.x,u.home.y)>12*T){u.task={kind:'return'};u.path=null;u.leash=5;return;} | |
| updateAttack(u,dt); | |
| } else if(t.kind==='return'){ | |
| if(!u.path) pathToPoint(u,u.home.x,u.home.y); | |
| if(moveAlong(u,dt)){u.task={kind:'idle'};u.path=null;u.hp=u.maxHp;} | |
| } else { | |
| u.acqT-=dt; | |
| if(u.acqT<=0){u.acqT=0.4;if(u.leash<=0){const e=findEnemyNear(u.x,u.y,u.r,ut.sight*T,NEUTRAL,false);if(e){u.task={kind:'attack',target:e,resume:null};u.path=null;}}} | |
| if(u.hp<u.maxHp) u.hp=Math.min(u.maxHp,u.hp+dt*2); | |
| } | |
| } | |
| function separate(){ | |
| const us=G.units, n=us.length; | |
| for(let i=0;i<n;i++){const a=us[i]; | |
| for(let j=i+1;j<n;j++){const b=us[j]; | |
| const dx=b.x-a.x,dy=b.y-a.y,md=(a.r+b.r)*0.9,d2=dx*dx+dy*dy; | |
| if(d2>=md*md) continue; | |
| let d=Math.sqrt(d2),nx,ny; if(d<0.01){nx=1;ny=0;d=0.01;}else{nx=dx/d;ny=dy/d;} | |
| const push=(md-d)*0.5*0.35; | |
| const wa=a.working||a.task.kind==='idle'?0.6:1, wb=b.working||b.task.kind==='idle'?0.6:1; | |
| tryNudge(a,-nx*push*wa,-ny*push*wa); tryNudge(b,nx*push*wb,ny*push*wb); | |
| } | |
| } | |
| } | |
| function tryNudge(u,dx,dy){ | |
| const nx=clamp(u.x+dx,4,WPX-4),ny=clamp(u.y+dy,4,HPX-4); | |
| if(!G.blocked[idx(tileOf(nx),tileOf(ny))]){u.x=nx;u.y=ny;} | |
| } | |
| // ============================================================ BUILDINGS LOGIC | |
| function updateBuilding(b,dt){ | |
| if(!b.built) return; | |
| const bt=BUILDINGS[b.type]; | |
| if(b.queue.length){const q=b.queue[0];q.progress+=dt/UNITS[q.type].time;if(q.progress>=1){b.queue.shift();spawnFromBuilding(b,q.type);if(G.selection.includes(b))panelT=0;}} | |
| if(bt.dmg){ | |
| b.cd=Math.max(0,b.cd-dt); | |
| if(b.cd<=0){const c=bCenter(b);const e=findEnemyNear(c.x,c.y,b.w*T/2,bt.range*T,PLAYER,true);if(e){b.cd=bt.cd;dealDamage(b,e);}} | |
| } | |
| } | |
| function spawnFromBuilding(b,type){ | |
| const c=bCenter(b); | |
| const spots=freeSpotsAroundRect(b.tx,b.ty,b.w,b.h,1,{x:c.x,y:(b.ty+b.h+1)*T}); | |
| const s=spots[0]||{x:b.tx,y:b.ty+b.h}; | |
| const u=spawnUnitAt(type,PLAYER,s.x*T+T/2,s.y*T+T/2); G.stats.trained++; | |
| if(b.rally){ | |
| const rx=b.rally.x,ry=b.rally.y,tx=tileOf(rx),ty=tileOf(ry); | |
| if(type==='worker'){const m=mineAt(tx,ty);if(m){u.task={kind:'gather',res:{type:'gold',mine:m},phase:'toRes'};return;}if(inMap(tx,ty)&&G.tiles[idx(tx,ty)]===TILE.TREE){u.task={kind:'gather',res:{type:'wood',x:tx,y:ty},phase:'toRes'};return;}} | |
| u.task={kind:'move',dest:{x:rx,y:ry}}; pathToPoint(u,rx,ry); | |
| } | |
| } | |
| function trainUnit(b,type){ | |
| const ut=UNITS[type]; | |
| if(b.queue.length>=5){msg('Training queue is full.');return;} | |
| if(!canAfford(ut.cost)){msg('Not enough '+(G.res.gold<ut.cost.gold?'gold':'wood')+'.');return;} | |
| if(supplyUsed()+ut.supply>supplyMax()){msg('Not enough food. Build more farms.');return;} | |
| pay(ut.cost); b.queue.push({type,progress:0}); panelT=0; | |
| } | |
| function cancelQueue(b,i){const q=b.queue[i];if(!q)return;b.queue.splice(i,1);refund(UNITS[q.type].cost,1);panelT=0;} | |
| function canPlace(type,tx,ty){ | |
| const bt=BUILDINGS[type]; | |
| for(let y=ty;y<ty+bt.h;y++)for(let x=tx;x<tx+bt.w;x++){ | |
| if(!inMap(x,y)) return false; | |
| const i=idx(x,y); if(G.blocked[i]||G.fog[i]===0) return false; | |
| if(G.tiles[i]===TILE.WATER||G.tiles[i]===TILE.TREE) return false; | |
| } | |
| return true; | |
| } | |
| function placeBuilding(type,tx,ty,builders){ | |
| const bt=BUILDINGS[type]; | |
| if(!canAfford(bt.cost)){msg('Not enough '+(G.res.gold<bt.cost.gold?'gold':'wood')+'.');return null;} | |
| if(!canPlace(type,tx,ty)){msg('Cannot build there.');return null;} | |
| pay(bt.cost); | |
| const b=addBuilding(type,tx,ty,PLAYER,false); | |
| // push units out of footprint | |
| for(const u of G.units){ | |
| const ux=tileOf(u.x),uy=tileOf(u.y); | |
| if(ux>=tx&&ux<tx+bt.w&&uy>=ty&&uy<ty+bt.h){const s=freeSpotsAroundRect(tx,ty,bt.w,bt.h,1,{x:u.x,y:u.y})[0];if(s){u.x=s.x*T+T/2;u.y=s.y*T+T/2;}u.path=null;} | |
| } | |
| for(const u of builders){u.task={kind:'build',b};u.path=null;} | |
| return b; | |
| } | |
| function cancelConstruction(b){ | |
| if(b.built) return; | |
| refund(BUILDINGS[b.type].cost,0.75); destroyBuilding(b); G.effects.length=Math.max(0,G.effects.length-6); | |
| } | |
| // ============================================================ FOG | |
| function updateFog(){ | |
| const fog=G.fog; | |
| for(let i=0;i<fog.length;i++) if(fog[i]===2) fog[i]=1; | |
| const reveal=(cx,cy,r)=>{ | |
| const x0=Math.max(0,Math.floor(cx-r)),x1=Math.min(MW-1,Math.ceil(cx+r)),y0=Math.max(0,Math.floor(cy-r)),y1=Math.min(MH-1,Math.ceil(cy+r)),r2=r*r; | |
| for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){const dx=x+0.5-cx,dy=y+0.5-cy;if(dx*dx+dy*dy<=r2){const i=idx(x,y);if(fog[i]===0)G.explored++;fog[i]=2;}} | |
| }; | |
| for(const u of G.units) if(u.owner===PLAYER) reveal(u.x/T,u.y/T,UNITS[u.type].sight+0.5); | |
| for(const b of G.buildings) if(b.owner===PLAYER){const c=bCenter(b);reveal(c.x/T,c.y/T,(b.built?BUILDINGS[b.type].sight:2)+0.5);} | |
| fogDirty=true; | |
| } | |
| function renderFogCanvas(){ | |
| const img=fctx.createImageData(MW,MH), d=img.data, fog=G.fog; | |
| for(let i=0;i<fog.length;i++){const a=fog[i]===0?255:fog[i]===1?125:0;d[i*4]=5;d[i*4+1]=6;d[i*4+2]=12;d[i*4+3]=a;} | |
| fctx.putImageData(img,0,0); fogDirty=false; | |
| } | |
| // ============================================================ DRAWING HELPERS | |
| function circ(x,y,r){ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();} | |
| function ell(x,y,rx,ry){ctx.beginPath();ctx.ellipse(x,y,rx,ry,0,0,Math.PI*2);ctx.fill();} | |
| function rr(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.lineTo(x+w-r,y);ctx.quadraticCurveTo(x+w,y,x+w,y+r);ctx.lineTo(x+w,y+h-r);ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);ctx.lineTo(x+r,y+h);ctx.quadraticCurveTo(x,y+h,x,y+h-r);ctx.lineTo(x,y+r);ctx.quadraticCurveTo(x,y,x+r,y);ctx.closePath();} | |
| function line(x0,y0,x1,y1,col,w){ctx.strokeStyle=col;ctx.lineWidth=w;ctx.beginPath();ctx.moveTo(x0,y0);ctx.lineTo(x1,y1);ctx.stroke();} | |
| function poly(pts,fill,stroke,lw){ctx.beginPath();ctx.moveTo(pts[0],pts[1]);for(let i=2;i<pts.length;i+=2)ctx.lineTo(pts[i],pts[i+1]);ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=lw||1.5;ctx.stroke();}} | |
| // ---- terrain | |
| function drawTerrainTile(c,x,y){ | |
| const i=idx(x,y),t=G.tiles[i],v=G.variant[i],px=x*T,py=y*T; | |
| const saved=ctx; ctx=c; | |
| if(t===TILE.WATER){ | |
| ctx.fillStyle='#2d6cab'; ctx.fillRect(px,py,T,T); | |
| ctx.fillStyle=(v&1)?'rgba(255,255,255,0.05)':'rgba(0,0,0,0.06)'; ctx.fillRect(px,py,T,T); | |
| ctx.strokeStyle='rgba(255,255,255,0.22)'; ctx.lineWidth=1.5; ctx.beginPath(); const wy=py+6+(v%18); ctx.moveTo(px+5,wy); ctx.quadraticCurveTo(px+11,wy-3,px+17,wy); ctx.quadraticCurveTo(px+22,wy+3,px+28,wy); ctx.stroke(); | |
| ctx.fillStyle='#d8c98c'; | |
| if(isLand(x,y-1)) ctx.fillRect(px,py,T,4); if(isLand(x,y+1)) ctx.fillRect(px,py+T-4,T,4); if(isLand(x-1,y)) ctx.fillRect(px,py,4,T); if(isLand(x+1,y)) ctx.fillRect(px+T-4,py,4,T); | |
| ctx.fillStyle='rgba(255,255,255,0.25)'; | |
| if(isLand(x,y-1)) ctx.fillRect(px,py+4,T,1); if(isLand(x,y+1)) ctx.fillRect(px,py+T-5,T,1); if(isLand(x-1,y)) ctx.fillRect(px+4,py,1,T); if(isLand(x+1,y)) ctx.fillRect(px+T-5,py,1,T); | |
| } else { | |
| const g=t===TILE.DIRT?((v&1)?'#a68b57':'#9e8452'):(v%3===0?'#4f8c3b':v%3===1?'#518f3d':'#4c883a'); | |
| ctx.fillStyle=g; ctx.fillRect(px,py,T,T); | |
| if(t===TILE.DIRT){ | |
| ctx.fillStyle='rgba(0,0,0,0.13)'; for(let k=0;k<4;k++) ctx.fillRect(px+((v*7+k*11)%27)+2,py+((v*13+k*5)%27)+2,2,2); | |
| ctx.fillStyle='rgba(255,255,255,0.10)'; ctx.fillRect(px+((v*3)%26)+3,py+((v*5)%26)+3,3,2); | |
| } else { | |
| if(v%4===0){ctx.strokeStyle='#3d7a2c';ctx.lineWidth=1.2;const tx=px+4+(v%20),ty=py+6+((v>>3)%20);ctx.beginPath();ctx.moveTo(tx,ty+4);ctx.lineTo(tx-2,ty);ctx.moveTo(tx+1,ty+4);ctx.lineTo(tx+2,ty-1);ctx.moveTo(tx+2,ty+4);ctx.lineTo(tx+5,ty+1);ctx.stroke();} | |
| if(v%13===0){ctx.fillStyle=(v%2)?'#eee26a':'#f4f4f4';ctx.fillRect(px+((v*3)%26)+3,py+((v*5)%26)+3,2,2);ctx.fillStyle='#e07a3a';if(v%26===0)ctx.fillRect(px+((v*7)%26)+3,py+((v*11)%26)+3,2,2);} | |
| if(v%7===0){ctx.fillStyle='rgba(0,0,0,0.05)';ctx.fillRect(px,py,T,T);} | |
| } | |
| if(t===TILE.TREE){ctx.fillStyle='rgba(0,0,0,0.10)';ctx.fillRect(px,py,T,T);} | |
| } | |
| ctx=saved; | |
| } | |
| function renderTerrain(){ | |
| for(let y=0;y<MH;y++)for(let x=0;x<MW;x++) drawTerrainTile(tctx,x,y); | |
| renderMiniTerrain(); | |
| } | |
| function redrawTile(x,y){ | |
| for(let d=-1;d<=1;d++)for(let e=-1;e<=1;e++) if(inMap(x+d,y+e)) drawTerrainTile(tctx,x+d,y+e); | |
| renderMiniTerrain(); | |
| } | |
| const MMC={0:'#4f8c3b',1:'#2d6cab',2:'#2a5f27',3:'#a68b57'}; | |
| function renderMiniTerrain(){ | |
| const img=mmtctx.createImageData(MW,MH),d=img.data; | |
| const cols={0:[79,140,59],1:[45,108,171],2:[42,95,39],3:[166,139,87]}; | |
| for(let i=0;i<MW*MH;i++){const c=cols[G.tiles[i]];d[i*4]=c[0];d[i*4+1]=c[1];d[i*4+2]=c[2];d[i*4+3]=255;} | |
| mmtctx.putImageData(img,0,0); | |
| } | |
| function drawTree(tx,ty){ | |
| const v=G.variant[idx(tx,ty)]; | |
| const cx=tx*T+T/2+((v%5)-2), cy=ty*T+T/2+2+((v>>2)%5-2), s=0.85+(v%4)*0.07; | |
| ctx.fillStyle='rgba(0,0,0,0.22)'; ell(cx+3,cy+9,11*s,4.5*s); | |
| if(v%3===0){ // pine | |
| ctx.fillStyle='#5a3a1e'; ctx.fillRect(cx-2,cy+2,4,8); | |
| poly([cx-11*s,cy+5,cx+11*s,cy+5,cx,cy-8*s],'#1f5a2a'); poly([cx-9*s,cy-2,cx+9*s,cy-2,cx,cy-15*s],'#276a32'); poly([cx-6.5*s,cy-9*s,cx+6.5*s,cy-9*s,cx,cy-22*s],'#2f7a3a'); | |
| ctx.fillStyle='rgba(255,255,255,0.10)'; poly([cx-3*s,cy-9*s,cx,cy-20*s,cx,cy-9*s],'rgba(255,255,255,0.10)'); | |
| } else { | |
| ctx.fillStyle='#6b4423'; ctx.fillRect(cx-2.5,cy+1,5,9); | |
| ctx.fillStyle=(v&8)?'#2f6e2c':'#2c7a35'; circ(cx,cy-3,11*s); | |
| ctx.fillStyle=(v&8)?'#3f8f3c':'#3b9442'; circ(cx-4*s,cy-6*s,8*s); circ(cx+5*s,cy-5*s,7*s); | |
| ctx.fillStyle='rgba(255,255,255,0.13)'; circ(cx-4*s,cy-9*s,4*s); | |
| } | |
| } | |
| function drawMine(m){ | |
| const px=m.x*T,py=m.y*T,W=64,H=64; | |
| ctx.fillStyle='rgba(0,0,0,0.2)'; ell(px+32,py+58,30,7); | |
| ctx.beginPath(); ctx.moveTo(px+2,py+58); ctx.quadraticCurveTo(px+2,py+14,px+32,py+8); ctx.quadraticCurveTo(px+62,py+14,px+62,py+58); ctx.closePath(); | |
| ctx.fillStyle='#7c6a58'; ctx.fill(); ctx.strokeStyle='#4b3f33'; ctx.lineWidth=2; ctx.stroke(); | |
| ctx.fillStyle='#8f7d69'; circ(px+18,py+26,7); circ(px+44,py+22,6); circ(px+50,py+40,5); circ(px+12,py+42,5); | |
| ctx.fillStyle='#655445'; circ(px+30,py+30,5); circ(px+22,py+48,4); | |
| ctx.fillStyle='#1c1410'; ctx.beginPath(); ctx.arc(px+32,py+48,12,Math.PI,0); ctx.lineTo(px+44,py+58); ctx.lineTo(px+20,py+58); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#8b5a2b'; ctx.fillRect(px+17,py+36,4,22); ctx.fillRect(px+43,py+36,4,22); ctx.fillRect(px+15,py+34,34,4); | |
| const frac=m.gold/m.maxGold, n=Math.max(1,Math.round(6*frac)); | |
| const spots=[[10,52],[52,50],[40,18],[16,30],[48,32],[26,20]]; | |
| for(let k=0;k<n;k++){const s=spots[k];ctx.fillStyle='#f3c634';circ(px+s[0],py+s[1],3);ctx.fillStyle='#fff3a6';circ(px+s[0]-1,py+s[1]-1,1.2);} | |
| } | |
| // ---- buildings | |
| const DRAWB={ | |
| hall(px,py,W,H,col){ | |
| ctx.fillStyle='rgba(0,0,0,0.2)'; ell(px+W/2,py+H-6,W/2,8); | |
| ctx.fillStyle='#8a8479'; ctx.fillRect(px+4,py+H-14,W-8,10); | |
| ctx.fillStyle='#cbc4b4'; ctx.fillRect(px+8,py+40,W-16,H-48); ctx.strokeStyle='#4d463c'; ctx.lineWidth=2; ctx.strokeRect(px+8,py+40,W-16,H-48); | |
| ctx.strokeStyle='rgba(0,0,0,0.13)'; ctx.lineWidth=1; | |
| for(let y=py+48;y<py+H-10;y+=8){ctx.beginPath();ctx.moveTo(px+9,y);ctx.lineTo(px+W-9,y);ctx.stroke();} | |
| for(let y=py+40,k=0;y<py+H-10;y+=8,k++) for(let x=px+12+(k%2)*8;x<px+W-9;x+=16){ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x,y+8);ctx.stroke();} | |
| poly([px+4,py+42,px+W-4,py+42,px+W-18,py+16,px+18,py+16],'#a83c2c','#5a1e14',2); | |
| ctx.strokeStyle='rgba(0,0,0,0.18)'; for(let y=py+22;y<py+42;y+=6){ctx.beginPath();ctx.moveTo(px+20-(y-py-16)*0.55,y);ctx.lineTo(px+W-20+(y-py-16)*0.55,y);ctx.stroke();} | |
| ctx.fillStyle='#cbc4b4'; ctx.fillRect(px+36,py+8,24,36); ctx.strokeStyle='#4d463c'; ctx.lineWidth=2; ctx.strokeRect(px+36,py+8,24,36); | |
| poly([px+31,py+10,px+65,py+10,px+48,py-8],'#a83c2c','#5a1e14',2); | |
| line(px+48,py-8,px+48,py-24,'#3a2a1a',2); poly([px+48,py-24,px+62,py-19,px+48,py-14],col,'rgba(0,0,0,0.4)',1); | |
| ctx.fillStyle='#3a2415'; ctx.beginPath(); ctx.arc(px+48,py+H-22,8,Math.PI,0); ctx.lineTo(px+56,py+H-10); ctx.lineTo(px+40,py+H-10); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#2a3c5a'; ctx.fillRect(px+18,py+54,8,10); ctx.fillRect(px+W-26,py+54,8,10); ctx.fillRect(px+45,py+18,6,8); | |
| ctx.fillStyle='rgba(255,230,150,0.5)'; ctx.fillRect(px+19,py+55,3,4); ctx.fillRect(px+W-25,py+55,3,4); | |
| ctx.fillStyle=col; ctx.fillRect(px+10,py+46,W-20,3); | |
| }, | |
| farm(px,py,W,H,col){ | |
| ctx.fillStyle='#9c7c44'; ctx.fillRect(px+2,py+2,W-4,H-4); | |
| ctx.fillStyle='#8a6b38'; for(let y=py+8;y<py+H-4;y+=9) ctx.fillRect(px+4,y,W-8,3); | |
| ctx.fillStyle='#63b33a'; for(let y=py+7;y<py+H-4;y+=9) for(let x=px+8;x<px+W-4;x+=7){circ(x,y,2.2);} | |
| ctx.fillStyle='#4f8f2c'; for(let y=py+7;y<py+H-4;y+=9) for(let x=px+8;x<px+W-4;x+=7){circ(x-1,y-1,1.2);} | |
| ctx.strokeStyle='#7a5230'; ctx.lineWidth=2; ctx.strokeRect(px+3,py+3,W-6,H-6); | |
| ctx.fillStyle='#8f6238'; for(let k=0;k<W;k+=10){ctx.fillRect(px+2+k,py+1,3,6);ctx.fillRect(px+2+k,py+H-6,3,6);} | |
| for(let k=0;k<H;k+=10){ctx.fillRect(px+1,py+2+k,6,3);ctx.fillRect(px+W-6,py+2+k,6,3);} | |
| ctx.fillStyle='rgba(0,0,0,0.2)'; ell(px+18,py+26,14,4); | |
| ctx.fillStyle='#d9c7a0'; ctx.fillRect(px+8,py+12,20,14); ctx.strokeStyle='#5a4630'; ctx.lineWidth=1.5; ctx.strokeRect(px+8,py+12,20,14); | |
| poly([px+5,py+13,px+31,py+13,px+18,py+2],'#8b4a2b','#4a2414',1.5); | |
| ctx.fillStyle='#3a2415'; ctx.fillRect(px+15,py+18,6,8); | |
| ctx.fillStyle=col; ctx.fillRect(px+9,py+13,18,2); | |
| }, | |
| barracks(px,py,W,H,col){ | |
| ctx.fillStyle='rgba(0,0,0,0.2)'; ell(px+W/2,py+H-6,W/2,8); | |
| ctx.fillStyle='#5b4632'; ctx.fillRect(px+6,py+34,W-12,H-40); ctx.strokeStyle='#2e2218'; ctx.lineWidth=2; ctx.strokeRect(px+6,py+34,W-12,H-40); | |
| ctx.strokeStyle='#3b2c1e'; ctx.lineWidth=2; for(let y=py+44;y<py+H-6;y+=12){ctx.beginPath();ctx.moveTo(px+7,y);ctx.lineTo(px+W-7,y);ctx.stroke();} | |
| ctx.strokeStyle='#8a6a48'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(px+10,py+38); ctx.lineTo(px+30,py+H-8); ctx.moveTo(px+30,py+38); ctx.lineTo(px+10,py+H-8); ctx.moveTo(px+W-10,py+38); ctx.lineTo(px+W-30,py+H-8); ctx.moveTo(px+W-30,py+38); ctx.lineTo(px+W-10,py+H-8); ctx.stroke(); | |
| poly([px+2,py+36,px+W-2,py+36,px+W-14,py+10,px+14,py+10],'#6a7180','#2c3038',2); | |
| ctx.strokeStyle='rgba(0,0,0,0.22)'; ctx.lineWidth=1; for(let y=py+16;y<py+36;y+=5){ctx.beginPath();ctx.moveTo(px+16-(y-py-10)*0.45,y);ctx.lineTo(px+W-16+(y-py-10)*0.45,y);ctx.stroke();} | |
| ctx.fillStyle='#8a8f9a'; ctx.fillRect(px+14,py+8,W-28,4); | |
| for(const x of [px+8,px+W-20]){ctx.fillStyle='#6a5540';ctx.fillRect(x,py+18,12,20);ctx.strokeStyle='#2e2218';ctx.lineWidth=1.5;ctx.strokeRect(x,py+18,12,20);poly([x-2,py+19,x+14,py+19,x+6,py+8],'#4a3a2a','#2e2218',1);} | |
| poly([px+W/2-9,py+30,px+W/2+9,py+30,px+W/2+9,py+56,px+W/2,py+62,px+W/2-9,py+56],col,'rgba(0,0,0,0.45)',1.5); | |
| line(px+W/2-5,py+38,px+W/2+5,py+50,'#f2e6c0',2); line(px+W/2+5,py+38,px+W/2-5,py+50,'#f2e6c0',2); | |
| ctx.fillStyle='#2b2018'; ctx.fillRect(px+W/2-8,py+H-24,16,18); ctx.strokeStyle='#8a6a48'; ctx.lineWidth=1.5; ctx.strokeRect(px+W/2-8,py+H-24,16,18); | |
| ctx.fillStyle='#2a3c5a'; ctx.fillRect(px+18,py+50,7,8); ctx.fillRect(px+W-25,py+50,7,8); | |
| }, | |
| tower(px,py,W,H,col){ | |
| ctx.fillStyle='rgba(0,0,0,0.22)'; ell(px+32,py+58,26,7); | |
| ctx.fillStyle='#8a8479'; ctx.fillRect(px+8,py+40,48,20); ctx.strokeStyle='#4d463c'; ctx.lineWidth=2; ctx.strokeRect(px+8,py+40,48,20); | |
| const g=ctx.createLinearGradient(px+14,0,px+50,0); g.addColorStop(0,'#b8b2a6'); g.addColorStop(0.5,'#d2ccbf'); g.addColorStop(1,'#9a948a'); | |
| ctx.fillStyle=g; ctx.fillRect(px+14,py-22,36,70); ctx.strokeStyle='#4d463c'; ctx.lineWidth=2; ctx.strokeRect(px+14,py-22,36,70); | |
| ctx.strokeStyle='rgba(0,0,0,0.13)'; ctx.lineWidth=1; for(let y=py-14;y<py+46;y+=8){ctx.beginPath();ctx.moveTo(px+15,y);ctx.lineTo(px+49,y);ctx.stroke();} | |
| ctx.fillStyle='#b8b2a6'; for(let k=0;k<4;k++){ctx.fillRect(px+12+k*10+(k>1?2:0),py-30,7,10);ctx.strokeRect(px+12+k*10+(k>1?2:0),py-30,7,10);} | |
| ctx.fillStyle='#8a8479'; ctx.fillRect(px+12,py-22,40,4); | |
| ctx.fillStyle='#1c1410'; ctx.fillRect(px+30,py-10,4,12); ctx.fillRect(px+30,py+12,4,12); | |
| ctx.fillStyle='#3a2415'; ctx.beginPath(); ctx.arc(px+32,py+40,7,Math.PI,0); ctx.lineTo(px+39,py+50); ctx.lineTo(px+25,py+50); ctx.closePath(); ctx.fill(); | |
| line(px+32,py-30,px+32,py-46,'#3a2a1a',2); poly([px+32,py-46,px+46,py-41,px+32,py-36],col,'rgba(0,0,0,0.4)',1); | |
| } | |
| }; | |
| function drawBuilding(b){ | |
| const bt=BUILDINGS[b.type],px=b.tx*T,py=b.ty*T,W=b.w*T,H=b.h*T,col=TEAM[b.owner]; | |
| if(b.built){DRAWB[b.type](px,py,W,H,col);return;} | |
| ctx.fillStyle='#8a7550'; ctx.fillRect(px+2,py+2,W-4,H-4); ctx.strokeStyle='#5c4a2e'; ctx.lineWidth=2; ctx.strokeRect(px+3,py+3,W-6,H-6); | |
| ctx.fillStyle='#a37a44'; ctx.fillRect(px+6,py+H-14,W-12,4); ctx.fillRect(px+6,py+H-8,W-12,4); | |
| ctx.save(); ctx.beginPath(); const top=py-50+(H+50)*(1-b.progress); ctx.rect(px-6,top,W+12,py+H+6-top); ctx.clip(); DRAWB[b.type](px,py,W,H,col); ctx.restore(); | |
| ctx.strokeStyle='#b8894a'; ctx.lineWidth=2; | |
| for(const x of [px+4,px+W-4]){ctx.beginPath();ctx.moveTo(x,py+H);ctx.lineTo(x,py-10);ctx.stroke();} | |
| for(let y=py+H-16;y>py-10;y-=18){ctx.beginPath();ctx.moveTo(px+2,y);ctx.lineTo(px+W-2,y);ctx.stroke();} | |
| } | |
| // ---- units | |
| function drawHumanoid(u,x,y,t,opts){ | |
| const r=u.r, walk=u.moving?Math.sin(t*14)*1:0, sw=u.moving?Math.sin(t*14):0; | |
| ctx.fillStyle='rgba(0,0,0,0.25)'; ell(x,y+r*0.85,r*0.9,r*0.35); | |
| ctx.save(); ctx.translate(x,y+walk*0.5); ctx.scale(u.facing,1); | |
| // legs | |
| ctx.strokeStyle='#3b2a1e'; ctx.lineWidth=3; ctx.lineCap='round'; | |
| ctx.beginPath(); ctx.moveTo(-3,r*0.3); ctx.lineTo(-3+sw*4,r*0.85); ctx.moveTo(3,r*0.3); ctx.lineTo(3-sw*4,r*0.85); ctx.stroke(); | |
| // body | |
| ctx.fillStyle=opts.body; ell(0,0,r*0.7,r*0.72); ctx.strokeStyle='rgba(0,0,0,0.45)'; ctx.lineWidth=1.2; ctx.stroke(); | |
| ctx.fillStyle=opts.team; ctx.fillRect(-r*0.5,-r*0.1,r*1.0,r*0.28); | |
| // head | |
| ctx.fillStyle='#e9b48f'; circ(0,-r*0.95,r*0.46); ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1; ctx.stroke(); | |
| ctx.fillStyle='#2a1a10'; ctx.fillRect(r*0.12,-r*1.02,1.6,1.6); | |
| // hat/helmet | |
| if(opts.hat==='straw'){ctx.fillStyle='#d8b95a';ell(0,-r*1.15,r*0.8,r*0.22);ctx.fillStyle='#e6c86a';ctx.beginPath();ctx.arc(0,-r*1.15,r*0.42,Math.PI,0);ctx.fill();} | |
| else if(opts.hat==='helm'){ctx.fillStyle='#b9bec8';ctx.beginPath();ctx.arc(0,-r*1.0,r*0.5,Math.PI,0);ctx.fill();ctx.fillRect(-r*0.5,-r*1.0,r,r*0.18);ctx.fillStyle=opts.team;ctx.fillRect(-r*0.1,-r*1.65,r*0.2,r*0.5);} | |
| else if(opts.hat==='hood'){ctx.fillStyle='#3f6b2f';poly([-r*0.5,-r*0.85,r*0.5,-r*0.85,r*0.1,-r*1.7,-r*0.2,-r*1.7],'#3f6b2f','rgba(0,0,0,0.3)',1);} | |
| // items | |
| const swing=u.working?Math.sin(t*10)*0.9:(u.attackAnim>0?-Math.sin(u.attackAnim/0.3*Math.PI)*1.2:0); | |
| ctx.save(); ctx.translate(r*0.55,-r*0.1); ctx.rotate(-0.5+swing); | |
| if(opts.right==='axe'){line(0,0,r*1.2,-r*0.6,'#6b4423',2.5);ctx.fillStyle='#9aa0a8';poly([r*1.0,-r*0.9,r*1.5,-r*0.7,r*1.25,-r*0.15,r*0.85,-r*0.4],'#9aa0a8','#3a3a3a',1);} | |
| else if(opts.right==='sword'){line(0,0,r*1.5,-r*1.1,'#d8dde5',2.5);line(r*0.2,-r*0.35,r*0.55,0.1,'#8a6a2f',3);} | |
| else if(opts.right==='bow'){ctx.strokeStyle='#8a5a2b';ctx.lineWidth=2;ctx.beginPath();ctx.arc(r*0.4,-r*0.2,r*0.9,-1.3,1.3);ctx.stroke();line(r*0.65,-r*1.05,r*0.65,r*0.65,'#e8e0c8',1);} | |
| ctx.restore(); | |
| if(opts.left==='shield'){ctx.fillStyle=opts.team;rr(-r*1.05,-r*0.55,r*0.7,r*0.95,3);ctx.fill();ctx.strokeStyle='#d8dde5';ctx.lineWidth=1.5;ctx.stroke();ctx.fillStyle='#d8dde5';circ(-r*0.7,-r*0.08,1.6);} | |
| if(opts.left==='quiver'){ctx.fillStyle='#6b4423';ctx.fillRect(-r*0.95,-r*0.6,r*0.35,r*0.9);line(-r*0.85,-r*0.6,-r*0.7,-r*1.1,'#e8e0c8',1.2);} | |
| // carrying | |
| if(u.carry){ if(u.carry.type==='gold'){ctx.fillStyle='#e8b830';circ(-r*0.75,-r*0.55,r*0.4);ctx.fillStyle='#fff0a0';circ(-r*0.85,-r*0.65,r*0.14);} else {ctx.fillStyle='#8b5a2b';ctx.save();ctx.translate(-r*0.6,-r*0.7);ctx.rotate(-0.6);ctx.fillRect(-r*0.7,-r*0.2,r*1.4,r*0.4);ctx.fillStyle='#d9b070';ctx.fillRect(r*0.5,-r*0.2,r*0.2,r*0.4);ctx.restore();} } | |
| ctx.restore(); | |
| } | |
| function drawScout(u,x,y,t){ | |
| const r=u.r, sw=u.moving?Math.sin(t*16):0, team=TEAM[u.owner]; | |
| ctx.fillStyle='rgba(0,0,0,0.25)'; ell(x,y+r*0.75,r*1.1,r*0.35); | |
| ctx.save(); ctx.translate(x,y); ctx.scale(u.facing,1); | |
| ctx.strokeStyle='#5a3a1e'; ctx.lineWidth=3; ctx.lineCap='round'; ctx.beginPath(); | |
| ctx.moveTo(-8,3); ctx.lineTo(-9+sw*4,r*0.75); ctx.moveTo(-4,3); ctx.lineTo(-4-sw*4,r*0.75); ctx.moveTo(5,3); ctx.lineTo(5+sw*4,r*0.75); ctx.moveTo(9,3); ctx.lineTo(10-sw*4,r*0.75); ctx.stroke(); | |
| ctx.fillStyle='#8b5a2b'; ell(0,0,r*1.15,r*0.55); ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=1.2; ctx.stroke(); | |
| ctx.fillStyle='#8b5a2b'; ctx.save(); ctx.translate(r*0.95,-r*0.35); ctx.rotate(-0.7); ell(0,0,r*0.35,r*0.6); ctx.restore(); ell(r*1.35,-r*0.85,r*0.42,r*0.3); | |
| ctx.fillStyle='#2a1a10'; ctx.fillRect(r*1.55,-r*0.95,1.8,1.8); line(-r*1.1,-r*0.2,-r*1.5,r*0.5,'#3a2415',2.5); | |
| ctx.fillStyle='#3a2415'; ctx.fillRect(r*0.95,-r*1.3,3,4); | |
| // rider | |
| ctx.fillStyle=team; ell(-r*0.15,-r*0.75,r*0.42,r*0.5); ctx.strokeStyle='rgba(0,0,0,0.45)'; ctx.lineWidth=1; ctx.stroke(); | |
| ctx.fillStyle='#e9b48f'; circ(-r*0.15,-r*1.35,r*0.3); ctx.fillStyle='#b9bec8'; ctx.beginPath(); ctx.arc(-r*0.15,-r*1.4,r*0.32,Math.PI,0); ctx.fill(); | |
| line(r*0.2,-r*0.6,r*1.6,-r*1.7,'#d8c8a0',2); poly([r*1.05,-r*1.3,r*1.5,-r*1.6,r*1.35,-r*1.05],team); | |
| ctx.restore(); | |
| } | |
| function drawWolf(u,x,y,t){ | |
| const r=u.r, sw=u.moving?Math.sin(t*16):0; | |
| ctx.fillStyle='rgba(0,0,0,0.25)'; ell(x,y+r*0.7,r*1.1,r*0.35); | |
| ctx.save(); ctx.translate(x,y); ctx.scale(u.facing,1); | |
| ctx.strokeStyle='#5b5b5f'; ctx.lineWidth=2.5; ctx.lineCap='round'; ctx.beginPath(); | |
| ctx.moveTo(-7,2); ctx.lineTo(-8+sw*4,r*0.7); ctx.moveTo(-3,2); ctx.lineTo(-3-sw*4,r*0.7); ctx.moveTo(4,2); ctx.lineTo(4+sw*4,r*0.7); ctx.moveTo(8,2); ctx.lineTo(9-sw*4,r*0.7); ctx.stroke(); | |
| ctx.fillStyle='#7d7d82'; ell(0,0,r*1.15,r*0.55); ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=1; ctx.stroke(); | |
| ctx.fillStyle='#9a9aa0'; ell(-r*0.1,r*0.12,r*0.8,r*0.28); | |
| circ(r*0.95,-r*0.35,r*0.5); ctx.fillStyle='#7d7d82'; circ(r*0.95,-r*0.35,r*0.5); ell(r*1.35,-r*0.2,r*0.35,r*0.22); | |
| poly([r*0.7,-r*0.7,r*0.85,-r*1.25,r*1.0,-r*0.75],'#6a6a70'); poly([r*1.0,-r*0.75,r*1.2,-r*1.2,r*1.3,-r*0.65],'#6a6a70'); | |
| ctx.fillStyle='#ff4030'; ctx.fillRect(r*1.05,-r*0.5,2,2); | |
| line(-r*1.05,-r*0.1,-r*1.55,-r*0.6,'#6a6a70',3); | |
| ctx.restore(); | |
| } | |
| function drawOgre(u,x,y,t){ | |
| const r=u.r, sw=u.moving?Math.sin(t*9):0, walk=u.moving?Math.abs(Math.sin(t*9))*2:0; | |
| ctx.fillStyle='rgba(0,0,0,0.28)'; ell(x,y+r*0.85,r*1.0,r*0.38); | |
| ctx.save(); ctx.translate(x,y-walk); ctx.scale(u.facing,1); | |
| ctx.strokeStyle='#5a6a2c'; ctx.lineWidth=5; ctx.lineCap='round'; ctx.beginPath(); ctx.moveTo(-5,r*0.4); ctx.lineTo(-6+sw*4,r*0.95); ctx.moveTo(5,r*0.4); ctx.lineTo(6-sw*4,r*0.95); ctx.stroke(); | |
| ctx.fillStyle='#7c9440'; ell(0,0,r*0.85,r*0.8); ctx.strokeStyle='rgba(0,0,0,0.45)'; ctx.lineWidth=1.5; ctx.stroke(); | |
| ctx.fillStyle='#95ad55'; ell(0,r*0.15,r*0.55,r*0.45); | |
| ctx.fillStyle='#6b4a2a'; ctx.fillRect(-r*0.6,r*0.25,r*1.2,r*0.35); | |
| ctx.fillStyle='#7c9440'; circ(0,-r*0.95,r*0.5); ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=1.2; ctx.stroke(); | |
| ctx.fillStyle='#f4f0e0'; poly([r*0.15,-r*0.75,r*0.25,-r*1.0,r*0.35,-r*0.75],'#f4f0e0'); poly([-r*0.15,-r*0.75,-r*0.25,-r*1.0,-r*0.35,-r*0.75],'#f4f0e0'); | |
| ctx.fillStyle='#ffde30'; ctx.fillRect(r*0.12,-r*1.1,2.5,2.5); ctx.fillRect(-r*0.3,-r*1.1,2.5,2.5); | |
| const swing=u.attackAnim>0?-Math.sin(u.attackAnim/0.3*Math.PI)*1.4:0; | |
| ctx.save(); ctx.translate(r*0.7,-r*0.1); ctx.rotate(-0.9+swing); line(0,0,r*1.4,0,'#6b4423',4); ctx.fillStyle='#5a3a1e'; circ(r*1.45,0,r*0.35); ctx.restore(); | |
| ctx.restore(); | |
| } | |
| function drawUnit(u){ | |
| const t=u.anim, team=TEAM[u.owner]; | |
| switch(u.type){ | |
| case 'worker': drawHumanoid(u,u.x,u.y,t,{body:'#a9743f',team,hat:'straw',right:'axe',left:null}); break; | |
| case 'footman': drawHumanoid(u,u.x,u.y,t,{body:'#b9bec8',team,hat:'helm',right:'sword',left:'shield'}); break; | |
| case 'archer': drawHumanoid(u,u.x,u.y,t,{body:'#4f7f3a',team,hat:'hood',right:'bow',left:'quiver'}); break; | |
| case 'scout': drawScout(u,u.x,u.y,t); break; | |
| case 'wolf': drawWolf(u,u.x,u.y,t); break; | |
| case 'ogre': drawOgre(u,u.x,u.y,t); break; | |
| } | |
| } | |
| function drawHealthBar(x,y,w,frac,col){ | |
| ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(x-w/2-1,y-1,w+2,5); | |
| ctx.fillStyle=col||(frac>0.6?'#4fce4a':frac>0.3?'#e8b830':'#e04030'); ctx.fillRect(x-w/2,y,Math.max(0,w*frac),3); | |
| } | |
| function drawEffect(e){ | |
| const p=e.t/e.dur; | |
| if(e.kind==='arrow'){ | |
| const x=e.x0+(e.x1-e.x0)*p,y=e.y0+(e.y1-e.y0)*p-Math.sin(p*Math.PI)*18; | |
| const ang=Math.atan2(e.y1-e.y0,e.x1-e.x0); | |
| ctx.save(); ctx.translate(x,y); ctx.rotate(ang); line(-7,0,5,0,'#e8dcc0',1.5); poly([5,0,2,-2,2,2],'#9aa0a8'); ctx.restore(); | |
| } else if(e.kind==='hit'){ | |
| ctx.strokeStyle='rgba(255,255,255,'+(1-p)+')'; ctx.lineWidth=2; ctx.beginPath(); | |
| for(let k=0;k<4;k++){const a=k*Math.PI/2+0.6;ctx.moveTo(e.x+Math.cos(a)*3,e.y+Math.sin(a)*3);ctx.lineTo(e.x+Math.cos(a)*(6+p*6),e.y+Math.sin(a)*(6+p*6));} | |
| ctx.stroke(); | |
| } else if(e.kind==='text'){ | |
| ctx.font='bold 12px sans-serif'; ctx.textAlign='center'; ctx.fillStyle=e.col; ctx.globalAlpha=1-p; ctx.fillText(e.text,e.x,e.y-p*18); ctx.globalAlpha=1; | |
| } else if(e.kind==='death'){ | |
| ctx.globalAlpha=(1-p)*0.7; ctx.fillStyle=e.col; ell(e.x,e.y+e.r*0.5,e.r*(1+p*0.5),e.r*0.45*(1+p*0.5)); ctx.globalAlpha=1; | |
| } else if(e.kind==='smoke'){ | |
| if(e.t<0) return; ctx.globalAlpha=(1-p)*0.6; ctx.fillStyle='#777'; circ(e.x,e.y-p*30,e.r*(0.5+p)); ctx.globalAlpha=1; | |
| } | |
| } | |
| // ============================================================ RENDER | |
| function render(){ | |
| ctx=vctx; | |
| ctx.fillStyle='#050608'; ctx.fillRect(0,0,VW,VH); | |
| const cx=Math.round(cam.x),cy=Math.round(cam.y); | |
| ctx.save(); ctx.translate(-cx,-cy); | |
| ctx.drawImage(terrainCv,0,0); | |
| const tx0=Math.max(0,tileOf(cx)-1),ty0=Math.max(0,tileOf(cy)-2),tx1=Math.min(MW-1,tileOf(cx+VW)+1),ty1=Math.min(MH-1,tileOf(cy+VH)+2); | |
| const fog=G.fog, items=[]; | |
| // selection rings | |
| for(const s of G.selection){ | |
| if(s.isUnit){ctx.strokeStyle=s.owner===PLAYER?'#5cf25c':'#ff6a5a';ctx.lineWidth=1.5;ctx.beginPath();ctx.ellipse(s.x,s.y+s.r*0.75,s.r+3,(s.r+3)*0.45,0,0,Math.PI*2);ctx.stroke();} | |
| else if(s.isBuilding){const r=bRect(s);ctx.strokeStyle='#5cf25c';ctx.lineWidth=2;ctx.strokeRect(r.x+1,r.y+1,r.w-2,r.h-2);} | |
| else if(s.isMine){ctx.strokeStyle='#ffd84a';ctx.lineWidth=2;ctx.strokeRect(s.x*T+1,s.y*T+1,64-2,64-2);} | |
| } | |
| // rally lines | |
| for(const s of G.selection) if(s.isBuilding&&s.rally){const c=bCenter(s);ctx.setLineDash([6,5]);line(c.x,c.y,s.rally.x,s.rally.y,'rgba(255,230,120,0.7)',1.5);ctx.setLineDash([]);ctx.fillStyle='#ffd84a';poly([s.rally.x,s.rally.y-16,s.rally.x+10,s.rally.y-12,s.rally.x,s.rally.y-8],'#ffd84a');line(s.rally.x,s.rally.y,s.rally.x,s.rally.y-16,'#f0e0a0',2);} | |
| for(let y=ty0;y<=ty1;y++)for(let x=tx0;x<=tx1;x++){const i=idx(x,y);if(G.tiles[i]===TILE.TREE&&fog[i]>0)items.push({y:y*T+T+2,k:0,x,ty:y});} | |
| for(const m of G.mines){if(m.x+2<tx0||m.x>tx1||m.y+2<ty0||m.y>ty1)continue;if(fog[idx(m.x,m.y)]>0||fog[idx(m.x+1,m.y+1)]>0)items.push({y:(m.y+2)*T-4,k:1,m});} | |
| for(const b of G.buildings){if(b.tx+b.w<tx0||b.tx>tx1||b.ty+b.h<ty0||b.ty>ty1)continue;items.push({y:(b.ty+b.h)*T-4,k:2,b});} | |
| for(const u of G.units){ | |
| const ux=clamp(tileOf(u.x),0,MW-1),uy=clamp(tileOf(u.y),0,MH-1); | |
| if(ux<tx0||ux>tx1||uy<ty0||uy>ty1)continue; | |
| if(u.owner===NEUTRAL&&fog[idx(ux,uy)]!==2)continue; | |
| items.push({y:u.y+u.r*0.8,k:3,u}); | |
| } | |
| items.sort((a,b)=>a.y-b.y||a.k-b.k); | |
| for(const it of items){ if(it.k===0)drawTree(it.x,it.ty); else if(it.k===1)drawMine(it.m); else if(it.k===2)drawBuilding(it.b); else drawUnit(it.u); } | |
| for(const e of G.effects) drawEffect(e); | |
| // fog | |
| if(fogDirty) renderFogCanvas(); | |
| ctx.imageSmoothingEnabled=true; ctx.drawImage(fogCv,0,0,MW,MH,0,0,WPX,HPX); | |
| // map border | |
| ctx.strokeStyle='#000'; ctx.lineWidth=4; ctx.strokeRect(-2,-2,WPX+4,HPX+4); | |
| // health bars & progress | |
| for(const u of G.units){ | |
| const ux=clamp(tileOf(u.x),0,MW-1),uy=clamp(tileOf(u.y),0,MH-1); | |
| if(u.owner===NEUTRAL&&fog[idx(ux,uy)]!==2)continue; | |
| const sel=G.selection.includes(u); | |
| if(sel||u.hp<u.maxHp) drawHealthBar(u.x,u.y-u.r*1.9,u.r*2.2,u.hp/u.maxHp); | |
| } | |
| for(const b of G.buildings){ | |
| const sel=G.selection.includes(b); const r=bRect(b); | |
| if(sel||b.hp<b.maxHp) drawHealthBar(r.x+r.w/2,r.y+r.h+2,r.w-8,b.hp/b.maxHp); | |
| if(!b.built) drawHealthBar(r.x+r.w/2,r.y+r.h+8,r.w-8,b.progress,'#5ab8ff'); | |
| else if(b.queue.length&&sel) drawHealthBar(r.x+r.w/2,r.y+r.h+8,r.w-8,b.queue[0].progress,'#5ab8ff'); | |
| } | |
| // placement ghost | |
| if(G.mode&&G.mode.kind==='place'&&mouse.inside){ | |
| const bt=BUILDINGS[G.mode.type]; const tx=tileOf(mouse.wx)-Math.floor(bt.w/2),ty=tileOf(mouse.wy)-Math.floor(bt.h/2); | |
| const ok=canPlace(G.mode.type,tx,ty); | |
| ctx.globalAlpha=0.55; DRAWB[G.mode.type](tx*T,ty*T,bt.w*T,bt.h*T,TEAM.player); ctx.globalAlpha=1; | |
| for(let y=ty;y<ty+bt.h;y++)for(let x=tx;x<tx+bt.w;x++){const good=inMap(x,y)&&!G.blocked[idx(x,y)]&&G.fog[idx(x,y)]>0&&G.tiles[idx(x,y)]!==TILE.WATER;ctx.fillStyle=good?'rgba(80,255,80,0.35)':'rgba(255,60,40,0.5)';ctx.fillRect(x*T+1,y*T+1,T-2,T-2);} | |
| ctx.strokeStyle=ok?'#5cf25c':'#ff5040'; ctx.lineWidth=2; ctx.strokeRect(tx*T,ty*T,bt.w*T,bt.h*T); | |
| } | |
| // drag box | |
| if(mouse.drag){const d=mouse.drag;const x=Math.min(d.x,mouse.wx),y=Math.min(d.y,mouse.wy),w=Math.abs(mouse.wx-d.x),h=Math.abs(mouse.wy-d.y);ctx.fillStyle='rgba(120,255,120,0.12)';ctx.fillRect(x,y,w,h);ctx.strokeStyle='#8fff8f';ctx.lineWidth=1;ctx.strokeRect(x+0.5,y+0.5,w,h);} | |
| ctx.restore(); | |
| // messages | |
| ctx.font='14px sans-serif'; ctx.textAlign='center'; | |
| for(let i=0;i<G.msgs.length;i++){const m=G.msgs[i];ctx.globalAlpha=Math.min(1,m.t);ctx.fillStyle='rgba(0,0,0,0.55)';const w=ctx.measureText(m.text).width+20;ctx.fillRect(VW/2-w/2,VH-30-i*24,w,20);ctx.fillStyle='#ffe8a8';ctx.fillText(m.text,VW/2,VH-16-i*24);} | |
| ctx.globalAlpha=1; | |
| // mode hint | |
| if(G.mode){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(VW/2-140,8,280,24);ctx.fillStyle='#9fd3ff';ctx.font='13px sans-serif';const t=G.mode.kind==='place'?'Click to place '+BUILDINGS[G.mode.type].name+' (right-click / Esc to cancel)':G.mode.kind==='attack'?'Attack: click an enemy or the ground':G.mode.kind==='move'?'Move: click destination':'Rally: click a location';ctx.fillText(t,VW/2,24);} | |
| if(paused){ctx.fillStyle='rgba(0,0,0,0.4)';ctx.fillRect(0,0,VW,VH);ctx.fillStyle='#ffe8a8';ctx.font='bold 32px sans-serif';ctx.fillText('PAUSED',VW/2,VH/2);} | |
| renderMinimap(); | |
| } | |
| function renderMinimap(){ | |
| const c=mmctx,W=mm.width,H=mm.height,sx=W/MW,sy=H/MH; | |
| c.imageSmoothingEnabled=false; c.drawImage(mmTer,0,0,W,H); | |
| for(const m of G.mines){if(G.fog[idx(m.x,m.y)]>0||G.fog[idx(m.x+1,m.y+1)]>0){c.fillStyle='#ffd84a';c.fillRect(m.x*sx,m.y*sy,2*sx,2*sy);}} | |
| for(const b of G.buildings){c.fillStyle=b.owner===PLAYER?'#5c9cff':'#ff5040';c.fillRect(b.tx*sx,b.ty*sy,b.w*sx,b.h*sy);} | |
| for(const u of G.units){ | |
| const ux=clamp(tileOf(u.x),0,MW-1),uy=clamp(tileOf(u.y),0,MH-1); | |
| if(u.owner===NEUTRAL&&G.fog[idx(ux,uy)]!==2)continue; | |
| c.fillStyle=u.owner===PLAYER?(G.selection.includes(u)?'#ffffff':'#7fe07f'):'#ff4030'; c.fillRect(u.x/T*sx-1.5,u.y/T*sy-1.5,3,3); | |
| } | |
| c.imageSmoothingEnabled=true; c.drawImage(fogCv,0,0,MW,MH,0,0,W,H); | |
| c.strokeStyle='#fff'; c.lineWidth=1; c.strokeRect(cam.x/T*sx+0.5,cam.y/T*sy+0.5,VW/T*sx,VH/T*sy); | |
| } | |
| // ============================================================ ICONS / PORTRAITS | |
| const iconCache={}; | |
| function iconCanvas(kind,w,h){ | |
| const key=kind+'_'+w+'x'+h; if(iconCache[key]) return iconCache[key].cloneNode ? cloneCanvas(iconCache[key]) : null; | |
| const c=document.createElement('canvas'); c.width=w*2; c.height=h*2; const cc=c.getContext('2d'); cc.scale(2,2); | |
| const saved=ctx; ctx=cc; | |
| ctx.fillStyle='#1d160e'; ctx.fillRect(0,0,w,h); | |
| if(UNITS[kind]){ | |
| const ut=UNITS[kind]; const fake={type:kind,owner:UNITS[kind].neutral?NEUTRAL:PLAYER,x:0,y:0,r:ut.r,anim:0,facing:1,moving:false,working:false,attackAnim:0,carry:null}; | |
| const s=Math.min(w,h)/ (ut.r*4.2); ctx.translate(w/2,h/2+ut.r*s*0.4); ctx.scale(s,s); drawUnit(fake); | |
| } else if(BUILDINGS[kind]){ | |
| const bt=BUILDINGS[kind]; const bw=bt.w*T,bh=bt.h*T+(kind==='tower'?48:26); const s=Math.min(w/(bw+8),h/(bh+8)); ctx.translate(w/2,h/2); ctx.scale(s,s); ctx.translate(-bw/2,-bh/2+(kind==='tower'?48:26)); DRAWB[kind](0,0,bt.w*T,bt.h*T,TEAM.player); | |
| } else { | |
| ctx.translate(w/2,h/2); ctx.lineCap='round'; | |
| if(kind==='move'){line(-12,6,8,-8,'#8fe0ff',3);poly([12,-12,12,-2,2,-12],'#8fe0ff');} | |
| else if(kind==='stop'){ctx.fillStyle='#ff7a6a';rr(-9,-9,18,18,3);ctx.fill();ctx.strokeStyle='#fff';ctx.lineWidth=2;ctx.stroke();} | |
| else if(kind==='attack'){line(-10,10,8,-8,'#e8ecf2',3.5);line(-4,-8,8,4,'#c9a227',3);line(-11,9,-7,13,'#8a6a2f',4);} | |
| else if(kind==='rally'){line(-6,12,-6,-12,'#f0e0a0',2.5);poly([-6,-12,10,-7,-6,-2],'#ffd84a');} | |
| else if(kind==='cancel'){line(-8,-8,8,8,'#ff7a6a',3.5);line(8,-8,-8,8,'#ff7a6a',3.5);} | |
| } | |
| ctx=saved; iconCache[key]=c; return cloneCanvas(c); | |
| } | |
| function cloneCanvas(c){const n=document.createElement('canvas');n.width=c.width;n.height=c.height;n.getContext('2d').drawImage(c,0,0);return n;} | |
| function portrait(e){ | |
| const c=document.createElement('canvas'); c.width=144; c.height=144; const cc=c.getContext('2d'); cc.scale(2,2); | |
| const saved=ctx; ctx=cc; | |
| ctx.fillStyle='#1d160e'; ctx.fillRect(0,0,72,72); | |
| const g=ctx.createRadialGradient(36,36,5,36,36,50); g.addColorStop(0,'rgba(90,70,40,0.6)'); g.addColorStop(1,'rgba(0,0,0,0)'); ctx.fillStyle=g; ctx.fillRect(0,0,72,72); | |
| if(e.isUnit){const s=72/(e.r*4.5);ctx.translate(36,40+e.r*s*0.3);ctx.scale(s,s);const fake=Object.assign({},e,{x:0,y:0,moving:false,working:false,anim:0,attackAnim:0,facing:1});drawUnit(fake);} | |
| else if(e.isBuilding){const bw=e.w*T,bh=e.h*T+(e.type==='tower'?48:26);const s=Math.min(66/bw,66/bh);ctx.translate(36,36);ctx.scale(s,s);ctx.translate(-bw/2,-bh/2+(e.type==='tower'?48:26));DRAWB[e.type](0,0,e.w*T,e.h*T,TEAM[e.owner]);} | |
| else if(e.isMine){ctx.translate(4,4);ctx.scale(1,1);drawMine({x:0,y:0,gold:e.gold,maxGold:e.maxGold});} | |
| ctx=saved; return c; | |
| } | |
| // ============================================================ UI / PANEL | |
| const infoEl=$('info'), cmdsEl=$('cmds'), tip=$('tooltip'); | |
| let currentCmds=[]; | |
| function selectionChanged(){ G.mode=null; view.className=''; buildPanel(); } | |
| function getCommands(){ | |
| const sel=G.selection; if(!sel.length) return []; | |
| const s0=sel[0]; | |
| if(s0.isMine) return []; | |
| if(s0.isBuilding){ | |
| const b=s0; const bt=BUILDINGS[b.type]; | |
| if(!b.built) return [{name:'Cancel construction',key:'X',icon:'cancel',desc:'Refunds 75% of the cost.',act:()=>{cancelConstruction(b);}}]; | |
| const cmds=[]; | |
| for(const ut of bt.trains||[]) cmds.push({name:'Train '+UNITS[ut].name,key:UNITS[ut].key,icon:ut,cost:UNITS[ut].cost,supply:UNITS[ut].supply,desc:UNITS[ut].desc,act:()=>trainUnit(b,ut)}); | |
| if(bt.trains) cmds.push({name:'Set rally point',key:'R',icon:'rally',desc:'New units will walk to this spot. Peasants rallied on a mine or tree start gathering.',act:()=>setMode({kind:'rally'})}); | |
| return cmds; | |
| } | |
| const cmds=[ | |
| {name:'Move',key:'M',icon:'move',desc:'Move to a location (or right-click).',act:()=>setMode({kind:'move'})}, | |
| {name:'Stop',key:'S',icon:'stop',desc:'Stop the current action.',act:()=>stopSelected()}, | |
| {name:'Attack',key:'A',icon:'attack',desc:'Attack a target, or attack-move to a location.',act:()=>setMode({kind:'attack'})}, | |
| null, | |
| ]; | |
| if(sel.some(u=>u.type==='worker')){ | |
| for(const bk of BUILD_ORDER){const bt=BUILDINGS[bk];cmds.push({name:'Build '+bt.name,key:bt.key,icon:bk,cost:bt.cost,desc:bt.desc+(bt.supply?' Food +'+bt.supply+'.':''),act:()=>setMode({kind:'place',type:bk})});} | |
| } | |
| return cmds; | |
| } | |
| function buildPanel(){ | |
| currentCmds=getCommands(); | |
| cmdsEl.innerHTML=''; | |
| for(const c of currentCmds){ | |
| if(!c){const d=document.createElement('div');cmdsEl.appendChild(d);continue;} | |
| const b=document.createElement('button'); b.className='cmd'; | |
| b.appendChild(iconCanvas(c.icon,52,38)); | |
| const k=document.createElement('span'); k.className='key'; k.textContent=c.key; b.appendChild(k); | |
| b.onclick=e=>{e.stopPropagation();c.act();refreshCmdState();}; | |
| b.onmouseenter=()=>showTip(b,c); b.onmouseleave=hideTip; | |
| c.el=b; cmdsEl.appendChild(b); | |
| } | |
| refreshCmdState(); buildInfo(); | |
| } | |
| function refreshCmdState(){ | |
| for(const c of currentCmds){ if(!c||!c.el) continue; | |
| let dis=false; if(c.cost&&!canAfford(c.cost)) dis=true; | |
| c.el.classList.toggle('dis',dis); | |
| const act=G.mode&&((G.mode.kind==='place'&&c.icon===G.mode.type)||(G.mode.kind===c.icon)); | |
| c.el.classList.toggle('active',!!act); | |
| } | |
| } | |
| function showTip(el,c){ | |
| let h='<div class="tn">'+c.name+(c.key?' <span style="color:#ffe08a">['+c.key+']</span>':'')+'</div>'; | |
| if(c.cost) h+='<div class="tc">'+fmtCost(c.cost)+(c.supply?' · food '+c.supply:'')+'</div>'; | |
| if(c.desc) h+='<div class="td">'+c.desc+'</div>'; | |
| tip.innerHTML=h; tip.style.display='block'; | |
| const r=el.getBoundingClientRect(); tip.style.left=Math.max(4,Math.min(window.innerWidth-270,r.left-60))+'px'; tip.style.top=(r.top-tip.offsetHeight-8)+'px'; | |
| } | |
| function hideTip(){tip.style.display='none';} | |
| function taskText(u){ | |
| const t=u.task; | |
| switch(t.kind){ | |
| case 'idle': return 'Idle'; | |
| case 'move': return 'Moving'; | |
| case 'amove': return 'Attack-moving'; | |
| case 'attack': return 'Attacking '+(t.target?(t.target.isUnit?UNITS[t.target.type].name:BUILDINGS[t.target.type].name):''); | |
| case 'gather': return (t.phase==='harvest'?(t.res.type==='wood'?'Chopping wood':'Mining gold'):t.phase==='toDrop'?'Returning '+(u.carry?u.carry.amount+' '+u.carry.type:''):'Going to '+(t.res.type==='wood'?'trees':'gold mine')); | |
| case 'build': return 'Constructing '+(t.b?BUILDINGS[t.b.type].name:''); | |
| case 'return': return 'Returning'; | |
| } | |
| return ''; | |
| } | |
| function buildInfo(){ | |
| const sel=G.selection; infoEl.innerHTML=''; | |
| if(!sel.length){ infoEl.innerHTML='<div class="det" style="color:#a08f6a;align-self:center"><div class="name" style="color:#d8c48a">Fable Craft</div>Select units with the left mouse button, command them with the right. Explore the entire map to win.<br>Press <b>F1</b> for help.</div>'; return; } | |
| if(sel.length===1||sel[0].isBuilding||sel[0].isMine){ | |
| const e=sel[0]; | |
| const p=document.createElement('div'); p.className='port'; p.appendChild(portrait(e)); infoEl.appendChild(p); | |
| const d=document.createElement('div'); d.className='det'; d.id='det'; infoEl.appendChild(d); | |
| updateInfo(); | |
| } else { | |
| const wrap=document.createElement('div'); wrap.className='multi'; wrap.id='multi'; | |
| for(const u of sel){const m=document.createElement('div');m.className='mini';const c=iconCanvas(u.type,46,36);m.appendChild(c);const hb=document.createElement('div');hb.className='mh';hb.innerHTML='<div style="width:'+Math.round(u.hp/u.maxHp*100)+'%"></div>';m.appendChild(hb);m.title=UNITS[u.type].name;m.onclick=e=>{e.stopPropagation();if(e.shiftKey){G.selection=G.selection.filter(s=>s!==u);}else G.selection=[u];selectionChanged();};wrap.appendChild(m);} | |
| infoEl.appendChild(wrap); | |
| } | |
| } | |
| function hpClass(f){return f>0.6?'':f>0.3?' low':' crit';} | |
| function updateInfo(){ | |
| const sel=G.selection; if(!sel.length) return; | |
| const d=$('det'); if(!d){ if(sel.length>1){const w=$('multi');if(w){const ms=w.children;for(let i=0;i<ms.length&&i<sel.length;i++){const u=sel[i];ms[i].firstChild.nextSibling.firstChild.style.width=Math.round(u.hp/u.maxHp*100)+'%';}}} return; } | |
| const e=sel[0]; let h=''; | |
| if(e.isUnit){ | |
| const ut=UNITS[e.type]; const f=e.hp/e.maxHp; | |
| h+='<div class="name">'+ut.name+'</div>'; | |
| h+='<div><span class="hpbar'+hpClass(f)+'"><div style="width:'+(f*100)+'%"></div></span>'+Math.ceil(e.hp)+' / '+e.maxHp+'</div>'; | |
| h+='<div class="stat">Attack '+ut.dmg+' · Range '+ut.range+' · Speed '+ut.speed+' · Sight '+ut.sight+'</div>'; | |
| h+='<div class="task">'+taskText(e)+(e.carry?' (carrying '+e.carry.amount+' '+e.carry.type+')':'')+'</div>'; | |
| } else if(e.isBuilding){ | |
| const bt=BUILDINGS[e.type]; const f=e.hp/e.maxHp; | |
| h+='<div class="name">'+bt.name+(e.built?'':' (under construction)')+'</div>'; | |
| h+='<div><span class="hpbar'+hpClass(f)+'"><div style="width:'+(f*100)+'%"></div></span>'+Math.ceil(e.hp)+' / '+e.maxHp+'</div>'; | |
| if(!e.built){h+='<div><span class="cbar"><div style="width:'+(e.progress*100)+'%"></div></span>'+Math.floor(e.progress*100)+'% built · '+G.units.filter(u=>u.task.kind==='build'&&u.task.b===e&&u.working).length+' peasants working</div>';} | |
| else { | |
| h+='<div class="stat">'+(bt.supply?'Food +'+bt.supply+' · ':'')+'Sight '+bt.sight+(bt.dmg?' · Attack '+bt.dmg+' · Range '+bt.range:'')+'</div>'; | |
| if(e.queue.length){h+='<div class="queue" id="queue"></div><div class="stat" style="font-size:11px">Training '+UNITS[e.queue[0].type].name+' · '+Math.floor(e.queue[0].progress*100)+'% · click to cancel</div>';} | |
| else if(bt.trains) h+='<div class="task">Idle · '+(e.rally?'rally point set':'right-click the map to set a rally point')+'</div>'; | |
| } | |
| } else if(e.isMine){ | |
| h+='<div class="name">Gold Mine</div><div class="stat" style="color:#ffd84a">'+e.gold+' gold remaining</div><div class="task">Send peasants here to mine gold.</div>'; | |
| } | |
| d.innerHTML=h; | |
| if(e.isBuilding&&e.built&&e.queue.length){ | |
| const q=$('queue'); | |
| e.queue.forEach((it,i)=>{const el=document.createElement('div');el.className='qitem';el.appendChild(iconCanvas(it.type,40,30));const p=document.createElement('div');p.className='qp';p.style.width=(i===0?it.progress*100:0)+'%';el.appendChild(p);el.title='Cancel '+UNITS[it.type].name;el.onclick=ev=>{ev.stopPropagation();cancelQueue(e,i);};q.appendChild(el);}); | |
| } | |
| } | |
| function updateHUD(){ | |
| $('gold').textContent=G.res.gold; $('wood').textContent=G.res.wood; | |
| const su=supplyUsed(),sm=supplyMax(); const se=$('supply'); se.textContent=su+'/'+sm; se.style.color=su>=sm?'#ff8a70':''; | |
| const pct=G.explored/(MW*MH)*100; $('explored').textContent=(pct>=99.95&&G.explored<MW*MH?99.9:pct).toFixed(pct>=99?1:0)+'%'; $('expfill').style.width=pct+'%'; | |
| $('clock').textContent=fmtTime(G.time); | |
| const idle=G.units.filter(u=>u.owner===PLAYER&&u.type==='worker'&&u.task.kind==='idle'); | |
| const ie=$('idle'); ie.style.display=idle.length?'block':'none'; $('idlen').textContent=idle.length; | |
| refreshCmdState(); | |
| } | |
| function msg(text){ if(G.msgs.length&&G.msgs[0].text===text){G.msgs[0].t=3;return;} G.msgs.unshift({text,t:3}); if(G.msgs.length>4)G.msgs.length=4; } | |
| function setMode(m){ G.mode=m; view.className=m?(m.kind==='place'?'place':'target'):''; refreshCmdState(); } | |
| function cancelMode(){ if(G.mode){setMode(null);} } | |
| function stopSelected(){ for(const u of G.selection) if(u.isUnit&&u.owner===PLAYER){u.task={kind:'idle'};u.path=null;} } | |
| let idleCycle=0; | |
| function selectIdleWorker(){ | |
| const idle=G.units.filter(u=>u.owner===PLAYER&&u.type==='worker'&&u.task.kind==='idle'); if(!idle.length) return; | |
| const u=idle[idleCycle++%idle.length]; G.selection=[u]; selectionChanged(); centerOn(u.x,u.y); | |
| } | |
| // ============================================================ COMMANDS | |
| function selectedUnits(){return G.selection.filter(s=>s.isUnit&&s.owner===PLAYER);} | |
| function commandMove(units,wx,wy,kind){ | |
| if(!units.length) return; | |
| wx=clamp(wx,4,WPX-4); wy=clamp(wy,4,HPX-4); | |
| const tx=tileOf(wx),ty=tileOf(wy); | |
| const spots=freeSpotsAroundRect(tx,ty,1,1,units.length,{x:wx,y:wy}); | |
| const sorted=units.slice().sort((a,b)=>dist(a.x,a.y,wx,wy)-dist(b.x,b.y,wx,wy)); | |
| sorted.forEach((u,i)=>{ | |
| const s=spots[i]||spots[0]; let px,py; | |
| if(i===0&&spots[0]&&spots[0].x===tx&&spots[0].y===ty){px=wx;py=wy;} else if(s){px=s.x*T+T/2;py=s.y*T+T/2;} else {px=wx;py=wy;} | |
| u.task={kind:kind||'move',dest:{x:px,y:py}}; u.path=null; pathToPoint(u,px,py); | |
| }); | |
| } | |
| function commandAttack(units,target){ for(const u of units){u.task={kind:'attack',target,resume:null};u.path=null;} } | |
| function commandGather(units,res){ for(const u of units){ if(u.type==='worker'){u.task={kind:'gather',res:Object.assign({},res),phase:'toRes'};u.path=null;} } } | |
| function commandBuild(units,b){ for(const u of units){ if(u.type==='worker'){u.task={kind:'build',b};u.path=null;} } } | |
| function contextCommand(wx,wy){ | |
| const units=selectedUnits(); | |
| if(!units.length){ | |
| const b=G.selection[0]; if(b&&b.isBuilding&&b.built&&BUILDINGS[b.type].trains){b.rally={x:wx,y:wy};panelT=0;} | |
| return; | |
| } | |
| const tx=tileOf(wx),ty=tileOf(wy); if(!inMap(tx,ty)) {commandMove(units,wx,wy,'move');return;} | |
| const hu=unitAt(wx,wy); | |
| if(hu&&hu.owner===NEUTRAL){commandAttack(units,hu);return;} | |
| const fogv=G.fog[idx(tx,ty)]; | |
| const m=mineAt(tx,ty), tree=G.tiles[idx(tx,ty)]===TILE.TREE, hb=buildingAt(tx,ty); | |
| const workers=units.filter(u=>u.type==='worker'), others=units.filter(u=>u.type!=='worker'); | |
| if(fogv>0&&(m||tree)&&workers.length){ | |
| commandGather(workers,m?{type:'gold',mine:m}:{type:'wood',x:tx,y:ty}); if(others.length) commandMove(others,wx,wy,'move'); return; | |
| } | |
| if(hb&&hb.owner===PLAYER){ | |
| if(!hb.built&&workers.length){commandBuild(workers,hb);if(others.length)commandMove(others,wx,wy,'move');return;} | |
| if(hb.built&&BUILDINGS[hb.type].dropoff&&workers.some(u=>u.carry)){for(const u of workers)if(u.carry){u.task=u.task.kind==='gather'?Object.assign(u.task,{phase:'toDrop',drop:null}):{kind:'gather',res:{type:u.carry.type,x:tileOf(u.x),y:tileOf(u.y),mine:null},phase:'toDrop'};u.path=null;}if(others.length)commandMove(others,wx,wy,'move');return;} | |
| } | |
| commandMove(units,wx,wy,'move'); | |
| } | |
| function modeClick(wx,wy){ | |
| const m=G.mode; if(!m) return; | |
| if(m.kind==='place'){ | |
| const bt=BUILDINGS[m.type]; const tx=tileOf(wx)-Math.floor(bt.w/2),ty=tileOf(wy)-Math.floor(bt.h/2); | |
| const workers=selectedUnits().filter(u=>u.type==='worker'); | |
| const b=placeBuilding(m.type,tx,ty,workers); | |
| if(b&&!keys.shift) setMode(null); else if(b) refreshCmdState(); | |
| return; | |
| } | |
| const units=selectedUnits(); | |
| if(m.kind==='move'){commandMove(units,wx,wy,'move');} | |
| else if(m.kind==='attack'){const hu=unitAt(wx,wy);if(hu&&hu.owner===NEUTRAL)commandAttack(units,hu);else commandMove(units,wx,wy,'amove');} | |
| else if(m.kind==='rally'){for(const b of G.selection)if(b.isBuilding)b.rally={x:wx,y:wy};panelT=0;} | |
| setMode(null); | |
| } | |
| function clickSelect(wx,wy,shift){ | |
| const u=unitAt(wx,wy); const tx=tileOf(wx),ty=tileOf(wy); | |
| let e=u; | |
| if(!e&&inMap(tx,ty)){ const b=buildingAt(tx,ty); if(b&&G.fog[idx(tx,ty)]>0) e=b; else {const m=mineAt(tx,ty); if(m&&G.fog[idx(tx,ty)]>0) e=m;} } | |
| if(!e){ if(!shift){G.selection=[];selectionChanged();} return; } | |
| if(e.isUnit&&e.owner===PLAYER&&shift&&G.selection.every(s=>s.isUnit&&s.owner===PLAYER)){ | |
| if(G.selection.includes(e)) G.selection=G.selection.filter(s=>s!==e); else G.selection.push(e); | |
| } else G.selection=[e]; | |
| selectionChanged(); | |
| } | |
| function boxSelect(x0,y0,x1,y1,shift){ | |
| const xa=Math.min(x0,x1),xb=Math.max(x0,x1),ya=Math.min(y0,y1),yb=Math.max(y0,y1); | |
| const inBox=G.units.filter(u=>u.owner===PLAYER&&u.x>=xa&&u.x<=xb&&u.y-u.r*0.5>=ya-u.r&&u.y<=yb+u.r*0.5); | |
| if(!inBox.length){ clickSelect(x1,y1,shift); return; } | |
| if(shift&&G.selection.every(s=>s.isUnit&&s.owner===PLAYER)){ for(const u of inBox) if(!G.selection.includes(u)) G.selection.push(u); } | |
| else G.selection=inBox; | |
| selectionChanged(); | |
| } | |
| function selectSameType(u){ | |
| const same=G.units.filter(o=>o.owner===PLAYER&&o.type===u.type&&o.x>=cam.x&&o.x<=cam.x+VW&&o.y>=cam.y&&o.y<=cam.y+VH); | |
| G.selection=same.length?same:[u]; selectionChanged(); | |
| } | |
| // ============================================================ INPUT | |
| function toWorld(e){const r=view.getBoundingClientRect();mouse.x=e.clientX-r.left;mouse.y=e.clientY-r.top;mouse.wx=mouse.x+cam.x;mouse.wy=mouse.y+cam.y;} | |
| view.addEventListener('mousemove',e=>{toWorld(e);mouse.inside=true;}); | |
| view.addEventListener('mouseenter',e=>{toWorld(e);mouse.inside=true;}); | |
| view.addEventListener('mouseleave',()=>{mouse.inside=false;}); | |
| document.addEventListener('mouseleave',()=>{mouse.inside=false;keys.edge=false;}); | |
| view.addEventListener('contextmenu',e=>e.preventDefault()); | |
| view.addEventListener('mousedown',e=>{ | |
| toWorld(e); if(G.over&&!G.won) return; hideTip(); | |
| if(e.button===0){ | |
| if(G.mode){modeClick(mouse.wx,mouse.wy);return;} | |
| mouse.down=true; mouse.drag={x:mouse.wx,y:mouse.wy,moved:false}; | |
| } else if(e.button===2){ | |
| if(G.mode){cancelMode();return;} | |
| contextCommand(mouse.wx,mouse.wy); | |
| } else if(e.button===1){ e.preventDefault(); } | |
| }); | |
| window.addEventListener('mouseup',e=>{ | |
| if(e.button!==0||!mouse.down) return; mouse.down=false; | |
| const d=mouse.drag; mouse.drag=null; if(!d) return; | |
| const dx=mouse.wx-d.x,dy=mouse.wy-d.y; | |
| if(Math.abs(dx)<5&&Math.abs(dy)<5){ | |
| const now=performance.now(); const u=unitAt(d.x,d.y); | |
| if(u&&u.owner===PLAYER&&now-mouse.lastClick<350&&dist(d.x,d.y,mouse.lastClickX,mouse.lastClickY)<8){selectSameType(u);mouse.lastClick=0;return;} | |
| mouse.lastClick=now; mouse.lastClickX=d.x; mouse.lastClickY=d.y; | |
| clickSelect(d.x,d.y,e.shiftKey); | |
| } else boxSelect(d.x,d.y,mouse.wx,mouse.wy,e.shiftKey); | |
| }); | |
| // minimap | |
| function mmToWorld(e){const r=mm.getBoundingClientRect();return {x:(e.clientX-r.left)/r.width*WPX,y:(e.clientY-r.top)/r.height*HPX};} | |
| let mmDrag=false; | |
| mm.addEventListener('contextmenu',e=>e.preventDefault()); | |
| mm.addEventListener('mousedown',e=>{const p=mmToWorld(e);if(e.button===0){mmDrag=true;centerOn(p.x,p.y);}else if(e.button===2){if(G.mode&&G.mode.kind!=='place'){modeClick(p.x,p.y);}else contextCommand(p.x,p.y);}}); | |
| window.addEventListener('mousemove',e=>{if(mmDrag){const p=mmToWorld(e);centerOn(p.x,p.y);}}); | |
| window.addEventListener('mouseup',()=>{mmDrag=false;}); | |
| // keyboard | |
| window.addEventListener('keydown',e=>{ | |
| if(e.target&&e.target.tagName==='INPUT') return; | |
| keys[e.key]=true; keys.shift=e.shiftKey; | |
| if(e.key==='F1'||e.key==='?'){e.preventDefault();toggleHelp();return;} | |
| if(!$('overlay').classList.contains('hidden')){ if(e.key==='Escape'){toggleHelp(false);} return; } | |
| if(e.key==='Escape'){ if(G.mode) cancelMode(); else {G.selection=[];selectionChanged();} return; } | |
| if(e.key==='p'||e.key==='P'){paused=!paused;return;} | |
| if(e.key===','){selectIdleWorker();return;} | |
| if(e.key===' '){e.preventDefault();const s=G.selection[0];if(s){const c=entCenter(s);centerOn(c.x,c.y);}return;} | |
| if(e.key>='0'&&e.key<='9'){ | |
| const k=e.key; | |
| if(e.ctrlKey||e.metaKey){e.preventDefault();G.groups[k]=G.selection.filter(s=>s.isUnit&&s.owner===PLAYER);msg('Control group '+k+' set.');} | |
| else { const g=(G.groups[k]||[]).filter(u=>!u.dead); if(g.length){ if(G.lastGroupKey===k&&performance.now()-G.lastGroupT<400){const c=entCenter(g[0]);centerOn(c.x,c.y);} G.selection=g.slice(); selectionChanged(); G.lastGroupKey=k; G.lastGroupT=performance.now(); } } | |
| return; | |
| } | |
| if(e.ctrlKey||e.metaKey||e.altKey) return; | |
| const k=e.key.toUpperCase(); | |
| for(const c of currentCmds){ if(c&&c.key===k){ e.preventDefault(); if(c.cost&&!canAfford(c.cost)){msg('Not enough '+(G.res.gold<(c.cost.gold||0)?'gold':'wood')+'.');return;} c.act(); refreshCmdState(); return; } } | |
| }); | |
| window.addEventListener('keyup',e=>{keys[e.key]=false;keys.shift=e.shiftKey;}); | |
| window.addEventListener('blur',()=>{for(const k in keys)keys[k]=false;}); | |
| $('btnHelp').onclick=()=>toggleHelp(); | |
| $('btnNew').onclick=()=>{startGame((Math.random()*1e9)|0);}; | |
| $('idle').onclick=()=>selectIdleWorker(); | |
| function toggleHelp(force){ | |
| const ov=$('overlay'); const show=force===undefined?ov.classList.contains('hidden'):force; | |
| if(show){ $('obox').innerHTML=HELP_HTML; ov.classList.remove('hidden'); paused=true; $('btnClose').onclick=()=>toggleHelp(false); } | |
| else { ov.classList.add('hidden'); paused=false; } | |
| } | |
| const HELP_HTML=`<h2>Fable Craft</h2> | |
| <div>A small real-time strategy game. Gather gold and wood, build a base, train an army, clear the wolves and ogres, and <b>explore the entire map</b> to win.</div> | |
| <h3>Controls</h3> | |
| <table> | |
| <tr><td><kbd>Left click</kbd> / drag</td><td>Select unit, building or mine; box-select units. <kbd>Shift</kbd> adds to selection, double-click selects all of a type on screen.</td></tr> | |
| <tr><td><kbd>Right click</kbd></td><td>Context command: move, attack an enemy, gather from a tree or gold mine, help construct a building, set a rally point for a selected building.</td></tr> | |
| <tr><td><kbd>A</kbd> + click</td><td>Attack-move: soldiers engage anything they meet on the way.</td></tr> | |
| <tr><td><kbd>M</kbd> / <kbd>S</kbd></td><td>Move / Stop.</td></tr> | |
| <tr><td><kbd>F</kbd> <kbd>B</kbd> <kbd>T</kbd> <kbd>H</kbd></td><td>With a peasant selected: build Farm, Barracks, Watchtower, Town Hall. Hold <kbd>Shift</kbd> to place several.</td></tr> | |
| <tr><td><kbd>Ctrl</kbd>+<kbd>1..9</kbd></td><td>Assign control group; press the number to select it, twice to center on it.</td></tr> | |
| <tr><td><kbd>,</kbd></td><td>Select an idle peasant.</td></tr> | |
| <tr><td><kbd>Arrows</kbd> / screen edge / minimap</td><td>Scroll the map. <kbd>Space</kbd> centers on the selection.</td></tr> | |
| <tr><td><kbd>P</kbd> / <kbd>Esc</kbd></td><td>Pause / cancel current command or clear selection.</td></tr> | |
| </table> | |
| <h3>Tips</h3> | |
| <div>Farms provide food for more units. Scout Riders are fast and see far. Watchtowers reveal a huge area and defend your base. Gold mines guarded by monsters must be cleared before peasants can work safely. The Town Hall is the only place resources are delivered, so build a second one near distant mines.</div> | |
| <div class="btns"><button class="ui" id="btnClose">Close</button></div>`; | |
| // ============================================================ GAME LOOP | |
| function update(dt){ | |
| if(G.over) return; | |
| G.time+=dt; | |
| fogT-=dt; if(fogT<=0){fogT=0.15;updateFog();} | |
| const us=G.units.slice(); for(const u of us) if(!u.dead) updateUnit(u,dt); | |
| const bs=G.buildings.slice(); for(const b of bs) if(!b.dead) updateBuilding(b,dt); | |
| separate(); | |
| for(let i=G.effects.length-1;i>=0;i--){const e=G.effects[i];e.t+=dt;if(e.t>=e.dur)G.effects.splice(i,1);} | |
| for(let i=G.msgs.length-1;i>=0;i--){G.msgs[i].t-=dt;if(G.msgs[i].t<=0)G.msgs.splice(i,1);} | |
| checkT-=dt; | |
| if(checkT<=0){ | |
| checkT=1; | |
| if(!G.won&&G.explored>=MW*MH){G.won=true;showEnd(true);} | |
| if(!G.units.some(u=>u.owner===PLAYER)&&!G.buildings.some(b=>b.owner===PLAYER)){G.over=true;showEnd(false);} | |
| else if(!G.units.some(u=>u.owner===PLAYER&&u.type==='worker')&&!G.buildings.some(b=>b.built&&(BUILDINGS[b.type].trains||[]).includes('worker'))&&!G.buildings.some(b=>b.queue.some(q=>q.type==='worker'))&&G.res.gold<50){ /* stuck but alive; leave it */ } | |
| } | |
| } | |
| function showEnd(won){ | |
| const s=G.stats; const ov=$('overlay'); | |
| $('obox').innerHTML=(won?'<h2>Victory! The whole map is explored.</h2>':'<h2>Defeat</h2><div>All your units and buildings are gone.</div>')+ | |
| '<div style="margin-top:8px">Time: <b>'+fmtTime(G.time)+'</b> · Gold gathered: <b>'+s.gathered.gold+'</b> · Wood gathered: <b>'+s.gathered.wood+'</b><br>Buildings built: <b>'+s.built+'</b> · Units trained: <b>'+s.trained+'</b> · Monsters slain: <b>'+s.kills+'</b> · Units lost: <b>'+s.lost+'</b></div>'+ | |
| '<div class="btns">'+(won?'<button class="ui" id="btnCont">Keep playing</button>':'')+'<button class="ui" id="btnNew2">New map</button></div>'; | |
| ov.classList.remove('hidden'); paused=true; | |
| if(won) $('btnCont').onclick=()=>{ov.classList.add('hidden');paused=false;}; | |
| $('btnNew2').onclick=()=>{ov.classList.add('hidden');startGame((Math.random()*1e9)|0);}; | |
| } | |
| function scrollCam(dt){ | |
| const sp=700*dt; let dx=0,dy=0; | |
| if(keys.ArrowLeft) dx-=sp; if(keys.ArrowRight) dx+=sp; if(keys.ArrowUp) dy-=sp; if(keys.ArrowDown) dy+=sp; | |
| if(mouse.inside&&!mouse.drag&&document.hasFocus()){const m=14;if(mouse.x<m)dx-=sp;if(mouse.x>VW-m)dx+=sp;if(mouse.y<m)dy-=sp;if(mouse.y>VH-m)dy+=sp;} | |
| if(mouse.drag&&mouse.inside){const m=10;if(mouse.x<m)dx-=sp*0.6;if(mouse.x>VW-m)dx+=sp*0.6;if(mouse.y<m)dy-=sp*0.6;if(mouse.y>VH-m)dy+=sp*0.6;} | |
| if(dx||dy){cam.x+=dx;cam.y+=dy;clampCam();mouse.wx=mouse.x+cam.x;mouse.wy=mouse.y+cam.y;} | |
| } | |
| let last=performance.now(), acc=0; | |
| function frame(now){ | |
| let dt=Math.min(0.1,(now-last)/1000); last=now; | |
| scrollCam(dt); | |
| if(!paused){ acc+=dt; let n=0; while(acc>=1/60&&n<5){update(1/60);acc-=1/60;n++;} if(n===5) acc=0; } | |
| hudT-=dt; if(hudT<=0){hudT=0.12;updateHUD();} | |
| panelT-=dt; if(panelT<=0){panelT=0.25;updateInfo();} | |
| render(); | |
| requestAnimationFrame(frame); | |
| } | |
| function startGame(seed){ | |
| G={seed,tiles:null,treeWood:null,variant:null,fog:new Uint8Array(MW*MH),blocked:new Uint8Array(MW*MH),explored:0,units:[],buildings:[],mines:[],effects:[],res:{gold:400,wood:200},time:0,nextId:1,selection:[],groups:{},mode:null,over:false,won:false,msgs:[],stats:{gathered:{gold:0,wood:0},kills:0,built:0,trained:0,lost:0}}; | |
| generateMap(seed); | |
| renderTerrain(); updateFog(); renderFogCanvas(); | |
| location.hash=String(seed); | |
| resize(); | |
| const hall=G.buildings[0]; const c=bCenter(hall); centerOn(c.x,c.y+40); | |
| paused=false; setMode(null); selectionChanged(); updateHUD(); | |
| msg('Map seed '+seed+'. Explore the whole map to win.'); | |
| } | |
| window.addEventListener('resize',resize); | |
| resize(); | |
| let seed=parseInt(location.hash.slice(1),10); if(!Number.isFinite(seed)) seed=(Math.random()*1e9)|0; | |
| startGame(seed); | |
| requestAnimationFrame(frame); | |
| window.__rts={get G(){return G;},cam,UNITS,BUILDINGS,startGame,commandMove,commandGather,placeBuilding,trainUnit,updateFog,selectedUnits,setSelection:s=>{G.selection=s;selectionChanged();},dbg:{ensureReachable,rebuildBlocked,reachMap,generateMap,renderTerrain,completeBuilding,spawnUnitAt,addBuilding,showEnd,killUnit}}; | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment