Created
July 1, 2026 14:10
-
-
Save senko/0957ae90be6cc0307a91a5b4b3c48aa4 to your computer and use it in GitHub Desktop.
RTS game by Sonnet 5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Ironhold — RTS</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --panel-bg: rgba(18,16,14,0.92); | |
| --panel-border: #6b5636; | |
| --gold: #e8c44a; | |
| --wood: #b5895a; | |
| --accent: #7fd858; | |
| --danger: #e05a4e; | |
| } | |
| *{box-sizing:border-box;} | |
| html,body{ | |
| margin:0; padding:0; width:100%; height:100%; overflow:hidden; | |
| background:#0a0a0a; font-family:'Trebuchet MS', Verdana, sans-serif; | |
| -webkit-user-select:none; user-select:none; | |
| } | |
| #gameCanvas{ | |
| position:fixed; top:0; left:0; display:block; background:#000; cursor:default; | |
| } | |
| #gameCanvas.placing{ cursor:crosshair; } | |
| .panel{ | |
| position:fixed; background:var(--panel-bg); border:2px solid var(--panel-border); | |
| border-radius:8px; color:#eee; font-size:13px; z-index:20; | |
| box-shadow:0 4px 14px rgba(0,0,0,0.6); | |
| } | |
| #hudTop{ | |
| top:10px; left:50%; transform:translateX(-50%); | |
| display:flex; align-items:center; gap:18px; padding:8px 18px; | |
| } | |
| #hudTop .stat{ display:flex; align-items:center; gap:6px; font-size:15px; font-weight:bold; white-space:nowrap;} | |
| .icon{ width:16px; height:16px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,0.4); } | |
| .icon.gold{ background:radial-gradient(circle at 35% 35%, #fff2b0, var(--gold)); } | |
| .icon.wood{ background:radial-gradient(circle at 35% 35%, #d8b78e, var(--wood)); border-radius:3px;} | |
| .icon.pop{ background:radial-gradient(circle at 35% 35%, #bfe8ff, #5a9bd8); } | |
| .icon.explore{ background:radial-gradient(circle at 35% 35%, #d8d8d8, #888); } | |
| #titleTag{ font-family:'Cinzel',serif; font-weight:700; color:var(--gold); font-size:16px; letter-spacing:1px; margin-right:6px;} | |
| #helpBtn{ | |
| background:#332a1a; color:var(--gold); border:1px solid var(--panel-border); border-radius:50%; | |
| width:24px; height:24px; cursor:pointer; font-weight:bold; | |
| } | |
| #commandPanel{ | |
| bottom:10px; left:10px; width:340px; min-height:150px; padding:10px 12px; | |
| display:flex; flex-direction:column; gap:8px; | |
| } | |
| #selectionInfo{ font-size:13px; line-height:1.5; } | |
| #selectionInfo .selTitle{ font-family:'Cinzel',serif; color:var(--gold); font-size:15px; font-weight:700; } | |
| .hpbarMini{ height:6px; background:#3a1e1e; border-radius:3px; overflow:hidden; margin-top:2px; border:1px solid #000;} | |
| .hpbarMini > div{ height:100%; background:var(--accent); } | |
| #actionButtons{ display:flex; flex-wrap:wrap; gap:6px; } | |
| .actBtn{ | |
| background:#26221a; border:1px solid var(--panel-border); color:#eee; border-radius:6px; | |
| padding:6px 8px; font-size:12px; cursor:pointer; width:96px; text-align:left; line-height:1.35; | |
| } | |
| .actBtn b{ display:block; color:var(--gold); font-size:12px;} | |
| .actBtn small{ color:#bbb; font-size:10px; } | |
| .actBtn:hover{ background:#3a3324; border-color:var(--gold); } | |
| .actBtn:disabled{ opacity:0.4; cursor:not-allowed; } | |
| .actBtn:disabled:hover{ background:#26221a; border-color:var(--panel-border); } | |
| .queueRow{ display:flex; align-items:center; gap:6px; font-size:11px; margin-top:2px;} | |
| .queueBar{ flex:1; height:8px; background:#222; border-radius:4px; overflow:hidden; border:1px solid #000;} | |
| .queueBar>div{ height:100%; background:#7fb0e0; } | |
| #minimapWrap{ | |
| bottom:10px; right:10px; padding:6px; z-index:20; | |
| } | |
| #minimapCanvas{ display:block; image-rendering:pixelated; cursor:pointer; border-radius:4px;} | |
| #helpOverlay{ | |
| top:50%; left:50%; transform:translate(-50%,-50%); width:420px; padding:18px 20px; z-index:40; | |
| } | |
| #helpOverlay h2{ font-family:'Cinzel',serif; color:var(--gold); margin:0 0 10px 0; font-size:18px;} | |
| #helpOverlay ul{ margin:0; padding-left:18px; font-size:13px; line-height:1.7; } | |
| #helpOverlay .closeBtn{ | |
| margin-top:14px; background:#332a1a; color:var(--gold); border:1px solid var(--panel-border); | |
| border-radius:6px; padding:6px 14px; cursor:pointer; font-weight:bold; | |
| } | |
| .hidden{ display:none !important; } | |
| #banner{ | |
| top:80px; left:50%; transform:translateX(-50%); z-index:30; padding:12px 24px; | |
| font-family:'Cinzel',serif; color:var(--gold); font-size:18px; font-weight:700; | |
| border-color:var(--gold); text-align:center; pointer-events:none; | |
| } | |
| #placementIndicator{ | |
| top:64px; left:50%; transform:translateX(-50%); z-index:25; padding:8px 18px; | |
| color:var(--accent); border-color:var(--accent); font-weight:bold; font-size:13px; | |
| pointer-events:none; text-align:center; white-space:nowrap; | |
| } | |
| #startOverlay{ | |
| position:fixed; inset:0; background:rgba(6,5,4,0.95); z-index:50; | |
| display:flex; align-items:center; justify-content:center; flex-direction:column; color:#eee; | |
| text-align:center; padding:20px; | |
| } | |
| #startOverlay h1{ font-family:'Cinzel',serif; color:var(--gold); font-size:38px; margin:0 0 6px 0; letter-spacing:2px;} | |
| #startOverlay p{ max-width:520px; font-size:14px; line-height:1.6; color:#ccc; } | |
| #startOverlay button{ | |
| margin-top:18px; background:#332a1a; color:var(--gold); border:2px solid var(--gold); | |
| border-radius:8px; padding:12px 30px; font-size:16px; cursor:pointer; font-family:'Cinzel',serif; font-weight:700; | |
| } | |
| #startOverlay button:hover{ background:#4a3d24; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="gameCanvas"></canvas> | |
| <div id="hudTop" class="panel"> | |
| <span id="titleTag">IRONHOLD</span> | |
| <div class="stat"><span class="icon gold"></span><span id="goldVal">0</span></div> | |
| <div class="stat"><span class="icon wood"></span><span id="woodVal">0</span></div> | |
| <div class="stat"><span class="icon pop"></span><span id="popVal">0/0</span></div> | |
| <div class="stat"><span class="icon explore"></span><span id="exploreVal">0%</span></div> | |
| <button id="helpBtn" title="Help">?</button> | |
| </div> | |
| <div id="commandPanel" class="panel"> | |
| <div id="selectionInfo"><i style="color:#999;">Nothing selected</i></div> | |
| <div id="actionButtons"></div> | |
| </div> | |
| <div id="minimapWrap" class="panel"> | |
| <canvas id="minimapCanvas"></canvas> | |
| </div> | |
| <div id="banner" class="panel hidden"></div> | |
| <div id="placementIndicator" class="panel hidden"></div> | |
| <div id="helpOverlay" class="panel hidden"> | |
| <h2>How to Play</h2> | |
| <ul> | |
| <li><b>Left click</b> a unit/building to select it, or <b>drag</b> to box-select units.</li> | |
| <li><b>Right click</b> ground to move, a tree/mine to gather, or an enemy to attack.</li> | |
| <li>Select a <b>Worker</b> and click a Build button, then click on the map to place it.</li> | |
| <li>Select a completed <b>building</b> to train units from it.</li> | |
| <li>Scroll with <b>WASD / arrow keys</b> or by moving the mouse to the screen edge.</li> | |
| <li>Click the <b>minimap</b> to jump the camera anywhere you've explored.</li> | |
| <li>Explore the whole map, gather Gold & Wood, and clear out hostile creeps!</li> | |
| </ul> | |
| <button class="closeBtn" id="helpCloseBtn">Got it</button> | |
| </div> | |
| <div id="startOverlay"> | |
| <h1>IRONHOLD</h1> | |
| <p>Build your stronghold, gather gold and wood, train workers and soldiers, and explore | |
| every corner of the map. Hostile creeps roam the wilds — build an army to deal with them.</p> | |
| <button id="startBtn">Begin</button> | |
| </div> | |
| <script> | |
| 'use strict'; | |
| /* ========================================================================================= | |
| CONFIG | |
| ========================================================================================= */ | |
| const TILE = 32; | |
| const MAP_W = 60, MAP_H = 42; | |
| const TILE_GRASS = 0, TILE_WATER = 1, TILE_ROCK = 2; | |
| const GATHER_TICK = 0.4; // seconds per harvest tick | |
| const GATHER_PER_TICK = 2; // resource units per tick | |
| const WORKER_CAPACITY = 10; | |
| const BUILDING_DEFS = { | |
| townhall: { name:'Town Hall', w:3,h:3, hp:1500, cost:{gold:400,wood:200}, buildTime:55, trains:['worker'], providesPop:10, vision:7, dropoff:true, buildable:true }, | |
| barracks: { name:'Barracks', w:3,h:2, hp:800, cost:{gold:120,wood:80}, buildTime:24, trains:['soldier','archer'], vision:5, buildable:true }, | |
| farm: { name:'Farm', w:2,h:2, hp:400, cost:{gold:30,wood:90}, buildTime:14, trains:[], providesPop:6, vision:4, buildable:true }, | |
| tower: { name:'Watch Tower', w:2,h:2, hp:550, cost:{gold:110,wood:40}, buildTime:20, trains:[], vision:7, attack:{damage:16,range:5,cooldown:0.9}, buildable:true }, | |
| }; | |
| // the starting town hall (index 0) is placed free, not counted as "buildable" cost-triggered | |
| const UNIT_DEFS = { | |
| worker: { name:'Worker', hp:40, cost:{gold:50,wood:0}, buildTime:8, speed:2.6, vision:4, radius:0.32, popCost:1, canGather:true, canBuild:true }, | |
| soldier: { name:'Soldier', hp:120, cost:{gold:80,wood:20}, buildTime:16, speed:2.2, vision:5, radius:0.38, popCost:2, attack:{damage:13,range:0.9,cooldown:0.85} }, | |
| archer: { name:'Archer', hp:70, cost:{gold:70,wood:40}, buildTime:16, speed:2.3, vision:6, radius:0.36, popCost:2, attack:{damage:9,range:4.2,cooldown:0.75} }, | |
| }; | |
| const CREEP_DEFS = { | |
| wolf: { name:'Wolf', hp:60, speed:2.4, radius:0.36, attack:{damage:7,range:0.9,cooldown:0.9}, aggro:4.2, leash:9 }, | |
| bandit: { name:'Bandit', hp:95, speed:1.9, radius:0.4, attack:{damage:11,range:0.9,cooldown:1.0}, aggro:5, leash:10 }, | |
| }; | |
| const START_GOLD = 250, START_WOOD = 150; | |
| /* ========================================================================================= | |
| GLOBAL STATE | |
| ========================================================================================= */ | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const minimapCanvas = document.getElementById('minimapCanvas'); | |
| const mctx = minimapCanvas.getContext('2d'); | |
| let dpr = Math.max(1, window.devicePixelRatio || 1); | |
| const world = { | |
| tiles: [], // [ty][tx] = {type, shade} | |
| resourceNodes: [], // gold / wood nodes | |
| buildings: [], | |
| units: [], | |
| creeps: [], | |
| particles: [], | |
| }; | |
| const player = { gold: START_GOLD, wood: START_WOOD, popCurrent: 0, popMax: 0 }; | |
| let fog = null; // Uint8Array MAP_W*MAP_H 0 unexplored, 1 explored, 2 visible | |
| let exploredPct = 0; | |
| let bannerShown = false; | |
| const camera = { x: 0, y: 0 }; | |
| const keys = {}; | |
| const mouse = { x:0, y:0, down:false, dragStart:null, dragging:false, button:null }; | |
| let selection = []; // array of unit/creep refs currently selected (units only, or a single creep) | |
| let selectedBuilding = null; | |
| let placement = null; // { key, def } | |
| let idSeq = 1; | |
| function nextId(){ return idSeq++; } | |
| let lastTime = 0; | |
| let gameStarted = false; | |
| /* ========================================================================================= | |
| UTILITY | |
| ========================================================================================= */ | |
| function clamp(v,min,max){ return v<min?min:(v>max?max:v); } | |
| function dist(a,b){ return Math.hypot(a.x-b.x, a.y-b.y); } | |
| function inBounds(tx,ty){ return tx>=0 && ty>=0 && tx<MAP_W && ty<MAP_H; } | |
| function tileIndex(tx,ty){ return ty*MAP_W+tx; } | |
| function tileAt(tx,ty){ return inBounds(tx,ty) ? world.tiles[ty][tx] : null; } | |
| function rand(min,max){ return min + Math.random()*(max-min); } | |
| function randInt(min,max){ return Math.floor(rand(min,max+1)); } | |
| function choice(arr){ return arr[Math.floor(Math.random()*arr.length)]; } | |
| /* ========================================================================================= | |
| MAP GENERATION | |
| ========================================================================================= */ | |
| const SPAWN_TX = 7, SPAWN_TY = 7; | |
| function genTiles(){ | |
| for(let y=0;y<MAP_H;y++){ | |
| const row = []; | |
| for(let x=0;x<MAP_W;x++){ | |
| row.push({ type: TILE_GRASS, shade: Math.random() }); | |
| } | |
| world.tiles.push(row); | |
| } | |
| // lakes | |
| const lakes = randInt(3,5); | |
| for(let i=0;i<lakes;i++){ | |
| const cx = randInt(4, MAP_W-5), cy = randInt(4, MAP_H-5); | |
| const r = rand(3.5,7); | |
| stampBlob(cx,cy,r,TILE_WATER); | |
| } | |
| // rocky clusters | |
| const rocks = randInt(4,6); | |
| for(let i=0;i<rocks;i++){ | |
| const cx = randInt(4, MAP_W-5), cy = randInt(4, MAP_H-5); | |
| const r = rand(2.5,5); | |
| stampBlob(cx,cy,r,TILE_ROCK); | |
| } | |
| // clear spawn area | |
| for(let y=SPAWN_TY-5;y<=SPAWN_TY+6;y++){ | |
| for(let x=SPAWN_TX-5;x<=SPAWN_TX+7;x++){ | |
| const t = tileAt(x,y); | |
| if(t) t.type = TILE_GRASS; | |
| } | |
| } | |
| } | |
| function stampBlob(cx,cy,r,type){ | |
| const r2 = r*r; | |
| for(let y=Math.floor(cy-r); y<=Math.ceil(cy+r); y++){ | |
| for(let x=Math.floor(cx-r); x<=Math.ceil(cx+r); x++){ | |
| if(!inBounds(x,y)) continue; | |
| const dx=x-cx, dy=y-cy; | |
| const d2 = dx*dx+dy*dy; | |
| if(d2 <= r2 * rand(0.7,1.05)){ | |
| world.tiles[y][x].type = type; | |
| } | |
| } | |
| } | |
| } | |
| function footprintFree(tx,ty,w,h){ | |
| for(let y=ty;y<ty+h;y++){ | |
| for(let x=tx;x<tx+w;x++){ | |
| if(!inBounds(x,y)) return false; | |
| const t = world.tiles[y][x]; | |
| if(t.type !== TILE_GRASS) return false; | |
| if(occupiedTile(x,y)) return false; | |
| } | |
| } | |
| return true; | |
| } | |
| function occupiedTile(tx,ty){ | |
| for(const b of world.buildings){ | |
| if(tx>=b.tx && tx<b.tx+b.w && ty>=b.ty && ty<b.ty+b.h) return true; | |
| } | |
| for(const n of world.resourceNodes){ | |
| if(tx>=n.tx && tx<n.tx+n.w && ty>=n.ty && ty<n.ty+n.h) return true; | |
| } | |
| return false; | |
| } | |
| function addResourceNode(type, tx, ty, w, h, amount){ | |
| const node = { | |
| id: nextId(), type, tx, ty, w, h, | |
| x: tx+w/2, y: ty+h/2, | |
| amount, max: amount, alive:true, | |
| }; | |
| world.resourceNodes.push(node); | |
| return node; | |
| } | |
| function placeGoldMine(minD, maxTries){ | |
| for(let i=0;i<maxTries;i++){ | |
| const tx = randInt(2, MAP_W-4), ty = randInt(2, MAP_H-4); | |
| const d = Math.hypot(tx-SPAWN_TX, ty-SPAWN_TY); | |
| if(d < minD) continue; | |
| if(footprintFree(tx,ty,2,2)){ | |
| return addResourceNode('gold', tx, ty, 2, 2, randInt(3000,5000)); | |
| } | |
| } | |
| return null; | |
| } | |
| function placeForestPatch(cx, cy, count){ | |
| let placed = 0; | |
| let tries = 0; | |
| while(placed < count && tries < count*8){ | |
| tries++; | |
| const tx = clamp(Math.round(cx + rand(-3,3)), 0, MAP_W-1); | |
| const ty = clamp(Math.round(cy + rand(-3,3)), 0, MAP_H-1); | |
| if(footprintFree(tx,ty,1,1)){ | |
| addResourceNode('wood', tx, ty, 1, 1, randInt(60,110)); | |
| placed++; | |
| } | |
| } | |
| } | |
| function genResources(){ | |
| // guaranteed nearby forest patches | |
| placeForestPatch(SPAWN_TX+9, SPAWN_TY-2, 8); | |
| placeForestPatch(SPAWN_TX-2, SPAWN_TY+10, 8); | |
| // guaranteed nearby gold mine | |
| placeGoldMine(11, 300) || placeGoldMine(6, 300); | |
| // extra random mines spread further out | |
| const extraMines = randInt(3,5); | |
| for(let i=0;i<extraMines;i++) placeGoldMine(16, 300); | |
| // extra random forest patches | |
| const extraPatches = randInt(8,12); | |
| for(let i=0;i<extraPatches;i++){ | |
| const cx = randInt(3, MAP_W-4), cy = randInt(3, MAP_H-4); | |
| if(Math.hypot(cx-SPAWN_TX,cy-SPAWN_TY) < 5) continue; | |
| placeForestPatch(cx, cy, randInt(4,9)); | |
| } | |
| } | |
| function genCreeps(){ | |
| const count = randInt(11,16); | |
| let placed = 0, tries = 0; | |
| while(placed < count && tries < count*15){ | |
| tries++; | |
| const tx = randInt(2, MAP_W-3), ty = randInt(2, MAP_H-3); | |
| if(Math.hypot(tx-SPAWN_TX,ty-SPAWN_TY) < 15) continue; | |
| const t = tileAt(tx,ty); | |
| if(!t || t.type !== TILE_GRASS) continue; | |
| if(occupiedTile(tx,ty)) continue; | |
| const kind = Math.random() < 0.6 ? 'wolf' : 'bandit'; | |
| spawnCreep(kind, tx+0.5, ty+0.5); | |
| placed++; | |
| } | |
| } | |
| /* ========================================================================================= | |
| ENTITY FACTORIES | |
| ========================================================================================= */ | |
| function placeBuildingInstant(key, tx, ty, constructing){ | |
| const def = BUILDING_DEFS[key]; | |
| const b = { | |
| id: nextId(), key, owner:'player', tx, ty, w:def.w, h:def.h, | |
| x: tx+def.w/2, y: ty+def.h/2, | |
| hp: constructing ? Math.max(1, Math.round(def.hp*0.1)) : def.hp, | |
| maxHp: def.hp, | |
| constructing: !!constructing, | |
| buildProgress: constructing ? 0 : 1, | |
| buildTime: def.buildTime, | |
| trainQueue: [], | |
| rally: null, | |
| builders: [], | |
| atkCooldown: 0, | |
| target: null, | |
| }; | |
| world.buildings.push(b); | |
| if(!constructing && def.providesPop) player.popMax += def.providesPop; | |
| return b; | |
| } | |
| function spawnUnit(key, x, y){ | |
| const def = UNIT_DEFS[key]; | |
| const u = { | |
| id: nextId(), key, owner:'player', x, y, hp: def.hp, maxHp: def.hp, | |
| speed: def.speed, vision: def.vision, radius: def.radius, popCost: def.popCost, | |
| canGather: !!def.canGather, canBuild: !!def.canBuild, attackDef: def.attack || null, | |
| command: null, state: 'idle', target: null, path: null, pathIndex: 0, | |
| carrying: 0, carryType: null, harvestTimer: 0, atkCooldown: 0, repathAt: 0, | |
| facing: 0, | |
| }; | |
| world.units.push(u); | |
| return u; | |
| } | |
| function spawnCreep(key, x, y){ | |
| const def = CREEP_DEFS[key]; | |
| const c = { | |
| id: nextId(), key, owner:'neutral', x, y, hp: def.hp, maxHp: def.hp, | |
| speed: def.speed, radius: def.radius, attackDef: def.attack, | |
| aggro: def.aggro, leash: def.leash, home: {x,y}, | |
| target: null, path: null, pathIndex: 0, atkCooldown: 0, repathAt: 0, | |
| wanderTimer: rand(1,4), facing: 0, | |
| }; | |
| world.creeps.push(c); | |
| return c; | |
| } | |
| /* ========================================================================================= | |
| PATHFINDING (A*) | |
| ========================================================================================= */ | |
| class MinHeap{ | |
| constructor(){ this.a = []; } | |
| get size(){ return this.a.length; } | |
| push(item, pri){ | |
| this.a.push([pri,item]); | |
| let i = this.a.length-1; | |
| while(i>0){ | |
| const p = (i-1)>>1; | |
| if(this.a[p][0] <= this.a[i][0]) break; | |
| [this.a[p],this.a[i]] = [this.a[i],this.a[p]]; | |
| i = p; | |
| } | |
| } | |
| pop(){ | |
| const top = this.a[0]; | |
| const last = this.a.pop(); | |
| if(this.a.length){ | |
| this.a[0] = last; | |
| let i=0; | |
| while(true){ | |
| let l=2*i+1, r=2*i+2, s=i; | |
| if(l<this.a.length && this.a[l][0]<this.a[s][0]) s=l; | |
| if(r<this.a.length && this.a[r][0]<this.a[s][0]) s=r; | |
| if(s===i) break; | |
| [this.a[s],this.a[i]]=[this.a[i],this.a[s]]; | |
| i=s; | |
| } | |
| } | |
| return top[1]; | |
| } | |
| } | |
| function isWalkableTile(tx,ty){ | |
| if(!inBounds(tx,ty)) return false; | |
| const t = world.tiles[ty][tx]; | |
| if(t.type !== TILE_GRASS) return false; | |
| if(occupiedTile(tx,ty)) return false; | |
| return true; | |
| } | |
| function findNearestWalkable(tx,ty,maxR){ | |
| if(isWalkableTile(tx,ty)) return {x:tx,y:ty}; | |
| for(let r=1;r<=maxR;r++){ | |
| for(let dx=-r;dx<=r;dx++){ | |
| for(let dy=-r;dy<=r;dy++){ | |
| if(Math.max(Math.abs(dx),Math.abs(dy)) !== r) continue; | |
| const nx=tx+dx, ny=ty+dy; | |
| if(isWalkableTile(nx,ny)) return {x:nx,y:ny}; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function findAdjacentToFootprint(tx,ty,w,h,maxR){ | |
| const candidates = []; | |
| for(let x=tx-1;x<=tx+w;x++){ | |
| candidates.push([x,ty-1]); | |
| candidates.push([x,ty+h]); | |
| } | |
| for(let y=ty;y<ty+h;y++){ | |
| candidates.push([tx-1,y]); | |
| candidates.push([tx+w,y]); | |
| } | |
| for(const [x,y] of candidates){ | |
| if(isWalkableTile(x,y)) return {x,y}; | |
| } | |
| return findNearestWalkable(tx-1,ty-1,maxR||6); | |
| } | |
| function findPath(sx,sy,tx,ty){ | |
| const start = {x:Math.floor(sx), y:Math.floor(sy)}; | |
| const goal = {x:Math.floor(tx), y:Math.floor(ty)}; | |
| if(!inBounds(goal.x,goal.y)) return null; | |
| if(start.x===goal.x && start.y===goal.y) return [{x:goal.x+0.5,y:goal.y+0.5}]; | |
| const open = new MinHeap(); | |
| const startIdx = tileIndex(start.x,start.y); | |
| const gScore = new Map([[startIdx,0]]); | |
| const cameFrom = new Map(); | |
| const closed = new Set(); | |
| open.push(start, heuristic(start,goal)); | |
| const inOpen = new Set([startIdx]); | |
| let iterations = 0; | |
| const MAX_ITER = 6000; | |
| while(open.size && iterations < MAX_ITER){ | |
| iterations++; | |
| const cur = open.pop(); | |
| const curIdx = tileIndex(cur.x,cur.y); | |
| inOpen.delete(curIdx); | |
| if(closed.has(curIdx)) continue; | |
| closed.add(curIdx); | |
| if(cur.x===goal.x && cur.y===goal.y){ | |
| return reconstructPath(cameFrom, cur); | |
| } | |
| for(const [dx,dy] of NEIGHBORS){ | |
| const nx = cur.x+dx, ny = cur.y+dy; | |
| if(!inBounds(nx,ny)) continue; | |
| const walkable = isWalkableTile(nx,ny) || (nx===goal.x && ny===goal.y); | |
| if(!walkable) continue; | |
| if(dx!==0 && dy!==0){ | |
| // prevent corner cutting | |
| if(!isWalkableTile(cur.x+dx, cur.y) || !isWalkableTile(cur.x, cur.y+dy)) continue; | |
| } | |
| const nIdx = tileIndex(nx,ny); | |
| if(closed.has(nIdx)) continue; | |
| const cost = (dx!==0 && dy!==0) ? 1.4142 : 1; | |
| const tentative = (gScore.get(curIdx)||0) + cost; | |
| if(tentative < (gScore.has(nIdx) ? gScore.get(nIdx) : Infinity)){ | |
| gScore.set(nIdx, tentative); | |
| cameFrom.set(nIdx, cur); | |
| const f = tentative + heuristic({x:nx,y:ny}, goal); | |
| open.push({x:nx,y:ny}, f); | |
| inOpen.add(nIdx); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| const NEIGHBORS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; | |
| function heuristic(a,b){ return Math.hypot(a.x-b.x, a.y-b.y); } | |
| function reconstructPath(cameFrom, node){ | |
| const path = [{x:node.x+0.5, y:node.y+0.5}]; | |
| let idx = tileIndex(node.x,node.y); | |
| let cur = node; | |
| while(cameFrom.has(idx)){ | |
| cur = cameFrom.get(idx); | |
| idx = tileIndex(cur.x,cur.y); | |
| path.unshift({x:cur.x+0.5, y:cur.y+0.5}); | |
| } | |
| return path; | |
| } | |
| function issueMove(entity, tx, ty){ | |
| const dest = findNearestWalkable(Math.floor(tx), Math.floor(ty), 8) || {x:Math.floor(tx),y:Math.floor(ty)}; | |
| const path = findPath(entity.x, entity.y, dest.x, dest.y); | |
| entity.path = path; entity.pathIndex = 0; | |
| return !!path; | |
| } | |
| /* ========================================================================================= | |
| MOVEMENT | |
| ========================================================================================= */ | |
| function moveAlongPath(entity, dt){ | |
| if(!entity.path || entity.pathIndex >= entity.path.length){ | |
| entity.path = null; | |
| return true; // arrived / no path | |
| } | |
| const wp = entity.path[entity.pathIndex]; | |
| const dx = wp.x - entity.x, dy = wp.y - entity.y; | |
| const d = Math.hypot(dx,dy); | |
| const step = entity.speed * dt; | |
| if(d <= step || d < 0.05){ | |
| entity.x = wp.x; entity.y = wp.y; | |
| entity.pathIndex++; | |
| if(entity.pathIndex >= entity.path.length){ | |
| entity.path = null; | |
| return true; | |
| } | |
| } else { | |
| entity.facing = Math.atan2(dy,dx); | |
| entity.x += (dx/d) * step; | |
| entity.y += (dy/d) * step; | |
| } | |
| return false; | |
| } | |
| /* ========================================================================================= | |
| COMBAT HELPERS | |
| ========================================================================================= */ | |
| function isAlive(e){ return e && e.hp > 0 && (e.alive !== false); } | |
| function findNearestEnemy(entity, enemyList, radius){ | |
| let best = null, bestD = Infinity; | |
| for(const e of enemyList){ | |
| if(!isAlive(e)) continue; | |
| const d = dist(entity, e); | |
| if(d <= radius && d < bestD){ best = e; bestD = d; } | |
| } | |
| return best; | |
| } | |
| function tryAttack(attacker, target, dt){ | |
| attacker.atkCooldown -= dt; | |
| const rangeOk = dist(attacker, target) <= attacker.attackDef.range + (target.radius||0.5); | |
| if(!rangeOk) return false; | |
| attacker.facing = Math.atan2(target.y-attacker.y, target.x-attacker.x); | |
| if(attacker.atkCooldown <= 0){ | |
| target.hp -= attacker.attackDef.damage; | |
| attacker.atkCooldown = attacker.attackDef.cooldown; | |
| spawnFloatText(target.x, target.y-0.3, '-'+attacker.attackDef.damage, '#ff8080'); | |
| if(target.hp <= 0) onDeath(target); | |
| } | |
| return true; | |
| } | |
| function chaseAndAttack(attacker, dt, now){ | |
| const t = attacker.target; | |
| if(!isAlive(t)){ attacker.target = null; attacker.path = null; return; } | |
| const d = dist(attacker, t); | |
| if(d <= attacker.attackDef.range + (t.radius||0.5)){ | |
| attacker.path = null; | |
| tryAttack(attacker, t, dt); | |
| } else { | |
| if(!attacker.path || now >= attacker.repathAt){ | |
| attacker.path = findPath(attacker.x, attacker.y, Math.floor(t.x), Math.floor(t.y)); | |
| attacker.pathIndex = 0; | |
| attacker.repathAt = now + 0.9; | |
| } | |
| moveAlongPath(attacker, dt); | |
| } | |
| } | |
| function onDeath(entity){ | |
| if(entity.hp > 0) entity.hp = 0; | |
| if(world.units.includes(entity)){ | |
| player.popCurrent = Math.max(0, player.popCurrent - entity.popCost); | |
| if(selection.includes(entity)) selection = selection.filter(u=>u!==entity); | |
| } else if(world.creeps.includes(entity)){ | |
| player.gold += 5; | |
| spawnFloatText(entity.x, entity.y-0.3, '+5', '#e8c44a'); | |
| if(selectedCreep===entity) selectedCreep = null; | |
| } else if(world.buildings.includes(entity)){ | |
| const def = BUILDING_DEFS[entity.key]; | |
| if(!entity.constructing && def.providesPop) player.popMax = Math.max(0, player.popMax - def.providesPop); | |
| if(selectedBuilding===entity) selectedBuilding = null; | |
| } | |
| } | |
| /* ========================================================================================= | |
| UNIT UPDATE | |
| ========================================================================================= */ | |
| function nearestDropoff(x,y){ | |
| let best=null, bestD=Infinity; | |
| for(const b of world.buildings){ | |
| if(b.owner!=='player' || b.constructing) continue; | |
| if(!BUILDING_DEFS[b.key].dropoff) continue; | |
| const d = Math.hypot(b.x-x,b.y-y); | |
| if(d<bestD){ bestD=d; best=b; } | |
| } | |
| return best; | |
| } | |
| function nearestResourceOfType(type, x, y, excludeId){ | |
| let best=null, bestD=Infinity; | |
| for(const n of world.resourceNodes){ | |
| if(!n.alive || n.type!==type || n.id===excludeId) continue; | |
| const d = Math.hypot(n.x-x,n.y-y); | |
| if(d<bestD){ bestD=d; best=n; } | |
| } | |
| return best; | |
| } | |
| function updateUnit(u, dt, now){ | |
| if(u.hp<=0) return; | |
| if(u.command === 'move'){ | |
| if(moveAlongPath(u, dt)) u.command = null; | |
| return; | |
| } | |
| if(u.command === 'gather'){ | |
| const node = u.target; | |
| if(u.state === 'moving'){ | |
| if(!node || !node.alive){ u.command=null; u.state='idle'; return; } | |
| if(moveAlongPath(u, dt)){ u.state = 'harvesting'; u.harvestTimer = 0; } | |
| return; | |
| } | |
| if(u.state === 'harvesting'){ | |
| if(!node || !node.alive || node.amount<=0){ | |
| const repl = nearestResourceOfType(node?node.type:u.carryType, u.x, u.y, node?node.id:null); | |
| if(repl){ | |
| u.target = repl; u.state='moving'; | |
| const adj = findAdjacentToFootprint(repl.tx,repl.ty,repl.w,repl.h,8); | |
| if(adj) issueMove(u, adj.x+0.5, adj.y+0.5); | |
| } else if(u.carrying>0){ | |
| const drop = nearestDropoff(u.x,u.y); | |
| if(drop){ u.state='returning'; u.target=drop; const adj=findAdjacentToFootprint(drop.tx,drop.ty,drop.w,drop.h,8); if(adj) issueMove(u,adj.x+0.5,adj.y+0.5); } | |
| else { u.command=null; u.state='idle'; } | |
| } else { u.command=null; u.state='idle'; } | |
| return; | |
| } | |
| u.harvestTimer += dt; | |
| if(u.harvestTimer >= GATHER_TICK){ | |
| u.harvestTimer -= GATHER_TICK; | |
| const amt = Math.min(GATHER_PER_TICK, node.amount, WORKER_CAPACITY-u.carrying); | |
| node.amount -= amt; u.carrying += amt; u.carryType = node.type; | |
| if(node.amount<=0) node.alive=false; | |
| } | |
| if(u.carrying >= WORKER_CAPACITY || !node.alive){ | |
| const drop = nearestDropoff(u.x,u.y); | |
| if(drop){ | |
| u.state='returning'; u.pendingNode = node; | |
| const adj = findAdjacentToFootprint(drop.tx,drop.ty,drop.w,drop.h,8); | |
| if(adj) issueMove(u, adj.x+0.5, adj.y+0.5); | |
| } else { u.command=null; u.state='idle'; } | |
| } | |
| return; | |
| } | |
| if(u.state === 'returning'){ | |
| if(moveAlongPath(u, dt)){ | |
| if(u.carrying>0){ | |
| if(u.carryType==='gold') player.gold += u.carrying; else player.wood += u.carrying; | |
| spawnFloatText(u.x,u.y-0.3, '+'+u.carrying, u.carryType==='gold'?'#e8c44a':'#b5895a'); | |
| u.carrying = 0; | |
| } | |
| const backNode = (node && node.alive) ? node : nearestResourceOfType(u.carryType||node?.type, u.x, u.y, null); | |
| if(backNode){ | |
| u.target = backNode; u.state='moving'; | |
| const adj = findAdjacentToFootprint(backNode.tx,backNode.ty,backNode.w,backNode.h,8); | |
| if(adj) issueMove(u, adj.x+0.5, adj.y+0.5); | |
| } else { u.command=null; u.state='idle'; } | |
| } | |
| return; | |
| } | |
| } | |
| if(u.command === 'build'){ | |
| const site = u.target; | |
| if(u.state === 'moving'){ | |
| if(!site || site.buildProgress>=1){ u.command=null; u.state='idle'; return; } | |
| if(moveAlongPath(u, dt)){ u.state='building'; if(!site.builders.includes(u)) site.builders.push(u); } | |
| return; | |
| } | |
| if(u.state === 'building'){ | |
| if(!site || site.hp<=0){ u.command=null; u.state='idle'; return; } | |
| if(site.buildProgress >= 1){ | |
| finishConstruction(site); | |
| u.command=null; u.state='idle'; | |
| return; | |
| } | |
| const builderCount = site.builders.filter(b=>isAlive(b)).length || 1; | |
| site.buildProgress += (0.045 * builderCount * dt * (20/site.buildTime)); | |
| site.buildProgress = Math.min(1, site.buildProgress); | |
| site.hp = Math.round(site.maxHp * (0.1 + 0.9*site.buildProgress)); | |
| if(site.buildProgress >= 1){ | |
| finishConstruction(site); | |
| u.command=null; u.state='idle'; | |
| } | |
| return; | |
| } | |
| } | |
| if(u.command === 'attack'){ | |
| chaseAndAttack(u, dt, now); | |
| if(!u.target) u.command = null; | |
| return; | |
| } | |
| // idle: auto acquire nearby creep if this unit can fight | |
| if(u.attackDef){ | |
| const enemy = findNearestEnemy(u, world.creeps, 3.6); | |
| if(enemy){ u.command='attack'; u.target=enemy; } | |
| } | |
| } | |
| function finishConstruction(site){ | |
| site.constructing = false; | |
| site.buildProgress = 1; | |
| site.hp = site.maxHp; | |
| const def = BUILDING_DEFS[site.key]; | |
| if(def.providesPop) player.popMax += def.providesPop; | |
| site.builders = []; | |
| spawnFloatText(site.x, site.y-0.5, def.name+' complete', '#7fd858'); | |
| } | |
| /* ========================================================================================= | |
| CREEP UPDATE | |
| ========================================================================================= */ | |
| function updateCreep(c, dt, now){ | |
| if(c.hp<=0) return; | |
| if(c.target && isAlive(c.target)){ | |
| const distFromHome = Math.hypot(c.x-c.home.x, c.y-c.home.y); | |
| if(distFromHome > c.leash){ | |
| c.target = null; c.path = null; | |
| issueMove(c, c.home.x, c.home.y); | |
| c.state = 'returning'; | |
| } else { | |
| chaseAndAttack(c, dt, now); | |
| return; | |
| } | |
| } | |
| if(c.state === 'returning'){ | |
| if(moveAlongPath(c, dt)) c.state = 'idle'; | |
| // still check aggro while returning | |
| } | |
| const enemy = findNearestEnemy(c, [...world.units], c.aggro); | |
| if(enemy){ c.target = enemy; c.state = 'chasing'; c.path=null; return; } | |
| // wander near home | |
| c.wanderTimer -= dt; | |
| if(c.wanderTimer <= 0 && !c.path){ | |
| c.wanderTimer = rand(3,6); | |
| const tx = clamp(Math.round(c.home.x + rand(-2.5,2.5)), 0, MAP_W-1); | |
| const ty = clamp(Math.round(c.home.y + rand(-2.5,2.5)), 0, MAP_H-1); | |
| if(isWalkableTile(tx,ty)) issueMove(c, tx, ty); | |
| } | |
| if(c.path) moveAlongPath(c, dt); | |
| } | |
| /* ========================================================================================= | |
| BUILDING UPDATE (training queues + tower attacks) | |
| ========================================================================================= */ | |
| function updateBuilding(b, dt, now){ | |
| if(b.hp<=0) return; | |
| if(!b.constructing && b.trainQueue.length){ | |
| const q = b.trainQueue[0]; | |
| q.timeLeft -= dt; | |
| if(q.timeLeft <= 0){ | |
| spawnTrainedUnit(b, q.key); | |
| b.trainQueue.shift(); | |
| } | |
| } | |
| const def = BUILDING_DEFS[b.key]; | |
| if(!b.constructing && def.attack){ | |
| if(!isAlive(b.target)){ | |
| b.target = findNearestEnemy(b, world.creeps, def.attack.range); | |
| } | |
| if(b.target){ | |
| b.attackDef = def.attack; | |
| tryAttack(b, b.target, dt); | |
| } | |
| } | |
| } | |
| function spawnTrainedUnit(building, key){ | |
| const spot = findAdjacentToFootprint(building.tx, building.ty, building.w, building.h, 6); | |
| const pos = spot || {x:building.tx, y:building.ty+building.h}; | |
| const u = spawnUnit(key, pos.x+0.5, pos.y+0.5); | |
| spawnFloatText(u.x, u.y-0.3, UNIT_DEFS[key].name+' ready', '#7fd858'); | |
| if(building.rally){ | |
| issueMove(u, building.rally.x, building.rally.y); | |
| u.command = 'move'; | |
| } | |
| } | |
| /* ========================================================================================= | |
| FLOATING TEXT PARTICLES | |
| ========================================================================================= */ | |
| function spawnFloatText(x,y,text,color){ | |
| world.particles.push({ x, y, text, color, life: 1.1, maxLife: 1.1 }); | |
| } | |
| function updateParticles(dt){ | |
| for(const p of world.particles){ p.life -= dt; p.y -= dt*0.35; } | |
| world.particles = world.particles.filter(p=>p.life>0); | |
| } | |
| /* ========================================================================================= | |
| FOG OF WAR | |
| ========================================================================================= */ | |
| function initFog(){ | |
| fog = new Uint8Array(MAP_W*MAP_H); | |
| } | |
| function updateFog(){ | |
| const visibleNow = new Uint8Array(MAP_W*MAP_H); | |
| const sources = []; | |
| for(const u of world.units) if(u.hp>0) sources.push({x:u.x,y:u.y,r:UNIT_DEFS[u.key].vision}); | |
| for(const b of world.buildings) if(b.hp>0) sources.push({x:b.x,y:b.y,r:BUILDING_DEFS[b.key].vision}); | |
| for(const s of sources){ | |
| const r = s.r; | |
| const minX = Math.max(0, Math.floor(s.x-r)), maxX = Math.min(MAP_W-1, Math.ceil(s.x+r)); | |
| const minY = Math.max(0, Math.floor(s.y-r)), maxY = Math.min(MAP_H-1, Math.ceil(s.y+r)); | |
| const r2 = r*r; | |
| for(let y=minY;y<=maxY;y++){ | |
| for(let x=minX;x<=maxX;x++){ | |
| const dx=x+0.5-s.x, dy=y+0.5-s.y; | |
| if(dx*dx+dy*dy <= r2) visibleNow[tileIndex(x,y)] = 1; | |
| } | |
| } | |
| } | |
| let exploredCount = 0; | |
| for(let i=0;i<fog.length;i++){ | |
| if(visibleNow[i]) fog[i] = 2; | |
| else if(fog[i]===2) fog[i] = 1; | |
| if(fog[i] > 0) exploredCount++; | |
| } | |
| exploredPct = Math.round((exploredCount / fog.length) * 100); | |
| if(exploredPct >= 100 && !bannerShown){ | |
| bannerShown = true; | |
| showBanner('Map fully explored!'); | |
| } | |
| } | |
| function fogAt(tx,ty){ return inBounds(tx,ty) ? fog[tileIndex(tx,ty)] : 0; } | |
| /* ========================================================================================= | |
| INPUT | |
| ========================================================================================= */ | |
| function screenToWorld(px,py){ return { x: px+camera.x, y: py+camera.y }; } | |
| function worldToTileF(wx,wy){ return { x: wx/TILE, y: wy/TILE }; } | |
| function hitTestUnit(wx,wy){ | |
| const tf = worldToTileF(wx,wy); | |
| let best=null, bestD=Infinity; | |
| for(const u of world.units){ | |
| if(u.hp<=0) continue; | |
| const d = Math.hypot(u.x-tf.x,u.y-tf.y); | |
| if(d <= u.radius+0.12 && d<bestD){ best=u; bestD=d; } | |
| } | |
| return best; | |
| } | |
| function hitTestCreep(wx,wy){ | |
| const tf = worldToTileF(wx,wy); | |
| let best=null, bestD=Infinity; | |
| for(const c of world.creeps){ | |
| if(c.hp<=0) continue; | |
| if(fogAt(Math.floor(c.x),Math.floor(c.y))!==2) continue; | |
| const d = Math.hypot(c.x-tf.x,c.y-tf.y); | |
| if(d <= c.radius+0.12 && d<bestD){ best=c; bestD=d; } | |
| } | |
| return best; | |
| } | |
| function hitTestBuilding(wx,wy){ | |
| const tf = worldToTileF(wx,wy); | |
| for(const b of world.buildings){ | |
| if(b.hp<=0) continue; | |
| if(tf.x>=b.tx && tf.x<b.tx+b.w && tf.y>=b.ty && tf.y<b.ty+b.h) return b; | |
| } | |
| return null; | |
| } | |
| function hitTestResource(wx,wy){ | |
| const tf = worldToTileF(wx,wy); | |
| for(const n of world.resourceNodes){ | |
| if(!n.alive) continue; | |
| if(fogAt(Math.floor(n.x),Math.floor(n.y))!==2) continue; | |
| if(tf.x>=n.tx && tf.x<n.tx+n.w && tf.y>=n.ty && tf.y<n.ty+n.h) return n; | |
| } | |
| return null; | |
| } | |
| canvas.addEventListener('contextmenu', e=>e.preventDefault()); | |
| // Tracks whether the *current* press-release gesture began on the canvas itself, as | |
| // opposed to a UI button/panel. The window-level mouseup listener below needs this: | |
| // mouseup bubbles to window regardless of where it originated, so without this guard, | |
| // releasing a click on any command-panel button would also fire a spurious game-world | |
| // click/command at that button's screen position (and could deselect the current | |
| // selection / rebuild the panel out from under the button, silently eating the click). | |
| let gameMouseActive = false; | |
| canvas.addEventListener('mousedown', (e)=>{ | |
| if(!gameStarted) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const px = e.clientX-rect.left, py = e.clientY-rect.top; | |
| gameMouseActive = true; | |
| mouse.down = true; mouse.button = e.button; | |
| mouse.dragStart = {x:px,y:py}; | |
| mouse.dragging = false; | |
| if(e.button === 0){ | |
| if(placement){ | |
| tryPlaceBuilding(px,py); | |
| return; | |
| } | |
| } | |
| }); | |
| window.addEventListener('mousemove', (e)=>{ | |
| const rect = canvas.getBoundingClientRect(); | |
| mouse.x = e.clientX-rect.left; mouse.y = e.clientY-rect.top; | |
| if(mouse.down && mouse.button===0 && mouse.dragStart){ | |
| const d = Math.hypot(mouse.x-mouse.dragStart.x, mouse.y-mouse.dragStart.y); | |
| if(d>4) mouse.dragging = true; | |
| } | |
| }); | |
| window.addEventListener('mouseup', (e)=>{ | |
| const wasGameMouse = gameMouseActive; | |
| gameMouseActive = false; | |
| if(!gameStarted){ mouse.down=false; return; } | |
| if(!wasGameMouse){ mouse.down=false; mouse.dragging=false; mouse.dragStart=null; return; } | |
| if(e.button===0 && !placement){ | |
| const rect = canvas.getBoundingClientRect(); | |
| const px = e.clientX-rect.left, py = e.clientY-rect.top; | |
| if(mouse.dragging && mouse.dragStart){ | |
| doBoxSelect(mouse.dragStart.x, mouse.dragStart.y, px, py); | |
| } else { | |
| doClickSelect(px,py); | |
| } | |
| } else if(e.button===2){ | |
| if(placement){ placement=null; canvas.classList.remove('placing'); } | |
| else { | |
| const rect = canvas.getBoundingClientRect(); | |
| const px = e.clientX-rect.left, py = e.clientY-rect.top; | |
| doRightClickCommand(px,py); | |
| } | |
| } | |
| mouse.down=false; mouse.dragging=false; mouse.dragStart=null; | |
| }); | |
| window.addEventListener('keydown', (e)=>{ | |
| keys[e.key.toLowerCase()] = true; | |
| if(e.key === 'Escape'){ | |
| if(placement){ placement=null; canvas.classList.remove('placing'); } | |
| else { selection=[]; selectedBuilding=null; refreshCommandPanel(); } | |
| } | |
| }); | |
| window.addEventListener('keyup', (e)=>{ keys[e.key.toLowerCase()] = false; }); | |
| function doClickSelect(px,py){ | |
| const w = screenToWorld(px,py); | |
| const u = hitTestUnit(w.x,w.y); | |
| if(u){ selection=[u]; selectedBuilding=null; refreshCommandPanel(); return; } | |
| const c = hitTestCreep(w.x,w.y); | |
| if(c){ selection=[]; selectedBuilding=null; selectedCreep=c; refreshCommandPanel(); return; } | |
| selectedCreep = null; | |
| const b = hitTestBuilding(w.x,w.y); | |
| if(b && b.owner==='player'){ selection=[]; selectedBuilding=b; refreshCommandPanel(); return; } | |
| selection=[]; selectedBuilding=null; refreshCommandPanel(); | |
| } | |
| let selectedCreep = null; | |
| function doBoxSelect(x1,y1,x2,y2){ | |
| const minX=Math.min(x1,x2), maxX=Math.max(x1,x2); | |
| const minY=Math.min(y1,y2), maxY=Math.max(y1,y2); | |
| const found = []; | |
| for(const u of world.units){ | |
| if(u.hp<=0) continue; | |
| const sx = u.x*TILE - camera.x, sy = u.y*TILE - camera.y; | |
| if(sx>=minX && sx<=maxX && sy>=minY && sy<=maxY) found.push(u); | |
| } | |
| if(found.length){ selection=found; selectedBuilding=null; selectedCreep=null; } | |
| refreshCommandPanel(); | |
| } | |
| function doRightClickCommand(px,py){ | |
| const w = screenToWorld(px,py); | |
| const tf = worldToTileF(w.x,w.y); | |
| if(selectedBuilding && selectedBuilding.owner==='player'){ | |
| if(!hitTestUnit(w.x,w.y) && !hitTestCreep(w.x,w.y)){ | |
| selectedBuilding.rally = {x:tf.x, y:tf.y}; | |
| spawnFloatText(tf.x,tf.y,'Rally set','#7fd858'); | |
| return; | |
| } | |
| } | |
| if(!selection.length) return; | |
| const resNode = hitTestResource(w.x,w.y); | |
| const creep = hitTestCreep(w.x,w.y); | |
| if(resNode){ | |
| for(const u of selection){ | |
| if(!u.canGather) continue; | |
| u.command='gather'; u.state='moving'; u.target=resNode; | |
| const adj = findAdjacentToFootprint(resNode.tx,resNode.ty,resNode.w,resNode.h,8); | |
| if(adj) issueMove(u, adj.x+0.5, adj.y+0.5); | |
| } | |
| return; | |
| } | |
| if(creep){ | |
| for(const u of selection){ | |
| if(!u.attackDef) continue; | |
| u.command='attack'; u.target=creep; u.path=null; | |
| } | |
| return; | |
| } | |
| // plain move | |
| for(const u of selection){ | |
| u.command='move'; u.state='idle'; u.target=null; | |
| issueMove(u, tf.x, tf.y); | |
| } | |
| } | |
| /* ---- Building placement ---- */ | |
| function startPlacement(key){ | |
| placement = { key, def: BUILDING_DEFS[key] }; | |
| canvas.classList.add('placing'); | |
| } | |
| function tryPlaceBuilding(px,py){ | |
| const def = placement.def; | |
| const w = screenToWorld(px,py); | |
| const tx = Math.round(w.x/TILE - def.w/2); | |
| const ty = Math.round(w.y/TILE - def.h/2); | |
| if(!canPlaceAt(tx,ty,def)){ | |
| spawnFloatText(tx+def.w/2, ty+def.h/2, 'Cannot build here', '#e05a4e'); | |
| return; | |
| } | |
| if(player.gold < def.cost.gold || player.wood < def.cost.wood){ | |
| spawnFloatText(tx+def.w/2, ty+def.h/2, 'Not enough resources', '#e05a4e'); | |
| return; | |
| } | |
| player.gold -= def.cost.gold; player.wood -= def.cost.wood; | |
| const site = placeBuildingInstant(placement.key, tx, ty, true); | |
| const workers = selection.filter(u=>u.canBuild); | |
| for(const wk of workers){ | |
| wk.command='build'; wk.state='moving'; wk.target=site; | |
| const adj = findAdjacentToFootprint(tx,ty,def.w,def.h,8); | |
| if(adj) issueMove(wk, adj.x+0.5, adj.y+0.5); | |
| } | |
| placement = null; | |
| canvas.classList.remove('placing'); | |
| } | |
| function canPlaceAt(tx,ty,def){ | |
| for(let y=ty;y<ty+def.h;y++){ | |
| for(let x=tx;x<tx+def.w;x++){ | |
| if(!inBounds(x,y)) return false; | |
| if(fog[tileIndex(x,y)] === 0) return false; | |
| const t = world.tiles[y][x]; | |
| if(t.type !== TILE_GRASS) return false; | |
| if(occupiedTile(x,y)) return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /* ========================================================================================= | |
| UI: command panel + hud | |
| ========================================================================================= */ | |
| const selectionInfoEl = document.getElementById('selectionInfo'); | |
| const actionButtonsEl = document.getElementById('actionButtons'); | |
| // The command panel is rebuilt (innerHTML wiped + buttons recreated) ONLY when the | |
| // selection actually changes shape (tracked via panelSignature). Between real changes, | |
| // liveUpdatePanel() just mutates existing elements (text/width/disabled) in place. | |
| // This matters: recreating a button while the mouse is mid-click on it makes the | |
| // browser drop the click entirely, since mousedown/mouseup no longer target the same node. | |
| let panelSignature = null; | |
| let panelRefs = {}; | |
| function computePanelSignature(){ | |
| if(selectedBuilding) return 'b:'+selectedBuilding.id+':'+selectedBuilding.constructing; | |
| if(selectedCreep) return 'c:'+selectedCreep.id; | |
| if(selection.length) return 'u:'+selection.map(u=>u.id).join(','); | |
| return 'none'; | |
| } | |
| function refreshCommandPanel(){ | |
| const sig = computePanelSignature(); | |
| if(sig === panelSignature){ | |
| liveUpdatePanel(); | |
| return; | |
| } | |
| panelSignature = sig; | |
| actionButtonsEl.innerHTML = ''; | |
| panelRefs = { mode: null }; | |
| if(selectedBuilding){ | |
| panelRefs.mode = 'building'; | |
| buildBuildingPanel(selectedBuilding); | |
| } else if(selectedCreep){ | |
| panelRefs.mode = 'creep'; | |
| buildCreepPanel(selectedCreep); | |
| } else if(selection.length){ | |
| panelRefs.mode = 'units'; | |
| buildUnitsPanel(selection); | |
| } else { | |
| selectionInfoEl.innerHTML = '<i style="color:#999;">Nothing selected</i>'; | |
| } | |
| } | |
| function liveUpdatePanel(){ | |
| if(panelRefs.mode==='building') liveUpdateBuildingPanel(); | |
| else if(panelRefs.mode==='units') liveUpdateUnitsPanel(); | |
| else if(panelRefs.mode==='creep') liveUpdateCreepPanel(); | |
| } | |
| function buildBuildingPanel(b){ | |
| const def = BUILDING_DEFS[b.key]; | |
| selectionInfoEl.innerHTML = ''; | |
| const title = document.createElement('div'); | |
| title.className = 'selTitle'; | |
| title.textContent = def.name; | |
| selectionInfoEl.appendChild(title); | |
| const hpWrap = document.createElement('div'); hpWrap.className = 'hpbarMini'; | |
| const hpFill = document.createElement('div'); | |
| hpWrap.appendChild(hpFill); | |
| selectionInfoEl.appendChild(hpWrap); | |
| const hpText = document.createElement('div'); | |
| selectionInfoEl.appendChild(hpText); | |
| panelRefs.building = b; | |
| panelRefs.hpFill = hpFill; | |
| panelRefs.hpText = hpText; | |
| if(b.constructing){ | |
| const progText = document.createElement('div'); | |
| selectionInfoEl.appendChild(progText); | |
| panelRefs.progText = progText; | |
| liveUpdateBuildingPanel(); | |
| return; | |
| } | |
| const trainBtns = []; | |
| for(const key of def.trains){ | |
| const udef = UNIT_DEFS[key]; | |
| const btn = document.createElement('button'); | |
| btn.className = 'actBtn'; | |
| btn.innerHTML = `<b>Train ${udef.name}</b><small>${udef.cost.gold}g ${udef.cost.wood}w · pop ${udef.popCost}</small>`; | |
| btn.onclick = () => queueTraining(b, key); | |
| actionButtonsEl.appendChild(btn); | |
| trainBtns.push({ btn, udef }); | |
| } | |
| panelRefs.trainBtns = trainBtns; | |
| const queueWrap = document.createElement('div'); | |
| queueWrap.style.width = '100%'; | |
| queueWrap.style.display = 'none'; | |
| const queueRow = document.createElement('div'); queueRow.className = 'queueRow'; | |
| const queueLabel = document.createElement('span'); | |
| queueRow.appendChild(queueLabel); | |
| const queueBar = document.createElement('div'); queueBar.className = 'queueBar'; | |
| const queueFill = document.createElement('div'); | |
| queueBar.appendChild(queueFill); | |
| queueWrap.appendChild(queueRow); | |
| queueWrap.appendChild(queueBar); | |
| actionButtonsEl.appendChild(queueWrap); | |
| panelRefs.queueWrap = queueWrap; | |
| panelRefs.queueLabel = queueLabel; | |
| panelRefs.queueFill = queueFill; | |
| liveUpdateBuildingPanel(); | |
| } | |
| function liveUpdateBuildingPanel(){ | |
| const b = panelRefs.building; | |
| if(!b || b.hp<=0) return; | |
| const pct = Math.max(0, b.hp/b.maxHp*100); | |
| panelRefs.hpFill.style.width = pct+'%'; | |
| panelRefs.hpText.textContent = Math.max(0,Math.round(b.hp))+'/'+b.maxHp+' HP'; | |
| if(b.constructing){ | |
| if(panelRefs.progText) panelRefs.progText.textContent = 'Constructing… '+(b.buildProgress*100).toFixed(0)+'%'; | |
| return; | |
| } | |
| if(panelRefs.trainBtns){ | |
| for(const { btn, udef } of panelRefs.trainBtns){ | |
| const afford = player.gold>=udef.cost.gold && player.wood>=udef.cost.wood; | |
| const popOk = player.popCurrent + udef.popCost <= player.popMax; | |
| btn.disabled = !afford || !popOk || b.trainQueue.length>=5; | |
| } | |
| } | |
| if(b.trainQueue.length){ | |
| const q = b.trainQueue[0]; | |
| const pct2 = 1 - (q.timeLeft/q.totalTime); | |
| panelRefs.queueWrap.style.display = ''; | |
| panelRefs.queueLabel.textContent = `${UNIT_DEFS[q.key].name} (${b.trainQueue.length} queued)`; | |
| panelRefs.queueFill.style.width = (pct2*100).toFixed(0)+'%'; | |
| } else { | |
| panelRefs.queueWrap.style.display = 'none'; | |
| } | |
| } | |
| function buildCreepPanel(c){ | |
| selectionInfoEl.innerHTML = ''; | |
| const def = CREEP_DEFS[c.key]; | |
| const title = document.createElement('div'); | |
| title.className = 'selTitle'; | |
| title.innerHTML = `${def.name} <span style="color:#e05a4e;">(hostile)</span>`; | |
| selectionInfoEl.appendChild(title); | |
| const hpWrap = document.createElement('div'); hpWrap.className = 'hpbarMini'; | |
| const hpFill = document.createElement('div'); | |
| hpWrap.appendChild(hpFill); | |
| selectionInfoEl.appendChild(hpWrap); | |
| const hpText = document.createElement('div'); | |
| selectionInfoEl.appendChild(hpText); | |
| panelRefs.creep = c; panelRefs.hpFill = hpFill; panelRefs.hpText = hpText; | |
| liveUpdateCreepPanel(); | |
| } | |
| function liveUpdateCreepPanel(){ | |
| const c = panelRefs.creep; | |
| if(!c || c.hp<=0) return; | |
| const pct = Math.max(0, c.hp/c.maxHp*100); | |
| panelRefs.hpFill.style.width = pct+'%'; | |
| panelRefs.hpText.textContent = Math.max(0,Math.round(c.hp))+'/'+c.maxHp+' HP'; | |
| } | |
| function buildUnitsPanel(units){ | |
| selectionInfoEl.innerHTML = ''; | |
| const title = document.createElement('div'); | |
| title.className = 'selTitle'; | |
| title.textContent = `${units.length} unit${units.length>1?'s':''} selected`; | |
| selectionInfoEl.appendChild(title); | |
| const counts = {}; | |
| for(const u of units) counts[u.key] = (counts[u.key]||0)+1; | |
| const countsDiv = document.createElement('div'); | |
| countsDiv.innerHTML = Object.keys(counts).map(k=>`${UNIT_DEFS[k].name} × ${counts[k]}`).join('<br>'); | |
| selectionInfoEl.appendChild(countsDiv); | |
| panelRefs.units = units; | |
| panelRefs.hpFill = null; panelRefs.hpText = null; | |
| if(units.length===1){ | |
| const hpWrap = document.createElement('div'); hpWrap.className = 'hpbarMini'; | |
| const hpFill = document.createElement('div'); | |
| hpWrap.appendChild(hpFill); | |
| selectionInfoEl.appendChild(hpWrap); | |
| const hpText = document.createElement('div'); | |
| selectionInfoEl.appendChild(hpText); | |
| panelRefs.hpFill = hpFill; panelRefs.hpText = hpText; | |
| } | |
| const hasWorker = units.some(u=>u.canBuild); | |
| const buildBtns = []; | |
| if(hasWorker){ | |
| for(const key in BUILDING_DEFS){ | |
| const def = BUILDING_DEFS[key]; | |
| if(!def.buildable) continue; | |
| const btn = document.createElement('button'); | |
| btn.className = 'actBtn'; | |
| btn.innerHTML = `<b>Build ${def.name}</b><small>${def.cost.gold}g ${def.cost.wood}w</small>`; | |
| btn.onclick = () => startPlacement(key); | |
| actionButtonsEl.appendChild(btn); | |
| buildBtns.push({ btn, def }); | |
| } | |
| } | |
| panelRefs.buildBtns = buildBtns; | |
| const stopBtn = document.createElement('button'); | |
| stopBtn.className = 'actBtn'; | |
| stopBtn.innerHTML = `<b>Stop</b>`; | |
| stopBtn.onclick = () => { for(const u of units){ u.command=null; u.target=null; u.path=null; u.state='idle'; } }; | |
| actionButtonsEl.appendChild(stopBtn); | |
| liveUpdateUnitsPanel(); | |
| } | |
| function liveUpdateUnitsPanel(){ | |
| if(panelRefs.hpFill && panelRefs.units.length===1){ | |
| const u = panelRefs.units[0]; | |
| if(u.hp>0){ | |
| const pct = Math.max(0, u.hp/u.maxHp*100); | |
| panelRefs.hpFill.style.width = pct+'%'; | |
| panelRefs.hpText.textContent = Math.max(0,Math.round(u.hp))+'/'+u.maxHp+' HP'; | |
| } | |
| } | |
| for(const { btn, def } of panelRefs.buildBtns){ | |
| const afford = player.gold>=def.cost.gold && player.wood>=def.cost.wood; | |
| btn.disabled = !afford; | |
| } | |
| } | |
| function queueTraining(building, key){ | |
| const udef = UNIT_DEFS[key]; | |
| if(player.gold<udef.cost.gold || player.wood<udef.cost.wood) return; | |
| if(player.popCurrent + udef.popCost > player.popMax) return; | |
| if(building.trainQueue.length>=5) return; | |
| player.gold -= udef.cost.gold; player.wood -= udef.cost.wood; | |
| player.popCurrent += udef.popCost; | |
| building.trainQueue.push({ key, timeLeft: udef.buildTime, totalTime: udef.buildTime }); | |
| } | |
| function updateHud(){ | |
| document.getElementById('goldVal').textContent = Math.floor(player.gold); | |
| document.getElementById('woodVal').textContent = Math.floor(player.wood); | |
| document.getElementById('popVal').textContent = player.popCurrent+'/'+player.popMax; | |
| document.getElementById('exploreVal').textContent = exploredPct+'%'; | |
| } | |
| function showBanner(text){ | |
| const el = document.getElementById('banner'); | |
| el.textContent = text; | |
| el.classList.remove('hidden'); | |
| setTimeout(()=>{ el.classList.add('hidden'); }, 4000); | |
| } | |
| const placementIndicatorEl = document.getElementById('placementIndicator'); | |
| function updatePlacementIndicator(){ | |
| if(placement){ | |
| placementIndicatorEl.textContent = `Placing ${placement.def.name} — click on the map to build (right-click or Esc to cancel)`; | |
| placementIndicatorEl.classList.remove('hidden'); | |
| } else { | |
| placementIndicatorEl.classList.add('hidden'); | |
| } | |
| } | |
| document.getElementById('helpBtn').onclick = ()=> document.getElementById('helpOverlay').classList.remove('hidden'); | |
| document.getElementById('helpCloseBtn').onclick = ()=> document.getElementById('helpOverlay').classList.add('hidden'); | |
| document.getElementById('startBtn').onclick = ()=>{ | |
| document.getElementById('startOverlay').style.display='none'; | |
| gameStarted = true; | |
| }; | |
| /* ========================================================================================= | |
| CAMERA | |
| ========================================================================================= */ | |
| function updateCamera(dt){ | |
| const speed = 620 * dt; | |
| const edge = 18; | |
| let dx=0, dy=0; | |
| if(keys['arrowleft']||keys['a']) dx -= 1; | |
| if(keys['arrowright']||keys['d']) dx += 1; | |
| if(keys['arrowup']||keys['w']) dy -= 1; | |
| if(keys['arrowdown']||keys['s']) dy += 1; | |
| if(mouse.x <= edge) dx -= 1; | |
| if(mouse.x >= canvas.clientWidth-edge) dx += 1; | |
| if(mouse.y <= edge) dy -= 1; | |
| if(mouse.y >= canvas.clientHeight-edge) dy += 1; | |
| if(dx||dy){ | |
| const len = Math.hypot(dx,dy)||1; | |
| camera.x += (dx/len)*speed; | |
| camera.y += (dy/len)*speed; | |
| } | |
| const maxX = Math.max(0, MAP_W*TILE - canvas.clientWidth); | |
| const maxY = Math.max(0, MAP_H*TILE - canvas.clientHeight); | |
| camera.x = clamp(camera.x, 0, maxX); | |
| camera.y = clamp(camera.y, 0, maxY); | |
| } | |
| /* ========================================================================================= | |
| MINIMAP | |
| ========================================================================================= */ | |
| let minimapTerrain = null; // offscreen canvas | |
| const MM_SCALE = 3; | |
| function buildMinimapTerrain(){ | |
| minimapTerrain = document.createElement('canvas'); | |
| minimapTerrain.width = MAP_W*MM_SCALE; | |
| minimapTerrain.height = MAP_H*MM_SCALE; | |
| const c2 = minimapTerrain.getContext('2d'); | |
| for(let y=0;y<MAP_H;y++){ | |
| for(let x=0;x<MAP_W;x++){ | |
| const t = world.tiles[y][x]; | |
| c2.fillStyle = t.type===TILE_WATER ? '#2b5f86' : (t.type===TILE_ROCK ? '#6b6b63' : '#3f6b34'); | |
| c2.fillRect(x*MM_SCALE,y*MM_SCALE,MM_SCALE,MM_SCALE); | |
| } | |
| } | |
| for(const n of world.resourceNodes){ | |
| c2.fillStyle = n.type==='gold' ? '#e8c44a' : '#2f7a2f'; | |
| c2.fillRect(n.tx*MM_SCALE, n.ty*MM_SCALE, n.w*MM_SCALE, n.h*MM_SCALE); | |
| } | |
| } | |
| function renderMinimap(){ | |
| minimapCanvas.width = MAP_W*MM_SCALE; | |
| minimapCanvas.height = MAP_H*MM_SCALE; | |
| mctx.drawImage(minimapTerrain,0,0); | |
| // fog overlay | |
| const img = mctx.getImageData(0,0,minimapCanvas.width,minimapCanvas.height); | |
| for(let y=0;y<MAP_H;y++){ | |
| for(let x=0;x<MAP_W;x++){ | |
| const f = fog[tileIndex(x,y)]; | |
| if(f===2) continue; | |
| const alpha = f===1 ? 0.55 : 1.0; | |
| for(let yy=0;yy<MM_SCALE;yy++){ | |
| for(let xx=0;xx<MM_SCALE;xx++){ | |
| const px = (x*MM_SCALE+xx), py=(y*MM_SCALE+yy); | |
| const idx = (py*img.width+px)*4; | |
| img.data[idx] = img.data[idx]*(1-alpha); | |
| img.data[idx+1] = img.data[idx+1]*(1-alpha); | |
| img.data[idx+2] = img.data[idx+2]*(1-alpha); | |
| } | |
| } | |
| } | |
| } | |
| mctx.putImageData(img,0,0); | |
| for(const b of world.buildings){ | |
| if(b.hp<=0) continue; | |
| mctx.fillStyle = '#7fc1ff'; | |
| mctx.fillRect(b.tx*MM_SCALE, b.ty*MM_SCALE, b.w*MM_SCALE, b.h*MM_SCALE); | |
| } | |
| for(const u of world.units){ | |
| if(u.hp<=0) continue; | |
| mctx.fillStyle = '#bfe8ff'; | |
| mctx.fillRect(u.x*MM_SCALE-1, u.y*MM_SCALE-1, 2,2); | |
| } | |
| for(const c of world.creeps){ | |
| if(c.hp<=0) continue; | |
| if(fogAt(Math.floor(c.x),Math.floor(c.y))!==2) continue; | |
| mctx.fillStyle = '#ff5040'; | |
| mctx.fillRect(c.x*MM_SCALE-1, c.y*MM_SCALE-1, 2,2); | |
| } | |
| // camera rect | |
| mctx.strokeStyle = '#fff'; | |
| mctx.lineWidth = 1; | |
| mctx.strokeRect( | |
| (camera.x/TILE)*MM_SCALE, (camera.y/TILE)*MM_SCALE, | |
| (canvas.clientWidth/TILE)*MM_SCALE, (canvas.clientHeight/TILE)*MM_SCALE | |
| ); | |
| } | |
| function minimapToCamera(mx,my){ | |
| const tx = mx/MM_SCALE, ty = my/MM_SCALE; | |
| camera.x = clamp(tx*TILE - canvas.clientWidth/2, 0, Math.max(0,MAP_W*TILE-canvas.clientWidth)); | |
| camera.y = clamp(ty*TILE - canvas.clientHeight/2, 0, Math.max(0,MAP_H*TILE-canvas.clientHeight)); | |
| } | |
| let mmDragging=false; | |
| minimapCanvas.addEventListener('mousedown',(e)=>{ | |
| mmDragging=true; | |
| const r = minimapCanvas.getBoundingClientRect(); | |
| minimapToCamera((e.clientX-r.left)*(minimapCanvas.width/r.width), (e.clientY-r.top)*(minimapCanvas.height/r.height)); | |
| }); | |
| window.addEventListener('mousemove',(e)=>{ | |
| if(!mmDragging) return; | |
| const r = minimapCanvas.getBoundingClientRect(); | |
| minimapToCamera((e.clientX-r.left)*(minimapCanvas.width/r.width), (e.clientY-r.top)*(minimapCanvas.height/r.height)); | |
| }); | |
| window.addEventListener('mouseup',()=>{ mmDragging=false; }); | |
| /* ========================================================================================= | |
| RENDERING | |
| ========================================================================================= */ | |
| function resizeCanvas(){ | |
| dpr = Math.max(1, window.devicePixelRatio || 1); | |
| const w = window.innerWidth, h = window.innerHeight; | |
| canvas.width = Math.floor(w*dpr); | |
| canvas.height = Math.floor(h*dpr); | |
| canvas.style.width = w+'px'; | |
| canvas.style.height = h+'px'; | |
| ctx.setTransform(dpr,0,0,dpr,0,0); | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| function render(){ | |
| const cw = canvas.clientWidth, ch = canvas.clientHeight; | |
| ctx.clearRect(0,0,cw,ch); | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(0,0,cw,ch); | |
| const minTx = Math.max(0, Math.floor(camera.x/TILE)); | |
| const minTy = Math.max(0, Math.floor(camera.y/TILE)); | |
| const maxTx = Math.min(MAP_W-1, Math.ceil((camera.x+cw)/TILE)); | |
| const maxTy = Math.min(MAP_H-1, Math.ceil((camera.y+ch)/TILE)); | |
| // terrain | |
| for(let ty=minTy; ty<=maxTy; ty++){ | |
| for(let tx=minTx; tx<=maxTx; tx++){ | |
| const f = fog[tileIndex(tx,ty)]; | |
| if(f===0) continue; | |
| const t = world.tiles[ty][tx]; | |
| const sx = tx*TILE-camera.x, sy = ty*TILE-camera.y; | |
| drawTile(sx,sy,t,f); | |
| } | |
| } | |
| // resource nodes | |
| for(const n of world.resourceNodes){ | |
| if(!n.alive) continue; | |
| const f = fog[tileIndex(n.tx,n.ty)]; | |
| if(f===0) continue; | |
| if(n.tx+n.w < minTx || n.tx > maxTx || n.ty+n.h < minTy || n.ty > maxTy) continue; | |
| drawResourceNode(n, f); | |
| } | |
| // buildings | |
| for(const b of world.buildings){ | |
| if(b.hp<=0) continue; | |
| drawBuilding(b); | |
| } | |
| // creeps | |
| for(const c of world.creeps){ | |
| if(c.hp<=0) continue; | |
| if(fogAt(Math.floor(c.x),Math.floor(c.y))!==2) continue; | |
| drawCreep(c); | |
| } | |
| // units | |
| for(const u of world.units){ | |
| if(u.hp<=0) continue; | |
| drawUnit(u); | |
| } | |
| // rally lines | |
| if(selectedBuilding && selectedBuilding.rally){ | |
| const r = selectedBuilding.rally; | |
| ctx.strokeStyle='rgba(127,216,88,0.8)'; | |
| ctx.setLineDash([4,4]); | |
| ctx.beginPath(); | |
| ctx.moveTo(selectedBuilding.x*TILE-camera.x, selectedBuilding.y*TILE-camera.y); | |
| ctx.lineTo(r.x*TILE-camera.x, r.y*TILE-camera.y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle='#7fd858'; | |
| ctx.beginPath(); ctx.arc(r.x*TILE-camera.x, r.y*TILE-camera.y, 4,0,Math.PI*2); ctx.fill(); | |
| } | |
| // particles | |
| for(const p of world.particles){ | |
| const sx = p.x*TILE-camera.x, sy = p.y*TILE-camera.y; | |
| ctx.globalAlpha = clamp(p.life/p.maxLife,0,1); | |
| ctx.fillStyle = p.color; | |
| ctx.font = 'bold 12px sans-serif'; | |
| ctx.textAlign='center'; | |
| ctx.fillText(p.text, sx, sy); | |
| ctx.globalAlpha = 1; | |
| } | |
| // placement ghost | |
| if(placement){ | |
| const w = screenToWorld(mouse.x, mouse.y); | |
| const def = placement.def; | |
| const tx = Math.round(w.x/TILE - def.w/2); | |
| const ty = Math.round(w.y/TILE - def.h/2); | |
| const ok = canPlaceAt(tx,ty,def) && player.gold>=def.cost.gold && player.wood>=def.cost.wood; | |
| ctx.fillStyle = ok ? 'rgba(127,216,88,0.35)' : 'rgba(224,90,78,0.35)'; | |
| ctx.strokeStyle = ok ? '#7fd858' : '#e05a4e'; | |
| ctx.lineWidth=2; | |
| const sx = tx*TILE-camera.x, sy = ty*TILE-camera.y; | |
| ctx.fillRect(sx,sy,def.w*TILE,def.h*TILE); | |
| ctx.strokeRect(sx,sy,def.w*TILE,def.h*TILE); | |
| } | |
| // drag select box | |
| if(mouse.down && mouse.dragging && mouse.dragStart && !placement){ | |
| ctx.strokeStyle = '#7fd858'; | |
| ctx.fillStyle = 'rgba(127,216,88,0.15)'; | |
| const x = Math.min(mouse.dragStart.x, mouse.x), y = Math.min(mouse.dragStart.y, mouse.y); | |
| const w = Math.abs(mouse.x-mouse.dragStart.x), h = Math.abs(mouse.y-mouse.dragStart.y); | |
| ctx.fillRect(x,y,w,h); ctx.strokeRect(x,y,w,h); | |
| } | |
| } | |
| function drawTile(sx,sy,t,fogState){ | |
| let base; | |
| if(t.type===TILE_WATER) base = `rgb(${40+t.shade*10},${95+t.shade*15},${140+t.shade*15})`; | |
| else if(t.type===TILE_ROCK) base = `rgb(${100+t.shade*20},${98+t.shade*20},${92+t.shade*18})`; | |
| else base = `rgb(${58+t.shade*22},${110+t.shade*28},${52+t.shade*20})`; | |
| ctx.fillStyle = base; | |
| ctx.fillRect(sx,sy,TILE,TILE); | |
| if(fogState===1){ | |
| ctx.fillStyle = 'rgba(0,0,0,0.55)'; | |
| ctx.fillRect(sx,sy,TILE,TILE); | |
| } | |
| } | |
| function drawResourceNode(n, fogState){ | |
| const sx = n.tx*TILE - camera.x, sy = n.ty*TILE - camera.y; | |
| const w = n.w*TILE, h = n.h*TILE; | |
| ctx.save(); | |
| if(fogState===1) ctx.globalAlpha = 0.45; | |
| if(n.type==='gold'){ | |
| ctx.fillStyle = '#8a7a5a'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx+w*0.1, sy+h*0.85); ctx.lineTo(sx+w*0.3, sy+h*0.25); | |
| ctx.lineTo(sx+w*0.7, sy+h*0.15); ctx.lineTo(sx+w*0.9, sy+h*0.8); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#e8c44a'; | |
| for(let i=0;i<5;i++){ | |
| const rx = sx + w*(0.25+0.5*Math.random()); | |
| const ry = sy + h*(0.35+0.45*Math.random()); | |
| ctx.beginPath(); ctx.arc(rx,ry,2.2,0,Math.PI*2); ctx.fill(); | |
| } | |
| } else { | |
| ctx.fillStyle = '#5a3a20'; | |
| ctx.fillRect(sx+w/2-3, sy+h*0.55, 6, h*0.4); | |
| ctx.fillStyle = '#2f6b2a'; | |
| ctx.beginPath(); ctx.arc(sx+w/2, sy+h*0.42, w*0.42, 0, Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#397a34'; | |
| ctx.beginPath(); ctx.arc(sx+w/2-4, sy+h*0.32, w*0.28, 0, Math.PI*2); ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawBuilding(b){ | |
| const sx = b.tx*TILE - camera.x, sy = b.ty*TILE - camera.y; | |
| const w = b.w*TILE, h = b.h*TILE; | |
| if(sx+w<0 || sy+h<0 || sx>canvas.clientWidth || sy>canvas.clientHeight) return; | |
| ctx.save(); | |
| if(b.constructing) ctx.globalAlpha = 0.65; | |
| const wallColor = '#3a5a78'; | |
| const roofColor = '#274a63'; | |
| ctx.fillStyle = wallColor; | |
| ctx.fillRect(sx+3, sy+h*0.35, w-6, h*0.6); | |
| ctx.fillStyle = roofColor; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx, sy+h*0.4); | |
| ctx.lineTo(sx+w/2, sy); | |
| ctx.lineTo(sx+w, sy+h*0.4); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#1c2e3d'; | |
| ctx.fillRect(sx+w/2-6, sy+h*0.65, 12, h*0.3); | |
| if(b.key==='tower'){ | |
| ctx.fillStyle = '#4a6a88'; | |
| ctx.fillRect(sx+w*0.3, sy+h*0.05, w*0.4, h*0.5); | |
| } | |
| if(b.key==='farm'){ | |
| ctx.strokeStyle = '#8a6a3a'; ctx.lineWidth=2; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx+4,sy+4); ctx.lineTo(sx+w-4,sy+h-4); | |
| ctx.moveTo(sx+w-4,sy+4); ctx.lineTo(sx+4,sy+h-4); | |
| ctx.stroke(); | |
| } | |
| ctx.strokeStyle = 'rgba(0,0,0,0.5)'; | |
| ctx.strokeRect(sx+3, sy+h*0.35, w-6, h*0.6); | |
| ctx.restore(); | |
| if(b.constructing){ | |
| drawProgressBar(sx+w/2, sy-8, w-8, b.buildProgress, '#7fb0e0'); | |
| } else if(b.hp < b.maxHp){ | |
| drawProgressBar(sx+w/2, sy-8, w-8, b.hp/b.maxHp, '#7fd858'); | |
| } | |
| if(selectedBuilding===b){ | |
| ctx.strokeStyle = '#f5e05a'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(sx, sy, w, h); | |
| } | |
| } | |
| function drawProgressBar(cx, sy, w, pct, color){ | |
| ctx.fillStyle = 'rgba(0,0,0,0.6)'; | |
| ctx.fillRect(cx-w/2, sy-4, w, 6); | |
| ctx.fillStyle = color; | |
| ctx.fillRect(cx-w/2, sy-4, w*clamp(pct,0,1), 6); | |
| } | |
| function drawUnit(u){ | |
| const sx = u.x*TILE - camera.x, sy = u.y*TILE - camera.y; | |
| if(sx<-20||sy<-20||sx>canvas.clientWidth+20||sy>canvas.clientHeight+20) return; | |
| const isSel = selection.includes(u); | |
| if(isSel){ | |
| ctx.strokeStyle = '#f5e05a'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.ellipse(sx, sy+u.radius*TILE*0.7, u.radius*TILE*1.15, u.radius*TILE*0.6, 0,0,Math.PI*2); ctx.stroke(); | |
| } | |
| const r = u.radius*TILE; | |
| let bodyColor = '#3a8ad8', accentColor = '#1c5a99'; | |
| if(u.key==='soldier'){ bodyColor='#4a8ad8'; accentColor='#c04a3a'; } | |
| if(u.key==='archer'){ bodyColor='#5aa070'; accentColor='#2f6b2a'; } | |
| ctx.fillStyle = accentColor; | |
| ctx.beginPath(); ctx.ellipse(sx, sy+r*0.75, r*0.9, r*0.35, 0,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = bodyColor; | |
| ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI*2); ctx.fill(); | |
| ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth=1.2; ctx.stroke(); | |
| if(u.key==='soldier'){ | |
| ctx.strokeStyle = '#eee'; ctx.lineWidth=2; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx + Math.cos(u.facing)*r*0.2, sy + Math.sin(u.facing)*r*0.2); | |
| ctx.lineTo(sx + Math.cos(u.facing)*r*1.6, sy + Math.sin(u.facing)*r*1.6); | |
| ctx.stroke(); | |
| } else if(u.key==='archer'){ | |
| ctx.strokeStyle = '#3a2a18'; ctx.lineWidth=1.5; | |
| ctx.beginPath(); ctx.arc(sx + Math.cos(u.facing)*r*0.9, sy + Math.sin(u.facing)*r*0.9, r*0.55, u.facing-0.9, u.facing+0.9); ctx.stroke(); | |
| } else { | |
| ctx.fillStyle = '#e8c44a'; | |
| if(u.carrying>0){ | |
| ctx.beginPath(); ctx.arc(sx, sy-r*1.3, 3.5, 0, Math.PI*2); ctx.fill(); | |
| } | |
| } | |
| if(u.hp < u.maxHp){ | |
| drawProgressBar(sx, sy-r*1.9, r*2.4, u.hp/u.maxHp, u.hp/u.maxHp>0.4?'#7fd858':'#e0a54e'); | |
| } | |
| } | |
| function drawCreep(c){ | |
| const sx = c.x*TILE - camera.x, sy = c.y*TILE - camera.y; | |
| if(sx<-20||sy<-20||sx>canvas.clientWidth+20||sy>canvas.clientHeight+20) return; | |
| const r = c.radius*TILE; | |
| if(selectedCreep===c){ | |
| ctx.strokeStyle = '#f5e05a'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.ellipse(sx, sy+r*0.7, r*1.15, r*0.6, 0,0,Math.PI*2); ctx.stroke(); | |
| } | |
| ctx.fillStyle = 'rgba(0,0,0,0.35)'; | |
| ctx.beginPath(); ctx.ellipse(sx, sy+r*0.75, r*0.9, r*0.35, 0,0,Math.PI*2); ctx.fill(); | |
| if(c.key==='wolf'){ | |
| ctx.fillStyle = '#8a8078'; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx-r, sy); | |
| ctx.lineTo(sx, sy-r*0.7); | |
| ctx.lineTo(sx+r, sy); | |
| ctx.lineTo(sx, sy+r*0.7); | |
| ctx.closePath(); ctx.fill(); | |
| } else { | |
| ctx.fillStyle = '#8a2a24'; | |
| ctx.beginPath(); ctx.arc(sx,sy,r,0,Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#3a1a14'; | |
| ctx.fillRect(sx-r*0.5, sy-r*0.15, r, r*0.3); | |
| } | |
| ctx.strokeStyle='rgba(0,0,0,0.5)'; ctx.lineWidth=1; ctx.stroke(); | |
| if(c.hp<c.maxHp){ | |
| drawProgressBar(sx, sy-r*1.9, r*2.4, c.hp/c.maxHp, '#e05a4e'); | |
| } | |
| } | |
| /* ========================================================================================= | |
| MAIN LOOP | |
| ========================================================================================= */ | |
| function tick(ts){ | |
| requestAnimationFrame(tick); | |
| if(!lastTime) lastTime = ts; | |
| let dt = (ts-lastTime)/1000; | |
| lastTime = ts; | |
| dt = Math.min(dt, 0.06); | |
| if(!gameStarted){ return; } | |
| const now = ts/1000; | |
| updateCamera(dt); | |
| for(const u of world.units) updateUnit(u, dt, now); | |
| for(const c of world.creeps) updateCreep(c, dt, now); | |
| for(const b of world.buildings) updateBuilding(b, dt, now); | |
| world.units = world.units.filter(u=>u.hp>0); | |
| world.creeps = world.creeps.filter(c=>c.hp>0); | |
| world.buildings = world.buildings.filter(b=>b.hp>0); | |
| updateParticles(dt); | |
| updateFog(); | |
| updateHud(); | |
| render(); | |
| renderMinimap(); | |
| refreshCommandPanel(); | |
| updatePlacementIndicator(); | |
| } | |
| /* ========================================================================================= | |
| INIT | |
| ========================================================================================= */ | |
| function init(){ | |
| resizeCanvas(); | |
| genTiles(); | |
| genResources(); | |
| initFog(); | |
| const th = placeBuildingInstant('townhall', SPAWN_TX, SPAWN_TY, false); | |
| player.popMax = BUILDING_DEFS.townhall.providesPop; | |
| const spawnSpots = [ | |
| {x:SPAWN_TX+3.5, y:SPAWN_TY+1.5}, | |
| {x:SPAWN_TX+3.5, y:SPAWN_TY+2.5}, | |
| {x:SPAWN_TX-0.5, y:SPAWN_TY+3.5}, | |
| ]; | |
| for(const s of spawnSpots){ | |
| spawnUnit('worker', s.x, s.y); | |
| player.popCurrent += UNIT_DEFS.worker.popCost; | |
| } | |
| genCreeps(); | |
| camera.x = clamp(th.x*TILE - canvas.clientWidth/2, 0, Math.max(0,MAP_W*TILE-canvas.clientWidth)); | |
| camera.y = clamp(th.y*TILE - canvas.clientHeight/2, 0, Math.max(0,MAP_H*TILE-canvas.clientHeight)); | |
| buildMinimapTerrain(); | |
| updateFog(); | |
| updateHud(); | |
| requestAnimationFrame(tick); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment