Skip to content

Instantly share code, notes, and snippets.

@SegFaultAX
Created May 29, 2026 01:34
Show Gist options
  • Select an option

  • Save SegFaultAX/c0d594bdef5cdd33746bd8fbb08c1b76 to your computer and use it in GitHub Desktop.

Select an option

Save SegFaultAX/c0d594bdef5cdd33746bd8fbb08c1b76 to your computer and use it in GitHub Desktop.
Opus 4.8 Factory Game Test
<!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 &nbsp; <span class="k">X</span> demolish &nbsp; <span class="k">Esc</span> cancel<br>
<span class="k">1-5</span> tools &nbsp; <span class="k">WASD</span> pan &nbsp; <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>

Create a Mindustry clone as a single HTML/CSS/JS page. You can bring in an external framework from a CDN, but preferred if it's built as straight Canvas + JS. It should have the following features:

  • Top down view of the map. Possibly with fog of war.
  • At least 2 different resource types (e.g. Iron and Copper) that spawn in random patches around the map.
  • Miners can be built on mineral patches to extract resources.
  • Conveyer belts that can accept resources from miners and move them around for consumption by other buildings.
  • At least 1 simple factory that takes an input resource (e.g. Iron) and produces a new output resource (e.g. Iron Plates), which can also be offloaded onto a conveyor.
  • At least one type of defense turret (which may or may not need to be fed ammunition of some type of resource).
  • An enemy spawner that produces waves of increasing number of enemy units that try to blow up the players main building (e.g. their 'core'). Units should path around terrain and buildings, shooting the nearest player units/buildings as they approach their target. If there is no path to the target, they will carve through the player builds to make one.

The graphics should be very clean and simple, minimal/abstract even. No AI or multiplayer needed, just a simple framework of a factory building game.

// model: opus-4.8-high

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment