Worms
Claude Opus 5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 95,469 bytes · SHA-256 c6a7f8fb9460
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WORMFRONT — Artillery Tactics</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#05070d;font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;color:#e8f0ff;user-select:none;-webkit-user-select:none}
#game{position:fixed;inset:0;display:block;cursor:crosshair;image-rendering:pixelated}
#ui{position:fixed;inset:0;pointer-events:none;z-index:10}
/* ---------- HUD ---------- */
#teamBar{position:absolute;top:10px;left:50%;transform:translateX(-50%);display:flex;gap:10px}
.tcard{min-width:150px;background:linear-gradient(180deg,rgba(14,22,38,.88),rgba(8,12,22,.92));
border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:7px 10px 8px;backdrop-filter:blur(8px);
box-shadow:0 6px 22px rgba(0,0,0,.5);transition:transform .25s,box-shadow .25s,opacity .3s}
.tcard.active{transform:translateY(4px) scale(1.06)}
.tcard.dead{opacity:.32;filter:grayscale(1)}
.tname{font-size:11px;letter-spacing:2px;font-weight:bold;text-transform:uppercase;display:flex;justify-content:space-between;align-items:center}
.tname i{font-style:normal;font-size:9px;opacity:.6;letter-spacing:1px}
.hpwrap{height:9px;background:rgba(0,0,0,.55);border-radius:5px;margin-top:5px;overflow:hidden;position:relative;border:1px solid rgba(255,255,255,.08)}
.hpfill{height:100%;border-radius:4px;transition:width .45s cubic-bezier(.2,.9,.3,1)}
.hpghost{position:absolute;inset:0;height:100%;background:rgba(255,90,90,.5);border-radius:4px;transition:width .9s ease .18s}
.pips{display:flex;gap:3px;margin-top:5px}
.pip{flex:1;height:4px;border-radius:2px;background:rgba(255,255,255,.14);transition:background .3s}
#timer{position:absolute;top:12px;left:20px;font-size:44px;font-weight:bold;font-variant-numeric:tabular-nums;
text-shadow:0 0 22px currentColor,0 3px 8px #000;transition:transform .12s;color:#8fe9ff;line-height:1}
#timerlbl{position:absolute;top:60px;left:22px;font-size:9px;letter-spacing:4px;opacity:.55}
#wind{position:absolute;top:14px;right:20px;text-align:right}
#windLbl{font-size:9px;letter-spacing:4px;opacity:.55;margin-bottom:2px}
#windBar{width:170px;height:16px;position:relative;background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.14);border-radius:8px;overflow:hidden}
#windFill{position:absolute;top:0;bottom:0;left:50%;background:linear-gradient(90deg,#4ad6ff,#7affc8);transition:width .5s,left .5s,background .5s}
#windBar:after{content:"";position:absolute;left:50%;top:0;bottom:0;width:1px;background:rgba(255,255,255,.5)}
#windTxt{font-size:10px;opacity:.8;margin-top:3px;letter-spacing:1px}
#weaponHud{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);display:flex;gap:7px;pointer-events:auto}
.wslot{width:56px;height:60px;background:linear-gradient(180deg,rgba(16,24,42,.9),rgba(8,12,22,.94));border:1px solid rgba(255,255,255,.11);
border-radius:9px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;cursor:pointer;
transition:transform .14s,border-color .14s,box-shadow .14s;position:relative;overflow:hidden}
.wslot:hover{transform:translateY(-5px);border-color:rgba(255,255,255,.4)}
.wslot.sel{border-color:#7affc8;box-shadow:0 0 18px rgba(122,255,200,.4),inset 0 0 20px rgba(122,255,200,.12);transform:translateY(-7px)}
.wslot.out{opacity:.3;filter:grayscale(1)}
.wico{font-size:21px;line-height:1}
.wnm{font-size:7px;letter-spacing:.5px;opacity:.75;text-align:center;padding:0 2px}
.wammo{position:absolute;top:2px;right:4px;font-size:8px;font-weight:bold;color:#ffd166}
.wkey{position:absolute;top:2px;left:4px;font-size:8px;opacity:.4}
#power{position:absolute;bottom:88px;left:50%;transform:translateX(-50%);width:280px;height:13px;
background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.2);border-radius:7px;overflow:hidden;opacity:0;transition:opacity .2s}
#powerFill{height:100%;width:0%;background:linear-gradient(90deg,#4ad6ff,#ffd166,#ff5d5d)}
#msg{position:absolute;top:34%;left:50%;transform:translate(-50%,-50%);font-size:46px;font-weight:bold;
letter-spacing:8px;text-transform:uppercase;text-shadow:0 4px 30px #000;opacity:0;pointer-events:none;text-align:center}
#sub{position:absolute;top:34%;left:50%;transform:translate(-50%,26px);font-size:13px;letter-spacing:5px;opacity:0;text-align:center}
#hint{position:absolute;bottom:84px;left:18px;font-size:10px;line-height:1.7;opacity:.4;letter-spacing:1px}
#invBar{position:absolute;bottom:14px;right:18px;display:flex;gap:7px;pointer-events:auto}
/* ---------- Menu ---------- */
#menu{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 40%,#122036 0%,#070a12 60%,#04060a 100%)}
#menuInner{width:min(780px,92vw);text-align:center}
h1{font-size:clamp(38px,7vw,76px);letter-spacing:12px;font-weight:900;
background:linear-gradient(180deg,#fff,#7affc8 45%,#2b9bd6);-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 6px 30px rgba(60,220,255,.35))}
.tag{font-size:11px;letter-spacing:9px;opacity:.5;margin-top:2px;margin-bottom:26px}
.row{display:flex;gap:10px;justify-content:center;align-items:center;margin:12px 0;flex-wrap:wrap}
.lbl{font-size:10px;letter-spacing:4px;opacity:.5;width:100%;margin-bottom:2px}
.btn{padding:9px 17px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.16);border-radius:8px;
color:#dfe9ff;font-size:12px;letter-spacing:2px;cursor:pointer;transition:all .16s;font-family:inherit}
.btn:hover{background:rgba(122,255,200,.14);border-color:#7affc8;transform:translateY(-2px)}
.btn.on{background:rgba(122,255,200,.2);border-color:#7affc8;color:#fff;box-shadow:0 0 16px rgba(122,255,200,.25)}
#play{margin-top:26px;padding:15px 54px;font-size:17px;letter-spacing:6px;font-weight:bold;
background:linear-gradient(180deg,rgba(122,255,200,.22),rgba(40,160,200,.18));border-color:#7affc8}
.tsetup{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:6px}
.tbox{width:120px;padding:8px;border-radius:9px;border:1px solid rgba(255,255,255,.13);background:rgba(255,255,255,.03)}
.tbox .tn{font-size:11px;font-weight:bold;letter-spacing:2px;margin-bottom:6px}
.tog{font-size:9px;padding:4px 0;border-radius:6px;cursor:pointer;border:1px solid rgba(255,255,255,.15);letter-spacing:2px;transition:all .15s}
.tog:hover{background:rgba(255,255,255,.1)}
#credits{position:absolute;bottom:14px;width:100%;text-align:center;font-size:9px;letter-spacing:3px;opacity:.28}
#fade{position:fixed;inset:0;background:#04060a;z-index:60;pointer-events:none;opacity:0;transition:opacity .4s}
#dmgFlash{position:fixed;inset:0;pointer-events:none;z-index:9;background:radial-gradient(ellipse at center,transparent 40%,rgba(255,40,40,.5));opacity:0;transition:opacity .35s}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="dmgFlash"></div>
<div id="ui">
<div id="timer">60</div><div id="timerlbl">TURN</div>
<div id="teamBar"></div>
<div id="wind"><div id="windLbl">WIND</div><div id="windBar"><div id="windFill"></div></div><div id="windTxt">CALM</div></div>
<div id="msg"></div><div id="sub"></div>
<div id="power"><div id="powerFill"></div></div>
<div id="weaponHud"></div>
<div id="invBar"></div>
<div id="hint">← → MOVE · ENTER JUMP · ↑↓ AIM · SPACE HOLD=POWER<br>1-9 WEAPON · TAB NEXT · MOUSE AIM · M MUTE</div>
</div>
<div id="menu"><div id="menuInner">
<h1>WORMFRONT</h1><div class="tag">ARTILLERY TACTICS</div>
<div class="row"><div class="lbl">TERRAIN</div>
<button class="btn on" data-map="random">RANDOM</button>
<button class="btn" data-map="cave">CAVERN</button>
<button class="btn" data-map="island">ISLAND</button>
<button class="btn" data-map="twin">TWIN ISLES</button></div>
<div class="row"><div class="lbl">TEAMS</div>
<button class="btn" data-teams="2">2</button>
<button class="btn on" data-teams="3">3</button>
<button class="btn" data-teams="4">4</button></div>
<div class="row"><div class="lbl">TURN TIME</div>
<button class="btn" data-time="30">30s</button>
<button class="btn on" data-time="45">45s</button>
<button class="btn" data-time="60">60s</button></div>
<div class="row"><div class="lbl">ROSTER</div><div class="tsetup" id="tsetup"></div></div>
<button class="btn" id="play">DEPLOY</button>
<div id="credits">HOTSEAT · CPU · DESTRUCTIBLE WORLD</div>
</div></div>
<div id="fade"></div>
<script>
"use strict";
/* ============================================================
WORMFRONT — single-file artillery game
============================================================ */
const TAU=Math.PI*2, rnd=(a=1,b=0)=>Math.random()*(a-b)+b, ri=(a,b)=>Math.floor(rnd(b+1,a));
const clamp=(v,a,b)=>v<a?a:v>b?b:v, lerp=(a,b,t)=>a+(b-a)*t;
const dist=(x,y,x2,y2)=>Math.hypot(x2-x,y2-y);
const pick=a=>a[Math.floor(Math.random()*a.length)];
/* ---------------- AUDIO ---------------- */
const Snd=(()=>{
let ctx=null,master=null,muted=false;
function init(){if(ctx)return;ctx=new (window.AudioContext||window.webkitAudioContext)();
master=ctx.createGain();master.gain.value=.35;
const comp=ctx.createDynamicsCompressor();comp.threshold.value=-18;comp.ratio.value=8;
master.connect(comp);comp.connect(ctx.destination);}
function now(){return ctx.currentTime}
function env(g,t,a,d,pk=1){g.gain.setValueAtTime(.0001,t);g.gain.exponentialRampToValueAtTime(pk,t+a);g.gain.exponentialRampToValueAtTime(.0001,t+a+d)}
function tone(f,f2,dur,type="sine",vol=.3,dly=0,atk=.005){
if(!ctx||muted)return;const t=now()+dly;const o=ctx.createOscillator(),g=ctx.createGain();
o.type=type;o.frequency.setValueAtTime(f,t);if(f2!=null)o.frequency.exponentialRampToValueAtTime(Math.max(1,f2),t+dur);
env(g,t,atk,dur,vol);o.connect(g);g.connect(master);o.start(t);o.stop(t+dur+atk+.05);}
function noise(dur,vol=.3,dly=0,f0=1200,f1=200,q=1,type="lowpass"){
if(!ctx||muted)return;const t=now()+dly;const n=Math.floor(ctx.sampleRate*dur);
const buf=ctx.createBuffer(1,n,ctx.sampleRate),d=buf.getChannelData(0);
for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*(1-i/n);
const s=ctx.createBufferSource();s.buffer=buf;const flt=ctx.createBiquadFilter();flt.type=type;
flt.frequency.setValueAtTime(f0,t);flt.frequency.exponentialRampToValueAtTime(Math.max(20,f1),t+dur);flt.Q.value=q;
const g=ctx.createGain();env(g,t,.004,dur,vol);
s.connect(flt);flt.connect(g);g.connect(master);s.start(t);}
return{
init,mute(){muted=!muted;return muted},isMuted:()=>muted,
hover(){tone(880,1180,.05,"triangle",.055)},
click(){tone(520,880,.08,"square",.1);tone(1300,1600,.05,"sine",.05,.02)},
walk(){tone(rnd(150,110),rnd(80,60),.06,"triangle",.07)},
jump(){tone(300,760,.18,"sine",.16);tone(600,1200,.1,"triangle",.05,.02)},
fire(){noise(.22,.4,0,2400,180,1);tone(180,50,.28,"sawtooth",.28)},
shotgun(){noise(.3,.5,0,5000,220,.6);tone(120,40,.3,"square",.25)},
laser(){tone(1600,240,.22,"sawtooth",.2);tone(3200,700,.16,"square",.06)},
airstrike(){tone(220,180,1.2,"sawtooth",.13);tone(226,184,1.2,"sawtooth",.13)},
explode(mag=1){
noise(.55*mag,.55,0,900*mag,60,.8);
tone(110/mag,26,.55*mag,"sine",.45);
tone(70,20,.8*mag,"triangle",.3,.02);
noise(.9*mag,.18,.05,400,40,.5);},
tick(p){tone(1000+p*900,null,.035,"square",.06+p*.1)},
bounce(){tone(rnd(420,300),rnd(200,140),.07,"triangle",.1)},
splash(){noise(.5,.32,0,1800,140,.7);tone(500,120,.35,"sine",.14)},
hurt(){tone(420,120,.2,"square",.16);noise(.14,.12,0,900,200)},
die(){tone(400,60,.7,"sawtooth",.22);tone(260,40,.9,"triangle",.16,.06);noise(.5,.15,.1,600,80)},
pickup(){[660,880,1320].forEach((f,i)=>tone(f,f*1.02,.14,"triangle",.14,i*.06))},
heal(){[520,660,784,1046].forEach((f,i)=>tone(f,f,.16,"sine",.12,i*.05))},
turn(){[392,523,659].forEach((f,i)=>tone(f,f,.2,"triangle",.13,i*.07))},
win(){[523,659,784,1046,1318].forEach((f,i)=>tone(f,f,.42,"triangle",.16,i*.11))},
drill(){noise(.3,.16,0,700,500,4,"bandpass")},
teleport(){tone(200,2400,.35,"sine",.18);tone(2400,200,.35,"triangle",.09,.1)},
chute(){noise(.6,.1,0,600,200,.5)},
clank(){tone(1400,700,.09,"square",.09);noise(.08,.1,0,3000,900)},
rope(){tone(900,1400,.06,"square",.07)},
wind(v){}
};})();
/* ---------------- CONFIG ---------------- */
const W=()=>innerWidth,H=()=>innerHeight;
const MAPW=2600, MAPH=1100;
const GRAV=0.34, WATER_Y=MAPH-118;
const TEAMS=[
{name:"CRIMSON", col:"#ff5d6e", col2:"#8e1f34", hat:"#ffd7dc"},
{name:"AZURE", col:"#5db8ff", col2:"#1c4d8e", hat:"#d7ecff"},
{name:"VERDANT", col:"#7affa0", col2:"#1f7a44", hat:"#dcffe6"},
{name:"AMBER", col:"#ffc75d", col2:"#8e5f1c", hat:"#fff0d7"}
];
const NAMES=["Bogsley","Nubbin","Grit","Wriggler","Squelch","Torpid","Blip","Mucus","Chaff","Gnash",
"Pip","Slag","Waddle","Crumb","Nozzle","Fizz","Tumbler","Grub","Squib","Noodle","Klank","Dredge"];
/* ---------------- WEAPONS ---------------- */
// cat: 'arc' indirect | 'direct' instant | 'aoe' map-altering | 'util'
const WEAPONS=[
{id:"bazooka",name:"BAZOOKA",ico:"🚀",cat:"arc",ammo:Infinity,pw:1,dmg:45,rad:52,wind:1,key:"1",
desc:"Wind-affected rocket"},
{id:"grenade",name:"GRENADE",ico:"💣",cat:"arc",ammo:Infinity,pw:.9,dmg:48,rad:56,wind:.35,fuse:3,bounce:.55,key:"2",
desc:"3s fuse, bounces"},
{id:"cluster",name:"CLUSTER",ico:"🧨",cat:"arc",ammo:4,pw:.9,dmg:26,rad:36,wind:.5,fuse:2.6,bounce:.4,cluster:6,key:"3",
desc:"Splits into 6 bomblets"},
{id:"shotgun",name:"SHOTGUN",ico:"🔫",cat:"direct",ammo:6,dmg:26,rad:26,shots:2,pellets:7,spread:.09,range:520,key:"4",
desc:"2 blasts, hitscan cone"},
{id:"railgun",name:"RAILGUN",ico:"⚡",cat:"direct",ammo:2,dmg:58,rad:22,pierce:true,range:1800,key:"5",
desc:"Pierces terrain & worms"},
{id:"mine",name:"MINE",ico:"🕳️",cat:"arc",ammo:3,pw:.55,dmg:52,rad:60,wind:.15,bounce:.2,proximity:true,key:"6",
desc:"Armed proximity charge"},
{id:"holy",name:"HOLY BOMB",ico:"🔔",cat:"aoe",ammo:1,pw:.85,dmg:95,rad:125,wind:.4,fuse:2.4,bounce:.3,shake:26,key:"7",
desc:"Devastating blast"},
{id:"airstrike",name:"AIRSTRIKE",ico:"✈️",cat:"aoe",ammo:2,dmg:38,rad:52,target:true,shake:16,key:"8",
desc:"Click a target zone"},
{id:"drill",name:"DRILL BOMB",ico:"🛠️",cat:"aoe",ammo:2,pw:.8,dmg:34,rad:46,wind:.3,drill:true,key:"9",
desc:"Burrows then detonates"},
{id:"quake",name:"TREMOR",ico:"🌋",cat:"aoe",ammo:1,dmg:30,rad:70,quake:true,shake:34,key:"0",
desc:"Collapses nearby terrain"},
// utilities
{id:"jetpack",name:"JETPACK",ico:"🎒",cat:"util",ammo:2,util:"jet",key:"J",desc:"Fly for 4 seconds"},
{id:"teleport",name:"TELEPORT",ico:"🌀",cat:"util",ammo:2,util:"tele",key:"T",desc:"Blink anywhere"},
{id:"girder",name:"GIRDER",ico:"🧱",cat:"util",ammo:3,util:"girder",key:"G",desc:"Place a platform"},
{id:"medkit",name:"MEDPACK",ico:"➕",cat:"util",ammo:1,util:"heal",key:"H",desc:"Restore 45 HP"},
{id:"chute",name:"PARACHUTE",ico:"🪂",cat:"util",ammo:2,util:"chute",key:"P",desc:"Negate fall damage"},
];
const WMAP={};WEAPONS.forEach(w=>WMAP[w.id]=w);
/* ---------------- STATE ---------------- */
let cv,ctx2d,terr,tctx,tdata,timg;
let G=null;
const keys={};let mouse={x:0,y:0,down:false,wx:0,wy:0};
/* ============================================================
TERRAIN — pixel mask (Uint8) + rendered canvas
============================================================ */
const T={w:MAPW,h:MAPH,mask:null,canvas:null,ctx:null,img:null,dirty:true};
function noise1D(n,octaves,seedArr){
const out=new Float32Array(n);
for(let o=0;o<octaves;o++){
const freq=Math.pow(2,o),amp=Math.pow(0.55,o);
const pts=Math.max(2,Math.ceil(freq*4)+1);const vals=[];
for(let i=0;i<pts;i++)vals.push(Math.random()*2-1);
for(let i=0;i<n;i++){
const t=i/(n-1)*(pts-1);const i0=Math.floor(t),i1=Math.min(pts-1,i0+1);const f=t-i0;
const s=f*f*(3-2*f);out[i]+=lerp(vals[i0],vals[i1],s)*amp;}
}
return out;
}
function genTerrain(kind){
T.mask=new Uint8Array(MAPW*MAPH);
const m=T.mask;
const surf=new Float32Array(MAPW);
if(kind==="cave"){
const top=noise1D(MAPW,5), bot=noise1D(MAPW,5), mid=noise1D(MAPW,3);
for(let x=0;x<MAPW;x++){
const ceil=110+top[x]*95;
const floor=MAPH-230+bot[x]*110;
surf[x]=floor;
for(let y=0;y<MAPH;y++){
let solid = (y<ceil)||(y>floor);
m[y*MAPW+x]=solid?1:0;
}
}
// carve internal blobs + pillars
for(let i=0;i<26;i++){
const cx=rnd(MAPW),cy=rnd(MAPH-260,180),r=rnd(120,45);
for(let y=Math.max(0,cy-r|0);y<Math.min(MAPH,cy+r);y++)for(let x=Math.max(0,cx-r|0);x<Math.min(MAPW,cx+r);x++){
const d=dist(x,y,cx,cy);if(d<r*(0.75+0.25*Math.sin(x*0.03+y*0.02)))m[y*MAPW+x]=0;}
}
for(let i=0;i<16;i++){ // stalactites/mites & floating platforms
const cx=rnd(MAPW),cy=rnd(MAPH-300,240),rw=rnd(150,50),rh=rnd(46,14);
for(let y=Math.max(0,cy-rh|0);y<Math.min(MAPH,cy+rh);y++)for(let x=Math.max(0,cx-rw|0);x<Math.min(MAPW,cx+rw);x++){
const dx=(x-cx)/rw,dy=(y-cy)/rh;if(dx*dx+dy*dy<1)m[y*MAPW+x]=1;}
}
} else if(kind==="island"){
const n=noise1D(MAPW,5);
const cx=MAPW/2, span=MAPW*0.40;
for(let x=0;x<MAPW;x++){
const d=Math.abs(x-cx)/span;
const dome=d<1?Math.cos(d*Math.PI/2):0;
const base=MAPH-150 - dome*(430+n[x]*90) - n[x]*35;
surf[x]=base;
const thick = d<1? (200+dome*430) : 0;
for(let y=0;y<MAPH;y++){
const solid = y>base && y < base+thick+250 && d<1.02;
m[y*MAPW+x]=solid?1:0;
}
}
carveCaves(m,7);
} else { // twin
const n=noise1D(MAPW,5);
for(let x=0;x<MAPW;x++){
let best=0;
for(const c of [MAPW*0.24,MAPW*0.76]){
const d=Math.abs(x-c)/(MAPW*0.215);
if(d<1)best=Math.max(best,Math.cos(d*Math.PI/2));
}
const base=MAPH-160-best*(400+n[x]*70)-n[x]*28;
surf[x]=base;
for(let y=0;y<MAPH;y++) m[y*MAPW+x]= (best>0.02 && y>base && y<base+240+best*400)?1:0;
}
// a thin bridge
if(Math.random()<0.55){
const by=MAPH-560+rnd(80,-40);
for(let x=MAPW*0.42;x<MAPW*0.58;x++)for(let y=by;y<by+26;y++) if(y>0&&y<MAPH) m[(y|0)*MAPW+(x|0)]=1;
}
carveCaves(m,5);
}
buildTerrainCanvas(kind);
}
function carveCaves(m,count){
for(let i=0;i<count;i++){
let x=rnd(MAPW*0.85,MAPW*0.15),y=rnd(MAPH-260,MAPH-560),a=rnd(TAU);
for(let s=0;s<180;s++){
const r=rnd(42,18);
for(let yy=Math.max(0,y-r|0);yy<Math.min(MAPH,y+r);yy++)for(let xx=Math.max(0,x-r|0);xx<Math.min(MAPW,x+r);xx++)
if(dist(xx,yy,x,y)<r)m[yy*MAPW+xx]=0;
a+=rnd(.5,-.5);x+=Math.cos(a)*10;y+=Math.sin(a)*8;
if(x<40||x>MAPW-40||y<200||y>MAPH-90)break;
}
}
}
let terrainPalette;
function buildTerrainCanvas(kind){
T.canvas=document.createElement("canvas");T.canvas.width=MAPW;T.canvas.height=MAPH;
T.ctx=T.canvas.getContext("2d",{willReadFrequently:false});
const palettes=[
{top:"#7ee06a",top2:"#4fae43",soil:"#8a5a34",soil2:"#5d3a1f",deep:"#3a2415",rock:"#4a4550"},
{top:"#8fe0d8",top2:"#3fa8a0",soil:"#5a6b8a",soil2:"#37445d",deep:"#222b3d",rock:"#4c5468"},
{top:"#e5c56a",top2:"#b8913a",soil:"#a06a3a",soil2:"#6b4322",deep:"#3f2916",rock:"#5a4c40"},
{top:"#d8a0e0",top2:"#8d4fa8",soil:"#6a4a7a",soil2:"#432c50",deep:"#2a1a33",rock:"#4d4058"}
];
terrainPalette=pick(palettes);
repaintTerrainRegion(0,0,MAPW,MAPH);
}
function repaintTerrainRegion(x0,y0,w,h){
x0=clamp(x0|0,0,MAPW);y0=clamp(y0|0,0,MAPH);
w=clamp(w|0,0,MAPW-x0);h=clamp(h|0,0,MAPH-y0);
if(w<=0||h<=0)return;
const p=terrainPalette;
const img=T.ctx.createImageData(w,h);const d=img.data;const m=T.mask;
const hexToRgb=hx=>[parseInt(hx.slice(1,3),16),parseInt(hx.slice(3,5),16),parseInt(hx.slice(5,7),16)];
const cTop=hexToRgb(p.top),cTop2=hexToRgb(p.top2),cSoil=hexToRgb(p.soil),cSoil2=hexToRgb(p.soil2),cDeep=hexToRgb(p.deep),cRock=hexToRgb(p.rock);
for(let y=0;y<h;y++){
const gy=y0+y;
for(let x=0;x<w;x++){
const gx=x0+x;const i=(y*w+x)*4;
if(!m[gy*MAPW+gx]){d[i+3]=0;continue;}
// depth from surface
let depth=0;
while(depth<26 && gy-depth-1>=0 && m[(gy-depth-1)*MAPW+gx])depth++;
const nz=(Math.sin(gx*0.11)*0.5+Math.sin(gy*0.17+gx*0.03)*0.5+Math.sin(gx*0.031+gy*0.041)*1.2);
let c;
if(depth<3){c=cTop;}
else if(depth<7){c=lerpC(cTop,cTop2,(depth-3)/4);}
else if(depth<12){c=lerpC(cTop2,cSoil,(depth-7)/5);}
else if(depth<20){c=lerpC(cSoil,cSoil2,(depth-12)/8);}
else {c=cSoil2;}
// deep rock band
if(gy>MAPH-300){const t=clamp((gy-(MAPH-300))/220,0,1);c=lerpC(c,cRock,t*0.75);}
if(depth>=24){c=lerpC(c,cDeep,.5)}
const v=nz*9 + (Math.random()*6-3);
d[i]=clamp(c[0]+v,0,255);d[i+1]=clamp(c[1]+v,0,255);d[i+2]=clamp(c[2]+v,0,255);d[i+3]=255;
}
}
T.ctx.putImageData(img,x0,y0);
}
function lerpC(a,b,t){return [lerp(a[0],b[0],t),lerp(a[1],b[1],t),lerp(a[2],b[2],t)]}
function solidAt(x,y){
x=x|0;y=y|0;
if(x<0||x>=MAPW||y<0)return false;
if(y>=MAPH)return false;
return T.mask[y*MAPW+x]===1;
}
function addSolid(x0,y0,w,h){
for(let y=Math.max(0,y0);y<Math.min(MAPH,y0+h);y++)for(let x=Math.max(0,x0);x<Math.min(MAPW,x0+w);x++)T.mask[y*MAPW+x]=1;
repaintTerrainRegion(x0-2,y0-2,w+4,h+4);
}
// Circular subtraction + spawn debris chunks
function blast(cx,cy,r,spawnDebris=true){
cx|=0;cy|=0;r|=0;
const x0=clamp(cx-r-2,0,MAPW),x1=clamp(cx+r+2,0,MAPW),y0=clamp(cy-r-2,0,MAPH),y1=clamp(cy+r+2,0,MAPH);
let removed=0;
const m=T.mask;
for(let y=y0;y<y1;y++){
const dy=y-cy;
for(let x=x0;x<x1;x++){
const dx=x-cx;
if(dx*dx+dy*dy<=r*r){ if(m[y*MAPW+x]){m[y*MAPW+x]=0;removed++;} }
}
}
// scorch ring: darken remaining edge pixels
T.ctx.save();
T.ctx.globalCompositeOperation="destination-out";
T.ctx.beginPath();T.ctx.arc(cx,cy,r,0,TAU);T.ctx.fill();
T.ctx.restore();
// repaint a ring band so edges get fresh surface shading
repaintRing(cx,cy,r);
if(spawnDebris&&removed>40){
const n=clamp(Math.floor(removed/260),3,26);
for(let i=0;i<n;i++){
const a=rnd(TAU),rr=rnd(r,r*.25);
const px=cx+Math.cos(a)*rr,py=cy+Math.sin(a)*rr;
G.debris.push(new Debris(px,py,Math.cos(a)*rnd(6,1)+rnd(1,-1),Math.sin(a)*rnd(6,1)-rnd(3,0),rnd(9,3)));
}
}
return removed;
}
function repaintRing(cx,cy,r){
const pad=30;
repaintTerrainRegion(cx-r-pad,cy-r-pad,(r+pad)*2,(r+pad)*2);
}
/* ============================================================
ENTITIES
============================================================ */
class Particle{
constructor(x,y,vx,vy,life,col,size,opts={}){
this.x=x;this.y=y;this.vx=vx;this.vy=vy;this.life=life;this.max=life;this.col=col;this.size=size;
this.g=opts.g!==undefined?opts.g:.12;this.drag=opts.drag||.99;this.glow=opts.glow||false;
this.shrink=opts.shrink!==undefined?opts.shrink:1;this.wind=opts.wind||0;this.spark=opts.spark||false;
this.smoke=opts.smoke||false;this.rot=rnd(TAU);this.vr=rnd(.3,-.3);
}
update(dt){
this.vy+=this.g*dt;this.vx*=Math.pow(this.drag,dt);this.vy*=Math.pow(this.drag,dt);
this.vx+=G.wind*this.wind*dt*0.06;
this.x+=this.vx*dt;this.y+=this.vy*dt;this.rot+=this.vr*dt;
this.life-=dt;
return this.life>0;
}
draw(c){
const t=this.life/this.max;
c.globalAlpha=clamp(this.smoke?t*.55:t,0,1);
const s=this.size*(this.shrink?(0.25+t*0.75):1)*(this.smoke?(2-t):1);
if(this.glow){c.globalCompositeOperation="lighter";}
c.fillStyle=this.col;
if(this.spark){
c.save();c.translate(this.x,this.y);c.rotate(Math.atan2(this.vy,this.vx));
c.fillRect(-s*2.2,-s*.4,s*4.4,s*.8);c.restore();
} else {
c.beginPath();c.arc(this.x,this.y,Math.max(.4,s),0,TAU);c.fill();
}
c.globalCompositeOperation="source-over";c.globalAlpha=1;
}
}
class Debris{
constructor(x,y,vx,vy,s){this.x=x;this.y=y;this.vx=vx;this.vy=vy;this.s=s;this.rot=rnd(TAU);this.vr=rnd(.35,-.35);
this.life=rnd(9,5);this.settled=false;
const p=terrainPalette;this.col=pick([p.soil,p.soil2,p.top2,p.deep]);}
update(dt){
if(this.settled){this.life-=dt*2;return this.life>0;}
this.vy+=GRAV*dt*.9;this.vx+=G.wind*0.004*dt;this.vx*=Math.pow(.995,dt);
let nx=this.x+this.vx*dt,ny=this.y+this.vy*dt;
if(ny>WATER_Y){ splashFX(this.x,WATER_Y,.5); return false; }
if(solidAt(nx,ny)){
// try slide
if(!solidAt(nx,this.y)){this.x=nx;this.vy*=-.25;this.vx*=.7;}
else if(!solidAt(this.x,ny)){this.y=ny;this.vx*=-.35;}
else{
// settle: deposit into terrain
this.settled=true;this.life=.3;
depositChunk(this.x,this.y,this.s*.55);
return true;
}
this.vr*=.6;
if(Math.abs(this.vy)<.6&&Math.abs(this.vx)<.4){this.settled=true;this.life=.3;depositChunk(this.x,this.y,this.s*.55);}
} else {this.x=nx;this.y=ny;}
this.rot+=this.vr*dt;
this.life-=dt*.016;
if(this.x<-50||this.x>MAPW+50)return false;
return this.life>0;
}
draw(c){
c.save();c.translate(this.x,this.y);c.rotate(this.rot);
c.globalAlpha=this.settled?clamp(this.life/.3,0,1):1;
c.fillStyle=this.col;c.fillRect(-this.s/2,-this.s/2,this.s,this.s);
c.fillStyle="rgba(0,0,0,.25)";c.fillRect(-this.s/2,this.s*.15,this.s,this.s*.35);
c.restore();c.globalAlpha=1;
}
}
function depositChunk(x,y,r){
x|=0;y|=0;r=Math.max(1,r|0);
for(let yy=y-r;yy<=y+r;yy++)for(let xx=x-r;xx<=x+r;xx++){
if(xx<0||xx>=MAPW||yy<0||yy>=MAPH)continue;
if((xx-x)**2+(yy-y)**2<=r*r)T.mask[yy*MAPW+xx]=1;
}
repaintTerrainRegion(x-r-3,y-r-3,r*2+6,r*2+6);
}
class FloatText{
constructor(x,y,txt,col,size=20){this.x=x;this.y=y;this.txt=txt;this.col=col;this.life=1.7;this.max=1.7;this.size=size;this.vy=-1.5;this.vx=rnd(.6,-.6)}
update(dt){this.y+=this.vy*dt;this.vy*=Math.pow(.94,dt);this.x+=this.vx*dt;this.life-=dt*.016;return this.life>0}
draw(c){
const t=this.life/this.max;c.globalAlpha=clamp(t*1.6,0,1);
c.font=`900 ${this.size*(1+(1-t)*.25)}px "Trebuchet MS",sans-serif`;c.textAlign="center";
c.lineWidth=5;c.strokeStyle="rgba(0,0,0,.8)";c.strokeText(this.txt,this.x,this.y);
c.fillStyle=this.col;c.fillText(this.txt,this.x,this.y);c.globalAlpha=1;
}
}
/* ---------------- WORM ---------------- */
class Worm{
constructor(team,name,x,y){
this.team=team;this.name=name;this.x=x;this.y=y;this.vx=0;this.vy=0;
this.hp=100;this.maxhp=100;this.dead=false;this.r=7;this.h=16;
this.face=Math.random()<.5?-1:1;this.aim=-0.6;this.onGround=false;
this.fallStart=null;this.walkT=0;this.blink=0;this.blinkT=rnd(4,1);
this.chute=false;this.jet=0;this.drowning=0;this.frozen=0;
this.hpShown=100;this.dmgAccum=0;this.recoil=0;this.breathe=rnd(TAU);
this.grave=0;this.launched=false;
}
get cx(){return this.x} get cy(){return this.y}
damage(amt,src){
if(this.dead)return;
amt=Math.round(amt);if(amt<=0)return;
this.hp-=amt;this.dmgAccum+=amt;
G.floats.push(new FloatText(this.x,this.y-22,"-"+amt,"#ff6b6b",Math.min(34,16+amt*.32)));
for(let i=0;i<Math.min(18,4+amt/4);i++)
G.parts.push(new Particle(this.x,this.y,rnd(3,-3),rnd(1,-4),rnd(30,14),pick(["#ff5d6e","#ffa0aa","#ff2d4d"]),rnd(3.5,1.4),{g:.16}));
if(G.active===this)flashDamage();
Snd.hurt();
if(this.hp<=0){this.hp=0;this.die();}
}
die(){
if(this.dead)return;this.dead=true;this.grave=1;
Snd.die();
explosion(this.x,this.y,52,32,null,{shake:9});
G.floats.push(new FloatText(this.x,this.y-34,"K.O.","#ffffff",26));
}
update(dt){
if(this.dead){return;}
this.breathe+=dt*.06;
if(this.blinkT>0){this.blinkT-=dt*.016;}else{this.blink+=dt;if(this.blink>7){this.blink=0;this.blinkT=rnd(5,1.6)}}
if(this.frozen>0)this.frozen-=dt*.016;
// jetpack
const isActive=(G.active===this&&(G.phase==="aim"||G.phase==="retreat")&&!G.cpuThinking);
if(this.jet>0){
this.jet-=dt*.016;
let th=0,tx=0;
if(isActive||this.aiJet){
if(this.aiJet){th=this.aiJetUp?1:0;tx=this.aiJetX||0;}
else{ if(keys["ArrowUp"]||keys["KeyW"])th=1; if(keys["ArrowLeft"]||keys["KeyA"])tx=-1; if(keys["ArrowRight"]||keys["KeyD"])tx=1; }
}
if(th){this.vy-=0.62*dt;
for(let i=0;i<2;i++)G.parts.push(new Particle(this.x+rnd(3,-3),this.y+8,rnd(1,-1),rnd(4,1.5),rnd(20,8),pick(["#ffd166","#ff8b3d","#fff2c0"]),rnd(3.4,1.6),{g:.02,glow:true}));
if(Math.random()<.2)Snd.chute();}
if(tx){this.vx+=tx*0.14*dt;this.face=tx;}
if(this.jet<=0){this.jet=0;this.aiJet=false;}
}
// physics
const inWater=this.y>WATER_Y;
if(inWater){
this.drowning+=dt*.016;
this.vy+=(GRAV*0.14)*dt; this.vy-=0.10*dt; // buoyancy-ish sink
this.vy=clamp(this.vy,-2,1.6);
this.vx*=Math.pow(.93,dt);
this.y+=this.vy*dt;this.x+=this.vx*dt;
if(Math.random()<.14)G.parts.push(new Particle(this.x+rnd(6,-6),this.y,rnd(.4,-.4),-rnd(1.4,.3),rnd(40,18),"#bfefff",rnd(2.6,1),{g:-0.01,drag:.99}));
this.damage(dt*0.28,"water");
if(this.hp<=0)return;
if(this.y>MAPH+40){this.hp=0;this.die();}
return;
} else this.drowning=0;
const moving=(isActive&&this.jet<=0&&!G.firing&&(keys["ArrowLeft"]||keys["ArrowRight"]||keys["KeyA"]||keys["KeyD"]))||this.aiMove;
let dir=0;
if(this.aiMove)dir=this.aiMove;
else if(isActive&&this.jet<=0){ if(keys["ArrowLeft"]||keys["KeyA"])dir=-1; if(keys["ArrowRight"]||keys["KeyD"])dir=1; }
if(dir&&this.onGround&&this.frozen<=0){
this.face=dir;
const speed=1.35;
// step up/down
let nx=this.x+dir*speed*dt;
let placed=false;
for(let s=0;s<=6;s++){ // try step up
if(!collidesAt(nx,this.y-s)){this.x=nx;this.y-=s;placed=true;break;}
}
if(!placed){/*blocked*/}
else{
this.walkT+=dt*.35;
if(Math.random()<.09)Snd.walk();
if(Math.random()<.35)G.parts.push(new Particle(this.x-dir*4,this.y+this.h/2,-dir*rnd(1,.2),-rnd(.8,0),rnd(22,10),"rgba(200,190,170,.75)",rnd(2.6,1.2),{g:.03,smoke:true}));
}
}
// wind push while airborne
if(!this.onGround) this.vx += G.wind*0.0016*dt*(this.chute?4:1);
this.vy+=GRAV*dt*(this.chute&&this.vy>0?0.12:1);
if(this.chute&&this.vy>1.1)this.vy=1.1;
this.vx*=Math.pow(this.onGround?.86:.995,dt);
if(Math.abs(this.vx)<.02)this.vx=0;
let steps=Math.max(1,Math.ceil((Math.abs(this.vx)+Math.abs(this.vy))*dt/3));
const sdt=dt/steps;
for(let s=0;s<steps;s++){
let nx=this.x+this.vx*sdt, ny=this.y+this.vy*sdt;
// vertical
if(!collidesAt(this.x,ny)){this.y=ny;this.onGround=false;}
else{
if(this.vy>0){ // landing
// find surface
let yy=Math.floor(this.y);
while(!collidesAt(this.x,yy+1)&&yy<ny)yy++;
this.y=yy;
this.land();
} else { this.vy=0; }
this.vy=0;
this.onGround=true;
}
// horizontal
if(!collidesAt(nx,this.y)){this.x=nx;}
else{
let ok=false;
for(let s2=1;s2<=5;s2++){if(!collidesAt(nx,this.y-s2)){this.x=nx;this.y-=s2;ok=true;break;}}
if(!ok){this.vx*=-.28;}
}
}
// ground check
if(this.vy>=0 && collidesAt(this.x,this.y+1)){ if(!this.onGround)this.land(); this.onGround=true; }
else if(this.vy!==0||!collidesAt(this.x,this.y+1)) this.onGround=collidesAt(this.x,this.y+1);
// fall damage tracking
if(!this.onGround){ if(this.fallStart===null)this.fallStart=this.y; else if(this.y<this.fallStart)this.fallStart=this.y; }
this.x=clamp(this.x,4,MAPW-4);
if(this.y>MAPH+60){this.hp=0;this.die();}
}
land(){
if(this.fallStart!==null){
const fall=this.y-this.fallStart;
if(fall>105&&!this.chute){
const d=Math.min(45,Math.floor((fall-105)/7));
if(d>0){this.damage(d,"fall");
G.shake=Math.min(14,G.shake+d*.3);
for(let i=0;i<10;i++)G.parts.push(new Particle(this.x+rnd(8,-8),this.y+8,rnd(2.5,-2.5),-rnd(1.6,.2),rnd(26,12),"rgba(210,200,180,.8)",rnd(3,1.4),{g:.06,smoke:true}));
}
} else if(fall>40){
for(let i=0;i<6;i++)G.parts.push(new Particle(this.x+rnd(7,-7),this.y+8,rnd(1.8,-1.8),-rnd(1,.1),rnd(20,10),"rgba(210,200,180,.65)",rnd(2.4,1),{g:.05,smoke:true}));
Snd.clank();
}
this.fallStart=null;
}
if(this.chute){this.chute=false;}
this.launched=false;
}
jump(mode=0){
if(!this.onGround||this.dead||this.frozen>0)return;
if(mode===0){this.vy=-5.4;this.vx=this.face*2.2;}
else{this.vy=-7.4;this.vx=-this.face*1.4;}
this.onGround=false;this.fallStart=this.y;Snd.jump();
for(let i=0;i<8;i++)G.parts.push(new Particle(this.x+rnd(6,-6),this.y+8,rnd(2,-2),rnd(1,-.3),rnd(20,8),"rgba(220,210,190,.7)",rnd(2.6,1.2),{g:.05,smoke:true}));
}
draw(c){
if(this.dead){return;}
const t=TEAMS[this.team];
const bob=Math.sin(this.breathe)*0.8;
c.save();c.translate(this.x,this.y+bob);
// shadow
c.globalAlpha=.28;c.fillStyle="#000";c.beginPath();c.ellipse(0,this.h/2+1,9,3.2,0,0,TAU);c.fill();c.globalAlpha=1;
const sq=this.onGround?1:1.06;
c.scale(this.face,1);
// body
const grad=c.createLinearGradient(0,-10,0,10);
grad.addColorStop(0,t.col);grad.addColorStop(1,t.col2);
c.fillStyle=grad;
c.beginPath();c.ellipse(0,0,8.5,9.5*sq,0,0,TAU);c.fill();
c.strokeStyle="rgba(0,0,0,.45)";c.lineWidth=1.4;c.stroke();
// belly highlight
c.fillStyle="rgba(255,255,255,.18)";c.beginPath();c.ellipse(-2.4,-3,3.6,4.4,-.3,0,TAU);c.fill();
// eyes
const eyeY=-3.2, blinking=this.blink>0&&this.blink<0.9;
c.fillStyle="#fff";
if(!blinking){
c.beginPath();c.ellipse(2.4,eyeY,3.1,3.4,0,0,TAU);c.fill();
c.beginPath();c.ellipse(-1.4,eyeY,2.6,3,0,0,TAU);c.fill();
const la=clamp(this.aim,-1.4,1.4);
const px=Math.cos(la)*1.1,py=Math.sin(la)*1.1;
c.fillStyle="#101018";
c.beginPath();c.arc(2.6+px,eyeY+py,1.5,0,TAU);c.fill();
c.beginPath();c.arc(-1.3+px,eyeY+py,1.3,0,TAU);c.fill();
}else{
c.strokeStyle="#101018";c.lineWidth=1.2;
c.beginPath();c.moveTo(.6,eyeY);c.lineTo(4.4,eyeY);c.moveTo(-3,eyeY);c.lineTo(-.2,eyeY);c.stroke();
}
// hat / band
c.fillStyle=t.hat;
c.beginPath();c.ellipse(0,-8.4,7.2,2.4,0,0,TAU);c.fill();
c.fillStyle=t.col2;c.fillRect(-4.2,-12.6,8.4,4.4);
c.fillStyle="rgba(255,255,255,.25)";c.fillRect(-4.2,-12.6,8.4,1.4);
c.restore();
// parachute
if(this.chute){
c.save();c.translate(this.x,this.y-8);
c.fillStyle="rgba(255,255,255,.9)";
c.beginPath();c.arc(0,-16,20,Math.PI,0);c.closePath();c.fill();
c.fillStyle=t.col;c.beginPath();c.arc(0,-16,20,Math.PI*1.15,Math.PI*1.45);c.lineTo(0,-16);c.fill();
c.beginPath();c.arc(0,-16,20,Math.PI*1.55,Math.PI*1.85);c.lineTo(0,-16);c.fill();
c.strokeStyle="rgba(255,255,255,.7)";c.lineWidth=1;
c.beginPath();c.moveTo(-19,-16);c.lineTo(-3,-2);c.moveTo(19,-16);c.lineTo(3,-2);c.stroke();
c.restore();
}
// jetpack
if(this.jet>0){
c.save();c.translate(this.x,this.y);c.scale(this.face,1);
c.fillStyle="#3a4052";c.fillRect(-11,-6,5,12);
c.fillStyle="#6b7488";c.fillRect(-11,-6,5,3);
c.restore();
}
// name + hp tag
const label=this.name;
c.font='bold 10px "Trebuchet MS",sans-serif';c.textAlign="center";
const tw=Math.max(c.measureText(label).width,30)+12;
const ty=this.y-26;
c.fillStyle="rgba(6,10,20,.72)";roundRect(c,this.x-tw/2,ty-11,tw,22,5);c.fill();
c.fillStyle=t.col;c.fillText(label,this.x,ty-1);
// hp bar
c.fillStyle="rgba(0,0,0,.6)";c.fillRect(this.x-tw/2+3,ty+3,tw-6,4);
this.hpShown=lerp(this.hpShown,this.hp,.12);
c.fillStyle=this.hp>55?"#7affa0":this.hp>25?"#ffd166":"#ff5d6e";
c.fillRect(this.x-tw/2+3,ty+3,(tw-6)*clamp(this.hpShown/this.maxhp,0,1),4);
if(G.active===this&&G.phase==="aim"){
// arrow
const a=Math.sin(performance.now()*.006)*3;
c.fillStyle="#fff";
c.beginPath();c.moveTo(this.x,ty-16+a);c.lineTo(this.x-6,ty-25+a);c.lineTo(this.x+6,ty-25+a);c.closePath();c.fill();
}
}
}
function roundRect(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
function collidesAt(x,y){
// worm body approximated by 3 probes
for(const[ox,oy] of [[0,-6],[0,6],[-5,2],[5,2],[0,0]]){
if(solidAt(x+ox,y+oy))return true;
}
return false;
}
/* ---------------- PROJECTILES ---------------- */
class Proj{
constructor(x,y,vx,vy,w,owner,opts={}){
this.x=x;this.y=y;this.vx=vx;this.vy=vy;this.w=w;this.owner=owner;
this.life=opts.life||14;this.fuse=opts.fuse!==undefined?opts.fuse:(w.fuse||null);
this.bounce=w.bounce||0;this.windF=(w.wind!==undefined?w.wind:1)*(opts.windMul||1);
this.dmg=opts.dmg||w.dmg;this.rad=opts.rad||w.rad;this.cluster=opts.noCluster?0:(w.cluster||0);
this.trailT=0;this.rot=Math.atan2(vy,vx);this.dead=false;this.drill=w.drill||false;this.drilling=0;
this.proximity=w.proximity||false;this.armed=0;this.smoke=opts.smoke!==undefined?opts.smoke:true;
this.shake=w.shake||0;this.age=0;this.homing=opts.homing||0;
}
update(dt){
this.age+=dt*.016;
if(this.fuse!==null){this.fuse-=dt*.016;if(this.fuse<=0){this.boom();return false}}
if(this.proximity){
this.armed+=dt*.016;
if(this.armed>1.2){
for(const wm of G.worms) if(!wm.dead&&wm!==this.ownerWorm&&dist(this.x,this.y,wm.x,wm.y)<38){this.boom();return false}
}
}
if(this.drilling>0){
this.drilling-=dt*.016;
const sp=2.6;
this.x+=Math.cos(this.rot)*sp*dt;this.y+=Math.sin(this.rot)*sp*dt;
blast(this.x,this.y,9,false);
if(Math.random()<.5)G.parts.push(new Particle(this.x,this.y,rnd(2,-2),rnd(2,-2),rnd(18,8),pick([terrainPalette.soil,terrainPalette.soil2]),rnd(3,1.2),{g:.1}));
if(Math.random()<.3)Snd.drill();
if(this.drilling<=0||!solidAt(this.x,this.y)&&this.age>1.2){this.boom();return false}
return true;
}
this.vy+=GRAV*dt;
this.vx+=G.wind*0.0022*this.windF*dt;
if(this.homing){
let best=null,bd=1e9;
for(const wm of G.worms)if(!wm.dead&&wm.team!==this.ownerTeam){const d=dist(this.x,this.y,wm.x,wm.y);if(d<bd){bd=d;best=wm}}
if(best&&bd<400){const a=Math.atan2(best.y-this.y,best.x-this.x);const sp=Math.hypot(this.vx,this.vy);
const ca=Math.atan2(this.vy,this.vx);let da=a-ca;while(da>Math.PI)da-=TAU;while(da<-Math.PI)da+=TAU;
const na=ca+clamp(da,-.05,.05)*dt;this.vx=Math.cos(na)*sp;this.vy=Math.sin(na)*sp;}
}
const steps=Math.max(1,Math.ceil((Math.abs(this.vx)+Math.abs(this.vy))*dt/2.5));
const sdt=dt/steps;
for(let s=0;s<steps;s++){
const nx=this.x+this.vx*sdt, ny=this.y+this.vy*sdt;
// worm hit
for(const wm of G.worms){
if(wm.dead)continue;
if(this.age<0.09&&wm===this.ownerWorm)continue;
if(dist(nx,ny,wm.x,wm.y)<11){this.x=nx;this.y=ny;this.boom();return false;}
}
if(ny>WATER_Y){this.x=nx;this.y=WATER_Y;splashFX(this.x,WATER_Y,1.2);Snd.splash();return false;}
if(solidAt(nx,ny)){
if(this.drill){this.rot=Math.atan2(this.vy,this.vx);this.drilling=1.0;this.x=nx;this.y=ny;Snd.drill();return true;}
if(this.bounce>0){
const n=surfaceNormal(nx,ny);
const dot=this.vx*n.x+this.vy*n.y;
this.vx=(this.vx-2*dot*n.x)*this.bounce;this.vy=(this.vy-2*dot*n.y)*this.bounce;
this.x+=n.x*2.2;this.y+=n.y*2.2;
if(Math.hypot(this.vx,this.vy)>1.1)Snd.bounce();
for(let i=0;i<4;i++)G.parts.push(new Particle(this.x,this.y,rnd(2,-2),rnd(1,-2),rnd(16,7),"rgba(220,210,190,.7)",rnd(2,.8),{g:.1,smoke:true}));
if(Math.hypot(this.vx,this.vy)<0.35&&this.fuse===null&&!this.proximity){this.boom();return false;}
break;
} else {this.x=nx;this.y=ny;this.boom();return false;}
}
this.x=nx;this.y=ny;
}
this.rot=Math.atan2(this.vy,this.vx);
if(this.x<-200||this.x>MAPW+200||this.y>MAPH+200){return false;}
if(this.y<-3000)return false;
this.trailT+=dt;
if(this.smoke&&this.trailT>1.4){this.trailT=0;
G.parts.push(new Particle(this.x,this.y,rnd(.4,-.4),rnd(.2,-.5),rnd(34,18),"rgba(220,220,230,.6)",rnd(3.4,1.8),{g:-0.01,drag:.96,smoke:true,wind:1}));
if(this.w.id==="bazooka"||this.w.id==="cluster")
G.parts.push(new Particle(this.x,this.y,rnd(.6,-.6),rnd(.6,-.6),rnd(12,5),pick(["#ffd166","#ff8b3d"]),rnd(2.2,1),{g:0,glow:true}));
}
this.life-=dt*.016;
return this.life>0;
}
boom(){
if(this.dead)return;this.dead=true;
explosion(this.x,this.y,this.rad,this.dmg,this.ownerWorm,{shake:this.shake||this.rad*.16,big:this.rad>95});
if(this.cluster){
for(let i=0;i<this.cluster;i++){
const a=-Math.PI/2+rnd(1.1,-1.1);const sp=rnd(5.2,2.4);
const p=new Proj(this.x,this.y-6,Math.cos(a)*sp,Math.sin(a)*sp,this.w,this.owner,{noCluster:true,dmg:this.w.dmg*.75,rad:this.w.rad*.8,fuse:rnd(1.6,.7)});
p.ownerWorm=this.ownerWorm;p.ownerTeam=this.ownerTeam;p.fuse=rnd(1.7,.6);
G.projs.push(p);
}
}
}
draw(c){
c.save();c.translate(this.x,this.y);
if(this.drilling>0){
c.rotate(this.rot);c.fillStyle="#c8ccd8";c.beginPath();c.moveTo(9,0);c.lineTo(-6,-4);c.lineTo(-6,4);c.closePath();c.fill();c.restore();return;
}
const id=this.w.id;
if(id==="bazooka"){
c.rotate(this.rot);
c.fillStyle="#e8e8ee";c.beginPath();c.moveTo(9,0);c.lineTo(-5,-3.6);c.lineTo(-5,3.6);c.closePath();c.fill();
c.fillStyle="#ff5d5d";c.fillRect(-7,-3.4,4,6.8);
c.globalCompositeOperation="lighter";c.fillStyle="rgba(255,180,80,.85)";
c.beginPath();c.ellipse(-10-Math.random()*4,0,rnd(7,4),rnd(3,1.6),0,0,TAU);c.fill();
} else if(id==="grenade"||id==="cluster"){
c.rotate(this.age*7);
c.fillStyle=id==="cluster"?"#c94a2a":"#3f5f3a";
c.beginPath();c.arc(0,0,5.4,0,TAU);c.fill();
c.strokeStyle="rgba(0,0,0,.4)";c.lineWidth=1;c.stroke();
c.fillStyle="#8a8a92";c.fillRect(-1.6,-8,3.2,3.4);
if(this.fuse!==null&&Math.sin(this.age*(18+ (3-this.fuse)*14))>0){
c.globalCompositeOperation="lighter";c.fillStyle="#ffdd66";c.beginPath();c.arc(0,-9,2.6,0,TAU);c.fill();}
} else if(id==="mine"){
c.fillStyle="#4a4a52";c.beginPath();c.arc(0,0,6,0,TAU);c.fill();
c.strokeStyle="#2a2a30";c.lineWidth=1.5;
for(let i=0;i<6;i++){const a=i/6*TAU;c.beginPath();c.moveTo(Math.cos(a)*5,Math.sin(a)*5);c.lineTo(Math.cos(a)*8.5,Math.sin(a)*8.5);c.stroke();}
const on=this.armed>1.2?(Math.sin(this.age*14)>0):(Math.sin(this.age*4)>0);
c.globalCompositeOperation="lighter";c.fillStyle=on?"#ff3b3b":"rgba(80,20,20,.6)";c.beginPath();c.arc(0,-1,2.4,0,TAU);c.fill();
} else if(id==="holy"){
c.rotate(Math.sin(this.age*6)*.4);
c.fillStyle="#ffd166";c.beginPath();c.moveTo(0,-9);c.lineTo(7,5);c.lineTo(-7,5);c.closePath();c.fill();
c.fillStyle="#fff3c4";c.fillRect(-8,5,16,3);
c.globalCompositeOperation="lighter";c.fillStyle="rgba(255,230,150,.35)";c.beginPath();c.arc(0,0,14+Math.sin(this.age*10)*3,0,TAU);c.fill();
} else if(id==="airstrike"){
c.rotate(this.rot+Math.PI/2);
c.fillStyle="#b8bcc8";c.beginPath();c.moveTo(0,-8);c.lineTo(3.4,2);c.lineTo(0,8);c.lineTo(-3.4,2);c.closePath();c.fill();
c.fillStyle="#ff6b4a";c.fillRect(-4,3,8,2.6);
} else {
c.fillStyle="#ddd";c.beginPath();c.arc(0,0,4,0,TAU);c.fill();
}
c.restore();c.globalCompositeOperation="source-over";
}
}
function surfaceNormal(x,y){
let nx=0,ny=0;const R=6;
for(let dy=-R;dy<=R;dy+=2)for(let dx=-R;dx<=R;dx+=2){
if(solidAt(x+dx,y+dy)){nx-=dx;ny-=dy;}
}
const l=Math.hypot(nx,ny)||1;return{x:nx/l,y:ny/l};
}
/* ---------------- EFFECTS ---------------- */
function explosion(x,y,r,dmg,srcWorm,opts={}){
const removed=blast(x,y,r,true);
Snd.explode(clamp(r/55,.6,2.1));
G.shake=Math.min(46,G.shake+(opts.shake||r*.16));
G.flashes.push({x,y,r,life:1,max:1});
if(opts.big||r>95){G.freeze=Math.max(G.freeze,0.09);}
else if(r>60)G.freeze=Math.max(G.freeze,0.045);
const n=Math.floor(clamp(r*1.5,22,120));
for(let i=0;i<n;i++){
const a=rnd(TAU),sp=rnd(r*.16,.6);
G.parts.push(new Particle(x,y,Math.cos(a)*sp,Math.sin(a)*sp,rnd(34,12),
pick(["#fff6c9","#ffd166","#ff8b3d","#ff4d2d"]),rnd(6,2),{g:.07,drag:.93,glow:true}));
}
for(let i=0;i<n*.6;i++){
const a=rnd(TAU),sp=rnd(r*.09,.3);
G.parts.push(new Particle(x+rnd(8,-8),y+rnd(8,-8),Math.cos(a)*sp,Math.sin(a)*sp-.4,rnd(70,34),
"rgba(70,68,72,.75)",rnd(11,4),{g:-.012,drag:.95,smoke:true,wind:1.6}));
}
for(let i=0;i<10;i++){
const a=rnd(TAU),sp=rnd(r*.24,r*.08);
G.parts.push(new Particle(x,y,Math.cos(a)*sp,Math.sin(a)*sp,rnd(20,9),"#fff8d8",rnd(2.4,1),{g:.05,glow:true,spark:true,drag:.9}));
}
// damage & knockback
for(const wm of G.worms){
if(wm.dead)continue;
const d=dist(x,y,wm.x,wm.y);
if(d<r*1.5){
const f=clamp(1-d/(r*1.5),0,1);
const dm=dmg*Math.pow(f,.75);
if(dm>1){
const a=Math.atan2(wm.y-y,wm.x-x)||-Math.PI/2;
const kb=f*(r/40)*3.4;
wm.vx+=Math.cos(a)*kb;wm.vy+=Math.sin(a)*kb-f*2.2;
wm.onGround=false;wm.launched=true;
if(wm.fallStart===null)wm.fallStart=wm.y;
wm.damage(dm,srcWorm);
if(srcWorm&&srcWorm!==wm&&srcWorm.team!==wm.team)G.turnDamage+=dm;
}
}
}
// detonate nearby mines
for(const p of G.projs){ if(p!==null&&p.proximity&&!p.dead&&dist(x,y,p.x,p.y)<r*1.2){p.fuse=rnd(.35,.05);} }
}
function splashFX(x,y,scale=1){
Snd.splash();
for(let i=0;i<Math.floor(26*scale);i++){
const a=-Math.PI/2+rnd(1.1,-1.1);const sp=rnd(6*scale,1.6);
G.parts.push(new Particle(x+rnd(6,-6),y,Math.cos(a)*sp,Math.sin(a)*sp,rnd(40,16),pick(["#9fe4ff","#ffffff","#5fc4f0"]),rnd(3.6,1.2),{g:.14,drag:.98}));
}
G.ripples.push({x,y,r:4,max:70*scale,life:1});
}
function flashDamage(){
const f=document.getElementById("dmgFlash");f.style.opacity=".85";
setTimeout(()=>f.style.opacity="0",70);
}
/* ============================================================
GAME SETUP
============================================================ */
const Cfg={map:"random",teams:3,time:45,cpu:[false,true,true,true]};
function newGame(){
const kind = Cfg.map==="random"?pick(["cave","island","twin"]):Cfg.map;
genTerrain(kind);
G={
kind, worms:[],projs:[],parts:[],debris:[],floats:[],flashes:[],ripples:[],girders:[],crates:[],
teamIdx:0, active:null, phase:"aim", turnTime:Cfg.time, timeLeft:Cfg.time,
wind:rnd(1,-1)*rnd(1,.15)*40, shake:0, freeze:0, cam:{x:MAPW/2,y:MAPH/2,zoom:1,tx:MAPW/2,ty:MAPH/2,tz:1},
power:0, charging:false, firing:false, weapon:"bazooka", ammo:{}, retreat:0,
over:false, turnDamage:0, cpuThinking:false, aiPlan:null, waitTimer:0, msgT:0,
clouds:[], birds:[], stars:[], sunk:0, targetMode:false, teleMode:false, girderMode:false,
girderAng:0, roundCount:0, lastFireW:null, camLock:null, camLockT:0, hudPulse:0
};
// ammo per team
for(let t=0;t<Cfg.teams;t++){G.ammo[t]={};for(const w of WEAPONS)G.ammo[t][w.id]=w.ammo;}
// spawn worms
const spots=findSpawnSpots(Cfg.teams*4);
let si=0;
const usedNames=[...NAMES];
for(let t=0;t<Cfg.teams;t++){
for(let i=0;i<4;i++){
const s=spots[si++]||{x:rnd(MAPW-200,200),y:200};
const nm=usedNames.length?usedNames.splice(ri(0,usedNames.length-1),1)[0]:"Worm"+si;
const wm=new Worm(t,nm,s.x,s.y);
G.worms.push(wm);
}
}
// clouds & bg
for(let i=0;i<18;i++)G.clouds.push({x:rnd(MAPW+600,-300),y:rnd(MAPH*.55,20),s:rnd(1.6,.5),sp:rnd(.5,.12),o:rnd(.5,.16)});
for(let i=0;i<70;i++)G.stars.push({x:rnd(MAPW),y:rnd(MAPH*.6),r:rnd(1.4,.4),p:rnd(TAU)});
G.active=G.worms[0];
centerCam(G.active.x,G.active.y,true);
buildTeamBar();buildWeaponHud();
updateWind();
startTurn(true);
}
function findSpawnSpots(n){
const spots=[];let tries=0;
while(spots.length<n&&tries<8000){
tries++;
const x=ri(70,MAPW-70);
let y=-1;
for(let yy=60;yy<WATER_Y-30;yy++){ if(solidAt(x,yy)&&!solidAt(x,yy-24)){y=yy-11;break;} }
if(y<0)continue;
if(y>WATER_Y-40)continue;
// headroom
let clear=true;
for(let k=1;k<26;k++) if(solidAt(x,y-k)){clear=false;break;}
if(!clear)continue;
if(spots.some(s=>Math.abs(s.x-x)<90))continue;
spots.push({x,y});
}
// shuffle so teams interleave
for(let i=spots.length-1;i>0;i--){const j=ri(0,i);[spots[i],spots[j]]=[spots[j],spots[i]];}
return spots;
}
/* ---------------- TURN LOGIC ---------------- */
function aliveWorms(t){return G.worms.filter(w=>!w.dead&&(t===undefined||w.team===t))}
function aliveTeams(){const s=new Set();G.worms.forEach(w=>{if(!w.dead)s.add(w.team)});return [...s]}
function startTurn(first){
G.phase="aim";G.firing=false;G.charging=false;G.power=0;G.retreat=0;
G.turnDamage=0;G.targetMode=false;G.teleMode=false;G.girderMode=false;G.aiPlan=null;G.cpuThinking=false;
G.timeLeft=G.turnTime;
if(!first)updateWind();
// pick next team with alive worms
const at=aliveTeams();
if(at.length<=1){endGame();return;}
let guard=0;
do{G.teamIdx=(G.teamIdx+1)%Cfg.teams;guard++;}while(!at.includes(G.teamIdx)&&guard<20);
if(first)G.teamIdx=at[0];
// next worm in that team (rotate)
const tw=G.worms.filter(w=>w.team===G.teamIdx);
const alive=tw.filter(w=>!w.dead);
let idx=tw.indexOf(G.active);
let next=null;
for(let i=1;i<=tw.length;i++){const cand=tw[(Math.max(0,idx)+i)%tw.length];if(!cand.dead){next=cand;break;}}
if(!next)next=alive[0];
G.active=next;
if(!G.active){endGame();return;}
G.weapon=validWeapon(G.weapon);
G.camLock=null;
centerCam(G.active.x,G.active.y);
G.cam.tz=1.0;
Snd.turn();
const t=TEAMS[G.teamIdx];
showMsg(t.name,(Cfg.cpu[G.teamIdx]?"CPU · ":"")+G.active.name.toUpperCase(),t.col);
buildTeamBar();buildWeaponHud();
// maybe drop a crate
if(!first&&Math.random()<0.34)dropCrate();
if(Cfg.cpu[G.teamIdx]){G.cpuThinking=true;setTimeout(()=>planAI(),650);}
}
function validWeapon(id){
if(G.ammo[G.teamIdx][id]>0)return id;
for(const w of WEAPONS)if(G.ammo[G.teamIdx][w.id]>0)return w.id;
return "bazooka";
}
function endTurn(){
if(G.phase==="over")return;
G.phase="wait";G.waitTimer=0;
}
function afterActions(){
// check win
if(aliveTeams().length<=1){endGame();return;}
G.roundCount++;
startTurn(false);
}
function endGame(){
G.phase="over";G.over=true;
const at=aliveTeams();
if(at.length===1){
const t=TEAMS[at[0]];showMsg(t.name+" WINS","VICTORY",t.col,4.5);Snd.win();
} else {showMsg("DRAW","MUTUAL DESTRUCTION","#ccc",4.5);}
setTimeout(()=>{document.getElementById("menu").style.display="flex";document.getElementById("menu").style.opacity=0;
requestAnimationFrame(()=>{document.getElementById("menu").style.transition="opacity .6s";document.getElementById("menu").style.opacity=1;});},4200);
}
function updateWind(){
const mag=rnd(1,0)**1.4;
G.wind=(Math.random()<.5?-1:1)*mag*46;
const f=document.getElementById("windFill");
const p=clamp(G.wind/50,-1,1);
f.style.width=Math.abs(p)*50+"%";
f.style.left=(p<0?50-Math.abs(p)*50:50)+"%";
f.style.background=Math.abs(p)>.6?"linear-gradient(90deg,#ff8b3d,#ff5d5d)":"linear-gradient(90deg,#4ad6ff,#7affc8)";
const s=Math.abs(G.wind);
document.getElementById("windTxt").textContent=(s<4?"CALM":(G.wind<0?"◀ ":"")+Math.round(s)+(G.wind>0?" ▶":""))+(s>34?" GALE":"");
}
function dropCrate(){
const kinds=[{k:"health",ico:"➕",col:"#7affa0"},{k:"ammo",ico:"📦",col:"#ffd166"},{k:"util",ico:"🎁",col:"#5db8ff"}];
const c=pick(kinds);
const x=rnd(MAPW-140,140);
G.crates.push({x,y:-30,vx:0,vy:0,...c,chute:true,r:11});
showToast("SUPPLY DROP INBOUND");
Snd.chute();
}
let toastT=0;
function showToast(s){const el=document.getElementById("sub");el.textContent=s;el.style.opacity=1;toastT=2.4;}
function showMsg(m,s,col,dur=1.9){
const e=document.getElementById("msg"),se=document.getElementById("sub");
e.textContent=m;e.style.color=col||"#fff";e.style.opacity=1;e.style.transition="none";
e.style.transform="translate(-50%,-50%) scale(1.25)";
se.textContent=s||"";se.style.opacity=.85;
requestAnimationFrame(()=>{e.style.transition="opacity .5s ease, transform .5s cubic-bezier(.2,1.2,.3,1)";
e.style.transform="translate(-50%,-50%) scale(1)";});
G.msgT=dur;
}
/* ---------------- FIRING ---------------- */
function currentWeapon(){return WMAP[G.weapon]}
function canFire(){return G.phase==="aim"&&!G.firing&&G.active&&!G.active.dead&&G.active.jet<=0}
function fireWeapon(power){
const w=currentWeapon();const a=G.active;
if(!a||a.dead)return;
if(G.ammo[G.teamIdx][w.id]<=0)return;
if(w.ammo!==Infinity)G.ammo[G.teamIdx][w.id]--;
G.firing=true;G.lastFireW=w;
a.recoil=1;
const ang=a.aim*1;
const dirx=Math.cos(ang)*a.face, diry=Math.sin(ang);
const mx=a.x+dirx*14, my=a.y+diry*14;
if(w.cat==="direct"){
if(w.id==="shotgun"){
Snd.shotgun();
hitscanCone(a,mx,my,dirx,diry,w);
a.vx-=dirx*1.2;
if(!a._sgShot){a._sgShot=1;G.firing=false;G.phase="aim";
G.ammo[G.teamIdx][w.id]++; // second barrel is free
showToast("ONE BARREL LEFT");
return;
}
a._sgShot=0;
G.retreat=3.0;G.phase="retreat";
return;
}
if(w.id==="railgun"){
Snd.laser();railBeam(a,mx,my,dirx,diry,w);
a.vx-=dirx*2.4;G.shake=Math.max(G.shake,10);
G.retreat=3.0;G.phase="retreat";return;
}
}
// arc / aoe projectile
const sp=(w.pw||1)*(6+power*17);
const p=new Proj(mx,my,dirx*sp,diry*sp,w,G.teamIdx);
p.ownerWorm=a;p.ownerTeam=G.teamIdx;
G.projs.push(p);
Snd.fire();
a.vx-=dirx*(w.id==="holy"?1.6:0.9);
// muzzle
for(let i=0;i<16;i++)G.parts.push(new Particle(mx,my,dirx*rnd(5,1)+rnd(1.4,-1.4),diry*rnd(5,1)+rnd(1.4,-1.4),rnd(20,8),pick(["#fff6c9","#ffd166","#ff8b3d"]),rnd(3.4,1.4),{g:.03,glow:true,drag:.9}));
G.shake=Math.max(G.shake,4);
G.phase="fly";G.camLock=p;
}
function hitscanCone(a,mx,my,dirx,diry,w){
const base=Math.atan2(diry,dirx);
for(let i=0;i<w.pellets;i++){
const ang=base+rnd(w.spread,-w.spread);
const r=raycast(mx,my,ang,w.range);
// tracer
G.parts.push(new Particle(mx,my,0,0,8,"rgba(255,240,200,.9)",2,{g:0,glow:true}));
tracer(mx,my,r.x,r.y,"rgba(255,230,160,.75)",1.2);
if(r.hitWorm){r.hitWorm.damage(w.dmg/w.pellets*2.2,a);
if(a.team!==r.hitWorm.team)G.turnDamage+=w.dmg/w.pellets*2.2;
bloodBurst(r.x,r.y,ang);}
else if(r.hit){ blast(r.x,r.y,w.rad*.34,true);
for(let k=0;k<5;k++)G.parts.push(new Particle(r.x,r.y,-Math.cos(ang)*rnd(3,.4)+rnd(1,-1),-Math.sin(ang)*rnd(3,.4)+rnd(1,-1),rnd(20,8),pick([terrainPalette.soil,terrainPalette.top2]),rnd(2.6,1),{g:.1}));
explosionSmall(r.x,r.y,10);
}
}
G.shake=Math.max(G.shake,7);
}
function railBeam(a,mx,my,dirx,diry,w){
const ang=Math.atan2(diry,dirx);
let x=mx,y=my,hits=0;
const pts=[[x,y]];
for(let d=0;d<w.range;d+=2){
x+=Math.cos(ang)*2;y+=Math.sin(ang)*2;
if(x<0||x>MAPW||y>MAPH||y<-800)break;
if(solidAt(x,y)){ blast(x,y,13,false); if(Math.random()<.3)G.parts.push(new Particle(x,y,rnd(2,-2),rnd(2,-2),rnd(22,10),pick([terrainPalette.soil,terrainPalette.soil2]),rnd(3,1.2),{g:.12})); }
for(const wm of G.worms){ if(wm.dead||wm===a)continue;
if(!wm._railHit&&dist(x,y,wm.x,wm.y)<12){wm._railHit=1;wm.damage(w.dmg,a);
if(a.team!==wm.team)G.turnDamage+=w.dmg;
bloodBurst(wm.x,wm.y,ang);wm.vx+=Math.cos(ang)*3;wm.vy+=Math.sin(ang)*3-1;wm.onGround=false;hits++;}
}
if(y>WATER_Y){splashFX(x,WATER_Y,.7);break;}
}
G.worms.forEach(w2=>w2._railHit=0);
G.rail={x1:mx,y1:my,x2:x,y2:y,life:1};
tracer(mx,my,x,y,"rgba(180,240,255,.95)",4);
repaintRing((mx+x)/2,(my+y)/2,Math.hypot(x-mx,y-my)/2+20);
}
function tracer(x1,y1,x2,y2,col,wd){
G.tracers=G.tracers||[];G.tracers.push({x1,y1,x2,y2,col,wd,life:1});
}
function bloodBurst(x,y,ang){
for(let i=0;i<14;i++)G.parts.push(new Particle(x,y,Math.cos(ang)*rnd(4,.5)+rnd(2,-2),Math.sin(ang)*rnd(4,.5)+rnd(2,-2),rnd(28,12),pick(["#ff5d6e","#c22a3d","#ff9aa5"]),rnd(3.2,1.2),{g:.16}));
}
function explosionSmall(x,y,r){
for(let i=0;i<12;i++){const a=rnd(TAU),sp=rnd(r*.2,.4);
G.parts.push(new Particle(x,y,Math.cos(a)*sp,Math.sin(a)*sp,rnd(20,8),pick(["#fff6c9","#ffd166","#ff8b3d"]),rnd(3,1),{g:.06,glow:true,drag:.92}));}
G.flashes.push({x,y,r:r*2,life:1,max:1});
}
/* ---------------- UTILITIES ---------------- */
function useUtility(w){
const a=G.active;if(!a)return;
if(G.ammo[G.teamIdx][w.id]<=0)return;
if(w.util==="jet"){
G.ammo[G.teamIdx][w.id]--;a.jet=4;Snd.chute();showToast("JETPACK · ↑ THRUST ← → STEER");
buildWeaponHud();G.weapon=validWeapon("bazooka");
} else if(w.util==="tele"){
G.teleMode=true;showToast("CLICK TO TELEPORT");
} else if(w.util==="girder"){
G.girderMode=true;showToast("CLICK TO PLACE · SCROLL TO ROTATE");
} else if(w.util==="heal"){
G.ammo[G.teamIdx][w.id]--;
a.hp=Math.min(a.maxhp,a.hp+45);Snd.heal();
G.floats.push(new FloatText(a.x,a.y-24,"+45","#7affa0",24));
for(let i=0;i<26;i++)G.parts.push(new Particle(a.x+rnd(12,-12),a.y+rnd(10,-10),rnd(.8,-.8),-rnd(1.6,.3),rnd(40,18),"#7affa0",rnd(3,1),{g:-.02,glow:true}));
buildTeamBar();buildWeaponHud();G.weapon=validWeapon("bazooka");
} else if(w.util==="chute"){
G.ammo[G.teamIdx][w.id]--;a.chute=true;Snd.chute();showToast("PARACHUTE ARMED");
buildWeaponHud();G.weapon=validWeapon("bazooka");
}
}
function doTeleport(x,y){
const a=G.active;
if(y>WATER_Y-6) {showToast("CAN'T TELEPORT INTO WATER");return;}
// find free spot
let ty=y;let ok=false;
for(let k=0;k<80;k++){ if(!collidesAt(x,ty)){ok=true;break;} ty-=2; }
if(!ok){showToast("BLOCKED");return;}
G.ammo[G.teamIdx]["teleport"]--;
Snd.teleport();
for(let i=0;i<34;i++)G.parts.push(new Particle(a.x,a.y,rnd(4,-4),rnd(4,-4),rnd(30,12),"#b98bff",rnd(3.4,1.2),{g:0,glow:true,drag:.9}));
a.x=x;a.y=ty;a.vx=0;a.vy=0;a.fallStart=null;a.onGround=false;
for(let i=0;i<34;i++)G.parts.push(new Particle(x,ty,rnd(4,-4),rnd(4,-4),rnd(30,12),"#8bd6ff",rnd(3.4,1.2),{g:0,glow:true,drag:.9}));
G.teleMode=false;G.weapon=validWeapon("bazooka");buildWeaponHud();
centerCam(x,ty);
}
function placeGirder(x,y){
const ang=G.girderAng;const L=88,Th=13;
G.ammo[G.teamIdx]["girder"]--;
// rasterize rotated rect into mask
const c=Math.cos(ang),s=Math.sin(ang);
const R=Math.ceil(L/2)+4;
for(let dy=-R;dy<=R;dy++)for(let dx=-R;dx<=R;dx++){
const lx=dx*c+dy*s, ly=-dx*s+dy*c;
if(Math.abs(lx)<=L/2&&Math.abs(ly)<=Th/2){
const px=(x+dx)|0,py=(y+dy)|0;
if(px>=0&&px<MAPW&&py>=0&&py<MAPH)T.mask[py*MAPW+px]=1;
}
}
G.girders.push({x,y,ang,L,Th});
repaintTerrainRegion(x-R-3,y-R-3,R*2+6,R*2+6);
// paint metal look
const tc=T.ctx;tc.save();tc.translate(x,y);tc.rotate(ang);
const g=tc.createLinearGradient(0,-Th/2,0,Th/2);
g.addColorStop(0,"#9aa4b8");g.addColorStop(.4,"#5b6478");g.addColorStop(1,"#333a49");
tc.fillStyle=g;tc.fillRect(-L/2,-Th/2,L,Th);
tc.fillStyle="rgba(255,255,255,.18)";tc.fillRect(-L/2,-Th/2,L,2);
tc.fillStyle="rgba(0,0,0,.3)";
for(let i=-L/2+6;i<L/2-2;i+=14){tc.beginPath();tc.arc(i,0,1.8,0,TAU);tc.fill();}
tc.restore();
Snd.clank();
G.girderMode=false;G.weapon=validWeapon("bazooka");buildWeaponHud();
}
/* ---------------- CAMERA ---------------- */
function centerCam(x,y,instant){
G.cam.tx=clamp(x,0,MAPW);G.cam.ty=clamp(y,0,MAPH);
if(instant){G.cam.x=G.cam.tx;G.cam.y=G.cam.ty;}
}
function updateCam(dt){
const c=G.cam;
let tx=c.tx,ty=c.ty;
if(G.camLock&&!G.camLock.dead){tx=G.camLock.x;ty=G.camLock.y;}
else if(G.active&&!G.active.dead&&(G.phase==="aim"||G.phase==="retreat")){tx=G.active.x;ty=G.active.y-20;}
// manual pan with right-drag handled elsewhere
if(G.panX!==undefined){tx=G.panX;ty=G.panY;}
const sp=1-Math.pow(0.86,dt);
c.x=lerp(c.x,tx,sp);c.y=lerp(c.y,ty,sp);
c.zoom=lerp(c.zoom,c.tz,1-Math.pow(0.9,dt));
const vw=W()/c.zoom,vh=H()/c.zoom;
c.x=clamp(c.x,Math.min(vw/2,MAPW/2),Math.max(MAPW-vw/2,MAPW/2));
c.y=clamp(c.y,vh/2-160,MAPH-vh/2+120);
}
function w2s(x,y){const c=G.cam;return[(x-c.x)*c.zoom+W()/2,(y-c.y)*c.zoom+H()/2]}
function s2w(x,y){const c=G.cam;return[(x-W()/2)/c.zoom+c.x,(y-H()/2)/c.zoom+c.y]}
/* ============================================================
CPU AI — risk assessment + ballistic solving
============================================================ */
function simulateShot(sx,sy,ang,power,w,ignoreWorm){
// returns {x,y,hitWorm,steps} landing point
const sp=(w.pw||1)*(6+power*17);
let x=sx,y=sy,vx=Math.cos(ang)*sp,vy=Math.sin(ang)*sp;
const windF=(w.wind!==undefined?w.wind:1);
let bounce=w.bounce||0, fuse=w.fuse||null;
for(let i=0;i<900;i++){
vy+=GRAV;vx+=G.wind*0.0022*windF;
if(fuse!==null){fuse-=1/60;if(fuse<=0)return{x,y,t:i};}
const steps=Math.max(1,Math.ceil((Math.abs(vx)+Math.abs(vy))/2.5));
for(let s=0;s<steps;s++){
const nx=x+vx/steps,ny=y+vy/steps;
for(const wm of G.worms){if(wm.dead||wm===ignoreWorm)continue;
if(dist(nx,ny,wm.x,wm.y)<11)return{x:nx,y:ny,hitWorm:wm,t:i};}
if(ny>WATER_Y)return{x:nx,y:WATER_Y,water:true,t:i};
if(solidAt(nx,ny)){
if(bounce>0){const n=surfaceNormal(nx,ny);const dot=vx*n.x+vy*n.y;
vx=(vx-2*dot*n.x)*bounce;vy=(vy-2*dot*n.y)*bounce;x+=n.x*2.2;y+=n.y*2.2;
if(Math.hypot(vx,vy)<0.35&&fuse===null)return{x,y,t:i};
s=steps;break;}
return{x:nx,y:ny,t:i};
}
x=nx;y=ny;
}
if(x<-150||x>MAPW+150||y>MAPH+150)return{x,y,off:true,t:i};
}
return{x,y,t:900};
}
function scoreImpact(ix,iy,rad,dmg,me){
let sc=0;
for(const wm of G.worms){
if(wm.dead)continue;
const d=dist(ix,iy,wm.x,wm.y);
if(d>rad*1.5)continue;
const f=clamp(1-d/(rad*1.5),0,1);
const dm=Math.min(wm.hp,dmg*Math.pow(f,.75));
if(dm<1)continue;
let v=dm;
// kill bonus
if(dm>=wm.hp)v+=45;
// near water bonus (knockback into water)
if(wm.y>WATER_Y-90)v+=dm*.5;
if(wm.team===me.team){sc-= (wm===me? v*2.6 : v*2.2);}
else sc+=v*(1+ (1-wm.hp/wm.maxhp)*0.5);
}
return sc;
}
function riskAt(x,y){
// higher = more dangerous
let r=0;
if(y>WATER_Y-70)r+=90;
// proximity to water horizontally (edge of island)
let ground=-1;
for(let yy=Math.max(0,y-30);yy<MAPH;yy++)if(solidAt(x,yy)){ground=yy;break;}
if(ground<0)r+=60; else if(ground>WATER_Y-40)r+=70;
// nearby mines
for(const p of G.projs)if(p.proximity&&dist(x,y,p.x,p.y)<70)r+=50;
return r;
}
function planAI(){
if(!G.active||G.phase==="over")return;
const me=G.active;
const enemies=G.worms.filter(w=>!w.dead&&w.team!==me.team);
if(!enemies.length){G.cpuThinking=false;endTurn();return;}
const myAmmo=G.ammo[G.teamIdx];
const skill=0.86;
let best=null;
const candidates=[];
// weapon list AI can use
const usable=WEAPONS.filter(w=>w.cat!=="util"&&(myAmmo[w.id]===Infinity||myAmmo[w.id]>0)&&w.id!=="airstrike"&&w.id!=="quake");
for(const w of usable){
if(w.cat==="direct"){
for(const e of enemies){
const ang=Math.atan2(e.y-me.y,e.x-me.x);
const d=dist(me.x,me.y,e.x,e.y);
if(d>(w.range||600))continue;
// line of sight
let blocked=0;
for(let s=14;s<d;s+=4){const px=me.x+Math.cos(ang)*s,py=me.y+Math.sin(ang)*s;if(solidAt(px,py))blocked++;}
if(w.id==="shotgun"&&blocked>3)continue;
if(w.id==="railgun"&&blocked>90)continue;
let sc=w.id==="shotgun"?scoreImpact(e.x,e.y,w.rad,w.dmg*1.6,me):w.dmg*1.3+(w.dmg>=e.hp?45:0);
if(w.id==="railgun"){ // count pierced enemies
let n=0;for(const e2 of enemies){const dd=Math.abs(Math.sin(ang)*(e2.x-me.x)-Math.cos(ang)*(e2.y-me.y));
const along=Math.cos(ang)*(e2.x-me.x)+Math.sin(ang)*(e2.y-me.y);
if(dd<12&&along>0&&along<w.range)n++;}
sc=w.dmg*n*1.4+(n>1?40:0);
for(const f of G.worms)if(!f.dead&&f.team===me.team&&f!==me){
const dd=Math.abs(Math.sin(ang)*(f.x-me.x)-Math.cos(ang)*(f.y-me.y));
const along=Math.cos(ang)*(f.x-me.x)+Math.sin(ang)*(f.y-me.y);
if(dd<12&&along>0)sc-=w.dmg*2.4;}
}
if(w.id==="shotgun"&&d<70)sc-=30;
sc-=(myAmmo[w.id]===Infinity?0:12);
candidates.push({w,ang,power:1,score:sc,direct:true});
}
continue;
}
// arc weapons — sample angle/power grid
const aimSign=1;
for(let face of [-1,1]){
for(let ai=0;ai<26;ai++){
const ang0=-Math.PI*0.92+ai*(Math.PI*0.84/25); // -166° .. +
const ang=face>0?ang0:Math.PI-ang0;
for(let pi=3;pi<=10;pi++){
const power=pi/10;
const r=simulateShot(me.x+Math.cos(ang)*14,me.y+Math.sin(ang)*14,ang,power,w,me);
if(r.off||r.water)continue;
if(r.t>820)continue;
let sc=scoreImpact(r.x,r.y,w.rad,w.dmg,me);
if(r.hitWorm&&r.hitWorm.team!==me.team)sc+=14;
sc-=(myAmmo[w.id]===Infinity?0:14);
sc-=r.t*0.006;
if(sc>4)candidates.push({w,ang,power,score:sc,face});
}
}
}
}
candidates.sort((a,b)=>b.score-a.score);
best=candidates[0];
// Decide: shoot, or move to better ground
const myRisk=riskAt(me.x,me.y);
let action;
if(best&&best.score>16){action={type:"shoot",plan:best};}
else if(myRisk>60||(!best||best.score<8)){action={type:"reposition"};}
else if(best&&best.score>4){action={type:"shoot",plan:best};}
else action={type:"reposition"};
if(action.type==="reposition"){
// choose direction that reduces risk & closes distance to nearest enemy
let near=enemies.reduce((p,c)=>dist(me.x,me.y,c.x,c.y)<dist(me.x,me.y,p.x,p.y)?c:p);
let dir=Math.sign(near.x-me.x)||1;
// check risk ahead
if(riskAt(me.x+dir*70,me.y)>riskAt(me.x-dir*70,me.y)+30)dir*=-1;
G.aiPlan={mode:"move",dir,until:performance.now()+rnd(2600,1100),then:best};
} else {
G.aiPlan={mode:"aimshoot",plan:best,t:0};
}
G.cpuThinking=false;
}
function updateAI(dt){
if(!Cfg.cpu[G.teamIdx]||G.phase!=="aim"||!G.active||G.active.dead)return;
const me=G.active;
if(G.cpuThinking)return;
if(!G.aiPlan){ if(G.timeLeft<G.turnTime-2)endTurn(); return; }
const P=G.aiPlan;
if(P.mode==="move"){
me.aiMove=P.dir;
me.face=P.dir;
// jump over obstacles
if(collidesAt(me.x+P.dir*8,me.y)&&collidesAt(me.x+P.dir*8,me.y-7)&&me.onGround&&Math.random()<.12)me.jump();
if(performance.now()>P.until||riskAt(me.x+P.dir*30,me.y)>90){
me.aiMove=0;
G.cpuThinking=true;
setTimeout(()=>{ if(G.phase!=="aim")return;
const savedT=G.timeLeft; planAI();
if(G.aiPlan&&G.aiPlan.mode==="move")G.aiPlan={mode:"aimshoot",plan:G.aiPlan.then||null,t:0};
},420);
}
if(G.timeLeft<8){me.aiMove=0;G.aiPlan={mode:"aimshoot",plan:P.then||null,t:0};}
return;
}
if(P.mode==="aimshoot"){
if(!P.plan){ // nothing good: lob a bazooka at nearest enemy
const enemies=G.worms.filter(w=>!w.dead&&w.team!==me.team);
if(!enemies.length){endTurn();return;}
const e=pick(enemies);
const ang=Math.atan2(e.y-me.y-120,e.x-me.x);
P.plan={w:WMAP.bazooka,ang,power:clamp(dist(me.x,me.y,e.x,e.y)/900,.35,1)};
}
const pl=P.plan;
G.weapon=pl.w.id;buildWeaponHud();
// face + smoothly move aim
const rel=Math.abs(pl.ang)>Math.PI/2?-1:1;
me.face=rel>=0?1:-1;
let target=rel>=0?pl.ang:(pl.ang>0?Math.PI-pl.ang:-Math.PI-pl.ang);
target=clamp(target,-Math.PI/2+0.02,Math.PI/2-0.02);
me.aim=lerp(me.aim,target,1-Math.pow(.86,dt));
P.t+=dt*.016;
G.charging=true;
G.power=Math.min(pl.power,G.power+dt*.014);
const aimed=Math.abs(me.aim-target)<0.02;
if(P.t>0.7&&aimed&&G.power>=pl.power-0.005){
G.charging=false;
// small aim error
me.aim=target+rnd(.014,-.014);
fireWeapon(pl.power);
G.aiPlan=null;
}
if(P.t>8){G.charging=false;G.aiPlan=null;endTurn();}
}
}
/* ============================================================
BACKGROUND / RENDER
============================================================ */
let skyGrad=null,skyKey="";
function drawSky(c){
const key=W()+"x"+H();
if(skyKey!==key||!skyGrad){
skyGrad=c.createLinearGradient(0,0,0,H());
skyGrad.addColorStop(0,"#0b1430");skyGrad.addColorStop(.35,"#1b3a63");
skyGrad.addColorStop(.65,"#3d6a92");skyGrad.addColorStop(1,"#7fa6bd");
skyKey=key;
}
c.fillStyle=skyGrad;c.fillRect(0,0,W(),H());
// sun glow
const [sx,sy]=w2s(MAPW*0.78,120);
const g=c.createRadialGradient(sx,sy,0,sx,sy,340*G.cam.zoom);
g.addColorStop(0,"rgba(255,230,180,.5)");g.addColorStop(.3,"rgba(255,190,120,.16)");g.addColorStop(1,"rgba(255,180,100,0)");
c.fillStyle=g;c.fillRect(0,0,W(),H());
c.fillStyle="rgba(255,244,214,.9)";c.beginPath();c.arc(sx,sy,34*G.cam.zoom,0,TAU);c.fill();
// stars (parallax, faint)
c.fillStyle="rgba(255,255,255,.5)";
for(const s of G.stars){
const px=(s.x-G.cam.x*.12)%MAPW,[X,Y]=[px*.5+W()*0,s.y-G.cam.y*.06];
const tw=.4+.6*Math.abs(Math.sin(performance.now()*.001+s.p));
c.globalAlpha=tw*.35;c.fillRect(X,Y,s.r,s.r);
}
c.globalAlpha=1;
// far mountains
drawParallaxHills(c,.18,"#16283f",MAPH*0.62);
drawParallaxHills(c,.34,"#1e3550",MAPH*0.72);
}
let hillCache={};
function drawParallaxHills(c,depth,col,base){
const key=depth+col;
if(!hillCache[key]){
const pts=[];const n=40;
for(let i=0;i<=n;i++)pts.push(Math.sin(i*1.7+depth*30)*0.5+Math.sin(i*0.6+depth*11)*0.5);
hillCache[key]=pts;
}
const pts=hillCache[key];
const camx=G.cam.x*depth, camy=G.cam.y*depth;
c.fillStyle=col;c.beginPath();
const w=MAPW*1.4/(pts.length-1);
c.moveTo(-200,H()+10);
for(let i=0;i<pts.length;i++){
const x=(i*w-camx)*G.cam.zoom+W()/2-MAPW*0.2*G.cam.zoom;
const y=(base+pts[i]*160-camy)*G.cam.zoom+H()/2;
c.lineTo(x,y);
}
c.lineTo(W()+200,H()+10);c.closePath();c.fill();
}
function drawClouds(c){
for(const cl of G.clouds){
cl.x+=G.wind*0.00035*cl.sp*16;
if(cl.x>MAPW+400)cl.x=-400;if(cl.x<-400)cl.x=MAPW+400;
const [X,Y]=w2s(cl.x,cl.y);
const z=G.cam.zoom*(0.5+cl.s*0.4);
const s=52*cl.s*z;
if(X<-300||X>W()+300)continue;
c.globalAlpha=cl.o;c.fillStyle="#dfeaf5";
c.beginPath();
c.ellipse(X,Y,s,s*.42,0,0,TAU);
c.ellipse(X-s*.5,Y+s*.1,s*.55,s*.3,0,0,TAU);
c.ellipse(X+s*.55,Y+s*.08,s*.6,s*.32,0,0,TAU);
c.ellipse(X+s*.1,Y-s*.22,s*.5,s*.34,0,0,TAU);
c.fill();c.globalAlpha=1;
}
}
function drawWater(c){
const t=performance.now()*.001;
const [,wy]=w2s(0,WATER_Y);
if(wy>H())return;
const z=G.cam.zoom;
c.save();
// body
const g=c.createLinearGradient(0,wy,0,H());
g.addColorStop(0,"rgba(70,170,220,.62)");g.addColorStop(.4,"rgba(28,96,150,.82)");g.addColorStop(1,"rgba(10,40,72,.95)");
c.fillStyle=g;c.fillRect(0,Math.max(0,wy),W(),H()-Math.max(0,wy));
// wave crests
c.strokeStyle="rgba(190,238,255,.55)";c.lineWidth=2;
for(let k=0;k<3;k++){
c.beginPath();
for(let x=0;x<=W();x+=8){
const wx=s2w(x,0)[0];
const y=wy+k*7*z+Math.sin(wx*0.012+t*(1.2+k*.4)+k)*5*z+Math.sin(wx*0.031-t*1.7)*2.4*z;
x===0?c.moveTo(x,y):c.lineTo(x,y);
}
c.globalAlpha=.5-k*.13;c.stroke();
}
c.globalAlpha=1;
// shimmer
c.globalCompositeOperation="lighter";
for(let i=0;i<26;i++){
const x=((i*137.5+t*30)%W());
const y=Math.max(0,wy)+((i*57)%Math.max(1,(H()-wy)))*1;
c.fillStyle="rgba(255,255,255,.05)";c.fillRect(x,y,26,1.4);
}
c.globalCompositeOperation="source-over";
c.restore();
}
/* ---------------- MAIN RENDER ---------------- */
function render(){
const c=ctx2d;
c.setTransform(1,0,0,1,0,0);
c.clearRect(0,0,W(),H());
drawSky(c);
drawClouds(c);
const z=G.cam.zoom;
let shx=0,shy=0;
if(G.shake>0.3){shx=rnd(G.shake,-G.shake);shy=rnd(G.shake,-G.shake);}
c.save();
c.translate(W()/2+shx,H()/2+shy);c.scale(z,z);c.translate(-G.cam.x,-G.cam.y);
// terrain
c.imageSmoothingEnabled=false;
if(T.canvas)c.drawImage(T.canvas,0,0);
// subtle terrain rim light
c.globalCompositeOperation="source-atop";
c.globalCompositeOperation="source-over";
// ripples
for(const r of G.ripples){
c.strokeStyle=`rgba(200,240,255,${r.life*.5})`;c.lineWidth=2/z;
c.beginPath();c.ellipse(r.x,r.y,r.r,r.r*.28,0,0,TAU);c.stroke();
}
// airstrike plane
if(G.planeFX&&G.planeFX.life>0){
const pf=G.planeFX;pf.x+=pf.dir*11;pf.life-=1/60;
const py=Math.max(-220,G.cam.y-H()/G.cam.zoom/2-60);
c.save();c.translate(pf.x,py);c.scale(pf.dir,1);
c.fillStyle="#4a5266";c.beginPath();c.moveTo(34,0);c.lineTo(4,-8);c.lineTo(-30,-7);c.lineTo(-34,2);c.lineTo(-6,8);c.closePath();c.fill();
c.fillStyle="#2e3444";c.beginPath();c.moveTo(2,-6);c.lineTo(-22,-26);c.lineTo(-30,-24);c.lineTo(-8,-4);c.closePath();c.fill();
c.fillStyle="#9fd8ff";c.beginPath();c.ellipse(14,-3,7,3.4,0,0,TAU);c.fill();
c.restore();
if(Math.random()<.6)G.parts.push(new Particle(pf.x-pf.dir*34,py+2,-pf.dir*rnd(1,.2),rnd(.3,-.3),rnd(60,30),"rgba(220,225,235,.5)",rnd(4,2),{g:-.005,smoke:true,wind:1}));
}
// crates
for(const cr of G.crates)drawCrate(c,cr);
// debris
for(const d of G.debris)d.draw(c);
// worms
for(const w of G.worms)w.draw(c);
// graves
for(const w of G.worms)if(w.dead&&w.grave>0)drawGrave(c,w);
// projectiles
for(const p of G.projs)p.draw(c);
// tracers
if(G.tracers)for(const t of G.tracers){
c.globalCompositeOperation="lighter";
c.strokeStyle=t.col;c.globalAlpha=t.life;c.lineWidth=t.wd*t.life;
c.beginPath();c.moveTo(t.x1,t.y1);c.lineTo(t.x2,t.y2);c.stroke();
c.globalAlpha=1;c.globalCompositeOperation="source-over";
}
// particles
for(const p of G.parts)p.draw(c);
// explosion flashes
c.globalCompositeOperation="lighter";
for(const f of G.flashes){
const t=f.life/f.max;
const g=c.createRadialGradient(f.x,f.y,0,f.x,f.y,f.r*(1.6-t*.5));
g.addColorStop(0,`rgba(255,250,220,${t*.95})`);
g.addColorStop(.35,`rgba(255,190,90,${t*.6})`);
g.addColorStop(1,"rgba(255,90,30,0)");
c.fillStyle=g;c.beginPath();c.arc(f.x,f.y,f.r*(1.6-t*.5),0,TAU);c.fill();
}
c.globalCompositeOperation="source-over";
drawWater(c);
// floats above water
for(const f of G.floats)f.draw(c);
// aiming
if(G.phase==="aim"&&G.active&&!G.active.dead&&!Cfg.cpu[G.teamIdx]&&G.active.jet<=0)drawAimUI(c);
if(G.phase==="aim"&&Cfg.cpu[G.teamIdx]&&G.aiPlan&&G.aiPlan.mode==="aimshoot")drawAimUI(c,true);
if(G.girderMode)drawGirderGhost(c);
if(G.teleMode)drawTeleGhost(c);
if(G.targetMode)drawTargetGhost(c);
c.restore();
// vignette
const vg=ctx2d.createRadialGradient(W()/2,H()/2,Math.min(W(),H())*.35,W()/2,H()/2,Math.max(W(),H())*.78);
vg.addColorStop(0,"rgba(0,0,0,0)");vg.addColorStop(1,"rgba(0,0,10,.6)");
c.fillStyle=vg;c.fillRect(0,0,W(),H());
drawOffscreenIndicators(c);
}
function drawGrave(c,w){
const t=TEAMS[w.team];
c.save();c.translate(w.x,w.y+4);
c.globalAlpha=.9;
c.fillStyle="#6b7280";
roundRect(c,-6,-14,12,16,5);c.fill();
c.fillRect(-7,0,14,4);
c.fillStyle="rgba(0,0,0,.35)";c.fillRect(-2,-11,4,2);c.fillRect(-1,-12,2,7);
c.restore();c.globalAlpha=1;
}
function drawCrate(c,cr){
c.save();c.translate(cr.x,cr.y);
if(cr.chute){
c.fillStyle="rgba(255,255,255,.85)";c.beginPath();c.arc(0,-24,17,Math.PI,0);c.closePath();c.fill();
c.fillStyle=cr.col;c.beginPath();c.arc(0,-24,17,Math.PI*1.2,Math.PI*1.5);c.lineTo(0,-24);c.fill();
c.strokeStyle="rgba(255,255,255,.6)";c.lineWidth=1;
c.beginPath();c.moveTo(-16,-24);c.lineTo(-6,-8);c.moveTo(16,-24);c.lineTo(6,-8);c.stroke();
}
c.fillStyle="#8a6236";roundRect(c,-10,-9,20,19,3);c.fill();
c.strokeStyle="#5c3f20";c.lineWidth=2;c.stroke();
c.fillStyle=cr.col;c.fillRect(-10,-2,20,4);
c.font="11px sans-serif";c.textAlign="center";c.fillText(cr.ico,0,4);
const pulse=.5+.5*Math.sin(performance.now()*.005);
c.globalCompositeOperation="lighter";
c.fillStyle=cr.col;c.globalAlpha=.18*pulse;c.beginPath();c.arc(0,0,22,0,TAU);c.fill();
c.restore();c.globalAlpha=1;c.globalCompositeOperation="source-over";
}
function drawAimUI(c,isAI){
const a=G.active;const w=currentWeapon();
const ang=a.aim,dx=Math.cos(ang)*a.face,dy=Math.sin(ang);
// crosshair
const cx=a.x+dx*46,cy=a.y+dy*46;
c.strokeStyle=isAI?"rgba(255,120,120,.8)":"rgba(255,255,255,.85)";c.lineWidth=1.6/G.cam.zoom;
c.beginPath();c.arc(cx,cy,6,0,TAU);c.stroke();
c.beginPath();c.moveTo(cx-11,cy);c.lineTo(cx-7,cy);c.moveTo(cx+7,cy);c.lineTo(cx+11,cy);
c.moveTo(cx,cy-11);c.lineTo(cx,cy-7);c.moveTo(cx,cy+7);c.lineTo(cx,cy+11);c.stroke();
// weapon barrel
c.save();c.translate(a.x,a.y);c.rotate(Math.atan2(dy,dx));
c.fillStyle="#3a4152";c.fillRect(2,-2.6,17,5.2);
c.fillStyle="#5d677e";c.fillRect(2,-2.6,17,1.6);
c.restore();
// predictive trajectory
if(w.cat==="arc"||w.cat==="aoe"&&!w.target&&!w.quake){
const pw=G.charging?G.power:(isAI?G.power:0.62);
const sp=(w.pw||1)*(6+pw*17);
let x=a.x+dx*14,y=a.y+dy*14,vx=dx*sp,vy=dy*sp;
const windF=(w.wind!==undefined?w.wind:1);
c.setLineDash([]);
const maxPts=G.charging?150:80;
let prev=null;
for(let i=0;i<maxPts;i++){
vy+=GRAV;vx+=G.wind*0.0022*windF;
x+=vx;y+=vy;
if(y>WATER_Y||solidAt(x,y)||x<0||x>MAPW)break;
if(i%3===0){
const t=1-i/maxPts;
c.globalAlpha=t*(G.charging?.85:.4);
c.fillStyle=G.charging?"#ffe082":"rgba(255,255,255,.8)";
c.beginPath();c.arc(x,y,Math.max(.8,2.1*t),0,TAU);c.fill();
}
}
c.globalAlpha=1;
} else if(w.cat==="direct"){
const r=raycast(a.x+dx*14,a.y+dy*14,Math.atan2(dy,dx),w.range||600);
c.globalAlpha=.35;c.strokeStyle=w.id==="railgun"?"#9fe4ff":"#ffd7a0";
c.setLineDash([6/G.cam.zoom,6/G.cam.zoom]);c.lineWidth=1.4/G.cam.zoom;
c.beginPath();c.moveTo(a.x+dx*14,a.y+dy*14);
if(w.pierce){c.lineTo(a.x+Math.cos(Math.atan2(dy,dx))*w.range,a.y+Math.sin(Math.atan2(dy,dx))*w.range);}
else c.lineTo(r.x,r.y);
c.stroke();c.setLineDash([]);c.globalAlpha=1;
}
}
function raycast(x,y,ang,maxd){
const cx=Math.cos(ang),cy=Math.sin(ang);
for(let d=0;d<maxd;d+=2){
const px=x+cx*d,py=y+cy*d;
if(px<0||px>MAPW||py>MAPH)return{x:px,y:py,hit:false};
for(const wm of G.worms){if(wm.dead)continue;if(d>10&&dist(px,py,wm.x,wm.y)<10)return{x:px,y:py,hit:true,hitWorm:wm};}
if(solidAt(px,py))return{x:px,y:py,hit:true};
}
return{x:x+cx*maxd,y:y+cy*maxd,hit:false};
}
function drawGirderGhost(c){
const [mx,my]=[mouse.wx,mouse.wy];
c.save();c.translate(mx,my);c.rotate(G.girderAng);
c.globalAlpha=.6;c.fillStyle="#8a94a8";c.fillRect(-44,-6.5,88,13);
c.strokeStyle="#dfe8ff";c.lineWidth=1.5/G.cam.zoom;c.strokeRect(-44,-6.5,88,13);
c.restore();c.globalAlpha=1;
}
function drawTeleGhost(c){
const [mx,my]=[mouse.wx,mouse.wy];
const ok=my<WATER_Y-6;
c.globalAlpha=.65;c.strokeStyle=ok?"#b98bff":"#ff6b6b";c.lineWidth=2/G.cam.zoom;
c.beginPath();c.arc(mx,my,14+Math.sin(performance.now()*.008)*3,0,TAU);c.stroke();
c.beginPath();c.arc(mx,my,7,0,TAU);c.stroke();c.globalAlpha=1;
}
function drawTargetGhost(c){
const [mx,my]=[mouse.wx,mouse.wy];
c.globalAlpha=.8;c.strokeStyle="#ff5d5d";c.lineWidth=2/G.cam.zoom;
const r=26+Math.sin(performance.now()*.006)*4;
c.beginPath();c.arc(mx,my,r,0,TAU);c.stroke();
c.beginPath();c.moveTo(mx-r-8,my);c.lineTo(mx+r+8,my);c.moveTo(mx,my-r-8);c.lineTo(mx,my+r+8);c.stroke();
c.globalAlpha=1;
}
function drawOffscreenIndicators(c){
if(!G.active)return;
const targets=[];
if(G.camLock&&!G.camLock.dead)targets.push({x:G.camLock.x,y:G.camLock.y,col:"#ffd166"});
else if(G.active&&!G.active.dead)targets.push({x:G.active.x,y:G.active.y,col:TEAMS[G.active.team].col});
for(const t of targets){
const [sx,sy]=w2s(t.x,t.y);
if(sx>40&&sx<W()-40&&sy>40&&sy<H()-40)continue;
const px=clamp(sx,34,W()-34),py=clamp(sy,34,H()-34);
const ang=Math.atan2(sy-H()/2,sx-W()/2);
c.save();c.translate(px,py);c.rotate(ang);
c.fillStyle=t.col;c.globalAlpha=.9;
c.beginPath();c.moveTo(12,0);c.lineTo(-8,-8);c.lineTo(-8,8);c.closePath();c.fill();
c.restore();c.globalAlpha=1;
}
}
/* ============================================================
HUD
============================================================ */
function buildTeamBar(){
const bar=document.getElementById("teamBar");
bar.innerHTML="";
for(let t=0;t<Cfg.teams;t++){
const ws=G.worms.filter(w=>w.team===t);
const total=ws.reduce((s,w)=>s+w.hp,0), max=ws.length*100;
const el=document.createElement("div");
el.className="tcard"+(t===G.teamIdx?" active":"")+(total<=0?" dead":"");
el.innerHTML=`<div class="tname" style="color:${TEAMS[t].col}">${TEAMS[t].name}<i>${Cfg.cpu[t]?"CPU":"P"+(t+1)}</i></div>
<div class="hpwrap"><div class="hpghost" style="width:${total/max*100}%"></div><div class="hpfill" style="width:${total/max*100}%;background:linear-gradient(90deg,${TEAMS[t].col2},${TEAMS[t].col})"></div></div>
<div class="pips">${ws.map(w=>`<div class="pip" style="background:${w.dead?"rgba(255,255,255,.1)":(w===G.active?"#fff":TEAMS[t].col)}"></div>`).join("")}</div>`;
bar.appendChild(el);
}
}
let hpAnimTimer=0;
function refreshHP(){
const bar=document.getElementById("teamBar");
[...bar.children].forEach((el,t)=>{
const ws=G.worms.filter(w=>w.team===t);
const total=ws.reduce((s,w)=>s+w.hp,0),max=ws.length*100;
el.querySelector(".hpfill").style.width=(total/max*100)+"%";
el.querySelector(".hpghost").style.width=(total/max*100)+"%";
el.classList.toggle("dead",total<=0);
el.classList.toggle("active",t===G.teamIdx);
const pips=el.querySelectorAll(".pip");
ws.forEach((w,i)=>{if(pips[i])pips[i].style.background=w.dead?"rgba(255,255,255,.1)":(w===G.active?"#fff":TEAMS[t].col);});
});
}
function buildWeaponHud(){
const hud=document.getElementById("weaponHud"),inv=document.getElementById("invBar");
hud.innerHTML="";inv.innerHTML="";
for(const w of WEAPONS){
const amt=G.ammo[G.teamIdx][w.id];
const el=document.createElement("div");
el.className="wslot"+(G.weapon===w.id?" sel":"")+(amt<=0?" out":"");
el.innerHTML=`<div class="wkey">${w.key}</div><div class="wico">${w.ico}</div><div class="wnm">${w.name}</div>`+
(w.ammo===Infinity?"":`<div class="wammo">${amt}</div>`);
el.title=w.name+" — "+w.desc;
el.onmouseenter=()=>{Snd.hover();showToast(w.name+" · "+w.desc.toUpperCase())};
el.onclick=()=>{selectWeapon(w.id);Snd.click()};
(w.cat==="util"?inv:hud).appendChild(el);
}
}
function selectWeapon(id){
if(G.phase!=="aim"||Cfg.cpu[G.teamIdx]||G.firing)return;
if(G.ammo[G.teamIdx][id]<=0){Snd.hurt();return;}
const w=WMAP[id];
G.targetMode=false;G.teleMode=false;G.girderMode=false;
G.weapon=id;
if(w.cat==="util"){useUtility(w);}
else if(w.target){G.targetMode=true;showToast("CLICK A TARGET ZONE");}
else if(w.quake){/* instant on fire */}
buildWeaponHud();
}
function updateHUD(dt){
const tEl=document.getElementById("timer");
const tl=Math.max(0,Math.ceil(G.timeLeft));
tEl.textContent=String(tl).padStart(2,"0");
const urgent=G.timeLeft<10&&G.phase==="aim";
if(urgent){
const p=1-G.timeLeft/10;
tEl.style.color=`hsl(${lerp(190,0,p)},100%,${lerp(70,60,p)}%)`;
const pulse=1+Math.abs(Math.sin(performance.now()*.006*(1+p*2)))*(.12+p*.3);
tEl.style.transform=`scale(${pulse})`;
} else {tEl.style.color="#8fe9ff";tEl.style.transform="scale(1)";}
// power bar
const pw=document.getElementById("power"),pf=document.getElementById("powerFill");
pw.style.opacity=G.charging?1:0;
pf.style.width=(G.power*100)+"%";
// messages
if(G.msgT>0){G.msgT-=dt*.016;
if(G.msgT<=0){document.getElementById("msg").style.opacity=0;document.getElementById("sub").style.opacity=0;}}
if(toastT>0){toastT-=dt*.016;if(toastT<=0&&G.msgT<=0)document.getElementById("sub").style.opacity=0;}
}
/* ============================================================
GAME LOOP
============================================================ */
let lastT=performance.now(),acc=0;
let tickSoundT=0;
function loop(now){
requestAnimationFrame(loop);
let raw=(now-lastT);lastT=now;
if(raw>100)raw=100;
let dt=raw/16.6667;
if(G.freeze>0){G.freeze-=raw/1000;dt*=0.08;}
step(dt,raw/1000);
render();
updateHUD(dt);
}
function step(dt,secs){
if(!G)return;
updateCam(dt);
G.shake*=Math.pow(.88,dt);
// timers
if(G.phase==="aim"&&!G.over){
G.timeLeft-=secs;
if(G.timeLeft<=0){G.timeLeft=0;endTurn();}
// escalating tick
if(G.timeLeft<11){
tickSoundT-=secs;
const interval=clamp(G.timeLeft/11,0.14,1)*0.85;
if(tickSoundT<=0){tickSoundT=interval;Snd.tick(1-G.timeLeft/11);}
}
}
if(G.phase==="retreat"){
G.retreat-=secs;
if(G.retreat<=0)endTurn();
}
// AI
if(G.phase==="aim")updateAI(dt);
// keyboard aim (human)
if(humanTurn()&&G.active&&G.active.jet<=0){
let d=0;
if(keys["ArrowUp"]||keys["KeyW"])d-=1;
if(keys["ArrowDown"]||keys["KeyS"])d+=1;
if(d)G.active.aim=clamp(G.active.aim+d*0.026*dt,-Math.PI/2+.02,Math.PI/2-.02);
}
// dynamic zoom
if(!G.over){
let tz=1.0;
if(G.phase==="fly")tz=0.82;
else if(G.charging)tz=1.12;
else if(G.phase==="settle"||G.phase==="wait")tz=0.9;
G.cam.tz=lerp(G.cam.tz,tz,1-Math.pow(0.97,dt));
}
// wind streaks
if(Math.abs(G.wind)>18&&Math.random()<0.12*dt){
const wy=rnd(MAPH*0.85,60);
const wx=G.wind>0?G.cam.x-W()/G.cam.zoom/2-40:G.cam.x+W()/G.cam.zoom/2+40;
G.parts.push(new Particle(wx,wy,Math.sign(G.wind)*rnd(9,4),rnd(.4,-.4),rnd(90,50),"rgba(255,255,255,.30)",rnd(1.6,.7),{g:0,drag:1,spark:true,shrink:0}));
}
// charging
if(G.charging&&!Cfg.cpu[G.teamIdx]){
G.power=Math.min(1,G.power+dt*.018);
if(G.power>=1){releaseFire();}
}
// worms
for(const w of G.worms)w.update(dt);
// projectiles
for(let i=G.projs.length-1;i>=0;i--){ if(!G.projs[i].update(dt))G.projs.splice(i,1); }
// debris
for(let i=G.debris.length-1;i>=0;i--){ if(!G.debris[i].update(dt))G.debris.splice(i,1); }
if(G.debris.length>420)G.debris.splice(0,G.debris.length-420);
// particles
for(let i=G.parts.length-1;i>=0;i--){ if(!G.parts[i].update(dt))G.parts.splice(i,1); }
if(G.parts.length>1400)G.parts.splice(0,G.parts.length-1400);
// floats
for(let i=G.floats.length-1;i>=0;i--){ if(!G.floats[i].update(dt))G.floats.splice(i,1); }
// flashes/ripples/tracers
for(let i=G.flashes.length-1;i>=0;i--){G.flashes[i].life-=dt*.09;if(G.flashes[i].life<=0)G.flashes.splice(i,1);}
for(let i=G.ripples.length-1;i>=0;i--){const r=G.ripples[i];r.r+=dt*2.2;r.life-=dt*.02;if(r.life<=0||r.r>r.max)G.ripples.splice(i,1);}
if(G.tracers)for(let i=G.tracers.length-1;i>=0;i--){G.tracers[i].life-=dt*.12;if(G.tracers[i].life<=0)G.tracers.splice(i,1);}
// crates
updateCrates(dt);
// camera lock release
if(G.camLock&&(G.camLock.dead||G.projs.indexOf(G.camLock)<0))G.camLock=null;
// phase transitions
if(G.phase==="fly"){
if(G.projs.length===0){ G.phase="settle"; G.settleT=0.9; }
} else if(G.phase==="settle"){
G.settleT-=secs;
const busy=G.projs.length>0||G.worms.some(w=>!w.dead&&(!w.onGround||w.y>WATER_Y));
if(busy)G.settleT=Math.max(G.settleT,0.35);
if(G.settleT<=0){ G.phase="wait";G.waitTimer=0; }
} else if(G.phase==="wait"){
G.waitTimer+=secs;
G.waitHard=(G.waitHard||0)+secs;
const busy=G.projs.length>0||G.worms.some(w=>!w.dead&&(!w.onGround||w.y>WATER_Y));
if(busy&&G.waitHard<9)G.waitTimer=Math.min(G.waitTimer,0.35);
if(G.waitTimer>0.75){
G.waitHard=0;
if(G.turnDamage>0)showToast(Math.round(G.turnDamage)+" DAMAGE DEALT");
afterActions();
}
}
refreshHP();
// dying worms cleanup
for(const w of G.worms)if(w.dead&&w.grave<1)w.grave=1;
}
function updateCrates(dt){
for(let i=G.crates.length-1;i>=0;i--){
const cr=G.crates[i];
cr.vy+=GRAV*dt*(cr.chute?0.1:1);
if(cr.chute)cr.vy=Math.min(cr.vy,1.2);
cr.vx+=G.wind*0.0018*dt*(cr.chute?1.6:.3);
cr.vx*=Math.pow(.99,dt);
let ny=cr.y+cr.vy*dt,nx=cr.x+cr.vx*dt;
if(ny>WATER_Y){splashFX(nx,WATER_Y,.9);G.crates.splice(i,1);continue;}
if(solidAt(nx,ny)){cr.chute=false;cr.vy=0;cr.vx*=.4;
while(solidAt(cr.x,cr.y+1)===false&&cr.y<ny)cr.y++;
} else {cr.y=ny;cr.x=nx;}
if(cr.y>MAPH+50){G.crates.splice(i,1);continue;}
// pickup
for(const w of G.worms){
if(w.dead)continue;
if(dist(w.x,w.y,cr.x,cr.y)<18){
collectCrate(cr,w);G.crates.splice(i,1);break;
}
}
}
}
function collectCrate(cr,w){
Snd.pickup();
for(let i=0;i<30;i++)G.parts.push(new Particle(cr.x,cr.y,rnd(3,-3),rnd(1,-4),rnd(34,14),cr.col,rnd(3.4,1.2),{g:.08,glow:true}));
if(cr.k==="health"){w.hp=Math.min(w.maxhp,w.hp+35);G.floats.push(new FloatText(w.x,w.y-26,"+35","#7affa0",22));showToast("HEALTH CRATE");}
else if(cr.k==="ammo"){
const opts=WEAPONS.filter(x=>x.ammo!==Infinity&&x.cat!=="util");
const pk=pick(opts);G.ammo[w.team][pk.id]+=2;
G.floats.push(new FloatText(w.x,w.y-26,"+2 "+pk.name,"#ffd166",17));showToast("AMMO CRATE · "+pk.name);
} else {
const opts=WEAPONS.filter(x=>x.cat==="util");
const pk=pick(opts);G.ammo[w.team][pk.id]+=1;
G.floats.push(new FloatText(w.x,w.y-26,"+1 "+pk.name,"#5db8ff",17));showToast("UTILITY CRATE · "+pk.name);
}
buildWeaponHud();
}
/* ---------------- QUAKE / AIRSTRIKE ---------------- */
function doQuake(){
const a=G.active;
G.ammo[G.teamIdx].quake--;
Snd.explode(2.2);G.shake=44;G.freeze=.12;
showToast("TREMOR!");
const cx=a.x;
// collapse terrain in a wide band: convert bottom edges into debris
for(let i=0;i<70;i++){
const x=cx+rnd(300,-300);
let y=-1;
for(let yy=Math.max(0,a.y-260);yy<MAPH;yy++)if(solidAt(x,yy)){y=yy;break;}
if(y<0)continue;
blast(x,y+rnd(30,0),rnd(30,14),true);
}
for(const w of G.worms){
if(w.dead)continue;
const d=Math.abs(w.x-cx);
if(d<340){
const f=1-d/340;
w.vy-=f*5.5;w.vx+=rnd(3,-3)*f;w.onGround=false;
if(w!==a){w.damage(30*f,a);if(w.team!==a.team)G.turnDamage+=30*f;}
else w.damage(8*f,a);
}
}
for(let i=0;i<90;i++)G.parts.push(new Particle(cx+rnd(340,-340),a.y+rnd(90,-40),rnd(3,-3),-rnd(4,.5),rnd(60,25),"rgba(120,110,100,.7)",rnd(9,3),{g:.05,smoke:true}));
G.phase="retreat";G.retreat=2.6;
buildWeaponHud();
}
function doAirstrike(tx){
G.ammo[G.teamIdx].airstrike--;
G.targetMode=false;
Snd.airstrike();
showToast("AIRSTRIKE INBOUND");
const dir=tx>G.active.x?1:-1;
const w=WMAP.airstrike;
G.phase="fly";
const startY=Math.max(-260,G.cam.y-H()/G.cam.zoom/2-160);
for(let i=0;i<5;i++){
setTimeout(()=>{
if(!G)return;
const p=new Proj(tx+(i-2)*46*dir - dir*90, startY, dir*2.6, 3.2, w, G.teamIdx,{});
p.ownerWorm=G.active;p.ownerTeam=G.teamIdx;p.smoke=true;
G.projs.push(p);
if(i===0)G.camLock=p;
},i*110);
}
G.planeFX={x:tx-dir*700,dir,life:2.4};
buildWeaponHud();
}
/* ============================================================
INPUT
============================================================ */
function releaseFire(){
if(!G.charging)return;
G.charging=false;
const p=Math.max(0.08,G.power);
G.power=0;
fireWeapon(p);
}
function humanTurn(){return G&&!Cfg.cpu[G.teamIdx]&&G.phase==="aim"&&G.active&&!G.active.dead}
addEventListener("keydown",e=>{
if(e.code==="Tab")e.preventDefault();
if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Space"].includes(e.code))e.preventDefault();
if(keys[e.code])return;
keys[e.code]=true;
Snd.init();
if(e.code==="KeyM"){const m=Snd.mute();showToast(m?"SOUND OFF":"SOUND ON");return;}
if(!G)return;
if(e.code==="Equal"||e.code==="NumpadAdd"){G.cam.tz=clamp(G.cam.tz*1.2,.45,2.2);return;}
if(e.code==="Minus"||e.code==="NumpadSubtract"){G.cam.tz=clamp(G.cam.tz/1.2,.45,2.2);return;}
if(!humanTurn())return;
const a=G.active;
if(e.code==="Enter"||e.code==="NumpadEnter"){a.jump(0);return;}
if(e.code==="Backspace"){a.jump(1);return;}
if(e.code==="Space"){
const w=currentWeapon();
if(w.cat==="util"){useUtility(w);return;}
if(w.quake){doQuake();return;}
if(w.target){showToast("CLICK A TARGET ZONE");return;}
if(!canFire())return;
if(w.cat==="direct"){fireWeapon(1);return;}
G.charging=true;G.power=0;return;
}
if(e.code==="Tab"){
// switch worm in team (only before acting)
if(G.timeLeft>G.turnTime-0.6||true){
const tw=G.worms.filter(w=>w.team===G.teamIdx&&!w.dead);
if(tw.length>1&&!G.firing&&G.turnDamage===0){
const i=tw.indexOf(G.active);
G.active=tw[(i+1)%tw.length];
Snd.click();centerCam(G.active.x,G.active.y);buildTeamBar();
}
}
return;
}
const numMap={Digit1:0,Digit2:1,Digit3:2,Digit4:3,Digit5:4,Digit6:5,Digit7:6,Digit8:7,Digit9:8,Digit0:9};
if(e.code in numMap){const w=WEAPONS[numMap[e.code]];if(w)selectWeapon(w.id);Snd.click();return;}
const letterMap={KeyJ:"jetpack",KeyT:"teleport",KeyG:"girder",KeyH:"medkit",KeyP:"chute"};
if(e.code in letterMap){selectWeapon(letterMap[e.code]);Snd.click();return;}
if(e.code==="Escape"){G.targetMode=false;G.teleMode=false;G.girderMode=false;G.weapon=validWeapon("bazooka");buildWeaponHud();}
});
addEventListener("keyup",e=>{
keys[e.code]=false;
if(!G)return;
if(e.code==="Space"&&G.charging&&!Cfg.cpu[G.teamIdx])releaseFire();
});
addEventListener("blur",()=>{for(const k in keys)keys[k]=false;});
function updateMouseWorld(){
if(!G)return;
const [wx,wy]=s2w(mouse.x,mouse.y);mouse.wx=wx;mouse.wy=wy;
}
addEventListener("mousemove",e=>{
mouse.x=e.clientX;mouse.y=e.clientY;updateMouseWorld();
if(humanTurn()&&G.active&&!G.charging&&!G.targetMode&&!G.teleMode&&!G.girderMode){
const a=G.active;
const dx=mouse.wx-a.x,dy=mouse.wy-a.y;
if(Math.hypot(dx,dy)>18){
const raw=Math.atan2(dy,dx);
const f=Math.abs(raw)>Math.PI/2?-1:1;
a.face=f;
a.aim=clamp(f>0?raw:(raw>0?Math.PI-raw:-Math.PI-raw),-Math.PI/2+.02,Math.PI/2-.02);
}
}
});
addEventListener("mousedown",e=>{
Snd.init();
if(!G)return;
if(e.button===2)return;
updateMouseWorld();
if(!humanTurn())return;
if(G.teleMode){doTeleport(mouse.wx,mouse.wy);return;}
if(G.girderMode){placeGirder(mouse.wx,mouse.wy);return;}
if(G.targetMode){doAirstrike(mouse.wx);return;}
if(e.target!==cv)return;
const w=currentWeapon();
if(w.cat==="util"){useUtility(w);return;}
if(w.quake){doQuake();return;}
if(!canFire())return;
if(w.cat==="direct"){fireWeapon(1);return;}
G.charging=true;G.power=0;
});
addEventListener("mouseup",e=>{
if(!G)return;
if(G.charging&&!Cfg.cpu[G.teamIdx])releaseFire();
});
addEventListener("contextmenu",e=>e.preventDefault());
addEventListener("wheel",e=>{
if(!G)return;
if(G.girderMode){G.girderAng+=Math.sign(e.deltaY)*Math.PI/12;Snd.rope();return;}
G.cam.tz=clamp(G.cam.tz*(e.deltaY>0?.9:1.11),.45,2.0);
},{passive:true});
// Touch (basic)
addEventListener("touchstart",e=>{Snd.init();if(!G)return;const t=e.touches[0];mouse.x=t.clientX;mouse.y=t.clientY;updateMouseWorld();},{passive:true});
/* ============================================================
MENU
============================================================ */
function buildMenuTeams(){
const el=document.getElementById("tsetup");
el.innerHTML="";
for(let i=0;i<Cfg.teams;i++){
const d=document.createElement("div");d.className="tbox";
d.innerHTML=`<div class="tn" style="color:${TEAMS[i].col}">${TEAMS[i].name}</div>
<div class="tog" data-i="${i}" style="border-color:${Cfg.cpu[i]?"#ffd166":"#7affc8"};color:${Cfg.cpu[i]?"#ffd166":"#7affc8"}">${Cfg.cpu[i]?"CPU":"HUMAN"}</div>`;
el.appendChild(d);
}
el.querySelectorAll(".tog").forEach(t=>{
t.onmouseenter=()=>Snd.hover();
t.onclick=()=>{const i=+t.dataset.i;Cfg.cpu[i]=!Cfg.cpu[i];Snd.click();buildMenuTeams();};
});
}
document.querySelectorAll("#menu .btn[data-map],#menu .btn[data-teams],#menu .btn[data-time]").forEach(b=>{
b.onmouseenter=()=>{Snd.init();Snd.hover()};
b.onclick=()=>{
Snd.click();
const grp=b.dataset.map?"map":b.dataset.teams?"teams":"time";
b.parentElement.querySelectorAll(".btn").forEach(x=>x.classList.remove("on"));
b.classList.add("on");
if(grp==="map")Cfg.map=b.dataset.map;
if(grp==="teams"){Cfg.teams=+b.dataset.teams;buildMenuTeams();}
if(grp==="time")Cfg.time=+b.dataset.time;
};
});
document.getElementById("play").onmouseenter=()=>{Snd.init();Snd.hover()};
document.getElementById("play").onclick=()=>{
Snd.init();Snd.click();
const f=document.getElementById("fade");f.style.opacity=1;
setTimeout(()=>{
document.getElementById("menu").style.display="none";
newGame();
f.style.opacity=0;
},420);
};
/* ============================================================
BOOT
============================================================ */
function resize(){
cv.width=Math.floor(innerWidth*Math.min(devicePixelRatio||1,2));
cv.height=Math.floor(innerHeight*Math.min(devicePixelRatio||1,2));
cv.style.width=innerWidth+"px";cv.style.height=innerHeight+"px";
const s=Math.min(devicePixelRatio||1,2);
ctx2d.setTransform(s,0,0,s,0,0);
dpr=s;skyGrad=null;
}
let dpr=1;
// Patch W/H to CSS pixels (canvas transform handles dpr)
function initCanvas(){
cv=document.getElementById("game");
ctx2d=cv.getContext("2d",{alpha:false});
resize();
addEventListener("resize",resize);
}
// override render transform to include dpr
const _render=render;
render=function(){
const s=dpr;
ctx2d.setTransform(s,0,0,s,0,0);
_render();
};
initCanvas();
// idle attract state so loop is safe before newGame
G={worms:[],projs:[],parts:[],debris:[],floats:[],flashes:[],ripples:[],girders:[],crates:[],
cam:{x:0,y:0,zoom:1,tx:0,ty:0,tz:1},wind:0,shake:0,freeze:0,phase:"menu",teamIdx:0,timeLeft:0,turnTime:45,
clouds:[],stars:[],ammo:[{}],active:null,msgT:0,power:0,charging:false};
buildMenuTeams();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-opus-5","provider":"openrouter","persona":"full","sessionId":"92bdb82c-3674-4b6e-ac9a-fee95207ec15","tokensIn":3103539,"tokensOut":68534,"tokensTotal":3172073,"turns":32,"toolCalls":31,"failedToolCalls":0,"timestamp":"2026-07-25T18:48:23.605Z"} -->