Worms
Ornith 1.5 35B A3B · 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 66,526 bytes · SHA-256 0fdb6dab12ea
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WARMFALL — Artillery Duel</title>
<style>
:root{
--bg0:#0a0e1a; --bg1:#121a2e; --panel:#0e1626; --panel2:#16223a;
--ink:#e8eefc; --ink2:#9fb0d0; --accent:#ff4d5e; --accent2:#ffcf4d;
--good:#3fe08a; --sky1:#1a2a4a; --sky2:#3a5a8a;
--gold:#ffd35c;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:var(--bg0);
font-family:'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--ink);
user-select:none;-webkit-user-select:none;}
#wrap{position:fixed;inset:0;}
canvas{position:absolute;top:0;left:0;display:block;}
#game{z-index:1;}
/* ---------- MENUS ---------- */
.overlay{position:absolute;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;
background:radial-gradient(circle at 50% 30%,rgba(30,45,80,.85),rgba(6,9,18,.96));
backdrop-filter:blur(3px);}
.overlay.hidden{display:none;}
.card{width:min(640px,92vw);max-height:92vh;overflow:auto;background:linear-gradient(180deg,var(--panel),var(--bg1));
border:1px solid rgba(120,160,255,.25);border-radius:18px;padding:26px 30px;
box-shadow:0 30px 80px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.06);}
h1.title{font-size:52px;margin:0;letter-spacing:2px;text-align:center;
background:linear-gradient(90deg,#ff4d5e,#ffcf4d,#3fe08a,#4db4ff);
-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 4px 24px rgba(255,207,77,.25);font-weight:900;}
.subtitle{text-align:center;color:var(--ink2);margin:4px 0 20px;letter-spacing:4px;font-size:12px;text-transform:uppercase;}
.field{margin:14px 0;}
.field>label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:2px;color:var(--ink2);margin-bottom:7px;}
.row{display:flex;gap:10px;flex-wrap:wrap;}
.seg{display:flex;background:#0a1220;border:1px solid rgba(120,160,255,.2);border-radius:10px;overflow:hidden;}
.seg button{flex:1;background:transparent;border:0;color:var(--ink2);padding:10px 12px;cursor:pointer;font-size:13px;font-weight:600;
transition:.15s;}
.seg button:hover{background:rgba(120,160,255,.12);}
.seg button.sel{background:linear-gradient(180deg,#3a5a9a,#2a4070);color:#fff;box-shadow:inset 0 0 0 1px rgba(120,160,255,.5);}
.btn{display:inline-block;background:linear-gradient(180deg,#ff5a6c,#e03046);color:#fff;border:0;border-radius:12px;
padding:14px 22px;font-size:16px;font-weight:800;cursor:pointer;letter-spacing:1px;transition:.12s;
box-shadow:0 6px 0 #a01828,0 10px 20px rgba(224,48,70,.4);}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 0 #a01828,0 14px 26px rgba(224,48,70,.5);}
.btn:active{transform:translateY(4px);box-shadow:0 2px 0 #a01828;}
.btn.block{display:block;width:100%;margin-top:18px;text-align:center;}
.btn.ghost{background:linear-gradient(180deg,#3a4a6a,#28364f);box-shadow:0 6px 0 #1a2436;}
.btn.small{padding:9px 14px;font-size:13px;}
.hint{font-size:12px;color:var(--ink2);margin-top:14px;line-height:1.6;text-align:center;}
.team-setup{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:6px;}
.team-card{background:#0c1524;border:1px solid rgba(120,160,255,.15);border-radius:12px;padding:12px;}
.team-card .tname{display:flex;align-items:center;gap:8px;font-weight:800;margin-bottom:8px;}
.swatch{width:18px;height:18px;border-radius:5px;box-shadow:0 0 10px currentColor;}
.team-card label{font-size:11px;color:var(--ink2);}
.team-card select,.team-card input{width:100%;background:#0a1220;border:1px solid rgba(120,160,255,.2);color:var(--ink);
border-radius:8px;padding:7px;font-size:13px;margin-top:3px;}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;background:#1a2a4a;color:var(--ink2);}
/* ---------- HUD ---------- */
#hud{position:absolute;inset:0;z-index:20;pointer-events:none;}
#hud *{pointer-events:auto;}
.topbar{position:absolute;top:12px;left:12px;right:12px;display:flex;justify-content:space-between;align-items:flex-start;gap:10px;}
.turn-pan{background:linear-gradient(180deg,var(--panel2),rgba(14,22,38,.9));border:1px solid rgba(120,160,255,.2);
border-radius:14px;padding:10px 16px;min-width:220px;box-shadow:0 8px 26px rgba(0,0,0,.4);}
.turnwho{display:flex;align-items:center;gap:10px;font-weight:800;font-size:16px;}
.team-dot{width:14px;height:14px;border-radius:50%;box-shadow:0 0 10px currentColor;}
.timer-wrap{margin-top:8px;}
.timer-track{height:8px;border-radius:6px;background:#0a1220;overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,.5);}
.timer-fill{height:100%;width:100%;background:linear-gradient(90deg,#3fe08a,#a6e04d);transition:width .1s linear;}
.timer-fill.warn{background:linear-gradient(90deg,#ffcf4d,#ff8a3d);}
.timer-fill.danger{background:linear-gradient(90deg,#ff5a6c,#ff2d44);}
.timer-txt{display:flex;justify-content:space-between;font-size:11px;color:var(--ink2);margin-top:4px;text-transform:uppercase;letter-spacing:2px;}
#turnTxt{font-size:12px;color:var(--ink2);margin-top:6px;font-weight:600;}
.wind-box{background:linear-gradient(180deg,var(--panel2),rgba(14,22,38,.9));border:1px solid rgba(120,160,255,.2);
border-radius:14px;padding:10px 14px;text-align:center;box-shadow:0 8px 26px rgba(0,0,0,.4);}
.wind-box .lbl{font-size:10px;letter-spacing:3px;color:var(--ink2);text-transform:uppercase;}
#windCanvas{display:block;margin:6px auto 0;}
.wind-val{font-size:12px;font-weight:700;color:var(--accent2);}
.hud-right{position:absolute;right:12px;top:12px;display:flex;flex-direction:column;gap:10px;align-items:flex-end;}
.team-list{display:flex;flex-direction:column;gap:6px;min-width:170px;}
.team-row{display:flex;align-items:center;gap:8px;background:linear-gradient(180deg,var(--panel2),rgba(14,22,38,.85));
border:1px solid rgba(120,160,255,.15);border-radius:10px;padding:6px 10px;font-size:12px;transition:.2s;}
.team-row.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),0 4px 16px rgba(255,77,94,.3);}
.team-row .nm{font-weight:800;flex:1;}
.team-row .hpbar{width:56px;height:7px;border-radius:5px;background:#0a1220;overflow:hidden;}
.team-row .hpfill{height:100%;background:linear-gradient(90deg,#3fe08a,#a6e04d);transition:width .3s;}
.team-row.dead{opacity:.4;}
/* weapon bar */
.weaponbar{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:flex;gap:8px;
background:linear-gradient(180deg,var(--panel2),rgba(10,16,28,.95));border:1px solid rgba(120,160,255,.2);
border-radius:16px;padding:8px;box-shadow:0 12px 34px rgba(0,0,0,.55);}
.weapon{width:64px;height:64px;border-radius:12px;background:#0c1524;border:2px solid rgba(120,160,255,.15);
cursor:pointer;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;
transition:.12s;}
.weapon:hover{border-color:rgba(120,160,255,.45);transform:translateY(-3px);}
.weapon.sel{border-color:var(--accent2);box-shadow:0 0 0 1px var(--accent2),0 0 20px rgba(255,207,77,.35);background:#1a2a44;}
.weapon .ico{font-size:24px;line-height:1;}
.weapon .nm{font-size:9px;color:var(--ink2);margin-top:3px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;}
.weapon .cost{position:absolute;top:3px;right:5px;font-size:9px;color:var(--accent2);font-weight:800;}
.weapon .hintkey{position:absolute;bottom:3px;left:5px;font-size:9px;color:#5a6a8a;}
.actionrow{position:absolute;bottom:90px;left:50%;transform:translateX(-50%);display:flex;gap:8px;}
.actbtn{background:linear-gradient(180deg,#3a4a6a,#28364f);border:1px solid rgba(120,160,255,.25);color:#fff;
border-radius:10px;padding:9px 16px;font-weight:700;font-size:13px;cursor:pointer;transition:.12s;}
.actbtn:hover{background:linear-gradient(180deg,#4a5a7a,#34465f);}
.actbtn.primary{background:linear-gradient(180deg,#3fe08a,#22b06a);box-shadow:0 5px 0 #167a49;}
.actbtn.danger{background:linear-gradient(180deg,#ff5a6c,#e03046);box-shadow:0 5px 0 #a01828;}
.actbtn:disabled{opacity:.4;cursor:not-allowed;}
.hintbar{position:absolute;bottom:12px;left:12px;font-size:11px;color:#6a7aa0;
background:rgba(10,16,28,.6);border-radius:8px;padding:6px 10px;line-height:1.5;}
.hintbar b{color:var(--ink2);}
/* combat / center text */
#centerMsg{position:absolute;top:34%;left:50%;transform:translate(-50%,-50%);text-align:center;z-index:40;
pointer-events:none;opacity:0;transition:opacity .3s;}
#centerMsg .big{font-size:44px;font-weight:900;letter-spacing:2px;text-shadow:0 4px 24px rgba(0,0,0,.6);
background:linear-gradient(90deg,#ffcf4d,#ff4d5e);-webkit-background-clip:text;background-clip:text;color:transparent;}
#centerMsg .small{font-size:16px;color:var(--ink2);margin-top:6px;}
/* scoreboard / gameover */
.score-row{display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:10px;margin:6px 0;
background:#0c1524;border:1px solid rgba(120,160,255,.12);}
.score-row .rank{font-size:20px;font-weight:900;width:30px;text-align:center;}
.score-row.winner{border-color:var(--gold);box-shadow:0 0 24px rgba(255,211,92,.3);}
.toast{position:absolute;top:90px;left:50%;transform:translateX(-50%);z-index:45;
background:rgba(14,22,38,.95);border:1px solid rgba(120,160,255,.3);border-radius:10px;
padding:8px 18px;font-weight:700;font-size:14px;opacity:0;transition:.3s;pointer-events:none;}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game"></canvas>
<!-- MAIN MENU -->
<div id="menu" class="overlay">
<div class="card">
<h1 class="title">WARMFALL</h1>
<div class="subtitle">Tactical Artillery Duel</div>
<div class="field">
<label>Number of Teams</label>
<div class="seg" id="teamCount">
<button data-v="2">2 Teams</button>
<button data-v="3">3 Teams</button>
<button data-v="4">4 Teams</button>
</div>
</div>
<div class="field">
<label>Team Setup</label>
<div class="team-setup" id="teamSetup"></div>
</div>
<div class="field">
<label>Map</label>
<div class="seg" id="mapType">
<button data-v="island">Single Island</button>
<button data-v="two">Two Islands</button>
<button data-v="cave">Cave</button>
</div>
</div>
<div class="row">
<div class="field" style="flex:1">
<label>Turn Time</label>
<div class="seg" id="turnTime">
<button data-v="60">60s</button>
<button data-v="45" class="sel">45s</button>
<button data-v="30">30s</button>
</div>
</div>
<div class="field" style="flex:1">
<label>Win Condition</label>
<div class="seg" id="winMode">
<button data-v="surv" class="sel">Survival</button>
<button data-v="score">Score</button>
</div>
</div>
</div>
<div class="field">
<label>Difficulty (CPU)</label>
<div class="seg" id="diff">
<button data-v="easy">Casual</button>
<button data-v="normal" class="sel">Normal</button>
<button data-v="hard">Brutal</button>
</div>
</div>
<button class="btn block" id="startBtn">START BATTLE</button>
<div class="hint">Hot-seat: same keyboard for alternating players. Press <b>Space</b> to fire. Aim with arrows / A-D, angle with Q/E.</div>
</div>
</div>
<!-- GAME OVER -->
<div id="gameover" class="overlay hidden">
<div class="card">
<h1 class="title" id="winTitle">GAME OVER</h1>
<div class="subtitle" id="winSub"></div>
<div id="scoreboard"></div>
<button class="btn block" id="againBtn">PLAY AGAIN</button>
<button class="btn block ghost" id="menuBtn">MAIN MENU</button>
</div>
</div>
<!-- HUD -->
<div id="hud" class="hidden">
<div class="topbar">
<div class="turn-pan">
<div class="turnwho"><span class="team-dot" id="teamDot"></span>
<span id="teamName">Team</span></div>
<div class="timer-wrap"><div class="timer-track"><div class="timer-fill" id="timerFill"></div></div>
<div class="timer-txt"><span id="whoTxt">Worm</span><span id="timerVal">45s</span></div>
</div>
</div>
<div class="wind-box">
<div class="lbl">Wind</div>
<canvas id="windCanvas" width="70" height="42"></canvas>
<div class="wind-val" id="windVal">0</div>
</div>
</div>
<div class="hud-right">
<div class="team-list" id="teamList"></div>
</div>
<div class="actionrow" id="actionRow"></div>
<div class="weaponbar" id="weaponbar"></div>
<div class="hintbar" id="hintbar"><b>←/→</b> Aim <b>↑/↓</b> Angle <b>Space</b> Charge & Fire <b>Q/E</b> Jump <b>F</b> Fire now</div>
<div id="centerMsg"><div class="big"></div><div class="small"></div></div>
<div class="toast" id="toast"></div>
</div>
</div>
<script>
"use strict";
/* ============================================================================
WARMFALL — a polished Worms-style artillery game
============================================================================ */
// ---------- Canvas ----------
const cv = document.getElementById('game');
const ctx = cv.getContext('2d');
let VW = 0, VH = 0, DPR = Math.min(window.devicePixelRatio||1, 2);
function resize(){
VW = window.innerWidth; VH = window.innerHeight;
cv.width = VW*DPR; cv.height = VH*DPR;
cv.style.width = VW+'px'; cv.style.height = VH+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
}
window.addEventListener('resize', resize); resize();
// ---------- Audio (WebAudio) ----------
const Sound = (()=>{
let ac=null, master=null;
function init(){ if(!ac){ ac=new (window.AudioContext||window.webkitAudioContext)();
master=ac.createGain(); master.gain.value=0.6; master.connect(ac.destination); }
if(ac.state==='suspended') ac.resume(); }
function env(node, t, dur, peak){ const g=ac.createGain(); g.gain.setValueAtTime(0,t);
g.gain.linearRampToValueAtTime(peak,t+0.005); g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
node.connect(g); g.connect(master); return g; }
function noise(t, dur, type, freq, gain){
const n=Math.floor(ac.sampleRate*dur); const buf=ac.createBuffer(1,n,ac.sampleRate);
const d=buf.getChannelData(0); for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(type==='sine'?0:1);
const src=ac.createBufferSource(); src.buffer=buf;
const f=ac.createBiquadFilter(); f.type=type||'lowpass'; f.frequency.value=freq||800;
env(src,t,dur,gain||0.5); src.connect(f); f.connect(master); src.start(t); }
function beep(t, freq, dur, type, gain, slideTo){
const o=ac.createOscillator(); o.type=type||'square'; o.frequency.setValueAtTime(freq,t);
if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo,t+dur);
env(o,t,dur,gain||0.2); o.connect(master); o.start(t); o.stop(t+dur+0.02); }
const S={
click(){ init(); const t=ac.currentTime; beep(t,520,0.05,'square',0.14,300); },
choose(){ init(); const t=ac.currentTime; beep(t,700,0.06,'triangle',0.16,950); },
aim(){ init(); beep(ac.currentTime,240,0.08,'sine',0.12,180); },
charge(lv){ init(); beep(ac.currentTime,180+lv*360,0.09,'sawtooth',0.10,120+lv*500); },
fire(){ init(); const t=ac.currentTime; noise(t,0.18,'lowpass',600,0.35); beep(t,300,0.12,'sawtooth',0.18,80); },
explode(big){ init(); const t=ac.currentTime; noise(t,big?0.6:0.3,'lowpass',big?400:700,big?0.6:0.4);
beep(t,big?90:140,big?0.5:0.28,'sine',big?0.5:0.35,big?30:60); },
hit(){ init(); const t=ac.currentTime; beep(t,900,0.05,'square',0.12,400); },
hurt(){ init(); const t=ac.currentTime; beep(t,220,0.18,'sawtooth',0.2,70); },
jump(){ init(); const t=ac.currentTime; beep(t,400,0.08,'sine',0.12,600); },
step(){ init(); beep(ac.currentTime,120,0.04,'sine',0.06,90); },
water(){ init(); beep(ac.currentTime,300,0.12,'sine',0.1,500); },
drown(){ init(); const t=ac.currentTime; beep(t,400,0.3,'sine',0.15,120); },
wind(){ init(); noise(ac.currentTime,0.4,'highpass',3000,0.12); },
win(){ init(); const t=ac.currentTime; [523,659,784,1046].forEach((f,i)=>beep(t+i*0.12,f,0.2,'triangle',0.2)); },
menu(){ init(); const t=ac.currentTime; beep(t,660,0.08,'triangle',0.16,880); },
warn(){ init(); beep(ac.currentTime,880,0.1,'square',0.12,880); },
countdown(){ init(); beep(ac.currentTime,660,0.12,'square',0.16); },
boom(){ init(); const t=ac.currentTime; noise(t,0.9,'lowpass',300,0.7); beep(t,60,0.8,'sine',0.6,20); },
float(){ init(); beep(ac.currentTime,500,0.1,'sine',0.08,700); },
};
window.S = S;
return S;
})();
// ---------- Utility ----------
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1);
const TAU=Math.PI*2;
// ---------- Constants ----------
const WORLD_W=2000, WORLD_H=1200;
const SKY_TOP=0, GROUND_X=400; // terrain top baseline world coords
const GRAV=1400, UNIT_GRAV=900, UNIT_SPEED=70, JUMP_V=620, MAX_FALL=700;
const FRICTION=0.86, GROUND_FRICTION=0.7;
const TILE=4; // destruction resolution (px)
// ---------- Game State ----------
const G={
state:'menu', // menu | aiming | charging | firing | cooldown | turnend | over
mapType:'island', turnTime:45, winMode:'surv', diff:'normal', teamCount:2,
teams:[], units:[], current:0, // current team index
activeUnit:null, activeTeam:null,
terrain:null, waterLevel:0, waterImg:null,
wind:0, windTimer:0,
projectiles:[], particles:[], debris:[], floaters:[], pickups:[], shockwaves:[],
cam:{x:0,y:0,z:1,tz:1,shake:0,shakeX:0,shakeY:0,freeze:0},
turnStart:0, turnLeft:0, charge:0,
scoreTarget:0,
};
// ---------- Team colors ----------
const TEAM_COLORS=[
{main:'#ff4d5e',dark:'#c0203a',light:'#ff8a96'},
{main:'#4db4ff',dark:'#1f78d0',light:'#8fd0ff'},
{main:'#3fe08a',dark:'#1fae60',light:'#8ff0ba'},
{main:'#ffcf4d',dark:'#d8a020',light:'#ffe58a'},
];
const DEFAULT_NAMES=['RED','BLUE','GREEN','GOLD'];
// ---------- Weapons ----------
// categories: indirect (projectile), direct (instant), aoe (map-altering), utility
const WEAPONS=[
{id:'shell', name:'Shell', ico:'💣', cost:1, cat:'indirect', dmg:100, radius:90, weight:1,
desc:'Standard explosive shell. Full power = big.'},
{id:'gun', name:'Gun', ico:'🔫', cost:1, cat:'direct', dmg:35, range:1400,
desc:'Instant-hit bullet. Low damage, hitscan.'},
{id:'dynamite', name:'Dynamite', ico:'🧨', cost:1, cat:'indirect', dmg:110, radius:80, fuse:0.9,
desc:'Fuse burns — plant it, then BOOM after a delay.'},
{id:'bazooka', name:'Bazooka', ico:'🚀', cost:1, cat:'indirect', dmg:90, radius:110, weight:1.4,
desc:'Slow arcing rocket, massive blast.'},
{id:'nuke', name:'Nuke', ico:'☢️', cost:3, cat:'aoe', dmg:150, radius:200, weight:1.8,
desc:'Screen-shaking nuke. Devastates the map.'},
{id:'dynam', name:'TNT', ico:'⬛', cost:2, cat:'aoe', dmg:120, radius:120, fuse:1.2,
desc:'Throw & detonate for a huge crater.'},
// utilities
{id:'rope', name:'Rope', ico:'🪢', cost:1, cat:'utility', desc:'Grappling rope — zip to high ground.'},
{id:'jetpack', name:'Jetpack', ico:'🎒', cost:2, cat:'utility', desc:'Limited thrust to reach islands.'},
{id:'bomb', name:'Bomb', ico:'💥', cost:1, cat:'utility', dmg:40, radius:60, desc:'Throw a bomb that home-mines the area.'},
{id:'shield', name:'Shield', ico:'🛡️', cost:2, cat:'utility', desc:'Absorb your next hit for free.'},
{id:'banana', name:'Banana', ico:'🍌', cost:0, cat:'utility', desc:'Slap hazard. Classic trap.'},
{id:'spring', name:'Spring', ico:'🌀', cost:1, cat:'utility', desc:'Magnetic pull toward the nearest enemy.'},
];
// ---------- Terrain ----------
function generateTerrain(type){
const w=Math.ceil(WORLD_W/TILE), h=Math.ceil(WORLD_H/TILE);
const t=new Uint8Array(w*h); // 0 air, 1 ground
// base water-fill level in tiles
const waterTileY=Math.floor(G.waterLevel/TILE);
const set=(x,y,v)=>{ if(x>=0&&x<w&&y>=0&&y<h)t[y*w+x]=v; };
const hgt=(x)=>0; // placeholder
if(type==='island'){
// single dome island rising in the middle
for(let x=0;x<w;x++){
const nx=x/w;
const edge=Math.pow(clamp(1-Math.pow(Math.abs(nx-0.5)*2,1.4),0,1),1.0);
const base=GROUND_X + (1-edge)*360;
const hill=Math.sin(nx*Math.PI)* -220 *edge;
let top=Math.round(base+hill + Math.sin(x*0.03)*14 + Math.sin(x*0.11)*7);
for(let y=0;y<h;y++) set(x,y,y>=top?1:0);
}
} else if(type==='two'){
for(let x=0;x<w;x++){
const nx=x/w;
// two humps
const bump=(cx,wid)=>Math.exp(-Math.pow((nx-cx)/wid,2));
const h1=bump(0.28,0.16), h2=bump(0.72,0.16);
const top=Math.round(GROUND_X + 250 - 230*h1 - 230*h2 + Math.sin(x*0.03)*12 + Math.sin(x*0.13)*6);
for(let y=0;y<h;y++) set(x,y,y>=top?1:0);
}
} else { // cave
for(let x=0;x<w;x++){
const nx=x/w;
const topEdge=Math.round(GROUND_X + Math.sin(x*0.02)*180 + Math.sin(x*0.07)*60 + 60);
// outer shell solid, inner hollow
for(let y=0;y<h;y++){
const y0=Math.max(0,topEdge), y1=Math.min(h, topEdge+420);
set(x,y,(y<y0||y>y1)?1:0);
}
}
}
G.terrain=t; G.terrainW=w; G.terrainH=h;
}
function isGround(x,y){
const tx=Math.floor(x/TILE), ty=Math.floor(y/TILE);
if(tx<0||ty<0||tx>=G.terrainW||ty>=G.terrainH)return false;
return G.terrain[ty*G.terrainW+tx]===1;
}
function sampleTerrainHeight(x){
const tx=Math.floor(x/TILE);
for(let y=0;y<G.terrainH;y++){ if(G.terrain[y*G.terrainW+tx]) return y*TILE; }
return WORLD_H;
}
// return {hit, x, y} nearest ground point going down from (x,y)
function groundUnder(x,y){
const tx=Math.floor(x/TILE);
for(let yw=0;yw<WORLD_H;yw+=TILE){ if(isGround(x,yw))return{x,y:yw}; }
return{x,y:WORLD_H};
}
// destroy terrain around a circle, produce debris
function destroyTerrain(cx,cy,radius,dmg){
const w=G.terrainW, h=G.terrainH, t=G.terrain;
const x0=Math.max(0,Math.floor((cx-radius)/TILE));
const x1=Math.min(w-1,Math.ceil((cx+radius)/TILE));
const y0=Math.max(0,Math.floor((cy-radius)/TILE));
const y1=Math.min(h-1,Math.ceil((cy+radius)/TILE));
const r2=radius*radius;
const created=[];
for(let ty=y0;ty<=y1;ty++){
for(let tx=x0;tx<=x1;tx++){
const px=(tx+0.5)*TILE, py=(ty+0.5)*TILE;
const d2=(px-cx)*(px-cx)+(py-cy)*(py-cy);
if(d2<=r2){
// destructive falloff: edge crumbles to water/air
const falloff=1-Math.sqrt(d2)/radius;
t[ty*w+tx]= (falloff<0.25 && Math.random()<0.5)? 2 /*water*/ : 0;
if(falloff>0.5 && Math.random()<0.35){
created.push({x:px,y:py});
}
}
}
}
// spawn debris chunks
const n=Math.min(40, Math.floor(radius*0.7));
for(let i=0;i<n;i++){
const c=created[randi(0,created.length-1)];
spawnDebris(c.x,c.y,radius);
}
}
function spawnDebris(x,y,power){
const count=Math.max(4,Math.floor(power*0.25));
for(let i=0;i<count;i++){
const a=Math.random()*TAU, sp=rand(40,220)*Math.min(2,power/60);
G.debris.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-rand(20,120),
life:rand(1.2,2.6),t:Math.random()*TAU,sz:rand(4,9),
col:Math.random()<0.5?'#6b4a2a':'#3f6b34',spin:rand(-8,8)});
}
}
// ---------- Worms ----------
function makeWorm(team,i){
const names=['Slappy','Nibbles','Buzz','Rico','Vix','Gus','Pinky','Sneaky','Dizzy','Fang','Kip','Lox'];
return {
name:names[(team*4+i)%names.length]+'-'+(i+1),
team, alive:true, health:100, maxHealth:100,
x:0,y:0, vx:0,vy:0,
dir: i%2===0?1:-1, // facing
onGround:true, grounded:false,
angle:Math.PI*0.25, // barrel angle (0 = right, PI = left)
aimDir:1,
fuel:3, // jetpack fuel
shield:0,
drownT:0, fallDmgT:0,
animT:0, onSpring:false,
alive:true,
};
}
function placeWorms(){
G.units=[];
for(const team of G.teams){
const placed=[];
for(let i=0;i<4;i++){
let attempts=0, x, w;
do{
x=rand(WORLD_W*0.08, WORLD_W*0.92);
const gy=groundUnder(x,0).y;
w={x, y:gy-TILE*1.2};
attempts++;
}while(placed.some(p=>dist(p.x,p.y,x,w.y)<140) && attempts<60);
const worm=makeWorm(team.wteam,i);
worm.x=w.x; worm.y=w.y;
G.units.push(worm); placed.push(w);
}
}
}
// ---------- Setup / New Game ----------
function startGame(){
G.teams=G.teams.map((t,i)=>({...t}));
generateTerrain(G.mapType);
// water level depends on terrain
if(G.mapType==='cave'){ G.waterLevel=GROUND_X+420; }
else if(G.mapType==='island'){ G.waterLevel=GROUND_X+300; }
else G.waterLevel=GROUND_X+330;
G.waterImg=buildWaterImage();
placeWorms();
G.units.forEach(u=>{ if(u.team===G.teams[0].wteam && G.teams[0].cpu) {} });
// assign first player
G.current=0;
G.activeTeam=G.teams[G.current];
G.activeUnit=nextAliveUnit(G.current);
G.wind=rand(-1,1)*60;
G.windTimer=rand(6,12);
G.projectiles=[]; G.particles=[]; G.debris=[]; G.floaters=[]; G.pickups=[]; G.shockwaves=[];
G.turnStart=performance.now()/1000;
G.turnLeft=G.turnTime; G.charge=0; G.state='aiming';
G.cam={x:0,y:0,z:1,tz:1,shake:0,shakeX:0,shakeY:0,freeze:0};
document.getElementById('menu').classList.add('hidden');
document.getElementById('gameover').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');
buildWeaponBar(); buildActionRow();
updateHUD();
showCenter(G.activeTeam.teamName+"'s Turn", 1400);
S.wind();
}
function nextAliveUnit(teamIdx){
const team=G.teams[teamIdx];
const members=G.units.filter(u=>u.team===team.wteam && u.alive);
if(!members.length)return null;
// find current member index
const cur=G.activeUnit;
if(cur && cur.alive && cur.team===team.wteam){
const idx=G.units.indexOf(cur);
// next
for(let k=1;k<=G.units.length;k++){ const u=G.units[(idx+k)%G.units.length]; if(u.team===team.wteam && u.alive)return u; }
}
return members[0];
}
function nextTurn(){
// advance to next alive team
let tries=0;
do{
G.current=(G.current+1)%G.teams.length;
tries++;
if(tries>G.teams.length+2){ endGame(); return; }
}while(!G.units.some(u=>u.team===G.teams[G.current].wteam && u.alive));
G.activeTeam=G.teams[G.current];
G.activeUnit=nextAliveUnit(G.current);
G.state='aiming'; G.turnStart=performance.now()/1000; G.turnLeft=G.turnTime; G.charge=0;
G.windTimer-=0.5;
updateHUD();
showCenter(G.activeTeam.teamName+"'s Turn", 1300);
if(G.activeTeam.cpu){ G.aiThinking=true; setTimeout(()=>{ if(G.state==='aiming'&&G.activeTeam.cpu){ G.aiThinking=false; aiThink(); } }, 900); }
}
// ---------- Projectile ----------
function spawnProjectile(owner, ox, oy, angle, power, weapon){
const speed=UNIT_SPEED*2.4*power;
const p={
x:ox+Math.cos(angle)*26, y:oy+Math.sin(angle)*26,
vx:Math.cos(angle)*speed, vy:Math.sin(angle)*speed,
weapon, owner:owner.team, life:8, angle,
spin:0, trail:[], fuse:weapon.fuse||0,
color:G.teams[owner.team].main,
};
G.projectiles.push(p);
G.state='firing';
S.fire();
}
// ---------- Explosions ----------
function explode(x,y,weapon,owner){
const dmg=weapon.dmg, rad=weapon.radius;
destroyTerrain(x,y,rad,dmg);
// damage units
for(const u of G.units){
if(!u.alive)continue;
const d=dist(x,y,u.x,u.y);
if(d<rad){
let d2=dmg*(1-d/rad);
// fall/angle damage mitigation if shield
if(u.shield>0){ u.shield=0; d2=0; S.shield&&S.shield(); }
damageUnit(u,d2,x,y);
}
}
// effects
const big=weapon.id==='nuke'||weapon.id==='dynam';
G.cam.shake+=big?26:10;
if(big){ G.cam.freeze=Math.max(G.cam.freeze, big?0.28:0.12); S.boom(); }
else S.explode(big);
G.shockwaves.push({x,y,r:4,max:rad*1.3,alpha:1,col:big?'#fff0a0':'#ffcf4d'});
// explosion particles
const pc=big?90:40;
for(let i=0;i<pc;i++){
const a=Math.random()*TAU, sp=rand(60,big?420:260);
G.particles.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-rand(20,90),
life:rand(0.5,1.4),t:Math.random()*2,sz:rand(2,6),
type:Math.random()<0.5?'fire':'smoke',
col:['#ffcf4d','#ff7a3d','#ff4d5e','#ffb03d'][randi(0,3)]});
}
G.floaters.push({x,y,text:'-'+Math.round(dmg),vy:-40,life:1.4,col:'#fff',big:true});
// impact freeze handled in update
}
function damageUnit(u,dmg,fromX,fromY){
if(!u.alive)return;
u.health-=dmg;
if(fromX!==undefined){ // knockback
const a=Math.atan2(u.y-fromY,u.x-fromX);
u.vx+=Math.cos(a)*dmg*0.9; u.vy+=Math.sin(a)*dmg*0.7-200;
u.onGround=false;
}
u.fallDmgT=0;
S.hurt();
if(u.health<=0){ u.health=0; killWorm(u); }
}
function killWorm(u){
u.alive=false;
// death particles
for(let i=0;i<30;i++){
const a=Math.random()*TAU, sp=rand(40,200);
G.particles.push({x:u.x,y:u.y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-100,
life:rand(0.6,1.4),t:0,sz:rand(2,5),type:'fire',col:G.teams[u.team].main});
}
G.floaters.push({x:u.x,y:u.y-20,text:'KO',vy:-50,life:1.6,col:G.teams[u.team].main,big:true});
S.hurt();
}
// ---------- Particles / debris / floaters ----------
function spawnDust(x,y,n){
for(let i=0;i<n;i++){
const a=-Math.PI*Math.random(), sp=rand(20,120);
G.particles.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-30,
life:rand(0.4,0.9),t:0,sz:rand(2,5),type:'dust',col:'#8a6b4a'});
}
}
function spawnSpark(x,y,col,n){
for(let i=0;i<n;i++){
const a=Math.random()*TAU, sp=rand(60,240);
G.particles.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
life:rand(0.3,0.8),t:0,sz:rand(1,3),type:'spark',col});
}
}
// ---------- Water image ----------
function buildWaterImage(){
const w=Math.ceil(WORLD_W/TILE), h=Math.ceil((WORLD_H-G.waterLevel)/TILE);
const c=document.createElement('canvas'); c.width=w; c.height=h;
const x=c.getContext('2d');
const grd=x.createLinearGradient(0,0,0,h);
grd.addColorStop(0,'rgba(60,130,200,0.45)'); grd.addColorStop(1,'rgba(20,70,140,0.72)');
x.fillStyle=grd; x.fillRect(0,0,w,h);
return {canvas:c,w,h};
}
// ============================================================================
// UPDATE LOOP
// ============================================================================
let last=performance.now();
function loop(now){
const dt=Math.min(0.033,(now-last)/1000); last=now;
if(G.state!=='menu' && G.state!=='over') update(dt);
render();
requestAnimationFrame(loop);
}
function update(dt){
// wind
G.windTimer-=dt;
if(G.windTimer<=0){ G.wind=rand(-1,1)*(G.mapType==='cave'?80:60); G.windTimer=rand(6,12); S.wind(); }
// cam freeze
if(G.cam.freeze>0) G.cam.freeze=Math.max(0,G.cam.freeze-dt);
// camera smoothing
const target=G.activeUnit && G.activeUnit.alive?G.activeUnit:{x:G.cam.x,y:G.cam.y};
const zoom=G.state==='firing'||G.state==='charging'?1.06:1;
G.cam.tz=zoom;
G.cam.x=lerp(G.cam.x,target.x,dt*4);
G.cam.y=lerp(G.cam.y,target.y,dt*4);
G.cam.z=lerp(G.cam.z,G.cam.tz,dt*4);
// shake
if(G.cam.shake>0){
G.cam.shakeX+=(rand(-1,1)*G.cam.shake - G.cam.shakeX)*0.2;
G.cam.shakeY+=(rand(-1,1)*G.cam.shake - G.cam.shakeY)*0.2;
G.cam.shake=Math.max(0,G.cam.shake-dt*40);
} else { G.cam.shakeX=0; G.cam.shakeY=0; }
updateUnits(dt);
updateProjectiles(dt);
updateParticles(dt);
updateDebris(dt);
updateFloaters(dt);
updatePickups(dt);
updateShockwaves(dt);
// after firing, end turn when projectile gone
if(G.state==='firing'){
if(G.awaitTurnEnd && G.projectiles.length===0){
G.awaitTurnEnd=false;
endTurn();
}
}
// jetpack while holding R during charging
const u=G.activeUnit;
if(jetActive && u && u.alive && (G.state==='charging' || G.state==='firing')){
u.vy-=900*dt; u.fuel--; u._jetT=0.6;
if(u.fuel<0)u.fuel=0;
if(Math.random()<0.7)G.particles.push({x:u.x-4,y:u.y+6,vx:rand(-30,30),vy:rand(60,140),
life:0.4,t:0,sz:rand(3,6),type:'fire',col:'#ffb03d'});
}
updateTurnTimer(dt);
maybeSpawnPickup();
// win check
checkWin();
}
function updateUnits(dt){
for(const u of G.units){
if(!u.alive)continue;
const inWater = u.y > G.waterLevel;
if(inWater){
// buoyancy + slow
u.vy = lerp(u.vy, 40, dt*3);
u.vx *= (1-dt*1.5);
u.drownT+=dt;
if(u.drownT>2.2){ killWorm(u); continue; }
// float visual
u.y-=Math.sin(performance.now()/300+u.x)*0.3;
} else {
u.drownT=0;
// gravity (reduced in water handled above)
u.vy+=UNIT_GRAV*dt;
if(u.onGround && Math.abs(u.vy)<20){ /* idle */ }
}
// apply velocity
u.x+=u.vx*dt; u.y+=u.vy*dt;
// world bounds
if(u.x<20){u.x=20;u.vx*=-0.3;} if(u.x>WORLD_W-20){u.x=WORLD_W-20;u.vx*=-0.3;}
// ground collision
const gy=groundUnder(u.x,u.y).y;
const feet=u.y;
if(feet>=gy){
u.y=gy;
const wasFall=u.vy>MAX_FALL;
u.vy=0;
u.onGround=true;
// friction
u.vx*=GROUND_FRICTION;
// fall damage
if(wasFall){
const dmg=clamp((u.vy-MAX_FALL)*0.6,0,40);
if(dmg>3){ damageUnit(u,dmg,u.x,u.y); spawnDust(u.x,u.y,8); }
}
if(u.vx>20 && Math.random()<0.2) spawnDust(u.x,u.y,1);
} else {
u.onGround=false;
}
// air friction (horizontal)
if(!u.onGround || inWater) u.vx*= (inWater?0.96:0.995);
u.animT+=dt;
// jetpack thrust handled in firing
}
}
function updateProjectiles(dt){
for(let i=G.projectiles.length-1;i>=0;i--){
const p=G.projectiles[i];
p.life-=dt;
// wind effect
p.vx+=G.wind*dt;
p.vy+=(UNIT_GRAV*dt); // gravity on projectile
if(p.weapon.weight)p.vy+=(p.weapon.weight-1)*120*dt;
// move
const dx=p.vx*dt, dy=p.vy*dt;
p.trail.push({x:p.x,y:p.y}); if(p.trail.length>14)p.trail.shift();
p.x+=dx; p.y+=dy;
p.angle=Math.atan2(p.vy,p.vx);
p.spin+=dt*20;
// fuse weapons (dynamite)
if(p.fuse>0){ p.fuse-=dt; if(p.fuse<=0){ explode(p.x,p.y,p.weapon,p.owner); G.projectiles.splice(i,1); continue; } }
// collision with ground
if(isGround(p.x,p.y)){
if(p.fuse>0){ /* keep falling until fuse */ }
else explode(p.x,p.y,p.weapon,p.owner);
G.projectiles.splice(i,1); continue;
}
// collision with units
let hit=false;
for(const u of G.units){
if(!u.alive||u.team===p.owner)continue;
if(dist(p.x,p.y,u.x,u.y)<16){
explode(p.x,p.y,p.weapon,p.owner); hit=true; break;
}
}
if(hit){ G.projectiles.splice(i,1); continue; }
if(p.y>WORLD_H||p.x<-50||p.x>WORLD_W+50||p.life<=0){
if(p.fuse<=0) explode(p.x,p.y,p.weapon,p.owner);
G.projectiles.splice(i,1);
}
}
}
function updateParticles(dt){
for(let i=G.particles.length-1;i>=0;i--){
const p=G.particles[i];
p.life-=dt;
if(p.life<=0){G.particles.splice(i,1);continue;}
p.vy+= (p.type==='smoke'?-30:300)*dt;
p.x+=p.vx*dt; p.y+=p.vy*dt;
p.vx*=0.96; p.vy*=0.96;
if(p.type==='smoke')p.sz+=dt*20;
}
}
function updateDebris(dt){
for(let i=G.debris.length-1;i>=0;i--){
const d=G.debris[i];
d.life-=dt;
if(d.life<=0){G.debris.splice(i,1);continue;}
d.vy+=GRAV*dt;
d.x+=d.vx*dt; d.y+=d.vy*dt;
d.t+=d.spin*dt;
d.vx*=0.99;
const gy=groundUnder(d.x,d.y).y;
if(d.y>=gy){ d.y=gy; d.vy*=-0.35; d.vx*=0.6; if(Math.abs(d.vy)<20)d.vy=0; }
if(d.life<0.3)G.debris.splice(i,1);
}
}
function updateFloaters(dt){
for(let i=G.floaters.length-1;i>=0;i--){
const f=G.floaters[i]; f.life-=dt; f.y+=f.vy*dt; f.vy*=0.96;
if(f.life<=0)G.floaters.splice(i,1);
}
}
function updatePickups(dt){
for(const p of G.pickups){
// gentle bob
p.bobT=(p.bobT||0)+dt;
// water float
p.y+=Math.sin(p.bobT*3)*0.4;
// gravity if below waterline
if(p.y<G.waterLevel){ p.vy=(p.vy||0)+UNIT_GRAV*dt; p.y+=p.vy*dt; if(p.y>G.waterLevel){p.vy=0;} }
// pickup by active worm
const u=G.activeUnit;
if(u&&u.alive&&dist(u.x,u.y,p.x,p.y)<24){
applyPickup(u,p.kind);
spawnSpark(p.x,p.y,'#ffd35c',12);
const idx=G.pickups.indexOf(p); if(idx>=0)G.pickups.splice(idx,1);
S.choose();
}
}
}
function updateShockwaves(dt){
for(let i=G.shockwaves.length-1;i>=0;i--){
const s=G.shockwaves[i]; s.r+=(s.max-s.r)*dt*10; s.alpha-=dt*2.5;
if(s.alpha<=0)G.shockwaves.splice(i,1);
}
}
// turn timer
function updateTurnTimer(dt){
if(G.state==='aiming'||G.state==='charging'||G.state==='action'){
G.turnLeft-=dt;
if(G.turnLeft<=0){ G.turnLeft=0; toast('Time up!'); S.warn(); endTurn(); }
}
}
function endTurn(){
if(G.state==='firing'||G.state==='cooldown')return;
G.state='turnend';
// clear active worm state
if(G.activeUnit){ G.activeUnit.vx=0; G.activeUnit.vy=0; }
setTimeout(()=>nextTurn(), 400);
}
function checkWin(){
const aliveTeams=G.teams.filter(t=>G.units.some(u=>u.team===t.wteam&&u.alive));
if(G.winMode==='surv'){
if(aliveTeams.length<=1){
endGame(aliveTeams[0]||null);
}
} else {
// score mode handled via kills -> tracked; if a team reaches scoreTarget
for(const t of G.teams){ if(t.score>=G.scoreTarget){ endGame(t); } }
}
}
// ============================================================================
// RENDER
// ============================================================================
function render(){
ctx.save();
// sky gradient
const g=ctx.createLinearGradient(0,0,0,VH);
g.addColorStop(0,'#1a2a4a'); g.addColorStop(0.5,'#2a4a7a'); g.addColorStop(1,'#4a6a9a');
ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
// stars
drawStars();
// distant mountains (parallax)
drawMountains();
// world transform
ctx.save();
ctx.translate(VW/2, VH*0.55);
ctx.scale(G.cam.z,G.cam.z);
ctx.translate(-G.cam.x+G.cam.shakeX, -G.cam.y+G.cam.shakeY);
drawWorld();
ctx.restore();
ctx.restore();
if(G.cam.freeze>0.02){ ctx.fillStyle=`rgba(255,255,255,${G.cam.freeze*0.5})`; ctx.fillRect(0,0,VW,VH); }
}
let starField=null;
function drawStars(){
if(!starField){ starField=[]; for(let i=0;i<80;i++)starField.push({x:Math.random(),y:Math.random()*0.4,s:Math.random()*1.6+0.3}); }
ctx.fillStyle='rgba(255,255,255,0.5)';
for(const s of starField){ ctx.globalAlpha=0.4+Math.random()*0.3; ctx.fillRect(s.x*VW,s.y*VH,s.s,s.s); }
ctx.globalAlpha=1;
}
function drawMountains(){
const ox=G.cam.x*0.2;
ctx.fillStyle='rgba(30,45,75,0.7)';
ctx.beginPath(); ctx.moveTo(0,VH);
for(let x=0;x<=VW;x+=40){
const wx=x+ox;
const y=VH*0.5+Math.sin(wx*0.0008)*60+Math.sin(wx*0.002)*30;
ctx.lineTo(x,y);
}
ctx.lineTo(VW,VH); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(25,38,64,0.6)';
ctx.beginPath(); ctx.moveTo(0,VH);
for(let x=0;x<=VW;x+=40){
const wx=x+ox*1.6;
const y=VH*0.6+Math.sin(wx*0.0012)*50+Math.sin(wx*0.003)*24;
ctx.lineTo(x,y);
}
ctx.lineTo(VW,VH); ctx.closePath(); ctx.fill();
}
function drawWorld(){
// water
if(G.waterImg){
const ox=0, oy=G.waterLevel;
ctx.save();
ctx.globalAlpha=1;
// draw water as filled region using terrain mask
ctx.beginPath();
// surface follows terrain top
for(let x=0;x<=WORLD_W;x+=TILE){
const gy=sampleTerrainHeight(x);
const sy=Math.max(gy,G.waterLevel);
if(x===0)ctx.moveTo(x,sy); else ctx.lineTo(x,sy);
}
ctx.lineTo(WORLD_W,WORLD_H); ctx.lineTo(0,WORLD_H); ctx.closePath();
const grd=ctx.createLinearGradient(0,G.waterLevel,0,WORLD_H);
grd.addColorStop(0,'rgba(70,140,210,0.5)'); grd.addColorStop(1,'rgba(20,70,140,0.78)');
ctx.fillStyle=grd; ctx.fill();
// surface shimmer
ctx.strokeStyle='rgba(150,200,255,0.4)'; ctx.lineWidth=2;
ctx.beginPath();
for(let x=0;x<=WORLD_W;x+=TILE){
const gy=sampleTerrainHeight(x);
const sy=Math.max(gy,G.waterLevel);
if(x===0)ctx.moveTo(x,sy); else ctx.lineTo(x,sy);
}
ctx.stroke();
ctx.restore();
}
// terrain
const w=G.terrainW, h=G.terrainH;
if(w && h){
// Build offscreen terrain canvas (rebuilt on destruction or generation)
if(!G._terrCanvas || G._terrW!==w || G._terrH!==h){
G._terrCanvas=document.createElement('canvas');
G._terrCanvas.width=w; G._terrCanvas.height=h;
G._terrW=w; G._terrH=h;
}
const tcx=G._terrCanvas.getContext('2d');
tcx.clearRect(0,0,w,h);
const img=tcx.createImageData(w,h);
const d=img.data;
for(let y=0;y<h;y++){
for(let x=0;x<w;x++){
if(G.terrain[y*w+x]===1){
const idx=(y*w+x)*4;
if(y<3){ d[idx]=123;d[idx+1]=179;d[idx+2]=74;d[idx+3]=255; }
else { d[idx]=107;d[idx+1]=74;d[idx+2]=42;d[idx+3]=255; }
}
}
}
tcx.putImageData(img,0,0);
ctx.save();
ctx.imageSmoothingEnabled=false;
ctx.drawImage(G._terrCanvas,0,0,WORLD_W,WORLD_H);
// grass top highlight line following surface
ctx.strokeStyle='#7bb34a'; ctx.lineWidth=4;
ctx.beginPath();
for(let x=0;x<=WORLD_W;x+=TILE){
const gy=sampleTerrainHeight(x);
if(x===0)ctx.moveTo(x,gy); else ctx.lineTo(x,gy);
}
ctx.stroke();
ctx.restore();
}
// draw individual terrain detail (subtle texture via small rects) - optional, skip perf
drawDebrisWorld();
drawPickups();
drawParticles();
drawProjectiles();
drawShockwaves();
drawUnits();
drawFloaters();
if(G.state==='aiming'||G.state==='charging'||G.state==='action') drawAim();
}
function drawDebrisWorld(){
for(const d of G.debris){
ctx.save(); ctx.translate(d.x,d.y); ctx.rotate(d.t);
ctx.fillStyle=d.col; ctx.fillRect(-d.sz/2,-d.sz/2,d.sz,d.sz);
ctx.restore();
}
}
function drawPickups(){
for(const p of G.pickups){
const bob=Math.sin(p.bobT*3)*3;
ctx.save(); ctx.translate(p.x,p.y+bob);
ctx.beginPath(); ctx.arc(0,0,14,0,TAU);
ctx.fillStyle='#0a1220'; ctx.strokeStyle='#ffd35c'; ctx.lineWidth=2; ctx.fill(); ctx.stroke();
ctx.font='16px serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(p.emoji,0,1);
ctx.restore();
}
}
function drawParticles(){
for(const p of G.particles){
ctx.save();
ctx.globalAlpha=clamp(p.life,0,1);
if(p.type==='smoke'){ ctx.fillStyle='rgba(80,80,80,'+clamp(p.life*0.6,0,0.6)+')'; }
else if(p.type==='dust'){ ctx.fillStyle='#8a6b4a'; }
else if(p.type==='spark'){ ctx.fillStyle=p.col; }
else { ctx.fillStyle=p.col; }
ctx.beginPath(); ctx.arc(p.x,p.y,p.sz,0,TAU); ctx.fill();
ctx.restore();
}
}
function drawProjectiles(){
for(const p of G.projectiles){
// trail
ctx.save();
for(let i=0;i<p.trail.length;i++){
const t=p.trail[i], a=i/p.trail.length;
ctx.globalAlpha=a*0.6;
ctx.fillStyle=p.weapon.id==='nuke'?'#ffcf4d':'#ffb03d';
ctx.beginPath(); ctx.arc(t.x,t.y,3*a+1,0,TAU); ctx.fill();
}
ctx.restore();
// bomb
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.angle);
ctx.fillStyle='#222'; ctx.beginPath(); ctx.arc(0,0,7,0,TAU); ctx.fill();
ctx.fillStyle=p.color; ctx.fillRect(-2,-9,4,4);
if(p.fuse>0 && Math.floor(performance.now()/80)%2){ ctx.fillStyle='#ffcf4d'; ctx.beginPath(); ctx.arc(9,0,3,0,TAU); ctx.fill(); }
ctx.restore();
}
}
function drawShockwaves(){
for(const s of G.shockwaves){
ctx.save(); ctx.globalAlpha=clamp(s.alpha,0,1);
ctx.strokeStyle=s.col; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,TAU); ctx.stroke();
ctx.restore();
}
}
function drawUnits(){
for(const u of G.units){
if(!u.alive)continue;
drawWorm(u, u===G.activeUnit);
}
}
function drawWorm(u, isActive){
const col=G.teams[u.team];
ctx.save();
ctx.translate(u.x,u.y);
// shadow
ctx.fillStyle='rgba(0,0,0,0.25)';
ctx.beginPath(); ctx.ellipse(0,2,12,5,0,0,TAU); ctx.fill();
const flip=u.dir<0?-1:1;
ctx.scale(flip,1);
const walking=Math.abs(u.vx)>10 && u.onGround;
const legPhase=walking?Math.sin(u.animT*14):0;
// legs
ctx.strokeStyle='#2a5a3a'; ctx.lineWidth=4;
ctx.beginPath();
ctx.moveTo(-4,0); ctx.lineTo(-6+legPhase*3,8);
ctx.moveTo(4,0); ctx.lineTo(6-legPhase*3,8);
ctx.stroke();
// body
ctx.fillStyle=col.main;
ctx.beginPath();
ctx.moveTo(-11,2);
ctx.quadraticCurveTo(-13,-14,0,-16);
ctx.quadraticCurveTo(13,-14,11,2);
ctx.quadraticCurveTo(0,8,-11,2);
ctx.fill();
// stripe
ctx.fillStyle=col.dark;
ctx.fillRect(-11,-2,22,4);
// head / eye
ctx.fillStyle='#fff';
ctx.beginPath(); ctx.arc(6,-10,5,0,TAU); ctx.fill();
ctx.fillStyle='#111';
ctx.beginPath(); ctx.arc(7.5,-10,2.2,0,TAU); ctx.fill();
// barrel — drawn OUTSIDE the flip so aim direction is correct
const aim=aimAngle(u);
ctx.save();
ctx.translate(4,-8);
ctx.rotate(-aim);
ctx.fillStyle='#333'; ctx.fillRect(0,-3,20,6);
ctx.fillStyle='#555'; ctx.fillRect(16,-4,4,8);
ctx.restore();
// shield bubble
if(u.shield>0){
ctx.strokeStyle='rgba(120,200,255,0.8)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,-6,18,0,TAU); ctx.stroke();
}
// jetpack
if(u.fuel>0){ ctx.fillStyle='#888'; ctx.fillRect(-14,-10,5,12); }
// drown indicator
if(u.drownT>0){ ctx.fillStyle='rgba(120,200,255,0.7)'; ctx.font='10px sans-serif'; ctx.textAlign='center';
ctx.fillText('💧',0,-24); }
ctx.restore();
// health bar (not for active to avoid clutter? show for all)
if(!isActive){
ctx.save(); ctx.translate(u.x,u.y-30);
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(-14,-3,28,5);
ctx.fillStyle=col.main; ctx.fillRect(-14,-3,28*(u.health/100),5);
ctx.restore();
} else {
// active worm marker
ctx.fillStyle=col.light; ctx.font='bold 10px sans-serif'; ctx.textAlign='center';
ctx.fillText('▲',0,-34);
}
// name label
ctx.fillStyle='rgba(255,255,255,0.85)'; ctx.font='10px sans-serif'; ctx.textAlign='center';
ctx.fillText(u.name,u.x,u.y-26);
}
function drawAim(){
const u=G.activeUnit; if(!u||!u.alive)return;
const ang=aimAngle(u);
const power=G.state==='charging'?G.charge:0.55;
const speed=UNIT_SPEED*2.4*power;
let px=u.x+Math.cos(ang)*26, py=u.y+Math.sin(ang)*26;
let pvx=Math.cos(ang)*speed, pvy=Math.sin(ang)*speed;
ctx.save();
ctx.strokeStyle='rgba(255,255,255,0.5)';
ctx.setLineDash([4,6]); ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(px,py);
for(let i=0;i<60;i++){
pvx+=G.wind*0.016; pvy+=UNIT_GRAV*0.016;
px+=pvx*0.016; py+=pvy*0.016;
if(py>WORLD_H||isGround(px,py))break;
ctx.lineTo(px,py);
}
ctx.stroke(); ctx.setLineDash([]);
// predicted landing marker
ctx.restore();
}
function drawFloaters(){
for(const f of G.floaters){
ctx.save();
ctx.globalAlpha=clamp(f.life,0,1);
ctx.fillStyle=f.col;
ctx.font=(f.big?'bold 22px':'bold 14px')+' sans-serif';
ctx.textAlign='center';
ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.lineWidth=3;
ctx.strokeText(f.text,f.x,f.y); ctx.fillText(f.text,f.x,f.y);
ctx.restore();
}
}
// absolute aim angle from worm facing + barrel angle
function aimAngle(u){
return u.dir>=0 ? u.angle : Math.PI - u.angle;
}
// ============================================================================
// INPUT
// ============================================================================
const keys={};
window.addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(keys[k])return; keys[k]=true;
if(G.state==='menu'||G.state==='over')return;
handleInput(k,e);
});
window.addEventListener('keyup',e=>{ keys[e.key.toLowerCase()]=false; });
function handleInput(k,e){
const u=G.activeUnit;
if(G.state==='aiming'||G.state==='action'){
if(k==='arrowright'||k==='d'){ u.dir=1; S.aim(); }
if(k==='arrowleft'||k==='a'){ u.dir=-1; S.aim(); }
if(k==='arrowup'||k==='w'){ u.angle=clamp(u.angle-0.03,0.05,Math.PI-0.05); S.aim(); }
if(k==='arrowdown'||k==='s'){ u.angle=clamp(u.angle+0.03,0.05,Math.PI-0.05); S.aim(); }
if(k==='q'){ // jump left
jump(-1);
}
if(k==='e'){ jump(1); }
if(k===' '){ e.preventDefault(); beginCharge(); }
if(k==='f'){ if(G.state==='aiming')fireNow(); }
// weapon nav
if(k>='1'&&k<='9'){ selectWeapon(parseInt(k)-1); }
} else if(G.state==='charging'){
if(k==='q'){ jump(-1); }
if(k==='e'){ jump(1); }
if(k===' '){ e.preventDefault(); fireNow(); } // release
if(k==='escape'){ cancelCharge(); }
}
}
function jump(dir){
const u=G.activeUnit; if(!u||!u.alive||!u.onGround)return;
u.vx+=dir*UNIT_SPEED*1.4; u.vy=-JUMP_V; u.onGround=false;
spawnDust(u.x,u.y,6); S.jump();
}
function beginCharge(){
if(G.state!=='aiming')return;
G.state='charging'; G.charge=0.4;
S.choose();
}
function cancelCharge(){
G.state='aiming'; G.charge=0; S.click();
}
function fireNow(){
const u=G.activeUnit; if(!u)return;
const wp=selectedWeapon();
if(wp.cost>u.team.credits){ toast('Not enough credits!'); S.warn(); return; }
u.team.credits-=wp.cost;
const ang=aimAngle(u);
const power=G.state==='charging'?clamp(G.charge,0.1,1):0.55;
spawnProjectile(u, u.x, u.y-10, ang, power, wp);
// recoil
u.vx-=Math.cos(ang)*power*40;
spawnDust(u.x,u.y-10,8);
G.state='firing';
G.awaitTurnEnd=true;
}
function weaponFlightTime(wp){ return wp.cat==='direct'?700:1100; }
// ---------- Jetpack / rope / spring / etc utilities ----------
function useUtility(kind){
const u=G.activeUnit; if(!u||!u.alive)return;
const wp=WEAPONS.find(w=>w.id===kind);
if(wp.cost>u.team.credits){ toast('Not enough credits!'); S.warn(); return; }
u.team.credits-=wp.cost;
if(kind==='jetpack'){
if(u.fuel<=0){toast('No fuel!');return;}
u.vy=-520; u.onGround=false; u._jetT=0.6;
S.fire();
} else if(kind==='shield'){
u.shield=1; toast('Shield active!'); S.choose();
} else if(kind==='banana'){
// drop trap in front
G.pickups.push({x:u.x+u.dir*20,y:u.y-4,vy:-100,kind:'banana',emoji:'🍌',bobT:0});
S.water();
} else if(kind==='rope'){
// zip upward toward highest ground in front
const targetX=u.x+u.dir*300;
const gy=sampleTerrainHeight(clamp(targetX,0,WORLD_W));
u.x=targetX; u.y=gy-20; u.vx=u.dir*300;
spawnDust(u.x,u.y,10); S.jump();
} else if(kind==='spring'){
// pull nearest enemy
let best=null,bd=1e9;
for(const o of G.units){ if(!o.alive||o.team===u.team)continue; const d=dist(u.x,u.y,o.x,o.y); if(d<bd){bd=d;best=o;} }
if(best){ u.vx+=(u.x-best.x)*0.08; u.vy+=(u.y-best.y)*0.06; u.onGround=false; S.spring&&S.spring(); }
}
updateHUD();
if(G.state==='aiming'){ /* utilities don't end turn */ }
}
// jetpack continuous while a key held — handled via charging-like state
let jetActive=false;
window.addEventListener('keydown',e=>{ if(e.key.toLowerCase()==='r' && G.state==='charging'){ jetActive=true; } });
window.addEventListener('keyup',e=>{ if(e.key.toLowerCase()==='r'){ jetActive=false; } });
// update jetpack during firing state
const _origUpdateUnits=updateUnits;
// ---------- Pickups spawn ----------
function maybeSpawnPickup(){
if(G.pickups.length<2 && Math.random()<0.01){
const kinds=['jetpack','shield','banana','dynamite','shell'];
const kind=kinds[randi(0,kinds.length-1)];
const wp=WEAPONS.find(w=>w.id===kind);
const x=rand(100,WORLD_W-100);
const gy=groundUnder(x,0).y;
G.pickups.push({x,y:gy-40,vy:0,kind,emoji:wp.ico,bobT:0});
S.choose();
}
}
// ============================================================================
// AI
// ============================================================================
function aiThink(){
const u=G.activeUnit; if(!u)return;
const team=G.activeTeam;
const diff=G.diff;
const acc = diff==='easy'?0.55:diff==='normal'?0.78:0.93;
// risk assessment: prefer low-value (low HP) targets that are on solid ground,
// avoiding those near water/cliffs (environmental hazard)
let targets=G.units.filter(o=>o.alive && o.team!==team.wteam);
if(!targets.length){ endTurn(); return; }
// score targets: prefer low HP, penalize near water, prefer close
function riskScore(t){
let s=0;
s += (100-t.health)/100*40; // prefer damaged
s += Math.min(1, dist(u.x,u.y,t.x,t.y)/1200)*20; // prefer closer
// environmental hazard: near water below feet
const feetY=sampleTerrainHeight(t.x);
if(t.y > G.waterLevel-10) s -= 60; // standing in water = risky for them anyway (good target) but also we might miss
if(feetY > G.waterLevel-30 && feetY < G.waterLevel+40) s += 25; // push to shoot near water edge
return s;
}
let best=null,bestS=-1e9;
for(const t of targets){ const s=riskScore(t); if(s>bestS){bestS=s;best=t;} }
const t=best;
const dx=t.x-u.x, dy=t.y-(u.y-10); // vertical drop (down positive)
const horizontal=dx;
u.dir = horizontal>=0?1:-1;
// numerical solve: find angle & power so projectile lands on target
let bestErr=1e9, bestAngle, bestPower;
const g=UNIT_GRAV, speedK=UNIT_SPEED*2.4;
// try a grid of launch angles (0..90 measured from horizontal)
for(let deg=8; deg<88; deg+=2){
const a=deg*Math.PI/180;
const cv=Math.cos(a), sv=Math.sin(a);
// solve power so that at t when x reaches horizontal:
// x = cv*speedK*power*t -> t = horizontal/(cv*speedK*power)
// but power appears in both t and dy. Use parametric: for unit speed=1,
// x=speedK*power*t, y=sv*speedK*power*t + 0.5*g*power²*t² ... messy.
// Instead binary search power for this angle.
let lo=0.1, hi=0.98, mid;
for(let it=0; it<24; it++){
mid=(lo+hi)/2;
const speed=speedK*mid;
const tx2 = horizontal/(cv*speed);
if(tx2<=0||tx2>10){ if(lo<hi)lo=mid; continue; }
const yHit = sv*speed*tx2 + 0.5*g*mid*mid*tx2*tx2; // down positive
const err=Math.abs(yHit-dy);
if(err<bestErr){ bestErr=err; bestAngle=a; bestPower=mid; }
if(yHit<dy) lo=mid; else hi=mid;
}
}
if(!bestAngle){ bestAngle=Math.atan2(Math.abs(dy),Math.abs(horizontal)); bestPower=0.55; }
// apply facing + barrel angle (u.angle is 0..PI from +x/up)
u.angle = horizontal>=0 ? (Math.PI/2 - bestAngle) + rand(-0.03,0.03)*(1-acc)
: (Math.PI/2 + bestAngle) + rand(-0.03,0.03)*(1-acc);
let power = bestPower + rand(-0.04,0.04)*(1-acc);
// wind compensation
power += G.wind*0.0003*Math.sign(horizontal||1);
power=clamp(power,0.15,0.98);
// execute after delay
const delay=500+Math.random()*700;
setTimeout(()=>{
if(G.activeUnit!==u || !u.alive){ return; }
// risk avoidance: if low health & credits, prefer shield utility
if(u.health<30 && Math.random()<0.4 && u.team.credits>=2){
u.team.credits-=2; u.shield=1; updateHUD(); toast(u.name+' uses Shield!'); S.choose();
setTimeout(()=>endTurn(),500); return;
}
fireAt(power);
}, delay);
}
function fireAt(power){
const u=G.activeUnit; const wp=selectedWeapon();
u.team.credits-=wp.cost;
const ang=aimAngle(u);
spawnProjectile(u,u.x,u.y-10,ang,power,wp);
S.fire();
G.awaitTurnEnd=true;
}
// ============================================================================
// HUD / UI
// ============================================================================
let selectedWeaponIdx=0;
function selectedWeapon(){ return WEAPONS[selectedWeaponIdx]; }
function selectWeapon(i){
if(i<0||i>=WEAPONS.length)return;
selectedWeaponIdx=i; S.choose();
buildWeaponBar();
}
function buildWeaponBar(){
const bar=document.getElementById('weaponbar'); bar.innerHTML='';
WEAPONS.forEach((w,i)=>{
const el=document.createElement('div');
el.className='weapon'+(i===selectedWeaponIdx?' sel':'');
el.innerHTML=`<div class="ico">${w.ico}</div><div class="nm">${w.name}</div>${w.cost?`<div class="cost">${w.cost}</div>`:''}`;
el.title=w.desc;
el.onclick=()=>{ selectWeapon(i); if(w.cat==='utility'&&G.state==='aiming')useUtility(w.id); };
el.addEventListener('mouseenter',()=>{ if(i===selectedWeaponIdx)S.menu(); });
bar.appendChild(el);
});
}
function buildActionRow(){
const row=document.getElementById('actionRow');
row.innerHTML='';
const info=document.createElement('div');
info.style.cssText='background:rgba(14,22,38,.85);border:1px solid rgba(120,160,255,.2);border-radius:10px;padding:8px 14px;font-size:12px;color:var(--ink2);max-width:340px;text-align:center;';
info.id='weaponInfo';
row.appendChild(info);
}
function updateWeaponInfo(){
const info=document.getElementById('weaponInfo'); if(!info)return;
const w=selectedWeapon();
const team=G.activeTeam;
info.innerHTML=`<b style="color:${G.teams[team.wteam].main}">${w.name}</b> — ${w.desc}<br>
${w.cat==='indirect'?`Dmg ${w.dmg} · Blast ${w.radius}`:w.cat==='direct'?`Dmg ${w.dmg} · Range ${w.range}`:
w.cat==='aoe'?`Dmg ${w.dmg} · Blast ${w.radius}`:`Utility`}<br>
<span style="color:${team.credits>=w.cost?'#3fe08a':'#ff5a6c'}">Credits: ${team.credits}</span> · <b>Space</b> charge & fire`;
}
function updateHUD(){
const team=G.activeTeam;
document.getElementById('teamDot').style.background=G.teams[team.wteam].main;
document.getElementById('teamDot').style.color=G.teams[team.wteam].main;
document.getElementById('teamName').textContent=team.teamName;
document.getElementById('whoTxt').textContent=(team.cpu?'CPU: ':'')+(G.activeUnit?G.activeUnit.name:'');
document.getElementById('timerVal').textContent=Math.ceil(G.turnLeft)+'s';
buildTeamList();
updateWeaponInfo();
}
function buildTeamList(){
const list=document.getElementById('teamList'); list.innerHTML='';
for(const t of G.teams){
const alive=G.units.filter(u=>u.team===t.wteam&&u.alive).length;
const totalHealth=G.units.filter(u=>u.team===t.wteam).reduce((s,u)=>s+u.health,0);
const maxH=G.units.filter(u=>u.team===t.wteam).length*100;
const row=document.createElement('div');
row.className='team-row'+(t===G.activeTeam?' active':'')+((alive===0?' dead':''));
row.innerHTML=`<span class="swatch" style="background:${G.teams[t.wteam].main};color:${G.teams[t.wteam].main}"></span>
<span class="nm">${t.teamName}${t.cpu?' 🤖':''}</span>
<span style="font-size:10px;color:#8a9ab0">${alive}</span>
<span class="hpbar"><span class="hpfill" style="width:${100*totalHealth/maxH}%"></span></span>`;
row.onclick=()=>{ if(!t.cpu){ /* hot seat manual switch could go here */ } };
list.appendChild(row);
}
}
function updateTimerBar(){
const pct=clamp(G.turnLeft/G.turnTime,0,1);
const fill=document.getElementById('timerFill');
fill.style.width=(pct*100)+'%';
fill.className='timer-fill'+(pct<0.2?' danger':pct<0.4?' warn':'');
document.getElementById('timerVal').textContent=Math.ceil(G.turnLeft)+'s';
// tension scaling
const scale=1+ (1-pct)*0.15;
document.getElementById('teamName').style.transform='scale('+scale+')';
document.getElementById('teamName').style.transformOrigin='left center';
// tempo
if(pct<0.25 && Math.random()<0.03)S.countdown();
}
let centerTimer=null;
function showCenter(big,small,dur){
const el=document.getElementById('centerMsg');
el.querySelector('.big').textContent=big;
el.querySelector('.small').textContent=small||'';
el.style.opacity=1;
if(centerTimer)clearTimeout(centerTimer);
if(dur)centerTimer=setTimeout(()=>el.style.opacity=0,dur);
}
let toastTimer=null;
function toast(msg){
const el=document.getElementById('toast');
el.textContent=msg; el.style.opacity=1;
if(toastTimer)clearTimeout(toastTimer);
toastTimer=setTimeout(()=>el.style.opacity=0,1400);
}
// ---------- Wind gauge ----------
const wc=document.getElementById('windCanvas'); const wcx=wc.getContext('2d');
function drawWindGauge(){
wcx.clearRect(0,0,70,42);
const w=G.wind, max=80;
const nx=35+ (w/max)*22;
wcx.strokeStyle='rgba(255,255,255,0.3)'; wcx.lineWidth=1;
wcx.beginPath(); wcx.moveTo(35,21); wcx.lineTo(35,21); wcx.stroke();
wcx.fillStyle='rgba(255,255,255,0.15)'; wcx.fillRect(10,18,50,6);
wcx.strokeStyle='#ffcf4d'; wcx.lineWidth=2;
wcx.beginPath(); wcx.moveTo(35,21); wcx.lineTo(nx,21); wcx.stroke();
// arrow
wcx.fillStyle='#ffcf4d'; wcx.beginPath();
const dir=w>=0?1:-1;
wcx.moveTo(nx,21); wcx.lineTo(nx-dir*7,16); wcx.lineTo(nx-dir*7,26); wcx.closePath(); wcx.fill();
wcx.fillStyle='rgba(255,255,255,0.5)'; wcx.font='9px sans-serif'; wcx.textAlign='center';
wcx.fillText(Math.abs(w).toFixed(0),35,38);
document.getElementById('windVal').textContent=(w>=0?'→':'←')+' '+Math.abs(w).toFixed(0);
}
// ============================================================================
// GAME FLOW CONTROL
// ============================================================================
function endTurn(){
if(G.state==='firing'||G.state==='cooldown'||G.state==='over'||G.state==='menu')return;
G.state='turnend';
G.turnLeft=0;
// cleanup
G.projectiles=[];
// ensure credits refresh
if(G.activeTeam)G.activeTeam.credits+=3;
updateHUD();
setTimeout(()=>nextTurn(), 500);
}
function endGame(winner){
if(G.state==='over')return;
G.state='over';
// rank teams by score / health
const ranked=[...G.teams].sort((a,b)=>(b.score||0)-(a.score||0));
const sb=document.getElementById('scoreboard'); sb.innerHTML='';
ranked.forEach((t,i)=>{
const r=document.createElement('div');
r.className='score-row'+(t===winner?' winner':'');
r.innerHTML=`<span class="rank" style="color:${G.teams[t.wteam].main}">${i+1}</span>
<span class="swatch" style="background:${G.teams[t.wteam].main}"></span>
<span style="flex:1;font-weight:700">${t.teamName}${t.cpu?' 🤖':''}</span>
<span class="badge">Score ${t.score||0}</span>`;
sb.appendChild(r);
});
if(winner){
document.getElementById('winTitle').textContent=winner.teamName+' WINS!';
document.getElementById('winSub').textContent=G.winMode==='surv'?'Last worm standing!':'Reached the score goal!';
S.win();
} else {
document.getElementById('winTitle').textContent="Draw!";
document.getElementById('winSub').textContent='No winners this round.';
}
setTimeout(()=>{ document.getElementById('gameover').classList.remove('hidden'); }, 800);
showCenter(winner?winner.teamName+' WINS!':'GAME OVER','',3000);
}
// ============================================================================
// MAIN MENU LOGIC
// ============================================================================
function setupMenu(){
// team count
const tc=document.getElementById('teamCount');
tc.querySelectorAll('button').forEach(b=>b.onclick=()=>{
tc.querySelectorAll('button').forEach(x=>x.classList.remove('sel'));
b.classList.add('sel'); G.teamCount=parseInt(b.dataset.v); S.choose(); renderTeamSetup();
});
// map type
const mt=document.getElementById('mapType');
mt.querySelectorAll('button').forEach(b=>b.onclick=()=>{
mt.querySelectorAll('button').forEach(x=>x.classList.remove('sel'));
b.classList.add('sel'); G.mapType=b.dataset.v; S.choose();
});
// turn time
const tt=document.getElementById('turnTime');
tt.querySelectorAll('button').forEach(b=>b.onclick=()=>{
tt.querySelectorAll('button').forEach(x=>x.classList.remove('sel'));
b.classList.add('sel'); G.turnTime=parseInt(b.dataset.v); S.choose();
});
// win mode
const wm=document.getElementById('winMode');
wm.querySelectorAll('button').forEach(b=>b.onclick=()=>{
wm.querySelectorAll('button').forEach(x=>x.classList.remove('sel'));
b.classList.add('sel'); G.winMode=b.dataset.v; S.choose();
});
// difficulty
const df=document.getElementById('diff');
df.querySelectorAll('button').forEach(b=>b.onclick=()=>{
df.querySelectorAll('button').forEach(x=>x.classList.remove('sel'));
b.classList.add('sel'); G.diff=b.dataset.v; S.choose();
});
renderTeamSetup();
document.getElementById('startBtn').onclick=()=>{ Sound.init(); startGame(); };
document.getElementById('againBtn').onclick=()=>{ startGame(); };
document.getElementById('menuBtn').onclick=()=>{
document.getElementById('gameover').classList.add('hidden');
document.getElementById('hud').classList.add('hidden');
document.getElementById('menu').classList.remove('hidden');
G.state='menu'; S.menu();
};
}
function renderTeamSetup(){
const box=document.getElementById('teamSetup'); box.innerHTML='';
G.teams=[];
for(let i=0;i<G.teamCount;i++){
const col=TEAM_COLORS[i];
const card=document.createElement('div');
card.className='team-card';
card.innerHTML=`<div class="tname"><span class="swatch" style="background:${col.main};color:${col.main}"></span>
<input value="${DEFAULT_NAMES[i]}" data-i="${i}" data-f="name" style="border:0;background:#0a1220;font-weight:800;padding:2px"></div>
<label>Human / CPU</label>
<select data-i="${i}" data-f="cpu">
<option value="0">Human</option>
<option value="1">CPU</option>
</select>`;
box.appendChild(card);
}
box.querySelectorAll('input').forEach(inp=>inp.oninput=e=>{
const i=parseInt(inp.dataset.i); G.teams[i].name=e.target.value;
});
box.querySelectorAll('select').forEach(sel=>sel.onchange=e=>{
const i=parseInt(sel.dataset.i); G.teams[i].cpu=sel.value==='1'; S.choose();
});
}
// ============================================================================
// MAIN UPDATE ENHANCEMENT: jetpack, pickups spawn, team credits
// ============================================================================
// wrap update to spawn pickups and handle jetpack
const _origUpdate = update;
function update(dt){
_origUpdate(dt);
maybeSpawnPickup();
// jetpack while holding R during charging
const u=G.activeUnit;
if(jetActive && u && u.alive && u._jetT>0){
u.vy-=800*dt; u.fuel--; u._jetT-=dt;
if(u.fuel<0)u.fuel=0;
if(Math.random()<0.6)G.particles.push({x:u.x-4,y:u.y+6,vx:rand(-30,30),vy:rand(60,140),
life:0.4,t:0,sz:rand(3,6),type:'fire',col:'#ffb03d'});
}
}
// ============================================================================
// INIT
// ============================================================================
G.teams=[];
// default teams placeholder for menu
for(let i=0;i<4;i++){ G.teams.push({wteam:i,teamName:DEFAULT_NAMES[i],cpu:true,credits:5,score:0}); }
setupMenu();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"91da0f53-258a-4984-beae-20756e4e6dbd","tokensIn":2978962,"tokensOut":58274,"tokensTotal":3037236,"cost":0,"turns":37,"toolCalls":36,"failedToolCalls":2,"timestamp":"2026-08-24T01:09:48.316Z"} -->