Worms
muse spark 1.2 · typescript

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 91,500 bytes · SHA-256 7bbcf8728557
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>POCKET WORMS — Tactical Artillery</title>
<style>
/* ── css: base ── */
@import url('https://fonts.googleapis.com/css2?family=Bangers&family=Nunito:wght@600;800;900&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f172a;--panel:#1e293bcc;--accent:#facc15;--red:#ef4444;--blue:#38bdf8;--green:#4ade80;--yellow:#facc15;--purple:#a78bfa}
html,body{height:100%;background:#0b1020;overflow:hidden}
body{font-family:'Nunito',system-ui,sans-serif;color:#f1f5f9}
#app{position:relative;width:100vw;height:100vh;overflow:hidden;background:radial-gradient(1200px 600px at 50% 0%, #1e3a5f 0%, #0b1020 70%)}
#c{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(5,10,24,0.78);backdrop-filter:blur(10px);z-index:20;transition:opacity .4s}
.overlay.hidden{opacity:0;pointer-events:none}
.setup-card{width:min(860px,96vw);background:linear-gradient(180deg,#1e293b 0%,#0f172a 100%);border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:28px 28px 18px;box-shadow:0 20px 60px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.08)}
.setup-card.narrow{width:420px;text-align:center}
.logo{text-align:center;margin-bottom:18px}
.logo-icon{font-size:42px;filter:drop-shadow(0 4px 12px rgba(250,204,21,.5))}
.logo h1{font-family:'Bangers',cursive;font-size:52px;letter-spacing:3px;color:#facc15;text-shadow:0 3px 0 #a16207,0 6px 16px rgba(0,0,0,.4);line-height:1}
.tagline{font-size:11px;letter-spacing:3px;color:#94a3b8;font-weight:900;margin-top:4px}
.setup-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:16px 0}
@media(max-width:720px){.setup-grid{grid-template-columns:1fr}}
.setup-section h3{font-size:11px;letter-spacing:2px;color:#94a3b8;margin-bottom:8px}
.pill-row{display:flex;gap:8px;flex-wrap:wrap}
.pill{padding:8px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.06);color:#cbd5e1;font-weight:800;font-size:11px;letter-spacing:.6px;cursor:pointer;transition:.2s}
.pill:hover{background:rgba(255,255,255,.12);transform:translateY(-1px)}
.pill.active{background:#facc15;color:#422006;border-color:#facc15;box-shadow:0 4px 14px rgba(250,204,21,.4)}
.team-setup{display:flex;flex-direction:column;gap:8px}
.team-row{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:8px 10px}
.team-color{width:18px;height:18px;border-radius:50%;border:2px solid rgba(255,255,255,.3)}
.team-row select,.team-row input{flex:1;background:#0f172a;border:1px solid rgba(255,255,255,.12);color:#f1f5f9;border-radius:8px;padding:6px 8px;font-weight:800;font-size:12px}
.team-row .remove{width:28px;height:28px;border-radius:8px;border:0;background:rgba(239,68,68,.15);color:#f87171;cursor:pointer}
.mini-btn{margin-top:8px;padding:8px 12px;border-radius:999px;border:1px dashed rgba(255,255,255,.18);background:transparent;color:#94a3b8;font-weight:800;cursor:pointer}
.launch-btn{width:100%;margin-top:18px;padding:14px;border-radius:14px;border:0;background:linear-gradient(180deg,#facc15,#eab308);color:#422006;font-family:'Bangers',cursive;font-size:22px;letter-spacing:2px;cursor:pointer;box-shadow:0 8px 20px rgba(250,204,21,.35), inset 0 1px 0 rgba(255,255,255,.6);transition:transform .1s}
.launch-btn:hover{transform:translateY(-2px) scale(1.01)}
.launch-btn:active{transform:translateY(0)}
.hint{text-align:center;font-size:11px;color:#64748b;margin-top:10px}
/* HUD */
.hud{position:absolute;inset:0;pointer-events:none;z-index:5}
.hud.hidden{display:none}
.top-bar{position:absolute;top:14px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:14px;pointer-events:auto}
.wind-widget{display:flex;align-items:center;gap:8px;background:rgba(15,23,42,.88);border:1px solid rgba(255,255,255,.12);border-radius:999px;padding:6px 12px;backdrop-filter:blur(8px)}
.wind-label{font-size:9px;letter-spacing:1.5px;color:#94a3b8;font-weight:900}
.wind-arrow-wrap{width:22px;height:22px;border-radius:50%;background:rgba(255,255,255,.08);display:grid;place-items:center}
#windArrow{display:inline-block;transition:transform .5s;color:#facc15}
.wind-val{font-weight:900;font-size:13px;min-width:28px;text-align:center}
.wind-bar{width:60px;height:6px;background:rgba(255,255,255,.12);border-radius:999px;overflow:hidden}
#windFill{height:100%;width:50%;background:linear-gradient(90deg,#38bdf8,#facc15,#ef4444);transition:width .4s}
.turn-banner{background:rgba(15,23,42,.88);border:1px solid rgba(255,255,255,.12);border-radius:999px;padding:8px 16px;display:flex;gap:6px;align-items:center;backdrop-filter:blur(8px);box-shadow:0 8px 20px rgba(0,0,0,.3)}
#turnTeamName{font-family:'Bangers',cursive;letter-spacing:1px;font-size:18px}
#turnWormName{font-size:12px;color:#cbd5e1}
.timer-widget{width:58px;height:58px;background:rgba(15,23,42,.9);border:2px solid rgba(255,255,255,.14);border-radius:50%;display:grid;place-items:center;position:relative;backdrop-filter:blur(8px);transition:transform .2s}
.timer-widget.panic{animation:panic 0.5s infinite alternate; border-color:#ef4444}
@keyframes panic{to{transform:scale(1.12); box-shadow:0 0 20px rgba(239,68,68,.6)}}
#timerVal{font-family:'Bangers',cursive;font-size:22px;line-height:1}
.timer-label{position:absolute;bottom:-8px;left:50%;transform:translateX(-50%);background:#ef4444;color:#fff;font-size:7px;letter-spacing:1px;padding:2px 6px;border-radius:999px;font-weight:900}
.left-panel{position:absolute;left:14px;top:80px;display:flex;flex-direction:column;gap:10px;pointer-events:auto;width:190px}
#teamRoster{display:flex;flex-direction:column;gap:8px}
.team-card{background:rgba(15,23,42,.86);border:1px solid rgba(255,255,255,.1);border-radius:14px;padding:10px;backdrop-filter:blur(8px)}
.team-card.active{border-color:rgba(250,204,21,.6);box-shadow:0 0 0 2px rgba(250,204,21,.2)}
.team-head{display:flex;align-items:center;gap:8px;font-weight:900;font-size:12px;letter-spacing:.6px}
.team-head i{width:10px;height:10px;border-radius:50%;display:block}
.worm-list{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap}
.worm-chip{flex:1;min-width:42px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:4px 6px;text-align:center;position:relative;overflow:hidden}
.worm-chip.active{outline:2px solid #facc15}
.worm-chip.dead{opacity:.35}
.worm-chip .hp{height:4px;background:rgba(255,255,255,.15);border-radius:999px;overflow:hidden;margin-top:4px}
.worm-chip .hp i{display:block;height:100%;background:linear-gradient(90deg,#4ade80,#22c55e);transition:width .3s}
.utility-row{display:flex;gap:6px}
.util-btn{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 4px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:rgba(15,23,42,.9);color:#e2e8f0;font-weight:900;font-size:10px;cursor:pointer;transition:.15s}
.util-btn:hover{transform:translateY(-1px);background:rgba(255,255,255,.1)}
.util-btn.active{background:#facc15;color:#422006;border-color:#facc15}
.util-btn:disabled{opacity:.35;pointer-events:none}
.weapon-dock{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);display:flex;gap:8px;pointer-events:auto;align-items:end}
.wep{width:86px;background:rgba(15,23,42,.92);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:8px 6px 6px;text-align:center;cursor:pointer;transition:.15s;position:relative;backdrop-filter:blur(8px)}
.wep:hover{transform:translateY(-4px);border-color:rgba(255,255,255,.22)}
.wep.selected{border-color:#facc15;box-shadow:0 8px 20px rgba(250,204,21,.3), inset 0 0 0 1px rgba(250,204,21,.5);transform:translateY(-6px)}
.wep .cat{font-size:7px;letter-spacing:1px;font-weight:900;color:#94a3b8}
.wep .icon{font-size:22px;margin:2px 0}
.wep .name{font-size:10px;font-weight:900;line-height:1.1}
.wep .ammo{font-size:9px;color:#94a3b8}
.wep .sel-dot{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:#facc15;color:#422006;display:grid;place-items:center;font-size:10px;opacity:0;transform:scale(.6);transition:.2s}
.wep.selected .sel-dot{opacity:1;transform:scale(1)}
.bottom-help{position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(15,23,42,.7);border:1px solid rgba(255,255,255,.08);padding:6px 12px;border-radius:999px;font-size:10px;color:#94a3b8;letter-spacing:.4px;pointer-events:none}
.power-bar{position:absolute;left:50%;bottom:108px;transform:translateX(-50%);width:320px;height:18px;background:rgba(15,23,42,.9);border:1px solid rgba(255,255,255,.12);border-radius:999px;overflow:hidden;display:flex;align-items:center;pointer-events:none}
.power-bar.hidden{opacity:0}
#powerFill{height:100%;width:0%;background:linear-gradient(90deg,#22c55e,#facc15,#ef4444);transition:width .05s}
#powerLabel{position:absolute;inset:0;display:grid;place-items:center;font-weight:900;font-size:11px;letter-spacing:1px;text-shadow:0 1px 2px rgba(0,0,0,.6)}
.turn-overlay{position:absolute;inset:0;display:grid;place-items:center;background:rgba(250,204,21,.12);backdrop-filter:blur(1px);z-index:4;pointer-events:none}
.turn-overlay.hidden{display:none}
.turn-overlay span{font-family:'Bangers',cursive;font-size:56px;letter-spacing:4px;color:#fff;text-shadow:0 4px 0 rgba(0,0,0,.4),0 0 30px rgba(250,204,21,.8);animation:pop .6s cubic-bezier(.175,.885,.32,1.275)}
@keyframes pop{0%{transform:scale(.6) rotate(-3deg);opacity:0}100%{transform:scale(1) rotate(0);opacity:1}}
.damage-layer{position:absolute;inset:0;pointer-events:none}
.fct{position:absolute;font-family:'Bangers',cursive;font-size:22px;color:#fff;text-shadow:0 2px 0 rgba(0,0,0,.5);animation:fct 0.9s forwards}
@keyframes fct{0%{transform:translateY(0) scale(1);opacity:1}100%{transform:translateY(-42px) scale(1.2);opacity:0}}
</style>
</head>
<body>
<div id="app">
<canvas id="c"></canvas>
<div id="setup" class="overlay">
<div class="setup-card">
<div class="logo">
<div class="logo-icon">🪱</div>
<h1>POCKET WORMS</h1>
<p class="tagline">TACTICAL ARTILLERY • HOT-SEAT MAYHEM</p>
</div>
<div class="setup-grid">
<div class="setup-section">
<h3>TERRAIN</h3>
<div class="pill-row" id="terrainPills">
<button class="pill active" data-v="island">SINGLE ISLAND</button>
<button class="pill" data-v="twin">TWIN ISLES</button>
<button class="pill" data-v="cave">CAVERN</button>
</div>
<h3 style="margin-top:14px">WIND</h3>
<div class="pill-row" id="windPills">
<button class="pill" data-v="calm">CALM</button>
<button class="pill active" data-v="breeze">BREEZE</button>
<button class="pill" data-v="storm">STORM</button>
</div>
</div>
<div class="setup-section">
<h3>TEAMS (2-4)</h3>
<div id="teamSetup" class="team-setup"></div>
<button id="addTeamBtn" class="mini-btn">+ ADD TEAM</button>
</div>
</div>
<button id="launchBtn" class="launch-btn">⚡ LAUNCH BATTLE ⚡</button>
<p class="hint">Hot-seat multiplayer • Mouse to aim • SPACE to charge & fire</p>
</div>
</div>
<div id="hud" class="hud hidden">
<div class="top-bar">
<div class="wind-widget">
<span class="wind-label">WIND</span>
<div class="wind-arrow-wrap"><span id="windArrow">➤</span></div>
<span id="windVal" class="wind-val">0.0</span>
<div class="wind-bar"><div id="windFill"></div></div>
</div>
<div class="turn-banner" id="turnBanner">
<span id="turnTeamName">TEAM</span> <em id="turnWormName">'s worm</em>
</div>
<div class="timer-widget" id="timerWidget">
<div class="timer-ring"><span id="timerVal">30</span></div>
<div class="timer-label">SEC</div>
</div>
</div>
<div class="left-panel">
<div id="teamRoster"></div>
<div class="utility-row">
<button class="util-btn" data-util="jetpack" title="Jetpack">🚀<span>JET</span></button>
<button class="util-btn" data-util="teleport" title="Teleport">🌀<span>TELE</span></button>
<button class="util-btn" data-util="grapple" title="Grapple">🪝<span>HOOK</span></button>
</div>
</div>
<div class="weapon-dock" id="weaponDock"></div>
<div class="bottom-help">
<span><b>A/D</b> Move • <b>W / SPACE</b> Jump • <b>Mouse</b> Aim • <b>Hold SPACE</b> Power • <b>Enter</b> Fire</span>
</div>
<div id="turnOverlay" class="turn-overlay hidden"><span id="turnOverlayText"></span></div>
<div id="damageLayer" class="damage-layer"></div>
<div id="powerBar" class="power-bar hidden"><div id="powerFill"></div><span id="powerLabel">0%</span></div>
</div>
<div id="gameOver" class="overlay hidden">
<div class="setup-card narrow">
<div class="logo"><h1 id="winnerTitle">VICTORY!</h1><p id="winnerSub">Team wins</p></div>
<button id="replayBtn" class="launch-btn">PLAY AGAIN</button>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./types":"src/types.ts","./audio":"src/audio.ts","./terrain":"src/terrain.ts","./particles":"src/particles.ts"},"src/particles.ts":{"./types":"src/types.ts","./terrain":"src/terrain.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const types_1 = require("./types");
const audio_1 = require("./audio");
const terrain_1 = require("./terrain");
const particles_1 = require("./particles");
// DOM
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const setupEl = document.getElementById('setup');
const hudEl = document.getElementById('hud');
const weaponDock = document.getElementById('weaponDock');
const teamRosterEl = document.getElementById('teamRoster');
const windArrow = document.getElementById('windArrow');
const windValEl = document.getElementById('windVal');
const windFill = document.getElementById('windFill');
const turnTeamName = document.getElementById('turnTeamName');
const turnWormName = document.getElementById('turnWormName');
const timerVal = document.getElementById('timerVal');
const timerWidget = document.getElementById('timerWidget');
const turnBanner = document.getElementById('turnBanner');
const damageLayer = document.getElementById('damageLayer');
const turnOverlay = document.getElementById('turnOverlay');
const turnOverlayText = document.getElementById('turnOverlayText');
const powerBar = document.getElementById('powerBar');
const powerFill = document.getElementById('powerFill');
const powerLabel = document.getElementById('powerLabel');
const teamSetupEl = document.getElementById('teamSetup');
let teams = [];
let turnTeamIdx = 0;
let turnWormIdx = 0;
let wind = 0;
let windSetting = 'breeze';
let terrainType = 'island';
let gameState = 'setup';
let projectiles = [];
let selectedWeapon = types_1.WEAPONS[0].id;
let selectedUtil = null;
let utilUses = { jetpack: 2, teleport: 1, grapple: 2 };
let jetFuel = 0;
let isJetpacking = false;
let turnTime = 30;
let turnTimer = 30;
let camera = { x: terrain_1.W / 2, y: terrain_1.H / 2, scale: 1, targetScale: 1 };
let shake = 0;
let freeze = 0;
let mouse = { x: 0, y: 0, worldX: 0, worldY: 0, down: false };
let aimAngle = -0.35; // radians
let isCharging = false;
let charge = 0;
let firing = false;
let grapple = null;
// weapon ammo map per team
let teamAmmo = new Map();
const GRAVITY = 0.38;
const WIND_FORCE = 0.018;
function resize() {
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = innerWidth * dpr;
canvas.height = innerHeight * dpr;
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
(0, terrain_1.initTerrain)((x, y, w, h) => (0, particles_1.addDebris)(x, y, w, h));
// SETUP UI
let setupTeams = [
{ colorIdx: 0, name: 'CRIMSON', cpu: false },
{ colorIdx: 1, name: 'AQUA', cpu: true },
];
function renderTeamSetup() {
teamSetupEl.innerHTML = '';
setupTeams.forEach((t, i) => {
const div = document.createElement('div');
div.className = 'team-row';
div.innerHTML = `
<span class="team-color" style="background:${types_1.TEAM_COLORS[t.colorIdx].col}"></span>
<input value="${t.name}" data-i="${i}" data-k="name" maxlength="10" />
<select data-i="${i}" data-k="color">
${types_1.TEAM_COLORS.map((c, idx) => `<option value="${idx}" ${idx === t.colorIdx ? 'selected' : ''}>${c.name}</option>`).join('')}
</select>
<select data-i="${i}" data-k="cpu">
<option value="0" ${!t.cpu ? 'selected' : ''}>Human</option>
<option value="1" ${t.cpu ? 'selected' : ''}>CPU</option>
</select>
${setupTeams.length > 2 ? `<button class="remove" data-rm="${i}">✕</button>` : ''}
`;
teamSetupEl.appendChild(div);
});
teamSetupEl.querySelectorAll('input,select').forEach(el => {
el.addEventListener('change', (e) => {
const t = e.target;
const i = Number(t.dataset.i);
const k = t.dataset.k;
if (k === 'name')
setupTeams[i].name = t.value.toUpperCase().slice(0, 10);
if (k === 'color')
setupTeams[i].colorIdx = Number(t.value);
if (k === 'cpu')
setupTeams[i].cpu = t.value === '1';
renderTeamSetup();
});
el.addEventListener('mouseenter', () => audio_1.SFX.hover());
});
teamSetupEl.querySelectorAll('[data-rm]').forEach(b => {
b.addEventListener('click', () => {
const idx = Number(b.dataset.rm);
setupTeams.splice(idx, 1);
renderTeamSetup();
audio_1.SFX.click();
});
});
}
renderTeamSetup();
document.getElementById('addTeamBtn').addEventListener('click', () => {
if (setupTeams.length >= 4)
return;
const used = new Set(setupTeams.map(s => s.colorIdx));
let free = types_1.TEAM_COLORS.findIndex((_, i) => !used.has(i));
if (free < 0)
free = setupTeams.length % types_1.TEAM_COLORS.length;
setupTeams.push({ colorIdx: free, name: `TEAM ${setupTeams.length + 1}`, cpu: setupTeams.length >= 2 });
renderTeamSetup();
audio_1.SFX.click();
});
document.querySelectorAll('#terrainPills .pill').forEach(p => {
p.addEventListener('click', () => {
document.querySelectorAll('#terrainPills .pill').forEach(x => x.classList.remove('active'));
p.classList.add('active');
terrainType = p.dataset.v;
audio_1.SFX.click();
});
});
document.querySelectorAll('#windPills .pill').forEach(p => {
p.addEventListener('click', () => {
document.querySelectorAll('#windPills .pill').forEach(x => x.classList.remove('active'));
p.classList.add('active');
windSetting = p.dataset.v;
audio_1.SFX.click();
});
});
document.getElementById('launchBtn').addEventListener('click', launch);
document.getElementById('replayBtn').addEventListener('click', () => { location.reload(); });
function launch() {
audio_1.SFX.menuHit();
// build teams
teams = [];
setupTeams.forEach((s, idx) => {
const col = types_1.TEAM_COLORS[s.colorIdx];
const team = { id: idx, name: s.name, color: col, isCPU: s.cpu, worms: [], alive: true };
teams.push(team);
const am = new Map();
types_1.WEAPONS.forEach(w => am.set(w.id, w.ammo === '∞' ? 999 : w.ammo));
teamAmmo.set(String(idx), am);
});
(0, terrain_1.generate)(terrainType);
const totalWorms = teams.length * 4;
const spots = (0, terrain_1.findSpawnPositions)(totalWorms);
let si = 0;
teams.forEach(team => {
for (let i = 0; i < 4; i++) {
const p = spots[si++] || { x: 200 + Math.random() * (terrain_1.W - 400), y: 200 };
const worm = { id: team.id * 10 + i, team: team.id, x: p.x, y: p.y, vx: 0, vy: 0, hp: 100, maxHp: 100, alive: true, name: `#${i + 1}`, grounded: false, fallDist: 0, drownT: 0 };
// drop to ground
let yy = p.y;
while (!(0, terrain_1.isSolid)(worm.x, yy + 12) && yy < terrain_1.WATER_Y)
yy++;
worm.y = yy - 12;
team.worms.push(worm);
}
});
rollWind();
buildWeaponDock();
updateRoster();
setupEl.classList.add('hidden');
hudEl.classList.remove('hidden');
nextTurn(true);
gameState = 'playing';
}
function rollWind() {
const mag = windSetting === 'calm' ? 1.2 : windSetting === 'breeze' ? 3.5 : 6;
wind = (Math.random() * 2 - 1) * mag;
const pct = (wind / mag + 1) / 2 * 100;
windFill.style.width = pct + '%';
windValEl.textContent = wind.toFixed(1);
const deg = wind > 0 ? 0 : 180;
windArrow.style.transform = `rotate(${deg}deg)`;
windArrow.style.color = Math.abs(wind) > mag * 0.7 ? '#ef4444' : Math.abs(wind) > mag * 0.35 ? '#facc15' : '#4ade80';
}
function buildWeaponDock() {
weaponDock.innerHTML = '';
types_1.WEAPONS.forEach(w => {
const d = document.createElement('button');
d.className = 'wep';
d.dataset.id = w.id;
if (w.id === selectedWeapon)
d.classList.add('selected');
const ammo = teamAmmo.get(String(turnTeamIdx))?.get(w.id) ?? 0;
const ammoTxt = w.ammo === '∞' ? '∞' : String(ammo);
d.innerHTML = `
<div class="cat">${w.cat.toUpperCase()}</div>
<div class="icon">${w.icon}</div>
<div class="name">${w.name}</div>
<div class="ammo">${ammoTxt} • ${w.damage}DMG</div>
<div class="sel-dot">✓</div>
`;
d.addEventListener('click', () => {
const am = teamAmmo.get(String(turnTeamIdx))?.get(w.id) ?? 0;
if (w.ammo !== '∞' && am <= 0)
return;
selectedWeapon = w.id;
document.querySelectorAll('.wep').forEach(e => e.classList.remove('selected'));
d.classList.add('selected');
audio_1.SFX.click();
});
d.addEventListener('mouseenter', () => audio_1.SFX.hover());
weaponDock.appendChild(d);
});
}
function updateRoster() {
teamRosterEl.innerHTML = '';
teams.forEach((t, ti) => {
const card = document.createElement('div');
card.className = 'team-card' + (ti === turnTeamIdx && gameState !== 'gameover' ? ' active' : '');
card.innerHTML = `
<div class="team-head"><i style="background:${t.color.col}"></i> ${t.name} ${t.isCPU ? '<span style="font-size:9px;background:rgba(255,255,255,.12);padding:2px 6px;border-radius:999px">CPU</span>' : ''}</div>
<div class="worm-list">${t.worms.map((w, wi) => {
const isAct = ti === turnTeamIdx && wi === turnWormIdx && w.alive;
return `<div class="worm-chip ${isAct ? 'active' : ''} ${!w.alive ? 'dead' : ''}">
<div style="font-size:10px;font-weight:900">${w.name} ${!w.alive ? '💀' : ''}</div>
<div style="font-size:10px">${w.hp}</div>
<div class="hp"><i style="width:${Math.max(0, w.hp)}%; background:${w.hp > 50 ? 'linear-gradient(90deg,#4ade80,#22c55e)' : w.hp > 25 ? 'linear-gradient(90deg,#facc15,#f59e0b)' : 'linear-gradient(90deg,#ef4444,#dc2626)'}"></i></div>
</div>`;
}).join('')}</div>
`;
teamRosterEl.appendChild(card);
});
const cur = activeWorm();
if (cur) {
turnTeamName.textContent = teams[turnTeamIdx].name;
turnTeamName.style.color = teams[turnTeamIdx].color.col;
turnTeamName.style.textShadow = `0 1px 6px ${teams[turnTeamIdx].color.col}66`;
turnWormName.textContent = `'s ${cur.name}`;
turnBanner.style.borderColor = teams[turnTeamIdx].color.col + '55';
}
}
function activeWorm() {
const t = teams[turnTeamIdx];
if (!t)
return null;
return t.worms[turnWormIdx] ?? null;
}
function nextTurn(first = false) {
// find next alive worm
let attempts = 0;
while (attempts < 20) {
turnWormIdx = (turnWormIdx + 1) % 4;
if (turnWormIdx === 0) {
turnTeamIdx = (turnTeamIdx + 1) % teams.length;
// skip dead teams
let skip = 0;
while (!teams[turnTeamIdx].alive && skip < 8) {
turnTeamIdx = (turnTeamIdx + 1) % teams.length;
skip++;
}
rollWind();
}
const w = activeWorm();
if (w && w.alive)
break;
attempts++;
if (attempts > 12)
break;
}
if (first) {
turnTeamIdx = 0;
turnWormIdx = 0; // ensure alive
let aw = activeWorm();
let c = 0;
while (aw && !aw.alive && c < 20) {
turnWormIdx = (turnWormIdx + 1) % 4;
if (turnWormIdx === 0)
turnTeamIdx = (turnTeamIdx + 1) % teams.length;
aw = activeWorm();
c++;
}
}
turnTime = 30;
turnTimer = 30;
isCharging = false;
charge = 0;
firing = false;
projectiles = [];
selectedUtil = null;
jetFuel = 0;
isJetpacking = false;
grapple = null;
utilUses = { jetpack: 2, teleport: 1, grapple: 2 };
document.querySelectorAll('.util-btn').forEach(b => b.classList.remove('active'));
buildWeaponDock();
updateRoster();
showTurnOverlay(`${teams[turnTeamIdx].name}'s TURN`);
audio_1.SFX.turn();
// center camera on worm
const aw = activeWorm();
if (aw) {
camera.x = aw.x;
camera.y = aw.y - 120;
}
if (teams[turnTeamIdx].isCPU)
setTimeout(cpuTurn, 900);
}
function showTurnOverlay(txt) {
turnOverlayText.textContent = txt;
turnOverlay.classList.remove('hidden');
setTimeout(() => turnOverlay.classList.add('hidden'), 1100);
}
function showDamage(x, y, dmg, crit = false) {
const el = document.createElement('div');
el.className = 'fct';
const rect = canvas.getBoundingClientRect();
// convert world to screen
const sx = (x - camera.x) * camera.scale + rect.width / 2;
const sy = (y - camera.y) * camera.scale + rect.height / 2;
el.style.left = sx + 'px';
el.style.top = sy + 'px';
el.style.color = crit ? '#facc15' : '#fff';
el.textContent = `-${dmg}`;
damageLayer.appendChild(el);
setTimeout(() => el.remove(), 950);
}
// INPUT
const keys = new Set();
window.addEventListener('keydown', (e) => {
if (gameState === 'setup')
return;
const k = e.key.toLowerCase();
keys.add(k);
if (k === ' ') {
if (gameState === 'playing' && !isCharging && !teams[turnTeamIdx].isCPU) {
// if jetpacking, already handled
if (selectedUtil === 'jetpack' && jetFuel > 0)
return;
isCharging = true;
charge = 0;
audio_1.SFX.charge();
e.preventDefault();
}
}
if (k === 'enter') {
if (isCharging)
fireCharged();
}
if (k === 'w') {
tryJump();
}
if (k === 'f1') {
selectUtil('jetpack');
e.preventDefault();
}
if (k === 'f2') {
selectUtil('teleport');
e.preventDefault();
}
if (k === 'f3') {
selectUtil('grapple');
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
const k = e.key.toLowerCase();
keys.delete(k);
if (k === ' ' && isCharging)
fireCharged();
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
mouse.worldX = (mouse.x - rect.width / 2) / camera.scale + camera.x;
mouse.worldY = (mouse.y - rect.height / 2) / camera.scale + camera.y;
const aw = activeWorm();
if (aw && gameState === 'playing') {
aimAngle = Math.atan2(mouse.worldY - aw.y, mouse.worldX - aw.x);
}
});
canvas.addEventListener('mousedown', (e) => {
if (gameState !== 'playing' || teams[turnTeamIdx].isCPU)
return;
if (selectedUtil === 'teleport') {
const aw = activeWorm();
if (!aw)
return;
if (utilUses.teleport <= 0)
return;
// teleport to mouse if not solid
if (!(0, terrain_1.isSolid)(mouse.worldX, mouse.worldY) && mouse.worldY < terrain_1.WATER_Y - 10) {
aw.x = mouse.worldX;
aw.y = mouse.worldY;
aw.vx = 0;
aw.vy = 0;
utilUses.teleport--;
(0, particles_1.spawnDust)(aw.x, aw.y, 18);
audio_1.SFX.water();
selectedUtil = null;
document.querySelectorAll('.util-btn').forEach(b => b.classList.remove('active'));
}
return;
}
if (selectedUtil === 'grapple') {
if (utilUses.grapple <= 0)
return;
const aw = activeWorm();
if (!aw)
return;
const hit = (0, terrain_1.rayTerrain)(aw.x, aw.y, mouse.worldX, mouse.worldY);
if (hit.hit) {
grapple = { x: hit.x, y: hit.y, active: true, len: Math.hypot(hit.x - aw.x, hit.y - aw.y), ang: Math.atan2(hit.y - aw.y, hit.x - aw.x) };
utilUses.grapple--;
audio_1.SFX.fire();
selectedUtil = null;
document.querySelectorAll('.util-btn').forEach(b => b.classList.remove('active'));
}
return;
}
isCharging = true;
charge = 0;
});
canvas.addEventListener('mouseup', () => { if (isCharging)
fireCharged(); });
document.querySelectorAll('.util-btn').forEach(b => {
b.addEventListener('click', () => {
const u = b.dataset.util;
selectUtil(u);
audio_1.SFX.click();
});
});
function selectUtil(u) {
if (gameState !== 'playing' || teams[turnTeamIdx].isCPU)
return;
if (selectedUtil === u) {
selectedUtil = null;
document.querySelectorAll('.util-btn').forEach(x => x.classList.remove('active'));
return;
}
if (u === 'jetpack' && utilUses.jetpack <= 0)
return;
if (u === 'teleport' && utilUses.teleport <= 0)
return;
if (u === 'grapple' && utilUses.grapple <= 0)
return;
selectedUtil = u;
document.querySelectorAll('.util-btn').forEach(x => x.classList.toggle('active', x.dataset.util === u));
if (u === 'jetpack') {
jetFuel = 110;
isJetpacking = false;
}
}
function tryJump() {
const w = activeWorm();
if (!w || !w.alive || gameState !== 'playing')
return;
if (!w.grounded)
return;
w.vy = -7.2;
w.grounded = false;
(0, particles_1.spawnDust)(w.x, w.y + 12, 5);
audio_1.SFX.jump();
// directional jump with A/D
if (keys.has('a'))
w.vx -= 3.5;
if (keys.has('d'))
w.vx += 3.5;
}
function fireCharged() {
if (!isCharging || firing)
return;
isCharging = false;
const pct = Math.min(1, charge);
if (pct < 0.08) {
charge = 0;
return;
}
fireWeapon(selectedWeapon, pct);
charge = 0;
powerBar.classList.add('hidden');
}
function fireWeapon(id, power) {
const aw = activeWorm();
if (!aw)
return;
const def = types_1.WEAPONS.find(w => w.id === id);
const amMap = teamAmmo.get(String(turnTeamIdx));
const cur = amMap.get(id) ?? 0;
if (def.ammo !== '∞' && cur <= 0)
return;
if (def.ammo !== '∞')
amMap.set(id, cur - 1);
buildWeaponDock();
// compute velocity
const speed = 4 + power * 16; // up to ~20
const vx = Math.cos(aimAngle) * speed + wind * 0.06;
const vy = Math.sin(aimAngle) * speed;
const sx = aw.x + Math.cos(aimAngle) * 18, sy = aw.y - 6 + Math.sin(aimAngle) * 10;
if (id === 'railgun') {
// instant ray
audio_1.SFX.rail();
const endX = sx + Math.cos(aimAngle) * 2600, endY = sy + Math.sin(aimAngle) * 2600;
const hit = (0, terrain_1.rayTerrain)(sx, sy, endX, endY);
const hx = hit.hit ? hit.x : endX, hy = hit.hit ? hit.y : endY;
// damage worms along line
teams.forEach(t => t.worms.forEach(w => {
if (!w.alive)
return;
// distance to line segment
const d = distToSegment(w.x, w.y, sx, sy, hx, hy);
if (d < 18) {
applyDamage(w, def.damage, sx, sy);
}
}));
if (hit.hit) {
(0, terrain_1.carveCircle)(hit.x, hit.y, def.radius * 0.7);
(0, particles_1.spawnExplosion)(hit.x, hit.y, 10, '#7dd3fc');
addShake(8);
}
// visual line handled in draw
projectiles.railLine = { x1: sx, y1: sy, x2: hx, y2: hy, life: 0.18 };
setTimeout(() => endProjectileTurn(), 420);
(0, particles_1.spawnMuzzle)(sx, sy, aimAngle);
return;
}
if (id === 'shotgun') {
audio_1.SFX.fire();
firing = true;
gameState = 'projectile';
for (let i = 0; i < 5; i++) {
const spread = (Math.random() - 0.5) * 0.28;
const a = aimAngle + spread;
const sp = speed * (0.9 + Math.random() * 0.2);
projectiles.push({ x: sx, y: sy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, r: 4, type: 'shotgun', alive: true, owner: turnTeamIdx });
}
(0, particles_1.spawnMuzzle)(sx, sy, aimAngle);
return;
}
if (id === 'airstrike') {
audio_1.SFX.fire();
firing = true;
gameState = 'projectile';
const tx = mouse.worldX;
for (let i = 0; i < 3; i++) {
setTimeout(() => {
const px = tx + (i - 1) * 110 + (Math.random() - 0.5) * 30;
projectiles.push({ x: px, y: 60, vx: wind * 0.05 + (Math.random() - 0.5) * 1.2, vy: 6 + Math.random() * 2, r: 6, type: 'airstrike', alive: true, owner: turnTeamIdx });
}, i * 220);
}
return;
}
if (id === 'holy') {
audio_1.SFX.holy();
firing = true;
gameState = 'projectile';
projectiles.push({ x: sx, y: sy, vx: Math.cos(aimAngle) * speed * 0.7, vy: Math.sin(aimAngle) * speed * 0.7, r: 8, type: 'holy', alive: true, bounce: 0, fuse: 3.2, owner: turnTeamIdx });
(0, particles_1.spawnMuzzle)(sx, sy, aimAngle);
return;
}
if (id === 'drill') {
audio_1.SFX.fire();
firing = true;
gameState = 'projectile';
// drill goes straight then explodes underground
projectiles.push({ x: sx, y: sy, vx: Math.cos(aimAngle) * speed * 0.9, vy: Math.sin(aimAngle) * speed * 0.9, r: 5, type: 'drill', alive: true, owner: turnTeamIdx, bounce: 0 });
(0, particles_1.spawnMuzzle)(sx, sy, aimAngle);
return;
}
// bazooka / grenade default
audio_1.SFX.fire();
firing = true;
gameState = 'projectile';
const type = id === 'grenade' ? 'grenade' : 'bazooka';
projectiles.push({ x: sx, y: sy, vx, vy, r: 6, type, alive: true, bounce: type === 'grenade' ? 0 : undefined, fuse: type === 'grenade' ? 3 : undefined, owner: turnTeamIdx });
(0, particles_1.spawnMuzzle)(sx, sy, aimAngle);
}
function distToSegment(px, py, x1, y1, x2, y2) {
const l2 = (x2 - x1) ** 2 + (y2 - y1) ** 2;
if (l2 === 0)
return Math.hypot(px - x1, py - y1);
let t = ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / l2;
t = Math.max(0, Math.min(1, t));
const x = x1 + t * (x2 - x1), y = y1 + t * (y2 - y1);
return Math.hypot(px - x, py - y);
}
function applyDamage(w, dmg, fromX, fromY) {
if (!w.alive)
return;
w.hp = Math.max(0, w.hp - dmg);
showDamage(w.x, w.y, dmg, dmg > 40);
audio_1.SFX.hurt();
(0, particles_1.spawnDust)(w.x, w.y, 4);
w.vx += (w.x - fromX) * 0.04;
w.vy += -2 - Math.random() * 2;
if (w.hp <= 0) {
w.alive = false;
(0, particles_1.spawnExplosion)(w.x, w.y, 12, '#ff4444');
}
updateRoster();
checkWin();
}
function explosionAt(x, y, rad, dmg) {
(0, terrain_1.carveCircle)(x, y, rad);
(0, particles_1.spawnExplosion)(x, y, rad * 0.45);
addShake(Math.min(18, rad * 0.16));
impactFreeze(rad > 100 ? 180 : rad > 70 ? 110 : 0);
// damage worms
teams.forEach(t => t.worms.forEach(w => {
if (!w.alive)
return;
const d = Math.hypot(w.x - x, w.y - y);
if (d < rad + 22) {
const fall = 1 - d / (rad + 22);
const dd = Math.floor(dmg * fall);
if (dd > 0)
applyDamage(w, dd, x, y);
// knockback
const ang = Math.atan2(w.y - y, w.x - x);
const force = (rad - d) * 0.08 + 4;
w.vx += Math.cos(ang) * force;
w.vy += Math.sin(ang) * force - 1;
}
}));
// chain terrain falling handled by debris
}
function addShake(v) { shake = Math.max(shake, v); }
function impactFreeze(ms) {
if (ms > 0)
freeze = ms;
}
function checkWin() {
const aliveTeams = teams.filter(t => t.worms.some(w => w.alive));
if (aliveTeams.length <= 1 && gameState !== 'gameover') {
gameState = 'gameover';
const win = aliveTeams[0];
document.getElementById('winnerTitle').textContent = win ? `${win.name} WINS!` : `DRAW!`;
document.getElementById('winnerSub').textContent = win ? `Victory in the pocket!` : `All worms perished`;
document.getElementById('gameOver').classList.remove('hidden');
audio_1.SFX.win();
}
}
function endProjectileTurn() {
// small delay then next turn unless gameover
if (gameState === 'gameover')
return;
gameState = 'playing';
firing = false;
projectiles = [];
projectiles.railLine = null;
// check if active worm died from self damage etc, still next turn
setTimeout(() => nextTurn(), 700);
}
// CPU AI
function cpuTurn() {
if (gameState !== 'playing' || !teams[turnTeamIdx].isCPU)
return;
const me = activeWorm();
if (!me || !me.alive) {
nextTurn();
return;
}
// simple movement: if near cliff/water, move inward
// evaluate targets
const enemies = [];
teams.forEach(t => { if (t.id !== turnTeamIdx)
t.worms.forEach(w => { if (w.alive)
enemies.push(w); }); });
if (enemies.length === 0) {
nextTurn();
return;
}
// choose high value target: lowest hp or closest
enemies.sort((a, b) => (a.hp - b.hp) || (Math.hypot(a.x - me.x, a.y - me.y) - Math.hypot(b.x - me.x, b.y - me.y)));
const target = enemies[0];
// risk assessment: avoid firing if target near water and wind pushes away? just basic
// choose weapon with ammo
const ammoMap = teamAmmo.get(String(turnTeamIdx));
let candidates = types_1.WEAPONS.filter(w => {
const a = ammoMap.get(w.id) ?? 0;
return w.ammo === '∞' ? true : a > 0;
});
// prefer high damage if close, else bazooka
const dist = Math.hypot(target.x - me.x, target.y - me.y);
let chosen = candidates.find(c => c.id === 'bazooka') || candidates[0];
if (dist > 600 && candidates.find(c => c.id === 'airstrike'))
chosen = candidates.find(c => c.id === 'airstrike');
else if (dist < 160 && candidates.find(c => c.id === 'shotgun'))
chosen = candidates.find(c => c.id === 'shotgun');
else if (dist < 250 && candidates.find(c => c.id === 'holy') && Math.random() < 0.3)
chosen = candidates.find(c => c.id === 'holy');
selectedWeapon = chosen.id;
buildWeaponDock();
// aim calculation: solve parabolic with wind compensation (simple iterative)
const g = GRAVITY;
const power = 0.78 + Math.random() * 0.18;
const speed = 4 + power * 16;
// estimate angle to hit target with wind
// brute search angles
let bestAng = aimAngle, bestErr = Infinity;
for (let deg = -80; deg <= 50; deg += 2) {
const a = deg * Math.PI / 180;
// simulate trajectory quickly
let sx = me.x, sy = me.y, vx = Math.cos(a) * speed + wind * 0.06, vy = Math.sin(a) * speed;
let hit = false;
let err = Infinity;
for (let t = 0; t < 180; t++) {
sx += vx;
sy += vy;
vy += g;
vx += wind * WIND_FORCE * 0.25;
if ((0, terrain_1.isSolid)(sx, sy)) {
hit = true;
break;
}
if (Math.hypot(sx - target.x, sy - target.y) < 42) {
err = Math.hypot(sx - target.x, sy - target.y);
break;
}
if (sy > terrain_1.WATER_Y + 40)
break;
}
if (err < bestErr) {
bestErr = err;
bestAng = a;
}
if (bestErr < 18)
break;
}
aimAngle = bestAng;
// avoid hazard: if worm near water edge, step away
let nearWater = false;
for (let dx = -18; dx <= 18; dx += 9)
if (!(0, terrain_1.isSolid)(me.x + dx, me.y + 22))
nearWater = true;
if (nearWater) {
// move inward: if on left half move right else left
const dir = me.x < terrain_1.W / 2 ? 1 : -1;
me.vx += dir * 2.2;
(0, particles_1.spawnDust)(me.x, me.y + 10, 3);
}
setTimeout(() => {
if (gameState !== 'playing')
return;
isCharging = true;
charge = power;
// simulate charge bar
setTimeout(() => fireWeapon(chosen.id, power), 650);
}, 700);
}
// LOOP
let last = performance.now();
function loop(now) {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
if (freeze > 0) {
freeze -= dt * 1000;
requestAnimationFrame(loop);
draw();
return;
}
update(dt);
draw();
requestAnimationFrame(loop);
}
function update(dt) {
if (gameState === 'setup')
return;
// timer
if (gameState === 'playing') {
turnTimer -= dt;
if (turnTimer <= 0) {
turnTimer = 0;
isCharging = false;
charge = 0;
nextTurn();
}
const pct = turnTimer / 30;
timerVal.textContent = Math.ceil(turnTimer).toString();
if (turnTimer < 8) {
timerWidget.classList.add('panic');
if (Math.floor(turnTimer * 2) % 2 === 0)
audio_1.SFX.panicTick();
}
else if (turnTimer < 15) {
timerWidget.classList.remove('panic');
if (Math.floor(turnTimer) % 1 === 0 && Math.random() < 0.02)
audio_1.SFX.tick(Math.floor(30 - turnTimer));
}
else
timerWidget.classList.remove('panic');
// scale timer based on urgency
const scale = turnTimer < 8 ? 1 + (8 - turnTimer) * 0.06 : 1;
timerWidget.style.transform = `scale(${scale})`;
// weapon dock tension
const dockScale = turnTimer < 8 ? 0.96 : 1;
weaponDock.style.transform = `translateX(-50%) scale(${dockScale})`;
}
// charging
if (isCharging) {
charge = Math.min(1, charge + dt * 0.85);
powerBar.classList.remove('hidden');
powerFill.style.width = (charge * 100) + '%';
powerLabel.textContent = Math.floor(charge * 100) + '%';
const col = charge < 0.33 ? '#4ade80' : charge < 0.66 ? '#facc15' : '#ef4444';
powerFill.style.background = col;
// subtle shake
if (charge > 0.9)
addShake(1.2);
}
// worm physics
teams.forEach(team => team.worms.forEach(w => {
if (!w.alive)
return;
// water
if (w.y > terrain_1.WATER_Y - 14) {
w.drownT = (w.drownT || 0) + dt;
w.y += 0.6; // float/sink slowly then drown
w.vy += 0.08;
(0, particles_1.spawnSplash)(w.x, terrain_1.WATER_Y);
if (w.drownT > 1.1) {
applyDamage(w, 14, w.x, w.y);
w.drownT = 0;
audio_1.SFX.water();
}
if (w.y > terrain_1.WATER_Y + 50) { // drowned
w.hp = 0;
w.alive = false;
(0, particles_1.spawnSplash)(w.x, w.y);
updateRoster();
checkWin();
}
}
else {
w.drownT = 0;
}
// gravity & terrain collision
w.vy += GRAVITY;
w.vx *= 0.992; // air friction ; ground friction applied later
// wind affects worm slightly when airborne
if (!w.grounded)
w.vx += wind * WIND_FORCE * 0.08 * dt * 60;
let nx = w.x + w.vx, ny = w.y + w.vy;
// vertical collision
if ((0, terrain_1.isSolidCircle)(nx, ny + 10, 11)) {
// try step up
if ((0, terrain_1.isSolidCircle)(nx, ny + 6, 11) && !(0, terrain_1.isSolidCircle)(nx, ny - 4, 11)) {
ny -= 4;
w.vy = Math.min(w.vy, 0);
}
else {
// land
// find ground
let gy = ny;
let iter = 0;
while ((0, terrain_1.isSolidCircle)(nx, gy + 10, 11) && iter < 10) {
gy -= 1;
iter++;
}
ny = gy;
// fall damage
if (w.vy > 9) {
const dmg = Math.floor((w.vy - 9) * 3.2);
if (dmg > 0)
applyDamage(w, Math.min(dmg, 38), w.x, w.y);
(0, particles_1.spawnDust)(w.x, ny + 12, 8);
w.vy *= -0.12;
}
else {
w.vy *= -0.18;
if (Math.abs(w.vy) < 0.6)
w.vy = 0;
}
if (Math.abs(w.vx) > 0.2)
(0, particles_1.spawnDust)(w.x, ny + 12, 1);
w.grounded = true;
w.fallDist = 0;
}
}
else {
w.grounded = false;
w.fallDist += Math.max(0, w.vy);
if (w.vy > 12)
w.vy = 12;
}
// horizontal collision
if ((0, terrain_1.isSolidCircle)(nx, ny + 4, 11)) {
w.vx *= -0.3;
nx = w.x;
}
w.x = nx;
w.y = ny;
// ground friction
if (w.grounded)
w.vx *= 0.86;
// bounds
if (w.x < 18) {
w.x = 18;
w.vx = 0;
}
if (w.x > terrain_1.W - 18) {
w.x = terrain_1.W - 18;
w.vx = 0;
}
if (w.y > terrain_1.H + 40) {
w.alive = false;
updateRoster();
}
}));
// player movement
const aw = activeWorm();
if (aw && gameState === 'playing' && !teams[turnTeamIdx].isCPU) {
const moveSpeed = 2.1;
const beforeX = aw.x;
if (keys.has('a') && !isCharging) {
aw.vx -= 0.45;
if (aw.grounded)
aw.x -= moveSpeed;
}
if (keys.has('d') && !isCharging) {
aw.vx += 0.45;
if (aw.grounded)
aw.x += moveSpeed;
}
if (Math.abs(aw.x - beforeX) > 0.8 && aw.grounded && Math.random() < 0.18) {
(0, particles_1.spawnDust)(aw.x, aw.y + 12, 1);
if (Math.random() < 0.25)
audio_1.SFX.step();
}
// jetpack
if (selectedUtil === 'jetpack' && jetFuel > 0) {
if (keys.has('w') || keys.has(' ') || keys.has('arrowup')) {
aw.vy -= 0.52;
aw.vx += wind * WIND_FORCE * 0.05;
jetFuel -= 1;
isJetpacking = true;
if (Math.random() < 0.5)
particles_1.particles.push({ x: aw.x + (Math.random() - 0.5) * 8, y: aw.y + 14, vx: (Math.random() - 0.5) * 1.2, vy: 2 + Math.random() * 2, life: 1, maxLife: 0.35, r: 2 + Math.random() * 2, col: Math.random() < 0.5 ? '#ff8c2a' : '#ffd23f', alpha: 1, type: 'spark' });
if (Math.random() < 0.08)
audio_1.SFX.fire();
if (jetFuel <= 0) {
selectedUtil = null;
utilUses.jetpack--;
document.querySelectorAll('.util-btn').forEach(b => b.classList.remove('active'));
}
}
else
isJetpacking = false;
}
// grapple physics
if (grapple && grapple.active) {
const dx = grapple.x - aw.x, dy = grapple.y - aw.y;
const dist = Math.hypot(dx, dy);
if (dist > grapple.len) {
const ang = Math.atan2(dy, dx);
const pull = (dist - grapple.len) * 0.08;
aw.vx += Math.cos(ang) * pull;
aw.vy += Math.sin(ang) * pull;
// wind affects rope
aw.vx += wind * 0.01;
}
// release if near
if (dist < 22) {
grapple = null;
}
if (keys.has(' ')) {
grapple = null;
audio_1.SFX.jump();
}
}
}
// projectiles
if (projectiles.length > 0) {
const toExplode = [];
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
if (!p.alive)
continue;
// special drill: goes through terrain then explodes
if (p.type === 'drill') {
p.vy += GRAVITY * 0.9;
p.vx += wind * WIND_FORCE * 0.6;
p.x += p.vx;
p.y += p.vy;
p.vy = Math.min(p.vy, 14);
// if inside solid, continue drilling then explode
if ((0, terrain_1.isSolid)(p.x, p.y)) {
// carve tunnel
(0, terrain_1.carveCircle)(p.x, p.y, 10);
p.vy += 0.6;
if (p.vy > 1.5 && Math.random() < 0.5)
(0, particles_1.spawnDust)(p.x, p.y, 1);
// after going 40px deep, explode
// we track distance drilled via bounce field
p.bounce += 1;
if (p.bounce > 28) {
toExplode.push({ x: p.x, y: p.y, rad: 44, dmg: 30 });
p.alive = false;
projectiles.splice(i, 1);
}
}
else {
// in air, still check water
if (p.y > terrain_1.WATER_Y) {
(0, particles_1.spawnSplash)(p.x, terrain_1.WATER_Y);
p.alive = false;
projectiles.splice(i, 1);
continue;
}
if (p.y > terrain_1.H + 100) {
p.alive = false;
projectiles.splice(i, 1);
continue;
}
}
continue;
}
p.vy += GRAVITY;
p.vx += wind * WIND_FORCE;
if (p.type === 'grenade' || p.type === 'holy') {
p.fuse -= dt;
if (p.fuse <= 0) {
toExplode.push({ x: p.x, y: p.y, rad: p.type === 'holy' ? 150 : 70, dmg: p.type === 'holy' ? 75 : 42 });
p.alive = false;
projectiles.splice(i, 1);
continue;
}
}
const nx = p.x + p.vx, ny = p.y + p.vy;
if ((0, terrain_1.isSolid)(nx, ny)) {
if (p.type === 'grenade' && (p.bounce ?? 0) < 4) {
// bounce
// simple reflect: check horizontal vs vertical
const hitX = (0, terrain_1.isSolid)(nx, p.y), hitY = (0, terrain_1.isSolid)(p.x, ny);
if (hitX)
p.vx *= -0.62;
if (hitY)
p.vy *= -0.55;
else {
p.vx *= -0.5;
p.vy *= -0.5;
}
p.x += p.vx * 0.5;
p.y += p.vy * 0.5;
p.bounce++;
(0, particles_1.spawnDust)(p.x, p.y, 3);
continue;
}
else if (p.type === 'holy') {
p.vx *= -0.4;
p.vy *= -0.4;
p.bounce++;
continue;
}
else {
// explode
const def = types_1.WEAPONS.find(w => w.projectile === p.type) || types_1.WEAPONS[0];
const rad = p.type === 'holy' ? 150 : p.type === 'airstrike' ? 62 : def.radius;
const dmg = p.type === 'holy' ? 75 : p.type === 'airstrike' ? 32 : def.damage;
toExplode.push({ x: nx, y: ny, rad, dmg });
p.alive = false;
projectiles.splice(i, 1);
continue;
}
}
p.x = nx;
p.y = ny;
// water splash
if (p.y > terrain_1.WATER_Y) {
(0, particles_1.spawnSplash)(p.x, terrain_1.WATER_Y);
if (p.type === 'airstrike' || p.type === 'bazooka' || p.type === 'shotgun') {
toExplode.push({ x: p.x, y: p.y - 8, rad: p.type === 'shotgun' ? 18 : 48, dmg: p.type === 'shotgun' ? 14 : 22 });
}
p.alive = false;
projectiles.splice(i, 1);
continue;
}
if (p.y > terrain_1.H + 120 || p.x < -120 || p.x > terrain_1.W + 120) {
p.alive = false;
projectiles.splice(i, 1);
}
}
toExplode.forEach(e => explosionAt(e.x, e.y, e.rad, e.dmg));
if (projectiles.length === 0 && firing) {
// small delay before ending turn
if (toExplode.length > 0)
setTimeout(() => endProjectileTurn(), 900);
else if (gameState === 'projectile')
setTimeout(() => endProjectileTurn(), 400);
}
}
// rail line fade
const rl = projectiles.railLine;
if (rl) {
rl.life -= dt;
if (rl.life <= 0)
projectiles.railLine = null;
}
(0, particles_1.updateParticles)(dt, GRAVITY, wind);
// camera
if (aw) {
const targetX = projectiles.length > 0 ? (projectiles[0].x + aw.x) / 2 : aw.x;
const targetY = projectiles.length > 0 ? (projectiles[0].y + aw.y) / 2 - 40 : aw.y - 90;
camera.x += (targetX - camera.x) * 0.08;
camera.y += (targetY - camera.y) * 0.08;
// zoom based on projectile height
let desiredScale = 1;
if (projectiles.length > 0) {
const h = Math.abs(projectiles[0].y - aw.y);
desiredScale = Math.max(0.72, Math.min(1, 1 - h / 1200));
}
else if (isCharging)
desiredScale = 0.92;
camera.targetScale += (desiredScale - camera.targetScale) * 0.06;
camera.scale += (camera.targetScale - camera.scale) * 0.08;
}
// clamp camera
const viewW = innerWidth / camera.scale, viewH = innerHeight / camera.scale;
camera.x = Math.max(viewW / 2, Math.min(terrain_1.W - viewW / 2, camera.x));
camera.y = Math.max(viewH / 2 - 60, Math.min(terrain_1.H - viewH / 2 + 30, camera.y));
if (shake > 0)
shake *= Math.pow(0.86, dt * 60);
if (shake < 0.12)
shake = 0;
// update hud ammo disables
document.querySelectorAll('.wep').forEach(el => {
const id = el.dataset.id;
const am = teamAmmo.get(String(turnTeamIdx))?.get(id) ?? 0;
const def = types_1.WEAPONS.find(w => w.id === id);
if (def.ammo !== '∞' && am <= 0)
el.style.opacity = '0.45';
else
el.style.opacity = '1';
});
// util btn disable
document.querySelectorAll('.util-btn').forEach(b => {
const u = b.dataset.util;
const left = utilUses[u];
b.disabled = left <= 0;
b.style.opacity = left <= 0 ? '0.38' : '1';
});
// check if current worm died and still playing -> next turn after projectile done
if (gameState === 'playing' && aw && !aw.alive) {
setTimeout(() => nextTurn(), 600);
}
// check all teams alive flag
teams.forEach(t => t.alive = t.worms.some(w => w.alive));
updateRoster();
}
function draw() {
const dpr = window.devicePixelRatio || 1;
const w = innerWidth, h = innerHeight;
ctx.save();
ctx.clearRect(0, 0, w, h);
// background sky gradient
const sky = ctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#0f1b33');
sky.addColorStop(0.35, '#1e3a5f');
sky.addColorStop(0.7, '#2a5a8a');
sky.addColorStop(1, '#7fb8e6');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, w, h);
// sun
ctx.fillStyle = 'rgba(255,240,180,0.9)';
ctx.beginPath();
ctx.arc(w * 0.82, 110, 42, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,240,180,0.18)';
ctx.beginPath();
ctx.arc(w * 0.82, 110, 78, 0, Math.PI * 2);
ctx.fill();
// clouds parallax
ctx.fillStyle = 'rgba(255,255,255,0.55)';
for (let i = 0; i < 4; i++) {
const cx = ((Date.now() * 0.02 + i * 300) % (w + 200)) - 100 + i * 70;
const cy = 70 + i * 34;
const sc = 0.9 + i * 0.15;
drawCloud(cx, cy, sc);
}
// camera transform
ctx.save();
// screen shake
const sxShake = (Math.random() - 0.5) * shake * (w / 80), syShake = (Math.random() - 0.5) * shake * (w / 80);
ctx.translate(w / 2 + sxShake, h / 2 + syShake);
ctx.scale(camera.scale, camera.scale);
ctx.translate(-camera.x, -camera.y);
// terrain canvas
const tCanvas = (0, terrain_1.getCanvas)();
if (tCanvas)
ctx.drawImage(tCanvas, 0, 0);
// water
const waterGrad = ctx.createLinearGradient(0, terrain_1.WATER_Y, 0, terrain_1.H);
waterGrad.addColorStop(0, 'rgba(56,189,248,0.55)');
waterGrad.addColorStop(1, 'rgba(14,116,144,0.85)');
ctx.fillStyle = waterGrad;
ctx.fillRect(0, terrain_1.WATER_Y, terrain_1.W, terrain_1.H - terrain_1.WATER_Y);
// water waveline
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x <= terrain_1.W; x += 10) {
const wx = Math.sin(x * 0.02 + Date.now() * 0.003) * 4 + Math.sin(x * 0.05 + Date.now() * 0.005) * 2;
if (x === 0)
ctx.moveTo(x, terrain_1.WATER_Y + wx);
else
ctx.lineTo(x, terrain_1.WATER_Y + wx);
}
ctx.stroke();
// subtle water shimmer
ctx.fillStyle = 'rgba(255,255,255,0.07)';
for (let x = 0; x < terrain_1.W; x += 36) {
const wx = Math.sin(x * 0.03 + Date.now() * 0.002) * 6;
ctx.fillRect(x, terrain_1.WATER_Y + 4 + wx, 22, 2);
}
// worms
teams.forEach(team => {
team.worms.forEach(w => {
if (!w.alive)
return;
drawWorm(w, team);
});
});
// debris
particles_1.debris.forEach(d => {
ctx.save();
ctx.translate(d.x, d.y);
ctx.rotate(d.rot);
ctx.fillStyle = '#4a3520';
ctx.fillRect(-d.w / 2, -d.h / 2, d.w, d.h);
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.strokeRect(-d.w / 2, -d.h / 2, d.w, d.h);
ctx.restore();
});
// projectiles
projectiles.forEach(p => {
ctx.save();
ctx.translate(p.x, p.y);
if (p.type === 'grenade') {
ctx.fillStyle = '#2f3a18';
ctx.beginPath();
ctx.arc(0, 0, 7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#8ab44b';
ctx.fillRect(-4, -2, 8, 4);
}
else if (p.type === 'holy') {
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.arc(0, 0, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('✝', 0, 1);
}
else if (p.type === 'drill') {
ctx.rotate(Math.atan2(p.vy, p.vx));
ctx.fillStyle = '#9ca3af';
ctx.fillRect(-10, -4, 20, 8);
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.moveTo(10, 0);
ctx.lineTo(4, -6);
ctx.lineTo(4, 6);
ctx.closePath();
ctx.fill();
}
else if (p.type === 'shotgun') {
ctx.fillStyle = '#facc15';
ctx.beginPath();
ctx.arc(0, 0, 3.5, 0, Math.PI * 2);
ctx.fill();
}
else if (p.type === 'airstrike') {
ctx.fillStyle = '#334155';
ctx.beginPath();
ctx.ellipse(0, 0, 8, 12, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ef4444';
ctx.fillRect(-3, 6, 6, 6);
}
else { // bazooka
const ang = Math.atan2(p.vy, p.vx);
ctx.rotate(ang);
ctx.fillStyle = '#1f2937';
ctx.fillRect(-10, -4, 18, 8);
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.moveTo(8, 0);
ctx.lineTo(2, -6);
ctx.lineTo(2, 6);
ctx.closePath();
ctx.fill();
}
ctx.restore();
});
// rail line
const rl = projectiles.railLine;
if (rl) {
ctx.strokeStyle = `rgba(125,211,252,${rl.life * 4})`;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(rl.x1, rl.y1);
ctx.lineTo(rl.x2, rl.y2);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(rl.x1, rl.y1);
ctx.lineTo(rl.x2, rl.y2);
ctx.stroke();
ctx.fillStyle = 'rgba(125,211,252,0.6)';
ctx.beginPath();
ctx.arc(rl.x2, rl.y2, 8, 0, Math.PI * 2);
ctx.fill();
}
// grapple rope
if (grapple && activeWorm()) {
const aw = activeWorm();
ctx.strokeStyle = '#cbd5e1';
ctx.lineWidth = 2;
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(aw.x, aw.y);
ctx.lineTo(grapple.x, grapple.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e2e8f0';
ctx.beginPath();
ctx.arc(grapple.x, grapple.y, 5, 0, Math.PI * 2);
ctx.fill();
}
// trajectory prediction
if (gameState === 'playing' && !teams[turnTeamIdx].isCPU && activeWorm() && !isJetpacking && !grapple) {
const aw = activeWorm();
const def = types_1.WEAPONS.find(w => w.id === selectedWeapon);
if (def.cat !== 'Direct' || def.id === 'shotgun') {
const previewPower = isCharging ? charge : 0.62;
const speed = 4 + previewPower * 16;
let px = aw.x + Math.cos(aimAngle) * 18, py = aw.y - 6 + Math.sin(aimAngle) * 10;
let vx = Math.cos(aimAngle) * speed + wind * 0.06, vy = Math.sin(aimAngle) * speed;
const pts = [];
for (let i = 0; i < 70; i++) {
px += vx;
py += vy;
vy += GRAVITY;
vx += wind * WIND_FORCE;
pts.push({ x: px, y: py });
if ((0, terrain_1.isSolid)(px, py) || py > terrain_1.WATER_Y)
break;
}
// dotted line
ctx.strokeStyle = isCharging ? 'rgba(250,204,21,0.95)' : 'rgba(255,255,255,0.55)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 8]);
ctx.beginPath();
pts.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.stroke();
ctx.setLineDash([]);
// impact marker
const last = pts[pts.length - 1];
if (last) {
ctx.strokeStyle = 'rgba(239,68,68,0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(last.x, last.y, 12, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(last.x - 8, last.y);
ctx.lineTo(last.x + 8, last.y);
ctx.moveTo(last.x, last.y - 8);
ctx.lineTo(last.x, last.y + 8);
ctx.stroke();
const r = def.radius;
ctx.fillStyle = 'rgba(239,68,68,0.12)';
ctx.beginPath();
ctx.arc(last.x, last.y, r, 0, Math.PI * 2);
ctx.fill();
}
// aim arrow at worm
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(aw.x, aw.y - 6);
ctx.lineTo(aw.x + Math.cos(aimAngle) * 34, aw.y - 6 + Math.sin(aimAngle) * 34);
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(aw.x + Math.cos(aimAngle) * 38, aw.y - 6 + Math.sin(aimAngle) * 38, 4, 0, Math.PI * 2);
ctx.fill();
}
else if (def.id === 'railgun') {
// laser preview
const sx = aw.x + Math.cos(aimAngle) * 18, sy = aw.y - 6 + Math.sin(aimAngle) * 10;
const ex = sx + Math.cos(aimAngle) * 2600, ey = sy + Math.sin(aimAngle) * 2600;
const hit = (0, terrain_1.rayTerrain)(sx, sy, ex, ey);
ctx.strokeStyle = 'rgba(250,204,21,0.35)';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(hit.hit ? hit.x : ex, hit.hit ? hit.y : ey);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(hit.hit ? hit.x : ex, hit.hit ? hit.y : ey);
ctx.stroke();
ctx.setLineDash([]);
if (hit.hit) {
ctx.fillStyle = 'rgba(239,68,68,0.9)';
ctx.beginPath();
ctx.arc(hit.x, hit.y, 6, 0, Math.PI * 2);
ctx.fill();
}
}
}
// particles
particles_1.particles.forEach(p => {
ctx.globalAlpha = Math.max(0, p.life);
if (p.type === 'smoke') {
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
else if (p.type === 'spark') {
ctx.fillStyle = p.col;
ctx.fillRect(p.x - 1.2, p.y - 1.2, 2.4, 2.4);
}
else if (p.type === 'water') {
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 0.7, 0, Math.PI * 2);
ctx.fill();
}
else {
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
ctx.fill();
}
});
ctx.globalAlpha = 1;
ctx.restore();
// vignette
const vg = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.6, w / 2, h / 2, Math.max(w, h) * 0.9);
vg.addColorStop(0, 'transparent');
vg.addColorStop(1, 'rgba(0,0,0,0.28)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
// power bar is DOM
}
function drawCloud(x, y, s) {
ctx.beginPath();
ctx.ellipse(x, y, 28 * s, 16 * s, 0, 0, Math.PI * 2);
ctx.ellipse(x + 18 * s, y + 4 * s, 22 * s, 12 * s, 0, 0, Math.PI * 2);
ctx.ellipse(x - 16 * s, y + 6 * s, 20 * s, 10 * s, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawWorm(w, team) {
const isActive = teams[turnTeamIdx] === team && teams[turnTeamIdx].worms[turnWormIdx] === w;
ctx.save();
ctx.translate(w.x, w.y);
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath();
ctx.ellipse(0, 14, 14, 5, 0, 0, Math.PI * 2);
ctx.fill();
// body
const bob = Math.sin(Date.now() * 0.004 + w.id) * 1.2;
ctx.translate(0, bob);
// outline
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(0.8, 1.2, 16, 12, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f8fafc'; // worm body
// body segments
ctx.beginPath();
ctx.ellipse(0, 0, 14, 10, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e2e8f0';
ctx.beginPath();
ctx.ellipse(-3, 2, 10, 7, 0, 0, Math.PI * 2);
ctx.fill();
// belly
ctx.fillStyle = '#fef3c7';
ctx.beginPath();
ctx.ellipse(0, 5, 8, 4, 0, 0, Math.PI * 2);
ctx.fill();
// head highlight
ctx.fillStyle = team.color.col;
ctx.beginPath();
ctx.arc(6, -4, 4, 0, Math.PI * 2);
ctx.fill();
// eyes
ctx.fillStyle = '#0f172a';
ctx.beginPath();
ctx.arc(5, -6, 2.2, 0, Math.PI * 2);
ctx.arc(9, -5, 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(5.6, -6.6, 0.9, 0, Math.PI * 2);
ctx.arc(9.6, -5.6, 0.9, 0, Math.PI * 2);
ctx.fill();
// bandana if active
if (isActive) {
ctx.fillStyle = team.color.col;
ctx.fillRect(-12, -10, 22, 4);
ctx.fillStyle = '#fff';
ctx.font = '700 5px sans-serif';
ctx.fillText('★', -9, -7);
}
// weapon in hand direction
const ang = isActive ? aimAngle : 0;
ctx.save();
ctx.rotate(ang);
ctx.fillStyle = '#334155';
ctx.fillRect(10, -2, 14, 3);
ctx.fillStyle = '#1e293b';
ctx.fillRect(18, -3, 4, 5);
ctx.restore();
// HP bar above
ctx.translate(0, -20);
const hpPct = w.hp / 100;
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(-18, -4, 36, 6);
ctx.fillStyle = hpPct > 0.5 ? '#4ade80' : hpPct > 0.25 ? '#facc15' : '#ef4444';
ctx.fillRect(-17, -3, 34 * hpPct, 4);
ctx.fillStyle = '#fff';
ctx.font = '700 7px Nunito,sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(w.hp), 0, 0);
ctx.restore();
}
// start loop
last = performance.now();
requestAnimationFrame(loop);
// expose for probe
window.game = { teams, get activeWorm() { return activeWorm(); }, get wind() { return wind; }, projectiles, particles: particles_1.particles };
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WEAPONS = exports.TEAM_COLORS = void 0;
exports.TEAM_COLORS = [
{ name: 'Crimson', col: '#ef4444', light: '#fca5a5', bg: '#7f1d1d' },
{ name: 'Ocean', col: '#38bdf8', light: '#bae6fd', bg: '#0c4a6e' },
{ name: 'Verdant', col: '#4ade80', light: '#bbf7d0', bg: '#14532d' },
{ name: 'Solar', col: '#facc15', light: '#fef08a', bg: '#713f12' },
{ name: 'Violet', col: '#a78bfa', light: '#ddd6fe', bg: '#4c1d95' },
{ name: 'Coral', col: '#fb7185', light: '#fecdd3', bg: '#881337' },
];
exports.WEAPONS = [
{ id: 'bazooka', name: 'Bazooka', icon: '🚀', cat: 'Indirect', ammo: '∞', damage: 38, radius: 78, desc: 'Classic parabolic rocket', projectile: 'bazooka' },
{ id: 'grenade', name: 'Grenade', icon: '🍍', cat: 'Indirect', ammo: 3, damage: 42, radius: 70, desc: 'Bouncy 3s fuse', projectile: 'grenade' },
{ id: 'shotgun', name: 'Shotgun', icon: '💥', cat: 'Direct', ammo: 2, damage: 14, radius: 18, desc: '5-pellet spread', projectile: 'shotgun', count: 5 },
{ id: 'railgun', name: 'Railgun', icon: '⚡', cat: 'Direct', ammo: 1, damage: 45, radius: 22, desc: 'Instant laser beam', projectile: 'rail' },
{ id: 'airstrike', name: 'Airstrike', icon: '✈️', cat: 'AoE', ammo: 1, damage: 32, radius: 62, desc: '3 missiles from the sky', projectile: 'airstrike', count: 3 },
{ id: 'holy', name: 'Holy Nade', icon: '🙏', cat: 'AoE', ammo: 1, damage: 75, radius: 150, desc: 'Divine devastation', projectile: 'holy' },
{ id: 'drill', name: 'Bunker Buster', icon: '🪛', cat: 'AoE', ammo: 2, damage: 30, radius: 44, desc: 'Drills then erupts', projectile: 'drill' },
];
};
// ── module: src/audio.ts ──
__mods["src/audio.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SFX = void 0;
let ctx = null;
function ac() {
if (!ctx)
ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended')
ctx.resume();
return ctx;
}
function tone(f, dur, type = 'sine', gain = 0.3, slideTo) {
try {
const c = ac();
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.value = f;
o.connect(g);
g.connect(c.destination);
g.gain.value = gain;
g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + dur);
if (slideTo !== undefined)
o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), c.currentTime + dur * 0.9);
o.start();
o.stop(c.currentTime + dur);
}
catch { }
}
function noise(dur, gain = 0.3, filt = 1200) {
try {
const c = ac();
const buffer = c.createBuffer(1, c.sampleRate * dur, c.sampleRate);
const d = buffer.getChannelData(0);
for (let i = 0; i < d.length; i++)
d[i] = (Math.random() * 2 - 1) * 0.6;
const src = c.createBufferSource();
src.buffer = buffer;
const bf = c.createBiquadFilter();
bf.type = 'lowpass';
bf.frequency.value = filt;
const g = c.createGain();
g.gain.value = gain;
g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + dur);
src.connect(bf);
bf.connect(g);
g.connect(c.destination);
src.start();
}
catch { }
}
exports.SFX = {
click() { tone(900, 0.08, 'square', 0.15, 1200); },
hover() { tone(1200, 0.05, 'sine', 0.08); },
jump() { tone(300, 0.18, 'square', 0.25, 600); tone(500, 0.12, 'sine', 0.15); },
step() { tone(180 + Math.random() * 80, 0.07, 'square', 0.09); },
charge() { tone(440, 0.08, 'sine', 0.12); },
fire() { tone(120, 0.35, 'square', 0.4, 40); noise(0.25, 0.25, 800); },
rail() { tone(1200, 0.12, 'sawtooth', 0.3, 200); tone(1800, 0.2, 'square', 0.2); },
explosion(s) { tone(80, 0.5 * s, 'square', 0.5, 30); noise(0.6 * s, 0.45, 600); setTimeout(() => noise(0.4 * s, 0.22, 400), 90); },
holy() { tone(523, 0.4, 'sine', 0.3, 659); setTimeout(() => tone(659, 0.4, 'sine', 0.3, 784), 180); setTimeout(() => tone(784, 0.6, 'sine', 0.3, 1046), 360); noise(1.2, 0.18, 900); },
water() { noise(0.5, 0.15, 1400); tone(220, 0.4, 'sine', 0.12, 180); },
hurt() { tone(220, 0.18, 'sawtooth', 0.28, 140); },
tick(t) { const f = 700 + t * 40; tone(f, 0.07, 'square', 0.14); },
panicTick() { tone(1100, 0.06, 'square', 0.22); },
win() { [0, 200, 400].forEach(d => setTimeout(() => tone(400 + d, 0.25, 'square', 0.22, 600 + d), d)); },
turn() { tone(600, 0.18, 'sine', 0.18, 900); },
menuHit() { tone(800, 0.09, 'square', 0.18, 1000); }
};
};
// ── module: src/terrain.ts ──
__mods["src/terrain.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WATER_Y = exports.H = exports.W = void 0;
exports.initTerrain = initTerrain;
exports.getCanvas = getCanvas;
exports.isSolid = isSolid;
exports.isSolidCircle = isSolidCircle;
exports.carveCircle = carveCircle;
exports.generate = generate;
exports.findSpawnPositions = findSpawnPositions;
exports.rayTerrain = rayTerrain;
exports.W = 2200;
exports.H = 740;
exports.WATER_Y = exports.H - 78;
let solids; // W*H 0/1
let canvas;
let g;
let debrisCb = null;
function initTerrain(cb) {
debrisCb = cb;
canvas = document.createElement('canvas');
canvas.width = exports.W;
canvas.height = exports.H;
g = canvas.getContext('2d');
solids = new Uint8Array(exports.W * exports.H);
}
function getCanvas() { return canvas; }
function isSolid(x, y) {
x = Math.floor(x);
y = Math.floor(y);
if (x < 0 || x >= exports.W || y < 0 || y >= exports.H)
return x < 0 || x >= exports.W ? true : false;
return solids[y * exports.W + x] === 1;
}
function isSolidCircle(x, y, r) {
for (let a = 0; a < 8; a++) {
const ang = a * Math.PI / 4;
if (isSolid(x + Math.cos(ang) * r, y + Math.sin(ang) * r))
return true;
}
return isSolid(x, y);
}
function carveCircle(cx, y, rad) {
const r = Math.ceil(rad);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > rad * rad)
continue;
const x = cx + dx, yy = y + dy;
if (x < 0 || x >= exports.W || yy < 0 || yy >= exports.H)
continue;
solids[yy * exports.W + x] = 0;
}
}
// punch canvas
g.globalCompositeOperation = 'destination-out';
g.beginPath();
g.arc(cx, y, rad, 0, Math.PI * 2);
g.fill();
g.globalCompositeOperation = 'source-over';
// debris chunks from edge
if (debrisCb) {
const n = Math.floor(rad / 10) + 2;
for (let i = 0; i < n; i++) {
const ang = Math.random() * Math.PI * 2;
const rr = rad * 0.7 + Math.random() * rad * 0.4;
const sx = cx + Math.cos(ang) * rr * 0.5;
const sy = y + Math.sin(ang) * rr * 0.5;
debrisCb(sx, sy, 8 + Math.random() * 14, 7 + Math.random() * 10);
}
}
}
function generate(type) {
solids.fill(0);
g.clearRect(0, 0, exports.W, exports.H);
// sky gradient already drawn in main render; here draw terrain fill
// base fill
if (type === 'island') {
const pts = [];
for (let x = 0; x <= exports.W; x += 12) {
const t = x / exports.W;
const hill = Math.sin(t * Math.PI) * 280 + Math.sin(t * 6) * 18 + (Math.random() - 0.5) * 10;
const base = exports.H - 90 - hill * 0.95;
pts.push([x, base]);
}
drawPoly(pts, '#3a2a14');
addTexture();
}
else if (type === 'twin') {
const pts = [];
for (let x = 0; x <= exports.W; x += 10) {
const t = x / exports.W;
const l = Math.sin(t * Math.PI * 2) * 0.5 + 0.5; // not needed
const twin = Math.max(0, Math.sin((t * 2) * Math.PI)) * 180 + 60 * Math.sin(t * Math.PI * 6) * 0; // placeholder
const h1 = Math.sin(Math.PI * (t * 2)) * (t < 0.5 ? 220 : 0);
const h2 = Math.sin(Math.PI * ((t - 0.5) * 2)) * (t >= 0.5 ? 210 : 0);
const h = Math.max(h1, h2) + Math.sin(t * 8) * 12 + Math.sin(t * 15) * 6 + Math.sin(t * Math.PI) * 20;
const y = exports.H - 95 - Math.max(0, h);
pts.push([x, y]);
}
// add valley
for (let i = 0; i < pts.length; i++) {
const t = pts[i][0] / exports.W;
if (t > 0.42 && t < 0.58)
pts[i][1] = exports.H - 90 - ((Math.abs(t - 0.5) * 2) * 20); // dip to near water
}
drawPoly(pts, '#3a2a14');
addTexture();
}
else { // cave
// fill full with terrain
g.fillStyle = '#2b1b0e';
g.fillRect(0, 0, exports.W, exports.H);
for (let y = 0; y < exports.H; y++)
for (let x = 0; x < exports.W; x++)
solids[y * exports.W + x] = 1;
// carve main cavern ellipse wavy
g.globalCompositeOperation = 'destination-out';
const cx = exports.W / 2, cy = exports.H / 2 + 40;
g.beginPath();
// wavy ellipse
const rx = exports.W * 0.42, ry = 220;
const steps = 72;
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2;
const wave = Math.sin(a * 6) * 18 + Math.sin(a * 13) * 8 + (Math.random() - 0.5) * 6;
const x = cx + Math.cos(a) * (rx + wave);
const y = cy + Math.sin(a) * (ry + wave * 0.6);
if (i === 0)
g.moveTo(x, y);
else
g.lineTo(x, y);
}
g.closePath();
g.fill();
// clear solids inside shape: we will clear by point-in-polygon approx: instead iterate and check ellipse dist with wave approximation (simplify clear oval)
for (let y = 0; y < exports.H; y++)
for (let x = 0; x < exports.W; x++) {
const dx = (x - cx) / rx, dy = (y - cy) / ry;
if (dx * dx + dy * dy < 0.88 + Math.sin(Math.atan2(dy, dx) * 6) * 0.04)
solids[y * exports.W + x] = 0;
}
// carve openings bottom for water
g.globalCompositeOperation = 'source-over';
// ensure floor platform
// add pillars
g.fillStyle = '#3d2816';
g.globalCompositeOperation = 'source-over';
// keep solids for pillars - add two pillars
for (let p = 0; p < 2; p++) {
const px = p === 0 ? exports.W * 0.32 : exports.W * 0.68;
const pw = 26 + Math.random() * 18;
for (let y = cy + ry * 0.2; y < exports.H - 70; y++) {
for (let dx = -pw / 2; dx < pw / 2; dx++) {
const x = Math.floor(px + dx + Math.sin(y * 0.05) * 4);
if (x >= 0 && x < exports.W)
solids[Math.floor(y) * exports.W + x] = 1;
}
g.fillRect(px - pw / 2 + Math.sin(y * 0.05) * 4, y, pw, 1);
}
}
g.globalCompositeOperation = 'source-over';
addTextureCave();
}
// ensure bottom solid under water
for (let y = exports.WATER_Y + 28; y < exports.H; y++)
for (let x = 0; x < exports.W; x++) {
solids[y * exports.W + x] = 1;
}
// bottom draw
g.fillStyle = '#1a1208';
g.globalCompositeOperation = 'source-over';
// re-draw bottom strip for visual
// we already have texture; just ensure dark base
}
function drawPoly(pts, col) {
g.fillStyle = col;
g.beginPath();
g.moveTo(0, exports.H);
pts.forEach(([x, y]) => g.lineTo(x, y));
g.lineTo(exports.W, exports.H);
g.closePath();
g.fill();
// solids
// rasterize: for each x find top y and fill below
for (let i = 0; i < pts.length - 1; i++) {
const [x0, y0] = pts[i], [x1, y1] = pts[i + 1];
const steps = Math.ceil(x1 - x0);
for (let s = 0; s <= steps; s++) {
const t = s / steps, x = Math.floor(x0 + t * (x1 - x0)), yTop = y0 + t * (y1 - y0);
for (let y = Math.floor(yTop); y < exports.H; y++)
if (x >= 0 && x < exports.W && y >= 0 && y < exports.H)
solids[y * exports.W + x] = 1;
}
}
}
function addTexture() {
// sprinkle lighter spots + grass
g.fillStyle = 'rgba(92,64,22,0.35)';
for (let i = 0; i < 6000; i++) {
const x = Math.random() * exports.W, y = Math.random() * exports.H;
if (isSolid(x, y) && y < exports.WATER_Y)
g.fillRect(x, y, 2, 2);
}
// grass top
g.strokeStyle = '#5a8a2a';
g.lineWidth = 2;
for (let x = 0; x < exports.W; x += 7) {
// find top
let top = -1;
for (let y = 0; y < exports.H; y++)
if (isSolid(x, y)) {
top = y;
break;
}
if (top >= 0 && top < exports.WATER_Y) {
g.beginPath();
g.moveTo(x, top);
g.lineTo(x + (Math.random() - 0.5) * 4, top - 6 - Math.random() * 6);
g.stroke();
}
}
// darker shadow under
g.fillStyle = 'rgba(0,0,0,0.12)';
for (let x = 0; x < exports.W; x++) {
let top = -1;
for (let y = 0; y < exports.H; y++)
if (isSolid(x, y)) {
top = y;
break;
}
if (top >= 0)
g.fillRect(x, top, 1, 10);
}
}
function addTextureCave() {
// subtle noise
g.fillStyle = 'rgba(255,255,255,0.04)';
for (let i = 0; i < 8000; i++) {
const x = Math.random() * exports.W, y = Math.random() * exports.H;
if (isSolid(x, y))
g.fillRect(x, y, 1, 1);
}
}
function findSpawnPositions(n) {
const res = [];
const tries = 500;
for (let k = 0; k < tries && res.length < n; k++) {
const x = 120 + Math.random() * (exports.W - 240);
// find top solid
let yTop = -1;
for (let y = 0; y < exports.H; y++)
if (isSolid(x, y)) {
yTop = y;
break;
}
if (yTop < 0 || yTop > exports.WATER_Y - 40)
continue;
// check flatness: sample either side
const yL = (() => { for (let y = 0; y < exports.H; y++)
if (isSolid(x - 16, y))
return y; return 9999; })(), yR = (() => { for (let y = 0; y < exports.H; y++)
if (isSolid(x + 16, y))
return y; return 9999; })();
if (Math.abs(yTop - yL) > 22 || Math.abs(yTop - yR) > 22)
continue;
// not too close to others
if (res.some(p => Math.abs(p.x - x) < 90))
continue;
res.push({ x, y: yTop - 14 });
}
// fallback if not enough
let attempts = 0;
while (res.length < n && attempts < 200) {
const x = 120 + Math.random() * (exports.W - 240);
let yTop = -1;
for (let y = 0; y < exports.H; y++)
if (isSolid(x, y)) {
yTop = y;
break;
}
if (yTop >= 0)
res.push({ x, y: yTop - 14 });
attempts++;
}
return res;
}
function rayTerrain(x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy);
const steps = Math.ceil(len / 2);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = x0 + dx * t, y = y0 + dy * t;
if (isSolid(x, y))
return { x, y, hit: true };
}
return { x: x1, y: y1, hit: false };
}
};
// ── module: src/particles.ts ──
__mods["src/particles.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.debris = exports.particles = void 0;
exports.spawnExplosion = spawnExplosion;
exports.spawnDust = spawnDust;
exports.spawnMuzzle = spawnMuzzle;
exports.spawnSplash = spawnSplash;
exports.addDebris = addDebris;
exports.updateParticles = updateParticles;
const terrain_1 = require("./terrain");
exports.particles = [];
exports.debris = [];
function spawnExplosion(x, y, power, color = '#ff6b35') {
const n = Math.floor(power * 0.9) + 18;
for (let i = 0; i < n; i++) {
const ang = Math.random() * Math.PI * 2, sp = (Math.random() * power * 0.45) + 2;
exports.particles.push({ x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - Math.random() * 2, life: 1, maxLife: 0.5 + Math.random() * 0.5, r: 2 + Math.random() * 4, col: i % 3 === 0 ? '#222' : i % 3 === 1 ? color : '#ffcc33', alpha: 1, type: Math.random() < 0.3 ? 'smoke' : 'spark' });
}
for (let i = 0; i < 12; i++) {
exports.particles.push({ x, y, vx: (Math.random() - 0.5) * power * 0.3, vy: (Math.random() - 0.5) * power * 0.3 - 1, life: 1, maxLife: 0.7 + Math.random() * 0.6, r: 4 + Math.random() * 6, col: 'rgba(60,60,60,0.7)', alpha: 0.6, type: 'smoke' });
}
// debris specks
for (let i = 0; i < 14; i++) {
exports.particles.push({ x, y, vx: (Math.random() - 0.5) * power * 0.4, vy: (Math.random() - 0.5) * power * 0.4 - 2, life: 1, maxLife: 0.8 + Math.random() * 0.5, r: 2 + Math.random() * 3, col: '#5c3a1a', alpha: 1, type: 'dust' });
}
}
function spawnDust(x, y, n = 6) {
for (let i = 0; i < n; i++)
exports.particles.push({ x, y, vx: (Math.random() - 0.5) * 3, vy: -Math.random() * 2.5, life: 1, maxLife: 0.4 + Math.random() * 0.3, r: 1 + Math.random() * 2, col: '#c2b280', alpha: 0.8, type: 'dust' });
}
function spawnMuzzle(x, y, ang) {
for (let i = 0; i < 6; i++)
exports.particles.push({ x, y, vx: Math.cos(ang) * (4 + Math.random() * 4) + (Math.random() - 0.5) * 2, vy: Math.sin(ang) * (4 + Math.random() * 4) + (Math.random() - 0.5) * 2, life: 1, maxLife: 0.18, r: 2 + Math.random() * 2, col: '#ffec8b', alpha: 1, type: 'spark' });
}
function spawnSplash(x, y) {
for (let i = 0; i < 10; i++)
exports.particles.push({ x, y, vx: (Math.random() - 0.5) * 4, vy: -Math.random() * 4 - 1, life: 1, maxLife: 0.5, r: 2 + Math.random() * 3, col: 'rgba(120,200,255,0.9)', alpha: 0.9, type: 'water' });
}
function addDebris(x, y, w, h) {
exports.debris.push({ x, y, vx: (Math.random() - 0.5) * 3, vy: -Math.random() * 2, w, h, rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, life: 1 });
}
function updateParticles(dt, gravity, wind) {
for (let i = exports.particles.length - 1; i >= 0; i--) {
const p = exports.particles[i];
p.x += p.vx * dt * 60;
p.y += p.vy * dt * 60;
p.vy += gravity * 0.35 * dt * 60 * (p.type === 'smoke' ? 0.25 : 1);
p.vx += wind * 0.02 * dt * 60;
if (p.type === 'smoke') {
p.vx *= 0.99;
p.vy *= 0.99;
p.r += 0.08;
}
else if (p.type === 'spark')
p.vy += 0.15;
p.life -= dt / p.maxLife;
if (p.type === 'dust' && (0, terrain_1.isSolid)(p.x, p.y))
p.life -= 0.08;
if (p.life <= 0)
exports.particles.splice(i, 1);
}
for (let i = exports.debris.length - 1; i >= 0; i--) {
const d = exports.debris[i];
d.vy += gravity * 0.55 * dt * 60;
d.vx += wind * 0.03 * dt * 60;
d.vx *= 0.995;
d.x += d.vx;
d.y += d.vy;
d.rot += d.vr;
// ground collision
if ((0, terrain_1.isSolid)(d.x, d.y + d.h / 2)) {
d.y -= 1;
d.vy *= -0.15;
d.vx *= 0.72;
d.vr *= 0.7;
if (Math.abs(d.vy) < 0.5)
d.vy = 0;
if (Math.abs(d.vx) < 0.15)
d.vx = 0;
d.life -= 0.012;
if (Math.random() < 0.03)
spawnDust(d.x, d.y, 1);
}
if (d.y > 740)
exports.debris.splice(i, 1);
if (d.life <= 0 && Math.abs(d.vy) < 0.2 && Math.abs(d.vx) < 0.2)
exports.debris.splice(i, 1);
}
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>