Worms
Gemini 3.7 Flash · 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 84,177 bytes · SHA-256 567de7f87335
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>WORMOIDS: Tactical Artillery Warfare</title>
<style>
:root {
--primary: #f59e0b;
--primary-glow: rgba(245, 158, 11, 0.4);
--accent: #ef4444;
--accent-blue: #06b6d4;
--bg-dark: #0f172a;
--panel-bg: rgba(15, 23, 42, 0.85);
--panel-border: rgba(255, 255, 255, 0.12);
}
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-user-select: none; font-family: 'Segoe UI', Roboto, -apple-system, system-ui, sans-serif; }
body, html { width: 100%; height: 100%; overflow: hidden; background: #070b14; color: #fff; }
#canvas-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; }
canvas#gameCanvas { display: block; width: 100%; height: 100%; }
/* UI Layer */
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.interactive { pointer-events: auto; }
/* Top HUD */
#top-hud {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 16px;
background: var(--panel-bg); backdrop-filter: blur(10px);
padding: 8px 24px; border-radius: 40px;
border: 1px solid var(--panel-border);
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
z-index: 20;
}
.hud-turn-badge {
display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: 14px; letter-spacing: 1px;
text-transform: uppercase; padding: 4px 12px; border-radius: 20px;
}
.hud-timer {
font-family: 'Courier New', Courier, monospace; font-size: 28px; font-weight: 900;
color: #fff; min-width: 44px; text-align: center; text-shadow: 0 0 10px rgba(255,255,255,0.4);
transition: transform 0.1s, color 0.2s;
}
.hud-timer.urgent {
color: #ef4444; text-shadow: 0 0 15px rgba(239, 68, 68, 0.8);
animation: timerPulse 0.5s infinite alternate;
}
@keyframes timerPulse { from { transform: scale(1); } to { transform: scale(1.25); } }
.hud-wind {
display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
background: rgba(0,0,0,0.3); padding: 4px 12px; border-radius: 20px;
}
.wind-meter {
width: 80px; height: 10px; background: rgba(255,255,255,0.1); border-radius: 5px; position: relative; overflow: hidden;
}
.wind-bar {
position: absolute; top: 0; height: 100%; background: linear-gradient(90deg, #38bdf8, #818cf8);
transition: width 0.3s, left 0.3s;
}
/* Teams Health Bar */
#teams-hud {
position: absolute; top: 12px; left: 16px; display: flex; flex-direction: column; gap: 6px;
background: var(--panel-bg); backdrop-filter: blur(10px); padding: 8px 14px; border-radius: 12px;
border: 1px solid var(--panel-border); font-size: 12px; font-weight: 700;
}
.team-status-row { display: flex; align-items: center; gap: 10px; min-width: 140px; }
.team-dot { width: 10px; height: 10px; border-radius: 50%; }
.team-hp-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.15); border-radius: 3px; overflow: hidden; }
.team-hp-fill { height: 100%; transition: width 0.3s ease; }
/* Top Right Buttons */
#top-right-tools {
position: absolute; top: 12px; right: 16px; display: flex; gap: 8px;
}
.btn-icon {
width: 38px; height: 38px; border-radius: 50%; background: var(--panel-bg);
border: 1px solid var(--panel-border); color: #fff; display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.2s; backdrop-filter: blur(10px);
}
.btn-icon:hover { transform: scale(1.08); border-color: var(--primary); color: var(--primary); }
/* Bottom Controls HUD */
#bottom-hud {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 14px;
background: var(--panel-bg); backdrop-filter: blur(12px);
padding: 10px 20px; border-radius: 24px;
border: 1px solid var(--panel-border);
box-shadow: 0 15px 35px rgba(0,0,0,0.6);
}
.weapon-btn {
display: flex; align-items: center; gap: 10px; padding: 6px 14px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
border-radius: 14px; cursor: pointer; transition: all 0.2s; font-weight: 700; font-size: 14px;
}
.weapon-btn:hover { background: rgba(255,255,255,0.18); border-color: var(--primary); transform: translateY(-2px); }
.weapon-btn .weapon-icon { font-size: 20px; }
.power-meter-box {
display: flex; flex-direction: column; gap: 3px; min-width: 140px;
}
.power-meter-bar {
width: 100%; height: 12px; background: rgba(255,255,255,0.15); border-radius: 6px;
overflow: hidden; position: relative;
}
.power-meter-fill {
height: 100%; width: 0%; background: linear-gradient(90deg, #22c55e, #eab308, #ef4444);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5);
}
.stats-info {
display: flex; justify-content: space-between; font-size: 11px; font-weight: 800; color: #94a3b8;
}
/* Weapon Selector Menu Modal / Overlay */
#weapon-menu {
position: absolute; bottom: 85px; left: 50%; transform: translateX(-50%);
background: rgba(15, 23, 42, 0.95); backdrop-filter: blur(20px);
border: 2px solid rgba(255,255,255,0.18); border-radius: 20px;
padding: 16px; display: none; grid-template-columns: repeat(4, 1fr); gap: 10px;
box-shadow: 0 25px 60px rgba(0,0,0,0.8); z-index: 50; width: 440px;
}
#weapon-menu.show { display: grid; animation: popUp 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
@keyframes popUp { from { opacity: 0; transform: translate(-50%, 15px) scale(0.95); } to { opacity: 1; transform: translate(-50%, 0) scale(1); } }
.weapon-card {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 12px 6px; background: rgba(255,255,255,0.05); border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08); cursor: pointer; transition: all 0.15s;
text-align: center; gap: 4px; position: relative;
}
.weapon-card:hover {
background: rgba(245, 158, 11, 0.2); border-color: var(--primary);
transform: translateY(-3px); box-shadow: 0 6px 16px rgba(245, 158, 11, 0.3);
}
.weapon-card.active {
background: var(--primary); color: #000; border-color: #fff;
}
.weapon-card .w-icon { font-size: 24px; }
.weapon-card .w-name { font-size: 11px; font-weight: 800; line-height: 1.1; }
.weapon-card .w-cat { font-size: 9px; opacity: 0.7; text-transform: uppercase; font-weight: 700; }
.weapon-card .w-ammo {
position: absolute; top: 4px; right: 6px; font-size: 10px; font-weight: 900;
background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 4px; color: #fff;
}
/* Modals (Menu, Game Over, Help, Settings) */
.modal-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(7, 11, 20, 0.85); backdrop-filter: blur(10px);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-overlay.hidden { display: none; }
.modal-card {
background: #1e293b; border: 1px solid rgba(255,255,255,0.15);
border-radius: 24px; padding: 28px; width: 90%; max-width: 520px;
box-shadow: 0 30px 60px rgba(0,0,0,0.7); text-align: center;
animation: modalIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
.modal-title { font-size: 28px; font-weight: 900; margin-bottom: 8px; letter-spacing: 1px; color: var(--primary); text-transform: uppercase; }
.modal-subtitle { font-size: 14px; color: #94a3b8; margin-bottom: 22px; }
.settings-grid { display: flex; flex-direction: column; gap: 14px; margin-bottom: 24px; text-align: left; }
.setting-row { display: flex; justify-content: space-between; align-items: center; }
.setting-label { font-size: 14px; font-weight: 700; }
select, input[type="range"], button.btn-menu {
background: #0f172a; border: 1px solid rgba(255,255,255,0.2); color: #fff;
padding: 8px 12px; border-radius: 10px; font-size: 13px; font-weight: 700; outline: none;
cursor: pointer;
}
button.btn-menu:hover { background: #334155; border-color: var(--primary); }
.btn-primary-action {
background: linear-gradient(135deg, #f59e0b, #d97706); color: #000;
font-size: 16px; font-weight: 900; padding: 14px 28px; border: none; border-radius: 16px;
cursor: pointer; box-shadow: 0 10px 25px var(--primary-glow); transition: all 0.2s;
width: 100%; text-transform: uppercase; letter-spacing: 1px;
}
.btn-primary-action:hover { transform: translateY(-2px); box-shadow: 0 14px 30px rgba(245,158,11,0.6); }
/* Mobile Onscreen Controls */
#mobile-controls {
position: absolute; bottom: 80px; width: 100%; padding: 0 20px;
display: flex; justify-content: space-between; pointer-events: none;
display: none; /* Auto revealed on touch devices */
}
.d-pad, .action-pad { display: flex; gap: 10px; pointer-events: auto; }
.touch-btn {
width: 50px; height: 50px; border-radius: 50%; background: rgba(15, 23, 42, 0.75);
border: 2px solid rgba(255,255,255,0.2); color: #fff; font-size: 20px; font-weight: 900;
display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px);
box-shadow: 0 6px 15px rgba(0,0,0,0.4);
}
.touch-btn:active { background: var(--primary); color: #000; transform: scale(0.92); }
/* Instructions modal quick list */
.key-badge {
background: rgba(255,255,255,0.15); padding: 2px 6px; border-radius: 4px;
font-family: monospace; font-size: 11px; font-weight: bold;
}
.guide-row { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 13px; color: #cbd5e1; }
/* Turn banner banner overlay */
#turn-announce {
position: absolute; top: 35%; left: 50%; transform: translate(-50%, -50%) scale(0.5);
background: linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95));
border: 3px solid var(--primary); padding: 18px 45px; border-radius: 28px;
text-align: center; pointer-events: none; opacity: 0;
transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 20px 50px rgba(0,0,0,0.8); z-index: 60;
}
#turn-announce.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
#turn-announce h2 { font-size: 32px; font-weight: 900; text-transform: uppercase; margin-bottom: 4px; }
#turn-announce p { font-size: 14px; font-weight: 700; color: #94a3b8; }
</style>
</head>
<body>
<div id="canvas-container">
<canvas id="gameCanvas"></canvas>
</div>
<div id="ui-layer">
<!-- Top HUD -->
<div id="top-hud" class="interactive">
<div id="turn-badge" class="hud-turn-badge" style="background:#ef4444; color:#fff;">
<span id="turn-team-name">Red Team</span>
<span id="turn-unit-name" style="font-weight:400; font-size:12px; opacity:0.9;">(Worm 1)</span>
</div>
<div id="turn-timer" class="hud-timer">45</div>
<div class="hud-wind">
<span>💨 WIND</span>
<div class="wind-meter">
<div id="wind-fill" class="wind-bar" style="left:50%; width:20%;"></div>
</div>
<span id="wind-text" style="font-family:monospace; min-width:32px;">0 E</span>
</div>
</div>
<!-- Teams Status HUD -->
<div id="teams-hud" class="interactive"></div>
<!-- Top Right Tools -->
<div id="top-right-tools" class="interactive">
<button id="btn-help" class="btn-icon" title="Controls & Help">❓</button>
<button id="btn-audio" class="btn-icon" title="Toggle Sound">🔊</button>
<button id="btn-pause" class="btn-icon" title="Menu / Restart">⚙️</button>
</div>
<!-- Mobile Controls (if touch detected) -->
<div id="mobile-controls">
<div class="d-pad">
<div class="touch-btn" id="m-left">◀</div>
<div class="touch-btn" id="m-right">▶</div>
<div class="touch-btn" id="m-jump">▲</div>
</div>
<div class="action-pad">
<div class="touch-btn" id="m-aim-up">∧</div>
<div class="touch-btn" id="m-aim-down">∨</div>
<div class="touch-btn" id="m-fire" style="background:#ef4444;">💥</div>
</div>
</div>
<!-- Bottom Weapon & Power HUD -->
<div id="bottom-hud" class="interactive">
<div id="btn-weapon-select" class="weapon-btn">
<span class="weapon-icon" id="cur-w-icon">🚀</span>
<div>
<div id="cur-w-name" style="font-size:13px; line-height:1.1;">Bazooka</div>
<div id="cur-w-ammo" style="font-size:10px; color:#94a3b8;">Unlimited</div>
</div>
<span style="font-size:11px; opacity:0.6; margin-left:4px;">▼</span>
</div>
<div class="power-meter-box">
<div class="stats-info">
<span>POWER / CHARGE</span>
<span id="cur-angle-text">45°</span>
</div>
<div class="power-meter-bar">
<div id="power-fill" class="power-fill power-meter-fill"></div>
</div>
</div>
</div>
<!-- Weapon Selector Modal Drawer -->
<div id="weapon-menu" class="interactive">
<!-- Populated dynamically -->
</div>
<!-- Turn Announcement Banner -->
<div id="turn-announce">
<h2 id="banner-team-name">Red Team's Turn</h2>
<p id="banner-unit-name">Wormy is ready for action!</p>
</div>
<!-- Main Menu / Match Config Modal -->
<div id="modal-menu" class="modal-overlay interactive">
<div class="modal-card">
<div class="modal-title">WORMOIDS</div>
<div class="modal-subtitle">Tactical 2D Artillery Mayhem</div>
<div class="settings-grid">
<div class="setting-row">
<span class="setting-label">Terrain Map</span>
<select id="cfg-map">
<option value="islands">Two Islands</option>
<option value="single">Single Island Peak</option>
<option value="cave">Subterranean Cave</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">Teams Count</span>
<select id="cfg-teams">
<option value="2">2 Teams (Red vs Blue)</option>
<option value="3">3 Teams (Red, Blue, Green)</option>
<option value="4" selected>4 Teams (Red, Blue, Green, Yellow)</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">Units per Team</span>
<select id="cfg-units">
<option value="2">2 Units each</option>
<option value="3">3 Units each</option>
<option value="4" selected>4 Units each (16 total)</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">Match Mode</span>
<select id="cfg-ai">
<option value="all-human">Hot-Seat (All Humans)</option>
<option value="vs-cpu" selected>Solo (You vs CPU Teams)</option>
<option value="all-cpu">Spectator (All CPUs)</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">Turn Duration</span>
<select id="cfg-turntime">
<option value="30">30 Seconds</option>
<option value="45" selected>45 Seconds</option>
<option value="60">60 Seconds</option>
</select>
</div>
</div>
<button id="btn-start-game" class="btn-primary-action">START BATTLE</button>
</div>
</div>
<!-- Help & Guide Modal -->
<div id="modal-help" class="modal-overlay hidden interactive">
<div class="modal-card">
<div class="modal-title">Field Manual</div>
<div class="modal-subtitle">Controls & Tactical Information</div>
<div style="text-align:left; margin-bottom:20px;">
<div class="guide-row"><span>Move Left / Right:</span> <span><span class="key-badge">A</span> / <span class="key-badge">D</span> or <span class="key-badge">←</span> <span class="key-badge">→</span></span></div>
<div class="guide-row"><span>Jump / Leap:</span> <span><span class="key-badge">W</span> or <span class="key-badge">↑</span> / <span class="key-badge">Backspace</span></span></div>
<div class="guide-row"><span>Aim Angle:</span> <span><span class="key-badge">Mouse</span> or <span class="key-badge">Q</span> / <span class="key-badge">E</span></span></div>
<div class="guide-row"><span>Charge & Fire Weapon:</span> <span><span class="key-badge">Hold Space</span> or <span class="key-badge">Left Click</span></span></div>
<div class="guide-row"><span>Choose Weapon:</span> <span><span class="key-badge">Tab</span> or Click Bottom HUD</span></div>
<div class="guide-row"><span>Pan Camera:</span> <span><span class="key-badge">Right Click Drag</span> / <span class="key-badge">Middle Drag</span></span></div>
<div class="guide-row"><span>Zoom In / Out:</span> <span><span class="key-badge">Mouse Wheel</span></span></div>
</div>
<div style="font-size:12px; color:#94a3b8; text-align:left; margin-bottom:18px; line-height:1.4;">
⚠️ <strong>Tips:</strong> Water kills instantly! Wind curves Bazooka rockets and affects jump momentum. Earthquakes and Dynamite trigger falling dirt blocks. Airstrikes must be clicked directly on target.
</div>
<button id="btn-close-help" class="btn-primary-action">Understood, Soldier!</button>
</div>
</div>
<!-- Game Over Modal -->
<div id="modal-gameover" class="modal-overlay hidden interactive">
<div class="modal-card">
<div class="modal-title" id="winner-title">VICTORY!</div>
<div class="modal-subtitle" id="winner-subtitle">Red Team has conquered the island!</div>
<div id="game-stats-summary" style="font-size:13px; color:#cbd5e1; margin-bottom:20px; line-height:1.5;"></div>
<button id="btn-restart-game" class="btn-primary-action">Play Again</button>
</div>
</div>
</div>
<script>
/**
* WORMOIDS - High Polish Artillery Game Engine
* Includes:
* - Pixel & Chunk Destructible Dual-Layer Terrain Canvas
* - Parabolic & Direct Projectile Physics with Global Wind
* - Hydrodynamic Rising/Waving Water with Drowning & Splashes
* - Falling Soil / Debris physics chunks
* - Advanced CPU Risk-Assessment AI (Avoid cliffs/water, trajectory simulation)
* - Rich Synthesized Sound Effects (Web Audio API)
* - Dynamic Camera follow, Zooming, Impact Screen Shake & Freeze-frames
* - 8+ Diverse weapons & tactical utilities
*/
// --- SOUND ENGINE (Web Audio API) ---
class SoundManager {
constructor() {
this.ctx = null;
this.enabled = true;
}
init() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
playTone(freq, type, duration, gainStart=0.2, gainEnd=0.001) {
if (!this.enabled || !this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(gainStart, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(gainEnd, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + duration);
} catch(e){}
}
playNoise(duration, filterFreq=1000, gainStart=0.3) {
if (!this.enabled || !this.ctx) return;
try {
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const whiteNoise = this.ctx.createBufferSource();
whiteNoise.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, this.ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(100, this.ctx.currentTime + duration);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(gainStart, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration);
whiteNoise.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
whiteNoise.start();
} catch(e){}
}
explosion(heavy = false) {
this.init();
this.playNoise(heavy ? 0.9 : 0.5, heavy ? 800 : 1200, heavy ? 0.6 : 0.35);
this.playTone(heavy ? 65 : 120, 'sawtooth', heavy ? 0.6 : 0.35, 0.4, 0.01);
}
launch() {
this.init();
if (!this.enabled || !this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(250, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.25);
gain.gain.setValueAtTime(0.25, this.ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.01, this.ctx.currentTime + 0.25);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.25);
}catch(e){}
}
gunshot() {
this.init();
this.playNoise(0.2, 3500, 0.4);
this.playTone(300, 'sawtooth', 0.15, 0.3, 0.01);
}
jump() {
this.init();
this.playTone(320, 'sine', 0.18, 0.15, 0.01);
}
wormHit() {
this.init();
this.playTone(180, 'triangle', 0.2, 0.3, 0.01);
}
splash() {
this.init();
this.playNoise(0.4, 900, 0.3);
this.playTone(280, 'sine', 0.25, 0.2, 0.01);
}
tick(urgent = false) {
this.init();
this.playTone(urgent ? 880 : 440, 'square', 0.04, urgent ? 0.15 : 0.05, 0.001);
}
teleport() {
this.init();
if (!this.enabled || !this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(300, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1400, this.ctx.currentTime + 0.3);
gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.3);
}catch(e){}
}
click() {
this.init();
this.playTone(900, 'sine', 0.03, 0.08, 0.001);
}
}
const sounds = new SoundManager();
// --- WEAPONS DATABASE ---
const WEAPONS = [
{
id: 'bazooka',
name: 'Bazooka',
icon: '🚀',
category: 'Indirect',
type: 'projectile',
damage: 50,
radius: 48,
affectedByWind: true,
bounces: 0,
fuse: 0,
ammo: -1,
desc: 'Classic rocket. Trajectory bends with atmospheric wind.'
},
{
id: 'grenade',
name: 'Grenade',
icon: '💣',
category: 'Indirect',
type: 'grenade',
damage: 60,
radius: 54,
affectedByWind: false,
bounces: 3,
fuse: 3.0,
ammo: -1,
desc: 'Bounces on terrain before detonating after 3 seconds.'
},
{
id: 'cluster',
name: 'Cluster Bomb',
icon: '💥',
category: 'Indirect',
type: 'cluster',
damage: 35,
radius: 38,
clusterCount: 5,
clusterDamage: 25,
clusterRadius: 28,
affectedByWind: false,
bounces: 2,
fuse: 2.2,
ammo: 3,
desc: 'Explodes into 5 bouncing sub-munitions with huge spread.'
},
{
id: 'sniper',
name: 'Sniper Rifle',
icon: '🎯',
category: 'Direct',
type: 'instant_ray',
damage: 45,
radius: 18,
ammo: -1,
desc: 'High velocity pinpoint shot with heavy kinetic knockback.'
},
{
id: 'shotgun',
name: 'Shotgun',
icon: '💥',
category: 'Direct',
type: 'shotgun',
damage: 25,
pellets: 4,
radius: 14,
ammo: -1,
desc: 'Fires a tight spread of 4 buckshot shells.'
},
{
id: 'airstrike',
name: 'Air Strike',
icon: '✈️',
category: 'Map-altering',
type: 'airstrike',
damage: 40,
bombs: 5,
radius: 36,
ammo: 2,
desc: 'Radio in a bomber for 5 napalm explosives directly on coordinate.'
},
{
id: 'dynamite',
name: 'Dynamite',
icon: '🧨',
category: 'Map-altering',
type: 'plantable',
damage: 85,
radius: 75,
fuse: 5.0,
ammo: 2,
desc: 'High-yield explosive. Drops at your feet. Massive crater.'
},
{
id: 'holygrenade',
name: 'Holy Hand Bomb',
icon: '✨',
category: 'Map-altering',
type: 'holy',
damage: 100,
radius: 95,
fuse: 3.5,
ammo: 1,
desc: 'Legendary divine ordinance. Clears huge chunks of earth!'
},
{
id: 'teleport',
name: 'Teleporter',
icon: '🌀',
category: 'Utility',
type: 'teleport',
ammo: 2,
desc: 'Instantly beam active worm to any open point on map.'
},
{
id: 'girder',
name: 'Girder Bridge',
icon: '🏗️',
category: 'Utility',
type: 'girder',
ammo: 3,
desc: 'Place a solid steel platform for bridge or protective shield.'
}
];
const TEAM_PALETTES = [
{ name: 'Red Commando', color: '#ef4444', dark: '#991b1b', light: '#fca5a5' },
{ name: 'Blue Marines', color: '#3b82f6', dark: '#1e40af', light: '#93c5fd' },
{ name: 'Green Guerrilla', color: '#22c55e', dark: '#166534', light: '#86efac' },
{ name: 'Yellow Blitz', color: '#eab308', dark: '#854d0e', light: '#fde047' }
];
const WORM_NAMES = [
['Sparky', 'Blaze', 'Pyro', 'Ash'],
['Splash', 'Frost', 'Torpedo', 'Riptide'],
['Sprout', 'Viper', 'Fern', 'Moss'],
['Zappy', 'Flash', 'Volt', 'Thunder']
];
// --- MAIN ENGINE STATE ---
class WormoidsGame {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
// World Dimensions
this.worldWidth = 2200;
this.worldHeight = 1200;
this.waterLevel = 1080;
this.waterPhase = 0;
// Terrain canvas for fast pixel masks & pixel-accurate collision
this.terrainCanvas = document.createElement('canvas');
this.terrainCanvas.width = this.worldWidth;
this.terrainCanvas.height = this.worldHeight;
this.terrainCtx = this.terrainCanvas.getContext('2d', { willReadFrequently: true });
// Terrain Decor Canvas (grass, dirt veins, rocks)
this.terrainDecorCanvas = document.createElement('canvas');
this.terrainDecorCanvas.width = this.worldWidth;
this.terrainDecorCanvas.height = this.worldHeight;
this.terrainDecorCtx = this.terrainDecorCanvas.getContext('2d');
// Camera
this.camera = {
x: 1100,
y: 600,
targetX: 1100,
targetY: 600,
zoom: 0.9,
targetZoom: 0.9,
shake: 0,
freezeFrames: 0
};
// Environment & Physics constants
this.gravity = 0.32;
this.wind = 0; // -1 to +1
this.windTarget = 0;
// Game Entities
this.teams = [];
this.currentTeamIndex = 0;
this.currentUnitIndex = 0;
this.projectiles = [];
this.particles = [];
this.debrisChunks = [];
this.floatingTexts = [];
// Turn Control
this.turnDuration = 45;
this.turnTimer = 45;
this.turnState = 'aiming'; // 'aiming', 'firing', 'resolving', 'turn_end', 'game_over'
this.lastTickTime = 0;
this.turnStartTime = 0;
this.activeWorm = null;
// Settings
this.gameConfig = {
mapShape: 'islands',
teamCount: 4,
unitsPerTeam: 4,
mode: 'vs-cpu', // 'all-human', 'vs-cpu', 'all-cpu'
turnTime: 45
};
// User Input
this.keys = {};
this.mouse = {
worldX: 0,
worldY: 0,
screenX: 0,
screenY: 0,
isDown: false,
rightDown: false,
dragStart: { x: 0, y: 0 }
};
this.isCharging = false;
this.power = 0; // 0 to 1
this.powerDirection = 1;
// Selected Weapon
this.selectedWeaponIndex = 0;
// Utility placing mode (Girder / AirStrike / Teleport)
this.placementAngle = 0;
// Animation & Loop
this.lastTime = performance.now();
this.initWindow();
this.initEvents();
this.initUI();
}
initWindow() {
const resize = () => {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
};
window.addEventListener('resize', resize);
resize();
}
initEvents() {
window.addEventListener('keydown', (e) => {
this.keys[e.key.toLowerCase()] = true;
this.keys[e.code] = true;
if (e.key === 'Tab') {
e.preventDefault();
this.toggleWeaponMenu();
}
if (e.key === ' ' && !e.repeat && this.isTurnActive() && !this.isActiveWormCPU()) {
const w = this.getCurrentWeapon();
if (w.type !== 'airstrike' && w.type !== 'teleport' && w.type !== 'girder') {
this.startCharging();
}
}
if ((e.key === 'w' || e.key === 'ArrowUp' || e.key === 'Backspace') && this.isTurnActive() && !this.isActiveWormCPU()) {
if (this.activeWorm && this.activeWorm.grounded) {
this.activeWorm.jump();
}
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.key.toLowerCase()] = false;
this.keys[e.code] = false;
if (e.key === ' ' && this.isCharging && !this.isActiveWormCPU()) {
this.releaseFire();
}
});
this.canvas.addEventListener('mousemove', (e) => {
this.mouse.screenX = e.clientX;
this.mouse.screenY = e.clientY;
const worldPos = this.screenToWorld(e.clientX, e.clientY);
this.mouse.worldX = worldPos.x;
this.mouse.worldY = worldPos.y;
if (this.mouse.rightDown) {
const dx = (e.clientX - this.mouse.dragStart.x) / this.camera.zoom;
const dy = (e.clientY - this.mouse.dragStart.y) / this.camera.zoom;
this.camera.targetX -= dx;
this.camera.targetY -= dy;
this.mouse.dragStart = { x: e.clientX, y: e.clientY };
}
// Update worm aim angle if mouse is moved during aiming
if (this.activeWorm && !this.isActiveWormCPU() && this.turnState === 'aiming') {
const dx = this.mouse.worldX - this.activeWorm.x;
const dy = this.mouse.worldY - (this.activeWorm.y - 12);
this.activeWorm.aimAngle = Math.atan2(dy, dx);
this.activeWorm.facing = dx >= 0 ? 1 : -1;
}
});
this.canvas.addEventListener('mousedown', (e) => {
if (e.button === 0) { // Left click
this.mouse.isDown = true;
if (this.isTurnActive() && !this.isActiveWormCPU()) {
const w = this.getCurrentWeapon();
if (w.type === 'airstrike' || w.type === 'teleport' || w.type === 'girder') {
this.executeTargetAction(this.mouse.worldX, this.mouse.worldY);
} else {
this.startCharging();
}
}
} else if (e.button === 2 || e.button === 1) { // Right or middle click drag
e.preventDefault();
this.mouse.rightDown = true;
this.mouse.dragStart = { x: e.clientX, y: e.clientY };
}
});
this.canvas.addEventListener('mouseup', (e) => {
if (e.button === 0) {
this.mouse.isDown = false;
if (this.isCharging && !this.isActiveWormCPU()) {
this.releaseFire();
}
} else if (e.button === 2 || e.button === 1) {
this.mouse.rightDown = false;
}
});
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
this.camera.targetZoom = Math.max(0.5, Math.min(1.8, this.camera.targetZoom * zoomFactor));
}, { passive: false });
// Touch Support for Mobile
this.setupTouchControls();
}
setupTouchControls() {
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
if (isTouch) {
const mob = document.getElementById('mobile-controls');
if (mob) mob.style.display = 'flex';
const hookBtn = (id, onDown, onUp) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('touchstart', (e) => { e.preventDefault(); onDown(); });
el.addEventListener('touchend', (e) => { e.preventDefault(); if (onUp) onUp(); });
};
hookBtn('m-left', () => { this.keys['arrowleft'] = true; }, () => { this.keys['arrowleft'] = false; });
hookBtn('m-right', () => { this.keys['arrowright'] = true; }, () => { this.keys['arrowright'] = false; });
hookBtn('m-jump', () => { if (this.activeWorm) this.activeWorm.jump(); });
hookBtn('m-aim-up', () => { if (this.activeWorm) this.activeWorm.aimAngle -= 0.1; });
hookBtn('m-aim-down', () => { if (this.activeWorm) this.activeWorm.aimAngle += 0.1; });
hookBtn('m-fire', () => { this.startCharging(); }, () => { this.releaseFire(); });
}
}
initUI() {
// Menu Buttons
document.getElementById('btn-start-game').onclick = () => {
sounds.click();
this.gameConfig.mapShape = document.getElementById('cfg-map').value;
this.gameConfig.teamCount = parseInt(document.getElementById('cfg-teams').value);
this.gameConfig.unitsPerTeam = parseInt(document.getElementById('cfg-units').value);
this.gameConfig.mode = document.getElementById('cfg-ai').value;
this.gameConfig.turnTime = parseInt(document.getElementById('cfg-turntime').value);
this.turnDuration = this.gameConfig.turnTime;
document.getElementById('modal-menu').classList.add('hidden');
this.startNewMatch();
};
document.getElementById('btn-pause').onclick = () => {
sounds.click();
document.getElementById('modal-menu').classList.remove('hidden');
};
document.getElementById('btn-help').onclick = () => {
sounds.click();
document.getElementById('modal-help').classList.remove('hidden');
};
document.getElementById('btn-close-help').onclick = () => {
sounds.click();
document.getElementById('modal-help').classList.add('hidden');
};
document.getElementById('btn-audio').onclick = () => {
sounds.enabled = !sounds.enabled;
document.getElementById('btn-audio').innerText = sounds.enabled ? '🔊' : '🔇';
sounds.click();
};
document.getElementById('btn-restart-game').onclick = () => {
sounds.click();
document.getElementById('modal-gameover').classList.add('hidden');
this.startNewMatch();
};
const weaponBtn = document.getElementById('btn-weapon-select');
weaponBtn.onclick = () => {
sounds.click();
this.toggleWeaponMenu();
};
this.populateWeaponMenu();
}
populateWeaponMenu() {
const container = document.getElementById('weapon-menu');
container.innerHTML = '';
WEAPONS.forEach((w, idx) => {
const card = document.createElement('div');
card.className = `weapon-card ${idx === this.selectedWeaponIndex ? 'active' : ''}`;
card.innerHTML = `
<div class="w-ammo">${w.ammo === -1 ? '∞' : w.ammo}</div>
<div class="w-icon">${w.icon}</div>
<div class="w-name">${w.name}</div>
<div class="w-cat">${w.category}</div>
`;
card.onclick = () => {
sounds.click();
this.selectWeapon(idx);
this.toggleWeaponMenu(false);
};
container.appendChild(card);
});
}
toggleWeaponMenu(forceState) {
const menu = document.getElementById('weapon-menu');
if (forceState !== undefined) {
if (forceState) menu.classList.add('show');
else menu.classList.remove('show');
} else {
menu.classList.toggle('show');
}
}
selectWeapon(index) {
this.selectedWeaponIndex = index;
const w = WEAPONS[index];
document.getElementById('cur-w-icon').innerText = w.icon;
document.getElementById('cur-w-name').innerText = w.name;
document.getElementById('cur-w-ammo').innerText = w.ammo === -1 ? 'Unlimited' : `${w.ammo} Left`;
// update active classes in menu
const cards = document.querySelectorAll('.weapon-card');
cards.forEach((c, i) => {
c.classList.toggle('active', i === index);
});
}
getCurrentWeapon() {
return WEAPONS[this.selectedWeaponIndex];
}
isTurnActive() {
return this.turnState === 'aiming' && this.activeWorm && this.activeWorm.alive;
}
isActiveWormCPU() {
if (!this.activeWorm) return false;
const team = this.teams[this.currentTeamIndex];
return team.isCPU;
}
startCharging() {
if (this.isCharging || this.turnState !== 'aiming') return;
const w = this.getCurrentWeapon();
if (w.ammo === 0) {
this.spawnFloatingText(this.activeWorm.x, this.activeWorm.y - 30, 'Out of Ammo!', '#f87171');
return;
}
this.isCharging = true;
this.power = 0.05;
this.powerDirection = 1;
}
releaseFire() {
if (!this.isCharging) return;
this.isCharging = false;
const finalPower = this.power;
this.power = 0;
this.fireWeapon(finalPower);
}
// --- MAP PROCEDURAL GENERATION ---
generateTerrain(mapShape) {
const ctx = this.terrainCtx;
const dCtx = this.terrainDecorCtx;
const W = this.worldWidth;
const H = this.worldHeight;
ctx.clearRect(0, 0, W, H);
dCtx.clearRect(0, 0, W, H);
ctx.fillStyle = '#65a30d'; // Base soil collision fill
// 1D Perlin-like composite noise
const noise = (x, freq, seed) => {
return Math.sin(x * freq + seed) * 0.5 + Math.sin(x * freq * 2.3 + seed * 1.5) * 0.25 + Math.cos(x * freq * 0.7 + seed * 3.1) * 0.25;
};
const seed = Math.random() * 100;
if (mapShape === 'islands') {
// Two distinct land masses separated by water in the middle
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 8) {
let island1 = Math.exp(-Math.pow((x - 550) / 320, 2)) * 480;
let island2 = Math.exp(-Math.pow((x - 1650) / 320, 2)) * 480;
let surfaceY = H - 240 - (island1 + island2) + noise(x, 0.008, seed) * 70;
ctx.lineTo(x, surfaceY);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
} else if (mapShape === 'single') {
// One massive central mountain island
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 8) {
let centerDist = Math.abs(x - W / 2);
let peak = Math.max(0, 600 - Math.pow(centerDist / 38, 1.8));
let surfaceY = H - 200 - peak + noise(x, 0.007, seed) * 90;
ctx.lineTo(x, surfaceY);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
} else if (mapShape === 'cave') {
// Top ceiling and bottom floor with cavernous pockets
ctx.beginPath();
ctx.moveTo(0, H);
// Floor
for (let x = 0; x <= W; x += 8) {
let surfaceY = H - 280 + noise(x, 0.006, seed) * 120;
ctx.lineTo(x, surfaceY);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
// Ceiling
ctx.beginPath();
ctx.moveTo(0, 0);
for (let x = 0; x <= W; x += 8) {
let ceilingY = 280 + noise(x, 0.007, seed + 50) * 140;
ctx.lineTo(x, ceilingY);
}
ctx.lineTo(W, 0);
ctx.closePath();
ctx.fill();
// Add central floating stalactites/pillars
for (let i = 0; i < 3; i++) {
let px = 500 + i * 600 + (Math.random() * 100 - 50);
let py = 550 + noise(px, 0.01, seed) * 80;
ctx.beginPath();
ctx.arc(px, py, 110 + Math.random() * 40, 0, Math.PI * 2);
ctx.fill();
}
}
// Carve random cave bubbles & tunnels in the terrain for tactical depth
ctx.globalCompositeOperation = 'destination-out';
for (let i = 0; i < 7; i++) {
let cx = 200 + Math.random() * (W - 400);
let cy = 500 + Math.random() * 400;
let r = 50 + Math.random() * 60;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
// Build gorgeous visual Decor Layer
this.renderTerrainDecorations();
}
renderTerrainDecorations() {
const dCtx = this.terrainDecorCtx;
const W = this.worldWidth;
const H = this.worldHeight;
// Rich underground dirt & rock
const grad = dCtx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#854d0e');
grad.addColorStop(0.3, '#713f12');
grad.addColorStop(0.7, '#451a03');
grad.addColorStop(1, '#1c1917');
dCtx.fillStyle = grad;
dCtx.fillRect(0, 0, W, H);
// Overlay strata rock stripes
dCtx.fillStyle = 'rgba(0,0,0,0.18)';
for (let y = 0; y < H; y += 35) {
dCtx.fillRect(0, y + (Math.sin(y * 0.05) * 12), W, 12);
}
// Ore and rock specks
dCtx.fillStyle = 'rgba(255,255,255,0.08)';
for (let i = 0; i < 800; i++) {
let rx = Math.random() * W;
let ry = Math.random() * H;
dCtx.beginPath();
dCtx.arc(rx, ry, Math.random() * 3 + 1, 0, Math.PI * 2);
dCtx.fill();
}
// Add lush green surface grass fringe on top of solid pixels
const terrainImg = this.terrainCtx.getImageData(0, 0, W, H);
const data = terrainImg.data;
dCtx.fillStyle = '#22c55e'; // vibrant grass green
// Quick scan surface edges to paint grass
for (let x = 0; x < W; x += 2) {
for (let y = 2; y < H - 2; y += 2) {
const idx = (y * W + x) * 4;
if (data[idx + 3] > 120) { // Solid pixel
const aboveIdx = ((y - 4) * W + x) * 4;
if (aboveIdx >= 0 && data[aboveIdx + 3] < 50) { // Air above it
// Grass crest
dCtx.fillRect(x - 2, y - 2, 4, 8);
}
}
}
}
// Mask the decoration using the collision terrain canvas
dCtx.globalCompositeOperation = 'destination-in';
dCtx.drawImage(this.terrainCanvas, 0, 0);
dCtx.globalCompositeOperation = 'source-over';
}
// Pixel-based terrain destruction crater
destroyTerrain(cx, cy, radius) {
if (radius <= 0) return;
// Punch hole in collision canvas
this.terrainCtx.globalCompositeOperation = 'destination-out';
this.terrainCtx.beginPath();
this.terrainCtx.arc(cx, cy, radius, 0, Math.PI * 2);
this.terrainCtx.fill();
this.terrainCtx.globalCompositeOperation = 'source-over';
// Punch matching hole in visual decor canvas
this.terrainDecorCtx.globalCompositeOperation = 'destination-out';
this.terrainDecorCtx.beginPath();
this.terrainDecorCtx.arc(cx, cy, radius, 0, Math.PI * 2);
this.terrainDecorCtx.fill();
this.terrainDecorCtx.globalCompositeOperation = 'source-over';
// Spawn physics falling debris chunks from the blast
const chunkCount = Math.floor(radius / 4);
for (let i = 0; i < chunkCount; i++) {
const ang = Math.random() * Math.PI * 2;
const dist = Math.random() * radius * 0.8;
const speed = 3 + Math.random() * 7;
this.debrisChunks.push({
x: cx + Math.cos(ang) * dist,
y: cy + Math.sin(ang) * dist,
vx: Math.cos(ang) * speed + (this.wind * 1.5),
vy: Math.sin(ang) * speed - (2 + Math.random() * 4),
radius: 2 + Math.random() * 4.5,
rotation: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.2,
color: Math.random() > 0.4 ? '#854d0e' : '#65a30d',
life: 180
});
}
}
addGirder(x1, y1, x2, y2, thickness = 14) {
const ctx = this.terrainCtx;
const dCtx = this.terrainDecorCtx;
[ctx, dCtx].forEach(c => {
c.strokeStyle = '#64748b';
c.lineWidth = thickness;
c.lineCap = 'round';
c.beginPath();
c.moveTo(x1, y1);
c.lineTo(x2, y2);
c.stroke();
});
// Girder steel rivet visuals
dCtx.strokeStyle = '#94a3b8';
dCtx.lineWidth = 2;
dCtx.beginPath();
dCtx.moveTo(x1, y1);
dCtx.lineTo(x2, y2);
dCtx.stroke();
}
isSolid(x, y) {
if (x < 0 || x >= this.worldWidth || y >= this.worldHeight) return true;
if (y < 0) return false;
const pixel = this.terrainCtx.getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
return pixel[3] > 120; // alpha check
}
// --- MATCH INITIALIZATION ---
startNewMatch() {
this.projectiles = [];
this.particles = [];
this.debrisChunks = [];
this.floatingTexts = [];
this.teams = [];
this.currentTeamIndex = 0;
this.currentUnitIndex = 0;
this.turnState = 'aiming';
this.turnTimer = this.turnDuration;
this.randomizeWind();
// 1. Generate procedural map
this.generateTerrain(this.gameConfig.mapShape);
// 2. Instantiate Teams and Worms
const numTeams = this.gameConfig.teamCount;
const unitsCount = this.gameConfig.unitsPerTeam;
const mode = this.gameConfig.mode;
for (let t = 0; t < numTeams; t++) {
const p = TEAM_PALETTES[t];
let isCPU = false;
if (mode === 'all-cpu') isCPU = true;
else if (mode === 'vs-cpu' && t > 0) isCPU = true;
const team = {
id: t,
name: p.name,
color: p.color,
darkColor: p.dark,
lightColor: p.light,
isCPU: isCPU,
units: []
};
for (let u = 0; u < unitsCount; u++) {
const wormName = WORM_NAMES[t][u % WORM_NAMES[t].length] || `Worm ${u+1}`;
const worm = new Worm(this, team, wormName);
this.spawnWormSafely(worm);
team.units.push(worm);
}
this.teams.push(team);
}
this.selectWeapon(0);
this.setupTurn();
this.updateHUD();
}
spawnWormSafely(worm) {
let safe = false;
let attempts = 0;
while (!safe && attempts < 150) {
attempts++;
const testX = 120 + Math.random() * (this.worldWidth - 240);
let foundGround = false;
let groundY = 0;
for (let y = 100; y < this.waterLevel - 30; y += 4) {
if (this.isSolid(testX, y)) {
groundY = y;
foundGround = true;
break;
}
}
if (foundGround && groundY < this.waterLevel - 50) {
worm.x = testX;
worm.y = groundY - 14;
worm.vx = 0;
worm.vy = 0;
worm.grounded = true;
safe = true;
}
}
}
randomizeWind() {
this.windTarget = (Math.random() * 2 - 1) * 0.8;
this.wind = this.windTarget;
this.updateWindUI();
}
updateWindUI() {
const windBar = document.getElementById('wind-fill');
const windText = document.getElementById('wind-text');
if (!windBar || !windText) return;
const pct = Math.abs(this.wind) * 50;
if (this.wind >= 0) {
windBar.style.left = '50%';
windBar.style.width = `${pct}%`;
windText.innerText = `${Math.round(this.wind * 100)} E`;
windText.style.color = '#38bdf8';
} else {
windBar.style.left = `${50 - pct}%`;
windBar.style.width = `${pct}%`;
windText.innerText = `${Math.round(Math.abs(this.wind) * 100)} W`;
windText.style.color = '#f472b6';
}
}
// --- TURN SYSTEM ---
setupTurn() {
// Pick next active living worm
let attempts = 0;
const totalTeams = this.teams.length;
// Check if match already won
const aliveTeams = this.teams.filter(t => t.units.some(u => u.alive));
if (aliveTeams.length <= 1) {
this.handleGameOver(aliveTeams[0]);
return;
}
let nextWorm = null;
while (!nextWorm && attempts < 30) {
const team = this.teams[this.currentTeamIndex];
const livingWorms = team.units.filter(u => u.alive);
if (livingWorms.length > 0) {
const uIdx = this.currentUnitIndex % livingWorms.length;
nextWorm = livingWorms[uIdx];
} else {
this.currentTeamIndex = (this.currentTeamIndex + 1) % totalTeams;
}
attempts++;
}
if (!nextWorm) return;
this.activeWorm = nextWorm;
this.turnState = 'aiming';
this.turnTimer = this.turnDuration;
this.turnStartTime = performance.now();
this.isCharging = false;
this.power = 0;
this.randomizeWind();
// Focus camera
this.camera.targetX = this.activeWorm.x;
this.camera.targetY = this.activeWorm.y - 40;
// Show banner notification
this.showTurnBanner(this.teams[this.currentTeamIndex].name, `${this.activeWorm.name} (${this.teams[this.currentTeamIndex].isCPU ? 'CPU' : 'Player'})`);
this.updateHUD();
// If CPU, trigger AI thinking routine
if (this.isActiveWormCPU()) {
setTimeout(() => {
if (this.turnState === 'aiming') {
this.executeCPUTurn();
}
}, 900);
}
}
showTurnBanner(teamName, wormName) {
const banner = document.getElementById('turn-announce');
const teamEl = document.getElementById('banner-team-name');
const unitEl = document.getElementById('banner-unit-name');
const curTeam = this.teams[this.currentTeamIndex];
if (banner && teamEl && unitEl) {
teamEl.innerText = teamName;
teamEl.style.color = curTeam.color;
unitEl.innerText = `${wormName}'s Turn!`;
banner.classList.add('show');
setTimeout(() => {
banner.classList.remove('show');
}, 1400);
}
}
endTurn() {
this.turnState = 'turn_end';
this.isCharging = false;
this.power = 0;
setTimeout(() => {
// Step to next team and unit
this.currentUnitIndex++;
this.currentTeamIndex = (this.currentTeamIndex + 1) % this.teams.length;
this.setupTurn();
}, 1200);
}
handleGameOver(winnerTeam) {
this.turnState = 'game_over';
const modal = document.getElementById('modal-gameover');
const title = document.getElementById('winner-title');
const subtitle = document.getElementById('winner-subtitle');
const stats = document.getElementById('game-stats-summary');
if (winnerTeam) {
title.innerText = `🏆 ${winnerTeam.name} WINS!`;
title.style.color = winnerTeam.color;
subtitle.innerText = `Total battlefield victory in Wormoids!`;
} else {
title.innerText = `DRAW GAME!`;
subtitle.innerText = `All worms succumbed to the tides of battle.`;
}
stats.innerHTML = `
Match Summary:<br>
Total Teams: ${this.teams.length}<br>
Casualties: ${this.teams.reduce((acc, t) => acc + t.units.filter(u => !u.alive).length, 0)} worms fallen in combat.
`;
modal.classList.remove('hidden');
}
// --- WEAPON FIRING & ACTION RESOLUTION ---
fireWeapon(chargePower) {
if (!this.activeWorm || !this.activeWorm.alive) return;
const w = this.getCurrentWeapon();
if (w.ammo > 0) w.ammo--;
this.turnState = 'firing';
const angle = this.activeWorm.aimAngle;
const startX = this.activeWorm.x + Math.cos(angle) * 18;
const startY = this.activeWorm.y - 10 + Math.sin(angle) * 18;
const speed = 4 + chargePower * 24;
if (w.type === 'projectile' || w.type === 'grenade' || w.type === 'cluster' || w.type === 'holy') {
sounds.launch();
this.projectiles.push(new Projectile(this, {
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
weapon: w,
owner: this.activeWorm
}));
} else if (w.type === 'instant_ray') {
sounds.gunshot();
this.fireSniperRay(startX, startY, angle, w.damage);
this.onAttackCompleted();
} else if (w.type === 'shotgun') {
sounds.gunshot();
for (let i = 0; i < (w.pellets || 4); i++) {
const spread = (Math.random() - 0.5) * 0.18;
const pSpeed = speed * (0.85 + Math.random() * 0.3);
this.projectiles.push(new Projectile(this, {
x: startX,
y: startY,
vx: Math.cos(angle + spread) * pSpeed,
vy: Math.sin(angle + spread) * pSpeed,
weapon: w,
owner: this.activeWorm
}));
}
} else if (w.type === 'plantable') {
// Plant dynamite at worm feet
sounds.launch();
this.projectiles.push(new Projectile(this, {
x: this.activeWorm.x,
y: this.activeWorm.y - 4,
vx: 0,
vy: 0,
weapon: w,
owner: this.activeWorm
}));
// Worm gets 3 seconds to flee!
this.spawnFloatingText(this.activeWorm.x, this.activeWorm.y - 35, 'RUN FOR IT!!', '#fbbf24');
}
this.updateHUD();
}
fireSniperRay(startX, startY, angle, damage) {
const dirX = Math.cos(angle);
const dirY = Math.sin(angle);
let hitX = startX;
let hitY = startY;
let maxDist = 1800;
for (let d = 0; d < maxDist; d += 4) {
hitX = startX + dirX * d;
hitY = startY + dirY * d;
// Check hit worm
let hitWorm = null;
for (const t of this.teams) {
for (const u of t.units) {
if (u.alive && Math.hypot(u.x - hitX, u.y - 8 - hitY) < 14) {
hitWorm = u;
break;
}
}
if (hitWorm) break;
}
if (hitWorm) {
hitWorm.takeDamage(damage, dirX * 10, dirY * 10 - 2);
this.createExplosion(hitX, hitY, 22, damage * 0.4, false);
break;
}
// Check hit terrain
if (this.isSolid(hitX, hitY)) {
this.createExplosion(hitX, hitY, 26, damage * 0.4, false);
break;
}
}
// Tracer line visual
this.particles.push({
type: 'laser',
x1: startX,
y1: startY,
x2: hitX,
y2: hitY,
life: 25,
maxLife: 25,
color: '#f43f5e'
});
}
executeTargetAction(targetX, targetY) {
const w = this.getCurrentWeapon();
if (w.ammo > 0) w.ammo--;
if (w.type === 'airstrike') {
sounds.launch();
this.turnState = 'firing';
const numBombs = w.bombs || 5;
const spacing = 45;
const startX = targetX - (numBombs * spacing) / 2;
for (let i = 0; i < numBombs; i++) {
setTimeout(() => {
this.projectiles.push(new Projectile(this, {
x: startX + i * spacing + (Math.random() * 15 - 7),
y: -50,
vx: this.wind * 2 + (Math.random() - 0.5),
vy: 8 + Math.random() * 3,
weapon: { ...w, type: 'projectile' },
owner: this.activeWorm
}));
}, i * 180);
}
} else if (w.type === 'teleport') {
if (!this.isSolid(targetX, targetY) && targetY < this.waterLevel - 20) {
sounds.teleport();
this.createTeleportEffect(this.activeWorm.x, this.activeWorm.y);
this.activeWorm.x = targetX;
this.activeWorm.y = targetY;
this.activeWorm.vx = 0;
this.activeWorm.vy = 0;
this.createTeleportEffect(targetX, targetY);
this.onAttackCompleted();
} else {
this.spawnFloatingText(targetX, targetY, 'Blocked!', '#ef4444');
}
} else if (w.type === 'girder') {
const len = 90;
const rad = this.placementAngle;
const x1 = targetX - Math.cos(rad) * len;
const y1 = targetY - Math.sin(rad) * len;
const x2 = targetX + Math.cos(rad) * len;
const y2 = targetY + Math.sin(rad) * len;
this.addGirder(x1, y1, x2, y2, 14);
sounds.jump();
this.createExplosion(targetX, targetY, 15, 0, false);
this.onAttackCompleted();
}
this.updateHUD();
}
onAttackCompleted() {
this.turnState = 'resolving';
// Let physics settle then advance turn
setTimeout(() => {
this.endTurn();
}, 1500);
}
createExplosion(x, y, radius, damage, heavy = false) {
sounds.explosion(heavy);
this.destroyTerrain(x, y, radius);
// Screen Shake & Impact Freeze Frame
this.camera.shake = Math.min(25, this.camera.shake + (heavy ? 18 : 8));
if (heavy) {
this.camera.freezeFrames = 4; // micro-freeze for dramatic weight
}
// Damage & Knockback living worms
for (const t of this.teams) {
for (const u of t.units) {
if (!u.alive) continue;
const dist = Math.hypot(u.x - x, u.y - 8 - y);
if (dist < radius * 1.5) {
const factor = Math.max(0.1, 1 - (dist / (radius * 1.5)));
const hitDmg = Math.round(damage * factor);
const force = (radius / (dist + 8)) * 12;
const kx = ((u.x - x) / (dist + 0.1)) * force;
const ky = ((u.y - 8 - y) / (dist + 0.1)) * force - 3;
u.takeDamage(hitDmg, kx, ky);
}
}
}
// Fire & Smoke Particles
const pCount = Math.floor(radius * 1.2);
for (let i = 0; i < pCount; i++) {
const pAng = Math.random() * Math.PI * 2;
const pSpeed = 1 + Math.random() * (radius / 5);
this.particles.push({
type: 'smoke',
x: x,
y: y,
vx: Math.cos(pAng) * pSpeed + (this.wind * 0.8),
vy: Math.sin(pAng) * pSpeed - (1 + Math.random() * 2),
size: 8 + Math.random() * (radius / 2.5),
color: Math.random() > 0.4 ? 'rgba(249, 115, 22, 0.8)' : 'rgba(75, 85, 99, 0.6)',
life: 30 + Math.random() * 25,
maxLife: 55
});
}
// Shockwave ring
this.particles.push({
type: 'ring',
x: x,
y: y,
radius: 5,
maxRadius: radius * 1.6,
life: 18,
maxLife: 18,
color: 'rgba(255, 255, 255, 0.9)'
});
}
createWaterSplash(x, count = 25) {
sounds.splash();
for (let i = 0; i < count; i++) {
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 1.2;
const spd = 3 + Math.random() * 8;
this.particles.push({
type: 'water',
x: x + (Math.random() - 0.5) * 16,
y: this.waterLevel,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
size: 3 + Math.random() * 4,
color: 'rgba(56, 189, 248, 0.85)',
life: 40,
maxLife: 40
});
}
}
createTeleportEffect(x, y) {
for (let i = 0; i < 30; i++) {
const a = Math.random() * Math.PI * 2;
const s = 1 + Math.random() * 5;
this.particles.push({
type: 'spark',
x: x,
y: y - 8,
vx: Math.cos(a) * s,
vy: Math.sin(a) * s,
size: 2 + Math.random() * 3,
color: '#06b6d4',
life: 25,
maxLife: 25
});
}
}
spawnFloatingText(x, y, text, color = '#fff') {
this.floatingTexts.push({
x: x,
y: y,
text: text,
color: color,
vy: -1.2,
life: 60,
maxLife: 60
});
}
// --- CPU AI RISK-ASSESSMENT LOGIC ---
executeCPUTurn() {
if (!this.activeWorm || !this.activeWorm.alive || this.turnState !== 'aiming') return;
const cpuWorm = this.activeWorm;
const myTeam = this.teams[this.currentTeamIndex];
// Find living enemies
let bestTarget = null;
let highestScore = -9999;
for (const t of this.teams) {
if (t.id === myTeam.id) continue;
for (const enemy of t.units) {
if (!enemy.alive) continue;
const dist = Math.hypot(enemy.x - cpuWorm.x, enemy.y - cpuWorm.y);
// Risk assessment score: Closer enemies with lower health = higher priority
let score = 1000 - dist + (100 - enemy.hp) * 3;
// Hazard penalty: Check if target is near water edge
if (enemy.y > this.waterLevel - 80) score += 300; // high value drownable target!
if (score > highestScore) {
highestScore = score;
bestTarget = enemy;
}
}
}
if (!bestTarget) return;
// AI Weapon Selection Logic
const distToTarget = Math.hypot(bestTarget.x - cpuWorm.x, bestTarget.y - cpuWorm.y);
let chosenWeaponIdx = 0; // default bazooka
if (distToTarget < 120 && Math.random() > 0.5) {
chosenWeaponIdx = 4; // shotgun close range
} else if (distToTarget > 600 && Math.random() > 0.4) {
chosenWeaponIdx = 5; // Airstrike long range if available
if (WEAPONS[chosenWeaponIdx].ammo === 0) chosenWeaponIdx = 0;
} else if (distToTarget > 350 && Math.random() > 0.6) {
chosenWeaponIdx = 3; // Sniper rifle
} else if (distToTarget < 280 && Math.random() > 0.5) {
chosenWeaponIdx = 1; // Grenade
}
this.selectWeapon(chosenWeaponIdx);
const weapon = this.getCurrentWeapon();
// If Airstrike, drop directly on best target
if (weapon.type === 'airstrike') {
setTimeout(() => {
this.executeTargetAction(bestTarget.x + (Math.random() * 20 - 10), bestTarget.y);
}, 1000);
return;
}
// Ballistic Trajectory Simulation to calculate optimal angle & power
let bestAngle = 0;
let bestPower = 0.5;
let minSimulationError = 99999;
const testFacing = bestTarget.x >= cpuWorm.x ? 1 : -1;
cpuWorm.facing = testFacing;
// Simulate 30 candidate trajectory arcs
for (let testDeg = (testFacing === 1 ? -80 : -100); (testFacing === 1 ? testDeg <= 10 : testDeg >= -170); testDeg += (testFacing === 1 ? 5 : -5)) {
const rad = (testDeg * Math.PI) / 180;
for (let testP = 0.3; testP <= 1.0; testP += 0.15) {
const speed = 4 + testP * 24;
let simX = cpuWorm.x + Math.cos(rad) * 18;
let simY = cpuWorm.y - 10 + Math.sin(rad) * 18;
let simVx = Math.cos(rad) * speed;
let simVy = Math.sin(rad) * speed;
let closestDist = 99999;
for (let step = 0; step < 70; step++) {
if (weapon.affectedByWind) simVx += this.wind * 0.12;
simVy += this.gravity;
simX += simVx;
simY += simVy;
const d = Math.hypot(simX - bestTarget.x, simY - bestTarget.y);
if (d < closestDist) closestDist = d;
// Check if hits self or water early (Hazard avoidance)
if (step > 4 && Math.hypot(simX - cpuWorm.x, simY - cpuWorm.y) < 30) {
closestDist += 5000; // huge penalty for self-harm!
break;
}
if (this.isSolid(simX, simY) || simY > this.waterLevel) break;
}
if (closestDist < minSimulationError) {
minSimulationError = closestDist;
bestAngle = rad;
bestPower = testP;
}
}
}
// Add subtle human-like CPU inaccuracy
bestAngle += (Math.random() - 0.5) * 0.06;
bestPower = Math.max(0.1, Math.min(1.0, bestPower + (Math.random() - 0.5) * 0.08));
cpuWorm.aimAngle = bestAngle;
// Simulate charge & fire with visual pacing
setTimeout(() => {
if (this.turnState === 'aiming') {
this.fireWeapon(bestPower);
}
}, 1200);
}
// --- COORDINATE TRANSFORMS ---
screenToWorld(sx, sy) {
const cx = this.canvas.width / 2;
const cy = this.canvas.height / 2;
return {
x: (sx - cx) / this.camera.zoom + this.camera.x,
y: (sy - cy) / this.camera.zoom + this.camera.y
};
}
worldToScreen(wx, wy) {
const cx = this.canvas.width / 2;
const cy = this.canvas.height / 2;
return {
x: (wx - this.camera.x) * this.camera.zoom + cx,
y: (wy - this.camera.y) * this.camera.zoom + cy
};
}
// --- MAIN LOOP UPDATE & RENDER ---
update(dt) {
// Impact freeze frame handling
if (this.camera.freezeFrames > 0) {
this.camera.freezeFrames--;
return;
}
this.waterPhase += dt * 2.5;
// Handle Turn Timer
if (this.turnState === 'aiming') {
this.turnTimer -= dt;
const sec = Math.ceil(this.turnTimer);
const timerEl = document.getElementById('turn-timer');
if (timerEl) {
timerEl.innerText = Math.max(0, sec);
if (sec <= 5) {
timerEl.classList.add('urgent');
if (Math.floor(this.turnTimer * 2) !== Math.floor((this.turnTimer + dt) * 2)) {
sounds.tick(true);
}
} else {
timerEl.classList.remove('urgent');
}
}
if (this.turnTimer <= 0) {
sounds.explosion(false);
this.spawnFloatingText(this.activeWorm.x, this.activeWorm.y - 30, 'Time Expired!', '#ef4444');
this.endTurn();
}
}
// Power Charge Animation
if (this.isCharging) {
this.power += dt * 0.9 * this.powerDirection;
if (this.power >= 1.0) {
this.power = 1.0;
this.powerDirection = -1;
} else if (this.power <= 0.05) {
this.power = 0.05;
this.powerDirection = 1;
}
const pFill = document.getElementById('power-fill');
if (pFill) pFill.style.width = `${Math.round(this.power * 100)}%`;
} else {
const pFill = document.getElementById('power-fill');
if (pFill && this.power === 0) pFill.style.width = '0%';
}
// Update Worms
for (const t of this.teams) {
for (const u of t.units) {
u.update(dt);
}
}
// Active Human Worm Controls
if (this.isTurnActive() && !this.isActiveWormCPU()) {
const worm = this.activeWorm;
if (this.keys['a'] || this.keys['arrowleft']) worm.move(-1);
if (this.keys['d'] || this.keys['arrowright']) worm.move(1);
if (this.keys['q']) worm.aimAngle -= 1.8 * dt;
if (this.keys['e']) worm.aimAngle += 1.8 * dt;
}
// Update Projectiles
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i];
p.update(dt);
if (p.dead) {
this.projectiles.splice(i, 1);
// If all projectiles cleared and turn is firing, resolve turn
if (this.projectiles.length === 0 && this.turnState === 'firing') {
this.onAttackCompleted();
}
}
}
// Update Falling Terrain Debris Chunks
for (let i = this.debrisChunks.length - 1; i >= 0; i--) {
const d = this.debrisChunks[i];
d.vy += this.gravity;
d.vx += this.wind * 0.04;
d.x += d.vx;
d.y += d.vy;
d.rotation += d.vRot;
d.life--;
// Collision with solid terrain
if (this.isSolid(d.x, d.y)) {
d.vx *= 0.4;
d.vy = -d.vy * 0.3;
d.vRot *= 0.5;
}
// Drown in water
if (d.y > this.waterLevel) {
d.vy *= 0.8;
d.vx *= 0.8;
}
if (d.life <= 0 || d.y > this.worldHeight + 50) {
this.debrisChunks.splice(i, 1);
}
}
// Update Particles
for (let i = this.particles.length - 1; i >= 0; i--) {
const pt = this.particles[i];
pt.life--;
if (pt.type === 'smoke' || pt.type === 'spark') {
pt.x += pt.vx;
pt.y += pt.vy;
pt.vx *= 0.96;
pt.vy *= 0.96;
} else if (pt.type === 'water') {
pt.vy += this.gravity;
pt.x += pt.vx;
pt.y += pt.vy;
} else if (pt.type === 'ring') {
pt.radius += (pt.maxRadius - pt.radius) * 0.2;
}
if (pt.life <= 0) this.particles.splice(i, 1);
}
// Update Floating Text
for (let i = this.floatingTexts.length - 1; i >= 0; i--) {
const ft = this.floatingTexts[i];
ft.y += ft.vy;
ft.life--;
if (ft.life <= 0) this.floatingTexts.splice(i, 1);
}
// Camera Tracking & Smooth Interpolation
let targetX = this.camera.x;
let targetY = this.camera.y;
if (this.projectiles.length > 0) {
const leadP = this.projectiles[0];
targetX = leadP.x;
targetY = leadP.y;
} else if (this.activeWorm && this.activeWorm.alive) {
targetX = this.activeWorm.x;
targetY = this.activeWorm.y - 40;
}
this.camera.targetX = Math.max(300, Math.min(this.worldWidth - 300, targetX));
this.camera.targetY = Math.max(200, Math.min(this.worldHeight - 150, targetY));
this.camera.x += (this.camera.targetX - this.camera.x) * 0.08;
this.camera.y += (this.camera.targetY - this.camera.y) * 0.08;
this.camera.zoom += (this.camera.targetZoom - this.camera.zoom) * 0.08;
if (this.camera.shake > 0) {
this.camera.shake *= 0.9;
if (this.camera.shake < 0.2) this.camera.shake = 0;
}
}
draw() {
const ctx = this.ctx;
const W = this.canvas.width;
const H = this.canvas.height;
ctx.clearRect(0, 0, W, H);
// Apply Camera Transform & Screen Shake
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(this.camera.zoom, this.camera.zoom);
let shakeX = 0;
let shakeY = 0;
if (this.camera.shake > 0) {
shakeX = (Math.random() - 0.5) * this.camera.shake * 2;
shakeY = (Math.random() - 0.5) * this.camera.shake * 2;
}
ctx.translate(-this.camera.x + shakeX, -this.camera.y + shakeY);
// 1. Draw Atmospheric Sky & Parallax Clouds
this.drawSky(ctx);
// 2. Draw Terrain (Decor layer clipped by destructible collision mask)
ctx.drawImage(this.terrainDecorCanvas, 0, 0);
// Draw Falling Debris Chunks
for (const d of this.debrisChunks) {
ctx.save();
ctx.translate(d.x, d.y);
ctx.rotate(d.rotation);
ctx.fillStyle = d.color;
ctx.fillRect(-d.radius, -d.radius, d.radius * 2, d.radius * 2);
ctx.restore();
}
// 3. Draw Worms
for (const t of this.teams) {
for (const u of t.units) {
u.draw(ctx);
}
}
// 4. Draw Predictive Aiming Trajectory (for active human worm)
if (this.isTurnActive() && !this.isActiveWormCPU()) {
this.drawAimingArc(ctx);
}
// 5. Draw Projectiles
for (const p of this.projectiles) {
p.draw(ctx);
}
// 6. Draw Hydrodynamic Water
this.drawWater(ctx);
// 7. Draw Particle Effects & Lasers
for (const pt of this.particles) {
if (pt.type === 'laser') {
ctx.strokeStyle = pt.color;
ctx.lineWidth = (pt.life / pt.maxLife) * 5;
ctx.beginPath();
ctx.moveTo(pt.x1, pt.y1);
ctx.lineTo(pt.x2, pt.y2);
ctx.stroke();
} else if (pt.type === 'smoke' || pt.type === 'spark' || pt.type === 'water') {
ctx.fillStyle = pt.color;
ctx.globalAlpha = Math.max(0, pt.life / pt.maxLife);
ctx.beginPath();
ctx.arc(pt.x, pt.y, pt.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
} else if (pt.type === 'ring') {
ctx.strokeStyle = pt.color;
ctx.lineWidth = 3;
ctx.globalAlpha = Math.max(0, pt.life / pt.maxLife);
ctx.beginPath();
ctx.arc(pt.x, pt.y, pt.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1.0;
}
}
// 8. Draw Floating Combat Text
for (const ft of this.floatingTexts) {
ctx.save();
ctx.font = 'bold 16px "Segoe UI", sans-serif';
ctx.fillStyle = ft.color;
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 6;
ctx.globalAlpha = Math.max(0, ft.life / ft.maxLife);
ctx.fillText(ft.text, ft.x, ft.y);
ctx.restore();
}
ctx.restore();
}
drawSky(ctx) {
const W = this.worldWidth;
const H = this.worldHeight;
// Deep atmospheric sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, H);
skyGrad.addColorStop(0, '#0c4a6e');
skyGrad.addColorStop(0.5, '#0369a1');
skyGrad.addColorStop(0.8, '#0284c7');
skyGrad.addColorStop(1, '#38bdf8');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, W, H);
// Parallax background mountain silhouettes
ctx.fillStyle = 'rgba(12, 74, 110, 0.45)';
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 60) {
let my = 580 + Math.sin(x * 0.003) * 140 + Math.cos(x * 0.007) * 80;
ctx.lineTo(x, my);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
}
drawWater(ctx) {
const W = this.worldWidth;
const H = this.worldHeight;
const baseWater = this.waterLevel;
// Multi-layer sine-wave water
ctx.fillStyle = 'rgba(14, 116, 144, 0.75)';
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 16) {
const wave = Math.sin(x * 0.02 + this.waterPhase) * 6 + Math.cos(x * 0.01 - this.waterPhase * 0.8) * 4;
ctx.lineTo(x, baseWater + wave);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
// Frothy water crest line
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 3;
ctx.stroke();
}
drawAimingArc(ctx) {
const worm = this.activeWorm;
const w = this.getCurrentWeapon();
if (w.type === 'airstrike' || w.type === 'teleport' || w.type === 'girder') {
// Draw targeting crosshair
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.mouse.worldX, this.mouse.worldY, 18, 0, Math.PI * 2);
ctx.moveTo(this.mouse.worldX - 25, this.mouse.worldY);
ctx.lineTo(this.mouse.worldX + 25, this.mouse.worldY);
ctx.moveTo(this.mouse.worldX, this.mouse.worldY - 25);
ctx.lineTo(this.mouse.worldX, this.mouse.worldY + 25);
ctx.stroke();
return;
}
const angle = worm.aimAngle;
const pPower = this.isCharging ? this.power : 0.65;
const speed = 4 + pPower * 24;
let simX = worm.x + Math.cos(angle) * 18;
let simY = worm.y - 10 + Math.sin(angle) * 18;
let simVx = Math.cos(angle) * speed;
let simVy = Math.sin(angle) * speed;
ctx.save();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.setLineDash([4, 6]);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(simX, simY);
for (let i = 0; i < 28; i++) {
if (w.affectedByWind) simVx += this.wind * 0.12;
simVy += this.gravity;
simX += simVx;
simY += simVy;
ctx.lineTo(simX, simY);
if (this.isSolid(simX, simY) || simY > this.waterLevel) break;
}
ctx.stroke();
// Aim crosshair dot at end
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.arc(simX, simY, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
updateHUD() {
// Update active team badge
const curTeam = this.teams[this.currentTeamIndex];
if (curTeam && this.activeWorm) {
const badge = document.getElementById('turn-badge');
const teamName = document.getElementById('turn-team-name');
const unitName = document.getElementById('turn-unit-name');
if (badge && teamName && unitName) {
badge.style.background = curTeam.color;
teamName.innerText = curTeam.name;
unitName.innerText = `(${this.activeWorm.name})`;
}
}
// Update angle display
if (this.activeWorm) {
const angleDeg = Math.round((this.activeWorm.aimAngle * 180) / Math.PI);
const angleEl = document.getElementById('cur-angle-text');
if (angleEl) angleEl.innerText = `${angleDeg}°`;
}
// Update Teams Health Bars
const teamsHud = document.getElementById('teams-hud');
if (teamsHud) {
teamsHud.innerHTML = '';
this.teams.forEach((t) => {
const totalHp = t.units.reduce((acc, u) => acc + (u.alive ? u.hp : 0), 0);
const maxHp = t.units.length * 100;
const pct = Math.max(0, (totalHp / maxHp) * 100);
const row = document.createElement('div');
row.className = 'team-status-row';
row.innerHTML = `
<div class="team-dot" style="background:${t.color};"></div>
<span style="min-width:70px; color:${t.color};">${t.name.split(' ')[0]}</span>
<div class="team-hp-bar">
<div class="team-hp-fill" style="width:${pct}%; background:${t.color};"></div>
</div>
<span style="font-family:monospace; min-width:30px; text-align:right;">${totalHp}</span>
`;
teamsHud.appendChild(row);
});
}
}
run() {
const loop = (timestamp) => {
const dt = Math.min(0.1, (timestamp - this.lastTime) / 1000);
this.lastTime = timestamp;
this.update(dt);
this.draw();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
}
// --- WORM UNIT ENTITY ---
class Worm {
constructor(game, team, name) {
this.game = game;
this.team = team;
this.name = name;
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.radius = 12;
this.hp = 100;
this.alive = true;
this.grounded = false;
this.aimAngle = -Math.PI / 4;
this.facing = 1; // 1: right, -1: left
this.animPhase = Math.random() * 10;
}
move(dir) {
this.facing = dir;
this.aimAngle = dir === 1 ? Math.abs(this.aimAngle) : -Math.PI - Math.abs(this.aimAngle);
if (!this.grounded) return;
const stepX = this.x + dir * 2.2;
// Walk over slight inclines (step climbing)
for (let dy = -6; dy <= 6; dy++) {
if (!this.game.isSolid(stepX, this.y + dy) && this.game.isSolid(stepX, this.y + dy + 1)) {
this.x = stepX;
this.y += dy;
sounds.jump();
return;
}
}
}
jump() {
if (!this.grounded) return;
sounds.jump();
this.vy = -7.2;
this.vx = this.facing * 3.5 + this.game.wind * 1.5;
this.grounded = false;
}
takeDamage(amount, kx = 0, ky = 0) {
if (!this.alive) return;
this.hp = Math.max(0, this.hp - amount);
this.vx += kx;
this.vy += ky;
this.grounded = false;
sounds.wormHit();
this.game.spawnFloatingText(this.x, this.y - 25, `-${amount}`, '#ef4444');
if (this.hp <= 0) {
this.die();
}
this.game.updateHUD();
}
die() {
this.alive = false;
this.game.spawnFloatingText(this.x, this.y - 30, '💀 RIP', this.team.color);
// Spurt tombstone or grave explosion
this.game.createExplosion(this.x, this.y, 25, 20, false);
}
update(dt) {
if (!this.alive) return;
this.animPhase += dt * 5;
// Apply gravity
this.vy += this.game.gravity;
this.x += this.vx;
this.y += this.vy;
this.vx *= 0.94; // friction
// Check drowning in water
if (this.y > this.game.waterLevel) {
this.game.createWaterSplash(this.x);
this.takeDamage(100);
return;
}
// Terrain Collision
if (this.game.isSolid(this.x, this.y + 1)) {
if (this.vy > 10) {
// Fall damage for visceral physics
const fallDmg = Math.round((this.vy - 10) * 4);
if (fallDmg > 0) this.takeDamage(fallDmg);
}
this.vy = 0;
this.grounded = true;
// Push out of terrain if embedded
while (this.game.isSolid(this.x, this.y) && this.y > 0) {
this.y -= 1;
}
} else {
this.grounded = false;
}
}
draw(ctx) {
if (!this.alive) return;
ctx.save();
ctx.translate(this.x, this.y);
const isActive = this.game.activeWorm === this;
// Active Arrow Indicator
if (isActive) {
const bob = Math.sin(this.animPhase * 2) * 5;
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.moveTo(0, -32 + bob);
ctx.lineTo(-7, -42 + bob);
ctx.lineTo(7, -42 + bob);
ctx.closePath();
ctx.fill();
}
// Name & HP Bar
ctx.font = 'bold 10px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
ctx.fillText(this.name, 0, -22);
// HP Mini Bar
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(-16, -18, 32, 4);
ctx.fillStyle = this.hp > 40 ? '#22c55e' : (this.hp > 20 ? '#eab308' : '#ef4444');
ctx.fillRect(-16, -18, (this.hp / 100) * 32, 4);
ctx.shadowBlur = 0;
// Draw Quirky Worm Body
ctx.scale(this.facing, 1);
// Cute animated worm segments
ctx.fillStyle = this.team.color;
ctx.strokeStyle = this.team.darkColor;
ctx.lineWidth = 2;
// Tail segment
ctx.beginPath();
ctx.arc(-8, -4 + Math.sin(this.animPhase) * 1.5, 6, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Body segment
ctx.beginPath();
ctx.arc(-3, -7, 7, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Head segment
ctx.beginPath();
ctx.arc(4, -10, 8, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Big expressive eyes
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(7, -12, 3.5, 0, Math.PI * 2);
ctx.fill();
// Pupils
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(8.5, -12, 1.8, 0, Math.PI * 2);
ctx.fill();
// Draw active weapon in hand
if (isActive) {
const curW = this.game.getCurrentWeapon();
ctx.font = '14px sans-serif';
ctx.fillText(curW.icon, 8, -6);
}
ctx.restore();
}
}
// --- PROJECTILE ENTITY ---
class Projectile {
constructor(game, opts) {
this.game = game;
this.x = opts.x;
this.y = opts.y;
this.vx = opts.vx;
this.vy = opts.vy;
this.weapon = opts.weapon;
this.owner = opts.owner;
this.bounces = opts.weapon.bounces || 0;
this.fuse = opts.weapon.fuse || 0;
this.age = 0;
this.dead = false;
this.radius = 4;
}
update(dt) {
if (this.dead) return;
this.age += dt;
// Physics
if (this.weapon.affectedByWind) {
this.vx += this.game.wind * 0.14;
}
this.vy += this.game.gravity;
this.x += this.vx;
this.y += this.vy;
// Smoke trail particles
if (Math.random() > 0.3) {
this.game.particles.push({
type: 'smoke',
x: this.x,
y: this.y,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
size: 3 + Math.random() * 3,
color: 'rgba(255, 200, 100, 0.6)',
life: 20,
maxLife: 20
});
}
// Water entry
if (this.y > this.game.waterLevel) {
this.game.createWaterSplash(this.x);
this.dead = true;
return;
}
// Fuse detonation
if (this.fuse > 0 && this.age >= this.fuse) {
this.detonate();
return;
}
// Direct Hit on Worms
for (const t of this.game.teams) {
for (const u of t.units) {
if (u.alive && Math.hypot(u.x - this.x, u.y - 8 - this.y) < 14) {
this.detonate();
return;
}
}
}
// Terrain Collision
if (this.game.isSolid(this.x, this.y)) {
if (this.bounces > 0) {
this.bounces--;
sounds.wormHit();
this.vx *= -0.55;
this.vy *= -0.55;
this.x += this.vx;
this.y += this.vy;
} else {
this.detonate();
}
}
}
detonate() {
this.dead = true;
const w = this.weapon;
if (w.type === 'cluster') {
// Explode into bouncing cluster sub-bombs
this.game.createExplosion(this.x, this.y, w.radius, w.damage, false);
for (let i = 0; i < (w.clusterCount || 5); i++) {
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 1.8;
const spd = 4 + Math.random() * 6;
this.game.projectiles.push(new Projectile(this.game, {
x: this.x,
y: this.y - 5,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
weapon: {
damage: w.clusterDamage,
radius: w.clusterRadius,
bounces: 1,
fuse: 1.5,
affectedByWind: false
},
owner: this.owner
}));
}
} else {
const isHeavy = w.radius >= 65;
this.game.createExplosion(this.x, this.y, w.radius, w.damage, isHeavy);
}
}
draw(ctx) {
if (this.dead) return;
ctx.save();
ctx.translate(this.x, this.y);
const angle = Math.atan2(this.vy, this.vx);
ctx.rotate(angle);
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.weapon.icon || '🚀', 0, 0);
// Fuse countdown timer text for grenades
if (this.fuse > 0) {
const rem = Math.max(0, Math.ceil(this.fuse - this.age));
ctx.rotate(-angle); // unrotate text
ctx.font = 'bold 12px monospace';
ctx.fillStyle = '#ef4444';
ctx.fillText(`${rem}`, 0, -14);
}
ctx.restore();
}
}
// Instantiate and kick off game engine
window.addEventListener('DOMContentLoaded', () => {
window.game = new WormoidsGame();
window.game.run();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"google/gemini-3.7-flash","provider":"openrouter","persona":"full","sessionId":"db77d1ba-119d-45b7-951d-f903f0c6b930","tokensIn":309977,"tokensOut":34224,"tokensTotal":344201,"cost":0.15129180000000003,"turns":10,"toolCalls":9,"failedToolCalls":0,"timestamp":"2026-08-15T22:15:16.313Z"} -->