Worms
seed 2.0 code · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 121,560 bytes · SHA-256 3197284e04ac
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WORMS: Tactical Chaos</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
color: white;
}
#gameContainer {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#gameCanvas {
border: 3px solid #e94560;
border-radius: 8px;
box-shadow: 0 0 30px rgba(233, 69, 96, 0.5), inset 0 0 60px rgba(0,0,0,0.3);
}
/* Menu Styles */
.menu-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, #1a1a2e 0%, #0f3460 100%);
z-index: 100;
}
.menu-screen.hidden {
display: none;
}
.game-title {
font-size: 72px;
font-weight: 900;
background: linear-gradient(45deg, #e94560, #ff6b6b, #feca57, #48dbfb, #ff9ff3);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 40px rgba(233, 69, 96, 0.5);
margin-bottom: 10px;
animation: titlePulse 2s ease-in-out infinite;
}
.game-subtitle {
font-size: 24px;
color: #48dbfb;
margin-bottom: 40px;
letter-spacing: 8px;
}
@keyframes titlePulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
.menu-btn {
padding: 15px 50px;
font-size: 20px;
font-weight: bold;
border: none;
border-radius: 30px;
cursor: pointer;
margin: 10px;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 2px;
}
.btn-primary {
background: linear-gradient(45deg, #e94560, #ff6b6b);
color: white;
box-shadow: 0 5px 20px rgba(233, 69, 96, 0.4);
}
.btn-primary:hover {
transform: translateY(-3px) scale(1.05);
box-shadow: 0 8px 30px rgba(233, 69, 96, 0.6);
}
.btn-secondary {
background: linear-gradient(45deg, #48dbfb, #0abde3);
color: white;
box-shadow: 0 5px 20px rgba(72, 219, 251, 0.4);
}
.btn-secondary:hover {
transform: translateY(-3px) scale(1.05);
box-shadow: 0 8px 30px rgba(72, 219, 251, 0.6);
}
/* Settings Panel */
.settings-panel {
background: rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
margin: 20px;
max-width: 500px;
}
.setting-group {
margin-bottom: 20px;
}
.setting-group label {
display: block;
margin-bottom: 8px;
font-size: 16px;
color: #feca57;
}
.setting-group select, .setting-group input {
width: 100%;
padding: 12px;
border: none;
border-radius: 10px;
background: rgba(255,255,255,0.2);
color: white;
font-size: 16px;
cursor: pointer;
}
.setting-group select option {
background: #1a1a2e;
}
/* HUD Styles */
#hud {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 50;
}
#hud > * {
pointer-events: auto;
}
.team-indicator {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.7);
padding: 15px 40px;
border-radius: 30px;
border: 2px solid;
font-size: 24px;
font-weight: bold;
backdrop-filter: blur(5px);
}
.timer-display {
position: absolute;
top: 20px;
right: 30px;
background: rgba(0,0,0,0.7);
padding: 15px 25px;
border-radius: 15px;
font-size: 28px;
font-weight: bold;
color: #feca57;
backdrop-filter: blur(5px);
min-width: 100px;
text-align: center;
}
.timer-display.urgent {
color: #e94560;
animation: timerPulse 0.5s ease-in-out infinite;
}
@keyframes timerPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.health-bar-container {
position: absolute;
top: 20px;
left: 30px;
background: rgba(0,0,0,0.7);
padding: 15px;
border-radius: 15px;
backdrop-filter: blur(5px);
}
.worm-name {
font-size: 14px;
margin-bottom: 5px;
}
.health-bar {
width: 200px;
height: 20px;
background: #333;
border-radius: 10px;
overflow: hidden;
}
.health-fill {
height: 100%;
background: linear-gradient(90deg, #e94560, #ff6b6b);
transition: width 0.3s ease;
border-radius: 10px;
}
.health-fill.high { background: linear-gradient(90deg, #1dd1a1, #10ac84); }
.health-fill.medium { background: linear-gradient(90deg, #feca57, #ff9f43); }
.health-fill.low { background: linear-gradient(90deg, #e94560, #ee5a24); }
/* Weapon Panel */
.weapon-panel {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
padding: 15px;
border-radius: 20px;
backdrop-filter: blur(10px);
display: flex;
gap: 10px;
}
.weapon-slot {
width: 60px;
height: 60px;
background: rgba(255,255,255,0.1);
border: 2px solid transparent;
border-radius: 12px;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: all 0.2s ease;
position: relative;
}
.weapon-slot:hover {
background: rgba(255,255,255,0.2);
transform: translateY(-5px);
}
.weapon-slot.selected {
border-color: #feca57;
box-shadow: 0 0 20px rgba(254, 202, 87, 0.5);
transform: translateY(-5px);
}
.weapon-icon {
font-size: 24px;
}
.weapon-key {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 10px;
color: #888;
}
.weapon-name {
position: absolute;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
padding: 8px 15px;
border-radius: 8px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.weapon-slot:hover .weapon-name {
opacity: 1;
}
/* Power Indicator */
.power-indicator {
position: absolute;
bottom: 110px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
padding: 10px 20px;
border-radius: 10px;
backdrop-filter: blur(5px);
}
.power-bar {
width: 200px;
height: 15px;
background: #333;
border-radius: 8px;
overflow: hidden;
}
.power-fill {
height: 100%;
background: linear-gradient(90deg, #48dbfb, #feca57, #e94560);
width: 0%;
transition: width 0.05s linear;
}
/* Wind Indicator */
.wind-indicator {
position: absolute;
top: 90px;
right: 30px;
background: rgba(0,0,0,0.7);
padding: 10px 15px;
border-radius: 10px;
backdrop-filter: blur(5px);
display: flex;
align-items: center;
gap: 10px;
}
.wind-arrow {
font-size: 24px;
transition: transform 0.3s ease;
}
/* Teams Panel */
.teams-panel {
position: absolute;
top: 90px;
left: 30px;
background: rgba(0,0,0,0.7);
padding: 10px;
border-radius: 10px;
backdrop-filter: blur(5px);
}
.team-entry {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 10px;
margin: 5px 0;
border-radius: 5px;
font-size: 14px;
}
.team-entry.active {
background: rgba(255,255,255,0.2);
}
.team-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
/* Aim Info */
.aim-info {
position: absolute;
bottom: 110px;
right: 30px;
background: rgba(0,0,0,0.7);
padding: 10px 15px;
border-radius: 10px;
backdrop-filter: blur(5px);
font-size: 14px;
}
/* Floating Text */
.floating-text {
position: absolute;
font-weight: bold;
font-size: 20px;
pointer-events: none;
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
animation: floatUp 1.5s ease-out forwards;
z-index: 1000;
}
@keyframes floatUp {
0% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-60px) scale(1.5); }
}
/* Game Over Screen */
.game-over-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 200;
}
.game-over-screen.hidden {
display: none;
}
.winner-text {
font-size: 64px;
font-weight: 900;
margin-bottom: 20px;
animation: winnerPulse 1s ease-in-out infinite;
}
@keyframes winnerPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
/* Controls Help */
.controls-help {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0,0,0,0.7);
padding: 10px 15px;
border-radius: 10px;
font-size: 12px;
color: #aaa;
backdrop-filter: blur(5px);
}
/* Pause Menu */
.pause-menu {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 150;
}
.pause-menu.hidden {
display: none;
}
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas"></canvas>
<!-- Main Menu -->
<div id="mainMenu" class="menu-screen">
<h1 class="game-title">WORMS</h1>
<p class="game-subtitle">TACTICAL CHAOS</p>
<div class="settings-panel" id="gameSettings">
<div class="setting-group">
<label>Terrain Type:</label>
<select id="terrainType">
<option value="single">Single Island</option>
<option value="two">Two Islands</option>
<option value="cave">Cave</option>
</select>
</div>
<div class="setting-group">
<label>Number of Teams:</label>
<select id="teamCount">
<option value="2">2 Teams</option>
<option value="3">3 Teams</option>
<option value="4">4 Teams</option>
</select>
</div>
<div class="setting-group">
<label>CPU Players:</label>
<select id="cpuCount">
<option value="0">None - All Human</option>
<option value="1">1 CPU</option>
<option value="2">2 CPU</option>
<option value="3">3 CPU</option>
</select>
</div>
<div class="setting-group">
<label>Round Time (seconds):</label>
<select id="roundTime">
<option value="30">30</option>
<option value="45" selected>45</option>
<option value="60">60</option>
</select>
</div>
</div>
<button class="menu-btn btn-primary" id="startBtn">⚔️ START BATTLE</button>
<button class="menu-btn btn-secondary" id="howToBtn">❓ HOW TO PLAY</button>
</div>
<!-- How To Play -->
<div id="howToMenu" class="menu-screen hidden">
<h2 style="font-size: 36px; color: #48dbfb; margin-bottom: 30px;">How to Play</h2>
<div class="settings-panel" style="max-width: 600px; text-align: left;">
<p style="margin-bottom: 15px;"><strong style="color: #feca57;">Movement:</strong> ← → Arrow keys to move, ↑ to jump</p>
<p style="margin-bottom: 15px;"><strong style="color: #feca57;">Aim:</strong> Mouse to aim angle</p>
<p style="margin-bottom: 15px;"><strong style="color: #feca57;">Fire:</strong> Hold SPACE to charge power, release to fire</p>
<p style="margin-bottom: 15px;"><strong style="color: #feca57;">Weapons:</strong> 1-8 keys to select weapon</p>
<p style="margin-bottom: 15px;"><strong style="color: #feca57;">Utilities:</strong> Press corresponding key for utilities</p>
<p style="margin-bottom: 15px;"><strong style="color: #feca57;">Pause:</strong> ESC key</p>
<p style="margin-bottom: 15px;"><strong style="color: #feca57;">Skip Turn:</strong> Enter key</p>
<hr style="border-color: #444; margin: 20px 0;">
<p style="color: #aaa; font-size: 14px;">💡 Wind affects projectile trajectories. Watch the wind indicator!</p>
<p style="color: #aaa; font-size: 14px;">💡 Falling into water causes instant drowning!</p>
<p style="color: #aaa; font-size: 14px;">💡 Falling from great heights causes fall damage!</p>
</div>
<button class="menu-btn btn-secondary" id="backBtn">← BACK</button>
</div>
<!-- HUD -->
<div id="hud" class="hidden">
<div class="team-indicator" id="teamIndicator">Team 1's Turn</div>
<div class="timer-display" id="timerDisplay">45</div>
<div class="health-bar-container">
<div class="worm-name" id="wormName">Worm 1</div>
<div class="health-bar">
<div class="health-fill" id="healthFill" style="width: 100%;"></div>
</div>
</div>
<div class="wind-indicator">
<span>Wind:</span>
<span class="wind-arrow" id="windArrow">➡️</span>
<span id="windStrength">0</span>
</div>
<div class="teams-panel" id="teamsPanel"></div>
<div class="aim-info" id="aimInfo">Angle: 45° | Power: 0%</div>
<div class="power-indicator" id="powerIndicator">
<div style="text-align: center; margin-bottom: 5px; font-size: 12px;">POWER</div>
<div class="power-bar">
<div class="power-fill" id="powerFill"></div>
</div>
</div>
<div class="weapon-panel" id="weaponPanel"></div>
<div class="controls-help">
← → Move | ↑ Jump | SPACE Fire | 1-8 Weapons | Enter Skip | ESC Pause
</div>
</div>
<!-- Pause Menu -->
<div id="pauseMenu" class="pause-menu hidden">
<h2 style="font-size: 48px; color: #feca57; margin-bottom: 40px;">PAUSED</h2>
<button class="menu-btn btn-primary" id="resumeBtn">▶️ RESUME</button>
<button class="menu-btn btn-secondary" id="quitBtn">🏠 QUIT TO MENU</button>
</div>
<!-- Game Over Screen -->
<div id="gameOverScreen" class="game-over-screen hidden">
<h1 class="winner-text" id="winnerText">TEAM 1 WINS!</h1>
<p style="font-size: 24px; color: #aaa; margin-bottom: 40px;" id="victoryMessage">Victory!</p>
<button class="menu-btn btn-primary" id="playAgainBtn">🔄 PLAY AGAIN</button>
<button class="menu-btn btn-secondary" id="menuBtn">🏠 MAIN MENU</button>
</div>
</div>
<script>
// =============================================================
// WORMS: TACTICAL CHAOS - Main Game Engine
// =============================================================
// Canvas Setup
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Set canvas size
function resizeCanvas() {
canvas.width = Math.min(window.innerWidth - 40, 1200);
canvas.height = Math.min(window.innerHeight - 40, 700);
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// =============================================================
// AUDIO SYSTEM - Sound Effects
// =============================================================
class AudioSystem {
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.log('Audio not supported');
}
}
playTone(freq, duration, type = 'sine', volume = 0.3) {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.frequency.value = freq;
osc.type = type;
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
playExplosion(size = 1) {
this.init();
// Low frequency rumble
this.playTone(80 * size, 0.3, 'sawtooth', 0.4 * Math.min(size, 1));
this.playTone(50 * size, 0.5, 'square', 0.2 * Math.min(size, 1));
// Noise burst
setTimeout(() => this.playTone(200, 0.1, 'sawtooth', 0.2), 50);
}
playShoot() {
this.init();
this.playTone(600, 0.1, 'square', 0.15);
this.playTone(400, 0.15, 'sawtooth', 0.1);
}
playJump() {
this.init();
this.playTone(300, 0.1, 'sine', 0.2);
setTimeout(() => this.playTone(500, 0.1, 'sine', 0.15), 50);
}
playStep() {
this.init();
this.playTone(100 + Math.random() * 50, 0.05, 'square', 0.1);
}
playHit() {
this.init();
this.playTone(150, 0.2, 'sawtooth', 0.3);
}
playSplash() {
this.init();
this.playTone(300, 0.2, 'sine', 0.2);
this.playTone(200, 0.3, 'sine', 0.15);
}
playDing() {
this.init();
this.playTone(800, 0.15, 'sine', 0.2);
}
playPop() {
this.init();
this.playTone(400, 0.08, 'sine', 0.15);
}
playSelect() {
this.init();
this.playTone(500, 0.05, 'sine', 0.1);
}
playClick() {
this.init();
this.playTone(600, 0.05, 'sine', 0.15);
}
playTimerTick(pitch = 440) {
this.init();
this.playTone(pitch, 0.05, 'sine', 0.1);
}
playGameOver() {
this.init();
[523, 659, 784, 1047].forEach((f, i) => {
setTimeout(() => this.playTone(f, 0.3, 'sine', 0.3), i * 200);
});
}
playVictory() {
this.init();
const notes = [523, 659, 784, 659, 784, 1047];
notes.forEach((f, i) => {
setTimeout(() => this.playTone(f, 0.2, 'sine', 0.25), i * 150);
});
}
}
const audio = new AudioSystem();
// =============================================================
// GAME CONSTANTS & SETTINGS
// =============================================================
const GRAVITY = 0.15;
const WORM_WIDTH = 20;
const WORM_HEIGHT = 24;
const MOVE_SPEED = 2;
const JUMP_FORCE = -5;
const MAX_POWER = 15;
const WATER_LEVEL_OFFSET = 50;
const FALL_DAMAGE_THRESHOLD = 100;
const FALL_DAMAGE_MULTIPLIER = 0.5;
// Team colors
const TEAM_COLORS = [
{ primary: '#e94560', secondary: '#ff6b6b', name: 'Red Team' },
{ primary: '#48dbfb', secondary: '#0abde3', name: 'Blue Team' },
{ primary: '#1dd1a1', secondary: '#10ac84', name: 'Green Team' },
{ primary: '#feca57', secondary: '#ff9f43', name: 'Gold Team' }
];
// Weapon definitions
const WEAPONS = [
{
id: 'bazooka',
name: 'Bazooka',
icon: '🚀',
category: 'indirect',
damage: 50,
radius: 40,
projectileSpeed: 1,
affectedByWind: true,
key: '1'
},
{
id: 'grenade',
name: 'Grenade',
icon: '💣',
category: 'indirect',
damage: 45,
radius: 35,
projectileSpeed: 0.8,
affectedByWind: true,
bounces: true,
fuseTime: 3000,
key: '2'
},
{
id: 'shotgun',
name: 'Shotgun',
icon: '🔫',
category: 'direct',
damage: 30,
pellets: 5,
spread: 0.15,
instant: true,
range: 400,
key: '3'
},
{
id: 'dynamite',
name: 'Dynamite',
icon: '🧨',
category: 'high_aoe',
damage: 70,
radius: 60,
placeable: true,
fuseTime: 3000,
key: '4'
},
{
id: 'cluster',
name: 'Cluster Bomb',
icon: '🎯',
category: 'high_aoe',
damage: 25,
radius: 25,
projectileSpeed: 0.9,
affectedByWind: true,
clusters: 5,
clusterDelay: 1000,
key: '5'
},
{
id: 'mortar',
name: 'Mortar',
icon: '💥',
category: 'indirect',
damage: 40,
radius: 45,
projectileSpeed: 0.7,
affectedByWind: true,
key: '6'
}
];
// Utility items
const UTILITIES = [
{
id: 'ninja_rope',
name: 'Ninja Rope',
icon: '🪢',
key: '7'
},
{
id: 'parachute',
name: 'Parachute',
icon: '🪂',
key: '8'
},
{
id: 'teleport',
name: 'Teleport',
icon: '✨'
},
{
id: 'first_aid',
name: 'First Aid',
icon: '💊',
healAmount: 30
}
];
// =============================================================
// GAME STATE
// =============================================================
let gameState = {
phase: 'menu', // menu, playing, paused, gameover
teams: [],
currentTeamIndex: 0,
currentWormIndex: 0,
turnTime: 45,
turnTimeRemaining: 45,
roundTimeSetting: 45,
wind: { x: 0, y: 0 },
terrainType: 'single',
projectiles: [],
particles: [],
floatingTexts: [],
debris: [],
activeProjectile: null,
selectedWeapon: WEAPONS[0],
isCharging: false,
chargePower: 0,
chargeDirection: 1,
aimAngle: -Math.PI / 4,
aimDirection: 1,
camera: { x: 0, y: 0, targetX: 0, targetY: 0, zoom: 1, targetZoom: 1 },
screenShake: { x: 0, y: 0, intensity: 0 },
impactFreeze: { active: false, duration: 0, maxDuration: 0 },
lastTick: 0,
cpuPlayers: [],
cpuThinking: false
};
// Terrain data
let terrainCanvas, terrainCtx;
let terrainData = null;
// Input state
const keys = {};
let mouseX = 0, mouseY = 0;
let mouseDown = false;
// =============================================================
// TERRAIN GENERATION
// =============================================================
function initTerrain() {
terrainCanvas = document.createElement('canvas');
terrainCanvas.width = canvas.width;
terrainCanvas.height = canvas.height;
terrainCtx = terrainCanvas.getContext('2d');
generateTerrain();
}
function generateTerrain() {
const w = terrainCanvas.width;
const h = terrainCanvas.height;
// Clear to transparent - only actual terrain will be visible to collision
terrainCtx.clearRect(0, 0, w, h);
// Generate based on type
switch(gameState.terrainType) {
case 'single':
generateSingleIsland(w, h);
break;
case 'two':
generateTwoIslands(w, h);
break;
case 'cave':
generateCave(w, h);
break;
}
// Water
const gradient = terrainCtx.createLinearGradient(0, h - WATER_LEVEL_OFFSET, 0, h);
gradient.addColorStop(0, 'rgba(72, 219, 251, 0.5)');
gradient.addColorStop(1, 'rgba(10, 189, 227, 0.8)');
terrainCtx.fillStyle = gradient;
terrainCtx.fillRect(0, h - WATER_LEVEL_OFFSET, w, WATER_LEVEL_OFFSET);
// Update terrain data
updateTerrainData();
}
function generateSingleIsland(w, h) {
// Generate a central island using midpoint displacement
const groundY = h - 150;
const points = [];
const segments = 60;
// Initialize with random height variation
let heights = [];
for (let i = 0; i <= segments; i++) {
heights[i] = 0;
}
// Midpoint displacement for terrain
let range = 80;
for (let level = 1; level <= 6; level++) {
const step = Math.floor(segments / Math.pow(2, level));
if (step < 1) break;
for (let i = step; i < segments; i += step * 2) {
const left = heights[i - step] || 0;
const right = heights[i + step] || 0;
heights[i] = (left + right) / 2 + (Math.random() - 0.5) * range;
}
range *= 0.6;
}
// Create plateau in center
const centerX = segments / 2;
const plateauWidth = segments * 0.3;
for (let i = 0; i <= segments; i++) {
const dist = Math.abs(i - centerX);
if (dist < plateauWidth) {
heights[i] *= 0.2;
} else if (dist < plateauWidth * 1.5) {
const t = (dist - plateauWidth) / (plateauWidth * 0.5);
heights[i] = heights[i] * t + (heights[i] * 0.2) * (1 - t);
}
}
// Build points array
for (let i = 0; i <= segments; i++) {
points.push({
x: (i / segments) * w,
y: groundY + heights[i]
});
}
// Draw terrain
const grd = terrainCtx.createLinearGradient(0, 0, 0, h);
grd.addColorStop(0, '#10ac84');
grd.addColorStop(0.3, '#1dd1a1');
grd.addColorStop(0.5, '#27ae60');
grd.addColorStop(1, '#1e3a5f');
terrainCtx.beginPath();
terrainCtx.moveTo(0, h);
terrainCtx.lineTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
terrainCtx.lineTo(points[i].x, points[i].y);
}
terrainCtx.lineTo(w, h);
terrainCtx.closePath();
terrainCtx.fillStyle = grd;
terrainCtx.fill();
// Add some texture
addTerrainTexture();
}
function generateTwoIslands(w, h) {
const groundY = h - 150;
const segments = 30;
// Left island
let heights1 = generateTerrainHeights(segments, 60);
// Create plateau
for (let i = 5; i < segments - 5; i++) {
if (i > segments * 0.3 && i < segments * 0.7) {
heights1[i] *= 0.15;
}
}
// Right island
let heights2 = generateTerrainHeights(segments, 60);
for (let i = 5; i < segments - 5; i++) {
if (i > segments * 0.3 && i < segments * 0.7) {
heights2[i] *= 0.15;
}
}
const grd = terrainCtx.createLinearGradient(0, 0, 0, h);
grd.addColorStop(0, '#10ac84');
grd.addColorStop(0.3, '#1dd1a1');
grd.addColorStop(0.5, '#27ae60');
grd.addColorStop(1, '#1e3a5f');
// Draw left island (left 40%)
terrainCtx.beginPath();
const leftWidth = w * 0.4;
terrainCtx.moveTo(-20, h);
for (let i = 0; i <= segments; i++) {
const x = (i / segments) * leftWidth;
const y = groundY + heights1[i];
terrainCtx.lineTo(x, y);
}
terrainCtx.lineTo(leftWidth, h);
terrainCtx.closePath();
terrainCtx.fillStyle = grd;
terrainCtx.fill();
// Draw right island (right 40%)
terrainCtx.beginPath();
const rightStart = w * 0.6;
terrainCtx.moveTo(rightStart, h);
for (let i = 0; i <= segments; i++) {
const x = rightStart + (i / segments) * (w - rightStart);
const y = groundY + heights2[i];
terrainCtx.lineTo(x, y);
}
terrainCtx.lineTo(w + 20, h);
terrainCtx.closePath();
terrainCtx.fillStyle = grd;
terrainCtx.fill();
addTerrainTexture();
}
function generateCave(w, h) {
// Generate ceiling and floor
const groundY = h - 120;
const ceilY = 80;
const segments = 60;
// Floor heights
const floorHeights = generateTerrainHeights(segments, 40);
// Ceiling heights (inverted)
const ceilHeights = generateTerrainHeights(segments, 40);
// Create a large flat area in center
for (let i = segments * 0.2; i < segments * 0.4; i++) floorHeights[i] *= 0.2;
for (let i = segments * 0.6; i < segments * 0.8; i++) floorHeights[i] *= 0.2;
for (let i = segments * 0.2; i < segments * 0.8; i++) ceilHeights[i] = Math.max(0, ceilHeights[i] - 20);
const grd = terrainCtx.createLinearGradient(0, 0, 0, h);
grd.addColorStop(0, '#5f27cd');
grd.addColorStop(0.5, '#341f97');
grd.addColorStop(1, '#1e3a5f');
// Draw cave walls (left and right)
terrainCtx.fillStyle = grd;
terrainCtx.fillRect(0, 0, 30, h);
terrainCtx.fillRect(w - 30, 0, 30, h);
// Draw ceiling
terrainCtx.beginPath();
terrainCtx.moveTo(30, 0);
for (let i = 0; i <= segments; i++) {
const x = 30 + (i / segments) * (w - 60);
const y = ceilY + ceilHeights[i];
terrainCtx.lineTo(x, y);
}
terrainCtx.lineTo(w - 30, 0);
terrainCtx.closePath();
terrainCtx.fill();
// Draw floor
terrainCtx.beginPath();
terrainCtx.moveTo(30, h);
for (let i = 0; i <= segments; i++) {
const x = 30 + (i / segments) * (w - 60);
const y = groundY + floorHeights[i];
terrainCtx.lineTo(x, y);
}
terrainCtx.lineTo(w - 30, h);
terrainCtx.closePath();
terrainCtx.fill();
// Add stalactites to ceiling
for (let i = 0; i < 10; i++) {
const sx = 50 + Math.random() * (w - 100);
const sy = ceilY + 20 + Math.random() * 40;
const sw = 8 + Math.random() * 12;
const sh = 15 + Math.random() * 25;
terrainCtx.beginPath();
terrainCtx.moveTo(sx - sw/2, sy);
terrainCtx.lineTo(sx, sy + sh);
terrainCtx.lineTo(sx + sw/2, sy);
terrainCtx.closePath();
terrainCtx.fillStyle = '#636e72';
terrainCtx.fill();
}
addTerrainTexture();
}
function generateTerrainHeights(segments, range) {
const heights = [];
for (let i = 0; i <= segments; i++) heights[i] = 0;
let currentRange = range;
for (let level = 1; level <= 6; level++) {
const step = Math.floor(segments / Math.pow(2, level));
if (step < 1) break;
for (let i = step; i < segments; i += step * 2) {
const left = heights[Math.max(0, i - step)];
const right = heights[Math.min(segments, i + step)];
heights[i] = (left + right) / 2 + (Math.random() - 0.5) * currentRange;
}
currentRange *= 0.6;
}
return heights;
}
function addTerrainTexture() {
// Add some noise/texture to terrain
const w = terrainCanvas.width;
const h = terrainCanvas.height;
terrainCtx.globalAlpha = 0.3;
for (let i = 0; i < 500; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
// Only draw if pixel is part of terrain
if (isTerrainPixel(Math.floor(x), Math.floor(y))) {
terrainCtx.fillStyle = `rgba(255,255,255,${Math.random() * 0.1})`;
terrainCtx.fillRect(x, y, 2, 2);
}
}
terrainCtx.globalAlpha = 1;
}
function updateTerrainData() {
const w = terrainCanvas.width;
const h = terrainCanvas.height;
const imgData = terrainCtx.getImageData(0, 0, w, h);
terrainData = {
width: w,
height: h,
data: new Uint8Array(imgData.data)
};
}
function isTerrainPixel(x, y) {
if (!terrainData) return false;
if (x < 0 || x >= terrainData.width || y < 0 || y >= terrainData.height) return false;
// Don't count water as terrain
if (y >= canvas.height - WATER_LEVEL_OFFSET) return false;
const idx = (Math.floor(y) * terrainData.width + Math.floor(x)) * 4;
// Check alpha channel
return terrainData.data[idx + 3] > 50;
}
function isWaterLevel(y) {
return y >= canvas.height - WATER_LEVEL_OFFSET;
}
// =============================================================
// TERRAIN DESTRUCTION
// =============================================================
function destroyTerrain(cx, cy, radius) {
// Create a circular hole
terrainCtx.globalCompositeOperation = 'destination-out';
terrainCtx.beginPath();
terrainCtx.arc(cx, cy, radius, 0, Math.PI * 2);
terrainCtx.fill();
terrainCtx.globalCompositeOperation = 'source-over';
// Create debris particles
const debrisCount = Math.floor(radius / 3);
for (let i = 0; i < debrisCount; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * radius;
const x = cx + Math.cos(angle) * dist;
const y = cy + Math.sin(angle) * dist;
if (isTerrainPixel(Math.floor(x), Math.floor(y))) {
// Check pixel color for debris color
const idx = (Math.floor(y) * terrainData.width + Math.floor(x)) * 4;
const r = terrainData.data[idx];
const g = terrainData.data[idx + 1];
const b = terrainData.data[idx + 2];
gameState.debris.push({
x: x,
y: y,
vx: (Math.random() - 0.5) * 5 + (x - cx) * 0.05,
vy: (Math.random() - 0.5) * 5 - 2,
size: 2 + Math.random() * 4,
color: `rgb(${r},${g},${b})`,
life: 180 + Math.random() * 120,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.2
});
}
}
updateTerrainData();
}
// =============================================================
// TEAM & WORM MANAGEMENT
// =============================================================
class Worm {
constructor(x, y, teamIndex, wormIndex) {
this.x = x;
this.y = y;
this.vx = 0;
this.vy = 0;
this.teamIndex = teamIndex;
this.wormIndex = wormIndex;
this.health = 100;
this.maxHealth = 100;
this.alive = true;
this.onGround = false;
this.facing = 1; // 1 right, -1 left
this.name = `Worm ${wormIndex + 1}`;
this.invincible = false;
this.invincibleTimer = 0;
this.parachuteActive = false;
this.lastY = y;
this.fallDistance = 0;
this.animFrame = 0;
this.animTimer = 0;
this.isMoving = false;
}
update(dt) {
if (!this.alive) return;
this.animTimer++;
if (this.animTimer > 10) {
this.animTimer = 0;
this.animFrame = (this.animFrame + 1) % 4;
}
// Invincibility timer
if (this.invincible) {
this.invincibleTimer--;
if (this.invincibleTimer <= 0) {
this.invincible = false;
}
}
// Track fall distance
if (!this.onGround && this.vy > 0) {
this.fallDistance += this.vy;
}
// Apply gravity
if (this.parachuteActive && this.vy > 0) {
this.vy += GRAVITY * 0.1;
this.vy = Math.min(this.vy, 1.5);
} else {
this.vy += GRAVITY;
}
// Wind affects airborne worms slightly
if (!this.onGround) {
this.vx += gameState.wind.x * 0.002;
}
// Friction
if (this.onGround) {
this.vx *= 0.8;
if (Math.abs(this.vx) < 0.1) this.vx = 0;
} else {
this.vx *= 0.99;
}
// Movement
if (keys['ArrowLeft'] || keys['KeyA']) {
if (this.onGround) {
this.vx = -MOVE_SPEED;
this.isMoving = true;
}
this.facing = -1;
} else if (keys['ArrowRight'] || keys['KeyD']) {
if (this.onGround) {
this.vx = MOVE_SPEED;
this.isMoving = true;
}
this.facing = 1;
} else {
this.isMoving = false;
}
// Jump
if ((keys['ArrowUp'] || keys['KeyW'] || keys['Space']) && this.onGround && !gameState.isCharging) {
this.vy = JUMP_FORCE;
this.onGround = false;
this.parachuteActive = false;
audio.playJump();
// Dust particles
createDustParticles(this.x, this.y + WORM_HEIGHT/2, 5);
}
// Update position
const newX = this.x + this.vx;
const newY = this.y + this.vy;
// Collision detection
this.onGround = false;
// Check vertical collision (feet first)
const footY = newY + WORM_HEIGHT / 2;
const leftFootX = newX - WORM_WIDTH / 2 + 3;
const rightFootX = newX + WORM_WIDTH / 2 - 3;
if (isTerrainPixel(leftFootX, footY) || isTerrainPixel(rightFootX, footY)) {
if (this.vy > 0) {
// Landing
this.y = snapToGround(newX, newY) - WORM_HEIGHT / 2;
// Fall damage
if (this.fallDistance > FALL_DAMAGE_THRESHOLD) {
const damage = Math.floor((this.fallDistance - FALL_DAMAGE_THRESHOLD) * FALL_DAMAGE_MULTIPLIER);
this.takeDamage(damage);
createFloatingText(this.x, this.y - 30, `-${damage}`, '#e94560');
audio.playHit();
}
this.fallDistance = 0;
this.vy = 0;
this.onGround = true;
this.parachuteActive = false;
}
} else {
this.y = newY;
}
// Check head collision
const headY = newY - WORM_HEIGHT / 2;
if (isTerrainPixel(newX, headY) && this.vy < 0) {
this.vy = 0;
}
// Horizontal collision
const bodyY = newY;
const leftX = newX - WORM_WIDTH / 2;
const rightX = newX + WORM_WIDTH / 2;
if (isTerrainPixel(leftX, bodyY)) {
this.vx = 0;
} else if (isTerrainPixel(rightX, bodyY)) {
this.vx = 0;
} else {
this.x = newX;
}
// Step sound when moving on ground
if (this.isMoving && this.onGround && Math.random() < 0.1) {
audio.playStep();
if (Math.random() < 0.3) {
createDustParticles(this.x, this.y + WORM_HEIGHT/2, 2);
}
}
// Water check
if (isWaterLevel(this.y + WORM_HEIGHT / 2)) {
this.drown();
}
// Boundary check
if (this.x < WORM_WIDTH/2 + 30) this.x = WORM_WIDTH/2 + 30;
if (this.x > canvas.width - WORM_WIDTH/2 - 30) this.x = canvas.width - WORM_WIDTH/2 - 30;
// If worm goes too high, kill
if (this.y < -50) {
this.takeDamage(50);
}
this.lastY = this.y;
}
takeDamage(amount, fromExplosion = false) {
if (this.invincible && !fromExplosion) return;
this.health -= amount;
if (this.health <= 0) {
this.health = 0;
this.die();
}
// Flash white
this.invincible = true;
this.invincibleTimer = 30;
}
die() {
this.alive = false;
// Death particles
createExplosionParticles(this.x, this.y, TEAM_COLORS[this.teamIndex].primary, 20);
audio.playExplosion(0.5);
createFloatingText(this.x, this.y - 20, 'K.O.!', '#e94560');
}
drown() {
this.alive = false;
audio.playSplash();
createSplashParticles(this.x, canvas.height - WATER_LEVEL_OFFSET);
createFloatingText(this.x, canvas.height - WATER_LEVEL_OFFSET - 30, 'DROWNED!', '#48dbfb');
}
draw(ctx, camX, camY, zoom) {
if (!this.alive) return;
const sx = (this.x - camX) * zoom + canvas.width / 2;
const sy = (this.y - camY) * zoom + canvas.height / 2;
const sw = WORM_WIDTH * zoom;
const sh = WORM_HEIGHT * zoom;
ctx.save();
ctx.translate(sx, sy);
ctx.scale(this.facing * zoom, zoom);
// Flash when invincible
if (this.invincible && Math.floor(this.invincibleTimer / 4) % 2 === 0) {
ctx.globalAlpha = 0.5;
}
const isActive = isCurrentWorm(this);
// Body
ctx.fillStyle = isActive ? '#feca57' : TEAM_COLORS[this.teamIndex].primary;
ctx.beginPath();
ctx.ellipse(0, 2, 10, 12, 0, 0, Math.PI * 2);
ctx.fill();
// Outline
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 2;
ctx.stroke();
// Eyes
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.ellipse(-3, -4, 4, 4, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(5, -4, 4, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Pupils
ctx.fillStyle = '#1a1a2e';
const lookDir = this.facing;
ctx.beginPath();
ctx.arc(-3 + lookDir * 1.5, -4, 2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(5 + lookDir * 1.5, -4, 2, 0, Math.PI * 2);
ctx.fill();
// Mouth
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (isActive && gameState.isCharging) {
// O face when charging
ctx.arc(1, 4, 3, 0, Math.PI * 2);
} else {
// Smile
ctx.arc(1, 2, 4, 0.2, Math.PI - 0.2);
}
ctx.stroke();
// Antenna
ctx.strokeStyle = TEAM_COLORS[this.teamIndex].primary;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -10);
ctx.quadraticCurveTo(5, -18, 3, -20);
ctx.stroke();
// Antenna ball
ctx.fillStyle = isActive ? '#feca57' : TEAM_COLORS[this.teamIndex].secondary;
ctx.beginPath();
ctx.arc(3, -20, 3, 0, Math.PI * 2);
ctx.fill();
// Parachute
if (this.parachuteActive) {
ctx.restore();
ctx.save();
ctx.translate(sx, sy);
ctx.fillStyle = TEAM_COLORS[this.teamIndex].primary;
ctx.beginPath();
ctx.moveTo(-20 * zoom, -25 * zoom);
ctx.quadraticCurveTo(0, -50 * zoom, 20 * zoom, -25 * zoom);
ctx.lineTo(0, -15 * zoom);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#888';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-15 * zoom, -25 * zoom);
ctx.lineTo(0, -12 * zoom);
ctx.moveTo(15 * zoom, -25 * zoom);
ctx.lineTo(0, -12 * zoom);
ctx.stroke();
}
ctx.restore();
// Health bar (screen space)
if (this.alive) {
const barWidth = 30 * zoom;
const barHeight = 4 * zoom;
const barY = sy - 20 * zoom;
ctx.fillStyle = '#333';
ctx.fillRect(sx - barWidth/2, barY, barWidth, barHeight);
const hp = this.health / this.maxHealth;
let hpColor = '#1dd1a1';
if (hp < 0.3) hpColor = '#e94560';
else if (hp < 0.6) hpColor = '#feca57';
ctx.fillStyle = hpColor;
ctx.fillRect(sx - barWidth/2, barY, barWidth * hp, barHeight);
}
// Active indicator
if (isActive) {
const bounce = Math.sin(Date.now() / 200) * 3;
ctx.fillStyle = '#feca57';
ctx.beginPath();
ctx.moveTo(sx, sy - 40 * zoom + bounce);
ctx.lineTo(sx - 8 * zoom, sy - 50 * zoom + bounce);
ctx.lineTo(sx + 8 * zoom, sy - 50 * zoom + bounce);
ctx.closePath();
ctx.fill();
}
}
}
class Team {
constructor(index, isCPU = false) {
this.index = index;
this.worms = [];
this.color = TEAM_COLORS[index];
this.isCPU = isCPU;
this.aliveWormCount = 4;
}
spawnWorms() {
const positions = [];
const w = canvas.width;
const safeY = canvas.height - WATER_LEVEL_OFFSET - 100; // Well above water
// Find all valid spawn regions first
let spawnRegions = [];
// Scan across map to find valid terrain above water
const scanStep = 5;
for (let x = 60; x < w - 60; x += scanStep) {
const gy = findGroundY(x);
// Must be well above water
if (gy < canvas.height - WATER_LEVEL_OFFSET - 50) {
spawnRegions.push({ x: x, groundY: gy });
}
}
if (spawnRegions.length === 0) {
// Fallback: just use center
console.warn('No valid spawn regions found! Using fallback.');
for (let i = 0; i < 4; i++) {
positions.push({
x: w/2 + (i - 1.5) * 40,
y: canvas.height - WATER_LEVEL_OFFSET - 100
});
}
} else {
// Split terrain into sections based on team count
const totalRegions = spawnRegions.length;
const regionsPerTeam = Math.floor(totalRegions / gameState.teams.length);
const startIdx = this.index * regionsPerTeam;
const endIdx = Math.min(startIdx + regionsPerTeam, totalRegions);
// Get our team's region
const myRegions = spawnRegions.slice(startIdx, endIdx);
// Sample 4 positions from our region
for (let i = 0; i < 4; i++) {
// Pick a position with some spread
const regionIdx = Math.floor((i / 4) * myRegions.length);
if (myRegions[regionIdx]) {
// Add some horizontal jitter
const jitter = (Math.random() - 0.5) * 30;
let tx = myRegions[regionIdx].x + jitter;
let ty = myRegions[regionIdx].groundY;
// Validate this position
const actualY = findGroundY(tx);
if (actualY < canvas.height - WATER_LEVEL_OFFSET - 50) {
ty = actualY;
}
positions.push({
x: Math.max(50, Math.min(w - 50, tx)),
y: ty - WORM_HEIGHT/2 - 5
});
} else {
// Fallback
positions.push({
x: w/2,
y: safeY
});
}
}
}
this.worms = positions.map((pos, i) => new Worm(pos.x, pos.y, this.index, i));
}
getAliveWorms() {
return this.worms.filter(w => w.alive);
}
updateAliveCount() {
this.aliveWormCount = this.getAliveWorms().length;
}
isDefeated() {
return this.aliveWormCount === 0;
}
}
function findGroundY(x) {
// Search from bottom (just above water) going up to find the terrain surface
const startY = canvas.height - WATER_LEVEL_OFFSET - 5;
const endY = 50;
let foundTerrainY = null;
for (let y = startY; y > endY; y--) {
if (isTerrainPixel(x, y)) {
foundTerrainY = y;
} else if (foundTerrainY !== null) {
// We were in terrain, now we're out - this is the surface
return foundTerrainY;
}
}
// Fallback: return a safe height
if (foundTerrainY !== null) return foundTerrainY;
return canvas.height - WATER_LEVEL_OFFSET - 80;
}
function snapToGround(x, y) {
for (let ty = y; ty < canvas.height - WATER_LEVEL_OFFSET; ty++) {
if (isTerrainPixel(x, ty)) {
return ty;
}
}
return y;
}
function getCurrentWorm() {
const team = gameState.teams[gameState.currentTeamIndex];
if (!team) return null;
const aliveWorms = team.getAliveWorms();
if (aliveWorms.length === 0) return null;
// Return the worm at currentWormIndex, cycling if needed
gameState.currentWormIndex = gameState.currentWormIndex % aliveWorms.length;
return aliveWorms[gameState.currentWormIndex];
}
function isCurrentWorm(worm) {
const current = getCurrentWorm();
return current === worm;
}
// =============================================================
// PROJECTILE SYSTEM
// =============================================================
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.bounceCount = 0;
this.maxBounces = 3;
this.fuseTimer = weapon.fuseTime || 0;
this.spawnTime = Date.now();
this.rotation = 0;
this.rotationSpeed = 0.2;
this.isCluster = weapon.id === 'cluster';
this.clusterTimer = weapon.clusterDelay || 0;
this.hasClustered = false;
}
update(dt) {
if (!this.alive) return;
// Save trail
this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > 50) this.trail.shift();
// Rotation
this.rotation += this.rotationSpeed;
// Fuse check
if (this.fuseTimer > 0 && Date.now() - this.spawnTime > this.fuseTimer) {
this.explode();
return;
}
// Cluster bomb check
if (this.isCluster && !this.hasClustered && Date.now() - this.spawnTime > this.clusterTimer) {
this.releaseClusters();
return;
}
// Apply gravity
this.vy += GRAVITY;
// Apply wind
if (this.weapon.affectedByWind) {
this.vx += gameState.wind.x * 0.01;
this.vy += gameState.wind.y * 0.005;
}
const newX = this.x + this.vx;
const newY = this.y + this.vy;
// Collision detection
const hitTerrain = isTerrainPixel(newX, newY);
const hitWater = isWaterLevel(newY);
const outOfBounds = newX < 0 || newX > canvas.width || newY < -100;
if (hitWater) {
createSplashParticles(this.x, canvas.height - WATER_LEVEL_OFFSET);
audio.playSplash();
this.alive = false;
return;
}
if (outOfBounds && newY > canvas.height + 100) {
this.alive = false;
return;
}
if (hitTerrain) {
if (this.weapon.bounces && this.bounceCount < this.maxBounces) {
// Bounce
this.bounceCount++;
// Simple normal estimation - check surrounding pixels
const upClear = !isTerrainPixel(this.x, this.y - 5);
const downClear = !isTerrainPixel(this.x, this.y + 5);
const leftClear = !isTerrainPixel(this.x - 5, this.y);
const rightClear = !isTerrainPixel(this.x + 5, this.y);
if (!upClear) this.vy = Math.abs(this.vy) * 0.6;
if (!downClear) this.vy = -Math.abs(this.vy) * 0.6;
if (!leftClear) this.vx = Math.abs(this.vx) * 0.6;
if (!rightClear) this.vx = -Math.abs(this.vx) * 0.6;
// Default vertical bounce if unsure
if (upClear && downClear && leftClear && rightClear) {
this.vy = -this.vy * 0.6;
this.vx *= 0.8;
}
audio.playPop();
createDustParticles(this.x, this.y, 5);
} else {
this.explode();
return;
}
}
// Check collision with worms (direct hit)
if (!this.weapon.instant) {
for (const team of gameState.teams) {
for (const worm of team.worms) {
if (!worm.alive) continue;
const dx = newX - worm.x;
const dy = newY - worm.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 15) {
// Direct hit!
if (team === gameState.teams[gameState.currentTeamIndex]) {
// Self hit
createFloatingText(worm.x, worm.y - 30, 'FRIENDLY FIRE!', '#feca57');
}
this.explode();
return;
}
}
}
}
this.x = newX;
this.y = newY;
}
explode() {
this.alive = false;
const damage = this.weapon.damage;
const radius = this.weapon.radius;
// Visual effects
createExplosionParticles(this.x, this.y, '#ff6b6b', 40);
createFireParticles(this.x, this.y, 20);
createShockwave(this.x, this.y, radius);
// Screen effects
const shakeIntensity = Math.min(radius / 10, 10);
addScreenShake(shakeIntensity);
if (radius >= 50) {
addImpactFreeze(200);
}
audio.playExplosion(radius / 40);
// Terrain destruction
destroyTerrain(this.x, this.y, radius);
// Damage worms in radius
for (const team of gameState.teams) {
for (const worm of team.worms) {
if (!worm.alive) continue;
const dx = worm.x - this.x;
const dy = worm.y - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius + 20) {
// Damage falloff
const dmgFactor = 1 - (dist / (radius + 20)) * 0.5;
const actualDamage = Math.floor(damage * dmgFactor);
worm.takeDamage(actualDamage, true);
// Knockback
const knockbackForce = (1 - dist / (radius + 30)) * 8;
const angle = Math.atan2(dy, dx);
worm.vx += Math.cos(angle) * knockbackForce;
worm.vy += Math.sin(angle) * knockbackForce - 2;
worm.onGround = false;
createFloatingText(worm.x, worm.y - 30, `-${actualDamage}`, '#e94560');
}
}
}
}
releaseClusters() {
this.alive = false;
this.hasClustered = true;
const clusterCount = this.weapon.clusters;
for (let i = 0; i < clusterCount; i++) {
const angle = (i / clusterCount) * Math.PI * 2 + Math.random() * 0.3;
const speed = 3 + Math.random() * 3;
const clusterWeapon = {
...this.weapon,
id: 'cluster_bomblet',
name: 'Cluster Bomblet',
radius: 20,
damage: 20,
clusters: 0,
affectedByWind: true
};
const proj = new Projectile(
this.x,
this.y,
Math.cos(angle) * speed + this.vx * 0.3,
Math.sin(angle) * speed + this.vy * 0.2,
clusterWeapon,
this.owner
);
gameState.projectiles.push(proj);
}
audio.playPop();
createExplosionParticles(this.x, this.y, '#feca57', 15);
}
draw(ctx, camX, camY, zoom) {
if (!this.alive) return;
const toScreen = (wx, wy) => ({
x: (wx - camX) * zoom + canvas.width / 2,
y: (wy - camY) * zoom + canvas.height / 2
});
// Draw trail
if (this.trail.length > 1) {
ctx.beginPath();
const start = toScreen(this.trail[0].x, this.trail[0].y);
ctx.moveTo(start.x, start.y);
for (let i = 1; i < this.trail.length; i++) {
const p = toScreen(this.trail[i].x, this.trail[i].y);
ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = 'rgba(254, 202, 87, 0.4)';
ctx.lineWidth = 2 * zoom;
ctx.stroke();
}
const pos = toScreen(this.x, this.y);
ctx.save();
ctx.translate(pos.x, pos.y);
ctx.rotate(this.rotation);
ctx.scale(zoom, zoom);
// Draw projectile based on weapon
switch(this.weapon.id) {
case 'bazooka':
case 'mortar':
// Rocket shape
ctx.fillStyle = '#555';
ctx.beginPath();
ctx.ellipse(0, 0, 6, 3, Math.atan2(this.vy, this.vx), 0, Math.PI * 2);
ctx.fill();
// Exhaust
ctx.fillStyle = '#feca57';
const exhaustAngle = Math.atan2(-this.vy, -this.vx);
ctx.beginPath();
ctx.arc(Math.cos(exhaustAngle) * 8, Math.sin(exhaustAngle) * 8, 3, 0, Math.PI * 2);
ctx.fill();
break;
case 'grenade':
case 'cluster':
ctx.fillStyle = '#2d3436';
ctx.beginPath();
ctx.arc(0, 0, 5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#636e72';
ctx.lineWidth = 1;
ctx.stroke();
// Pin
ctx.strokeStyle = '#b2bec3';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, -5);
ctx.lineTo(0, -8);
ctx.lineTo(2, -10);
ctx.stroke();
break;
default:
ctx.fillStyle = '#ff6b6b';
ctx.beginPath();
ctx.arc(0, 0, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
// =============================================================
// PARTICLE SYSTEM
// =============================================================
function createExplosionParticles(x, y, color, count) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 1 + Math.random() * 8;
gameState.particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 2 + Math.random() * 5,
color: color,
life: 60 + Math.random() * 60,
maxLife: 120,
gravity: 0.1,
type: 'explosion'
});
}
}
function createFireParticles(x, y, count) {
for (let i = 0; i < count; i++) {
const angle = -Math.PI/2 + (Math.random() - 0.5) * 0.8;
const speed = 1 + Math.random() * 4;
gameState.particles.push({
x: x + (Math.random() - 0.5) * 20,
y: y + (Math.random() - 0.5) * 20,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 4 + Math.random() * 6,
life: 40 + Math.random() * 30,
maxLife: 70,
gravity: -0.05,
type: 'fire'
});
}
}
function createDustParticles(x, y, count) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 0.5 + Math.random() * 2;
gameState.particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 1,
size: 2 + Math.random() * 4,
color: '#b2bec3',
life: 60 + Math.random() * 60,
maxLife: 120,
gravity: 0.02,
type: 'dust'
});
}
}
function createSplashParticles(x, y) {
for (let i = 0; i < 15; i++) {
const angle = -Math.PI/2 + (Math.random() - 0.5) * 1.5;
const speed = 2 + Math.random() * 5;
gameState.particles.push({
x: x + (Math.random() - 0.5) * 30,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 2 + Math.random() * 4,
color: '#48dbfb',
life: 40 + Math.random() * 40,
maxLife: 80,
gravity: 0.15,
type: 'splash'
});
}
}
function createShockwave(x, y, radius) {
gameState.particles.push({
x: x,
y: y,
radius: 5,
maxRadius: radius * 1.5,
life: 30,
maxLife: 30,
type: 'shockwave'
});
}
function createFloatingText(x, y, text, color) {
gameState.floatingTexts.push({
x: x,
y: y,
text: text,
color: color,
life: 90,
maxLife: 90,
vy: -1.5
});
}
function updateParticles() {
// Regular particles
gameState.particles = gameState.particles.filter(p => {
p.life--;
if (p.life <= 0) return false;
if (p.type === 'shockwave') {
p.radius += (p.maxRadius - 5) / p.maxLife;
} else {
p.vy += p.gravity || 0.1;
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.98;
}
return true;
});
// Floating texts
gameState.floatingTexts = gameState.floatingTexts.filter(t => {
t.life--;
t.y += t.vy;
return t.life > 0;
});
// Debris
gameState.debris = gameState.debris.filter(d => {
d.life--;
if (d.life <= 0) return false;
d.vy += GRAVITY;
d.x += d.vx;
d.y += d.vy;
d.rotation += d.rotationSpeed;
d.vx *= 0.99;
// Collision with terrain
if (isTerrainPixel(d.x, d.y) && d.vy > 0) {
d.vy *= -0.3;
d.vx *= 0.5;
if (Math.abs(d.vy) < 0.5) {
d.vy = 0;
d.vx = 0;
}
}
// Water
if (isWaterLevel(d.y)) {
return false;
}
return true;
});
}
function drawParticles(ctx, camX, camY, zoom) {
const toScreen = (wx, wy) => ({
x: (wx - camX) * zoom + canvas.width / 2,
y: (wy - camY) * zoom + canvas.height / 2
});
// Debris
for (const d of gameState.debris) {
const pos = toScreen(d.x, d.y);
ctx.save();
ctx.translate(pos.x, pos.y);
ctx.rotate(d.rotation);
ctx.fillStyle = d.color;
ctx.fillRect(-d.size/2 * zoom, -d.size/2 * zoom, d.size * zoom, d.size * zoom);
ctx.restore();
}
// Particles
for (const p of gameState.particles) {
if (p.type === 'shockwave') {
const pos = toScreen(p.x, p.y);
const alpha = p.life / p.maxLife;
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.5})`;
ctx.lineWidth = 3 * zoom;
ctx.beginPath();
ctx.arc(pos.x, pos.y, p.radius * zoom, 0, Math.PI * 2);
ctx.stroke();
} else {
const pos = toScreen(p.x, p.y);
const alpha = p.life / p.maxLife;
let color = p.color;
if (p.type === 'fire') {
// Fire color gradient
const t = 1 - p.life / p.maxLife;
if (t < 0.3) color = '#feca57';
else if (t < 0.6) color = '#ff9f43';
else color = '#ee5a24';
}
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(pos.x, pos.y, p.size * zoom / 2, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// Floating texts
for (const t of gameState.floatingTexts) {
const pos = toScreen(t.x, t.y);
const alpha = t.life / t.maxLife;
const scale = 1 + (1 - alpha) * 0.5;
ctx.save();
ctx.globalAlpha = alpha;
ctx.font = `bold ${20 * scale}px 'Segoe UI', sans-serif`;
ctx.fillStyle = t.color;
ctx.textAlign = 'center';
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.lineWidth = 3;
ctx.strokeText(t.text, pos.x, pos.y);
ctx.fillText(t.text, pos.x, pos.y);
ctx.restore();
}
}
// =============================================================
// CAMERA & SCREEN EFFECTS
// =============================================================
function updateCamera() {
const worm = getCurrentWorm();
if (worm && worm.alive) {
// Focus on active worm
gameState.camera.targetX = worm.x;
gameState.camera.targetY = worm.y;
} else if (gameState.activeProjectile) {
// Follow projectile
gameState.camera.targetX = gameState.activeProjectile.x;
gameState.camera.targetY = gameState.activeProjectile.y;
}
// Smooth camera movement
gameState.camera.x += (gameState.camera.targetX - gameState.camera.x) * 0.08;
gameState.camera.y += (gameState.camera.targetY - gameState.camera.y) * 0.08;
// Clamp camera
const marginX = canvas.width / 2 - 50;
const marginY = canvas.height / 2 - 50;
gameState.camera.x = Math.max(marginX, Math.min(canvas.width - marginX, gameState.camera.x));
gameState.camera.y = Math.max(marginY, Math.min(canvas.height - marginY, gameState.camera.y));
// Screen shake decay
if (gameState.screenShake.intensity > 0) {
gameState.screenShake.x = (Math.random() - 0.5) * gameState.screenShake.intensity * 2;
gameState.screenShake.y = (Math.random() - 0.5) * gameState.screenShake.intensity * 2;
gameState.screenShake.intensity *= 0.92;
if (gameState.screenShake.intensity < 0.1) {
gameState.screenShake.intensity = 0;
gameState.screenShake.x = 0;
gameState.screenShake.y = 0;
}
}
// Impact freeze
if (gameState.impactFreeze.active) {
gameState.impactFreeze.duration--;
if (gameState.impactFreeze.duration <= 0) {
gameState.impactFreeze.active = false;
}
}
}
function addScreenShake(intensity) {
gameState.screenShake.intensity = Math.max(gameState.screenShake.intensity, intensity);
}
function addImpactFreeze(duration) {
gameState.impactFreeze.active = true;
gameState.impactFreeze.duration = duration;
gameState.impactFreeze.maxDuration = duration;
}
// =============================================================
// AI SYSTEM
// =============================================================
class CPUPlayer {
constructor(teamIndex) {
this.teamIndex = teamIndex;
this.state = 'idle'; // idle, aiming, charging, firing, waiting
this.aimTimer = 0;
this.chargeTimer = 0;
this.waitTimer = 0;
this.targetAngle = 0;
this.targetPower = 0;
this.currentWorm = null;
this.targetWorm = null;
}
startTurn() {
this.state = 'thinking';
this.aimTimer = 0;
this.chargeTimer = 0;
this.waitTimer = 0;
this.currentWorm = getCurrentWorm();
gameState.cpuThinking = true;
// Risk assessment - check if in danger
this.assessRisks();
// Choose best target
this.chooseTarget();
// Calculate optimal aim
this.calculateAim();
// Decide if we should move first
const shouldMove = this.decideToMove();
if (shouldMove) {
this.state = 'moving';
this.moveTimer = 60;
} else {
this.state = 'aiming';
}
}
assessRisks() {
if (!this.currentWorm) return;
const worm = this.currentWorm;
// Check water proximity
const distToWater = (canvas.height - WATER_LEVEL_OFFSET) - worm.y;
if (distToWater < 60) {
// Danger close to water - mark as high risk
this.waterRisk = true;
} else {
this.waterRisk = false;
}
// Check edge proximity
if (worm.x < 80 || worm.x > canvas.width - 80) {
this.edgeRisk = true;
} else {
this.edgeRisk = false;
}
}
decideToMove() {
if (!this.currentWorm) return false;
const worm = this.currentWorm;
// If near water, definitely move
if (this.waterRisk) return true;
if (this.edgeRisk && Math.random() < 0.7) return true;
// Check if there's good cover nearby (we don't have cover system, so skip)
// Random chance to reposition for better angle
if (this.targetWorm) {
const dx = this.targetWorm.x - worm.x;
const dist = Math.abs(dx);
// If too close, back away
if (dist < 100 && Math.random() < 0.6) return true;
// If too far, maybe move closer (for direct weapons)
if (dist > 400 && gameState.selectedWeapon.category === 'direct' && Math.random() < 0.5) return true;
}
return Math.random() < 0.2;
}
chooseTarget() {
let bestTarget = null;
let bestScore = -Infinity;
for (let ti = 0; ti < gameState.teams.length; ti++) {
if (ti === this.teamIndex) continue;
const team = gameState.teams[ti];
for (const worm of team.worms) {
if (!worm.alive) continue;
// Score target
let score = 0;
// Low health = high priority
score += (100 - worm.health) * 2;
// Proximity (closer = easier to hit)
const dist = this.currentWorm ? Math.abs(worm.x - this.currentWorm.x) : 300;
score += Math.max(0, (500 - dist) / 10);
// Isolation (grouped targets better for AoE)
let nearbyAllies = 0;
for (const other of team.worms) {
if (other === worm || !other.alive) continue;
const od = Math.abs(other.x - worm.x);
if (od < 80) nearbyAllies++;
}
score += nearbyAllies * 20;
if (score > bestScore) {
bestScore = score;
bestTarget = worm;
}
}
}
this.targetWorm = bestTarget;
// Choose weapon based on target
this.chooseWeapon();
}
chooseWeapon() {
if (!this.targetWorm) {
gameState.selectedWeapon = WEAPONS[0];
return;
}
const dist = Math.abs(this.targetWorm.x - this.currentWorm.x);
// If clustered targets, use cluster or grenade
if (this.hasClusteredTargets() && Math.random() < 0.6) {
gameState.selectedWeapon = WEAPONS.find(w => w.id === 'cluster') || WEAPONS[0];
}
// If close, use shotgun
else if (dist < 200 && Math.random() < 0.5) {
gameState.selectedWeapon = WEAPONS.find(w => w.id === 'shotgun') || WEAPONS[0];
}
// If far, use mortar or bazooka
else if (dist > 400 && Math.random() < 0.4) {
gameState.selectedWeapon = WEAPONS.find(w => w.id === 'mortar') || WEAPONS[0];
}
// Default
else {
const options = ['bazooka', 'grenade'];
const choice = options[Math.floor(Math.random() * options.length)];
gameState.selectedWeapon = WEAPONS.find(w => w.id === choice) || WEAPONS[0];
}
updateWeaponPanel();
}
hasClusteredTargets() {
for (let ti = 0; ti < gameState.teams.length; ti++) {
if (ti === this.teamIndex) continue;
const team = gameState.teams[ti];
const alive = team.getAliveWorms();
for (let i = 0; i < alive.length; i++) {
for (let j = i + 1; j < alive.length; j++) {
const dist = Math.abs(alive[i].x - alive[j].x);
if (dist < 80) return true;
}
}
}
return false;
}
calculateAim() {
if (!this.currentWorm || !this.targetWorm) {
this.targetAngle = -Math.PI / 4;
this.targetPower = 0.5;
return;
}
const worm = this.currentWorm;
const target = this.targetWorm;
const dx = target.x - worm.x;
const dy = target.y - worm.y;
// Set facing
this.currentWorm.facing = dx >= 0 ? 1 : -1;
// Predictive aiming with simple physics estimation
// Adjust for wind
const windAdjustment = gameState.wind.x * 0.05;
// Solve ballistic equation approximately
// We'll try different angles and pick the best one
const baseAngle = Math.atan2(dy, Math.abs(dx));
// For indirect fire, use a parabolic arc
let bestAngle = baseAngle;
let bestError = Infinity;
let bestPower = 0.6;
// Monte Carlo approach - try a few combinations
for (let test = 0; test < 20; test++) {
const testAngle = -Math.PI/3 + (test / 20) * Math.PI/2;
const testPower = 0.4 + (test % 5) * 0.15;
const error = this.simulateTrajectory(
worm.x, worm.y,
testAngle,
testPower * MAX_POWER * (gameState.selectedWeapon.projectileSpeed || 1),
target.x, target.y
);
if (error < bestError) {
bestError = error;
bestAngle = testAngle;
bestPower = testPower;
}
}
// Add some randomness (imperfect aim)
const accuracy = 0.85; // 85% accuracy
const randomAngleOffset = (Math.random() - 0.5) * 0.3 * (1 - accuracy);
const randomPowerOffset = (Math.random() - 0.5) * 0.2 * (1 - accuracy);
this.targetAngle = bestAngle + randomAngleOffset;
this.targetPower = Math.max(0.1, Math.min(1, bestPower + randomPowerOffset));
// Adjust angle for facing
if (dx < 0) {
this.targetAngle = Math.PI - this.targetAngle;
}
}
simulateTrajectory(startX, startY, angle, power, targetX, targetY) {
let x = startX;
let y = startY;
let vx = Math.cos(angle) * power;
let vy = Math.sin(angle) * power;
let minError = Infinity;
for (let step = 0; step < 500; step++) {
vy += GRAVITY;
if (gameState.selectedWeapon.affectedByWind) {
vx += gameState.wind.x * 0.01;
}
x += vx;
y += vy;
const error = Math.sqrt((x - targetX) ** 2 + (y - targetY) ** 2);
minError = Math.min(minError, error);
if (isTerrainPixel(x, y) || isWaterLevel(y) || y > canvas.height + 100) {
break;
}
}
return minError;
}
update() {
if (this.state === 'idle' || !gameState.cpuThinking) return;
const worm = this.currentWorm;
if (!worm || !worm.alive) {
this.state = 'idle';
gameState.cpuThinking = false;
return;
}
switch(this.state) {
case 'thinking':
this.waitTimer++;
if (this.waitTimer > 30) {
this.waitTimer = 0;
this.state = 'aiming';
audio.playDing();
}
break;
case 'moving':
this.moveTimer--;
if (this.waterRisk) {
// Move away from water - move upward if possible, or horizontally
const moveDir = worm.y > canvas.height/2 ? -1 : 1;
// Just jump to try to get higher ground
if (worm.onGround && this.moveTimer % 30 === 0) {
worm.vy = JUMP_FORCE;
worm.onGround = false;
audio.playJump();
}
// Move horizontally toward center
worm.vx = worm.x < canvas.width/2 ? MOVE_SPEED : -MOVE_SPEED;
worm.facing = worm.vx >= 0 ? 1 : -1;
} else if (this.edgeRisk) {
// Move toward center
const centerX = canvas.width / 2;
worm.vx = worm.x < centerX ? MOVE_SPEED : -MOVE_SPEED;
worm.facing = worm.vx >= 0 ? 1 : -1;
} else {
// Move toward a better position relative to target
if (this.targetWorm) {
const dx = this.targetWorm.x - worm.x;
// If too close, back up
if (Math.abs(dx) < 100) {
worm.vx = dx > 0 ? -MOVE_SPEED : MOVE_SPEED;
} else {
worm.vx = dx > 0 ? MOVE_SPEED * 0.5 : -MOVE_SPEED * 0.5;
}
worm.facing = dx >= 0 ? 1 : -1;
}
}
if (this.moveTimer <= 0) {
worm.vx = 0;
this.calculateAim();
this.state = 'aiming';
this.aimTimer = 0;
}
break;
case 'aiming':
// Smoothly interpolate aim
this.aimTimer++;
// Update game state aim
const aimLerp = Math.min(1, this.aimTimer / 60);
gameState.aimAngle = gameState.aimAngle + (this.targetAngle - gameState.aimAngle) * 0.05;
if (this.aimTimer > 80) {
this.state = 'charging';
this.chargeTimer = 0;
gameState.isCharging = true;
gameState.chargePower = 0;
}
break;
case 'charging':
// Charge up to target power
this.chargeTimer++;
if (gameState.chargePower < this.targetPower) {
gameState.chargePower += 0.015;
if (gameState.chargePower > 1) gameState.chargePower = 1;
}
// Hold at target power briefly, then fire
const targetReached = gameState.chargePower >= this.targetPower * 0.95;
if (targetReached && this.chargeTimer > 30) {
this.state = 'firing';
this.fireWeapon();
}
break;
case 'firing':
this.waitTimer++;
// Wait for projectile to finish
const hasActiveProjectiles = gameState.projectiles.some(p => p.alive);
if (!hasActiveProjectiles && this.waitTimer > 60) {
this.state = 'idle';
gameState.cpuThinking = false;
endTurn();
}
break;
}
}
fireWeapon() {
const worm = this.currentWorm;
if (!worm) return;
const weapon = gameState.selectedWeapon;
const power = gameState.chargePower * MAX_POWER * (weapon.projectileSpeed || 1);
if (weapon.instant) {
fireInstantWeapon(worm, weapon);
} else if (weapon.placeable) {
placeWeapon(worm, weapon);
} else {
const vx = Math.cos(gameState.aimAngle) * power;
const vy = Math.sin(gameState.aimAngle) * power;
const proj = new Projectile(worm.x, worm.y - 5, vx, vy, weapon, worm);
gameState.projectiles.push(proj);
gameState.activeProjectile = proj;
}
gameState.isCharging = false;
gameState.chargePower = 0;
audio.playShoot();
}
}
// =============================================================
// WEAPON FIRING
// =============================================================
function fireInstantWeapon(worm, weapon) {
if (weapon.id === 'shotgun') {
const baseAngle = gameState.aimAngle;
for (let i = 0; i < weapon.pellets; i++) {
const spread = (i - (weapon.pellets - 1) / 2) * weapon.spread;
const angle = baseAngle + spread + (Math.random() - 0.5) * 0.05;
// Raycast
let hitX = worm.x;
let hitY = worm.y;
let hitWorm = null;
const range = weapon.range;
for (let d = 0; d < range; d += 3) {
const tx = worm.x + Math.cos(angle) * d;
const ty = worm.y - 5 + Math.sin(angle) * d;
if (isTerrainPixel(tx, ty) || isWaterLevel(ty)) {
hitX = tx;
hitY = ty;
break;
}
// Check worm hit
for (const team of gameState.teams) {
for (const w of team.worms) {
if (!w.alive) continue;
const dx = tx - w.x;
const dy = ty - w.y;
if (Math.sqrt(dx*dx + dy*dy) < 12) {
hitX = tx;
hitY = ty;
hitWorm = w;
d = range;
break;
}
}
}
}
// Trail particle
createShotgunTrail(worm.x, worm.y - 5, hitX, hitY);
if (hitWorm) {
const dmg = Math.floor(weapon.damage / weapon.pellets);
hitWorm.takeDamage(dmg);
createFloatingText(hitWorm.x, hitWorm.y - 30, `-${dmg}`, '#e94560');
createExplosionParticles(hitX, hitY, '#feca57', 5);
} else {
createDustParticles(hitX, hitY, 3);
}
}
audio.playShoot();
addScreenShake(2);
}
}
function createShotgunTrail(x1, y1, x2, y2) {
const dist = Math.sqrt((x2-x1)**2 + (y2-y1)**2);
const steps = Math.floor(dist / 10);
for (let i = 0; i < steps; i++) {
const t = i / steps;
const x = x1 + (x2 - x1) * t;
const y = y1 + (y2 - y1) * t;
gameState.particles.push({
x: x,
y: y,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
size: 2 + Math.random() * 2,
color: '#feca57',
life: 10 + Math.random() * 10,
maxLife: 20,
gravity: 0,
type: 'spark'
});
}
}
function placeWeapon(worm, weapon) {
// Place at worm's feet
const weaponData = {
...weapon,
x: worm.x,
y: worm.y + WORM_HEIGHT/2,
vx: 0,
vy: 0,
fuseTimer: weapon.fuseTime,
spawnTime: Date.now(),
placed: true
};
// Treat dynamite as a projectile with fuse
const proj = new Projectile(
worm.x,
worm.y + WORM_HEIGHT/2,
0, 0.5,
weapon,
worm
);
gameState.projectiles.push(proj);
createFloatingText(worm.x, worm.y - 30, weapon.name + '!', '#feca57');
audio.playPop();
}
// =============================================================
// TRAJECTORY PREDICTION
// =============================================================
function calculateTrajectoryPoints(startX, startY, angle, power, weapon, maxPoints = 100) {
const points = [];
let x = startX;
let y = startY;
let vx = Math.cos(angle) * power * (weapon.projectileSpeed || 1);
let vy = Math.sin(angle) * power * (weapon.projectileSpeed || 1);
for (let i = 0; i < maxPoints; i++) {
points.push({ x, y });
vy += GRAVITY;
if (weapon.affectedByWind) {
vx += gameState.wind.x * 0.01;
}
x += vx;
y += vy;
if (isTerrainPixel(x, y) || isWaterLevel(y) || y > canvas.height + 50 || x < -50 || x > canvas.width + 50) {
points.push({ x, y });
break;
}
}
return points;
}
// =============================================================
// TURN MANAGEMENT
// =============================================================
function startTurn() {
// Check for game over first
if (checkGameOver()) return;
// Skip defeated teams
while (gameState.teams[gameState.currentTeamIndex].isDefeated()) {
gameState.currentTeamIndex = (gameState.currentTeamIndex + 1) % gameState.teams.length;
}
const team = gameState.teams[gameState.currentTeamIndex];
const aliveWorms = team.getAliveWorms();
// Cycle to next worm
gameState.currentWormIndex = (gameState.currentWormIndex + 1) % aliveWorms.length;
const worm = getCurrentWorm();
// Reset turn state
gameState.turnTimeRemaining = gameState.roundTimeSetting;
gameState.isCharging = false;
gameState.chargePower = 0;
gameState.activeProjectile = null;
// Update HUD
updateHUD();
updateTeamsPanel();
// Set camera
if (worm) {
gameState.camera.targetX = worm.x;
gameState.camera.targetY = worm.y;
}
// Announce turn
const teamColor = TEAM_COLORS[gameState.currentTeamIndex];
const indicator = document.getElementById('teamIndicator');
indicator.textContent = team.isCPU ? `CPU - ${teamColor.name}'s Turn` : `${teamColor.name}'s Turn`;
indicator.style.borderColor = teamColor.primary;
createFloatingText(canvas.width/2, 150, team.isCPU ? 'CPU TURN' : 'YOUR TURN!', teamColor.primary);
audio.playDing();
// Start AI if CPU team
if (team.isCPU) {
const cpu = gameState.cpuPlayers.find(c => c.teamIndex === gameState.currentTeamIndex);
if (cpu) {
cpu.startTurn();
}
}
}
function endTurn() {
// Check for worms under terrain and push them out
for (const team of gameState.teams) {
for (const worm of team.worms) {
if (!worm.alive) continue;
// If stuck in terrain, nudge them
if (isTerrainPixel(worm.x, worm.y)) {
// Try to move up
while (worm.y > 0 && isTerrainPixel(worm.x, worm.y)) {
worm.y -= 1;
}
}
// Also check feet - if floating, let them fall
worm.onGround = isTerrainPixel(worm.x - 3, worm.y + WORM_HEIGHT/2) ||
isTerrainPixel(worm.x + 3, worm.y + WORM_HEIGHT/2);
}
}
// Update alive counts
for (const team of gameState.teams) {
team.updateAliveCount();
}
// Check game over
if (checkGameOver()) return;
// Change wind slightly each turn
const windChange = (Math.random() - 0.5) * 2;
gameState.wind.x = Math.max(-5, Math.min(5, gameState.wind.x + windChange));
updateWindIndicator();
// Next team
gameState.currentTeamIndex = (gameState.currentTeamIndex + 1) % gameState.teams.length;
startTurn();
}
function checkGameOver() {
const aliveTeams = gameState.teams.filter(t => !t.isDefeated());
if (aliveTeams.length <= 1) {
// Game over!
gameState.phase = 'gameover';
const winner = aliveTeams[0];
const color = TEAM_COLORS[winner.index];
document.getElementById('winnerText').textContent = `${color.name.toUpperCase()} WINS!`;
document.getElementById('winnerText').style.color = color.primary;
document.getElementById('victoryMessage').textContent = winner.isCPU ? 'CPU Victory!' : 'Congratulations!';
document.getElementById('hud').classList.add('hidden');
document.getElementById('gameOverScreen').classList.remove('hidden');
audio.playVictory();
return true;
}
return false;
}
// =============================================================
// UI UPDATES
// =============================================================
function updateHUD() {
const worm = getCurrentWorm();
if (!worm) return;
document.getElementById('wormName').textContent = worm.name;
const hpPercent = (worm.health / worm.maxHealth) * 100;
const healthFill = document.getElementById('healthFill');
healthFill.style.width = `${hpPercent}%`;
healthFill.className = 'health-fill';
if (hpPercent > 60) healthFill.classList.add('high');
else if (hpPercent > 30) healthFill.classList.add('medium');
else healthFill.classList.add('low');
}
function updateTimerDisplay() {
const el = document.getElementById('timerDisplay');
const time = Math.ceil(gameState.turnTimeRemaining);
el.textContent = time;
if (time <= 10) {
el.classList.add('urgent');
// Tick sound
if (Math.floor(gameState.turnTimeRemaining * 5) !== Math.floor((gameState.turnTimeRemaining + 0.016) * 5)) {
const pitch = 440 + (10 - time) * 50;
audio.playTimerTick(pitch);
}
} else {
el.classList.remove('urgent');
}
}
function updateWindIndicator() {
const arrow = document.getElementById('windArrow');
const strength = document.getElementById('windStrength');
const w = gameState.wind.x;
strength.textContent = Math.abs(w).toFixed(1);
if (Math.abs(w) < 0.5) {
arrow.textContent = '➖';
} else if (w > 0) {
arrow.textContent = '➡️';
} else {
arrow.textContent = '⬅️';
}
}
function updateTeamsPanel() {
const panel = document.getElementById('teamsPanel');
panel.innerHTML = '';
for (let i = 0; i < gameState.teams.length; i++) {
const team = gameState.teams[i];
const color = TEAM_COLORS[i];
const alive = team.getAliveWorms().length;
const entry = document.createElement('div');
entry.className = 'team-entry' + (i === gameState.currentTeamIndex ? ' active' : '');
entry.innerHTML = `
<div class="team-color" style="background: ${color.primary}"></div>
<span>${color.name}${team.isCPU ? ' (CPU)' : ''}: ${alive}/4 alive</span>
`;
panel.appendChild(entry);
}
}
function updateWeaponPanel() {
const panel = document.getElementById('weaponPanel');
panel.innerHTML = '';
for (const weapon of WEAPONS) {
const slot = document.createElement('div');
slot.className = 'weapon-slot' + (gameState.selectedWeapon.id === weapon.id ? ' selected' : '');
let catColor = '#888';
if (weapon.category === 'indirect') catColor = '#48dbfb';
else if (weapon.category === 'direct') catColor = '#1dd1a1';
else if (weapon.category === 'high_aoe') catColor = '#e94560';
slot.innerHTML = `
<div class="weapon-icon">${weapon.icon}</div>
<div class="weapon-key">${weapon.key}</div>
<div class="weapon-name">${weapon.name}</div>
`;
slot.style.borderLeft = `3px solid ${catColor}`;
slot.addEventListener('click', () => {
gameState.selectedWeapon = weapon;
updateWeaponPanel();
audio.playSelect();
});
panel.appendChild(slot);
}
}
function updateAimInfo() {
const angleDeg = Math.round(gameState.aimAngle * 180 / Math.PI);
const powerPercent = Math.round(gameState.chargePower * 100);
document.getElementById('aimInfo').textContent =
`Angle: ${angleDeg}° | Power: ${powerPercent}%`;
document.getElementById('powerFill').style.width = `${powerPercent}%`;
}
// =============================================================
// INPUT HANDLING
// =============================================================
document.addEventListener('keydown', (e) => {
keys[e.code] = true;
audio.init();
if (gameState.phase === 'playing') {
const currentTeam = gameState.teams[gameState.currentTeamIndex];
const isPlayerTurn = currentTeam && !currentTeam.isCPU;
// Weapon selection
const weaponKeys = { Digit1: 0, Digit2: 1, Digit3: 2, Digit4: 3, Digit5: 4, Digit6: 5 };
if (weaponKeys[e.code] !== undefined && isPlayerTurn) {
const idx = weaponKeys[e.code];
if (idx < WEAPONS.length) {
gameState.selectedWeapon = WEAPONS[idx];
updateWeaponPanel();
audio.playSelect();
}
}
// Start charging
if (e.code === 'Space' && isPlayerTurn && !gameState.isCharging && !gameState.projectiles.some(p => p.alive)) {
const worm = getCurrentWorm();
if (worm && worm.onGround) {
gameState.isCharging = true;
gameState.chargePower = 0;
gameState.chargeDirection = 1;
}
}
// Skip turn
if (e.code === 'Enter' && isPlayerTurn && !gameState.projectiles.some(p => p.alive)) {
endTurn();
}
// Pause
if (e.code === 'Escape') {
gameState.phase = 'paused';
document.getElementById('pauseMenu').classList.remove('hidden');
}
}
});
document.addEventListener('keyup', (e) => {
keys[e.code] = false;
if (gameState.phase === 'playing') {
const currentTeam = gameState.teams[gameState.currentTeamIndex];
const isPlayerTurn = currentTeam && !currentTeam.isCPU;
// Fire weapon
if (e.code === 'Space' && gameState.isCharging && isPlayerTurn) {
fireChargedWeapon();
}
}
});
document.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
if (gameState.phase === 'playing') {
const worm = getCurrentWorm();
if (!worm) return;
// Convert mouse to world coordinates
const worldMouseX = (mouseX - canvas.width/2) / gameState.camera.zoom + gameState.camera.x;
const worldMouseY = (mouseY - canvas.height/2) / gameState.camera.zoom + gameState.camera.y;
// Calculate angle from worm to mouse
const dx = worldMouseX - worm.x;
const dy = worldMouseY - worm.y;
gameState.aimAngle = Math.atan2(dy, dx);
// Update facing
if (dx > 0) worm.facing = 1;
else worm.facing = -1;
}
});
document.addEventListener('mousedown', () => { mouseDown = true; });
document.addEventListener('mouseup', () => { mouseDown = false; });
function fireChargedWeapon() {
const worm = getCurrentWorm();
if (!worm) return;
const weapon = gameState.selectedWeapon;
const power = gameState.chargePower * MAX_POWER;
if (weapon.instant) {
fireInstantWeapon(worm, weapon);
} else if (weapon.placeable) {
placeWeapon(worm, weapon);
} else {
const vx = Math.cos(gameState.aimAngle) * power * (weapon.projectileSpeed || 1);
const vy = Math.sin(gameState.aimAngle) * power * (weapon.projectileSpeed || 1);
const proj = new Projectile(worm.x, worm.y - 5, vx, vy, weapon, worm);
gameState.projectiles.push(proj);
gameState.activeProjectile = proj;
}
gameState.isCharging = false;
gameState.chargePower = 0;
audio.playShoot();
}
// =============================================================
// MENU BUTTONS
// =============================================================
document.getElementById('startBtn').addEventListener('click', () => {
audio.init();
audio.playClick();
startNewGame();
});
document.getElementById('howToBtn').addEventListener('click', () => {
audio.playClick();
document.getElementById('mainMenu').classList.add('hidden');
document.getElementById('howToMenu').classList.remove('hidden');
});
document.getElementById('backBtn').addEventListener('click', () => {
audio.playClick();
document.getElementById('howToMenu').classList.add('hidden');
document.getElementById('mainMenu').classList.remove('hidden');
});
document.getElementById('resumeBtn').addEventListener('click', () => {
audio.playClick();
gameState.phase = 'playing';
document.getElementById('pauseMenu').classList.add('hidden');
});
document.getElementById('quitBtn').addEventListener('click', () => {
audio.playClick();
returnToMenu();
});
document.getElementById('playAgainBtn').addEventListener('click', () => {
audio.playClick();
startNewGame();
});
document.getElementById('menuBtn').addEventListener('click', () => {
audio.playClick();
returnToMenu();
});
function returnToMenu() {
gameState.phase = 'menu';
document.getElementById('pauseMenu').classList.add('hidden');
document.getElementById('hud').classList.add('hidden');
document.getElementById('gameOverScreen').classList.add('hidden');
document.getElementById('mainMenu').classList.remove('hidden');
}
// =============================================================
// GAME INITIALIZATION
// =============================================================
function startNewGame() {
// Read settings
gameState.terrainType = document.getElementById('terrainType').value;
const teamCount = parseInt(document.getElementById('teamCount').value);
const cpuCount = parseInt(document.getElementById('cpuCount').value);
gameState.roundTimeSetting = parseInt(document.getElementById('roundTime').value);
// Init terrain
initTerrain();
// Create teams first
gameState.teams = [];
gameState.cpuPlayers = [];
for (let i = 0; i < teamCount; i++) {
const isCPU = i >= (teamCount - cpuCount);
gameState.teams.push(new Team(i, isCPU));
if (isCPU) {
gameState.cpuPlayers.push(new CPUPlayer(i));
}
}
// NOW spawn worms - gameState.teams.length is correct
for (const team of gameState.teams) {
team.spawnWorms();
}
// Init wind
gameState.wind = { x: (Math.random() - 0.5) * 6, y: 0 };
// Reset game state
gameState.currentTeamIndex = 0;
gameState.currentWormIndex = 0;
gameState.projectiles = [];
gameState.particles = [];
gameState.floatingTexts = [];
gameState.debris = [];
gameState.activeProjectile = null;
gameState.selectedWeapon = WEAPONS[0];
gameState.isCharging = false;
gameState.chargePower = 0;
gameState.screenShake = { x: 0, y: 0, intensity: 0 };
gameState.impactFreeze = { active: false, duration: 0, maxDuration: 0 };
gameState.cpuThinking = false;
// Reset camera
const firstWorm = gameState.teams[0].worms[0];
gameState.camera = {
x: firstWorm.x,
y: firstWorm.y,
targetX: firstWorm.x,
targetY: firstWorm.y,
zoom: 1,
targetZoom: 1
};
// Update UI
updateWeaponPanel();
updateTeamsPanel();
updateWindIndicator();
// Show HUD, hide menu
document.getElementById('mainMenu').classList.add('hidden');
document.getElementById('gameOverScreen').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');
// Start game
gameState.phase = 'playing';
startTurn();
}
// =============================================================
// MAIN GAME LOOP
// =============================================================
let lastTime = 0;
let turnTimerAccum = 0;
function gameLoop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
// Update
if (gameState.phase === 'playing') {
// Skip update during impact freeze
if (!gameState.impactFreeze.active) {
// Turn timer
const currentTeam = gameState.teams[gameState.currentTeamIndex];
const isPlayerTurn = currentTeam && !currentTeam.isCPU;
const hasActiveProjectiles = gameState.projectiles.some(p => p.alive);
if (isPlayerTurn && !hasActiveProjectiles && !gameState.cpuThinking) {
turnTimerAccum += dt;
if (turnTimerAccum >= 1) {
turnTimerAccum -= 1;
gameState.turnTimeRemaining -= 1;
if (gameState.turnTimeRemaining <= 0) {
// Time's up!
if (gameState.isCharging) {
fireChargedWeapon();
} else {
endTurn();
}
}
}
}
// Update charge power
if (gameState.isCharging) {
gameState.chargePower += 0.01;
if (gameState.chargePower > 1) gameState.chargePower = 0;
}
// Update current worm
const worm = getCurrentWorm();
if (worm && worm.alive) {
worm.update(dt);
}
// Update all worms (for physics, falling, etc.)
for (const team of gameState.teams) {
for (const w of team.worms) {
if (w !== worm && w.alive) {
w.update(dt);
}
}
}
// Update projectiles
for (const proj of gameState.projectiles) {
proj.update(dt);
}
// Update AI
for (const cpu of gameState.cpuPlayers) {
if (gameState.currentTeamIndex === cpu.teamIndex) {
cpu.update();
}
}
// Update particles
updateParticles();
// Update camera
updateCamera();
// Update HUD
updateHUD();
updateTimerDisplay();
updateAimInfo();
}
}
// Render
render();
requestAnimationFrame(gameLoop);
}
// =============================================================
// RENDERING
// =============================================================
function render() {
// Clear
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState.phase === 'menu' || gameState.phase === 'paused' || gameState.phase === 'gameover') {
// Still draw terrain if game was started
if (terrainCanvas) {
ctx.drawImage(terrainCanvas, 0, 0);
}
return;
}
const cam = gameState.camera;
const shake = gameState.screenShake;
// Apply screen shake
ctx.save();
ctx.translate(shake.x, shake.y);
// World to screen conversion
const toScreen = (wx, wy) => ({
x: (wx - cam.x) * cam.zoom + canvas.width / 2,
y: (wy - cam.y) * cam.zoom + canvas.height / 2
});
// Draw sky gradient
const skyGrd = ctx.createLinearGradient(0, 0, 0, canvas.height);
if (gameState.terrainType === 'cave') {
skyGrd.addColorStop(0, '#2d3436');
skyGrd.addColorStop(1, '#1a1a2e');
} else {
skyGrd.addColorStop(0, '#0984e3');
skyGrd.addColorStop(0.5, '#74b9ff');
skyGrd.addColorStop(1, '#a29bfe');
}
ctx.fillStyle = skyGrd;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw background clouds
ctx.fillStyle = 'rgba(255,255,255,0.1)';
const cloudOffset = (Date.now() / 100) % (canvas.width + 200);
drawCloud(ctx, cloudOffset - 100, 80, 60);
drawCloud(ctx, (cloudOffset + 400) % (canvas.width + 200) - 100, 120, 40);
// Draw terrain
ctx.drawImage(terrainCanvas, 0, 0);
// Draw water animation
const waterY = canvas.height - WATER_LEVEL_OFFSET;
ctx.fillStyle = 'rgba(72, 219, 251, 0.3)';
for (let x = 0; x < canvas.width; x += 20) {
const waveY = Math.sin((x + Date.now() / 200) * 0.05) * 3;
ctx.fillRect(x, waterY + waveY, 20, WATER_LEVEL_OFFSET);
}
// Draw aiming trajectory
const worm = getCurrentWorm();
if (worm && worm.alive) {
const currentTeam = gameState.teams[gameState.currentTeamIndex];
const isPlayerTurn = currentTeam && !currentTeam.isCPU;
const hasActiveProjectiles = gameState.projectiles.some(p => p.alive);
if (isPlayerTurn && !hasActiveProjectiles && !gameState.cpuThinking) {
const power = gameState.isCharging ?
gameState.chargePower * MAX_POWER :
0.5 * MAX_POWER;
const trajPoints = calculateTrajectoryPoints(
worm.x,
worm.y - 5,
gameState.aimAngle,
power,
gameState.selectedWeapon
);
if (trajPoints.length > 1) {
ctx.beginPath();
const start = toScreen(trajPoints[0].x, trajPoints[0].y);
ctx.moveTo(start.x, start.y);
for (let i = 1; i < trajPoints.length; i++) {
const p = toScreen(trajPoints[i].x, trajPoints[i].y);
ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = gameState.isCharging ?
'rgba(254, 202, 87, 0.8)' :
'rgba(255, 255, 255, 0.3)';
ctx.setLineDash(gameState.isCharging ? [] : [5, 5]);
ctx.lineWidth = 2;
ctx.stroke();
ctx.setLineDash([]);
// Draw dots along trajectory
for (let i = 5; i < trajPoints.length; i += 10) {
const p = toScreen(trajPoints[i].x, trajPoints[i].y);
ctx.fillStyle = 'rgba(254, 202, 87, 0.6)';
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
// Draw particles
drawParticles(ctx, cam.x, cam.y, cam.zoom);
// Draw worms
for (const team of gameState.teams) {
for (const w of team.worms) {
w.draw(ctx, cam.x, cam.y, cam.zoom);
}
}
// Draw projectiles
for (const proj of gameState.projectiles) {
proj.draw(ctx, cam.x, cam.y, cam.zoom);
}
// Vignette effect
const vignette = ctx.createRadialGradient(
canvas.width/2, canvas.height/2, canvas.height * 0.3,
canvas.width/2, canvas.height/2, canvas.height * 0.8
);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(1, 'rgba(0,0,0,0.4)');
ctx.fillStyle = vignette;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}
function drawCloud(ctx, x, y, size) {
ctx.beginPath();
ctx.arc(x, y, size * 0.5, 0, Math.PI * 2);
ctx.arc(x + size * 0.4, y - size * 0.1, size * 0.4, 0, Math.PI * 2);
ctx.arc(x + size * 0.8, y, size * 0.45, 0, Math.PI * 2);
ctx.arc(x + size * 0.3, y + size * 0.2, size * 0.35, 0, Math.PI * 2);
ctx.fill();
}
// =============================================================
// START
// =============================================================
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"bytedance-seed/seed-2.0-code","provider":"openrouter","persona":"full","sessionId":"c054efcb-ce10-478f-b55f-e3dfdd4c4cc7","tokensIn":1939034,"tokensOut":52150,"tokensTotal":1991184,"cost":1.1259669999999997,"turns":30,"toolCalls":29,"failedToolCalls":0,"timestamp":"2026-08-15T22:06:48.550Z"} -->