Worms
Qwen 3.6 27B MTP GGUF · typescript

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 113,602 bytes · SHA-256 982b93cce29d
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Worm Warfare — Artillery Battle</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #0a0a1a; font-family: 'Segoe UI', Tahoma, sans-serif; user-select: none; }
#app { position: relative; width: 100vw; height: 100vh; }
#game { display: block; width: 100%; height: 100%; }
#overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
#hud-top {
position: absolute; top: 0; left: 0; right: 0; display: flex; justify-content: center; align-items: center;
gap: 24px; padding: 12px 24px; background: linear-gradient(to bottom, rgba(0,0,0,0.75), transparent);
}
#wind-indicator { color: #aad; font-size: 20px; font-weight: bold; text-shadow: 0 0 12px rgba(100,200,255,0.5); }
#timer-container {
position: relative; width: 150px; height: 24px;
background: rgba(0,0,0,0.6); border-radius: 12px; overflow: hidden;
border: 2px solid rgba(255,255,255,0.3);
}
#timer-bar {
height: 100%; background: linear-gradient(90deg, #4f4, #6f6);
width: 100%; transition: width 0.1s linear;
}
#timer-text {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: #fff; font-size: 13px; font-weight: bold; text-shadow: 0 0 4px #000;
}
#turn-count { color: #ffd700; font-size: 18px; font-weight: bold; text-shadow: 0 0 8px rgba(255,215,0,0.4); }
#hud-bottom {
position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: flex-end;
padding: 12px 24px; gap: 20px; background: linear-gradient(to top, rgba(0,0,0,0.85), transparent);
pointer-events: auto;
}
#unit-info { display: flex; align-items: center; gap: 12px; min-width: 200px; }
#unit-name { color: #fff; font-size: 18px; font-weight: bold; text-shadow: 0 0 8px rgba(255,255,255,0.3); }
#unit-health-bar { width: 130px; height: 16px; background: rgba(0,0,0,0.5); border-radius: 8px; overflow: hidden; border: 1px solid rgba(255,255,255,0.2); }
#unit-health-fill { height: 100%; background: linear-gradient(90deg, #2ecc71, #4f4); transition: width 0.3s ease; width: 100%; }
#unit-health-text { color: #fff; font-size: 16px; font-weight: bold; min-width: 30px; text-shadow: 0 0 4px rgba(0,0,0,0.5); }
#weapon-selector { display: flex; gap: 8px; flex-wrap: wrap; }
.weapon-btn {
background: rgba(255,255,255,0.1); border: 2px solid rgba(255,255,255,0.3); border-radius: 8px;
color: #fff; padding: 8px 14px; cursor: pointer; font-size: 14px; transition: all 0.2s;
pointer-events: auto; white-space: nowrap;
}
.weapon-btn:hover { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.6); transform: scale(1.05); }
.weapon-btn.selected { background: rgba(100,200,255,0.3); border-color: #4af; box-shadow: 0 0 12px rgba(68,170,255,0.5); }
.weapon-btn .w-icon { font-size: 20px; margin-right: 4px; }
#item-selector { display: flex; gap: 8px; }
.item-btn {
background: rgba(100,255,100,0.1); border: 2px solid rgba(100,255,100,0.3); border-radius: 8px;
color: #afa; padding: 8px 14px; cursor: pointer; font-size: 14px; transition: all 0.2s;
pointer-events: auto; white-space: nowrap;
}
.item-btn:hover { background: rgba(100,255,100,0.2); }
.item-btn.selected { background: rgba(100,255,100,0.3); border-color: #4f4; }
.item-btn.used { opacity: 0.3; pointer-events: none; }
#aim-info {
position: absolute; bottom: 80px; left: 50%; transform: translateX(-50%);
display: flex; gap: 24px; color: #fff; font-size: 16px;
text-shadow: 0 0 8px rgba(0,0,0,0.8); background: rgba(0,0,0,0.4);
padding: 6px 20px; border-radius: 8px;
}
#menu-screen {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(135deg, #1a0a2e, #16213e, #0f3460);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100; pointer-events: auto;
}
#menu-screen h1 { font-size: 60px; color: #ffd700; text-shadow: 0 0 30px rgba(255,215,0,0.5); margin-bottom: 30px; }
#menu-buttons { display: flex; flex-direction: column; align-items: center; gap: 16px; }
#btn-start {
background: linear-gradient(135deg, #f44, #f80); color: #fff; border: none; padding: 18px 52px;
font-size: 26px; font-weight: bold; border-radius: 12px; cursor: pointer; transition: all 0.3s;
box-shadow: 0 4px 20px rgba(255,100,0,0.4);
}
#btn-start:hover { transform: scale(1.1); box-shadow: 0 6px 30px rgba(255,100,0,0.6); }
#team-select { display: flex; gap: 24px; color: #ccc; font-size: 18px; margin-top: 10px; }
#team-select label { display: flex; align-items: center; gap: 8px; }
#team-select select {
background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.3);
padding: 8px 14px; border-radius: 6px; font-size: 15px;
}
#team-select select option { background: #16213e; color: #fff; }
#gameover-screen {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100; pointer-events: auto;
}
#gameover-screen h1 { font-size: 52px; color: #ffd700; margin-bottom: 30px; text-shadow: 0 0 20px rgba(255,215,0,0.5); }
#btn-restart {
background: linear-gradient(135deg, #4f4, #0a0); color: #fff; border: none; padding: 16px 44px;
font-size: 22px; font-weight: bold; border-radius: 10px; cursor: pointer; transition: all 0.3s;
}
#btn-restart:hover { transform: scale(1.1); }
#pause-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center;
z-index: 90; pointer-events: auto; backdrop-filter: blur(4px);
}
#pause-content { text-align: center; color: #fff; background: rgba(0,0,0,0.5); padding: 40px 60px; border-radius: 16px; border: 2px solid rgba(255,255,255,0.2); }
#pause-content h2 { font-size: 40px; margin-bottom: 16px; }
#pause-content p { font-size: 22px; margin-bottom: 20px; }
#btn-resume {
background: linear-gradient(135deg, #4488ff, #44aaff); color: #fff; border: none; padding: 14px 40px;
font-size: 20px; font-weight: bold; border-radius: 10px; cursor: pointer; transition: all 0.3s;
}
#btn-resume:hover { transform: scale(1.08); }
/* Move meter bar */
#move-meter-container {
position: absolute; bottom: 140px; left: 50%; transform: translateX(-50%);
width: 200px; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px;
border: 1px solid rgba(255,255,255,0.2);
}
#move-meter-fill {
height: 100%; background: linear-gradient(90deg, #4af, #6cf); border-radius: 3px;
width: 100%; transition: width 0.1s linear;
}
</style>
</head>
<body>
<div id="app">
<canvas id="game"></canvas>
<div id="overlay">
<div id="hud-top">
<div id="wind-indicator">🌬️ <span id="wind-val">0</span></div>
<div id="timer-container">
<div id="timer-bar"></div>
<span id="timer-text">15</span>
</div>
<div id="turn-count">#1</div>
</div>
<div id="hud-bottom">
<div id="unit-info">
<div id="unit-name">Worm A</div>
<div id="unit-health-bar"><div id="unit-health-fill"></div></div>
<span id="unit-health-text">100</span>
</div>
<div id="weapon-selector"></div>
<div id="item-selector"></div>
</div>
<div id="move-meter-container"><div id="move-meter-fill"></div></div><div id="aim-info">
<span id="power-display">Power: 50</span>
<span id="angle-display">Angle: 45°</span>
</div>
</div>
<div id="menu-screen">
<h1>🪱 WORM WARFARE</h1>
<div id="menu-buttons">
<button id="btn-start">Start Game</button>
<div id="team-select">
<label>Teams:
<select id="num-teams">
<option value="2">2 Teams</option>
<option value="3">3 Teams</option>
<option value="4">4 Teams</option>
</select>
</label>
<label>Mode:
<select id="game-mode">
<option value="cpu">vs CPU</option>
<option value="hotseat">Hot-Seat</option>
</select>
</label>
<label>Terrain:
<select id="terrain-shape">
<option value="single">Single Island</option>
<option value="double">Double Islands</option>
<option value="cave">Cave</option>
</select>
</label>
</div>
</div>
</div>
<div id="gameover-screen" style="display:none">
<h1 id="winner-text">🏆 Team Wins!</h1>
<button id="btn-restart">Play Again</button>
</div>
<div id="pause-overlay" style="display:none">
<div id="pause-content">
<h2>⏸️ PAUSED</h2>
<p id="next-player-info">Next: Worm B</p>
<button id="btn-resume">Continue</button>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./types":"src/types.ts","./game":"src/game.ts","./terrain":"src/terrain.ts","./renderer":"src/renderer.ts","./sound":"src/sound.ts","./ai":"src/ai.ts"},"src/terrain.ts":{"./types":"src/types.ts"},"src/game.ts":{"./types":"src/types.ts"},"src/renderer.ts":{"./types":"src/types.ts","./sound":"src/sound.ts","./game":"src/game.ts"},"src/ai.ts":{"./types":"src/types.ts","./game":"src/game.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.start = start;
const types_1 = require("./types");
const terrain_1 = require("./terrain");
const game_1 = require("./game");
const renderer_1 = require("./renderer");
const sound_1 = require("./sound");
const ai_1 = require("./ai");
let gs = null;
let lastTime = 0;
let keys = { left: false, right: false, up: false, down: false, jump: false, usedJump: false };
let mouseDown = false;
let aiFireQueued = false;
let aiItemQueued = false;
let turnTickTimer = 0;
let isPlayerTurn = false;
let gameOverHandled = false;
let turnPauseTimer = 0;
let betweenTurns = false;
let windChangeTimer = 0;
const canvas = document.getElementById('game');
(0, renderer_1.setRenderTarget)(canvas);
// Expose for debugging
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'gameState', {
get: () => gs,
configurable: true,
});
}
// --- Menu ---
const menuScreen = document.getElementById('menu-screen');
const btnStart = document.getElementById('btn-start');
const btnRestart = document.getElementById('btn-restart');
const gameOverScreen = document.getElementById('gameover-screen');
const winnerText = document.getElementById('winner-text');
const pauseOverlay = document.getElementById('pause-overlay');
const btnResume = document.getElementById('btn-resume');
const nextPlayerInfo = document.getElementById('next-player-info');
btnStart.addEventListener('click', () => {
sound_1.sound.init();
sound_1.sound.playClick();
startGame();
});
btnRestart.addEventListener('click', () => {
sound_1.sound.playClick();
gameOverScreen.style.display = 'none';
menuScreen.style.display = 'flex';
});
btnResume.addEventListener('click', () => {
sound_1.sound.playClick();
gs.phase = 'playing';
pauseOverlay.style.display = 'none';
betweenTurns = false;
});
function startGame() {
menuScreen.style.display = 'none';
gameOverScreen.style.display = 'none';
pauseOverlay.style.display = 'none';
const numTeams = parseInt(document.getElementById('num-teams').value);
const mode = document.getElementById('game-mode').value;
const terrainShape = document.getElementById('terrain-shape').value;
gs = (0, game_1.createGameState)(numTeams, mode === 'cpu', terrainShape);
const td = (0, terrain_1.generateTerrain)(terrainShape);
gs.terrainCanvas = td.canvas;
gs.terrainCtx = td.ctx;
gs.waterCanvas = td.waterCanvas;
gs.waterCtx = td.waterCtx;
gs.hasWater = td.hasWater;
gs.waterLevel = td.waterLevel;
gs.terrainShape = td.shape;
(0, game_1.setTerrainData)(td.heights, td.shape, td.canvas);
(0, game_1.placeUnitsOnTerrain)(gs);
(0, ai_1.resetAI)();
aiFireQueued = false;
aiItemQueued = false;
gameOverHandled = false;
betweenTurns = false;
turnPauseTimer = 0;
const unit = (0, game_1.getCurrentUnit)(gs);
if (unit) {
isPlayerTurn = !gs.teams[unit.teamId].isCPU;
}
gs.phase = 'playing';
// If first turn is CPU, don't show pause
if (!isPlayerTurn) {
pauseOverlay.style.display = 'none';
}
else {
// Human turn - show pause
if (unit) {
const team = gs.teams[unit.teamId];
nextPlayerInfo.textContent = `${team.name} - ${unit.name}`;
nextPlayerInfo.style.color = team.color;
}
pauseOverlay.style.display = 'flex';
gs.phase = 'paused';
}
lastTime = performance.now();
requestAnimationFrame(gameLoop);
}
// --- Input ---
document.addEventListener('keydown', (e) => {
if (!gs || gs.phase === 'menu' || gs.phase === 'gameover')
return;
switch (e.key) {
case 'ArrowLeft':
case 'a':
case 'A':
keys.left = true;
break;
case 'ArrowRight':
case 'd':
case 'D':
keys.right = true;
break;
case 'ArrowUp':
case 'w':
case 'W':
keys.up = true;
break;
case 'ArrowDown':
case 's':
case 'S':
keys.down = true;
break;
case ' ':
case 'z':
case 'x':
e.preventDefault();
if (!keys.jump) {
sound_1.sound.playJump();
keys.jump = true;
keys.usedJump = false;
}
break;
case 'p':
case 'Escape':
if (gs.phase === 'playing') {
gs.phase = 'paused';
const u = (0, game_1.getCurrentUnit)(gs);
if (u) {
const t = gs.teams[u.teamId];
nextPlayerInfo.textContent = `${t.name} - ${u.name}`;
nextPlayerInfo.style.color = t.color;
}
pauseOverlay.style.display = 'flex';
}
else if (gs.phase === 'paused' && !betweenTurns) {
gs.phase = 'playing';
pauseOverlay.style.display = 'none';
}
break;
}
});
document.addEventListener('keyup', (e) => {
switch (e.key) {
case 'ArrowLeft':
case 'a':
case 'A':
keys.left = false;
break;
case 'ArrowRight':
case 'd':
case 'D':
keys.right = false;
break;
case 'ArrowUp':
case 'w':
case 'W':
keys.up = false;
break;
case 'ArrowDown':
case 's':
case 'S':
keys.down = false;
break;
case ' ':
case 'z':
case 'x':
keys.jump = false;
keys.usedJump = false;
break;
}
});
canvas.addEventListener('mousemove', (e) => {
if (!gs)
return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const cw = rect.width, ch = rect.height;
const cam = gs.camera;
gs.mouseX = (mx - cw / 2) / cam.zoom + cam.x;
gs.mouseY = (my - ch / 2) / cam.zoom + cam.y;
});
canvas.addEventListener('mousedown', (e) => {
if (!gs || gs.phase !== 'playing')
return;
const unit = (0, game_1.getCurrentUnit)(gs);
if (!unit || !unit.alive || gs.teams[unit.teamId].isCPU)
return;
sound_1.sound.init();
mouseDown = true;
gs.aimMode = 'power';
});
canvas.addEventListener('mouseup', (e) => {
if (!gs || gs.aimMode !== 'power')
return;
const unit = (0, game_1.getCurrentUnit)(gs);
if (!unit || !unit.alive || gs.teams[unit.teamId].isCPU)
return;
(0, game_1.fireWeapon)(gs, gs.powerCharge, getAngle());
sound_1.sound.playFire(gs.selectedWeapon);
gs.aimMode = null;
gs.powerCharge = 50;
mouseDown = false;
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
function getAngle() {
if (!gs)
return 45;
const unit = (0, game_1.getCurrentUnit)(gs);
if (!unit)
return 45;
const dx = gs.mouseX - unit.x;
const dy = gs.mouseY - (unit.y - 15);
let angle = Math.atan2(-dy, dx) * 180 / Math.PI;
return Math.max(0, Math.min(180, angle));
}
// --- Game Loop ---
function gameLoop(timestamp) {
if (!gs)
return;
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
if (gs.phase === 'playing') {
update(dt);
}
(0, renderer_1.render)(gs);
requestAnimationFrame(gameLoop);
}
function update(dt) {
if (!gs)
return;
// Freeze time for impact
if (gs.freezeTime > 0) {
gs.freezeTime -= dt;
(0, renderer_1.render)(gs);
return;
}
// Screen shake decay
if (gs.shakeTime > 0) {
gs.shakeTime -= dt;
gs.shakeIntensity *= 0.92;
}
else {
gs.shakeIntensity = 0;
}
// Between-turn pause
if (betweenTurns) {
turnPauseTimer -= dt;
if (turnPauseTimer <= 0) {
betweenTurns = false;
pauseOverlay.style.display = 'none';
}
return;
}
const unit = (0, game_1.getCurrentUnit)(gs);
if (!unit)
return;
// Reset input keys each frame for clean state
// (keys are already set by events)
// Update current unit physics
(0, game_1.updateUnit)(unit, dt, gs, keys);
// Update other units (for water effects, debris, etc)
for (const u of gs.units) {
if (u.id !== unit.id && u.alive) {
// Still apply gravity/water to others
if (gs.hasWater && u.y + 12 > gs.waterLevel) {
u.inWater = true;
u.drownTimer += dt;
if (u.drownTimer > 5) {
// Water damage handled in updateUnit, but let's apply it here too
u.hp -= 2 * dt;
if (u.hp <= 0) {
u.hp = 0;
u.alive = false;
gs.floatingTexts.push({
x: u.x, y: u.y - 20, text: 'Drowned!',
life: 2, maxLife: 2, color: '#4af', size: 14, vy: -40,
});
}
}
}
else {
u.inWater = false;
u.drownTimer = Math.max(0, u.drownTimer - dt * 2);
}
}
}
// Turn timer
gs.turnTimer -= dt;
gs.moveMeter = Math.min(gs.moveMeterMax, gs.moveMeter + dt * 5);
// Power charging
if (gs.aimMode === 'power' && mouseDown) {
gs.powerCharge += dt * 80;
if (gs.powerCharge > 100)
gs.powerCharge = 100;
}
// Urgent turn tick sound
if (gs.turnTimer < 5 && gs.turnTimer > 0.5) {
turnTickTimer -= dt;
if (turnTickTimer <= 0) {
const urgency = 1 - gs.turnTimer / 5;
sound_1.sound.playTurnTick(urgency);
turnTickTimer = Math.max(0.3, 1 - urgency * 0.7);
}
}
// Final second beeps
if (gs.turnTimer < 1 && gs.turnTimer > 0) {
turnTickTimer -= dt;
if (turnTickTimer <= 0) {
sound_1.sound.playTurnTick(0.9);
turnTickTimer = 0.15;
}
}
// Time's up
if (gs.turnTimer <= 0) {
sound_1.sound.playTurnEnd();
onTurnEnd();
return;
}
// AI processing
if (!isPlayerTurn && gs.phase === 'playing') {
processAI(dt);
}
// Update projectiles
(0, game_1.updateProjectiles)(dt, gs);
// Update particles
(0, game_1.updateParticles)(dt, gs);
// Update debris
(0, game_1.updateDebris)(dt, gs);
// Update floating texts
(0, game_1.updateFloatingTexts)(dt, gs);
// Wind changes slowly
windChangeTimer += dt;
if (windChangeTimer > 30) {
windChangeTimer = 0;
gs.wind += (Math.random() - 0.5) * 30;
gs.wind = Math.max(-200, Math.min(200, gs.wind));
}
// Check game over
if (!gameOverHandled) {
const winner = (0, game_1.checkGameOver)(gs);
if (winner) {
gameOverHandled = true;
gs.phase = 'gameover';
winnerText.textContent = `🏆 ${winner} Wins!`;
gs.freezeTime = 2;
}
}
}
function onTurnEnd() {
if (!gs)
return;
(0, game_1.advanceTurn)(gs);
aiFireQueued = false;
aiItemQueued = false;
(0, ai_1.resetAI)();
const newUnit = (0, game_1.getCurrentUnit)(gs);
if (!newUnit)
return;
isPlayerTurn = !gs.teams[newUnit.teamId].isCPU;
// If CPU turn, auto-continue after brief pause
if (!isPlayerTurn) {
betweenTurns = true;
turnPauseTimer = 0.5;
gs.phase = 'playing';
}
else {
// Human turn - show pause
const team = gs.teams[newUnit.teamId];
nextPlayerInfo.textContent = `${team.name} - ${newUnit.name}`;
nextPlayerInfo.style.color = team.color;
pauseOverlay.style.display = 'flex';
gs.phase = 'paused';
betweenTurns = false;
}
}
function processAI(dt) {
if (!gs)
return;
const unit = (0, game_1.getCurrentUnit)(gs);
if (!unit || !unit.alive)
return;
const aiAction = (0, ai_1.updateAI)(dt, gs);
if (!aiAction)
return;
switch (aiAction.action) {
case 'move':
if (aiAction.moveDir !== undefined && aiAction.moveDir !== 0) {
keys.left = aiAction.moveDir === -1;
keys.right = aiAction.moveDir === 1;
if (gs.moveMeter > 0) {
gs.moveMeter -= dt * 10;
}
}
else {
keys.left = false;
keys.right = false;
}
break;
case 'fire':
if (!aiFireQueued && aiAction.fireAngle !== undefined && aiAction.firePower !== undefined) {
// Set fake mouse pos for aim line
gs.mouseX = unit.x + Math.cos(-aiAction.fireAngle * Math.PI / 180) * 300;
gs.mouseY = unit.y - 15 + Math.sin(-aiAction.fireAngle * Math.PI / 180) * 300;
(0, game_1.fireWeapon)(gs, aiAction.firePower, aiAction.fireAngle);
sound_1.sound.playFire(gs.selectedWeapon);
aiFireQueued = true;
gs.aimMode = null;
}
break;
case 'useItem':
if (!aiItemQueued && aiAction.itemId) {
if (!gs.usedItems.includes(aiAction.itemId)) {
applyAIItem(gs, aiAction.itemId);
gs.usedItems.push(aiAction.itemId);
}
aiItemQueued = true;
}
break;
case 'steerMissile':
if (aiAction.mouseX !== undefined && aiAction.mouseY !== undefined) {
gs.mouseX = aiAction.mouseX;
gs.mouseY = aiAction.mouseY;
}
break;
}
}
function applyAIItem(gs, itemId) {
const unit = (0, game_1.getCurrentUnit)(gs);
if (!unit)
return;
switch (itemId) {
case 'parachute':
unit.itemActive = 'parachute';
unit.maxJumps = 99;
unit.itemTimer = 999;
break;
case 'jetpack':
unit.itemActive = 'jetpack';
unit.itemTimer = 3;
break;
case 'shield':
unit.invincible = true;
unit.invincibleTimer = 5;
break;
case 'speed':
unit.itemActive = 'speed';
unit.itemTimer = gs.turnTimer;
break;
case 'grappling':
unit.itemActive = 'grappling';
unit.itemTimer = 10;
unit.maxJumps = 3;
break;
case 'extraTime':
gs.turnTimer = Math.min(gs.turnTimer + 10, types_1.TURN_TIME);
break;
}
}
// --- Menu Animation ---
function drawMenuBg(context, w, h, time) {
const grad = context.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, '#1a0a2e');
grad.addColorStop(0.5, '#16213e');
grad.addColorStop(1, '#0f3460');
context.fillStyle = grad;
context.fillRect(0, 0, w, h);
context.fillStyle = 'rgba(255, 255, 255, 0.4)';
for (let i = 0; i < 80; i++) {
const sx = (i * 137.508 + Math.sin(time * 0.2 + i * 0.5) * 30) % w;
const sy = (i * 73.123 + Math.cos(time * 0.15 + i * 0.3) * 20) % h;
const size = 1 + Math.sin(time * 2 + i * 1.7) * 0.8;
context.beginPath();
context.arc(sx, sy, size, 0, Math.PI * 2);
context.fill();
}
// Terrain silhouette
context.fillStyle = 'rgba(40, 80, 60, 0.15)';
context.beginPath();
context.moveTo(0, h);
for (let x = 0; x <= w; x += 8) {
const y = h * 0.72 + Math.sin(x * 0.008 + time * 0.2) * 40 + Math.sin(x * 0.025) * 15;
context.lineTo(x, y);
}
context.lineTo(w, h);
context.closePath();
context.fill();
}
// Menu animation loop (runs before game starts)
let menuAnimId = 0;
function start() {
menuScreen.style.display = 'flex';
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function menuAnim(ts) {
if (gs && gs.phase !== 'menu')
return;
const ctx = canvas.getContext('2d');
if (ctx)
drawMenuBg(ctx, canvas.width, canvas.height, ts * 0.001);
menuAnimId = requestAnimationFrame(menuAnim);
}
menuAnim(0);
}
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WORM_NAMES = exports.TEAM_NAMES = exports.TEAM_COLORS = exports.ITEMS = exports.WEAPONS = exports.MOVE_SPEED = exports.JUMP_FORCE = exports.MAX_FALL_VEL = exports.WATER_DAMAGE = exports.FALL_DAMAGE_PER_PX = exports.FALL_DAMAGE_THRESHOLD = exports.TURN_TIME = exports.UNIT_MAX_HP = exports.UNIT_RADIUS = exports.GRAVITY = exports.WORLD_H = exports.WORLD_W = void 0;
exports.WORLD_W = 2000;
exports.WORLD_H = 800;
exports.GRAVITY = 980;
exports.UNIT_RADIUS = 12;
exports.UNIT_MAX_HP = 100;
exports.TURN_TIME = 15; // seconds
exports.FALL_DAMAGE_THRESHOLD = 300;
exports.FALL_DAMAGE_PER_PX = 0.15;
exports.WATER_DAMAGE = 2;
exports.MAX_FALL_VEL = 800;
exports.JUMP_FORCE = 380;
exports.MOVE_SPEED = 120;
exports.WEAPONS = [
{ id: 'grenade', name: 'Grenade', icon: '💣', category: 'indirect', desc: 'Standard explosive projectile' },
{ id: 'missile', name: 'Missile', icon: '🚀', category: 'indirect', desc: 'Guided explosive (click to steer)' },
{ id: 'shotgun', name: 'Shotgun', icon: '🔫', category: 'direct', desc: 'Instant-hit spread blast' },
{ id: 'cannon', name: 'Cannon', icon: '💥', category: 'indirect', desc: 'High damage, large explosion' },
{ id: 'cluster', name: 'Cluster', icon: '🎆', category: 'aoe', desc: 'Splits into sub-bombs' },
{ id: 'nuke', name: 'Nuke', icon: '☢️', category: 'aoe', desc: 'Massive area destruction' },
{ id: 'harpoon', name: 'Harpoon', icon: '🎯', category: 'direct', desc: 'Pulls target toward you' },
{ id: 'flamethrower', name: 'Flame', icon: '🔥', category: 'direct', desc: 'Close range fire stream' },
];
exports.ITEMS = [
{ id: 'parachute', name: 'Parachute', icon: '🪂', desc: 'Safe fall, extra jump' },
{ id: 'grappling', name: 'Grappler', icon: '🪝', desc: 'Swing and move fast' },
{ id: 'jetpack', name: 'Jetpack', icon: '✈️', desc: 'Fly for 3 seconds' },
{ id: 'shield', name: 'Shield', icon: '🛡️', desc: 'Invincibility for 5s' },
{ id: 'speed', name: 'Speed Boots', icon: '⚡', desc: '2x movement for turn' },
{ id: 'extraTime', name: 'Extra Time', icon: '⏰', desc: '+10 seconds' },
];
exports.TEAM_COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12'];
exports.TEAM_NAMES = ['Red Fangs', 'Blue Sharks', 'Green Goblins', 'Orange Devils'];
exports.WORM_NAMES = ['Boris', 'Cuddles', 'Sir Flaps', 'Wormy', 'Daisy', 'Twitch', 'Fluffy', 'Nuts', 'Bertie', 'Gizmo'];
};
// ── module: src/terrain.ts ──
__mods["src/terrain.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateTerrain = generateTerrain;
exports.isTerrainAt = isTerrainAt;
exports.getSurfaceY = getSurfaceY;
exports.destroyTerrainCircle = destroyTerrainCircle;
exports.spawnDebrisFromExplosion = spawnDebrisFromExplosion;
const types_1 = require("./types");
// --- Simplex-like noise ---
function hash2D(x, y) {
let n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return n - Math.floor(n);
}
function smoothNoise(x, y = 0) {
const ix = Math.floor(x), iy = Math.floor(y);
const fx = x - ix, fy = y - iy;
const sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy);
return lerp(lerp(hash2D(ix, iy), hash2D(ix + 1, iy), sx), lerp(hash2D(ix, iy + 1), hash2D(ix + 1, iy + 1), sx), sy);
}
function octaveNoise(x, y, octaves) {
let v = 0, a = 1, f = 1, m = 0;
for (let i = 0; i < octaves; i++) {
v += smoothNoise(x * f, y * f) * a;
m += a;
a *= 0.5;
f *= 2;
}
return v / m;
}
function lerp(a, b, t) { return a + (b - a) * t; }
function generateTerrain(shape) {
const canvas = new OffscreenCanvas(types_1.WORLD_W, types_1.WORLD_H);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const heights = new Float32Array(types_1.WORLD_W);
// Generate heights
switch (shape) {
case 'single': {
for (let x = 0; x < types_1.WORLD_W; x++) {
const n1 = octaveNoise(x * 0.003, 0, 4) * 180;
const n2 = octaveNoise(x * 0.01, 100, 3) * 80;
const n3 = octaveNoise(x * 0.03, 200, 2) * 30;
heights[x] = types_1.WORLD_H * 0.4 + n1 + n2 + n3;
heights[x] = Math.max(HEIGHTS_MIN, Math.min(types_1.WORLD_H - 50, heights[x]));
}
break;
}
case 'double': {
for (let x = 0; x < types_1.WORLD_W; x++) {
const li = Math.exp(-Math.pow((x - 500) / 250, 2));
const ri = Math.exp(-Math.pow((x - 1500) / 250, 2));
const island = li + ri;
const n = octaveNoise(x * 0.008, 0, 3) * 120 + octaveNoise(x * 0.03, 50, 2) * 30;
heights[x] = types_1.WORLD_H * 0.35 + n * Math.max(0.15, island) + (1 - island) * 60;
heights[x] = Math.max(HEIGHTS_MIN, Math.min(types_1.WORLD_H - 30, heights[x]));
}
break;
}
case 'cave': {
for (let x = 0; x < types_1.WORLD_W; x++) {
const cn = octaveNoise(x * 0.006, 0, 3) * 70;
heights[x] = 100 + cn; // ceiling
}
break;
}
}
// Render terrain
const imgData = ctx.createImageData(types_1.WORLD_W, types_1.WORLD_H);
const d = imgData.data;
for (let x = 0; x < types_1.WORLD_W; x++) {
for (let y = 0; y < types_1.WORLD_H; y++) {
const i = (y * types_1.WORLD_W + x) * 4;
let isTerrain = false;
if (shape === 'cave') {
const floorBase = types_1.WORLD_H * 0.8;
const floorNoise = octaveNoise(x * 0.008, 200, 3) * 60;
const floorH = floorBase + floorNoise;
const ceilH = heights[x];
isTerrain = y < ceilH || y > floorH;
if (!isTerrain) {
// Cave interior
d[i] = 18;
d[i + 1] = 18;
d[i + 2] = 30;
d[i + 3] = 255;
}
else {
// Rock
const n = octaveNoise(x * 0.1, y * 0.1, 2) * 25;
d[i] = 55 + n;
d[i + 1] = 48 + n * 0.8;
d[i + 2] = 38 + n * 0.6;
d[i + 3] = 255;
}
}
else {
isTerrain = y >= heights[x];
if (!isTerrain) {
// Sky gradient with subtle variation
const t = y / types_1.WORLD_H;
const noiseVal = octaveNoise(x * 0.002, y * 0.002, 2) * 5;
d[i] = 10 + t * 20 + noiseVal;
d[i + 1] = 14 + t * 30 + noiseVal;
d[i + 2] = 35 + t * 60 + noiseVal * 0.5;
d[i + 3] = 255;
// Stars
if (t < 0.4 && hash2D(x * 0.5, y * 0.5) > 0.98) {
d[i] = 180 + Math.random() * 75;
d[i + 1] = 180 + Math.random() * 75;
d[i + 2] = 200 + Math.random() * 55;
}
// Clouds
const cloud = smoothNoise(x * 0.003 + 50, y * 0.005 + 50);
if (t < 0.35 && cloud > 0.55) {
const cloudAlpha = (cloud - 0.55) * 4;
d[i] = d[i] + (200 - d[i]) * cloudAlpha * 0.3;
d[i + 1] = d[i + 1] + (210 - d[i + 1]) * cloudAlpha * 0.3;
d[i + 2] = d[i + 2] + (230 - d[i + 2]) * cloudAlpha * 0.3;
}
}
else {
const depth = y - heights[x];
const n = octaveNoise(x * 0.08, y * 0.08, 2) * 20;
if (depth < 4) {
d[i] = 34 + n * 2;
d[i + 1] = 140 + n * 2;
d[i + 2] = 34 + n;
}
else if (depth < 18) {
const t = (depth - 4) / 14;
d[i] = lerp(80, 110, t) + n;
d[i + 1] = lerp(70, 65, t) + n * 0.7;
d[i + 2] = lerp(35, 30, t) + n * 0.5;
}
else {
d[i] = 70 + n * 2;
d[i + 1] = 55 + n;
d[i + 2] = 35 + n * 0.5;
}
d[i + 3] = 255;
}
}
}
}
ctx.putImageData(imgData, 0, 0);
// Water
let hasWater = false, waterLevel = 0;
const waterCanvas = new OffscreenCanvas(types_1.WORLD_W, types_1.WORLD_H);
const waterCtx = waterCanvas.getContext('2d');
if (shape === 'double') {
hasWater = true;
waterLevel = types_1.WORLD_H * 0.58;
waterCtx.fillStyle = 'rgba(20, 60, 140, 0.65)';
waterCtx.fillRect(0, waterLevel, types_1.WORLD_W, types_1.WORLD_H - waterLevel);
for (let x = 0; x < types_1.WORLD_W; x++) {
const wave = Math.sin(x * 0.04) * 2;
waterCtx.fillStyle = `rgba(50, 120, 200, 0.25)`;
waterCtx.fillRect(x, waterLevel + wave, 1, 6);
}
}
return { canvas, ctx, waterCanvas, waterCtx, heights, hasWater, waterLevel, shape };
}
const HEIGHTS_MIN = 100;
// --- Terrain API ---
function isTerrainAt(td, x, y) {
const ix = Math.round(x), iy = Math.round(y);
if (ix < 0 || ix >= types_1.WORLD_W || iy < 0 || iy >= types_1.WORLD_H)
return true;
const px = td.ctx.getImageData(ix, iy, 1, 1).data;
if (td.shape === 'cave') {
// Cave: terrain is dark rock (R>40), sky is very dark
return px[0] > 40 && px[1] > 35;
}
// Surface: sky is dark (R<35), terrain is brighter
return px[0] > 35 || px[1] > 30;
}
function getSurfaceY(td, x) {
const ix = Math.round(x);
if (ix < 0 || ix >= types_1.WORLD_W)
return types_1.WORLD_H;
if (td.shape === 'cave') {
// Scan from bottom for floor
for (let y = types_1.WORLD_H - 1; y >= 0; y--) {
if (isTerrainAt(td, ix, y)) {
// Go up to find top of floor
for (let sy = y; sy >= 0; sy--) {
if (!isTerrainAt(td, ix, sy))
return sy + 1;
}
return 0;
}
}
return types_1.WORLD_H;
}
return td.heights[ix];
}
function destroyTerrainCircle(td, cx, cy, radius) {
// Destroy terrain in a circle
const r = Math.round(radius);
const x0 = Math.max(0, Math.round(cx - r));
const y0 = Math.max(0, Math.round(cy - r));
const x1 = Math.min(types_1.WORLD_W, Math.round(cx + r) + 1);
const y1 = Math.min(types_1.WORLD_H, Math.round(cy + r) + 1);
let destroyed = false;
// Collect debris pixel regions
const debrisRegions = [];
for (let x = x0; x < x1; x++) {
for (let y = y0; y < y1; y++) {
const dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy < r * r) {
if (isTerrainAt(td, x, y)) {
td.ctx.clearRect(x, y, 1, 1);
// Make sky color
const t = y / types_1.WORLD_H;
if (td.shape === 'cave') {
td.ctx.fillStyle = `rgb(${18 | 0},${18 | 0},${30 | 0})`;
}
else {
td.ctx.fillStyle = `rgb(${12 + t * 25 | 0},${16 + t * 35 | 0},${40 + t * 65 | 0})`;
}
td.ctx.fillRect(x, y, 1, 1);
destroyed = true;
}
}
}
}
// Update height map after destruction
if (destroyed) {
updateHeightsAfterDestruction(td);
}
return [destroyed];
}
function updateHeightsAfterDestruction(td) {
if (td.shape === 'cave')
return;
for (let x = 0; x < types_1.WORLD_W; x++) {
// Scan from top to find new surface
for (let y = 0; y < types_1.WORLD_H; y++) {
const px = td.ctx.getImageData(x, y, 1, 1).data;
if (px[0] > 35 || px[1] > 30) {
td.heights[x] = y;
break;
}
}
}
}
// Create debris chunks from destroyed terrain
function spawnDebrisFromExplosion(td, cx, cy, radius) {
const debris = [];
const r = Math.round(radius * 1.2);
// Spawn debris in ring around explosion
for (let a = 0; a < Math.PI * 2; a += 0.3) {
const dist = radius * (0.7 + Math.random() * 0.5);
const dx = cx + Math.cos(a) * dist;
const dy = cy + Math.sin(a) * dist;
const ix = Math.round(dx), iy = Math.round(dy);
if (ix >= 0 && ix < types_1.WORLD_W && iy >= 0 && iy < types_1.WORLD_H) {
if (isTerrainAt(td, ix, iy)) {
const size = 3 + Math.random() * 6;
const n = octaveNoise(ix * 0.1, iy * 0.1, 1);
const shade = n * 40;
debris.push({
x: dx, y: dy,
vx: Math.cos(a) * (30 + Math.random() * 60),
vy: Math.sin(a) * (30 + Math.random() * 60) - 40,
w: size, h: size,
color: `rgb(${90 + shade | 0},${75 + shade | 0},${50 + shade * 0.5 | 0})`
});
}
}
}
return debris;
}
};
// ── module: src/game.ts ──
__mods["src/game.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createGameState = createGameState;
exports.placeUnitsOnTerrain = placeUnitsOnTerrain;
exports.setTerrainData = setTerrainData;
exports.rebuildTerrainCache = rebuildTerrainCache;
exports.getTerrainSurfaceY = getTerrainSurfaceY;
exports.getCurrentUnit = getCurrentUnit;
exports.updateUnit = updateUnit;
exports.isUnitInTerrain = isUnitInTerrain;
exports.takeDamage = takeDamage;
exports.fireWeapon = fireWeapon;
exports.updateProjectiles = updateProjectiles;
exports.createExplosion = createExplosion;
exports.updateParticles = updateParticles;
exports.updateDebris = updateDebris;
exports.updateFloatingTexts = updateFloatingTexts;
exports.advanceTurn = advanceTurn;
exports.checkGameOver = checkGameOver;
exports.handleInput = handleInput;
const types_1 = require("./types");
let idCounter = 0;
function uid() { return `id_${idCounter++}`; }
// --- Game Init ---
function createGameState(numTeams, isCPU, terrainShape) {
const teams = [];
for (let i = 0; i < numTeams; i++) {
const wormColors = types_1.TEAM_COLORS[i] === '#e74c3c' ? ['#c0392b', '#e74c3c', '#ec7063', '#f1948a'] :
types_1.TEAM_COLORS[i] === '#3498db' ? ['#2471a3', '#3498db', '#5dade2', '#85c1e9'] :
types_1.TEAM_COLORS[i] === '#2ecc71' ? ['#1e8449', '#2ecc71', '#58d68d', '#82e0aa'] :
['#b9770e', '#f39c12', '#f5b041', '#f8c471'];
teams.push({
name: types_1.TEAM_NAMES[i],
color: types_1.TEAM_COLORS[i],
wormColors,
isCPU: isCPU,
});
}
const units = [];
let nameIdx = 0;
for (let t = 0; t < numTeams; t++) {
for (let u = 0; u < 4; u++) {
units.push({
id: uid(),
teamId: t,
name: types_1.WORM_NAMES[nameIdx++],
hp: types_1.UNIT_MAX_HP,
x: 0, y: 0, // Set after terrain generated
vx: 0, vy: 0,
onGround: false,
facing: 1,
color: teams[t].wormColors[u],
alive: true,
lastFallVel: 0,
inWater: false,
drownTimer: 0,
invincible: false,
invincibleTimer: 0,
jumpCount: 0,
maxJumps: 1,
itemActive: null,
itemTimer: 0,
});
}
}
// Items per turn: one weapon item
const shuffledWeapons = [...types_1.WEAPONS].sort(() => Math.random() - 0.5);
const turnWeapons = shuffledWeapons.slice(0, Math.min(5, shuffledWeapons.length));
const turnItem = types_1.ITEMS[Math.floor(Math.random() * types_1.ITEMS.length)];
return {
phase: 'playing',
units,
teams,
currentUnitIndex: 0,
turnNumber: 1,
turnTimer: types_1.TURN_TIME,
wind: (Math.random() - 0.5) * 200,
terrainCanvas: null,
terrainCtx: null,
waterCanvas: null,
waterCtx: null,
projectiles: [],
particles: [],
debris: [],
floatingTexts: [],
selectedWeapon: turnWeapons[0].id,
selectedItem: turnItem.id,
mouseX: 0,
mouseY: 0,
aimMode: 'aiming',
powerCharge: 50,
camera: { x: types_1.WORLD_W / 2, y: types_1.WORLD_H / 2, zoom: 0.8, targetX: types_1.WORLD_W / 2, targetY: types_1.WORLD_H / 2, targetZoom: 0.8 },
shakeX: 0, shakeY: 0, shakeIntensity: 0, shakeTime: 0,
freezeTime: 0,
items: { weapon: turnWeapons, item: turnItem },
usedItems: [],
waterLevel: 0,
hasWater: false,
terrainShape,
moveMeter: 100,
moveMeterMax: 100,
};
}
function placeUnitsOnTerrain(gs) {
const td = {
canvas: gs.terrainCanvas,
ctx: gs.terrainCtx,
waterCanvas: gs.waterCanvas,
waterCtx: gs.waterCtx,
heights: getTerrainHeightsSafe(gs),
hasWater: gs.hasWater,
waterLevel: gs.waterLevel,
shape: gs.terrainShape,
};
const alivePerTeam = gs.units.map(u => u.teamId).reduce((acc, t) => {
acc[t] = (acc[t] || 0) + 1;
return acc;
}, []);
for (let t = 0; t < gs.teams.length; t++) {
const teamUnits = gs.units.filter(u => u.teamId === t);
const spread = types_1.WORLD_W / (gs.teams.length * 2);
const baseX = spread * (t * 2 + 1);
for (let i = 0; i < teamUnits.length; i++) {
const u = teamUnits[i];
const x = baseX + (i - 1.5) * 30;
const surfaceY = getTerrainSurfaceY(gs, x);
u.x = x;
u.y = surfaceY - types_1.UNIT_RADIUS;
u.alive = true;
u.hp = types_1.UNIT_MAX_HP;
u.vx = 0;
u.vy = 0;
u.onGround = false;
u.invincible = true;
u.invincibleTimer = 3; // 3 seconds invincibility on spawn
}
}
}
// We need a way to get height data since terrain.ts generates it
// Store it in a global-accessible way
let _terrainHeights = null;
let _terrainShape = '';
let _terrainCanvas = null;
let _terrainCache = null; // 0=sky, 1=terrain for fast lookups
let _terrainCacheW = 0;
function setTerrainData(heights, shape, canvas) {
_terrainHeights = heights;
_terrainShape = shape;
_terrainCanvas = canvas;
// Build terrain cache for fast lookups (every 2px)
const step = 2;
const cw = Math.ceil(types_1.WORLD_W / step);
const ch = Math.ceil(types_1.WORLD_H / step);
_terrainCache = new Uint8Array(cw * ch);
_terrainCacheW = cw;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
const imgData = ctx.getImageData(0, 0, types_1.WORLD_W, types_1.WORLD_H);
const d = imgData.data;
for (let sy = 0; sy < ch; sy++) {
for (let sx = 0; sx < cw; sx++) {
const px = sx * step;
const py = sy * step;
if (px < types_1.WORLD_W && py < types_1.WORLD_H) {
const i = (py * types_1.WORLD_W + px) * 4;
const isTerrain = shape === 'cave'
? d[i] > 40 && d[i + 1] > 35
: d[i] > 35 || d[i + 1] > 30;
_terrainCache[sy * cw + sx] = isTerrain ? 1 : 0;
}
}
}
}
}
}
// Rebuild cache after terrain modification
function rebuildTerrainCache(gs) {
if (!_terrainCanvas || !gs.terrainCtx)
return;
const step = 2;
const cw = Math.ceil(types_1.WORLD_W / step);
const ch = Math.ceil(types_1.WORLD_H / step);
if (!_terrainCache || _terrainCacheW !== cw) {
_terrainCache = new Uint8Array(cw * ch);
_terrainCacheW = cw;
}
const imgData = gs.terrainCtx.getImageData(0, 0, types_1.WORLD_W, types_1.WORLD_H);
const d = imgData.data;
for (let sy = 0; sy < ch; sy++) {
for (let sx = 0; sx < cw; sx++) {
const px = sx * step;
const py = sy * step;
if (px < types_1.WORLD_W && py < types_1.WORLD_H) {
const i = (py * types_1.WORLD_W + px) * 4;
const isTerrain = gs.terrainShape === 'cave'
? d[i] > 40 && d[i + 1] > 35
: d[i] > 35 || d[i + 1] > 30;
_terrainCache[sy * cw + sx] = isTerrain ? 1 : 0;
}
}
}
}
function getTerrainHeightsSafe(gs) {
return _terrainHeights;
}
function getTerrainSurfaceY(gs, x) {
if (!_terrainHeights)
return types_1.WORLD_H * 0.5;
const ix = Math.max(0, Math.min(types_1.WORLD_W - 1, Math.round(x)));
if (_terrainShape === 'cave') {
// For cave, floor is at ~80% of world height + noise
return types_1.WORLD_H * 0.8;
}
return _terrainHeights[ix];
}
function getCurrentUnit(gs) {
if (gs.currentUnitIndex >= gs.units.length)
return null;
return gs.units[gs.currentUnitIndex];
}
// --- Unit Update ---
function updateUnit(u, dt, gs, keys) {
if (!u.alive)
return;
// Invincibility timer
if (u.invincible) {
u.invincibleTimer -= dt;
if (u.invincibleTimer <= 0)
u.invincible = false;
}
// Item effects
if (u.itemActive) {
u.itemTimer -= dt;
if (u.itemTimer <= 0) {
u.itemActive = null;
if (u.itemActive === 'parachute')
u.maxJumps = 1;
}
}
const speedMult = u.itemActive === 'speed' ? 2 : 1;
const speed = types_1.MOVE_SPEED * speedMult;
// Movement
let moveX = 0;
if (keys.left)
moveX = -1;
if (keys.right)
moveX = 1;
// Check move meter
const moveCost = Math.abs(moveX) * dt * 10;
if (moveCost > gs.moveMeter) {
moveX = 0;
}
else {
gs.moveMeter -= moveCost;
}
u.vx = moveX * speed;
u.facing = moveX === 0 ? u.facing : (moveX > 0 ? 1 : -1);
// Wind effect on units
u.vx += gs.wind * dt * 0.1;
// Jetpack
if (u.itemActive === 'jetpack' && u.itemTimer > 0) {
u.vy -= 500 * dt;
// Jetpack particles
for (let i = 0; i < 3; i++) {
gs.particles.push({
x: u.x + (Math.random() - 0.5) * 6,
y: u.y + 10,
vx: (Math.random() - 0.5) * 30,
vy: 50 + Math.random() * 40,
life: 0.3 + Math.random() * 0.3,
maxLife: 0.5,
size: 3 + Math.random() * 3,
color: `hsl(${20 + Math.random() * 20}, 100%, ${50 + Math.random() * 30}%)`,
type: 'spark',
gravity: 0,
friction: 0.95,
});
}
}
// Apply gravity
if (u.itemActive !== 'parachute' || !keys.up) {
const gravMult = u.itemActive === 'parachute' ? 0.3 : 1;
u.vy += types_1.GRAVITY * gravMult * dt;
}
// Wind on airborne
if (!u.onGround) {
u.vx += gs.wind * dt * 0.02;
}
// Clamp velocity
u.vy = Math.min(u.vy, types_1.MAX_FALL_VEL);
u.vx = Math.max(-300, Math.min(300, u.vx));
// Move
u.x += u.vx * dt;
u.y += u.vy * dt;
// World bounds
if (u.x < types_1.UNIT_RADIUS) {
u.x = types_1.UNIT_RADIUS;
u.vx = 0;
}
if (u.x > types_1.WORLD_W - types_1.UNIT_RADIUS) {
u.x = types_1.WORLD_W - types_1.UNIT_RADIUS;
u.vx = 0;
}
// Water check
if (gs.hasWater && u.y + types_1.UNIT_RADIUS > gs.waterLevel) {
u.inWater = true;
u.drownTimer += dt;
u.vy *= 0.9; // Damping
if (u.drownTimer > 5) {
takeDamage(u, types_1.WATER_DAMAGE * dt, -1, gs);
}
}
else {
u.inWater = false;
u.drownTimer = Math.max(0, u.drownTimer - dt * 2);
}
// Terrain collision
handleTerrainCollision(u, gs);
// Fall damage
if (u.onGround && Math.abs(u.lastFallVel) > types_1.FALL_DAMAGE_THRESHOLD) {
const dmg = (Math.abs(u.lastFallVel) - types_1.FALL_DAMAGE_THRESHOLD) * types_1.FALL_DAMAGE_PER_PX;
if (dmg > 0 && !u.invincible) {
takeDamage(u, dmg, -1, gs);
gs.floatingTexts.push({
x: u.x, y: u.y - 30,
text: `Fall ${dmg.toFixed(0)}`,
life: 1.5, maxLife: 1.5,
color: '#ff8800', size: 16,
vy: -40,
});
}
u.lastFallVel = 0;
}
}
function handleTerrainCollision(u, gs) {
const wasOnGround = u.onGround;
const feetY = u.y + types_1.UNIT_RADIUS;
const leftX = u.x - types_1.UNIT_RADIUS + 2;
const rightX = u.x + types_1.UNIT_RADIUS - 2;
// Check if feet are in terrain
const surfaceY = getTerrainSurfaceY(gs, u.x);
const inTerrain = isUnitInTerrain(gs, u.x, feetY);
if (inTerrain) {
// Push unit up
u.y = surfaceY - types_1.UNIT_RADIUS;
if (u.vy > 0) {
u.lastFallVel = u.vy;
}
u.vy = 0;
u.onGround = true;
u.jumpCount = 0;
u.vx *= 0.7; // Friction
}
else {
u.onGround = false;
if (!wasOnGround) {
u.lastFallVel = u.vy;
}
}
// Wall collision
const wallLeft = isUnitInTerrain(gs, leftX, u.y);
const wallRight = isUnitInTerrain(gs, rightX, u.y);
if (wallLeft)
u.x = leftX + types_1.UNIT_RADIUS - 2;
if (wallRight)
u.x = rightX - types_1.UNIT_RADIUS + 2;
// Fall off world
if (u.y > types_1.WORLD_H + 50) {
u.alive = false;
u.hp = 0;
}
}
function isUnitInTerrain(gs, x, y) {
// Use terrain cache for fast lookup
if (_terrainCache && _terrainCacheW > 0) {
const sx = Math.max(0, Math.min(_terrainCacheW - 1, Math.floor(x / 2)));
const sy = Math.max(0, Math.min(Math.ceil(types_1.WORLD_H / 2) - 1, Math.floor(y / 2)));
return _terrainCache[sy * _terrainCacheW + sx] === 1;
}
// Fallback to pixel read
const ix = Math.max(0, Math.min(types_1.WORLD_W - 1, Math.round(x)));
const iy = Math.max(0, Math.min(types_1.WORLD_H - 1, Math.round(y)));
if (!gs.terrainCtx)
return false;
try {
const px = gs.terrainCtx.getImageData(ix, iy, 1, 1).data;
if (gs.terrainShape === 'cave') {
return px[0] > 40 && px[1] > 35;
}
return px[0] > 35 || px[1] > 30;
}
catch {
return false;
}
}
function takeDamage(u, amount, attackerTeamId, gs) {
if (u.invincible || !u.alive)
return;
u.hp -= amount;
if (u.hp <= 0) {
u.hp = 0;
u.alive = false;
// Death particles
for (let i = 0; i < 20; i++) {
gs.particles.push({
x: u.x, y: u.y,
vx: (Math.random() - 0.5) * 200,
vy: (Math.random() - 0.5) * 200 - 50,
life: 0.5 + Math.random(), maxLife: 1.5,
size: 3 + Math.random() * 5,
color: u.color,
type: 'explosion',
gravity: 200, friction: 0.97,
});
}
}
}
// --- Weapon Fire ---
function fireWeapon(gs, power, angle) {
const unit = getCurrentUnit(gs);
if (!unit || !unit.alive)
return;
const rad = -angle * Math.PI / 180;
const baseSpeed = power * 4;
switch (gs.selectedWeapon) {
case 'grenade': {
gs.projectiles.push({
id: uid(), x: unit.x, y: unit.y - 15,
vx: Math.cos(rad) * baseSpeed, vy: Math.sin(rad) * baseSpeed,
weaponId: 'grenade', ownerTeamId: unit.teamId,
alive: true, life: 8, trail: [],
bounceCount: 0, maxBounces: 0, explodeOnTouch: false,
gravity: types_1.GRAVITY, size: 5, color: '#444',
rotation: 0, fuse: 0, arming: 30,
});
break;
}
case 'missile': {
gs.projectiles.push({
id: uid(), x: unit.x, y: unit.y - 15,
vx: Math.cos(rad) * baseSpeed * 0.8, vy: Math.sin(rad) * baseSpeed * 0.8,
weaponId: 'missile', ownerTeamId: unit.teamId,
alive: true, life: 6, trail: [],
bounceCount: 0, maxBounces: 0, explodeOnTouch: false,
gravity: types_1.GRAVITY * 0.3, size: 4, color: '#f44',
rotation: 0, fuse: 0, arming: 50,
});
break;
}
case 'shotgun': {
for (let i = -3; i <= 3; i++) {
const spread = i * 0.08;
const pelletSpeed = baseSpeed * 1.5;
gs.projectiles.push({
id: uid(), x: unit.x, y: unit.y - 15,
vx: Math.cos(rad + spread) * pelletSpeed, vy: Math.sin(rad + spread) * pelletSpeed,
weaponId: 'shotgun', ownerTeamId: unit.teamId,
alive: true, life: 1.5, trail: [],
bounceCount: 0, maxBounces: 0, explodeOnTouch: true,
gravity: types_1.GRAVITY, size: 3, color: '#fa0',
rotation: 0, fuse: 0, arming: 0,
});
}
break;
}
case 'cannon': {
gs.projectiles.push({
id: uid(), x: unit.x, y: unit.y - 15,
vx: Math.cos(rad) * baseSpeed * 0.7, vy: Math.sin(rad) * baseSpeed * 0.7,
weaponId: 'cannon', ownerTeamId: unit.teamId,
alive: true, life: 10, trail: [],
bounceCount: 0, maxBounces: 0, explodeOnTouch: false,
gravity: types_1.GRAVITY, size: 8, color: '#666',
rotation: 0, fuse: 0, arming: 20,
});
break;
}
case 'cluster': {
gs.projectiles.push({
id: uid(), x: unit.x, y: unit.y - 15,
vx: Math.cos(rad) * baseSpeed, vy: Math.sin(rad) * baseSpeed,
weaponId: 'cluster', ownerTeamId: unit.teamId,
alive: true, life: 8, trail: [],
bounceCount: 0, maxBounces: 0, explodeOnTouch: false,
gravity: types_1.GRAVITY, size: 6, color: '#f0f',
rotation: 0, fuse: 0, arming: 80,
});
break;
}
case 'nuke': {
gs.projectiles.push({
id: uid(), x: unit.x, y: unit.y - 15,
vx: Math.cos(rad) * baseSpeed * 0.5, vy: Math.sin(rad) * baseSpeed * 0.5,
weaponId: 'nuke', ownerTeamId: unit.teamId,
alive: true, life: 15, trail: [],
bounceCount: 0, maxBounces: 0, explodeOnTouch: false,
gravity: types_1.GRAVITY * 0.8, size: 12, color: '#0f0',
rotation: 0, fuse: 0, arming: 40,
});
break;
}
case 'harpoon': {
// Instant-hit line projectile
const dirX = Math.cos(rad), dirY = Math.sin(rad);
let hitX = unit.x + dirX * 600, hitY = unit.y + dirY * 600;
// Check terrain intersection
for (let d = 0; d < 600; d += 5) {
const tx = unit.x + dirX * d, ty = unit.y + dirY * d;
if (isUnitInTerrain(gs, tx, ty)) {
hitX = tx;
hitY = ty;
break;
}
}
// Check unit hits
for (const target of gs.units) {
if (target.id === unit.id || !target.alive)
continue;
const dx = target.x - unit.x, dy = target.y - (unit.y - 15);
const dot = dx * dirX + dy * dirY;
if (dot > 0 && dot < 600) {
const perpDist = Math.abs(dx * dirY - dy * dirX);
if (perpDist < types_1.UNIT_RADIUS * 2) {
hitX = target.x;
hitY = target.y;
// Pull effect
target.vx += (unit.x - target.x) * 3;
target.vy -= 200;
takeDamage(target, 20, unit.teamId, gs);
gs.floatingTexts.push({
x: target.x, y: target.y - 20,
text: '20', life: 1.5, maxLife: 1.5,
color: '#ff44ff', size: 20, vy: -50,
});
}
}
}
// Create explosion at hit point
createExplosion(gs, hitX, hitY, 40, 15, unit.teamId, '#ff0');
break;
}
case 'flamethrower': {
const flameCount = Math.ceil(power / 5);
for (let i = 0; i < flameCount; i++) {
const spread = (Math.random() - 0.5) * 0.5;
const dist = 20 + Math.random() * power * 2;
const fx = unit.x + Math.cos(rad + spread) * dist;
const fy = unit.y - 15 + Math.sin(rad + spread) * dist;
// Check unit hits
for (const target of gs.units) {
if (target.id === unit.id || !target.alive)
continue;
const dx = target.x - fx, dy = target.y - fy;
if (dx * dx + dy * dy < 400) {
takeDamage(target, 1 + Math.random() * 2, unit.teamId, gs);
}
}
gs.particles.push({
x: fx, y: fy,
vx: (Math.random() - 0.5) * 40, vy: (Math.random() - 0.5) * 40,
life: 0.2 + Math.random() * 0.3, maxLife: 0.5,
size: 4 + Math.random() * 6,
color: `hsl(${Math.random() * 30}, 100%, ${50 + Math.random() * 30}%)`,
type: 'explosion', gravity: 0, friction: 0.9,
});
}
break;
}
}
// Recoil
unit.vx -= Math.cos(rad) * 30;
unit.vy -= Math.sin(rad) * 20;
}
// --- Projectile Update ---
function updateProjectiles(dt, gs) {
for (const p of gs.projectiles) {
if (!p.alive)
continue;
p.life -= dt;
if (p.life <= 0) {
p.alive = false;
createExplosion(gs, p.x, p.y, weaponData(p.weaponId).aoe, weaponData(p.weaponId).damage, p.ownerTeamId, weaponData(p.weaponId).color);
continue;
}
// Arming distance
if (p.arming > 0) {
p.arming -= Math.sqrt(p.vx * p.vx + p.vy * p.vy) * dt;
continue;
}
// Wind effect
p.vx += gs.wind * dt * 0.1;
// Gravity
p.vy += p.gravity * dt;
// Missile steering
if (p.weaponId === 'missile') {
// Steer toward mouse
const dx = gs.mouseX - p.x, dy = gs.mouseY - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 1) {
const steer = 300 * dt;
p.vx += (dx / dist) * steer;
p.vy += (dy / dist) * steer;
}
}
// Update rotation
p.rotation = Math.atan2(p.vy, p.vx);
// Trail
p.trail.push({ x: p.x, y: p.y });
if (p.trail.length > 30)
p.trail.shift();
p.x += p.vx * dt;
p.y += p.vy * dt;
// Out of bounds
if (p.x < -50 || p.x > types_1.WORLD_W + 50 || p.y > types_1.WORLD_H + 50) {
p.alive = false;
continue;
}
// Terrain collision
if (isUnitInTerrain(gs, p.x, p.y)) {
if (p.explodeOnTouch || p.weaponId === 'nuke' || p.weaponId === 'cannon') {
const data = weaponData(p.weaponId);
createExplosion(gs, p.x, p.y, data.aoe, data.damage, p.ownerTeamId, data.color);
p.alive = false;
}
else if (p.bounceCount < p.maxBounces) {
p.bounceCount++;
p.vy = -p.vy * 0.5;
p.vx *= 0.7;
}
else {
p.alive = false;
createExplosion(gs, p.x, p.y, 40, 30, p.ownerTeamId, '#ff8');
}
continue;
}
// Unit collision
for (const u of gs.units) {
if (!u.alive || u.invincible)
continue;
const dx = u.x - p.x, dy = u.y - p.y;
if (dx * dx + dy * dy < types_1.UNIT_RADIUS * types_1.UNIT_RADIUS) {
const data = weaponData(p.weaponId);
createExplosion(gs, p.x, p.y, data.aoe, data.damage, p.ownerTeamId, data.color);
p.alive = false;
break;
}
}
// Cluster bomb splits at 50% life
if (p.weaponId === 'cluster' && p.life < 4) {
// Already split flag check via a hack: check if we're the main and life is low
if (!p.id.includes('_sub')) {
for (let i = 0; i < 5; i++) {
const a = (Math.PI * 2 * i) / 5 + Math.random() * 0.5;
gs.projectiles.push({
id: uid() + '_sub', x: p.x, y: p.y,
vx: Math.cos(a) * 80 + p.vx * 0.3, vy: Math.sin(a) * 80 + p.vy * 0.3,
weaponId: 'grenade', ownerTeamId: p.ownerTeamId,
alive: true, life: 3, trail: [],
bounceCount: 0, maxBounces: 0, explodeOnTouch: true,
gravity: types_1.GRAVITY, size: 3, color: '#f0f',
rotation: 0, fuse: 0, arming: 0,
});
}
p.alive = false;
}
}
// Nuke self-destruct
if (p.weaponId === 'nuke' && p.life < 1) {
createExplosion(gs, p.x, p.y, 250, 150, p.ownerTeamId, '#ff0');
p.alive = false;
}
}
// Cleanup
gs.projectiles = gs.projectiles.filter(p => p.alive);
}
function weaponData(id) {
const map = {
grenade: { aoe: 50, damage: 30, color: '#fa0' },
missile: { aoe: 60, damage: 35, color: '#f44' },
shotgun: { aoe: 15, damage: 8, color: '#fa0' },
cannon: { aoe: 80, damage: 50, color: '#ff4' },
cluster: { aoe: 40, damage: 20, color: '#f0f' },
nuke: { aoe: 250, damage: 150, color: '#ff0' },
harpoon: { aoe: 40, damage: 15, color: '#ff0' },
flamethrower: { aoe: 0, damage: 0, color: '#fa0' },
};
return map[id] || { aoe: 50, damage: 30, color: '#fa0' };
}
// --- Explosions ---
function createExplosion(gs, x, y, radius, damage, ownerTeamId, color) {
// Screen shake
const shakeAmt = Math.min(radius * 0.15, 20);
gs.shakeIntensity = shakeAmt;
gs.shakeTime = 0.3 + radius * 0.002;
// Freeze time for big explosions
if (radius > 100) {
gs.freezeTime = 0.15;
}
// Damage units
for (const u of gs.units) {
if (!u.alive || u.invincible)
continue;
const dx = u.x - x, dy = u.y - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius + types_1.UNIT_RADIUS) {
const falloff = 1 - Math.min(dist / (radius + types_1.UNIT_RADIUS), 1);
const dmg = damage * falloff;
takeDamage(u, dmg, ownerTeamId, gs);
// Knockback
const kb = radius * 0.8 * falloff;
if (dist > 0) {
u.vx += (dx / dist) * kb;
u.vy += (dy / dist) * kb - kb * 0.3;
}
// Floating damage text
gs.floatingTexts.push({
x: u.x + (Math.random() - 0.5) * 10,
y: u.y - 20,
text: `${dmg.toFixed(0)}`,
life: 1.5, maxLife: 1.5,
color: ownerTeamId !== u.teamId ? '#ff4444' : '#ffaa00',
size: 14 + dmg * 0.3,
vy: -60,
});
}
}
// Explosion particles
const pCount = Math.min(radius * 2, 100);
for (let i = 0; i < pCount; i++) {
const a = Math.random() * Math.PI * 2;
const speed = Math.random() * radius * 2;
gs.particles.push({
x, y,
vx: Math.cos(a) * speed, vy: Math.sin(a) * speed - Math.random() * 50,
life: 0.5 + Math.random() * 1, maxLife: 1.5,
size: 2 + Math.random() * (radius / 10),
color,
type: i % 3 === 0 ? 'smoke' : 'explosion',
gravity: 100 + Math.random() * 100,
friction: 0.97,
});
}
// Smoke
for (let i = 0; i < 10; i++) {
gs.particles.push({
x: x + (Math.random() - 0.5) * radius,
y: y + (Math.random() - 0.5) * radius,
vx: (Math.random() - 0.5) * 30,
vy: -20 - Math.random() * 30,
life: 1 + Math.random() * 2, maxLife: 3,
size: 5 + Math.random() * 10,
color: '#555',
type: 'smoke',
gravity: -10,
friction: 0.99,
});
}
// Spawn debris from terrain
if (gs.terrainCtx) {
const debrisData = spawnDebrisFromExplosionLocal(gs, x, y, radius);
for (const db of debrisData) {
gs.debris.push({
x: db.x, y: db.y,
w: db.w, h: db.h,
vx: db.vx, vy: db.vy,
rotation: 0, vr: (Math.random() - 0.5) * 10,
pixels: null,
settled: false,
settleTime: 0,
_color: db.color,
});
}
}
// Destroy terrain
if (gs.terrainCtx) {
destroyTerrainCircleLocal(gs, x, y, radius);
}
}
function spawnDebrisFromExplosionLocal(gs, cx, cy, radius) {
const debris = [];
const r = Math.round(radius);
for (let a = 0; a < Math.PI * 2; a += 0.4) {
const dist = r * (0.5 + Math.random() * 0.8);
const dx = cx + Math.cos(a) * dist;
const dy = cy + Math.sin(a) * dist;
const ix = Math.round(dx), iy = Math.round(dy);
if (ix >= 0 && ix < types_1.WORLD_W && iy >= 0 && iy < types_1.WORLD_H) {
try {
const px = gs.terrainCtx.getImageData(ix, iy, 1, 1).data;
const isTerrain = gs.terrainShape === 'cave'
? px[0] > 40 && px[1] > 35
: px[0] > 35 || px[1] > 30;
if (isTerrain) {
const size = 3 + Math.random() * 8;
debris.push({
x: dx, y: dy,
vx: Math.cos(a) * (40 + Math.random() * 80),
vy: Math.sin(a) * (40 + Math.random() * 80) - 60,
w: size, h: size,
color: `rgb(${px[0] | 0},${px[1] | 0},${px[2] | 0})`,
});
}
}
catch { /* skip */ }
}
}
return debris;
}
function destroyTerrainCircleLocal(gs, cx, cy, radius) {
if (!gs.terrainCtx)
return;
// Use image data for batch modification
let imgData;
try {
imgData = gs.terrainCtx.getImageData(0, 0, types_1.WORLD_W, types_1.WORLD_H);
}
catch {
return;
}
const d = imgData.data;
const r = Math.round(radius);
const r2 = r * r;
const x0 = Math.max(0, Math.round(cx - r));
const y0 = Math.max(0, Math.round(cy - r));
const x1 = Math.min(types_1.WORLD_W, Math.round(cx + r) + 1);
const y1 = Math.min(types_1.WORLD_H, Math.round(cy + r) + 1);
const isCave = gs.terrainShape === 'cave';
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy < r2) {
const i = (y * types_1.WORLD_W + x) * 4;
const isTerrain = isCave
? d[i] > 40 && d[i + 1] > 35
: d[i] > 35 || d[i + 1] > 30;
if (isTerrain) {
d[i] = isCave ? 18 : (12 + y / types_1.WORLD_H * 25) | 0;
d[i + 1] = isCave ? 18 : (16 + y / types_1.WORLD_H * 35) | 0;
d[i + 2] = isCave ? 30 : (40 + y / types_1.WORLD_H * 65) | 0;
d[i + 3] = 255;
}
}
}
}
gs.terrainCtx.putImageData(imgData, 0, 0);
rebuildTerrainCache(gs);
}
// --- Particles ---
function updateParticles(dt, gs) {
for (const p of gs.particles) {
p.life -= dt;
if (p.life <= 0)
continue;
p.vy += p.gravity * dt;
p.vx *= p.friction;
p.vy *= p.friction;
p.x += p.vx * dt;
p.y += p.vy * dt;
// Terrain collision for debris particles
if (p.type === 'debris' && gs.terrainCtx) {
try {
const px = gs.terrainCtx.getImageData(Math.round(p.x), Math.round(p.y), 1, 1).data;
const isTerrain = gs.terrainShape === 'cave'
? px[0] > 40 && px[1] > 35
: px[0] > 35 || px[1] > 30;
if (isTerrain) {
p.life = 0;
}
}
catch { /* skip */ }
}
}
gs.particles = gs.particles.filter(p => p.life > 0);
}
// --- Debris ---
function updateDebris(dt, gs) {
for (const d of gs.debris) {
if (d.settled) {
d.settleTime -= dt;
if (d.settleTime <= 0)
d._remove = true;
continue;
}
d.vy += types_1.GRAVITY * 0.7 * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
d.rotation += d.vr * dt;
// Terrain collision
if (gs.terrainCtx) {
try {
const px = gs.terrainCtx.getImageData(Math.round(d.x), Math.round(d.y + d.h), 1, 1).data;
const isTerrain = gs.terrainShape === 'cave'
? px[0] > 40 && px[1] > 35
: px[0] > 35 || px[1] > 30;
if (isTerrain) {
d.y -= d.h;
d.vy = 0;
d.vx = 0;
d.vr = 0;
d.settled = true;
d.settleTime = 3;
}
}
catch { /* skip */ }
}
if (d.y > types_1.WORLD_H + 100)
d._remove = true;
}
gs.debris = gs.debris.filter(d => !d._remove);
}
// --- Floating Text ---
function updateFloatingTexts(dt, gs) {
for (const ft of gs.floatingTexts) {
ft.life -= dt;
ft.y += ft.vy * dt;
}
gs.floatingTexts = gs.floatingTexts.filter(ft => ft.life > 0);
}
// --- Turn Management ---
function advanceTurn(gs) {
// Find next alive unit
let nextIdx = (gs.currentUnitIndex + 1) % gs.units.length;
let safety = 0;
while (!gs.units[nextIdx].alive && safety < gs.units.length) {
nextIdx = (nextIdx + 1) % gs.units.length;
safety++;
}
if (safety >= gs.units.length) {
// All dead
return;
}
// Check if we're back to start = new round
if (nextIdx < gs.currentUnitIndex) {
gs.turnNumber++;
// Change wind
gs.wind = (Math.random() - 0.5) * 200;
}
gs.currentUnitIndex = nextIdx;
gs.turnTimer = types_1.TURN_TIME;
gs.aimMode = 'aiming';
gs.powerCharge = 50;
gs.moveMeter = gs.moveMeterMax;
// New items for this turn
const shuffled = [...types_1.WEAPONS].sort(() => Math.random() - 0.5);
gs.items.weapon = shuffled.slice(0, 5);
gs.items.item = types_1.ITEMS[Math.floor(Math.random() * types_1.ITEMS.length)];
gs.usedItems = [];
const unit = getCurrentUnit(gs);
if (unit) {
gs.selectedWeapon = gs.items.weapon[0].id;
}
}
// --- Check Game Over ---
function checkGameOver(gs) {
const aliveTeams = new Set();
for (const u of gs.units) {
if (u.alive)
aliveTeams.add(u.teamId);
}
if (aliveTeams.size <= 1) {
const winner = [...aliveTeams][0];
return gs.teams[winner].name;
}
return null;
}
function handleInput(gs, keys, mouseDown, click) {
const unit = getCurrentUnit(gs);
if (!unit || !unit.alive)
return;
if (gs.phase !== 'playing')
return;
// Jump
if (keys.jump && !keys.usedJump) {
if (unit.onGround || unit.jumpCount < unit.maxJumps) {
unit.vy = -types_1.JUMP_FORCE;
unit.onGround = false;
unit.jumpCount++;
keys.usedJump = true;
}
}
if (!keys.jump)
keys.usedJump = false;
// Aiming
if (gs.aimMode === 'aiming') {
// Aim toward mouse
}
}
};
// ── module: src/sound.ts ──
__mods["src/sound.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.sound = void 0;
// Simple synthesized sound effects using Web Audio API
class SoundManager {
constructor() {
this.ctx = null;
this.masterGain = null;
}
init() {
if (this.ctx)
return;
this.ctx = new AudioContext();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.3;
this.masterGain.connect(this.ctx.destination);
}
getCtx() {
if (!this.ctx)
this.init();
return this.ctx;
}
playOsc(freq, duration, type = 'square', volume = 0.2, freqEnd) {
const ctx = this.getCtx();
if (!ctx)
return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
if (freqEnd)
osc.frequency.exponentialRampToValueAtTime(Math.max(freqEnd, 1), ctx.currentTime + duration);
gain.gain.setValueAtTime(volume, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(ctx.currentTime + duration);
}
playClick() {
this.playOsc(800, 0.05, 'square', 0.15, 600);
}
playExplosion(size = 1) {
const ctx = this.getCtx();
if (!ctx)
return;
// Noise burst
const bufferSize = ctx.sampleRate * 0.3 * size;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(Math.min(size * 0.3, 0.5), ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3 * size);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 800;
source.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
source.start();
}
playJump() {
this.playOsc(300, 0.15, 'sine', 0.2, 600);
}
playHit() {
this.playOsc(200, 0.1, 'sawtooth', 0.15, 50);
}
playDeath() {
this.playOsc(400, 0.5, 'sawtooth', 0.2, 80);
}
playFire(type) {
switch (type) {
case 'grenade':
this.playOsc(200, 0.2, 'square', 0.15, 400);
break;
case 'missile':
this.playOsc(150, 0.3, 'sawtooth', 0.1, 800);
break;
case 'shotgun':
this.playOsc(100, 0.15, 'sawtooth', 0.25, 200);
break;
case 'cannon':
this.playOsc(80, 0.4, 'square', 0.3, 40);
break;
case 'cluster':
this.playOsc(500, 0.2, 'square', 0.15, 300);
break;
case 'nuke':
this.playOsc(60, 1.0, 'sawtooth', 0.4, 20);
break;
case 'harpoon':
this.playOsc(1000, 0.1, 'square', 0.1, 200);
break;
case 'flamethrower':
const ctx = this.getCtx();
if (!ctx)
return;
const bufSize = ctx.sampleRate * 0.5;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufSize * 0.3)) * Math.sin(i * 0.05);
}
const src = ctx.createBufferSource();
src.buffer = buf;
const g = ctx.createGain();
g.gain.value = 0.15;
src.connect(g);
g.connect(this.masterGain);
src.start();
break;
}
}
playTurnTick(urgency) {
// urgency 0-1, higher = more urgent
const freq = 800 + urgency * 2000;
this.playOsc(freq, 0.05, 'square', 0.08 * (1 - urgency * 0.5));
}
playTurnEnd() {
this.playOsc(400, 0.3, 'triangle', 0.2, 100);
}
playSelect() {
this.playOsc(600, 0.08, 'sine', 0.15);
}
playDamage() {
this.playOsc(150 + Math.random() * 100, 0.1, 'sawtooth', 0.15, 50);
}
playWater() {
const ctx = this.getCtx();
if (!ctx)
return;
const bufSize = ctx.sampleRate * 0.2;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufSize * 0.2)) * 0.3;
}
const src = ctx.createBufferSource();
src.buffer = buf;
const g = ctx.createGain();
g.gain.value = 0.1;
src.connect(g);
g.connect(this.masterGain);
src.start();
}
}
exports.sound = new SoundManager();
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setRenderTarget = setRenderTarget;
exports.render = render;
const types_1 = require("./types");
const sound_1 = require("./sound");
const game_1 = require("./game");
let rt = null;
let lastRenderTime = 0;
function setRenderTarget(canvas) {
rt = { canvas, ctx: canvas.getContext('2d') };
}
function render(gs) {
if (!rt)
return;
const { canvas, ctx } = rt;
lastRenderTime = performance.now();
const cw = canvas.clientWidth, ch = canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== cw * dpr || canvas.height !== ch * dpr) {
canvas.width = cw * dpr;
canvas.height = ch * dpr;
}
ctx.save();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cw, ch);
// --- Camera ---
const cam = gs.camera;
const unit = gs.units[gs.currentUnitIndex];
cam.targetX = 0;
cam.targetY = 0;
if (unit && unit.alive) {
cam.targetX = unit.x;
cam.targetY = unit.y;
}
cam.x += (cam.targetX - cam.x) * 0.06;
cam.y += (cam.targetY - cam.y) * 0.06;
const idealZoom = Math.min(cw / types_1.WORLD_W, ch / types_1.WORLD_H) * 0.88;
cam.targetZoom = Math.max(0.3, idealZoom);
cam.zoom += (cam.targetZoom - cam.zoom) * 0.04;
// Screen shake
let shakeX = 0, shakeY = 0;
if (gs.shakeTime > 0) {
shakeX = (Math.random() - 0.5) * gs.shakeIntensity * 2;
shakeY = (Math.random() - 0.5) * gs.shakeIntensity * 2;
}
// Apply camera
ctx.translate(cw / 2, ch / 2);
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x - shakeX, -cam.y - shakeY);
// --- Draw World ---
if (gs.terrainCanvas) {
ctx.drawImage(gs.terrainCanvas, 0, 0, types_1.WORLD_W, types_1.WORLD_H);
}
// Water
if (gs.hasWater && gs.waterCanvas) {
ctx.drawImage(gs.waterCanvas, 0, 0, types_1.WORLD_W, types_1.WORLD_H);
// Animated water surface
const now = Date.now() * 0.001;
ctx.strokeStyle = 'rgba(80, 160, 240, 0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < types_1.WORLD_W; x += 4) {
const wave = Math.sin(x * 0.03 + now * 2) * 3 + Math.sin(x * 0.07 + now * 3) * 1.5;
if (x === 0)
ctx.moveTo(x, gs.waterLevel + wave);
else
ctx.lineTo(x, gs.waterLevel + wave);
}
ctx.stroke();
}
// Debris
for (const d of gs.debris) {
ctx.save();
ctx.translate(d.x, d.y);
ctx.rotate(d.rotation);
ctx.fillStyle = d._color || '#886644';
ctx.fillRect(-d.w / 2, -d.h / 2, d.w, d.h);
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 0.5;
ctx.strokeRect(-d.w / 2, -d.h / 2, d.w, d.h);
ctx.restore();
}
// Projectile trails
for (const p of gs.projectiles) {
if (!p.alive || p.trail.length < 2)
continue;
ctx.strokeStyle = p.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.moveTo(p.trail[0].x, p.trail[0].y);
for (let i = 1; i < p.trail.length; i++) {
ctx.lineTo(p.trail[i].x, p.trail[i].y);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
// Projectiles
for (const p of gs.projectiles) {
if (!p.alive)
continue;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rotation);
// Glow
ctx.shadowColor = p.color;
ctx.shadowBlur = 8;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(0, 0, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Inner highlight
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath();
ctx.arc(-p.size * 0.2, -p.size * 0.2, p.size * 0.4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Nuke glow
if (p.weaponId === 'nuke') {
const pulse = 0.5 + 0.5 * Math.sin(Date.now() * 0.01);
ctx.fillStyle = `rgba(0, 255, 100, ${pulse * 0.25})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 4, 0, Math.PI * 2);
ctx.fill();
}
}
// Units (worms)
const activeUnit = gs.units[gs.currentUnitIndex];
for (const u of gs.units) {
if (!u.alive)
continue;
drawWorm(u, gs, u === activeUnit);
}
// Particles
for (const p of gs.particles) {
const alpha = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
if (p.type === 'smoke') {
const s = p.size * (1 + (1 - alpha) * 0.8);
ctx.arc(p.x, p.y, s, 0, Math.PI * 2);
}
else {
ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI * 2);
}
ctx.fill();
}
ctx.globalAlpha = 1;
// Floating texts
for (const ft of gs.floatingTexts) {
const alpha = ft.life / ft.maxLife;
const scale = 1 + (1 - alpha) * 0.6;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = ft.color;
ctx.font = `bold ${Math.round(ft.size * scale)}px 'Segoe UI', sans-serif`;
ctx.textAlign = 'center';
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.strokeText(ft.text, ft.x, ft.y);
ctx.fillText(ft.text, ft.x, ft.y);
ctx.restore();
}
// Trajectory preview
if (gs.aimMode === 'power' && unit && unit.alive && gs.phase === 'playing') {
drawTrajectory(gs, unit);
}
if (unit && unit.alive && gs.phase === 'playing') {
drawAimLine(gs, unit);
}
// Active unit indicator ring
if (activeUnit && activeUnit.alive) {
const ringTime = Date.now() * 0.003;
const ringR = 22 + Math.sin(ringTime) * 3;
ctx.strokeStyle = gs.teams[activeUnit.teamId].color + '80';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.lineDashOffset = -ringTime * 10;
ctx.beginPath();
ctx.arc(activeUnit.x, activeUnit.y, ringR, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.restore();
// Freeze effect
if (gs.freezeTime > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${gs.freezeTime * 0.4})`;
ctx.fillRect(0, 0, cw, ch);
}
updateHUD(gs, cw, ch);
}
function drawWorm(u, gs, isActive) {
const ctx = rt.ctx;
const x = u.x, y = u.y;
const now = Date.now() * 0.001;
const t = gs.teams[u.teamId];
const isFallen = !u.onGround && u.vy > 100;
const bodyWobble = Math.sin(now * 3 + u.id.charCodeAt(0)) * 2;
ctx.save();
ctx.translate(x, y);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.beginPath();
ctx.ellipse(0, 14, 14, 4, 0, 0, Math.PI * 2);
ctx.fill();
// --- Worm body (elongated, slightly wobbly) ---
const bodyColor = u.color;
const darker = shadeColor(bodyColor, -30);
const lighter = shadeColor(bodyColor, 30);
// Body segments (drawn as overlapping ellipses for a worm look)
for (let s = 2; s >= -2; s--) {
const segY = s * 4;
const segW = 10 - Math.abs(s) * 1.5;
const segH = 7;
const wobble = Math.sin(now * 4 + s * 0.8) * 0.8;
const grad = ctx.createRadialGradient(0, segY, 0, 0, segY, segW);
grad.addColorStop(0, lighter);
grad.addColorStop(1, darker);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(wobble, segY, segW, segH, 0, 0, Math.PI * 2);
ctx.fill();
}
// Body outline
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(0, 0, 10, 12, 0, 0, Math.PI * 2);
ctx.stroke();
// --- Head ---
const headY = -8;
ctx.fillStyle = bodyColor;
ctx.beginPath();
ctx.ellipse(0, headY, 8, 6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.stroke();
// Highlight on head
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.beginPath();
ctx.ellipse(-2, headY - 2, 4, 2, -0.3, 0, Math.PI * 2);
ctx.fill();
// --- Eyes ---
const eyeY = headY - 1;
const eyeSpread = 3;
const pupilOff = u.facing * 1.2;
// Eye whites
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.ellipse(-eyeSpread, eyeY, 2.8, 3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(eyeSpread, eyeY, 2.8, 3, 0, 0, Math.PI * 2);
ctx.fill();
// Pupils
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(-eyeSpread + pupilOff, eyeY + 0.3, 1.4, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(eyeSpread + pupilOff, eyeY + 0.3, 1.4, 0, Math.PI * 2);
ctx.fill();
// Mouth (changes based on state)
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
if (isFallen) {
// Scared mouth (O shape)
ctx.arc(0, eyeY + 5, 1.5, 0, Math.PI * 2);
ctx.stroke();
}
else {
// Happy mouth
ctx.arc(u.facing * 2, eyeY + 5, 2, 0.1, Math.PI - 0.1);
ctx.stroke();
}
// --- Legs ---
if (u.onGround) {
const legAnim = Math.sin(now * 6) * 2;
const hasMove = gs.units[gs.currentUnitIndex]?.id === u.id;
const moveAnim = hasMove ? Math.sin(now * 8) * 3 : 0;
ctx.strokeStyle = bodyColor;
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
// Left leg
ctx.beginPath();
ctx.moveTo(-5, 3);
ctx.lineTo(-7 - moveAnim, 12 + legAnim);
ctx.lineTo(-9, 13);
ctx.stroke();
// Right leg
ctx.beginPath();
ctx.moveTo(5, 3);
ctx.lineTo(7 + moveAnim, 12 - legAnim);
ctx.lineTo(9, 13);
ctx.stroke();
}
else {
// Airborne legs (tucked)
ctx.strokeStyle = bodyColor;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-4, 5);
ctx.lineTo(-6, 9);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(4, 5);
ctx.lineTo(6, 9);
ctx.stroke();
}
// --- Hat (for active unit) ---
if (isActive) {
drawHat(ctx, 0, headY - 5, u.facing, t.color);
}
ctx.restore();
// Name tag
ctx.save();
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
const nameY = y - 24;
// Name background
const nameW = ctx.measureText(u.name).width + 8;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(ctx, x - nameW / 2, nameY - 10, nameW, 14, 4);
ctx.fill();
ctx.fillStyle = isActive ? '#ffd700' : '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.strokeText(u.name, x, nameY);
ctx.fillText(u.name, x, nameY);
// HP bar
const barW = 36, barH = 4;
const barX = x - barW / 2;
const barY = nameY + 4;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(ctx, barX, barY, barW, barH, 2);
ctx.fill();
const hpColor = u.hp > 60 ? '#2ecc71' : u.hp > 30 ? '#f39c12' : '#e74c3c';
ctx.fillStyle = hpColor;
roundRect(ctx, barX, barY, barW * Math.max(0, u.hp / 100), barH, 2);
ctx.fill();
ctx.restore();
// Water effect
if (u.inWater) {
ctx.strokeStyle = 'rgba(150, 200, 255, 0.4)';
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const bx = x + Math.sin(now * 3 + i * 1.5) * 8;
const by = y - 12 - ((now * 15 + i * 12) % 25);
ctx.beginPath();
ctx.arc(bx, by, 2, 0, Math.PI * 2);
ctx.stroke();
}
}
// Item icon
if (u.itemActive) {
const icons = {
parachute: '🪂', jetpack: '✈️', shield: '🛡️', speed: '⚡', grappling: '🪝'
};
const icon = icons[u.itemActive];
if (icon) {
ctx.font = '14px sans-serif';
ctx.fillText(icon, x + 16, y - 12);
}
}
}
function drawHat(ctx, x, y, facing, teamColor) {
// Little party hat
ctx.fillStyle = teamColor;
ctx.beginPath();
ctx.moveTo(x - 5, y);
ctx.lineTo(x, y - 12);
ctx.lineTo(x + 5, y);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 0.5;
ctx.stroke();
// Hat brim
ctx.fillStyle = shadeColor(teamColor, -20);
ctx.fillRect(x - 6, y - 1, 12, 2);
// Pom-pom
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.arc(x, y - 12, 2, 0, Math.PI * 2);
ctx.fill();
}
function drawTrajectory(gs, unit) {
const ctx = rt.ctx;
const angle = getAimAngle(gs, unit);
const rad = -angle * Math.PI / 180;
const power = gs.powerCharge;
const baseSpeed = power * 4;
const vx = Math.cos(rad) * baseSpeed;
const vy = Math.sin(rad) * baseSpeed;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 6]);
ctx.beginPath();
let px = unit.x, py = unit.y - 15;
const dt = 0.04;
let pvx = vx, pvy = vy;
ctx.moveTo(px, py);
for (let i = 0; i < 100; i++) {
pvx += gs.wind * dt * 0.1;
pvy += types_1.GRAVITY * dt;
px += pvx * dt;
py += pvy * dt;
if (px < 0 || px > types_1.WORLD_W || py > types_1.WORLD_H)
break;
if ((0, game_1.isUnitInTerrain)(gs, px, py))
break;
ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
// Impact dot
ctx.fillStyle = 'rgba(255, 200, 50, 0.4)';
ctx.beginPath();
ctx.arc(px, py, 4, 0, Math.PI * 2);
ctx.fill();
}
function drawAimLine(gs, unit) {
const ctx = rt.ctx;
const angle = getAimAngle(gs, unit);
const rad = -angle * Math.PI / 180;
const len = 35 + gs.powerCharge * 0.4;
ctx.strokeStyle = 'rgba(255, 200, 50, 0.6)';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(unit.x, unit.y - 15);
ctx.lineTo(unit.x + Math.cos(rad) * len, unit.y - 15 + Math.sin(rad) * len);
ctx.stroke();
// Arrow tip
const ax = unit.x + Math.cos(rad) * len;
const ay = unit.y - 15 + Math.sin(rad) * len;
const tipLen = 6;
ctx.strokeStyle = 'rgba(255, 200, 50, 0.6)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(ax - Math.cos(rad - 0.4) * tipLen, ay - Math.sin(rad - 0.4) * tipLen);
ctx.moveTo(ax, ay);
ctx.lineTo(ax - Math.cos(rad + 0.4) * tipLen, ay - Math.sin(rad + 0.4) * tipLen);
ctx.stroke();
}
function getAimAngle(gs, unit) {
const dx = gs.mouseX - unit.x;
const dy = gs.mouseY - (unit.y - 15);
let angle = Math.atan2(-dy, dx) * 180 / Math.PI;
return Math.max(0, Math.min(180, angle));
}
function roundRect(ctx, x, y, w, h, r) {
if (w <= 0)
return;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
function shadeColor(color, percent) {
// Parse hex or rgb color
const num = parseInt(color.replace('#', ''), 16);
const r = Math.min(255, Math.max(0, ((num >> 16) & 0xff) + percent));
const g = Math.min(255, Math.max(0, ((num >> 8) & 0xff) + percent));
const b = Math.min(255, Math.max(0, (num & 0xff) + percent));
return `rgb(${r},${g},${b})`;
}
let _hudUnitId = '';
let _hudTurnNum = 0;
function updateHUD(gs, cw, _ch) {
const unit = gs.units[gs.currentUnitIndex];
const team = gs.teams[unit?.teamId ?? 0];
// Rebuild weapons/items when unit or turn changes
const needsRebuild = unit && (unit.id !== _hudUnitId || gs.turnNumber !== _hudTurnNum);
if (needsRebuild) {
_hudUnitId = unit.id;
_hudTurnNum = gs.turnNumber;
const wSel = document.getElementById('weapon-selector');
if (wSel) {
wSel.innerHTML = '';
for (const w of gs.items.weapon) {
const btn = document.createElement('button');
btn.className = 'weapon-btn' + (w.id === gs.selectedWeapon ? ' selected' : '');
btn.innerHTML = `<span class="w-icon">${w.icon}</span> ${w.name}`;
btn.title = w.desc;
btn.onclick = () => {
sound_1.sound.playSelect();
gs.selectedWeapon = w.id;
wSel.querySelectorAll('.weapon-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
};
wSel.appendChild(btn);
}
}
const iSel = document.getElementById('item-selector');
if (iSel) {
iSel.innerHTML = '';
const item = gs.items.item;
const btn = document.createElement('button');
btn.className = 'item-btn';
btn.innerHTML = `${item.icon} ${item.name}`;
btn.title = item.desc;
btn.onclick = () => {
if (gs.usedItems.includes(item.id))
return;
sound_1.sound.playSelect();
applyItem(gs, item);
gs.usedItems.push(item.id);
btn.classList.add('used');
};
iSel.appendChild(btn);
}
}
else {
// Just update selection highlight
const wSel = document.getElementById('weapon-selector');
if (wSel) {
const btns = wSel.querySelectorAll('.weapon-btn');
let idx = 0;
for (const w of gs.items.weapon) {
if (idx < btns.length) {
btns[idx].className = 'weapon-btn' + (w.id === gs.selectedWeapon ? ' selected' : '');
}
idx++;
}
}
}
// Wind
const windEl = document.getElementById('wind-val');
if (windEl) {
const dir = gs.wind > 0 ? '→' : gs.wind < 0 ? '←' : '—';
windEl.textContent = `🌬 ${dir} ${Math.abs(Math.round(gs.wind))}`;
}
// Timer
const timerBar = document.getElementById('timer-bar');
const timerText = document.getElementById('timer-text');
if (timerBar && timerText) {
const pct = Math.max(0, (gs.turnTimer / types_1.TURN_TIME) * 100);
timerBar.style.width = `${pct}%`;
timerBar.style.background = pct > 50
? 'linear-gradient(90deg, #2ecc71, #4f4)'
: pct > 25
? 'linear-gradient(90deg, #e67e22, #f39c12)'
: 'linear-gradient(90deg, #c0392b, #e74c3c)';
timerText.textContent = `${Math.ceil(Math.max(0, gs.turnTimer))}`;
const urgency = 1 - gs.turnTimer / types_1.TURN_TIME;
if (urgency > 0.7) {
timerBar.parentElement.style.transform = `scale(${1 + (urgency - 0.7) * 1.5})`;
}
else {
timerBar.parentElement.style.transform = 'scale(1)';
}
}
// Turn count
const turnEl = document.getElementById('turn-count');
if (turnEl)
turnEl.textContent = `Turn #${gs.turnNumber}`;
// Unit info
if (unit) {
const nameEl = document.getElementById('unit-name');
const fillEl = document.getElementById('unit-health-fill');
const hpEl = document.getElementById('unit-health-text');
if (nameEl) {
nameEl.textContent = `${unit.name}`;
nameEl.style.color = team.color;
}
if (fillEl) {
fillEl.style.width = `${Math.max(0, unit.hp)}%`;
fillEl.style.background = unit.hp > 60
? 'linear-gradient(90deg, #2ecc71, #4f4)'
: unit.hp > 30
? 'linear-gradient(90deg, #e67e22, #f39c12)'
: 'linear-gradient(90deg, #c0392b, #e74c3c)';
}
if (hpEl)
hpEl.textContent = `${Math.round(Math.max(0, unit.hp))}`;
// Weapon selector
const wSel = document.getElementById('weapon-selector');
if (wSel && !wSel.dataset.built) {
wSel.innerHTML = '';
for (const w of gs.items.weapon) {
const btn = document.createElement('button');
btn.className = 'weapon-btn' + (w.id === gs.selectedWeapon ? ' selected' : '');
btn.innerHTML = `<span class="w-icon">${w.icon}</span> ${w.name}`;
btn.title = w.desc;
btn.onclick = () => {
sound_1.sound.playSelect();
gs.selectedWeapon = w.id;
wSel.querySelectorAll('.weapon-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
};
wSel.appendChild(btn);
}
wSel.dataset.built = '1';
}
else if (wSel && wSel.dataset.built) {
// Update selection highlight only
const btns = wSel.querySelectorAll('.weapon-btn');
let idx = 0;
for (const w of gs.items.weapon) {
if (idx < btns.length) {
btns[idx].className = 'weapon-btn' + (w.id === gs.selectedWeapon ? ' selected' : '');
}
idx++;
}
}
// Item selector
const iSel = document.getElementById('item-selector');
if (iSel && !iSel.dataset.built) {
iSel.innerHTML = '';
const item = gs.items.item;
const btn = document.createElement('button');
btn.className = 'item-btn';
btn.innerHTML = `${item.icon} ${item.name}`;
btn.title = item.desc;
btn.onclick = () => {
if (gs.usedItems.includes(item.id))
return;
sound_1.sound.playSelect();
applyItem(gs, item);
gs.usedItems.push(item.id);
btn.classList.add('used');
};
iSel.appendChild(btn);
iSel.dataset.built = '1';
}
}
// Move meter
const mmFill = document.getElementById('move-meter-fill');
if (mmFill && unit) {
const pct = (gs.moveMeter / gs.moveMeterMax) * 100;
mmFill.style.width = `${pct}%`;
}
// Aim info
if (unit && unit.alive) {
const angle = getAimAngle(gs, unit);
const pEl = document.getElementById('power-display');
const aEl = document.getElementById('angle-display');
if (pEl)
pEl.textContent = `Power: ${Math.round(gs.powerCharge)}`;
if (aEl)
aEl.textContent = `Angle: ${Math.round(angle)}°`;
}
}
function applyItem(gs, item) {
const unit = gs.units[gs.currentUnitIndex];
if (!unit)
return;
switch (item.id) {
case 'parachute':
unit.itemActive = 'parachute';
unit.maxJumps = 99;
unit.itemTimer = 999;
break;
case 'jetpack':
unit.itemActive = 'jetpack';
unit.itemTimer = 3;
break;
case 'shield':
unit.invincible = true;
unit.invincibleTimer = 5;
break;
case 'speed':
unit.itemActive = 'speed';
unit.itemTimer = gs.turnTimer;
break;
case 'grappling':
unit.itemActive = 'grappling';
unit.itemTimer = 10;
unit.maxJumps = 3;
break;
case 'extraTime':
gs.turnTimer = Math.min(gs.turnTimer + 10, types_1.TURN_TIME);
break;
}
}
};
// ── module: src/ai.ts ──
__mods["src/ai.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resetAI = resetAI;
exports.updateAI = updateAI;
const types_1 = require("./types");
const game_1 = require("./game");
let aiDelay = 0;
let aiState = null;
function resetAI() {
aiState = null;
aiDelay = 0;
}
function updateAI(dt, gs) {
const unit = (0, game_1.getCurrentUnit)(gs);
const team = gs.teams[unit?.teamId ?? 0];
if (!unit || !unit.alive || !team.isCPU)
return null;
if (!aiState || gs.currentUnitIndex !== (aiState._prevIdx ?? -1)) {
aiState = {
action: { action: 'wait' },
timer: 0,
phase: 'assess',
targetUnit: null,
bestAngle: 45,
bestPower: 70,
evaluated: false,
_prevIdx: gs.currentUnitIndex,
};
aiDelay = 0.5 + Math.random() * 0.5; // Initial delay
}
aiState.timer += dt;
// Delay for more natural feel
if (aiDelay > 0) {
aiDelay -= dt;
return aiState.action;
}
// Phase transitions
switch (aiState.phase) {
case 'assess': {
// Find best target and position
const target = findBestTarget(gs, unit);
aiState.targetUnit = target;
if (target) {
const bestShot = evaluateShots(gs, unit, target);
aiState.bestAngle = bestShot.angle;
aiState.bestPower = bestShot.power;
aiState.assessedTarget = { x: target.x, y: target.y, teamId: target.teamId };
}
// Consider moving to better position
const shouldMove = shouldAIMove(gs, unit);
if (shouldMove) {
aiState.phase = 'move';
aiState.action = {
action: 'move',
moveDir: shouldMove,
};
}
else {
aiState.phase = 'aim';
aiState.action = { action: 'wait' };
}
break;
}
case 'move': {
if (aiState.timer > 1.5) {
aiState.phase = 'aim';
aiState.action = { action: 'wait' };
}
break;
}
case 'aim': {
// Start firing after assessment
if (aiState.timer > 0.8) {
aiState.phase = 'fire';
// Maybe use an item first
if (!gs.usedItems.length && Math.random() < 0.3) {
// Consider jetpack or speed boots
const item = gs.items.item;
if (item.id === 'jetpack' || item.id === 'speed') {
aiState.action = { action: 'useItem', itemId: item.id };
gs.usedItems.push(item.id);
break;
}
}
aiState.action = {
action: 'fire',
fireAngle: aiState.bestAngle,
firePower: aiState.bestPower,
};
}
break;
}
case 'fire': {
// After firing, go idle
aiState.phase = 'idle';
aiState.action = { action: 'wait' };
break;
}
}
// Missile steering
if (gs.selectedWeapon === 'missile') {
for (const p of gs.projectiles) {
if (p.weaponId === 'missile' && p.ownerTeamId === unit.teamId && p.alive) {
if (aiState.targetUnit && aiState.targetUnit.alive) {
aiState.action = {
action: 'steerMissile',
mouseX: aiState.targetUnit.x,
mouseY: aiState.targetUnit.y,
};
}
}
}
}
return aiState.action;
}
function findBestTarget(gs, unit) {
let bestTarget = null;
let bestScore = -Infinity;
for (const other of gs.units) {
if (!other.alive || other.teamId === unit.teamId)
continue;
const dx = other.x - unit.x;
const dy = (other.y - 15) - (unit.y - 15);
const dist = Math.sqrt(dx * dx + dy * dy);
// Score: closer is better, facing matters
let score = 1000 - dist * 0.5;
// Prefer targets that are in cover (harder to hit = more valuable to remove)
if (!isInCover(gs, other))
score += 30;
// Prefer low HP targets (easier to eliminate)
const finalScore = score + (100 - other.hp) * 0.5;
if (finalScore > bestScore) {
bestScore = finalScore;
bestTarget = other;
}
}
return bestTarget;
}
function isInCover(gs, unit) {
// Check if terrain blocks the shot
const surfaceY = (0, game_1.getTerrainSurfaceY)(gs, unit.x);
return unit.y + types_1.UNIT_RADIUS >= surfaceY - 5;
}
function evaluateShots(gs, shooter, target) {
let best = { angle: 45, power: 70, score: -Infinity };
const targetX = target.x;
const targetY = target.y - 15;
const startX = shooter.x;
const startY = shooter.y - 15;
// Test multiple angle/power combinations
for (let angle = 15; angle <= 85; angle += 5) {
for (let power = 20; power <= 100; power += 5) {
const result = simulateProjectile(startX, startY, angle, power, gs.wind);
if (result) {
const dx = result.x - targetX;
const dy = result.y - targetY;
const dist = Math.sqrt(dx * dx + dy * dy);
let evalScore = 1000 - dist;
// Prefer shots that actually hit terrain near target
if (result.hitTerrain)
evalScore += 50;
if (evalScore > best.score) {
best = { angle, power, score: evalScore };
}
}
}
}
// Add some randomness for variety
best.angle += (Math.random() - 0.5) * 8;
best.power += (Math.random() - 0.5) * 10;
best.angle = Math.max(5, Math.min(90, best.angle));
best.power = Math.max(10, Math.min(100, best.power));
return best;
}
function simulateProjectile(x, y, angle, power, wind) {
const rad = -angle * Math.PI / 180;
const speed = power * 4;
let vx = Math.cos(rad) * speed;
let vy = Math.sin(rad) * speed;
const dt = 0.05;
for (let i = 0; i < 100; i++) {
vx += wind * dt * 0.1;
vy += types_1.GRAVITY * dt;
x += vx * dt;
y += vy * dt;
if (x < 0 || x > types_1.WORLD_W)
return null;
if (y > types_1.WORLD_H)
return { x, y: types_1.WORLD_H, hitTerrain: true };
// Check terrain (simplified - would need terrain access for accuracy)
// For now, just return trajectory endpoint
if (vy > 0 && i > 10) {
return { x, y, hitTerrain: true };
}
}
return { x, y, hitTerrain: false };
}
function shouldAIMove(gs, unit) {
const surfaceY = (0, game_1.getTerrainSurfaceY)(gs, unit.x);
const onGround = unit.y + types_1.UNIT_RADIUS >= surfaceY - 5;
if (!onGround)
return 0;
// Check if near edge
const leftSafe = isSafePosition(gs, unit.x - 50);
const rightSafe = isSafePosition(gs, unit.x + 50);
if (!leftSafe && !rightSafe)
return 0; // Nowhere safe to go
// If near left edge, move right
if (!leftSafe && rightSafe)
return 1;
// If near right edge, move left
if (rightSafe === false && !leftSafe === false)
return -1;
// Avoid water
if (gs.hasWater && unit.y + types_1.UNIT_RADIUS > gs.waterLevel - 20) {
// Move to higher ground
const leftHeight = (0, game_1.getTerrainSurfaceY)(gs, unit.x - 100);
const rightHeight = (0, game_1.getTerrainSurfaceY)(gs, unit.x + 100);
if (leftHeight < rightHeight)
return -1;
return 1;
}
return 0;
}
function isSafePosition(gs, x) {
const surfaceY = (0, game_1.getTerrainSurfaceY)(gs, x);
if (surfaceY > types_1.WORLD_H - 20)
return false; // Edge of world
if (gs.hasWater && surfaceY > gs.waterLevel)
return false;
return true;
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-MTP-GGUF","provider":"brigid","persona":"typescript","sessionId":"2d3e6717-cf75-4719-862c-cbc844418483","tokensIn":4001844,"tokensOut":66407,"tokensTotal":4068251,"cost":0,"turns":73,"toolCalls":72,"failedToolCalls":2,"timestamp":"2026-08-02T12:28:09.962Z"} -->