Created
July 20, 2026 07:12
-
-
Save senko/125a7abfcee55be6ef193a22cfd0036b to your computer and use it in GitHub Desktop.
RTS game by Qwen 3.8 Max Preview
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>Tiny Realms — Mini RTS</title> | |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='80' font-size='80'>⚔️</text></svg>"> | |
| <style> | |
| :root{ | |
| --gold:#e8c66a; --gold-dim:#a8894a; --panel:#241c0e; --panel2:#33291a; | |
| --border:#6b532f; --border-hi:#9a7a44; --text:#efe4c8; --dim:#b3a482; | |
| --blue:#6fc3ff; --red:#ff6a5e; --green:#7ddf7d; | |
| } | |
| *{box-sizing:border-box; margin:0; padding:0;} | |
| html,body{height:100%; overflow:hidden; background:#0d0b07; color:var(--text); | |
| font-family:ui-sans-serif,system-ui,"Segoe UI",Roboto,sans-serif; user-select:none; -webkit-user-select:none;} | |
| #app{display:flex; flex-direction:column; height:100%;} | |
| /* ---------- top bar ---------- */ | |
| #topbar{height:44px; flex:0 0 44px; display:flex; align-items:center; gap:18px; padding:0 14px; | |
| background:linear-gradient(#322814,#1b1408); border-bottom:2px solid var(--border); | |
| box-shadow:0 2px 8px rgba(0,0,0,.6); z-index:5;} | |
| #topbar .logo{font-family:Georgia,'Times New Roman',serif; font-weight:700; letter-spacing:1px; | |
| color:var(--gold); font-size:17px; text-shadow:0 1px 0 #000, 0 0 12px rgba(232,198,106,.25); white-space:nowrap;} | |
| .res{display:flex; align-items:center; gap:7px; font-size:15px; font-variant-numeric:tabular-nums; min-width:70px;} | |
| .res b{font-weight:600; color:#fff;} | |
| .res.warn b{color:var(--red);} | |
| .ico{width:14px; height:14px; display:inline-block; flex:0 0 14px; box-shadow:0 1px 2px rgba(0,0,0,.6);} | |
| .ico.gold{border-radius:50%; background:radial-gradient(circle at 35% 30%, #ffefad, #e3b45c 55%, #8a6a2a);} | |
| .ico.wood{border-radius:3px; background:linear-gradient(160deg,#a9814d,#6a4a26 70%);} | |
| .ico.food{border-radius:50% 50% 45% 45%; background:radial-gradient(circle at 40% 30%, #ffd9a0, #d0763a 70%);} | |
| .ico.map{border-radius:3px; border:1px solid #7c93a8; background:linear-gradient(45deg,#2c3f50 50%,#3d576c 50%);} | |
| #mapBar{width:64px; height:7px; background:#14100a; border:1px solid #4a3a20; border-radius:4px; overflow:hidden;} | |
| #mapBar i{display:block; height:100%; width:0%; background:linear-gradient(90deg,#4f8f5a,#8fd67f); transition:width .3s;} | |
| .spacer{flex:1;} | |
| .tbtn{background:linear-gradient(#3d3122,#221a0c); | |
| border:1px solid var(--border); color:var(--text); font-size:13px; padding:5px 12px; border-radius:6px; | |
| cursor:pointer; box-shadow:inset 0 1px 0 rgba(255,255,255,.08);} | |
| .tbtn:hover{border-color:var(--border-hi); color:#fff;} | |
| .tbtn:active{transform:translateY(1px);} | |
| /* ---------- stage ---------- */ | |
| #stage{flex:1; position:relative; overflow:hidden; background:#000;} | |
| #cv{position:absolute; inset:0; display:block;} | |
| #toast{position:absolute; left:50%; bottom:26px; transform:translateX(-50%); background:rgba(15,11,5,.92); | |
| border:1px solid var(--border); color:var(--text); padding:8px 18px; border-radius:8px; font-size:14px; | |
| pointer-events:none; opacity:0; transition:opacity .35s; max-width:80%; text-align:center; | |
| box-shadow:0 4px 16px rgba(0,0,0,.6); z-index:6;} | |
| #toast.show{opacity:1;} | |
| #banner{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; | |
| background:rgba(5,4,2,.55); z-index:8;} | |
| #banner .card{background:linear-gradient(#322814,#1b1408); border:2px solid var(--gold-dim); border-radius:14px; | |
| padding:34px 54px; text-align:center; box-shadow:0 10px 50px rgba(0,0,0,.8), 0 0 60px rgba(232,198,106,.15);} | |
| #banner h1{font-family:Georgia,serif; color:var(--gold); font-size:34px; margin-bottom:10px; letter-spacing:1px;} | |
| #banner p{color:var(--dim); margin-bottom:22px; font-size:15px;} | |
| #banner button{font-size:15px; padding:9px 26px; border-radius:8px; border:1px solid var(--border-hi); | |
| background:linear-gradient(#4a3a1e,#2a2010); color:#fff; cursor:pointer;} | |
| #banner button:hover{border-color:var(--gold); color:var(--gold);} | |
| .hidden{display:none !important;} | |
| #help{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(5,4,2,.6); z-index:9;} | |
| #help .card{background:linear-gradient(#2c2312,#17110a); border:2px solid var(--border); border-radius:14px; | |
| padding:26px 34px; max-width:640px; width:92%; box-shadow:0 10px 50px rgba(0,0,0,.8);} | |
| #help h2{font-family:Georgia,serif; color:var(--gold); margin-bottom:14px; font-size:22px;} | |
| #help table{width:100%; border-collapse:collapse; font-size:13.5px; margin-bottom:14px;} | |
| #help td{padding:4px 8px; border-bottom:1px solid rgba(107,83,47,.35); vertical-align:top;} | |
| #help td:first-child{color:var(--gold); white-space:nowrap; width:150px; font-weight:600;} | |
| #help .obj{color:var(--dim); font-size:13.5px; line-height:1.55;} | |
| #help .obj b{color:var(--text);} | |
| #help .close{margin-top:16px; text-align:right;} | |
| /* ---------- bottom panel ---------- */ | |
| #bottom{height:182px; flex:0 0 182px; display:flex; gap:10px; padding:10px; | |
| background:linear-gradient(#241c0e,#151007); border-top:2px solid var(--border); z-index:5;} | |
| #miniWrap{position:relative; width:162px; height:162px; flex:0 0 162px; border:2px solid var(--border); | |
| border-radius:6px; overflow:hidden; background:#0a1420; box-shadow:inset 0 0 12px rgba(0,0,0,.7);} | |
| #mini{width:162px; height:162px; display:block;} | |
| #info{flex:1; min-width:0; border:2px solid var(--border); border-radius:6px; background:rgba(10,8,4,.55); | |
| padding:10px 14px; overflow:hidden; display:flex; flex-direction:column; gap:6px;} | |
| #info .name{font-family:Georgia,serif; font-size:16px; color:var(--gold); letter-spacing:.5px;} | |
| #info .sub{font-size:12.5px; color:var(--dim);} | |
| .bar{height:9px; background:#14100a; border:1px solid #4a3a20; border-radius:5px; overflow:hidden; max-width:340px;} | |
| .bar i{display:block; height:100%; background:linear-gradient(90deg,#3f9f4a,#7ddf7d);} | |
| .bar.mid i{background:linear-gradient(90deg,#b98a2a,#e8c66a);} | |
| .bar.low i{background:linear-gradient(90deg,#a33227,#ff6a5e);} | |
| .bar.build i{background:linear-gradient(90deg,#2a6ab9,#6fc3ff);} | |
| #info .row{display:flex; align-items:center; gap:10px; font-size:13px; flex-wrap:wrap;} | |
| #info .cnt{display:inline-flex; align-items:center; gap:4px; background:rgba(255,255,255,.05); | |
| border:1px solid rgba(107,83,47,.5); border-radius:5px; padding:2px 7px;} | |
| #info .cnt canvas{width:20px; height:20px;} | |
| #info .q{display:inline-flex; gap:5px; align-items:center;} | |
| #info .q canvas{width:26px; height:26px; border:1px solid var(--border); border-radius:4px; background:rgba(0,0,0,.35);} | |
| #info .q .prog{position:relative; width:26px; height:26px;} | |
| #info .q .prog::after{content:''; position:absolute; left:0; right:0; bottom:0; background:rgba(111,195,255,.35); | |
| height:var(--p,0%);} | |
| #card{width:212px; flex:0 0 212px; display:grid; grid-template-columns:repeat(3,1fr); gap:6px; align-content:start;} | |
| .btn{position:relative; height:52px; border:1px solid var(--border); border-radius:7px; cursor:pointer; | |
| background:linear-gradient(#3a2f1c,#221a0c); box-shadow:inset 0 1px 0 rgba(255,255,255,.07), 0 2px 4px rgba(0,0,0,.5); | |
| display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px; padding:2px;} | |
| .btn:hover{border-color:var(--gold); background:linear-gradient(#4a3c22,#2a2010);} | |
| .btn:active{transform:translateY(1px);} | |
| .btn canvas{width:30px; height:30px; pointer-events:none;} | |
| .btn .lbl{font-size:9.5px; color:var(--dim); line-height:1; pointer-events:none; white-space:nowrap;} | |
| .btn .cost{font-size:9.5px; line-height:1; pointer-events:none; font-variant-numeric:tabular-nums;} | |
| .btn .cost .g{color:#ffd75e;} .btn .cost .w{color:#a5d67e;} | |
| .btn .cost .no{color:var(--red);} | |
| .btn .key{position:absolute; top:2px; left:4px; font-size:9px; color:#8a7a55; font-weight:700; pointer-events:none;} | |
| .btn.dis{opacity:.45; filter:grayscale(.6);} | |
| .btn.dis:hover{border-color:var(--border); background:linear-gradient(#3a2f1c,#221a0c);} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <div id="topbar"> | |
| <div class="logo">⚔ TINY REALMS</div> | |
| <div class="res" title="Gold — mined from gold mines by workers"><span class="ico gold"></span><b id="vGold">0</b></div> | |
| <div class="res" title="Lumber — chopped from trees by workers"><span class="ico wood"></span><b id="vWood">0</b></div> | |
| <div class="res" id="resFood" title="Food — build Farms to raise the limit"><span class="ico food"></span><b id="vFood">0/0</b></div> | |
| <div class="res" title="Map explored — uncover 100% to win!"><span class="ico map"></span><b id="vMap">0%</b><div id="mapBar"><i></i></div></div> | |
| <div class="spacer"></div> | |
| <button class="tbtn" id="btnSpeed" title="Game speed (P)">▶ 1×</button> | |
| <button class="tbtn" id="btnHelp" title="Help (F1)">?</button> | |
| </div> | |
| <div id="stage"> | |
| <canvas id="cv"></canvas> | |
| <div id="toast"></div> | |
| <div id="banner" class="hidden"> | |
| <div class="card"> | |
| <h1>🏆 The Realm is Revealed!</h1> | |
| <p>Every corner of the map has been uncovered. Victory is yours —<br>the sandbox remains open: build, gather and conquer at leisure.</p> | |
| <button id="btnContinue">Keep Playing</button> | |
| </div> | |
| </div> | |
| <div id="help" class="hidden"> | |
| <div class="card"> | |
| <h2>How to Play</h2> | |
| <table> | |
| <tr><td>Left drag</td><td>box-select units</td></tr> | |
| <tr><td>Left click</td><td>select unit / building · <b>Shift</b> adds · <b>double-click</b> selects all of that type</td></tr> | |
| <tr><td>Right click</td><td>move · gather resource · attack beast · build / repair site · set rally point (on a training building)</td></tr> | |
| <tr><td>Arrow keys / screen edges</td><td>scroll the map · <b>Space</b> centers on selection</td></tr> | |
| <tr><td>B</td><td>build menu (workers selected)</td></tr> | |
| <tr><td>A + click / S / H</td><td>attack-move / stop / hold position</td></tr> | |
| <tr><td>P</td><td>game speed: pause → 1× → 2× → 4×</td></tr> | |
| <tr><td>Esc</td><td>cancel placement / deselect</td></tr> | |
| </table> | |
| <div class="obj"> | |
| <b>Objective:</b> mine <b>gold</b>, chop <b>lumber</b>, expand your base and raise an army. | |
| Uncover <b>100% of the map</b> to win. Beware the beasts that roam the realm — they attack | |
| anyone who wanders close, but drop gold bounties when slain. Guard Towers shoot them on sight. | |
| </div> | |
| <div class="close"><button class="tbtn" id="btnHelpClose">Close (Esc)</button></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="bottom"> | |
| <div id="miniWrap"><canvas id="mini" width="162" height="162"></canvas></div> | |
| <div id="info"><div id="infoInner"></div></div> | |
| <div id="card"></div> | |
| </div> | |
| </div> | |
| <script> | |
| "use strict"; | |
| /* ================================================================ | |
| Tiny Realms — a miniature real-time strategy game in one file. | |
| ================================================================ */ | |
| /* ---------------- polyfill ---------------- */ | |
| if(!CanvasRenderingContext2D.prototype.roundRect){ | |
| CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){ | |
| r=Math.min(r,w/2,h/2); | |
| this.moveTo(x+r,y); this.arcTo(x+w,y,x+w,y+h,r); this.arcTo(x+w,y+h,x,y+h,r); | |
| this.arcTo(x,y+h,x,y,r); this.arcTo(x,y,x+w,y,r); this.closePath(); return this; | |
| }; | |
| } | |
| /* ---------------- utils ---------------- */ | |
| const T=32, MAP_W=64, MAP_H=64, WORLD_W=MAP_W*T, WORLD_H=MAP_H*T, SQ2=Math.SQRT2; | |
| 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 rand=(a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a); | |
| const randi=(a,b)=>Math.floor(rand(a,b)); | |
| const pick=a=>a[randi(0,a.length)]; | |
| function hash2(x,y){let h=(x*374761393+y*668265263)|0; h=(h^(h>>13))*1274126177|0; h^=h>>16; return (h>>>0)/4294967295;} | |
| const tileX=px=>Math.floor(px/T), tileY=py=>Math.floor(py/T); | |
| const tcx=tx=>(tx+0.5)*T, tcy=ty=>(ty+0.5)*T; | |
| /* ---------------- defs ---------------- */ | |
| const UNITS={ | |
| worker: {name:'Worker', gold:75, wood:0, food:1, time:12, hp:60, speed:66, radius:8, sight:6, dmg:3, range:18, cd:1.2, canGather:true, canBuild:true}, | |
| footman:{name:'Footman', gold:60, wood:0, food:2, time:14, hp:130, speed:76, radius:9, sight:7, dmg:12, range:20, cd:1.0, military:true}, | |
| archer: {name:'Archer', gold:80, wood:20, food:2, time:16, hp:85, speed:80, radius:8, sight:8, dmg:13, range:150, cd:1.3, military:true, ranged:true, projSpeed:340}, | |
| knight: {name:'Knight', gold:160, wood:60, food:3, time:22, hp:280, speed:96, radius:11, sight:7, dmg:24, range:26, cd:1.1, military:true}, | |
| }; | |
| const BUILDS={ | |
| townhall:{name:'Town Hall', gold:400, wood:150, time:45, hp:1500, w:3, h:3, sight:9, food:10, trains:['worker'], dropoff:true}, | |
| farm: {name:'Farm', gold:80, wood:20, time:14, hp:400, w:2, h:2, sight:5, food:6}, | |
| barracks:{name:'Barracks', gold:180, wood:40, time:26, hp:900, w:3, h:2, sight:6, trains:['footman','archer','knight']}, | |
| tower: {name:'Guard Tower', gold:110, wood:70, time:22, hp:550, w:2, h:2, sight:9, dmg:11, range:220, cd:0.9}, | |
| }; | |
| const CREEPDEFS={ | |
| wolf:{name:'Dire Wolf', hp:70, speed:90, radius:9, sight:5, dmg:8, range:18, cd:1.0, bounty:12}, | |
| ogre:{name:'Ogre', hp:200, speed:56, radius:12, sight:5.5, dmg:18, range:24, cd:1.4, bounty:30}, | |
| }; | |
| const CARRY=20, TREE_AMOUNT=120, MINE_AMOUNT=25000, MINE_WORK_T=1.6, WOOD_WORK_T=2.0, FOOD_MAX=80; | |
| /* ---------------- state ---------------- */ | |
| const S={ | |
| gold:500, wood:250, foodUsed:0, foodCap:0, | |
| time:0, speed:1, paused:false, | |
| explored:0, totalTiles:MAP_W*MAP_H, won:false, | |
| sel:[], placing:null, buildMenu:false, | |
| cam:{x:0,y:0}, attackMoveMode:false, | |
| }; | |
| let units=[], creeps=[], buildings=[], trees=[], rocks=[], mines=[]; | |
| let projectiles=[], particles=[], floaters=[], corpses=[]; | |
| let nextId=1; | |
| const GRASS=0, WATER=1; | |
| const terrain=new Uint8Array(MAP_W*MAP_H); | |
| const blocked=new Uint8Array(MAP_W*MAP_H); | |
| const fog=new Uint8Array(MAP_W*MAP_H); // 0 unexplored, 1 explored, 2 visible | |
| let visList=[]; | |
| const idx=(tx,ty)=>ty*MAP_W+tx; | |
| function isBlocked(tx,ty){ if(tx<0||ty<0||tx>=MAP_W||ty>=MAP_H)return true; const i=idx(tx,ty); return blocked[i]===1||terrain[i]===WATER; } | |
| function setBlock(tx,ty,v){ if(tx>=0&&ty>=0&&tx<MAP_W&&ty<MAP_H) blocked[idx(tx,ty)]=v?1:0; } | |
| function setBlockRect(tx,ty,w,h,v){ for(let y=0;y<h;y++)for(let x=0;x<w;x++) setBlock(tx+x,ty+y,v); } | |
| /* ---------------- map generation ---------------- */ | |
| let startTX=10, startTY=MAP_H-11; | |
| function genWorld(){ | |
| terrain.fill(GRASS); | |
| // water blobs | |
| const blobs=randi(3,6); | |
| for(let i=0;i<blobs;i++){ | |
| let x=randi(5,MAP_W-5), y=randi(5,MAP_H-5); | |
| const n=randi(35,100); | |
| for(let j=0;j<n;j++){ | |
| const r=randi(1,3); | |
| for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){ | |
| if(dx*dx+dy*dy>r*r+1)continue; | |
| const tx=x+dx, ty=y+dy; | |
| if(tx>1&&ty>1&&tx<MAP_W-2&&ty<MAP_H-2) terrain[idx(tx,ty)]=WATER; | |
| } | |
| if(Math.random()<0.6) x=clamp(x+randi(-1,2),2,MAP_W-3); else y=clamp(y+randi(-1,2),2,MAP_H-3); | |
| } | |
| } | |
| // clear start area | |
| for(let dy=-6;dy<=6;dy++)for(let dx=-6;dx<=6;dx++){ | |
| const tx=startTX+dx, ty=startTY+dy; | |
| if(tx>=0&&ty>=0&&tx<MAP_W&&ty<MAP_H&&dx*dx+dy*dy<=38) terrain[idx(tx,ty)]=GRASS; | |
| } | |
| // town hall | |
| const th=makeBuilding('townhall',startTX-1,startTY-1); | |
| th.built=true; th.progress=th.def.time; th.hp=th.maxHp; | |
| buildings.push(th); setBlockRect(th.tileX,th.tileY,th.w,th.h,1); | |
| th.rally={x:th.x,y:(th.tileY+th.h)*T+18}; | |
| // mines | |
| const mineSpots=[]; | |
| const addMine=(tx,ty)=>{ | |
| for(let dy=-1;dy<=2;dy++)for(let dx=-1;dx<=2;dx++){ | |
| const x=tx+dx,y=ty+dy; | |
| if(x>=0&&y>=0&&x<MAP_W&&y<MAP_H) terrain[idx(x,y)]=GRASS; | |
| } | |
| const m=makeMine(tx,ty); mines.push(m); setBlockRect(tx,ty,2,2,1); mineSpots.push(m); | |
| }; | |
| { // guaranteed nearby mine | |
| const a=rand(0,Math.PI*2), d=rand(6,8); | |
| let tx=Math.round(startTX+Math.cos(a)*d), ty=Math.round(startTY+Math.sin(a)*d); | |
| tx=clamp(tx,2,MAP_W-4); ty=clamp(ty,2,MAP_H-4); | |
| addMine(tx,ty); | |
| } | |
| for(let i=0;i<4;i++){ | |
| for(let tries=0;tries<60;tries++){ | |
| const tx=randi(3,MAP_W-5), ty=randi(3,MAP_H-5); | |
| if(terrain[idx(tx,ty)]!==GRASS)continue; | |
| if(dist(tx,ty,startTX,startTY)<13)continue; | |
| if(mineSpots.some(m=>dist(tx,ty,m.tx,m.ty)<15))continue; | |
| addMine(tx,ty); break; | |
| } | |
| } | |
| // forests | |
| const addTree=(tx,ty)=>{ | |
| if(tx<1||ty<1||tx>=MAP_W-1||ty>=MAP_H-1)return false; | |
| if(terrain[idx(tx,ty)]!==GRASS||blocked[idx(tx,ty)])return false; | |
| if(dist(tx,ty,startTX,startTY)<5.5)return false; | |
| if(mines.some(m=>tx>=m.tx-2&&tx<=m.tx+3&&ty>=m.ty-2&&ty<=m.ty+3))return false; | |
| const t=makeTree(tx,ty); trees.push(t); setBlock(tx,ty,1); return true; | |
| }; | |
| { // guaranteed trees near start | |
| const a=rand(0,Math.PI*2), cx=Math.round(startTX+Math.cos(a)*6.5), cy=Math.round(startTY+Math.sin(a)*6.5); | |
| for(let j=0;j<14;j++) addTree(cx+randi(-2,3),cy+randi(-2,3)); | |
| } | |
| const forests=randi(10,14); | |
| for(let i=0;i<forests;i++){ | |
| let x=randi(3,MAP_W-3), y=randi(3,MAP_H-3); | |
| if(dist(x,y,startTX,startTY)<8)continue; | |
| const n=randi(8,26); | |
| for(let j=0;j<n;j++){ | |
| addTree(x+randi(-1,2),y+randi(-1,2)); | |
| if(Math.random()<0.5)x=clamp(x+randi(-1,2),2,MAP_W-3); else y=clamp(y+randi(-1,2),2,MAP_H-3); | |
| } | |
| } | |
| for(let i=0;i<26;i++) addTree(randi(2,MAP_W-2),randi(2,MAP_H-2)); | |
| // rocks | |
| const addRock=(tx,ty)=>{ | |
| if(tx<1||ty<1||tx>=MAP_W-1||ty>=MAP_H-1)return; | |
| if(terrain[idx(tx,ty)]!==GRASS||blocked[idx(tx,ty)])return; | |
| if(dist(tx,ty,startTX,startTY)<6)return; | |
| if(mines.some(m=>tx>=m.tx-2&&tx<=m.tx+3&&ty>=m.ty-2&&ty<=m.ty+3))return; | |
| const r=makeRock(tx,ty); rocks.push(r); setBlock(tx,ty,1); | |
| }; | |
| for(let i=0;i<5;i++){ | |
| const x=randi(3,MAP_W-3), y=randi(3,MAP_H-3); | |
| const n=randi(2,6); | |
| for(let j=0;j<n;j++) addRock(x+randi(-2,3),y+randi(-2,3)); | |
| } | |
| for(let i=0;i<16;i++) addRock(randi(2,MAP_W-2),randi(2,MAP_H-2)); | |
| // creep camps | |
| const camps=[]; | |
| for(let i=0;i<7;i++){ | |
| for(let tries=0;tries<80;tries++){ | |
| const tx=randi(4,MAP_W-4), ty=randi(4,MAP_H-4); | |
| if(terrain[idx(tx,ty)]!==GRASS)continue; | |
| if(dist(tx,ty,startTX,startTY)<15)continue; | |
| if(camps.some(c=>dist(tx,ty,c.x,c.y)<11))continue; | |
| camps.push({x:tx,y:ty}); | |
| terrain[idx(tx,ty)]=GRASS; | |
| const nw=randi(2,4), ogre=Math.random()<(i<2?0.9:0.4); | |
| for(let k=0;k<nw;k++) spawnCreep('wolf',tcx(tx+randi(-2,3)),tcy(ty+randi(-2,3))); | |
| if(ogre) spawnCreep('ogre',tcx(tx+randi(-1,2)),tcy(ty+randi(-1,2))); | |
| break; | |
| } | |
| } | |
| // mine spots (adjacent free tiles) | |
| for(const m of mines) computeMineSpots(m); | |
| // starting workers | |
| for(let i=0;i<5;i++){ | |
| const a=i/5*Math.PI*2, r=44; | |
| const u=makeUnit('worker',tcx(startTX)+Math.cos(a)*r,tcy(startTY)+Math.sin(a)*r+20); | |
| units.push(u); | |
| } | |
| recomputeFood(); | |
| S.foodUsed=units.reduce((s,u)=>s+u.def.food,0); | |
| // camera + selection | |
| centerOn(tcx(startTX),tcy(startTY)); | |
| setSelection(units.slice()); | |
| } | |
| /* ---------------- entity factories ---------------- */ | |
| function makeUnit(type,x,y){ | |
| const d=UNITS[type]; | |
| return {id:nextId++, kind:'unit', type, def:d, team:'player', x, y, px:x, py:y, | |
| hp:d.hp, maxHp:d.hp, face:1, order:{type:'idle'}, target:null, path:null, | |
| cd:0, scanT:rand(0,0.3), repathT:0, carry:null, carryAmt:0, | |
| walkPhase:rand(0,6), moving:false, attackAnim:0, swing:0, hitFlash:0, dead:false}; | |
| } | |
| function spawnCreep(type,x,y){ | |
| const d=CREEPDEFS[type]; | |
| creeps.push({id:nextId++, kind:'creep', type, def:d, team:'neutral', x, y, px:x, py:y, | |
| hp:d.hp, maxHp:d.hp, face:Math.random()<0.5?-1:1, order:{type:'idle'}, target:null, path:null, | |
| cd:0, scanT:rand(0,0.3), repathT:0, walkPhase:rand(0,6), moving:false, attackAnim:0, hitFlash:0, | |
| home:{x,y}, wanderT:rand(1,5), dead:false}); | |
| } | |
| function makeBuilding(type,tx,ty){ | |
| const d=BUILDS[type]; | |
| return {id:nextId++, kind:'building', type, def:d, team:'player', tileX:tx, tileY:ty, w:d.w, h:d.h, | |
| x:(tx+d.w/2)*T, y:(ty+d.h/2)*T, hp:Math.max(1,Math.floor(d.hp*0.05)), maxHp:d.hp, | |
| progress:0, built:false, queue:[], trainT:0, cd:0, rally:null, target:null, flash:0, hitFlash:0, dead:false}; | |
| } | |
| function makeTree(tx,ty){ return {id:nextId++, kind:'tree', tx, ty, x:tcx(tx), y:tcy(ty), amount:TREE_AMOUNT, v:hash2(tx,ty), dead:false}; } | |
| function makeRock(tx,ty){ return {id:nextId++, kind:'rock', tx, ty, x:tcx(tx), y:tcy(ty), v:hash2(tx*3+7,ty*5+1), dead:false}; } | |
| function makeMine(tx,ty){ return {id:nextId++, kind:'mine', tx, ty, x:(tx+1)*T, y:(ty+1)*T, amount:MINE_AMOUNT, v:hash2(tx*11+3,ty*7+5), spots:[], dead:false}; } | |
| function computeMineSpots(m){ | |
| m.spots=[]; | |
| for(let ty=m.ty-1;ty<=m.ty+2;ty++)for(let tx=m.tx-1;tx<=m.tx+2;tx++){ | |
| if(tx>=m.tx&&tx<=m.tx+1&&ty>=m.ty&&ty<=m.ty+1)continue; | |
| if(!isBlocked(tx,ty)) m.spots.push({x:tcx(tx),y:tcy(ty)}); | |
| } | |
| } | |
| /* ---------------- pathfinding ---------------- */ | |
| class Heap{ | |
| constructor(){this.a=[];} | |
| get size(){return this.a.length;} | |
| push(v,p){const a=this.a;let i=a.length;a.push({v,p});while(i>0){const j=(i-1)>>1;if(a[j].p<=p)break;const t=a[i];a[i]=a[j];a[j]=t;i=j;}} | |
| pop(){const a=this.a;const top=a[0];const last=a.pop();if(a.length){a[0]=last;let i=0;const n=a.length;for(;;){let l=2*i+1,r=l+1,j=i;if(l<n&&a[l].p<a[j].p)j=l;if(r<n&&a[r].p<a[j].p)j=r;if(j===i)break;const t=a[i];a[i]=a[j];a[j]=t;i=j;}}return top?top.v:undefined;} | |
| } | |
| function nearestFree(tx,ty,maxR){ | |
| if(!isBlocked(tx,ty))return {x:tx,y:ty}; | |
| for(let r=1;r<=maxR;r++){ | |
| let best=null,bd=1e9; | |
| for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){ | |
| if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue; | |
| const x=tx+dx,y=ty+dy; | |
| if(x<0||y<0||x>=MAP_W||y>=MAP_H)continue; | |
| if(!isBlocked(x,y)){ const d=dx*dx+dy*dy; if(d<bd){bd=d;best={x,y};} } | |
| } | |
| if(best)return best; | |
| } | |
| return null; | |
| } | |
| function lineClearPx(x0,y0,x1,y1){ | |
| let tx0=tileX(x0),ty0=tileY(y0); const tx1=tileX(x1),ty1=tileY(y1); | |
| const dx=Math.abs(tx1-tx0), dy=Math.abs(ty1-ty0); | |
| const sx=tx0<tx1?1:-1, sy=ty0<ty1?1:-1; | |
| let err=dx-dy; | |
| for(let i=0;i<200;i++){ | |
| if(isBlocked(tx0,ty0))return false; | |
| if(tx0===tx1&&ty0===ty1)return true; | |
| const e2=2*err; | |
| if(e2>-dy){err-=dy;tx0+=sx;} | |
| if(e2<dx){err+=dx;ty0+=sy;} | |
| } | |
| return false; | |
| } | |
| const octile=(x,y,gx,gy)=>{const dx=Math.abs(x-gx),dy=Math.abs(y-gy);return (dx+dy)+(SQ2-2)*Math.min(dx,dy);}; | |
| function findPath(sx,sy,gx,gy){ | |
| sx=clamp(sx,0,MAP_W-1); sy=clamp(sy,0,MAP_H-1); | |
| gx=clamp(gx,0,MAP_W-1); gy=clamp(gy,0,MAP_H-1); | |
| if(isBlocked(gx,gy)){ const f=nearestFree(gx,gy,12); if(!f)return null; gx=f.x; gy=f.y; } | |
| if(sx===gx&&sy===gy)return []; | |
| const N=MAP_W*MAP_H; | |
| const g=new Float32Array(N).fill(1e15); | |
| const from=new Int32Array(N).fill(-1); | |
| const closed=new Uint8Array(N); | |
| const si=idx(sx,sy); g[si]=0; | |
| const heap=new Heap(); heap.push(si,octile(sx,sy,gx,gy)); | |
| let guard=0; | |
| while(heap.size&&guard++<8000){ | |
| const cur=heap.pop(); | |
| if(closed[cur])continue; closed[cur]=1; | |
| const cx=cur%MAP_W, cy=(cur/MAP_W)|0; | |
| if(cx===gx&&cy===gy){ | |
| const path=[]; let c=cur; | |
| while(c!==si){ path.push(c); c=from[c]; } | |
| path.reverse(); | |
| const pts=path.map(i=>({x:tcx(i%MAP_W),y:tcy((i/MAP_W)|0)})); | |
| return smoothPath({x:tcx(sx),y:tcy(sy)},pts); | |
| } | |
| for(const [dx,dy,cost] of [[1,0,1],[-1,0,1],[0,1,1],[0,-1,1],[1,1,SQ2],[1,-1,SQ2],[-1,1,SQ2],[-1,-1,SQ2]]){ | |
| const nx=cx+dx, ny=cy+dy; | |
| if(nx<0||ny<0||nx>=MAP_W||ny>=MAP_H)continue; | |
| if(isBlocked(nx,ny))continue; | |
| if(dx&&dy&&(isBlocked(cx+dx,cy)||isBlocked(cx,cy+dy)))continue; | |
| const ni=idx(nx,ny); | |
| if(closed[ni])continue; | |
| const ng=g[cur]+cost; | |
| if(ng<g[ni]){ g[ni]=ng; from[ni]=cur; heap.push(ni,ng+octile(nx,ny,gx,gy)); } | |
| } | |
| } | |
| return null; | |
| } | |
| function smoothPath(start,pts){ | |
| const all=[start,...pts]; | |
| if(all.length<3)return pts; | |
| const out=[]; | |
| let i=0; | |
| while(i<all.length-1){ | |
| let j=all.length-1; | |
| while(j>i+1&&!lineClearPx(all[i].x,all[i].y,all[j].x,all[j].y))j--; | |
| if(j>0)out.push(all[j]); | |
| i=j; | |
| } | |
| return out; | |
| } | |
| /* ---------------- economy helpers ---------------- */ | |
| function canAfford(c){ return S.gold>=(c.gold||0)&&S.wood>=(c.wood||0); } | |
| function pay(c){ S.gold-=(c.gold||0); S.wood-=(c.wood||0); } | |
| function refund(c,f=1){ S.gold+=Math.floor((c.gold||0)*f); S.wood+=Math.floor((c.wood||0)*f); } | |
| function recomputeFood(){ | |
| let cap=0; | |
| for(const b of buildings) if(!b.dead&&b.built&&b.def.food) cap+=b.def.food; | |
| S.foodCap=Math.min(FOOD_MAX,cap); | |
| } | |
| function nearestDropoff(x,y){ | |
| let best=null,bd=1e9; | |
| for(const b of buildings){ | |
| if(b.dead||!b.built||!b.def.dropoff)continue; | |
| const d=dist(x,y,b.x,b.y); | |
| if(d<bd){bd=d;best=b;} | |
| } | |
| return best; | |
| } | |
| function nearestTree(x,y,maxTiles){ | |
| let best=null,bd=(maxTiles*T)*(maxTiles*T); | |
| for(const t of trees){ | |
| if(t.dead||t.amount<=0)continue; | |
| const d=(t.x-x)*(t.x-x)+(t.y-y)*(t.y-y); | |
| if(d<bd){bd=d;best=t;} | |
| } | |
| return best; | |
| } | |
| function nearestMine(x,y,maxTiles){ | |
| let best=null,bd=(maxTiles*T)*(maxTiles*T); | |
| for(const m of mines){ | |
| if(m.dead||m.amount<=0)continue; | |
| const d=(m.x-x)*(m.x-x)+(m.y-y)*(m.y-y); | |
| if(d<bd){bd=d;best=m;} | |
| } | |
| return best; | |
| } | |
| /* ---------------- orders ---------------- */ | |
| function setOrderMove(u,x,y,type='move'){ | |
| u.order={type,x,y}; u.target=null; | |
| u.path=findPath(tileX(u.x),tileY(u.y),tileX(x),tileY(y)); | |
| if(!u.path){ float(u.x,u.y-22,'No path','#ff9a8a'); u.order={type:'idle'}; } | |
| } | |
| function orderGather(u,res){ u.order={type:'gather',res,phase:'toRes',t:0}; u.path=null; u.target=null; } | |
| function orderAttack(u,t){ u.order={type:'attack',target:t,resume:null,hold:null}; u.target=t; u.path=null; } | |
| function startReturn(u,prevRes){ | |
| const th=nearestDropoff(u.x,u.y); | |
| if(!th){ u.order={type:'idle'}; return; } | |
| u.order={type:'return',drop:th,phase:'toDrop',t:0,prevRes:prevRes||null}; | |
| u.path=null; | |
| } | |
| function perimeterPoint(e,ux,uy){ | |
| // nearest free tile center adjacent to entity footprint | |
| let tx,ty,w,h; | |
| if(e.kind==='building'){ tx=e.tileX; ty=e.tileY; w=e.w; h=e.h; } | |
| else if(e.kind==='mine'){ tx=e.tx; ty=e.ty; w=2; h=2; } | |
| else { tx=e.tx; ty=e.ty; w=1; h=1; } | |
| let best=null,bd=1e9; | |
| for(let y=ty-1;y<=ty+h;y++)for(let x=tx-1;x<=tx+w;x++){ | |
| if(x>=tx&&x<tx+w&&y>=ty&&y<ty+h)continue; | |
| if(isBlocked(x,y))continue; | |
| const px=tcx(x),py=tcy(y); | |
| const d=(px-ux)*(px-ux)+(py-uy)*(py-uy); | |
| if(d<bd){bd=d;best={x:px,y:py};} | |
| } | |
| return best; | |
| } | |
| function formationOffset(i,n){ | |
| if(n<=1)return {x:0,y:0}; | |
| const a=i*2.4, r=16*Math.sqrt(i); | |
| return {x:Math.cos(a)*r,y:Math.sin(a)*r}; | |
| } | |
| /* ---------------- movement ---------------- */ | |
| function tileBlockedAtPx(px,py){ return isBlocked(tileX(px),tileY(py)); } | |
| function moveAlong(u,dt){ | |
| if(!u.path||!u.path.length){ u.moving=false; return true; } | |
| // skip waypoints reachable in a straight line | |
| while(u.path.length>1&&lineClearPx(u.x,u.y,u.path[1].x,u.path[1].y)) u.path.shift(); | |
| const wp=u.path[0]; | |
| const dx=wp.x-u.x, dy=wp.y-u.y; | |
| const d=Math.hypot(dx,dy); | |
| const step=u.def.speed*dt; | |
| u.moving=true; | |
| if(d<=Math.max(4,step)){ | |
| u.x=wp.x; u.y=wp.y; u.path.shift(); | |
| if(!u.path.length)u.moving=false; | |
| }else{ | |
| const nx=u.x+dx/d*step, ny=u.y+dy/d*step; | |
| if(!tileBlockedAtPx(nx,ny)){ u.x=nx; u.y=ny; } | |
| else if(!tileBlockedAtPx(nx,u.y)){ u.x=nx; } | |
| else if(!tileBlockedAtPx(u.x,ny)){ u.y=ny; } | |
| else { u.path=null; u.moving=false; return true; } | |
| if(dx>0.5)u.face=1; else if(dx<-0.5)u.face=-1; | |
| } | |
| u.x=clamp(u.x,6,WORLD_W-6); u.y=clamp(u.y,6,WORLD_H-6); | |
| u.walkPhase+=dt*(u.def.speed/9); | |
| return !u.path||!u.path.length; | |
| } | |
| function separate(){ | |
| const mobs=units.concat(creeps); | |
| for(let i=0;i<mobs.length;i++){ | |
| const a=mobs[i]; | |
| if(a.dead)continue; | |
| for(let j=i+1;j<mobs.length;j++){ | |
| const b=mobs[j]; | |
| if(b.dead)continue; | |
| const dx=b.x-a.x, dy=b.y-a.y; | |
| const rr=a.def.radius+b.def.radius; | |
| const d2=dx*dx+dy*dy; | |
| if(d2>0.01&&d2<rr*rr){ | |
| const d=Math.sqrt(d2), push=(rr-d)/2*0.4; | |
| const ux=dx/d, uy=dy/d; | |
| a.x-=ux*push; a.y-=uy*push; b.x+=ux*push; b.y+=uy*push; | |
| } | |
| } | |
| // push out of blocked tiles (e.g. building completed under unit) | |
| if(tileBlockedAtPx(a.x,a.y)){ | |
| const f=nearestFree(tileX(a.x),tileY(a.y),6); | |
| if(f){ a.x=tcx(f.x); a.y=tcy(f.y); } | |
| } | |
| } | |
| } | |
| /* ---------------- combat ---------------- */ | |
| function faceTo(u,t){ u.face=(t.x>=u.x)?1:-1; } | |
| function tryAttack(u,t){ | |
| if(u.cd>0)return; | |
| u.cd=u.def.cd; u.attackAnim=0.28; faceTo(u,t); | |
| if(u.def.ranged){ | |
| projectiles.push({x:u.x+u.face*5,y:u.y-14,target:t,speed:u.def.projSpeed||340,dmg:u.def.dmg,color:'#ffe9a0'}); | |
| }else{ | |
| damage(t,u.def.dmg,u); | |
| burst(t.x,t.y-t.def.radius,'#ffd27d',4,60); | |
| } | |
| } | |
| function damage(t,dmg,from){ | |
| if(t.dead)return; | |
| t.hp-=dmg; t.hitFlash=0.13; | |
| if(t.hp<=0) kill(t,from); | |
| } | |
| function kill(t,from){ | |
| if(t.dead)return; | |
| t.dead=true; t.hp=0; | |
| corpses.push({x:t.x,y:t.y,r:t.def.radius+4,t:0,max:9,color:t.kind==='creep'?'rgba(122,60,60,':'rgba(90,50,50,'}); | |
| burst(t.x,t.y-8,'#c0564a',12,110); | |
| if(t.kind==='creep'){ | |
| S.gold+=t.def.bounty; | |
| float(t.x,t.y-18,'+'+t.def.bounty+' gold','#ffd75e'); | |
| } | |
| if(t.kind==='unit'){ S.foodUsed=Math.max(0,S.foodUsed-t.def.food); } | |
| if(t.kind==='building'){ | |
| setBlockRect(t.tileX,t.tileY,t.w,t.h,0); | |
| recomputeFood(); | |
| corpses.push({x:t.x,y:t.y,r:t.w*T*0.4,t:0,max:14,color:'rgba(110,105,95,'}); | |
| } | |
| if(S.sel.includes(t)){ S.sel=S.sel.filter(e=>e!==t); refreshCard(); refreshInfo(); } | |
| } | |
| function autoAcquire(u){ | |
| const R=u.def.sight*T; | |
| let best=null,bd=R*R; | |
| for(const c of creeps){ | |
| if(c.dead)continue; | |
| const dx=c.x-u.x, dy=c.y-u.y, d2=dx*dx+dy*dy; | |
| if(d2<bd){bd=d2;best=c;} | |
| } | |
| if(!best)return false; | |
| if(u.order.type==='hold'&&Math.sqrt(bd)>u.def.range+best.def.radius+6)return false; | |
| u.order={type:'attack',target:best, | |
| resume:u.order.type==='attackmove'?{type:'attackmove',x:u.order.x,y:u.order.y}:null, | |
| hold:u.order.type==='hold'?{x:u.x,y:u.y}:null}; | |
| u.target=best; u.path=null; | |
| return true; | |
| } | |
| /* ---------------- unit update ---------------- */ | |
| function updateUnit(u,dt){ | |
| u.cd-=dt; u.hitFlash-=dt; u.attackAnim-=dt; | |
| const o=u.order; | |
| switch(o.type){ | |
| case 'idle': | |
| u.moving=false; | |
| u.scanT-=dt; | |
| if(u.scanT<=0){ u.scanT=0.28; if(u.def.military)autoAcquire(u); } | |
| break; | |
| case 'hold': | |
| u.moving=false; | |
| u.scanT-=dt; | |
| if(u.scanT<=0){ u.scanT=0.28; autoAcquire(u); } | |
| break; | |
| case 'move': | |
| if(moveAlong(u,dt)) u.order={type:'idle'}; | |
| break; | |
| case 'attackmove': | |
| u.scanT-=dt; | |
| if(u.scanT<=0){ u.scanT=0.28; if(autoAcquire(u))break; } | |
| if(moveAlong(u,dt)) u.order={type:'idle'}; | |
| break; | |
| case 'attack':{ | |
| const t=u.target=o.target; | |
| if(!t||t.dead){ | |
| if(o.resume){ setOrderMove(u,o.resume.x,o.resume.y,'attackmove'); } | |
| else if(o.hold){ u.order={type:'hold'}; } | |
| else u.order={type:'idle'}; | |
| break; | |
| } | |
| const d=dist(u.x,u.y,t.x,t.y)-t.def.radius; | |
| if(d<=u.def.range+3){ | |
| u.path=null; u.moving=false; tryAttack(u,t); | |
| }else if(o.hold){ | |
| u.moving=false; | |
| }else{ | |
| if(!u.path||!u.path.length||u.repathT<=0){ | |
| u.path=findPath(tileX(u.x),tileY(u.y),tileX(t.x),tileY(t.y)); | |
| u.repathT=0.35; | |
| } | |
| u.repathT-=dt; | |
| moveAlong(u,dt); | |
| } | |
| break; | |
| } | |
| case 'gather': updateGather(u,dt); break; | |
| case 'return': updateReturn(u,dt); break; | |
| case 'build': updateBuild(u,dt); break; | |
| } | |
| if(u.moving)u.swing=0; | |
| } | |
| function updateGather(u,dt){ | |
| const o=u.order, res=o.res; | |
| if(!res||res.dead||res.amount<=0){ | |
| if(u.carryAmt>0){ startReturn(u,null); return; } | |
| const alt=res&&res.kind==='tree'?nearestTree(u.x,u.y,18):nearestMine(u.x,u.y,60); | |
| if(alt){ orderGather(u,alt); return; } | |
| u.order={type:'idle'}; | |
| float(u.x,u.y-22,'Depleted','#cfcfcf'); | |
| return; | |
| } | |
| if(o.phase==='toRes'){ | |
| const p=perimeterPoint(res,u.x,u.y); | |
| if(!p){ u.order={type:'idle'}; return; } | |
| if(!u.path||!u.path.length){ | |
| if(dist(u.x,u.y,p.x,p.y)<16){ o.phase='work'; o.t=res.kind==='mine'?MINE_WORK_T:WOOD_WORK_T; return; } | |
| u.path=findPath(tileX(u.x),tileY(u.y),tileX(p.x),tileY(p.y)); | |
| if(!u.path){ u.order={type:'idle'}; float(u.x,u.y-22,'No path','#ff9a8a'); return; } | |
| } | |
| if(moveAlong(u,dt)||dist(u.x,u.y,p.x,p.y)<14){ | |
| u.path=null; u.moving=false; o.phase='work'; o.t=res.kind==='mine'?MINE_WORK_T:WOOD_WORK_T; | |
| } | |
| }else if(o.phase==='work'){ | |
| u.moving=false; u.swing+=dt; | |
| faceTo(u,res); | |
| o.t-=dt; | |
| if(o.t<=0){ | |
| const amt=Math.min(CARRY,res.amount); | |
| res.amount-=amt; | |
| u.carryAmt=amt; u.carry=res.kind==='mine'?'gold':'wood'; | |
| burst(res.x,res.y-10,res.kind==='mine'?'#ffd75e':'#a5d67e',3,50); | |
| if(res.kind==='tree'&&res.amount<=0) removeTree(res); | |
| startReturn(u,res); | |
| } | |
| } | |
| } | |
| function updateReturn(u,dt){ | |
| const o=u.order; | |
| let b=o.drop; | |
| if(!b||b.dead){ | |
| b=nearestDropoff(u.x,u.y); | |
| if(!b){ u.order={type:'idle'}; return; } | |
| o.drop=b; u.path=null; | |
| } | |
| if(o.phase==='toDrop'){ | |
| const p=perimeterPoint(b,u.x,u.y); | |
| if(!p){ u.order={type:'idle'}; return; } | |
| if(!u.path||!u.path.length){ | |
| if(dist(u.x,u.y,p.x,p.y)<18){ o.phase='drop'; o.t=0.35; return; } | |
| u.path=findPath(tileX(u.x),tileY(u.y),tileX(p.x),tileY(p.y)); | |
| if(!u.path){ u.order={type:'idle'}; return; } | |
| } | |
| if(moveAlong(u,dt)||dist(u.x,u.y,p.x,p.y)<16){ u.path=null; u.moving=false; o.phase='drop'; o.t=0.35; } | |
| }else{ | |
| u.moving=false; | |
| o.t-=dt; | |
| if(o.t<=0){ | |
| if(u.carry==='gold')S.gold+=u.carryAmt; else S.wood+=u.carryAmt; | |
| float(b.x,b.y-b.h*T/2-14,'+'+u.carryAmt,u.carry==='gold'?'#ffd75e':'#a5d67e'); | |
| u.carryAmt=0; u.carry=null; | |
| const res=o.prevRes; | |
| if(res&&!res.dead&&res.amount>0) orderGather(u,res); | |
| else{ | |
| const alt=res&&res.kind==='tree'?nearestTree(u.x,u.y,18):nearestMine(u.x,u.y,60); | |
| if(alt)orderGather(u,alt); else u.order={type:'idle'}; | |
| } | |
| } | |
| } | |
| } | |
| function updateBuild(u,dt){ | |
| const b=u.order.b; | |
| if(!b||b.dead||b.built){ u.order={type:'idle'}; return; } | |
| const p=perimeterPoint(b,u.x,u.y); | |
| if(!p){ u.order={type:'idle'}; return; } | |
| if(dist(u.x,u.y,p.x,p.y)>30){ | |
| if(!u.path||!u.path.length||u.repathT<=0){ | |
| u.path=findPath(tileX(u.x),tileY(u.y),tileX(p.x),tileY(p.y)); | |
| u.repathT=0.6; | |
| } | |
| u.repathT-=dt; | |
| moveAlong(u,dt); | |
| }else{ | |
| u.path=null; u.moving=false; u.swing+=dt; faceTo(u,b); | |
| b.progress+=dt; | |
| b.hp=Math.max(b.hp,Math.ceil(b.maxHp*Math.min(1,b.progress/b.def.time))); | |
| if(Math.random()<dt*2.5) | |
| particles.push({x:b.x+rand(-b.w*T/2,b.w*T/2),y:b.y+rand(-6,b.h*T/2),vx:rand(-8,8),vy:rand(-30,-12),t:0,max:0.7,size:rand(1.5,3),color:'#c9b18a'}); | |
| if(b.progress>=b.def.time) completeBuilding(b); | |
| } | |
| } | |
| /* ---------------- buildings ---------------- */ | |
| function completeBuilding(b){ | |
| if(b.built)return; | |
| b.built=true; b.progress=b.def.time; b.hp=b.maxHp; | |
| recomputeFood(); | |
| if(!b.rally) b.rally={x:b.x,y:(b.tileY+b.h)*T+16}; | |
| float(b.x,b.y-b.h*T/2-10,'Construction complete','#9fe0ff'); | |
| burst(b.x,b.y,'#ffe9a0',14,130); | |
| refreshInfo(); | |
| } | |
| function enqueueTrain(b,type){ | |
| if(!b||b.dead)return; | |
| if(!b.built){ toast('Building is still under construction'); return; } | |
| const d=UNITS[type]; | |
| if(!canAfford(d)){ toast('Not enough resources'); return; } | |
| if(S.foodUsed+d.food>S.foodCap){ toast('Not enough food — build a Farm'); return; } | |
| if(b.queue.length>=5){ toast('Queue is full'); return; } | |
| pay(d); | |
| b.queue.push(type); | |
| refreshInfo(); refreshCard(); | |
| } | |
| function cancelLastQueued(b){ | |
| if(!b.queue.length)return; | |
| const type=b.queue.pop(); | |
| refund(UNITS[type]); | |
| refreshInfo(); refreshCard(); | |
| } | |
| function updateTraining(b,dt){ | |
| if(!b.built||!b.queue.length)return; | |
| b.trainT+=dt; | |
| while(b.queue.length){ | |
| const d=UNITS[b.queue[0]]; | |
| if(b.trainT<d.time)break; | |
| b.trainT-=d.time; | |
| spawnTrained(b,b.queue.shift()); | |
| } | |
| } | |
| function spawnTrained(b,type){ | |
| const f=nearestFree(b.tileX+Math.floor(b.w/2),b.tileY+b.h,8); | |
| const x=f?tcx(f.x):b.x, y=f?tcy(f.y):b.y; | |
| const u=makeUnit(type,x,y); | |
| units.push(u); | |
| S.foodUsed+=u.def.food; | |
| burst(u.x,u.y-8,'#9fe0ff',6,70); | |
| const r=b.rally; | |
| if(r){ | |
| if(r.ent&&!r.ent.dead){ | |
| if(r.ent.kind==='tree'||r.ent.kind==='mine') orderGather(u,r.ent); | |
| else if(r.ent.kind==='creep') orderAttack(u,r.ent); | |
| else setOrderMove(u,r.ent.x,r.ent.y); | |
| }else setOrderMove(u,r.x!==undefined?r.x:b.x,r.y!==undefined?r.y:(b.tileY+b.h)*T+16); | |
| } | |
| refreshInfo(); | |
| } | |
| function updateTower(b,dt){ | |
| if(!b.built||!b.def.dmg)return; | |
| b.cd-=dt; | |
| let best=null,bd=b.def.range*b.def.range; | |
| for(const c of creeps){ | |
| if(c.dead)continue; | |
| const dx=c.x-b.x, dy=c.y-b.y, d2=dx*dx+dy*dy; | |
| if(d2<bd){bd=d2;best=c;} | |
| } | |
| b.target=best; | |
| if(best&&b.cd<=0){ | |
| b.cd=b.def.cd; b.flash=0.16; | |
| projectiles.push({x:b.x,y:b.y-b.h*T*0.55,target:best,speed:400,dmg:b.def.dmg,color:'#9fe0ff'}); | |
| } | |
| } | |
| function cancelConstruction(b){ | |
| refund(b.def,0.75); | |
| b.dead=true; | |
| setBlockRect(b.tileX,b.tileY,b.w,b.h,0); | |
| for(const u of units) if(u.order.type==='build'&&u.order.b===b) u.order={type:'idle'}; | |
| float(b.x,b.y-20,'Cancelled (75% refund)','#cfcfcf'); | |
| S.sel=S.sel.filter(e=>e!==b); | |
| refreshCard(); refreshInfo(); | |
| } | |
| /* ---------------- creeps ---------------- */ | |
| function updateCreep(c,dt){ | |
| c.cd-=dt; c.hitFlash-=dt; c.attackAnim-=dt; | |
| const o=c.order; | |
| if(o.type==='idle'){ | |
| if(c.hp<c.maxHp)c.hp=Math.min(c.maxHp,c.hp+6*dt); | |
| c.wanderT-=dt; | |
| if(c.wanderT<=0){ | |
| c.wanderT=rand(3,7); | |
| const a=rand(0,Math.PI*2), r=rand(0,2*T); | |
| const tx=clamp(c.home.x+Math.cos(a)*r,8,WORLD_W-8), ty=clamp(c.home.y+Math.sin(a)*r,8,WORLD_H-8); | |
| c.path=findPath(tileX(c.x),tileY(c.y),tileX(tx),tileY(ty)); | |
| } | |
| moveAlong(c,dt); | |
| c.scanT-=dt; | |
| if(c.scanT<=0){ | |
| c.scanT=0.3; | |
| const R=c.def.sight*T; | |
| let best=null,bd=R*R; | |
| for(const u of units){ | |
| if(u.dead)continue; | |
| const dx=u.x-c.x, dy=u.y-c.y, d2=dx*dx+dy*dy; | |
| if(d2<bd){bd=d2;best=u;} | |
| } | |
| if(best){ c.order={type:'attack',target:best}; c.path=null; } | |
| } | |
| }else if(o.type==='attack'){ | |
| const t=o.target; | |
| if(!t||t.dead){ c.order={type:'return'}; c.path=null; return; } | |
| if(dist(c.x,c.y,c.home.x,c.home.y)>9.5*T){ c.order={type:'return'}; c.path=null; return; } | |
| const d=dist(c.x,c.y,t.x,t.y)-t.def.radius; | |
| if(d<=c.def.range+3){ c.path=null; c.moving=false; tryAttack(c,t); } | |
| else{ | |
| if(!c.path||!c.path.length||c.repathT<=0){ | |
| c.path=findPath(tileX(c.x),tileY(c.y),tileX(t.x),tileY(t.y)); | |
| c.repathT=0.4; | |
| } | |
| c.repathT-=dt; | |
| moveAlong(c,dt); | |
| } | |
| }else if(o.type==='return'){ | |
| if(dist(c.x,c.y,c.home.x,c.home.y)<12){ c.order={type:'idle'}; c.path=null; return; } | |
| if(!c.path||!c.path.length) c.path=findPath(tileX(c.x),tileY(c.y),tileX(c.home.x),tileY(c.home.y)); | |
| moveAlong(c,dt); | |
| } | |
| } | |
| /* ---------------- trees / projectiles / effects ---------------- */ | |
| function removeTree(t){ | |
| if(t.dead)return; | |
| t.dead=true; | |
| setBlock(t.tx,t.ty,0); | |
| corpses.push({x:t.x,y:t.y,r:8,t:0,max:12,color:'rgba(106,74,38,'}); | |
| burst(t.x,t.y-10,'#a5d67e',8,90); | |
| } | |
| function updateProjectiles(dt){ | |
| for(const p of projectiles){ | |
| const t=p.target; | |
| if(!t||t.dead){ p.dead=true; burst(p.x,p.y,'#d8d8d8',3,40); continue; } | |
| const dx=t.x-p.x, dy=(t.y-t.def.radius*0.6)-p.y; | |
| const d=Math.hypot(dx,dy); | |
| const step=p.speed*dt; | |
| if(d<=step+6){ | |
| damage(t,p.dmg,null); | |
| burst(t.x,t.y-t.def.radius,'#ffe9a0',4,60); | |
| p.dead=true; | |
| }else{ | |
| p.x+=dx/d*step; p.y+=dy/d*step; | |
| p.ang=Math.atan2(dy,dx); | |
| } | |
| } | |
| projectiles=projectiles.filter(p=>!p.dead); | |
| } | |
| function burst(x,y,color,n,spd){ | |
| for(let i=0;i<n;i++){ | |
| const a=rand(0,Math.PI*2), s=rand(spd*0.3,spd); | |
| particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-20,t:0,max:rand(0.3,0.7),size:rand(1.5,3.2),color}); | |
| } | |
| if(particles.length>500)particles.splice(0,particles.length-500); | |
| } | |
| function float(x,y,text,color){ | |
| floaters.push({x,y,text,color,t:0,max:1.1}); | |
| if(floaters.length>60)floaters.shift(); | |
| } | |
| function updateEffects(dt){ | |
| for(const p of particles){ p.t+=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=140*dt; } | |
| particles=particles.filter(p=>p.t<p.max); | |
| for(const f of floaters){ f.t+=dt; f.y-=26*dt; } | |
| floaters=floaters.filter(f=>f.t<f.max); | |
| for(const c of corpses)c.t+=dt; | |
| corpses=corpses.filter(c=>c.t<c.max); | |
| } | |
| /* ---------------- fog of war ---------------- */ | |
| function updateFog(){ | |
| for(const i of visList) if(fog[i]===2)fog[i]=1; | |
| visList.length=0; | |
| const stamp=(wx,wy,rTiles)=>{ | |
| const cx=Math.floor(wx/T), cy=Math.floor(wy/T), r=Math.ceil(rTiles), r2=rTiles*rTiles; | |
| for(let dy=-r;dy<=r;dy++){ | |
| const ty=cy+dy; | |
| if(ty<0||ty>=MAP_H)continue; | |
| for(let dx=-r;dx<=r;dx++){ | |
| const tx=cx+dx; | |
| if(tx<0||tx>=MAP_W)continue; | |
| if(dx*dx+dy*dy>r2)continue; | |
| const i=idx(tx,ty); | |
| if(fog[i]!==2){ | |
| if(fog[i]===0)S.explored++; | |
| fog[i]=2; visList.push(i); | |
| } | |
| } | |
| } | |
| }; | |
| for(const u of units) if(!u.dead) stamp(u.x,u.y,u.def.sight); | |
| for(const b of buildings) if(!b.dead) stamp(b.x,b.y,b.def.sight); | |
| if(!S.won&&S.explored>=S.totalTiles) win(); | |
| } | |
| function win(){ | |
| S.won=true; | |
| document.getElementById('banner').classList.remove('hidden'); | |
| } | |
| /* ---------------- world update ---------------- */ | |
| function updateWorld(dt){ | |
| for(const u of units) if(!u.dead) updateUnit(u,dt); | |
| for(const c of creeps) if(!c.dead) updateCreep(c,dt); | |
| for(const b of buildings){ | |
| if(b.dead)continue; | |
| b.hitFlash-=dt; b.flash-=dt; | |
| updateTraining(b,dt); | |
| updateTower(b,dt); | |
| } | |
| separate(); | |
| updateProjectiles(dt); | |
| updateEffects(dt); | |
| units=units.filter(u=>!u.dead); | |
| creeps=creeps.filter(c=>!c.dead); | |
| buildings=buildings.filter(b=>!b.dead); | |
| trees=trees.filter(t=>!t.dead); | |
| } | |
| /* ================================================================ | |
| RENDERING | |
| ================================================================ */ | |
| const cv=document.getElementById('cv'), ctx=cv.getContext('2d'); | |
| let vw=800, vh=600, dpr=1; | |
| function resize(){ | |
| const st=document.getElementById('stage'); | |
| vw=st.clientWidth; vh=st.clientHeight; | |
| dpr=Math.min(2,window.devicePixelRatio||1); | |
| cv.width=Math.round(vw*dpr); cv.height=Math.round(vh*dpr); | |
| cv.style.width=vw+'px'; cv.style.height=vh+'px'; | |
| clampCam(); | |
| } | |
| function clampCam(){ | |
| S.cam.x=clamp(S.cam.x,0,Math.max(0,WORLD_W-vw)); | |
| S.cam.y=clamp(S.cam.y,0,Math.max(0,WORLD_H-vh)); | |
| if(WORLD_W<vw)S.cam.x=(WORLD_W-vw)/2; | |
| if(WORLD_H<vh)S.cam.y=(WORLD_H-vh)/2; | |
| } | |
| function centerOn(x,y){ | |
| S.cam.x=x-vw/2; S.cam.y=y-vh/2; clampCam(); | |
| } | |
| /* ---- ground pre-render ---- */ | |
| const ground=document.createElement('canvas'); | |
| ground.width=WORLD_W; ground.height=WORLD_H; | |
| function buildGround(){ | |
| const g=ground.getContext('2d'); | |
| const grassCols=['#5d9440','#578d3c','#639a45','#5a9040']; | |
| for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){ | |
| const h=hash2(tx,ty); | |
| g.fillStyle=terrain[idx(tx,ty)]===WATER?'#2e6bb0':grassCols[Math.floor(h*grassCols.length)%grassCols.length]; | |
| g.fillRect(tx*T,ty*T,T,T); | |
| } | |
| // soft large patches | |
| for(let i=0;i<160;i++){ | |
| const x=rand(0,WORLD_W), y=rand(0,WORLD_H), r=rand(40,130); | |
| g.fillStyle=Math.random()<0.5?'rgba(255,255,230,0.045)':'rgba(20,60,10,0.05)'; | |
| g.beginPath(); g.ellipse(x,y,r,r*0.6,rand(0,3),0,Math.PI*2); g.fill(); | |
| } | |
| // water shading + edges | |
| for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){ | |
| if(terrain[idx(tx,ty)]!==WATER)continue; | |
| const x=tx*T,y=ty*T; | |
| const gr=g.createLinearGradient(x,y,x,y+T); | |
| gr.addColorStop(0,'#3574bd'); gr.addColorStop(1,'#2a5f9e'); | |
| g.fillStyle=gr; g.fillRect(x,y,T,T); | |
| g.fillStyle='rgba(10,30,60,0.25)'; | |
| if(ty>0&&terrain[idx(tx,ty-1)]===WATER)g.fillRect(x,y,T,2); | |
| g.fillStyle='rgba(220,240,255,0.35)'; | |
| if(ty>0&&terrain[idx(tx,ty-1)]!==WATER)g.fillRect(x,y,T,2); | |
| if(tx>0&&terrain[idx(tx-1,ty)]!==WATER)g.fillRect(x,y,2,T); | |
| } | |
| // decals on grass | |
| for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){ | |
| if(terrain[idx(tx,ty)]!==GRASS)continue; | |
| const h=hash2(tx*5+1,ty*3+2), x=tx*T, y=ty*T; | |
| if(h>0.93){ // flowers | |
| const cols=['#f2f0da','#f0d473','#e8a5c0']; | |
| for(let i=0;i<3;i++){ | |
| g.fillStyle=cols[i%3]; | |
| g.fillRect(x+6+i*9+(h*7|0),y+8+((h*97+i*31)%18),2.4,2.4); | |
| } | |
| }else if(h<0.05){ // pebbles | |
| g.fillStyle='rgba(120,115,100,0.5)'; | |
| g.beginPath(); g.ellipse(x+10+h*100%12,y+14,3,2.2,0,0,Math.PI*2); g.fill(); | |
| }else if(h>0.55&&h<0.62){ // grass tufts | |
| g.strokeStyle='rgba(40,80,30,0.5)'; g.lineWidth=1; | |
| for(let i=0;i<3;i++){ | |
| g.beginPath(); | |
| g.moveTo(x+8+i*7,y+22); | |
| g.lineTo(x+8+i*7+(i-1)*2,y+15); | |
| g.stroke(); | |
| } | |
| } | |
| } | |
| } | |
| /* ---- minimap base ---- */ | |
| const miniBase=document.createElement('canvas'); | |
| miniBase.width=MAP_W; miniBase.height=MAP_H; | |
| function buildMiniBase(){ | |
| const g=miniBase.getContext('2d'); | |
| for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){ | |
| const h=hash2(tx,ty); | |
| g.fillStyle=terrain[idx(tx,ty)]===WATER?(h>0.5?'#2e6bb0':'#2a63a8'):(h>0.5?'#4f8436':'#4a7d33'); | |
| g.fillRect(tx,ty,1,1); | |
| } | |
| } | |
| /* ---- sprites ---- */ | |
| function ellipse(ctx,x,y,rx,ry){ ctx.beginPath(); ctx.ellipse(x,y,rx,ry,0,0,Math.PI*2); ctx.fill(); } | |
| function shadow(ctx,x,y,r){ ctx.fillStyle='rgba(0,0,0,0.28)'; ellipse(ctx,x,y+1,r,r*0.38); } | |
| function drawHumanoid(ctx,x,y,o){ | |
| // o: {body,trim,pants,skin,phase,moving,face,attackAnim,swing,tool,carry,helm,hood,big} | |
| const bob=o.moving?Math.sin(o.phase)*1.3:0; | |
| const ls=o.moving?Math.sin(o.phase)*2.6:0; | |
| ctx.save(); ctx.translate(x,y+bob); if(o.face<0)ctx.scale(-1,1); | |
| // legs | |
| ctx.fillStyle=o.pants||'#4a3a26'; | |
| ctx.fillRect(-4,-7-Math.max(0,ls)*0.4,3.4,7+Math.max(0,ls)*0.4); | |
| ctx.fillRect(0.8,-7-Math.max(0,-ls)*0.4,3.4,7+Math.max(0,-ls)*0.4); | |
| // body | |
| ctx.fillStyle=o.body; | |
| ctx.beginPath(); ctx.roundRect(-5.5,-17,11,11,3); ctx.fill(); | |
| ctx.fillStyle=o.trim||'rgba(0,0,0,0.25)'; | |
| ctx.fillRect(-5.5,-9.5,11,2.2); | |
| // back arm | |
| ctx.fillStyle=o.body; | |
| ctx.fillRect(-7,-16,3,7); | |
| // head | |
| ctx.fillStyle=o.skin||'#eac08f'; | |
| ctx.beginPath(); ctx.arc(0,-21,4.2,0,Math.PI*2); ctx.fill(); | |
| if(o.helm){ | |
| ctx.fillStyle='#9aa3ad'; | |
| ctx.beginPath(); ctx.arc(0,-22,4.4,Math.PI,0); ctx.fill(); | |
| ctx.fillRect(-4.4,-22.5,8.8,2.4); | |
| ctx.fillStyle=o.trim||'#4a86d8'; | |
| ctx.beginPath(); ctx.ellipse(0,-27,1.6,3.4,0,0,Math.PI*2); ctx.fill(); // plume | |
| }else if(o.hood){ | |
| ctx.fillStyle=o.hood; | |
| ctx.beginPath(); ctx.arc(0,-21.5,4.8,Math.PI*0.9,Math.PI*2.1); ctx.lineTo(4,-17) ; ctx.lineTo(-5,-17); ctx.fill(); | |
| }else if(o.hat){ | |
| ctx.fillStyle=o.hat; | |
| ctx.beginPath(); ctx.arc(0,-22.5,4.5,Math.PI,0); ctx.fill(); | |
| ctx.fillRect(-5.5,-22.8,11,1.8); | |
| } | |
| // front arm + tool | |
| const atk=o.attackAnim>0?o.attackAnim/0.28:0; | |
| const sw=o.swing?Math.sin(o.swing*9)*0.7:0; | |
| ctx.save(); | |
| ctx.translate(5,-14); | |
| if(o.tool==='pick'){ | |
| ctx.rotate(-0.9+sw+(atk?atk*1.2:0)); | |
| ctx.fillStyle='#7a5a34'; ctx.fillRect(-1,0,2.2,11); | |
| ctx.fillStyle='#b9b0a0'; | |
| ctx.beginPath(); ctx.roundRect(-5,9,10,3,1.5); ctx.fill(); | |
| }else if(o.tool==='axe'){ | |
| ctx.rotate(-0.8+sw); | |
| ctx.fillStyle='#7a5a34'; ctx.fillRect(-1,0,2.2,11); | |
| ctx.fillStyle='#c8cdd4'; | |
| ctx.beginPath(); ctx.roundRect(-1,8,6,5,1.5); ctx.fill(); | |
| }else if(o.tool==='sword'){ | |
| ctx.rotate(-1.2+atk*2.0+sw*0.3); | |
| ctx.fillStyle='#d7dbe0'; ctx.fillRect(-1,-11,2.6,12); | |
| ctx.fillStyle='#8a6a2a'; ctx.fillRect(-3,0,7,2.4); | |
| }else if(o.tool==='bow'){ | |
| ctx.rotate(atk?-0.3:0.15); | |
| ctx.strokeStyle='#8a6a3a'; ctx.lineWidth=1.8; | |
| ctx.beginPath(); ctx.arc(2,0,8,-1.2,1.2); ctx.stroke(); | |
| ctx.strokeStyle='#e8e4d4'; ctx.lineWidth=0.8; | |
| ctx.beginPath(); ctx.moveTo(2+8*Math.cos(-1.2),8*Math.sin(-1.2)); ctx.lineTo(2+8*Math.cos(1.2),8*Math.sin(1.2)); ctx.stroke(); | |
| if(atk>0.4){ ctx.strokeStyle='#d7dbe0'; ctx.lineWidth=1.2; ctx.beginPath(); ctx.moveTo(-4,0); ctx.lineTo(9,0); ctx.stroke(); } | |
| }else if(o.tool==='lance'){ | |
| ctx.rotate(0.5); | |
| ctx.fillStyle='#7a5a34'; ctx.fillRect(-1,-16,2.4,24); | |
| ctx.fillStyle='#d7dbe0'; | |
| ctx.beginPath(); ctx.moveTo(-1.6,-16); ctx.lineTo(1.6,-16); ctx.lineTo(0,-22); ctx.fill(); | |
| }else{ | |
| ctx.fillStyle=o.body; ctx.fillRect(-1.5,0,3,6); | |
| } | |
| ctx.restore(); | |
| // carry sack | |
| if(o.carry){ | |
| ctx.fillStyle=o.carry==='gold'?'#d9a94f':'#7a5a34'; | |
| ctx.beginPath(); ctx.arc(-6.5,-12,3.6,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='rgba(255,255,255,0.35)'; | |
| ctx.beginPath(); ctx.arc(-7.5,-13,1.2,0,Math.PI*2); ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawUnitSprite(ctx,u){ | |
| shadow(ctx,u.x,u.y,u.def.radius); | |
| const o={phase:u.walkPhase,moving:u.moving,face:u.face,attackAnim:u.attackAnim,swing:u.swing,carry:u.carry}; | |
| if(u.type==='worker') drawHumanoid(ctx,u.x,u.y,{...o,body:'#8a6a3f',trim:'#4a86d8',pants:'#5a4632',hat:'#4a86d8',tool:(u.order.type==='gather'||u.order.type==='build')?'pick':null}); | |
| else if(u.type==='footman') drawHumanoid(ctx,u.x,u.y,{...o,body:'#8d97a3',trim:'#4a86d8',pants:'#5a5148',helm:true,tool:'sword'}); | |
| else if(u.type==='archer') drawHumanoid(ctx,u.x,u.y,{...o,body:'#5a7a4a',trim:'#3a5230',pants:'#4a4238',hood:'#3f6638',tool:'bow'}); | |
| else if(u.type==='knight'){ | |
| // horse | |
| const ph=u.walkPhase; | |
| ctx.save(); ctx.translate(u.x,u.y); if(u.face<0)ctx.scale(-1,1); | |
| ctx.fillStyle='#6e5236'; | |
| const legY=Math.sin(ph)*2; | |
| ctx.fillRect(-8,-6+legY,2.6,7); ctx.fillRect(-3,-6-legY,2.6,7); | |
| ctx.fillRect(3,-6+legY*0.7,2.6,7); ctx.fillRect(7,-6-legY*0.7,2.6,7); | |
| ctx.beginPath(); ctx.ellipse(0,-10,11,6.5,0,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='#5d452c'; | |
| ctx.beginPath(); ctx.ellipse(10,-15,4,3.4,-0.5,0,Math.PI*2); ctx.fill(); // head | |
| ctx.beginPath(); ctx.moveTo(8,-18); ctx.lineTo(10,-21); ctx.lineTo(11.5,-17.5); ctx.fill(); // ear | |
| ctx.strokeStyle='#4a3620'; ctx.lineWidth=1.6; | |
| ctx.beginPath(); ctx.moveTo(-10,-12); ctx.quadraticCurveTo(-15,-8,-13,-3); ctx.stroke(); // tail | |
| ctx.restore(); | |
| drawHumanoid(ctx,u.x,u.y-8,{...o,moving:false,body:'#9aa3ad',trim:'#4a86d8',helm:true,tool:'lance'}); | |
| } | |
| if(u.hitFlash>0){ | |
| ctx.fillStyle='rgba(255,255,255,'+Math.min(0.6,u.hitFlash*4)+')'; | |
| ellipse(ctx,u.x,u.y-12,u.def.radius,u.def.radius*1.2); | |
| } | |
| } | |
| function drawCreepSprite(ctx,c){ | |
| shadow(ctx,c.x,c.y,c.def.radius); | |
| if(c.type==='wolf'){ | |
| const ph=c.walkPhase, mv=c.moving; | |
| ctx.save(); ctx.translate(c.x,c.y); if(c.face<0)ctx.scale(-1,1); | |
| const l=mv?Math.sin(ph)*2.4:0; | |
| ctx.strokeStyle='#6d727c'; ctx.lineWidth=2; | |
| ctx.beginPath(); | |
| ctx.moveTo(-6,-5); ctx.lineTo(-6-l,1); | |
| ctx.moveTo(-2,-5); ctx.lineTo(-2+l,1); | |
| ctx.moveTo(4,-5); ctx.lineTo(4-l,1); | |
| ctx.moveTo(7,-5); ctx.lineTo(7+l,1); | |
| ctx.stroke(); | |
| ctx.fillStyle='#8a8f98'; | |
| ctx.beginPath(); ctx.ellipse(0,-8,10,5,0,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='#a6abb4'; | |
| ctx.beginPath(); ctx.ellipse(0,-6.5,7,2.6,0,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='#8a8f98'; | |
| ctx.beginPath(); ctx.arc(9,-11,4.2,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='#6d727c'; | |
| ctx.beginPath(); ctx.roundRect(11,-11,5,2.6,1); ctx.fill(); // snout | |
| ctx.beginPath(); ctx.moveTo(6,-14); ctx.lineTo(8,-18); ctx.lineTo(9.5,-13.5); ctx.fill(); // ears | |
| ctx.beginPath(); ctx.moveTo(9.5,-14); ctx.lineTo(11.5,-17.5); ctx.lineTo(12.5,-13); ctx.fill(); | |
| ctx.strokeStyle='#6d727c'; ctx.lineWidth=2; | |
| ctx.beginPath(); ctx.moveTo(-9,-9); ctx.quadraticCurveTo(-14,-12,-13,-16); ctx.stroke(); // tail | |
| ctx.fillStyle=c.order.type==='attack'?'#ff5040':'#2a2a2a'; | |
| ctx.beginPath(); ctx.arc(8.5,-12,1.1,0,Math.PI*2); ctx.fill(); | |
| ctx.restore(); | |
| }else{ // ogre | |
| const ph=c.walkPhase, mv=c.moving; | |
| ctx.save(); ctx.translate(c.x,c.y); if(c.face<0)ctx.scale(-1,1); | |
| const l=mv?Math.sin(ph)*2.6:0; | |
| ctx.fillStyle='#6a5a3a'; | |
| ctx.fillRect(-7,-9+l*0.4,5.5,9); ctx.fillRect(2,-9-l*0.4,5.5,9); // legs | |
| ctx.fillStyle='#8a7a4a'; | |
| ctx.beginPath(); ctx.ellipse(0,-17,10,10,0,0,Math.PI*2); ctx.fill(); // body | |
| ctx.fillStyle='#a3925e'; | |
| ctx.beginPath(); ctx.ellipse(0,-14,6,6,0,0,Math.PI*2); ctx.fill(); // belly | |
| ctx.fillStyle='#8a7a4a'; | |
| ctx.beginPath(); ctx.arc(0,-29,5.5,0,Math.PI*2); ctx.fill(); // head | |
| ctx.fillStyle='#6a5a3a'; | |
| ctx.beginPath(); ctx.roundRect(-4,-27,8,3,1.5); ctx.fill(); // jaw | |
| ctx.fillStyle='#ff5040'; | |
| ctx.beginPath(); ctx.arc(-2,-30.5,1,0,Math.PI*2); ctx.arc(2.5,-30.5,1,0,Math.PI*2); ctx.fill(); | |
| // arms + club | |
| const atk=c.attackAnim>0?c.attackAnim/0.28:0; | |
| ctx.save(); ctx.translate(8,-20); ctx.rotate(-0.6+atk*1.8); | |
| ctx.fillStyle='#7d6e42'; ctx.beginPath(); ctx.roundRect(-2,0,5,10,2); ctx.fill(); | |
| ctx.fillStyle='#6e5236'; ctx.fillRect(0,8,3,12); | |
| ctx.fillStyle='#9aa3ad'; | |
| ctx.beginPath(); ctx.roundRect(-3,17,9,7,2); ctx.fill(); | |
| ctx.restore(); | |
| ctx.fillStyle='#7d6e42'; | |
| ctx.beginPath(); ctx.roundRect(-11,-22,4.5,10,2); ctx.fill(); | |
| ctx.restore(); | |
| } | |
| if(c.hitFlash>0){ | |
| ctx.fillStyle='rgba(255,255,255,'+Math.min(0.6,c.hitFlash*4)+')'; | |
| ellipse(ctx,c.x,c.y-c.def.radius,c.def.radius,c.def.radius); | |
| } | |
| } | |
| function drawTree(ctx,t){ | |
| const x=t.x,y=t.y,v=t.v; | |
| const k=0.55+0.45*(t.amount/TREE_AMOUNT); | |
| ctx.fillStyle='rgba(0,0,0,0.25)'; ellipse(ctx,x,y+2,10,4); | |
| ctx.fillStyle='#6a4a26'; | |
| ctx.fillRect(x-2.5,y-13,5,13); | |
| ctx.fillStyle='#57391d'; | |
| ctx.fillRect(x-2.5,y-13,1.6,13); | |
| const s=(0.8+v*0.45)*k; | |
| ctx.fillStyle='#2c6e31'; | |
| ctx.beginPath(); ctx.arc(x,y-19*s,12*s,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='#38873d'; | |
| ctx.beginPath(); ctx.arc(x-5*s,y-23*s,8.5*s,0,Math.PI*2); ctx.arc(x+6*s,y-21*s,7.5*s,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='#4fa457'; | |
| ctx.beginPath(); ctx.arc(x-3*s,y-26*s,5.5*s,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='rgba(255,255,220,0.25)'; | |
| ctx.beginPath(); ctx.arc(x-5*s,y-27*s,2.4*s,0,Math.PI*2); ctx.fill(); | |
| } | |
| function drawRock(ctx,r){ | |
| const x=r.x,y=r.y,v=r.v; | |
| ctx.fillStyle='rgba(0,0,0,0.25)'; ellipse(ctx,x,y+2,11,4); | |
| ctx.fillStyle='#8d8a80'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x-11,y); ctx.lineTo(x-8,y-9-v*4); ctx.lineTo(x-1,y-13-v*3); | |
| ctx.lineTo(x+7,y-10-v*2); ctx.lineTo(x+11,y-2); ctx.lineTo(x+8,y+1); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#a5a298'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x-8,y-9-v*4); ctx.lineTo(x-1,y-13-v*3); ctx.lineTo(x+3,y-6); ctx.lineTo(x-4,y-4); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#6e6b62'; | |
| ctx.beginPath(); ctx.moveTo(x+7,y-10-v*2); ctx.lineTo(x+11,y-2); ctx.lineTo(x+4,y-3); ctx.closePath(); ctx.fill(); | |
| } | |
| function drawMine(ctx,m){ | |
| const x=m.x,y=m.y; | |
| ctx.fillStyle='rgba(0,0,0,0.28)'; ellipse(ctx,x,y+26,26,8); | |
| // rock mound | |
| ctx.fillStyle='#7b766c'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x-27,y+24); ctx.lineTo(x-22,y-2); ctx.lineTo(x-8,y-16); | |
| ctx.lineTo(x+8,y-14); ctx.lineTo(x+22,y+2); ctx.lineTo(x+27,y+24); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#948f84'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x-22,y-2); ctx.lineTo(x-8,y-16); ctx.lineTo(x+2,y-4); ctx.lineTo(x-10,y+2); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#635f56'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+8,y-14); ctx.lineTo(x+22,y+2); ctx.lineTo(x+10,y+6); ctx.closePath(); ctx.fill(); | |
| // cave | |
| ctx.fillStyle='#241f18'; | |
| ctx.beginPath(); ctx.roundRect(x-9,y+4,18,20,7); ctx.fill(); | |
| ctx.fillStyle='#6e5236'; | |
| ctx.fillRect(x-11,y+2,3,22); ctx.fillRect(x+8,y+2,3,22); ctx.fillRect(x-11,y+1,22,3.4); | |
| // gold nuggets | |
| const nug=[[x-17,y+8],[x-14,y+16],[x+15,y+10],[x+12,y+18],[x-4,y-8],[x+6,y-6]]; | |
| for(const [nx,ny] of nug){ | |
| ctx.fillStyle='#f6c545'; | |
| ctx.beginPath(); ctx.arc(nx,ny,3,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle='#ffe89a'; | |
| ctx.beginPath(); ctx.arc(nx-1,ny-1,1.1,0,Math.PI*2); ctx.fill(); | |
| } | |
| // sparkle | |
| const sp=Math.sin(S.time*3+m.v*9)*0.5+0.5; | |
| ctx.fillStyle='rgba(255,244,200,'+(sp*0.9)+')'; | |
| ctx.beginPath(); ctx.arc(x-4,y-8,1.6+sp,0,Math.PI*2); ctx.fill(); | |
| } | |
| /* ---- buildings ---- */ | |
| function drawTownHall(ctx,x,y){ | |
| // x,y top-left, 96x96 | |
| ctx.fillStyle='rgba(0,0,0,0.3)'; ellipse(ctx,x+48,y+88,44,10); | |
| // stone base | |
| ctx.fillStyle='#b6ad9c'; | |
| ctx.beginPath(); ctx.roundRect(x+10,y+42,76,44,3); ctx.fill(); | |
| ctx.strokeStyle='rgba(90,80,60,0.5)'; ctx.lineWidth=1; | |
| for(let i=1;i<4;i++){ ctx.beginPath(); ctx.moveTo(x+10,y+42+i*11); ctx.lineTo(x+86,y+42+i*11); ctx.stroke(); } | |
| for(let i=0;i<4;i++){ ctx.beginPath(); ctx.moveTo(x+10+19*i+((i%2)?9:0),y+42); ctx.lineTo(x+10+19*i+((i%2)?9:0),y+86); ctx.stroke(); } | |
| // door + windows | |
| ctx.fillStyle='#4a3620'; | |
| ctx.beginPath(); ctx.roundRect(x+40,y+60,16,26,6); ctx.fill(); | |
| ctx.fillStyle='#2e2214'; | |
| ctx.beginPath(); ctx.roundRect(x+42,y+62,12,24,5); ctx.fill(); | |
| ctx.fillStyle='#7fb2d9'; | |
| ctx.fillRect(x+20,y+52,10,10); ctx.fillRect(x+66,y+52,10,10); | |
| ctx.strokeStyle='#4a3620'; ctx.strokeRect(x+20,y+52,10,10); ctx.strokeRect(x+66,y+52,10,10); | |
| // upper wood floor | |
| ctx.fillStyle='#8a6a3f'; | |
| ctx.fillRect(x+14,y+26,68,17); | |
| ctx.fillStyle='#6e5236'; | |
| for(let i=0;i<5;i++)ctx.fillRect(x+14+i*14,y+26,2,17); | |
| ctx.fillStyle='#7fb2d9'; | |
| ctx.fillRect(x+42,y+29,12,10); | |
| ctx.strokeStyle='#4a3620'; ctx.strokeRect(x+42,y+29,12,10); | |
| // roof | |
| ctx.fillStyle='#3f66a8'; | |
| ctx.beginPath(); ctx.moveTo(x+4,y+28); ctx.lineTo(x+48,y-4); ctx.lineTo(x+92,y+28); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#35568c'; | |
| ctx.beginPath(); ctx.moveTo(x+48,y-4); ctx.lineTo(x+92,y+28); ctx.lineTo(x+70,y+28); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle='#2a4470'; ctx.lineWidth=1.5; | |
| ctx.beginPath(); ctx.moveTo(x+4,y+28); ctx.lineTo(x+48,y-4); ctx.lineTo(x+92,y+28); ctx.stroke(); | |
| // flag | |
| const wv=Math.sin(S.time*4)*2; | |
| ctx.strokeStyle='#4a3620'; ctx.lineWidth=2; | |
| ctx.beginPath(); ctx.moveTo(x+48,y-4); ctx.lineTo(x+48,y-20); ctx.stroke(); | |
| ctx.fillStyle='#4a86d8'; | |
| ctx.beginPath(); ctx.moveTo(x+48,y-20); ctx.lineTo(x+64,y-16+wv); ctx.lineTo(x+48,y-11); ctx.closePath(); ctx.fill(); | |
| } | |
| function drawFarm(ctx,x,y){ | |
| // 64x64 | |
| ctx.fillStyle='rgba(0,0,0,0.28)'; ellipse(ctx,x+32,y+58,28,7); | |
| // wheat patch | |
| ctx.fillStyle='#c9ab5e'; | |
| ctx.beginPath(); ctx.roundRect(x+4,y+46,20,14,3); ctx.fill(); | |
| ctx.strokeStyle='#a8894a'; ctx.lineWidth=1; | |
| for(let i=0;i<5;i++){ ctx.beginPath(); ctx.moveTo(x+7+i*4,y+58); ctx.lineTo(x+7+i*4,y+50); ctx.stroke(); } | |
| // house base | |
| ctx.fillStyle='#9a7a4a'; | |
| ctx.beginPath(); ctx.roundRect(x+14,y+30,40,26,2); ctx.fill(); | |
| ctx.fillStyle='#6e5236'; | |
| ctx.fillRect(x+14,y+30,40,2); ctx.fillRect(x+14,y+42,40,2); | |
| ctx.fillStyle='#4a3620'; | |
| ctx.beginPath(); ctx.roundRect(x+28,y+42,12,14,3); ctx.fill(); | |
| ctx.fillStyle='#7fb2d9'; | |
| ctx.fillRect(x+18,y+34,8,7); ctx.fillRect(x+42,y+34,8,7); | |
| // thatched roof | |
| ctx.fillStyle='#c9a55a'; | |
| ctx.beginPath(); ctx.moveTo(x+8,y+32); ctx.lineTo(x+34,y+10); ctx.lineTo(x+60,y+32); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle='#b3903f'; | |
| ctx.beginPath(); ctx.moveTo(x+34,y+10); ctx.lineTo(x+60,y+32); ctx.lineTo(x+46,y+32); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle='#8a6a2a'; ctx.lineWidth=1; | |
| for(let i=1;i<4;i++){ | |
| ctx.beginPath(); ctx.moveTo(x+8+i*4,y+32-i*1.2); ctx.lineTo(x+34,y+10+i*2); ctx.stroke(); | |
| } | |
| } | |
| function drawBarracks(ctx,x,y){ | |
| // 96x64 | |
| ctx.fillStyle='rgba(0,0,0,0.3)'; ellipse(ctx,x+48,y+58,44,8); | |
| ctx.fillStyle='#6e5236'; | |
| ctx.beginPath(); ctx.roundRect(x+6,y+18,84,40,3); ctx.fill(); | |
| ctx.fillStyle='#5d452c'; | |
| for(let i=0;i<7;i++)ctx.fillRect(x+6+i*12,y+18,2,40); | |
| // battlements | |
| ctx.fillStyle='#7d5f3e'; | |
| for(let i=0;i<6;i++)ctx.fillRect(x+8+i*14,y+10,9,9); | |
| // door | |
| ctx.fillStyle='#2e2214'; | |
| ctx.beginPath(); ctx.roundRect(x+40,y+36,16,22,4); ctx.fill(); | |
| // banner | |
| ctx.fillStyle='#a83232'; | |
| ctx.beginPath(); ctx.roundRect(x+14,y+24,16,22,2); ctx.fill(); | |
| ctx.strokeStyle='#e8d9b0'; ctx.lineWidth=1.8; | |
| ctx.beginPath(); ctx.moveTo(x+18,y+28); ctx.lineTo(x+26,y+40); ctx.moveTo(x+26,y+28); ctx.lineTo(x+18,y+40); ctx.stroke(); | |
| // weapon rack | |
| ctx.strokeStyle='#8d8a80'; ctx.lineWidth=1.6; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+70,y+52); ctx.lineTo(x+70,y+30); | |
| ctx.moveTo(x+76,y+52); ctx.lineTo(x+76,y+28); | |
| ctx.moveTo(x+82,y+52); ctx.lineTo(x+82,y+31); | |
| ctx.stroke(); | |
| ctx.fillStyle='#8d8a80'; | |
| ctx.beginPath(); ctx.moveTo(x+68.5,y+30); ctx.lineTo(x+71.5,y+30); ctx.lineTo(x+70,y+25); ctx.fill(); | |
| } | |
| function drawTower(ctx,x,y,b){ | |
| // 64x64 | |
| ctx.fillStyle='rgba(0,0,0,0.3)'; ellipse(ctx,x+32,y+58,22,7); | |
| ctx.fillStyle='#a8a090'; | |
| ctx.beginPath(); ctx.roundRect(x+16,y+14,32,44,3); ctx.fill(); | |
| ctx.strokeStyle='rgba(90,80,60,0.5)'; ctx.lineWidth=1; | |
| for(let i=1;i<4;i++){ ctx.beginPath(); ctx.moveTo(x+16,y+14+i*11); ctx.lineTo(x+48,y+14+i*11); ctx.stroke(); } | |
| ctx.fillStyle='#8d8578'; | |
| ctx.fillRect(x+16,y+14,5,44); | |
| // battlements | |
| ctx.fillStyle='#b6ad9c'; | |
| for(let i=0;i<3;i++)ctx.fillRect(x+14+i*13,y+6,9,9); | |
| // window slits | |
| ctx.fillStyle='#241f18'; | |
| ctx.fillRect(x+30,y+24,4,10); ctx.fillRect(x+30,y+40,4,10); | |
| // flag | |
| const wv=Math.sin(S.time*4+2)*1.6; | |
| ctx.strokeStyle='#4a3620'; ctx.lineWidth=1.6; | |
| ctx.beginPath(); ctx.moveTo(x+32,y+6); ctx.lineTo(x+32,y-6); ctx.stroke(); | |
| ctx.fillStyle='#4a86d8'; | |
| ctx.beginPath(); ctx.moveTo(x+32,y-6); ctx.lineTo(x+44,y-3+wv); ctx.lineTo(x+32,y+0); ctx.closePath(); ctx.fill(); | |
| // firing flash | |
| if(b&&b.flash>0){ | |
| ctx.fillStyle='rgba(159,224,255,'+(b.flash*4)+')'; | |
| ctx.beginPath(); ctx.arc(x+32,y+20,7,0,Math.PI*2); ctx.fill(); | |
| } | |
| } | |
| const BLD_DRAW={townhall:drawTownHall,farm:drawFarm,barracks:drawBarracks,tower:drawTower}; | |
| function drawBuilding(ctx,b){ | |
| const x=b.tileX*T, y=b.tileY*T; | |
| const dw=b.w*T, dh=b.h*T; | |
| if(!b.built){ | |
| // blueprint + rising build | |
| const p=clamp(b.progress/b.def.time,0,1); | |
| ctx.save(); | |
| ctx.globalAlpha=0.28+0.55*p; | |
| BLD_DRAW[b.type](ctx,x,y,b); | |
| ctx.restore(); | |
| // scaffold frame | |
| ctx.strokeStyle='rgba(232,198,106,0.5)'; | |
| ctx.lineWidth=1; | |
| ctx.setLineDash([5,4]); | |
| ctx.strokeRect(x+2,y+2,dw-4,dh-4); | |
| ctx.setLineDash([]); | |
| // plank line at build height | |
| const py=y+dh-4-(dh-8)*p; | |
| ctx.strokeStyle='#8a6a3f'; ctx.lineWidth=2.5; | |
| ctx.beginPath(); ctx.moveTo(x+3,py); ctx.lineTo(x+dw-3,py); ctx.stroke(); | |
| }else{ | |
| BLD_DRAW[b.type](ctx,x,y,b); | |
| } | |
| if(b.hitFlash>0){ | |
| ctx.fillStyle='rgba(255,255,255,'+Math.min(0.5,b.hitFlash*3)+')'; | |
| ctx.fillRect(x+4,y+4,dw-8,dh-8); | |
| } | |
| } | |
| /* ---- bars / overlays ---- */ | |
| function drawBar(x,y,w,h,ratio,cls){ | |
| ctx.fillStyle='rgba(10,8,4,0.75)'; | |
| ctx.fillRect(x-1,y-1,w+2,h+2); | |
| ctx.fillStyle=cls==='build'?'#4f9fd9':ratio>0.5?'#5fbf5f':ratio>0.25?'#d9a94f':'#d95f4f'; | |
| ctx.fillRect(x,y,w*ratio,h); | |
| } | |
| function onScreen(x,y,pad){ | |
| return x>S.cam.x-pad&&x<S.cam.x+vw+pad&&y>S.cam.y-pad&&y<S.cam.y+vh+pad; | |
| } | |
| /* ---- main render ---- */ | |
| const fogCv=document.createElement('canvas'); | |
| fogCv.width=MAP_W; fogCv.height=MAP_H; | |
| function render(){ | |
| ctx.setTransform(dpr,0,0,dpr,0,0); | |
| ctx.fillStyle='#0a0805'; | |
| ctx.fillRect(0,0,vw,vh); | |
| ctx.save(); | |
| ctx.translate(-Math.round(S.cam.x),-Math.round(S.cam.y)); | |
| // ground | |
| ctx.imageSmoothingEnabled=false; | |
| ctx.drawImage(ground,S.cam.x,S.cam.y,vw,vh,S.cam.x,S.cam.y,vw,vh); | |
| // water shimmer | |
| const tx0=Math.max(0,tileX(S.cam.x)), ty0=Math.max(0,tileY(S.cam.y)); | |
| const tx1=Math.min(MAP_W-1,tileX(S.cam.x+vw)), ty1=Math.min(MAP_H-1,tileY(S.cam.y+vh)); | |
| ctx.strokeStyle='rgba(230,245,255,0.35)'; | |
| ctx.lineWidth=1; | |
| for(let ty=ty0;ty<=ty1;ty++)for(let tx=tx0;tx<=tx1;tx++){ | |
| if(terrain[idx(tx,ty)]!==WATER||fog[idx(tx,ty)]===0)continue; | |
| const h=hash2(tx,ty); | |
| if(h>0.55)continue; | |
| const a=Math.sin(S.time*1.6+h*20)*0.5+0.5; | |
| ctx.globalAlpha=a*0.3; | |
| const wx=tx*T+6+h*10, wy=ty*T+8+((h*57)%14); | |
| ctx.beginPath(); ctx.moveTo(wx,wy); ctx.lineTo(wx+9,wy); ctx.stroke(); | |
| } | |
| ctx.globalAlpha=1; | |
| // corpses / decals | |
| for(const c of corpses){ | |
| if(!onScreen(c.x,c.y,40))continue; | |
| const a=(1-c.t/c.max)*0.4; | |
| ctx.fillStyle=c.color+a+')'; | |
| ellipse(ctx,c.x,c.y,c.r,c.r*0.4); | |
| } | |
| // selection circles (under sprites) | |
| for(const e of S.sel){ | |
| if(e.dead)continue; | |
| if(e.kind==='unit'||e.kind==='creep'){ | |
| ctx.strokeStyle=e.team==='player'?'rgba(127,224,255,0.9)':'rgba(255,106,94,0.9)'; | |
| ctx.lineWidth=1.6; | |
| ctx.beginPath(); ctx.ellipse(e.x,e.y+1,e.def.radius+3,(e.def.radius+3)*0.42,0,0,Math.PI*2); ctx.stroke(); | |
| }else if(e.kind==='building'){ | |
| const x=e.tileX*T,y=e.tileY*T,dw=e.w*T,dh=e.h*T; | |
| ctx.strokeStyle='rgba(127,224,255,0.9)'; ctx.lineWidth=2; | |
| const L=10; | |
| ctx.beginPath(); | |
| ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); | |
| ctx.moveTo(x+dw-L,y); ctx.lineTo(x+dw,y); ctx.lineTo(x+dw,y+L); | |
| ctx.moveTo(x+dw,y+dh-L); ctx.lineTo(x+dw,y+dh); ctx.lineTo(x+dw-L,y+dh); | |
| ctx.moveTo(x+L,y+dh); ctx.lineTo(x,y+dh); ctx.lineTo(x,y+dh-L); | |
| ctx.stroke(); | |
| } | |
| } | |
| // rally points | |
| for(const e of S.sel){ | |
| if(e.kind==='building'&&e.built&&e.rally&&e.def.trains){ | |
| const r=e.rally; | |
| ctx.strokeStyle='rgba(255,255,255,0.25)'; | |
| ctx.setLineDash([4,5]); ctx.lineWidth=1; | |
| ctx.beginPath(); ctx.moveTo(e.x,(e.tileY+e.h)*T); ctx.lineTo(r.x,r.y); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle='rgba(232,198,106,'+(0.6+0.3*Math.sin(S.time*5))+')'; | |
| ctx.beginPath(); ctx.moveTo(r.x,r.y); ctx.lineTo(r.x,r.y-12); ctx.lineTo(r.x+9,r.y-9); ctx.lineTo(r.x,r.y-6); ctx.fill(); | |
| } | |
| } | |
| // gather visible drawables, sort by ground-y | |
| const draws=[]; | |
| for(const t of trees){ | |
| if(!onScreen(t.x,t.y,60))continue; | |
| if(fog[idx(t.tx,t.ty)]===0)continue; | |
| draws.push({y:t.y+2,f:()=>drawTree(ctx,t)}); | |
| } | |
| for(const r of rocks){ | |
| if(!onScreen(r.x,r.y,60))continue; | |
| if(fog[idx(r.tx,r.ty)]===0)continue; | |
| draws.push({y:r.y+2,f:()=>drawRock(ctx,r)}); | |
| } | |
| for(const m of mines){ | |
| if(!onScreen(m.x,m.y,80))continue; | |
| if(fog[idx(m.tx,m.ty)]===0&&fog[idx(m.tx+1,m.ty+1)]===0)continue; | |
| draws.push({y:m.y+26,f:()=>drawMine(ctx,m)}); | |
| } | |
| for(const b of buildings){ | |
| if(b.dead||!onScreen(b.x,b.y,140))continue; | |
| draws.push({y:(b.tileY+b.h)*T,f:()=>drawBuilding(ctx,b)}); | |
| } | |
| for(const u of units){ | |
| if(u.dead||!onScreen(u.x,u.y,60))continue; | |
| draws.push({y:u.y,f:()=>drawUnitSprite(ctx,u)}); | |
| } | |
| for(const c of creeps){ | |
| if(c.dead)continue; | |
| if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue; | |
| if(!onScreen(c.x,c.y,60))continue; | |
| draws.push({y:c.y,f:()=>drawCreepSprite(ctx,c)}); | |
| } | |
| draws.sort((a,b)=>a.y-b.y); | |
| for(const d of draws)d.f(); | |
| // health / progress bars | |
| for(const u of units){ | |
| if(u.dead||!onScreen(u.x,u.y,60))continue; | |
| if(u.hp<u.maxHp||S.sel.includes(u)) | |
| drawBar(u.x-11,u.y-u.def.radius*2-14,22,3.4,u.hp/u.maxHp); | |
| } | |
| for(const c of creeps){ | |
| if(c.dead||!onScreen(c.x,c.y,60))continue; | |
| if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue; | |
| if(c.hp<c.maxHp) drawBar(c.x-11,c.y-c.def.radius*2-12,22,3.4,c.hp/c.maxHp); | |
| } | |
| for(const b of buildings){ | |
| if(b.dead||!onScreen(b.x,b.y,140))continue; | |
| const w=Math.max(26,b.w*T*0.6), bx=b.x-w/2, by=b.tileY*T-8; | |
| if(!b.built) drawBar(bx,by,w,4,b.progress/b.def.time,'build'); | |
| else if(b.hp<b.maxHp||S.sel.includes(b)) drawBar(bx,by,w,4,b.hp/b.maxHp); | |
| } | |
| // projectiles | |
| for(const p of projectiles){ | |
| if(!onScreen(p.x,p.y,40))continue; | |
| ctx.strokeStyle=p.color; ctx.lineWidth=2; | |
| const a=p.ang||0; | |
| ctx.beginPath(); ctx.moveTo(p.x-Math.cos(a)*7,p.y-Math.sin(a)*7); ctx.lineTo(p.x,p.y); ctx.stroke(); | |
| ctx.fillStyle='rgba(255,255,255,0.8)'; | |
| ctx.beginPath(); ctx.arc(p.x,p.y,1.6,0,Math.PI*2); ctx.fill(); | |
| } | |
| // particles | |
| for(const p of particles){ | |
| if(!onScreen(p.x,p.y,20))continue; | |
| ctx.globalAlpha=1-p.t/p.max; | |
| ctx.fillStyle=p.color; | |
| ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,Math.PI*2); ctx.fill(); | |
| } | |
| ctx.globalAlpha=1; | |
| // floating text | |
| ctx.font='600 11.5px ui-sans-serif,system-ui'; | |
| ctx.textAlign='center'; | |
| for(const f of floaters){ | |
| ctx.globalAlpha=1-f.t/f.max; | |
| ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=3; | |
| ctx.strokeText(f.text,f.x,f.y); | |
| ctx.fillStyle=f.color; | |
| ctx.fillText(f.text,f.x,f.y); | |
| } | |
| ctx.globalAlpha=1; | |
| // placement ghost | |
| if(S.placing){ | |
| const d=BUILDS[S.placing.type]; | |
| const x=S.placing.tx*T, y=S.placing.ty*T, dw=d.w*T, dh=d.h*T; | |
| ctx.fillStyle=S.placing.ok?'rgba(90,230,120,0.25)':'rgba(230,80,70,0.28)'; | |
| ctx.fillRect(x,y,dw,dh); | |
| ctx.strokeStyle=S.placing.ok?'rgba(120,255,150,0.9)':'rgba(255,110,100,0.9)'; | |
| ctx.lineWidth=1.6; | |
| ctx.strokeRect(x+1,y+1,dw-2,dh-2); | |
| ctx.globalAlpha=0.55; | |
| BLD_DRAW[S.placing.type](ctx,x,y,null); | |
| ctx.globalAlpha=1; | |
| ctx.strokeStyle='rgba(255,255,255,0.18)'; | |
| ctx.beginPath(); ctx.arc(x+dw/2,y+dh/2,d.sight*T,0,Math.PI*2); ctx.stroke(); | |
| } | |
| // fog of war | |
| const fctx=fogCv.getContext('2d'); | |
| const img=fctx.createImageData(MAP_W,MAP_H); | |
| for(let i=0;i<MAP_W*MAP_H;i++){ | |
| const a=fog[i]===0?255:fog[i]===1?135:0; | |
| img.data[i*4]=0; img.data[i*4+1]=0; img.data[i*4+2]=0; img.data[i*4+3]=a; | |
| } | |
| fctx.putImageData(img,0,0); | |
| ctx.imageSmoothingEnabled=true; | |
| ctx.drawImage(fogCv,0,0,MAP_W,MAP_H,0,0,WORLD_W,WORLD_H); | |
| ctx.imageSmoothingEnabled=false; | |
| ctx.restore(); | |
| // screen-space: selection box | |
| if(drag.active&&drag.moved){ | |
| const x=Math.min(drag.sx,drag.cx), y=Math.min(drag.sy,drag.cy); | |
| const w=Math.abs(drag.cx-drag.sx), h=Math.abs(drag.cy-drag.sy); | |
| ctx.fillStyle='rgba(127,224,255,0.12)'; | |
| ctx.fillRect(x,y,w,h); | |
| ctx.strokeStyle='rgba(127,224,255,0.8)'; | |
| ctx.lineWidth=1; | |
| ctx.strokeRect(x+0.5,y+0.5,w,h); | |
| } | |
| } | |
| /* ---- minimap ---- */ | |
| const mini=document.getElementById('mini'), mctx=mini.getContext('2d'); | |
| const MINI=162, MS=MINI/MAP_W; | |
| const fogMini=document.createElement('canvas'); | |
| fogMini.width=MAP_W; fogMini.height=MAP_H; | |
| function renderMini(){ | |
| mctx.imageSmoothingEnabled=false; | |
| mctx.clearRect(0,0,MINI,MINI); | |
| mctx.drawImage(miniBase,0,0,MAP_W,MAP_H,0,0,MINI,MINI); | |
| // trees & rocks | |
| mctx.fillStyle='#2c6e31'; | |
| for(const t of trees){ | |
| if(fog[idx(t.tx,t.ty)]===0)continue; | |
| mctx.fillRect(t.tx*MS,t.ty*MS,MS,MS); | |
| } | |
| mctx.fillStyle='#8d8a80'; | |
| for(const r of rocks){ | |
| if(fog[idx(r.tx,r.ty)]===0)continue; | |
| mctx.fillRect(r.tx*MS,r.ty*MS,MS,MS); | |
| } | |
| // mines | |
| mctx.fillStyle='#f6c545'; | |
| for(const m of mines){ | |
| if(fog[idx(m.tx,m.ty)]===0)continue; | |
| mctx.fillRect(m.tx*MS-1,m.ty*MS-1,MS*2+2,MS*2+2); | |
| } | |
| // buildings | |
| for(const b of buildings){ | |
| if(b.dead)continue; | |
| mctx.fillStyle=b.built?'#6fc3ff':'#3a6a8f'; | |
| mctx.fillRect(b.tileX*MS,b.tileY*MS,b.w*MS,b.h*MS); | |
| } | |
| // units | |
| mctx.fillStyle='#bfe8ff'; | |
| for(const u of units){ | |
| if(u.dead)continue; | |
| mctx.fillRect(u.x/T*MS-1,u.y/T*MS-1,2.4,2.4); | |
| } | |
| mctx.fillStyle='#ff6a5e'; | |
| for(const c of creeps){ | |
| if(c.dead)continue; | |
| if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue; | |
| mctx.fillRect(c.x/T*MS-1.2,c.y/T*MS-1.2,2.8,2.8); | |
| } | |
| // fog overlay | |
| const fctx=fogMini.getContext('2d'); | |
| const img=fctx.createImageData(MAP_W,MAP_H); | |
| for(let i=0;i<MAP_W*MAP_H;i++){ | |
| const a=fog[i]===0?235:fog[i]===1?110:0; | |
| img.data[i*4]=2; img.data[i*4+1]=4; img.data[i*4+2]=8; img.data[i*4+3]=a; | |
| } | |
| fctx.putImageData(img,0,0); | |
| mctx.drawImage(fogMini,0,0,MAP_W,MAP_H,0,0,MINI,MINI); | |
| // camera rect | |
| mctx.strokeStyle='rgba(255,255,255,0.85)'; | |
| mctx.lineWidth=1; | |
| mctx.strokeRect(S.cam.x/T*MS+0.5,S.cam.y/T*MS+0.5,vw/T*MS,vh/T*MS); | |
| } | |
| /* ================================================================ | |
| UI | |
| ================================================================ */ | |
| const $=id=>document.getElementById(id); | |
| let toastTimer=null; | |
| function toast(msg,ms=2600){ | |
| const el=$('toast'); | |
| el.textContent=msg; | |
| el.classList.add('show'); | |
| clearTimeout(toastTimer); | |
| toastTimer=setTimeout(()=>el.classList.remove('show'),ms); | |
| } | |
| /* ---- icon cache ---- */ | |
| const iconCache={}; | |
| function iconCanvas(key,drawFn){ | |
| if(iconCache[key])return iconCache[key]; | |
| const c=document.createElement('canvas'); | |
| c.width=60; c.height=60; | |
| const g=c.getContext('2d'); | |
| drawFn(g); | |
| iconCache[key]=c; | |
| return c; | |
| } | |
| function unitIcon(type){ | |
| return iconCanvas('u_'+type,g=>{ | |
| const o={phase:0,moving:false,face:1,attackAnim:0.15,swing:0.4,carry:null}; | |
| if(type==='knight'){ | |
| g.translate(30,52); g.scale(1.25,1.25); | |
| g.fillStyle='#6e5236'; | |
| g.beginPath(); g.ellipse(0,-10,11,6.5,0,0,Math.PI*2); g.fill(); | |
| g.beginPath(); g.ellipse(10,-15,4,3.4,-0.5,0,Math.PI*2); g.fill(); | |
| drawHumanoid(g,0,-8,{...o,body:'#9aa3ad',trim:'#4a86d8',helm:true,tool:'lance'}); | |
| }else{ | |
| g.translate(30,50); g.scale(1.45,1.45); | |
| if(type==='worker')drawHumanoid(g,0,0,{...o,body:'#8a6a3f',trim:'#4a86d8',pants:'#5a4632',hat:'#4a86d8',tool:'pick'}); | |
| else if(type==='footman')drawHumanoid(g,0,0,{...o,body:'#8d97a3',trim:'#4a86d8',pants:'#5a5148',helm:true,tool:'sword'}); | |
| else if(type==='archer')drawHumanoid(g,0,0,{...o,body:'#5a7a4a',trim:'#3a5230',pants:'#4a4238',hood:'#3f6638',tool:'bow'}); | |
| } | |
| }); | |
| } | |
| function bldIcon(type){ | |
| return iconCanvas('b_'+type,g=>{ | |
| const d=BUILDS[type]; | |
| const s=Math.min(54/(d.w*T),44/(d.h*T)); | |
| g.translate(30-d.w*T*s/2, 56-d.h*T*s); | |
| g.scale(s,s); | |
| BLD_DRAW[type](g,0,0,null); | |
| }); | |
| } | |
| function glyphIcon(key,drawFn){ | |
| return iconCanvas('g_'+key,g=>{ | |
| g.translate(30,30); | |
| drawFn(g); | |
| }); | |
| } | |
| const ICONS={ | |
| build:()=>glyphIcon('build',g=>{ // hammer | |
| g.rotate(-0.6); | |
| g.fillStyle='#8a6a3f'; g.fillRect(-2,-2,4,20); | |
| g.fillStyle='#b9b0a0'; g.beginPath(); g.roundRect(-9,-10,18,9,2); g.fill(); | |
| g.fillStyle='#d7dbe0'; g.fillRect(-9,-10,18,2.5); | |
| }), | |
| stop:()=>glyphIcon('stop',g=>{ | |
| g.fillStyle='#c04a3a'; | |
| g.beginPath(); | |
| const r=13; | |
| for(let i=0;i<8;i++){const a=Math.PI/8+i*Math.PI/4; g.lineTo(Math.cos(a)*r,Math.sin(a)*r);} | |
| g.closePath(); g.fill(); | |
| g.fillStyle='#fff'; g.fillRect(-2.5,-7,5,14); | |
| }), | |
| hold:()=>glyphIcon('hold',g=>{ | |
| g.fillStyle='#4a86d8'; | |
| g.beginPath(); g.moveTo(0,-14); g.lineTo(12,-9); g.lineTo(10,4); g.quadraticCurveTo(6,12,0,15); | |
| g.quadraticCurveTo(-6,12,-10,4); g.lineTo(-12,-9); g.closePath(); g.fill(); | |
| g.fillStyle='#e8c66a'; | |
| g.beginPath(); g.arc(0,-1,4.5,0,Math.PI*2); g.fill(); | |
| }), | |
| atkmove:()=>glyphIcon('atkmove',g=>{ | |
| g.strokeStyle='#e8c66a'; g.lineWidth=3.4; g.lineCap='round'; | |
| g.beginPath(); g.moveTo(-11,11); g.lineTo(9,-9); g.stroke(); | |
| g.beginPath(); g.moveTo(-9,-7); g.lineTo(11,9); g.stroke(); | |
| g.fillStyle='#e8c66a'; | |
| g.beginPath(); g.moveTo(9,-9); g.lineTo(1,-11); g.lineTo(11,-1); g.fill(); | |
| g.beginPath(); g.moveTo(-9,-7); g.lineTo(-11,1); g.lineTo(-1,-9); g.fill(); | |
| }), | |
| cancel:()=>glyphIcon('cancel',g=>{ | |
| g.strokeStyle='#c8cdd4'; g.lineWidth=4.5; g.lineCap='round'; | |
| g.beginPath(); g.moveTo(-9,-9); g.lineTo(9,9); g.moveTo(9,-9); g.lineTo(-9,9); g.stroke(); | |
| }), | |
| back:()=>glyphIcon('back',g=>{ | |
| g.fillStyle='#c8cdd4'; | |
| g.beginPath(); g.moveTo(8,-12); g.lineTo(-8,0); g.lineTo(8,12); g.lineTo(8,5); g.lineTo(2,5); | |
| g.lineTo(2,-5); g.lineTo(8,-5); g.closePath(); g.fill(); | |
| }), | |
| }; | |
| /* ---- command card ---- */ | |
| const cardEl=$('card'); | |
| let cardBtns=[]; | |
| function makeButton(cfg){ | |
| const el=document.createElement('div'); | |
| el.className='btn'; | |
| el.title=cfg.tip||cfg.label||''; | |
| if(cfg.act)el.dataset.act=cfg.act; | |
| if(cfg.key){ | |
| const k=document.createElement('span'); | |
| k.className='key'; k.textContent=cfg.key; | |
| el.appendChild(k); | |
| } | |
| const ic=document.createElement('canvas'); | |
| ic.width=60; ic.height=60; | |
| ic.getContext('2d').drawImage(cfg.icon,0,0); | |
| el.appendChild(ic); | |
| if(cfg.label){ | |
| const l=document.createElement('span'); | |
| l.className='lbl'; l.textContent=cfg.label; | |
| el.appendChild(l); | |
| } | |
| if(cfg.cost&&(cfg.cost.gold||cfg.cost.wood)){ | |
| const c=document.createElement('span'); | |
| c.className='cost'; | |
| let html=''; | |
| if(cfg.cost.gold)html+='<span class="g">'+cfg.cost.gold+'</span>'; | |
| if(cfg.cost.gold&&cfg.cost.wood)html+=' '; | |
| if(cfg.cost.wood)html+='<span class="w">'+cfg.cost.wood+'</span>'; | |
| c.innerHTML=html; | |
| el.appendChild(c); | |
| cfg._costEl=c; | |
| } | |
| el.addEventListener('click',()=>{ | |
| cfg.fn(); | |
| el.blur?.(); | |
| document.activeElement?.blur?.(); | |
| }); | |
| cfg._el=el; | |
| return el; | |
| } | |
| function refreshCard(){ | |
| cardEl.innerHTML=''; | |
| cardBtns=[]; | |
| const list=cardForSelection(); | |
| for(const cfg of list){ | |
| if(!cfg)continue; | |
| const el=makeButton(cfg); | |
| cardEl.appendChild(el); | |
| cardBtns.push(cfg); | |
| } | |
| updateCardStates(); | |
| } | |
| function updateCardStates(){ | |
| for(const cfg of cardBtns){ | |
| if(!cfg._el)continue; | |
| const en=cfg.enabled?cfg.enabled():true; | |
| cfg._el.classList.toggle('dis',!en); | |
| if(cfg._costEl&&cfg.cost){ | |
| const noG=cfg.cost.gold&&S.gold<cfg.cost.gold; | |
| const noW=cfg.cost.wood&&S.wood<cfg.cost.wood; | |
| cfg._costEl.querySelector('.g')?.classList.toggle('no',!!noG); | |
| cfg._costEl.querySelector('.w')?.classList.toggle('no',!!noW); | |
| } | |
| } | |
| } | |
| function trainCfg(b,type,key){ | |
| const d=UNITS[type]; | |
| return {act:'train-'+type, icon:unitIcon(type), label:d.name, key, cost:{gold:d.gold,wood:d.wood}, | |
| tip:'Train '+d.name+' ('+d.hp+' hp, '+d.dmg+' dmg)', | |
| fn:()=>enqueueTrain(b,type), | |
| enabled:()=>!b.dead&&b.built&&b.queue.length<5}; | |
| } | |
| function cardForSelection(){ | |
| const sel=S.sel.filter(e=>!e.dead); | |
| if(S.placing){ | |
| return [{act:'cancel-place',icon:ICONS.cancel(),label:'Cancel (Esc)',key:'Esc', | |
| fn:()=>{S.placing=null;refreshCard();}}]; | |
| } | |
| if(S.buildMenu){ | |
| const out=[]; | |
| const bkeys={farm:'F',barracks:'B',tower:'T',townhall:'H'}; | |
| for(const type of ['farm','barracks','tower','townhall']){ | |
| const d=BUILDS[type]; | |
| out.push({act:'place-'+type,icon:bldIcon(type),label:d.name,key:bkeys[type], | |
| cost:{gold:d.gold,wood:d.wood},tip:d.name+' — '+d.hp+' hp', | |
| fn:()=>startPlacing(type), | |
| enabled:()=>canAfford(d)}); | |
| } | |
| out.push({act:'back',icon:ICONS.back(),label:'Back',key:'Esc',fn:()=>{S.buildMenu=false;refreshCard();}}); | |
| return out; | |
| } | |
| const workers=sel.filter(u=>u.kind==='unit'&&u.def.canGather); | |
| const military=sel.filter(u=>u.kind==='unit'&&u.def.military); | |
| const bld=sel.length===1&&sel[0].kind==='building'?sel[0]:null; | |
| if(bld){ | |
| if(!bld.built){ | |
| return [{act:'cancel-build',icon:ICONS.cancel(),label:'Cancel (75%)', | |
| fn:()=>cancelConstruction(bld)}]; | |
| } | |
| const out=[]; | |
| if(bld.def.trains){ | |
| if(bld.type==='townhall') out.push(trainCfg(bld,'worker','W')); | |
| if(bld.type==='barracks'){ | |
| out.push(trainCfg(bld,'footman','F')); | |
| out.push(trainCfg(bld,'archer','A')); | |
| out.push(trainCfg(bld,'knight','K')); | |
| } | |
| if(bld.queue.length) | |
| out.push({act:'cancel-queue',icon:ICONS.cancel(),label:'Cancel last',key:'X', | |
| fn:()=>cancelLastQueued(bld)}); | |
| } | |
| return out; | |
| } | |
| if(workers.length){ | |
| return [ | |
| {act:'buildmenu',icon:ICONS.build(),label:'Build',key:'B', | |
| fn:()=>{S.buildMenu=true;refreshCard();}, | |
| enabled:()=>workers.some(u=>!u.dead)}, | |
| {act:'stop',icon:ICONS.stop(),label:'Stop',key:'S',fn:cmdStop}, | |
| ]; | |
| } | |
| if(military.length||sel.some(u=>u.kind==='unit')){ | |
| return [ | |
| {act:'stop',icon:ICONS.stop(),label:'Stop',key:'S',fn:cmdStop}, | |
| {act:'hold',icon:ICONS.hold(),label:'Hold',key:'H',fn:cmdHold}, | |
| {act:'atkmove',icon:ICONS.atkmove(),label:'Attack-Move',key:'A', | |
| fn:()=>{S.attackMoveMode=!S.attackMoveMode; | |
| toast(S.attackMoveMode?'Attack-move: click a destination':'Attack-move cancelled',1400);}}, | |
| ]; | |
| } | |
| return []; | |
| } | |
| function cmdStop(){ | |
| for(const u of S.sel) if(u.kind==='unit'){ u.order={type:'idle'}; u.path=null; u.target=null; } | |
| } | |
| function cmdHold(){ | |
| for(const u of S.sel) if(u.kind==='unit'){ u.order={type:'hold'}; u.path=null; u.target=null; } | |
| } | |
| /* ---- placement ---- */ | |
| function startPlacing(type){ | |
| const d=BUILDS[type]; | |
| if(!canAfford(d)){ toast('Not enough resources'); return; } | |
| S.buildMenu=false; | |
| S.placing={type,tx:0,ty:0,ok:false}; | |
| updateGhost(mouse.x+S.cam.x,mouse.y+S.cam.y); | |
| refreshCard(); | |
| } | |
| function updateGhost(wx,wy){ | |
| if(!S.placing)return; | |
| const d=BUILDS[S.placing.type]; | |
| const tx=Math.round(wx/T-d.w/2), ty=Math.round(wy/T-d.h/2); | |
| S.placing.tx=tx; S.placing.ty=ty; | |
| S.placing.ok=validPlacement(S.placing.type,tx,ty); | |
| } | |
| function validPlacement(type,tx,ty){ | |
| const d=BUILDS[type]; | |
| for(let y=0;y<d.h;y++)for(let x=0;x<d.w;x++){ | |
| const gx=tx+x, gy=ty+y; | |
| if(gx<0||gy<0||gx>=MAP_W||gy>=MAP_H)return false; | |
| if(terrain[idx(gx,gy)]===WATER)return false; | |
| if(blocked[idx(gx,gy)])return false; | |
| if(fog[idx(gx,gy)]===0)return false; | |
| } | |
| return true; | |
| } | |
| function placeBuilding(type,tx,ty){ | |
| const d=BUILDS[type]; | |
| if(!canAfford(d)){ toast('Not enough resources'); return false; } | |
| if(!validPlacement(type,tx,ty))return false; | |
| pay(d); | |
| const b=makeBuilding(type,tx,ty); | |
| buildings.push(b); | |
| setBlockRect(tx,ty,d.w,d.h,1); | |
| burst(b.x,b.y,'#ffe9a0',8,80); | |
| let assigned=false; | |
| for(const u of S.sel){ | |
| if(u.kind==='unit'&&u.def.canBuild){ | |
| u.order={type:'build',b}; u.path=null; u.target=null; | |
| assigned=true; | |
| } | |
| } | |
| if(!assigned) toast('Site placed — right-click it with a worker to build'); | |
| refreshInfo(); | |
| return true; | |
| } | |
| /* ---- info panel ---- */ | |
| function barHTML(ratio,cls=''){ | |
| const c=ratio>0.5?'':ratio>0.25?'mid':'low'; | |
| return '<div class="bar '+c+' '+cls+'"><i style="width:'+Math.round(clamp(ratio,0,1)*100)+'%"></i></div>'; | |
| } | |
| function orderLabel(u){ | |
| switch(u.order.type){ | |
| case 'gather': return u.order.phase==='work'?(u.order.res?.kind==='mine'?'Mining gold':'Chopping lumber'):'Going to gather'; | |
| case 'return': return 'Returning resources'; | |
| case 'build': return 'Constructing '+ (u.order.b?u.order.b.def.name:''); | |
| case 'attack': return 'Attacking '+(u.target?u.target.def.name:''); | |
| case 'move': return 'Moving'; | |
| case 'attackmove': return 'Attack-moving'; | |
| case 'hold': return 'Holding position'; | |
| default: return 'Idle'; | |
| } | |
| } | |
| function refreshInfo(){ | |
| const el=$('infoInner'); | |
| const sel=S.sel.filter(e=>!e.dead); | |
| if(!sel.length){ | |
| el.innerHTML='<div class="name">Tiny Realms</div><div class="sub">Select units by dragging a box. Right-click to command them.<br>Uncover 100% of the map to win — press <b>H</b> for help.</div>'; | |
| return; | |
| } | |
| if(sel.length===1){ | |
| const e=sel[0]; | |
| if(e.kind==='unit'){ | |
| el.innerHTML='<div class="name">'+e.def.name+'</div>'+barHTML(e.hp/e.maxHp)+ | |
| '<div class="row"><span class="sub">'+Math.ceil(e.hp)+'/'+e.maxHp+' hp · '+e.def.dmg+' dmg'+(e.def.range>40?' · ranged':'')+'</span></div>'+ | |
| '<div class="row"><span class="sub">'+orderLabel(e)+(e.carryAmt?' · carrying '+e.carryAmt+' '+(e.carry==='gold'?'gold':'lumber'):'')+'</span></div>'; | |
| return; | |
| } | |
| if(e.kind==='creep'){ | |
| el.innerHTML='<div class="name">'+e.def.name+' <span class="sub">(neutral)</span></div>'+barHTML(e.hp/e.maxHp)+ | |
| '<div class="row"><span class="sub">'+Math.ceil(e.hp)+'/'+e.maxHp+' hp · '+e.def.dmg+' dmg · bounty '+e.def.bounty+' gold</span></div>'+ | |
| '<div class="row"><span class="sub">Attacks units that wander close.</span></div>'; | |
| return; | |
| } | |
| if(e.kind==='building'){ | |
| let html='<div class="name">'+e.def.name+(e.built?'':' (under construction)')+'</div>'; | |
| if(!e.built){ | |
| html+=barHTML(e.progress/e.def.time,'build')+ | |
| '<div class="row"><span class="sub">Building… '+Math.floor(e.progress/e.def.time*100)+'%</span></div>'; | |
| }else{ | |
| html+=barHTML(e.hp/e.maxHp)+ | |
| '<div class="row"><span class="sub">'+Math.ceil(e.hp)+'/'+e.maxHp+' hp</span></div>'; | |
| if(e.def.dropoff)html+='<div class="row"><span class="sub">Resource drop-off point.</span></div>'; | |
| if(e.def.dmg)html+='<div class="row"><span class="sub">Attacks nearby beasts automatically ('+e.def.dmg+' dmg).</span></div>'; | |
| if(e.def.food)html+='<div class="row"><span class="sub">Provides '+e.def.food+' food.</span></div>'; | |
| if(e.queue.length){ | |
| html+='<div class="row"><span class="sub">Training:</span><span class="q">'; | |
| e.queue.forEach((t,i)=>{ | |
| const p=i===0?e.trainT/UNITS[t].time:0; | |
| html+='<span class="prog" style="--p:'+Math.round(p*100)+'%"><canvas data-icon="u_'+t+'"></canvas></span>'; | |
| }); | |
| html+='</span></div>'; | |
| }else if(e.def.trains){ | |
| html+='<div class="row"><span class="sub">Right-click to set rally point.</span></div>'; | |
| } | |
| } | |
| el.innerHTML=html; | |
| el.querySelectorAll('canvas[data-icon]').forEach(c=>{ | |
| const src=unitIcon(c.dataset.icon.slice(2)); | |
| c.width=52; c.height=52; c.getContext('2d').drawImage(src,0,0); | |
| }); | |
| return; | |
| } | |
| } | |
| // multiple | |
| const counts={}; | |
| for(const e of sel){ | |
| if(e.kind!=='unit')continue; | |
| counts[e.type]=(counts[e.type]||0)+1; | |
| } | |
| let html='<div class="name">'+sel.length+' units selected</div><div class="row">'; | |
| for(const t in counts){ | |
| html+='<span class="cnt"><canvas data-icon="u_'+t+'"></canvas>×'+counts[t]+'</span>'; | |
| } | |
| html+='</div><div class="row"><span class="sub">Right-click: move / gather / attack.</span></div>'; | |
| el.innerHTML=html; | |
| el.querySelectorAll('canvas[data-icon]').forEach(c=>{ | |
| const src=unitIcon(c.dataset.icon.slice(2)); | |
| c.width=40; c.height=40; c.getContext('2d').drawImage(src,0,0); | |
| }); | |
| } | |
| /* ---- top bar ---- */ | |
| function refreshTop(){ | |
| $('vGold').textContent=Math.floor(S.gold); | |
| $('vWood').textContent=Math.floor(S.wood); | |
| $('vFood').textContent=S.foodUsed+'/'+S.foodCap; | |
| $('resFood').classList.toggle('warn',S.foodUsed>=S.foodCap); | |
| const pct=Math.floor(S.explored/S.totalTiles*100); | |
| $('vMap').textContent=pct+'%'; | |
| $('mapBar').firstElementChild.style.width=pct+'%'; | |
| $('btnSpeed').textContent=S.paused?'⏸ paused':('▶ '+S.speed+'×'); | |
| } | |
| /* ================================================================ | |
| INPUT | |
| ================================================================ */ | |
| const stage=$('stage'); | |
| const mouse={x:0,y:0,over:false}; | |
| const drag={active:false,moved:false,sx:0,sy:0,cx:0,cy:0}; | |
| const keys={}; | |
| let lastClick={t:0,kind:null,type:null}; | |
| function canvasPos(e){ | |
| const r=cv.getBoundingClientRect(); | |
| return {x:e.clientX-r.left,y:e.clientY-r.top}; | |
| } | |
| cv.addEventListener('contextmenu',e=>e.preventDefault()); | |
| cv.addEventListener('mousedown',e=>{ | |
| const p=canvasPos(e); | |
| if(e.button===0){ | |
| if(S.placing){ | |
| if(S.placing.ok){ | |
| const ok=placeBuilding(S.placing.type,S.placing.tx,S.placing.ty); | |
| if(ok&&!e.shiftKey){ S.placing=null; refreshCard(); } | |
| }else toast('Can\'t build there'); | |
| return; | |
| } | |
| drag.active=true; drag.moved=false; | |
| drag.sx=drag.cx=p.x; drag.sy=drag.cy=p.y; | |
| }else if(e.button===2){ | |
| if(S.placing){ S.placing=null; refreshCard(); return; } | |
| commandAt(p.x+S.cam.x,p.y+S.cam.y); | |
| } | |
| }); | |
| window.addEventListener('mousemove',e=>{ | |
| const p=canvasPos(e); | |
| mouse.x=p.x; mouse.y=p.y; | |
| const r=cv.getBoundingClientRect(); | |
| mouse.over=e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom; | |
| if(drag.active){ | |
| drag.cx=p.x; drag.cy=p.y; | |
| if(Math.hypot(drag.cx-drag.sx,drag.cy-drag.sy)>5)drag.moved=true; | |
| } | |
| if(S.placing) updateGhost(p.x+S.cam.x,p.y+S.cam.y); | |
| else updateCursor(p.x+S.cam.x,p.y+S.cam.y); | |
| }); | |
| window.addEventListener('mouseup',e=>{ | |
| if(e.button!==0||!drag.active)return; | |
| drag.active=false; | |
| const p=canvasPos(e); | |
| if(drag.moved){ | |
| boxSelect(Math.min(drag.sx,p.x)+S.cam.x,Math.min(drag.sy,p.y)+S.cam.y, | |
| Math.abs(p.x-drag.sx),Math.abs(p.y-drag.sy),e.shiftKey); | |
| }else{ | |
| clickSelect(p.x+S.cam.x,p.y+S.cam.y,e.shiftKey); | |
| } | |
| }); | |
| function updateCursor(wx,wy){ | |
| const pick=pickEntity(wx,wy); | |
| let cur='default'; | |
| if(pick){ | |
| if(pick.kind==='creep')cur='crosshair'; | |
| else if((pick.kind==='tree'||pick.kind==='mine')&&S.sel.some(u=>u.kind==='unit'&&u.def.canGather))cur='pointer'; | |
| else if(pick.kind==='building'&&pick.ent.team==='player')cur='pointer'; | |
| } | |
| cv.style.cursor=cur; | |
| } | |
| function pickEntity(wx,wy){ | |
| let best=null,bd=1e9; | |
| for(const u of units){ | |
| if(u.dead)continue; | |
| const d=dist(wx,wy,u.x,u.y-8); | |
| if(d<u.def.radius+7&&d<bd){bd=d;best={ent:u,kind:'unit'};} | |
| } | |
| for(const c of creeps){ | |
| if(c.dead)continue; | |
| if(fog[idx(clamp(tileX(c.x),0,MAP_W-1),clamp(tileY(c.y),0,MAP_H-1))]!==2)continue; | |
| const d=dist(wx,wy,c.x,c.y-8); | |
| if(d<c.def.radius+7&&d<bd){bd=d;best={ent:c,kind:'creep'};} | |
| } | |
| for(const b of buildings){ | |
| if(b.dead)continue; | |
| if(wx>=b.tileX*T-4&&wx<=(b.tileX+b.w)*T+4&&wy>=b.tileY*T-4&&wy<=(b.tileY+b.h)*T+4){ | |
| const d=dist(wx,wy,b.x,b.y); | |
| if(d<bd){bd=d;best={ent:b,kind:'building'};} | |
| } | |
| } | |
| for(const t of trees){ | |
| if(t.dead)continue; | |
| if(fog[idx(t.tx,t.ty)]===0)continue; | |
| const d=dist(wx,wy,t.x,t.y-8); | |
| if(d<15&&d<bd){bd=d;best={ent:t,kind:'tree'};} | |
| } | |
| for(const m of mines){ | |
| if(m.dead)continue; | |
| if(wx>m.tx*T-8&&wx<(m.tx+2)*T+8&&wy>m.ty*T-12&&wy<(m.ty+2)*T+8){ | |
| const d=dist(wx,wy,m.x,m.y); | |
| if(d<34&&d<bd){bd=d;best={ent:m,kind:'mine'};} | |
| } | |
| } | |
| for(const r of rocks){ | |
| if(r.dead)continue; | |
| const d=dist(wx,wy,r.x,r.y-6); | |
| if(d<14&&d<bd){bd=d;best={ent:r,kind:'rock'};} | |
| } | |
| return best; | |
| } | |
| function clickSelect(wx,wy,shift){ | |
| const pick=pickEntity(wx,wy); | |
| const now=performance.now(); | |
| if(pick&&(pick.kind==='unit'||pick.kind==='creep')){ | |
| const e=pick.ent; | |
| if(now-lastClick.t<350&&lastClick.type===e.type&&e.kind==='unit'){ | |
| setSelection(units.filter(u=>u.type===e.type&&!u.dead)); | |
| lastClick.t=0; | |
| return; | |
| } | |
| lastClick={t:now,kind:e.kind,type:e.type}; | |
| if(shift){ | |
| if(S.sel.includes(e)) setSelection(S.sel.filter(x=>x!==e)); | |
| else setSelection(S.sel.concat([e])); | |
| }else setSelection([e]); | |
| }else if(pick&&pick.kind==='building'){ | |
| if(shift){ | |
| if(S.sel.includes(pick.ent)) setSelection(S.sel.filter(x=>x!==pick.ent)); | |
| else setSelection(S.sel.concat([pick.ent])); | |
| }else setSelection([pick.ent]); | |
| }else if(!shift){ | |
| setSelection([]); | |
| } | |
| } | |
| function boxSelect(x,y,w,h,shift){ | |
| const list=units.filter(u=>!u.dead&&u.x>=x&&u.x<=x+w&&u.y>=y-20&&u.y<=y+h); | |
| if(list.length) setSelection(shift?[...new Set(S.sel.concat(list))]:list); | |
| else if(!shift) setSelection([]); | |
| } | |
| function setSelection(list){ | |
| S.sel=list.filter(e=>!e.dead); | |
| S.buildMenu=false; | |
| if(!S.sel.some(u=>u.kind==='unit'&&u.def.military))S.attackMoveMode=false; | |
| refreshCard(); | |
| refreshInfo(); | |
| } | |
| function commandAt(wx,wy){ | |
| const pick=pickEntity(wx,wy); | |
| const mobs=S.sel.filter(u=>u.kind==='unit'&&!u.dead); | |
| if(!mobs.length){ | |
| const b=S.sel.length===1&&S.sel[0].kind==='building'?S.sel[0]:null; | |
| if(b&&b.built&&b.def.trains){ | |
| if(pick&&(pick.kind==='tree'||pick.kind==='mine'||pick.kind==='creep')) b.rally={ent:pick.ent}; | |
| else b.rally={x:wx,y:wy}; | |
| burst(wx,wy,'#e8c66a',4,50); | |
| } | |
| return; | |
| } | |
| if(S.attackMoveMode){ | |
| mobs.forEach((u,i)=>{ | |
| if(!u.def.military){ setOrderMove(u,wx,wy); return; } | |
| const off=formationOffset(i,mobs.length); | |
| setOrderMove(u,wx+off.x,wy+off.y,'attackmove'); | |
| }); | |
| S.attackMoveMode=false; | |
| refreshCard(); | |
| return; | |
| } | |
| mobs.forEach((u,i)=>{ | |
| if(pick){ | |
| const e=pick.ent; | |
| if(pick.kind==='creep'){ orderAttack(u,e); return; } | |
| if((pick.kind==='tree'||pick.kind==='mine')&&u.def.canGather){ orderGather(u,e); return; } | |
| if(pick.kind==='building'&&e.team==='player'){ | |
| if(!e.built&&u.def.canBuild){ u.order={type:'build',b:e}; u.path=null; u.target=null; return; } | |
| if(e.def.dropoff&&u.carryAmt>0){ startReturn(u,u.order.type==='gather'?u.order.res:null); return; } | |
| } | |
| } | |
| const off=formationOffset(i,mobs.length); | |
| setOrderMove(u,wx+off.x,wy+off.y); | |
| }); | |
| } | |
| /* ---- minimap input ---- */ | |
| function miniToWorld(e){ | |
| const r=mini.getBoundingClientRect(); | |
| const mx=(e.clientX-r.left)/r.width*MAP_W, my=(e.clientY-r.top)/r.height*MAP_H; | |
| return {x:mx*T,y:my*T}; | |
| } | |
| let miniDrag=false; | |
| mini.addEventListener('contextmenu',e=>e.preventDefault()); | |
| mini.addEventListener('mousedown',e=>{ | |
| const w=miniToWorld(e); | |
| if(e.button===0){ miniDrag=true; centerOn(w.x,w.y); } | |
| else if(e.button===2){ commandAt(w.x,w.y); } | |
| }); | |
| window.addEventListener('mousemove',e=>{ | |
| if(miniDrag&&e.buttons&1){ const w=miniToWorld(e); centerOn(w.x,w.y); } | |
| }); | |
| window.addEventListener('mouseup',()=>miniDrag=false); | |
| /* ---- keyboard ---- */ | |
| window.addEventListener('keydown',e=>{ | |
| if(e.target&&e.target.tagName==='INPUT')return; | |
| const k=e.key.length===1?e.key.toLowerCase():e.key; | |
| if(k==='Escape'){ | |
| if(!$('help').classList.contains('hidden')){ $('help').classList.add('hidden'); return; } | |
| if(S.placing){ S.placing=null; refreshCard(); return; } | |
| if(S.buildMenu){ S.buildMenu=false; refreshCard(); return; } | |
| if(S.attackMoveMode){ S.attackMoveMode=false; toast('Attack-move cancelled',1200); return; } | |
| setSelection([]); | |
| return; | |
| } | |
| if(k==='F1'){ | |
| e.preventDefault(); | |
| $('help').classList.toggle('hidden'); | |
| return; | |
| } | |
| if(k==='p'){ cycleSpeed(); return; } | |
| if(k===' '){ | |
| e.preventDefault(); | |
| const sel=S.sel.filter(x=>!x.dead); | |
| if(sel.length){ | |
| const cx=sel.reduce((s,x)=>s+x.x,0)/sel.length; | |
| const cy=sel.reduce((s,x)=>s+x.y,0)/sel.length; | |
| centerOn(cx,cy); | |
| } | |
| return; | |
| } | |
| keys[e.code]=true; | |
| if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code))e.preventDefault(); | |
| // card hotkeys | |
| for(const cfg of cardBtns){ | |
| if(cfg.key&&cfg.key.toLowerCase()===k){ | |
| const en=cfg.enabled?cfg.enabled():true; | |
| if(en)cfg.fn(); | |
| else toast('Can\'t do that right now'); | |
| return; | |
| } | |
| } | |
| }); | |
| window.addEventListener('keyup',e=>{ keys[e.code]=false; }); | |
| window.addEventListener('blur',()=>{ for(const k in keys)keys[k]=false; }); | |
| /* ---- camera ---- */ | |
| function updateCamera(dt){ | |
| let vx=0,vy=0; | |
| const sp=620; | |
| if(keys.ArrowLeft)vx-=1; if(keys.ArrowRight)vx+=1; | |
| if(keys.ArrowUp)vy-=1; if(keys.ArrowDown)vy+=1; | |
| if(mouse.over){ | |
| const edge=22; | |
| if(mouse.x<edge)vx-=1; else if(mouse.x>vw-edge)vx+=1; | |
| if(mouse.y<edge)vy-=1; else if(mouse.y>vh-edge)vy+=1; | |
| } | |
| if(vx||vy){ | |
| const l=Math.hypot(vx,vy); | |
| S.cam.x+=vx/l*sp*dt; S.cam.y+=vy/l*sp*dt; | |
| clampCam(); | |
| } | |
| } | |
| /* ---- speed ---- */ | |
| function cycleSpeed(){ | |
| if(!S.paused&&S.speed===1){ S.speed=2; } | |
| else if(!S.paused&&S.speed===2){ S.speed=4; } | |
| else if(!S.paused&&S.speed===4){ S.paused=true; } | |
| else { S.paused=false; S.speed=1; } | |
| refreshTop(); | |
| } | |
| $('btnSpeed').addEventListener('click',()=>{cycleSpeed();$('btnSpeed').blur();}); | |
| $('btnHelp').addEventListener('click',()=>{$('help').classList.remove('hidden');$('btnHelp').blur();}); | |
| $('btnHelpClose').addEventListener('click',()=>$('help').classList.add('hidden')); | |
| $('btnContinue').addEventListener('click',()=>$('banner').classList.add('hidden')); | |
| /* ================================================================ | |
| TEST / DEBUG API | |
| ================================================================ */ | |
| window.GAME={ | |
| version:1, | |
| get state(){ | |
| return {gold:Math.floor(S.gold),wood:Math.floor(S.wood),foodUsed:S.foodUsed,foodCap:S.foodCap, | |
| exploredPct:Math.floor(S.explored/S.totalTiles*100),units:units.length, | |
| buildings:buildings.length,creeps:creeps.filter(c=>!c.dead).length, | |
| speed:S.speed,paused:S.paused,time:Math.floor(S.time),won:S.won, | |
| selected:S.sel.length}; | |
| }, | |
| selectWorkers(){ setSelection(units.filter(u=>u.def.canGather&&!u.dead)); return S.sel.length; }, | |
| selectMilitary(){ setSelection(units.filter(u=>u.def.military&&!u.dead)); return S.sel.length; }, | |
| selectBuildingOfType(type){ const b=buildings.find(b=>b.type===type&&!b.dead); if(b)setSelection([b]); return !!b; }, | |
| nearestMinePos(){ | |
| const th=buildings.find(b=>b.type==='townhall'&&!b.dead); | |
| const m=nearestMine(th?th.x:WORLD_W/2,th?th.y:WORLD_H/2,999); | |
| return m?{x:m.x,y:m.y,id:m.id}:null; | |
| }, | |
| orderGatherMine(){ | |
| const sel=S.sel.filter(u=>u.kind==='unit'&&u.def.canGather&&!u.dead); | |
| if(!sel.length)return 0; | |
| const cx=sel.reduce((s,u)=>s+u.x,0)/sel.length, cy=sel.reduce((s,u)=>s+u.y,0)/sel.length; | |
| const m=nearestMine(cx,cy,9999)||mines[0]; | |
| let n=0; | |
| for(const u of sel){ orderGather(u,m); n++; } | |
| return n; | |
| }, | |
| rightClick(x,y){ commandAt(x,y); }, | |
| buildSpot(type){ | |
| // find a valid spot near the town hall | |
| const th=buildings.find(b=>b.type==='townhall'&&!b.dead); | |
| const cx=th?th.tileX:10, cy=th?th.tileY:10; | |
| for(let r=3;r<20;r++)for(let a=0;a<16;a++){ | |
| const tx=cx+Math.round(Math.cos(a/16*Math.PI*2)*r); | |
| const ty=cy+Math.round(Math.sin(a/16*Math.PI*2)*r); | |
| if(validPlacement(type,tx,ty))return {tx,ty}; | |
| } | |
| return null; | |
| }, | |
| place(type,tx,ty){ | |
| if(!validPlacement(type,tx,ty))return false; | |
| pay(BUILDS[type]); | |
| const b=makeBuilding(type,tx,ty); | |
| buildings.push(b); setBlockRect(tx,ty,b.w,b.h,1); | |
| for(const u of units) if(u.def.canBuild&&!u.dead){ u.order={type:'build',b}; u.path=null; } | |
| return true; | |
| }, | |
| train(type){ | |
| const need=type==='worker'?'townhall':'barracks'; | |
| const b=buildings.find(b=>b.type===need&&b.built&&!b.dead); | |
| if(!b)return false; | |
| enqueueTrain(b,type); | |
| return b.queue.length>0; | |
| }, | |
| setSpeed(n){ S.paused=n===0; S.speed=Math.max(1,n); refreshTop(); }, | |
| revealAll(){ | |
| for(let i=0;i<fog.length;i++){ if(fog[i]===0)S.explored++; fog[i]=1; } | |
| updateFog(); | |
| }, | |
| centerOn, | |
| unitScreenPositions(){ | |
| return units.filter(u=>!u.dead).map(u=>({id:u.id,type:u.type,x:u.x-S.cam.x,y:u.y-S.cam.y})); | |
| }, | |
| entityScreenPos(id){ | |
| const e=[...units,...buildings,...creeps].find(x=>x.id===id); | |
| return e?{x:e.x-S.cam.x,y:e.y-S.cam.y}:null; | |
| }, | |
| camHere(){ return {x:S.cam.x,y:S.cam.y}; }, | |
| debugGiveArmy(){ | |
| const th=buildings.find(b=>b.type==='townhall'&&!b.dead); | |
| const x=th?th.x:WORLD_W/2, y=th?th.y+60:WORLD_H/2; | |
| for(let i=0;i<3;i++){ const u=makeUnit('footman',x-40+i*26,y); units.push(u); S.foodUsed+=u.def.food; } | |
| for(let i=0;i<2;i++){ const u=makeUnit('archer',x-25+i*30,y+30); units.push(u); S.foodUsed+=u.def.food; } | |
| return 5; | |
| }, | |
| debugSpawnWolf(){ | |
| const th=buildings.find(b=>b.type==='townhall'&&!b.dead); | |
| const x=(th?th.x:WORLD_W/2)+80, y=(th?th.y:WORLD_H/2)+40; | |
| spawnCreep('wolf',x,y); | |
| return {x,y}; | |
| }, | |
| debugFastBuild(){ | |
| for(const b of buildings) if(!b.built&&!b.dead) completeBuilding(b); | |
| }, | |
| selectionInfo(){ | |
| return S.sel.map(e=>({kind:e.kind,type:e.type,id:e.id})); | |
| }, | |
| }; | |
| /* ================================================================ | |
| BOOT + LOOP | |
| ================================================================ */ | |
| buildGround(); | |
| buildMiniBase(); | |
| genWorld(); | |
| resize(); | |
| window.addEventListener('resize',resize); | |
| refreshTop(); | |
| refreshCard(); | |
| refreshInfo(); | |
| updateFog(); | |
| // intro hints | |
| setTimeout(()=>toast('Drag a box around your workers, then right-click the gold mine to gather gold.',6000),800); | |
| setTimeout(()=>toast('Select a worker and press B to build — start with a Farm for food.',6000),8000); | |
| setTimeout(()=>toast('Raise an army at the Barracks and uncover 100% of the map to win!',6000),15500); | |
| let last=performance.now(), uiAcc=0; | |
| function frame(now){ | |
| requestAnimationFrame(frame); | |
| let dtR=(now-last)/1000; | |
| last=now; | |
| dtR=Math.min(dtR,0.05); | |
| updateCamera(dtR); | |
| if(S.placing&&mouse.over) updateGhost(mouse.x+S.cam.x,mouse.y+S.cam.y); | |
| const dt=S.paused?0:dtR*S.speed; | |
| if(dt>0){ S.time+=dt; updateWorld(dt); } | |
| updateFog(); | |
| render(); | |
| renderMini(); | |
| uiAcc+=dtR; | |
| if(uiAcc>0.12){ | |
| uiAcc=0; | |
| refreshTop(); | |
| updateCardStates(); | |
| if(S.sel.some(e=>e.dead)) setSelection(S.sel.filter(e=>!e.dead)); | |
| else refreshInfo(); | |
| } | |
| } | |
| requestAnimationFrame(frame); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment