Worms
muse spark 1.2 · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 67,643 bytes · SHA-256 888c3cc59ad5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WORMSQUALL — Artillery Mayhem</title>
<link href="https://fonts.googleapis.com/css2?family=Bangers&family=Fredoka:wght@400;600;700;800&family=JetBrains+Mono:wght@700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0d1526;--panel:#18243d;--accent:#ffde59;--pink:#ff5a7a;--mint:#34d399;--sky:#38bdf8}
body{background:radial-gradient(1200px 600px at 50% -10%,#1a2e5a 0%,var(--bg) 60%);color:#fff;font-family:'Fredoka',sans-serif;overflow:hidden;height:100vh;height:100dvh;display:flex;flex-direction:column;user-select:none}
a{color:inherit}
/* HEADER */
.topbar{height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;background:linear-gradient(180deg,#111d33,#0f1a2f);border-bottom:2px solid #24365b;flex-shrink:0;z-index:10}
.logo{display:flex;align-items:center;gap:12px}
.logo h1{font-family:'Bangers',cursive;letter-spacing:1px;font-size:30px;line-height:1}
.logo h1 span{color:var(--accent);-webkit-text-stroke:1px #000;text-shadow:0 2px 0 #000}
.logo small{opacity:.7;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;font-weight:800}
.windbox{display:flex;align-items:center;gap:10px;background:#0b1222;border:1px solid #2a3a5e;border-radius:999px;padding:6px 14px}
.windbox b{font-size:11px;letter-spacing:1px;opacity:.7}
#windArrow{width:28px;height:28px;border-radius:50%;background:#1e2d4d;display:grid;place-items:center;font-size:16px;transition:transform .4s}
#windVal{font-family:'JetBrains Mono',monospace;font-weight:800;color:var(--accent)}
.timerWrap{display:flex;align-items:center;gap:10px}
.timerCircle{width:46px;height:46px;border-radius:50%;background:conic-gradient(var(--accent) 0deg,#1e2d4d 0deg);display:grid;place-items:center;border:2px solid #2a3a5e;position:relative;transition:transform .2s}
.timerCircle b{font-family:'JetBrains Mono',monospace;font-size:16px}
.timerCircle.panic{animation:pulse .45s infinite alternate}
@keyframes pulse{to{transform:scale(1.12)}}
/* MAIN LAYOUT */
.main{flex:1;display:flex;overflow:hidden}
.leftPanel{width:290px;background:linear-gradient(180deg,#141f37,#0f1a2f);border-right:2px solid #23355a;padding:14px;display:flex;flex-direction:column;gap:12px;overflow:auto}
.rightPanel{width:290px;background:linear-gradient(180deg,#141f37,#0f1a2f);border-left:2px solid #23355a;padding:14px;display:flex;flex-direction:column;gap:12px;overflow:auto}
.center{flex:1;position:relative;overflow:hidden;background:#061021;display:flex;flex-direction:column}
#gameWrap{flex:1;position:relative;overflow:hidden}
canvas#game{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
.hudOverlay{position:absolute;inset:0;pointer-events:none}
.weaponBar{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:8px;pointer-events:auto;background:rgba(10,18,33,.92);backdrop-filter:blur(10px);padding:8px;border-radius:16px;border:1px solid #2a3d63;box-shadow:0 10px 30px rgba(0,0,0,.5)}
.wbtn{width:64px;height:64px;border-radius:12px;border:2px solid #2a3d63;background:#1a2947;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;cursor:pointer;transition:.15s;position:relative}
.wbtn:hover{transform:translateY(-2px);border-color:var(--accent)}
.wbtn.active{border-color:var(--accent);background:#2a3d63;box-shadow:0 0 0 2px rgba(255,222,89,.3)}
.wbtn i{font-size:22px;line-height:1}
.wbtn span{font-size:8px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;opacity:.9}
.wbtn em{position:absolute;top:-6px;right:-6px;background:var(--accent);color:#000;font-style:normal;font-size:9px;font-weight:800;border-radius:999px;padding:1px 5px;border:1px solid #000}
.catLabel{position:absolute;top:-10px;left:50%;transform:translateX(-50%);font-size:8px;letter-spacing:1px;padding:2px 6px;border-radius:999px;font-weight:800;color:#000}
.turnBanner{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:var(--accent);color:#000;padding:6px 18px;border-radius:999px;font-family:'Bangers',cursive;font-size:22px;letter-spacing:1px;border:2px solid #000;box-shadow:0 6px 0 #000;pointer-events:none}
.turnBanner small{font-family:'Fredoka',sans-serif;font-size:11px;font-weight:800;vertical-align:middle;margin-left:8px}
.teamCard{background:linear-gradient(180deg,#1c2c4e,#16233f);border:1px solid #2a3d63;border-radius:16px;padding:10px;position:relative;overflow:hidden}
.teamCard.active{outline:2px solid var(--accent);box-shadow:0 0 18px rgba(255,222,89,.25)}
.teamHead{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.teamHead h3{font-size:13px;letter-spacing:.8px;text-transform:uppercase;display:flex;align-items:center;gap:8px}
.dot{width:12px;height:12px;border-radius:50%;border:2px solid #000;box-shadow:0 1px 0 rgba(255,255,255,.3) inset}
.wormRow{display:flex;gap:6px}
.wormChip{flex:1;background:#0f1c33;border:1px solid #2a3d63;border-radius:10px;padding:6px;text-align:center;position:relative;overflow:hidden}
.wormChip.dead{opacity:.35;filter:grayscale(1)}
.wormChip.activeWorm{border-color:var(--accent);background:#22365f}
.wormChip b{font-size:12px;display:block}
.wormChip i{font-size:10px;opacity:.7}
.hpBar{height:6px;background:#0a1120;border-radius:999px;overflow:hidden;margin-top:4px;border:1px solid #000}
.hpFill{height:100%;background:linear-gradient(90deg,#ff5a7a,#ff8a5a);width:100%;transition:width .4s}
.controlsHint{background:rgba(255,255,255,.06);border:1px dashed #2a3d63;border-radius:12px;padding:10px;font-size:11px;line-height:1.5}
.controlsHint kbd{background:#0f1c33;border:1px solid #33466e;border-bottom-width:2px;padding:1px 5px;border-radius:5px;font-family:'JetBrains Mono',monospace;font-size:10px}
.btn{appearance:none;border:2px solid #000;background:var(--accent);color:#000;font-family:'Bangers',cursive;font-size:18px;letter-spacing:.5px;padding:8px 16px;border-radius:12px;cursor:pointer;box-shadow:0 4px 0 #000;transition:.1s}
.btn:active{transform:translateY(3px);box-shadow:0 1px 0 #000}
.btn.ghost{background:#1a2947;color:#fff;border-color:#2a3d63;box-shadow:none;font-family:'Fredoka',sans-serif;font-weight:800;font-size:13px}
.btn.small{padding:6px 10px;font-size:14px}
.btnRow{display:flex;gap:8px;flex-wrap:wrap}
.select{width:100%;background:#0f1c33;color:#fff;border:1px solid #2a3d63;border-radius:10px;padding:8px 10px;font-family:'Fredoka',sans-serif;font-weight:700}
.rangeRow{display:flex;align-items:center;gap:8px;font-size:12px}
input[type=range]{flex:1;accent-color:var(--accent)}
/* SETUP MODAL */
#setup{position:absolute;inset:0;background:rgba(5,10,22,.78);backdrop-filter:blur(8px);display:grid;place-items:center;z-index:50;padding:18px}
.setupCard{width:min(860px,100%);background:linear-gradient(180deg,#182848,#111d33);border:2px solid #2a3d63;border-radius:20px;box-shadow:0 20px 60px rgba(0,0,0,.6);overflow:hidden}
.setupHead{padding:18px 20px;background:linear-gradient(90deg,#ffde59,#ffb347);color:#000;display:flex;justify-content:space-between;align-items:center}
.setupHead h2{font-family:'Bangers',cursive;font-size:32px;letter-spacing:1px}
.setupBody{padding:18px;display:grid;grid-template-columns:1.1fr .9fr;gap:18px}
@media(max-width:900px){.setupBody{grid-template-columns:1fr} .leftPanel,.rightPanel{display:none}}
.optGroup{background:rgba(255,255,255,.05);border:1px solid #2a3d63;border-radius:14px;padding:12px}
.optGroup h4{font-size:11px;letter-spacing:1.2px;opacity:.7;margin-bottom:8px;text-transform:uppercase}
.terrainBtns{display:flex;gap:8px}
.tBtn{flex:1;padding:10px;border-radius:12px;border:2px solid #2a3d63;background:#0f1c33;color:#fff;cursor:pointer;text-align:center}
.tBtn.active{border-color:var(--accent);background:#22365f}
.tBtn i{font-size:22px;display:block}
.teamToggles{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.teamToggle{display:flex;align-items:center;gap:8px;background:#0f1c33;border:1px solid #2a3d63;border-radius:10px;padding:8px}
.teamToggle input{accent-color:var(--accent)}
/* UTIL BAR */
.utilBar{display:flex;gap:6px;flex-wrap:wrap}
.uBtn{padding:6px 10px;border-radius:999px;border:1px solid #2a3d63;background:#0f1c33;color:#fff;font-size:11px;font-weight:800;cursor:pointer}
.uBtn.active{border-color:var(--mint);background:#1a3a2e}
/* FLOAT TEXT */
.floatText{position:absolute;color:#fff;font-family:'Bangers',cursive;font-size:22px;-webkit-text-stroke:1px #000;text-shadow:0 2px 0 #000;pointer-events:none;animation:floatUp .9s ease-out forwards}
@keyframes floatUp{0%{transform:translateY(0) scale(1);opacity:1}100%{transform:translateY(-46px) scale(1.1);opacity:0}}
/* SHAKE */
.shake{animation:shake .35s}
@keyframes shake{0%,100%{transform:translate(0)}20%{transform:translate(-6px,3px)}40%{transform:translate(6px,-2px)}60%{transform:translate(-4px,2px)}80%{transform:translate(4px,1px)}}
.powerMeter{position:absolute;left:50%;bottom:92px;transform:translateX(-50%);width:280px;height:14px;background:#0a1120;border:1px solid #2a3d63;border-radius:999px;overflow:hidden;display:none;pointer-events:none}
.powerFill{height:100%;width:0%;background:linear-gradient(90deg,#34d399,#ffde59,#ff5a7a);transition:width .05s}
.aimInfo{position:absolute;top:64px;right:14px;background:rgba(10,18,33,.9);border:1px solid #2a3d63;border-radius:12px;padding:8px 10px;font-size:11px;line-height:1.4;pointer-events:none}
.zoomBadge{position:absolute;bottom:14px;left:14px;background:rgba(10,18,33,.9);border:1px solid #2a3d63;border-radius:999px;padding:6px 10px;font-size:11px;font-weight:800}
</style>
</head>
<body>
<div class="topbar">
<div class="logo">
<h1>WORM<span>SQUALL</span></h1>
<small>Artillery Mayhem • Hot-Seat + CPU</small>
</div>
<div class="windbox" title="Wind affects projectiles & light worms">
<b>WIND</b>
<div id="windArrow">➤</div>
<span id="windVal">+2</span>
<span style="opacity:.6;font-size:11px" id="windDesc">Breeze</span>
</div>
<div class="timerWrap">
<div style="text-align:right;line-height:1">
<div style="font-size:11px;letter-spacing:1px;opacity:.7;font-weight:800" id="turnLabel">TEAM RED TURN</div>
<div style="font-size:10px;opacity:.6">Move: A/D • Jump: W • Aim: Mouse • Power: Hold SPACE</div>
</div>
<div class="timerCircle" id="timerCircle"><b id="timerNum">30</b></div>
<button class="btn ghost" id="btnSkip" style="padding:6px 10px">END TURN ↩</button>
</div>
</div>
<div class="main">
<div class="leftPanel">
<div class="btnRow">
<button class="btn small" id="btnRestart">↻ New Match</button>
<button class="btn ghost small" id="btnSound">🔊 Sound ON</button>
</div>
<div id="teamsLeft"></div>
<div class="controlsHint">
<b>How to play</b><br>
<kbd>A</kbd> <kbd>D</kbd> move • <kbd>W</kbd> jump / jetpack • <kbd>Mouse</kbd> aim • Hold <kbd>SPACE</kbd> to charge • Release to fire<br>
Camera follows active worm • <kbd>Scroll</kbd> to zoom • <kbd>Q/E</kbd> switch worm view when spectating projectile
</div>
<div class="optGroup">
<h4>Utilities</h4>
<div class="utilBar">
<button class="uBtn" data-util="jetpack">🚀 Jetpack (4s)</button>
<button class="uBtn" data-util="teleport">🌀 Teleport</button>
<button class="uBtn" data-util="shield">🛡️ Shield</button>
<button class="uBtn" data-util="health">💉 +40 HP</button>
</div>
<div style="font-size:10px;opacity:.6;margin-top:6px">Utilities cost 3s of turn time. Shield lasts until hit.</div>
</div>
<div class="optGroup">
<h4>Camera</h4>
<div class="rangeRow"><span>Zoom</span><input id="zoomRange" type="range" min="0.6" max="1.6" step="0.05" value="1"></div>
</div>
</div>
<div class="center">
<div id="gameWrap">
<canvas id="game" width="1280" height="720"></canvas>
<div class="hudOverlay">
<div class="turnBanner" id="turnBanner">RED TEAM <small>— AIM & FIRE!</small></div>
<div class="aimInfo" id="aimInfo">Angle 32° • Power 0%<br><span style="opacity:.7">Trajectory predicted with wind</span></div>
<div class="powerMeter" id="powerMeter"><div class="powerFill" id="powerFill"></div></div>
<div class="zoomBadge" id="zoomBadge">● LIVE • 60 FPS • Wind physics ON</div>
</div>
<div class="weaponBar" id="weaponBar"></div>
<!-- Setup Screen -->
<div id="setup">
<div class="setupCard">
<div class="setupHead">
<h2>⚔️ New Battle</h2>
<div style="font-weight:800;opacity:.8">2-4 TEAMS • 4 WORMS EACH</div>
</div>
<div class="setupBody">
<div style="display:flex;flex-direction:column;gap:12px">
<div class="optGroup">
<h4>Terrain Shape</h4>
<div class="terrainBtns">
<button class="tBtn active" data-terrain="island"><i>🏝️</i><b>Single Island</b><small style="display:block;opacity:.7">Classic hill</small></button>
<button class="tBtn" data-terrain="two"><i>🌊</i><b>Two Islands</b><small style="display:block;opacity:.7">Gap & tactics</small></button>
<button class="tBtn" data-terrain="cave"><i>🕳️</i><b>Cave</b><small style="display:block;opacity:.7">Roof & tunnels</small></button>
</div>
</div>
<div class="optGroup">
<h4>Teams (2-4) & CPU</h4>
<div class="teamToggles" id="teamToggles"></div>
<div style="display:flex;gap:8px;margin-top:10px">
<label style="font-size:12px;display:flex;gap:6px;align-items:center"><input type="checkbox" id="cpuEnable" checked> Enable CPU for unchecked teams</label>
</div>
</div>
<button class="btn" id="btnStart" style="width:100%;font-size:22px;padding:12px">▶ LAUNCH BATTLE</button>
<div style="font-size:11px;opacity:.6;text-align:center">Hot-seat multiplayer • CPU uses risk-assessment • Destructible pixel terrain • Wind & water physics</div>
</div>
<div class="optGroup" style="display:flex;flex-direction:column;gap:10px">
<h4>Arsenal Preview</h4>
<div style="font-size:12px;line-height:1.5">
<b>Indirect (parabolic):</b> Bazooka, Grenade (3s + bounce), Mortar (cluster)<br>
<b>Direct / Instant:</b> Shotgun (3 pellets), Laser Rifle (hitscan)<br>
<b>High-AoE / Map-altering:</b> Airstrike, Holy Bomb, Dynamite (drop)<br>
<b>Utilities:</b> Jetpack, Teleport, Shield, Medkit
</div>
<div style="background:#0f1c33;border-radius:12px;padding:10px;border:1px solid #2a3d63">
<b style="font-size:12px">✨ Juice</b>
<ul style="margin:6px 0 0 16px;font-size:11px;opacity:.85;line-height:1.6">
<li>Predictive trajectory with wind</li>
<li>Dynamic camera zoom & smooth follow</li>
<li>Impact freeze + screen shake</li>
<li>Floating combat text & debris chunks</li>
<li>Turn timer tension (scale + tick tempo)</li>
</ul>
</div>
<div style="background:linear-gradient(90deg,#ffde59,#ff8a5a);color:#000;border-radius:12px;padding:10px;text-align:center;font-weight:800">Tip: Wind changes each turn — aim off-target!</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="rightPanel">
<div id="teamsRight"></div>
<div class="optGroup">
<h4>Match Log</h4>
<div id="log" style="height:160px;overflow:auto;font-size:12px;line-height:1.4;display:flex;flex-direction:column;gap:4px"></div>
</div>
<div class="optGroup">
<h4>Legend</h4>
<div style="font-size:11px;line-height:1.7;opacity:.85">
🌬️ Wind pushes projectiles & drifting particles<br>
💧 Water at bottom — fall = drown (15 dmg/sec)<br>
🧱 Brown debris falls with physics after explosions<br>
⏱ Timer panic = UI pulse + faster ticks
</div>
</div>
</div>
</div>
<script>
/* ===== SOUND ENGINE (WebAudio) ===== */
let AC, soundOn=true;
function ac(){ if(!AC) AC=new (window.AudioContext||window.webkitAudioContext)(); if(AC.state==='suspended') AC.resume(); return AC; }
function tone(freq, dur, type='sine', gain=0.18, slide=0){
if(!soundOn) return;
try{
const a=ac(), o=a.createOscillator(), g=a.createGain(), f=a.createBiquadFilter();
o.type=type; o.frequency.value=freq;
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(40,freq*slide), a.currentTime+dur);
f.type='lowpass'; f.frequency.value=2800;
g.gain.value=gain; g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime+dur);
o.connect(f).connect(g).connect(a.destination); o.start(); o.stop(a.currentTime+dur);
}catch(e){}
}
function sfx(name){
if(!soundOn) return;
if(name==='select') tone(900,.12,'square',.12);
if(name==='hover') tone(1200,.08,'sine',.07);
if(name==='start') {tone(440,.2,'square',.2); setTimeout(()=>tone(660,.22,'square',.18),120); setTimeout(()=>tone(880,.3,'square',.15),240);}
if(name==='jump') {tone(300,.25,'sine',.22,1.8)}
if(name==='shoot') {tone(140,.18,'square',.28,0.6); setTimeout(()=>tone(80,.25,'triangle',.18),60)}
if(name==='shotgun') {tone(200,.08,'square',.3); setTimeout(()=>tone(180,.08,'square',.28),70); setTimeout(()=>tone(160,.08,'square',.26),140)}
if(name==='laser') {tone(1400,.15,'square',.15,0.5)}
if(name==='explode') {tone(90,.45,'square',.35,0.4); setTimeout(()=>tone(60,.35,'triangle',.25),80)}
if(name==='holy') {tone(600,.6,'sine',.2,1.5); setTimeout(()=>tone(120,.7,'square',.35,0.5),350)}
if(name==='tick') tone(1800,.06,'square',.08)
if(name==='tickFast') tone(2200,.05,'square',.12)
if(name==='hurt') tone(220,.25,'sawtooth',.18,0.7)
if(name==='drown') tone(180,.5,'triangle',.15,0.8)
if(name==='power') tone(700,.08,'sine',.1)
if(name==='wind') {} // subtle
}
/* ===== GAME STATE ===== */
const WORLD_W=2400, WORLD_H=760;
const WATER_Y= 640;
const GRAVITY=0.38;
const TEAMS_DEF=[
{id:0,name:'RED',color:'#ff5a5a',dot:'#ff2b2b'},
{id:1,name:'BLUE',color:'#3b82f6',dot:'#1d4ed8'},
{id:2,name:'GREEN',color:'#22c55e',dot:'#15803d'},
{id:3,name:'YELLOW',color:'#eab308',dot:'#a16207'},
];
const WEAPONS=[
{id:'bazooka', name:'Bazooka', icon:'🎯', cat:'Indirect', dmg:42, aoe:86, power:14, desc:'Parabolic missile', color:'#ffde59'},
{id:'grenade', name:'Grenade', icon:'🍍', cat:'Indirect', dmg:38, aoe:72, power:12, desc:'Bounces, 2.1s fuse', color:'#a3e635'},
{id:'mortar', name:'Mortar', icon:'☄️', cat:'Indirect', dmg:18, aoe:54, power:15, desc:'Air burst → 4 bomblets', color:'#fb7185'},
{id:'shotgun', name:'Shotgun', icon:'💥', cat:'Direct', dmg:16, aoe:20, power:22, desc:'3 instant pellets', color:'#f97316'},
{id:'laser', name:'Laser Rifle', icon:'🔫', cat:'Direct', dmg:32, aoe:10, power:30, desc:'Instant hitscan', color:'#38bdf8'},
{id:'airstrike', name:'Airstrike', icon:'✈️', cat:'High-AoE', dmg:28, aoe:78, power:0, desc:'5 bombs across', color:'#c084fc'},
{id:'dynamite', name:'Dynamite', icon:'🧨', cat:'High-AoE', dmg:55, aoe:110, power:2, desc:'Drop & run!', color:'#facc15'},
{id:'holy', name:'Holy Bomb', icon:'✨', cat:'High-AoE', dmg:78, aoe:160, power:10, desc:'Map-altering kaboom', color:'#fef08a'},
];
let canvas, ctx, terrainCanvas, tctx;
let camera={x:WORLD_W/2, y:WORLD_H/2, zoom:1, tx:WORLD_W/2, ty:WORLD_H/2, tz:1};
let worms=[], projectiles=[], particles=[], debris=[], floatTexts=[], activeWorm=null;
let currentTeamIdx=0, turnTime=30, turnTimer=null, wind=2, aimingAngle= 32*Math.PI/180, aimingPower=0, isCharging=false, chargeVal=0, selectedWeapon=WEAPONS[0], gameState='setup'; // setup, aiming, projectile, gameover
let mouseWorld={x:0,y:0}, powerDir=1;
let terrainShape='island';
let teamConfigs=[]; // {enabled, isCPU}
let shakeTime=0, freezeTime=0, timeScale=1;
/* ===== TERRAIN ===== */
function initTerrainCanvases(){
terrainCanvas=document.createElement('canvas');
terrainCanvas.width=WORLD_W; terrainCanvas.height=WORLD_H;
tctx=terrainCanvas.getContext('2d');
}
function generateTerrain(shape){
tctx.clearRect(0,0,WORLD_W,WORLD_H);
// sky gradient will be drawn on main ctx; terrain is green/brown shapes
tctx.save();
if(shape==='island'){
// Single island hilly
tctx.fillStyle='#1a3a18';
tctx.beginPath();
tctx.moveTo(0,WATER_Y+30);
for(let x=0;x<=WORLD_W;x+=8){
let nx=x/WORLD_W;
let h= Math.sin(nx*Math.PI*1.2)*140 + Math.sin(nx*Math.PI*4)*22 + Math.cos(nx*Math.PI*7)*10;
let y= WATER_Y - 110 - h;
if(x===0) tctx.lineTo(x,y); else tctx.lineTo(x,y);
}
tctx.lineTo(WORLD_W,WATER_Y+30); tctx.lineTo(0,WATER_Y+30); tctx.closePath(); tctx.fill();
// top grass
tctx.fillStyle='#3f9a2e'; tctx.globalAlpha=0.95; tctx.fill(); tctx.globalAlpha=1;
// texture bumps
tctx.fillStyle='rgba(0,0,0,.08)'; for(let i=0;i<120;i++){let x=Math.random()*WORLD_W, y= WATER_Y - 40 - Math.random()*140; tctx.beginPath(); tctx.arc(x,y, 6+Math.random()*10,0,Math.PI*2); tctx.fill();}
} else if(shape==='two'){
tctx.fillStyle='#1a3a18';
tctx.beginPath();
tctx.moveTo(0,WATER_Y+30);
for(let x=0;x<=WORLD_W;x+=8){
let nx=x/WORLD_W;
let island= Math.sin(nx*Math.PI*2)*0.5 +0.5; // 0..1
// make two humps with gap
let h = Math.sin(nx*Math.PI*2)*90 + Math.cos(nx*Math.PI*6)*18;
// gap in middle
let gap = Math.exp(-Math.pow((nx-0.5)/0.09,2))*140;
h -= gap;
let y= WATER_Y - 100 - h;
tctx.lineTo(x,y);
}
tctx.lineTo(WORLD_W,WATER_Y+30); tctx.lineTo(0,WATER_Y+30); tctx.closePath(); tctx.fill();
tctx.fillStyle='#3f9a2e'; tctx.globalAlpha=0.9; tctx.fill(); tctx.globalAlpha=1;
} else if(shape==='cave'){
// cave: floor + ceiling with opening
tctx.fillStyle='#2b2117';
// floor
tctx.beginPath();
tctx.moveTo(0,WATER_Y+40);
for(let x=0;x<=WORLD_W;x+=10){
let y= WATER_Y - 60 - Math.sin(x*0.004)*28 - Math.sin(x*0.012)*10;
tctx.lineTo(x,y);
}
tctx.lineTo(WORLD_W,WORLD_H); tctx.lineTo(0,WORLD_H); tctx.closePath(); tctx.fill();
// ceiling
tctx.fillStyle='#1c130d';
tctx.beginPath();
tctx.moveTo(0,0); tctx.lineTo(WORLD_W,0); tctx.lineTo(WORLD_W, 150);
for(let x=WORLD_W;x>=0;x-=10){
let y= 150 + Math.sin(x*0.005)*30 + Math.cos(x*0.011)*12;
// opening center
let open = Math.exp(-Math.pow((x-WORLD_W/2)/220,2))*120;
y += open;
tctx.lineTo(x,y);
}
tctx.closePath(); tctx.fill();
// pillars
tctx.fillStyle='#3a2a1a';
for(let i=0;i<3;i++){let x= 420 + i*680, w= 90+Math.random()*40; tctx.fillRect(x, 180, w, WATER_Y-240);}
// grass on floor
tctx.fillStyle='#3f9a2e'; tctx.fillRect(0,WATER_Y-68,WORLD_W,18);
}
tctx.restore();
// add rocks detail
tctx.fillStyle='rgba(255,255,255,.06)';
for(let i=0;i<70;i++){ tctx.beginPath(); tctx.arc(Math.random()*WORLD_W, 320+Math.random()*300, 2+Math.random()*4,0,Math.PI*2); tctx.fill(); }
}
function carveTerrain(x,y,r){
tctx.save();
tctx.globalCompositeOperation='destination-out';
tctx.beginPath(); tctx.arc(x,y,r,0,Math.PI*2); tctx.fill();
// irregular edge
tctx.beginPath(); tctx.arc(x+ r*0.3,y- r*0.2, r*0.6,0,Math.PI*2); tctx.fill();
tctx.beginPath(); tctx.arc(x- r*0.25,y+ r*0.25, r*0.5,0,Math.PI*2); tctx.fill();
tctx.restore();
// spawn debris chunks
let n= Math.floor(r/10)+3;
for(let i=0;i<n;i++){
debris.push({
x:x+ (Math.random()-0.5)*r, y:y+ (Math.random()-0.5)*r,
vx:(Math.random()-0.5)*6, vy:-Math.random()*5 -1,
r: 4+Math.random()* (r*0.18), rot:Math.random()*Math.PI, vr:(Math.random()-0.5)*0.3,
life:1, solid:true
});
}
}
function isTerrainSolid(wx,wy){
if(wx<0||wx>=WORLD_W||wy<0||wy>=WORLD_H) return false;
// read pixel alpha; downsample check by direct data? Use tctx getImageData for 1px (okay occasional)
// optimize: use offscreen cache? but fine for few checks per frame
try{
const d=tctx.getImageData(Math.floor(wx), Math.floor(wy),1,1).data;
return d[3] > 10;
}catch(e){return false;}
}
/* ===== SPAWN WORMS ===== */
function spawnWorms(){
worms=[];
const enabledTeams= teamConfigs.map((c,i)=>c.enabled?i:null).filter(v=>v!==null);
let idx=0;
enabledTeams.forEach(ti=>{
for(let w=0;w<4;w++){
let x;
// spread across terrain
let seg= WORLD_W / enabledTeams.length;
let base = ti*seg + 80 + Math.random()*(seg-160);
// vary for cave we keep inside
x= base + (Math.random()-0.5)*80;
let y= findGroundY(x);
worms.push({
id:`${ti}-${w}`, team:ti, name: `Worm ${w+1}`, x, y: y-10, vx:0, vy:0,
r:13, hp:100, maxHp:100, alive:true, onGround:false, facing: Math.random()>0.5?1:-1,
jetFuel:0, shield:false, drownTime:0
});
idx++;
}
});
// ensure active worm is first of first team
currentTeamIdx=0;
nextTurn(true);
}
function findGroundY(x){
// scan down from top to find first solid then find top surface
for(let y=80;y<WATER_Y+20;y++){
if(isTerrainSolid(x,y)){
// find top continuous
let top=y;
while(top>0 && isTerrainSolid(x,top-1)) top--;
// find actually surface just above
return top-1;
}
}
return WATER_Y-20;
}
/* ===== TURN LOGIC ===== */
function getAliveTeams(){
const map=new Map();
worms.forEach(w=>{ if(w.alive) map.set(w.team,(map.get(w.team)||0)+1)});
return [...map.keys()];
}
function getActiveTeamWorms(){
const tid= teamConfigs.findIndex((c,i)=> i===currentTeamIdx && c.enabled);
// currentTeamIdx is team id really
return worms.filter(w=>w.team===currentTeamIdx && w.alive);
}
function nextTurn(initial=false){
if(!initial){
// check win
const alive=getAliveTeams();
if(alive.length<=1){
gameState='gameover';
logMsg(`🏆 TEAM ${TEAMS_DEF[alive[0]]?.name || '???'} WINS!`, '#ffde59');
document.getElementById('turnBanner').textContent= `🏆 ${TEAMS_DEF[alive[0]]?.name} VICTORY!`;
sfx('holy'); return;
}
}
// rotate to next alive team
let tries=0;
do{
currentTeamIdx = (currentTeamIdx+1)%4;
if(currentTeamIdx===0 && initial) break;
tries++;
if(tries>10) break;
} while(!teamConfigs[currentTeamIdx]?.enabled || !worms.some(w=>w.team===currentTeamIdx && w.alive));
if(!worms.some(w=>w.team===currentTeamIdx && w.alive)){
// find any alive
const alive=getAliveTeams(); currentTeamIdx=alive[0];
}
// pick active worm: the one with most hp or next in rotation
const teamWorms= worms.filter(w=>w.team===currentTeamIdx && w.alive);
activeWorm = teamWorms[Math.floor(Math.random()*teamWorms.length)] || teamWorms[0];
// wind changes each turn
wind = (Math.random()*16-8);
wind = Math.round(wind*10)/10;
updateWindUI();
// reset timer
turnTime=30;
updateTimerUI();
clearInterval(turnTimer);
turnTimer=setInterval(tickTurn,1000);
gameState='aiming';
isCharging=false; chargeVal=0;
updateBanner();
logMsg(`→ ${TEAMS_DEF[currentTeamIdx].name} turn — ${activeWorm.name}`, TEAMS_DEF[currentTeamIdx].color);
// camera target
camera.tx=activeWorm.x; camera.ty=activeWorm.y-80;
// AI turn?
if(teamConfigs[currentTeamIdx]?.isCPU){
setTimeout(()=> aiTakeTurn(), 900);
}
}
function tickTurn(){
if(gameState!=='aiming') return;
turnTime--;
updateTimerUI();
if(turnTime<=0){
logMsg(`⏱ Time up!`, '#ff8a5a');
endTurn();
} else if(turnTime<=5){
sfx('tickFast');
document.getElementById('timerCircle').classList.add('panic');
} else if(turnTime<=10){
sfx('tick');
}
}
function endTurn(){
clearInterval(turnTimer);
document.getElementById('timerCircle').classList.remove('panic');
// if projectile still flying, wait
if(projectiles.length>0){
setTimeout(()=>endTurn(),400);
return;
}
nextTurn();
}
function updateWindUI(){
const el=document.getElementById('windVal'), arr=document.getElementById('windArrow'), desc=document.getElementById('windDesc');
el.textContent=(wind>0?'+':'')+wind.toFixed(1);
arr.style.transform=`rotate(${wind>0?0:180}deg) scale(${0.9+Math.min(1.4,Math.abs(wind)/8)})`;
let label='Calm'; if(Math.abs(wind)<2) label='Calm'; else if(Math.abs(wind)<5) label='Breeze'; else if(Math.abs(wind)<8) label='Gale'; else label='Storm!';
desc.textContent=label;
}
function updateTimerUI(){
document.getElementById('timerNum').textContent=turnTime;
document.getElementById('turnLabel').textContent=`TEAM ${TEAMS_DEF[currentTeamIdx].name} TURN`;
const deg= (1-turnTime/30)*360;
document.getElementById('timerCircle').style.background=`conic-gradient(var(--accent) ${deg}deg,#1e2d4d ${deg}deg)`;
if(turnTime<=5) document.getElementById('timerCircle').style.transform='scale(1.1)';
else document.getElementById('timerCircle').style.transform='scale(1)';
}
function updateBanner(){
const b=document.getElementById('turnBanner');
b.innerHTML=`${TEAMS_DEF[currentTeamIdx].name} TEAM <small>— ${teamConfigs[currentTeamIdx].isCPU?'CPU THINKING…':'AIM & FIRE!'}</small>`;
b.style.background=TEAMS_DEF[currentTeamIdx].color;
}
function logMsg(text,color){
const log=document.getElementById('log');
const d=document.createElement('div');
d.style.cssText=`background:rgba(255,255,255,.06);border-left:3px solid ${color||'#2a3d63'};padding:6px 8px;border-radius:8px`;
d.textContent=text; log.prepend(d);
while(log.children.length>18) log.removeChild(log.lastChild);
}
/* ===== INPUT ===== */
let keys={};
window.addEventListener('keydown',e=>{
if(e.code==='Space' && gameState==='aiming' && activeWorm && !teamConfigs[currentTeamIdx].isCPU){
if(!isCharging){ isCharging=true; chargeVal=0; powerDir=1; document.getElementById('powerMeter').style.display='block'; }
e.preventDefault();
}
if(e.key==='w' || e.key==='W' || e.code==='ArrowUp'){
if(activeWorm && gameState==='aiming' && !teamConfigs[currentTeamIdx].isCPU) doJump();
}
keys[e.code]=true;
});
window.addEventListener('keyup',e=>{
keys[e.code]=false;
if(e.code==='Space' && isCharging && gameState==='aiming'){
isCharging=false; document.getElementById('powerMeter').style.display='none';
fireWeapon(chargeVal);
}
});
canvas.addEventListener('mousemove',e=>{
const rect=canvas.getBoundingClientRect();
const mx=(e.clientX-rect.left)/rect.width*canvas.width;
const my=(e.clientY-rect.top)/rect.height*canvas.height;
// convert to world
const wx= camera.x - (canvas.width/2)/camera.zoom + mx / camera.zoom;
const wy= camera.y - (canvas.height/2)/camera.zoom + my / camera.zoom;
mouseWorld.x=wx; mouseWorld.y=wy;
if(activeWorm){
let dx= wx - activeWorm.x, dy= wy - activeWorm.y;
aimingAngle= Math.atan2(dy,dx);
// clamp
if(activeWorm) activeWorm.facing= dx>=0?1:-1;
}
updateAimInfo();
});
canvas.addEventListener('click',e=>{
if(gameState!=='aiming' || !activeWorm) return;
// teleport utility
if(selectedUtil==='teleport'){
const rect=canvas.getBoundingClientRect();
const mx=(e.clientX-rect.left)/rect.width*canvas.width;
const my=(e.clientY-rect.top)/rect.height*canvas.height;
const wx= camera.x - (canvas.width/2)/camera.zoom + mx / camera.zoom;
const wy= camera.y - (canvas.height/2)/camera.zoom + my / camera.zoom;
if(!isTerrainSolid(wx,wy) && wy < WATER_Y-10){
activeWorm.x=wx; activeWorm.y=wy; activeWorm.vx=0; activeWorm.vy=0;
selectedUtil=null; document.querySelectorAll('.uBtn').forEach(b=>b.classList.remove('active'));
turnTime=Math.max(0,turnTime-3); sfx('select'); spawnPoof(wx,wy);
logMsg(`🌀 ${activeWorm.name} teleported!`, '#38bdf8');
}
return;
}
});
let selectedUtil=null;
canvas.addEventListener('wheel',e=>{
e.preventDefault();
camera.tz= Math.min(1.6, Math.max(0.6, camera.tz + (e.deltaY>0?-0.07:0.07)));
document.getElementById('zoomRange').value=camera.tz;
},{passive:false});
function doJump(){
if(!activeWorm || !activeWorm.onGround) {
// jetpack
if(activeWorm.jetFuel>0){
activeWorm.vy -= 0.9; activeWorm.vx += activeWorm.facing*0.25;
spawnParticles(activeWorm.x, activeWorm.y+8, 3, '#ff8a5a');
sfx('jump');
return;
}
return;
}
activeWorm.vy=-7.2; activeWorm.vx+= activeWorm.facing*1.2; activeWorm.onGround=false;
sfx('jump'); spawnParticles(activeWorm.x, activeWorm.y+10, 6,'#c2b280');
}
function updateAimInfo(){
const deg=Math.round(aimingAngle*180/Math.PI);
const p=Math.round(chargeVal*100);
document.getElementById('aimInfo').innerHTML=`Angle ${deg}° • Power ${isCharging?p:0}%<br><span style="opacity:.7">Wind ${wind>0?'+':''}${wind.toFixed(1)} • ${selectedWeapon.name}</span>`;
}
/* ===== WEAPONS ===== */
function fireWeapon(power01){
if(!activeWorm || gameState!=='aiming') return;
const w=selectedWeapon;
const power = 0.35 + power01*0.95; // 0.35..1.3
const baseSpeed = w.power || 14; // weapon base power
let speed = baseSpeed * (0.55 + power*0.9);
// cap
speed=Math.min(24,speed);
const vx= Math.cos(aimingAngle)*speed + wind*0.08;
const vy= Math.sin(aimingAngle)*speed;
// UX
sfx(w.id==='shotgun'?'shotgun': w.id==='laser'?'laser':'shoot');
if(w.id==='shotgun'){
for(let i=-1;i<=1;i++){
let ang= aimingAngle + i*0.12;
let sp= speed*(0.95+Math.random()*0.1);
projectiles.push({x:activeWorm.x+Math.cos(ang)*18, y:activeWorm.y-6+Math.sin(ang)*6, vx:Math.cos(ang)*sp, vy:Math.sin(ang)*sp, r:4, weapon:w, life: 90, bounces:0, trail:[]});
}
// hitscan-ish but we use fast projectiles
} else if(w.id==='laser'){
// instant line
doLaserShot(activeWorm.x, activeWorm.y-8, aimingAngle);
} else if(w.id==='airstrike'){
// 5 bombs from sky
for(let i=0;i<5;i++){
setTimeout(()=>{
let bx= activeWorm.x + Math.cos(aimingAngle)* (120 + i*70) + (Math.random()-0.5)*30;
projectiles.push({x:bx, y: 60, vx: wind*0.12, vy: 6, r:7, weapon:{...w,dmg:28,aoe:78}, life: 200, bounces:0, trail:[]});
spawnParticles(bx,60,4,'#94a3b8');
}, i*220);
}
} else if(w.id==='dynamite'){
projectiles.push({x:activeWorm.x+activeWorm.facing*16, y:activeWorm.y-6, vx: activeWorm.facing*1.2 + wind*0.05, vy:-1.5, r:8, weapon:w, life: 260, bounces:1, isDynamite:true, timer: 180, trail:[]});
} else if(w.id==='grenade'){
projectiles.push({x:activeWorm.x+Math.cos(aimingAngle)*18, y:activeWorm.y-6, vx:Math.cos(aimingAngle)*speed, vy:Math.sin(aimingAngle)*speed, r:6, weapon:w, life:130, bounces:3, isGrenade:true, timer: 126, trail:[]});
} else if(w.id==='mortar'){
projectiles.push({x:activeWorm.x+Math.cos(aimingAngle)*18, y:activeWorm.y-6, vx:Math.cos(aimingAngle)*speed*0.95, vy:Math.sin(aimingAngle)*speed*0.95, r:6, weapon:w, life: 180, isMortar:true, trail:[]});
} else {
// bazooka / holy
projectiles.push({x:activeWorm.x+Math.cos(aimingAngle)*18, y:activeWorm.y-6, vx, vy, r: w.id==='holy'?10:6, weapon:w, life: 220, trail:[]});
}
gameState='projectile';
clearInterval(turnTimer);
// camera will follow projectile
}
function doLaserShot(ox,oy,ang){
const maxDist= 1400;
let ex= ox + Math.cos(ang)*maxDist, ey= oy + Math.sin(ang)*maxDist;
// march to find terrain hit
let hit=null, hitDist=maxDist;
for(let d=20; d<maxDist; d+=6){
let x= ox + Math.cos(ang)*d, y= oy + Math.sin(ang)*d;
if(isTerrainSolid(x,y) || y>WATER_Y){ hit={x,y}; hitDist=d; break; }
// worm hit?
for(let w of worms){ if(!w.alive) continue; if(w===activeWorm) continue; if(Math.hypot(w.x - x, w.y -8 - y)< w.r+4){ hit={x:w.x,y:w.y-8, worm:w}; hitDist=d; break; }}
if(hit) break;
}
if(hit){ ex=hit.x; ey=hit.y; }
// beam particles
for(let i=0;i<18;i++){ particles.push({x: ox + Math.cos(ang)* (hitDist*(i/18)), y: oy + Math.sin(ang)* (hitDist*(i/18)), vx:(Math.random()-0.5)*1, vy:(Math.random()-0.5)*1, life: 18, max:18, r: 2+Math.random()*3, col:'#38bdf8', gravity:0}); }
// impact
explode(ex,ey, selectedWeapon.aoe*0.6, selectedWeapon.dmg, selectedWeapon);
if(hit && hit.worm) damageWorm(hit.worm, selectedWeapon.dmg+6);
// draw beam flash
ctx.save(); ctx.translate(0,0);
// handled in render via temporary laser object
projectiles.push({isLaser:true, x:ox,y:oy, ex,ey, life:12});
setTimeout(()=>{ if(projectiles.length===0) endTurn(); }, 350);
// damage falloff for terrain laser already exploded
}
/* ===== EXPLOSIONS / DAMAGE ===== */
function explode(x,y,r,dmg,weapon){
// screen shake & freeze
shakeTime= r>90? 18: r>60?12:6;
if(r>110){ freezeTime=10; }
// carve terrain
carveTerrain(x,y,r);
// particles
spawnExplosion(x,y,r, weapon?.id==='holy'?'#fef08a':'#ff8a3c');
sfx(weapon?.id==='holy'?'holy':'explode');
// damage worms
worms.forEach(w=>{
if(!w.alive) return;
let d=Math.hypot(w.x - x, w.y -8 - y);
if(d < r + w.r + 6){
let falloff = 1 - (d / (r+12));
falloff=Math.max(0.15, falloff);
let dmgDealt = Math.round(dmg * falloff);
if(w.shield){ dmgDealt=Math.round(dmgDealt*0.35); w.shield=false; spawnFloat(w.x, w.y-18, 'SHIELDED!', '#38bdf8');}
damageWorm(w, dmgDealt);
// knockback
let ang=Math.atan2((w.y-8)-y, w.x - x);
w.vx += Math.cos(ang)* (6*falloff);
w.vy += Math.sin(ang)* (7*falloff) -2;
w.onGround=false;
}
});
// mortar cluster
if(weapon && weapon.id==='mortar'){
for(let i=0;i<4;i++){
setTimeout(()=>{
let bx= x + (Math.random()-0.5)*40, by= y - 10 - Math.random()*20;
projectiles.push({x:bx, y:by, vx:(Math.random()-0.5)*4 + wind*0.05, vy: -2+Math.random()*2, r:5, weapon:{...weapon,dmg:18,aoe:46}, life:70, trail:[]});
}, 280 + i*90);
}
}
// holy extra particles
if(weapon?.id==='holy'){
for(let i=0;i<22;i++) debris.push({x,y, vx:(Math.random()-0.5)*9, vy: -Math.random()*9, r:6+Math.random()*9, rot:0,vr:(Math.random()-0.5)*0.4, life:1, solid:true});
}
logMsg(`${weapon?weapon.name:'Explosion'} at ${Math.round(x)},${Math.round(y)} — r${Math.round(r)}`, weapon?.color||'#ffde59');
}
function damageWorm(w, amt){
if(!w.alive) return;
w.hp -= amt; if(w.hp<0) w.hp=0;
spawnFloat(w.x, w.y-22, `-${amt}`, amt>35?'#ff2b2b': '#ffde59');
sfx('hurt');
w.vx += (Math.random()-0.5)*2;
// screen flash for active
if(w===activeWorm) shakeTime=Math.max(shakeTime,6);
if(w.hp<=0){
w.alive=false; w.hp=0;
spawnParticles(w.x,w.y-8,18,'#94a3b8'); spawnFloat(w.x,w.y-30,'ELIMINATED!','#ff5a7a');
logMsg(`💀 ${TEAMS_DEF[w.team].name} ${w.name} eliminated!`, '#ff5a7a');
// check if active worm died -> end turn quickly
if(w===activeWorm && gameState==='aiming') setTimeout(()=>endTurn(), 600);
}
}
function spawnExplosion(x,y,r, col){
let n=Math.floor(r*0.6)+12;
for(let i=0;i<n;i++){
let ang=Math.random()*Math.PI*2, sp= Math.random()* (r*0.12+4);
particles.push({x, y, vx:Math.cos(ang)*sp, vy:Math.sin(ang)*sp - Math.random()*3, life: 22+Math.random()*18, max:40, r: 3+Math.random()*5, col: i%3===0?'#fff': col, gravity:0.12});
}
for(let i=0;i<12;i++) particles.push({x,y, vx:(Math.random()-0.5)*2, vy:(Math.random()-0.5)*2, life:36, max:36, r:1.5, col:'rgba(255,255,255,.6)', gravity:0});
}
function spawnParticles(x,y,count,col){
for(let i=0;i<count;i++) particles.push({x,y, vx:(Math.random()-0.5)*4, vy: -Math.random()*4 -1, life: 18+Math.random()*16, max:34, r:2+Math.random()*3, col, gravity:0.18});
}
function spawnPoof(x,y){ spawnParticles(x,y,14,'#38bdf8'); }
function spawnFloat(x,y,text,col){
floatTexts.push({x,y,text,col, life: 48, max:48});
}
/* ===== AI ===== */
function aiTakeTurn(){
if(gameState!=='aiming' || !activeWorm || !teamConfigs[currentTeamIdx].isCPU) return;
// risk assessment: find best target
let best=null, bestScore=-Infinity;
const enemies= worms.filter(w=> w.alive && w.team!==currentTeamIdx);
// evaluate each enemy as target
enemies.forEach(e=>{
let dist=Math.hypot(e.x-activeWorm.x, e.y-activeWorm.y);
let score= 0;
// prioritize low hp / close
score += (100 - e.hp)*0.6;
score += Math.max(0, 600 - dist)*0.25;
// hazard assessment: if enemy near water/cliff, extra value (easy to push)
if(e.y > WATER_Y-50) score+= 18;
// if line to enemy is blocked by terrain, penalty
let blocked=false;
let steps= 20;
for(let i=0;i<=steps;i++){
let t=i/steps, x= activeWorm.x + (e.x-activeWorm.x)*t, y= activeWorm.y + (e.y-activeWorm.y)*t - Math.sin(t*Math.PI)*90;
if(isTerrainSolid(x,y)) { blocked=true; break; }
}
if(blocked) score-= 30;
// avoid self-damage: if dist small and high AoE, penalty
if(dist<120) score-= 20;
// wind assistance: if wind pushes toward enemy, bonus
if((e.x>activeWorm.x && wind>1) || (e.x<activeWorm.x && wind<-1)) score+= 10;
if(score>bestScore){ bestScore=score; best=e; }
});
if(!best) best=enemies[0];
if(!best){ endTurn(); return; }
// choose weapon based on distance and risk
let dist=Math.hypot(best.x-activeWorm.x, best.y-activeWorm.y);
let chosen;
if(dist>520) chosen= WEAPONS.find(w=>w.id==='airstrike') || WEAPONS[0];
else if(dist<90) chosen= dist<70? WEAPONS.find(w=>w.id==='shotgun'): WEAPONS.find(w=>w.id==='grenade');
else if(Math.abs(wind)>6 && dist>200) chosen= WEAPONS.find(w=>w.id==='bazooka');
else if(best.hp<40) chosen= WEAPONS.find(w=>w.id==='laser');
else chosen= Math.random()<0.5? WEAPONS.find(w=>w.id==='bazooka'): WEAPONS.find(w=>w.id==='mortar');
// sometimes holy for fun
if(Math.random()<0.08) chosen= WEAPONS.find(w=>w.id==='holy');
selectedWeapon=chosen; renderWeaponBar();
// aim calculation: solve parabolic with wind approximation
let dx= best.x - activeWorm.x, dy= (best.y-8) - (activeWorm.y-6);
// try angles 25-65 deg
let bestAng=0, bestPow=0, bestErr=Infinity;
for(let angDeg=20; angDeg<=68; angDeg+=4){
let ang= Math.atan2(dy,dx); // direction
// we want magnitude; use angDeg but preserve sign of dx
let a = angDeg*Math.PI/180 * (dx<0? -1:1);
// if dx negative, mirror
let ax= Math.abs(dx), ay= dy;
// try power
for(let p=0.5;p<=1.25;p+=0.15){
let speed= (chosen.power||14)*(0.55+p*0.9);
let vx=Math.cos(a)*speed, vy=Math.sin(a)* (dx<0? -1:1) // need correct sign
// Actually compute with signed angle
let signedA = Math.atan2(dy, dx); // not good
// simpler: use iteration to predict landing x
// simulate quick
let sx=activeWorm.x, sy=activeWorm.y-6, svx= Math.cos(a)*speed*(dx<0?-1:1), svy= -Math.abs(Math.sin(a))*speed + (dy>0?0:0);
// adjust vy sign: upward is negative
svy = -Math.sin(a*Math.abs? a: a)*speed;
// This is messy; instead approximate by brute simulate with wind
sx=activeWorm.x; sy=activeWorm.y-6; svx= Math.cos(a)*speed*(dx<0?-1:1); svy= -Math.sin(Math.abs(a))*speed;
if(dx<0) svx = -Math.abs(svx); else svx = Math.abs(svx);
let t=0;
for(let s=0;s<120;s++){
svx += wind*0.015; svy += GRAVITY; sx+=svx; sy+=svy; t++;
if(sy>= best.y) break;
if(isTerrainSolid(sx,sy)) break;
}
let err=Math.abs(sx - best.x) + Math.abs(sy - best.y)*0.5;
if(err<bestErr){ bestErr=err; bestAng=a*(dx<0?1:1); bestPow=p; }
}
}
// fallback: direct angle to target with compensation for wind and gravity
let angToTarget= Math.atan2(dy, dx);
// add arc lift for distance
let arcLift = Math.min(0.55, dist/900);
aimingAngle = angToTarget - arcLift*(dx>0?1:-1) + (Math.random()-0.5)*0.12;
// clamp to -150..150 deg but keep roughly toward enemy
// add wind compensation
aimingAngle -= wind*0.012;
// power based on dist
let pwr= Math.min(0.98, Math.max(0.45, dist/700 + 0.35 + Math.random()*0.12));
// safety: if risk high (would blow self), reduce power
let riskDist= Math.hypot(activeWorm.x - best.x, activeWorm.y - best.y);
if(riskDist<110 && chosen.aoe>80) pwr*=0.7;
// maybe move a bit to get better position (avoid cliffs/water)
// check if active worm near water edge
let nearWater = activeWorm.y > WATER_Y-70 || isTerrainSolid(activeWorm.x + activeWorm.facing*18, activeWorm.y+6)===false;
if(nearWater && Math.random()<0.6){
// step back
activeWorm.vx = -activeWorm.facing*2.2; activeWorm.vy=-3;
setTimeout(()=>{
aimingAngle = angToTarget - arcLift*(dx>0?1:-1);
doAIFire(pwr);
}, 520);
} else {
setTimeout(()=> doAIFire(pwr), 820 + Math.random()*700);
}
// visual aiming line update
function doAIFire(p){
chargeVal=p; updateAimInfo();
// charging animation
let c=0, iv=setInterval(()=>{
c+=0.08; chargeVal=Math.min(p, c); document.getElementById('powerMeter').style.display='block'; document.getElementById('powerFill').style.width=(chargeVal*100)+'%';
if(c>=p){ clearInterval(iv); document.getElementById('powerMeter').style.display='none'; fireWeapon(p); }
}, 28);
}
}
/* ===== UPDATE LOOP ===== */
function update(){
// freeze (impact freeze)
if(freezeTime>0){ freezeTime--; return; }
// Handle charging power oscillation
if(isCharging){
chargeVal += 0.018*powerDir;
if(chargeVal>=1){ chargeVal=1; powerDir=-1; }
if(chargeVal<=0){ chargeVal=0; powerDir=1; }
document.getElementById('powerFill').style.width=(chargeVal*100)+'%';
if(Math.random()<0.15) sfx('power');
}
// keys movement (when aiming and human)
if(gameState==='aiming' && activeWorm && !teamConfigs[currentTeamIdx].isCPU){
let move=0;
if(keys['KeyA']||keys['ArrowLeft']) move=-1;
if(keys['KeyD']||keys['ArrowRight']) move=1;
if(move!==0){
activeWorm.facing=move;
// simple ground move with collision
let nx= activeWorm.x + move*1.7;
// check if next x has ground (not cliff)
let gy=findGroundY(nx);
let steep=Math.abs(gy - (activeWorm.y));
if(steep<22 && gy < WATER_Y-6){
activeWorm.x=nx;
// keep on ground
if(activeWorm.onGround) activeWorm.y= gy-6;
}
// wind affects light movement on ground little
activeWorm.x += wind*0.02;
}
// jetpack
if((keys['KeyW']||keys['ArrowUp']) && activeWorm.jetFuel>0){
activeWorm.vy -= 0.55; activeWorm.vx += wind*0.03;
activeWorm.jetFuel-=0.02;
if(Math.random()<0.5) spawnParticles(activeWorm.x, activeWorm.y+6,1,'#ff8a5a');
}
}
// physics for worms
worms.forEach(w=>{
if(!w.alive) return;
// water
if(w.y > WATER_Y-8){
w.drownTime += 1/60;
if(w.drownTime>0.4){
w.hp -= 0.9; if(w.hp<=0){ w.alive=false; spawnFloat(w.x,w.y-20,'DROWNED!','#38bdf8'); logMsg(`💧 ${w.name} drowned!`, '#38bdf8'); }
if(Math.random()<0.3) spawnParticles(w.x, WATER_Y-4,1,'#38bdf8');
w.vy *= 0.85; w.vx *= 0.9;
// bobbing
w.y = WATER_Y -8 + Math.sin(Date.now()*0.005 + w.x)*3;
}
if(w.drownTime>1) sfx('drown');
} else {
w.drownTime=Math.max(0,w.drownTime-0.02);
}
if(!w.onGround){
w.vy += GRAVITY;
w.vx += wind*0.015; // wind pushes airborne worms
w.vx *= 0.99;
} else {
w.vx *= 0.82;
if(Math.abs(w.vx)<0.05) w.vx=0;
}
let nx=w.x + w.vx, ny=w.y + w.vy;
// terrain collision vertical
// check foot solid
let footSolid=isTerrainSolid(nx, ny+6);
let headSolid=isTerrainSolid(nx, ny-14);
if(headSolid){ w.vy=1; ny=w.y+1; }
if(footSolid && w.vy>=0){
// snap to ground
let gy=findGroundY(nx);
if(ny+6 >= gy){
ny= gy -6; w.vy=0; w.onGround=true;
// fall damage
if(w._fallVy && w._fallVy>9){
let dmg=Math.round((w._fallVy-9)*5);
if(dmg>0){ damageWorm(w, Math.min(35,dmg)); w.vx+=(Math.random()-0.5)*3; }
}
w._fallVy=0;
}
} else {
if(w.vy>0) w._fallVy = Math.max(w._fallVy||0, w.vy);
w.onGround=false;
}
// side wall collision
if(isTerrainSolid(nx + w.r* w.facing, ny-4)){
w.vx*= -0.3;
nx=w.x;
}
w.x=nx; w.y=ny;
// bounds
w.x=Math.max(14, Math.min(WORLD_W-14, w.x));
if(w.y> WORLD_H+40){ // fell off bottom
w.alive=false; spawnFloat(w.x, WATER_Y-10, 'FELL!', '#ff5a7a');
}
});
// projectiles
for(let i=projectiles.length-1;i>=0;i--){
let p=projectiles[i];
if(p.isLaser){
p.life--; if(p.life<=0) projectiles.splice(i,1); continue;
}
// trail
p.trail = p.trail||[]; p.trail.push({x:p.x,y:p.y}); if(p.trail.length>14) p.trail.shift();
// dynamite timer
if(p.isDynamite){
p.timer--; p.vy+=GRAVITY; p.vx+= wind*0.01; p.x+=p.vx; p.y+=p.vy;
// ground contact
if(isTerrainSolid(p.x,p.y+4)){
p.vx*=0.7; p.vy*=-0.35; p.y-=2;
if(Math.abs(p.vy)<0.6) p.vy=0;
}
if(p.timer<=0){ explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1); }
continue;
}
if(p.isGrenade){
p.timer--;
p.vy+=GRAVITY; p.vx+= wind*0.012; p.x+=p.vx; p.y+=p.vy;
if(isTerrainSolid(p.x,p.y) || p.y>WATER_Y){
if(p.bounces>0){ p.vy*=-0.55; p.vx*=0.75; p.bounces--; p.y-=4; }
else { explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1); continue; }
} else {
// worm hit
for(let w of worms){ if(!w.alive) continue; if(Math.hypot(w.x-p.x, w.y-8-p.y)< w.r+5){ explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1); break; }}
}
if(p.timer<=0){ explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1); }
continue;
}
if(p.isMortar){
p.vy+=GRAVITY; p.vx+= wind*0.014; p.x+=p.vx; p.y+=p.vy;
// airburst after apex? explode on ground or timeout high
if(isTerrainSolid(p.x,p.y) || p.y>WATER_Y || p.life--<=0){
explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1);
} else {
for(let w of worms){ if(!w.alive) continue; if(Math.hypot(w.x-p.x,w.y-8-p.y)<w.r+6){ explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1); break; }}
}
continue;
}
// generic parabolic
p.vy+=GRAVITY; p.vx+= wind*0.014;
p.x+=p.vx; p.y+=p.vy;
p.life--;
// water
if(p.y > WATER_Y){
spawnParticles(p.x, WATER_Y-6,8,'#38bdf8'); projectiles.splice(i,1); continue;
}
if(isTerrainSolid(p.x,p.y)){
explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1); continue;
}
for(let w of worms){
if(!w.alive) continue;
// don't hit shooter for first 12 frames
if(w===activeWorm && p.life>208) continue;
if(Math.hypot(w.x - p.x, (w.y-8)-p.y) < w.r+ p.r){
explode(p.x,p.y,p.weapon.aoe,p.weapon.dmg,p.weapon); projectiles.splice(i,1); break;
}
}
if(p.life<=0) projectiles.splice(i,1);
}
// particles
for(let i=particles.length-1;i>=0;i--){
let pt=particles[i]; pt.x+=pt.vx; pt.y+=pt.vy; pt.vy+= (pt.gravity||0.12); pt.vx+= wind*0.01; pt.life--;
if(pt.life<=0) particles.splice(i,1);
}
// debris physics
for(let i=debris.length-1;i>=0;i--){
let d=debris[i]; d.vy+=GRAVITY*0.7; d.vx+= wind*0.006; d.x+=d.vx; d.y+=d.vy; d.rot+=d.vr;
if(isTerrainSolid(d.x,d.y)){
d.vy*=-0.35; d.vx*=0.6; d.vr*=0.7; d.y-=2;
if(Math.abs(d.vy)<0.8) { d.vy=0; d.vr*=0.5; }
}
if(d.y> WATER_Y){ d.vy*=-0.25; d.y= WATER_Y-2; d.vx*=0.9; }
// fade after long
if(Math.abs(d.vx)<0.05 && Math.abs(d.vy)<0.05) d.life-=0.01;
if(d.life<=0) debris.splice(i,1);
}
// float texts
for(let i=floatTexts.length-1;i>=0;i--){
let f=floatTexts[i]; f.y-=0.9; f.life--; if(f.life<=0) floatTexts.splice(i,1);
}
// camera smoothing & zoom
let targetX, targetY;
if(projectiles.length>0 && !projectiles[0].isLaser){
let p=projectiles[0]; targetX=p.x; targetY=p.y;
camera.tz= Math.min(1.25, Math.max(0.85, 1.1));
} else if(activeWorm){
targetX=activeWorm.x; targetY=activeWorm.y-70;
// keep manual zoom
camera.tz= parseFloat(document.getElementById('zoomRange').value);
} else {
targetX=WORLD_W/2; targetY=WORLD_H/2;
}
camera.tx += (targetX - camera.tx)*0.08;
camera.ty += (targetY - camera.ty)*0.08;
camera.x += (camera.tx - camera.x)*0.12;
camera.y += (camera.ty - camera.y)*0.12;
camera.zoom += (camera.tz - camera.zoom)*0.09;
// shake
if(shakeTime>0) shakeTime--;
// if projectile finished and no debris dramatic, end turn after delay
if(gameState==='projectile' && projectiles.length===0){
let movingDebris= debris.some(d=> Math.abs(d.vy)>0.7);
if(!movingDebris){
gameState='aiming'; setTimeout(()=>{ if(gameState==='aiming') endTurn(); }, 900);
gameState='waiting';
}
}
// update UI teams
renderTeams();
}
/* ===== RENDER ===== */
function render(){
const shakeX= shakeTime>0 ? (Math.random()-0.5)* (shakeTime*1.2) : 0;
const shakeY= shakeTime>0 ? (Math.random()-0.5)* (shakeTime*1.2) : 0;
ctx.save();
ctx.clearRect(0,0,canvas.width,canvas.height);
// background sky gradient
let g=ctx.createLinearGradient(0,0,0,canvas.height);
g.addColorStop(0,'#0b1a3a'); g.addColorStop(0.5,'#1e3a6b'); g.addColorStop(1,'#3b82f6');
ctx.fillStyle=g; ctx.fillRect(0,0,canvas.width,canvas.height);
// sun
ctx.fillStyle='rgba(255,222,89,.9)'; ctx.beginPath(); ctx.arc(canvas.width*0.78, 90, 42,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,222,89,.18)'; ctx.beginPath(); ctx.arc(canvas.width*0.78,90,74,0,Math.PI*2); ctx.fill();
// clouds parallax
ctx.fillStyle='rgba(255,255,255,.55)';
for(let i=0;i<4;i++){
let cx= (Date.now()*0.015 + i*340) % (canvas.width+200) -100;
ctx.beginPath(); ctx.ellipse(cx, 70+i*18, 54,18,0,0,Math.PI*2); ctx.ellipse(cx+28,74+i*18,38,14,0,0,Math.PI*2); ctx.fill();
}
// camera transform
ctx.translate(shakeX, shakeY);
ctx.translate(canvas.width/2, canvas.height/2);
ctx.scale(camera.zoom, camera.zoom);
ctx.translate(-camera.x, -camera.y);
// water
ctx.fillStyle='#0ea5e9'; ctx.fillRect(0, WATER_Y, WORLD_W, WORLD_H - WATER_Y);
ctx.fillStyle='rgba(255,255,255,.18)';
for(let x=0;x<WORLD_W;x+=28){
let y= WATER_Y + Math.sin(x*0.02 + Date.now()*0.003)*3;
ctx.fillRect(x, y, 18,2);
}
// terrain image
ctx.drawImage(terrainCanvas,0,0);
// terrain outline highlight (grass edge)
ctx.strokeStyle='rgba(63,154,46,.35)'; ctx.lineWidth=4; ctx.strokeRect(0,0,0,0); // noop
// debris
debris.forEach(d=>{
ctx.save(); ctx.translate(d.x,d.y); ctx.rotate(d.rot);
ctx.fillStyle='#5a3e2b'; ctx.strokeStyle='#3a2716'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.roundRect(-d.r,-d.r, d.r*2, d.r*1.4, 3); ctx.fill(); ctx.stroke();
ctx.restore();
});
// worms
worms.forEach(w=>{
if(!w.alive){
// grave
ctx.fillStyle='rgba(0,0,0,.35)'; ctx.beginPath(); ctx.ellipse(w.x, findGroundY(w.x), 14,5,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#2d2d2d'; ctx.font='18px serif'; ctx.textAlign='center'; ctx.fillText('✝', w.x, w.y-2);
return;
}
ctx.save(); ctx.translate(w.x, w.y);
// shadow
ctx.fillStyle='rgba(0,0,0,.25)'; ctx.beginPath(); ctx.ellipse(0, 8, 14,5,0,0,Math.PI*2); ctx.fill();
// body
let teamCol= TEAMS_DEF[w.team].color;
ctx.fillStyle= teamCol; ctx.strokeStyle='#000'; ctx.lineWidth=2;
ctx.beginPath(); ctx.ellipse(0, -2, 14, 13, 0,0,Math.PI*2); ctx.fill(); ctx.stroke();
// belly
ctx.fillStyle='rgba(255,255,255,.85)'; ctx.beginPath(); ctx.ellipse(0,2,8,6,0,0,Math.PI*2); ctx.fill();
// eyes
let eyeX= w.facing*5;
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(eyeX-3, -7, 5,0,Math.PI*2); ctx.arc(eyeX+4, -7, 4.2,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#000'; ctx.lineWidth=1.1; ctx.stroke();
ctx.fillStyle='#000'; ctx.beginPath(); ctx.arc(eyeX-2 + (activeWorm===w? Math.cos(Date.now()*0.004)*1:0), -7, 2.2,0,Math.PI*2); ctx.arc(eyeX+5, -7,1.9,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(eyeX-1.2,-8.5,0.9,0,Math.PI*2); ctx.arc(eyeX+5.8,-8.3,0.8,0,Math.PI*2); ctx.fill();
// mouth
ctx.strokeStyle='#000'; ctx.lineWidth=1; ctx.beginPath(); if(w===activeWorm && gameState==='aiming') ctx.arc(eyeX, -1, 3, 0.2, Math.PI-0.2); else ctx.ellipse(eyeX,0,2,1,0,0,Math.PI*2); ctx.stroke();
// helmet / marker for active
if(w===activeWorm){
ctx.fillStyle='#ffde59'; ctx.strokeStyle='#000'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(0,-18); ctx.lineTo(-7,-26); ctx.lineTo(7,-26); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle='#000'; ctx.font='700 7px Fredoka'; ctx.textAlign='center'; ctx.fillText('▶',0,-19);
}
// shield bubble
if(w.shield){
ctx.strokeStyle='rgba(56,189,248,.9)'; ctx.lineWidth=2; ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.arc(0,-2,19,0,Math.PI*2); ctx.stroke(); ctx.setLineDash([]);
}
// hp bar above
ctx.fillStyle='rgba(0,0,0,.7)'; ctx.fillRect(-16, -30, 32,5);
ctx.fillStyle= w.hp>50? '#22c55e' : w.hp>25? '#eab308' : '#ef4444'; ctx.fillRect(-15, -29, 30* (w.hp/100),3);
ctx.fillStyle='#fff'; ctx.font='700 7px JetBrains Mono'; ctx.textAlign='center'; ctx.fillText(w.hp,0,-32);
ctx.restore();
});
// projectiles
projectiles.forEach(p=>{
if(p.isLaser){
ctx.strokeStyle='rgba(56,189,248,.95)'; ctx.lineWidth=4; ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(p.ex,p.ey); ctx.stroke();
ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(p.ex,p.ey); ctx.stroke();
ctx.fillStyle='rgba(56,189,248,.4)'; ctx.beginPath(); ctx.arc(p.ex,p.ey,12,0,Math.PI*2); ctx.fill();
return;
}
// trail
if(p.trail){
ctx.strokeStyle= p.weapon?.id==='holy'?'rgba(254,240,138,.9)':'rgba(255,255,255,.55)';
ctx.lineWidth=2; ctx.beginPath();
p.trail.forEach((t,i)=>{ if(i===0) ctx.moveTo(t.x,t.y); else ctx.lineTo(t.x,t.y); });
ctx.stroke();
}
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(Math.atan2(p.vy,p.vx));
let col= p.weapon?.color||'#ffde59';
ctx.fillStyle=col; ctx.strokeStyle='#000'; ctx.lineWidth=1.5;
if(p.isDynamite){
ctx.fillStyle='#d92a2a'; ctx.fillRect(-7,-6,14,12); ctx.fillStyle='#fff'; ctx.font='700 8px monospace'; ctx.fillText('TNT',-9,0);
ctx.strokeRect(-7,-6,14,12);
} else if(p.weapon?.id==='grenade'){
ctx.beginPath(); ctx.arc(0,0,6,0,Math.PI*2); ctx.fill(); ctx.stroke();
ctx.fillStyle='#000'; ctx.fillRect(2,-3,4,2);
} else {
ctx.beginPath(); ctx.ellipse(0,0,7,5,0,0,Math.PI*2); ctx.fill(); ctx.stroke();
// fins
ctx.fillStyle='#000'; ctx.beginPath(); ctx.moveTo(-6,-4); ctx.lineTo(-9,-7); ctx.lineTo(-6,0); ctx.closePath(); ctx.fill();
}
ctx.restore();
// grenade timer text
if(p.isGrenade){ ctx.fillStyle='#000'; ctx.font='700 8px JetBrains Mono'; ctx.textAlign='center'; ctx.fillText(Math.ceil(p.timer/60),p.x,p.y-12); }
});
// particles
particles.forEach(pt=>{
ctx.globalAlpha= Math.max(0, pt.life/pt.max);
ctx.fillStyle=pt.col; ctx.beginPath(); ctx.arc(pt.x,pt.y, pt.r,0,Math.PI*2); ctx.fill();
ctx.globalAlpha=1;
});
// predictive trajectory (only when aiming human)
if(gameState==='aiming' && activeWorm && !teamConfigs[currentTeamIdx].isCPU){
let power = 0.35 + chargeVal*0.95;
let speed = (selectedWeapon.power||14)*(0.55+power*0.9);
let ang= aimingAngle;
let sx=activeWorm.x, sy=activeWorm.y-6, vx=Math.cos(ang)*speed, vy=Math.sin(ang)*speed;
ctx.fillStyle='rgba(255,255,255,.85)';
ctx.strokeStyle='rgba(255,222,89,.9)'; ctx.setLineDash([3,5]); ctx.lineWidth=1.2; ctx.beginPath(); ctx.moveTo(sx,sy);
let dots=[];
for(let i=0;i<38;i++){
vx+= wind*0.014; vy+=GRAVITY; sx+=vx; sy+=vy;
dots.push({x:sx,y:sy});
if(isTerrainSolid(sx,sy) || sy>WATER_Y) break;
}
dots.forEach((d,i)=>{ if(i%2===0){ ctx.fillStyle= i%4===0?'#ffde59':'rgba(255,255,255,.9)'; ctx.beginPath(); ctx.arc(d.x,d.y, i%4===0?3:1.7,0,Math.PI*2); ctx.fill(); }});
// line
ctx.beginPath(); dots.forEach((d,i)=> i===0?ctx.moveTo(d.x,d.y):ctx.lineTo(d.x,d.y)); ctx.stroke(); ctx.setLineDash([]);
// impact marker
let last=dots[dots.length-1];
if(last){ ctx.strokeStyle='#ff5a7a'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(last.x,last.y, 10,0,Math.PI*2); ctx.stroke(); ctx.fillStyle='rgba(255,90,122,.2)'; ctx.beginPath(); ctx.arc(last.x,last.y,10,0,Math.PI*2); ctx.fill(); }
}
ctx.restore(); // end camera
// floating text (screen space)
floatTexts.forEach(f=>{
// project to screen
let sx= (f.x - camera.x)*camera.zoom + canvas.width/2;
let sy= (f.y - camera.y)*camera.zoom + canvas.height/2;
let a= f.life / f.max;
ctx.save(); ctx.globalAlpha=a; ctx.fillStyle=f.col; ctx.strokeStyle='#000'; ctx.lineWidth=3;
ctx.font='800 22px Bangers'; ctx.textAlign='center'; ctx.strokeText(f.text, sx, sy); ctx.fillText(f.text, sx, sy); ctx.restore();
});
}
/* ===== UI BUILDERS ===== */
function renderWeaponBar(){
const bar=document.getElementById('weaponBar');
bar.innerHTML='';
WEAPONS.forEach(w=>{
const b=document.createElement('button'); b.className='wbtn'+(selectedWeapon.id===w.id?' active':'');
b.innerHTML=`<span class="catLabel" style="background:${w.color}">${w.cat}</span><i>${w.icon}</i><span>${w.name}</span><em>${w.dmg}</em>`;
b.onclick=()=>{ selectedWeapon=w; sfx('select'); renderWeaponBar(); updateAimInfo(); };
b.onmouseenter=()=> sfx('hover');
bar.appendChild(b);
});
}
function renderTeams(){
const left=document.getElementById('teamsLeft'), right=document.getElementById('teamsRight');
left.innerHTML=''; right.innerHTML='';
TEAMS_DEF.forEach((t,idx)=>{
if(!teamConfigs[idx]?.enabled) return;
const card=document.createElement('div'); card.className='teamCard'+(currentTeamIdx===idx?' active':'');
let wormsOfTeam= worms.filter(w=>w.team===idx);
let aliveCount= wormsOfTeam.filter(w=>w.alive).length;
card.innerHTML=`
<div class="teamHead"><h3><span class="dot" style="background:${t.color}"></span> ${t.name} ${teamConfigs[idx].isCPU?'<span style="font-size:10px;background:#000;color:#fff;padding:2px 6px;border-radius:999px">CPU</span>':''}</h3><span style="font-size:11px;opacity:.7">${aliveCount}/4 alive</span></div>
<div class="wormRow">${wormsOfTeam.map(w=>`
<div class="wormChip ${!w.alive?'dead':''} ${w===activeWorm?'activeWorm':''}">
<b>${w.name}</b><i>${w.hp} HP</i>
<div class="hpBar"><div class="hpFill" style="width:${w.hp}%;background:${w.hp>50?'linear-gradient(90deg,#22c55e,#16a34a)':w.hp>25?'linear-gradient(90deg,#eab308,#f97316)':'linear-gradient(90deg,#ef4444,#991b1b)'}"></div></div>
${w.shield?'🛡️':''}
</div>`).join('')}</div>
`;
// distribute left/right
if(idx%2===0) left.appendChild(card); else right.appendChild(card);
});
}
function buildSetup(){
const toggles=document.getElementById('teamToggles');
toggles.innerHTML='';
TEAMS_DEF.forEach((t,i)=>{
const row=document.createElement('label'); row.className='teamToggle';
row.innerHTML=`<input type="checkbox" ${i<2?'checked':''} data-idx="${i}"> <span class="dot" style="background:${t.color}"></span> <b>${t.name}</b> <span style="margin-left:auto;font-size:11px;opacity:.7">CPU</span><input type="checkbox" data-cpu="${i}" ${i>=2?'checked':''}>`;
toggles.appendChild(row);
});
document.querySelectorAll('.tBtn').forEach(b=>{
b.onclick=()=>{
document.querySelectorAll('.tBtn').forEach(x=>x.classList.remove('active'));
b.classList.add('active'); terrainShape=b.dataset.terrain; sfx('select');
};
});
}
/* ===== INIT ===== */
function startMatch(){
// read configs
teamConfigs=[];
document.querySelectorAll('[data-idx]').forEach(cb=>{
let idx=parseInt(cb.dataset.idx);
teamConfigs[idx]= teamConfigs[idx]||{};
teamConfigs[idx].enabled= cb.checked;
});
document.querySelectorAll('[data-cpu]').forEach(cb=>{
let idx=parseInt(cb.dataset.cpu);
teamConfigs[idx]= teamConfigs[idx]||{};
teamConfigs[idx].isCPU= cb.checked;
});
// ensure at least 2 teams
let enabled= teamConfigs.filter(c=>c.enabled).length;
if(enabled<2){ alert('Enable at least 2 teams!'); return; }
// if human team is CPU but we want at least one human if possible? allow all CPU
initTerrainCanvases(); generateTerrain(terrainShape);
spawnWorms();
document.getElementById('setup').style.display='none';
sfx('start'); logMsg(`🗺️ Terrain: ${terrainShape} — Wind ${wind}`, '#ffde59');
renderWeaponBar(); renderTeams();
}
canvas=document.getElementById('game'); ctx=canvas.getContext('2d');
function resize(){
const wrap=document.getElementById('gameWrap');
canvas.width= wrap.clientWidth * (window.devicePixelRatio||1);
canvas.height= wrap.clientHeight * (window.devicePixelRatio||1);
canvas.style.width=wrap.clientWidth+'px'; canvas.style.height=wrap.clientHeight+'px';
ctx.setTransform(window.devicePixelRatio||1,0,0,window.devicePixelRatio||1,0,0);
// reset transform scaling handled in render; but we use logical size
canvas.width= wrap.clientWidth; canvas.height= wrap.clientHeight;
}
window.addEventListener('resize', resize); resize();
buildSetup(); renderWeaponBar();
document.getElementById('btnStart').onclick=startMatch;
document.getElementById('btnRestart').onclick=()=>{ document.getElementById('setup').style.display='grid'; clearInterval(turnTimer); gameState='setup'; worms=[]; projectiles=[]; particles=[]; debris=[]; };
document.getElementById('btnSkip').onclick=()=>{ if(gameState==='aiming') endTurn(); };
document.getElementById('btnSound').onclick=(e)=>{ soundOn=!soundOn; e.target.textContent=soundOn?'🔊 Sound ON':'🔇 Sound OFF'; sfx('select'); };
document.getElementById('zoomRange').oninput=(e)=>{ camera.tz=parseFloat(e.target.value); };
document.querySelectorAll('.uBtn').forEach(b=>{
b.onclick=()=>{
if(gameState!=='aiming' || !activeWorm) return;
let util=b.dataset.util;
if(util==='jetpack'){ activeWorm.jetFuel= 4*60; turnTime=Math.max(0,turnTime-3); sfx('select'); logMsg(`🚀 Jetpack engaged! Hold W`, '#34d399'); b.classList.add('active'); setTimeout(()=>b.classList.remove('active'),2000); }
if(util==='shield'){ activeWorm.shield=true; turnTime=Math.max(0,turnTime-3); sfx('select'); logMsg(`🛡️ Shield on ${activeWorm.name}`, '#38bdf8'); }
if(util==='health'){ activeWorm.hp=Math.min(100, activeWorm.hp+40); turnTime=Math.max(0,turnTime-3); sfx('select'); spawnFloat(activeWorm.x, activeWorm.y-18, '+40 HP', '#22c55e'); logMsg(`💉 Healed ${activeWorm.name}`, '#22c55e'); renderTeams(); }
if(util==='teleport'){ selectedUtil='teleport'; document.querySelectorAll('.uBtn').forEach(x=>x.classList.remove('active')); b.classList.add('active'); logMsg(`🌀 Click terrain to teleport`, '#38bdf8'); }
};
});
/* ===== MAIN LOOP ===== */
let last=performance.now();
function loop(now){
requestAnimationFrame(loop);
let dt=Math.min(32, now-last); last=now;
// timeScale for freeze handled inside update
update();
render();
}
requestAnimationFrame(loop);
// initial wind
updateWindUI();
// verify helper
window._game={ get worms(){return worms}, get activeWorm(){return activeWorm}, get wind(){return wind}, fire:fireWeapon, explode };
</script>
</body>
</html>