Worms
Grok 4.5 · legacy

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 97,485 bytes · SHA-256 dff2ca71adcb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wormageddon — Artillery Tactics</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600;700&family=Press+Start+2P&display=swap');
:root {
--sky-top: #1a3a6b;
--sky-mid: #4a90c8;
--sky-bot: #87ceeb;
--water: #1e6fad;
--water-deep: #0d3d5c;
--panel: rgba(12, 18, 32, 0.88);
--panel-border: rgba(120, 180, 255, 0.35);
--accent: #ffd36b;
--danger: #ff5a5a;
--ok: #6bff9a;
--text: #f0f4ff;
--muted: #8aa0c0;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #0a0e18;
font-family: 'Fredoka', system-ui, sans-serif;
color: var(--text);
user-select: none;
}
#app {
width: 100%; height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
canvas#game {
display: block;
image-rendering: pixelated;
background: #000;
box-shadow: 0 0 80px rgba(80, 140, 255, 0.2);
border-radius: 4px;
}
/* ===== SCREENS ===== */
.screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 50;
background:
radial-gradient(ellipse at 50% 20%, rgba(60,120,220,0.25), transparent 55%),
linear-gradient(180deg, #0b1220 0%, #152040 50%, #0a1428 100%);
transition: opacity 0.4s ease, visibility 0.4s;
}
.screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.logo {
font-family: 'Press Start 2P', monospace;
font-size: clamp(22px, 4vw, 42px);
background: linear-gradient(180deg, #fff8c8 0%, #ffb347 40%, #ff6b35 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: none;
filter: drop-shadow(0 4px 0 #8b2e0e) drop-shadow(0 8px 20px rgba(255,120,40,0.4));
margin-bottom: 8px;
letter-spacing: 2px;
animation: logoBob 3s ease-in-out infinite;
}
@keyframes logoBob {
0%,100% { transform: translateY(0) rotate(-1deg); }
50% { transform: translateY(-8px) rotate(1deg); }
}
.tagline {
color: var(--muted);
font-size: 15px;
margin-bottom: 36px;
letter-spacing: 3px;
text-transform: uppercase;
}
.menu-card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 18px;
padding: 28px 36px;
min-width: min(420px, 92vw);
backdrop-filter: blur(16px);
box-shadow: 0 20px 60px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06);
}
.menu-section { margin-bottom: 20px; }
.menu-section label {
display: block;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--muted);
margin-bottom: 10px;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
flex: 1;
min-width: 70px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(120,160,220,0.25);
background: rgba(30,45,80,0.6);
color: var(--text);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.chip:hover { border-color: var(--accent); transform: translateY(-1px); }
.chip.active {
background: linear-gradient(180deg, #3a6ab0, #254a88);
border-color: #7ab0ff;
box-shadow: 0 0 0 2px rgba(120,180,255,0.25), 0 4px 12px rgba(40,80,180,0.4);
}
.btn-primary {
width: 100%;
margin-top: 10px;
padding: 16px 24px;
border: none;
border-radius: 12px;
font-family: 'Fredoka', sans-serif;
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
cursor: pointer;
color: #2a1800;
background: linear-gradient(180deg, #ffe08a 0%, #ffb020 55%, #e08810 100%);
box-shadow: 0 4px 0 #9a5800, 0 8px 24px rgba(255,160,20,0.35);
transition: transform 0.1s, box-shadow 0.1s;
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 0 #9a5800, 0 12px 30px rgba(255,160,20,0.4); }
.btn-primary:active { transform: translateY(2px); box-shadow: 0 2px 0 #9a5800; }
.btn-ghost {
width: 100%;
margin-top: 10px;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(120,160,220,0.3);
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
}
.btn-ghost:hover { color: var(--text); border-color: var(--accent); }
/* ===== HUD ===== */
#hud {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 20;
display: none;
}
#hud.visible { display: block; }
.hud-top {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 14px 18px;
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, transparent 100%);
}
.team-strip {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.team-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 10px;
background: rgba(8,12,24,0.75);
border: 1px solid rgba(255,255,255,0.1);
font-size: 13px;
font-weight: 600;
transition: all 0.3s;
}
.team-badge.active {
border-color: var(--accent);
box-shadow: 0 0 16px rgba(255,211,107,0.3);
transform: scale(1.05);
}
.team-dot {
width: 12px; height: 12px;
border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.timer-wrap {
text-align: center;
min-width: 120px;
}
.timer-label {
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--muted);
}
.timer-val {
font-family: 'Press Start 2P', monospace;
font-size: 28px;
color: #fff;
transition: transform 0.2s, color 0.2s;
text-shadow: 0 0 20px rgba(255,200,80,0.5);
}
.timer-val.urgent {
color: #ff5050;
animation: timerPulse 0.4s ease-in-out infinite;
}
@keyframes timerPulse {
0%,100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.wind-meter {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba(8,12,24,0.75);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.1);
font-size: 13px;
}
.wind-arrow {
font-size: 18px;
transition: transform 0.3s;
}
.hud-bottom {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 12px 16px 16px;
background: linear-gradient(0deg, rgba(0,0,0,0.65) 0%, transparent 100%);
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: auto;
}
.weapon-bar {
display: flex;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.wep-btn {
position: relative;
width: 54px; height: 54px;
border-radius: 12px;
border: 2px solid rgba(255,255,255,0.12);
background: rgba(15,22,40,0.9);
color: #fff;
font-size: 22px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.wep-btn:hover { border-color: rgba(255,200,80,0.5); transform: translateY(-3px); }
.wep-btn.selected {
border-color: var(--accent);
background: linear-gradient(180deg, #3a5080, #1a2848);
box-shadow: 0 0 0 2px rgba(255,211,107,0.3), 0 6px 16px rgba(0,0,0,0.4);
transform: translateY(-4px) scale(1.08);
}
.wep-btn .key {
position: absolute;
top: 2px; right: 4px;
font-size: 9px;
color: var(--muted);
font-family: monospace;
}
.wep-btn .ammo {
position: absolute;
bottom: 2px; right: 4px;
font-size: 10px;
font-weight: 700;
color: var(--accent);
}
.turn-banner {
position: absolute;
top: 28%;
left: 50%;
transform: translate(-50%, -50%);
font-family: 'Press Start 2P', monospace;
font-size: clamp(16px, 3vw, 28px);
text-align: center;
padding: 18px 32px;
border-radius: 14px;
background: rgba(8,12,24,0.9);
border: 2px solid var(--accent);
box-shadow: 0 0 40px rgba(255,180,40,0.3);
z-index: 30;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s, transform 0.3s;
}
.turn-banner.show {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.turn-banner.hide {
opacity: 0;
transform: translate(-50%, -60%) scale(0.9);
}
.float-text-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 25;
}
.float-dmg {
position: absolute;
font-family: 'Press Start 2P', monospace;
font-size: 14px;
font-weight: 700;
color: #ff6b6b;
text-shadow: 0 2px 0 #800, 0 0 10px rgba(255,50,50,0.6);
animation: floatUp 1.2s ease-out forwards;
pointer-events: none;
}
.float-dmg.heal {
color: #6bff9a;
text-shadow: 0 2px 0 #060, 0 0 10px rgba(50,255,100,0.5);
}
@keyframes floatUp {
0% { opacity: 1; transform: translateY(0) scale(0.6); }
20% { transform: translateY(-10px) scale(1.2); }
100% { opacity: 0; transform: translateY(-50px) scale(1); }
}
.help-hint {
text-align: center;
font-size: 12px;
color: rgba(200,220,255,0.55);
letter-spacing: 0.5px;
}
.power-bar-wrap {
position: absolute;
bottom: 140px;
left: 50%;
transform: translateX(-50%);
width: 220px;
height: 18px;
background: rgba(0,0,0,0.7);
border-radius: 9px;
border: 2px solid rgba(255,255,255,0.2);
overflow: hidden;
display: none;
z-index: 22;
}
.power-bar-wrap.show { display: block; }
.power-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4ade80, #facc15, #f97316, #ef4444);
border-radius: 7px;
transition: none;
}
.power-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-shadow: 0 1px 2px #000;
}
.end-screen-content {
text-align: center;
}
.end-screen-content h2 {
font-family: 'Press Start 2P', monospace;
font-size: clamp(18px, 3vw, 28px);
margin-bottom: 16px;
background: linear-gradient(180deg, #fff, #ffd36b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.end-screen-content p {
color: var(--muted);
margin-bottom: 24px;
font-size: 16px;
}
.tooltip {
position: absolute;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(10,14,28,0.95);
border: 1px solid var(--panel-border);
padding: 8px 14px;
border-radius: 8px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 40;
}
.tooltip.show { opacity: 1; }
/* Loading / gen */
.gen-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,8,16,0.85);
z-index: 60;
flex-direction: column;
gap: 16px;
}
.gen-overlay.hidden { display: none; }
.spinner {
width: 48px; height: 48px;
border: 4px solid rgba(120,160,255,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="app">
<canvas id="game"></canvas>
<!-- MENU -->
<div id="menu" class="screen">
<div class="logo">WORMAGEDDON</div>
<div class="tagline">Artillery · Destruction · Chaos</div>
<div class="menu-card">
<div class="menu-section">
<label>Teams</label>
<div class="chip-row" id="teamCountChips">
<button class="chip active" data-v="2">2 Teams</button>
<button class="chip" data-v="3">3 Teams</button>
<button class="chip" data-v="4">4 Teams</button>
</div>
</div>
<div class="menu-section">
<label>Players / CPU</label>
<div class="chip-row" id="cpuChips">
<button class="chip active" data-v="1">1 Human</button>
<button class="chip" data-v="2">2 Humans</button>
<button class="chip" data-v="0">All CPU</button>
<button class="chip" data-v="-1">All Human</button>
</div>
</div>
<div class="menu-section">
<label>Terrain</label>
<div class="chip-row" id="terrainChips">
<button class="chip active" data-v="island">🏝️ Island</button>
<button class="chip" data-v="two">🏝️🏝️ Twin Isles</button>
<button class="chip" data-v="cave">🕳️ Cave</button>
</div>
</div>
<button class="btn-primary" id="btnStart">🔥 Deploy Worms!</button>
<button class="btn-ghost" id="btnHow">How to Play</button>
</div>
</div>
<!-- HOW TO -->
<div id="howScreen" class="screen hidden">
<div class="menu-card" style="max-width:520px">
<div style="font-family:'Press Start 2P';font-size:14px;margin-bottom:18px;color:var(--accent)">CONTROLS</div>
<div style="font-size:14px;line-height:1.7;color:#c8d8f0;margin-bottom:16px">
<b>A/D</b> or ←/→ — Move · <b>W</b> or ↑ — Jump<br>
<b>Mouse</b> — Aim · <b>Hold LMB</b> — Charge power · release to fire<br>
<b>1–9</b> — Select weapon · <b>Q/E</b> cycle weapons<br>
<b>Space</b> — Jump · <b>S</b> — Dig / use utility<br>
<b>Esc</b> — Pause / Menu
</div>
<div style="font-size:13px;line-height:1.6;color:var(--muted);margin-bottom:20px">
Wind pushes projectiles. Fall damage hurts. Don't drown.
Last team standing wins. Worms have 100 HP each — 4 per team.
</div>
<button class="btn-primary" id="btnBackHow">Got it!</button>
</div>
</div>
<!-- END -->
<div id="endScreen" class="screen hidden">
<div class="menu-card end-screen-content">
<h2 id="endTitle">VICTORY!</h2>
<p id="endDesc">Team Red remains victorious!</p>
<button class="btn-primary" id="btnRematch">Play Again</button>
<button class="btn-ghost" id="btnMenu">Main Menu</button>
</div>
</div>
<div id="genOverlay" class="gen-overlay hidden">
<div class="spinner"></div>
<div style="color:var(--muted);letter-spacing:2px;font-size:13px">DIGGING TUNNELS…</div>
</div>
<!-- HUD -->
<div id="hud">
<div class="hud-top">
<div class="team-strip" id="teamStrip"></div>
<div class="timer-wrap">
<div class="timer-label">TURN</div>
<div class="timer-val" id="timerVal">45</div>
</div>
<div class="wind-meter" id="windMeter">
<span class="wind-arrow" id="windArrow">→</span>
<span id="windText">Wind 0</span>
</div>
</div>
<div class="turn-banner" id="turnBanner"></div>
<div class="float-text-layer" id="floatLayer"></div>
<div class="power-bar-wrap" id="powerBar">
<div class="power-bar-fill" id="powerFill"></div>
<div class="power-label">POWER</div>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="hud-bottom">
<div class="weapon-bar" id="weaponBar"></div>
<div class="help-hint" id="helpHint">Aim with mouse · Hold fire to charge · Release to launch</div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// WORMAGEDDON — Highly polished Worms-like Artillery Game
// ═══════════════════════════════════════════════════════════
(() => {
'use strict';
// ---------- Canvas / Core ----------
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const WORLD_W = 1920;
const WORLD_H = 1080;
const WATER_Y = WORLD_H - 90;
let SCALE = 1;
let dpr = 1;
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
const maxW = window.innerWidth;
const maxH = window.innerHeight;
SCALE = Math.min(maxW / WORLD_W, maxH / WORLD_H);
canvas.style.width = (WORLD_W * SCALE) + 'px';
canvas.style.height = (WORLD_H * SCALE) + 'px';
canvas.width = Math.floor(WORLD_W * dpr);
canvas.height = Math.floor(WORLD_H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// ---------- Audio (Web Audio quirky SFX) ----------
const AudioSys = {
ctx: null,
master: null,
muted: false,
tempo: 1,
init() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.28;
this.master.connect(this.ctx.destination);
},
resume() {
this.init();
if (this.ctx.state === 'suspended') this.ctx.resume();
},
tone(freq, dur, type = 'square', vol = 0.3, slide = 0, delay = 0) {
if (this.muted || !this.ctx) return;
const t0 = this.ctx.currentTime + delay;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t0);
if (slide) o.frequency.linearRampToValueAtTime(freq + slide, t0 + dur);
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
o.connect(g); g.connect(this.master);
o.start(t0); o.stop(t0 + dur + 0.02);
},
noise(dur, vol = 0.2, bpFreq = 800) {
if (this.muted || !this.ctx) return;
const t0 = this.ctx.currentTime;
const n = Math.floor(this.ctx.sampleRate * dur);
const buf = this.ctx.createBuffer(1, n, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 1.5);
const src = this.ctx.createBufferSource();
src.buffer = buf;
const bp = this.ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = bpFreq;
bp.Q.value = 0.6;
const g = this.ctx.createGain();
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
src.connect(bp); bp.connect(g); g.connect(this.master);
src.start(t0);
},
click() { this.tone(880, 0.04, 'square', 0.12); this.tone(1320, 0.05, 'triangle', 0.08, 0, 0.02); },
select() { this.tone(520, 0.06, 'triangle', 0.15); this.tone(780, 0.08, 'sine', 0.1, 0, 0.04); },
jump() { this.tone(220, 0.1, 'square', 0.15, 180); },
land() { this.noise(0.08, 0.15, 300); this.tone(90, 0.06, 'triangle', 0.1); },
charge() { this.tone(180 + Math.random() * 40, 0.05, 'sawtooth', 0.05); },
fire() { this.noise(0.12, 0.22, 600); this.tone(140, 0.15, 'sawtooth', 0.2, -80); },
boom(size = 1) {
this.noise(0.25 + size * 0.2, 0.3 * Math.min(1.4, size), 200 / size);
this.tone(60 / size, 0.35 + size * 0.1, 'sine', 0.35, -30);
this.tone(40, 0.5, 'triangle', 0.15 * size);
},
splash() { this.noise(0.3, 0.2, 1200); this.tone(300, 0.2, 'sine', 0.1, -200); },
hurt() { this.tone(180, 0.08, 'square', 0.2, -60); this.tone(120, 0.15, 'sawtooth', 0.12, -40, 0.05); },
death() {
this.tone(400, 0.1, 'square', 0.2, -100);
this.tone(300, 0.12, 'square', 0.15, -80, 0.08);
this.tone(200, 0.2, 'square', 0.12, -60, 0.16);
this.noise(0.35, 0.15, 400);
},
tick(urgent) {
const f = urgent ? 880 : 640;
this.tone(f, 0.04, 'square', urgent ? 0.15 : 0.08);
},
win() {
[523, 659, 784, 1046].forEach((f, i) => this.tone(f, 0.2, 'triangle', 0.18, 0, i * 0.12));
},
uiHover() { this.tone(600, 0.03, 'sine', 0.05); },
whoosh() { this.noise(0.18, 0.12, 2000); this.tone(600, 0.15, 'sine', 0.08, -400); },
dig() { this.noise(0.06, 0.12, 500); this.tone(100, 0.05, 'square', 0.08); },
ice() { this.tone(900, 0.08, 'sine', 0.1); this.tone(1200, 0.12, 'triangle', 0.08, 200, 0.04); },
beep() { this.tone(1100, 0.05, 'square', 0.1); },
};
// ---------- Input ----------
const keys = {};
const mouse = { x: 0, y: 0, worldX: 0, worldY: 0, down: false, justUp: false };
let lastKeyWeapon = -1;
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
AudioSys.resume();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('mousemove', e => {
const r = canvas.getBoundingClientRect();
mouse.x = (e.clientX - r.left) / SCALE;
mouse.y = (e.clientY - r.top) / SCALE;
});
canvas.addEventListener('mousedown', e => {
if (e.button === 0) { mouse.down = true; AudioSys.resume(); }
});
canvas.addEventListener('mouseup', e => {
if (e.button === 0) { mouse.down = false; mouse.justUp = true; }
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
// ---------- Math / helpers ----------
const PI = Math.PI;
const TAU = PI * 2;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const dist = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1);
const rand = (a, b) => a + Math.random() * (b - a);
const randi = (a, b) => (a + Math.floor(Math.random() * (b - a + 1)));
const chance = p => Math.random() < p;
const sign = v => (v < 0 ? -1 : v > 0 ? 1 : 0);
const easeOut = t => 1 - Math.pow(1 - t, 3);
// ---------- Game Config ----------
const TEAM_COLORS = [
{ name: 'Crimson', primary: '#e74c3c', light: '#ff7b6e', dark: '#8b1e14', skin: '#f5a89a' },
{ name: 'Azure', primary: '#3498db', light: '#7ec8f5', dark: '#1a5276', skin: '#a8d4f0' },
{ name: 'Emerald', primary: '#2ecc71', light: '#7dffa8', dark: '#145a32', skin: '#a0e8b8' },
{ name: 'Gold', primary: '#f1c40f', light: '#ffe66d', dark: '#9a7b00', skin: '#f5e6a0' },
];
const UNITS_PER_TEAM = 4;
const UNIT_HP = 100;
const TURN_TIME = 45;
const MOVE_SPEED = 1.65;
const JUMP_V = -7.2;
const GRAVITY = 0.22;
const FRICTION = 0.82;
const AIR_FRICTION = 0.995;
const FALL_DMG_THRESH = 8.5;
// ---------- Weapons ----------
const WEAPONS = [
{
id: 'bazooka', name: 'Bazooka', icon: '🚀', key: '1', cat: 'indirect', ammo: Infinity,
desc: 'Classic rocket — arcs with wind', radius: 55, dmg: 48, powerNeeded: true,
fire(u, ang, power) {
const spd = 6 + power * 14;
projectiles.push(new Projectile({
x: u.x + Math.cos(ang) * 18, y: u.y + Math.sin(ang) * 18 - 6,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
r: 5, team: u.team, owner: u, color: '#ff8844',
trail: true, explodeRadius: 55, damage: 48, wind: true, gravity: 0.16,
}));
AudioSys.fire();
}
},
{
id: 'grenade', name: 'Grenade', icon: '💣', key: '2', cat: 'indirect', ammo: Infinity,
desc: 'Bouncy timed grenade (3s)', radius: 60, dmg: 55, powerNeeded: true,
fire(u, ang, power) {
const spd = 4 + power * 12;
projectiles.push(new Projectile({
x: u.x + Math.cos(ang) * 14, y: u.y + Math.sin(ang) * 14 - 6,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
r: 6, team: u.team, owner: u, color: '#2d5a27',
trail: false, explodeRadius: 60, damage: 55, wind: true, gravity: 0.22,
bounce: 0.55, fuse: 3.0, sticky: false, sprite: 'grenade',
}));
AudioSys.fire();
}
},
{
id: 'shotgun', name: 'Shotgun', icon: '🔫', key: '3', cat: 'direct', ammo: 8,
desc: 'Close-range blast of pellets', radius: 18, dmg: 18, powerNeeded: false,
fire(u, ang) {
AudioSys.noise(0.1, 0.25, 1500);
AudioSys.tone(90, 0.1, 'sawtooth', 0.2);
spawnMuzzleFlash(u.x + Math.cos(ang) * 16, u.y + Math.sin(ang) * 16 - 6);
for (let i = 0; i < 6; i++) {
const spread = (Math.random() - 0.5) * 0.28;
const a = ang + spread;
const spd = 16 + Math.random() * 4;
projectiles.push(new Projectile({
x: u.x + Math.cos(a) * 16, y: u.y + Math.sin(a) * 16 - 6,
vx: Math.cos(a) * spd, vy: Math.sin(a) * spd,
r: 2.5, team: u.team, owner: u, color: '#ffdd88',
trail: true, explodeRadius: 16, damage: 14 + randi(0, 6),
wind: false, gravity: 0.05, lifetime: 0.45, shrink: true,
}));
}
// Knock back self slightly
u.vx -= Math.cos(ang) * 1.5;
}
},
{
id: 'holy', name: 'Holy Bomb', icon: '✨', key: '4', cat: 'aoe', ammo: 2,
desc: 'Divine devastation! Huge crater', radius: 110, dmg: 80, powerNeeded: true,
fire(u, ang, power) {
const spd = 5 + power * 11;
projectiles.push(new Projectile({
x: u.x + Math.cos(ang) * 14, y: u.y + Math.sin(ang) * 14 - 6,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
r: 8, team: u.team, owner: u, color: '#ffe566',
trail: true, explodeRadius: 110, damage: 80, wind: true, gravity: 0.2,
bounce: 0.4, fuse: 3.5, sprite: 'holy', glow: true,
}));
AudioSys.tone(660, 0.15, 'sine', 0.15);
AudioSys.fire();
}
},
{
id: 'airstrike', name: 'Airstrike', icon: '✈️', key: '5', cat: 'aoe', ammo: 2,
desc: 'Click map to rain bombs', radius: 45, dmg: 40, powerNeeded: false, placeCursor: true,
fire(u, ang, power, tx, ty) {
const cx = tx ?? mouse.worldX;
AudioSys.whoosh();
for (let i = 0; i < 5; i++) {
setTimeout(() => {
projectiles.push(new Projectile({
x: cx + (i - 2) * 40 + rand(-10, 10), y: -30,
vx: wind * 0.4 + rand(-0.3, 0.3), vy: 5 + i * 0.3,
r: 5, team: u.team, owner: u, color: '#cc4422',
trail: true, explodeRadius: 48, damage: 38, wind: true, gravity: 0.18,
}));
}, i * 180);
}
}
},
{
id: 'bat', name: 'Baseball Bat', icon: '🏏', key: '6', cat: 'direct', ammo: Infinity,
desc: 'Melee smash — send foes flying!', radius: 30, dmg: 25, powerNeeded: true, melee: true,
fire(u, ang, power) {
AudioSys.tone(200, 0.08, 'square', 0.2);
AudioSys.noise(0.1, 0.15, 800);
const reach = 36;
const hx = u.x + Math.cos(ang) * reach;
const hy = u.y + Math.sin(ang) * reach - 6;
particles.burst(hx, hy, { count: 8, color: '#fff', speed: 3, life: 0.3 });
for (const w of allUnits()) {
if (w === u || w.dead) continue;
if (dist(hx, hy, w.x, w.y) < 38) {
const force = 6 + power * 16;
w.vx = Math.cos(ang) * force;
w.vy = Math.sin(ang) * force - 3;
w.grounded = false;
dmgUnit(w, 15 + power * 20, u);
particles.burst(w.x, w.y, { count: 12, color: TEAM_COLORS[w.team].primary, speed: 4 });
}
}
// Terrain chip
digCircle(hx, hy, 12, false);
}
},
{
id: 'dynamite', name: 'Dynamite', icon: '🧨', key: '7', cat: 'aoe', ammo: 3,
desc: 'Place & run! 5 second fuse', radius: 75, dmg: 70, powerNeeded: false,
fire(u) {
projectiles.push(new Projectile({
x: u.x + u.facing * 14, y: u.y - 4,
vx: u.facing * 0.8, vy: -1,
r: 7, team: u.team, owner: u, color: '#cc2222',
trail: false, explodeRadius: 75, damage: 70, wind: false, gravity: 0.25,
bounce: 0.2, fuse: 5.0, sprite: 'dynamite',
}));
AudioSys.beep();
}
},
{
id: 'sheep', name: 'Bouncy Sheep', icon: '🐑', key: '8', cat: 'indirect', ammo: 2,
desc: 'It lives! Walks then BOOM', radius: 70, dmg: 60, powerNeeded: false,
fire(u, ang) {
const dir = Math.cos(ang) >= 0 ? 1 : -1;
projectiles.push(new Sheep({
x: u.x + dir * 20, y: u.y - 10,
vx: dir * 3.5, vy: -2,
team: u.team, owner: u,
}));
AudioSys.tone(400, 0.08, 'sine', 0.15);
AudioSys.tone(500, 0.08, 'sine', 0.12, 0, 0.08);
}
},
{
id: 'rope', name: 'Ninja Rope', icon: '🪢', key: '9', cat: 'utility', ammo: 5,
desc: 'Swing into position', radius: 0, dmg: 0, powerNeeded: false, utility: true,
fire(u, ang) {
const maxLen = 220;
const steps = 40;
let hx = u.x, hy = u.y - 8;
const dx = Math.cos(ang) * (maxLen / steps);
const dy = Math.sin(ang) * (maxLen / steps);
let hit = false;
for (let i = 0; i < steps; i++) {
hx += dx; hy += dy;
if (terrain.solid(hx, hy)) { hit = true; break; }
}
if (hit) {
u.rope = { x: hx, y: hy, len: dist(u.x, u.y, hx, hy), active: true };
u.grounded = false;
AudioSys.whoosh();
} else {
AudioSys.tone(150, 0.1, 'square', 0.1);
}
}
},
{
id: 'teleport', name: 'Teleport', icon: '🌀', key: '0', cat: 'utility', ammo: 2,
desc: 'Click to warp (safe ground)', radius: 0, dmg: 0, powerNeeded: false, placeCursor: true, utility: true,
fire(u, ang, power, tx, ty) {
const px = tx ?? mouse.worldX;
const py = ty ?? mouse.worldY;
// Find safe ground near point
let sx = px, sy = py;
let found = false;
for (let dy = 0; dy < 80 && !found; dy++) {
if (terrain.solid(px, py + dy) && !terrain.solid(px, py + dy - 18)) {
sx = px; sy = py + dy - 12; found = true;
}
}
if (!found) {
for (let r = 10; r < 60 && !found; r += 5) {
for (let a = 0; a < TAU && !found; a += 0.4) {
const cx = px + Math.cos(a) * r, cy = py + Math.sin(a) * r;
if (terrain.solid(cx, cy + 10) && !terrain.solid(cx, cy - 8) && cy < WATER_Y - 10) {
sx = cx; sy = cy; found = true;
}
}
}
}
if (found && sy < WATER_Y - 15) {
particles.burst(u.x, u.y, { count: 20, color: '#a78bfa', speed: 3, size: 0.5 });
u.x = sx; u.y = sy; u.vx = 0; u.vy = 0;
particles.burst(u.x, u.y, { count: 20, color: '#c4b5fd', speed: 3, life: 0.5 });
AudioSys.tone(300, 0.1, 'sine', 0.15, 400);
AudioSys.tone(600, 0.15, 'triangle', 0.12, 200, 0.1);
} else {
AudioSys.tone(100, 0.15, 'sawtooth', 0.15);
}
}
},
{
id: 'jetpack', name: 'Jetpack', icon: '🎒', key: 'J', cat: 'utility', ammo: 2,
desc: 'Fly for a short burn (fuel)', radius: 0, dmg: 0, powerNeeded: false, utility: true, holdUse: true,
fire(u) {
u.jetFuel = 2.8;
u.usingJet = true;
AudioSys.tone(120, 0.05, 'sawtooth', 0.1);
}
},
{
id: 'girder', name: 'Girder Kit', icon: '🧱', key: 'G', cat: 'utility', ammo: 3,
desc: 'Build a temporary bridge', radius: 0, dmg: 0, powerNeeded: false, placeCursor: true, utility: true,
fire(u, ang, power, tx, ty) {
const px = tx ?? mouse.worldX;
const py = ty ?? mouse.worldY;
const a = Math.atan2((ty ?? mouse.worldY) - u.y, (tx ?? mouse.worldX) - u.x);
// Stamp a girder rectangle into terrain
const len = 90, thick = 12;
const ca = Math.cos(a), sa = Math.sin(a);
for (let i = -len / 2; i < len / 2; i++) {
for (let j = -thick / 2; j < thick / 2; j++) {
const gx = px + ca * i - sa * j;
const gy = py + sa * i + ca * j;
terrain.setSolid(gx, gy, true, 3); // metal type
}
}
particles.burst(px, py, { count: 10, color: '#888', speed: 1.5, life: 0.4 });
AudioSys.dig();
AudioSys.tone(200, 0.08, 'triangle', 0.1);
}
},
{
id: 'freeze', name: 'Ice Bomb', icon: '❄️', key: 'F', cat: 'indirect', ammo: 3,
desc: 'Freeze enemies in place', radius: 50, dmg: 10, powerNeeded: true,
fire(u, ang, power) {
const spd = 5 + power * 12;
projectiles.push(new Projectile({
x: u.x + Math.cos(ang) * 14, y: u.y + Math.sin(ang) * 14 - 6,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
r: 6, team: u.team, owner: u, color: '#88ddff',
trail: true, explodeRadius: 55, damage: 12, wind: true, gravity: 0.18,
bounce: 0.3, fuse: 2.5, freeze: true, glow: true, sprite: 'ice',
}));
AudioSys.ice();
}
},
];
// ---------- Terrain ----------
const TERR = { empty: 0, dirt: 1, grass: 2, rock: 3, metal: 4 };
class Terrain {
constructor(w, h) {
this.w = w; this.h = h;
this.data = new Uint8Array(w * h);
this.imageData = null;
this.dirty = true;
this.off = document.createElement('canvas');
this.off.width = w; this.off.height = h;
this.octx = this.off.getContext('2d');
}
idx(x, y) {
x = x | 0; y = y | 0;
if (x < 0 || y < 0 || x >= this.w || y >= this.h) return -1;
return y * this.w + x;
}
get(x, y) {
const i = this.idx(x, y);
return i < 0 ? TERR.rock : this.data[i];
}
solid(x, y) {
return this.get(x, y) !== TERR.empty;
}
setSolid(x, y, on, type = TERR.dirt) {
const i = this.idx(x, y);
if (i < 0) return;
this.data[i] = on ? type : TERR.empty;
this.dirty = true;
}
generate(shape) {
this.data.fill(TERR.empty);
const w = this.w, h = this.h;
const noise2 = (x, y, s) => {
// Simple value noise-ish layers of sin
return (
Math.sin(x * 0.008 * s + y * 0.003) * 0.4 +
Math.sin(x * 0.02 * s + 3.1) * 0.25 +
Math.sin(x * 0.05 * s + y * 0.01 + 1.7) * 0.15 +
Math.sin(x * 0.11 * s + 4.2) * 0.08 +
Math.sin((x + y) * 0.015 * s) * 0.12
);
};
const surface = new Float32Array(w);
if (shape === 'island') {
for (let x = 0; x < w; x++) {
const edge = Math.pow(Math.sin((x / w) * PI), 0.7);
const n = noise2(x, 0, 1);
surface[x] = h * 0.55 - n * 120 * edge - edge * 80 + (1 - edge) * 200;
// Beach shoulders
if (x < 80 || x > w - 80) surface[x] = h;
}
} else if (shape === 'two') {
for (let x = 0; x < w; x++) {
const t = x / w;
const island1 = Math.exp(-Math.pow((t - 0.25) * 5.5, 2));
const island2 = Math.exp(-Math.pow((t - 0.75) * 5.5, 2));
const n = noise2(x, 10, 1.2);
const m = Math.max(island1, island2);
surface[x] = h * 0.62 - m * 220 - n * 70 * m;
if (m < 0.08) surface[x] = h;
}
} else { // cave
for (let x = 0; x < w; x++) {
const n = noise2(x, 20, 0.8);
surface[x] = h * 0.28 + n * 40;
}
}
// Fill solid below surface
for (let x = 0; x < w; x++) {
const sy = clamp(surface[x] | 0, 0, h);
for (let y = sy; y < h; y++) {
const depth = y - sy;
let type = TERR.dirt;
if (depth < 4) type = TERR.grass;
else if (depth > 120 + noise2(x, y, 2) * 40) type = TERR.rock;
// Cave: carve tunnels
if (shape === 'cave') {
const caveN = noise2(x * 1.5, y * 1.8, 2.5);
const caveN2 = noise2(x * 0.7 + 50, y * 0.9, 1.4);
if (y > surface[x] + 40 && y < WATER_Y - 40) {
if (caveN > 0.35 || caveN2 > 0.42) type = TERR.empty;
}
// Ceiling fill already done by surface
// Floor
if (y > h * 0.72 + noise2(x, 0, 1) * 30) type = TERR.rock;
}
// Water area stays empty above water... terrain fills underwater as seabed
if (y > WATER_Y + 5 && type !== TERR.empty) {
// keep seabed
}
this.data[y * w + x] = type;
}
// cave ceiling rock
if (shape === 'cave') {
for (let y = 0; y < surface[x]; y++) {
this.data[y * w + x] = TERR.rock;
}
// stalactites
if (chance(0.02)) {
const len = randi(20, 70);
for (let y = surface[x]; y < surface[x] + len && y < h; y++) {
const thick = 1 + (1 - (y - surface[x]) / len) * 4;
for (let dx = -thick; dx < thick; dx++) {
const xi = (x + dx) | 0;
if (xi >= 0 && xi < w) this.data[y * w + xi] = TERR.rock;
}
}
}
}
}
// Sculpt some hills / platforms mid-air for island modes
if (shape !== 'cave') {
const plats = shape === 'two' ? 5 : 4;
for (let p = 0; p < plats; p++) {
const cx = rand(120, w - 120);
const cy = rand(h * 0.32, h * 0.55);
const rw = rand(50, 110), rh = rand(18, 35);
for (let x = -rw; x < rw; x++) {
for (let y = -rh; y < rh; y++) {
const nx = x / rw, ny = y / rh;
if (nx * nx + ny * ny * 1.8 < 1) {
const px = (cx + x) | 0, py = (cy + y) | 0;
if (px >= 0 && px < w && py >= 0 && py < h) {
const i = py * w + px;
if (this.data[i] === TERR.empty) {
this.data[i] = y < -rh * 0.5 ? TERR.grass : TERR.dirt;
}
}
}
}
}
}
}
// Smooth a bit / grass top pass
for (let x = 1; x < w - 1; x++) {
for (let y = 1; y < h - 1; y++) {
const i = y * w + x;
if (this.data[i] !== TERR.empty && this.data[i - w] === TERR.empty) {
this.data[i] = TERR.grass;
if (y + 1 < h && this.data[i + w] === TERR.dirt) { /* ok */ }
}
}
}
this.dirty = true;
this.rebuildImage();
}
rebuildImage() {
const img = this.octx.createImageData(this.w, this.h);
const d = img.data;
const dirtPalette = [
[0, 0, 0, 0],
[139, 90, 43, 255], // dirt
[76, 160, 60, 255], // grass
[90, 90, 100, 255], // rock
[140, 145, 160, 255], // metal
];
// Variation
for (let i = 0; i < this.data.length; i++) {
const t = this.data[i];
const p = dirtPalette[t] || dirtPalette[0];
const o = i * 4;
if (t === 0) {
d[o] = d[o + 1] = d[o + 2] = d[o + 3] = 0;
} else {
const j = ((i * 1103515245 + 12345) >>> 0) % 40;
const v = j - 20;
d[o] = clamp(p[0] + v, 0, 255);
d[o + 1] = clamp(p[1] + v * 0.8, 0, 255);
d[o + 2] = clamp(p[2] + v * 0.5, 0, 255);
d[o + 3] = 255;
// dirt depth darken
if (t === TERR.dirt) {
const y = (i / this.w) | 0;
const dark = (y / this.h) * 30;
d[o] = clamp(d[o] - dark, 0, 255);
d[o + 1] = clamp(d[o + 1] - dark, 0, 255);
d[o + 2] = clamp(d[o + 2] - dark * 0.5, 0, 255);
}
// grass highlight
if (t === TERR.grass) {
d[o + 1] = clamp(d[o + 1] + 10, 0, 255);
}
}
}
this.octx.putImageData(img, 0, 0);
// Soft grass edge overlay
this.octx.save();
this.dirty = false;
}
carveCircle(cx, cy, radius, spawnDebris = true) {
const r = radius | 0;
const r2 = r * r;
const chunks = [];
for (let y = -r; y <= r; y++) {
for (let x = -r; x <= r; x++) {
if (x * x + y * y <= r2) {
const px = (cx + x) | 0, py = (cy + y) | 0;
const i = this.idx(px, py);
if (i >= 0 && this.data[i] !== TERR.empty) {
if (spawnDebris && chance(0.08) && this.data[i] !== TERR.metal) {
chunks.push({ x: px, y: py, type: this.data[i] });
}
this.data[i] = TERR.empty;
}
}
}
}
// Jagged edge — destroy some extra perimeter
for (let a = 0; a < TAU; a += 0.15) {
const j = r + randi(0, 4);
const px = (cx + Math.cos(a) * j) | 0;
const py = (cy + Math.sin(a) * j) | 0;
const i = this.idx(px, py);
if (i >= 0 && this.data[i] !== TERR.metal) this.data[i] = TERR.empty;
}
this.dirty = true;
// Spawn debris entities
if (spawnDebris) {
for (let i = 0; i < chunks.length && i < 40; i++) {
const c = chunks[i];
debris.push(new Debris(c.x, c.y, c.type, (c.x - cx) * 0.15, (c.y - cy) * 0.15 - rand(1, 3)));
}
}
}
// stamp image each frame if dirty
draw(ctx, cam) {
if (this.dirty) this.rebuildImage();
ctx.drawImage(this.off, 0, 0);
}
// Find stand positions for unit placement
findSpawnPoints(count) {
const pts = [];
const margin = 80;
const attempts = 4000;
for (let k = 0; k < attempts && pts.length < count; k++) {
const x = randi(margin, this.w - margin);
// scan downward for surface
for (let y = 40; y < WATER_Y - 30; y++) {
if (!this.solid(x, y) && this.solid(x, y + 1)) {
// open headroom
let clear = true;
for (let h = 1; h <= 20; h++) if (this.solid(x, y - h)) { clear = false; break; }
if (!clear) break;
// not too close to others
if (pts.some(p => Math.abs(p.x - x) < 55)) break;
pts.push({ x, y: y - 1 });
break;
}
}
}
// Sort left to right, interleave teams later
pts.sort((a, b) => a.x - b.x);
return pts;
}
}
// ---------- Particles ----------
class ParticleSystem {
constructor() { this.list = []; }
add(p) { this.list.push(p); }
burst(x, y, opts = {}) {
const n = opts.count || 12;
for (let i = 0; i < n; i++) {
const a = opts.angle != null ? opts.angle + rand(-0.5, 0.5) : rand(0, TAU);
const sp = (opts.speed || 3) * rand(0.3, 1.2);
this.list.push({
x, y,
vx: Math.cos(a) * sp + (opts.vx || 0),
vy: Math.sin(a) * sp + (opts.vy || 0),
life: (opts.life || 0.6) * rand(0.5, 1.2),
maxLife: opts.life || 0.6,
r: (opts.r || 3) * rand(0.5, 1.4),
color: opts.color || '#ffaa44',
grav: opts.grav != null ? opts.grav : 0.12,
fade: opts.fade !== false,
type: opts.type || 'circle',
});
}
}
explosion(x, y, radius) {
const n = clamp((radius * 0.8) | 0, 15, 80);
for (let i = 0; i < n; i++) {
const a = rand(0, TAU);
const sp = rand(1, radius * 0.18);
this.list.push({
x, y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - rand(0, 2),
life: rand(0.4, 1.1),
maxLife: 1,
r: rand(2, 7),
color: chance(0.4) ? '#ff6622' : chance(0.5) ? '#ffcc33' : '#fff0c0',
grav: 0.08,
fade: true,
type: 'circle',
});
}
// Smoke
for (let i = 0; i < n * 0.4; i++) {
this.list.push({
x: x + rand(-radius * 0.3, radius * 0.3),
y: y + rand(-radius * 0.3, radius * 0.3),
vx: rand(-0.5, 0.5),
vy: rand(-1.5, -0.3),
life: rand(0.8, 1.8),
maxLife: 1.5,
r: rand(6, 16),
color: `rgba(60,60,70,0.7)`,
grav: -0.02,
fade: true,
type: 'smoke',
});
}
// Dirt debris colors
for (let i = 0; i < n * 0.5; i++) {
this.list.push({
x, y,
vx: rand(-radius * 0.12, radius * 0.12),
vy: rand(-radius * 0.15, -1),
life: rand(0.5, 1.2),
maxLife: 1,
r: rand(2, 5),
color: chance(0.5) ? '#8B5A2B' : '#5a7a3a',
grav: 0.2,
fade: true,
type: 'circle',
});
}
}
dust(x, y, dir = 0) {
this.burst(x, y, { count: 5, color: '#c2a882', speed: 1.2, life: 0.35, r: 2, angle: -PI / 2 + dir * 0.5 });
}
update(dt) {
for (let i = this.list.length - 1; i >= 0; i--) {
const p = this.list[i];
p.vy += p.grav;
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.99;
p.life -= dt;
if (p.life <= 0) this.list.splice(i, 1);
}
}
draw(ctx) {
for (const p of this.list) {
const a = p.fade ? clamp(p.life / (p.maxLife * 0.5), 0, 1) : 1;
ctx.globalAlpha = a;
if (p.type === 'smoke') {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * (1.5 - a * 0.5), 0, TAU);
ctx.fill();
} else {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * a, 0, TAU);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
}
const particles = new ParticleSystem();
// ---------- Debris chunks ----------
class Debris {
constructor(x, y, type, vx, vy) {
this.x = x; this.y = y;
this.vx = vx + rand(-1, 1);
this.vy = vy + rand(-1, 1);
this.type = type;
this.r = rand(2, 5);
this.rot = rand(0, TAU);
this.vr = rand(-0.3, 0.3);
this.life = rand(1.5, 3.5);
this.dead = false;
}
update(dt) {
this.vy += GRAVITY * 0.9;
this.vx *= 0.995;
this.x += this.vx;
this.y += this.vy;
this.rot += this.vr;
this.life -= dt;
// collide terrain
if (terrain.solid(this.x, this.y + this.r)) {
this.vy *= -0.3;
this.vx *= 0.6;
this.y -= 2;
if (Math.abs(this.vy) < 0.5) this.vy = 0;
}
if (this.y > WATER_Y) {
this.vy *= 0.5;
this.vx *= 0.9;
if (chance(0.1)) particles.burst(this.x, WATER_Y, { count: 1, color: '#8cf', speed: 0.8, life: 0.3 });
}
if (this.life <= 0 || this.y > WORLD_H + 20) this.dead = true;
}
draw(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.rot);
ctx.globalAlpha = clamp(this.life, 0, 1);
ctx.fillStyle = this.type === TERR.grass ? '#4ca03c' : this.type === TERR.rock ? '#666' : '#8B5A2B';
ctx.fillRect(-this.r, -this.r * 0.7, this.r * 2, this.r * 1.4);
ctx.restore();
}
}
// ---------- Projectile ----------
class Projectile {
constructor(o) {
Object.assign(this, {
x: 0, y: 0, vx: 0, vy: 0, r: 4,
team: 0, owner: null, color: '#fff',
trail: false, explodeRadius: 40, damage: 30,
wind: true, gravity: 0.18, bounce: 0, fuse: 0,
sticky: false, lifetime: 0, shrink: false,
sprite: null, glow: false, freeze: false,
dead: false, age: 0, prevX: 0, prevY: 0,
}, o);
this.prevX = this.x; this.prevY = this.y;
this.trailPts = [];
}
update(dt) {
if (this.dead) return;
this.age += dt;
this.prevX = this.x; this.prevY = this.y;
if (this.wind) this.vx += wind * 0.012;
this.vy += this.gravity;
this.x += this.vx;
this.y += this.vy;
if (this.trail) {
this.trailPts.push({ x: this.x, y: this.y, a: 1 });
if (this.trailPts.length > 18) this.trailPts.shift();
for (const t of this.trailPts) t.a *= 0.9;
if (chance(0.4)) {
particles.add({
x: this.x, y: this.y, vx: rand(-0.3, 0.3), vy: rand(-0.3, 0.3),
life: 0.3, maxLife: 0.3, r: 2, color: this.color, grav: 0, fade: true, type: 'circle'
});
}
}
// Fuse / lifetime
if (this.fuse > 0) {
this.fuse -= dt;
if (this.fuse <= 0) { this.explode(); return; }
// tick sound near end
if (this.fuse < 1.5 && Math.floor(this.fuse * 6) !== Math.floor((this.fuse + dt) * 6)) {
AudioSys.beep();
}
}
if (this.lifetime > 0) {
this.lifetime -= dt;
if (this.lifetime <= 0) { this.explode(); return; }
}
// Out of bounds
if (this.x < -50 || this.x > WORLD_W + 50 || this.y > WORLD_H + 50) {
this.dead = true; return;
}
// Water splash
if (this.y > WATER_Y && this.prevY <= WATER_Y) {
particles.burst(this.x, WATER_Y, { count: 10, color: '#8cf', speed: 2, life: 0.5, vy: -2 });
AudioSys.splash();
// most projectiles slow/sink and explode if fuse else live
this.vx *= 0.4; this.vy *= 0.3;
}
// Terrain collision — sub-step
const steps = Math.ceil(Math.hypot(this.vx, this.vy) / 3);
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const cx = lerp(this.prevX, this.x, t);
const cy = lerp(this.prevY, this.y, t);
if (terrain.solid(cx, cy)) {
if (this.bounce > 0 && this.fuse > 0) {
// bounce
this.x = this.prevX; this.y = this.prevY;
// simple normal estimate
let nx = 0, ny = 0;
for (let a = 0; a < TAU; a += PI / 4) {
if (terrain.solid(cx + Math.cos(a) * 4, cy + Math.sin(a) * 4)) {
nx -= Math.cos(a); ny -= Math.sin(a);
}
}
const nl = Math.hypot(nx, ny) || 1;
nx /= nl; ny /= nl;
const dot = this.vx * nx + this.vy * ny;
this.vx = (this.vx - 2 * dot * nx) * this.bounce;
this.vy = (this.vy - 2 * dot * ny) * this.bounce;
this.x += nx * 3; this.y += ny * 3;
AudioSys.land();
particles.dust(cx, cy);
return;
} else {
this.x = cx; this.y = cy;
this.explode();
return;
}
}
}
// Hit units
for (const w of allUnits()) {
if (w.dead || w === this.owner) continue;
if (dist(this.x, this.y, w.x, w.y - 6) < this.r + 10) {
this.explode();
return;
}
}
}
explode() {
if (this.dead) return;
this.dead = true;
const R = this.explodeRadius;
// Impact freeze for large
if (R >= 70) {
impactFreeze = 0.12;
}
shake = Math.min(22, R * 0.22);
particles.explosion(this.x, this.y, R);
terrain.carveCircle(this.x, this.y, R * 0.85, true);
AudioSys.boom(R / 50);
// Damage units
for (const w of allUnits()) {
if (w.dead) continue;
const d = dist(this.x, this.y, w.x, w.y - 6);
if (d < R + 10) {
const falloff = 1 - d / (R + 10);
const dmg = this.damage * falloff * falloff;
const force = falloff * (R * 0.18);
const ang = Math.atan2(w.y - this.y, w.x - this.x);
w.vx += Math.cos(ang) * force;
w.vy += Math.sin(ang) * force - 2 * falloff;
w.grounded = false;
if (this.freeze) {
w.frozen = 3.5;
particles.burst(w.x, w.y, { count: 15, color: '#aef', speed: 2, life: 0.6 });
AudioSys.ice();
}
dmgUnit(w, dmg, this.owner);
}
}
// Secondary debris hydro
if (this.y > WATER_Y - 20) {
particles.burst(this.x, Math.min(this.y, WATER_Y), { count: 20, color: '#6bf', speed: 4, life: 0.7, vy: -3 });
}
}
draw(ctx) {
if (this.dead) return;
// trail
if (this.trailPts.length > 1) {
ctx.beginPath();
for (let i = 0; i < this.trailPts.length; i++) {
const t = this.trailPts[i];
if (i === 0) ctx.moveTo(t.x, t.y); else ctx.lineTo(t.x, t.y);
}
ctx.strokeStyle = this.color;
ctx.globalAlpha = 0.35;
ctx.lineWidth = 2;
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.save();
ctx.translate(this.x, this.y);
if (this.glow) {
ctx.shadowColor = this.color;
ctx.shadowBlur = 16;
}
const ang = Math.atan2(this.vy, this.vx);
if (this.sprite === 'grenade') {
ctx.rotate(this.age * 8);
ctx.fillStyle = '#2d5a27';
ctx.beginPath(); ctx.arc(0, 0, 6, 0, TAU); ctx.fill();
ctx.fillStyle = '#444';
ctx.fillRect(-2, -9, 4, 4);
// spark
if (this.fuse < 99 && Math.sin(this.age * 30) > 0) {
ctx.fillStyle = '#ff0';
ctx.fillRect(-1, -12, 2, 3);
}
} else if (this.sprite === 'holy') {
ctx.rotate(this.age * 5);
ctx.fillStyle = '#ffe566';
ctx.beginPath(); ctx.arc(0, 0, 8, 0, TAU); ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('†', 0, 3);
} else if (this.sprite === 'dynamite') {
ctx.fillStyle = '#c22';
ctx.fillRect(-4, -10, 8, 18);
ctx.fillStyle = '#222';
ctx.fillRect(-5, -11, 10, 3);
// fuse spark
ctx.fillStyle = Math.sin(this.age * 40) > 0 ? '#ff0' : '#f80';
ctx.beginPath(); ctx.arc(0, -14, 2, 0, TAU); ctx.fill();
} else if (this.sprite === 'ice') {
ctx.rotate(this.age * 6);
ctx.fillStyle = '#8df';
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = i / 6 * TAU;
const rr = i % 2 === 0 ? 8 : 4;
if (i === 0) ctx.moveTo(Math.cos(a) * rr, Math.sin(a) * rr);
else ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
}
ctx.closePath(); ctx.fill();
} else {
// rocket / generic
ctx.rotate(ang);
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.moveTo(8, 0); ctx.lineTo(-6, -4); ctx.lineTo(-6, 4);
ctx.closePath(); ctx.fill();
if (this.trail) {
ctx.fillStyle = '#ff8800';
ctx.beginPath(); ctx.arc(-7, 0, 3 + Math.random() * 2, 0, TAU); ctx.fill();
}
}
ctx.restore();
}
}
// ---------- Sheep special ----------
class Sheep extends Projectile {
constructor(o) {
super({
r: 10, color: '#f5f5f5', explodeRadius: 70, damage: 60,
wind: false, gravity: 0.22, bounce: 0.4, fuse: 5.5,
trail: false, sprite: 'sheep', ...o
});
this.dir = Math.sign(this.vx) || 1;
this.walkTimer = 0;
}
update(dt) {
if (this.dead) return;
this.age += dt;
this.prevX = this.x; this.prevY = this.y;
this.fuse -= dt;
if (this.fuse <= 0) { this.explode(); return; }
this.vy += this.gravity;
// walk
if (terrain.solid(this.x, this.y + 11)) {
this.vy = Math.min(0, this.vy);
this.vx = this.dir * 2.8;
// hop tiny ledges
if (terrain.solid(this.x + this.dir * 10, this.y) && !terrain.solid(this.x + this.dir * 10, this.y - 14)) {
this.vy = -4;
}
// edge turn
if (!terrain.solid(this.x + this.dir * 12, this.y + 14) || terrain.solid(this.x + this.dir * 10, this.y - 2)) {
if (chance(0.02)) this.dir *= -1;
}
// random jump SEMI often like worms sheep
this.walkTimer += dt;
if (this.walkTimer > 0.8 && chance(0.03)) {
this.vy = -5.5;
this.walkTimer = 0;
}
}
this.x += this.vx;
this.y += this.vy;
if (this.y > WATER_Y) {
this.explode();
return;
}
if (this.x < 10) this.dir = 1;
if (this.x > WORLD_W - 10) this.dir = -1;
// baa occasionally
if (chance(0.005)) {
AudioSys.tone(350, 0.08, 'sine', 0.1, 80);
AudioSys.tone(280, 0.1, 'sine', 0.08, -40, 0.08);
}
}
draw(ctx) {
if (this.dead) return;
ctx.save();
ctx.translate(this.x, this.y);
ctx.scale(this.dir, 1);
// body
ctx.fillStyle = '#f4f4f4';
ctx.beginPath(); ctx.ellipse(0, 0, 12, 9, 0, 0, TAU); ctx.fill();
// head
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(10, -2, 5, 0, TAU); ctx.fill();
// legs
ctx.fillStyle = '#222';
ctx.fillRect(-6, 6, 3, 5);
ctx.fillRect(2, 6, 3, 5);
// fuse indicator
if (this.fuse < 2) {
ctx.fillStyle = `rgba(255,0,0,${0.5 + Math.sin(this.age * 20) * 0.5})`;
ctx.beginPath(); ctx.arc(0, -12, 4, 0, TAU); ctx.fill();
}
ctx.restore();
}
}
// ---------- Unit (Worm) ----------
class Unit {
constructor(x, y, team, index) {
this.x = x; this.y = y;
this.vx = 0; this.vy = 0;
this.team = team;
this.index = index;
this.hp = UNIT_HP;
this.maxHp = UNIT_HP;
this.dead = false;
this.facing = 1;
this.grounded = false;
this.anim = 0;
this.name = WORM_NAMES[team % WORM_NAMES.length][index % WORM_NAMES[0].length];
this.blink = 0;
this.lookAngle = 0;
this.fallSpeed = 0;
this.rope = null;
this.frozen = 0;
this.jetFuel = 0;
this.usingJet = false;
this.drownTimer = 0;
this.prevGrounded = false;
this.bob = Math.random() * TAU;
this.floatVY = 0; // water float
}
update(dt, isActive, canControl) {
if (this.dead) return;
this.anim += dt;
this.bob += dt * 3;
this.blink -= dt;
if (this.blink < -2 && chance(0.01)) this.blink = 0.15;
if (this.frozen > 0) {
this.frozen -= dt;
this.vx *= 0.9;
// still apply gravity lightly
this.vy += GRAVITY * 0.5;
this.movePhysics(dt);
return;
}
// Jetpack
if (this.usingJet && this.jetFuel > 0 && isActive) {
this.jetFuel -= dt;
this.vy += -0.55;
this.grounded = false;
if (keys['KeyA'] || keys['ArrowLeft']) this.vx -= 0.15;
if (keys['KeyD'] || keys['ArrowRight']) this.vx += 0.15;
particles.burst(this.x - this.facing * 2, this.y + 8, {
count: 2, color: '#ff8800', speed: 1.5, life: 0.25, angle: PI / 2, r: 2
});
if (chance(0.3)) AudioSys.tone(80 + Math.random() * 40, 0.04, 'sawtooth', 0.04);
if (this.jetFuel <= 0) this.usingJet = false;
}
// Rope physics
if (this.rope && this.rope.active) {
this.grounded = false;
const dx = this.x - this.rope.x;
const dy = this.y - this.rope.y;
const distR = Math.hypot(dx, dy) || 1;
// constraint
if (distR > this.rope.len) {
const nx = dx / distR, ny = dy / distR;
this.x = this.rope.x + nx * this.rope.len;
this.y = this.rope.y + ny * this.rope.len;
// dampen radial
const vrad = this.vx * nx + this.vy * ny;
this.vx -= nx * vrad;
this.vy -= ny * vrad;
}
this.vy += GRAVITY * 0.85;
// swing control
if (canControl) {
if (keys['KeyA'] || keys['ArrowLeft']) this.vx -= 0.2;
if (keys['KeyD'] || keys['ArrowRight']) this.vx += 0.2;
if (keys['KeyW'] || keys['ArrowUp']) this.rope.len = Math.max(30, this.rope.len - 2.5);
if (keys['KeyS'] || keys['ArrowDown']) this.rope.len = Math.min(260, this.rope.len + 2.5);
if (keys['Space']) {
this.rope = null;
this.vy -= 2;
AudioSys.jump();
}
}
this.vx *= 0.995;
this.x += this.vx; this.y += this.vy;
// collide
this.resolveCollision();
if (this.grounded) this.rope = null;
return;
}
// Water
if (this.y > WATER_Y - 4) {
this.grounded = false;
// buoyancy
const submersion = clamp((this.y - (WATER_Y - 8)) / 20, 0, 1);
this.vy += GRAVITY * 0.3;
this.vy -= submersion * 0.45;
this.vx *= 0.92;
this.vy *= 0.94;
// wind less
if (canControl && isActive) {
if (keys['KeyA'] || keys['ArrowLeft']) { this.vx -= 0.08; this.facing = -1; }
if (keys['KeyD'] || keys['ArrowRight']) { this.vx += 0.08; this.facing = 1; }
if ((keys['KeyW'] || keys['ArrowUp'] || keys['Space']) && submersion > 0.3) this.vy -= 0.25;
}
this.x += this.vx; this.y += this.vy;
if (this.y > WATER_Y + 8) {
this.drownTimer += dt;
if (chance(0.15)) particles.burst(this.x, this.y - 6, { count: 1, color: '#cdf', speed: 0.6, life: 0.6, vy: -1 });
if (this.drownTimer > 1.2) {
dmgUnit(this, 35 * dt, null); // drowning damage
}
}
// fully under map
if (this.y > WORLD_H + 30) {
this.hp = 0;
killUnit(this);
}
return;
} else {
this.drownTimer = 0;
}
// Controls
if (canControl && isActive && !this.usingJet) {
let moving = false;
if (keys['KeyA'] || keys['ArrowLeft']) {
this.vx = -MOVE_SPEED; this.facing = -1; moving = true;
} else if (keys['KeyD'] || keys['ArrowRight']) {
this.vx = MOVE_SPEED; this.facing = 1; moving = true;
}
if (moving && this.grounded) {
if (chance(0.15)) particles.dust(this.x, this.y + 10, this.facing);
}
// step-up stairs
if (moving && this.grounded) {
const ahead = this.x + this.facing * 6;
if (terrain.solid(ahead, this.y) && !terrain.solid(ahead, this.y - 12) && !terrain.solid(this.x, this.y - 12)) {
this.y -= 8;
}
}
if ((keys['KeyW'] || keys['ArrowUp'] || keys['Space']) && this.grounded) {
this.vy = JUMP_V;
this.grounded = false;
AudioSys.jump();
particles.dust(this.x, this.y + 10);
}
}
// wind nudge when midair
if (!this.grounded) this.vx += wind * 0.004;
this.movePhysics(dt);
}
movePhysics(dt) {
this.prevGrounded = this.grounded;
this.vy += GRAVITY;
if (this.grounded) this.vx *= FRICTION;
else this.vx *= AIR_FRICTION;
// integrate with substeps
const steps = 3;
for (let s = 0; s < steps; s++) {
this.x += this.vx / steps;
this.y += this.vy / steps;
this.resolveCollision();
}
// fall damage
if (this.grounded && !this.prevGrounded) {
if (this.fallSpeed > FALL_DMG_THRESH) {
const dmg = (this.fallSpeed - FALL_DMG_THRESH) * 4.5;
dmgUnit(this, dmg, null);
particles.burst(this.x, this.y + 8, { count: 8, color: '#c2a882', speed: 2 });
shake = Math.min(shake + dmg * 0.1, 10);
}
AudioSys.land();
this.fallSpeed = 0;
}
if (!this.grounded) this.fallSpeed = Math.max(this.fallSpeed, this.vy);
else this.fallSpeed = 0;
// bounds
this.x = clamp(this.x, 8, WORLD_W - 8);
}
resolveCollision() {
const hw = 7, hh = 10;
this.grounded = false;
// bottom
if (terrain.solid(this.x, this.y + hh) || terrain.solid(this.x - 4, this.y + hh) || terrain.solid(this.x + 4, this.y + hh)) {
// push up
let push = 0;
while (push < 20 && (terrain.solid(this.x, this.y + hh - push) || terrain.solid(this.x - 4, this.y + hh - push))) push++;
this.y -= push;
if (this.vy > 0) this.vy = 0;
this.grounded = true;
}
// top
if (terrain.solid(this.x, this.y - hh) || terrain.solid(this.x - 3, this.y - hh) || terrain.solid(this.x + 3, this.y - hh)) {
this.y += 3;
if (this.vy < 0) this.vy = 0;
}
// left / right
if (terrain.solid(this.x - hw, this.y) || terrain.solid(this.x - hw, this.y - 5) || terrain.solid(this.x - hw, this.y + 5)) {
this.x += 2;
this.vx = Math.max(0, this.vx);
}
if (terrain.solid(this.x + hw, this.y) || terrain.solid(this.x + hw, this.y - 5) || terrain.solid(this.x + hw, this.y + 5)) {
this.x -= 2;
this.vx = Math.min(0, this.vx);
}
}
draw(ctx) {
if (this.dead) return;
const c = TEAM_COLORS[this.team];
ctx.save();
ctx.translate(this.x, this.y);
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath();
ctx.ellipse(0, 11, 10, 3, 0, 0, TAU);
ctx.fill();
// rope line
// drawn separately
// frozen tint ice
if (this.frozen > 0) {
ctx.fillStyle = 'rgba(150,220,255,0.35)';
ctx.beginPath();
ctx.arc(0, -2, 16, 0, TAU);
ctx.fill();
}
const walkBob = this.grounded && Math.abs(this.vx) > 0.3 ? Math.sin(this.anim * 18) * 1.5 : 0;
ctx.translate(0, walkBob);
// body (worm sausage)
ctx.fillStyle = c.primary;
ctx.beginPath();
ctx.ellipse(0, 0, 9, 11, 0, 0, TAU);
ctx.fill();
// belly
ctx.fillStyle = c.skin;
ctx.beginPath();
ctx.ellipse(0, 3, 6, 6, 0, 0, TAU);
ctx.fill();
// helmet / bandana
ctx.fillStyle = c.dark;
ctx.beginPath();
ctx.ellipse(0, -8, 9, 6, 0, PI, TAU);
ctx.fill();
ctx.fillStyle = c.light;
ctx.fillRect(-9, -9, 18, 3);
// eyes
const eyeX = this.facing * 3;
if (this.blink > 0) {
ctx.strokeStyle = '#222';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(eyeX - 3, -4); ctx.lineTo(eyeX + 3, -4);
ctx.stroke();
} else {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.ellipse(eyeX - 2.5, -4, 3.2, 3.5, 0, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.ellipse(eyeX + 3, -4, 3.2, 3.5, 0, 0, TAU); ctx.fill();
// pupils look toward aim
let px = clamp(Math.cos(this.lookAngle) * 1.5, -1.5, 1.5);
let py = clamp(Math.sin(this.lookAngle) * 1.5, -1.5, 1.5);
ctx.fillStyle = '#111';
ctx.beginPath(); ctx.arc(eyeX - 2.5 + px, -4 + py, 1.5, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(eyeX + 3 + px, -4 + py, 1.5, 0, TAU); ctx.fill();
}
// smile
ctx.strokeStyle = c.dark;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(this.facing * 1, 2, 3, 0.2, PI - 0.2);
ctx.stroke();
// jetpack
if (this.usingJet || this.jetFuel > 0) {
ctx.fillStyle = '#555';
ctx.fillRect(-12, -4, 5, 10);
ctx.fillRect(7, -4, 5, 10);
}
// name + hp
ctx.restore();
ctx.save();
ctx.translate(this.x, this.y);
ctx.font = 'bold 11px Fredoka, sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillText(this.name, 0.5, -24.5);
ctx.fillStyle = c.light;
ctx.fillText(this.name, 0, -25);
// HP bar
const bw = 28, bh = 4;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(-bw / 2 - 1, -20, bw + 2, bh + 2);
const hpr = this.hp / this.maxHp;
ctx.fillStyle = hpr > 0.5 ? '#4ade80' : hpr > 0.25 ? '#facc15' : '#ef4444';
ctx.fillRect(-bw / 2, -19, bw * hpr, bh);
// Active indicator
if (this === activeUnit && phase === 'playing') {
ctx.fillStyle = varAccent();
ctx.beginPath();
ctx.moveTo(0, -32 + Math.sin(performance.now() / 200) * 3);
ctx.lineTo(-5, -40 + Math.sin(performance.now() / 200) * 3);
ctx.lineTo(5, -40 + Math.sin(performance.now() / 200) * 3);
ctx.fill();
}
ctx.restore();
}
}
function varAccent() { return '#ffd36b'; }
const WORM_NAMES = [
['Bazooka Bob', 'Sarge', 'Noodle', 'Wiggly'],
['Captain uke', 'Splash', 'Gyro', 'Pip'],
['Leaf', 'Moss', 'Clover', 'Bean'],
['Sunny', 'Clank', 'Nugget', 'Zap'],
];
// ---------- Global game state ----------
let terrain = null;
let teams = []; // arrays of Unit
let projectiles = [];
let debris = [];
let teamCount = 2;
let humanCount = 1; // special: -1 all human, 0 all cpu
let terrainShape = 'island';
let wind = 0;
let activeTeam = 0;
let activeIndex = 0;
let activeUnit = null;
let turnTime = TURN_TIME;
let phase = 'menu'; // menu, playing, waiting, end
let selectedWeapon = 0;
let weaponAmmo = {};
let charging = false;
let chargePower = 0;
let shake = 0;
let impactFreeze = 0;
let cam = { x: WORLD_W / 2, y: WORLD_H / 2, zoom: 1, tx: 0, ty: 0, tzoom: 1 };
let aimAngle = 0;
let turnLocked = false; // after shooting, wait for projectiles
let aiState = null;
let waterTime = 0;
let isCPUTurn = false;
let placeMode = false;
let gameOver = false;
let waitTimer = 0;
function allUnits() {
return teams.flat();
}
function livingTeams() {
return teams.map((t, i) => ({ i, alive: t.some(u => !u.dead && u.hp > 0) })).filter(t => t.alive);
}
// ---------- Damage / death ----------
function dmgUnit(u, amount, source) {
if (u.dead || amount <= 0) return;
amount = Math.round(amount);
if (amount <= 0) return;
u.hp -= amount;
floatText(u.x, u.y - 20, '-' + amount, false);
AudioSys.hurt();
particles.burst(u.x, u.y - 4, { count: 6, color: TEAM_COLORS[u.team].primary, speed: 2.5, life: 0.4 });
if (u.hp <= 0) {
u.hp = 0;
killUnit(u);
}
}
function killUnit(u) {
if (u.dead) return;
u.dead = true;
u.rope = null;
AudioSys.death();
particles.explosion(u.x, u.y, 30);
// tombstone particle
floatText(u.x, u.y - 30, '💀 ' + u.name, false);
checkWin();
}
function floatText(x, y, text, heal) {
// convert world to screen
const layer = document.getElementById('floatLayer');
const el = document.createElement('div');
el.className = 'float-dmg' + (heal ? ' heal' : '');
el.textContent = text;
const sx = (x - cam.x) * cam.zoom + WORLD_W / 2;
const sy = (y - cam.y) * cam.zoom + WORLD_H / 2;
el.style.left = (sx * SCALE) + 'px';
el.style.top = (sy * SCALE) + 'px';
layer.appendChild(el);
setTimeout(() => el.remove(), 1200);
}
function checkWin() {
const living = livingTeams();
if (living.length <= 1 && phase === 'playing') {
phase = 'end';
gameOver = true;
const winner = living[0];
const end = document.getElementById('endScreen');
end.classList.remove('hidden');
const title = document.getElementById('endTitle');
const desc = document.getElementById('endDesc');
if (!winner) {
title.textContent = 'DRAW!';
desc.textContent = 'No worms survived the mayhem…';
} else {
const c = TEAM_COLORS[winner.i];
title.textContent = c.name.toUpperCase() + ' WINS!';
desc.textContent = `Team ${c.name} remains victorious!`;
title.style.color = c.primary;
}
AudioSys.win();
document.getElementById('hud').classList.remove('visible');
}
}
// ---------- Camera ----------
function updateCamera(dt) {
if (activeUnit && !activeUnit.dead) {
cam.tx = activeUnit.x;
cam.ty = activeUnit.y - 40;
// zoom out when projectile flying
if (projectiles.length > 0 || turnLocked) {
cam.tzoom = 0.75;
// follow last projectile if any
if (projectiles.length) {
const p = projectiles[projectiles.length - 1];
cam.tx = p.x;
cam.ty = p.y;
}
} else if (charging) {
cam.tzoom = 0.95;
} else {
cam.tzoom = 1.05;
}
}
// follow interesting event
cam.x = lerp(cam.x, cam.tx, 1 - Math.pow(0.001, dt));
cam.y = lerp(cam.y, cam.ty, 1 - Math.pow(0.001, dt));
cam.zoom = lerp(cam.zoom, cam.tzoom, 1 - Math.pow(0.0001, dt));
// shake
if (shake > 0) shake = Math.max(0, shake - dt * 30);
}
// ---------- Trajectory preview ----------
function predictTrajectory(x, y, vx, vy, gravity, useWind, steps = 60) {
const pts = [];
let px = x, py = y, pvx = vx, pvy = vy;
for (let i = 0; i < steps; i++) {
if (useWind) pvx += wind * 0.012;
pvy += gravity;
px += pvx; py += pvy;
pts.push({ x: px, y: py });
if (terrain.solid(px, py) || py > WATER_Y + 20) break;
}
return pts;
}
// ---------- Turns ----------
function beginTurn() {
turnLocked = false;
placeMode = false;
charging = false;
chargePower = 0;
turnTime = TURN_TIME;
waitTimer = 0;
// next living unit — round robin teams
let tries = 0;
do {
// find next living on active team
const team = teams[activeTeam];
let found = false;
for (let k = 0; k < team.length; k++) {
activeIndex = (activeIndex + 1) % team.length;
if (!team[activeIndex].dead && team[activeIndex].hp > 0) {
found = true;
break;
}
}
if (!found) {
// advance team
activeTeam = (activeTeam + 1) % teams.length;
activeIndex = -1;
} else break;
tries++;
} while (tries < 20);
// If we need new team starting from first valid
if (!teams[activeTeam].some(u => !u.dead)) {
for (let t = 0; t < teams.length; t++) {
if (teams[t].some(u => !u.dead)) { activeTeam = t; break; }
}
activeIndex = teams[activeTeam].findIndex(u => !u.dead);
}
activeUnit = teams[activeTeam][activeIndex];
if (!activeUnit || activeUnit.dead) {
checkWin();
return;
}
// Randomize wind each turn
wind = rand(-1, 1) * rand(0.5, 5);
updateWindUI();
// Reset jet/rope state residual
activeUnit.usingJet = false;
isCPUTurn = !isHumanTeam(activeTeam);
aiState = isCPUTurn ? { stage: 'think', t: 0.6 + Math.random() * 0.5, plan: null } : null;
// Banner
showBanner(`Team ${TEAM_COLORS[activeTeam].name}\n${activeUnit.name}'s Turn` + (isCPUTurn ? ' (CPU)' : ''));
updateTeamStrip();
updateWeaponBar();
selectedWeapon = Math.min(selectedWeapon, WEAPONS.length - 1);
// pick default weapon with ammo
ensureWeaponAmmo();
}
function isHumanTeam(t) {
if (humanCount === 0) return false;
if (humanCount === -1) return true;
return t < humanCount;
}
function endTurn() {
if (phase !== 'playing') return;
// clear residual controls
charging = false;
// Wait for world to settle
turnLocked = true;
waitTimer = 0.6;
}
function afterActionSettle(dt) {
waitTimer += dt;
// wait until projectiles done and debris slow + units nearly still
const busy = projectiles.length > 0;
const unitsFlying = allUnits().some(u => !u.dead && !u.grounded && Math.abs(u.vy) > 1.5 && u.y < WATER_Y);
if (!busy && !unitsFlying && waitTimer > 1.2) {
// next team
activeTeam = (activeTeam + 1) % teams.length;
activeIndex = -1;
beginTurn();
}
// safety timeout
if (waitTimer > 12) {
activeTeam = (activeTeam + 1) % teams.length;
activeIndex = -1;
beginTurn();
}
}
function showBanner(text) {
const b = document.getElementById('turnBanner');
b.innerHTML = text.replace('\n', '<br>');
b.style.borderColor = TEAM_COLORS[activeTeam].primary;
b.classList.remove('hide');
b.classList.add('show');
setTimeout(() => { b.classList.add('hide'); b.classList.remove('show'); }, 1800);
}
// ---------- UI updates ----------
function updateWindUI() {
const el = document.getElementById('windText');
const arrow = document.getElementById('windArrow');
const strength = Math.abs(wind).toFixed(1);
el.textContent = `Wind ${strength}`;
arrow.textContent = wind > 0.15 ? '→' : wind < -0.15 ? '←' : '≈';
arrow.style.transform = `scaleX(${clamp(Math.abs(wind) / 2, 0.5, 2.5)})`;
arrow.style.color = Math.abs(wind) > 3 ? '#ff8844' : '#8cf';
}
function updateTeamStrip() {
const strip = document.getElementById('teamStrip');
strip.innerHTML = '';
teams.forEach((team, i) => {
const alive = team.filter(u => !u.dead).length;
const totalHp = team.reduce((s, u) => s + Math.max(0, u.hp), 0);
const badge = document.createElement('div');
badge.className = 'team-badge' + (i === activeTeam ? ' active' : '');
badge.innerHTML = `<span class="team-dot" style="background:${TEAM_COLORS[i].primary};color:${TEAM_COLORS[i].primary}"></span>
${TEAM_COLORS[i].name} · ${alive}/${team.length} · ${totalHp}HP
${isHumanTeam(i) ? '' : ' <span style="opacity:0.5">CPU</span>'}`;
strip.appendChild(badge);
});
}
function ensureWeaponAmmo() {
WEAPONS.forEach(w => {
if (weaponAmmo[w.id] == null) weaponAmmo[w.id] = w.ammo;
});
}
function updateWeaponBar() {
ensureWeaponAmmo();
const bar = document.getElementById('weaponBar');
bar.innerHTML = '';
WEAPONS.forEach((w, i) => {
const ammo = weaponAmmo[w.id];
if (ammo !== Infinity && ammo <= 0) return;
const btn = document.createElement('button');
btn.className = 'wep-btn' + (i === selectedWeapon ? ' selected' : '');
btn.innerHTML = `${w.icon}<span class="key">${w.key}</span>${ammo !== Infinity ? `<span class="ammo">${ammo}</span>` : ''}`;
btn.title = w.name + ' — ' + w.desc;
btn.addEventListener('mouseenter', () => {
AudioSys.uiHover();
const tip = document.getElementById('tooltip');
tip.textContent = `${w.name}: ${w.desc}`;
tip.classList.add('show');
});
btn.addEventListener('mouseleave', () => document.getElementById('tooltip').classList.remove('show'));
btn.addEventListener('click', () => {
if (isCPUTurn || turnLocked) return;
selectedWeapon = i;
placeMode = !!WEAPONS[i].placeCursor;
AudioSys.select();
updateWeaponBar();
document.getElementById('helpHint').textContent = w.desc;
});
bar.appendChild(btn);
});
}
// ---------- AI ----------
function updateAI(dt) {
if (!aiState || !activeUnit || activeUnit.dead) return;
aiState.t -= dt;
if (aiState.stage === 'think') {
if (aiState.t > 0) return;
aiState.plan = planAI();
aiState.stage = 'move';
aiState.t = aiState.plan.moveTime || 1.2;
return;
}
if (aiState.stage === 'move') {
// risk assessment movement
const plan = aiState.plan;
if (plan.moveDir !== 0) {
keys['KeyA'] = plan.moveDir < 0;
keys['KeyD'] = plan.moveDir > 0;
// avoid water / cliff
const lookX = activeUnit.x + plan.moveDir * 25;
if (!terrain.solid(lookX, activeUnit.y + 20) && activeUnit.grounded) {
// cliff! stop or jump back
keys['KeyA'] = keys['KeyD'] = false;
if (activeUnit.y > WATER_Y - 80) plan.moveDir *= -1;
}
if (activeUnit.y > WATER_Y - 30) {
// panic jump away from water
keys['Space'] = true;
plan.moveDir = activeUnit.x < WORLD_W / 2 ? 1 : -1;
}
}
if (plan.jump && activeUnit.grounded && aiState.t < (plan.moveTime || 1) * 0.5) {
keys['Space'] = true;
}
if (aiState.t <= 0) {
keys['KeyA'] = keys['KeyD'] = keys['Space'] = false;
aiState.stage = 'aim';
aiState.t = 0.5;
}
return;
}
if (aiState.stage === 'aim') {
keys['KeyA'] = keys['KeyD'] = keys['Space'] = false;
if (aiState.t > 0) return;
// select weapon
selectedWeapon = aiState.plan.weapon;
updateWeaponBar();
aimAngle = aiState.plan.angle;
activeUnit.lookAngle = aimAngle;
activeUnit.facing = Math.cos(aimAngle) >= 0 ? 1 : -1;
aiState.stage = 'charge';
aiState.t = 0;
chargePower = 0;
charging = WEAPONS[selectedWeapon].powerNeeded;
if (!charging) {
fireSelected(aiState.plan.power || 0.7, aiState.plan.targetX, aiState.plan.targetY);
aiState.stage = 'done';
}
return;
}
if (aiState.stage === 'charge') {
chargePower += dt * 0.55;
if (chance(0.2)) AudioSys.charge();
document.getElementById('powerBar').classList.add('show');
document.getElementById('powerFill').style.width = (clamp(chargePower, 0, 1) * 100) + '%';
if (chargePower >= aiState.plan.power) {
charging = false;
document.getElementById('powerBar').classList.remove('show');
fireSelected(clamp(chargePower, 0.15, 1), aiState.plan.targetX, aiState.plan.targetY);
aiState.stage = 'done';
}
return;
}
}
function planAI() {
const me = activeUnit;
// find high-value targets (lowest HP or clustered)
const enemies = allUnits().filter(u => !u.dead && u.team !== me.team);
if (!enemies.length) return { moveDir: 0, moveTime: 0.3, weapon: 0, angle: -0.4, power: 0.5, jump: false };
// score targets
let best = null;
let bestScore = -Infinity;
for (const e of enemies) {
let score = (UNIT_HP - e.hp) * 1.5; // prefer wounded
// cluster bonus
const nearby = enemies.filter(o => o !== e && dist(o.x, o.y, e.x, e.y) < 80).length;
score += nearby * 25;
// prefer closer if using direct
const d = dist(me.x, me.y, e.x, e.y);
score += clamp(400 - d, 0, 200) * 0.1;
// avoid if target near water hazard less? actually push into water is good
if (e.y > WATER_Y - 60) score += 15;
// risk: am I near cliff/water?
if (me.y > WATER_Y - 80) score -= 20;
if (score > bestScore) { bestScore = score; best = e; }
}
const target = best;
const dx = target.x - me.x;
const dy = target.y - me.y;
const d = Math.hypot(dx, dy);
// pick weapon
let weapon = 0;
let power = 0.55;
let angle = Math.atan2(dy - 40, dx);
let placeX = target.x, placeY = target.y;
ensureWeaponAmmo();
const can = id => {
const w = WEAPONS.find(x => x.id === id);
const idx = WEAPONS.indexOf(w);
return weaponAmmo[id] > 0 || weaponAmmo[id] === Infinity ? idx : -1;
};
if (d < 50 && chance(0.5)) {
const b = can('bat');
if (b >= 0) { weapon = b; power = 0.7 + Math.random() * 0.3; angle = Math.atan2(dy, dx); }
} else if (d < 160 && chance(0.4)) {
const s = can('shotgun');
if (s >= 0) { weapon = s; angle = Math.atan2(dy, dx); }
} else if (d > 280 && chance(0.35) && can('airstrike') >= 0) {
weapon = can('airstrike');
placeX = target.x; placeY = target.y;
} else if (chance(0.2) && can('holy') >= 0 && d < 350) {
weapon = can('holy');
// lob
angle = -Math.PI / 3 * Math.sign(dx || 1) + (dx > 0 ? -0.3 : -Math.PI + 0.3);
angle = dx > 0 ? -PI / 3 : -PI + PI / 3;
power = clamp(d / 500, 0.35, 0.95);
} else if (chance(0.25) && can('sheep') >= 0) {
weapon = can('sheep');
angle = dx > 0 ? -0.2 : -PI + 0.2;
} else if (chance(0.3) && can('grenade') >= 0) {
weapon = can('grenade');
angle = dx > 0 ? -PI / 3.5 : -PI + PI / 3.5;
power = clamp(d / 450 + 0.1, 0.3, 0.95);
} else {
weapon = can('bazooka') >= 0 ? can('bazooka') : 0;
// ballistic estimate
angle = dx > 0 ? -PI / 3.2 : -PI + PI / 3.2;
// tune for height
if (dy < -50) angle += (dx > 0 ? -0.2 : 0.2);
power = clamp(d / 480 + Math.abs(dy) / 800, 0.25, 0.98);
// wind compensation
angle -= wind * 0.02;
}
// freeze wounded stacked targets
if (target.hp < 40 && can('freeze') >= 0 && chance(0.3)) {
weapon = can('freeze');
angle = dx > 0 ? -PI / 3 : -PI + PI / 3;
power = clamp(d / 450, 0.3, 0.9);
}
// movement: get to better ground / away from water / closer if melee
let moveDir = 0;
let moveTime = rand(0.4, 1.5);
let jump = chance(0.25);
if (me.y > WATER_Y - 70) {
moveDir = me.x < WORLD_W / 2 ? 1 : -1;
moveTime = 1.5;
jump = true;
} else if (WEAPONS[weapon].melee) {
moveDir = sign(dx);
moveTime = clamp(d / 60, 0.3, 2);
} else if (chance(0.4)) {
// fortify position slightly
moveDir = chance(0.5) ? 1 : -1;
// risk: don't walk off
const probe = me.x + moveDir * 40;
if (!terrain.solid(probe, me.y + 25)) moveDir *= -1;
// don't approach water
if (terrain.solid(probe, WATER_Y - 5) === false && me.y > WATER_Y - 100) moveDir = 0;
}
// utility: teleport out of bad place
if (me.hp < 30 && me.y > WATER_Y - 90 && can('teleport') >= 0 && chance(0.5)) {
weapon = can('teleport');
placeX = me.x + (me.x < WORLD_W / 2 ? 120 : -120);
placeY = me.y - 80;
}
return {
moveDir, moveTime, jump, weapon, angle, power, targetX: placeX, targetY: placeY
};
}
// ---------- Firing ----------
function fireSelected(power, tx, ty) {
if (!activeUnit || turnLocked || phase !== 'playing') return;
const w = WEAPONS[selectedWeapon];
ensureWeaponAmmo();
if (weaponAmmo[w.id] !== Infinity && weaponAmmo[w.id] <= 0) return;
const ang = aimAngle;
w.fire(activeUnit, ang, power, tx, ty);
if (weaponAmmo[w.id] !== Infinity) {
weaponAmmo[w.id]--;
}
updateWeaponBar();
// utilities that allow continued turn? rope/jetpack don't end immediately the same way
if (w.id === 'rope' || w.id === 'jetpack') {
// partial turn continue
placeMode = false;
return;
}
placeMode = false;
endTurn();
}
function spawnMuzzleFlash(x, y) {
particles.burst(x, y, { count: 10, color: '#ffaa33', speed: 3, life: 0.2, r: 2 });
}
// ---------- Dig / utilities ----------
function digCircle(x, y, r, debris = true) {
terrain.carveCircle(x, y, r, debris);
}
// ---------- Drawing sky / water ----------
function drawBackground(ctx) {
// sky gradient
const g = ctx.createLinearGradient(0, 0, 0, WORLD_H);
g.addColorStop(0, '#0d1b3a');
g.addColorStop(0.35, '#1e4d8c');
g.addColorStop(0.7, '#5ba3d9');
g.addColorStop(1, '#9fd4f0');
ctx.fillStyle = g;
ctx.fillRect(0, 0, WORLD_W, WORLD_H);
// stars / atmosphere
ctx.fillStyle = 'rgba(255,255,255,0.7)';
for (let i = 0; i < 40; i++) {
const sx = ((i * 347) % WORLD_W);
const sy = ((i * 191) % (WORLD_H * 0.4));
const tw = 0.5 + Math.sin(waterTime * 2 + i) * 0.5;
ctx.globalAlpha = 0.3 + tw * 0.5;
ctx.fillRect(sx, sy, 2, 2);
}
ctx.globalAlpha = 1;
// sun / moon
const sunX = WORLD_W * 0.82, sunY = WORLD_H * 0.18;
const rg = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, 80);
rg.addColorStop(0, 'rgba(255,240,180,0.9)');
rg.addColorStop(0.4, 'rgba(255,200,100,0.3)');
rg.addColorStop(1, 'rgba(255,200,100,0)');
ctx.fillStyle = rg;
ctx.beginPath(); ctx.arc(sunX, sunY, 80, 0, TAU); ctx.fill();
ctx.fillStyle = '#ffe8a0';
ctx.beginPath(); ctx.arc(sunX, sunY, 28, 0, TAU); ctx.fill();
// far clouds
drawCloud(ctx, 200 + Math.sin(waterTime * 0.1) * 30, 120, 1.2, 0.25);
drawCloud(ctx, 700 + Math.sin(waterTime * 0.07 + 1) * 40, 180, 1.6, 0.2);
drawCloud(ctx, 1400 + Math.sin(waterTime * 0.09 + 2) * 20, 100, 1.0, 0.22);
drawCloud(ctx, 1000, 220, 1.4, 0.18);
// distant mountains silhouette
ctx.fillStyle = 'rgba(40,70,100,0.35)';
ctx.beginPath();
ctx.moveTo(0, WORLD_H * 0.55);
for (let x = 0; x <= WORLD_W; x += 20) {
const y = WORLD_H * 0.52 + Math.sin(x * 0.005) * 40 + Math.sin(x * 0.012) * 20;
ctx.lineTo(x, y);
}
ctx.lineTo(WORLD_W, WORLD_H); ctx.lineTo(0, WORLD_H);
ctx.fill();
}
function drawCloud(ctx, x, y, s, a) {
ctx.fillStyle = `rgba(255,255,255,${a})`;
ctx.beginPath();
ctx.ellipse(x, y, 40 * s, 18 * s, 0, 0, TAU);
ctx.ellipse(x + 30 * s, y + 5 * s, 30 * s, 14 * s, 0, 0, TAU);
ctx.ellipse(x - 30 * s, y + 5 * s, 28 * s, 14 * s, 0, 0, TAU);
ctx.ellipse(x + 10 * s, y - 12 * s, 25 * s, 14 * s, 0, 0, TAU);
ctx.fill();
}
function drawWater(ctx) {
const t = waterTime;
// deep
ctx.fillStyle = 'rgba(10,50,90,0.75)';
ctx.fillRect(0, WATER_Y, WORLD_W, WORLD_H - WATER_Y);
// surface gradient
const wg = ctx.createLinearGradient(0, WATER_Y - 10, 0, WATER_Y + 80);
wg.addColorStop(0, 'rgba(80,180,230,0.55)');
wg.addColorStop(0.3, 'rgba(30,100,170,0.7)');
wg.addColorStop(1, 'rgba(8,40,70,0.85)');
ctx.fillStyle = wg;
ctx.beginPath();
ctx.moveTo(0, WORLD_H);
ctx.lineTo(0, WATER_Y);
for (let x = 0; x <= WORLD_W; x += 8) {
const wave = Math.sin(x * 0.02 + t * 2.5) * 3 + Math.sin(x * 0.05 + t * 3.5) * 2;
ctx.lineTo(x, WATER_Y + wave);
}
ctx.lineTo(WORLD_W, WORLD_H);
ctx.fill();
// shimmer lines
ctx.strokeStyle = 'rgba(180,230,255,0.2)';
ctx.lineWidth = 1;
for (let i = 0; i < 6; i++) {
ctx.beginPath();
const yy = WATER_Y + 10 + i * 12;
for (let x = 0; x <= WORLD_W; x += 12) {
const y = yy + Math.sin(x * 0.03 + t * 2 + i) * 2;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
// caustics-ish
ctx.fillStyle = 'rgba(150,220,255,0.05)';
for (let i = 0; i < 8; i++) {
const cx = ((t * 30 + i * 240) % (WORLD_W + 100)) - 50;
ctx.beginPath();
ctx.ellipse(cx, WATER_Y + 30 + (i % 3) * 20, 40, 8, 0, 0, TAU);
ctx.fill();
}
}
// ---------- Main loop ----------
let lastT = performance.now();
function frame(now) {
let dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
if (impactFreeze > 0) {
impactFreeze -= dt;
dt *= 0.15; // slow mo
}
waterTime += dt;
if (phase === 'playing') {
updateGame(dt);
}
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
function updateGame(dt) {
// turn timer
if (!turnLocked && !isCPUTurn) {
turnTime -= dt;
const tv = document.getElementById('timerVal');
tv.textContent = Math.max(0, Math.ceil(turnTime));
const urgent = turnTime < 10;
tv.classList.toggle('urgent', urgent);
// ticking
if (urgent && Math.floor(turnTime * (turnTime < 5 ? 4 : 2)) !== Math.floor((turnTime + dt) * (turnTime < 5 ? 4 : 2))) {
AudioSys.tick(turnTime < 5);
AudioSys.tempo = turnTime < 5 ? 1.5 : 1.2;
}
// UI scale tension via style
tv.style.transform = urgent ? `scale(${1 + (10 - turnTime) * 0.03})` : '';
if (turnTime <= 0) {
endTurn();
}
}
if (isCPUTurn && !turnLocked) updateAI(dt);
else if (!isCPUTurn && !turnLocked) handlePlayerInput(dt);
// units
for (const u of allUnits()) {
const isAct = u === activeUnit;
const canCtrl = isAct && !turnLocked && !isCPUTurn && phase === 'playing';
u.update(dt, isAct, canCtrl);
}
// projectiles
for (let i = projectiles.length - 1; i >= 0; i--) {
projectiles[i].update(dt);
if (projectiles[i].dead) projectiles.splice(i, 1);
}
// debris
for (let i = debris.length - 1; i >= 0; i--) {
debris[i].update(dt);
if (debris[i].dead) debris.splice(i, 1);
}
particles.update(dt);
updateCamera(dt);
if (turnLocked) afterActionSettle(dt);
// mouse world pos
const invZ = 1 / cam.zoom;
mouse.worldX = (mouse.x - WORLD_W / 2) * invZ + cam.x;
mouse.worldY = (mouse.y - WORLD_H / 2) * invZ + cam.y;
if (activeUnit && !isCPUTurn) {
aimAngle = Math.atan2(mouse.worldY - (activeUnit.y - 6), mouse.worldX - activeUnit.x);
activeUnit.lookAngle = aimAngle;
activeUnit.facing = Math.cos(aimAngle) >= 0 ? 1 : -1;
}
mouse.justUp = false;
}
function handlePlayerInput(dt) {
if (!activeUnit || activeUnit.dead || activeUnit.frozen > 0) return;
const w = WEAPONS[selectedWeapon];
// weapon keys
for (let i = 0; i < WEAPONS.length; i++) {
const code = 'Digit' + WEAPONS[i].key;
if (WEAPONS[i].key === '0' && keys['Digit0']) selectWep(i);
else if (WEAPONS[i].key.length === 1 && keys['Digit' + WEAPONS[i].key]) selectWep(i);
else if (WEAPONS[i].key === 'J' && keys['KeyJ']) selectWep(i);
else if (WEAPONS[i].key === 'G' && keys['KeyG']) selectWep(i);
else if (WEAPONS[i].key === 'F' && keys['KeyF']) selectWep(i);
}
if (keys['KeyQ']) { cycleWep(-1); keys['KeyQ'] = false; }
if (keys['KeyE']) { cycleWep(1); keys['KeyE'] = false; }
// place mode click
if (placeMode || w.placeCursor) {
document.getElementById('helpHint').textContent = 'Click target location on the map';
if (mouse.justUp) {
fireSelected(0.5, mouse.worldX, mouse.worldY);
}
return;
}
// charge & fire
if (w.powerNeeded) {
if (mouse.down && !charging) {
charging = true; chargePower = 0;
document.getElementById('powerBar').classList.add('show');
}
if (charging && mouse.down) {
chargePower = Math.min(1, chargePower + dt * 0.7);
document.getElementById('powerFill').style.width = (chargePower * 100) + '%';
if (chance(0.15)) AudioSys.charge();
}
if (charging && !mouse.down) {
charging = false;
document.getElementById('powerBar').classList.remove('show');
fireSelected(clamp(chargePower, 0.08, 1));
}
} else if (w.holdUse) {
// jetpack already fired via select
} else {
if (mouse.justUp) {
fireSelected(0.7);
}
}
}
function selectWep(i) {
if (turnLocked || isCPUTurn) return;
selectedWeapon = i;
placeMode = !!WEAPONS[i].placeCursor;
AudioSys.select();
updateWeaponBar();
document.getElementById('helpHint').textContent = WEAPONS[i].desc;
// auto-activate jetpack / dynamite style on select for jetpack
if (WEAPONS[i].id === 'jetpack' && weaponAmmo.jetpack > 0) {
// wait for click
}
}
let wepCycleLock = false;
function cycleWep(dir) {
if (turnLocked || isCPUTurn || wepCycleLock) return;
let i = selectedWeapon;
for (let k = 0; k < WEAPONS.length; k++) {
i = (i + dir + WEAPONS.length) % WEAPONS.length;
const ammo = weaponAmmo[WEAPONS[i].id];
if (ammo === Infinity || ammo > 0) { selectWep(i); break; }
}
}
// ---------- Render ----------
function render() {
ctx.save();
// clear
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, WORLD_W, WORLD_H);
// camera transform
const sx = shake > 0 ? rand(-shake, shake) : 0;
const sy = shake > 0 ? rand(-shake, shake) : 0;
ctx.save();
ctx.translate(WORLD_W / 2 + sx, WORLD_H / 2 + sy);
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
if (phase === 'playing' || phase === 'end') {
drawBackground(ctx);
terrain.draw(ctx, cam);
// ropes
for (const u of allUnits()) {
if (u.rope && u.rope.active && !u.dead) {
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(u.rope.x, u.rope.y);
ctx.lineTo(u.x, u.y - 6);
ctx.stroke();
ctx.fillStyle = '#888';
ctx.beginPath(); ctx.arc(u.rope.x, u.rope.y, 3, 0, TAU); ctx.fill();
}
}
// debris
for (const d of debris) d.draw(ctx);
// units
for (const u of allUnits()) u.draw(ctx);
// projectiles
for (const p of projectiles) p.draw(ctx);
particles.draw(ctx);
drawWater(ctx);
// aim trajectory + crosshair for player
if (phase === 'playing' && activeUnit && !activeUnit.dead && !turnLocked && !isCPUTurn) {
const w = WEAPONS[selectedWeapon];
if (w.powerNeeded && !placeMode) {
const power = charging ? chargePower : 0.55;
const spd = w.id === 'grenade' || w.id === 'holy' || w.id === 'freeze'
? 4 + power * 12
: 6 + power * 14;
const grav = w.id === 'bazooka' ? 0.16 : 0.2;
const ox = activeUnit.x + Math.cos(aimAngle) * 18;
const oy = activeUnit.y + Math.sin(aimAngle) * 18 - 6;
const pts = predictTrajectory(ox, oy, Math.cos(aimAngle) * spd, Math.sin(aimAngle) * spd, grav, true, 50);
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 6]);
ctx.beginPath();
pts.forEach((p, i) => { if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); });
ctx.stroke();
ctx.setLineDash([]);
if (pts.length) {
const last = pts[pts.length - 1];
ctx.strokeStyle = 'rgba(255,100,100,0.6)';
ctx.beginPath(); ctx.arc(last.x, last.y, w.radius * 0.3, 0, TAU); ctx.stroke();
}
}
// aim line short
ctx.strokeStyle = TEAM_COLORS[activeTeam].light;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(activeUnit.x, activeUnit.y - 6);
ctx.lineTo(activeUnit.x + Math.cos(aimAngle) * 50, activeUnit.y - 6 + Math.sin(aimAngle) * 50);
ctx.stroke();
ctx.globalAlpha = 1;
// crosshair at mouse
const mx = mouse.worldX, my = mouse.worldY;
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(mx - 10, my); ctx.lineTo(mx - 3, my);
ctx.moveTo(mx + 3, my); ctx.lineTo(mx + 10, my);
ctx.moveTo(mx, my - 10); ctx.lineTo(mx, my - 3);
ctx.moveTo(mx, my + 3); ctx.lineTo(mx, my + 10);
ctx.stroke();
if (placeMode || w.placeCursor) {
ctx.beginPath(); ctx.arc(mx, my, 20, 0, TAU); ctx.stroke();
ctx.fillStyle = 'rgba(255,200,50,0.15)';
ctx.fill();
}
}
} else {
// menu bg pretty
drawBackground(ctx);
drawWater(ctx);
}
ctx.restore();
ctx.restore();
}
// ---------- Game start ----------
function startGame() {
AudioSys.resume();
document.getElementById('genOverlay').classList.remove('hidden');
document.getElementById('menu').classList.add('hidden');
document.getElementById('endScreen').classList.add('hidden');
setTimeout(() => {
terrain = new Terrain(WORLD_W, WORLD_H);
terrain.generate(terrainShape);
teams = [];
projectiles = [];
debris = [];
particles.list = [];
weaponAmmo = {};
gameOver = false;
const spawns = terrain.findSpawnPoints(teamCount * UNITS_PER_TEAM);
// distribute spawns across map for fairness
// interleave team assignment left-right
const slots = spawns.slice(0, teamCount * UNITS_PER_TEAM);
// give each team spread positions
const perTeam = [];
for (let t = 0; t < teamCount; t++) perTeam.push([]);
slots.forEach((s, i) => {
// snake distribute
const t = i % teamCount;
perTeam[t].push(s);
});
for (let t = 0; t < teamCount; t++) {
const units = [];
for (let u = 0; u < UNITS_PER_TEAM; u++) {
const p = perTeam[t][u] || { x: 200 + t * 200 + u * 40, y: 400 };
// drop to ground
let y = p.y;
while (y < WATER_Y - 20 && !terrain.solid(p.x, y + 1)) y++;
units.push(new Unit(p.x, y - 1, t, u));
}
teams.push(units);
}
ensureWeaponAmmo();
activeTeam = randi(0, teamCount - 1);
activeIndex = -1;
phase = 'playing';
cam.x = WORLD_W / 2; cam.y = WORLD_H / 2; cam.zoom = 0.8;
document.getElementById('hud').classList.add('visible');
document.getElementById('genOverlay').classList.add('hidden');
beginTurn();
}, 80);
}
// ---------- Menu wiring ----------
function wireChips(id, cb) {
const root = document.getElementById(id);
root.querySelectorAll('.chip').forEach(chip => {
chip.addEventListener('click', () => {
root.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
AudioSys.click();
cb(chip.dataset.v);
});
chip.addEventListener('mouseenter', () => AudioSys.uiHover());
});
}
wireChips('teamCountChips', v => { teamCount = parseInt(v, 10); });
wireChips('cpuChips', v => { humanCount = parseInt(v, 10); });
wireChips('terrainChips', v => { terrainShape = v; });
document.getElementById('btnStart').addEventListener('click', () => { AudioSys.click(); startGame(); });
document.getElementById('btnHow').addEventListener('click', () => {
AudioSys.click();
document.getElementById('howScreen').classList.remove('hidden');
});
document.getElementById('btnBackHow').addEventListener('click', () => {
AudioSys.click();
document.getElementById('howScreen').classList.add('hidden');
});
document.getElementById('btnRematch').addEventListener('click', () => { AudioSys.click(); startGame(); });
document.getElementById('btnMenu').addEventListener('click', () => {
AudioSys.click();
phase = 'menu';
document.getElementById('endScreen').classList.add('hidden');
document.getElementById('menu').classList.remove('hidden');
document.getElementById('hud').classList.remove('visible');
});
window.addEventListener('keydown', e => {
if (e.code === 'Escape') {
if (phase === 'playing') {
phase = 'menu';
document.getElementById('menu').classList.remove('hidden');
document.getElementById('hud').classList.remove('visible');
}
}
});
// initial decorative camera sway on menu
setInterval(() => {
if (phase === 'menu') {
cam.x = WORLD_W / 2 + Math.sin(performance.now() / 3000) * 40;
cam.y = WORLD_H / 2 + Math.cos(performance.now() / 4000) * 20;
cam.zoom = 0.55;
}
}, 50);
// generate a preview terrain for menu?
terrain = new Terrain(WORLD_W, WORLD_H);
terrain.generate('island');
phase = 'menu';
})();
</script>
</body>
</html>