Worms
Qwen 3.6 27b MTP · composer

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 117,854 bytes · SHA-256 86aeb7c93e4c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Worms Artillery Battle</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a1a; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
#app { position: relative; width: 100%; height: 100%; }
#gameCanvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
#uiOverlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: auto; transition: opacity 0.3s; }
.screen.hidden { opacity: 0; pointer-events: none !important; }
.screen.active { opacity: 1; }
#menuScreen { background: radial-gradient(ellipse at center, #1a1a3e 0%, #0a0a1a 70%); }
#titleText { font-size: 4em; color: #ff6b35; text-shadow: 0 0 20px rgba(255,107,53,0.5), 0 4px 8px rgba(0,0,0,0.8); margin-bottom: 40px; letter-spacing: 8px; animation: titlePulse 2s ease-in-out infinite; }
@keyframes titlePulse { 0%,100% { transform: scale(1); text-shadow: 0 0 20px rgba(255,107,53,0.5), 0 4px 8px rgba(0,0,0,0.8); } 50% { transform: scale(1.05); text-shadow: 0 0 40px rgba(255,107,53,0.8), 0 4px 8px rgba(0,0,0,0.8); } }
#menuOptions button { font-size: 1.5em; padding: 15px 50px; margin: 10px; border: 2px solid #ff6b35; background: rgba(255,107,53,0.1); color: #fff; cursor: pointer; transition: all 0.2s; letter-spacing: 3px; text-transform: uppercase; }
#menuOptions button:hover { background: rgba(255,107,53,0.4); transform: scale(1.05); box-shadow: 0 0 20px rgba(255,107,53,0.3); }
#setupScreen { background: radial-gradient(ellipse at center, #1a1a3e 0%, #0a0a1a 70%); }
#setupScreen h2 { font-size: 2.5em; color: #ff6b35; margin-bottom: 30px; text-shadow: 0 0 10px rgba(255,107,53,0.3); }
#setupOptions label { display: block; font-size: 1.2em; color: #ccc; margin: 10px 0; }
#setupOptions select { padding: 8px 15px; background: rgba(255,255,255,0.1); border: 1px solid #ff6b35; color: #fff; font-size: 1em; cursor: pointer; }
#teamSetup { margin: 20px 0; }
.teamRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
.teamRow label { color: #ccc; font-size: 1em; min-width: 60px; }
.teamRow input, .teamRow select { padding: 5px 10px; background: rgba(255,255,255,0.1); border: 1px solid #666; color: #fff; font-size: 0.9em; }
#btnStart { font-size: 1.3em; padding: 12px 40px; margin-top: 20px; border: 2px solid #ff6b35; background: rgba(255,107,53,0.2); color: #fff; cursor: pointer; letter-spacing: 2px; }
#btnStart:hover { background: rgba(255,107,53,0.5); transform: scale(1.05); }
#hud { justify-content: space-between; padding: 0; pointer-events: none; }
#topBar { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 20px; background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent); pointer-events: auto; }
#turnInfo { font-size: 1.4em; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,0.8); }
#windIndicator { font-size: 1em; color: #aaa; }
#windValue { font-weight: bold; }
#timerContainer { display: flex; align-items: center; gap: 10px; }
#timerText { font-size: 1.2em; color: #fff; min-width: 40px; text-align: right; }
#bottomBar { display: flex; align-items: flex-end; justify-content: space-between; width: 100%; padding: 15px 20px; background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); pointer-events: auto; }
#unitInfo { display: flex; align-items: center; gap: 10px; }
#wormName { font-size: 1.3em; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,0.8); min-width: 120px; }
#healthBar { width: 150px; height: 16px; background: rgba(0,0,0,0.5); border: 1px solid #444; border-radius: 3px; overflow: hidden; }
#healthFill { height: 100%; background: linear-gradient(to right, #ff4444, #44ff44); transition: width 0.3s; border-radius: 2px; }
#healthText { font-size: 1em; color: #fff; min-width: 35px; text-align: center; }
#actionButtons { display: flex; gap: 8px; }
#actionButtons button { padding: 10px 20px; border: 2px solid rgba(255,255,255,0.3); background: rgba(0,0,0,0.4); color: #fff; cursor: pointer; font-size: 0.9em; letter-spacing: 1px; transition: all 0.15s; }
#actionButtons button:hover { border-color: #ff6b35; background: rgba(255,107,53,0.3); transform: scale(1.05); }
#wheelCanvas { cursor: crosshair; }
#gameOverScreen { background: radial-gradient(ellipse at center, #1a1a3e 0%, #0a0a1a 70%); }
#gameOverScreen h2 { font-size: 3em; color: #ff6b35; margin-bottom: 20px; text-shadow: 0 0 20px rgba(255,107,53,0.5); }
#winnerText { font-size: 1.8em; color: #fff; margin-bottom: 30px; }
#btnRestart { font-size: 1.3em; padding: 12px 40px; border: 2px solid #ff6b35; background: rgba(255,107,53,0.2); color: #fff; cursor: pointer; letter-spacing: 2px; }
#btnRestart:hover { background: rgba(255,107,53,0.5); transform: scale(1.05); }
.hidden { display: none !important; }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="uiOverlay">
<div id="menuScreen" class="screen active">
<h1 id="titleText">WORMS BATTLE</h1>
<div id="menuOptions">
<button id="btnQuickPlay" data-sound="click">QUICK PLAY</button>
<button id="btnSetup" data-sound="click">CUSTOM SETUP</button>
</div>
</div>
<div id="setupScreen" class="screen hidden">
<h2>BATTLE SETUP</h2>
<div id="setupOptions">
<label>Teams: <select id="teamCount"><option value="2">2</option><option value="3">3</option><option value="4">4</option></select></label>
<label>Terrain: <select id="terrainType"><option value="island">Single Island</option><option value="doubleIsland">Double Island</option><option value="cave">Cave</option></select></label>
<div id="teamSetup"></div>
</div>
<button id="btnStart" data-sound="click">START BATTLE!</button>
</div>
<div id="hud" class="screen hidden">
<div id="topBar">
<div id="turnInfo"><span id="currentTeamName"></span></div>
<div id="windIndicator">Wind: <span id="windValue">0</span></div>
<div id="timerContainer"><canvas id="timerCanvas" width="200" height="30"></canvas><span id="timerText">15s</span></div>
</div>
<div id="bottomBar">
<div id="unitInfo">
<div id="wormName"></div>
<div id="healthBar"><div id="healthFill"></div></div>
<div id="healthText">100</div>
</div>
<div id="weaponWheel" class="hidden">
<canvas id="wheelCanvas" width="320" height="200"></canvas>
</div>
<div id="actionButtons">
<button id="btnMove" data-sound="click">MOVE</button>
<button id="btnAttack" data-sound="click">ATTACK</button>
<button id="btnItem" data-sound="click">ITEMS</button>
<button id="btnSkip" data-sound="click">SKIP</button>
</div>
</div>
</div>
<div id="gameOverScreen" class="screen hidden">
<h2>BATTLE OVER!</h2>
<div id="winnerText"></div>
<button id="btnRestart" data-sound="click">PLAY AGAIN</button>
</div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
const CONFIG = {
WORLD_WIDTH: 2400,
WORLD_HEIGHT: 800,
GRAVITY: 980,
UNIT_RADIUS: 12,
UNIT_SPEED: 150,
JUMP_FORCE: -380,
FALL_DAMAGE_THRESHOLD: 400,
TURN_TIME: 20,
MAX_HEALTH: 100,
WATER_LEVEL_OFFSET: 600,
TERRAIN_RESOLUTION: 2,
};
// ── const: TEAM_COLORS ──
const TEAM_COLORS = [
{ primary: '#ff4444', secondary: '#cc3333', name: 'Red Team' },
{ primary: '#4488ff', secondary: '#3366cc', name: 'Blue Team' },
{ primary: '#44dd44', secondary: '#33aa33', name: 'Green Team' },
{ primary: '#ffaa00', secondary: '#cc8800', name: 'Yellow Team' },
];
// ── const: WORM_NAMES ──
const WORM_NAMES = [
'Sir Worms Alot', 'Wormzilla', 'Baron von Worm', 'Colonel Mustard',
'Wormy McWormface', 'General Noodle', 'Captain Crunch', 'Dr. Wormington',
'Major Disaster', 'Private Snuggles', 'Sergeant Pepper', 'Lieutenant Dan',
'Bombastic Chuck', 'Explosive Eddie', 'Rocket Ron', 'Grenade Gary',
];
// ── const: WEAPONS ──
const WEAPONS = [
{ id: 'rifle', name: 'Rifle', type: 'indirect', power: 30, radius: 40, speed: 700, ammoCost: 1, icon: '🔫', desc: 'Standard rifle shot' },
{ id: 'rocket', name: 'Rocket Launcher', type: 'indirect', power: 60, radius: 70, speed: 550, ammoCost: 2, icon: '🚀', desc: 'High damage rocket' },
{ id: 'homingMissile', name: 'Homing Missile', type: 'indirect', power: 50, radius: 60, speed: 450, ammoCost: 2, icon: '💨', desc: 'Locks onto nearest enemy' },
{ id: 'shotgun', name: 'Shotgun', type: 'direct', power: 25, radius: 30, speed: 900, ammoCost: 1, icon: '🔥', desc: 'Close range blast (3 pellets)' },
{ id: 'minigun', name: 'Minigun', type: 'direct', power: 15, radius: 20, speed: 800, ammoCost: 1, icon: '⚡', desc: 'Rapid fire (5 shots)' },
{ id: 'superSaw', name: 'Super Saw', type: 'direct', power: 40, radius: 35, speed: 250, ammoCost: 2, icon: '🪚', desc: 'Flying buzzsaw' },
{ id: 'clusterBomb', name: 'Cluster Bomb', type: 'aoe', power: 80, radius: 100, speed: 600, ammoCost: 3, icon: '💣', desc: 'Splits into sub-bombs' },
{ id: 'earthquake', name: 'Earthquake', type: 'aoe', power: 40, radius: 200, speed: 0, ammoCost: 3, icon: '🌋', desc: 'Shakes the ground' },
];
// ── const: ITEMS ──
const ITEMS = [
{ id: 'parachute', name: 'Parachute', icon: '🪂', desc: 'Slow fall for next jump', effect: 'slowFall' },
{ id: 'extraTime', name: 'Extra Time', icon: '⏰', desc: '+10 seconds on timer', effect: 'addTime' },
{ id: 'windGauge', name: 'Wind Gauge', icon: '🌬️', desc: 'Reveal wind direction', effect: 'showWind' },
{ id: 'medikit', name: 'Medikit', icon: '💊', desc: '+30 health', effect: 'heal' },
{ id: 'mine', name: 'Land Mine', icon: '💀', desc: 'Place explosive mine', effect: 'placeMine' },
];
// ── const: TERRAIN_TYPES ──
const TERRAIN_TYPES = ['island', 'doubleIsland', 'cave'];
// ── class: AudioManager ──
class AudioManager {
constructor() {
this.ctx = null;
this.initialized = false;
}
init() {
if (this.initialized) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.initialized = true;
} catch(e) { console.warn('Web Audio not available'); }
}
play(type, params = {}) {
if (!this.ctx) return;
const t = this.ctx.currentTime;
switch(type) {
case 'click': this._click(t); break;
case 'explosion': this._explosion(t, params); break;
case 'hit': this._hit(t, params); break;
case 'jump': this._jump(t); break;
case 'laugh': this._laugh(t); break;
case 'death': this._death(t); break;
case 'tick': this._tick(t, params); break;
case 'whoosh': this._whoosh(t); break;
case 'splash': this._splash(t); break;
case 'select': this._select(t); break;
case 'warning': this._warning(t); break;
}
}
_click(t) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.connect(g); g.connect(this.ctx.destination);
o.type = 'square';
o.frequency.setValueAtTime(800, t);
o.frequency.exponentialRampToValueAtTime(400, t + 0.05);
g.gain.setValueAtTime(0.15, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
o.start(t); o.stop(t + 0.08);
}
_explosion(t, { size = 'medium' } = {}) {
const dur = size === 'large' ? 0.6 : size === 'small' ? 0.2 : 0.35;
const vol = size === 'large' ? 0.4 : size === 'small' ? 0.1 : 0.2;
// Noise burst
const bufSize = this.ctx.sampleRate * dur;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufSize, 2);
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
const filt = this.ctx.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.setValueAtTime(size === 'large' ? 800 : 1200, t);
filt.frequency.exponentialRampToValueAtTime(100, t + dur);
const g = this.ctx.createGain();
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(filt); filt.connect(g); g.connect(this.ctx.destination);
src.start(t); src.stop(t + dur);
// Low boom
const o = this.ctx.createOscillator();
const g2 = this.ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(80, t);
o.frequency.exponentialRampToValueAtTime(30, t + dur * 0.7);
g2.gain.setValueAtTime(vol * 1.5, t);
g2.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.8);
o.connect(g2); g2.connect(this.ctx.destination);
o.start(t); o.stop(t + dur);
}
_hit(t, { heavy = false } = {}) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(heavy ? 200 : 400, t);
o.frequency.exponentialRampToValueAtTime(100, t + 0.15);
g.gain.setValueAtTime(heavy ? 0.2 : 0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + 0.15);
}
_jump(t) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(300, t);
o.frequency.exponentialRampToValueAtTime(600, t + 0.15);
g.gain.setValueAtTime(0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + 0.2);
}
_laugh(t) {
for (let i = 0; i < 4; i++) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sawtooth';
const st = t + i * 0.12;
o.frequency.setValueAtTime(300 + Math.random() * 200, st);
o.frequency.linearRampToValueAtTime(400 + Math.random() * 150, st + 0.06);
o.frequency.linearRampToValueAtTime(250 + Math.random() * 100, st + 0.1);
g.gain.setValueAtTime(0, st);
g.gain.linearRampToValueAtTime(0.08, st + 0.03);
g.gain.exponentialRampToValueAtTime(0.001, st + 0.12);
o.connect(g); g.connect(this.ctx.destination);
o.start(st); o.stop(st + 0.12);
}
}
_death(t) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(500, t);
o.frequency.exponentialRampToValueAtTime(80, t + 0.6);
g.gain.setValueAtTime(0.15, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.6);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + 0.6);
}
_tick(t, { urgent = false } = {}) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'square';
o.frequency.setValueAtTime(urgent ? 1200 : 600, t);
g.gain.setValueAtTime(urgent ? 0.15 : 0.08, t);
g.gain.exponentialRampToValueAtTime(0.001, t + (urgent ? 0.1 : 0.06));
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + 0.12);
}
_whoosh(t) {
const bufSize = this.ctx.sampleRate * 0.3;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.sin(i / bufSize * Math.PI);
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
const filt = this.ctx.createBiquadFilter();
filt.type = 'bandpass';
filt.frequency.setValueAtTime(2000, t);
filt.Q.value = 5;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.1, t);
src.connect(filt); filt.connect(g); g.connect(this.ctx.destination);
src.start(t); src.stop(t + 0.3);
}
_splash(t) {
const bufSize = this.ctx.sampleRate * 0.4;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(Math.sin(i / bufSize * Math.PI), 0.5);
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
const filt = this.ctx.createBiquadFilter();
filt.type = 'highpass';
filt.frequency.value = 2000;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.15, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
src.connect(filt); filt.connect(g); g.connect(this.ctx.destination);
src.start(t); src.stop(t + 0.4);
}
_select(t) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(523, t);
o.frequency.setValueAtTime(659, t + 0.08);
g.gain.setValueAtTime(0.1, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + 0.15);
}
_warning(t) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'square';
o.frequency.setValueAtTime(440, t);
o.frequency.setValueAtTime(880, t + 0.15);
o.frequency.setValueAtTime(440, t + 0.3);
g.gain.setValueAtTime(0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + 0.4);
}
}
// ── const: audio ──
const audio = new AudioManager();
// ── class: Terrain ──
class Terrain {
constructor(width, height) {
this.width = width;
this.height = height;
this.res = CONFIG.TERRAIN_RESOLUTION;
this.cols = Math.ceil(width / this.res);
this.rows = Math.ceil(height / this.res);
this.grid = new Uint8Array(this.cols * this.rows); // 0=air, 1+=solid
this.waterLevel = height - CONFIG.WATER_LEVEL_OFFSET;
this.skyTop = 0;
this.caveMode = false;
this.caveCeiling = [];
this.generate('island');
}
idx(x, y) { return x + y * this.cols; }
getCell(x, y) {
const cx = Math.floor(x / this.res);
const cy = Math.floor(y / this.res);
if (cx < 0 || cx >= this.cols || cy < 0 || cy >= this.rows) return 1; // out of bounds = solid
return this.grid[this.idx(cx, cy)];
}
isSolid(x, y) { return this.getCell(x, y) > 0; }
isWater(y) { return y >= this.waterLevel; }
generate(type) {
this.clear();
this.caveMode = type === 'cave';
if (type === 'island') this._genIsland(1);
else if (type === 'doubleIsland') this._genDoubleIsland();
else if (type === 'cave') this._genCave();
}
clear() {
this.grid.fill(0);
}
_noise(x, seed) {
const n = Math.sin(x * 12.9898 + seed * 78.233) * 43758.5453;
return n - Math.floor(n);
}
_smoothNoise(x, seed) {
const ix = Math.floor(x);
const fx = x - ix;
const t = fx * fx * (3 - 2 * fx); // smoothstep
return this._noise(ix, seed) * (1 - t) + this._noise(ix + 1, seed) * t;
}
_fbm(x, octaves, seed) {
let val = 0, amp = 1, freq = 1, max = 0;
for (let i = 0; i < octaves; i++) {
val += this._smoothNoise(x * freq, seed + i * 100) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return val / max;
}
_genIsland(count = 1) {
const baseH = this.height * 0.65;
for (let x = 0; x < this.cols; x++) {
let h;
if (count === 1) {
// Single island - bell curve shape
const cx = this.cols / 2;
const dist = Math.abs(x - cx) / cx;
const base = Math.max(0, 1 - dist * dist);
h = baseH + (this._fbm(x * 0.02, 4, 42) - 0.5) * 80 * base;
} else {
h = this.height; // flat ground for double island
}
const terrainTop = Math.floor(h);
for (let y = terrainTop; y < this.rows; y++) {
this.grid[this.idx(x, y)] = 1;
}
}
}
_genDoubleIsland() {
const baseH = this.height * 0.65;
for (let x = 0; x < this.cols; x++) {
// Two peaks with gap in middle
const peak1 = Math.exp(-Math.pow((x - this.cols * 0.25) / (this.cols * 0.15), 2));
const peak2 = Math.exp(-Math.pow((x - this.cols * 0.75) / (this.cols * 0.15), 2));
const shape = Math.max(peak1, peak2);
// Valley in middle is water/air
let h;
if (shape > 0.3) {
h = baseH + (this._fbm(x * 0.02, 4, 77) - 0.5) * 60 * shape;
} else {
h = this.height; // deep valley
}
const terrainTop = Math.floor(h);
for (let y = terrainTop; y < this.rows; y++) {
this.grid[this.idx(x, y)] = 1;
}
}
}
_genCave() {
// Fill everything solid first
this.grid.fill(1);
// Carve out cave using cellular automata + noise
const carveRadius = Math.floor(this.height * 0.35 / this.res);
const centerY = Math.floor(this.rows * 0.45);
for (let x = 0; x < this.cols; x++) {
// Main tunnel with variation
const tunnelY = centerY + Math.sin(x * 0.01) * carveRadius * 0.3
+ (this._fbm(x * 0.05, 3, 99) - 0.5) * carveRadius;
for (let y = 0; y < this.rows; y++) {
const dist = Math.abs(y - tunnelY);
if (dist < carveRadius + (this._fbm(x * 0.1 + y * 0.1, 2, 55) - 0.5) * 30) {
this.grid[this.idx(x, y)] = 0; // air
}
}
}
// Add some extra tunnels/rooms
for (let i = 0; i < 8; i++) {
const cx = Math.floor(Math.random() * this.cols);
const cy = centerY + (Math.random() - 0.5) * carveRadius * 1.5;
const r = 20 + Math.random() * 40;
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
if (dx*dx + dy*dy < r*r) {
const px = cx + dx, py = cy + dy;
if (px >= 0 && px < this.cols && py >= 0 && py < this.rows) {
this.grid[this.idx(px, py)] = 0;
}
}
}
}
}
// Ensure top and bottom are solid
for (let x = 0; x < this.cols; x++) {
for (let y = 0; y < Math.floor(this.rows * 0.1); y++) {
this.grid[this.idx(x, y)] = 1;
}
for (let y = Math.floor(this.rows * 0.9); y < this.rows; y++) {
this.grid[this.idx(x, y)] = 1;
}
}
// Water at bottom of cave
this.waterLevel = centerY + carveRadius + 20;
}
destroy(x, y, radius) {
const r = Math.ceil(radius / this.res);
const cx = Math.floor(x / this.res);
const cy = Math.floor(y / this.res);
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
if (dx*dx + dy*dy < r*r) {
const px = cx + dx, py = cy + dy;
if (px >= 0 && px < this.cols && py >= 0 && py < this.rows) {
this.grid[this.idx(px, py)] = 0;
}
}
}
}
}
getTerrainHeight(x) {
const cx = Math.floor(x / this.res);
if (cx < 0 || cx >= this.cols) return this.height;
for (let y = 0; y < this.rows; y++) {
if (this.grid[this.idx(cx, y)] > 0) {
return y * this.res;
}
}
return this.height;
}
getSurfaceY(x) {
const cx = Math.floor(x / this.res);
if (cx < 0 || cx >= this.cols) return this.height;
for (let y = 0; y < this.rows; y++) {
if (this.grid[this.idx(cx, y)] > 0 && (y === 0 || this.grid[this.idx(cx, y-1)] === 0)) {
return y * this.res;
}
}
return this.height;
}
findSpawnPoints(count) {
const points = [];
const step = Math.floor(this.cols / (count + 2));
for (let i = 1; i <= count; i++) {
const cx = step * i;
let surfaceY = this.height;
if (!this.caveMode) {
// Find top of terrain
for (let y = 0; y < this.rows; y++) {
if (this.grid[this.idx(cx, y)] > 0 && (y === 0 || this.grid[this.idx(cx, y-1)] === 0)) {
surfaceY = y * this.res - CONFIG.UNIT_RADIUS * 2;
break;
}
}
} else {
// Find ceiling of cave (bottom-most air cell from top)
for (let y = this.rows - 1; y >= 0; y--) {
if (this.grid[this.idx(cx, y)] === 0 && (y === this.rows - 1 || this.grid[this.idx(cx, y+1)] > 0)) {
surfaceY = y * this.res + CONFIG.UNIT_RADIUS * 2;
break;
}
}
}
points.push({ x: cx * this.res, y: Math.min(surfaceY, this.waterLevel - 30) });
}
return points;
}
getTerrainColor(x, y) {
const cell = this.getCell(x, y);
if (cell === 0) return null; // air
const ny = y / this.height;
if (!this.caveMode && ny > 0.85) {
// Sandy bottom
return `rgb(${139 + Math.random()*20|0}, ${119 + Math.random()*15|0}, ${80 + Math.random()*10|0})`;
} else if (!this.caveMode && ny < 0.4) {
// Rocky top
return `rgb(${100 + Math.random()*30|0}, ${90 + Math.random()*25|0}, ${80 + Math.random()*20|0})`;
} else if (this.caveMode) {
// Cave walls - darker stone
const v = 60 + Math.random() * 40 | 0;
return `rgb(${v+15}, ${v+10}, ${v})`;
} else {
// Dirt/mud middle
const v = 80 + Math.random() * 30 | 0;
return `rgb(${v+20}, ${v+10}, ${v-10})`;
}
}
getGrassColor(x) {
const n = this._noise(x * 0.5, 123);
const g = 140 + n * 60 | 0;
return `rgb(${60 + n*30|0}, ${g}, ${30 + n*20|0})`;
}
// Check if a point is exposed to air (for debris falling)
isExposed(x, y) {
const cx = Math.floor(x / this.res);
const cy = Math.floor(y / this.res);
// Check above - if there's air above, it can fall
for (let dy = -1; dy >= -5; dy--) {
const py = cy + dy;
if (py < 0) return true;
if (this.grid[this.idx(cx, py)] === 0) return true;
}
return false;
}
// Make unsupported terrain fall (debris physics)
makeDebrisFall() {
const debris = [];
for (let x = this.cols - 1; x >= 0; x--) {
let foundAirBelow = false;
for (let y = this.rows - 1; y >= 0; y--) {
if (this.grid[this.idx(x, y)] === 0) {
foundAirBelow = true;
} else if (foundAirBelow && !this._isSupported(x, y)) {
// This cell is unsupported - make it fall
debris.push({ x: x * this.res + this.res/2, y: y * this.res + this.res/2 });
this.grid[this.idx(x, y)] = 0;
}
}
}
return debris;
}
_isSupported(cx, cy) {
// Check if supported from below or sides
if (cy >= this.rows - 1) return true;
if (this.grid[this.idx(cx, cy + 1)] > 0) return true;
if (cx > 0 && this.grid[this.idx(cx-1, cy)] > 0 &&
this.grid[this.idx(cx-1, cy+1)] > 0) return true;
if (cx < this.cols - 1 && this.grid[this.idx(cx+1, cy)] > 0 &&
this.grid[this.idx(cx+1, cy+1)] > 0) return true;
return false;
}
}
// ── class: Particle ──
class Particle {
constructor(x, y, vx, vy, life, color, size, type = 'circle') {
this.x = x; this.y = y;
this.vx = vx; this.vy = vy;
this.life = life; this.maxLife = life;
this.color = color; this.size = size;
this.type = type;
this.rotation = Math.random() * Math.PI * 2;
this.rotSpeed = (Math.random() - 0.5) * 10;
this.gravity = true;
this.alpha = 1;
}
update(dt) {
if (this.gravity) this.vy += CONFIG.GRAVITY * 0.3 * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.rotation += this.rotSpeed * dt;
this.life -= dt;
this.alpha = Math.max(0, this.life / this.maxLife);
return this.life > 0;
}
draw(ctx) {
ctx.save();
ctx.globalAlpha = this.alpha;
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation);
if (this.type === 'circle') {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(0, 0, this.size * this.alpha + 1, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'debris') {
ctx.fillStyle = this.color;
const s = this.size * (0.5 + this.alpha * 0.5);
ctx.fillRect(-s/2, -s/2, s, s);
} else if (this.type === 'spark') {
ctx.strokeStyle = this.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(this.vx * 0.05, this.vy * 0.05);
ctx.stroke();
} else if (this.type === 'smoke') {
ctx.fillStyle = this.color;
const s = this.size * (2 - this.alpha);
ctx.beginPath();
ctx.arc(0, 0, s, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'water') {
ctx.fillStyle = `rgba(100, 180, 255, ${this.alpha})`;
ctx.beginPath();
ctx.arc(0, 0, this.size * 0.7, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'fire') {
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, this.size);
grad.addColorStop(0, `rgba(255, 255, 100, ${this.alpha})`);
grad.addColorStop(0.5, `rgba(255, 150, 30, ${this.alpha * 0.7})`);
grad.addColorStop(1, `rgba(200, 50, 0, 0)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(0, 0, this.size * (1 + (1-this.alpha) * 0.5), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
// ── class: ParticleSystem ──
class ParticleSystem {
constructor() {
this.particles = [];
}
emit(x, y, count, config) {
for (let i = 0; i < count; i++) {
const angle = config.angle !== undefined ?
config.angle + (Math.random() - 0.5) * (config.spread || Math.PI * 2) :
Math.random() * Math.PI * 2;
const speed = (config.speedMin || 0) + Math.random() * ((config.speedMax || 100));
const p = new Particle(
x + (Math.random() - 0.5) * (config.offsetX || 0),
y + (Math.random() - 0.5) * (config.offsetY || 0),
Math.cos(angle) * speed,
Math.sin(angle) * speed,
config.lifeMin + Math.random() * ((config.lifeMax || 1) - (config.lifeMin || 0.5)),
typeof config.color === 'function' ? config.color() : config.color,
config.sizeMin + Math.random() * ((config.sizeMax || 3) - (config.sizeMin || 1)),
config.type || 'circle'
);
if (!config.gravity) p.gravity = false;
this.particles.push(p);
}
}
explosion(x, y, size = 'medium') {
const scale = size === 'large' ? 2 : size === 'small' ? 0.5 : 1;
// Fire particles
this.emit(x, y, Math.floor(30 * scale), {
speedMin: 50, speedMax: 200 * scale,
lifeMin: 0.2, lifeMax: 0.6,
color: () => ['#ff4400', '#ff8800', '#ffcc00', '#ffff00'][Math.random()*4|0],
sizeMin: 3, sizeMax: 10 * scale,
type: 'fire'
});
// Smoke
this.emit(x, y, Math.floor(20 * scale), {
speedMin: 20, speedMax: 80 * scale,
lifeMin: 0.5, lifeMax: 1.5,
color: () => `rgba(${60+Math.random()*40|0}, ${60+Math.random()*40|0}, ${60+Math.random()*40|0}, 0.8)`,
sizeMin: 5, sizeMax: 15 * scale,
type: 'smoke'
});
// Sparks
this.emit(x, y, Math.floor(25 * scale), {
speedMin: 100, speedMax: 400 * scale,
lifeMin: 0.3, lifeMax: 0.8,
color: () => ['#ffff00', '#ffaa00', '#ffffff'][Math.random()*3|0],
sizeMin: 1, sizeMax: 3,
type: 'spark'
});
// Debris chunks
this.emit(x, y, Math.floor(15 * scale), {
speedMin: 80, speedMax: 250 * scale,
lifeMin: 0.5, lifeMax: 1.5,
color: () => `rgb(${100+Math.random()*60|0}, ${80+Math.random()*40|0}, ${60+Math.random()*30|0})`,
sizeMin: 2, sizeMax: 6 * scale,
type: 'debris'
});
// Ring effect (expanding circle)
this.emit(x, y, Math.floor(15 * scale), {
angle: 0, spread: Math.PI * 2,
speedMin: 150 * scale, speedMax: 180 * scale,
lifeMin: 0.3, lifeMax: 0.5,
color: '#ff6600',
sizeMin: 2, sizeMax: 4,
type: 'circle'
});
}
waterSplash(x, y) {
this.emit(x, y, 15, {
angle: -Math.PI / 2, spread: Math.PI * 0.8,
speedMin: 50, speedMax: 150,
lifeMin: 0.3, lifeMax: 0.7,
color: '#66bbff',
sizeMin: 2, sizeMax: 5,
type: 'water'
});
this.emit(x, y, 8, {
angle: -Math.PI / 2, spread: Math.PI * 0.4,
speedMin: 30, speedMax: 100,
lifeMin: 0.5, lifeMax: 1.0,
color: 'rgba(150, 200, 255, 0.6)',
sizeMin: 4, sizeMax: 8,
type: 'smoke', gravity: false
});
}
jumpDust(x, y) {
this.emit(x, y, 8, {
angle: Math.PI / 2, spread: Math.PI * 0.6,
speedMin: 20, speedMax: 60,
lifeMin: 0.3, lifeMax: 0.6,
color: 'rgba(180, 160, 140, 0.7)',
sizeMin: 2, sizeMax: 5,
type: 'smoke', gravity: false
});
}
moveDust(x, y) {
this.emit(x, y + 8, 3, {
angle: Math.PI / 2, spread: Math.PI * 0.4,
speedMin: 5, speedMax: 20,
lifeMin: 0.2, lifeMax: 0.4,
color: 'rgba(160, 140, 120, 0.5)',
sizeMin: 1, sizeMax: 3,
type: 'smoke', gravity: false
});
}
hitEffect(x, y) {
this.emit(x, y, 6, {
speedMin: 30, speedMax: 80,
lifeMin: 0.2, lifeMax: 0.4,
color: '#ffffff',
sizeMin: 1, sizeMax: 3,
type: 'spark'
});
}
update(dt) {
this.particles = this.particles.filter(p => p.update(dt));
}
draw(ctx) {
for (const p of this.particles) {
p.draw(ctx);
}
}
clear() {
this.particles.length = 0;
}
}
// ── class: FloatingText ──
class FloatingText {
constructor(x, y, text, color = '#fff', size = 16) {
this.x = x; this.y = y;
this.text = text; this.color = color; this.size = size;
this.life = 1.5; this.maxLife = 1.5;
this.vy = -80;
}
update(dt) {
this.y += this.vy * dt;
this.vy *= 0.95;
this.life -= dt;
return this.life > 0;
}
draw(ctx, camX, camY, zoom) {
const alpha = Math.max(0, this.life / this.maxLife);
ctx.save();
ctx.globalAlpha = alpha;
ctx.font = `bold ${this.size}px Arial`;
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.fillText(this.text, this.x + 1, this.y + 1);
ctx.fillStyle = this.color;
ctx.fillText(this.text, this.x, this.y);
ctx.restore();
}
}
// ── class: Worm ──
class Worm {
constructor(id, teamId, name, x, y) {
this.id = id;
this.teamId = teamId;
this.name = name;
this.x = x;
this.y = y;
this.vx = 0;
this.vy = 0;
this.health = CONFIG.MAX_HEALTH;
this.maxHealth = CONFIG.MAX_HEALTH;
this.alive = true;
this.radius = CONFIG.UNIT_RADIUS;
this.onGround = false;
this.facing = 1; // 1=right, -1=left
this.moveTime = 0;
this.maxMoveTime = 8;
this.angle = 0; // body angle for rendering
this.bobPhase = Math.random() * Math.PI * 2;
this.inWater = false;
this.hasParachute = false;
this.laughing = false;
this.laughTimer = 0;
this.drowning = false;
this.drownTimer = 0;
this.lastFallSpeed = 0;
this.wasOnGround = true;
this.moving = false;
this.eyeBlink = 0;
this.blinkTimer = Math.random() * 3;
this.items = ['parachute', 'medikit']; // Starting items
}
update(dt, terrain) {
if (!this.alive) return;
// Blink timer
this.blinkTimer -= dt;
if (this.blinkTimer <= 0) {
this.eyeBlink = 1;
this.blinkTimer = 2 + Math.random() * 4;
}
if (this.eyeBlink > 0) this.eyeBlink -= dt * 5;
// Laugh timer
if (this.laughing) {
this.laughTimer -= dt;
if (this.laughTimer <= 0) this.laughing = false;
}
// Water physics
const wasInWater = this.inWater;
this.inWater = terrain.isWater(this.y + this.radius);
if (this.inWater) {
this.drownTimer += dt;
if (!wasInWater) audio.play('splash');
// Buoyancy - slow upward force
this.vy -= 200 * dt;
// Water drag
this.vx *= Math.pow(0.95, dt * 60);
this.vy *= Math.pow(0.95, dt * 60);
if (this.drownTimer > 3) {
this.health -= 20 * dt;
if (this.health <= 0) {
this.alive = false;
return;
}
}
} else {
this.drownTimer = Math.max(0, this.drownTimer - dt * 2);
}
// Gravity
const gravMult = this.hasParachute ? 0.3 : 1;
this.vy += CONFIG.GRAVITY * gravMult * dt;
// Track fall speed for damage
if (this.vy > 0) {
this.lastFallSpeed = Math.max(this.lastFallSpeed, this.vy);
}
// Apply velocity
this.x += this.vx * dt;
this.y += this.vy * dt;
// Move time countdown
if (this.moveTime > 0) {
this.moveTime -= dt;
if (this.moving) {
const moveDist = CONFIG.UNIT_SPEED * dt;
this.x += this.facing * moveDist;
this.vx = this.facing * CONFIG.UNIT_SPEED;
} else {
this.vx *= 0.9; // friction when not moving
}
}
// Terrain collision
this._collideTerrain(terrain, dt);
// World bounds
if (this.x < this.radius) { this.x = this.radius; this.vx = 0; }
if (this.x > CONFIG.WORLD_WIDTH - this.radius) { this.x = CONFIG.WORLD_WIDTH - this.radius; this.vx = 0; }
// Fall damage on landing
if (this.onGround && !this.wasOnGround && this.lastFallSpeed > CONFIG.FALL_DAMAGE_THRESHOLD) {
const dmg = Math.floor((this.lastFallSpeed - CONFIG.FALL_DAMAGE_THRESHOLD) / 15);
if (dmg > 0) {
this.health -= dmg;
game.floatingTexts.push(new FloatingText(this.x, this.y - 30, `-${dmg}`, '#ff4444'));
audio.play('hit', { heavy: dmg > 15 });
}
}
// Death check
if (this.health <= 0) {
this.alive = false;
this.health = 0;
}
// Body angle for rendering
const targetAngle = Math.atan2(this.vy, Math.abs(this.vx)) * 0.3;
this.angle += (targetAngle - this.angle) * 0.1;
// Bob animation
if (this.onGround && !this.moving) {
this.bobPhase += dt * 3;
}
this.wasOnGround = this.onGround;
}
_collideTerrain(terrain, dt) {
const r = this.radius;
// Check feet (bottom of worm)
const footY = Math.floor((this.y + r) / terrain.res);
const footX = Math.floor(this.x / terrain.res);
if (footX >= 0 && footX < terrain.cols && footY >= 0 && footY < terrain.rows) {
// Check below feet
if (terrain.grid[terrain.idx(footX, footY)] > 0) {
this.y = footY * terrain.res - r;
if (this.vy > 0) this.vy = 0;
this.onGround = true;
return;
}
}
// Check sides
const midY = Math.floor(this.y / terrain.res);
// Left side
const leftX = footX - 1;
if (leftX >= 0 && midY >= 0 && midY < terrain.rows) {
if (terrain.grid[terrain.idx(leftX, midY)] > 0) {
this.x = (leftX + 1) * terrain.res + r;
this.vx = 0;
}
}
// Right side
const rightX = footX + 1;
if (rightX < terrain.cols && midY >= 0 && midY < terrain.rows) {
if (terrain.grid[terrain.idx(rightX, midY)] > 0) {
this.x = rightX * terrain.res - r;
this.vx = 0;
}
}
// Check head (top of worm)
const headY = Math.floor((this.y - r) / terrain.res);
if (headY >= 0 && footX >= 0 && footX < terrain.cols) {
if (terrain.grid[terrain.idx(footX, headY)] > 0) {
this.y = (headY + 1) * terrain.res + r;
if (this.vy < 0) this.vy = 0;
}
}
// Check corners for better collision
const corners = [
[-r, -r], [r, -r], [-r, r], [r, r]
];
for (const [dx, dy] of corners) {
const cx = Math.floor((this.x + dx) / terrain.res);
const cy = Math.floor((this.y + dy) / terrain.res);
if (cx >= 0 && cx < terrain.cols && cy >= 0 && cy < terrain.rows) {
if (terrain.grid[terrain.idx(cx, cy)] > 0) {
// Push worm out of this cell
const cellCenterX = (cx + 0.5) * terrain.res;
const cellCenterY = (cy + 0.5) * terrain.res;
const overlapX = r - Math.abs(this.x - cellCenterX);
const overlapY = r - Math.abs(this.y - cellCenterY);
if (overlapX < overlapY) {
this.x += (this.x > cellCenterX ? 1 : -1) * overlapX;
this.vx = 0;
} else {
this.y += (this.y > cellCenterY ? 1 : -1) * overlapY;
if (dy > 0 && this.vy > 0) this.vy = 0;
if (dy < 0 && this.vy < 0) this.vy = 0;
}
}
}
}
// If not on ground check, mark as airborne
const belowY = Math.floor((this.y + r + 2) / terrain.res);
let grounded = false;
for (let dx = -1; dx <= 1; dx++) {
const bx = footX + dx;
if (bx >= 0 && bx < terrain.cols && belowY >= 0 && belowY < terrain.rows) {
if (terrain.grid[terrain.idx(bx, belowY)] > 0) {
grounded = true;
break;
}
}
}
this.onGround = grounded;
// If in water and at surface, float
if (this.inWater && !grounded) {
this.vy -= 150 * dt; // buoyancy - now dt is passed!
}
}
takeDamage(amount, sourceX, sourceY) {
if (!this.alive) return;
const dist = Math.hypot(this.x - sourceX, this.y - sourceY);
const falloff = Math.max(0.2, 1 - dist / 150);
const actualDmg = Math.floor(amount * falloff);
this.health -= actualDmg;
if (this.health < 0) this.health = 0;
// Knockback
const angle = Math.atan2(this.y - sourceY, this.x - sourceX);
const kbForce = actualDmg * 3;
this.vx += Math.cos(angle) * kbForce;
this.vy += Math.sin(angle) * kbForce - 100;
// Floating damage text
game.floatingTexts.push(new FloatingText(
this.x + (Math.random()-0.5)*20,
this.y - 30,
`-${actualDmg}`,
actualDmg > 20 ? '#ff4444' : '#ffaa00',
Math.min(24, 14 + actualDmg * 0.3) | 0
));
if (this.health <= 0) {
this.alive = false;
audio.play('death');
} else {
audio.play('hit', { heavy: actualDmg > 25 });
}
}
draw(ctx, camX, camY, zoom) {
if (!this.alive) return;
const sx = this.x - camX;
const sy = this.y - camY;
const bob = Math.sin(this.bobPhase) * 2;
ctx.save();
ctx.translate(sx, sy + bob);
ctx.scale(this.facing, 1);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, this.radius + 2, this.radius * 0.8, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Body (worm shape - segmented)
const color = TEAM_COLORS[this.teamId].primary;
const darkColor = TEAM_COLORS[this.teamId].secondary;
// Main body segments
for (let i = 0; i < 3; i++) {
const segY = i * 5 - 5;
const segR = this.radius * (1 - i * 0.15);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(0, segY + this.angle * 3, segR, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = darkColor;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Head
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(0, -this.radius + this.angle * 2, this.radius * 0.9, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = darkColor;
ctx.lineWidth = 1.5;
ctx.stroke();
// Eyes
const eyeY = -this.radius + this.angle * 2 - 2;
if (this.eyeBlink <= 0) {
// White of eyes
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.ellipse(-4, eyeY, 4, 5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(4, eyeY, 4, 5, 0, 0, Math.PI * 2);
ctx.fill();
// Pupils (look in facing direction)
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(-3 + this.facing * 1.5, eyeY, 2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(5 + this.facing * 1.5, eyeY, 2, 0, Math.PI * 2);
ctx.fill();
} else {
// Blinking - just lines
ctx.strokeStyle = '#111';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-7, eyeY);
ctx.lineTo(-1, eyeY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(1, eyeY);
ctx.lineTo(7, eyeY);
ctx.stroke();
}
// Mouth (smile or grimace based on health)
if (this.health < 30) {
// Worried face
ctx.strokeStyle = '#111';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(0, eyeY + 8, 4, Math.PI * 0.2, Math.PI * 0.8);
ctx.stroke();
} else if (this.laughing) {
// Big laugh mouth
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(0, eyeY + 7, 5, 0, Math.PI);
ctx.fill();
} else {
// Normal smile
ctx.strokeStyle = '#111';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(0, eyeY + 6, 3, 0, Math.PI);
ctx.stroke();
}
// Parachute indicator
if (this.hasParachute) {
ctx.fillStyle = '#ff4444';
ctx.beginPath();
ctx.arc(0, -this.radius * 2.5, 12, Math.PI, 0);
ctx.fill();
ctx.strokeStyle = '#cc3333';
ctx.lineWidth = 1;
ctx.stroke();
// Strings
ctx.strokeStyle = '#fff';
ctx.beginPath();
ctx.moveTo(-8, -this.radius * 2.5);
ctx.lineTo(0, -this.radius + this.angle * 2);
ctx.moveTo(8, -this.radius * 2.5);
ctx.lineTo(0, -this.radius + this.angle * 2);
ctx.stroke();
}
// Health bar above worm
if (this.health < this.maxHealth) {
const barW = 30;
const barH = 4;
const healthPct = this.health / this.maxHealth;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(-barW/2, -this.radius * 2 - 10 + bob * 0, barW, barH);
const hColor = healthPct > 0.5 ? '#44ff44' : healthPct > 0.25 ? '#ffaa00' : '#ff4444';
ctx.fillStyle = hColor;
ctx.fillRect(-barW/2, -this.radius * 2 - 10 + bob * 0, barW * healthPct, barH);
}
// Name tag
ctx.restore();
ctx.save();
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
const nameY = sy - this.radius * 2.5 + bob;
ctx.strokeText(this.name, sx, nameY);
ctx.fillText(this.name, sx, nameY);
ctx.restore();
// Drowning indicator
if (this.drownTimer > 0) {
const dmgPct = this.drownTimer / 3;
ctx.save();
ctx.fillStyle = `rgba(100, 150, 255, ${0.3 + Math.sin(Date.now() * 0.01) * 0.2})`;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.fillText('💧', sx, sy - this.radius * 3);
ctx.restore();
}
}
}
// ── class: Projectile ──
class Projectile {
constructor(x, y, vx, vy, weapon, owner) {
this.x = x; this.y = y;
this.vx = vx; this.vy = vy;
this.weapon = weapon;
this.owner = owner;
this.alive = true;
this.trail = [];
this.age = 0;
this.homingTarget = null;
this.isHoming = false;
this.subBombs = [];
this.rotAngle = 0;
}
update(dt, terrain, worms) {
if (!this.alive) return;
this.age += dt;
// Wind effect
this.vx += game.wind * 20 * dt;
// Gravity (unless it's a direct weapon with no arc)
if (this.weapon.type === 'indirect' || this.weapon.id === 'superSaw') {
this.vy += CONFIG.GRAVITY * dt;
} else if (this.weapon.id === 'earthquake') {
// Earthquake doesn't move as projectile, handled separately
return;
}
// Homing missile logic
if (this.weapon.id === 'homingMissile' && !this.isHoming) {
let closest = null;
let closestDist = Infinity;
for (const w of worms) {
if (!w.alive || w.teamId === this.owner.teamId) continue;
const d = Math.hypot(w.x - this.x, w.y - this.y);
if (d < closestDist) { closestDist = d; closest = w; }
}
if (closest) {
this.isHoming = true;
this.homingTarget = closest;
}
}
if (this.isHoming && this.homingTarget && this.homingTarget.alive) {
const angle = Math.atan2(this.homingTarget.y - this.y, this.homingTarget.x - this.x);
const currentAngle = Math.atan2(this.vy, this.vx);
let diff = angle - currentAngle;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
const turnRate = 3 * dt;
const newAngle = currentAngle + Math.sign(diff) * Math.min(Math.abs(diff), turnRate);
const speed = Math.hypot(this.vx, this.vy);
this.vx = Math.cos(newAngle) * speed;
this.vy = Math.sin(newAngle) * speed;
}
// Move
this.x += this.vx * dt;
this.y += this.vy * dt;
// Rotation for visual
this.rotAngle = Math.atan2(this.vy, this.vx);
// Trail
this.trail.push({ x: this.x, y: this.y, age: 0 });
if (this.trail.length > 30) this.trail.shift();
for (const t of this.trail) t.age += dt;
// Terrain collision
if (terrain.isSolid(this.x, this.y)) {
this.explode(terrain, worms);
return;
}
// Water collision
if (terrain.isWater(this.y)) {
audio.play('splash');
game.particles.waterSplash(this.x, terrain.waterLevel);
if (this.weapon.id === 'rocket' || this.weapon.id === 'clusterBomb') {
this.explode(terrain, worms);
} else {
// Most projectiles just stop in water
this.vy *= 0.5;
this.vx *= 0.7;
if (this.age > 2) this.alive = false;
}
}
// Worm collision
for (const w of worms) {
if (!w.alive || w.teamId === this.owner.teamId && this.weapon.id !== 'earthquake') continue;
const dist = Math.hypot(w.x - this.x, w.y - this.y);
if (dist < w.radius + 5) {
this.explode(terrain, worms);
return;
}
}
// Out of bounds or too old
if (this.x < -100 || this.x > CONFIG.WORLD_WIDTH + 100 ||
this.y > CONFIG.WORLD_HEIGHT + 100 || this.age > 8) {
this.alive = false;
}
// Cluster bomb splits at apex or after time
if (this.weapon.id === 'clusterBomb' && !this.subBombs.length && this.vy > -50 && this.age > 1.5) {
for (let i = -2; i <= 2; i++) {
const subVx = this.vx + i * 80;
const subVy = this.vy + Math.random() * 50;
game.projectiles.push(new Projectile(
this.x, this.y, subVx, subVy,
{ ...this.weapon, power: this.weapon.power * 0.4, radius: this.weapon.radius * 0.5 },
this.owner
));
}
this.alive = false;
}
}
explode(terrain, worms) {
if (!this.alive) return;
this.alive = false;
const w = this.weapon;
// Determine explosion size
const expSize = w.radius > 80 ? 'large' : w.radius < 35 ? 'small' : 'medium';
// Screen shake for big explosions
if (w.power > 50) {
game.screenShake = Math.min(15, w.power * 0.2);
game.freezeTimer = Math.max(0, (w.power - 40) * 0.03);
}
// Particle effects
game.particles.explosion(this.x, this.y, expSize);
// Sound
audio.play('explosion', { size: expSize });
// Terrain destruction
terrain.destroy(this.x, this.y, w.radius);
// Debris falling
const debris = terrain.makeDebrisFall();
for (const d of debris) {
game.particles.emit(d.x, d.y, 1, {
speedMin: 0, speedMax: 30,
lifeMin: 0.5, lifeMax: 1.2,
color: `rgb(${80+Math.random()*60|0}, ${70+Math.random()*40|0}, ${50+Math.random()*30|0})`,
sizeMin: 2, sizeMax: 5,
type: 'debris'
});
}
// Damage worms
for (const worm of worms) {
if (!worm.alive) continue;
const dist = Math.hypot(worm.x - this.x, worm.y - this.y);
if (dist < w.radius + worm.radius) {
worm.takeDamage(w.power, this.x, this.y);
// Make target laugh if not killed
if (worm.alive && worm.teamId !== this.owner.teamId) {
worm.laughing = true;
worm.laughTimer = 1.5;
audio.play('laugh');
}
}
}
// Damage debris from falling terrain
for (const d of debris) {
for (const worm of worms) {
if (!worm.alive) continue;
const dist = Math.hypot(worm.x - d.x, worm.y - d.y);
if (dist < 20) {
worm.takeDamage(15, d.x, d.y);
}
}
}
// Check for game end
game.checkGameEnd();
}
draw(ctx, camX, camY, zoom) {
if (!this.alive) return;
const sx = this.x - camX;
const sy = this.y - camY;
// Trail
ctx.save();
for (let i = 0; i < this.trail.length; i++) {
const t = this.trail[i];
const alpha = Math.max(0, 1 - t.age * 3) * 0.5;
if (alpha <= 0) continue;
ctx.globalAlpha = alpha;
ctx.fillStyle = '#ffaa44';
const size = this.weapon.id === 'rocket' ? 3 : 2;
ctx.beginPath();
ctx.arc(t.x - camX, t.y - camY, size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// Projectile body
ctx.save();
ctx.translate(sx, sy);
if (this.weapon.id === 'rocket') {
ctx.rotate(this.rotAngle);
ctx.fillStyle = '#ff4400';
ctx.beginPath();
ctx.ellipse(0, 0, 12, 5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#cc3300';
ctx.fillRect(-8, -3, 6, 6);
// Flame
if (Math.random() > 0.3) {
ctx.fillStyle = `rgba(255, ${150+Math.random()*100|0}, 0, 0.8)`;
ctx.beginPath();
ctx.moveTo(-12, -2);
ctx.lineTo(-18 - Math.random() * 6, 0);
ctx.lineTo(-12, 2);
ctx.fill();
}
} else if (this.weapon.id === 'homingMissile') {
ctx.rotate(this.rotAngle);
ctx.fillStyle = '#44aaff';
ctx.beginPath();
ctx.ellipse(0, 0, 10, 4, 0, 0, Math.PI * 2);
ctx.fill();
} else if (this.weapon.id === 'superSaw') {
ctx.rotate(this.rotAngle + Date.now() * 0.02);
ctx.fillStyle = '#aaa';
ctx.beginPath();
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const r = i % 2 === 0 ? 10 : 7;
if (i === 0) ctx.moveTo(Math.cos(a)*r, Math.sin(a)*r);
else ctx.lineTo(Math.cos(a)*r, Math.sin(a)*r);
}
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#666';
ctx.lineWidth = 1;
ctx.stroke();
} else if (this.weapon.id === 'clusterBomb') {
ctx.fillStyle = '#8B4513';
ctx.beginPath();
ctx.arc(0, 0, 7, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#654321';
ctx.lineWidth = 1;
ctx.stroke();
} else {
// Default bullet
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.arc(0, 0, 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
// ── class: Mine ──
class Mine {
constructor(x, y, owner) {
this.x = x; this.y = y;
this.owner = owner;
this.alive = true;
this.radius = 8;
this.placed = Date.now();
}
update(dt, terrain, worms) {
if (!this.alive) return;
// Check proximity to worms
for (const w of worms) {
if (!w.alive) continue;
const dist = Math.hypot(w.x - this.x, w.y - this.y);
if (dist < 25) {
this.explode(terrain, worms);
return;
}
}
// Check terrain collision (mine falls with terrain)
if (!terrain.isSolid(this.x, this.y + 10)) {
this.y += CONFIG.GRAVITY * dt * 0.5;
} else {
const surfaceY = terrain.getSurfaceY(this.x);
this.y = surfaceY - 3;
}
// Out of bounds
if (this.y > CONFIG.WORLD_HEIGHT + 100) this.alive = false;
}
explode(terrain, worms) {
this.alive = false;
game.screenShake = 8;
game.freezeTimer = 0.15;
game.particles.explosion(this.x, this.y, 'medium');
audio.play('explosion', { size: 'medium' });
terrain.destroy(this.x, this.y, 40);
for (const worm of worms) {
if (!worm.alive) continue;
const dist = Math.hypot(worm.x - this.x, worm.y - this.y);
if (dist < 50) {
worm.takeDamage(35, this.x, this.y);
if (worm.alive && worm.teamId !== this.owner.teamId) {
worm.laughing = true;
worm.laughTimer = 1.5;
audio.play('laugh');
}
}
}
game.checkGameEnd();
}
draw(ctx, camX, camY, zoom) {
if (!this.alive) return;
const sx = this.x - camX;
const sy = this.y - camY;
// Mine body
ctx.fillStyle = '#333';
ctx.beginPath();
ctx.arc(sx, sy, 6, Math.PI, 0);
ctx.fill();
// Spikes
for (let i = 0; i < 5; i++) {
const a = Math.PI + (i / 4) * Math.PI;
ctx.fillStyle = '#555';
ctx.beginPath();
ctx.moveTo(sx + Math.cos(a) * 6, sy);
ctx.lineTo(sx + Math.cos(a - 0.15) * 9, sy - 3);
ctx.lineTo(sx + Math.cos(a + 0.15) * 9, sy - 3);
ctx.fill();
}
// Blinking light
if (Math.sin(Date.now() * 0.005) > 0) {
ctx.fillStyle = '#ff0000';
ctx.beginPath();
ctx.arc(sx, sy - 2, 2, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ── class: Camera ──
class Camera {
constructor() {
this.x = 0;
this.y = 0;
this.targetX = 0;
this.targetY = 0;
this.zoom = 1;
this.targetZoom = 1;
this.shakeAmount = 0;
this.shakeDecay = 0.9;
this.smoothFactor = 0.08;
this.zoomSmooth = 0.05;
}
follow(target, worms) {
if (target && target.alive) {
this.targetX = target.x;
this.targetY = target.y - 50; // Look slightly above
// Zoom based on number of alive worms
const aliveCount = worms.filter(w => w.alive).length;
if (aliveCount <= 2) this.targetZoom = 1.3;
else if (aliveCount <= 4) this.targetZoom = 1.15;
else if (aliveCount <= 6) this.targetZoom = 1.0;
else this.targetZoom = 0.85;
}
// Smooth follow
this.x += (this.targetX - this.x) * this.smoothFactor;
this.y += (this.targetY - this.y) * this.smoothFactor;
// Smooth zoom
this.zoom += (this.targetZoom - this.zoom) * this.zoomSmooth;
// Screen shake decay
if (this.shakeAmount > 0.5) {
this.shakeAmount *= this.shakeDecay;
} else {
this.shakeAmount = 0;
}
}
getShakeOffset() {
if (this.shakeAmount < 1) return { x: 0, y: 0 };
return {
x: (Math.random() - 0.5) * this.shakeAmount * 2,
y: (Math.random() - 0.5) * this.shakeAmount * 2
};
}
apply(ctx, canvasW, canvasH) {
const shake = this.getShakeOffset();
ctx.save();
ctx.translate(canvasW / 2 + shake.x, canvasH / 2 + shake.y);
ctx.scale(this.zoom, this.zoom);
ctx.translate(-this.x, -this.y);
}
unapply(ctx) {
ctx.restore();
}
getVisibleBounds() {
return {
left: this.x - 600 / this.zoom,
right: this.x + 600 / this.zoom,
top: this.y - 350 / this.zoom,
bottom: this.y + 350 / this.zoom
};
}
screenToWorld(sx, sy, canvasW, canvasH) {
return {
x: (sx - canvasW/2) / this.zoom + this.x,
y: (sy - canvasH/2) / this.zoom + this.y
};
}
}
// ── class: AIController ──
class AIController {
constructor(worm, game) {
this.worm = worm;
this.game = game;
this.thinkingTimer = 0;
this.actionQueue = [];
this.currentAction = null;
this.selectedWeapon = null;
this.aimAngle = -Math.PI / 4;
this.aimPower = 50;
this.isAiming = false;
this.riskAssessment = { waterRisk: 0, cliffRisk: 0 };
}
update(dt) {
if (!this.worm.alive || !this.game.activeWorm ||
this.game.activeWorm.id !== this.worm.id) return;
this.thinkingTimer -= dt;
// Execute queued actions
if (this.actionQueue.length > 0 && this.thinkingTimer <= 0) {
const action = this.actionQueue.shift();
this.executeAction(action);
this.thinkingTimer = 0.5 + Math.random() * 1.0; // Think time between actions
}
// Plan next action if idle and timer is low enough
if (this.actionQueue.length === 0 && this.thinkingTimer <= 0) {
this.planActions();
}
// Update aim display during aiming
if (this.isAiming && this.selectedWeapon) {
this.updateAim(dt);
}
}
planActions() {
const worm = this.worm;
const enemies = this.game.teams.flatMap(t => t.units).filter(u => u.alive && u.teamId !== worm.teamId);
if (enemies.length === 0) return; // No enemies left
this.riskAssessment = this.assessRisks();
// Decide: move, attack, use item, or skip?
const roll = Math.random();
// Use medikit if low health
if (worm.health < 40 && worm.items.includes('medikit') && Math.random() > 0.3) {
this.actionQueue.push({ type: 'useItem', item: 'medikit' });
}
// Consider moving to safer position
if (this.riskAssessment.waterRisk > 0.5 || this.riskAssessment.cliffRisk > 0.7) {
const moveDir = this.findSafeDirection();
if (moveDir !== null && worm.moveTime > 1) {
this.actionQueue.push({ type: 'move', dir: moveDir, duration: Math.min(worm.moveTime, 3) });
}
}
// Attack with random weapon
const availableWeapons = WEAPONS.filter(w => w.ammoCost <= worm.maxMoveTime);
if (availableWeapons.length > 0 && enemies.length > 0) {
// Prefer weapons based on distance to nearest enemy
const nearestEnemy = this.findNearestEnemy(enemies);
const distToNearest = Math.hypot(nearestEnemy.x - worm.x, nearestEnemy.y - worm.y);
let weaponChoice;
if (distToNearest < 100) {
// Close range - prefer direct weapons
const directWeapons = availableWeapons.filter(w => w.type === 'direct');
weaponChoice = directWeapons.length > 0 ?
directWeapons[Math.random() * directWeapons.length | 0] :
availableWeapons[Math.random() * availableWeapons.length | 0];
} else if (distToNearest > 400) {
// Long range - prefer powerful indirect weapons
const indirectWeapons = availableWeapons.filter(w => w.type === 'indirect');
weaponChoice = indirectWeapons.length > 0 ?
indirectWeapons[Math.random() * indirectWeapons.length | 0] :
availableWeapons[Math.random() * availableWeapons.length | 0];
} else {
weaponChoice = availableWeapons[Math.random() * availableWeapons.length | 0];
}
this.actionQueue.push({ type: 'attack', weapon: weaponChoice, target: nearestEnemy });
}
// Use parachute if about to jump near water
if (this.riskAssessment.waterRisk > 0.3 && worm.items.includes('parachute') && Math.random() > 0.5) {
this.actionQueue.push({ type: 'useItem', item: 'parachute' });
}
// Place mine strategically
if (worm.items.includes('mine') && enemies.length > 1 && Math.random() > 0.6) {
const nearEnemy = enemies[Math.random() * enemies.length | 0];
this.actionQueue.push({ type: 'placeMine', targetX: nearEnemy.x + (Math.random()-0.5)*80 });
}
// If no actions planned, skip turn eventually
if (this.actionQueue.length === 0) {
this.thinkingTimer = Math.min(worm.moveTime, 3 + Math.random() * 4);
}
}
executeAction(action) {
const worm = this.worm;
switch (action.type) {
case 'move':
if (worm.moveTime <= 0) return;
worm.facing = action.dir;
worm.moving = true;
setTimeout(() => { worm.moving = false; }, action.duration * 1000);
break;
case 'attack':
this.selectedWeapon = action.weapon;
this.isAiming = true;
// Calculate aim at target with some randomness
const dx = action.target.x - worm.x;
const dy = action.target.y - worm.y;
const dist = Math.hypot(dx, dy);
if (action.weapon.type === 'direct') {
this.aimAngle = Math.atan2(dy, dx) + (Math.random() - 0.5) * 0.15;
this.aimPower = Math.min(80, dist * 0.3);
// Fire after short delay
setTimeout(() => {
if (!worm.alive || !this.isAiming) return;
this.fireWeapon();
}, 500 + Math.random() * 1000);
} else {
// Indirect - calculate parabolic trajectory
const power = 30 + (dist / CONFIG.WORLD_WIDTH) * 60 + Math.random() * 20;
this.aimPower = Math.min(95, power);
if (dx > 0) {
this.aimAngle = -Math.PI/4 - Math.random() * Math.PI/6;
} else {
this.aimAngle = -Math.PI * 3/4 + Math.random() * Math.PI/6;
}
setTimeout(() => {
if (!worm.alive || !this.isAiming) return;
this.fireWeapon();
}, 800 + Math.random() * 1500);
}
break;
case 'useItem':
game.useItem(action.item);
break;
case 'placeMine':
// Move to position and place mine
const moveDir = action.targetX > worm.x ? 1 : -1;
worm.facing = moveDir;
worm.moving = true;
setTimeout(() => {
worm.moving = false;
game.placeMine(worm);
}, 2000);
break;
case 'skip':
game.skipTurn();
break;
}
}
fireWeapon() {
if (!this.worm.alive || !this.selectedWeapon) return;
const worm = this.worm;
const w = this.selectedWeapon;
// Face the aim direction
worm.facing = Math.cos(this.aimAngle) > 0 ? 1 : -1;
if (w.id === 'earthquake') {
game.triggerEarthquake(worm);
this.isAiming = false;
return;
}
const speed = w.speed * (this.aimPower / 50);
if (w.id === 'shotgun') {
for (let i = -1; i <= 1; i++) {
game.projectiles.push(new Projectile(
worm.x, worm.y - 20,
Math.cos(this.aimAngle + i * 0.15) * speed,
Math.sin(this.aimAngle + i * 0.15) * speed,
w, worm
));
}
} else if (w.id === 'minigun') {
for (let i = 0; i < 5; i++) {
setTimeout(() => {
if (!worm.alive) return;
game.projectiles.push(new Projectile(
worm.x, worm.y - 20,
Math.cos(this.aimAngle + (Math.random()-0.5)*0.1) * speed,
Math.sin(this.aimAngle + (Math.random()-0.5)*0.1) * speed,
w, worm
));
}, i * 120);
}
} else {
game.projectiles.push(new Projectile(
worm.x, worm.y - 20,
Math.cos(this.aimAngle) * speed,
Math.sin(this.aimAngle) * speed,
w, worm
));
}
audio.play('whoosh');
this.isAiming = false;
game.checkGameEnd();
}
updateAim(dt) {
// Slight aim adjustment over time for AI
if (Math.random() < 0.02) {
this.aimAngle += (Math.random() - 0.5) * 0.05;
}
}
findNearestEnemy(enemies) {
let nearest = enemies[0];
let minDist = Infinity;
for (const e of enemies) {
const d = Math.hypot(e.x - this.worm.x, e.y - this.worm.y);
if (d < minDist) { minDist = d; nearest = e; }
}
return nearest;
}
assessRisks() {
const terrain = game.terrain;
const worm = this.worm;
// Water risk - how close to water level?
const distToWater = Math.max(0, terrain.waterLevel - (worm.y + worm.radius));
const waterRisk = distToWater < 100 ? 1 - distToWater / 100 : 0;
// Cliff risk - is there solid ground below?
const belowY = Math.floor((worm.y + worm.radius + 20) / terrain.res);
const centerX = Math.floor(worm.x / terrain.res);
let cliffRisk = 0;
if (centerX >= 0 && centerX < terrain.cols && belowY >= 0 && belowY < terrain.rows) {
if (terrain.grid[terrain.idx(centerX, belowY)] === 0) {
// No ground below - high risk!
cliffRisk = 1;
// Check sides for escape route
let hasSideGround = false;
for (let dx = -5; dx <= 5; dx++) {
const sx = centerX + dx;
if (sx >= 0 && sx < terrain.cols) {
for (let dy = 0; dy < 10; dy++) {
if (belowY + dy < terrain.rows && terrain.grid[terrain.idx(sx, belowY + dy)] > 0) {
hasSideGround = true;
break;
}
}
}
if (hasSideGround) break;
}
if (hasSideGround) cliffRisk *= 0.5; // Lower risk if there's ground nearby
}
}
return { waterRisk, cliffRisk };
}
findSafeDirection() {
const terrain = game.terrain;
const worm = this.worm;
const centerX = Math.floor(worm.x / terrain.res);
const centerY = Math.floor((worm.y + worm.radius) / terrain.res);
// Check which direction has more solid ground below
let leftScore = 0, rightScore = 0;
for (let dx = -15; dx <= 15; dx++) {
const cx = centerX + dx;
if (cx < 0 || cx >= terrain.cols) continue;
// Check ground below this position
let hasGround = false;
for (let dy = centerY; dy < Math.min(centerY + 20, terrain.rows); dy++) {
if (terrain.grid[terrain.idx(cx, dy)] > 0) {
hasGround = true;
break;
}
}
if (hasGround) {
if (dx < 0) leftScore++;
else rightScore++;
}
}
// Also consider water level
const distToLeftWater = Math.max(0, terrain.waterLevel - worm.y);
if (leftScore > rightScore + 3) return -1;
if (rightScore > leftScore + 3) return 1;
return null; // No clear safe direction
}
reset() {
this.actionQueue = [];
this.currentAction = null;
this.selectedWeapon = null;
this.isAiming = false;
this.thinkingTimer = 0;
}
}
// ── let: game ──
let game;
// ── class: ArtilleryGame ──
// Global reference for AI and projectiles to access
class ArtilleryGame {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.state = 'menu'; // menu, setup, playing, gameOver
this.terrain = null;
this.teams = [];
this.worms = [];
this.projectiles = [];
this.mines = [];
this.particles = new ParticleSystem();
this.camera = new Camera();
this.floatingTexts = [];
this.activeTeamIndex = 0;
this.activeWormIndex = 0;
this.activeWorm = null;
this.turnTimer = CONFIG.TURN_TIME;
this.maxTurnTime = CONFIG.TURN_TIME;
this.wind = (Math.random() - 0.5) * 200;
this.screenShake = 0;
this.freezeTimer = 0;
// Input state
this.keys = {};
this.mousePos = { x: 0, y: 0 };
this.mouseDown = false;
this.aimAngle = -Math.PI / 4;
this.aimPower = 50;
this.isAiming = false;
this.selectedWeapon = null;
this.showingWeapons = false;
this.showingItems = false;
this.weaponWheelHover = -1;
// Movement state
this.movingLeft = false;
this.movingRight = false;
this.jumpPressed = false;
// Terrain type for current game
this.terrainType = 'island';
}
init() {
this.resize();
window.addEventListener('resize', () => this.resize());
// Input handlers
document.addEventListener('keydown', e => {
this.keys[e.key] = true;
if (this.state === 'playing') {
if (e.key === ' ' || e.key === 'ArrowUp') {
e.preventDefault();
if (!this.jumpPressed && this.activeWorm && this.activeWorm.onGround) {
this.doJump();
this.jumpPressed = true;
}
}
if (e.key === 'Escape' || e.key === 'q') {
this.cancelAim();
}
// Number keys for quick weapon select
const numKey = parseInt(e.key);
if (numKey >= 1 && numKey <= WEAPONS.length) {
audio.init();
this.quickSelectWeapon(numKey - 1);
}
}
});
document.addEventListener('keyup', e => {
this.keys[e.key] = false;
if (e.key === ' ' || e.key === 'ArrowUp') this.jumpPressed = false;
});
// Mouse wheel for aim power
this.canvas.addEventListener('wheel', e => {
if (this.state !== 'playing' || !this.isAiming) return;
e.preventDefault();
this.aimPower = Math.max(10, Math.min(95, this.aimPower + (e.deltaY > 0 ? -3 : 3)));
}, { passive: false });
this.canvas.addEventListener('mousemove', e => {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
this.mousePos.x = (e.clientX - rect.left) * scaleX;
this.mousePos.y = (e.clientY - rect.top) * scaleY;
// Update aim angle based on mouse position during aiming
if (this.isAiming && this.activeWorm) {
const worldPos = this.camera.screenToWorld(
e.clientX - rect.left, e.clientY - rect.top,
window.innerWidth, window.innerHeight
);
const dx = worldPos.x - this.activeWorm.x;
const dy = (worldPos.y) - (this.activeWorm.y - 20);
this.aimAngle = Math.atan2(dy, dx);
}
});
this.canvas.addEventListener('mousedown', e => {
if (this.state !== 'playing') return;
audio.init();
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
this.mousePos.x = (e.clientX - rect.left) * scaleX;
this.mousePos.y = (e.clientY - rect.top) * scaleY;
if (this.showingWeapons) {
this.selectWeaponFromWheel();
} else if (this.isAiming && e.button === 0) {
// Fire!
this.fireProjectile();
} else if (!this.isAiming && !this.showingWeapons && !this.showingItems) {
// Click on terrain to start aiming direction
if (this.activeWorm && this.selectedWeapon) {
const worldPos = this.camera.screenToWorld(
e.clientX - rect.left, e.clientY - rect.top,
window.innerWidth, window.innerHeight
);
const dx = worldPos.x - this.activeWorm.x;
const dy = worldPos.y - (this.activeWorm.y - 20);
this.aimAngle = Math.atan2(dy, dx);
this.isAiming = true;
}
}
});
this.canvas.addEventListener('mouseup', () => {
if (this.movingLeft || this.movingRight) {
this.activeWorm && (this.activeWorm.moving = false);
this.movingLeft = false;
this.movingRight = false;
}
});
// UI button handlers
document.getElementById('btnQuickPlay').addEventListener('click', () => {
audio.init(); audio.play('click');
this.startGame(2, 'island');
});
document.getElementById('btnSetup').addEventListener('click', () => {
audio.init(); audio.play('click');
this.showScreen('setup');
this.updateTeamSetupUI();
});
document.getElementById('teamCount').addEventListener('change', () => {
audio.init(); audio.play('click');
this.updateTeamSetupUI();
});
document.getElementById('btnStart').addEventListener('click', () => {
audio.init(); audio.play('select');
const teamCount = parseInt(document.getElementById('teamCount').value);
const terrainType = document.getElementById('terrainType').value;
this.startGame(teamCount, terrainType);
});
document.getElementById('btnMove').addEventListener('click', () => {
audio.init(); audio.play('click');
if (this.state === 'playing' && !this.isAiming) {
// Toggle movement hint
game.floatingTexts.push(new FloatingText(
this.activeWorm.x, this.activeWorm.y - 50,
'← → to move, Space to jump', '#88ccff', 12
));
}
});
document.getElementById('btnAttack').addEventListener('click', () => {
audio.init(); audio.play('click');
if (this.state === 'playing') {
this.showingWeapons = !this.showingWeapons;
this.showingItems = false;
document.getElementById('weaponWheel').classList.toggle('hidden', !this.showingWeapons);
if (this.showingWeapons) this.renderWeaponWheel();
}
});
document.getElementById('btnItem').addEventListener('click', () => {
audio.init(); audio.play('click');
if (this.state === 'playing') {
this.showingItems = !this.showingItems;
this.showingWeapons = false;
document.getElementById('weaponWheel').classList.toggle('hidden', !this.showingItems);
if (this.showingItems) this.renderItemWheel();
}
});
document.getElementById('btnSkip').addEventListener('click', () => {
audio.init(); audio.play('warning');
if (this.state === 'playing') {
this.skipTurn();
}
});
document.getElementById('btnRestart').addEventListener('click', () => {
audio.init(); audio.play('click');
this.showScreen('menu');
});
// Start render loop
this.lastTime = performance.now();
requestAnimationFrame(t => this.gameLoop(t));
}
resize() {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = window.innerWidth * dpr;
this.canvas.height = window.innerHeight * dpr;
this.ctx.scale(dpr, dpr);
this.canvas.style.width = window.innerWidth + 'px';
this.canvas.style.height = window.innerHeight + 'px';
}
showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
const screenId = name === 'menu' ? 'menuScreen' :
name === 'setup' ? 'setupScreen' :
name === 'playing' ? 'hud' : 'gameOverScreen';
document.getElementById(screenId).classList.remove('hidden');
if (name !== 'playing') {
document.getElementById('hud').classList.add('hidden');
} else {
document.getElementById('hud').classList.remove('hidden');
}
}
updateTeamSetupUI() {
const count = parseInt(document.getElementById('teamCount').value);
const container = document.getElementById('teamSetup');
container.innerHTML = '';
for (let i = 0; i < count; i++) {
const row = document.createElement('div');
row.className = 'teamRow';
const colorDot = document.createElement('span');
colorDot.style.cssText = `display:inline-block;width:16px;height:16px;border-radius:50%;background:${TEAM_COLORS[i].primary};margin-right:8px;`;
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = TEAM_COLORS[i].name;
nameInput.id = `teamName${i}`;
nameInput.style.color = TEAM_COLORS[i].primary;
const typeSelect = document.createElement('select');
typeSelect.id = `teamType${i}`;
typeSelect.innerHTML = '<option value="human">Human</option><option value="cpu">CPU</option>';
row.appendChild(colorDot);
row.appendChild(nameInput);
row.appendChild(typeSelect);
container.appendChild(row);
}
}
startGame(teamCount, terrainType) {
this.terrainType = terrainType;
this.state = 'playing';
// Generate terrain
this.terrain = new Terrain(CONFIG.WORLD_WIDTH, CONFIG.WORLD_HEIGHT);
this.terrain.generate(terrainType);
// Create teams and worms
const spawnPoints = this.terrain.findSpawnPoints(teamCount * 4);
this.teams = [];
this.worms = [];
let wormIdx = 0;
for (let t = 0; t < teamCount; t++) {
const isCPU = document.getElementById(`teamType${t}`)?.value === 'cpu';
const name = document.getElementById(`teamName${t}`)?.value || TEAM_COLORS[t].name;
this.teams.push({
id: t,
name: name,
color: TEAM_COLORS[t],
isCPU: isCPU,
units: []
});
for (let u = 0; u < 4; u++) {
const sp = spawnPoints[wormIdx % spawnPoints.length];
const wormName = WORM_NAMES[(t * 4 + u) % WORM_NAMES.length];
const worm = new Worm(wormIdx, t, wormName, sp.x, sp.y);
// Give random starting items
this.assignRandomItems(worm);
this.worms.push(worm);
this.teams[t].units.push(worm);
wormIdx++;
}
}
// Create AI controllers for CPU teams
this.aiControllers = {};
for (const team of this.teams) {
if (team.isCPU) {
for (const unit of team.units) {
this.aiControllers[unit.id] = new AIController(unit, this);
}
}
}
// Reset game state
this.activeTeamIndex = 0;
this.activeWormIndex = 0;
this.projectiles = [];
this.mines = [];
this.particles.clear();
this.floatingTexts = [];
this.wind = (Math.random() - 0.5) * 200;
this.screenShake = 0;
this.freezeTimer = 0;
// Set first active worm
this.setActiveWorm(0, 0);
this.showScreen('playing');
audio.play('select');
}
assignRandomItems(worm) {
worm.items = [];
const itemPool = ['parachute', 'medikit', 'extraTime', 'windGauge', 'mine'];
// Give 2-3 random items
const count = 2 + Math.floor(Math.random() * 2);
for (let i = 0; i < count; i++) {
const item = itemPool[Math.floor(Math.random() * itemPool.length)];
if (!worm.items.includes(item)) {
worm.items.push(item);
}
}
}
setActiveWorm(teamIdx, wormIdx) {
const team = this.teams[teamIdx];
if (!team) return;
// Find next alive worm in this team
let found = false;
for (let i = 0; i < team.units.length; i++) {
const idx = (wormIdx + i) % team.units.length;
if (team.units[idx].alive) {
this.activeWormIndex = idx;
this.activeWorm = team.units[idx];
found = true;
// Reset worm state for new turn
this.activeWorm.moveTime = CONFIG.TURN_TIME * 0.4;
this.activeWorm.maxMoveTime = CONFIG.TURN_TIME * 0.4;
this.activeWorm.hasParachute = false;
this.activeWorm.drownTimer = 0;
// Give random items each turn
if (!team.isCPU) {
this.assignRandomItems(this.activeWorm);
}
break;
}
}
if (!found) {
// Team eliminated! Skip to next team
this.nextTeam();
return;
}
this.turnTimer = CONFIG.TURN_TIME;
this.maxTurnTime = CONFIG.TURN_TIME;
this.isAiming = false;
this.selectedWeapon = null;
this.showingWeapons = false;
this.showingItems = false;
// Reset AI for CPU worms
if (this.aiControllers[this.activeWorm.id]) {
this.aiControllers[this.activeWorm.id].reset();
}
document.getElementById('weaponWheel').classList.add('hidden');
this.updateHUD();
}
nextTeam() {
let attempts = 0;
do {
this.activeTeamIndex = (this.activeTeamIndex + 1) % this.teams.length;
this.activeWormIndex = 0;
attempts++;
// Check if team has any alive worms
const team = this.teams[this.activeTeamIndex];
if (team.units.some(u => u.alive)) break;
} while (attempts < this.teams.length);
// Change wind each round
this.wind = Math.max(-200, Math.min(200, this.wind + (Math.random() - 0.5) * 100));
this.setActiveWorm(this.activeTeamIndex, 0);
}
nextWorm() {
const team = this.teams[this.activeTeamIndex];
let found = false;
for (let i = 1; i <= team.units.length; i++) {
const idx = (this.activeWormIndex + i) % team.units.length;
if (team.units[idx].alive) {
this.setActiveWorm(this.activeTeamIndex, idx);
found = true;
break;
}
}
if (!found) this.nextTeam();
}
skipTurn() {
audio.play('warning');
// Check if current team has more alive worms
const team = this.teams[this.activeTeamIndex];
let hasMoreWorms = false;
for (let i = 0; i < team.units.length; i++) {
if (i !== this.activeWormIndex && team.units[i].alive) {
hasMoreWorms = true;
break;
}
}
if (hasMoreWorms) {
this.nextWorm();
} else {
this.nextTeam();
}
}
doJump() {
if (!this.activeWorm || !this.activeWorm.onGround) return;
this.activeWorm.vy = CONFIG.JUMP_FORCE * (this.activeWorm.hasParachute ? 0.7 : 1);
audio.play('jump');
game.particles.jumpDust(this.activeWorm.x, this.activeWorm.y + this.activeWorm.radius);
}
quickSelectWeapon(idx) {
if (!this.activeWorm || !this.activeWorm.alive) return;
const weapon = WEAPONS[idx];
if (!weapon) return;
// Check ammo cost vs move time
if (weapon.ammoCost > this.activeWorm.moveTime * 0.5 + 1) {
game.floatingTexts.push(new FloatingText(
this.activeWorm.x, this.activeWorm.y - 40,
'Not enough time!', '#ff8800'
));
return;
}
this.selectedWeapon = weapon;
audio.play('select');
// Start aiming with default angle toward nearest enemy or facing direction
const enemies = this.worms.filter(w => w.alive && w.teamId !== this.activeWorm.teamId);
if (enemies.length > 0) {
let nearest = enemies[0];
let minDist = Infinity;
for (const e of enemies) {
const d = Math.hypot(e.x - this.activeWorm.x, e.y - this.activeWorm.y);
if (d < minDist) { minDist = d; nearest = e; }
}
const dx = nearest.x - this.activeWorm.x;
const dy = nearest.y - (this.activeWorm.y - 20);
this.aimAngle = Math.atan2(dy, dx);
} else {
this.aimAngle = this.activeWorm.facing > 0 ? -Math.PI/4 : -Math.PI*3/4;
}
if (weapon.type === 'direct') {
// Direct weapons fire immediately at angle
setTimeout(() => this.fireProjectile(), 300);
} else {
this.isAiming = true;
}
this.showingWeapons = false;
document.getElementById('weaponWheel').classList.add('hidden');
}
selectWeaponFromWheel() {
if (this.weaponWheelHover < 0) return;
const worm = this.activeWorm;
if (!worm || !worm.alive) return;
// Check ammo cost vs move time
const weapon = WEAPONS[this.weaponWheelHover];
if (weapon.ammoCost > worm.moveTime * 0.5 + 1) {
game.floatingTexts.push(new FloatingText(worm.x, worm.y - 40, 'Not enough time!', '#ff8800'));
return;
}
this.selectedWeapon = weapon;
audio.play('select');
// Start aiming
const worldPos = this.camera.screenToWorld(this.mousePos.x, this.mousePos.y,
window.innerWidth, window.innerHeight);
const dx = worldPos.x - worm.x;
const dy = worldPos.y - (worm.y - 20);
this.aimAngle = Math.atan2(dy, dx);
if (weapon.type === 'direct') {
// Direct weapons fire immediately at angle
setTimeout(() => this.fireProjectile(), 300);
} else {
this.isAiming = true;
}
this.showingWeapons = false;
document.getElementById('weaponWheel').classList.add('hidden');
}
fireProjectile() {
if (!this.activeWorm || !this.selectedWeapon) return;
const worm = this.activeWorm;
const w = this.selectedWeapon;
// Face the aim direction
worm.facing = Math.cos(this.aimAngle) > 0 ? 1 : -1;
if (w.id === 'earthquake') {
this.triggerEarthquake(worm);
this.isAiming = false;
return;
}
const speed = w.speed * (this.aimPower / 50);
if (w.id === 'shotgun') {
for (let i = -1; i <= 1; i++) {
this.projectiles.push(new Projectile(
worm.x, worm.y - 20,
Math.cos(this.aimAngle + i * 0.15) * speed,
Math.sin(this.aimAngle + i * 0.15) * speed,
w, worm
));
}
} else if (w.id === 'minigun') {
for (let i = 0; i < 5; i++) {
setTimeout(() => {
if (!worm.alive) return;
this.projectiles.push(new Projectile(
worm.x, worm.y - 20,
Math.cos(this.aimAngle + (Math.random()-0.5)*0.1) * speed,
Math.sin(this.aimAngle + (Math.random()-0.5)*0.1) * speed,
w, worm
));
}, i * 120);
}
} else {
this.projectiles.push(new Projectile(
worm.x, worm.y - 20,
Math.cos(this.aimAngle) * speed,
Math.sin(this.aimAngle) * speed,
w, worm
));
}
audio.play('whoosh');
this.isAiming = false;
// Deduct move time for weapon use
worm.moveTime -= w.ammoCost * 2;
this.checkGameEnd();
}
cancelAim() {
this.isAiming = false;
this.selectedWeapon = null;
audio.play('click');
}
triggerEarthquake(worm) {
game.screenShake = 15;
game.freezeTimer = 0.3;
audio.play('explosion', { size: 'large' });
// Damage all worms based on height above ground
for (const w of this.worms) {
if (!w.alive) continue;
const groundY = this.terrain.getTerrainHeight(w.x);
const heightAboveGround = Math.max(0, groundY - w.y);
// Higher worms take more damage
const dmg = Math.floor(heightAboveGround / 15 + 10);
if (dmg > 0) {
w.takeDamage(dmg, w.x, this.terrain.waterLevel);
// Knock them down
w.vy += 200;
w.vx += (Math.random() - 0.5) * 100;
}
if (w.alive && w.teamId !== worm.teamId) {
w.laughing = true;
w.laughTimer = 2;
audio.play('laugh');
}
}
// Visual effect - shake all particles
for (let i = 0; i < 50; i++) {
const x = Math.random() * CONFIG.WORLD_WIDTH;
const y = this.terrain.getTerrainHeight(x) + Math.random() * 20;
game.particles.emit(x, y, 2, {
speedMin: 10, speedMax: 50,
lifeMin: 0.3, lifeMax: 0.8,
color: `rgb(${140+Math.random()*40|0}, ${120+Math.random()*30|0}, ${90+Math.random()*20|0})`,
sizeMin: 2, sizeMax: 5,
type: 'debris'
});
}
worm.moveTime -= 6;
this.checkGameEnd();
}
useItem(itemId) {
const worm = this.activeWorm;
if (!worm || !worm.alive) return;
const itemIdx = worm.items.indexOf(itemId);
if (itemIdx < 0) return;
worm.items.splice(itemIdx, 1);
audio.play('select');
switch (itemId) {
case 'parachute':
worm.hasParachute = true;
game.floatingTexts.push(new FloatingText(worm.x, worm.y - 40, 'Parachute!', '#ff8844'));
break;
case 'extraTime':
this.turnTimer += 10;
this.maxTurnTime += 10;
game.floatingTexts.push(new FloatingText(worm.x, worm.y - 40, '+10 seconds!', '#44ff88'));
break;
case 'windGauge':
game.floatingTexts.push(new FloatingText(
worm.x, worm.y - 50,
`Wind: ${Math.abs(this.wind).toFixed(0)} ${this.wind > 0 ? '→' : '←'}`,
'#88ccff', 14
));
break;
case 'medikit':
worm.health = Math.min(CONFIG.MAX_HEALTH, worm.health + 30);
game.floatingTexts.push(new FloatingText(worm.x, worm.y - 40, '+30 HP!', '#44ff44'));
break;
}
this.showingItems = false;
document.getElementById('weaponWheel').classList.add('hidden');
this.updateHUD();
}
placeMine(worm) {
const mineIdx = worm.items.indexOf('mine');
if (mineIdx < 0) return;
worm.items.splice(mineIdx, 1);
this.mines.push(new Mine(worm.x, worm.y - 20, worm));
game.floatingTexts.push(new FloatingText(worm.x, worm.y - 40, 'Mine placed!', '#ffaa00'));
audio.play('click');
this.showingItems = false;
document.getElementById('weaponWheel').classList.add('hidden');
}
checkGameEnd() {
// Check if only one team remains
const aliveTeams = this.teams.filter(t => t.units.some(u => u.alive));
if (aliveTeams.length <= 1) {
setTimeout(() => {
this.state = 'gameOver';
let winnerText;
if (aliveTeams.length === 0) {
winnerText = "It's a draw! Everyone died!";
} else {
const aliveCount = aliveTeams[0].units.filter(u => u.alive).length;
winnerText = `${aliveTeams[0].name} wins with ${aliveCount} worm${aliveCount !== 1 ? 's' : ''} left!`;
}
document.getElementById('winnerText').textContent = winnerText;
this.showScreen('gameOver');
}, 2000);
}
}
updateHUD() {
if (!this.activeWorm) return;
const team = this.teams[this.activeTeamIndex];
document.getElementById('currentTeamName').textContent = `${team.name}'s Turn`;
document.getElementById('currentTeamName').style.color = team.color.primary;
document.getElementById('windValue').textContent =
`${Math.abs(this.wind).toFixed(0)} ${this.wind > 0 ? '→' : this.wind < 0 ? '←' : '•'}`;
document.getElementById('windValue').style.color = Math.abs(this.wind) > 100 ? '#ff4444' : '#aaa';
document.getElementById('wormName').textContent = this.activeWorm.name;
document.getElementById('wormName').style.color = team.color.primary;
const healthPct = (this.activeWorm.health / CONFIG.MAX_HEALTH) * 100;
document.getElementById('healthFill').style.width = `${healthPct}%`;
document.getElementById('healthText').textContent = Math.ceil(this.activeWorm.health);
// Update timer text
document.getElementById('timerText').textContent = `${Math.ceil(this.turnTimer)}s`;
}
renderWeaponWheel() {
const canvas = document.getElementById('wheelCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const worm = this.activeWorm;
const availableWeapons = WEAPONS.filter(w => w.ammoCost <= worm.moveTime * 0.5 + 1);
const cols = Math.ceil(Math.sqrt(availableWeapons.length));
const rows = Math.ceil(availableWeapons.length / cols);
const cellW = (w - 20) / cols;
const cellH = (h - 30) / rows;
this.weaponWheelHover = -1;
availableWeapons.forEach((weapon, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const cx = 10 + col * cellW + cellW/2;
const cy = 15 + row * cellH + cellH/2;
// Check hover using canvas-relative mouse position
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (this.mousePos.x - this.canvas.offsetLeft) * scaleX;
const my = (this.mousePos.y - this.canvas.offsetTop) * scaleY;
// Simple hover detection based on screen position mapped to canvas
const isHovered = Math.abs(mx - cx) < cellW/2 && Math.abs(my - cy) < cellH/2;
if (isHovered) this.weaponWheelHover = WEAPONS.indexOf(weapon);
// Background
ctx.fillStyle = isHovered ? 'rgba(255,107,53,0.4)' : 'rgba(0,0,0,0.6)';
ctx.strokeStyle = isHovered ? '#ff6b35' : '#444';
ctx.lineWidth = 2;
const pad = 4;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(10 + col * cellW + pad, 15 + row * cellH + pad,
cellW - pad*2, cellH - pad*2, 8);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(10 + col * cellW + pad, 15 + row * cellH + pad,
cellW - pad*2, cellH - pad*2);
ctx.strokeRect(10 + col * cellW + pad, 15 + row * cellH + pad,
cellW - pad*2, cellH - pad*2);
}
// Icon
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.fillText(weapon.icon, cx, cy + 5);
// Name
ctx.font = 'bold 10px Arial';
ctx.fillStyle = '#fff';
ctx.fillText(weapon.name, cx, cy + cellH/2 - 8);
// Type indicator
ctx.font = '9px Arial';
ctx.fillStyle = weapon.type === 'indirect' ? '#ffaa44' :
weapon.type === 'direct' ? '#44aaff' : '#ff4444';
ctx.fillText(weapon.type.toUpperCase(), cx, cy + cellH/2 - 0);
// Ammo cost
ctx.fillStyle = '#aaa';
ctx.font = '9px Arial';
ctx.fillText(`Cost: ${weapon.ammoCost}`, cx, cy + cellH/2 + 10);
});
}
renderItemWheel() {
const canvas = document.getElementById('wheelCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const worm = this.activeWorm;
// Show all items (owned and available)
const cols = Math.ceil(Math.sqrt(ITEMS.length));
const cellW = (w - 20) / cols;
const cellH = (h - 30);
ITEMS.forEach((item, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const cx = 10 + col * cellW + cellW/2;
const cy = 15 + row * cellH + cellH/2;
const hasItem = worm.items.includes(item.id);
ctx.fillStyle = hasItem ? 'rgba(68,136,255,0.3)' : 'rgba(0,0,0,0.4)';
ctx.strokeStyle = hasItem ? '#4488ff' : '#333';
ctx.lineWidth = 2;
const pad = 4;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(10 + col * cellW + pad, 15 + row * cellH + pad,
cellW - pad*2, cellH - pad*2, 8);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(10 + col * cellW + pad, 15 + row * cellH + pad,
cellW - pad*2, cellH - pad*2);
ctx.strokeRect(10 + col * cellW + pad, 15 + row * cellH + pad,
cellW - pad*2, cellH - pad*2);
}
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.globalAlpha = hasItem ? 1 : 0.3;
ctx.fillText(item.icon, cx, cy + 5);
ctx.globalAlpha = 1;
ctx.font = 'bold 10px Arial';
ctx.fillStyle = '#fff';
ctx.fillText(item.name, cx, cy + cellH/2 - 8);
if (hasItem) {
ctx.fillStyle = '#44ff44';
ctx.font = '9px Arial';
ctx.fillText('OWNED', cx, cy + cellH/2 + 5);
} else {
ctx.fillStyle = '#666';
ctx.font = '9px Arial';
ctx.fillText('MISSING', cx, cy + cellH/2 + 5);
}
});
// Click handler for items
canvas.onclick = (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
ITEMS.forEach((item, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const cx = 10 + col * cellW + cellW/2;
const cy = 15 + row * cellH + cellH/2;
if (Math.abs(mx - cx) < cellW/2 && Math.abs(my - cy) < cellH/2) {
if (item.id === 'mine') {
this.placeMine(worm);
} else {
this.useItem(item.id);
}
}
});
};
}
update(dt) {
if (this.state !== 'playing') return;
// Freeze frame for big explosions
if (this.freezeTimer > 0) {
this.freezeTimer -= dt;
if (this.freezeTimer > 0) return;
}
const worm = this.activeWorm;
// Update turn timer
this.turnTimer -= dt;
// Timer warning sounds
if (this.turnTimer <= 5 && this.turnTimer > 4.9) {
audio.play('warning');
}
if (this.turnTimer <= 0) {
this.skipTurn();
return;
}
// Handle player movement input
if (!this.isAiming && !this.showingWeapons && !this.showingItems && worm && worm.alive) {
const wasMoving = worm.moving;
if ((this.keys['ArrowLeft'] || this.keys['a']) && worm.moveTime > 0) {
worm.facing = -1;
worm.moving = true;
worm.vx = -CONFIG.UNIT_SPEED;
worm.moveTime -= dt;
if (Math.random() < 0.3) game.particles.moveDust(worm.x, worm.y);
} else if ((this.keys['ArrowRight'] || this.keys['d']) && worm.moveTime > 0) {
worm.facing = 1;
worm.moving = true;
worm.vx = CONFIG.UNIT_SPEED;
worm.moveTime -= dt;
if (Math.random() < 0.3) game.particles.moveDust(worm.x, worm.y);
} else {
worm.moving = false;
worm.vx *= 0.9;
}
// Wind effect on moving worms
if (worm.moving) {
worm.vx += this.wind * dt * 0.1;
}
}
// Update active worm
if (worm && worm.alive) {
worm.update(dt, this.terrain);
// Check if worm fell off world
if (worm.y > CONFIG.WORLD_HEIGHT + 200) {
worm.health = 0;
worm.alive = false;
audio.play('death');
setTimeout(() => {
const team = this.teams[this.activeTeamIndex];
let hasMoreWorms = team.units.some((u, i) => u.alive && i !== this.activeWormIndex);
if (hasMoreWorms) this.nextWorm();
else this.nextTeam();
}, 1000);
}
}
// Update all worms (for physics even when not active)
for (const w of this.worms) {
if (w !== worm && w.alive) {
w.update(dt, this.terrain);
// Laugh timer decay
if (w.laughing) {
w.laughTimer -= dt;
if (w.laughTimer <= 0) w.laughing = false;
}
}
}
// Update projectiles
for (const p of this.projectiles) {
p.update(dt, this.terrain, this.worms);
}
this.projectiles = this.projectiles.filter(p => p.alive);
// Update mines
for (const m of this.mines) {
m.update(dt, this.terrain, this.worms);
}
this.mines = this.mines.filter(m => m.alive);
// Update particles
this.particles.update(dt);
// Update floating texts
this.floatingTexts = this.floatingTexts.filter(t => t.update(dt));
// Update camera
this.camera.follow(this.activeWorm, this.worms);
// Apply screen shake from explosions
if (this.screenShake > 0) {
this.camera.shakeAmount = Math.max(this.camera.shakeAmount, this.screenShake);
this.screenShake *= 0.9;
if (this.screenShake < 0.5) this.screenShake = 0;
}
// Update AI controllers
for (const id in this.aiControllers) {
this.aiControllers[id].update(dt);
}
// Update HUD periodically
this.updateHUD();
// Render weapon wheel if showing
if (this.showingWeapons) {
this.renderWeaponWheel();
}
}
render() {
const ctx = this.ctx;
const cw = window.innerWidth;
const ch = window.innerHeight;
ctx.clearRect(0, 0, cw, ch);
if (this.state === 'menu' || this.state === 'setup') {
// Animated background for menu
this.renderMenuBackground(ctx, cw, ch);
return;
}
if (this.state !== 'playing' && this.state !== 'gameOver') return;
// Apply camera transform
this.camera.apply(ctx, cw, ch);
// Render sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, CONFIG.WORLD_HEIGHT);
if (this.terrain.caveMode) {
skyGrad.addColorStop(0, '#1a1a2e');
skyGrad.addColorStop(0.5, '#16213e');
skyGrad.addColorStop(1, '#0f3460');
} else {
skyGrad.addColorStop(0, '#87CEEB');
skyGrad.addColorStop(0.4, '#B0E0FF');
skyGrad.addColorStop(0.7, '#FFE4C4');
skyGrad.addColorStop(1, '#DEB887');
}
ctx.fillStyle = skyGrad;
ctx.fillRect(-500, -500, CONFIG.WORLD_WIDTH + 1000, CONFIG.WORLD_HEIGHT + 1000);
// Render clouds (non-cave mode)
if (!this.terrain.caveMode) {
this.renderClouds(ctx);
}
// Render terrain
this.renderTerrain(ctx);
// Render water
this.renderWater(ctx);
// Render mines
for (const m of this.mines) {
m.draw(ctx, 0, 0, 1);
}
// Render worms
for (const w of this.worms) {
if (w.alive) w.draw(ctx, 0, 0, 1);
}
// Render projectiles
for (const p of this.projectiles) {
p.draw(ctx, 0, 0, 1);
}
// Render particles
this.particles.draw(ctx);
// Render floating texts
for (const t of this.floatingTexts) {
t.draw(ctx, 0, 0, 1);
}
// Render aim trajectory
if (this.isAiming && this.activeWorm && this.selectedWeapon) {
this.renderTrajectory(ctx);
}
// Unapply camera
this.camera.unapply(ctx);
// Render timer bar overlay
this.renderTimerBar(ctx, cw, ch);
// Render wind indicator on canvas
this.renderWindArrow(ctx, cw, ch);
// Render move time indicator
if (this.activeWorm) {
this.renderMoveTime(ctx, cw, ch);
}
}
renderMenuBackground(ctx, w, h) {
const t = Date.now() * 0.001;
// Gradient background
const grad = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, w*0.7);
grad.addColorStop(0, '#1a1a3e');
grad.addColorStop(1, '#0a0a1a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
// Animated particles
for (let i = 0; i < 50; i++) {
const x = (Math.sin(t * 0.3 + i * 1.7) * 0.5 + 0.5) * w;
const y = (Math.cos(t * 0.2 + i * 2.3) * 0.5 + 0.5) * h;
const size = 2 + Math.sin(t + i) * 1;
ctx.fillStyle = `rgba(255, ${107 + i*2|0}, ${53 + i|0}, ${0.2 + Math.sin(t+i)*0.1})`;
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
}
}
renderClouds(ctx) {
const t = Date.now() * 0.0001;
for (let i = 0; i < 8; i++) {
const x = ((i * 350 + t * 200) % (CONFIG.WORLD_WIDTH + 400)) - 200;
const y = 30 + Math.sin(i * 1.5) * 60;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
for (let j = 0; j < 5; j++) {
const cx = x + j * 30 - 60;
const cy = y + Math.sin(j * 1.2) * 10;
ctx.beginPath();
ctx.arc(cx, cy, 25 + Math.sin(i+j)*8, 0, Math.PI * 2);
ctx.fill();
}
}
}
renderTerrain(ctx) {
const terrain = this.terrain;
const res = terrain.res;
// Render visible cells only for performance
const bounds = this.camera.getVisibleBounds();
const startX = Math.max(0, Math.floor(bounds.left / res) - 1);
const endX = Math.min(terrain.cols, Math.ceil(bounds.right / res) + 1);
const startY = Math.max(0, Math.floor(bounds.top / res) - 1);
const endY = Math.min(terrain.rows, Math.ceil(bounds.bottom / res) + 1);
for (let x = startX; x < endX; x++) {
for (let y = startY; y < endY; y++) {
if (terrain.grid[terrain.idx(x, y)] === 0) continue;
const px = x * res;
const py = y * res;
// Determine color based on position and exposure
let color;
// Check if this is a surface cell (air above it)
const isSurface = y > 0 && terrain.grid[terrain.idx(x, y-1)] === 0;
if (isSurface && !this.terrain.caveMode) {
// Grass on top
color = terrain.getGrassColor(x);
} else if (this.terrain.caveMode) {
const isExposed = (y > 0 && terrain.grid[terrain.idx(x, y-1)] === 0) ||
(x > 0 && terrain.grid[terrain.idx(x-1, y)] === 0) ||
(x < terrain.cols-1 && terrain.grid[terrain.idx(x+1, y)] === 0);
if (isExposed) {
const v = 70 + Math.sin(x*0.5+y*0.3)*20 | 0;
color = `rgb(${v+15}, ${v+10}, ${v})`;
} else {
const v = 40 + Math.random()*15 | 0;
color = `rgb(${v+10}, ${v+8}, ${v+5})`;
}
} else {
// Underground dirt/rock
const depthRatio = y / terrain.rows;
if (depthRatio < 0.3) {
const v = 90 + Math.random()*20 | 0;
color = `rgb(${v+15}, ${v+8}, ${v-10})`;
} else if (depthRatio > 0.7) {
const v = 60 + Math.random()*15 | 0;
color = `rgb(${v+20}, ${v+15}, ${v+10})`;
} else {
const v = 80 + Math.random()*20 | 0;
color = `rgb(${v+10}, ${v+5}, ${v-5})`;
}
}
ctx.fillStyle = color;
ctx.fillRect(px, py, res + 0.5, res + 0.5); // +0.5 to avoid gaps
// Grass blades on surface
if (isSurface && !this.terrain.caveMode && y > 2) {
ctx.fillStyle = '#3a8c3f';
for (let g = 0; g < 3; g++) {
const gx = px + g * res/3 + Math.random()*2;
const gh = 4 + Math.sin(x*3+g)*2;
ctx.fillRect(gx, py - gh, 1.5, gh);
}
}
}
}
// Render grass line on top of terrain (non-cave)
if (!this.terrain.caveMode) {
for (let x = startX; x < endX; x++) {
let surfaceY = -1;
for (let y = startY; y < endY; y++) {
if (terrain.grid[terrain.idx(x, y)] > 0 &&
(y === 0 || terrain.grid[terrain.idx(x, y-1)] === 0)) {
surfaceY = y * res;
break;
}
}
if (surfaceY >= 0) {
ctx.fillStyle = '#4CAF50';
ctx.fillRect(x * res, surfaceY - 2, res + 1, 3);
// Grass tufts
if (x % 3 === 0) {
ctx.strokeStyle = '#66BB6A';
ctx.lineWidth = 1.5;
for (let g = 0; g < 2; g++) {
const gx = x * res + g * 4 + 2;
ctx.beginPath();
ctx.moveTo(gx, surfaceY - 2);
ctx.lineTo(gx - 1, surfaceY - 7 - Math.random()*3);
ctx.stroke();
}
}
}
}
}
}
renderWater(ctx) {
const terrain = this.terrain;
const waterLevel = terrain.waterLevel;
// Water body
const bounds = this.camera.getVisibleBounds();
const grad = ctx.createLinearGradient(0, waterLevel, 0, CONFIG.WORLD_HEIGHT);
grad.addColorStop(0, 'rgba(30, 100, 200, 0.6)');
grad.addColorStop(0.3, 'rgba(20, 80, 180, 0.7)');
grad.addColorStop(1, 'rgba(10, 40, 100, 0.85)');
ctx.fillStyle = grad;
ctx.fillRect(bounds.left - 100, waterLevel, bounds.right - bounds.left + 200, CONFIG.WORLD_HEIGHT - waterLevel);
// Water surface waves
const t = Date.now() * 0.002;
ctx.strokeStyle = 'rgba(150, 200, 255, 0.6)';
ctx.lineWidth = 2;
for (let wave = 0; wave < 3; wave++) {
ctx.beginPath();
const yOff = waterLevel + wave * 8 - 4;
for (let x = bounds.left; x <= bounds.right; x += 5) {
const y = yOff + Math.sin(x * 0.03 + t + wave) * 3;
if (x === bounds.left) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
// Water bubbles
for (let i = 0; i < 15; i++) {
const bx = ((i * 173 + t * 30) % CONFIG.WORLD_WIDTH);
const by = waterLevel + 20 + Math.sin(i * 2.7 + t * 0.5) * (CONFIG.WORLD_HEIGHT - waterLevel - 40);
const size = 2 + Math.sin(t + i) * 1;
ctx.fillStyle = `rgba(180, 220, 255, ${0.3 + Math.sin(t+i)*0.1})`;
ctx.beginPath();
ctx.arc(bx, by, size, 0, Math.PI * 2);
ctx.fill();
}
}
renderTrajectory(ctx) {
const worm = this.activeWorm;
const w = this.selectedWeapon;
if (!worm || !w || w.type === 'direct') return;
const startX = worm.x;
const startY = worm.y - 20;
const speed = w.speed * (this.aimPower / 50);
let vx = Math.cos(this.aimAngle) * speed;
let vy = Math.sin(this.aimAngle) * speed;
ctx.save();
ctx.setLineDash([4, 6]);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(startX, startY);
const dt = 0.03;
let px = startX, py = startY;
for (let i = 0; i < 200; i++) {
vx += this.wind * 20 * dt;
vy += CONFIG.GRAVITY * dt;
px += vx * dt;
py += vy * dt;
if (py > CONFIG.WORLD_HEIGHT || px < 0 || px > CONFIG.WORLD_WIDTH) break;
// Check terrain collision for trajectory
if (this.terrain.isSolid(px, py)) {
ctx.lineTo(px, py);
break;
}
if (i % 3 === 0) {
ctx.lineTo(px, py);
// Draw dot at intervals
if (i % 15 === 0) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.fillRect(px - 1, py - 1, 3, 3);
}
}
}
ctx.stroke();
// Draw impact indicator
if (px >= 0 && px <= CONFIG.WORLD_WIDTH && py <= CONFIG.WORLD_HEIGHT) {
const r = w.radius;
ctx.strokeStyle = 'rgba(255, 100, 50, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(px, py, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
// Power indicator
const powerPct = this.aimPower / 100;
ctx.fillStyle = `rgba(255, ${Math.floor(255*(1-powerPct))}, 0, 0.8)`;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.fillText(`Power: ${this.aimPower.toFixed(0)}%`, worm.x, worm.y - 60);
// Angle indicator
const angleDeg = ((this.aimAngle * 180 / Math.PI) % 360 + 360) % 360;
ctx.fillText(`Angle: ${angleDeg.toFixed(0)}°`, worm.x, worm.y - 75);
// Scroll hint
ctx.font = '11px Arial';
ctx.fillStyle = '#aaa';
ctx.fillText('Scroll to adjust power • Click to fire', worm.x, worm.y - 92);
}
renderTimerBar(ctx, cw, ch) {
if (!this.activeWorm) return;
const timerPct = this.turnTimer / CONFIG.TURN_TIME;
const barWidth = Math.min(300, cw * 0.25);
const barHeight = 8;
const x = (cw - barWidth) / 2;
const y = ch - 70;
// Background
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(x - 1, y - 1, barWidth + 2, barHeight + 2);
// Timer fill with color based on urgency
let fillColor;
if (timerPct > 0.5) fillColor = '#44ff44';
else if (timerPct > 0.25) fillColor = '#ffaa00';
else fillColor = '#ff4444';
// Pulsing effect when low
if (timerPct < 0.25) {
const pulse = Math.sin(Date.now() * 0.01) * 0.3 + 0.7;
ctx.globalAlpha = pulse;
}
ctx.fillStyle = fillColor;
ctx.fillRect(x, y, barWidth * timerPct, barHeight);
ctx.globalAlpha = 1;
// Timer text
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
const timeText = `${Math.ceil(this.turnTimer)}s`;
ctx.strokeText(timeText, cw/2, y - 8);
ctx.fillText(timeText, cw/2, y - 8);
// Move time remaining indicator
if (this.activeWorm) {
const movePct = this.activeWorm.moveTime / CONFIG.TURN_TIME;
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(x, y + barHeight + 4, barWidth, 4);
ctx.fillStyle = '#88aaff';
ctx.fillRect(x, y + barHeight + 4, barWidth * movePct, 4);
}
}
renderWindArrow(ctx, cw, ch) {
const x = 60;
const y = 35;
// Wind arrow on canvas (in addition to HUD)
ctx.save();
ctx.translate(x, y);
const windStrength = Math.abs(this.wind) / 200;
const dir = this.wind > 0 ? 1 : -1;
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath();
ctx.arc(0, 0, 25, 0, Math.PI * 2);
ctx.fill();
// Arrow
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
const arrowLen = 15 + windStrength * 10;
ctx.beginPath();
ctx.moveTo(-dir * arrowLen, 0);
ctx.lineTo(dir * arrowLen, 0);
ctx.stroke();
// Arrowhead
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.moveTo(dir * (arrowLen + 5), 0);
ctx.lineTo(dir * (arrowLen - 3), -6);
ctx.lineTo(dir * (arrowLen - 3), 6);
ctx.closePath();
ctx.fill();
// Wind label
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#aaa';
ctx.fillText('WIND', 0, 20);
ctx.restore();
}
renderMoveTime(ctx, cw, ch) {
if (!this.activeWorm) return;
const x = cw - 150;
const y = 30;
ctx.font = 'bold 12px Arial';
ctx.textAlign = 'right';
ctx.fillStyle = '#aaa';
ctx.fillText('Move Time:', x - 60, y);
// Move time bar
const movePct = this.activeWorm.moveTime / (CONFIG.TURN_TIME * 0.4);
const barW = 120;
const barH = 8;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(x, y - 6, barW, barH);
ctx.fillStyle = movePct > 0.3 ? '#44aaff' : '#ff4444';
ctx.fillRect(x, y - 6, barW * Math.max(0, movePct), barH);
ctx.font = 'bold 11px Arial';
ctx.fillStyle = '#fff';
ctx.fillText(`${this.activeWorm.moveTime.toFixed(1)}s`, x + barW + 40, y);
// Items count
const itemCount = this.activeWorm.items.length;
if (itemCount > 0) {
ctx.font = 'bold 12px Arial';
ctx.textAlign = 'right';
ctx.fillStyle = '#ffaa44';
ctx.fillText(`Items: ${itemCount}`, x + barW, y + 20);
}
// Team status (alive worms)
const team = this.teams[this.activeTeamIndex];
if (team) {
let aliveStr = '';
for (const u of team.units) {
aliveStr += u.alive ? '●' : '○';
}
ctx.font = '14px Arial';
ctx.textAlign = 'right';
ctx.fillStyle = team.color.primary;
ctx.fillText(aliveStr, x + barW, y + 38);
}
// Controls hint at bottom
if (this.isAiming) {
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#aaa';
ctx.fillText('Click to fire • Scroll for power • ESC to cancel', cw/2, ch - 30);
} else if (!this.showingWeapons && !this.showingItems) {
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#666';
ctx.fillText('← → Move • Space Jump • 1-8 Weapons • ATTACK/ITEMS buttons', cw/2, ch - 30);
}
}
gameLoop(timestamp) {
const dt = Math.min(0.05, (timestamp - this.lastTime) / 1000);
this.lastTime = timestamp;
this.update(dt);
this.render();
requestAnimationFrame(t => this.gameLoop(t));
}
}
// ── main ──
// Polyfill roundRect if needed
if (!CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
if (typeof r === 'number') r = [r];
const radii = r.map(v => Math.min(v, w/2, h/2));
const rad = radii[0] || 0;
this.moveTo(x + rad, y);
this.arcTo(x + w, y, x + w, y + h, rad);
this.arcTo(x + w, y + h, x, y + h, rad);
this.arcTo(x, y + h, x, y, rad);
this.arcTo(x, y, x + w, y, rad);
this.closePath();
};
}
// Initialize game
game = new ArtilleryGame();
game.init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"198039e7-77ca-45ca-b91f-33a6d08d9b44","tokensIn":2343794,"tokensOut":74575,"tokensTotal":2418369,"turns":42,"toolCalls":41,"failedToolCalls":1,"timestamp":"2026-07-16T02:49:05.233Z"} -->