Worms
thinkingcap qwen3.6 27b · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 63,486 bytes · SHA-256 85ed9c67d5d3
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Worms Artillery Battle</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; overflow: hidden; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; user-select: none; }
canvas { display: block; }
#hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#topBar { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: linear-gradient(180deg, rgba(0,0,0,0.7) 0%, transparent 100%); pointer-events: auto; }
#teamInfo { color: #fff; font-size: 14px; display: flex; gap: 12px; align-items: center; }
.team-badge { padding: 4px 12px; border-radius: 12px; font-weight: bold; font-size: 13px; border: 2px solid rgba(255,255,255,0.3); transition: all 0.3s; }
.team-badge.active { transform: scale(1.15); box-shadow: 0 0 12px currentColor; }
#turnTimer { text-align: center; }
#timerValue { font-size: 36px; font-weight: bold; color: #fff; text-shadow: 0 0 10px rgba(255,255,255,0.5); transition: transform 0.1s; }
#windIndicator { color: #aef; font-size: 14px; background: rgba(0,0,0,0.4); padding: 6px 14px; border-radius: 8px; }
#bottomBar { position: absolute; bottom: 0; left: 0; right: 0; display: flex; justify-content: center; align-items: flex-end; gap: 20px; padding: 12px; background: linear-gradient(0deg, rgba(0,0,0,0.8) 0%, transparent 100%); pointer-events: auto; }
#weaponSelect { display: flex; gap: 6px; }
.weapon-btn { width: 56px; height: 56px; border-radius: 10px; border: 2px solid rgba(255,255,255,0.3); background: rgba(0,0,0,0.5); color: #fff; font-size: 24px; cursor: pointer; transition: all 0.15s; display: flex; align-items: center; justify-content: center; position: relative; }
.weapon-btn:hover { transform: translateY(-4px); border-color: #fff; background: rgba(255,255,255,0.15); }
.weapon-btn.selected { border-color: #ffeb3b; box-shadow: 0 0 16px rgba(255,235,59,0.5); transform: translateY(-4px); }
.weapon-btn .wname { position: absolute; bottom: -18px; font-size: 9px; white-space: nowrap; color: #ccc; }
#actionButtons { display: flex; gap: 6px; align-items: center; }
.action-btn { padding: 8px 20px; border-radius: 8px; border: none; background: linear-gradient(135deg, #e94560, #c62a71); color: #fff; font-weight: bold; cursor: pointer; font-size: 14px; transition: all 0.15s; }
.action-btn:hover { transform: scale(1.08); filter: brightness(1.2); }
#healthBars { position: absolute; top: 50px; left: 0; right: 0; pointer-events: none; }
.overlay { position: fixed; inset: 0; background: rgba(10,10,30,0.92); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; pointer-events: auto; }
.overlay.hidden { display: none !important; }
.overlay h1 { font-size: 52px; color: #ffeb3b; text-shadow: 0 0 30px rgba(255,235,59,0.4); margin-bottom: 30px; }
.overlay h2 { font-size: 36px; color: #fff; margin-bottom: 20px; }
.overlay button { padding: 14px 40px; border-radius: 12px; border: none; background: linear-gradient(135deg, #e94560, #c62a71); color: #fff; font-size: 20px; font-weight: bold; cursor: pointer; transition: all 0.2s; margin-top: 10px; }
.overlay button:hover { transform: scale(1.08); filter: brightness(1.2); box-shadow: 0 0 20px rgba(233,69,96,0.5); }
label { color: #ccc; margin: 8px; font-size: 16px; display: inline-block; }
select { padding: 6px 12px; border-radius: 6px; background: #333; color: #fff; border: 1px solid #555; font-size: 14px; margin-left: 6px; }
#cpuSelect { display: flex; gap: 10px; margin: 12px 0; flex-wrap: wrap; justify-content: center; }
.cpu-toggle { padding: 8px 16px; border-radius: 8px; border: 2px solid #555; background: rgba(0,0,0,0.4); color: #fff; cursor: pointer; transition: all 0.15s; }
.cpu-toggle.active { border-color: #e94560; background: rgba(233,69,96,0.3); }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud">
<div id="topBar">
<div id="teamInfo"></div>
<div id="turnTimer"><span id="timerValue">30</span></div>
<div id="windIndicator">Wind: → 0</div>
</div>
<div id="bottomBar">
<div id="weaponSelect"></div>
<div id="actionButtons"></div>
</div>
<div id="healthBars"></div>
<div id="menuOverlay" class="overlay">
<h1>🐛 WORMS BATTLE 🐛</h1>
<button id="btnSetup">Start Game</button>
</div>
<div id="setupOverlay" class="overlay hidden">
<h2>Game Setup</h2>
<label>Teams: <select id="teamCount"><option value="2">2</option><option value="3">3</option><option value="4">4</option></select></label>
<label>Terrain: <select id="terrainType"><option value="island">Single Island</option><option value="twoIslands">Two Islands</option><option value="cave">Cave</option></select></label>
<div id="cpuSelect"></div>
<button id="btnStart">Begin Battle!</button>
</div>
<div id="gameOverOverlay" class="overlay hidden">
<h2 id="winnerText"></h2>
<button id="btnRestart">Play Again</button>
</div>
</div>
<script>
'use strict';
// ── const: AudioEngine ──
// ===== AUDIO ENGINE =====
const AudioEngine = {
ctx: null,
init() {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
},
resume() {
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
},
play(type, params = {}) {
if (!this.ctx) return;
const t = this.ctx.currentTime;
const g = this.ctx.createGain();
g.connect(this.ctx.destination);
switch(type) {
case 'explosion': {
const dur = params.dur || 0.6;
const bufSize = this.ctx.sampleRate * dur;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i/bufSize, 2);
const src = this.ctx.createBufferSource();
src.buffer = buf;
g.gain.setValueAtTime(params.vol || 0.3, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(g).connect(this.ctx.destination);
src.start(t);
break;
}
case 'hit': {
const o = this.ctx.createOscillator();
o.type = 'square';
o.frequency.setValueAtTime(params.freq || 400, t);
o.frequency.exponentialRampToValueAtTime(100, t + 0.15);
g.gain.setValueAtTime(0.2, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
o.connect(g); o.start(t); o.stop(t + 0.15);
break;
}
case 'jump': {
const o = this.ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(300, t);
o.frequency.exponentialRampToValueAtTime(600, t + 0.12);
g.gain.setValueAtTime(0.15, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
o.connect(g); o.start(t); o.stop(t + 0.12);
break;
}
case 'click': {
const o = this.ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(800, t);
g.gain.setValueAtTime(0.1, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
o.connect(g); o.start(t); o.stop(t + 0.05);
break;
}
case 'splash': {
const dur = 0.4;
const bufSize = this.ctx.sampleRate * dur;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i/bufSize, 3) * 0.5;
const src = this.ctx.createBufferSource();
src.buffer = buf;
g.gain.setValueAtTime(0.2, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(g).connect(this.ctx.destination);
src.start(t);
break;
}
case 'drown': {
const o = this.ctx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(200, t);
o.frequency.exponentialRampToValueAtTime(50, t + 0.8);
g.gain.setValueAtTime(0.15, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
o.connect(g); o.start(t); o.stop(t + 0.8);
break;
}
case 'tick': {
const o = this.ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(1200, t);
g.gain.setValueAtTime(0.06, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.03);
o.connect(g); o.start(t); o.stop(t + 0.03);
break;
}
case 'turnEnd': {
[523, 659, 784].forEach((f, i) => {
const o = this.ctx.createOscillator();
const gg = this.ctx.createGain();
o.type = 'sine';
o.frequency.value = f;
gg.gain.setValueAtTime(0.15, t + i * 0.12);
gg.gain.exponentialRampToValueAtTime(0.001, t + i * 0.12 + 0.3);
o.connect(gg).connect(this.ctx.destination);
o.start(t + i * 0.12); o.stop(t + i * 0.12 + 0.3);
});
break;
}
case 'kill': {
const dur = 0.5;
const bufSize = this.ctx.sampleRate * dur;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
const env = Math.pow(1 - i/bufSize, 1.5);
d[i] = Math.sin(i * 0.02) * env + (Math.random() * 2 - 1) * env * 0.3;
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
g.gain.setValueAtTime(0.35, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(g).connect(this.ctx.destination);
src.start(t);
break;
}
}
}
};
// ── const: Particles ──
// ===== PARTICLE SYSTEM =====
const Particles = {
pool: [],
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||4)-(config.speedMin||1));
this.pool.push({
x, y,
vx: Math.cos(angle)*speed*(config.vxMul||1),
vy: Math.sin(angle)*speed*(config.vyMul||1),
life: 1, decay: config.decay || (0.02 + Math.random()*0.03),
size: (config.sizeMin||2) + Math.random()*((config.sizeMax||5)-(config.sizeMin||2)),
color: config.colors ? config.colors[Math.floor(Math.random()*config.colors.length)] : '#fff',
gravity: config.gravity ?? 0.1,
friction: config.friction ?? 0.98,
type: config.type || 'circle'
});
}
},
update(dt) {
for (let i = this.pool.length - 1; i >= 0; i--) {
const p = this.pool[i];
p.vy += p.gravity * dt * 60;
p.vx *= Math.pow(p.friction, dt*60);
p.x += p.vx * dt * 60;
p.y += p.vy * dt * 60;
p.life -= p.decay * dt * 60;
if (p.life <= 0) this.pool.splice(i, 1);
}
},
render(ctx, camX, camY, scale) {
for (const p of this.pool) {
const sx = (p.x - camX) * scale;
const sy = (p.y - camY) * scale;
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
if (p.type === 'circle') {
ctx.beginPath();
ctx.arc(sx, sy, p.size * scale * 0.5, 0, Math.PI*2);
ctx.fill();
} else {
ctx.fillRect(sx - p.size*scale*0.3, sy - p.size*scale*0.3, p.size*scale*0.6, p.size*scale*0.6);
}
}
ctx.globalAlpha = 1;
},
explosion(x, y, size) {
const colors = ['#ff4500','#ff8c00','#ffd700','#fff','#ff6347'];
this.emit(x, y, Math.floor(size*2), { speedMin:1, speedMax:size*0.3, decay:0.025, sizeMin:2, sizeMax:size*0.4, colors, gravity: 0.08 });
// Smoke
this.emit(x, y, Math.floor(size*0.5), { speedMin:0.5, speedMax:size*0.15, decay:0.012, sizeMin:4, sizeMax:size*0.6, colors:['#555','#777','#999'], gravity:-0.03 });
// Sparks
this.emit(x, y, Math.floor(size), { speedMin:3, speedMax:size*0.5, decay:0.04, sizeMin:1, sizeMax:2, colors:['#fff','#ffeb3b','#ff9800'], gravity: 0.15 });
},
dust(x, y) {
this.emit(x, y, 6, { speedMin:0.5, speedMax:2, decay:0.04, sizeMin:2, sizeMax:4, colors:['#c8a96e','#b8956a'], gravity:-0.02 });
},
splash(x, y) {
this.emit(x, y, 15, { speedMin:1, speedMax:4, decay:0.03, sizeMin:2, sizeMax:5, colors:['#4fc3f7','#81d4fa','#b3e5fc'], gravity:0.1 });
},
smoke(x, y) {
this.emit(x, y, 8, { speedMin:0.3, speedMax:1.5, decay:0.015, sizeMin:5, sizeMax:12, colors:['#666','#888','#aaa'], gravity:-0.04 });
}
};
// ── const: CombatText ──
// ===== FLOATING COMBAT TEXT =====
const CombatText = {
texts: [],
add(x, y, text, color='#fff', size=18) {
this.texts.push({ x, y, text, color, size, life:1, vy:-2 });
},
update(dt) {
for (let i = this.texts.length-1; i >= 0; i--) {
const t = this.texts[i];
t.y += t.vy * dt * 60;
t.life -= dt * 1.2;
if (t.life <= 0) this.texts.splice(i, 1);
}
},
render(ctx, camX, camY, scale) {
ctx.textAlign = 'center';
for (const t of this.texts) {
const sx = (t.x - camX) * scale;
const sy = (t.y - camY) * scale;
ctx.globalAlpha = Math.max(0, t.life);
ctx.font = `bold ${Math.floor(t.size*scale)}px sans-serif`;
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.strokeText(t.text, sx, sy);
ctx.fillStyle = t.color;
ctx.fillText(t.text, sx, sy);
}
ctx.globalAlpha = 1;
}
};
// ── const: WEAPONS ──
// ===== WEAPON DEFINITIONS =====
const WEAPONS = [
{ id:'gun', name:'Gun', icon:'🔫', category:'direct', desc:'Instant hit, low damage', power:15, radius:0, color:'#ffeb3b' },
{ id:'rocket', name:'Rocket', icon:'🚀', category:'indirect', desc:'Parabolic projectile', power:40, radius:60, color:'#ff5722' },
{ id:'homingMissile', name:'Homing', icon:'🎯', category:'indirect', desc:'Tracks nearest enemy', power:35, radius:50, color:'#e040fb' },
{ id:'bouncingBetty', name:'B.Betty', icon:'💣', category:'indirect', desc:'Bounces then explodes', power:50, radius:70, color:'#ff9800' },
{ id:'nuke', name:'Nuke', icon:'☢️', category:'aoe', desc:'Massive AoE destruction', power:120, radius:160, color:'#f44336' },
{ id:'mines', name:'Mines', icon:'💥', category:'utility', desc:'Place explosive mines', power:45, radius:55, color:'#795548' },
{ id:'teleport', name:'Teleport', icon:'🌀', category:'utility', desc:'Warp to location', power:0, radius:0, color:'#2196f3' },
{ id:'parachute', name:'Parachute', icon:'🪂', category:'utility', desc:'Drop from sky', power:0, radius:0, color:'#4caf50' }
];
// ── class: Projectile ──
// ===== PROJECTILE SYSTEM =====
class Projectile {
constructor(x, y, vx, vy, weapon, ownerTeamId) {
this.x = x; this.y = y;
this.vx = vx; this.vy = vy;
this.weapon = weapon;
this.ownerTeamId = ownerTeamId;
this.alive = true;
this.bounces = 0;
this.maxBounces = weapon.id === 'bouncingBetty' ? 3 : 0;
this.homingTarget = null;
this.trailTimer = 0;
this.age = 0;
}
update(dt, wind) {
if (!this.alive) return;
this.age += dt;
// Homing missile logic
if (this.weapon.id === 'homingMissile' && this.homingTarget && this.homingTarget.alive) {
const dx = this.homingTarget.x - this.x;
const dy = this.homingTarget.y - this.y;
const angle = Math.atan2(dy, dx);
const turnRate = 3 * dt;
const currentAngle = Math.atan2(this.vy, this.vx);
let diff = angle - currentAngle;
while (diff > Math.PI) diff -= Math.PI*2;
while (diff < -Math.PI) diff += Math.PI*2;
const newAngle = currentAngle + Math.sign(diff) * Math.min(Math.abs(diff), turnRate);
const speed = Math.sqrt(this.vx*this.vx + this.vy*this.vy);
this.vx = Math.cos(newAngle) * speed;
this.vy = Math.sin(newAngle) * speed;
}
// Wind effect
this.vx += wind * 8 * dt;
// Gravity (less for direct weapons)
const gravMult = this.weapon.category === 'direct' ? 0.1 : 500;
this.vy += gravMult * dt;
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
// Trail particles
this.trailTimer += dt;
if (this.trailTimer > 0.03) {
this.trailTimer = 0;
Particles.emit(this.x, this.y, 1, { speedMin:0.2, speedMax:0.8, decay:0.06, sizeMin:1, sizeMax:3, colors:['#ffeb3b','#ff9800'], gravity:0 });
}
// Bouncing Betty bounce
if (this.weapon.id === 'bouncingBetty' && Terrain.isSolid(this.x, this.y) && this.bounces < this.maxBounces) {
this.vy = -Math.abs(this.vy) * 0.6;
this.vx *= 0.7;
this.bounces++;
Particles.dust(this.x, this.y);
AudioEngine.play('hit', {freq: 300});
}
// Terrain collision or out of bounds
if (Terrain.isSolid(this.x, this.y) || this.x < -50 || this.x > Terrain.width+50 || this.y > Terrain.height + 50 || this.age > 12) {
this.explode();
}
// Hit unit check
for (const u of Game.units) {
if (!u.alive || u.teamId === this.ownerTeamId) continue;
const dx = u.x - this.x, dy = u.y - this.y;
if (Math.sqrt(dx*dx+dy*dy) < 20) {
this.explode();
break;
}
}
}
explode() {
this.alive = false;
if (this.weapon.power > 0) {
const dmg = Math.floor(this.weapon.power * (0.8 + Math.random()*0.4));
// Damage units in radius
for (const u of Game.units) {
if (!u.alive || u.teamId === this.ownerTeamId) continue;
const dx = u.x - this.x, dy = u.y - this.y;
const dist = Math.sqrt(dx*dx+dy*dy);
if (dist < this.weapon.radius + 20) {
const falloff = Math.max(0.3, 1 - dist/(this.weapon.radius+20));
u.takeDamage(Math.floor(dmg * falloff), this.x, this.y);
}
}
// Destroy terrain
Terrain.destroy(this.x, this.y, this.weapon.radius);
// Effects
Particles.explosion(this.x, this.y, Math.min(this.weapon.radius*0.3, 25));
AudioEngine.play('explosion', {dur: Math.min(1, this.weapon.radius/100), vol: Math.min(0.4, this.weapon.radius/200)});
// Screen shake for big explosions
if (this.weapon.radius > 80) {
Game.screenShake = Math.min(this.weapon.radius / 15, 12);
Game.freezeTimer = Math.min(this.weapon.radius / 300, 0.15);
} else {
Game.screenShake = Math.max(2, this.weapon.radius / 20);
}
// Water splash if near water level
if (this.y > Terrain.waterLevel - 40) {
Particles.splash(this.x, Terrain.waterLevel);
AudioEngine.play('splash');
}
} else if (this.weapon.id === 'mines') {
Game.mines.push({ x: this.x, y: this.y, teamId: this.ownerTeamId, weapon: this.weapon });
Particles.smoke(this.x, this.y);
}
}
render(ctx, camX, camY, scale) {
if (!this.alive) return;
const sx = (this.x - camX) * scale;
const sy = (this.y - camY) * scale;
ctx.save();
ctx.translate(sx, sy);
switch(this.weapon.id) {
case 'gun':
ctx.fillStyle = '#ffeb3b';
ctx.beginPath();
ctx.arc(0, 0, 4*scale, 0, Math.PI*2);
ctx.fill();
break;
case 'rocket':
ctx.fillStyle = '#ff5722';
ctx.rotate(Math.atan2(this.vy, this.vx));
ctx.beginPath();
ctx.moveTo(8*scale, 0);
ctx.lineTo(-4*scale, -4*scale);
ctx.lineTo(-4*scale, 4*scale);
ctx.closePath();
ctx.fill();
break;
case 'homingMissile':
ctx.fillStyle = '#e040fb';
ctx.rotate(Math.atan2(this.vy, this.vx));
ctx.beginPath();
ctx.moveTo(10*scale, 0);
ctx.lineTo(-5*scale, -3*scale);
ctx.lineTo(-5*scale, 3*scale);
ctx.closePath();
ctx.fill();
// Glow
ctx.shadowColor = '#e040fb';
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(0, 0, 3*scale, 0, Math.PI*2);
ctx.fill();
break;
case 'bouncingBetty':
ctx.fillStyle = '#ff9800';
ctx.beginPath();
ctx.arc(0, 0, 5*scale, 0, Math.PI*2);
ctx.fill();
// Fuse spark
Particles.emit(this.x + (Math.random()-0.5)*4, this.y - 5, 1, { speedMin:0.3, speedMax:1, decay:0.1, sizeMin:1, sizeMax:2, colors:['#fff','#ffeb3b'], gravity:-0.05 });
break;
case 'nuke':
ctx.fillStyle = '#f44336';
ctx.shadowColor = '#f44336';
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.arc(0, 0, 8*scale, 0, Math.PI*2);
ctx.fill();
break;
}
ctx.restore();
}
}
// ── const: AI ──
// ===== AI SYSTEM =====
const AI = {
calculateShot(teamId, weapon) {
// Find nearest enemy
let bestTarget = null;
let bestDist = Infinity;
for (const u of Game.units) {
if (!u.alive || u.teamId === teamId) continue;
const dx = u.x - Game.activeUnit.x;
const dy = u.y - Game.activeUnit.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < bestDist) { bestDist = dist; bestTarget = u; }
}
if (!bestTarget) return null;
// Risk assessment: check for hazards near active unit
const au = Game.activeUnit;
const hazardRisk = this.assessHazard(au);
if (hazardRisk > 0.7 && weapon.category !== 'utility') {
// Consider moving away first - but we'll just shoot anyway with adjusted aim
}
const dx = bestTarget.x - au.x;
const dy = bestTarget.y - au.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (weapon.category === 'direct') {
return { angle: Math.atan2(dy, dx), power: 10 };
} else if (weapon.category === 'indirect' || weapon.category === 'aoe') {
// Calculate parabolic trajectory
const wind = Game.wind;
const g = 500;
// Try multiple angles and pick best
let bestAngle = Math.PI * 0.25 + Math.random() * 0.3;
let bestPower = 8 + dist / 150;
if (dx > 0) {
bestAngle = Math.atan2(-dy, dist * 0.7) - Math.PI * 0.1;
} else {
bestAngle = Math.PI - Math.atan2(-dy, dist * 0.7) + Math.PI * 0.1;
}
// Add some randomness for realism
bestAngle += (Math.random() - 0.5) * 0.15;
bestPower = Math.max(4, Math.min(20, dist / 80));
return { angle: bestAngle, power: bestPower };
} else if (weapon.category === 'utility') {
if (weapon.id === 'mines') {
// Place mine near enemy path or cliff edge
const mx = au.x + au.facing * 40;
return { angle: Math.atan2(1, 0), power: 5, targetX: mx };
} else if (weapon.id === 'teleport') {
// Teleport to safe location near enemy
const tx = bestTarget.x + (Math.random()-0.5)*100;
return { angle: Math.atan2(1, 0), power: 5, targetX: tx };
} else if (weapon.id === 'parachute') {
// Drop near enemy
const px = bestTarget.x + (Math.random()-0.5)*80;
return { angle: Math.atan2(1, 0), power: 5, targetX: px };
}
}
return null;
},
assessHazard(unit) {
let risk = 0;
// Check water proximity
if (Terrain.isWater(unit.x, unit.y + unit.height/2)) risk += 0.5;
else if (unit.y + unit.height/2 > Terrain.waterLevel - 30) risk += 0.2;
// Check cliff edge
const leftGround = Terrain.getGroundY(unit.x - 15);
const rightGround = Terrain.getGroundY(unit.x + 15);
if (Math.abs(leftGround - unit.groundY) > 40 || Math.abs(rightGround - unit.groundY) > 40) risk += 0.3;
return Math.min(1, risk);
},
executeTurn(unit, weapon) {
const shot = this.calculateShot(unit.teamId, weapon);
if (!shot) return Promise.resolve();
return new Promise(resolve => {
Game.fireProjectile(weapon, shot.angle, shot.power, unit);
// For utility weapons with target position
if (shot.targetX && (weapon.id === 'teleport' || weapon.id === 'parachute')) {
setTimeout(() => {
if (weapon.id === 'teleport') {
Particles.explosion(unit.x, unit.y, 8);
unit.x = shot.targetX;
unit.y = Terrain.getGroundY(shot.targetX) - unit.height/2 - 5;
Particles.explosion(unit.x, unit.y, 8);
} else if (weapon.id === 'parachute') {
unit.hasParachute = true;
unit.parachuteOpen = true;
unit.x = shot.targetX;
unit.y = 50;
unit.vy = 0;
}
resolve();
}, 300);
} else {
setTimeout(resolve, 1000);
}
});
}
};
// ── const: Sky ──
// ===== SKY RENDERER =====
const Sky = {
gradient: null,
render(ctx, camX, camY, scale) {
if (!this.gradient) {
this.gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height);
this.gradient.addColorStop(0, '#1a237e');
this.gradient.addColorStop(0.4, '#283593');
this.gradient.addColorStop(0.7, '#5c6bc0');
this.gradient.addColorStop(1, '#9fa8da');
}
// Draw sky background (in screen space)
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = this.gradient;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// Stars
const starSeed = 42;
for (let i = 0; i < 50; i++) {
const sx = ((i * 7919 + starSeed) % ctx.canvas.width);
const sy = ((i * 6271 + starSeed) % (ctx.canvas.height * 0.5));
const brightness = 0.3 + Math.sin(Date.now()*0.003 + i)*0.3;
ctx.fillStyle = `rgba(255,255,255,${brightness})`;
ctx.fillRect(sx, sy, 1.5, 1.5);
}
// Clouds (parallax)
const time = Date.now() * 0.0001;
for (let i = 0; i < 6; i++) {
const cx = ((i * 437 + 100 + time * (20+i*5)) % (ctx.canvas.width + 200)) - 100;
const cy = 40 + i * 35;
ctx.fillStyle = `rgba(255,255,255,${0.08 + i*0.01})`;
ctx.beginPath();
ctx.arc(cx, cy, 30+i*5, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.arc(cx+25, cy-10, 20+i*3, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
};
// ── const: Trajectory ──
// ===== TRAJECTORY PREDICTOR =====
const Trajectory = {
draw(ctx, startX, startY, angle, power, weapon) {
const g = 500;
const wind = Game.wind;
let vx = Math.cos(angle) * power * 8;
let vy = -Math.sin(angle) * power * 8;
let px = startX, py = startY;
ctx.save();
ctx.setLineDash([4, 6]);
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(px, py);
for (let i = 0; i < 80; i++) {
vx += wind * 8 * 0.016;
vy += g * 0.016;
px += vx * 0.016 * 60;
py += vy * 0.016 * 60;
if (Terrain.isSolid(px, py) || py > Terrain.height) break;
ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
};
// ── const: Camera ──
// ===== CAMERA SYSTEM =====
const Camera = {
x: 0, y: 0,
targetX: 0, targetY: 0,
zoom: 1,
targetZoom: 1,
shakeX: 0, shakeY: 0,
follow(unit) {
if (unit && unit.alive) {
this.targetX = unit.x;
this.targetY = unit.y - 50;
}
},
update(dt) {
const smooth = Math.min(1, dt * 4);
this.x += (this.targetX - this.x) * smooth;
this.y += (this.targetY - this.y) * smooth;
this.zoom += (this.targetZoom - this.zoom) * dt * 3;
// Clamp to map bounds using Terrain dimensions
const halfW = Terrain.width / 2;
const halfH = Terrain.height / 2;
this.x = Math.max(halfW, Math.min(Terrain.width - halfW, this.x));
this.y = Math.max(-100, Math.min(halfH, this.y));
},
apply(ctx) {
ctx.save();
if (Game.screenShake > 0.5) {
this.shakeX = (Math.random()-0.5) * Game.screenShake;
this.shakeY = (Math.random()-0.5) * Game.screenShake;
Game.screenShake *= 0.85;
} else {
this.shakeX = 0; this.shakeY = 0; Game.screenShake = 0;
}
ctx.translate(ctx.canvas.width/2 + this.shakeX, ctx.canvas.height/2 + this.shakeY);
ctx.scale(this.zoom, this.zoom);
ctx.translate(-this.x, -this.y);
},
restore(ctx) {
ctx.restore();
}
};
// ── class: Unit ──
// ===== UNIT / WORM CLASS =====
class Unit {
constructor(teamId, index, x, y) {
this.teamId = teamId;
this.index = index;
this.x = x;
this.y = y;
this.vx = 0;
this.vy = 0;
this.hp = 100;
this.maxHp = 100;
this.alive = true;
this.onGround = false;
this.facing = 1;
this.width = 20;
this.height = 30;
this.moveTimeLeft = 8;
this.isMoving = false;
this.jumpCount = 0;
this.maxJumps = 2;
this.inWater = false;
this.drowningTimer = 0;
this.hasParachute = false;
this.parachuteOpen = false;
this.animPhase = Math.random() * Math.PI * 2;
this.bobOffset = 0;
this.name = 'Worm ' + String.fromCharCode(65+index);
this.spawnInvuln = 1.5;
}
get groundY() {
return Terrain.getGroundY(this.x);
}
update(dt, wind) {
if (!this.alive) return;
this.animPhase += dt * 3;
this.bobOffset = Math.sin(this.animPhase) * 2;
if (this.spawnInvuln > 0) {
this.spawnInvuln -= dt;
}
const gravMult = this.parachuteOpen ? 0.15 : 1;
this.vy += (this.inWater ? 80 : 600) * dt * gravMult;
if (this.inWater && !this.hasParachute) {
this.drowningTimer += dt;
if (this.drowningTimer > 5 && this.spawnInvuln <= 0) {
this.hp -= 20 * dt;
if (this.hp <= 0) this.die();
}
} else {
this.drowningTimer = Math.max(0, this.drowningTimer - dt * 2);
}
if (!this.onGround && !this.parachuteOpen) {
this.vx += wind * 15 * dt;
}
if (this.onGround) {
this.vx *= Math.pow(0.85, dt*60);
} else {
this.vx *= Math.pow(0.99, dt*60);
}
const prevY = this.y;
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
this.x = Math.max(20, Math.min(Terrain.width - 20, this.x));
const feetY = this.y + this.height/2;
if (Terrain.isSolid(this.x, feetY)) {
this.y = Terrain.getGroundY(this.x) - this.height/2 - 1;
// Fall damage only after spawn invulnerability
if (this.spawnInvuln <= 0 && feetY - prevY > 80 && !this.inWater) {
const dmg = Math.floor((feetY - prevY - 80) * 0.3);
if (dmg > 0) {
this.hp -= dmg;
CombatText.add(this.x, this.y - 20, '-' + dmg, '#ff9800');
AudioEngine.play('hit', {freq: 200});
}
}
this.vy = 0;
this.onGround = true;
this.jumpCount = 0;
} else {
this.onGround = false;
}
if (Terrain.isSolid(this.x, this.y - this.height/2)) {
this.vy = Math.max(0, this.vy);
}
this.inWater = Terrain.isWater(this.x, this.y + this.height/2);
if (this.hp <= 0) this.die();
}
jump() {
if (this.jumpCount < this.maxJumps && this.alive) {
this.vy = -18;
this.jumpCount++;
AudioEngine.play('jump');
Particles.dust(this.x, this.y + this.height/2);
}
}
move(dir, dt) {
if (!this.alive || !this.isMoving || this.moveTimeLeft <= 0) return;
const speed = this.inWater ? 40 : 120;
this.vx += dir * speed * dt;
this.facing = dir > 0 ? 1 : -1;
this.moveTimeLeft -= dt;
if (this.onGround && Math.abs(this.vx) > 10) {
Particles.dust(this.x, this.y + this.height/2);
}
}
takeDamage(dmg, fromX, fromY) {
if (this.spawnInvuln > 0) return;
this.hp -= dmg;
CombatText.add(this.x, this.y - 30, '-' + dmg, '#ff4444', 22);
AudioEngine.play('hit');
const dx = this.x - fromX;
const dy = this.y - fromY;
const dist = Math.max(1, Math.sqrt(dx*dx + dy*dy));
this.vx += (dx/dist) * 5;
this.vy -= 3;
if (this.hp <= 0) this.die();
}
die() {
this.alive = false;
Particles.explosion(this.x, this.y, 12);
AudioEngine.play('kill');
CombatText.add(this.x, this.y - 30, '💀', '#ff0000', 28);
}
render(ctx, camX, camY, scale) {
if (!this.alive) return;
const sx = (this.x - camX) * scale;
const sy = (this.y - camY + this.bobOffset) * scale;
const s = scale;
ctx.save();
ctx.translate(sx, sy);
const teamColors = ['#e94560','#2196f3','#4caf50','#ff9800'];
const bodyColor = teamColors[this.teamId] || '#fff';
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.ellipse(0, this.height/2*s*0.8, 14*s, 5*s, 0, 0, Math.PI*2);
ctx.fill();
// Body segments
for (let i = 3; i >= -3; i--) {
const segY = i * 4 * s;
const segW = (12 - Math.abs(i)*1.5) * s;
ctx.fillStyle = bodyColor;
ctx.beginPath();
ctx.ellipse(0, segY, segW*0.5, 3*s, 0, 0, Math.PI*2);
ctx.fill();
}
// Head
ctx.fillStyle = bodyColor;
ctx.beginPath();
ctx.arc(0, -10*s, 8*s, 0, Math.PI*2);
ctx.fill();
// Eyes
const eyeOffX = this.facing * 3 * s;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.ellipse(-4*s + eyeOffX*0.3, -12*s, 3.5*s, 4*s, 0, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(4*s + eyeOffX*0.3, -12*s, 3.5*s, 4*s, 0, 0, Math.PI*2);
ctx.fill();
// Pupils
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(-4*s + eyeOffX, -12*s, 1.8*s, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.arc(4*s + eyeOffX, -12*s, 1.8*s, 0, Math.PI*2);
ctx.fill();
// Mouth
if (!this.alive) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5 * s;
ctx.beginPath();
ctx.moveTo(-3*s, -6*s);
ctx.lineTo(0, -8*s);
ctx.lineTo(3*s, -6*s);
ctx.stroke();
} else {
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(this.facing * 2*s, -7*s, 2*s, 0, Math.PI*2);
ctx.fill();
}
// Parachute
if (this.parachuteOpen) {
ctx.fillStyle = '#ff5722';
ctx.beginPath();
ctx.arc(0, -35*s, 18*s, Math.PI, 0);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-15*s, -35*s);
ctx.lineTo(0, -10*s);
ctx.moveTo(15*s, -35*s);
ctx.lineTo(0, -10*s);
ctx.stroke();
}
// Health bar above unit
if (this.hp < this.maxHp) {
const barW = 24 * s;
const barH = 4 * s;
const hpRatio = Math.max(0, this.hp / this.maxHp);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(-barW/2, -22*s, barW, barH);
ctx.fillStyle = hpRatio > 0.5 ? '#4caf50' : hpRatio > 0.25 ? '#ff9800' : '#f44336';
ctx.fillRect(-barW/2, -22*s, barW * hpRatio, barH);
}
// Spawn invulnerability indicator
if (this.spawnInvuln > 0) {
ctx.strokeStyle = 'rgba(255,255,255,' + this.spawnInvuln + ')';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, -10*s, 16*s, 0, Math.PI*2);
ctx.stroke();
}
// Drowning indicator
if (this.drowningTimer > 0) {
ctx.fillStyle = 'rgba(100,200,255,0.3)';
ctx.beginPath();
ctx.arc(0, -10*s, 16*s, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
}
// ── const: Game ──
// ===== GAME STATE & MAIN LOOP =====
const Game = {
canvas: null, ctx: null,
units: [],
projectiles: [],
mines: [],
teams: [],
teamColors: ['#e94560','#2196f3','#4caf50','#ff9800'],
teamNames: ['Red','Blue','Green','Orange'],
currentTeamIndex: 0,
activeUnitIndex: 0,
selectedWeapon: 0,
turnTimeLeft: 30,
maxTurnTime: 30,
wind: 0,
windTimer: 0,
phase: 'menu',
screenShake: 0,
freezeTimer: 0,
aimAngle: -Math.PI/4,
aimPower: 10,
isAiming: false,
mouseDown: false,
mouseX: 0, mouseY: 0,
moveDir: 0,
cpuTeams: new Set(),
init() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.resize();
window.addEventListener('resize', () => this.resize());
document.getElementById('btnSetup').addEventListener('click', () => {
AudioEngine.play('click');
document.getElementById('menuOverlay').classList.add('hidden');
document.getElementById('setupOverlay').classList.remove('hidden');
this.buildCpuSelect();
});
document.getElementById('btnStart').addEventListener('click', () => {
AudioEngine.play('click');
this.startGame();
});
document.getElementById('btnRestart').addEventListener('click', () => {
AudioEngine.play('click');
document.getElementById('gameOverOverlay').classList.add('hidden');
document.getElementById('menuOverlay').classList.remove('hidden');
this.phase = 'menu';
});
},
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
},
buildCpuSelect() {
const container = document.getElementById('cpuSelect');
container.innerHTML = '';
for (let i = 0; i < parseInt(document.getElementById('teamCount').value); i++) {
const btn = document.createElement('div');
btn.className = 'cpu-toggle';
btn.textContent = this.teamNames[i] + ' Team';
btn.addEventListener('click', () => {
AudioEngine.play('click');
if (this.cpuTeams.has(i)) {
this.cpuTeams.delete(i);
btn.classList.remove('active');
} else {
this.cpuTeams.add(i);
btn.classList.add('active');
}
});
container.appendChild(btn);
}
},
startGame() {
AudioEngine.resume();
document.getElementById('setupOverlay').classList.add('hidden');
const teamCount = parseInt(document.getElementById('teamCount').value);
const terrainType = document.getElementById('terrainType').value;
Terrain.generate(terrainType);
this.units = [];
this.teams = [];
for (let t = 0; t < teamCount; t++) {
this.teams.push({ id: t, name: this.teamNames[t], color: this.teamColors[t], alive: 4 });
const spacing = Terrain.width / (teamCount * 4 + 1);
for (let u = 0; u < 4; u++) {
const x = spacing * (t * 4 + u + 1);
// Place unit well above terrain, then settle it
const groundY = Terrain.getGroundY(x);
this.units.push(new Unit(t, u, x, groundY - 20));
}
}
// Settle units onto terrain (simulate a few frames)
for (let frame = 0; frame < 30; frame++) {
for (const unit of this.units) {
if (!unit.alive) continue;
const dt = 1/60;
// Apply gravity
unit.vy += 600 * dt;
unit.y += unit.vy * dt * 60;
// Ground collision
const feetY = unit.y + unit.height/2;
if (Terrain.isSolid(unit.x, feetY)) {
unit.y = Terrain.getGroundY(unit.x) - unit.height/2 - 1;
unit.vy = 0;
unit.onGround = true;
} else {
unit.onGround = false;
}
}
}
this.currentTeamIndex = 0;
this.activeUnitIndex = 0;
this.selectedWeapon = 1;
this.turnTimeLeft = this.maxTurnTime;
this.wind = (Math.random() - 0.5) * 6;
this.projectiles = [];
this.mines = [];
this.phase = 'playing';
Camera.follow(this.getActiveUnit());
Camera.targetZoom = Math.min(1.2, 1400 / this.canvas.width);
this.updateHUD();
AudioEngine.play('turnEnd');
},
getActiveUnit() {
return this.units[this.activeUnitIndex];
},
getCurrentTeam() {
return this.teams[this.currentTeamIndex];
},
fireProjectile(weapon, angle, power, unit) {
const vx = Math.cos(angle) * power * 8;
const vy = -Math.sin(angle) * power * 8;
if (weapon.category === 'direct') {
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let hitX = unit.x, hitY = unit.y;
let dist = 0;
while (dist < 600) {
hitX += dx * 5;
hitY += dy * 5;
dist += 5;
if (Terrain.isSolid(hitX, hitY)) break;
for (const u of this.units) {
if (!u.alive || u.teamId === unit.teamId) continue;
const ddx = u.x - hitX, ddy = u.y - hitY;
if (Math.sqrt(ddx*ddx+ddy*ddy) < 20) break;
}
}
for (const u of this.units) {
if (!u.alive || u.teamId === unit.teamId) continue;
const ddx = u.x - hitX, ddy = u.y - hitY;
const dist2 = Math.sqrt(ddx*ddx+ddy*ddy);
if (dist2 < 40) {
u.takeDamage(Math.floor(weapon.power * (1 - dist2/50)), unit.x, unit.y);
}
}
Particles.explosion(hitX, hitY, 8);
AudioEngine.play('hit', {freq: 600});
Terrain.destroy(hitX, hitY, 15);
} else if (weapon.id === 'mines') {
const proj = new Projectile(unit.x + unit.facing * 30, unit.y - 20, unit.facing * 2, -3, weapon, unit.teamId);
this.projectiles.push(proj);
} else if (weapon.id === 'teleport' || weapon.id === 'parachute') {
return;
} else {
const proj = new Projectile(unit.x + unit.facing * 15, unit.y - 20, vx, vy, weapon, unit.teamId);
if (weapon.id === 'homingMissile') {
let bestTarget = null, bestDist = Infinity;
for (const u of this.units) {
if (!u.alive || u.teamId === unit.teamId) continue;
const d = Math.sqrt((u.x-unit.x)*(u.x-unit.x) + (u.y-unit.y)*(u.y-unit.y));
if (d < bestDist) { bestDist = d; bestTarget = u; }
}
proj.homingTarget = bestTarget;
}
this.projectiles.push(proj);
}
},
nextTurn() {
let found = false;
for (let attempt = 0; attempt < this.units.length; attempt++) {
this.activeUnitIndex = (this.activeUnitIndex + 1) % this.units.length;
if (!this.units[this.activeUnitIndex].alive) continue;
const newTeamId = this.units[this.activeUnitIndex].teamId;
if (newTeamId !== this.currentTeamIndex) {
this.currentTeamIndex = newTeamId;
AudioEngine.play('turnEnd');
}
this.turnTimeLeft = this.maxTurnTime;
this.moveDir = 0;
found = true;
break;
}
if (!found) {
this.endGame();
return;
}
this.windTimer++;
if (this.windTimer >= 3) {
this.wind += (Math.random() - 0.5) * 2;
this.wind = Math.max(-8, Math.min(8, this.wind));
this.windTimer = 0;
}
Camera.follow(this.getActiveUnit());
this.updateHUD();
},
endGame() {
this.phase = 'gameOver';
let bestTeam = null, bestAlive = 0;
for (const t of this.teams) {
if (t.alive > bestAlive) { bestAlive = t.alive; bestTeam = t; }
}
document.getElementById('winnerText').textContent = bestTeam.name + ' Team Wins! 🎉';
document.getElementById('gameOverOverlay').classList.remove('hidden');
},
updateHUD() {
const au = this.getActiveUnit();
if (!au) return;
const teamInfo = document.getElementById('teamInfo');
teamInfo.innerHTML = '';
for (const t of this.teams) {
const badge = document.createElement('div');
badge.className = 'team-badge' + (t.id === this.currentTeamIndex ? ' active' : '');
badge.style.background = t.color;
badge.style.color = '#fff';
badge.textContent = t.name + ' (' + t.alive + ')';
teamInfo.appendChild(badge);
}
const weaponSelect = document.getElementById('weaponSelect');
weaponSelect.innerHTML = '';
for (let i = 0; i < WEAPONS.length; i++) {
const btn = document.createElement('button');
btn.className = 'weapon-btn' + (i === this.selectedWeapon ? ' selected' : '');
btn.textContent = WEAPONS[i].icon;
btn.title = WEAPONS[i].name + ' - ' + WEAPONS[i].desc;
btn.addEventListener('click', () => {
AudioEngine.play('click');
this.selectedWeapon = i;
this.updateHUD();
});
weaponSelect.appendChild(btn);
}
const actionBtns = document.getElementById('actionButtons');
actionBtns.innerHTML = '';
if (this.phase === 'playing') {
const skipBtn = document.createElement('button');
skipBtn.className = 'action-btn';
skipBtn.textContent = 'Skip Turn';
skipBtn.addEventListener('click', () => {
AudioEngine.play('click');
this.nextTurn();
});
actionBtns.appendChild(skipBtn);
}
const windEl = document.getElementById('windIndicator');
const dir = this.wind >= 0 ? '→' : '←';
windEl.textContent = 'Wind: ' + dir + ' ' + Math.abs(this.wind).toFixed(1);
},
update(dt) {
if (this.phase !== 'playing') return;
if (this.freezeTimer > 0) {
this.freezeTimer -= dt;
return;
}
const au = this.getActiveUnit();
this.turnTimeLeft -= dt;
document.getElementById('timerValue').textContent = Math.ceil(this.turnTimeLeft);
if (this.turnTimeLeft <= 0) {
this.nextTurn();
return;
}
if (this.turnTimeLeft < 10 && Math.floor(this.turnTimeLeft + dt) !== Math.floor(this.turnTimeLeft)) {
AudioEngine.play('tick');
}
const timerEl = document.getElementById('timerValue');
if (this.turnTimeLeft <= 5) {
timerEl.style.transform = 'scale(' + (1 + (5-this.turnTimeLeft)*0.1) + ')';
timerEl.style.color = '#f44336';
} else {
timerEl.style.transform = '';
timerEl.style.color = '#fff';
}
if (this.cpuTeams.has(this.currentTeamIndex)) {
this.handleCPUTurn(dt);
}
if (au && au.alive) {
if (this.moveDir !== 0) {
au.isMoving = true;
au.move(this.moveDir, dt);
} else {
au.isMoving = false;
}
au.update(dt, this.wind);
}
for (const p of this.projectiles) {
p.update(dt, this.wind);
}
this.projectiles = this.projectiles.filter(p => p.alive);
// Mine collision check
for (let i = this.mines.length - 1; i >= 0; i--) {
const m = this.mines[i];
let exploded = false;
for (const u of this.units) {
if (!u.alive || u.teamId === m.teamId) continue;
const dx = u.x - m.x, dy = u.y - m.y;
if (Math.sqrt(dx*dx+dy*dy) < 25) {
for (const uu of this.units) {
if (!uu.alive || uu.teamId === m.teamId) continue;
const ddx = uu.x - m.x, ddy = uu.y - m.y;
const dist = Math.sqrt(ddx*ddx+ddy*ddy);
if (dist < m.weapon.radius + 20) {
const falloff = Math.max(0.3, 1 - dist/(m.weapon.radius+20));
uu.takeDamage(Math.floor(m.weapon.power * falloff), m.x, m.y);
}
}
Terrain.destroy(m.x, m.y, m.weapon.radius);
Particles.explosion(m.x, m.y, 15);
AudioEngine.play('explosion');
this.mines.splice(i, 1);
exploded = true;
break;
}
}
}
Terrain.updateDebris(dt);
Particles.update(dt);
CombatText.update(dt);
Camera.update(dt);
// Update team alive counts
for (const t of this.teams) {
let count = 0;
for (const u of this.units) {
if (u.alive && u.teamId === t.id) count++;
}
t.alive = count;
}
// Check game over
const aliveTeams = this.teams.filter(t => t.alive > 0);
if (aliveTeams.length <= 1 && this.phase !== 'gameOver') {
this.endGame();
}
this.updateHUD();
},
cpuTurnTimer: 0,
cpuActionQueue: [],
handleCPUTurn(dt) {
const au = this.getActiveUnit();
if (!au || !au.alive) { this.nextTurn(); return; }
this.cpuTurnTimer += dt;
if (this.cpuTurnTimer < 1.5) return;
if (this.cpuActionQueue.length === 0) {
const hazardRisk = AI.assessHazard(au);
if (hazardRisk > 0.5 && au.onGround) {
const safeDir = Terrain.getGroundY(au.x - 30) < Terrain.getGroundY(au.x + 30) ? -1 : 1;
this.cpuActionQueue.push({ type: 'move', dir: safeDir, duration: 1 });
}
const weaponIdx = Math.floor(Math.random() * WEAPONS.length);
const weapon = WEAPONS[weaponIdx];
this.cpuActionQueue.push({ type: 'fire', weaponIndex: weaponIdx });
}
if (this.cpuActionQueue.length > 0) {
const action = this.cpuActionQueue.shift();
if (action.type === 'move') {
au.isMoving = true;
au.move(action.dir, dt);
if (this.cpuTurnTimer - dt <= action.duration + 1.5) {
this.cpuActionQueue.unshift(action);
}
} else if (action.type === 'fire') {
const weapon = WEAPONS[action.weaponIndex];
AI.executeTurn(au, weapon).then(() => {
if (this.cpuActionQueue.length === 0) {
this.nextTurn();
}
});
}
}
if (this.turnTimeLeft <= 1 && this.cpuActionQueue.length === 0) {
this.nextTurn();
}
},
render() {
const ctx = this.ctx;
const camX = Camera.x, camY = Camera.y, scale = Camera.zoom;
Sky.render(ctx, camX, camY, scale);
Camera.apply(ctx);
Terrain.render(ctx, camX, camY, scale);
// Mines
for (const m of this.mines) {
const sx = (m.x - camX) * scale;
const sy = (m.y - camY) * scale;
ctx.fillStyle = '#795548';
ctx.beginPath();
ctx.arc(sx, sy, 6*scale, 0, Math.PI*2);
ctx.fill();
}
// Units
for (const u of this.units) {
u.render(ctx, camX, camY, scale);
}
// Projectiles
for (const p of this.projectiles) {
p.render(ctx, camX, camY, scale);
}
Particles.render(ctx, camX, camY, scale);
CombatText.render(ctx, camX, camY, scale);
// Trajectory preview
if (this.phase === 'playing' && !this.cpuTeams.has(this.currentTeamIndex)) {
const au = this.getActiveUnit();
if (au && au.alive) {
const weapon = WEAPONS[this.selectedWeapon];
if (weapon.category === 'indirect' || weapon.category === 'aoe') {
Trajectory.draw(ctx, au.x + au.facing*15, au.y - 20, this.aimAngle, this.aimPower, weapon);
}
}
}
// Active unit indicator
const activeUnit = this.getActiveUnit();
if (activeUnit && activeUnit.alive) {
const sx = (activeUnit.x - camX) * scale;
const sy = (activeUnit.y - 40 - camY) * scale;
ctx.fillStyle = '#ffeb3b';
ctx.font = 'bold ' + Math.floor(16*scale) + 'px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('▼', sx, sy);
}
Camera.restore(ctx);
},
setupInput() {
const canvas = this.canvas;
canvas.addEventListener('mousedown', (e) => {
if (this.phase !== 'playing' || this.cpuTeams.has(this.currentTeamIndex)) return;
AudioEngine.resume();
this.mouseDown = true;
this.mouseX = e.clientX;
this.mouseY = e.clientY;
});
canvas.addEventListener('mousemove', (e) => {
if (!this.mouseDown || this.phase !== 'playing') return;
const au = this.getActiveUnit();
if (!au || !au.alive) return;
const wx = (e.clientX - this.canvas.width/2) / Camera.zoom + Camera.x;
const wy = (e.clientY - this.canvas.height/2) / Camera.zoom + Camera.y;
const dx = wx - au.x;
const dy = -(wy - au.y);
this.aimAngle = Math.atan2(Math.abs(dy), Math.abs(dx));
if (dx < 0) this.aimAngle = Math.PI - this.aimAngle;
const dist = Math.sqrt((wx-au.x)*(wx-au.x) + (wy-au.y)*(wy-au.y));
this.aimPower = Math.max(3, Math.min(20, dist / 50));
});
canvas.addEventListener('mouseup', (e) => {
if (!this.mouseDown || this.phase !== 'playing') return;
const au = this.getActiveUnit();
if (!au || !au.alive) return;
const weapon = WEAPONS[this.selectedWeapon];
if (weapon.id === 'teleport' || weapon.id === 'parachute') {
const wx = (e.clientX - this.canvas.width/2) / Camera.zoom + Camera.x;
if (weapon.id === 'teleport') {
Particles.explosion(au.x, au.y, 8);
au.x = Math.max(20, Math.min(Terrain.width-20, wx));
au.y = Terrain.getGroundY(au.x) - au.height/2 - 5;
Particles.explosion(au.x, au.y, 8);
AudioEngine.play('hit', {freq: 500});
} else if (weapon.id === 'parachute') {
au.hasParachute = true;
au.parachuteOpen = true;
au.x = Math.max(20, Math.min(Terrain.width-20, wx));
au.y = 50;
au.vy = 0;
AudioEngine.play('jump');
}
} else {
this.fireProjectile(weapon, this.aimAngle, this.aimPower, au);
}
this.mouseDown = false;
});
const keys = {};
window.addEventListener('keydown', (e) => {
if (this.phase !== 'playing' || this.cpuTeams.has(this.currentTeamIndex)) return;
switch(e.key) {
case 'ArrowLeft': case 'a':
keys['left'] = true;
this.moveDir = -1;
break;
case 'ArrowRight': case 'd':
keys['right'] = true;
this.moveDir = 1;
break;
case 'ArrowUp': case 'w': case ' ':
const au2 = this.getActiveUnit();
if (au2) au2.jump();
break;
case 'Escape':
this.nextTurn();
break;
}
});
window.addEventListener('keyup', (e) => {
switch(e.key) {
case 'ArrowLeft': case 'a': keys['left'] = false; if (!keys['right']) this.moveDir = 0; break;
case 'ArrowRight': case 'd': keys['right'] = false; if (!keys['left']) this.moveDir = 0; break;
}
});
window.addEventListener('keydown', (e) => {
const num = parseInt(e.key);
if (num >= 1 && num <= WEAPONS.length) {
AudioEngine.play('click');
this.selectedWeapon = num - 1;
this.updateHUD();
}
});
},
lastTime: 0,
loop(timestamp) {
const dt = Math.min(0.05, (timestamp - this.lastTime) / 1000);
this.lastTime = timestamp;
Game.update(dt);
Game.render();
requestAnimationFrame((t) => Game.loop(t));
},
start() {
this.init();
this.setupInput();
this.lastTime = performance.now();
requestAnimationFrame((t) => this.loop(t));
}
};
// ── const: Terrain ──
// ===== TERRAIN SYSTEM =====
const Terrain = {
width: 2400,
height: 700,
waterLevel: 620,
data: null,
heightmap: null,
debris: [],
generate(type) {
this.data = new Uint8ClampedArray(this.width * this.height * 4);
this.heightmap = new Float32Array(this.width);
this.debris = [];
const h = this.height;
switch(type) {
case 'island': this.genIsland(); break;
case 'twoIslands': this.genTwoIslands(); break;
case 'cave': this.genCave(); break;
}
},
genIsland() {
const w = this.width, h = this.height;
const baseH = h * 0.45;
for (let x = 0; x < w; x++) {
let y = baseH;
y += Math.sin(x * 0.008) * 60;
y += Math.sin(x * 0.02 + 1) * 30;
y += Math.sin(x * 0.05 + 3) * 15;
y += (Math.random() - 0.5) * 8;
this.heightmap[x] = Math.max(h * 0.2, Math.min(this.waterLevel - 10, y));
}
for (let x = 0; x < w; x++) {
const sy = Math.floor(this.heightmap[x]);
for (let y = 0; y < h; y++) {
const idx = (y * w + x) * 4;
if (y >= sy && y < this.waterLevel) {
const depth = (y - sy) / Math.max(1, this.waterLevel - sy);
// Grass layer on top
if (y === sy || y === sy + 1) {
this.data[idx] = 60; this.data[idx+1] = 140; this.data[idx+2] = 50; this.data[idx+3] = 255;
} else {
const r = Math.floor(120 - depth * 50 + Math.random() * 10);
const g = Math.floor(80 - depth * 30 + Math.random() * 8);
const b = Math.floor(40 - depth * 15 + Math.random() * 5);
this.data[idx] = r; this.data[idx+1] = g; this.data[idx+2] = b; this.data[idx+3] = 255;
}
} else if (y >= this.waterLevel) {
const wave = Math.sin(x * 0.05 + y * 0.1) * 10;
this.data[idx] = 30; this.data[idx+1] = 60 + wave; this.data[idx+2] = 140; this.data[idx+3] = 200;
} else {
this.data[idx+3] = 0;
}
}
}
},
genTwoIslands() {
const w = this.width, h = this.height;
for (let x = 0; x < w; x++) {
let y = h * 0.5;
const d1 = Math.exp(-Math.pow((x - w*0.3) / 200, 2));
const d2 = Math.exp(-Math.pow((x - w*0.7) / 200, 2));
y -= (d1 + d2) * 150;
y += Math.sin(x * 0.015) * 30;
y += Math.sin(x * 0.04) * 15;
y += (Math.random() - 0.5) * 6;
this.heightmap[x] = Math.max(h * 0.2, Math.min(this.waterLevel - 10, y));
}
for (let x = 0; x < w; x++) {
const sy = Math.floor(this.heightmap[x]);
for (let y = 0; y < h; y++) {
const idx = (y * w + x) * 4;
if (y >= sy && y < this.waterLevel) {
if (y === sy || y === sy + 1) {
this.data[idx] = 60; this.data[idx+1] = 140; this.data[idx+2] = 50; this.data[idx+3] = 255;
} else {
const depth = (y - sy) / Math.max(1, this.waterLevel - sy);
const r = Math.floor(120 - depth * 50 + Math.random() * 10);
const g = Math.floor(80 - depth * 30 + Math.random() * 8);
const b = Math.floor(40 - depth * 15 + Math.random() * 5);
this.data[idx] = r; this.data[idx+1] = g; this.data[idx+2] = b; this.data[idx+3] = 255;
}
} else if (y >= this.waterLevel) {
const wave = Math.sin(x * 0.05 + y * 0.1) * 10;
this.data[idx] = 30; this.data[idx+1] = 60 + wave; this.data[idx+2] = 140; this.data[idx+3] = 200;
} else {
this.data[idx+3] = 0;
}
}
}
},
genCave() {
const w = this.width, h = this.height;
for (let x = 0; x < w; x++) {
let topY = h * 0.15 + Math.sin(x * 0.01) * 20 + Math.random() * 5;
const arch = Math.exp(-Math.pow((x - w/2) / (w*0.3), 2));
topY += arch * h * 0.35;
this.heightmap[x] = topY;
for (let y = 0; y < h; y++) {
const idx = (y * w + x) * 4;
if ((y <= topY || y >= this.waterLevel)) {
if (y >= this.waterLevel) {
const wave = Math.sin(x * 0.05 + y * 0.1) * 10;
this.data[idx] = 30; this.data[idx+1] = 60 + wave; this.data[idx+2] = 140; this.data[idx+3] = 200;
} else {
const depth = y / Math.max(1, topY);
const r = Math.floor(100 - depth * 40 + Math.random() * 8);
const g = Math.floor(70 - depth * 25 + Math.random() * 6);
const b = Math.floor(35 - depth * 10 + Math.random() * 4);
this.data[idx] = r; this.data[idx+1] = g; this.data[idx+2] = b; this.data[idx+3] = 255;
}
} else {
this.data[idx+3] = 0;
}
}
}
},
isSolid(px, py) {
if (!this.data) return false;
const x = Math.floor(px), y = Math.floor(py);
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return true;
const idx = (y * this.width + x) * 4;
return this.data[idx+3] > 128 && y < this.waterLevel;
},
isWater(px, py) {
if (!this.data) return false;
const x = Math.floor(px), y = Math.floor(py);
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return false;
return y >= this.waterLevel && this.data[(y * this.width + x) * 4 + 3] > 128;
},
destroy(cx, cy, radius) {
if (!this.data) return;
const r = Math.ceil(radius);
let debrisCount = 0;
for (let dy = -r; dy <= r && debrisCount < 30; dy++) {
for (let dx = -r; dx <= r && debrisCount < 30; dx++) {
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist > radius) continue;
const px = Math.floor(cx + dx), py = Math.floor(cy + dy);
if (px < 0 || px >= this.width || py < 0 || py >= this.height) continue;
const idx = (py * this.width + px) * 4;
if (this.data[idx+3] > 128 && py < this.waterLevel) {
const cr = this.data[idx], cg = this.data[idx+1], cb = this.data[idx+2];
this.data[idx+3] = 0;
if (Math.random() < 0.15 && debrisCount < 30) {
this.debris.push({
x: px, y: py, vx: (Math.random()-0.5)*4, vy: -Math.random()*6 - 2,
size: 2 + Math.random()*4, color: 'rgb('+cr+','+cg+','+cb+')', life: 1
});
debrisCount++;
}
}
}
}
},
getGroundY(x) {
if (!this.heightmap) return this.height * 0.5;
const xi = Math.floor(Math.max(0, Math.min(this.width-1, x)));
return this.heightmap[xi];
},
updateDebris(dt) {
for (let i = this.debris.length - 1; i >= 0; i--) {
const d = this.debris[i];
d.vy += 25 * dt;
d.x += d.vx * dt * 60;
d.y += d.vy * dt * 60;
d.life -= dt * 0.8;
if (d.life <= 0 || this.isSolid(d.x, d.y) || d.y > this.height) {
this.debris.splice(i, 1);
}
}
},
render(ctx, camX, camY, scale) {
if (!this.data) return;
const viewW = Math.ceil(ctx.canvas.width / scale);
const viewH = Math.ceil(ctx.canvas.height / scale);
const startX = Math.max(0, Math.floor(camX));
const startY = Math.max(0, Math.floor(camY));
const endX = Math.min(this.width, startX + viewW);
const endY = Math.min(this.height, startY + viewH);
if (endX <= startX || endY <= startY) return;
const imgData = ctx.createImageData(endX - startX, endY - startY);
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const srcIdx = (y * this.width + x) * 4;
const dstIdx = ((y - startY) * (endX - startX) + (x - startX)) * 4;
imgData.data[dstIdx] = this.data[srcIdx];
imgData.data[dstIdx+1] = this.data[srcIdx+1];
imgData.data[dstIdx+2] = this.data[srcIdx+2];
imgData.data[dstIdx+3] = this.data[srcIdx+3];
}
}
ctx.putImageData(imgData, 0, 0);
// Draw terrain surface highlight line
ctx.strokeStyle = 'rgba(100,200,80,0.4)';
ctx.lineWidth = 2 * scale;
ctx.beginPath();
for (let x = startX; x < endX; x++) {
const sy = this.heightmap[x];
if (sy >= startY && sy <= endY) {
const sx = (x - camX) * scale;
const ssy = (sy - camY) * scale;
if (x === startX) ctx.moveTo(sx, ssy);
else ctx.lineTo(sx, ssy);
}
}
ctx.stroke();
// Render debris
for (const d of this.debris) {
const sx = (d.x - camX) * scale;
const sy = (d.y - camY) * scale;
ctx.globalAlpha = Math.max(0, d.life);
ctx.fillStyle = d.color;
ctx.fillRect(sx, sy, d.size * scale, d.size * scale);
}
ctx.globalAlpha = 1;
}
};
// ── main ──
Game.start();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"composer","sessionId":"119b9a96-999f-4b2e-acc2-fe28e28083a2","tokensIn":2150378,"tokensOut":57993,"tokensTotal":2208371,"turns":48,"toolCalls":48,"failedToolCalls":3,"timestamp":"2026-07-18T16:41:52.947Z"} -->