Worms
Qwen 3.6 27b MTP · 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 95,629 bytes · SHA-256 73c73350b6af
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Worm Warfare</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a1a; overflow: hidden; font-family: 'Segoe UI', Tahoma, sans-serif; user-select: none; }
canvas { display: block; cursor: crosshair; }
#ui-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
#ui-overlay > * { pointer-events: auto; }
/* Menu Screen */
#menu-screen {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: linear-gradient(180deg, #0a0e27 0%, #1a1040 50%, #2d1b69 100%);
z-index: 100;
}
#menu-screen h1 {
font-size: 72px; color: #ffcc00; text-shadow: 0 0 30px #ff6600, 0 4px 8px rgba(0,0,0,0.5);
margin-bottom: 10px; letter-spacing: 4px; font-weight: 900;
}
#menu-screen .subtitle { color: #aaa; font-size: 16px; margin-bottom: 40px; }
.menu-section { margin-bottom: 25px; text-align: center; }
.menu-section label { color: #ccc; font-size: 14px; display: block; margin-bottom: 8px; }
.menu-row { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.menu-btn {
padding: 12px 28px; border: 2px solid #555; background: rgba(255,255,255,0.05);
color: #ddd; font-size: 16px; cursor: pointer; border-radius: 8px; transition: all 0.2s;
min-width: 100px; text-align: center;
}
.menu-btn:hover { background: rgba(255,255,255,0.15); border-color: #ffcc00; color: #fff; }
.menu-btn.selected { background: rgba(255,204,0,0.2); border-color: #ffcc00; color: #ffcc00; font-weight: bold; }
#start-btn {
margin-top: 30px; padding: 16px 60px; font-size: 22px; background: linear-gradient(180deg, #ff6600, #cc3300);
border: none; color: white; cursor: pointer; border-radius: 12px; font-weight: bold;
text-shadow: 0 2px 4px rgba(0,0,0,0.5); box-shadow: 0 4px 15px rgba(255,102,0,0.4);
transition: all 0.2s; letter-spacing: 2px;
}
#start-btn:hover { transform: scale(1.05); box-shadow: 0 6px 25px rgba(255,102,0,0.6); }
/* HUD */
#hud-top {
position: absolute; top: 0; left: 0; width: 100%; padding: 8px 16px;
display: flex; justify-content: space-between; align-items: center;
background: linear-gradient(180deg, rgba(0,0,0,0.7) 0%, transparent 100%);
}
.team-info { display: flex; gap: 6px; align-items: center; }
.team-badge {
padding: 4px 12px; border-radius: 4px; font-size: 13px; font-weight: bold; color: white;
text-shadow: 0 1px 2px rgba(0,0,0,0.5); min-width: 80px; text-align: center;
}
.team-badge.active { box-shadow: 0 0 12px currentColor; animation: pulse-glow 1s infinite alternate; }
@keyframes pulse-glow { from { opacity: 0.7; } to { opacity: 1; } }
#turn-timer-container { text-align: center; min-width: 160px; }
#turn-timer-bar { width: 140px; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden; margin-top: 4px; }
#turn-timer-fill { height: 100%; background: #4f4; transition: width 0.1s linear, background 0.3s; border-radius: 4px; }
#turn-timer-text { color: white; font-size: 20px; font-weight: bold; text-shadow: 0 0 8px rgba(255,255,255,0.3); }
#wind-indicator {
position: absolute; top: 60px; left: 50%; transform: translateX(-50%);
color: white; font-size: 14px; text-align: center; background: rgba(0,0,0,0.5);
padding: 4px 16px; border-radius: 20px;
}
/* Bottom HUD */
#hud-bottom {
position: absolute; bottom: 0; left: 0; width: 100%; padding: 8px 16px;
display: flex; justify-content: space-between; align-items: flex-end;
background: linear-gradient(0deg, rgba(0,0,0,0.7) 0%, transparent 100%);
}
/* Unit Info */
#unit-info {
color: white; font-size: 14px; min-width: 200px;
}
#unit-name { font-weight: bold; font-size: 18px; margin-bottom: 4px; }
#health-bar-container { width: 180px; height: 16px; background: rgba(0,0,0,0.5); border-radius: 8px; overflow: hidden; border: 2px solid rgba(255,255,255,0.2); }
#health-bar { height: 100%; background: linear-gradient(90deg, #ff3333, #44ff44); transition: width 0.3s; border-radius: 6px; }
/* Weapon Panel */
#weapon-panel {
display: flex; gap: 6px; align-items: center; padding: 8px;
background: rgba(0,0,0,0.5); border-radius: 12px; margin-bottom: 4px;
}
.weapon-btn {
width: 56px; height: 56px; border: 2px solid #444; background: rgba(255,255,255,0.05);
color: white; cursor: pointer; border-radius: 8px; display: flex; flex-direction: column;
align-items: center; justify-content: center; font-size: 10px; transition: all 0.15s;
}
.weapon-btn:hover { background: rgba(255,255,255,0.15); border-color: #888; }
.weapon-btn.selected { border-color: #ffcc00; background: rgba(255,204,0,0.2); box-shadow: 0 0 10px rgba(255,204,0,0.3); }
.weapon-btn .icon { font-size: 22px; margin-bottom: 2px; }
/* Action Buttons */
#action-buttons { display: flex; gap: 8px; align-items: center; }
.action-btn {
padding: 10px 24px; border: none; color: white; cursor: pointer; border-radius: 8px;
font-size: 14px; font-weight: bold; transition: all 0.15s; text-transform: uppercase; letter-spacing: 1px;
}
#skip-btn { background: linear-gradient(180deg, #666, #444); }
#skip-btn:hover { background: linear-gradient(180deg, #777, #555); }
/* Power Meter */
#power-meter-container {
position: absolute; bottom: 120px; left: 50%; transform: translateX(-50%);
width: 300px; height: 24px; background: rgba(0,0,0,0.6); border-radius: 12px;
overflow: hidden; border: 2px solid rgba(255,255,255,0.2); display: none;
}
#power-meter-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #4f4, #ff0, #f44); border-radius: 10px; transition: width 0.05s; }
#power-text { position: absolute; top: -22px; left: 50%; transform: translateX(-50%); color: white; font-size: 16px; font-weight: bold; text-shadow: 0 0 4px black; }
/* Angle display */
#angle-display {
position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
color: white; font-size: 20px; font-weight: bold; text-shadow: 0 0 8px rgba(0,0,0,0.8); display: none;
}
/* Game Over */
#game-over {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); display: none; flex-direction: column;
align-items: center; justify-content: center; z-index: 200;
}
#game-over h2 { font-size: 64px; color: #ffcc00; text-shadow: 0 0 30px #ff6600; margin-bottom: 20px; }
#game-over p { color: #ccc; font-size: 20px; margin-bottom: 30px; }
/* Turn announcement */
#turn-announce {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: white; font-size: 48px; font-weight: bold; text-shadow: 0 0 20px rgba(255,255,255,0.5), 0 4px 8px rgba(0,0,0,0.8);
opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 50; text-align: center;
}
/* Utility items */
#utility-panel { display: flex; gap: 4px; margin-top: 6px; }
.util-btn {
width: 40px; height: 32px; border: 1px solid #555; background: rgba(255,255,255,0.08);
color: #ccc; cursor: pointer; border-radius: 4px; font-size: 9px; display: flex;
align-items: center; justify-content: center; transition: all 0.15s;
}
.util-btn:hover { background: rgba(255,255,255,0.2); }
.util-btn.selected { border-color: #ffcc00; color: #ffcc00; }
/* Minimap */
#minimap-container {
position: absolute; top: 60px; right: 10px; width: 180px; height: 100px;
background: rgba(0,0,0,0.5); border-radius: 8px; overflow: hidden; border: 2px solid rgba(255,255,255,0.1);
}
#minimap { width: 100%; height: 100%; }
/* Chat/Log */
#game-log {
position: absolute; top: 60px; left: 10px; max-width: 250px;
color: rgba(255,255,255,0.7); font-size: 12px; pointer-events: none;
}
.log-entry { padding: 2px 8px; background: rgba(0,0,0,0.3); border-radius: 4px; margin-bottom: 2px; animation: fade-log 5s forwards; }
@keyframes fade-log { from { opacity: 1; } to { opacity: 0; } }
/* Zoom controls */
#zoom-controls {
position: absolute; bottom: 130px; right: 16px; display: flex; gap: 4px;
}
.zoom-btn {
width: 32px; height: 32px; border: 1px solid #555; background: rgba(0,0,0,0.5);
color: white; cursor: pointer; border-radius: 6px; font-size: 18px; display: flex;
align-items: center; justify-content: center;
}
.zoom-btn:hover { background: rgba(255,255,255,0.2); }
/* Team colors */
.team-0 { color: #ff4444; } .team-bg-0 { background-color: #cc3333; }
.team-1 { color: #4488ff; } .team-bg-1 { background-color: #3366cc; }
.team-2 { color: #44dd44; } .team-bg-2 { background-color: #33aa33; }
.team-3 { color: #ddaa00; } .team-bg-3 { background-color: #bb8800; }
/* Instructions overlay */
#instructions {
position: absolute; bottom: 160px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.6); font-size: 13px; text-align: center; pointer-events: none;
}
</style>
</head>
<body>
<canvas id="game-canvas"></canvas>
<div id="ui-overlay">
<!-- Menu Screen -->
<div id="menu-screen">
<h1>🪱 WORM WARFARE</h1>
<p class="subtitle">Tactical Artillery Mayhem</p>
<div class="menu-section">
<label>Terrain Type</label>
<div class="menu-row" id="terrain-select">
<button class="menu-btn selected" data-val="island">🏝️ Island</button>
<button class="menu-btn" data-val="cave">🕳️ Cave</button>
<button class="menu-btn" data-val="dual-islands">⛰️ Dual Islands</button>
</div>
</div>
<div class="menu-section">
<label>Number of Teams</label>
<div class="menu-row" id="teams-select">
<button class="menu-btn" data-val="2">2 Teams</button>
<button class="menu-btn selected" data-val="3">3 Teams</button>
<button class="menu-btn" data-val="4">4 Teams</button>
</div>
</div>
<div class="menu-section">
<label>CPU Difficulty</label>
<div class="menu-row" id="difficulty-select">
<button class="menu-btn" data-val="easy">Easy</button>
<button class="menu-btn selected" data-val="medium">Medium</button>
<button class="menu-btn" data-val="hard">Hard</button>
</div>
</div>
<div class="menu-section">
<label>CPU Teams (rest are human)</label>
<div class="menu-row" id="cpu-select">
<button class="menu-btn selected" data-val="1">1 CPU</button>
<button class="menu-btn" data-val="2">2 CPU</button>
<button class="menu-btn" data-val="3">3 CPU</button>
<button class="menu-btn" data-val="4">All CPU</button>
</div>
</div>
<button id="start-btn">⚔️ START BATTLE ⚔️</button>
</div>
<!-- HUD -->
<div id="hud-top" style="display:none;">
<div class="team-info" id="team-badges"></div>
<div id="turn-timer-container">
<div id="turn-timer-text">30</div>
<div id="turn-timer-bar"><div id="turn-timer-fill"></div></div>
</div>
<div style="display:flex;gap:8px;">
<button class="action-btn" id="sound-toggle" style="background:#444;padding:6px 12px;font-size:12px;">🔊</button>
<button class="action-btn" id="menu-btn" style="background:#444;padding:6px 12px;font-size:12px;">☰ Menu</button>
</div>
</div>
<div id="wind-indicator" style="display:none;">💨 Wind: <span id="wind-value">0</span></div>
<div id="hud-bottom" style="display:none;">
<div id="unit-info">
<div id="unit-name">Worm 1</div>
<div id="health-bar-container"><div id="health-bar"></div></div>
<div id="utility-panel"></div>
</div>
<div style="text-align:center;">
<div id="weapon-panel"></div>
<div id="action-buttons">
<button class="action-btn" id="skip-btn">Skip Turn</button>
</div>
</div>
<div style="min-width:200px;text-align:right;color:white;font-size:13px;" id="turn-info"></div>
</div>
<div id="power-meter-container"><div id="power-text">Power</div><div id="power-meter-fill"></div></div>
<div id="angle-display">0°</div>
<div id="minimap-container" style="display:none;"><canvas id="minimap"></canvas></div>
<div id="game-log"></div>
<div id="turn-announce"></div>
<div id="instructions"></div>
<div id="zoom-controls" style="display:none;">
<button class="zoom-btn" id="zoom-in">+</button>
<button class="zoom-btn" id="zoom-out">−</button>
</div>
<!-- Game Over -->
<div id="game-over">
<h2 id="winner-text">Team Wins!</h2>
<p id="winner-sub"></p>
<button class="action-btn" id="restart-btn" style="background:linear-gradient(180deg,#ff6600,#cc3300);padding:14px 50px;font-size:18px;">Play Again</button>
</div>
</div>
<script>
// ============================================================
// WORM WARFARE - Complete Game Engine
// ============================================================
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
const minimapCanvas = document.getElementById('minimap');
const minimapCtx = minimapCanvas.getContext('2d');
// --- Constants ---
const WORLD_W = 3000;
const WORLD_H = 1500;
const GRAVITY = 0.4;
const WATER_LEVEL = WORLD_H - 200;
const TURN_TIME = 30; // seconds per turn
const UNIT_MAX_HP = 100;
const MOVE_SPEED = 3;
const JUMP_FORCE = -9;
const FRICTION_GROUND = 0.85;
const FRICTION_AIR = 0.98;
// --- Audio Engine (Web Audio API) ---
class SoundEngine {
constructor() {
this.ctx = null;
this.enabled = true;
try { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
}
resume() { if(this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); }
play(type, params = {}) {
if (!this.enabled || !this.ctx) return;
this.resume();
const t = this.ctx.currentTime;
switch(type) {
case 'click': this._tone(800, 0.05, 'square', 0.1); break;
case 'select': this._tone(600, 0.08, 'sine', 0.15); break;
case 'explosion': this._noise(params.duration || 0.4, params.vol || 0.3); break;
case 'big_explosion':
this._noise(0.8, 0.5);
this._tone(60, 0.6, 'sine', 0.4);
break;
case 'hit': this._tone(200 + Math.random()*300, 0.1, 'square', 0.2); break;
case 'jump': this._tone(400, 0.15, 'sine', 0.1, {freqEnd: 800}); break;
case 'weapon_fire':
if(params.weapon === 'nuke') { this._noise(1.2, 0.6); this._tone(40, 1.0, 'sawtooth', 0.5); }
else if(params.weapon === 'earthquake') { this._noise(0.8, 0.4); this._tone(30, 0.8, 'sine', 0.3); }
else this._tone(150 + Math.random()*200, 0.2, 'square', 0.15);
break;
case 'drown': this._noise(0.3, 0.15); this._tone(300, 0.4, 'sine', 0.1, {freqEnd: 100}); break;
case 'death':
this._tone(500, 0.3, 'sawtooth', 0.2, {freqEnd: 100});
this._noise(0.3, 0.2);
break;
case 'timer_warning': this._tone(880, 0.05, 'square', 0.1); break;
case 'turn_start':
this._tone(440, 0.1, 'sine', 0.2);
setTimeout(() => { if(this.ctx) this._tone(660, 0.15, 'sine', 0.2); }, 100);
break;
case 'splash': this._noise(0.2, 0.15); break;
case 'laser':
const o = this.ctx.createOscillator(); const g = this.ctx.createGain();
o.type = 'sawtooth'; o.frequency.setValueAtTime(2000, t);
o.frequency.exponentialRampToValueAtTime(200, t + 0.3);
g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
o.connect(g).connect(this.ctx.destination); o.start(t); o.stop(t+0.3);
break;
case 'medkit': this._tone(600, 0.1, 'sine', 0.15); setTimeout(()=>this._tone(900,0.1,'sine',0.15),80); break;
case 'parachute': this._noise(0.4, 0.08); break;
}
}
_tone(freq, dur, type='sine', vol=0.1, opts={}) {
if(!this.ctx) return;
const t = this.ctx.currentTime;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if(opts.freqEnd) o.frequency.exponentialRampToValueAtTime(opts.freqEnd, t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g).connect(this.ctx.destination);
o.start(t); o.stop(t + dur);
}
_noise(dur, vol) {
if(!this.ctx) return;
const t = this.ctx.currentTime;
const bufSize = this.ctx.sampleRate * dur;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for(let i=0;i<bufSize;i++) data[i] = (Math.random()*2-1) * Math.pow(1 - i/bufSize, 2);
const src = this.ctx.createBufferSource();
const g = this.ctx.createGain();
const filt = this.ctx.createBiquadFilter();
filt.type = 'lowpass'; filt.frequency.value = 800;
src.buffer = buf;
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(filt).connect(g).connect(this.ctx.destination);
src.start(t);
}
// Timer tick sound that speeds up as time runs out
playTimerTick(urgency) {
if(!this.enabled || !this.ctx) return;
const freq = 600 + urgency * 800;
this._tone(freq, 0.03, 'square', 0.05);
}
}
const sound = new SoundEngine();
// --- Particle System ---
class Particle {
constructor(x, y, vx, vy, life, color, size, type='circle') {
this.x = x; this.y = y; this.vx = vx; this.vy = vy;
this.life = life; this.maxLife = life;
this.color = color; this.size = size; this.type = type;
this.gravity = 0.15; this.alpha = 1;
}
update() {
this.x += this.vx; this.y += this.vy;
this.vy += this.gravity;
this.life--;
this.alpha = Math.max(0, this.life / this.maxLife);
return this.life > 0;
}
draw(ctx) {
ctx.globalAlpha = this.alpha;
if(this.type === 'circle') {
ctx.fillStyle = this.color;
ctx.beginPath(); ctx.arc(this.x, this.y, this.size * this.alpha, 0, Math.PI*2); ctx.fill();
} else if(this.type === 'debris') {
ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(Math.random()*6.28);
ctx.fillStyle = this.color;
ctx.fillRect(-this.size/2, -this.size/2, this.size, this.size * 0.6);
ctx.restore();
} else if(this.type === 'smoke') {
ctx.fillStyle = this.color;
const s = this.size * (1 + (1-this.alpha)*2);
ctx.beginPath(); ctx.arc(this.x, this.y, s, 0, Math.PI*2); ctx.fill();
} else if(this.type === 'spark') {
ctx.strokeStyle = this.color; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(this.x, this.y);
ctx.lineTo(this.x - this.vx*3, this.y - this.vy*3); ctx.stroke();
}
ctx.globalAlpha = 1;
}
}
class ParticleSystem {
constructor() { this.particles = []; }
emit(x, y, count, config) {
for(let i=0;i<count;i++) {
const angle = config.angle !== undefined ? config.angle + (Math.random()-0.5)*(config.spread||1) : Math.random()*Math.PI*2;
const speed = (config.speedMin||1) + Math.random()*((config.speedMax||3)-(config.speedMin||1));
this.particles.push(new Particle(
x + (Math.random()-0.5)*(config.offsetX||0),
y + (Math.random()-0.5)*(config.offsetY||0),
Math.cos(angle)*speed, Math.sin(angle)*speed,
config.lifeMin + Math.random()*((config.lifeMax||30)-(config.lifeMin||15)),
config.colors ? config.colors[Math.floor(Math.random()*config.colors.length)] : (config.color||'#ff8800'),
config.sizeMin + Math.random()*((config.sizeMax||4)-(config.sizeMin||2)),
config.type || 'circle'
));
}
}
explosion(x, y, radius) {
// Fire particles
this.emit(x, y, 30, { angle: -Math.PI/2, spread: Math.PI*1.5, speedMin: 2, speedMax: 8, lifeMin: 15, lifeMax: 40, colors: ['#ff4400','#ff8800','#ffcc00','#ffee44'], sizeMin: 3, sizeMax: 8 });
// Smoke
this.emit(x, y, 15, { angle: -Math.PI/2, spread: Math.PI*0.8, speedMin: 0.5, speedMax: 3, lifeMin: 30, lifeMax: 60, colors: ['#444','#555','#666'], sizeMin: 5, sizeMax: 12, type: 'smoke' });
// Debris chunks
this.emit(x, y, 20, { angle: -Math.PI/2, spread: Math.PI*1.2, speedMin: 3, speedMax: 10, lifeMin: 40, lifeMax: 80, colors: ['#8B6914','#A0782C','#6B5010'], sizeMin: 3, sizeMax: 8, type: 'debris' });
// Sparks
this.emit(x, y, 15, { angle: -Math.PI/2, spread: Math.PI*2, speedMin: 4, speedMax: 12, lifeMin: 10, lifeMax: 25, colors: ['#ffff88','#ffffff','#ffcc00'], sizeMin: 1, sizeMax: 3, type: 'spark' });
// Ring particles
for(let i=0;i<24;i++) {
const a = (i/24)*Math.PI*2;
this.particles.push(new Particle(x,y, Math.cos(a)*(radius*0.15), Math.sin(a)*(radius*0.15), 20, '#ff6600', radius*0.3, 'circle'));
}
}
smallExplosion(x, y) {
this.emit(x, y, 10, { angle: -Math.PI/2, spread: Math.PI, speedMin: 1, speedMax: 4, lifeMin: 10, lifeMax: 25, colors: ['#ff8800','#ffcc00'], sizeMin: 2, sizeMax: 5 });
this.emit(x, y, 5, { angle: -Math.PI/2, spread: Math.PI*0.6, speedMin: 0.3, speedMax: 1.5, lifeMin: 20, lifeMax: 40, colors: ['#555','#777'], sizeMin: 3, sizeMax: 8, type: 'smoke' });
}
dust(x, y) {
this.emit(x, y, 5, { angle: -Math.PI/2, spread: Math.PI*0.6, speedMin: 0.5, speedMax: 2, lifeMin: 15, lifeMax: 30, colors: ['#aa9977','#bb9955'], sizeMin: 2, sizeMax: 5, type: 'smoke' });
}
splash(x, y) {
this.emit(x, y, 12, { angle: -Math.PI/2, spread: Math.PI*0.8, speedMin: 2, speedMax: 6, lifeMin: 15, lifeMax: 30, colors: ['#4488ff','#66aaff','#88ccff'], sizeMin: 2, sizeMax: 5 });
}
blood(x, y) {
this.emit(x, y, 8, { angle: -Math.PI/2, spread: Math.PI*0.8, speedMin: 1, speedMax: 4, lifeMin: 10, lifeMax: 25, colors: ['#ff3333','#cc0000','#ff6666'], sizeMin: 2, sizeMax: 4 });
}
update() { this.particles = this.particles.filter(p => p.update()); }
draw(ctx) { this.particles.forEach(p => p.draw(ctx)); }
}
const particles = new ParticleSystem();
// --- Floating Combat Text ---
class FloatingText {
constructor(x, y, text, color='#fff', size=20) {
this.x = x; this.y = y; this.text = text; this.color = color; this.size = size;
this.life = 60; this.maxLife = 60; this.vy = -2;
}
update() { this.y += this.vy; this.vy *= 0.95; return --this.life > 0; }
draw(ctx) {
const alpha = Math.min(1, this.life / (this.maxLife * 0.3));
ctx.globalAlpha = alpha;
ctx.fillStyle = this.color;
ctx.font = `bold ${this.size}px 'Segoe UI', sans-serif`;
ctx.textAlign = 'center';
ctx.strokeStyle = '#000'; ctx.lineWidth = 3;
ctx.strokeText(this.text, this.x, this.y);
ctx.fillText(this.text, this.x, this.y);
ctx.globalAlpha = 1;
}
}
let floatingTexts = [];
// --- Terrain System ---
class Terrain {
constructor() {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.terrainCanvas = document.createElement('canvas');
this.terrainCtx = this.terrainCanvas.getContext('2d');
this.waterCanvas = document.createElement('canvas');
this.waterCtx = this.waterCanvas.getContext('2d');
}
generate(type, waterLevel) {
const w = WORLD_W, h = WORLD_H;
this.canvas.width = w; this.canvas.height = h;
this.terrainCanvas.width = w; this.terrainCanvas.height = h;
this.waterCanvas.width = w; this.waterCanvas.height = h;
// Sky gradient background
const skyGrad = this.ctx.createLinearGradient(0, 0, 0, h);
skyGrad.addColorStop(0, '#1a237e');
skyGrad.addColorStop(0.4, '#283593');
skyGrad.addColorStop(0.7, '#5c6bc0');
skyGrad.addColorStop(1, '#9fa8da');
this.ctx.fillStyle = skyGrad;
this.ctx.fillRect(0, 0, w, h);
// Stars
for(let i=0;i<200;i++) {
const sx = Math.random()*w, sy = Math.random()*h*0.5;
this.ctx.fillStyle = `rgba(255,255,255,${Math.random()*0.8+0.2})`;
this.ctx.fillRect(sx, sy, 1+Math.random(), 1+Math.random());
}
// Generate terrain heightmap
const heights = new Float32Array(w);
if(type === 'island') {
for(let x=0;x<w;x++) {
const nx = x/w;
let h_val = 0.5 + 0.3*Math.sin(nx*6) + 0.15*Math.sin(nx*13+2) + 0.08*Math.sin(nx*27+5);
// Make edges lower (island shape)
const edgeFactor = Math.pow(Math.sin(nx * Math.PI), 0.5);
heights[x] = h_val * edgeFactor;
}
} else if(type === 'cave') {
for(let x=0;x<w;x++) {
const nx = x/w;
let topH = 0.3 + 0.1*Math.sin(nx*8) + 0.05*Math.sin(nx*20);
let botH = 0.7 + 0.1*Math.sin(nx*6+1) + 0.05*Math.sin(nx*15+3);
heights[x] = (topH + botH)/2; // We'll handle cave differently
}
} else if(type === 'dual-islands') {
for(let x=0;x<w;x++) {
const nx = x/w;
let h_val = 0.3 + 0.2*Math.sin(nx*10) + 0.1*Math.sin(nx*22);
// Two peaks
const peak1 = Math.exp(-Math.pow((nx-0.3)*4, 2));
const peak2 = Math.exp(-Math.pow((nx-0.7)*4, 2));
heights[x] = h_val + (peak1+peak2)*0.5;
}
}
// Draw terrain with pixel-level detail
this._drawTerrain(heights, type);
// Build cached heightmap for fast ground lookups
this._buildHeightMap(type, heights);
// Water
this._drawWater(waterLevel);
}
_buildHeightMap(type, heights) {
const w = WORLD_W, h = WORLD_H;
this._heightMap = new Int32Array(w).fill(WORLD_H);
if(type === 'cave') {
// For caves, the "ground" is the floor (bottom surface)
for(let x=0;x<w;x++) {
const nx = x/w;
let floorY = Math.floor((0.75 + 0.08*Math.sin(nx*8+2) + 0.04*Math.sin(nx*20+1))*h);
this._heightMap[x] = floorY - 1;
}
} else {
// For islands, use the heightmap directly
for(let x=0;x<w;x++) {
const surfaceY = Math.ceil(heights[x] * h);
this._heightMap[x] = surfaceY - 1;
}
}
}
_drawTerrain(heights, type) {
const w = WORLD_W, h = WORLD_H;
const tCtx = this.terrainCtx;
// Use ImageData for fast pixel manipulation
const imgData = tCtx.createImageData(w, h);
const data = imgData.data;
if(type === 'cave') {
for(let x=0;x<w;x++) {
const nx = x/w;
let ceilY = Math.floor((0.2 + 0.08*Math.sin(nx*10) + 0.04*Math.sin(nx*25))*h);
let floorY = Math.floor((0.75 + 0.08*Math.sin(nx*8+2) + 0.04*Math.sin(nx*20+1))*h);
for(let y=0;y<ceilY;y++) {
const depth = y/ceilY;
const idx = (y*w+x)*4;
data[idx] = Math.floor(60-depth*30);
data[idx+1] = Math.floor(50-depth*25);
data[idx+2] = Math.floor(40-depth*20);
data[idx+3] = 255;
}
for(let y=floorY;y<h;y++) {
const depth = (y-floorY)/(h-floorY);
const idx = (y*w+x)*4;
data[idx] = Math.floor(80-depth*40);
data[idx+1] = Math.floor(65-depth*35);
data[idx+2] = Math.floor(45-depth*25);
data[idx+3] = 255;
}
}
} else {
for(let x=0;x<w;x++) {
const surfaceY = heights[x] * h;
for(let y=Math.ceil(surfaceY);y<h;y++) {
const depth = (y-surfaceY)/(h-surfaceY);
const idx = (y*w+x)*4;
if(depth < 0.02) {
data[idx] = Math.floor(60+Math.random()*30);
data[idx+1] = Math.floor(140+Math.random()*40);
data[idx+2] = Math.floor(40+Math.random()*20);
} else if(depth < 0.15) {
const noise = Math.sin(x*0.5+y*0.3)*10;
data[idx] = Math.floor(139+noise);
data[idx+1] = Math.floor(119+noise);
data[idx+2] = Math.floor(80+noise);
} else if(depth < 0.5) {
const noise = Math.sin(x*0.3+y*0.7)*15;
data[idx] = Math.floor(100+noise);
data[idx+1] = Math.floor(90+noise);
data[idx+2] = Math.floor(80+noise);
} else {
const noise = Math.sin(x*0.2+y*0.4)*10;
data[idx] = Math.floor(70+noise);
data[idx+1] = Math.floor(65+noise);
data[idx+2] = Math.floor(60+noise);
}
data[idx+3] = 255;
}
}
}
tCtx.putImageData(imgData, 0, 0);
this.ctx.drawImage(this.terrainCanvas, 0, 0);
}
_drawWater(waterLevel) {
// Water is drawn in the main draw loop now for animation
}
// Get terrain pixel data for collision/destruction
getTerrainPixel(x, y) {
if(x<0||x>=WORLD_W||y<0||y>=WORLD_H) return 'sky';
const px = Math.floor(x), py = Math.y=Math.floor(y);
try {
const pixel = this.terrainCtx.getImageData(px, py, 1, 1).data;
if(pixel[3] > 0) return 'ground';
return 'sky';
} catch(e) { return 'sky'; }
}
// Destroy terrain in a circular area - returns debris info
destroy(x, y, radius) {
const px = Math.floor(x), py = Math.floor(y), pr = Math.ceil(radius);
try {
this.terrainCtx.globalCompositeOperation = 'destination-out';
this.terrainCtx.beginPath();
this.terrainCtx.arc(px, py, pr, 0, Math.PI*2);
this.terrainCtx.fill();
this.terrainCtx.globalCompositeOperation = 'source-over';
// Also update main canvas
this.ctx.drawImage(this.terrainCanvas, 0, 0);
} catch(e) {}
// Generate falling debris chunks from destroyed area
const debrisCount = Math.floor(radius * 1.5);
for(let i=0;i<debrisCount;i++) {
const angle = Math.random()*Math.PI*2;
const dist = Math.random()*radius;
const dx = px + Math.cos(angle)*dist;
const dy = py + Math.sin(angle)*dist;
if(dy > 0 && dy < WORLD_H) {
particles.particles.push(new Particle(
dx, dy, (Math.random()-0.5)*3, -Math.random()*2-1,
60+Math.random()*40,
['#8B6914','#A0782C','#6B5010','#5a4510'][Math.floor(Math.random()*4)],
2+Math.random()*6, 'debris'
));
}
}
}
// Get ground Y at X position (for unit standing)
// Cached heightmap for fast ground lookups
_heightMap = null;
getGroundY(x) {
const px = Math.floor(x);
if(px<0||px>=WORLD_W) return WORLD_H;
// Use cached height map if available
if(this._heightMap && this._heightMap[px] !== undefined) {
return this._heightMap[px];
}
// Fallback: scan from top
for(let y=0;y<WORLD_H;y++) {
try {
const pixel = this.terrainCtx.getImageData(px, y, 1, 1).data;
if(pixel[3] > 0) return y - 1;
} catch(e) {}
}
return WORLD_H; // No ground found
}
isGround(x, y) {
const px = Math.floor(x), py = Math.floor(y);
if(px<0||px>=WORLD_W||py<0||py>=WORLD_H) return false;
// Use cached height map for fast check
if(this._heightMap && this._heightMap[px] !== undefined) {
const groundY = this._heightMap[px];
// Check if y is at or below the surface (but also check cave ceiling)
return py >= groundY;
}
try {
const pixel = this.terrainCtx.getImageData(px, py, 1, 1).data;
return pixel[3] > 0;
} catch(e) { return false; }
}
// Check if position is in water
isInWater(x, y) {
return y >= WATER_LEVEL && !this.isGround(x, y);
}
render(ctx, camX, camY, zoom) {
ctx.drawImage(this.canvas, 0, 0);
// Draw terrain on top of sky
ctx.save();
ctx.setTransform(zoom, 0, 0, zoom, -camX*zoom + canvas.width/2, -camY*zoom + canvas.height/2);
ctx.drawImage(this.terrainCanvas, 0, 0);
// Draw water with animation
this._drawWaterAnimated(WATER_LEVEL);
ctx.restore();
}
_drawWaterAnimated(waterLevel) {
const w = WORLD_W;
this.ctx.save();
// Animated water surface
for(let x=0;x<w;x+=2) {
const waveY = waterLevel + Math.sin(x*0.03+Date.now()*0.003)*4;
this.ctx.fillStyle = `rgba(30,80,180,0.5)`;
this.ctx.fillRect(x, waveY, 2, WORLD_H - waveY);
}
// Surface highlight
this.ctx.strokeStyle = 'rgba(100,180,255,0.4)';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
for(let x=0;x<w;x+=3) {
const waveY = waterLevel + Math.sin(x*0.03+Date.now()*0.003)*4;
if(x===0) this.ctx.moveTo(x, waveY);
else this.ctx.lineTo(x, waveY);
}
this.ctx.stroke();
this.ctx.restore();
}
renderMinimap(ctx) {
const mw = minimapCanvas.width, mh = minimapCanvas.height;
ctx.fillStyle = '#1a237e';
ctx.fillRect(0, 0, mw, mh);
// Draw terrain
const scaleX = mw / WORLD_W;
const scaleY = mh / WORLD_H;
for(let x=0;x<WORLD_W;x+=4) {
const gy = this.getGroundY(x);
ctx.fillStyle = '#5a7a3a';
ctx.fillRect(x*scaleX, gy*scaleY, scaleX*4, (WORLD_H-gy)*scaleY);
}
// Water
ctx.fillStyle = 'rgba(30,80,180,0.5)';
ctx.fillRect(0, WATER_LEVEL*scaleY, mw, mh - WATER_LEVEL*scaleY);
}
}
// --- Weapon Definitions ---
const WEAPONS = [
{ id:'rocket', name:'Rocket', icon:'🚀', category:'indirect', damage:35, radius:60, speed:8, color:'#ff4400', desc:'Parabolic explosive projectile' },
{ id:'grenade', name:'Grenade', icon:'💣', category:'indirect', damage:25, radius:40, speed:7, color:'#ccaa00', desc:'Bouncing explosive' },
{ id:'cannonball', name:'Cannon', icon:'⚫', category:'indirect', damage:30, radius:50, speed:12, color:'#888', desc:'Heavy fast projectile' },
{ id:'shotgun', name:'Shotgun', icon:'🔫', category:'direct', damage:20, radius:0, speed:0, color:'#ffaa00', desc:'Instant-hit spread shot' },
{ id:'laser', name:'Laser', icon:'⚡', category:'direct', damage:15, radius:0, speed:0, color:'#ff00ff', desc:'Instant beam weapon' },
{ id:'cluster', name:'Cluster', icon:'💥', category:'indirect', damage:20, radius:35, speed:6, color:'#ff8844', desc:'Splits into sub-bombs' },
{ id:'nuke', name:'NUKE', icon:'☢️', category:'aoe', damage:80, radius:200, speed:5, color:'#ff0000', desc:'Massive area destruction (1 per game)' },
{ id:'earthquake', name:'Quake', icon:'🌋', category:'aoe', damage:40, radius:300, speed:0, color:'#aa6600', desc:'Shakes entire map' },
];
const UTILITIES = [
{ id:'medkit', name:'Medkit', icon:'💊', desc:'+25 HP', maxUses:1 },
{ id:'parachute', name:'Parachute', icon:'🪂', desc:'Safe landing', maxUses:1 },
{ id:'binoculars', name:'Scout', icon:'🔭', desc:'See enemy positions', maxUses:1 },
];
// --- Projectile ---
class Projectile {
constructor(x, y, vx, vy, weapon, owner) {
this.x = x; this.y = y; this.vx = vx; this.vy = vy;
this.weapon = weapon; this.owner = owner;
this.alive = true; this.bounces = 0;
this.trail = [];
this.age = 0;
if(weapon.id === 'cluster') {
this.splitDone = false;
this.splitAge = 60; // Split after ~1 second
}
}
update(wind) {
if(!this.alive) return;
this.age++;
// Store trail position
this.trail.push({x: this.x, y: this.y});
if(this.trail.length > 20) this.trail.shift();
// Apply wind
this.vx += wind * 0.01;
// Gravity (except for direct weapons which don't use projectiles)
if(this.weapon.category === 'indirect') {
this.vy += GRAVITY;
}
this.x += this.vx;
this.y += this.vy;
// Cluster bomb split
if(this.weapon.id === 'cluster' && !this.splitDone && this.age >= this.splitAge) {
this.splitDone = true;
for(let i=0;i<5;i++) {
const a = (i/5)*Math.PI*2 + Math.random()*0.3;
game.projectiles.push(new Projectile(
this.x, this.y,
Math.cos(a)*4, Math.sin(a)*4 - 2,
{id:'grenade', name:'Sub-bomb', category:'indirect', damage:15, radius:20, speed:4},
this.owner
));
}
}
// Check bounds
if(this.x < -50 || this.x > WORLD_W+50 || this.y > WORLD_H+50) {
this.alive = false;
return;
}
// Terrain collision
if(terrain.isGround(Math.floor(this.x), Math.floor(this.y))) {
this.explode();
return;
}
// Water collision
if(this.y >= WATER_LEVEL) {
sound.play('splash');
particles.splash(this.x, this.y);
this.alive = false;
return;
}
// Unit collision
for(const unit of game.units) {
if(!unit.alive || unit.team === this.owner) continue;
const dx = this.x - unit.x, dy = this.y - unit.y;
if(Math.sqrt(dx*dx+dy*dy) < 15) {
this.explode();
return;
}
}
// Bounce for grenades
if(this.weapon.id === 'grenade' && this.bounces < 3) {
const groundY = terrain.getGroundY(this.x);
if(Math.abs(this.y - groundY) < 5 && this.vy > 0) {
this.vy *= -0.5;
this.vx *= 0.7;
this.bounces++;
particles.dust(this.x, this.y);
sound.play('hit');
}
}
}
explode() {
this.alive = false;
if(this.weapon.category === 'aoe' && this.weapon.id === 'nuke') {
terrain.destroy(this.x, this.y, this.weapon.radius);
particles.explosion(this.x, this.y, this.weapon.radius);
sound.play('big_explosion');
game.screenShake = 20;
game.impactFreeze = 30;
// Damage all units in radius
for(const unit of game.units) {
if(!unit.alive) continue;
const dx = this.x - unit.x, dy = this.y - unit.y;
const dist = Math.sqrt(dx*dx+dy*dy);
if(dist < this.weapon.radius) {
const dmg = Math.floor(this.weapon.damage * (1 - dist/this.weapon.radius));
unit.takeDamage(dmg, this.owner);
}
}
} else if(this.weapon.category === 'aoe' && this.weapon.id === 'earthquake') {
sound.play('weapon_fire', {weapon:'earthquake'});
game.screenShake = 15;
// Damage all units based on terrain instability
for(const unit of game.units) {
if(!unit.alive || unit.team === this.owner) continue;
const groundY = terrain.getGroundY(unit.x);
if(Math.abs(unit.y - groundY) > 50) {
// Units high up take more damage from shaking
unit.takeDamage(this.weapon.damage, this.owner);
} else {
unit.takeDamage(Math.floor(this.weapon.damage * 0.3), this.owner);
}
}
// Create random terrain destructions
for(let i=0;i<8;i++) {
const rx = Math.random()*WORLD_W;
const ry = terrain.getGroundY(rx) - Math.random()*50;
terrain.destroy(rx, ry, 30+Math.random()*20);
particles.smallExplosion(rx, ry);
}
} else {
// Normal explosion
terrain.destroy(this.x, this.y, this.weapon.radius);
if(this.weapon.radius > 40) {
particles.explosion(this.x, this.y, this.weapon.radius);
sound.play('explosion', {duration: 0.3+this.weapon.radius/200});
game.screenShake = Math.min(15, this.weapon.radius / 8);
} else {
particles.smallExplosion(this.x, this.y);
sound.play('hit');
game.screenShake = 5;
}
// Damage units in radius
for(const unit of game.units) {
if(!unit.alive || unit.team === this.owner) continue;
const dx = this.x - unit.x, dy = this.y - unit.y;
const dist = Math.sqrt(dx*dx+dy*dy);
if(dist < this.weapon.radius + 10) {
const falloff = Math.max(0, 1 - dist/(this.weapon.radius+10));
const dmg = Math.floor(this.weapon.damage * falloff);
unit.takeDamage(dmg, this.owner);
}
}
}
game.addLog(`${WEAPONS.find(w=>w.id===this.weapon.id)?.name||'Weapon'} hit!`, '#ff8800');
}
draw(ctx) {
if(!this.alive) return;
// Draw trail
if(this.trail.length > 1 && this.weapon.category === 'indirect') {
ctx.strokeStyle = `rgba(255,200,100,0.3)`;
ctx.lineWidth = 2;
ctx.beginPath();
for(let i=0;i<this.trail.length;i++) {
const p = this.trail[i];
if(i===0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
}
// Draw projectile
ctx.fillStyle = this.weapon.color;
ctx.beginPath();
const size = this.weapon.id === 'nuke' ? 8 : (this.weapon.id === 'cannonball' ? 6 : 4);
ctx.arc(this.x, this.y, size, 0, Math.PI*2);
ctx.fill();
// Glow effect
ctx.fillStyle = `rgba(255,255,200,${0.3+Math.sin(this.age*0.3)*0.2})`;
ctx.beginPath();
ctx.arc(this.x, this.y, size+3, 0, Math.PI*2);
ctx.fill();
}
}
// --- Unit/Worm Class ---
class Worm {
constructor(x, y, team, index) {
this.x = x; this.y = y;
this.vx = 0; this.vy = 0;
this.team = team;
this.index = index;
this.hp = UNIT_MAX_HP;
this.alive = true;
this.onGround = false;
this.facing = 1; // 1=right, -1=left
this.name = `Worm ${index+1}`;
this.hasParachute = false;
this.animFrame = 0;
this.deathAnim = 0;
this.invincible = 0;
// Movement budget per turn
this.moveBudget = 200; // pixels of movement allowed
// Team colors
const teamColors = ['#ff4444','#4488ff','#44dd44','#ddaa00'];
this.color = teamColors[team % 4];
this.bodyColor = this._lighten(teamColors[team % 4], 30);
}
_lighten(hex, amount) {
let r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
r = Math.min(255,r+amount); g = Math.min(255,g+amount); b = Math.min(255,b+amount);
return `rgb(${r},${g},${b})`;
}
update(wind) {
if(!this.alive) { this.deathAnim++; return; }
if(this.invincible > 0) this.invincible--;
this.animFrame += 0.1;
// Apply wind to movement (slight effect on units too)
this.vx += wind * 0.002;
// Gravity
this.vy += GRAVITY;
// Friction
if(this.onGround) {
this.vx *= FRICTION_GROUND;
} else {
this.vx *= FRICTION_AIR;
// Parachute effect
if(this.hasParachute && this.vy > 0) {
this.vy *= 0.92;
if(Math.random()<0.3) particles.dust(this.x + (Math.random()-0.5)*10, this.y-10);
}
}
// Move
const newX = this.x + this.vx;
const newY = this.y + this.vy;
// Horizontal collision with terrain
if(!terrain.isGround(Math.floor(newX), Math.floor(this.y)) &&
!terrain.isGround(Math.floor(newX), Math.floor(this.y-10))) {
this.x = newX;
} else {
this.vx = 0;
}
// Vertical collision with terrain
if(!terrain.isGround(Math.floor(this.x), Math.floor(newY)) &&
!terrain.isGround(Math.floor(this.x+5), Math.floor(newY)) &&
!terrain.isGround(Math.floor(this.x-5), Math.floor(newY))) {
this.y = newY;
this.onGround = false;
} else {
if(this.vy > 0) {
// Landing on ground
const groundY = terrain.getGroundY(this.x);
if(groundY < WORLD_H - 5 && Math.abs(newY - groundY) < 15) {
this.y = groundY;
// Fall damage
if(this.vy > 12 && !this.hasParachute) {
const fallDmg = Math.floor((this.vy - 12) * 3);
if(fallDmg > 0) {
this.takeDamage(fallDmg, -1); // Environmental damage
game.addLog(`${this.name} took ${fallDmg} fall damage!`, '#ffaa00');
}
}
if(Math.abs(this.vy) > 3) particles.dust(this.x, this.y);
}
this.vy = 0;
this.onGround = true;
} else {
this.vy = 0;
}
}
// Water physics - drowning
if(terrain.isInWater(this.x, this.y)) {
if(!this.hasParachute) {
// Drowning damage
if(Math.random() < 0.02) {
this.takeDamage(1, -1);
particles.splash(this.x + (Math.random()-0.5)*10, this.y);
}
} else {
// Float on water with parachute
this.vy = Math.min(this.vy, 0);
if(this.y >= WATER_LEVEL) this.y = WATER_LEVEL - 2;
}
}
// Keep in bounds
this.x = Math.max(10, Math.min(WORLD_W-10, this.x));
// If fell off the world
if(this.y > WORLD_H + 50) {
this.hp = 0;
this.alive = false;
game.addLog(`${this.name} fell to their doom!`, '#ff4444');
sound.play('death');
}
}
move(dir) {
if(!this.alive || this.moveBudget <= 0) return;
this.vx = dir * MOVE_SPEED;
this.facing = dir;
this.moveBudget -= Math.abs(this.vx);
}
jump() {
if(!this.alive || !this.onGround || this.moveBudget <= 0) return;
this.vy = JUMP_FORCE;
this.onGround = false;
this.moveBudget -= 15;
sound.play('jump');
particles.dust(this.x, this.y);
}
takeDamage(amount, attackerTeam) {
if(!this.alive || this.invincible > 0) return;
const oldHp = this.hp;
this.hp = Math.max(0, this.hp - amount);
// Floating damage text
floatingTexts.push(new FloatingText(this.x, this.y-20, `-${amount}`, '#ff4444', 18));
particles.blood(this.x, this.y);
sound.play('hit');
if(this.hp <= 0 && oldHp > 0) {
this.alive = false;
game.addLog(`${this.name} was eliminated!`, '#ff0000');
sound.play('death');
// Death explosion particles
particles.explosion(this.x, this.y, 30);
}
}
draw(ctx) {
if(!this.alive && this.deathAnim > 60) return;
const alpha = this.alive ? 1 : Math.max(0, 1 - this.deathAnim/60);
ctx.globalAlpha = alpha;
// Invincibility flash
if(this.invincible > 0 && Math.floor(this.invincible/3)%2===0) {
ctx.globalAlpha *= 0.5;
}
const x = this.x, y = this.y;
// Parachute visual
if(this.hasParachute && !this.onGround) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y-30, 15, Math.PI, 0);
ctx.stroke();
// Strings
ctx.beginPath();
ctx.moveTo(x-15, y-30); ctx.lineTo(x-5, y-8);
ctx.moveTo(x+15, y-30); ctx.lineTo(x+5, y-8);
ctx.stroke();
}
// Worm body (segmented)
const segments = 4;
for(let i=segments-1;i>=0;i--) {
const segX = x + Math.sin(this.animFrame+i*0.8)*3 - this.facing*i*5;
const segY = y - i*6 + Math.abs(Math.sin(this.animFrame+i*0.5))*2;
const size = 7 - i*1.2;
ctx.fillStyle = i===0 ? this.color : this.bodyColor;
ctx.beginPath();
ctx.arc(segX, segY, size, 0, Math.PI*2);
ctx.fill();
// Outline
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 1;
ctx.stroke();
}
// Eyes (on head segment)
const eyeX = x + this.facing*4;
const eyeY = y - 2;
// Eye whites
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(eyeX-3, eyeY-2, 3.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(eyeX+3, eyeY-2, 3.5, 0, Math.PI*2); ctx.fill();
// Pupils (look in facing direction)
ctx.fillStyle = '#111';
ctx.beginPath(); ctx.arc(eyeX-3+this.facing*1.5, eyeY-2, 1.8, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(eyeX+3+this.facing*1.5, eyeY-2, 1.8, 0, Math.PI*2); ctx.fill();
// Mouth (smirk)
if(this.alive) {
ctx.strokeStyle = '#333';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(eyeX+this.facing*2, eyeY+4, 3, 0.2, Math.PI-0.2);
ctx.stroke();
}
// Health bar above worm
if(this.alive && this.hp < UNIT_MAX_HP) {
const barW = 30;
const hpRatio = this.hp / UNIT_MAX_HP;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(x-barW/2-1, y-25, barW+2, 6);
ctx.fillStyle = hpRatio > 0.5 ? '#4f4' : (hpRatio > 0.25 ? '#ff0' : '#f44');
ctx.fillRect(x-barW/2, y-24, barW*hpRatio, 4);
}
// Active unit indicator
if(game.activeUnit === this) {
// Pulsing arrow above
const arrowY = y - 35 + Math.sin(Date.now()*0.005)*5;
ctx.fillStyle = '#ffcc00';
ctx.beginPath();
ctx.moveTo(x, arrowY+8);
ctx.lineTo(x-6, arrowY);
ctx.lineTo(x+6, arrowY);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
}
// --- AI Controller ---
class AIController {
constructor(difficulty) {
this.difficulty = difficulty; // 'easy', 'medium', 'hard'
this.thinkingTimer = 0;
this.actionQueue = [];
}
getAccuracy() {
switch(this.difficulty) {
case 'easy': return 0.4;
case 'medium': return 0.65;
case 'hard': return 0.85;
default: return 0.65;
}
}
getReactionTime() {
switch(this.difficulty) {
case 'easy': return 120; // ~2 seconds
case 'medium': return 80;
case 'hard': return 40;
default: return 80;
}
}
startTurn(unit) {
this.currentUnit = unit;
this.thinkingTimer = this.getReactionTime();
this.actionQueue = [];
// AI decides what to do
setTimeout(() => this.planAction(), 500);
}
planAction() {
const unit = this.currentUnit;
if(!unit || !unit.alive) return;
// Risk assessment: check for environmental hazards
const groundY = terrain.getGroundY(unit.x);
const nearWater = terrain.isInWater(unit.x, unit.y);
const onCliffEdge = this._isOnCliffEdge(unit);
// If in danger, move to safety first
if(nearWater && !unit.hasParachute) {
this.actionQueue.push({type:'move', dir: this._findSafeDirection(unit)});
return;
}
if(onCliffEdge && Math.random() < 0.7) {
// Move away from cliff edge
const safeDir = unit.x < groundY ? -1 : 1;
this.actionQueue.push({type:'move', dir: safeDir});
}
// Find best target and weapon
const targets = game.units.filter(u => u.alive && u.team !== unit.team);
if(targets.length === 0) {
this.actionQueue.push({type:'skip'});
return;
}
// Prioritize low HP targets (hard AI) or random (easy)
let target;
if(this.difficulty === 'hard') {
target = [...targets].sort((a,b) => a.hp - b.hp)[0];
} else {
target = targets[Math.floor(Math.random()*targets.length)];
}
// Choose weapon based on distance and accuracy
const dist = Math.abs(target.x - unit.x);
const heightDiff = target.y - unit.y;
let bestWeapon = this._selectBestWeapon(unit, target, dist, heightDiff);
// Check if nuke is available and worth using (hard AI)
if(this.difficulty === 'hard' && !game.nukeUsed[unit.team] && targets.length <= 2) {
const nukeW = WEAPONS.find(w => w.id === 'nuke');
bestWeapon = nukeW;
}
// Use utility items if beneficial
if(unit.hp < 50 && Math.random() < (this.difficulty==='hard'?0.8:0.4)) {
this.actionQueue.push({type:'utility', item:'medkit'});
}
// Aim and fire
const aimData = this._calculateAim(unit, target, bestWeapon);
if(aimData) {
this.actionQueue.push({type:'fire', weapon:bestWeapon, angle:aimData.angle, power:aimData.power});
} else {
// Fallback: random shot
const angle = -Math.PI/4 + (Math.random()-0.5)*0.5;
const power = 30 + Math.random()*60;
this.actionQueue.push({type:'fire', weapon:bestWeapon, angle, power});
}
// Execute actions with delays
let delay = 0;
for(const action of this.actionQueue) {
setTimeout(() => this.executeAction(action), delay);
delay += 800 + Math.random()*400;
}
// Skip if no actions
if(this.actionQueue.length === 0) {
setTimeout(() => game.skipTurn(), 1000);
}
}
_isOnCliffEdge(unit) {
const groundY = terrain.getGroundY(unit.x);
const leftY = terrain.getGroundY(unit.x - 30);
const rightY = terrain.getGroundY(unit.x + 30);
return (leftY > groundY + 50 || rightY > groundY + 50);
}
_findSafeDirection(unit) {
// Find direction away from water and toward higher ground
const leftY = terrain.getGroundY(unit.x - 50);
const rightY = terrain.getGroundY(unit.x + 50);
if(leftY < rightY) return -1;
return 1;
}
_selectBestWeapon(unit, target, dist, heightDiff) {
const availableWeapons = WEAPONS.filter(w => w.id !== 'nuke' || !game.nukeUsed[unit.team]);
// Score each weapon
let bestScore = -Infinity;
let bestW = availableWeapons[0];
for(const w of availableWeapons) {
let score = 0;
if(w.category === 'indirect') {
// Good for long range and targets above/below
score += Math.max(0, 100 - Math.abs(dist - 400)) * 0.2;
score += (heightDiff > 0 ? 20 : 0); // Prefer indirect when target is below
} else if(w.category === 'direct') {
// Good for close range, line of sight
score += Math.max(0, 100 - dist) * 0.5;
// Check line of sight
if(this._hasLineOfSight(unit, target)) score += 30;
} else if(w.category === 'aoe') {
// Good when multiple targets nearby
const nearbyTargets = game.units.filter(u => u.alive && u.team !== unit.team && Math.abs(u.x-target.x)<200).length;
score += nearbyTargets * 40;
}
// Accuracy factor
score *= this.getAccuracy();
if(score > bestScore) {
bestScore = score;
bestW = w;
}
}
return bestW;
}
_hasLineOfSight(from, to) {
const steps = Math.floor(Math.sqrt((to.x-from.x)**2 + (to.y-from.y)**2) / 10);
for(let i=0;i<=steps;i++) {
const t = i/steps;
const cx = from.x + (to.x-from.x)*t;
const cy = from.y + (to.y-from.y)*t;
if(terrain.isGround(Math.floor(cx), Math.floor(cy))) return false;
}
return true;
}
_calculateAim(unit, target, weapon) {
if(weapon.category === 'direct') {
// Direct weapons: aim at target with some inaccuracy
const dx = target.x - unit.x;
const dy = target.y - unit.y;
const angle = Math.atan2(dy, dx);
// Add inaccuracy based on difficulty
const accuracy = this.getAccuracy();
const noise = (1-accuracy) * 0.3;
return { angle: angle + (Math.random()-0.5)*noise, power: 100 };
}
if(weapon.category === 'aoe') {
// AoE: aim at center of target cluster or directly at target
const dx = target.x - unit.x;
const dy = target.y - unit.y;
if(weapon.id === 'earthquake') {
return { angle: 0, power: 100 }; // Earthquake doesn't need aiming
}
// For nuke, aim at cluster center
let cx=target.x, cy=target.y;
const nearby = game.units.filter(u => u.alive && u.team !== unit.team);
if(nearby.length > 1) {
cx = nearby.reduce((s,u)=>s+u.x,0)/nearby.length;
cy = nearby.reduce((s,u)=>s+u.y,0)/nearby.length;
}
const aimAngle = this._solveTrajectory(unit.x, unit.y, cx, cy, weapon.speed);
if(aimAngle) return aimAngle;
}
// Indirect: solve parabolic trajectory
const accuracy = this.getAccuracy();
const noise = (1-accuracy) * 0.2;
// Add some randomness to target position
const tx = target.x + (Math.random()-0.5)*40*(1-accuracy);
const ty = target.y + (Math.random()-0.5)*30*(1-accuracy);
const aimAngle = this._solveTrajectory(unit.x, unit.y, tx, ty, weapon.speed);
if(aimAngle) {
return {
angle: aimAngle.angle + (Math.random()-0.5)*noise,
power: aimAngle.power * (0.9 + Math.random()*0.2*accuracy)
};
}
// Fallback trajectory calculation
const dx = target.x - unit.x;
const dist = Math.abs(dx);
const angle = dx > 0 ? -Math.PI/4 : -Math.PI*3/4;
return { angle: angle + (Math.random()-0.5)*noise, power: Math.min(100, dist/8) };
}
_solveTrajectory(x1, y1, x2, y2, speed) {
const dx = x2 - x1;
const dy = y2 - y1;
// Try different angles and find one that works
let bestAngle = null;
let bestError = Infinity;
for(let angle=-0.1;angle<-Math.PI+0.1;angle-=0.05) {
const vx = Math.cos(angle)*speed;
const vy = Math.sin(angle)*speed;
// Simulate trajectory
let sx=x1, sy=y1, svx=vx, svy=vy;
for(let t=0;t<200;t++) {
svy += GRAVITY;
svx += game.wind * 0.01;
sx += svx;
sy += svy;
if(sy > WORLD_H) break; // Hit ground
const err = Math.sqrt((sx-x2)**2 + (sy-y2)**2);
if(err < bestError && t > 10) {
bestError = err;
bestAngle = angle;
}
if(sx >= x2-5 && sx <= x2+5 && Math.abs(sy-y2)<20) {
return { angle: bestAngle, power: speed/15*100 };
}
}
}
if(bestError < 100) {
return { angle: bestAngle, power: speed/15*100 };
}
return null;
}
executeAction(action) {
const unit = this.currentUnit;
if(!unit || !unit.alive) return;
switch(action.type) {
case 'move':
// Move toward a safer position or closer to target
for(let i=0;i<20;i++) {
unit.move(action.dir);
}
break;
case 'utility':
if(action.item === 'medkit' && unit.hp < UNIT_MAX_HP) {
unit.hp = Math.min(UNIT_MAX_HP, unit.hp + 25);
floatingTexts.push(new FloatingText(unit.x, unit.y-20, '+25', '#44ff44'));
sound.play('medkit');
game.addLog(`${unit.name} used a Medkit!`, '#44ff44');
} else if(action.item === 'parachute') {
unit.hasParachute = true;
sound.play('parachute');
game.addLog(`${unit.name} deployed a Parachute!`, '#88ccff');
}
break;
case 'fire':
if(action.weapon.id === 'earthquake') {
// Earthquake: no aiming needed
const proj = new Projectile(unit.x, unit.y, 0, 0, action.weapon, unit.team);
game.projectiles.push(proj);
setTimeout(() => proj.explode(), 100);
sound.play('weapon_fire', {weapon:'earthquake'});
} else if(action.weapon.category === 'direct') {
// Direct fire
this._fireDirect(unit, action.weapon, action.angle);
} else {
// Indirect fire
const power = (action.power / 100) * 15;
const vx = Math.cos(action.angle) * power;
const vy = Math.sin(action.angle) * power;
const proj = new Projectile(unit.x, unit.y-15, vx, vy, action.weapon, unit.team);
game.projectiles.push(proj);
sound.play('weapon_fire', {weapon:action.weapon.id});
}
// After firing, skip turn after delay
setTimeout(() => game.skipTurn(), 2000);
break;
case 'skip':
game.skipTurn();
break;
}
}
_fireDirect(unit, weapon, angle) {
if(weapon.id === 'shotgun') {
// Spread shot: multiple pellets
for(let i=0;i<7;i++) {
const spread = (i-3)*0.08 + (Math.random()-0.5)*0.05;
const dx = Math.cos(angle+spread)*20;
const dy = Math.sin(angle+spread)*20;
// Raycast for hit detection
let hitX = unit.x, hitY = unit.y-10;
const stepSize = 5;
for(let s=0;s<60;s++) {
hitX += dx*stepSize/20;
hitY += dy*stepSize/20;
// Check terrain
if(terrain.isGround(Math.floor(hitX), Math.floor(hitY))) break;
// Check units
for(const target of game.units) {
if(!target.alive || target.team === unit.team) continue;
const tdx = hitX-target.x, tdy = hitY-target.y;
if(Math.sqrt(tdx*tdx+tdy*tdy)<12) {
target.takeDamage(weapon.damage/7, unit.team);
particles.blood(target.x, target.y);
break;
}
}
}
}
// Visual effect
ctx.save();
const endX = unit.x + Math.cos(angle)*300;
const endY = unit.y-10 + Math.sin(angle)*300;
ctx.strokeStyle = '#ffaa00';
ctx.lineWidth = 2;
for(let i=0;i<7;i++) {
const spread = (i-3)*0.08;
ctx.beginPath();
ctx.moveTo(unit.x, unit.y-10);
ctx.lineTo(unit.x+Math.cos(angle+spread)*200, unit.y-10+Math.sin(angle+spread)*200);
ctx.stroke();
}
ctx.restore();
sound.play('weapon_fire');
particles.smallExplosion(endX, endY);
} else if(weapon.id === 'laser') {
// Laser beam: instant line damage
const maxDist = 500;
let hitX = unit.x + Math.cos(angle)*maxDist;
let hitY = unit.y-10 + Math.sin(angle)*maxDist;
// Find closest intersection with terrain or units
let minDist = maxDist;
for(const target of game.units) {
if(!target.alive || target.team === unit.team) continue;
const dist = this._lineToCircleDistance(unit.x, unit.y-10, angle, target.x, target.y, 15);
if(dist > 0 && dist < minDist) {
minDist = dist;
hitX = unit.x + Math.cos(angle)*dist;
hitY = unit.y-10 + Math.sin(angle)*dist;
target.takeDamage(weapon.damage, unit.team);
particles.blood(target.x, target.y);
}
}
// Draw laser beam
game.laserBeam = {x1:unit.x, y1:unit.y-10, x2:hitX, y2:hitY, life:15};
sound.play('laser');
}
}
_lineToCircleDistance(lx, ly, angle, cx, cy, r) {
const dx = Math.cos(angle), dy = Math.sin(angle);
const fx = cx-lx, fy = cy-ly;
const t = fx*dx + fy*dy;
if(t < 0) return -1;
const distX = lx+dx*t-cx, distY = ly+dy*t-cy;
const perpDist = Math.sqrt(distX*distX+distY*distY);
if(perpDist <= r) {
return t - Math.sqrt(r*r-perpDist*perpDist);
}
return -1;
}
}
// --- Main Game Class ---
class Game {
constructor() {
this.state = 'menu'; // menu, playing, gameover
this.units = [];
this.projectiles = [];
this.activeTeamIndex = 0;
this.activeUnitIndex = 0;
this.turnTimer = TURN_TIME;
this.turnTimerMax = TURN_TIME;
this.wind = 0;
this.cameraX = WORLD_W/2;
this.cameraY = WORLD_H/2;
this.targetCameraX = WORLD_W/2;
this.targetCameraY = WORLD_H/2;
this.zoom = 1;
this.targetZoom = 1;
this.screenShake = 0;
this.impactFreeze = 0;
this.nukeUsed = {}; // team -> bool
this.laserBeam = null;
this.battleCount = 0;
// Input state
this.keys = {};
this.mouseX = 0;
this.mouseY = 0;
this.mouseDown = false;
this.aimAngle = -Math.PI/4;
this.aimPower = 50;
this.isAiming = false;
this.selectedWeapon = 0;
this.selectedUtility = null;
// Menu settings
this.menuTerrain = 'island';
this.menuTeams = 3;
this.menuDifficulty = 'medium';
this.menuCPUCount = 1;
// AI controllers per team
this.aiControllers = {};
// Log entries
this.logEntries = [];
// Trajectory preview points
this.trajectoryPoints = [];
// Turn announcement
this.announceTimer = 0;
this.announceText = '';
// Tick timer for sound
this.lastTickSecond = -1;
}
get activeUnit() {
if(this.activeTeamIndex >= this.units.length) return null;
const teamUnits = this.units.filter(u => u.team === this.activeTeamIndex && u.alive);
if(teamUnits.length === 0) return null;
return teamUnits[this.activeUnitIndex % teamUnits.length];
}
get isCPU() {
const unit = this.activeUnit;
if(!unit) return false;
return !!this.aiControllers[unit.team];
}
init(terrainType, numTeams, difficulty, cpuCount) {
this.menuTerrain = terrainType;
this.menuTeams = numTeams;
this.menuDifficulty = difficulty;
this.menuCPUCount = cpuCount;
// Generate terrain
terrain.generate(terrainType, WATER_LEVEL);
// Create units
this.units = [];
const teamColors = ['#ff4444','#4488ff','#44dd44','#ddaa00'];
const teamNames = ['Red','Blue','Green','Gold'];
for(let t=0;t<numTeams;t++) {
// Place units on terrain
const spacing = WORLD_W / (numTeams * 4);
const offsetX = spacing * 2 + Math.random()*spacing;
for(let i=0;i<4;i++) {
const x = offsetX + i * spacing * 1.5 + (Math.random()-0.5)*30;
const y = terrain.getGroundY(x) - 5;
const worm = new Worm(x, y, t, i);
worm.name = `${teamNames[t]} ${i+1}`;
this.units.push(worm);
}
}
// Setup AI controllers
this.aiControllers = {};
for(let c=0;c<cpuCount && c<numTeams;c++) {
this.aiControllers[c] = new AIController(difficulty);
}
// Reset game state
this.activeTeamIndex = 0;
this.activeUnitIndex = 0;
this.turnTimer = TURN_TIME;
this.wind = (Math.random()-0.5)*10;
this.nukeUsed = {};
this.battleCount++;
// Set initial camera
const firstUnit = this.units.find(u => u.alive);
if(firstUnit) {
this.targetCameraX = firstUnit.x;
this.targetCameraY = firstUnit.y - 100;
}
this.state = 'playing';
this.startTurn();
}
startTurn() {
// Find next alive unit
let found = false;
const maxAttempts = this.units.length * 2;
let attempts = 0;
while(attempts < maxAttempts) {
const teamUnits = this.units.filter(u => u.team === this.activeTeamIndex && u.alive);
if(teamUnits.length === 0) {
// Team eliminated!
this.addLog(`Team ${this.activeTeamIndex+1} has been eliminated!`, '#ff4444');
this.activeTeamIndex = (this.activeTeamIndex + 1) % this.menuTeams;
attempts++;
continue;
}
if(this.activeUnitIndex >= teamUnits.length) {
// All units on this team have taken turns, move to next team
this.activeTeamIndex = (this.activeTeamIndex + 1) % this.menuTeams;
this.activeUnitIndex = 0;
attempts++;
continue;
}
found = true;
break;
}
if(!found) {
// Check if only one team remains
const aliveTeams = new Set(this.units.filter(u=>u.alive).map(u=>u.team));
if(aliveTeams.size <= 1) {
this.endGame();
return;
}
}
const unit = this.activeUnit;
if(!unit) return;
// Reset turn state
this.turnTimer = TURN_TIME;
this.lastTickSecond = -1;
unit.moveBudget = 200;
this.selectedWeapon = 0;
this.selectedUtility = null;
this.isAiming = false;
this.aimPower = 50;
// Announce turn
const teamNames = ['Red','Blue','Green','Gold'];
this.showAnnouncement(`${teamNames[unit.team]} Team's Turn\n${unit.name}`, unit.color);
sound.play('turn_start');
// Update camera target
this.targetCameraX = unit.x;
this.targetCameraY = Math.min(unit.y - 100, WATER_LEVEL - 200);
// Dynamic zoom based on number of alive units
const aliveCount = this.units.filter(u=>u.alive).length;
if(aliveCount <= 4) this.targetZoom = 1.3;
else if(aliveCount <= 8) this.targetZoom = 1.1;
else this.targetZoom = 0.9;
// CPU turn?
if(this.isCPU) {
const ai = this.aiControllers[unit.team];
if(ai) ai.startTurn(unit);
}
this.updateHUD();
}
skipTurn() {
if(this.state !== 'playing') return;
sound.play('click');
// Move to next unit
const teamUnits = this.units.filter(u => u.team === this.activeTeamIndex && u.alive);
this.activeUnitIndex++;
if(this.activeUnitIndex >= teamUnits.length) {
this.activeTeamIndex = (this.activeTeamIndex + 1) % this.menuTeams;
this.activeUnitIndex = 0;
}
this.startTurn();
}
fireWeapon() {
const unit = this.activeUnit;
if(!unit || !unit.alive || this.isCPU) return;
const weapon = WEAPONS[this.selectedWeapon];
sound.play('resume');
// Check nuke usage limit
if(weapon.id === 'nuke' && this.nukeUsed[unit.team]) {
this.addLog('Nuke already used by your team!', '#ff4444');
return;
}
if(weapon.category === 'direct') {
// Direct fire - aim at mouse position
const worldMouseX = (this.mouseX - canvas.width/2) / this.zoom + this.cameraX;
const worldMouseY = (this.mouseY - canvas.height/2) / this.zoom + this.cameraY;
const dx = worldMouseX - unit.x;
const dy = worldMouseY - (unit.y-10);
const angle = Math.atan2(dy, dx);
// Simulate direct weapon fire
if(weapon.id === 'shotgun') {
for(let i=0;i<7;i++) {
const spread = (i-3)*0.08 + (Math.random()-0.5)*0.05;
this._raycastShot(unit, angle+spread, weapon);
}
sound.play('weapon_fire');
} else if(weapon.id === 'laser') {
this._fireLaser(unit, angle, weapon);
sound.play('laser');
}
} else if(weapon.category === 'aoe' && weapon.id === 'earthquake') {
// Earthquake - instant effect
const proj = new Projectile(unit.x, unit.y, 0, 0, weapon, unit.team);
this.projectiles.push(proj);
setTimeout(() => proj.explode(), 200);
sound.play('weapon_fire', {weapon:'earthquake'});
} else if(weapon.category === 'aoe' && weapon.id === 'nuke') {
// Nuke - fire projectile
const power = (this.aimPower / 100) * 15;
const vx = Math.cos(this.aimAngle) * power;
const vy = Math.sin(this.aimAngle) * power;
const proj = new Projectile(unit.x, unit.y-15, vx, vy, weapon, unit.team);
this.projectiles.push(proj);
this.nukeUsed[unit.team] = true;
sound.play('weapon_fire', {weapon:'nuke'});
} else {
// Indirect projectile
const power = (this.aimPower / 100) * 15;
const vx = Math.cos(this.aimAngle) * power;
const vy = Math.sin(this.aimAngle) * power;
const proj = new Projectile(unit.x, unit.y-15, vx, vy, weapon, unit.team);
this.projectiles.push(proj);
sound.play('weapon_fire', {weapon:weapon.id});
}
// Stop aiming after firing
this.isAiming = false;
}
_raycastShot(unit, angle, weapon) {
const dx = Math.cos(angle)*5;
const dy = Math.sin(angle)*5;
let hx = unit.x, hy = unit.y-10;
for(let s=0;s<80;s++) {
hx += dx; hy += dy;
if(terrain.isGround(Math.floor(hx), Math.floor(hy))) {
particles.dust(hx, hy);
break;
}
for(const target of this.units) {
if(!target.alive || target.team === unit.team) continue;
const tdx = hx-target.x, tdy = hy-target.y;
if(Math.sqrt(tdx*tdx+tdy*tdy)<12) {
target.takeDamage(weapon.damage/7, unit.team);
particles.blood(target.x, target.y);
break;
}
}
}
}
_fireLaser(unit, angle, weapon) {
const maxDist = 500;
let hitX = unit.x + Math.cos(angle)*maxDist;
let hitY = unit.y-10 + Math.sin(angle)*maxDist;
// Find closest intersection
let minDist = maxDist;
for(const target of this.units) {
if(!target.alive || target.team === unit.team) continue;
// Simple distance check along beam
const steps = 50;
for(let s=1;s<=steps;s++) {
const t = s/steps;
const bx = unit.x + Math.cos(angle)*maxDist*t;
const by = unit.y-10 + Math.sin(angle)*maxDist*t;
const dx = bx-target.x, dy = by-target.y;
if(Math.sqrt(dx*dx+dy*dy)<15) {
const dist = s/steps * maxDist;
if(dist < minDist) {
minDist = dist;
hitX = bx; hitY = by;
target.takeDamage(weapon.damage, unit.team);
particles.blood(target.x, target.y);
}
}
}
}
// Check terrain intersection
for(let s=1;s<=steps;s++) {
const t = s/50;
const bx = unit.x + Math.cos(angle)*maxDist*t;
const by = unit.y-10 + Math.sin(angle)*maxDist*t;
if(terrain.isGround(Math.floor(bx), Math.floor(by))) {
const dist = s/50*maxDist;
if(dist < minDist) {
minDist = dist;
hitX = bx; hitY = by;
}
break;
}
}
this.laserBeam = {x1:unit.x, y1:unit.y-10, x2:hitX, y2:hitY, life:20};
}
useUtility(item) {
const unit = this.activeUnit;
if(!unit || !unit.alive || this.isCPU) return;
switch(item.id) {
case 'medkit':
if(unit.hp < UNIT_MAX_HP) {
unit.hp = Math.min(UNIT_MAX_HP, unit.hp + 25);
floatingTexts.push(new FloatingText(unit.x, unit.y-20, '+25 HP', '#44ff44'));
sound.play('medkit');
this.addLog(`${unit.name} used a Medkit!`, '#44ff44');
}
break;
case 'parachute':
unit.hasParachute = true;
sound.play('parachute');
this.addLog(`${unit.name} deployed a Parachute!`, '#88ccff');
break;
case 'binoculars':
// Reveal enemy positions briefly (show on minimap)
this.showEnemyPositions = true;
setTimeout(() => { this.showEnemyPositions = false; }, 5000);
sound.play('select');
this.addLog(`${unit.name} used Binoculars!`, '#88ccff');
break;
}
this.selectedUtility = null;
}
showAnnouncement(text, color) {
const el = document.getElementById('turn-announce');
el.textContent = text;
el.style.color = color || 'white';
el.style.opacity = 1;
this.announceTimer = 120;
}
addLog(text, color='#fff') {
const el = document.getElementById('game-log');
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.style.color = color || '#ccc';
entry.textContent = text;
el.appendChild(entry);
// Remove old entries
while(el.children.length > 8) {
el.removeChild(el.firstChild);
}
setTimeout(() => { if(entry.parentNode) entry.parentNode.removeChild(entry); }, 5000);
}
endGame() {
this.state = 'gameover';
const aliveTeams = new Set(this.units.filter(u=>u.alive).map(u=>u.team));
const winnerTeam = [...aliveTeams][0];
if(winnerTeam !== undefined) {
const teamNames = ['Red','Blue','Green','Gold'];
document.getElementById('winner-text').textContent = `${teamNames[winnerTeam]} Team Wins!`;
document.getElementById('winner-sub').textContent = `Survivors: ${this.units.filter(u=>u.alive && u.team===winnerTeam).length}`;
} else {
document.getElementById('winner-text').textContent = 'Draw!';
document.getElementById('winner-sub').textContent = 'All teams eliminated';
}
document.getElementById('game-over').style.display = 'flex';
}
updateHUD() {
const unit = this.activeUnit;
if(!unit) return;
// Team badges
const badgesEl = document.getElementById('team-badges');
badgesEl.innerHTML = '';
const teamNames = ['Red','Blue','Green','Gold'];
for(let t=0;t<this.menuTeams;t++) {
const aliveCount = this.units.filter(u=>u.alive && u.team===t).length;
const badge = document.createElement('div');
badge.className = `team-badge team-bg-${t}` + (t === this.activeTeamIndex ? ' active' : '');
badge.textContent = `${teamNames[t]} (${aliveCount})`;
badgesEl.appendChild(badge);
}
// Unit info
document.getElementById('unit-name').textContent = unit.name;
document.getElementById('unit-name').style.color = unit.color;
const hpRatio = unit.hp / UNIT_MAX_HP;
const hpBar = document.getElementById('health-bar');
hpBar.style.width = `${hpRatio*100}%`;
hpBar.style.background = `linear-gradient(90deg, ${hpRatio>0.5?'#44ff44':'#ff4444'}, ${hpRatio>0.25?'#aaff00':'#cc0000'})`;
// Weapon panel
const wpEl = document.getElementById('weapon-panel');
wpEl.innerHTML = '';
WEAPONS.forEach((w, i) => {
const btn = document.createElement('button');
btn.className = 'weapon-btn' + (i === this.selectedWeapon ? ' selected' : '');
btn.innerHTML = `<span class="icon">${w.icon}</span><span>${w.name}</span>`;
btn.title = w.desc;
// Check if nuke already used
if(w.id === 'nuke' && unit && this.nukeUsed[unit.team]) {
btn.style.opacity = '0.3';
btn.title += ' (Already Used)';
}
btn.onclick = () => {
sound.play('select');
this.selectedWeapon = i;
this.updateHUD();
};
wpEl.appendChild(btn);
});
// Utility panel
const utilEl = document.getElementById('utility-panel');
utilEl.innerHTML = '';
UTILITIES.forEach(u => {
const btn = document.createElement('button');
btn.className = 'util-btn' + (this.selectedUtility === u ? ' selected' : '');
btn.textContent = u.icon;
btn.title = `${u.name}: ${u.desc}`;
btn.onclick = () => {
sound.play('select');
this.useUtility(u);
this.updateHUD();
};
utilEl.appendChild(btn);
});
// Turn info
document.getElementById('turn-info').innerHTML = `Turn: ${this.battleCount}<br>Wind: ${this.wind > 0 ? '→' : '←'} ${Math.abs(this.wind).toFixed(1)}`;
// Wind indicator
document.getElementById('wind-value').textContent = `${this.wind > 0 ? '→' : '←'} ${Math.abs(this.wind).toFixed(1)}`;
// Instructions
const instrEl = document.getElementById('instructions');
if(!this.isCPU) {
instrEl.textContent = 'Arrow Keys: Move | Space: Jump | Click & Hold: Aim | Release: Fire | Scroll: Zoom';
} else {
instrEl.textContent = `${unit.name} (CPU) is thinking...`;
}
}
update() {
if(this.state !== 'playing') return;
// Impact freeze
if(this.impactFreeze > 0) {
this.impactFreeze--;
return;
}
const unit = this.activeUnit;
// Turn timer
if(!this.isCPU && unit) {
this.turnTimer -= 1/60;
// Timer tick sounds
const currentSecond = Math.floor(this.turnTimer);
if(currentSecond !== this.lastTickSecond && currentSecond <= 5 && currentSecond > 0) {
sound.play('timer_tick', {urgency: (5-currentSecond)/5});
this.lastTickSecond = currentSecond;
}
if(this.turnTimer <= 0) {
this.addLog(`${unit.name} ran out of time!`, '#ff8800');
this.skipTurn();
return;
}
}
// Update timer UI
const timerFill = document.getElementById('turn-timer-fill');
const timerText = document.getElementById('turn-timer-text');
if(timerFill && timerText) {
const ratio = Math.max(0, this.turnTimer / TURN_TIME);
timerFill.style.width = `${ratio*100}%`;
timerText.textContent = Math.ceil(this.turnTimer);
// Color changes with urgency
if(ratio > 0.5) timerFill.style.background = '#4f4';
else if(ratio > 0.25) timerFill.style.background = '#ff0';
else {
timerFill.style.background = `rgb(${Math.floor(255*(1-ratio*2))},${Math.floor(255*ratio*2)},0)`;
// Pulsing effect when low
if(Math.sin(Date.now()*0.01) > 0.5) {
timerText.style.transform = 'scale(1.2)';
} else {
timerText.style.transform = 'scale(1)';
}
}
}
// Announce timer
if(this.announceTimer > 0) {
this.announceTimer--;
const el = document.getElementById('turn-announce');
if(this.announceTimer < 30) {
el.style.opacity = Math.max(0, this.announceTimer/30);
}
}
// Player input (only for human turns)
if(!this.isCPU && unit && unit.alive) {
if(this.keys['ArrowLeft'] || this.keys['a']) unit.move(-1);
if(this.keys['ArrowRight'] || this.keys['d']) unit.move(1);
if(this.keys[' '] || this.keys['ArrowUp'] || this.keys['w']) unit.jump();
// Aiming with mouse
if(this.mouseDown) {
const worldMouseX = (this.mouseX - canvas.width/2) / this.zoom + this.cameraX;
const worldMouseY = (this.mouseY - canvas.height/2) / this.zoom + this.cameraY;
const dx = worldMouseX - unit.x;
const dy = worldMouseY - (unit.y-10);
this.aimAngle = Math.atan2(dy, dx);
// Power based on distance held or mouse distance
const dist = Math.sqrt(dx*dx+dy*dy);
this.aimPower = Math.min(100, Math.max(10, dist / 5));
}
}
// Update units
for(const u of this.units) {
u.update(this.wind);
}
// Update projectiles
for(const p of this.projectiles) {
p.update(this.wind);
}
this.projectiles = this.projectiles.filter(p => p.alive);
// Update particles
particles.update();
// Update floating texts
floatingTexts = floatingTexts.filter(ft => ft.update());
// Screen shake decay
if(this.screenShake > 0) this.screenShake *= 0.9;
if(this.screenShake < 0.5) this.screenShake = 0;
// Camera smoothing
const camUnit = this.activeUnit;
if(camUnit && camUnit.alive) {
this.targetCameraX += (camUnit.x - this.targetCameraX) * 0.02;
this.targetCameraY += (Math.min(camUnit.y - 100, WATER_LEVEL - 250) - this.targetCameraY) * 0.02;
}
this.cameraX += (this.targetCameraX - this.cameraX) * 0.08;
this.cameraY += (this.targetCameraY - this.cameraY) * 0.08;
this.zoom += (this.targetZoom - this.zoom) * 0.05;
// Calculate trajectory preview for aiming
if(this.isAiming && unit && !this.isCPU) {
const weapon = WEAPONS[this.selectedWeapon];
if(weapon.category === 'indirect' || (weapon.category === 'aoe' && weapon.id !== 'earthquake')) {
this.trajectoryPoints = [];
const power = (this.aimPower / 100) * 15;
let px = unit.x, py = unit.y-15;
let pvx = Math.cos(this.aimAngle)*power;
let pvy = Math.sin(this.aimAngle)*power;
for(let t=0;t<120;t++) {
pvy += GRAVITY;
pvx += this.wind * 0.01;
px += pvx;
py += pvy;
if(py > WORLD_H || px < 0 || px > WORLD_W) break;
if(terrain.isGround(Math.floor(px), Math.floor(py))) {
this.trajectoryPoints.push({x:px, y:py, hit:true});
break;
}
if(t % 3 === 0) {
this.trajectoryPoints.push({x:px, y:py, hit:false});
}
}
}
} else {
this.trajectoryPoints = [];
}
// Laser beam decay
if(this.laserBeam && --this.laserBeam.life <= 0) {
this.laserBeam = null;
}
// Check for game end
const aliveTeams = new Set(this.units.filter(u=>u.alive).map(u=>u.team));
if(aliveTeams.size === 1 && this.state === 'playing') {
setTimeout(() => this.endGame(), 1000);
}
// Update HUD periodically
if(Math.random() < 0.05) this.updateHUD();
}
draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Screen shake offset
const shakeX = (Math.random()-0.5)*this.screenShake*2;
const shakeY = (Math.random()-0.5)*this.screenShake*2;
ctx.save();
ctx.translate(shakeX, shakeY);
// Camera transform
ctx.setTransform(this.zoom, 0, 0, this.zoom,
-this.cameraX*this.zoom + canvas.width/2,
-this.cameraY*this.zoom + canvas.height/2);
// Draw terrain (sky + ground)
terrain.ctx.save();
terrain.ctx.restore();
// Sky background
const skyGrad = ctx.createLinearGradient(0, 0, 0, WORLD_H);
skyGrad.addColorStop(0, '#1a237e');
skyGrad.addColorStop(0.4, '#283593');
skyGrad.addColorStop(0.7, '#5c6bc0');
skyGrad.addColorStop(1, '#9fa8da');
ctx.fillStyle = skyGrad;
ctx.fillRect(-100, -100, WORLD_W+200, WORLD_H+200);
// Stars
for(let i=0;i<150;i++) {
const sx = (i*137.5)%WORLD_W;
const sy = (i*97.3)%(WORLD_H*0.4);
ctx.fillStyle = `rgba(255,255,255,${0.3+Math.sin(Date.now()*0.001+i)*0.3})`;
ctx.fillRect(sx, sy, 1.5, 1.5);
}
// Draw terrain
ctx.drawImage(terrain.canvas, 0, 0);
ctx.drawImage(terrain.terrainCanvas, 0, 0);
// Water
this._drawWater(ctx);
// Trajectory preview
if(this.trajectoryPoints.length > 1) {
ctx.setLineDash([5, 5]);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
for(let i=0;i<this.trajectoryPoints.length;i++) {
const p = this.trajectoryPoints[i];
if(i===0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.setLineDash([]);
// Explosion preview at end point
const lastPt = this.trajectoryPoints[this.trajectoryPoints.length-1];
if(lastPt && lastPt.hit) {
const weapon = WEAPONS[this.selectedWeapon];
ctx.strokeStyle = 'rgba(255,100,0,0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(lastPt.x, lastPt.y, weapon.radius, 0, Math.PI*2);
ctx.stroke();
}
}
// Laser beam
if(this.laserBeam) {
const alpha = this.laserBeam.life / 20;
ctx.strokeStyle = `rgba(255,0,255,${alpha})`;
ctx.lineWidth = 4*alpha;
ctx.shadowColor = '#ff00ff';
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.moveTo(this.laserBeam.x1, this.laserBeam.y1);
ctx.lineTo(this.laserBeam.x2, this.laserBeam.y2);
ctx.stroke();
// Inner beam
ctx.strokeStyle = `rgba(255,200,255,${alpha})`;
ctx.lineWidth = 2*alpha;
ctx.beginPath();
ctx.moveTo(this.laserBeam.x1, this.laserBeam.y1);
ctx.lineTo(this.laserBeam.x2, this.laserBeam.y2);
ctx.stroke();
ctx.shadowBlur = 0;
}
// Draw units (sorted by Y for depth)
const sortedUnits = [...this.units].sort((a,b) => a.y - b.y);
for(const u of sortedUnits) {
u.draw(ctx);
}
// Draw projectiles
for(const p of this.projectiles) {
p.draw(ctx);
}
// Draw particles
particles.draw(ctx);
// Draw floating texts
for(const ft of floatingTexts) {
ft.draw(ctx);
}
ctx.restore();
}
_drawWater(ctx) {
const time = Date.now() * 0.002;
// Water body
ctx.save();
ctx.globalAlpha = 0.6;
for(let x=0;x<WORLD_W;x+=3) {
const waveY = WATER_LEVEL + Math.sin(x*0.03+time)*4 + Math.sin(x*0.07+time*1.5)*2;
// Water gradient
const grad = ctx.createLinearGradient(0, waveY, 0, WORLD_H);
grad.addColorStop(0, 'rgba(30,80,180,0.6)');
grad.addColorStop(0.5, 'rgba(20,50,140,0.7)');
grad.addColorStop(1, 'rgba(10,20,80,0.8)');
ctx.fillStyle = grad;
ctx.fillRect(x, waveY, 3, WORLD_H - waveY);
}
// Surface highlight
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#66aaff';
ctx.lineWidth = 2;
ctx.beginPath();
for(let x=0;x<WORLD_W;x+=3) {
const waveY = WATER_LEVEL + Math.sin(x*0.03+time)*4 + Math.sin(x*0.07+time*1.5)*2;
if(x===0) ctx.moveTo(x, waveY);
else ctx.lineTo(x, waveY);
}
ctx.stroke();
// Sparkles on water surface
for(let i=0;i<30;i++) {
const sx = (i*97+time*50)%WORLD_W;
const sy = WATER_LEVEL + Math.sin(sx*0.03+time)*4 + 2;
if(Math.sin(time+i*2)>0.7) {
ctx.fillStyle = 'rgba(150,200,255,0.6)';
ctx.fillRect(sx, sy, 2, 2);
}
}
ctx.restore();
}
drawUI() {
// Power meter (when aiming)
const powerEl = document.getElementById('power-meter-container');
const angleEl = document.getElementById('angle-display');
if(this.isAiming && !this.isCPU) {
powerEl.style.display = 'block';
angleEl.style.display = 'block';
document.getElementById('power-meter-fill').style.width = `${this.aimPower}%`;
const degrees = Math.round((this.aimAngle * 180 / Math.PI) % 360);
angleEl.textContent = `${degrees}° | Power: ${Math.round(this.aimPower)}%`;
} else {
powerEl.style.display = 'none';
angleEl.style.display = 'none';
}
// Minimap
if(document.getElementById('minimap-container').style.display !== 'none') {
minimapCanvas.width = 180;
minimapCanvas.height = 100;
terrain.renderMinimap(minimapCtx);
// Draw units on minimap
const scaleX = 180 / WORLD_W;
const scaleY = 100 / WORLD_H;
for(const u of this.units) {
if(!u.alive) continue;
minimapCtx.fillStyle = u.color;
minimapCtx.beginPath();
minimapCtx.arc(u.x*scaleX, u.y*scaleY, 3, 0, Math.PI*2);
minimapCtx.fill();
// Highlight active unit
if(this.activeUnit === u) {
minimapCtx.strokeStyle = '#ffcc00';
minimapCtx.lineWidth = 1;
minimapCtx.beginPath();
minimapCtx.arc(u.x*scaleX, u.y*scaleY, 5, 0, Math.PI*2);
minimapCtx.stroke();
}
}
// Camera viewport on minimap
const viewW = (canvas.width/this.zoom) * scaleX;
const viewH = (canvas.height/this.zoom) * scaleY;
minimapCtx.strokeStyle = 'rgba(255,255,255,0.3)';
minimapCtx.lineWidth = 1;
minimapCtx.strokeRect(
(this.cameraX - canvas.width/(2*this.zoom))*scaleX,
(this.cameraY - canvas.height/(2*this.zoom))*scaleY,
viewW, viewH
);
}
}
}
// --- Initialize ---
const terrain = new Terrain();
const game = new Game();
// Resize handler
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
// Input handlers
document.addEventListener('keydown', e => {
game.keys[e.key] = true;
if(e.key === 'Escape') {
if(game.state === 'playing' && !game.isCPU) {
game.skipTurn();
}
}
// Weapon selection with number keys
const num = parseInt(e.key);
if(num >= 1 && num <= WEAPONS.length) {
game.selectedWeapon = num - 1;
sound.play('select');
game.updateHUD();
}
});
document.addEventListener('keyup', e => {
game.keys[e.key] = false;
});
canvas.addEventListener('mousedown', e => {
if(game.state !== 'playing' || game.isCPU) return;
game.mouseDown = true;
game.isAiming = true;
sound.play('click');
});
canvas.addEventListener('mousemove', e => {
game.mouseX = e.clientX;
game.mouseY = e.clientY;
});
canvas.addEventListener('mouseup', e => {
if(game.state !== 'playing' || game.isCPU) return;
if(game.mouseDown && game.isAiming) {
game.fireWeapon();
}
game.mouseDown = false;
game.isAiming = false;
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
const zoomDelta = e.deltaY > 0 ? -0.1 : 0.1;
game.targetZoom = Math.max(0.4, Math.min(2.5, game.targetZoom + zoomDelta));
});
// Touch support for mobile
canvas.addEventListener('touchstart', e => {
if(game.state !== 'playing' || game.isCPU) return;
const touch = e.touches[0];
game.mouseX = touch.clientX;
game.mouseY = touch.clientY;
game.mouseDown = true;
game.isAiming = true;
});
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const touch = e.touches[0];
game.mouseX = touch.clientX;
game.mouseY = touch.clientY;
}, {passive: false});
canvas.addEventListener('touchend', e => {
if(game.state !== 'playing' || game.isCPU) return;
if(game.mouseDown && game.isAiming) {
game.fireWeapon();
}
game.mouseDown = false;
game.isAiming = false;
});
// Menu handlers
document.querySelectorAll('#terrain-select .menu-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#terrain-select .menu-btn').forEach(b=>b.classList.remove('selected'));
btn.classList.add('selected');
sound.play('click');
};
});
document.querySelectorAll('#teams-select .menu-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#teams-select .menu-btn').forEach(b=>b.classList.remove('selected'));
btn.classList.add('selected');
sound.play('click');
};
});
document.querySelectorAll('#difficulty-select .menu-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#difficulty-select .menu-btn').forEach(b=>b.classList.remove('selected'));
btn.classList.add('selected');
sound.play('click');
};
});
document.querySelectorAll('#cpu-select .menu-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#cpu-select .menu-btn').forEach(b=>b.classList.remove('selected'));
btn.classList.add('selected');
sound.play('click');
};
});
// Start button
document.getElementById('start-btn').onclick = () => {
sound.resume();
sound.play('select');
const terrainType = document.querySelector('#terrain-select .menu-btn.selected')?.dataset.val || 'island';
const numTeams = parseInt(document.querySelector('#teams-select .menu-btn.selected')?.dataset.val || '3');
const difficulty = document.querySelector('#difficulty-select .menu-btn.selected')?.dataset.val || 'medium';
const cpuCount = parseInt(document.querySelector('#cpu-select .menu-btn.selected')?.dataset.val || '1');
game.init(terrainType, numTeams, difficulty, cpuCount);
// Show HUD elements
document.getElementById('menu-screen').style.display = 'none';
document.getElementById('hud-top').style.display = 'flex';
document.getElementById('hud-bottom').style.display = 'flex';
document.getElementById('wind-indicator').style.display = 'block';
document.getElementById('minimap-container').style.display = 'block';
document.getElementById('zoom-controls').style.display = 'flex';
};
// Skip button
document.getElementById('skip-btn').onclick = () => {
sound.play('click');
game.skipTurn();
};
// Sound toggle
document.getElementById('sound-toggle').onclick = () => {
sound.enabled = !sound.enabled;
document.getElementById('sound-toggle').textContent = sound.enabled ? '🔊' : '🔇';
};
// Menu button (back to menu)
document.getElementById('menu-btn').onclick = () => {
game.state = 'menu';
document.getElementById('menu-screen').style.display = 'flex';
document.getElementById('hud-top').style.display = 'none';
document.getElementById('hud-bottom').style.display = 'none';
document.getElementById('wind-indicator').style.display = 'none';
document.getElementById('minimap-container').style.display = 'none';
document.getElementById('zoom-controls').style.display = 'none';
document.getElementById('game-over').style.display = 'none';
};
// Restart button
document.getElementById('restart-btn').onclick = () => {
document.getElementById('game-over').style.display = 'none';
game.state = 'menu';
document.getElementById('menu-screen').style.display = 'flex';
document.getElementById('hud-top').style.display = 'none';
document.getElementById('hud-bottom').style.display = 'none';
};
// Zoom buttons
document.getElementById('zoom-in').onclick = () => {
game.targetZoom = Math.min(2.5, game.targetZoom + 0.2);
sound.play('click');
};
document.getElementById('zoom-out').onclick = () => {
game.targetZoom = Math.max(0.4, game.targetZoom - 0.2);
sound.play('click');
};
// --- Main Game Loop ---
function gameLoop() {
game.update();
if(game.state === 'playing') {
game.draw();
game.drawUI();
// Redraw water animation on terrain canvas periodically
if(Math.random() < 0.1) {
terrain._drawWaterAnimated(WATER_LEVEL);
}
} else if(game.state === 'menu') {
// Animated background for menu
ctx.fillStyle = '#0a0e27';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Floating particles in menu
const time = Date.now() * 0.001;
for(let i=0;i<50;i++) {
const x = (Math.sin(time+i*0.7)*0.5+0.5)*canvas.width;
const y = (Math.cos(time*0.7+i*1.1)*0.5+0.5)*canvas.height;
ctx.fillStyle = `rgba(255,204,0,${0.1+Math.sin(time+i)*0.1})`;
ctx.beginPath();
ctx.arc(x, y, 2+Math.sin(time*2+i)*1, 0, Math.PI*2);
ctx.fill();
}
}
requestAnimationFrame(gameLoop);
}
// Start the game loop
gameLoop();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"810f9e77-7c27-4bf5-9452-4a76c6612ff5","tokensIn":2688366,"tokensOut":40177,"tokensTotal":2728543,"turns":36,"toolCalls":35,"failedToolCalls":0,"timestamp":"2026-07-30T03:24:39.266Z"} -->