Worms
Deepseek v4.1 Flash · 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 113,907 bytes · SHA-256 c6147f1ca253
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Wiggle Wars — Hot-Seat Artillery</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden;background:#04050a;color:#eaf0ff;
font-family:'Trebuchet MS','Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
canvas#game{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
/* ================= HUD ================= */
#hud{position:fixed;inset:0;pointer-events:none;z-index:20}
.glass{background:linear-gradient(180deg,rgba(20,26,44,.86),rgba(10,13,24,.9));
border:1px solid rgba(160,190,255,.16);border-radius:14px;
box-shadow:0 8px 26px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.09);
backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px)}
#topbar{position:absolute;top:12px;left:50%;transform:translateX(-50%);
display:flex;gap:14px;align-items:center}
#timerWrap{position:relative;width:250px;height:38px;display:flex;align-items:center;
padding:0 12px;gap:10px}
#timerIcon{font-size:17px;filter:drop-shadow(0 1px 2px #000);opacity:.9}
#timerCol{flex:1}
#timerBar{height:11px;border-radius:8px;background:rgba(0,0,0,.55);overflow:hidden;
box-shadow:inset 0 1px 3px rgba(0,0,0,.8),0 0 0 1px rgba(255,255,255,.07)}
#timerFill{display:block;height:100%;width:100%;border-radius:8px;
background:linear-gradient(90deg,#6ef2a5,#38d9a9 55%,#8ce99a);
transition:background .35s linear;box-shadow:0 0 10px rgba(90,240,180,.5)}
#timerText{font-size:14px;font-weight:bold;min-width:24px;text-align:right;
font-variant-numeric:tabular-nums;letter-spacing:.5px;text-shadow:0 1px 3px #000}
#timerWrap.hot #timerFill{background:linear-gradient(90deg,#ffd43b,#ff922b)}
#timerWrap.warn #timerFill{background:linear-gradient(90deg,#ff8787,#f03e3e)}
#timerWrap.warn{animation:pulseWrap .5s ease-in-out infinite alternate}
#timerWrap.crit{animation:pulseWrap .22s ease-in-out infinite alternate;
border-color:rgba(255,90,90,.6)}
@keyframes pulseWrap{from{transform:scale(1)}to{transform:scale(1.09)}}
#windBox{width:270px;height:38px;display:flex;align-items:center;gap:9px;padding:0 12px;
position:relative;overflow:hidden}
#windBox:before{content:'';position:absolute;inset:0;background:
repeating-linear-gradient(90deg,rgba(255,255,255,.045) 0 1px,transparent 1px 14px);pointer-events:none}
.wlabel{font-size:10px;letter-spacing:2.4px;opacity:.62;font-weight:bold}
#windTrack{flex:1;height:12px;position:relative;border-radius:7px;background:rgba(0,0,0,.5);
box-shadow:inset 0 1px 3px rgba(0,0,0,.8)}
#windTrack:after{content:'';position:absolute;left:50%;top:-3px;width:1px;height:18px;
background:rgba(255,255,255,.25)}
#windNeedle{position:absolute;top:1px;left:50%;width:12px;height:10px;border-radius:3px;
background:linear-gradient(180deg,#8cd8ff,#3b7fe0);box-shadow:0 0 9px rgba(110,190,255,.85);
margin-left:-6px;transition:left .45s cubic-bezier(.4,1.6,.5,1)}
#windVal{font-size:12px;font-weight:bold;min-width:30px;text-align:right;opacity:.9;
font-variant-numeric:tabular-nums}
#teams{position:absolute;left:12px;top:12px;display:flex;flex-direction:column;gap:7px;width:196px}
.team{position:relative;padding:6px 8px 7px;border-radius:12px;overflow:hidden;
background:linear-gradient(180deg,rgba(18,23,40,.82),rgba(9,12,22,.86));
border:1px solid rgba(255,255,255,.09);transition:transform .25s,box-shadow .25s,opacity .35s}
.team .thead{display:flex;justify-content:space-between;align-items:center;font-size:11px;
font-weight:bold;letter-spacing:1.1px;margin-bottom:5px;text-transform:uppercase}
.team .thead .nm{display:flex;align-items:center;gap:5px}
.team .dot{width:9px;height:9px;border-radius:50%;box-shadow:0 0 8px currentColor}
.team .tot{font-size:10px;opacity:.72;font-variant-numeric:tabular-nums}
.team .units{display:flex;gap:4px}
.uchip{flex:1;height:16px;border-radius:5px;background:rgba(0,0,0,.5);position:relative;
overflow:hidden;box-shadow:inset 0 1px 2px rgba(0,0,0,.7)}
.uchip i{position:absolute;left:0;top:0;bottom:0;width:100%;border-radius:5px;
transition:width .3s ease-out;box-shadow:0 0 7px currentColor}
.uchip.dead i{width:0}
.uchip.dead:after{content:'✕';position:absolute;inset:0;font-size:9px;text-align:center;
line-height:16px;color:#ff8080;opacity:.85}
.team.active{transform:translateX(7px) scale(1.035);
box-shadow:0 0 0 1px var(--tc), 0 0 24px -2px var(--tc), 0 8px 22px rgba(0,0,0,.55)}
.team.active:after{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--tc);
box-shadow:0 0 12px var(--tc)}
.team.out{opacity:.34;filter:grayscale(.7)}
#bottom{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);
display:flex;flex-direction:column;align-items:center;gap:7px;pointer-events:none}
#weaponStrip{display:flex;gap:6px;padding:7px;border-radius:15px;background:
linear-gradient(180deg,rgba(18,23,40,.86),rgba(9,12,22,.92));
border:1px solid rgba(160,190,255,.16);box-shadow:0 8px 26px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.08);
backdrop-filter:blur(9px);pointer-events:auto}
.wslot{width:50px;height:50px;border-radius:11px;position:relative;cursor:pointer;
background:linear-gradient(180deg,rgba(255,255,255,.09),rgba(255,255,255,.02));
border:1px solid rgba(255,255,255,.09);display:flex;align-items:center;justify-content:center;
transition:transform .14s,box-shadow .2s,background .2s,opacity .2s}
.wslot svg{width:29px;height:29px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.6))}
.wslot:hover{transform:translateY(-4px);background:linear-gradient(180deg,rgba(255,255,255,.18),rgba(255,255,255,.05));
box-shadow:0 6px 16px rgba(0,0,0,.5)}
.wslot.sel{background:linear-gradient(180deg,rgba(255,214,102,.28),rgba(255,160,60,.12));
border-color:rgba(255,208,110,.85);box-shadow:0 0 0 1px rgba(255,208,110,.5),0 0 20px -2px rgba(255,190,80,.8)}
.wslot .ammo{position:absolute;right:3px;bottom:2px;font-size:10px;font-weight:bold;
color:#ffe9a8;text-shadow:0 1px 2px #000;font-variant-numeric:tabular-nums}
.wslot.empty{opacity:.35;cursor:not-allowed}
.wslot.empty:after{content:'';position:absolute;inset:0;border-radius:11px;
background:repeating-linear-gradient(45deg,rgba(255,0,0,.14) 0 4px,transparent 4px 8px)}
.wkey{position:absolute;left:3px;top:2px;font-size:8px;opacity:.45;font-weight:bold}
#weaponTip{position:absolute;left:50%;bottom:78px;transform:translateX(-50%) translateY(6px);
padding:6px 12px;border-radius:9px;font-size:12px;opacity:0;transition:.18s;
background:rgba(6,9,18,.94);border:1px solid rgba(160,190,255,.2);white-space:nowrap;
box-shadow:0 6px 20px rgba(0,0,0,.6);pointer-events:none;max-width:70vw}
#weaponTip .wn{font-weight:bold;color:#ffd369;letter-spacing:.6px}
#weaponTip .wd{opacity:.72}
#weaponTip.show{opacity:1;transform:translateX(-50%) translateY(0)}
#powerBox{position:absolute;right:14px;bottom:16px;display:flex;flex-direction:column;
align-items:center;gap:5px;opacity:0;transition:opacity .2s}
#powerBox.show{opacity:1}
#powerBar{width:26px;height:168px;border-radius:13px;background:rgba(6,9,18,.85);
border:1px solid rgba(160,190,255,.2);position:relative;overflow:hidden;
box-shadow:0 8px 22px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.08)}
#powerFill{position:absolute;left:0;right:0;bottom:0;height:0%;
background:linear-gradient(0deg,#3ddc97,#ffd43b 55%,#ff5c5c);transition:height .05s linear;
box-shadow:0 0 14px rgba(255,180,80,.55)}
#powerNum{font-size:13px;font-weight:bold;text-shadow:0 1px 3px #000;font-variant-numeric:tabular-nums}
#banner{position:absolute;left:0;right:0;top:32%;text-align:center;pointer-events:none;
opacity:0;transition:opacity .3s}
#banner .bt{font-size:min(7.4vw,86px);font-weight:900;letter-spacing:3px;line-height:1;
text-transform:uppercase;text-shadow:0 4px 0 rgba(0,0,0,.45),0 10px 40px rgba(0,0,0,.7);
background:linear-gradient(180deg,#fff,var(--bc,#7fd0ff));-webkit-background-clip:text;
background-clip:text;color:transparent;transform:scale(.9)}
#banner .bs{margin-top:8px;font-size:min(2.4vw,20px);letter-spacing:6px;opacity:.8;
text-transform:uppercase;font-weight:bold}
#banner.show{opacity:1}
#banner.show .bt{animation:banIn .5s cubic-bezier(.2,1.7,.4,1) forwards}
@keyframes banIn{from{transform:scale(.7) rotate(-2deg);opacity:0}
to{transform:scale(1) rotate(0);opacity:1}}
#toasts{position:absolute;right:14px;top:12px;display:flex;flex-direction:column;gap:6px;
align-items:flex-end;width:250px}
.toast{padding:7px 12px;border-radius:10px;font-size:12.5px;font-weight:bold;
background:linear-gradient(180deg,rgba(20,26,44,.92),rgba(10,13,24,.94));
border:1px solid rgba(160,190,255,.18);box-shadow:0 6px 18px rgba(0,0,0,.5);
animation:toastIn .35s cubic-bezier(.2,1.6,.4,1),toastOut .4s 2.6s forwards;
max-width:250px}
@keyframes toastIn{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}}
@keyframes toastOut{to{transform:translateX(30px);opacity:0}}
#hint{position:absolute;left:14px;bottom:14px;font-size:11.5px;line-height:1.65;opacity:.62;
background:rgba(6,9,18,.6);padding:8px 11px;border-radius:10px;border:1px solid rgba(255,255,255,.07);
max-width:245px}
#hint b{color:#ffd369}
#menuBtns{position:absolute;right:14px;top:12px;display:flex;gap:6px;pointer-events:auto;
opacity:0;transition:opacity .3s}
#menuBtns.show{opacity:1}
.mini{padding:6px 10px;font-size:11px;border-radius:9px;cursor:pointer;font-weight:bold;
background:linear-gradient(180deg,rgba(26,34,56,.92),rgba(12,16,30,.94));color:#dfe8ff;
border:1px solid rgba(160,190,255,.18);transition:.15s}
.mini:hover{transform:translateY(-2px);border-color:rgba(200,220,255,.5);color:#fff}
/* ================= SCREENS ================= */
.screen{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;
background:radial-gradient(circle at 50% 30%,rgba(40,60,120,.34),rgba(3,4,9,.93) 70%),
linear-gradient(160deg,#070a16,#0b0518 60%,#04060d);
backdrop-filter:blur(3px);transition:opacity .4s,visibility .4s}
.screen.hide{opacity:0;visibility:hidden;pointer-events:none}
.card{width:min(880px,94vw);max-height:94vh;overflow:auto;padding:26px 30px 22px;
border-radius:22px;background:linear-gradient(180deg,rgba(19,25,44,.92),rgba(8,10,20,.95));
border:1px solid rgba(160,190,255,.18);
box-shadow:0 30px 80px rgba(0,0,0,.7),inset 0 1px 0 rgba(255,255,255,.1)}
h1{font-size:min(11vw,74px);line-height:.92;font-weight:900;letter-spacing:-1px;text-align:center}
h1 span{display:block;background:linear-gradient(100deg,#7bf1c0,#7fd0ff 40%,#c58cff 75%,#ff9ad0);
-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 6px 24px rgba(120,180,255,.35))}
h1 .a{font-size:.55em;letter-spacing:.28em;font-weight:800;color:#9fb4dd;display:block;
margin-bottom:4px;text-shadow:none}
.tag{text-align:center;font-size:13px;letter-spacing:2.4px;text-transform:uppercase;
opacity:.6;margin:10px 0 20px}
.grid2{display:grid;grid-template-columns:1.05fr .95fr;gap:22px}
@media(max-width:720px){.grid2{grid-template-columns:1fr}}
h3{font-size:11px;letter-spacing:2.6px;text-transform:uppercase;opacity:.65;margin:0 0 9px;
font-weight:bold}
.trow{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:11px;margin-bottom:7px;
background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);transition:.2s}
.trow .chip{width:15px;height:15px;border-radius:5px;box-shadow:0 0 10px currentColor;flex:0 0 auto}
.trow .tn{flex:1;font-size:13.5px;font-weight:bold;letter-spacing:.7px}
.trow.off{opacity:.36}
.tgl{display:flex;border-radius:9px;overflow:hidden;border:1px solid rgba(255,255,255,.12)}
.tgl button{padding:5px 10px;font-size:10.5px;font-weight:bold;letter-spacing:.8px;cursor:pointer;
background:transparent;color:#a9b8d8;border:0;transition:.15s}
.tgl button.on{background:linear-gradient(180deg,rgba(120,210,255,.32),rgba(60,130,255,.2));color:#fff}
.tgl button:hover{color:#fff}
.keys{list-style:none;font-size:12.5px;line-height:1.9;opacity:.86}
.keys li{margin-bottom:2px}
.keys b{display:inline-block;min-width:78px;color:#ffd369;font-weight:bold}
.wgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:11.5px}
.wgrid div{display:flex;gap:8px;align-items:center;opacity:.88}
.wgrid svg{width:21px;height:21px;flex:0 0 auto;filter:drop-shadow(0 1px 2px rgba(0,0,0,.6))}
.row{display:flex;gap:12px;justify-content:center;margin-top:22px;flex-wrap:wrap}
button.big{padding:14px 34px;font-size:16px;font-weight:bold;letter-spacing:2px;border-radius:14px;
cursor:pointer;color:#04211a;border:0;background:linear-gradient(180deg,#8cf7c5,#31c98d);
box-shadow:0 10px 26px rgba(40,220,150,.32),inset 0 1px 0 rgba(255,255,255,.6);transition:.16s}
button.big:hover{transform:translateY(-3px) scale(1.02);box-shadow:0 16px 34px rgba(40,220,150,.45)}
button.big:active{transform:translateY(0) scale(.99)}
button.ghost{padding:14px 22px;font-size:13px;font-weight:bold;letter-spacing:1.5px;border-radius:14px;
cursor:pointer;color:#cfe0ff;background:rgba(255,255,255,.06);
border:1px solid rgba(160,190,255,.22);transition:.16s}
button.ghost:hover{background:rgba(255,255,255,.13);color:#fff;transform:translateY(-2px)}
#over .card{width:min(620px,94vw);text-align:center}
#winTitle{font-size:min(9vw,56px);font-weight:900;letter-spacing:1px}
#winSub{font-size:13px;opacity:.65;letter-spacing:2px;text-transform:uppercase;margin-top:8px}
#stats{margin:18px auto 0;font-size:12.5px;line-height:2;max-width:420px;text-align:left}
#stats .sr{display:flex;justify-content:space-between;border-bottom:1px dashed rgba(255,255,255,.1)}
#stats .sr b{color:#ffd369}
.credit{text-align:center;font-size:10.5px;opacity:.4;letter-spacing:1.6px;margin-top:16px}
.scroll::-webkit-scrollbar{width:9px}
.scroll::-webkit-scrollbar-thumb{background:rgba(140,180,255,.3);border-radius:9px}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud">
<div id="topbar">
<div id="timerWrap" class="glass">
<span id="timerIcon">⏳</span>
<div id="timerCol">
<div id="timerBar"><i id="timerFill"></i></div>
</div>
<span id="timerText">45</span>
</div>
<div id="windBox" class="glass">
<span class="wlabel">WIND</span>
<div id="windTrack"><i id="windNeedle"></i></div>
<span id="windVal">0</span>
</div>
</div>
<div id="teams"></div>
<div id="menuBtns">
<button class="mini" id="btnMute">🔊</button>
<button class="mini" id="btnQuit">MENU</button>
</div>
<div id="toasts"></div>
<div id="hint">
<b>A / D</b> walk <b>W</b> jump<br>
<b>Mouse</b> aim <b>Hold LMB</b> charge<br>
<b>1-9</b> weapons <b>RMB</b> cancel
</div>
<div id="banner"><div class="bt">TEAM</div><div class="bs">prepare</div></div>
<div id="weaponTip"><span class="wn"></span> <span class="wd"></span></div>
<div id="bottom">
<div id="weaponStrip"></div>
</div>
<div id="powerBox">
<div id="powerBar"><i id="powerFill"></i></div>
<div id="powerNum">0</div>
</div>
</div>
<div id="title" class="screen">
<div class="card scroll">
<h1><span class="a">HOT-SEAT ARTILLERY</span><span>WIGGLE WARS</span></h1>
<p class="tag">4 worms per team · destructible earth · shifting wind</p>
<div class="grid2">
<div>
<h3>Combatants</h3>
<div id="teamSetup"></div>
<h3 style="margin-top:16px">CPU Skill</h3>
<div id="diffRow" class="tgl" style="display:inline-flex"></div>
</div>
<div>
<h3>Arsenal</h3>
<div class="wgrid" id="arsenal"></div>
<h3 style="margin-top:16px">Controls</h3>
<ul class="keys">
<li><b>A / D</b> walk left / right</li>
<li><b>W / ↑</b> jump (mind fall damage)</li>
<li><b>Mouse</b> aim · hold <b>LMB</b> to charge</li>
<li><b>1-9</b> weapon · <b>RMB</b> cancel shot</li>
<li><b>T</b> teleport · <b>E</b> jetpack thrust</li>
</ul>
</div>
</div>
<div class="row">
<button id="btnStart" class="big">► START BATTLE</button>
<button id="btnSound" class="ghost">🔊 SOUND ON</button>
</div>
<div class="credit">built with love, canvas & web audio — no assets, all procedural</div>
</div>
</div>
<div id="over" class="screen hide">
<div class="card">
<div id="winTitle">VICTORY</div>
<div id="winSub">last worm standing</div>
<div id="stats"></div>
<div class="row">
<button id="btnRematch" class="big">↻ REMATCH</button>
<button id="btnMenu" class="ghost">MENU</button>
</div>
</div>
</div>
<script>
/* =====================================================================
WIGGLE WARS — single-file hot-seat artillery
===================================================================== */
'use strict';
const W = 1280, H = 720; let WATER_Y = 598;
/* ------------------------------ utils ------------------------------ */
const clamp = (v,a,b)=>v<a?a:v>b?b:v;
const lerp = (a,b,t)=>a+(b-a)*t;
const dist = (a,b,c,d)=>Math.hypot(a-c,b-d);
const TAU = Math.PI*2;
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);
t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
let RNG = mulberry32(12345);
const rr = (a=1,b=0)=>b+RNG()*(a-b);
const ri = (a,b)=>Math.floor(rr(b+1,a));
const pick = arr=>arr[Math.floor(RNG()*arr.length)];
function hash2(x,y){let h=x*374761393+y*668265263;h=(h^(h>>>13))*1274126177;return((h^(h>>>16))>>>0)/4294967296}
function makeFbm(rng,smooth){
const oct=[];for(let i=0;i<4;i++)oct.push({f:Math.pow(2,i)*(smooth||0.9),p:rng()*TAU,a:1/Math.pow(1.85,i)});
return x=>{let s=0,t=0;for(const o of oct){s+=Math.sin(x*o.f+o.p)*o.a;t+=o.a}return s/t};
}
const fmt = n=>Math.max(0,Math.round(n));
/* ------------------------------ audio ------------------------------ */
const Sound = (()=>{
let AC=null,master=null,muted=false,noiseBuf=null,windGain=null,windFilt=null;
function init(){
if(AC) { if(AC.state==='suspended') AC.resume(); return; }
const C = window.AudioContext||window.webkitAudioContext; if(!C) return;
AC = new C();
master = AC.createGain(); master.gain.value=.5; master.connect(AC.destination);
const len = Math.floor(AC.sampleRate*2);
noiseBuf = AC.createBuffer(1,len,AC.sampleRate);
const d = noiseBuf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
const src = AC.createBufferSource(); src.buffer=noiseBuf; src.loop=true;
windFilt = AC.createBiquadFilter(); windFilt.type='lowpass'; windFilt.frequency.value=420;
windGain = AC.createGain(); windGain.gain.value=0;
src.connect(windFilt); windFilt.connect(windGain); windGain.connect(master);
src.start();
}
const on = ()=>AC && !muted;
function tone(f1,f2,dur,type,vol,delay){
if(!on())return; const t=AC.currentTime+(delay||0);
const o=AC.createOscillator(), g=AC.createGain();
o.type=type||'sine'; o.frequency.setValueAtTime(Math.max(20,f1),t);
if(f2&&f2!==f1) o.frequency.exponentialRampToValueAtTime(Math.max(20,f2),t+dur);
g.gain.setValueAtTime(0.0001,t);
g.gain.linearRampToValueAtTime(vol,t+Math.min(dur*0.15,0.012));
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g); g.connect(master); o.start(t); o.stop(t+dur+0.06);
}
function nz(dur,vol,f1,f2,type,q,delay,rate){
if(!on())return; const t=AC.currentTime+(delay||0);
const s=AC.createBufferSource(); s.buffer=noiseBuf; s.playbackRate.value=rate||1;
const f=AC.createBiquadFilter(); f.type=type||'lowpass'; f.Q.value=q||1;
f.frequency.setValueAtTime(Math.max(30,f1),t);
if(f2) f.frequency.exponentialRampToValueAtTime(Math.max(30,f2),t+dur);
const g=AC.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.linearRampToValueAtTime(vol,t+Math.min(dur*0.1,0.02));
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
s.connect(f); f.connect(g); g.connect(master); s.start(t); s.stop(t+dur+0.05);
}
return {
init, on,
get muted(){return muted},
toggle(){ muted=!muted; if(master) master.gain.value = muted?0:0.5; return muted; },
setWind(v){ if(!AC)return; const a=Math.abs(v);
windGain.gain.setTargetAtTime(0.012+a*0.085, AC.currentTime, .25);
windFilt.frequency.setTargetAtTime(280+a*620, AC.currentTime, .4); },
hover(){ tone(760,900,.05,'square',.035); },
select(){ tone(520,780,.07,'square',.05); tone(1040,1240,.09,'triangle',.035,.04); },
back(){ tone(420,220,.11,'square',.045); },
deny(){ tone(180,110,.16,'sawtooth',.05); },
tick(hi){ tone(hi?1420:1000, hi?1420:1000,.035,'square',.045); },
tickUrgent(n){ tone(1500+n*60,1200,.05,'square',.06); },
jump(){ tone(250,620,.14,'sine',.055); nz(.1,.03,600,1600,'bandpass',2); },
land(){ nz(.13,.055,400,90,'lowpass',1); tone(120,60,.1,'sine',.05); },
step(){ nz(.05,.028,700,300,'bandpass',1.4); },
fire(){ nz(.22,.14,1800,140,'lowpass',1); tone(200,70,.2,'triangle',.08); },
launch(){ nz(.3,.11,1400,180,'highpass',1); tone(420,120,.25,'sawtooth',.05); },
pellet(){ nz(.08,.1,2600,600,'bandpass',1.6); tone(320,120,.06,'square',.05); },
mg(){ nz(.045,.07,1800,700,'bandpass',1.2); },
pop(){ tone(900,1600,.06,'sine',.05); },
boom(size){
const s = size||1;
nz(.55*s,.28,900/(s*0.6+0.6),60,'lowpass',1,null,1/Math.max(.6,s*.5));
tone(120/s,42,.5*s,'sine',.24);
nz(.22,.1,3200,400,'highpass',.7,.02);
},
crumble(){ nz(.45,.09,300,80,'lowpass',1.2); },
splash(){ nz(.5,.13,2000,300,'bandpass',1.1); tone(700,180,.3,'sine',.05); },
drown(){ for(let i=0;i<7;i++) tone(300+RNG()*500, 700+RNG()*600, .07,'sine',.045, i*0.14); },
hurt(){ tone(300,120,.16,'square',.07); },
heal(){ tone(560,1200,.22,'triangle',.07); },
death(){ tone(420,70,.6,'sawtooth',.09); nz(.4,.09,700,120,'lowpass',1,.05); },
fuseBeep(t){ tone(1500,1500,.05,'square',.09); },
tele(){ [0,.05,.1,.16].forEach((d,i)=>tone(500+i*260, 1100+i*320, .12,'triangle',.05,d)); },
whoosh(){ nz(.9,.09,300,1400,'bandpass',1.6); },
whistle(){ tone(1500,320,1.05,'sine',.05); },
plane(){ nz(2.2,.07,220,300,'lowpass',1); tone(90,80,1.6,'sawtooth',.035); },
craten(){ tone(880,1320,.16,'triangle',.07); tone(1320,1760,.14,'triangle',.05,.14); },
horn(){ [392,523,659,784].forEach((f,i)=>tone(f,f,.3,'triangle',.075,i*.11)); },
win(){ [523,659,784,1046,1318].forEach((f,i)=>tone(f,f*1.01,.34,'triangle',.08,i*.13)); },
lose(){ [500,420,340,220].forEach((f,i)=>tone(f,f*.9,.4,'sawtooth',.06,i*.16)); }
};
})();
/* ------------------------------ terrain ------------------------------ */
const TC = document.createElement('canvas'); TC.width=W; TC.height=H;
const tctx = TC.getContext('2d',{willReadFrequently:true});
let mask = new Uint8Array(W*H);
let noSpawn = [];
let theme = null;
let shapeName = 'island';
const THEMES = [
{name:'Verdant', grass:[96,182,92], grass2:[58,132,66], dirt:[132,94,58], dirt2:[96,66,42],
rock:[88,80,84], rock2:[40,38,46], sky:['#122a4d','#3f6d9e','#a8cbe8'], sun:'#ffe9b0',
haze:'#7fa8cf', water:['#2f7fb5','#123b63'], far:['#28486e','#1b3455'], star:0},
{name:'Ember', grass:[206,150,72], grass2:[164,104,48], dirt:[150,92,58], dirt2:[104,58,40],
rock:[96,72,66], rock2:[46,32,34], sky:['#2a1024','#8a3a2e','#e8a45c'], sun:'#ffd07a',
haze:'#c98b5a', water:['#3b6ea8','#123055'], far:['#5a2c33','#3a1c25'], star:0},
{name:'Glacier', grass:[222,242,255], grass2:[170,208,236], dirt:[198,208,220], dirt2:[148,160,180],
rock:[120,132,150], rock2:[62,72,92], sky:['#0d1c3a','#3a6f9e','#cfe8f7'], sun:'#ffffff',
haze:'#bcd8ee', water:['#2b8fc0','#0d3a5c'], far:['#3a5f88','#25425f'], star:0},
{name:'Cinder', grass:[124,96,96], grass2:[86,64,68], dirt:[92,74,74], dirt2:[62,48,52],
rock:[100,88,92], rock2:[42,36,44], sky:['#1a0f1e','#5c2333','#c85c3c'], sun:'#ff9a52',
haze:'#a05545', water:['#8a3a2a','#3a1210'], far:['#3d1a26','#26101a'], star:0},
{name:'Midnight', grass:[126,110,220], grass2:[86,72,168], dirt:[92,80,140], dirt2:[62,54,104],
rock:[96,92,136], rock2:[44,40,80], sky:['#050318','#1b1c4a','#4a3f8a'], sun:'#c9b8ff',
haze:'#6a5fb0', water:['#5a3fd0','#1a1046'], far:['#241d54','#170f36'], star:1},
];
function solidAt(x,y){
x|=0; y|=0;
if(y<0) return 0; if(y>=H) return 1;
if(x<0) return 0; if(x>=W) return 0;
return mask[y*W+x];
}
function carveDisc(cx,cy,r,wob,fbm){
const x0=clamp(Math.floor(cx-r-6),0,W-1), x1=clamp(Math.ceil(cx+r+6),0,W-1);
const y0=clamp(Math.floor(cy-r-6),0,H-1), y1=clamp(Math.ceil(cy+r+6),0,H-1);
for(let y=y0;y<=y1;y++){
for(let x=x0;x<=x1;x++){
const d=Math.hypot(x-cx,y-cy);
const rad = wob&&fbm ? r*(1+wob*fbm(x*0.035+y*0.021)) : r;
if(d<=rad) mask[y*W+x]=0;
}
}
}
function fillDisc(cx,cy,r){
const x0=clamp(Math.floor(cx-r),0,W-1), x1=clamp(Math.ceil(cx+r),0,W-1);
const y0=clamp(Math.floor(cy-r),0,H-1), y1=clamp(Math.ceil(cy+r),0,H-1);
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)
if(Math.hypot(x-cx,y-cy)<=r) mask[y*W+x]=1;
}
function genTerrain(shape,seed){
RNG = mulberry32(seed);
shapeName = shape;
mask.fill(0);
noSpawn=[];
const f1 = makeFbm(RNG,0.006), fd = makeFbm(RNG,1);
if(shape==='island' || shape==='two'){
const surfs = [];
if(shape==='island') surfs.push({c:W*0.5,half:W*0.46,amp:250});
else { surfs.push({c:W*0.255,half:W*0.225,amp:255}); surfs.push({c:W*0.745,half:W*0.225,amp:255}); }
for(let x=0;x<W;x++){
let y = H+400;
for(const s of surfs){
const d=Math.abs(x-s.c)/s.half;
if(d>=1) continue;
const fall=Math.pow(Math.cos(d*Math.PI*0.5),0.95);
let sy = H-30 - fall*(s.amp + 52*f1(x*1.0));
sy += 11*f1(x*6.0) + 5*f1(x*17.0);
if(sy<y) y=sy;
}
y=Math.max(60,y);
for(let yy=Math.floor(y);yy<H;yy++) if(yy>=0) mask[yy*W+x]=1;
}
// solid floating rock platforms
noSpawn=[];
for(let i=0;i<2;i++){
const cx=W*(0.24+i*0.52)+rr(70,-70), cy=WATER_Y-rr(330,230), rx=rr(115,75), ry=rr(26,15);
noSpawn.push([cx-rx-58,cx+rx+58]);
for(let x=Math.floor(cx-rx);x<=cx+rx;x++){
if(x<2||x>=W-2) continue;
const k=(x-cx)/rx;
const h=ry*(1-k*k)*(0.65+0.5*f1(x*0.05+i*3));
const top=cy-h*0.55;
for(let y=Math.floor(top);y<=cy+h;y++) if(y>2&&y<H-2) mask[y*W+x]=1;
}
}
} else { // cave
for(let y=0;y<H;y++)for(let x=0;x<W;x++) mask[y*W+x]=1;
// uneven ceiling
for(let x=0;x<W;x++){
const top = 46 + 34*f1(x*2.4) + 14*f1(x*9);
for(let y=0;y<top;y++) mask[y*W+x]=0;
}
// winding caverns
let px=W*0.5, py=200;
for(let i=0;i<26;i++){
const r=rr(96,52);
carveDisc(px,py,r,0.22,fd);
px += rr(1,-1)<0 ? rr(180,70) : -rr(180,70);
py += rr(90,-90);
px=clamp(px,110,W-110); py=clamp(py,130,WATER_Y-110);
}
// side chambers
for(let i=0;i<14;i++){
carveDisc(rr(W-90,90), rr(WATER_Y-120,120), rr(80,42), 0.3, fd);
}
// hanging stalactites / spires
for(let i=0;i<26;i++){
const x=rr(W-40,40), y=rr(WATER_Y-90,120), r=rr(16,6);
fillDisc(x,y,r); fillDisc(x,y-r*2,r*0.7);
}
// lake basin below the waterline
for(let y=Math.floor(WATER_Y-26);y<H-14;y++)for(let x=0;x<W;x++) mask[y*W+x]=0;
for(let i=0;i<22;i++){ // pillars + rock spires in the lake
const x=rr(W-60,60);
fillDisc(x, WATER_Y+rr(40,0), rr(26,10));
}
for(let y=H-14;y<H;y++)for(let x=0;x<W;x++) mask[y*W+x]=1;
}
theme = THEMES[Math.floor(RNG()*THEMES.length)];
paintRegion(0,W-1);
}
/* paint terrain texture */
function colPick(d, x,y, topY,botY, i, w){
const t=theme, n=hash2(x,y), n2=hash2(y*3+11,x*7-5);
const gr=[t.grass[0],t.grass[1],t.grass[2]], gd=[t.grass2[0],t.grass2[1],t.grass2[2]];
const dirt=[t.dirt[0],t.dirt[1],t.dirt[2]], dirt2=[t.dirt2[0],t.dirt2[1],t.dirt2[2]];
const rk=[t.rock[0],t.rock[1],t.rock[2]], rk2=[t.rock2[0],t.rock2[1],t.rock2[2]];
// distance to nearest surface (this column and neighbours)
let ed=999;
for(let k=-5;k<=5;k++){
const j=i+k; if(j<0||j>=w) continue;
const tt=topY[j], bb=botY[j];
if(tt>=0) ed=Math.min(ed,Math.abs(y-tt));
if(bb>=0) ed=Math.min(ed,Math.abs(y-bb));
}
let r,g,b;
if(y===topY[i]){
const lit=.22+n*.28;
r=lerp(gr[0],255,lit); g=lerp(gr[1],255,lit); b=lerp(gr[2],255,lit);
} else if(ed<3.5){
// grass fading into dirt
const t0=clamp((ed-0.5)/3.0,0,1);
const gn=(n-0.5)*22;
r=lerp(gd[0]+gn, dirt[0], t0); g=lerp(gd[1]+gn, dirt[1], t0); b=lerp(gd[2]+gn, dirt[2], t0);
} else if(ed<13){
const t0=clamp((ed-3.5)/9.5,0,1);
const gn=(n2-0.5)*26;
r=lerp(dirt[0]+gn, rk[0], t0); g=lerp(dirt[1]+gn, rk[1], t0); b=lerp(dirt[2]+gn, rk[2], t0);
} else {
const dep = clamp(y/H,0,1);
const strata = Math.sin(y*0.075+x*0.006)*7 + Math.sin(y*0.23-x*0.01)*4;
const gn=(n-0.5)*16 + strata + (n2>0.94?26:0);
r=lerp(rk[0],rk2[0],dep*0.9)+gn; g=lerp(rk[1],rk2[1],dep*0.9)+gn; b=lerp(rk[2],rk2[2],dep*0.9)+gn;
}
return [clamp(r,0,255),clamp(g,0,255),clamp(b,0,255)];
}
function paintRegion(x0,x1){
x0=clamp(x0|0,0,W-1); x1=clamp(x1|0,0,W-1);
const w=x1-x0+1;
const img=tctx.getImageData(x0,0,w,H); const d=img.data;
const topY=new Int16Array(w).fill(-1), botY=new Int16Array(w).fill(-1);
for(let i=0;i<w;i++){
const x=x0+i, base=x;
for(let y=0;y<H;y++) if(mask[y*W+x]){topY[i]=y;break}
for(let y=H-1;y>=0;y--) if(mask[y*W+x]){botY[i]=y;break}
}
for(let i=0;i<w;i++){
const x=x0+i;
for(let y=0;y<H;y++){
const p=(y*w+i)*4;
if(!mask[y*W+x]){ d[p+3]=0; continue; }
const c=colPick(0,x,y,topY,botY,i,w);
d[p]=c[0]; d[p+1]=c[1]; d[p+2]=c[2]; d[p+3]=255;
}
}
tctx.putImageData(img,x0,0);
}
function paintAround(x0,x1){ paintRegion(x0-3,x1+3); }
/* carve + physical response: returns freed area */
function destroyTerrain(cx,cy,r){
carveDisc(cx,cy,r,0.14,makeFbm(mulberry32((cx*31+cy*17)|0),0.05));
paintAround(Math.floor(cx-r)-2, Math.ceil(cx+r)+2);
return Math.PI*r*r;
}
/* ------------------------------ particles ------------------------------ */
const GRAV = 0.42, WIND_P = 0.034;
const parts = [];
const chunks = [];
const texts = [];
function spawnPart(o){
if(parts.length>1500) parts.splice(0,60);
o.life=o.life||1; o.max=o.max||o.life; o.age=0;
o.vx=o.vx||0; o.vy=o.vy||0; o.g=o.g===undefined?0.05:o.g;
o.drag=o.drag===undefined?0.97:o.drag; o.rot=o.rot||0; o.vr=o.vr||0;
parts.push(o); return o;
}
function dust(x,y,n,col,pw){
for(let i=0;i<n;i++) spawnPart({type:'dust',x:x+rr(8,-8),y:y+rr(6,-6),
vx:rr(pw||3,-(pw||3)),vy:rr(-0.4,-3.2),size:rr(9,3),size2:0,g:0.06,drag:0.93,
life:rr(1.1,0.5),col:col||'rgba(196,176,148,'});
}
function smoke(x,y,n,col,sc){
for(let i=0;i<n;i++) spawnPart({type:'smoke',x:x+rr(10,-10),y:y+rr(10,-10),
vx:rr(1.6,-1.6)*(sc||1),vy:rr(0.4,-2.4)*(sc||1),size:rr(14,6)*(sc||1),size2:rr(46,22)*(sc||1),
g:-0.012,drag:0.94,life:rr(1.9,1.0),col:col||'rgba(120,116,124,'});
}
function sparks(x,y,n,col,pw){
for(let i=0;i<n;i++){const a=rr(TAU);
spawnPart({type:'spark',x,y,vx:Math.cos(a)*rr(pw||8,2),vy:Math.sin(a)*rr(pw||8,2),
size:rr(3,1.5),g:0.22,drag:0.94,life:rr(0.7,0.25),col:col||'rgba(255,206,120,'});}
}
function bubbles(x,y,n,sc){
for(let i=0;i<n;i++) spawnPart({type:'bubble',x:x+rr(8,-8),y:y+rr(8,-8),
vx:rr(.5,-.5),vy:rr(-1.2,-.2),size:rr(5,2)*(sc||1),g:-0.03,drag:0.97,
life:rr(1.4,0.6),col:'rgba(200,240,255,'});
}
function ring(x,y,r0,col,w,lw){
spawnPart({type:'ring',x,y,size:r0,size2:(w||r0*2.4),life:l0(lw||0.42),g:0,drag:1,col:col||'rgba(255,240,200,'});
}
function l0(v){return v}
function flash(x,y,r,col){
spawnPart({type:'flash',x,y,size:r,size2:r*0.5,life:0.22,g:0,drag:1,col:col||'rgba(255,248,220,'});
}
function goo(x,y,n,col){
for(let i=0;i<n;i++){const a=rr(TAU);
spawnPart({type:'goo',x,y,vx:Math.cos(a)*rr(5,1),vy:Math.sin(a)*rr(5,1)-1,
size:rr(4,2),g:0.3,drag:0.96,life:rr(0.9,0.4),col:col||'rgba(150,230,140,'});}
}
function textPop(x,y,txt,col,size){
texts.push({x,y,txt,col:col||'#fff',size:size||18,age:0,life:1.25,vy:-1.15,vx:rr(0.5,-0.5)});
if(texts.length>26) texts.shift();
}
function spawnChunks(x,y,r,count){
for(let i=0;i<count;i++){
const a=rr(TAU), d=Math.sqrt(RNG())*r*0.92;
const cx=x+Math.cos(a)*d, cy=y+Math.sin(a)*d;
if(!solidAt(cx,cy)) continue;
const sz=rr(r*0.22,3)+2;
const c=theme;
const shade=hash2(cx|0,cy|0);
const col=`rgb(${fmt(lerp(c.dirt[0],c.rock[0],shade))},${fmt(lerp(c.dirt[1],c.rock[1],shade))},${fmt(lerp(c.dirt[2],c.rock[2],shade))})`;
const a2=rr(TAU), pw=rr(6,1.2);
if(chunks.length>260) chunks.splice(0,20);
chunks.push({x:cx,y:cy,vx:Math.cos(a2)*pw+(x<cx?-1:1)*rr(3,0.5),vy:Math.sin(a2)*pw-rr(4,0.5),
r:sz,rot:rr(TAU),vr:rr(.22,-.22),col,still:false,life:0,fade:1,rest:rr(7,5)});
}
}
function updateParticles(dt,dtS){
for(let i=parts.length-1;i>=0;i--){
const p=parts[i]; p.age+=dtS;
if(p.age>=p.life){ parts.splice(i,1); continue; }
p.vy+=p.g*dt; p.vx*=Math.pow(p.drag,dt); p.vy*=Math.pow(p.drag,dt);
p.x+=p.vx*dt; p.y+=p.vy*dt; p.rot+=p.vr*dt;
if(p.type==='dust'||p.type==='bubble') p.vx += wind*0.02*dt;
}
for(let i=chunks.length-1;i>=0;i--){
const c=chunks[i]; c.life+=dtS;
if(!c.still){
c.vy+=GRAV*dt; c.vx+=wind*0.012*dt;
c.vx*=Math.pow(0.995,dt);
let steps=Math.max(1,Math.ceil(Math.hypot(c.vx,c.vy)*dt/2.5));
for(let s=0;s<steps;s++){
const nx=c.x+c.vx*dt/steps, ny=c.y+c.vy*dt/steps;
const hitX=solidAt(nx,c.y)||nx<0||nx>=W, hitY=solidAt(c.x,ny)||ny>=H;
if(hitX) c.vx*=-0.24; else c.x=nx;
if(hitY){ c.vy*=-0.2; c.vx*=0.7; if(Math.abs(c.vy)<0.6) c.vy=0; } else c.y=ny;
c.rot+=c.vr*dt;
if((hitX&&hitY)||(hitY&&Math.abs(c.vx)<0.22&&Math.abs(c.vy)<0.5)){
if(c.y<H-2 && solidAt(c.x,c.y+1) && Math.abs(c.vx)<0.3){ c.still=true; c.y-=c.r*0.3; }
}
}
if(c.y>WATER_Y+4){ chunks.splice(i,1); bubbles(c.x,c.y,2); continue; }
} else {
if(!solidAt(c.x,c.y+2)&&c.y<H-2){ c.still=false; } // ground vanished -> fall
}
if(c.life>c.rest) c.fade-=dtS*0.55;
if(c.fade<=0) chunks.splice(i,1);
}
for(let i=texts.length-1;i>=0;i--){
const t=texts[i]; t.age+=dtS;
t.y+=t.vy*dt; t.x+=t.vx*dt; t.vy*=Math.pow(0.96,dt);
if(t.age>=t.life) texts.splice(i,1);
}
}
/* ------------------------------ camera ------------------------------ */
const fit = {v:1};
const cam = {x:W*0.5,y:H*0.5,z:1,scale:1,shake:0,sx:0,sy:0,flash:0,frz:0};
function camFocus(fx,fy,zoomMul,dt,speed){
const k=Math.min(1,dt*(speed||3.4));
cam.x=lerp(cam.x,fx,k); cam.y=lerp(cam.y,fy,k);
cam.z=lerp(cam.z,zoomMul,k*0.8);
}
function updateCameraShake(dt){
cam.shake*=Math.pow(0.88,dt);
if(cam.shake<0.05) cam.shake=0;
cam.sx=rr(cam.shake,-cam.shake); cam.sy=rr(cam.shake,-cam.shake);
cam.flash*=Math.pow(0.9,dt);
if(cam.frz>0) cam.frz-=dt;
}
function screenToWorld(mx,my){
return {x:(mx-cw/2)/(fit.v*cam.z)+cam.x, y:(my-ch/2)/(fit.v*cam.z)+cam.y};
}
/* ------------------------------ units ------------------------------ */
const TEAM_DEF = [
{name:'Alpha', c:'#ff6b6b', c2:'#ffd3d3'},
{name:'Beta', c:'#4dabf7', c2:'#cfe8ff'},
{name:'Gamma', c:'#7bdc8b', c2:'#d8ffe0'},
{name:'Delta', c:'#e599f7', c2:'#f6ddff'},
];
let worms = [], teams = [];
function makeTeams(cfg){
teams = []; worms = [];
cfg.forEach((c,i)=>{
if(!c.on) return;
const T = {idx:teams.length, name:TEAM_DEF[i].name, cpu:c.cpu, color:TEAM_DEF[i].c,
color2:TEAM_DEF[i].c2, worms:[], ammo:{}, active:0, alive:true, dealt:0, taken:0};
// weapon ammo per team
WEAPONS.forEach(w=>{ T.ammo[w.id] = w.ammo===Infinity?Infinity:w.ammo; });
teams.push(T);
});
return teams;
}
function teamBands(n){
if(n<=1) return [[0.05,0.95]];
if(n===2) return [[0.06,0.44],[0.56,0.94]];
if(n===3) return [[0.03,0.33],[0.35,0.65],[0.67,0.97]];
return [[0.02,0.26],[0.26,0.5],[0.5,0.74],[0.74,0.98]];
}
function surfaceY(x){
// first solid pixel with at least 30px of clear air above it (works for caves too)
let air=0;
for(let y=26;y<WATER_Y-18;y++){
if(mask[y*W+x]){ if(air>=30) return y; air=0; }
else air++;
}
return -1;
}
function findTeamSpots(b,count){
const cand=[];
for(let x=Math.floor(W*b[0]);x<Math.floor(W*b[1]);x+=5){
if(noSpawn.some(r=>x>r[0]&&x<r[1])) continue; // skip floating platforms
const y=surfaceY(x);
if(y<0||y>WATER_Y-46) continue; // keep well clear of the waterline
let clear=true;
for(let k=-12;k<=12;k+=6) if(solidAt(x+k,y-30)) clear=false;
for(let k=-9;k<=9;k+=9) if(!solidAt(x+k,y+7)) clear=false;
if(!clear) continue;
const l7=surfaceY(clamp(x-7,1,W-2)), r7=surfaceY(clamp(x+7,1,W-2));
if(l7<0||r7<0||Math.abs(l7-y)>18||Math.abs(r7-y)>18) continue; // avoid sheer cliffs
let flat=0;
for(let dx=-14;dx<=14;dx+=7){
const yy=surfaceY(clamp(x+dx,1,W-2));
flat += yy<0? 60 : Math.abs(yy-y);
}
cand.push({x,y,score:flat + Math.abs(y-(WATER_Y-150))*0.3});
}
cand.sort((a,b2)=>a.score-b2.score);
const out=[];
if(!cand.length) return out;
out.push(cand[0]);
while(out.length<count){
let bestI=-1,best=-1e9;
for(let i=0;i<cand.length;i++){
const c=cand[i];
if(out.some(o=>Math.abs(o.x-c.x)<40)) continue;
let dmin=1e9; out.forEach(o=>{ dmin=Math.min(dmin,Math.abs(o.x-c.x)); });
const sc=Math.min(dmin,240)-c.score*0.6;
if(sc>best){ best=sc; bestI=i; }
}
if(bestI<0) break;
out.push(cand[bestI]);
}
return out;
}
function makeWorms(){
worms=[];
const bands=teamBands(teams.length);
teams.forEach((T,ti)=>{
let spots=findTeamSpots(bands[ti]||[0.05,0.95],4);
if(spots.length<4){ // wide search so nobody ever spawns over the void
const wide=findTeamSpots([0.03,0.97],24);
wide.forEach(s=>{ if(spots.length<4 && !spots.some(o=>Math.abs(o.x-s.x)<44)) spots.push(s); });
}
for(let i=0;i<4;i++){
const sp=spots[i]||spots[spots.length-1]||{x:W*0.5,y:WATER_Y-150};
const w = {
id:worms.length, team:T, x:clamp(sp.x+rr(6,-6),20,W-20),
y:sp.y-16-i*10,
vx:0,vy:0,r:9,hp:100,maxhp:100,alive:true,facing:1,onGround:false,
anim:0,walkT:0,blink:rr(4,1),flash:0,onLand:0,inWater:false,waterT:0,sink:0,
dmg:0,name:'W'+(i+1),idx:i,walkPhase:rr(TAU),squish:0,jet:0,fuel:100,cry:0
};
// make sure we are not spawned inside rock
if(blockedAt(w.x,w.y,w.r)){
for(let d=1;d<60;d++){
if(!blockedAt(w.x,w.y-d,w.r)){ w.y-=d; break; }
if(!blockedAt(w.x,w.y+d,w.r)){ w.y+=d; break; }
}
}
// drop onto ground
let gy=w.y;
for(let k=0;k<H;k++){ if(solidAt(w.x,gy)) break; gy+=2; if(gy>WATER_Y-20) break; }
w.y=Math.min(gy-1,w.y);
T.worms.push(w); worms.push(w);
}
});
}
function findSpawnSpots(n){
// scan columns for land above water in each horizontal band
const spots=[];
const bands=[];
if(n===2) bands.push([0.08,0.42],[0.58,0.92]);
else if(n===3) bands.push([0.04,0.32],[0.36,0.64],[0.68,0.96]);
else bands.push([0.03,0.25],[0.27,0.49],[0.51,0.73],[0.75,0.97]);
bands.forEach(b=>{
let best=null;
for(let x=Math.floor(W*b[0]);x<Math.floor(W*b[1]);x+=4){
let y=-1;
for(let yy=40;yy<WATER_Y-14;yy++){ if(mask[yy*W+x]){ y=yy; break } }
if(y>0){
// prefer flat ground with headroom and away from water
let flat=0;
let clear=true;
for(let k=-14;k<=14;k+=7) if(solidAt(x+k,y-24)) clear=false;
if(!clear) continue;
for(let dx=-12;dx<=12;dx+=6){ let yy=-1;
for(let t=40;t<WATER_Y-14;t++){ if(mask[t*W+clamp(x+dx,1,W-2)]){yy=t;break} }
flat+=Math.abs(yy-y);
}
const score = flat + Math.abs(y-(WATER_Y-140))*0.35;
if(!best||score<best.score) best={x,y,score};
}
}
if(best) spots.push(best);
});
while(spots.length<n) spots.push({x:W*0.2+RNG()*W*0.6,y:WATER_Y-160});
return spots;
}
/* worm physics ------------------------------------------------------- */
function blockedAt(x,y,r){
if(y+r>=H) return true;
if(x-r<0||x+r>=W) return true;
const r2=r*r;
for(let oy=-r;oy<=r;oy+=2.5){
for(let ox=-r;ox<=r;ox+=2.5){
if(ox*ox+oy*oy>r2) continue;
if(solidAt(x+ox,y+oy)) return true;
}
}
return false;
}
function groundUnder(x,y,r){
if(y+r>=H) return true;
if(solidAt(x,y+r+0.5)) return true;
return blockedAt(x,y+1.2,r);
}
function stepWorm(w,dt,dtS){
if(!w.alive){ w.vy+=GRAV*dt*0.6; w.y+=w.vy*dt; w.vx*=Math.pow(0.97,dt); w.x+=w.vx*dt;
if(w.y>WATER_Y+10) w.y+=0.2*dt; return; }
// water buoyancy / drowning
w.inWater = w.y > WATER_Y-4;
if(w.inWater){
w.waterT+=dtS; w.vy = lerp(w.vy,-0.5,Math.min(1,dtS*2.6));
w.vx*=Math.pow(0.9,dt);
w.hp-=15*dtS;
if(Math.random()<0.35*dtS) bubbles(w.x,w.y-4,1);
if(w.waterT-(w.lastDrown||0)>1.25){ w.lastDrown=w.waterT; Sound.drown(); }
if(w.hp<=0 && w.alive) killWorm(w,'drowned');
w.squish=bob(w.waterT);
} else w.waterT=0;
// vertical
w.vy+=GRAV*dt;
w.vy=clamp(w.vy,-22,26);
let steps=Math.max(1,Math.ceil(Math.abs(w.vy*dt)/2.4));
for(let s=0;s<steps;s++){
const ny=w.y+w.vy*dt/steps;
if(blockedAt(w.x,ny,w.r)){
if(w.vy>0){
if(!w.onGround){
const fallSpd=w.vy;
spawnLandDust(w);
if(fallSpd>8.6){ const d=(fallSpd-8.6)*7.4; hurt(w,d,null,true); }
else if(fallSpd>3) Sound.land();
}
w.squish=Math.min(1,w.vy*0.06);
// settle onto surface
let k=0; while(blockedAt(w.x,w.y,w.r)&&k<14){ w.y-=0.6; k++; }
}
w.vy=0; break;
}
w.y=ny;
}
w.onGround = groundUnder(w.x,w.y,w.r);
// horizontal
if(Math.abs(w.vx)>0.001){
let steps=Math.max(1,Math.ceil(Math.abs(w.vx*dt)/1.6));
for(let s=0;s<steps;s++){
const nx=w.x+w.vx*dt/steps;
if(blockedAt(nx,w.y,w.r)){
let climbed=false;
for(let up=1;up<=13;up++){
if(!blockedAt(nx,w.y-up,w.r)&&!blockedAt(w.x,w.y-up,w.r)){ w.y-=up; w.x=nx; climbed=true; break; }
}
if(!climbed){ w.vx=0; break; }
} else w.x=nx;
}
if(w.onGround) w.walkT+=dtS;
}
if(!w.onGround){ w.vx+=wind*0.022*dt; w.vx*=Math.pow(0.985,dt); }
w.x=clamp(w.x,w.r,W-w.r);
// animation decay
w.squish=lerp(w.squish,0,Math.min(1,dtS*7));
w.blink-=dtS; if(w.blink<-0.14) w.blink=rr(5,2);
w.flash=Math.max(0,w.flash-dtS*3.2);
w.anim = w.onGround&&Math.abs(w.vx)>0.15 ? w.anim+dtS*13 : lerp(w.anim,0,Math.min(1,dtS*8));
w.cry=Math.max(0,w.cry-dtS);
}
function bob(t){ return Math.sin(t*3.4)*0.5+0.5 }
function spawnLandDust(w){
const c=theme;
dust(w.x,w.y+w.r*0.7,8,`rgba(${c.dirt[0]},${c.dirt[1]},${c.dirt[2]},`,2.4);
Sound.crumble();
}
function hurt(w,amount,byTeam,silent){
if(!w.alive) return;
w.hp-=amount; w.flash=1;
w.team.taken+=amount;
if(!silent){ textPop(w.x, w.y-22, '-'+fmt(amount), '#ff8a8a', 17+Math.min(10,amount*0.16)); Sound.hurt(); }
else textPop(w.x,w.y-22,'-'+fmt(amount),'#ffb27a',14);
goo(w.x,w.y,Math.min(14,3+amount*0.3),`rgba(${hex2rgb(w.team.color)},`);
if(w.hp<=0){ w.hp=0; killWorm(w,'damage'); }
}
function killWorm(w,how){
if(!w.alive) return;
w.alive=false; w.vy=-3.5; w.vx=rr(2,-2);
goo(w.x,w.y,26,`rgba(${hex2rgb(w.team.color)},`);
goo(w.x,w.y,14,'rgba(240,250,255,');
textPop(w.x,w.y-30, how==='drowned'?'GLUG GLUG…':'K.O.!', w.team.color, 22);
Sound.death();
addToast(`${w.team.name} worm ${w.idx+1} is down!`, w.team.color);
if(w.team.worms.every(x=>!x.alive)){
w.team.alive=false;
if(how!=='drowned') addToast(`${w.team.name} is eliminated!`, w.team.color);
}
checkGameOver();
}
function hex2rgb(h){
const v=h.replace('#','');
return [parseInt(v.substr(0,2),16),parseInt(v.substr(2,2),16),parseInt(v.substr(4,2),16)].join(',');
}
/* ------------------------------ weapons ------------------------------ */
const ICON = {
rocket:'<path d="M3.6 11.4C7.6 6.6 12.6 3.9 18 3.4c.5 5.4-2.2 10.4-7 14.4l-3.6-2.6L3.6 11.4z" fill="#ff9c5b"/><path d="M3.6 15.6l4.8 4.8-6.2 1.4 1.4-6.2z" fill="#ffd06b"/><circle cx="15.2" cy="8.9" r="2.3" fill="#22304d"/>',
grenade:'<circle cx="11" cy="14" r="7" fill="#6f8f52"/><circle cx="8.6" cy="11.6" r="2.2" fill="#93b96b"/><path d="M11 7.4V4.6h3.4" stroke="#c9c9c9" stroke-width="1.6" fill="none"/><path d="M14.4 4.6l2.2-1.6" stroke="#ffd166" stroke-width="1.6"/><path d="M11.6 4.2h3.4" stroke="#9aa5b1" stroke-width="1.4"/>',
holy:'<circle cx="12" cy="14.5" r="6.4" fill="#f5e9c8"/><path d="M12 10.6v7.8M8.6 13.4h6.8" stroke="#c89b3c" stroke-width="1.8"/><ellipse cx="12" cy="5.4" rx="5" ry="1.7" fill="none" stroke="#ffe066" stroke-width="1.6"/><path d="M12 8.2v1.6" stroke="#ffe066" stroke-width="1.4"/>',
dynamite:'<rect x="6.6" y="7" width="3.4" height="13" rx="1.2" fill="#d64545"/><rect x="10.4" y="7" width="3.4" height="13" rx="1.2" fill="#e05c5c"/><rect x="14.2" y="7" width="3.4" height="13" rx="1.2" fill="#c73e3e"/><rect x="6" y="10" width="12" height="1.6" fill="#f0d9a8"/><path d="M12 6.6c0-2 2.4-1.6 2.4-3.4" stroke="#e9c46a" stroke-width="1.3" fill="none"/><circle cx="14.6" cy="2.8" r="1.2" fill="#ffd166"/>',
shotgun:'<rect x="2.6" y="10.4" width="13" height="3.2" rx="1" fill="#8d99ae"/><rect x="2.6" y="13.4" width="8" height="3.6" rx="1.2" fill="#6b4b2a"/><rect x="15" y="10.8" width="6.4" height="2.4" rx="1" fill="#b9c4d2"/><circle cx="7.6" cy="16.6" r="2.4" fill="none" stroke="#e0a95c" stroke-width="1.4"/>',
gun:'<rect x="3" y="9" width="12" height="5" rx="2" fill="#7d8896"/><circle cx="17.4" cy="11.5" r="3" fill="#9aa5b1"/><rect x="18.6" y="9.6" width="3.2" height="1.6" rx="0.8" fill="#cbd5e1"/><rect x="3" y="14.4" width="4" height="6" rx="1.4" fill="#5b6470"/><rect x="8" y="14.4" width="3" height="4" rx="1" fill="#39414c"/>',
plane:'<path d="M2 13.4l8.6-2.2 4.6-6.6 2.4.9-2.2 6 4.4 1.2-3.2 3.4-6.4-1.2-2.4 3.4-2-4.9z" fill="#cbd5e1"/><path d="M2 13.4l8.6-2.2" stroke="#94a3b8" stroke-width="0.8"/>',
tele:'<circle cx="12" cy="12" r="8.6" fill="none" stroke="none"/><path d="M8.4 16.8L16.4 8" stroke="#7fd0ff" stroke-width="2.6" stroke-linecap="round"/><path d="M16.4 8l-4.6.6 4 4z" fill="#7fd0ff"/><circle cx="6.6" cy="6.6" r="1.7" fill="#c58cff"/><circle cx="17.6" cy="17.6" r="1.7" fill="#c58cff"/>',
jet:'<rect x="7.4" y="4.6" width="9.2" height="10.4" rx="3" fill="#94a3b8"/><rect x="9.4" y="6.6" width="5.2" height="6.4" rx="2" fill="#cbd5e1"/><path d="M12 16.4c-2.4 0-3.2 2-3.2 4.4 1.6-.8 2.4-2.4 3.2-4.4z" fill="#ff9c5b"/><path d="M12 16.4c2.4 0 3.2 2 3.2 4.4-1.6-.8-2.4-2.4-3.2-4.4z" fill="#ffd06b"/>',
health:'<rect x="3.4" y="6.4" width="17.2" height="13.2" rx="3" fill="#f2f4f8"/><rect x="3.4" y="6.4" width="17.2" height="4.4" rx="2" fill="#e07a7a"/><path d="M12 10.6v6.6M8.7 13.9h6.6" stroke="#e04b4b" stroke-width="2.4" stroke-linecap="round"/>',
};
const WEAPONS = [
{id:'bazooka', name:'Bazooka', desc:'contact rocket · infinite ammo · medium crater', kind:'indirect', ammo:Infinity, radius:44, dmg:46, icon:'rocket', wind:1, kb:1},
{id:'grenade', name:'Grenade', desc:'bouncy · 3s fuse · nice blast', kind:'indirect', ammo:5, radius:50, dmg:50, icon:'grenade', fuse:3.0, bouncy:true, wind:1, kb:1.1},
{id:'holy', name:'Holy Hand Grenade', desc:'map-altering halo · 3s fuse', kind:'indirect', ammo:2, radius:88, dmg:74, icon:'holy', fuse:3.2, bouncy:true, wind:0.85, kb:1.4},
{id:'dynamite', name:'Dynamite', desc:'drop it & RUN · 4s fuse · big crater', kind:'placed', ammo:3, radius:76, dmg:64, icon:'dynamite', fuse:4.0, wind:0, kb:1.3},
{id:'shotgun', name:'Shotgun', desc:'instant hitscan · 4 pellets · 2 shots', kind:'direct', ammo:2, radius:12, dmg:11, icon:'shotgun', shots:4, wind:0, kb:0.7},
{id:'minigun', name:'Minigun', desc:'instant stream of 12 rounds', kind:'direct', ammo:1, radius:10, dmg:6, icon:'gun', shots:12, wind:0, kb:0.4},
{id:'airstrike', name:'Airstrike', desc:'click a spot · carpet bombs from the sky', kind:'support', ammo:2, radius:44, dmg:38, icon:'plane', wind:0, kb:1},
{id:'medkit', name:'Medkit Drop', desc:'click to heal any worm in the blast (+30)', kind:'support', ammo:2, radius:0, dmg:0, icon:'health', wind:0, kb:0},
{id:'teleport', name:'Teleporter', desc:'click to warp across the map · ends turn', kind:'utility', ammo:2, icon:'tele', wind:0, kb:0},
{id:'jetpack', name:'Jetpack', desc:'hold mouse for thrust · fuel-limited flight', kind:'utility', ammo:2, icon:'jet', wind:0, kb:0},
];
const WMAP = {}; WEAPONS.forEach(w=>WMAP[w.id]=w);
/* ------------------------------ game state ------------------------------ */
const G = {
phase:'title', turnTeam:0, active:null, time:45, shotFired:false, round:1,
wind:0, charging:false, power:0, angle:-0.6, mouse:{x:W*0.5,y:H*0.4},
hitstop:0, over:false, winner:null, ai:null, aiQueue:[], waveT:0, overT:0,
sudden:false, waterRise:0, turnCount:0, pendingEnd:0, aimValid:true, matchId:0
};
const TURN_TIME = 45;
/* ------------------------------ projectiles ------------------------------ */
const projs = [];
function makeProj(o){ projs.push(Object.assign({
kind:'rocket', x:0,y:0,vx:0,vy:0,r:4,team:null,weapon:null,dmg:0,radius:20,
fuse:0, bouncy:false, dead:false, age:0, spin:0, spinV:0, trail:0, wind:1, spin2:0
},o)); return projs[projs.length-1]; }
function terrainNormal(x,y){
const e=2.2;
const dx = (solidAt(x+e,y)?1:0)-(solidAt(x-e,y)?1:0);
const dy = (solidAt(x,y+e)?1:0)-(solidAt(x,y-e)?1:0);
const n=Math.hypot(dx,dy)||1; return {x:dx/n,y:dy/n};
}
function updateProjectiles(dt,dtS){
for(let i=projs.length-1;i>=0;i--){
const p=projs[i];
if(p.dead){ projs.splice(i,1); continue; }
p.age+=dtS; p.spin+=p.spinV*dt;
if(p.kind==='dyn'){
p.vy+=GRAV*dt; p.vy=clamp(p.vy,-22,22);
const steps=Math.max(1,Math.ceil(Math.abs(p.vy*dt)/2.4));
for(let s=0;s<steps;s++){
const ny=p.y+p.vy*dt/steps;
if(blockedAt(p.x,ny,p.r)){ p.vy=0; break; }
p.y=ny;
}
if(p.y>WATER_Y-2){ p.dead=true; Sound.splash(); wave(); if(G.phase==='player') G.dynExploded=true; continue; }
p.fuse-=dtS; if(p.fuse<0) p.fuse=0;
if(p.fuse<0.9 && Math.floor(p.fuse*4)!==Math.floor((p.fuse+dtS)*4)) Sound.fuseBeep(p.fuse);
if(p.fuse<=0){ p.dead=true; explode(p.x,p.y,p.radius,p.dmg,p.team); if(G.phase==='player') G.dynExploded=true; }
continue;
}
if(p.kind==='crate'){
p.vy+=GRAV*0.85*dt;
p.y+=p.vy*dt; p.x+=p.vx*dt;
if(p.chute) p.vy=lerp(p.vy,3.2,Math.min(1,dtS*1.6));
if(solidAt(p.x,p.y+8)||p.y>WATER_Y-10){
p.dead=true; Sound.crumble();
dust(p.x,p.y+8,14,`rgba(${theme.dirt.join(',')},`,3);
flash(p.x,p.y,60,'rgba(255,240,200,');
Sound.craten(); textPop(p.x,p.y-30,'SUPPLIES!','#8cf7c5',20);
worms.forEach(w=>{ if(w.alive&&dist(w.x,w.y,p.x,p.y)<95){
const before=w.hp; w.hp=Math.min(w.maxhp,w.hp+30);
if(w.hp>before){ Sound.heal(); textPop(w.x,w.y-26,'+30','#8cf7c5',18); } } });
for(let k=0;k<24;k++) spawnPart({type:'star',x:p.x+rr(20,-20),y:p.y+rr(20,-20),
vx:rr(1,-1),vy:rr(-1,-3),size:rr(5,2),g:-0.01,life:rr(1.2,.6),col:'rgba(140,247,197,'});
}
continue;
}
if(p.kind==='plane'){
p.x+=p.vx*dt; p.y=p.dropY;
if(p.dropX!==undefined){
if(!p.dropping && ((p.vx>0&&p.x>p.dropX-p.spread*0.5)||(p.vx<0&&p.x<p.dropX+p.spread*0.5)))
{ p.dropping=true; p.dt=0; }
if(p.dropping && p.bombsLeft>0){
p.dt-=dtS;
if(p.dt<=0){ p.dt=0.085; p.bombsLeft--; dropBombs(p,1); }
}
}
if(p.x<-140||p.x>W+140) p.dead=true;
smoke(p.x-p.vx*3,p.y,1,'rgba(220,225,235,',0.5);
continue;
}
if(p.kind==='bomb'){
p.vy+=GRAV*1.15*dt; p.vy=clamp(p.vy,-24,26);
p.x+=p.vx*dt; p.y+=p.vy*dt;
p.trail-=dtS;
if(p.trail<0){ p.trail=0.05; smoke(p.x,p.y,1,'rgba(200,200,210,',0.5); }
if(p.y>WATER_Y){ p.dead=true; Sound.splash(); wave(); continue; }
if(solidAt(p.x,p.y)){ p.dead=true; explode(p.x,p.y-p.r,p.radius,p.dmg,p.team); }
continue;
}
/* --- ballistic projectiles --- */
p.vy+=GRAV*dt;
p.vx+=wind*WIND_P*p.wind*dt;
const spd=Math.hypot(p.vx,p.vy);
const steps=Math.max(1,Math.ceil(spd*dt/3.2));
let exploded=false;
for(let s=0;s<steps&&!exploded;s++){
const px=p.x, py=p.y;
const nx=p.x+p.vx*dt/steps, ny=p.y+p.vy*dt/steps;
if(nx<-150||nx>W+150||ny<-900){ p.dead=true; exploded=true; break; } // left the battlefield
if(ny>WATER_Y){ p.x=nx; p.y=WATER_Y; p.dead=true; Sound.splash();
for(let k=0;k<16;k++) spawnPart({type:'drop',x:p.x+rr(14,-14),y:WATER_Y,vx:rr(3,-3),vy:rr(-1,-7),size:rr(4,2),g:0.3,life:rr(.7,.3),col:'rgba(200,235,255,'});
wave(); exploded=true; break; }
if(p.hitsWorms!==false){
for(const w of worms){
if(!w.alive) continue;
if(dist(nx,ny,w.x,w.y)<w.r+2){
if(p.bouncy){ // bounce off worm
const a=Math.atan2(p.y-w.y,p.x-w.x), sp2=Math.hypot(p.vx,p.vy)*0.5;
p.vx=Math.cos(a)*sp2; p.vy=Math.sin(a)*sp2-1; break;
}
explode(nx,ny,p.radius,p.dmg,p.team); p.dead=true; exploded=true; break;
}
}
if(exploded) break;
}
if(solidAt(nx,ny)){
if(p.bouncy && p.age < p.fuse-0.05){
const n=terrainNormal(nx,ny);
const d=p.vx*n.x+p.vy*n.y;
p.vx=(p.vx-2*d*n.x)*0.52; p.vy=(p.vy-2*d*n.y)*0.52;
p.x=px; p.y=py;
if(Math.hypot(p.vx,p.vy)<1.1){ p.vx*=0.4; p.vy*=0.4; }
p.spinV=clamp(p.vx*0.12,-0.4,0.4);
Sound.tick();
} else {
explode(px+(nx-px)*0.5, py+(ny-py)*0.5, p.radius, p.dmg, p.team);
p.dead=true; exploded=true; break;
}
} else { p.x=nx; p.y=ny; }
}
if(p.dead) continue;
p.spinV=clamp(p.spinV+(p.vx*0.0006),-0.5,0.5);
// trails
p.trail-=dtS;
if(p.trail<=0){
p.trail= p.weapon&&p.weapon.id==='holy'?0.02:0.035;
if(p.bouncy) smoke(p.x,p.y,1,'rgba(210,210,220,',0.35);
else { spawnPart({type:'trail',x:p.x,y:p.y,vx:0,vy:0,size:p.r*1.6,size2:1,g:-0.01,drag:1,life:0.4,col:'rgba(255,220,180,'});
smoke(p.x,p.y,1,'rgba(190,190,200,',0.35); }
}
if(p.fuse>0 && p.age>=p.fuse){ explode(p.x,p.y,p.radius,p.dmg,p.team); p.dead=true; }
if(p.age>9){ p.dead=true; }
}
}
function dropBombs(plane,count){
const n=count||1;
for(let i=0;i<n;i++){
makeProj({kind:'bomb',x:plane.x+rr(9,-9),y:plane.dropY+6,vx:plane.vx*0.75,vy:2+Math.random()*2,r:4,
team:plane.team,dmg:plane.dmg,radius:plane.radius,trail:0});
Sound.whistle();
}
}
function rng(){ return Math.random(); }
/* ------------------------------ explosions ------------------------------ */
function countSolid(x,y,r){
let n=0; const x0=clamp(Math.floor(x-r),0,W-1),x1=clamp(Math.ceil(x+r),0,W-1);
const y0=clamp(Math.floor(y-r),0,H-1),y1=clamp(Math.ceil(y+r),0,H-1);
for(let yy=y0;yy<=y1;yy+=2)for(let xx=x0;xx<=x1;xx+=2)
if(mask[yy*W+xx]&&Math.hypot(xx-x,yy-y)<=r) n++;
return n*4;
}
function explode(x,y,radius,dmg,team,opt){
opt=opt||{};
const crater = radius*(opt.craterMul||0.86);
const before = countSolid(x,y,crater);
destroyTerrain(x,y,crater);
const dn = clamp(Math.round(before/700),5,26);
spawnChunks(x,y,crater,dn);
// juice
flash(x,y,crater*1.5);
ring(x,y,crater*0.3,'rgba(255,238,180,');
sparks(x,y,Math.round(14+radius*0.25),'rgba(255,205,105,',9+radius*0.12);
smoke(x,y,Math.round(8+radius*0.2),'rgba(96,94,102,',1+radius*0.008);
dust(x,y,Math.round(10+radius*0.22),`rgba(${theme.dirt.join(',')},`,2+radius*0.05);
if(radius>60){ ring(x,y,crater*0.5,'rgba(255,255,255,',crater*3.4,0.5); }
const dmgR = radius*(opt.dmgMul||1.3);
worms.forEach(w=>{
if(!w.alive) return;
const d=dist(x,y,w.x,w.y);
if(d<dmgR+w.r){
const f=1-clamp((d-w.r)/dmgR,0,1);
const amt=dmg*(0.3+0.7*f);
if(team) team.dealt+=amt;
if(dmg>0){
const a=Math.atan2(w.y-y,w.x-x)||0;
const kb=(opt.kb===undefined?1:opt.kb)*f*(radius*0.12);
w.vx+=Math.cos(a)*kb; w.vy+=Math.sin(a)*kb-1.6;
hurt(w,amt,team);
}
}
});
Sound.boom(radius/46);
cam.shake=Math.min(34,cam.shake+radius*0.32);
cam.flash=Math.min(0.8,cam.flash+radius*0.011);
if(radius>58){ cam.frz=Math.max(cam.frz,0.09); G.hitstop=Math.max(G.hitstop,0.10); }
else G.hitstop=Math.max(G.hitstop,0.035);
}
/* ------------------------------ firing ------------------------------ */
function muzzleSpeed(power){ return 4.0+(power/100)*16.0; }
function muzzlePos(w){
const a=G.angle;
return {x:w.x+Math.cos(a)*(w.r+8), y:w.y-4+Math.sin(a)*(w.r+8)};
}
function simShot(sx,sy,ang,power,weapon){
const sp=muzzleSpeed(power);
let x=sx,y=sy,vx=Math.cos(ang)*sp,vy=Math.sin(ang)*sp,t=0;
const seg=[{x,y}], wk=weapon.wind===undefined?1:weapon.wind, fuse=weapon.fuse||0;
for(let i=0;i<500;i++){
vy+=GRAV; vx+=wind*WIND_P*wk;
const sN=Math.max(1,Math.ceil(Math.hypot(vx,vy)/3.2));
for(let s=0;s<sN;s++){
const px=x,py=y;
x+=vx/sN; y+=vy/sN; t+=1/(sN*60);
if(fuse&&t>=fuse) return {x,y,path:seg,fuse:true};
if(y>WATER_Y) return {x,y,water:true,path:seg};
if(solidAt(x,y)){
if(weapon.bouncy&&t<fuse-0.06){
const n=terrainNormal(x,y), d=vx*n.x+vy*n.y;
vx=(vx-2*d*n.x)*0.52; vy=(vy-2*d*n.y)*0.52; x=px; y=py;
if(Math.hypot(vx,vy)<1.1) return {x,y,path:seg,fizzle:true};
} else return {x:px+(x-px)*0.5,y:py+(y-py)*0.5,path:seg,terrain:true};
}
seg.push({x,y});
}
}
return {x,y,path:seg,out:true};
}
function raycast(w,ang,maxD){
let x=w.x+Math.cos(ang)*(w.r+6), y=w.y-4+Math.sin(ang)*(w.r+6);
const dx=Math.cos(ang), dy=Math.sin(ang);
for(let t=0;t<maxD;t+=2.5){
const px=x+dx*t, py=y+dy*t;
if(py>WATER_Y) return {x:px,y:py,water:true};
if(solidAt(px,py)) return {x:px,y:py,terrain:true,ang};
for(const o of worms) if(o.alive&&o!==w&&dist(px,py,o.x,o.y)<o.r+2) return {x:px,y:py,worm:o,ang};
}
return {x:x+dx*maxD,y:y+dy*maxD,miss:true,ang};
}
function teamAmmo(t,id){ return t.ammo[id]; }
function fireWeapon(worm,weapon,power){
const T=worm.team;
if(weapon.ammo!==Infinity && T.ammo[weapon.id]<=0) return false;
if(weapon.ammo!==Infinity) T.ammo[weapon.id]--;
const m=muzzlePos(worm);
worm.facing = Math.cos(G.angle)>=0?1:-1;
worm.squish=1;
if(weapon.kind==='indirect'){
makeProj({kind:'ball',x:m.x,y:m.y,vx:Math.cos(G.angle)*muzzleSpeed(power),vy:Math.sin(G.angle)*muzzleSpeed(power),
r:weapon.id==='holy'?6:4,team:T,weapon,dmg:weapon.dmg,radius:weapon.radius,
fuse:weapon.fuse||0,bouncy:!!weapon.bouncy,wind:weapon.wind,spin:0,spinV:rr(.2,-.2)});
Sound.launch();
for(let i=0;i<10;i++) spawnPart({type:'smoke',x:m.x,y:m.y,vx:rr(2,-2)+Math.cos(G.angle)*2,vy:rr(2,-2),
size:rr(9,4),size2:rr(22,10),g:-0.02,drag:0.9,life:rr(.6,.3),col:'rgba(210,210,215,'});
sparks(m.x,m.y,8,'rgba(255,200,120,',4);
G.shotFired=true; G.phase='flight'; G.aimValid=true;
} else if(weapon.kind==='placed'){
makeProj({kind:'dyn',x:worm.x,y:worm.y-2,vx:0,vy:0,r:5,team:T,weapon,dmg:weapon.dmg,
radius:weapon.radius,fuse:weapon.fuse});
Sound.pop(); G.shotFired=true;
addToast('Dynamite armed — run!', T.color);
} else if(weapon.kind==='direct'){
G.burst={left:weapon.shots||4, weapon, worm, t:0.001, gap: weapon.id==='minigun'?0.05:0.03};
G.shotFired=true; G.phase='burst';
} else if(weapon.kind==='support'){
if(weapon.id==='airstrike'){
const tx=clamp(G.mouse.x,80,W-80);
for(let i=0;i<2;i++){
const fromLeft=(i===0);
makeProj({kind:'plane', x:fromLeft?-70:W+70, y:0, vx:(fromLeft?1:-1)*3.6, vy:0,
dropY:62+rr(52), bombsLeft:6, spread:300, dropX:tx+(fromLeft?-45:45),
dropping:false, dt:0, team:T, dmg:weapon.dmg, radius:weapon.radius});
}
Sound.plane(); addToast('Airstrike inbound!', T.color);
G.shotFired=true; G.phase='flight';
} else if(weapon.id==='medkit'){
makeProj({kind:'crate',x:G.mouse.x,y:-30,vx:0,vy:2.5,r:9,chute:true,team:T});
Sound.whoosh(); G.shotFired=true; G.phase='flight';
}
} else if(weapon.kind==='utility'){
if(weapon.id==='teleport'){
const p=G.mouse; const ok=!solidAt(p.x,p.y+8)&&!solidAt(p.x,p.y)&&p.y<WATER_Y-14;
if(!ok){ Sound.deny(); addToast('No room to teleport there!','#ff8787'); T.ammo[weapon.id]++; return false; }
for(let k=0;k<26;k++) spawnPart({type:'star',x:worm.x+rr(24,-24),y:worm.y+rr(24,-24),
vx:rr(1.5,-1.5),vy:rr(-2,-4),size:rr(6,2),g:-0.02,life:rr(1,.5),col:'rgba(140,200,255,'});
Sound.tele();
worm.x=clamp(p.x,14,W-14); worm.y=p.y;
let k=0; while(blockedAt(worm.x,worm.y,worm.r)&&k<40){ worm.y--; k++; }
for(let k2=0;k2<26;k2++) spawnPart({type:'star',x:worm.x+rr(24,-24),y:worm.y+rr(24,-24),
vx:rr(1.5,-1.5),vy:rr(-2,-4),size:rr(6,2),g:-0.02,life:rr(1,.5),col:'rgba(200,160,255,'});
flash(worm.x,worm.y,50,'rgba(180,220,255,');
G.shotFired=true; endTurn(1.0);
} else if(weapon.id==='jetpack'){
worm.jet=1; worm.fuel=100; G.phase='jetpack';
addToast('Jetpack! Hold mouse to thrust','#ffd166'); Sound.whoosh();
G.shotFired=true;
}
}
if(weapon.kind==='direct'||weapon.kind==='indirect') updateAmmoUI();
updateAmmoUI();
G.fireT=0;
return true;
}
function fireBurst(worm,weapon,angle){
const m=muzzlePos(worm);
const spread = (weapon.id==='minigun') ? rr(0.06,-0.06) : rr(0.08,-0.08);
const ang = (angle===undefined?G.angle:angle)+spread;
const hit=raycast(worm,ang,940);
spawnPart({type:'tracer',x:m.x,y:m.y,ex:hit.x,ey:hit.y,size:1,life:0.1,g:0,drag:1,col:'rgba(255,232,165,'});
if(hit.worm){
const T=worm.team; T.dealt+=weapon.dmg;
hurt(hit.worm,weapon.dmg,T);
const kb=(weapon.kb||0.6)*4.6;
hit.worm.vx+=Math.cos(ang)*kb; hit.worm.vy+=Math.sin(ang)*kb-1.1;
goo(hit.x,hit.y,6,`rgba(${hex2rgb(hit.worm.team.color)},`);
cam.shake=Math.min(10,cam.shake+1.1);
} else if(hit.terrain){
carveSmall(hit.x,hit.y,weapon.radius,ang);
dust(hit.x,hit.y,6,`rgba(${theme.dirt.join(',')},`,2);
sparks(hit.x,hit.y,7,'rgba(255,220,150,',5);
Sound.crumble(); cam.shake=Math.min(8,cam.shake+0.7);
} else if(hit.water){
for(let k=0;k<10;k++) spawnPart({type:'drop',x:hit.x+rr(8,-8),y:WATER_Y,vx:rr(2,-2),vy:rr(-1,-6),size:rr(3,1.6),g:0.3,life:rr(.6,.3),col:'rgba(200,235,255,'});
wave();
}
if(weapon.id==='minigun'){ Sound.mg(); cam.shake=Math.min(12,cam.shake+1.2); } else Sound.pellet();
}
function carveSmall(x,y,r,ang){
carveDisc(x,y,r*(0.7+0.3*RNG()),0.3,makeFbm(mulberry32((x*13+y*7)|0),0.3));
paintAround(Math.floor(x-r-3),Math.ceil(x+r+3));
}
function wave(){
for(let i=0;i<3;i++){ ring(W*0.5+rng()*W*0.5, WATER_Y+2, 10, 'rgba(190,235,255,', 90, 0.7); }
}
/* ------------------------------ turn flow ------------------------------ */
function addToast(msg,col){
const box=document.getElementById('toasts'); if(!box) return;
const d=document.createElement('div'); d.className='toast'; d.textContent=msg;
if(col) d.style.borderColor=col, d.style.color=col;
box.appendChild(d);
setTimeout(()=>d.remove(),3200);
while(box.children.length>4) box.firstChild.remove();
}
function showBanner(title,sub,color){
const b=document.getElementById('banner');
b.querySelector('.bt').textContent=title;
b.querySelector('.bs').textContent=sub||'';
b.style.setProperty('--bc',color||'#7fd0ff');
b.classList.remove('show'); void b.offsetWidth; b.classList.add('show');
clearTimeout(b._t); b._t=setTimeout(()=>b.classList.remove('show'),1700);
}
function playerTeams(){ return teams.filter(t=>!t.cpu&&t.alive); }
function nextActiveWorm(T){
const alive=T.worms.filter(w=>w.alive);
if(!alive.length) return null;
T.active=(T.active+1)%alive.length;
return alive[T.active%alive.length];
}
function startTurn(ti){
if(G.over) return;
const alive=teams.filter(t=>t.alive);
if(alive.length<=1){ gameOver(); return; }
let tries=0;
while(tries++<8 && !teams[ti].alive) ti=(ti+1)%teams.length;
G.turnTeam=ti;
const T=teams[ti];
G.active=nextActiveWorm(T);
if(!G.active){ G.active=T.worms[0]; }
G.time=TURN_TIME; G.shotFired=false; G.power=0; G.charging=false;
G.turnCount++;
// wind
if(Math.random()<1.0){
const mag=rr(1,-1);
G.wind=Math.abs(mag)<0.12?0:clamp(mag,-1,1);
}
Sound.setWind(G.wind);
G.phase='banner'; G.pendingEnd=0;
if(G.turnCount>teams.length*2 && !G.sudden){ G.sudden=true; addToast('SUDDEN DEATH: the tide is rising!','#ff8787'); }
if(G.sudden && G.turnCount>teams.length*3 && WATER_Y>400){
WATER_Y-=7; G.waterRise+=7; addToast('The tide rises… '+Math.round(598-WATER_Y)+'px','#8cd8ff');
}
updateHud(true);
const label = T.cpu? 'CPU '+T.name : T.name;
showBanner(T.name, T.cpu?'calculating…':'your turn', T.color);
Sound.horn();
G.bannerT=1.45;
}
function endTurn(delay){
if(G.over) return;
G.pendingEnd=delay===undefined?1.0:delay;
G.phase='settle';
}
function finishTurn(){
const alive=teams.filter(t=>t.alive);
if(alive.length<=1){ gameOver(); return; }
let ti=(G.turnTeam+1)%teams.length, tries=0;
while(tries++<teams.length && !teams[ti].alive) ti=(ti+1)%teams.length;
startTurn(ti);
}
function checkGameOver(){
const alive=teams.filter(t=>t.alive);
if(alive.length<=1 && !G.over){
const mid=G.matchId;
setTimeout(()=>{ if(G.matchId===mid && !G.over) gameOver(); },1400);
}
}
function gameOver(){
if(G.over) return;
if(teams.filter(t=>t.alive).length>1) return; // never end while 2+ teams stand
G.over=true; G.phase='over'; G.winner=teams.filter(t=>t.alive)[0]||null;
Sound.setWind(0);
if(G.winner) Sound.win(); else Sound.lose();
showBanner(G.winner?G.winner.name+' WINS!':'DRAW','battle over', G.winner?G.winner.color:'#fff');
const wt=document.getElementById('winTitle'), ws=document.getElementById('winSub');
wt.textContent = G.winner? G.winner.name+' VICTORY':'STALEMATE';
wt.style.color = G.winner? G.winner.color : '#fff';
ws.textContent = G.winner? 'last worms standing' : 'everyone is worm food';
const st=document.getElementById('stats');
st.innerHTML = teams.map(t=>{
const hp=t.worms.reduce((a,w)=>a+w.hp,0);
const al=t.worms.filter(w=>w.alive).length;
return `<div class="sr"><span style="color:${t.color}">${t.name}${t.cpu?' (CPU)':''}</span>
<b>${al} alive · ${fmt(hp)} hp · ${fmt(t.dealt)} dmg dealt</b></div>`;
}).join('');
setTimeout(()=>document.getElementById('over').classList.remove('hide'),1600);
}
/* ------------------------------ CPU brain ------------------------------ */
const DIFFS = { chill:1.05, normal:0.66, brutal:0.28 };
let DIFF = 0.5;
function simFast(sx,sy,ang,power,step){
const sp=muzzleSpeed(power);
let x=sx,y=sy,vx=Math.cos(ang)*sp,vy=Math.sin(ang)*sp;
for(let i=0;i<420;i++){
vy+=GRAV; vx+=wind*WIND_P;
const n=Math.max(1,Math.ceil(Math.hypot(vx,vy)/6));
for(let s=0;s<n;s++){
x+=vx/n; y+=vy/n;
if(y>WATER_Y) return {x,y,water:true};
if(x<-60||x>W+60||y<-700) return {x,y,out:true};
if(solidAt(x,y)) return {x,y,terrain:true};
for(const w of worms) if(w.alive&&dist(x,y,w.x,w.y)<w.r+2) return {x,y,worm:w};
}
}
return {x,y,out:true};
}
function aiPlanShot(T,worm,weapon,target){
const sx=worm.x+Math.cos(0)*10, sy=worm.y-4;
let best=null;
if(weapon.kind==='direct'){
const ang=Math.atan2(target.y-worm.y,target.x-worm.x);
const hit=raycast(worm,ang,940);
return {ang:ang,power:60,score:hit.worm===target?60:20,impact:{x:hit.x,y:hit.y}};
}
const powers=[38,50,62,74,86,100];
for(let a=-3.02;a<=-0.24;a+=0.05){
for(const pw of powers){
const im=simFast(sx,sy,a,pw);
const sc=aiScore(im,weapon,T,worm,target);
if(!best||sc>best.score) best={ang:a,power:pw,score:sc,impact:im};
}
}
// also try low flat shots when very close
return best;
}
function aiScore(im,weapon,T,shooter,target){
let s=0;
const R=weapon.radius*(weapon.kind==='indirect'?1.3:1.1)+10;
for(const w of worms){
if(!w.alive) continue;
const d=dist(im.x,im.y,w.x,w.y);
if(d<R){
const f=1-d/R, amt=weapon.dmg*(0.3+0.7*f);
if(w.team===T) s-=amt*2.1;
else {
const value=1+(1-w.hp/100)*0.8;
const lethal = amt>=w.hp?2.4:1;
s+=amt*1.7*value*lethal;
if(w===target) s+=12;
}
}
}
if(im.water) s-=30;
if(im.out) s-=60;
const selfD=dist(im.x,im.y,shooter.x,shooter.y);
s-=selfD*0.02;
if(target){
const tD=dist(im.x,im.y,target.x,target.y);
s-=tD*0.05; // reward accuracy
s+=clamp(1-(tD/300),0,1)*8; // reward landing near the target
}
return s;
}
function aiPickWeapon(T,worm,target){
const d=dist(worm.x,worm.y,target.x,target.y);
const los=!raycast(worm,Math.atan2(target.y-worm.y,target.x-worm.x),940).terrain;
const A=T.ammo;
// cluster detection
let cluster=0;
for(const w of worms) if(w.alive&&w.team!==T&&dist(w.x,w.y,target.x,target.y)<70) cluster++;
if(worm.hp<45 && A.medkit>0 && Math.random()<0.35) return {w:WMAP.medkit, mode:'support'};
if(cluster>=2 && A.holy>0 && d>90 && Math.random()<0.8) return {w:WMAP.holy};
if(d<170 && los && A.shotgun>0 && Math.random()<0.75) return {w:WMAP.shotgun};
if(d<260 && los && A.minigun>0 && Math.random()<0.5) return {w:WMAP.minigun};
if(target.hp<28 && d<300 && A.grenade>0 && Math.random()<0.4) return {w:WMAP.grenade};
if(A.dynamite>0 && d<95 && Math.random()<0.45) return {w:WMAP.dynamite};
if(A.airstrike>0 && d>420 && Math.random()<0.28) return {w:WMAP.airstrike, mode:'support'};
if(A.grenade>0 && Math.random()<0.42) return {w:WMAP.grenade};
return {w:WMAP.bazooka};
}
function aiTarget(T){
let list=[];
for(const w of worms) if(w.alive&&w.team!==T){
const d=dist(w.x,w.y,G.active?G.active.x:0,G.active?G.active.y:0);
list.push({w,score:(100-w.hp)*1.5 - d*0.28 + (w.y>WATER_Y-30?25:0)});
}
list.sort((a,b)=>b.score-a.score);
return list.length?list[0].w:null;
}
function aiStart(){
const T=teams[G.turnTeam], worm=G.active;
const target=aiTarget(T);
const plan={phase:'think',t:0,T,worm,target,dir:0,moveT:0,weapon:null,ang:G.angle,power:60,shots:0};
const wsel=target?aiPickWeapon(T,worm,target):{w:WMAP.bazooka};
plan.weapon=wsel.w; plan.mode=wsel.mode;
G.ai=plan;
}
function aiHazard(worm){
// danger if close to water or perched with nothing beneath
let danger=0;
if(worm.y>WATER_Y-80) danger+=2;
let drop=0;
for(let y=worm.y;y<WATER_Y;y+=6){ if(solidAt(worm.x,y)) { drop=y-worm.y; break; } drop=y-worm.y; }
if(drop>120) danger+=1;
return danger;
}
function aiPickMove(T,worm,target){
if(!target) return {dir:0,dist:0};
const nearWater = worm.y>WATER_Y-86;
let best={dir:0,dist:0,score:-1e9};
for(const dir of [-1,1]){
for(let step=1;step<=7;step++){
const stepD=step*24;
const x=clamp(worm.x+dir*stepD,14,W-14);
let gy=-1;
for(let y=Math.max(20,worm.y-90);y<WATER_Y-6;y++){ if(solidAt(x,y)){ gy=y; break; } }
if(gy<0) continue;
if(gy>WATER_Y-46) continue; // would stand in the splash zone
if(blockedAt(x,gy-worm.r-4,worm.r+1)) continue; // no headroom
if(!solidAt(x,gy+4)&&!solidAt(x,gy+9)) continue; // flimsy footing
const d=dist(x,gy,target.x,target.y);
let sc=-Math.abs(d-320)*0.5 - dist*0.9 + (WATER_Y-gy)*0.25;
if(nearWater) sc+=(worm.y-gy)*1.6;
if(sc>best.score) best={dir,dist,score:sc,x,y:gy};
}
}
return best;
}
function aiUpdate(dt){
const A=G.ai; if(!A) return;
const T=A.T, worm=A.worm;
if(!worm||!worm.alive){ endTurn(0.5); return; }
if(!A.target||!A.target.alive){ A.target=aiTarget(T); if(!A.target){ endTurn(0.5); return; } }
A.t+=dt;
if(A.phase==='think'){
G.angle=Math.atan2(A.target? A.target.y-worm.y : -1, A.target? A.target.x-worm.x : 1);
if(A.t>0.72){
const mv=aiPickMove(T,worm,A.target);
A.dir=mv.dir; A.dist=mv.dist;
A.phase = mv.dir? 'move':'aim'; A.t=0; A.moveDur=clamp(mv.dist/1.6/60,0.15,1.6);
if(A.dir) showBanner('','',T.color);
}
} else if(A.phase==='move'){
worm.vx=A.dir*1.35;
worm.walkT+=dt;
if(A.t>A.moveDur*0.5 && Math.random()<dt*2.2 && Math.abs(worm.vx)>0.4&&worm.onGround){
worm.vy=-6.6; Sound.jump();
}
if(A.t>A.moveDur){ worm.vx=0; A.phase='aim'; A.t=0;
if(!A.target) A.target=aiTarget(T); }
} else if(A.phase==='aim'){
if(A.mode==='support'){
if(A.weapon.id==='airstrike'){
G.mouse.x=clamp(A.target.x-10,WATER_Y>0?40:40,W-40); G.mouse.y=A.target.y;
if(A.t>0.8){ A.phase='fire'; A.t=0; }
} else { // medkit on a hurt friend
const hurt=worm.team.worms.filter(w=>w.alive).sort((a,b)=>a.hp-b.hp)[0]||worm;
G.mouse.x=hurt.x; G.mouse.y=hurt.y;
if(A.t>0.7){ A.phase='fire'; A.t=0; }
}
return;
}
if(!A.plan){
const plan=aiPlanShot(T,worm,A.weapon,A.target);
A.plan=plan||{ang:-1,power:70};
A.noise=DIFF;
}
const k=Math.min(1,dt*3.4);
G.angle=lerp(G.angle,A.plan.ang,k);
G.power=lerp(G.power,A.plan.power,k*0.9);
if(A.t>0.95){ A.phase='fire'; A.t=0; }
} else if(A.phase==='fire'){
const noise=DIFF;
const ang=A.plan? A.plan.ang+rr(noise*0.06,-noise*0.06) : G.angle;
const pow=clamp((A.plan?A.plan.power:70)+rr(noise*9,-noise*9),12,100);
G.angle=ang; G.power=pow;
const ok=fireWeapon(worm,A.weapon,pow);
if(!ok){ A.weapon=WMAP.bazooka; fireWeapon(worm,WMAP.bazooka,pow); }
A.phase='done';
}
}
/* ------------------------------ render ------------------------------ */
const cv=document.getElementById('game'), ctx=cv.getContext('2d');
let cw=0, ch=0, dpr=1;
let clouds=[], stars=[], hills=[[],[]];
function resize(){
dpr=Math.min(2,window.devicePixelRatio||1);
cw=window.innerWidth; ch=window.innerHeight;
cv.width=Math.floor(cw*dpr); cv.height=Math.floor(ch*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
fit.v=Math.max(cw/W, ch/H);
}
window.addEventListener('resize',resize);
function buildDecor(seed){
const r=mulberry32(seed);
clouds=[]; stars=[]; hills=[[],[]];
for(let i=0;i<11;i++) clouds.push({x:r()*W*1.6-W*0.3,y:40+r()*250,s:0.5+r()*1.5,sp:0.06+r()*0.16,o:0.1+r()*0.22});
for(let i=0;i<130;i++) stars.push({x:r()*W,y:r()*430,s:r()*1.6+0.4,tw:r()*TAU});
for(let L=0;L<2;L++){
const amp=L?44:86, base=L?520:470, f=makeFbm(mulberry32(seed+L*77),L?0.004:0.0022);
for(let x=-200;x<W+200;x+=14) hills[L].push({x,y:base+f(x)*amp});
}
}
function drawSky(){
const t=theme||THEMES[0];
const g=ctx.createLinearGradient(0,0,0,ch);
g.addColorStop(0,t.sky[0]); g.addColorStop(0.55,t.sky[1]); g.addColorStop(1,t.sky[2]);
ctx.fillStyle=g; ctx.fillRect(0,0,cw,ch);
// stars
if(t.star){
const tt=performance.now()*0.001;
for(const s of stars){
const a=0.35+0.65*Math.abs(Math.sin(tt*0.9+s.tw));
ctx.fillStyle=`rgba(255,255,255,${a*0.75})`;
ctx.fillRect(s.x,s.y,s.s,s.s);
}
}
// sun / moon glow
const sx=cw*0.78, sy=ch*0.16;
const rg=ctx.createRadialGradient(sx,sy,4,sx,sy,ch*0.6);
rg.addColorStop(0,t.sun); rg.addColorStop(0.12,hexA(t.sun,0.35)); rg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=rg; ctx.fillRect(0,0,cw,ch);
// clouds
const drift=performance.now()*0.0004;
for(const c of clouds){
const px=((c.x+drift*(6+c.sp*26)*(wind>0?1:-1)*(0.4+Math.abs(wind)))-(cam.x-W/2)*0.22*(fit.v*cam.z))%(W*1.6)-200;
const py=c.y-(cam.y-H/2)*0.22*(fit.v*cam.z);
const s=c.s*(fit.v*cam.z)*0.55;
ctx.save(); ctx.translate(px,py); ctx.scale(s,s);
const cg=ctx.createRadialGradient(0,0,4,0,0,60);
cg.addColorStop(0,`rgba(255,255,255,${c.o})`); cg.addColorStop(0.6,`rgba(255,255,255,${c.o*0.5})`);
cg.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=cg; ctx.beginPath(); ctx.ellipse(0,0,60,22,0,0,TAU); ctx.fill();
ctx.beginPath(); ctx.ellipse(-26,6,34,15,0,0,TAU); ctx.fill();
ctx.beginPath(); ctx.ellipse(30,5,38,17,0,0,TAU); ctx.fill();
ctx.restore();
}
}
function hexA(hex,a){
const c=hex2rgb(hex); return `rgba(${c},${a})`;
}
function drawHills(){
const t=theme||THEMES[0];
const sc=fit.v*cam.z;
for(let L=0;L<2;L++){
const par=L?0.5:0.3;
const offX=-(cam.x-W/2)*par*sc+W*0.5*(1-par)*(sc-1);
const offY=-(cam.y-H/2)*par*sc;
ctx.save();
ctx.translate(cw/2+offX, ch/2+offY); ctx.scale(sc,sc); ctx.translate(-W/2,-H/2);
ctx.beginPath(); ctx.moveTo(-220,H+120);
for(const p of hills[L]) ctx.lineTo(p.x,p.y);
ctx.lineTo(W+220,H+120); ctx.closePath();
ctx.fillStyle=t.far[L]; ctx.globalAlpha=L?0.95:0.75; ctx.fill(); ctx.globalAlpha=1;
// haze top
ctx.strokeStyle=hexA(t.haze,L?0.18:0.25); ctx.lineWidth=3; ctx.stroke();
ctx.restore();
}
}
function drawWater(){
const t=theme||THEMES[0];
const sc=fit.v*cam.z;
const wy=WATER_Y;
if(wy>H+10) return;
const g=ctx.createLinearGradient(0,wy-10,0,H);
g.addColorStop(0,hexA(t.water[0],0.72)); g.addColorStop(0.35,hexA(t.water[0],0.86));
g.addColorStop(1,hexA(t.water[1],0.96));
ctx.save();
ctx.beginPath();
const tt=performance.now()*0.0013;
const step=16;
const XO=-3400, XW=W+3400;
ctx.moveTo(XO,wy+6);
for(let x=XO;x<=XW;x+=step){
const y=wy+Math.sin(x*0.022+tt*1.6)*3.4+Math.sin(x*0.061-tt*2.3)*1.8;
ctx.lineTo(x,y);
}
ctx.lineTo(XW,H+60); ctx.lineTo(XO,H+60); ctx.closePath();
ctx.fillStyle=g; ctx.fill();
// surface highlight
ctx.beginPath();
for(let x=XO;x<=XW;x+=step){
const y=wy+Math.sin(x*0.022+tt*1.6)*3.4+Math.sin(x*0.061-tt*2.3)*1.8;
x===XO?ctx.moveTo(x,y):ctx.lineTo(x,y);
}
ctx.strokeStyle='rgba(255,255,255,.42)'; ctx.lineWidth=2; ctx.stroke();
// sparkles
for(let i=0;i<26;i++){
const x=(i*137+ (tt*30)%W)%W;
const y=wy+14+((i*53)%80);
const a=0.1+0.18*Math.abs(Math.sin(tt*1.4+i));
ctx.fillStyle=`rgba(255,255,255,${a})`;
ctx.fillRect(x,y, 14+((i*7)%16), 1.4);
}
// caustic light under the surface
ctx.globalCompositeOperation='lighter';
for(let i=0;i<7;i++){
const x=((i*211+tt*40)%(W+200))-100;
const rg=ctx.createRadialGradient(x,wy+30,2,x,wy+30,90);
rg.addColorStop(0,'rgba(255,255,255,.05)'); rg.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=rg; ctx.fillRect(x-90,wy,180,H-wy);
}
ctx.globalCompositeOperation='source-over';
ctx.restore();
}
function drawWorm(w,isActive,team){
if(!w.alive){ drawGrave(w); return; }
const t=w.team;
const sq=1+w.squish*0.28, st=1-w.squish*0.2;
const wob=Math.sin(w.anim)*0.12;
ctx.save();
ctx.globalAlpha = w.inWater? 0.72 : 1;
ctx.translate(w.x,w.y);
// shadow
ctx.fillStyle='rgba(0,0,0,.3)';
ctx.beginPath(); ctx.ellipse(0,w.r*0.85,w.r*1.05*st,3.2,0,0,TAU); ctx.fill();
ctx.rotate(wob*0.3);
ctx.scale(st,sq);
// body
const g=ctx.createLinearGradient(0,-w.r,0,w.r);
g.addColorStop(0,t.color2); g.addColorStop(0.45,t.color); g.addColorStop(1,shade(t.color,-0.45));
ctx.beginPath(); ctx.ellipse(0,0,w.r*1.02,w.r*0.98,0,0,TAU);
ctx.fillStyle=g; ctx.fill();
ctx.lineWidth=1.6; ctx.strokeStyle=shade(t.color,-0.62); ctx.stroke();
// belly
ctx.beginPath(); ctx.ellipse(0,w.r*0.28,w.r*0.62,w.r*0.5,0,0,TAU);
ctx.fillStyle='rgba(255,255,255,.2)'; ctx.fill();
// eyes
const look=(()=>{ if(isActive&&(G.phase==='player'||G.phase==='aim')) return Math.atan2(G.mouse.y-w.y,G.mouse.x-w.x);
return w.facing>0?0:Math.PI; })();
const ex=Math.cos(look)*1.6, ey=Math.sin(look)*1.2-1.6;
for(const s of [-1,1]){
const bx=s*3.6;
ctx.beginPath(); ctx.ellipse(bx,-4.2,3.1,3.4,0,0,TAU); ctx.fillStyle='#f7fbff'; ctx.fill();
ctx.lineWidth=1; ctx.strokeStyle='rgba(30,36,50,.55)'; ctx.stroke();
const bl = w.blink<0? 0.35 : 1;
if(bl<1){ ctx.beginPath(); ctx.ellipse(bx,-4.2,3.1,3.4*bl,0,0,TAU); ctx.fillStyle=shade(t.color,-0.2); ctx.fill(); }
else { ctx.beginPath(); ctx.ellipse(bx+ex,-4.2+ey,1.5,1.6,0,0,TAU); ctx.fillStyle='#1b2230'; ctx.fill();
ctx.beginPath(); ctx.arc(bx+ex-0.5,-5+ey,0.55,0,TAU); ctx.fillStyle='#fff'; ctx.fill(); }
}
// mouth – worried when hurt
ctx.beginPath();
if(w.hp<40){ ctx.arc(0,3.2,2.6,0.15*Math.PI,0.85*Math.PI); }
else { ctx.arc(0,1.4,2.4,0.2*Math.PI,0.8*Math.PI); }
ctx.strokeStyle='rgba(25,30,45,.75)'; ctx.lineWidth=1.2; ctx.stroke();
if(w.flash>0){ ctx.globalAlpha=w.flash*0.7; ctx.fillStyle='#fff';
ctx.beginPath(); ctx.ellipse(0,0,w.r*1.05,w.r*1.02,0,0,TAU); ctx.fill(); ctx.globalAlpha=1; }
ctx.restore();
ctx.globalAlpha=1;
// hp bar
if(w.hp<w.maxhp||isActive){
const bw=26, bh=4, by=w.y-w.r-13;
ctx.fillStyle='rgba(6,9,18,.72)'; roundRect(w.x-bw/2-1,by-1,bw+2,bh+2,3); ctx.fill();
const hpc=w.hp>60?'#6ef2a5':w.hp>30?'#ffd43b':'#ff6b6b';
ctx.fillStyle=hpc; roundRect(w.x-bw/2,by,bw*clamp(w.hp/100,0,1),bh,2); ctx.fill();
}
if(isActive){
const bob=Math.sin(performance.now()*0.005)*3;
ctx.save(); ctx.translate(w.x, w.y-w.r-26+bob);
ctx.beginPath(); ctx.moveTo(0,8); ctx.lineTo(-7,-3); ctx.lineTo(7,-3); ctx.closePath();
ctx.fillStyle=w.team.color; ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.45)'; ctx.lineWidth=1.4; ctx.stroke();
ctx.restore();
// team ring
ctx.beginPath(); ctx.arc(w.x,w.y,w.r+7,0,TAU);
ctx.strokeStyle=hexA(w.team.color,0.42+0.2*Math.sin(performance.now()*0.005)); ctx.lineWidth=2; ctx.stroke();
}
}
function drawGrave(w){
ctx.save(); ctx.translate(w.x,Math.min(w.y,WATER_Y+80)); ctx.globalAlpha=0.75;
ctx.fillStyle='rgba(190,195,205,.85)';
roundRect(-7,-12,14,17,4); ctx.fill();
ctx.fillStyle='rgba(120,126,140,.9)'; roundRect(-7,3,14,3.4,1.6); ctx.fill();
ctx.fillStyle='#ffd166'; ctx.font='bold 8px Trebuchet MS'; ctx.textAlign='center';
ctx.fillText(w.idx+1,0,-2);
ctx.restore();
}
function shade(hex,amt){
const c=hex2rgb(hex).split(',').map(Number);
return `rgb(${c.map(v=>clamp(Math.round(amt>0?lerp(v,255,amt):v*(1+amt)),0,255)).join(',')})`;
}
function roundRect(x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
}
function drawParticles(){
for(const p of parts){
const k=p.age/p.life, a=1-k;
switch(p.type){
case 'smoke':{
const s=lerp(p.size,p.size2,k);
const g=ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,s);
g.addColorStop(0,p.col+(a*0.5)+')'); g.addColorStop(1,p.col+'0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(p.x,p.y,s,0,TAU); ctx.fill(); break; }
case 'dust':{
ctx.fillStyle=p.col+(a*0.6)+')';
ctx.beginPath(); ctx.arc(p.x,p.y,lerp(p.size,p.size2,k),0,TAU); ctx.fill(); break; }
case 'spark':{
ctx.strokeStyle=p.col+a+')'; ctx.lineWidth=p.size*0.8; ctx.beginPath();
ctx.moveTo(p.x,p.y); ctx.lineTo(p.x-p.vx*1.8,p.y-p.vy*1.8); ctx.stroke(); break; }
case 'goo':{
ctx.fillStyle=p.col+(a*0.85)+')';
ctx.beginPath(); ctx.arc(p.x,p.y,lerp(p.size,p.size*0.4,k),0,TAU); ctx.fill(); break; }
case 'bubble':{
ctx.strokeStyle=p.col+(a*0.7)+')'; ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,TAU); ctx.stroke(); break; }
case 'drop':{
ctx.fillStyle=p.col+(a*0.8)+')';
ctx.beginPath(); ctx.ellipse(p.x,p.y,p.size*0.7,p.size,0,0,TAU); ctx.fill(); break; }
case 'ring':{
const s=lerp(p.size,p.size2,k);
ctx.strokeStyle=p.col+(a*0.9)+')'; ctx.lineWidth=lerp(7,0.6,k);
ctx.beginPath(); ctx.arc(p.x,p.y,s,0,TAU); ctx.stroke(); break; }
case 'flash':{
const s=lerp(p.size,p.size2,k);
const g=ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,s);
g.addColorStop(0,'rgba(255,255,255,'+a*0.95+')');
g.addColorStop(0.5,p.col+a*0.6+')'); g.addColorStop(1,p.col+'0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(p.x,p.y,s,0,TAU); ctx.fill(); break; }
case 'star':{
ctx.fillStyle=p.col+a+')';
const s=lerp(p.size,p.size*0.2,k);
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.age*4);
ctx.beginPath();
for(let i=0;i<4;i++){ const ang=i*Math.PI/2; ctx.moveTo(0,0);
ctx.lineTo(Math.cos(ang)*s*2,Math.sin(ang)*s*2); }
ctx.lineWidth=1.4; ctx.strokeStyle=p.col+a+')'; ctx.stroke(); ctx.restore(); break; }
case 'trail':{
ctx.fillStyle=p.col+a*0.5+')';
ctx.beginPath(); ctx.arc(p.x,p.y,lerp(p.size,p.size2,k),0,TAU); ctx.fill(); break; }
case 'tracer':{
ctx.strokeStyle=p.col+Math.max(a,0.3)+')'; ctx.lineWidth=2.2;
ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(p.ex,p.ey); ctx.stroke();
ctx.lineWidth=5; ctx.strokeStyle=p.col+(a*0.25)+')'; ctx.stroke(); break; }
}
}
}
function drawChunks(){
for(const c of chunks){
ctx.save(); ctx.globalAlpha=clamp(c.fade,0,1);
ctx.translate(c.x,c.y); ctx.rotate(c.rot);
ctx.fillStyle=c.col;
ctx.beginPath();
const n=5;
for(let i=0;i<n;i++){
const a=i/n*TAU, rr2=c.r*(0.75+0.5*hash2((c.x|0)+i,(c.y|0)-i));
i? ctx.lineTo(Math.cos(a)*rr2,Math.sin(a)*rr2):ctx.moveTo(Math.cos(a)*rr2,Math.sin(a)*rr2);
}
ctx.closePath(); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.28)'; ctx.lineWidth=1; ctx.stroke();
ctx.restore();
}
}
function drawProjectiles(){
for(const p of projs){
ctx.save(); ctx.translate(p.x,p.y);
if(p.kind==='bomb'){ ctx.rotate(Math.atan2(p.vy,p.vx)+Math.PI/2);
ctx.fillStyle='#cfd6e0'; ctx.beginPath(); ctx.ellipse(0,0,4.6,7.6,0,0,TAU); ctx.fill();
ctx.fillStyle='#9aa5b1'; ctx.beginPath(); ctx.moveTo(-4,5); ctx.lineTo(0,11); ctx.lineTo(4,5); ctx.closePath(); ctx.fill();
} else if(p.kind==='plane'){
ctx.scale(p.vx>0?1:-1,1);
ctx.fillStyle='#c9d2de';
ctx.beginPath(); ctx.moveTo(-16,0); ctx.lineTo(6,-4); ctx.lineTo(16,-1); ctx.lineTo(16,2); ctx.lineTo(6,4); ctx.closePath(); ctx.fill();
ctx.fillStyle='#8f9aa8'; ctx.beginPath(); ctx.moveTo(2,0); ctx.lineTo(-10,-2); ctx.lineTo(-10,2); ctx.closePath(); ctx.fill();
ctx.fillStyle='#6b7684'; ctx.beginPath(); ctx.moveTo(-16,0); ctx.lineTo(-22,-8); ctx.lineTo(-18,0); ctx.closePath(); ctx.fill();
} else if(p.kind==='dyn'){
ctx.rotate(p.age*2);
ctx.fillStyle='#c73e3e'; ctx.beginPath(); ctx.arc(0,0,5.2,0,TAU); ctx.fill();
ctx.fillStyle='#f0d9a8'; ctx.fillRect(-5,0,10,1.6);
const f=Math.abs(Math.sin(performance.now()*0.02));
ctx.fillStyle=`rgba(255,${180+60*f},80,${0.5+0.5*f})`;
ctx.beginPath(); ctx.arc(0,-7-2*f,2.4+1.6*f,0,TAU); ctx.fill();
} else if(p.kind==='crate'){
ctx.rotate(Math.sin(performance.now()*0.003)*0.1);
ctx.fillStyle='#f2f4f8'; roundRect(-10,-8,20,16,3); ctx.fill();
ctx.fillStyle='#e04b4b'; ctx.fillRect(-10,-8,20,4);
ctx.fillStyle='#e04b4b'; ctx.fillRect(-1.8,-4,3.6,10);
ctx.fillRect(-6.5,0.5,13,3);
if(p.chute){
ctx.strokeStyle='rgba(240,245,255,.8)'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(-9,-8); ctx.lineTo(-14,-24); ctx.moveTo(9,-8); ctx.lineTo(14,-24); ctx.stroke();
ctx.fillStyle='rgba(255,120,120,.9)';
ctx.beginPath(); ctx.moveTo(-17,-22); ctx.quadraticCurveTo(0,-38,17,-22);
ctx.quadraticCurveTo(0,-30,-17,-22); ctx.fill();
}
} else {
// rocket / grenade / holy
const holy=p.weapon&&p.weapon.id==='holy';
if(holy){
const tt=performance.now()*0.006;
ctx.globalCompositeOperation='lighter';
const g=ctx.createRadialGradient(0,0,1,0,0,22);
g.addColorStop(0,`rgba(255,250,200,${0.5+0.25*Math.sin(tt)})`);
g.addColorStop(1,'rgba(255,220,120,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(0,0,22,0,TAU); ctx.fill();
ctx.globalCompositeOperation='source-over';
ctx.rotate(Math.sin(tt)*0.2);
ctx.fillStyle='#f5e9c8'; ctx.beginPath(); ctx.arc(0,0,6.4,0,TAU); ctx.fill();
ctx.strokeStyle='#c89b3c'; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.moveTo(-3.4,-1.6); ctx.lineTo(3.4,-1.6); ctx.moveTo(0,-5); ctx.lineTo(0,1.8); ctx.stroke();
ctx.strokeStyle='rgba(255,230,140,.9)';
ctx.beginPath(); ctx.ellipse(0,-9,6,2,0,0,TAU); ctx.stroke();
} else if(p.bouncy){
ctx.rotate(p.spin);
ctx.fillStyle='#6f8f52'; ctx.beginPath(); ctx.arc(0,0,5,0,TAU); ctx.fill();
ctx.fillStyle='#93b96b'; ctx.beginPath(); ctx.arc(-1.6,-1.6,2,0,TAU); ctx.fill();
ctx.fillStyle='#b8c0c8'; ctx.fillRect(-1.6,-7.6,3.2,2.8);
} else {
ctx.rotate(Math.atan2(p.vy,p.vx));
ctx.fillStyle='#e8eef7'; ctx.beginPath(); ctx.ellipse(0,0,7,3.4,0,0,TAU); ctx.fill();
ctx.fillStyle='#ff9c5b'; ctx.beginPath();
ctx.moveTo(-7,0); ctx.lineTo(-14,-3.4); ctx.lineTo(-14,3.4); ctx.closePath(); ctx.fill();
ctx.fillStyle='#ffd06b'; ctx.beginPath(); ctx.arc(2.4,0,2.2,0,TAU); ctx.fill();
}
}
ctx.restore();
}
}
function drawTexts(){
ctx.textAlign='center';
for(const t of texts){
const k=t.age/t.life, a=1-k*k;
ctx.save();
ctx.globalAlpha=a;
ctx.font=`bold ${t.size*(1+0.18*(1-Math.min(1,k*4)))}px Trebuchet MS, sans-serif`;
ctx.lineWidth=4; ctx.strokeStyle='rgba(6,9,18,.85)';
ctx.strokeText(t.txt,t.x,t.y);
ctx.fillStyle=t.col; ctx.fillText(t.txt,t.x,t.y);
ctx.restore();
}
ctx.textAlign='left';
}
function drawAim(){
if(!G.active||!G.active.alive) return;
const worm=G.active;
const human = !worm.team.cpu;
const wep = currentWeapon();
if(!WEP_READY(wep)) return;
if(human && (G.phase!=='player'||G.shotFired)) { /* still draw while charging */ }
const isHumanTurn = human && (G.phase==='player'||G.phase==='jetpack');
if(!isHumanTurn) return;
// reticle of the mouse
const mx=G.mouse.x,my=G.mouse.y;
if(wep.kind==='utility'||wep.kind==='support'){
const ok = wep.id!=='teleport' || (!solidAt(mx,my+8)&&!solidAt(mx,my)&&my<WATER_Y-14);
ctx.save(); ctx.translate(mx,my);
ctx.strokeStyle=ok?'rgba(140,247,197,.95)':'rgba(255,120,120,.95)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,0,16,0,TAU); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-22,0); ctx.lineTo(-9,0); ctx.moveTo(9,0); ctx.lineTo(22,0);
ctx.moveTo(0,-22); ctx.lineTo(0,-9); ctx.moveTo(0,9); ctx.lineTo(0,22); ctx.stroke();
if(wep.id==='teleport'||wep.id==='medkit'){
ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.arc(0,0,42,0,TAU);
ctx.strokeStyle='rgba(255,255,255,.35)'; ctx.lineWidth=1.4; ctx.stroke(); ctx.setLineDash([]);
}
ctx.restore();
return;
}
// aim direction line
const m=muzzlePos(worm);
const a=G.angle;
ctx.save();
let path=null, impact=null;
if(wep.kind==='direct'){
const hit=raycast(worm,a,940);
impact=hit;
ctx.beginPath(); ctx.moveTo(m.x,m.y); ctx.lineTo(hit.x,hit.y);
ctx.strokeStyle='rgba(255,120,120,.35)'; ctx.lineWidth=5; ctx.stroke();
ctx.setLineDash([7,7]); ctx.strokeStyle='rgba(255,235,170,.95)'; ctx.lineWidth=1.8;
ctx.stroke(); ctx.setLineDash([]);
} else {
const sim=simShot(m.x,m.y,a,G.power,wep);
path=sim.path; impact=sim;
ctx.setLineDash([3,7]); ctx.lineWidth=2.6; ctx.strokeStyle='rgba(255,255,255,.28)';
ctx.beginPath();
for(let i=0;i<path.length;i+=2){ i?ctx.lineTo(path[i].x,path[i].y):ctx.moveTo(path[i].x,path[i].y); }
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth=1.6; ctx.strokeStyle=hexA(worm.team.color,0.85);
ctx.beginPath();
for(let i=0;i<path.length;i+=2){ i?ctx.lineTo(path[i].x,path[i].y):ctx.moveTo(path[i].x,path[i].y); }
ctx.stroke();
// dotted markers along the arc
for(let i=10;i<path.length;i+=26){
ctx.beginPath(); ctx.arc(path[i].x,path[i].y,1.9,0,TAU);
ctx.fillStyle='rgba(255,255,255,.55)'; ctx.fill();
}
}
if(impact){
const hitWorm=impact.worm;
const col = impact.water? '#79c8ff' : hitWorm? '#ff6b6b' : impact.terrain? '#ffd166' : 'rgba(255,255,255,.7)';
const R=wep.kind==='direct'?10:wep.radius*0.9;
ctx.strokeStyle=col; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(impact.x,impact.y,R,0,TAU); ctx.stroke();
ctx.setLineDash([3,4]);
ctx.beginPath(); ctx.arc(impact.x,impact.y,R*0.55,0,TAU); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(impact.x-R-6,impact.y); ctx.lineTo(impact.x-R+2,impact.y);
ctx.moveTo(impact.x+R+6,impact.y); ctx.lineTo(impact.x+R-2,impact.y);
ctx.moveTo(impact.x,impact.y-R-6); ctx.lineTo(impact.x,impact.y-R+2);
ctx.moveTo(impact.x,impact.y+R+6); ctx.lineTo(impact.x,impact.y+R-2);
ctx.stroke();
}
ctx.restore();
// power arc under the worm
if(G.charging&&wep.kind==='indirect'){
const pw=G.power/100;
ctx.save(); ctx.translate(worm.x,worm.y);
ctx.beginPath(); ctx.arc(0,0,worm.r+11,-Math.PI*0.5,-Math.PI*0.5+pw*TAU);
ctx.strokeStyle=`hsl(${lerp(140,0,pw)},90%,60%)`; ctx.lineWidth=4; ctx.lineCap='round'; ctx.stroke();
ctx.restore();
}
}
function drawWindStreaks(){
if(Math.abs(wind)<0.06) return;
const tt=performance.now()*0.001;
ctx.save(); ctx.strokeStyle=`rgba(255,255,255,${0.05+Math.abs(wind)*0.11})`; ctx.lineWidth=1.4;
for(let i=0;i<26;i++){
const y=(i*47)%H;
const sp=wind*(130+ (i%5)*36);
let x=(tt*sp + i*211)%(W+300)-150;
const len=12+Math.abs(wind)*40;
ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+ (wind>0?len:-len),y); ctx.stroke();
}
ctx.restore();
}
function render(){
const sc=fit.v*cam.z;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch);
drawSky();
drawHills();
drawWindStreaks();
ctx.save();
ctx.translate(cw/2+cam.sx*sc, ch/2+cam.sy*sc);
ctx.scale(sc,sc);
ctx.translate(-cam.x,-cam.y);
// terrain
ctx.imageSmoothingEnabled=false;
ctx.drawImage(TC,0,0);
ctx.imageSmoothingEnabled=true;
drawChunks();
drawWater();
// worms
const active=G.active;
for(const w of worms) drawWorm(w,w===active&&w.alive,w.team);
drawProjectiles();
drawParticles();
drawAim();
drawTexts();
ctx.restore();
// vignette
const vg=ctx.createRadialGradient(cw/2,ch/2,Math.min(cw,ch)*0.32,cw/2,ch/2,Math.max(cw,ch)*0.78);
vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,.42)');
ctx.fillStyle=vg; ctx.fillRect(0,0,cw,ch);
if(cam.flash>0.002){
ctx.fillStyle=`rgba(255,246,225,${Math.min(0.55,cam.flash*0.55)})`;
ctx.fillRect(0,0,cw,ch);
}
if(G.hitstop>0){
ctx.fillStyle=`rgba(255,255,255,${Math.min(0.1,G.hitstop*1.1)})`;
ctx.fillRect(0,0,cw,ch);
}
}
/* ------------------------------ HUD (DOM) ------------------------------ */
let G_wepIdx = 0;
function currentWeapon(){ return WEAPONS[G_wepIdx]; }
function WEP_READY(w){ const T=teams[G.turnTeam]; if(!T) return false;
return w.ammo===Infinity || (T.ammo[w.id]||0)>0; }
function buildHud(){
const box=document.getElementById('teams'); box.innerHTML='';
teams.forEach(T=>{
const el=document.createElement('div');
el.className='team'; el.dataset.t=T.idx;
el.style.setProperty('--tc',T.color);
el.innerHTML=`<div class="thead"><span class="nm"><i class="dot" style="background:${T.color};color:${T.color}"></i>${T.name}${T.cpu?' · CPU':''}</span><span class="tot">400</span></div>
<div class="units">${T.worms.map(w=>`<div class="uchip" data-u="${T.idx}-${w.idx}"><i style="background:linear-gradient(90deg,${T.color2},${T.color});color:${T.color}"></i></div>`).join('')}</div>`;
box.appendChild(el);
});
const strip=document.getElementById('weaponStrip'); strip.innerHTML='';
WEAPONS.forEach((w,i)=>{
const d=document.createElement('div');
d.className='wslot'; d.dataset.i=i;
d.innerHTML=`<span class="wkey">${(i+1)%10}</span><svg viewBox="0 0 24 24">${ICON[w.icon]}</svg><span class="ammo"></span>`;
d.addEventListener('mouseenter',()=>{ if(Sound.on()) Sound.hover();
const tip=document.getElementById('weaponTip');
tip.querySelector('.wn').textContent=w.name;
tip.querySelector('.wd').textContent='— '+w.desc;
tip.classList.add('show'); });
d.addEventListener('mouseleave',()=>document.getElementById('weaponTip').classList.remove('show'));
d.addEventListener('click',()=>selectWeapon(i));
strip.appendChild(d);
});
updateAmmoUI();
}
function selectWeapon(i){
const w=WEAPONS[i]; if(!w) return;
const T=teams[G.turnTeam];
if(T && w.ammo!==Infinity && (T.ammo[w.id]||0)<=0){ Sound.deny();
addToast('No '+w.name+' left!','#ff8787'); return; }
if(G.phase==='jetpack'){ return; }
G_wepIdx=i; Sound.select(); updateAmmoUI();
}
function updateAmmoUI(){
const T=teams[G.turnTeam]; if(!T) return;
document.querySelectorAll('.wslot').forEach(el=>{
const i=+el.dataset.i, w=WEAPONS[i];
const amt = w.ammo===Infinity?'∞':(T.ammo[w.id]||0);
el.querySelector('.ammo').textContent=amt;
el.classList.toggle('empty', w.ammo!==Infinity && (T.ammo[w.id]||0)<=0);
el.classList.toggle('sel', i===G_wepIdx);
});
}
let hudPrevSec=-99;
function updateHud(force){
const T=teams[G.turnTeam];
const t=Math.max(0,G.time);
const tf=document.getElementById('timerFill'), tt=document.getElementById('timerText'), tw=document.getElementById('timerWrap');
tf.style.width=clamp(t/TURN_TIME*100,0,100)+'%';
tt.textContent = t>0? Math.ceil(t) : (G.over?'–':'OT');
tw.classList.toggle('hot', t<=24&&t>12);
tw.classList.toggle('warn', t<=12&&t>5);
tw.classList.toggle('crit', t<=5);
const wn=document.getElementById('windNeedle'), wv=document.getElementById('windVal');
wn.style.left=(50+clamp(wind,-1,1)*46)+'%';
const wmag=Math.abs(wind);
wv.textContent=(wind<0?'◀ ':'')+(wmag*100).toFixed(0)+(wind>0?' ▶':'');
wv.style.color= wmag>0.66?'#ff8787': wmag>0.33?'#ffd43b':'#8cd8ff';
// teams
document.querySelectorAll('.team').forEach(el=>{
const ti=+el.dataset.t, Tm=teams[ti];
if(!Tm){ el.style.display='none'; return; }
const hp=Tm.worms.reduce((a,w)=>a+Math.max(0,w.hp),0);
el.querySelector('.tot').textContent=fmt(hp)+' hp';
el.classList.toggle('active', ti===G.turnTeam && !G.over);
el.classList.toggle('out', !Tm.alive);
Tm.worms.forEach(w=>{
const chip=el.querySelector(`.uchip[data-u="${ti}-${w.idx}"]`);
if(!chip) return;
chip.querySelector('i').style.width=clamp(w.hp/100*100,0,100)+'%';
chip.classList.toggle('dead',!w.alive);
});
});
// power
const pb=document.getElementById('powerBox');
const show = G.charging && T && !T.cpu && G.phase==='player';
pb.classList.toggle('show',!!show);
document.getElementById('powerFill').style.height=(G.power)+'%';
document.getElementById('powerNum').textContent=Math.round(G.power);
document.getElementById('menuBtns').classList.toggle('show',G.phase!=='title');
}
function hudTick(){
const t=Math.max(0,G.time);
const sec=Math.ceil(t);
if(G.phase==='player'&&!G.shotFired&&sec!==hudPrevSec&&sec<=10&&sec>0){
hudPrevSec=sec; Sound.tick(sec<=5);
if(sec<=3){ document.getElementById('timerText').animate(
[{transform:'scale(1.6)'},{transform:'scale(1)'}],{duration:260,easing:'ease-out'}); }
}
}
/* ------------------------------ input ------------------------------ */
const keys={};
function keyName(e){ return (e.key||'').toLowerCase(); }
window.addEventListener('keydown',e=>{
if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(keyName(e))) e.preventDefault();
Sound.init();
const k=keyName(e);
if(keys[k]) return; keys[k]=true;
if(G.phase==='title'||G.over) return;
const T=teams[G.turnTeam]; if(!T||T.cpu) return;
const worm=G.active; if(!worm) return;
if(k>='1'&&k<='9'){ selectWeapon((+k)-1); }
if(k==='0'){ selectWeapon(9); }
if(k==='t'){ const i=WEAPONS.findIndex(w=>w.id==='teleport'); selectWeapon(i); }
const canMove = G.phase==='player' && G.time>0 && (!G.shotFired||currentWeapon().kind==='placed');
if((k==='w'||k==='arrowup'||k===' ') && canMove && worm.onGround && !worm.inWater){
worm.vy=-7.9; worm.onGround=false; worm.squish=0.6; Sound.jump();
dust(worm.x,worm.y+worm.r*0.8,5,`rgba(${theme.dirt.join(',')},`,1.6);
}
});
window.addEventListener('keyup',e=>{ keys[keyName(e)]=false; });
window.addEventListener('blur',()=>{ for(const k in keys) keys[k]=false; });
cv.addEventListener('contextmenu',e=>e.preventDefault());
cv.addEventListener('pointerdown',e=>{
Sound.init();
if(G.phase==='title'||G.over) return;
const r=cv.getBoundingClientRect();
const p=screenToWorld(e.clientX-r.left, e.clientY-r.top);
G.mouse=p;
const T=teams[G.turnTeam]; if(!T||T.cpu) return;
const worm=G.active; if(!worm||!worm.alive) return;
const wep=currentWeapon();
if(G.phase==='jetpack'){ if(e.button===2) endTurn(0.4); return; }
if(G.phase!=='player') return;
if(!WEP_READY(wep)){ Sound.deny(); return; }
if(e.button===2){ if(G.charging){G.charging=false;G.power=0;} return; }
worm.facing = p.x>worm.x?1:-1;
G.angle=Math.atan2(p.y-(worm.y-4), p.x-worm.x);
if(wep.kind==='indirect'){ G.charging=true; G.power=0; Sound.select(); cam.shake=Math.min(cam.shake+0.6,2); }
else { fireWeapon(worm,wep,70); }
});
window.addEventListener('pointerup',e=>{
if(!G.charging) return;
const T=teams[G.turnTeam], worm=G.active;
G.charging=false;
if(worm&&T&&!T.cpu&&G.phase==='player'){
const wep=currentWeapon();
if(G.power<6){ Sound.deny(); G.power=0; return; }
const pw=G.power; G.power=0;
fireWeapon(worm,wep,pw);
}
});
cv.addEventListener('pointermove',e=>{
const r=cv.getBoundingClientRect();
const p=screenToWorld(e.clientX-r.left, e.clientY-r.top);
G.mouse=p;
const worm=G.active;
const T=teams[G.turnTeam];
if(worm&&worm.alive&&T&&!T.cpu&&(G.phase==='player'||G.phase==='jetpack')){
G.angle=Math.atan2(p.y-(worm.y-4), p.x-worm.x);
if(pointerDown) worm.facing = p.x>worm.x?1:-1;
}
});
let pointerDown=false;
cv.addEventListener('pointerdown',()=>{pointerDown=true;});
window.addEventListener('pointerup',()=>{pointerDown=false;});
/* ------------------------------ simulation ------------------------------ */
let wind=0;
let walkAcc=0;
function canMove(){
const T=teams[G.turnTeam]; if(!T||T.cpu) return false;
return G.phase==='player' && G.time>0 && (!G.shotFired||currentWeapon().kind==='placed');
}
function updateGame(dtS){
const dt=dtS*60; // physics frames
// wind easing
wind=lerp(wind,G.wind,Math.min(1,dtS*1.2));
if(G.hitstop>0){ G.hitstop-=dtS; return; }
// particles always
updateParticles(dt,dtS);
const T=teams[G.turnTeam];
const worm=G.active;
if(G.phase==='banner'){
G.bannerT-=dtS;
if(G.bannerT<=0){ G.phase='player'; hudPrevSec=-99;
if(T.cpu){ aiStart(); } else { updateAmmoUI(); addToast('Your move — '+WEAPONS[G_wepIdx].name+' ready',T.color); } }
} else if(G.phase==='player'){
if(G.time>0) G.time-=dtS;
if(T.cpu){ if(G.ai) aiUpdate(dtS); }
else {
// charge up the shot
if(G.charging && worm&&worm.alive) G.power=Math.min(100,G.power+dtS*95);
if(worm&&worm.alive && canMove()){
let mvx=0;
if(keys['a']||keys['arrowleft']) mvx-=1;
if(keys['d']||keys['arrowright']) mvx+=1;
if(mvx){ worm.vx=mvx*1.38; worm.facing=mvx;
if(!G.charging) walkAcc+=dtS*(1+Math.abs(worm.vx));
if(walkAcc>0.26){ walkAcc=0; Sound.step();
dust(worm.x-worm.facing*5,worm.y+worm.r*0.8,2,`rgba(${theme.dirt.join(',')},`,1.1); } }
else if(worm.onGround) worm.vx*=Math.pow(0.02,dt);
} else if(worm&&worm.onGround) worm.vx*=Math.pow(0.05,dt);
}
if(worm&&!worm.alive){ G.charging=false; endTurn(1.1); }
if(G.dynExploded){ G.dynExploded=false; endTurn(1.1); }
if(G.shotFired){ G.fireT=(G.fireT||0)+dtS;
if(G.fireT>4.5 && projs.length===0){ G.fireT=0; endTurn(0.6); } }
if(G.time<-10 && !G.shotFired){ G.charging=false; addToast('Out of time!','#ff8787'); endTurn(0.3); }
} else if(G.phase==='burst'){
const b=G.burst;
if(b){
b.t+=dtS;
let guard=0;
while(b.t>=b.gap && b.left>0 && guard++<8){ b.t-=b.gap; b.left--; fireBurst(b.worm,b.weapon); }
if(b.left<=0){ G.phase='burstEnd'; G.burstT=0.55; }
} else G.phase='player';
} else if(G.phase==='burstEnd'){
G.burstT-=dtS;
if(G.burstT<=0){
const w=G.burst?G.burst.weapon:null;
const amt = w&&w.ammo!==Infinity ? (T.ammo[w.id]||0) : 0;
if(amt>0 && w){ G.phase='player'; G.burst=null; updateAmmoUI(); addToast(w.name+' — '+amt+' use left',T.color); }
else { G.burst=null; endTurn(0.7); }
}
} else if(G.phase==='jetpack'){
if(worm&&worm.alive){
const thrust = (pointerDown||keys['w']) ? 1:0;
if(thrust && worm.fuel>0){
worm.vy-=1.05*dt; worm.fuel-=dtS*34;
if(worm.fuel<0) worm.fuel=0;
for(let i=0;i<2;i++) spawnPart({type:'spark',x:worm.x+rr(5,-5),y:worm.y+9,
vx:rr(1.2,-1.2),vy:rr(3,1.4),size:rr(3.4,1.6),g:0.05,drag:0.9,life:rr(.35,.15),
col: Math.random()<.5?'rgba(255,170,70,':'rgba(255,230,140,'});
}
let mvx=0;
if(keys['a']||keys['arrowleft']) mvx-=1;
if(keys['d']||keys['arrowright']) mvx+=1;
worm.vx=lerp(worm.vx,mvx*2.0,Math.min(1,dtS*3));
worm.vy=clamp(worm.vy,-9,12);
if(worm.fuel<=0){ addToast('Out of fuel!','#ff8787'); endTurn(0.5); }
if(worm.inWater){ endTurn(0.4); }
}
} else if(G.phase==='flight'){
if(projs.length===0){
const settled = worms.every(w=>!w.alive||w.onGround||w.inWater);
if(settled && !G.over){ G.flightT=(G.flightT||0)+dtS; if(G.flightT>0.55){ G.flightT=0; endTurn(0.1);} }
else G.flightT=0;
} else G.flightT=0;
} else if(G.phase==='settle'){
G.pendingEnd-=dtS;
if(G.pendingEnd<=0) finishTurn();
}
// projectiles + worms
updateProjectiles(dt,dtS);
for(const w of worms) stepWorm(w,dt,dtS);
// falling into water splash for worms
for(const w of worms){
if(!w.alive||w.inWater) continue;
if(w.y>WATER_Y-2){ Sound.splash(); for(let i=0;i<14;i++) spawnPart({type:'drop',x:w.x+rr(10,-10),y:WATER_Y,vx:rr(2.6,-2.6),vy:rr(-3,-7),size:rr(3.4,1.4),g:0.32,life:rr(.7,.3),col:'rgba(200,235,255,'}); wave(); }
}
updateCamera(dtS);
// sudden-death tide
if(G.waterRise>0 && WATER_Y>360){ WATER_Y-=dtS*2.4; }
}
function updateCamera(dtS){
const worm=G.active;
let fx=cam.x, fy=cam.y, z=1.0;
if(projs.length){
let minx=1e9,maxx=-1e9,miny=1e9,maxy=-1e9,n=0;
for(const p of projs){ if(p.kind==='plane'&&p.x<-30) continue;
minx=Math.min(minx,p.x);maxx=Math.max(maxx,p.x);miny=Math.min(miny,p.y);maxy=Math.max(maxy,p.y);n++; }
if(n){
if(worm&&worm.alive){ minx=Math.min(minx,worm.x);maxx=Math.max(maxx,worm.x);miny=Math.min(miny,worm.y);maxy=Math.max(maxy,worm.y); }
fx=(minx+maxx)/2; fy=(miny+maxy)/2;
const bw=maxx-minx+420, bh=maxy-miny+300;
z=clamp(Math.min(cw/bw, ch/bh)/fit.v, 0.62, 1.22);
}
} else if(worm){
fx=worm.x; fy=worm.y-10;
z = (G.phase==='jetpack')? 1.35 : (G.charging? 1.28 : 1.12);
if(worm.inWater) z=1.2;
}
if(G.phase==='title'||G.over){
const t=performance.now()*0.00006;
fx=W*0.5+Math.sin(t)*W*0.16; fy=H*0.5+Math.sin(t*0.7)*40; z=1.02;
}
camFocus(fx,fy,z,dtS,projs.length?5.5:3.2);
// keep the view inside the world horizontally (no void at the edges)
const halfW=cw/(2*fit.v*Math.max(0.3,cam.z));
if(halfW*2<W) cam.x=clamp(cam.x,halfW,W-halfW); else cam.x=W*0.5;
cam.scale=fit.v*cam.z;
}
function updateCameraShake(dt,dtS){
cam.shake*=Math.pow(0.9,dt);
if(cam.shake<0.06) cam.shake=0;
cam.sx=rr(cam.shake,-cam.shake); cam.sy=rr(cam.shake,-cam.shake);
cam.flash*=Math.pow(0.9,dt);
if(cam.frz>0) cam.frz-=dtS;
}
/* ------------------------------ main loop ------------------------------ */
let lastTs=0;
function loop(ts){
let dtS=Math.min(0.05,(lastTs? (ts-lastTs)/1000:0.0167)); lastTs=ts;
if(!(dtS>0)) dtS=0.0167;
const dtF=Math.min(3,dtS*60);
if(G.phase!=='title') updateGame(dtS); else { updateParticles(dtF,dtS); updateCamera(dtS); }
updateCameraShake(dtF,dtS);
hudTick();
render();
if(G.phase!=='title') updateHud();
requestAnimationFrame(loop);
}
/* ------------------------------ setup menus ------------------------------ */
const COLORS=TEAM_DEF;
let setup=[{on:true,cpu:false},{on:true,cpu:true},{on:false,cpu:true},{on:false,cpu:true}];
let curDiff='normal';
function buildTeamSetup(){
const box=document.getElementById('teamSetup'); box.innerHTML='';
setup.forEach((s,i)=>{
const row=document.createElement('div');
row.className='trow'+(s.on?'':' off');
row.innerHTML=`<i class="chip" style="background:${COLORS[i].c};color:${COLORS[i].c}"></i>
<span class="tn">Team ${COLORS[i].name}</span>
<span class="tgl">
<button data-a="human" class="${s.on&&!s.cpu?'on':''}">HUMAN</button>
<button data-a="cpu" class="${s.on&&s.cpu?'on':''}">CPU</button>
<button data-a="off" class="${!s.on?'on':''}">OFF</button>
</span>`;
row.querySelectorAll('button').forEach(b=>{
b.addEventListener('mouseenter',()=>Sound.hover());
b.addEventListener('click',()=>{
const a=b.dataset.a;
if(a==='off') s.on=false;
else { s.on=true; s.cpu=(a==='cpu'); }
const live=setup.filter(x=>x.on).length;
if(live<2){ addToast('Need at least 2 teams!','#ff8787'); Sound.deny(); s.on=true; s.cpu=false; }
else Sound.select();
buildTeamSetup(); updateSoundBtn();
});
});
box.appendChild(row);
});
}
function buildDiff(){
const row=document.getElementById('diffRow'); row.innerHTML='';
const names={chill:'Chill',normal:'Normal',brutal:'Brutal'};
Object.keys(names).forEach(k=>{
const b=document.createElement('button');
b.textContent=names[k]; b.className=k===curDiff?'on':'';
b.addEventListener('mouseenter',()=>Sound.hover());
b.addEventListener('click',()=>{ curDiff=k; Sound.select(); buildDiff(); });
row.appendChild(b);
});
}
function buildArsenal(){
const box=document.getElementById('arsenal');
box.innerHTML=WEAPONS.map(w=>`<div><svg viewBox="0 0 24 24">${ICON[w.icon]}</svg><span>${w.name}</span></div>`).join('');
}
let soundOn=true;
function updateSoundBtn(){
const b=document.getElementById('btnSound');
b.textContent = soundOn? '🔊 SOUND ON':'🔇 SOUND OFF';
const m=document.getElementById('btnMute');
if(m) m.textContent = soundOn? '🔊':'🔇';
}
function startGame(){
Sound.init();
DIFF=DIFFS[curDiff];
const shape=pick(['island','two','cave','island','two']);
const seed=Math.floor(Math.random()*999999);
projs.length=0; parts.length=0; chunks.length=0; texts.length=0;
WATER_Y=598; G.waterRise=0; G.sudden=false; G.over=false; G.turnCount=0;
G.matchId=(G.matchId||0)+1;
G.hitstop=0; G.burst=null; G.ai=null; G.charging=false; G.power=0; G.shotFired=false;
buildDecor(seed);
genTerrain(shape,seed);
makeTeams(setup);
makeWorms();
G_wepIdx=0;
cam.x=W/2; cam.y=H/2; cam.z=1; cam.shake=0; cam.flash=0;
buildHud();
document.getElementById('title').classList.add('hide');
document.getElementById('over').classList.add('hide');
updateSoundBtn();
const label={island:'a single island',two:'twin islands',cave:'the deep caverns'}[shape];
addToast('The battlefield: '+label,'#8cd8ff');
G.phase='banner';
startTurn(Math.floor(Math.random()*Math.max(1,teams.length)));
}
/* ------------------------------ boot ------------------------------ */
function boot(){
resize();
buildDecor(4242);
genTerrain(pick(['island','two','cave']),Math.floor(Math.random()*99999));
makeTeams([{on:true,cpu:false},{on:true,cpu:true},{on:false,cpu:true},{on:false,cpu:true}]);
makeWorms();
buildTeamSetup(); buildDiff(); buildArsenal(); buildHud(); updateSoundBtn();
document.getElementById('btnStart').addEventListener('click',()=>{ startGame(); });
document.getElementById('btnSound').addEventListener('click',()=>{
Sound.init(); soundOn=!Sound.toggle(); if(soundOn) Sound.select(); updateSoundBtn(); });
document.getElementById('btnMute').addEventListener('click',()=>{
soundOn=!Sound.toggle(); if(soundOn) Sound.select(); updateSoundBtn(); });
document.getElementById('btnQuit').addEventListener('click',()=>{
Sound.back(); G.phase='title'; document.getElementById('title').classList.remove('hide');
document.getElementById('menuBtns').classList.remove('show'); updateHud(); });
document.getElementById('btnRematch').addEventListener('click',()=>{ Sound.select(); startGame(); });
document.getElementById('btnMenu').addEventListener('click',()=>{ Sound.back();
document.getElementById('over').classList.add('hide');
document.getElementById('title').classList.remove('hide');
G.phase='title'; updateHud(); });
document.querySelectorAll('button').forEach(b=>{
b.addEventListener('mouseenter',()=>Sound.hover());
});
G.phase='title';
updateHud();
requestAnimationFrame(loop);
}
window.addEventListener('pointerdown',()=>Sound.init(),{once:true});
window.addEventListener('keydown',()=>Sound.init(),{once:true});
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"6cb4b0dd-39e8-46d2-98e0-95ee880d9465","tokensIn":42730683,"tokensOut":169245,"tokensTotal":42899928,"cost":0.289883658,"turns":128,"toolCalls":127,"failedToolCalls":0,"timestamp":"2026-09-10T22:59:11.952Z"} -->