|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8" /> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> |
|
<title>FOUNDRY — a factory defense framework</title> |
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet"> |
|
<style> |
|
:root{ |
|
--bg:#0b0f15; |
|
--panel:#121a24; |
|
--panel-2:#0e151d; |
|
--line:#1f2c3a; |
|
--ink:#cdd9e6; |
|
--ink-dim:#7d8ea0; |
|
--teal:#37d6c2; |
|
--teal-dim:#1c6e66; |
|
--amber:#f2b138; |
|
--copper:#e08a4a; |
|
--iron:#8ea3b8; |
|
--plate:#9fe9ff; |
|
--red:#ff5a6e; |
|
--blue:#4aa0ff; |
|
--shadow:0 6px 22px rgba(0,0,0,.45); |
|
} |
|
*{box-sizing:border-box;margin:0;padding:0;} |
|
html,body{height:100%;width:100%;overflow:hidden;background:var(--bg);} |
|
body{ |
|
font-family:'Chakra Petch',sans-serif; |
|
color:var(--ink); |
|
-webkit-font-smoothing:antialiased; |
|
user-select:none; |
|
touch-action:none; |
|
} |
|
#game{position:fixed;inset:0;display:block;cursor:crosshair;} |
|
.mono{font-family:'JetBrains Mono',monospace;} |
|
|
|
/* ---------- HUD top bar ---------- */ |
|
#topbar{ |
|
position:fixed;top:0;left:0;right:0;height:54px; |
|
display:flex;align-items:center;gap:18px;padding:0 16px; |
|
background:linear-gradient(180deg,rgba(11,15,21,.95),rgba(11,15,21,.78)); |
|
border-bottom:1px solid var(--line); |
|
backdrop-filter:blur(6px); |
|
z-index:10; |
|
} |
|
#brand{font-weight:700;letter-spacing:3px;font-size:18px;color:var(--teal);} |
|
#brand small{color:var(--ink-dim);font-weight:400;letter-spacing:2px;font-size:10px;display:block;margin-top:-3px;} |
|
.res{display:flex;align-items:center;gap:7px;font-family:'JetBrains Mono',monospace;font-size:15px;} |
|
.res .dot{width:13px;height:13px;border-radius:3px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.15);} |
|
.res .lbl{color:var(--ink-dim);font-size:10px;letter-spacing:1px;text-transform:uppercase;} |
|
.res .val{min-width:34px;text-align:right;} |
|
.spacer{flex:1;} |
|
#waveinfo{text-align:right;font-size:12px;color:var(--ink-dim);letter-spacing:1px;} |
|
#waveinfo b{color:var(--ink);font-size:20px;font-family:'JetBrains Mono',monospace;} |
|
#corehpwrap{width:150px;} |
|
#corehpwrap .lbl{font-size:9px;letter-spacing:2px;color:var(--ink-dim);text-transform:uppercase;margin-bottom:3px;} |
|
#corehpbar{height:8px;border-radius:4px;background:#0c1219;border:1px solid var(--line);overflow:hidden;} |
|
#corehpfill{height:100%;width:100%;background:linear-gradient(90deg,var(--teal),#69f0dc);transition:width .15s;} |
|
#wavebtn{ |
|
font-family:'Chakra Petch';font-weight:600;letter-spacing:1.5px;font-size:12px; |
|
color:#06221f;background:var(--teal);border:none;border-radius:6px; |
|
padding:9px 14px;cursor:pointer;text-transform:uppercase;transition:.12s; |
|
} |
|
#wavebtn:hover{background:#69f0dc;transform:translateY(-1px);} |
|
#wavebtn:disabled{background:#243240;color:var(--ink-dim);cursor:default;transform:none;} |
|
|
|
/* ---------- build toolbar ---------- */ |
|
#toolbar{ |
|
position:fixed;left:50%;transform:translateX(-50%);bottom:16px; |
|
display:flex;gap:8px;padding:8px;border-radius:14px; |
|
background:rgba(14,21,29,.92);border:1px solid var(--line); |
|
box-shadow:var(--shadow);backdrop-filter:blur(6px);z-index:10; |
|
} |
|
.tool{ |
|
width:74px;padding:8px 6px 7px;border-radius:9px;cursor:pointer; |
|
background:var(--panel);border:1px solid transparent;text-align:center; |
|
transition:.12s;position:relative; |
|
} |
|
.tool:hover{border-color:var(--teal-dim);background:#16212d;} |
|
.tool.active{border-color:var(--teal);background:#13242a;box-shadow:0 0 0 1px var(--teal) inset;} |
|
.tool .ic{height:30px;display:flex;align-items:center;justify-content:center;} |
|
.tool .nm{font-size:10px;letter-spacing:.5px;margin-top:3px;color:var(--ink);text-transform:uppercase;} |
|
.tool .ct{font-family:'JetBrains Mono';font-size:9.5px;color:var(--ink-dim);margin-top:2px;line-height:1.25;} |
|
.tool .key{position:absolute;top:4px;right:5px;font-size:8.5px;color:var(--ink-dim);font-family:'JetBrains Mono';} |
|
.tool.del.active{border-color:var(--red);box-shadow:0 0 0 1px var(--red) inset;background:#241419;} |
|
|
|
/* ---------- helper / legend ---------- */ |
|
#help{ |
|
position:fixed;left:14px;bottom:16px;z-index:10;max-width:250px; |
|
background:rgba(14,21,29,.9);border:1px solid var(--line);border-radius:10px; |
|
padding:11px 13px;font-size:11.5px;color:var(--ink-dim);line-height:1.55; |
|
box-shadow:var(--shadow); |
|
} |
|
#help b{color:var(--ink);} |
|
#help .k{display:inline-block;background:#0c1219;border:1px solid var(--line);border-radius:4px; |
|
padding:0 5px;font-family:'JetBrains Mono';font-size:10px;color:var(--teal);} |
|
#help h4{font-size:11px;letter-spacing:2px;color:var(--teal);margin-bottom:6px;text-transform:uppercase;} |
|
#help .close{position:absolute;top:7px;right:9px;cursor:pointer;color:var(--ink-dim);font-size:14px;} |
|
#help .close:hover{color:var(--ink);} |
|
|
|
#fogbtn{ |
|
position:fixed;right:14px;bottom:16px;z-index:10;cursor:pointer; |
|
background:rgba(14,21,29,.9);border:1px solid var(--line);border-radius:8px; |
|
padding:8px 12px;font-size:11px;letter-spacing:1px;color:var(--ink-dim);text-transform:uppercase; |
|
} |
|
#fogbtn:hover{color:var(--ink);border-color:var(--teal-dim);} |
|
#fogbtn b{color:var(--teal);} |
|
|
|
/* ---------- overlay ---------- */ |
|
#overlay{ |
|
position:fixed;inset:0;z-index:50;display:none;align-items:center;justify-content:center; |
|
background:radial-gradient(circle at center,rgba(7,10,14,.7),rgba(7,10,14,.96));backdrop-filter:blur(3px); |
|
} |
|
#overlay.show{display:flex;} |
|
#ovcard{ |
|
text-align:center;padding:38px 46px;border:1px solid var(--line);border-radius:16px; |
|
background:var(--panel);box-shadow:var(--shadow); |
|
} |
|
#ovcard h1{font-size:36px;letter-spacing:4px;color:var(--red);margin-bottom:6px;} |
|
#ovcard p{color:var(--ink-dim);margin-bottom:22px;letter-spacing:1px;} |
|
#ovcard p b{color:var(--ink);font-family:'JetBrains Mono';} |
|
#restart{ |
|
font-family:'Chakra Petch';font-weight:600;letter-spacing:2px;font-size:14px; |
|
color:#06221f;background:var(--teal);border:none;border-radius:8px;padding:12px 26px;cursor:pointer;text-transform:uppercase; |
|
} |
|
#restart:hover{background:#69f0dc;} |
|
#toast{ |
|
position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:20; |
|
background:rgba(36,20,25,.95);border:1px solid var(--red);color:#ffd5db; |
|
padding:8px 16px;border-radius:8px;font-size:12px;letter-spacing:1px; |
|
opacity:0;transition:opacity .25s;pointer-events:none; |
|
} |
|
#toast.show{opacity:1;} |
|
</style> |
|
</head> |
|
<body> |
|
<canvas id="game"></canvas> |
|
|
|
<div id="topbar"> |
|
<div id="brand">FOUNDRY<small>FACTORY DEFENSE</small></div> |
|
<div class="res"><span class="dot" style="background:var(--copper)"></span><span><span class="lbl">Cu</span><br><span class="val" id="rCopper">0</span></span></div> |
|
<div class="res"><span class="dot" style="background:var(--iron)"></span><span><span class="lbl">Fe</span><br><span class="val" id="rIron">0</span></span></div> |
|
<div class="res"><span class="dot" style="background:var(--plate)"></span><span><span class="lbl">Plate</span><br><span class="val" id="rPlate">0</span></span></div> |
|
<div class="spacer"></div> |
|
<div id="corehpwrap"><div class="lbl">Core integrity</div><div id="corehpbar"><div id="corehpfill"></div></div></div> |
|
<div id="waveinfo">WAVE <b id="waveNum">0</b><br><span id="waveSub">prep: 35s</span></div> |
|
<button id="wavebtn">Call wave ▸</button> |
|
</div> |
|
|
|
<div id="toolbar"></div> |
|
|
|
<div id="help"> |
|
<span class="close" id="helpClose">✕</span> |
|
<h4>Operator manual</h4> |
|
<b>Build:</b> pick a tool, click the map. Drag to draw belts/walls.<br> |
|
<b>Belts</b> carry ore in the drag direction.<br> |
|
<b>Drills</b> sit on ore. <b>Smelters</b> turn Fe → Plate. <b>Turrets</b> burn Cu as ammo.<br> |
|
Feed your <b>core</b> with belts to bank resources.<br><br> |
|
<span class="k">R</span> rotate <span class="k">X</span> demolish <span class="k">Esc</span> cancel<br> |
|
<span class="k">1-5</span> tools <span class="k">WASD</span> pan <span class="k">wheel</span> zoom<br> |
|
<span class="k">Space</span> call next wave |
|
</div> |
|
|
|
<button id="fogbtn">Fog of war: <b id="fogState">ON</b></button> |
|
|
|
<div id="toast"></div> |
|
|
|
<div id="overlay"> |
|
<div id="ovcard"> |
|
<h1 id="ovtitle">CORE LOST</h1> |
|
<p>You survived to wave <b id="ovwave">0</b> and banked <b id="ovscore">0</b> resources.</p> |
|
<button id="restart">Rebuild</button> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
"use strict"; |
|
/* ============================================================ |
|
FOUNDRY — a minimal Mindustry-style factory defense framework |
|
Pure Canvas2D + vanilla JS. No external runtime libraries. |
|
============================================================ */ |
|
|
|
// ---------- canvas ---------- |
|
const canvas = document.getElementById('game'); |
|
const ctx = canvas.getContext('2d'); |
|
let W=0,H=0,DPR=Math.min(window.devicePixelRatio||1,2); |
|
function resize(){ |
|
W=window.innerWidth; H=window.innerHeight; |
|
canvas.width=W*DPR; canvas.height=H*DPR; |
|
canvas.style.width=W+'px'; canvas.style.height=H+'px'; |
|
ctx.setTransform(DPR,0,0,DPR,0,0); |
|
} |
|
window.addEventListener('resize',resize); resize(); |
|
|
|
// ---------- constants ---------- |
|
const MAP_W=78, MAP_H=54, N=MAP_W*MAP_H; |
|
const TILE=28; // world px per tile |
|
const idx=(x,y)=>y*MAP_W+x; |
|
const inBounds=(x,y)=>x>=0&&y>=0&&x<MAP_W&&y<MAP_H; |
|
|
|
const COL = { |
|
ground:'#10161e', grid:'#161f29', |
|
rock:'#27313e', rockEdge:'#1a222c', |
|
oreIron:'#4d5e72', oreCopper:'#b06d33', |
|
iron:'#8ea3b8', copper:'#e08a4a', plate:'#9fe9ff', |
|
belt:'#283442', beltChev:'#3c4d60', |
|
drill:'#f2b138', smelter:'#dd6a44', turret:'#4aa0ff', wall:'#3a4756', |
|
core:'#e8eef5', accent:'#37d6c2', |
|
enemy:'#ff5a6e', enemyDark:'#a02a3a', |
|
tBullet:'#7ce8ff', eBullet:'#ffb347', |
|
}; |
|
|
|
// Building definitions (cost is paid from the core stockpile) |
|
const DEF = { |
|
conveyor:{name:'Belt', hp:90, cost:{copper:1}, rot:true, key:'1'}, |
|
drill: {name:'Drill', hp:150, cost:{copper:10}, rot:true, key:'2'}, |
|
smelter: {name:'Smelter', hp:170, cost:{copper:25}, rot:true, key:'3'}, |
|
turret: {name:'Turret', hp:280, cost:{copper:25,plate:8}, rot:true, key:'4'}, |
|
wall: {name:'Wall', hp:1000, cost:{iron:6}, rot:false, key:'5'}, |
|
core: {name:'Core', hp:1400, cost:{}, rot:false}, |
|
}; |
|
|
|
const DRILL_TIME=0.85; // seconds per ore mined |
|
const SMELT_TIME=1.1; // seconds per plate |
|
const SMELT_IN=2; // iron consumed per plate |
|
const CONV_SPEED=2.3; // belt position units / sec (1 = one tile) |
|
const SPACING=0.27; // min gap between items on a belt |
|
const SMELT_CAP=10, TURRET_AMMO_CAP=40, TURRET_AMMO_START=12; |
|
|
|
const TUR_RANGE=6.5*TILE, TUR_FIRE=0.42, TUR_DMG=17, TUR_PROJ=17*TILE, TUR_AMMO_PER=1; |
|
const ENE_SPEED=1.7, ENE_RANGE=2.7*TILE, ENE_FIRE=0.85, ENE_DMG=7, ENE_PROJ=8*TILE, ENE_RADIUS=9; |
|
const ENE_KILL_REWARD=2; // copper per kill |
|
|
|
const WAVE_PREP=35, WAVE_INTERVAL=30; |
|
const DIRS=[[0,-1],[1,0],[0,1],[-1,0]]; // up,right,down,left |
|
const NEIGH=[[0,-1,1],[1,0,1],[0,1,1],[-1,0,1],[1,-1,1.4142],[1,1,1.4142],[-1,1,1.4142],[-1,-1,1.4142]]; |
|
|
|
// ---------- world state ---------- |
|
let terrain, ore, buildingAt, buildings, enemies, projectiles, items_floats; |
|
let core, stock, fieldOpen, fieldCarve, fieldsDirty, fieldTimer; |
|
let explored, visible, fogOn=true; |
|
let spawnPoints, wave, waveTimer, spawnQueue, spawnTimer, gameOver, banked; |
|
let time=0; |
|
|
|
// ---------- min-heap for Dijkstra ---------- |
|
class MinHeap{ |
|
constructor(){this.k=[];this.v=[];} |
|
get size(){return this.k.length;} |
|
push(key,val){ |
|
const k=this.k,v=this.v; let i=k.length; k.push(key); v.push(val); |
|
while(i>0){const p=(i-1)>>1; if(k[p]<=k[i])break; [k[p],k[i]]=[k[i],k[p]];[v[p],v[i]]=[v[i],v[p]]; i=p;} |
|
} |
|
pop(){ |
|
const k=this.k,v=this.v,n=k.length; const rk=k[0],rv=v[0]; |
|
const lk=k.pop(),lv=v.pop(); |
|
if(n>1){k[0]=lk;v[0]=lv;let i=0; |
|
while(true){let l=2*i+1,r=l+1,s=i; |
|
if(l<k.length&&k[l]<k[s])s=l; if(r<k.length&&k[r]<k[s])s=r; |
|
if(s===i)break; [k[s],k[i]]=[k[i],k[s]];[v[s],v[i]]=[v[i],v[s]]; i=s;}} |
|
return [rk,rv]; |
|
} |
|
} |
|
|
|
// ============================================================ |
|
// MAP GENERATION |
|
// ============================================================ |
|
function genMap(){ |
|
terrain=new Uint8Array(N); |
|
ore=new Uint8Array(N); // 0 none, 1 iron, 2 copper |
|
const cx=MAP_W>>1, cy=MAP_H>>1; |
|
|
|
// scattered impassable rock blobs (avoid the core region) |
|
for(let b=0;b<9;b++){ |
|
const bx=2+((Math.random()*(MAP_W-4))|0), by=2+((Math.random()*(MAP_H-4))|0); |
|
if(Math.abs(bx-cx)<10 && Math.abs(by-cy)<8) continue; |
|
const r=2+(Math.random()*2|0); |
|
for(let y=-r;y<=r;y++)for(let x=-r;x<=r;x++){ |
|
const nx=bx+x,ny=by+y; if(!inBounds(nx,ny))continue; |
|
if(x*x+y*y<=r*r && Math.random()<0.75) terrain[idx(nx,ny)]=1; |
|
} |
|
} |
|
|
|
// ore patches |
|
function patch(type,px,py,r){ |
|
for(let y=-r;y<=r;y++)for(let x=-r;x<=r;x++){ |
|
const nx=px+x,ny=py+y; if(!inBounds(nx,ny))continue; |
|
const d=Math.sqrt(x*x+y*y); |
|
if(d<=r && Math.random()<(1-d/(r+0.6))*0.95){ |
|
const i=idx(nx,ny); if(terrain[i])continue; ore[i]=type; |
|
} |
|
} |
|
} |
|
// guaranteed starter patches near the core |
|
patch(1, cx-6, cy-3, 3); // iron |
|
patch(2, cx+6, cy+3, 3); // copper |
|
patch(2, cx+5, cy-5, 2); |
|
patch(1, cx-5, cy+5, 2); |
|
// random scattered patches |
|
for(let p=0;p<16;p++){ |
|
const px=3+((Math.random()*(MAP_W-6))|0), py=3+((Math.random()*(MAP_H-6))|0); |
|
if(Math.abs(px-cx)<5 && Math.abs(py-cy)<4) continue; |
|
patch(Math.random()<0.5?1:2, px,py, 2+(Math.random()*2|0)); |
|
} |
|
|
|
// clear core footprint |
|
for(let y=-1;y<=1;y++)for(let x=-1;x<=1;x++){const i=idx(cx+x,cy+y);terrain[i]=0;ore[i]=0;} |
|
|
|
// spawn points on the edges, on walkable ground |
|
spawnPoints=[]; |
|
const cands=[[cx,2],[cx,MAP_H-3],[2,cy],[MAP_W-3,cy],[6,6],[MAP_W-7,MAP_H-7],[MAP_W-7,6],[6,MAP_H-7]]; |
|
for(const [sx,sy] of cands){ |
|
let bx=sx,by=sy; |
|
if(terrain[idx(bx,by)]){ // nudge to nearest open |
|
outer: for(let r=1;r<5;r++)for(let y=-r;y<=r;y++)for(let x=-r;x<=r;x++){ |
|
if(inBounds(sx+x,sy+y)&&!terrain[idx(sx+x,sy+y)]){bx=sx+x;by=sy+y;break outer;} |
|
} |
|
} |
|
spawnPoints.push({x:bx,y:by}); |
|
} |
|
} |
|
|
|
// ============================================================ |
|
// BUILDINGS |
|
// ============================================================ |
|
function makeBuilding(type,gx,gy,dir){ |
|
const d=DEF[type]; |
|
const b={type,gx,gy,dir:dir|0,hp:d.hp,maxhp:d.hp}; |
|
if(type==='conveyor'){ b.items=[]; } // [{item,pos}], pos 0(back)→1(front) |
|
if(type==='drill'){ b.cool=DRILL_TIME; b.hold=null; b.oreType=ore[idx(gx,gy)]; } |
|
if(type==='smelter'){ b.in=0; b.prog=0; b.hold=null; } |
|
if(type==='turret'){ b.ammo=TURRET_AMMO_START; b.cool=0; b.angle=-Math.PI/2; b.target=null; } |
|
if(type==='core'){ b.angle=0; } |
|
return b; |
|
} |
|
function placeBuilding(type,gx,gy,dir){ |
|
const b=makeBuilding(type,gx,gy,dir); |
|
const i=idx(gx,gy); |
|
buildingAt[i]=b; buildings.push(b); |
|
fieldsDirty=true; |
|
return b; |
|
} |
|
function removeBuilding(b){ |
|
buildingAt[idx(b.gx,b.gy)]=null; |
|
const k=buildings.indexOf(b); if(k>=0)buildings.splice(k,1); |
|
fieldsDirty=true; |
|
} |
|
const oreName=t=>t===1?'iron':t===2?'copper':null; |
|
|
|
// what a destination tile will accept being pushed into it |
|
function canAccept(b,item){ |
|
if(!b) return false; |
|
switch(b.type){ |
|
case 'conveyor':{ |
|
// space near the back (pos 0)? |
|
for(const it of b.items) if(it.pos<SPACING) return false; |
|
return b.items.length<6; |
|
} |
|
case 'smelter': return item==='iron' && b.in<SMELT_CAP; |
|
case 'turret': return item==='copper' && b.ammo<TURRET_AMMO_CAP; |
|
case 'core': return true; |
|
default: return false; |
|
} |
|
} |
|
function deposit(b,item){ |
|
switch(b.type){ |
|
case 'conveyor': b.items.push({item,pos:0}); break; |
|
case 'smelter': b.in++; break; |
|
case 'turret': b.ammo++; break; |
|
case 'core': stock[item]++; banked++; break; |
|
} |
|
} |
|
// producer (drill/smelter) pushes its held item to the tile it faces |
|
function pushToFront(b){ |
|
const [dx,dy]=DIRS[b.dir]; |
|
const tx=b.gx+dx, ty=b.gy+dy; if(!inBounds(tx,ty)) return false; |
|
const t=buildingAt[idx(tx,ty)]; |
|
if(canAccept(t,b.hold)){ deposit(t,b.hold); b.hold=null; return true; } |
|
return false; |
|
} |
|
|
|
function updateBuilding(b,dt){ |
|
switch(b.type){ |
|
case 'drill':{ |
|
if(b.hold===null){ |
|
if(b.oreType){ b.cool-=dt; if(b.cool<=0){ b.hold=oreName(b.oreType); b.cool=DRILL_TIME; } } |
|
} else pushToFront(b); |
|
break; |
|
} |
|
case 'smelter':{ |
|
if(b.hold===null && b.in>=SMELT_IN){ |
|
b.prog+=dt; |
|
if(b.prog>=SMELT_TIME){ b.in-=SMELT_IN; b.hold='plate'; b.prog=0; } |
|
} |
|
if(b.hold!==null) pushToFront(b); |
|
break; |
|
} |
|
case 'conveyor':{ |
|
const its=b.items; |
|
// keep sorted front→back (descending pos) for clean advancement |
|
its.sort((p,q)=>q.pos-p.pos); |
|
for(let i=0;i<its.length;i++){ |
|
const it=its[i]; |
|
let max = (i===0)?1.0:(its[i-1].pos-SPACING); |
|
it.pos=Math.min(it.pos+CONV_SPEED*dt, max); |
|
} |
|
// transfer the front item if it reached the end |
|
if(its.length){ |
|
const front=its[0]; |
|
if(front.pos>=0.999){ |
|
const [dx,dy]=DIRS[b.dir]; |
|
const tx=b.gx+dx, ty=b.gy+dy; |
|
if(inBounds(tx,ty)){ |
|
const t=buildingAt[idx(tx,ty)]; |
|
if(canAccept(t,front.item)){ deposit(t,front.item); its.shift(); } |
|
} |
|
} |
|
} |
|
break; |
|
} |
|
case 'turret':{ |
|
// acquire nearest enemy in range |
|
const wx=(b.gx+0.5)*TILE, wy=(b.gy+0.5)*TILE; |
|
let best=null,bd=TUR_RANGE*TUR_RANGE; |
|
for(const e of enemies){ const d=(e.x-wx)**2+(e.y-wy)**2; if(d<bd){bd=d;best=e;} } |
|
b.target=best; |
|
if(best){ const want=Math.atan2(best.y-wy,best.x-wx); b.angle=angLerp(b.angle,want,12*dt); } |
|
b.cool-=dt; |
|
if(best && b.ammo>0 && b.cool<=0){ |
|
// simple lead targeting |
|
const tof=Math.sqrt(bd)/TUR_PROJ; |
|
const px=best.x+best.vx*tof, py=best.y+best.vy*tof; |
|
const a=Math.atan2(py-wy,px-wx); |
|
projectiles.push({x:wx,y:wy,vx:Math.cos(a)*TUR_PROJ,vy:Math.sin(a)*TUR_PROJ,dmg:TUR_DMG,life:1.0,from:'t'}); |
|
b.ammo-=TUR_AMMO_PER; b.cool=TUR_FIRE; b.angle=a; |
|
} |
|
break; |
|
} |
|
case 'core':{ b.angle+=dt*0.4; break; } |
|
} |
|
} |
|
|
|
// ============================================================ |
|
// PATHFINDING FIELDS (Dijkstra from the core, two variants) |
|
// ============================================================ |
|
function computeField(blockBuildings){ |
|
const dist=new Float64Array(N).fill(Infinity); |
|
const heap=new MinHeap(); |
|
const ci=idx(core.gx,core.gy); |
|
dist[ci]=0; heap.push(0,ci); |
|
const isBlockedTile=(i)=> terrain[i]===1 || |
|
(blockBuildings && buildingAt[i] && buildingAt[i].type!=='core'); |
|
while(heap.size){ |
|
const [d,i]=heap.pop(); if(d>dist[i]) continue; |
|
const x=i%MAP_W, y=(i/MAP_W)|0; |
|
for(const [dx,dy,c] of NEIGH){ |
|
const nx=x+dx, ny=y+dy; if(!inBounds(nx,ny)) continue; |
|
const ni=ny*MAP_W+nx; |
|
if(terrain[ni]===1) continue; // rock always blocks |
|
const nbBuilding = buildingAt[ni] && buildingAt[ni].type!=='core'; |
|
if(blockBuildings && nbBuilding) continue; // open field: buildings are walls |
|
if(dx!==0 && dy!==0){ // no diagonal corner-cutting |
|
if(isBlockedTile(y*MAP_W+(x+dx)) || isBlockedTile((y+dy)*MAP_W+x)) continue; |
|
} |
|
let step=c; |
|
if(!blockBuildings && nbBuilding) step += 0.5 + buildingAt[ni].hp/120; // carve cost ∝ toughness |
|
const nd=d+step; |
|
if(nd<dist[ni]){ dist[ni]=nd; heap.push(nd,ni); } |
|
} |
|
} |
|
return dist; |
|
} |
|
function recomputeFields(){ |
|
fieldOpen=computeField(true); |
|
fieldCarve=computeField(false); |
|
recomputeVisibility(); |
|
fieldsDirty=false; |
|
} |
|
|
|
// ============================================================ |
|
// FOG OF WAR |
|
// ============================================================ |
|
function discAround(gx,gy,r,arr){ |
|
const r2=r*r, x0=Math.max(0,(gx-r)|0), x1=Math.min(MAP_W-1,(gx+r)|0), |
|
y0=Math.max(0,(gy-r)|0), y1=Math.min(MAP_H-1,(gy+r)|0); |
|
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){ |
|
const dx=x-gx,dy=y-gy; if(dx*dx+dy*dy<=r2){ const i=idx(x,y); arr[i]=1; explored[i]=1; } |
|
} |
|
} |
|
function recomputeVisibility(){ |
|
visible.fill(0); |
|
for(const b of buildings){ |
|
let r=2.5; |
|
if(b.type==='core') r=9; |
|
else if(b.type==='turret') r=TUR_RANGE/TILE+0.5; |
|
else if(b.type==='drill') r=3; |
|
discAround(b.gx+0.5,b.gy+0.5,r,visible); |
|
} |
|
} |
|
|
|
// ============================================================ |
|
// ENEMIES |
|
// ============================================================ |
|
function spawnEnemy(sp,hp){ |
|
enemies.push({ |
|
x:(sp.x+0.5)*TILE, y:(sp.y+0.5)*TILE, vx:0, vy:0, |
|
hp, maxhp:hp, cool:Math.random()*ENE_FIRE, ang:0, |
|
}); |
|
} |
|
function bestNeighbor(e){ |
|
const tx=Math.floor(e.x/TILE), ty=Math.floor(e.y/TILE); |
|
if(!inBounds(tx,ty)) return {tx,ty,carve:false,target:null}; |
|
const ti=idx(tx,ty); |
|
const useOpen=isFinite(fieldOpen[ti]); |
|
const field=useOpen?fieldOpen:fieldCarve; |
|
let best=-1, bestv=field[ti], bx=tx, by=ty; |
|
for(const [dx,dy] of NEIGH){ |
|
const nx=tx+dx, ny=ty+dy; if(!inBounds(nx,ny)) continue; |
|
const ni=idx(nx,ny); |
|
if(terrain[ni]===1) continue; |
|
const nb=buildingAt[ni] && buildingAt[ni].type!=='core'; |
|
if(useOpen && nb) continue; // open mode avoids buildings |
|
if(dx!==0&&dy!==0){ // corner rule |
|
const a=idx(tx+dx,ty), b2=idx(tx,ty+dy); |
|
const ba=terrain[a]===1||(useOpen&&buildingAt[a]&&buildingAt[a].type!=='core'); |
|
const bb=terrain[b2]===1||(useOpen&&buildingAt[b2]&&buildingAt[b2].type!=='core'); |
|
if(ba||bb) continue; |
|
} |
|
const v=field[ni]; |
|
if(v<bestv){ bestv=v; best=ni; bx=nx; by=ny; } |
|
} |
|
let blocking=null; |
|
if(!useOpen && best>=0){ |
|
const cand=buildingAt[best]; |
|
if(cand && cand.type!=='core') blocking=cand; |
|
} |
|
return {tx:bx,ty:by,carve:!useOpen,target:blocking,found:best>=0}; |
|
} |
|
function updateEnemy(e,dt){ |
|
// --- choose attack target --- |
|
let atk=null, ad=ENE_RANGE*ENE_RANGE; |
|
for(const b of buildings){ |
|
const bx=(b.gx+0.5)*TILE, by=(b.gy+0.5)*TILE; |
|
const d=(b.x?0:0)+(e.x-bx)**2+(e.y-by)**2; |
|
if(d<ad){ ad=d; atk=b; } |
|
} |
|
const bn=bestNeighbor(e); |
|
if(bn.target){ // carving: prioritise the blocking building |
|
const bx=(bn.target.gx+0.5)*TILE, by=(bn.target.gy+0.5)*TILE; |
|
const d=(e.x-bx)**2+(e.y-by)**2; |
|
if(d < (ENE_RANGE*1.1)**2) atk=bn.target; |
|
} |
|
|
|
// --- movement --- |
|
let tgx,tgy; |
|
if(bn.found){ tgx=(bn.tx+0.5)*TILE; tgy=(bn.ty+0.5)*TILE; } |
|
else { tgx=(core.gx+0.5)*TILE; tgy=(core.gy+0.5)*TILE; } |
|
let dx=tgx-e.x, dy=tgy-e.y; const dlen=Math.hypot(dx,dy)||1; |
|
let move=true; |
|
if(bn.target){ |
|
// stop just short of the building we're breaking |
|
const bx=(bn.target.gx+0.5)*TILE, by=(bn.target.gy+0.5)*TILE; |
|
if(Math.hypot(e.x-bx,e.y-by) < TILE*0.85) move=false; |
|
} |
|
// light separation from neighbours |
|
let sx=0,sy=0; |
|
for(const o of enemies){ if(o===e)continue; const ox=e.x-o.x, oy=e.y-o.y; const dd=ox*ox+oy*oy; |
|
if(dd>0 && dd<(ENE_RADIUS*2.2)**2){ const inv=1/Math.sqrt(dd); sx+=ox*inv; sy+=oy*inv; } } |
|
|
|
if(move){ |
|
let vx=(dx/dlen)*ENE_SPEED*TILE + sx*22; |
|
let vy=(dy/dlen)*ENE_SPEED*TILE + sy*22; |
|
e.vx=vx; e.vy=vy; e.x+=vx*dt; e.y+=vy*dt; e.ang=Math.atan2(vy,vx); |
|
} else { e.vx*=0.6; e.vy*=0.6; } |
|
|
|
// --- attack --- |
|
e.cool-=dt; |
|
if(atk && e.cool<=0){ |
|
const bx=(atk.gx+0.5)*TILE, by=(atk.gy+0.5)*TILE; |
|
const a=Math.atan2(by-e.y,bx-e.x); |
|
projectiles.push({x:e.x,y:e.y,vx:Math.cos(a)*ENE_PROJ,vy:Math.sin(a)*ENE_PROJ,dmg:ENE_DMG,life:1.4,from:'e'}); |
|
e.cool=ENE_FIRE; e.ang=a; |
|
} |
|
} |
|
|
|
// ============================================================ |
|
// PROJECTILES |
|
// ============================================================ |
|
function updateProjectiles(dt){ |
|
for(let i=projectiles.length-1;i>=0;i--){ |
|
const p=projectiles[i]; |
|
p.x+=p.vx*dt; p.y+=p.vy*dt; p.life-=dt; |
|
let dead=p.life<=0 || p.x<0||p.y<0||p.x>MAP_W*TILE||p.y>MAP_H*TILE; |
|
if(!dead && p.from==='t'){ |
|
for(const e of enemies){ |
|
if((e.x-p.x)**2+(e.y-p.y)**2 < (ENE_RADIUS+3)**2){ |
|
e.hp-=p.dmg; dead=true; break; |
|
} |
|
} |
|
} else if(!dead && p.from==='e'){ |
|
const tx=Math.floor(p.x/TILE), ty=Math.floor(p.y/TILE); |
|
if(inBounds(tx,ty)){ |
|
const b=buildingAt[idx(tx,ty)]; |
|
if(b){ damageBuilding(b,p.dmg); dead=true; } |
|
} |
|
} |
|
if(dead) projectiles.splice(i,1); |
|
} |
|
} |
|
function damageBuilding(b,dmg){ |
|
b.hp-=dmg; |
|
if(b.hp<=0){ |
|
if(b.type==='core'){ endGame(); return; } |
|
removeBuilding(b); |
|
} |
|
} |
|
|
|
// ============================================================ |
|
// WAVES |
|
// ============================================================ |
|
function startWave(){ |
|
wave++; |
|
const count=4+Math.floor(wave*1.6); |
|
const hp=30+wave*12; |
|
const picks=[]; |
|
const k=1+ (wave>4?1:0) + (wave>9?1:0); |
|
for(let j=0;j<k;j++) picks.push(spawnPoints[(Math.random()*spawnPoints.length)|0]); |
|
spawnQueue=[]; |
|
for(let n=0;n<count;n++) spawnQueue.push({sp:picks[n%picks.length],hp}); |
|
spawnTimer=0; |
|
waveTimer=WAVE_INTERVAL; |
|
} |
|
function updateWaves(dt){ |
|
if(gameOver) return; |
|
// spawn queue trickle |
|
if(spawnQueue.length){ |
|
spawnTimer-=dt; |
|
if(spawnTimer<=0){ const s=spawnQueue.shift(); spawnEnemy(s.sp,s.hp); spawnTimer=0.45; } |
|
} |
|
waveTimer-=dt; |
|
if(waveTimer<=0 && spawnQueue.length===0) startWave(); |
|
} |
|
|
|
// ============================================================ |
|
// GAME LIFECYCLE |
|
// ============================================================ |
|
function endGame(){ |
|
if(gameOver) return; gameOver=true; |
|
document.getElementById('ovwave').textContent=wave; |
|
document.getElementById('ovscore').textContent=banked; |
|
document.getElementById('overlay').classList.add('show'); |
|
} |
|
function newGame(){ |
|
buildingAt=new Array(N).fill(null); |
|
buildings=[]; enemies=[]; projectiles=[]; |
|
explored=new Uint8Array(N); visible=new Uint8Array(N); |
|
stock={copper:180, iron:80, plate:0}; |
|
banked=0; wave=0; waveTimer=WAVE_PREP; spawnQueue=[]; spawnTimer=0; |
|
gameOver=false; time=0; |
|
genMap(); |
|
const cx=MAP_W>>1, cy=MAP_H>>1; |
|
core=placeBuilding('core',cx,cy,0); |
|
recomputeFields(); |
|
cam.x=(cx+0.5)*TILE; cam.y=(cy+0.5)*TILE; cam.zoom=1; |
|
document.getElementById('overlay').classList.remove('show'); |
|
} |
|
|
|
// ============================================================ |
|
// CAMERA + INPUT |
|
// ============================================================ |
|
const cam={x:0,y:0,zoom:1}; |
|
const worldToScreen=(wx,wy)=>({sx:(wx-cam.x)*cam.zoom+W/2, sy:(wy-cam.y)*cam.zoom+H/2}); |
|
const screenToWorld=(sx,sy)=>({wx:(sx-W/2)/cam.zoom+cam.x, wy:(sy-H/2)/cam.zoom+cam.y}); |
|
|
|
let tool=null; // selected building type or 'delete' |
|
let ghostDir=1; // current rotation |
|
let mouse={x:0,y:0,tx:0,ty:0,down:false,btn:0}; |
|
let panning=false, panStart=null, dragPrev=null, dragBtn=0; |
|
const keys={}; |
|
|
|
function setTool(t){ |
|
tool = (tool===t)?null:t; |
|
renderToolbar(); |
|
} |
|
function refundCost(cost){ |
|
for(const k in cost) stock[k]=(stock[k]||0)+Math.floor(cost[k]*0.5); |
|
} |
|
function payCost(cost){ |
|
for(const k in cost){ if((stock[k]||0)<cost[k]) return false; } |
|
for(const k in cost) stock[k]-=cost[k]; |
|
return true; |
|
} |
|
function toast(msg){ |
|
const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); |
|
clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove('show'),1300); |
|
} |
|
|
|
function tryPlaceAt(tx,ty,dir){ |
|
if(!inBounds(tx,ty)) return false; |
|
const i=idx(tx,ty); |
|
if(terrain[i]) { return false; } |
|
if(buildingAt[i]) return false; |
|
if(tool==='drill' && !ore[i]){ return 'noore'; } |
|
const cost=DEF[tool].cost; |
|
if(!payCost(cost)){ return 'broke'; } |
|
placeBuilding(tool,tx,ty,dir); |
|
return true; |
|
} |
|
function tryDeleteAt(tx,ty){ |
|
if(!inBounds(tx,ty)) return; |
|
const b=buildingAt[idx(tx,ty)]; |
|
if(b && b.type!=='core'){ refundCost(DEF[b.type].cost); removeBuilding(b); } |
|
} |
|
|
|
function handleClickWorld(){ |
|
if(!tool) return; |
|
const tx=mouse.tx, ty=mouse.ty; |
|
if(tool==='delete'){ tryDeleteAt(tx,ty); dragPrev={x:tx,y:ty}; return; } |
|
if(tool==='conveyor'){ |
|
const r=tryPlaceAt(tx,ty,ghostDir); |
|
if(r==='broke'){ toast('Not enough resources'); } |
|
dragPrev={x:tx,y:ty}; |
|
return; |
|
} |
|
// single-placement buildings (and wall via drag below) |
|
const r=tryPlaceAt(tx,ty,ghostDir); |
|
if(r==='broke') toast('Not enough resources'); |
|
else if(r==='noore') toast('Drills must sit on ore'); |
|
dragPrev={x:tx,y:ty}; |
|
} |
|
function handleDragWorld(){ |
|
const tx=mouse.tx, ty=mouse.ty; |
|
if(!dragPrev || (dragPrev.x===tx && dragPrev.y===ty)) return; |
|
if(tool==='delete'){ tryDeleteAt(tx,ty); dragPrev={x:tx,y:ty}; return; } |
|
if(tool==='wall'){ |
|
if(tryPlaceAt(tx,ty,0)===true) dragPrev={x:tx,y:ty}; |
|
else dragPrev={x:tx,y:ty}; |
|
return; |
|
} |
|
if(tool==='conveyor'){ |
|
const ddx=tx-dragPrev.x, ddy=ty-dragPrev.y; |
|
if(Math.abs(ddx)+Math.abs(ddy)===1){ // adjacent step → flow in drag direction |
|
const dir = ddx===1?1: ddx===-1?3: ddy===1?2:0; |
|
// orient the previously placed belt toward the new cell |
|
const prev=buildingAt[idx(dragPrev.x,dragPrev.y)]; |
|
if(prev && prev.type==='conveyor'){ prev.dir=dir; fieldsDirty=true; } |
|
const r=tryPlaceAt(tx,ty,dir); |
|
if(r===true || buildingAt[idx(tx,ty)]) { ghostDir=dir; dragPrev={x:tx,y:ty}; } |
|
else if(r==='broke'){ toast('Not enough resources'); dragPrev={x:tx,y:ty}; } |
|
} else { |
|
dragPrev={x:tx,y:ty}; |
|
} |
|
} |
|
} |
|
|
|
// ----- pointer events ----- |
|
canvas.addEventListener('contextmenu',e=>e.preventDefault()); |
|
canvas.addEventListener('mousedown',e=>{ |
|
mouse.btn=e.button; |
|
if(e.button===2 || e.button===1 || (e.button===0 && keys[' '])){ |
|
panning=true; panStart={mx:e.clientX,my:e.clientY,cx:cam.x,cy:cam.y}; return; |
|
} |
|
if(e.button===0){ mouse.down=true; updateMouse(e); handleClickWorld(); } |
|
}); |
|
window.addEventListener('mousemove',e=>{ |
|
updateMouse(e); |
|
if(panning && panStart){ |
|
cam.x=panStart.cx-(e.clientX-panStart.mx)/cam.zoom; |
|
cam.y=panStart.cy-(e.clientY-panStart.my)/cam.zoom; |
|
} else if(mouse.down){ handleDragWorld(); } |
|
}); |
|
window.addEventListener('mouseup',e=>{ |
|
if(e.button===2||e.button===1) panning=false; |
|
if(e.button===0){ mouse.down=false; panning=false; dragPrev=null; } |
|
}); |
|
canvas.addEventListener('wheel',e=>{ |
|
e.preventDefault(); |
|
const before=screenToWorld(e.clientX,e.clientY); |
|
const f=e.deltaY<0?1.12:1/1.12; |
|
cam.zoom=Math.max(0.45,Math.min(2.4,cam.zoom*f)); |
|
const after=screenToWorld(e.clientX,e.clientY); |
|
cam.x+=before.wx-after.wx; cam.y+=before.wy-after.wy; |
|
},{passive:false}); |
|
|
|
function updateMouse(e){ |
|
mouse.x=e.clientX; mouse.y=e.clientY; |
|
const w=screenToWorld(e.clientX,e.clientY); |
|
mouse.tx=Math.floor(w.wx/TILE); mouse.ty=Math.floor(w.wy/TILE); |
|
} |
|
|
|
// ----- keyboard ----- |
|
window.addEventListener('keydown',e=>{ |
|
const k=e.key.toLowerCase(); |
|
keys[e.key]=true; |
|
if(k==='r'){ ghostDir=(ghostDir+1)&3; } |
|
else if(k==='x'){ setTool(tool==='delete'?null:'delete'); } |
|
else if(k==='escape'){ tool=null; renderToolbar(); } |
|
else if(k===' '){ if(spawnQueue.length===0) startWave(); e.preventDefault(); } |
|
else if('12345'.includes(k)){ |
|
const map={'1':'conveyor','2':'drill','3':'smelter','4':'turret','5':'wall'}; |
|
setTool(map[k]); |
|
} |
|
}); |
|
window.addEventListener('keyup',e=>{ keys[e.key]=false; }); |
|
|
|
// ============================================================ |
|
// RENDERING |
|
// ============================================================ |
|
function draw(){ |
|
ctx.clearRect(0,0,W,H); |
|
ctx.fillStyle=COL.ground; ctx.fillRect(0,0,W,H); |
|
|
|
// visible tile range |
|
const tl=screenToWorld(0,0), br=screenToWorld(W,H); |
|
const x0=Math.max(0,Math.floor(tl.wx/TILE)-1), x1=Math.min(MAP_W-1,Math.floor(br.wx/TILE)+1); |
|
const y0=Math.max(0,Math.floor(tl.wy/TILE)-1), y1=Math.min(MAP_H-1,Math.floor(br.wy/TILE)+1); |
|
const z=cam.zoom, T=TILE; |
|
|
|
// --- ground / ore / rock --- |
|
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){ |
|
const i=idx(x,y); |
|
if(fogOn && !explored[i]) continue; // unexplored drawn later as black |
|
const s=worldToScreen(x*T,y*T); const px=s.sx, py=s.sy, sz=T*z; |
|
if(terrain[i]===1){ |
|
ctx.fillStyle=COL.rock; ctx.fillRect(px,py,sz+0.5,sz+0.5); |
|
ctx.fillStyle=COL.rockEdge; ctx.fillRect(px,py,sz+0.5,3*z); ctx.fillRect(px,py,3*z,sz+0.5); |
|
} else { |
|
// subtle grid |
|
ctx.strokeStyle=COL.grid; ctx.lineWidth=1; |
|
ctx.strokeRect(px+0.5,py+0.5,sz,sz); |
|
if(ore[i]){ |
|
ctx.fillStyle = ore[i]===1?COL.oreIron:COL.oreCopper; |
|
// speckled ore look |
|
const r=Math.max(1.4,2.2*z); |
|
for(let k=0;k<4;k++){ |
|
const ox=((x*7+y*3+k*5)%5)/5, oy=((x*3+y*7+k*2)%5)/5; |
|
ctx.globalAlpha=0.85; |
|
ctx.beginPath(); ctx.arc(px+(0.2+ox*0.6)*sz, py+(0.2+oy*0.6)*sz, r, 0, 7); ctx.fill(); |
|
} |
|
ctx.globalAlpha=1; |
|
} |
|
} |
|
} |
|
|
|
// --- buildings --- |
|
for(const b of buildings){ |
|
if(b.gx<x0-1||b.gx>x1+1||b.gy<y0-1||b.gy>y1+1) continue; |
|
if(fogOn && !explored[idx(b.gx,b.gy)]) continue; |
|
drawBuilding(b,z,T); |
|
} |
|
|
|
// --- enemies & projectiles (hidden in fog) --- |
|
for(const p of projectiles){ |
|
const tx=Math.floor(p.x/TILE), ty=Math.floor(p.y/TILE); |
|
if(fogOn && (!inBounds(tx,ty)||!visible[idx(tx,ty)])) continue; |
|
const s=worldToScreen(p.x,p.y); |
|
ctx.strokeStyle = p.from==='t'?COL.tBullet:COL.eBullet; |
|
ctx.lineWidth=Math.max(1.5,2.2*z); ctx.lineCap='round'; |
|
const back=0.04; |
|
ctx.beginPath(); ctx.moveTo(s.sx,s.sy); |
|
ctx.lineTo(s.sx-p.vx*back*z, s.sy-p.vy*back*z); ctx.stroke(); |
|
} |
|
for(const e of enemies){ |
|
const tx=Math.floor(e.x/TILE), ty=Math.floor(e.y/TILE); |
|
if(fogOn && (!inBounds(tx,ty)||!visible[idx(tx,ty)])) continue; |
|
drawEnemy(e,z); |
|
} |
|
|
|
// --- fog overlay --- |
|
if(fogOn){ |
|
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){ |
|
const i=idx(x,y); const s=worldToScreen(x*T,y*T); const sz=T*z+0.5; |
|
if(!explored[i]){ ctx.fillStyle=COL.bg||'#0b0f15'; ctx.fillStyle='#0b0f15'; ctx.fillRect(s.sx,s.sy,sz,sz); } |
|
else if(!visible[i]){ ctx.fillStyle='rgba(8,11,16,0.55)'; ctx.fillRect(s.sx,s.sy,sz,sz); } |
|
} |
|
} |
|
|
|
// --- build ghost --- |
|
if(tool && inBounds(mouse.tx,mouse.ty)) drawGhost(z,T); |
|
|
|
// --- spawn markers --- |
|
for(const sp of spawnPoints){ |
|
if(fogOn && !explored[idx(sp.x,sp.y)]) continue; |
|
const s=worldToScreen((sp.x+0.5)*T,(sp.y+0.5)*T); |
|
ctx.strokeStyle='rgba(255,90,110,0.5)'; ctx.lineWidth=1.5*z; |
|
const r=(0.5+0.12*Math.sin(time*3))*T*z; |
|
ctx.beginPath(); ctx.arc(s.sx,s.sy,r,0,7); ctx.stroke(); |
|
} |
|
} |
|
|
|
function rrect(x,y,w,h,r){ |
|
ctx.beginPath(); |
|
ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); |
|
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); |
|
} |
|
const itemColor=t=>t==='iron'?COL.iron:t==='copper'?COL.copper:COL.plate; |
|
|
|
function drawBuilding(b,z,T){ |
|
const s=worldToScreen(b.gx*T,b.gy*T); const px=s.sx,py=s.sy,sz=T*z, cx=px+sz/2, cy=py+sz/2; |
|
const pad=sz*0.12; |
|
switch(b.type){ |
|
case 'conveyor':{ |
|
ctx.fillStyle=COL.belt; rrect(px+1,py+1,sz-2,sz-2,3*z); ctx.fill(); |
|
// animated chevrons in flow direction |
|
const [dx,dy]=DIRS[b.dir]; const ang=Math.atan2(dy,dx); |
|
ctx.save(); ctx.translate(cx,cy); ctx.rotate(ang); |
|
ctx.strokeStyle=COL.beltChev; ctx.lineWidth=Math.max(1.2,2*z); |
|
const flow=(time*CONV_SPEED)%1; |
|
for(let c=-1;c<=1;c++){ |
|
const off=((c+flow)-0.5)*sz*0.6; |
|
ctx.beginPath(); |
|
ctx.moveTo(off-sz*0.12, -sz*0.16); ctx.lineTo(off+sz*0.05, 0); ctx.lineTo(off-sz*0.12, sz*0.16); |
|
ctx.stroke(); |
|
} |
|
ctx.restore(); |
|
// items |
|
for(const it of b.items){ |
|
const t=it.pos; // 0..1 along dir, centered |
|
const ix=cx + dx*(t-0.5)*sz, iy=cy + dy*(t-0.5)*sz; |
|
ctx.fillStyle=itemColor(it.item); |
|
ctx.beginPath(); ctx.arc(ix,iy,Math.max(2,sz*0.16),0,7); ctx.fill(); |
|
ctx.strokeStyle='rgba(0,0,0,.3)'; ctx.lineWidth=1; ctx.stroke(); |
|
} |
|
break; |
|
} |
|
case 'drill':{ |
|
ctx.fillStyle='#1c2530'; rrect(px+pad,py+pad,sz-2*pad,sz-2*pad,4*z); ctx.fill(); |
|
ctx.strokeStyle=COL.drill; ctx.lineWidth=Math.max(1.5,2*z); ctx.stroke(); |
|
// rotating bit |
|
ctx.save(); ctx.translate(cx,cy); ctx.rotate(time*4); |
|
ctx.fillStyle=COL.drill; |
|
for(let k=0;k<3;k++){ ctx.rotate(Math.PI*2/3); |
|
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(sz*0.22,-sz*0.06); ctx.lineTo(sz*0.22,sz*0.06); ctx.fill(); } |
|
ctx.restore(); |
|
drawHold(b,cx,cy,sz); |
|
drawHpBar(b,px,py,sz); |
|
break; |
|
} |
|
case 'smelter':{ |
|
ctx.fillStyle='#241a18'; rrect(px+pad,py+pad,sz-2*pad,sz-2*pad,4*z); ctx.fill(); |
|
const glow=b.in>=SMELT_IN?0.55+0.35*Math.sin(time*6):0.2; |
|
ctx.fillStyle=`rgba(221,106,68,${glow})`; |
|
ctx.beginPath(); ctx.arc(cx,cy,sz*0.2,0,7); ctx.fill(); |
|
ctx.strokeStyle=COL.smelter; ctx.lineWidth=Math.max(1.5,2*z); rrect(px+pad,py+pad,sz-2*pad,sz-2*pad,4*z); ctx.stroke(); |
|
drawArrow(b,cx,cy,sz); |
|
drawHold(b,cx,cy,sz); |
|
drawHpBar(b,px,py,sz); |
|
break; |
|
} |
|
case 'turret':{ |
|
ctx.fillStyle='#16222e'; rrect(px+pad,py+pad,sz-2*pad,sz-2*pad,4*z); ctx.fill(); |
|
ctx.strokeStyle=COL.turret; ctx.lineWidth=Math.max(1.5,2*z); ctx.stroke(); |
|
// barrel |
|
ctx.save(); ctx.translate(cx,cy); ctx.rotate(b.angle); |
|
ctx.fillStyle = b.ammo>0?'#9cc7ff':'#5a6b7d'; |
|
rrect(0,-sz*0.09, sz*0.46, sz*0.18, 2*z); ctx.fill(); |
|
ctx.restore(); |
|
ctx.fillStyle=COL.turret; ctx.beginPath(); ctx.arc(cx,cy,sz*0.13,0,7); ctx.fill(); |
|
// ammo pip ring |
|
const af=b.ammo/TURRET_AMMO_CAP; |
|
ctx.strokeStyle='rgba(124,232,255,0.7)'; ctx.lineWidth=2*z; |
|
ctx.beginPath(); ctx.arc(cx,cy,sz*0.36,-Math.PI/2,-Math.PI/2+af*Math.PI*2); ctx.stroke(); |
|
drawHpBar(b,px,py,sz); |
|
break; |
|
} |
|
case 'wall':{ |
|
ctx.fillStyle=COL.wall; rrect(px+1.5,py+1.5,sz-3,sz-3,3*z); ctx.fill(); |
|
ctx.strokeStyle='#52627a'; ctx.lineWidth=Math.max(1,1.5*z); ctx.stroke(); |
|
drawHpBar(b,px,py,sz); |
|
break; |
|
} |
|
case 'core':{ |
|
ctx.save(); |
|
ctx.shadowColor=COL.accent; ctx.shadowBlur=18*z; |
|
ctx.fillStyle=COL.core; rrect(px+sz*0.16,py+sz*0.16,sz*0.68,sz*0.68,4*z); ctx.fill(); |
|
ctx.restore(); |
|
ctx.save(); ctx.translate(cx,cy); ctx.rotate(b.angle); |
|
ctx.strokeStyle=COL.accent; ctx.lineWidth=2*z; |
|
ctx.beginPath(); ctx.arc(0,0,sz*0.5,0,Math.PI*1.4); ctx.stroke(); |
|
ctx.restore(); |
|
ctx.fillStyle='#0b0f15'; ctx.beginPath(); ctx.arc(cx,cy,sz*0.13,0,7); ctx.fill(); |
|
drawHpBar(b,px,py,sz,true); |
|
break; |
|
} |
|
} |
|
} |
|
function drawHold(b,cx,cy,sz){ |
|
if(b.hold){ ctx.fillStyle=itemColor(b.hold); ctx.beginPath(); ctx.arc(cx,cy,sz*0.1,0,7); ctx.fill(); } |
|
} |
|
function drawArrow(b,cx,cy,sz){ |
|
const [dx,dy]=DIRS[b.dir]; const ang=Math.atan2(dy,dx); |
|
ctx.save(); ctx.translate(cx+dx*sz*0.32,cy+dy*sz*0.32); ctx.rotate(ang); |
|
ctx.fillStyle='rgba(255,255,255,0.5)'; |
|
ctx.beginPath(); ctx.moveTo(0,-sz*0.08); ctx.lineTo(sz*0.1,0); ctx.lineTo(0,sz*0.08); ctx.fill(); |
|
ctx.restore(); |
|
} |
|
function drawHpBar(b,px,py,sz,always){ |
|
if(!always && b.hp>=b.maxhp) return; |
|
const f=Math.max(0,b.hp/b.maxhp); |
|
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(px+sz*0.12,py-4,sz*0.76,3); |
|
ctx.fillStyle= f>0.5?'#5ad6a0': f>0.25?'#f2b138':'#ff5a6e'; |
|
ctx.fillRect(px+sz*0.12,py-4,sz*0.76*f,3); |
|
} |
|
function drawEnemy(e,z){ |
|
const s=worldToScreen(e.x,e.y); const r=ENE_RADIUS*z; |
|
ctx.save(); ctx.translate(s.sx,s.sy); ctx.rotate(e.ang); |
|
ctx.fillStyle=COL.enemy; ctx.strokeStyle=COL.enemyDark; ctx.lineWidth=1.5; |
|
ctx.beginPath(); ctx.moveTo(r*1.1,0); ctx.lineTo(-r*0.8,-r*0.8); ctx.lineTo(-r*0.4,0); ctx.lineTo(-r*0.8,r*0.8); ctx.closePath(); |
|
ctx.fill(); ctx.stroke(); |
|
ctx.restore(); |
|
if(e.hp<e.maxhp){ |
|
const f=e.hp/e.maxhp; |
|
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(s.sx-r,s.sy-r-6,r*2,3); |
|
ctx.fillStyle=COL.red||'#ff5a6e'; ctx.fillStyle='#ff7a8a'; ctx.fillRect(s.sx-r,s.sy-r-6,r*2*f,3); |
|
} |
|
} |
|
function drawGhost(z,T){ |
|
const tx=mouse.tx,ty=mouse.ty,i=idx(tx,ty); |
|
const s=worldToScreen(tx*T,ty*T); const px=s.sx,py=s.sy,sz=T*z; |
|
let ok = !terrain[i] && !buildingAt[i]; |
|
if(tool==='delete'){ |
|
const b=buildingAt[i]; |
|
ctx.strokeStyle = (b&&b.type!=='core')?'#ff5a6e':'rgba(255,90,110,0.35)'; |
|
ctx.lineWidth=2*z; ctx.strokeRect(px+2,py+2,sz-4,sz-4); |
|
ctx.beginPath(); ctx.moveTo(px+5,py+5); ctx.lineTo(px+sz-5,py+sz-5); |
|
ctx.moveTo(px+sz-5,py+5); ctx.lineTo(px+5,py+sz-5); ctx.stroke(); |
|
return; |
|
} |
|
if(tool==='drill' && !ore[i]) ok=false; |
|
if(!payCheck(DEF[tool].cost)) ok=false; |
|
ctx.globalAlpha=0.55; |
|
ctx.fillStyle = ok?'rgba(55,214,194,0.25)':'rgba(255,90,110,0.25)'; |
|
ctx.fillRect(px+1,py+1,sz-2,sz-2); |
|
ctx.strokeStyle = ok?COL.accent:COL.enemy; ctx.lineWidth=2*z; |
|
ctx.strokeRect(px+1.5,py+1.5,sz-3,sz-3); |
|
if(DEF[tool].rot){ |
|
const [dx,dy]=DIRS[ghostDir]; |
|
ctx.fillStyle=ok?COL.accent:COL.enemy; |
|
const ax=px+sz/2+dx*sz*0.32, ay=py+sz/2+dy*sz*0.32, an=Math.atan2(dy,dx); |
|
ctx.save(); ctx.translate(ax,ay); ctx.rotate(an); |
|
ctx.beginPath(); ctx.moveTo(0,-sz*0.1); ctx.lineTo(sz*0.12,0); ctx.lineTo(0,sz*0.1); ctx.fill(); |
|
ctx.restore(); |
|
} |
|
ctx.globalAlpha=1; |
|
} |
|
function payCheck(cost){ for(const k in cost){ if((stock[k]||0)<cost[k]) return false; } return true; } |
|
|
|
// ============================================================ |
|
// UI: toolbar + HUD |
|
// ============================================================ |
|
function iconSVG(type){ |
|
// small abstract glyphs |
|
const c={conveyor:COL.beltChev,drill:COL.drill,smelter:COL.smelter,turret:COL.turret,wall:COL.wall,delete:COL.enemy}[type]; |
|
if(type==='conveyor') return `<svg width="30" height="26"><rect x="3" y="6" width="24" height="14" rx="3" fill="#283442"/><path d="M10 9 L15 13 L10 17 M16 9 L21 13 L16 17" stroke="${c}" stroke-width="2" fill="none"/></svg>`; |
|
if(type==='drill') return `<svg width="30" height="26"><rect x="5" y="4" width="20" height="18" rx="4" fill="none" stroke="${c}" stroke-width="2"/><path d="M15 9 L19 13 L15 17 L11 13 Z" fill="${c}"/></svg>`; |
|
if(type==='smelter') return `<svg width="30" height="26"><rect x="5" y="4" width="20" height="18" rx="4" fill="none" stroke="${c}" stroke-width="2"/><circle cx="15" cy="13" r="5" fill="${c}"/></svg>`; |
|
if(type==='turret') return `<svg width="30" height="26"><rect x="5" y="4" width="20" height="18" rx="4" fill="none" stroke="${c}" stroke-width="2"/><circle cx="13" cy="13" r="4" fill="${c}"/><rect x="13" y="11" width="12" height="4" rx="1" fill="${c}"/></svg>`; |
|
if(type==='wall') return `<svg width="30" height="26"><rect x="4" y="5" width="22" height="16" rx="2" fill="${c}" stroke="#52627a" stroke-width="1.5"/></svg>`; |
|
if(type==='delete') return `<svg width="30" height="26"><path d="M8 7 L22 19 M22 7 L8 19" stroke="${c}" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>`; |
|
} |
|
function costStr(type){ |
|
const c=DEF[type].cost; const parts=[]; |
|
for(const k in c) parts.push(c[k]+({copper:'Cu',iron:'Fe',plate:'Pl'})[k]); |
|
return parts.join(' '); |
|
} |
|
function renderToolbar(){ |
|
const tb=document.getElementById('toolbar'); tb.innerHTML=''; |
|
for(const t of ['conveyor','drill','smelter','turret','wall']){ |
|
const el=document.createElement('div'); |
|
el.className='tool'+(tool===t?' active':''); |
|
el.innerHTML=`<span class="key">${DEF[t].key}</span><div class="ic">${iconSVG(t)}</div> |
|
<div class="nm">${DEF[t].name}</div><div class="ct">${costStr(t)}</div>`; |
|
el.onclick=()=>setTool(t); |
|
tb.appendChild(el); |
|
} |
|
const del=document.createElement('div'); |
|
del.className='tool del'+(tool==='delete'?' active':''); |
|
del.innerHTML=`<span class="key">X</span><div class="ic">${iconSVG('delete')}</div><div class="nm">Demolish</div><div class="ct">+50% refund</div>`; |
|
del.onclick=()=>setTool('delete'); |
|
tb.appendChild(del); |
|
} |
|
function updateHUD(){ |
|
document.getElementById('rCopper').textContent=Math.floor(stock.copper); |
|
document.getElementById('rIron').textContent=Math.floor(stock.iron); |
|
document.getElementById('rPlate').textContent=Math.floor(stock.plate); |
|
document.getElementById('waveNum').textContent=wave; |
|
const remaining=enemies.length+spawnQueue.length; |
|
const sub=document.getElementById('waveSub'); |
|
if(remaining>0) sub.textContent=remaining+' hostiles'; |
|
else sub.textContent='next: '+Math.ceil(Math.max(0,waveTimer))+'s'; |
|
document.getElementById('corehpfill').style.width=Math.max(0,core.hp/core.maxhp*100)+'%'; |
|
const wbtn=document.getElementById('wavebtn'); |
|
wbtn.disabled = spawnQueue.length>0; |
|
} |
|
|
|
// ============================================================ |
|
// MAIN LOOP |
|
// ============================================================ |
|
let last=performance.now(); |
|
function loop(now){ |
|
let dt=(now-last)/1000; last=now; if(dt>0.05)dt=0.05; |
|
time+=dt; |
|
|
|
// keyboard panning |
|
const ps=420/cam.zoom*dt; |
|
if(keys['w']||keys['ArrowUp'])cam.y-=ps; |
|
if(keys['s']||keys['ArrowDown'])cam.y+=ps; |
|
if(keys['a']||keys['ArrowLeft'])cam.x-=ps; |
|
if(keys['d']||keys['ArrowRight'])cam.x+=ps; |
|
|
|
if(!gameOver){ |
|
// rebuild fields periodically when dirty |
|
fieldTimer-=dt; |
|
if(fieldsDirty && fieldTimer<=0){ recomputeFields(); fieldTimer=0.25; } |
|
|
|
for(const b of buildings) updateBuilding(b,dt); |
|
for(let i=enemies.length-1;i>=0;i--){ |
|
const e=enemies[i]; updateEnemy(e,dt); |
|
if(e.hp<=0){ enemies.splice(i,1); stock.copper+=ENE_KILL_REWARD; } |
|
} |
|
updateProjectiles(dt); |
|
updateWaves(dt); |
|
} |
|
|
|
draw(); |
|
updateHUD(); |
|
requestAnimationFrame(loop); |
|
} |
|
|
|
// ============================================================ |
|
// BOOT |
|
// ============================================================ |
|
let fieldTimer0=0; fieldTimer=0; |
|
document.getElementById('wavebtn').onclick=()=>{ if(spawnQueue.length===0) startWave(); }; |
|
document.getElementById('restart').onclick=()=>{ newGame(); }; |
|
document.getElementById('helpClose').onclick=()=>{ document.getElementById('help').style.display='none'; }; |
|
document.getElementById('fogbtn').onclick=()=>{ fogOn=!fogOn; document.getElementById('fogState').textContent=fogOn?'ON':'OFF'; }; |
|
|
|
renderToolbar(); |
|
newGame(); |
|
requestAnimationFrame(loop); |
|
</script> |
|
</body> |
|
</html> |