Worms
thinkingcap qwen3.6 27b · 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 121,868 bytes · SHA-256 236e01c11c94
<!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; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #0a0a1a;
font-family: Arial, sans-serif;
}
#app {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
image-rendering: pixelated;
cursor: crosshair;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./game":"src/game.ts","./rendering":"src/rendering.ts"},"src/terrain.ts":{"./types":"src/types.ts"},"src/physics.ts":{"./types":"src/types.ts","./terrain":"src/terrain.ts"},"src/weapons.ts":{"./types":"src/types.ts"},"src/particles.ts":{"./types":"src/types.ts"},"src/ai.ts":{"./types":"src/types.ts","./physics":"src/physics.ts","./terrain":"src/terrain.ts","./weapons":"src/weapons.ts"},"src/camera.ts":{"./types":"src/types.ts"},"src/game.ts":{"./types":"src/types.ts","./terrain":"src/terrain.ts","./physics":"src/physics.ts","./weapons":"src/weapons.ts","./particles":"src/particles.ts","./sounds":"src/sounds.ts","./ai":"src/ai.ts","./camera":"src/camera.ts"},"src/rendering.ts":{"./game":"src/game.ts","./types":"src/types.ts","./weapons":"src/weapons.ts","./physics":"src/physics.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 game_1 = require("./game");
const rendering_1 = require("./rendering");
let game;
let renderer;
let lastTime = 0;
let animFrameId;
function start() {
// Setup canvas
const app = document.getElementById('app');
if (!app)
return;
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
canvas.style.display = 'block';
app.appendChild(canvas);
// Initialize game
game = new game_1.Game();
game.canvas = canvas;
game.ctx = canvas.getContext('2d');
renderer = new rendering_1.Renderer(game);
game.init();
// Handle clicks on canvas
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
if (game.phase === 'menu') {
renderer.handleMenuClick(mx, my);
}
else if (game.phase === 'gameOver') {
renderer.handleGameOverClick(mx, my);
}
else if (game.phase === 'weaponSelect') {
renderer.handleWeaponPanelClick(mx, my);
}
});
// Handle scroll for power adjustment
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
renderer.handlePowerScroll(e.deltaY);
}, { passive: false });
// Start game loop
requestAnimationFrame(gameLoop);
}
function gameLoop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 16.67, 3); // Cap at ~3 frames worth of physics
lastTime = timestamp;
// Update
game.update(dt);
// Render
renderer.render();
animFrameId = requestAnimationFrame(gameLoop);
}
// Auto-start when loaded
start();
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// Core type definitions for Worms Artillery Battle
Object.defineProperty(exports, "__esModule", { value: true });
exports.WORM_NAMES = exports.TEAM_NAMES = exports.TEAM_COLORS = void 0;
exports.TEAM_COLORS = ['#FF4444', '#44AAFF', '#44FF44', '#FFAA00'];
exports.TEAM_NAMES = ['Red Team', 'Blue Team', 'Green Team', 'Orange Team'];
exports.WORM_NAMES = [
'Sgt. Blast', 'Lt. Boom', 'Cpl. Crunch', 'Pvt. Pop',
'Maj. Mortar', 'Capt. Cannon', 'Gen. Grenade', 'Col. Charge',
'Sgt. Spark', 'Lt. Lightning', 'Cpl. Crash', 'Pvt. Powder',
'Maj. Missile', 'Capt. Cluster', 'Gen. Gunpowder', 'Col. Combustion'
];
};
// ── module: src/terrain.ts ──
__mods["src/terrain.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Terrain = void 0;
// Terrain is stored as a 2D grid: 0 = air, 1+ = solid (with color variation)
class Terrain {
constructor(width, height) {
this.imageData = null;
this.width = width;
this.height = height;
this.grid = new Uint8Array(width * height);
}
get(x, y) {
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
return 1; // edges are solid
return this.grid[y * this.width + x];
}
set(x, y, val) {
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
return;
this.grid[y * this.width + x] = val;
}
isSolid(x, y) {
return this.get(Math.floor(x), Math.floor(y)) > 0;
}
// Generate terrain based on shape type
generate(shape) {
const w = this.width;
const h = this.height;
this.grid.fill(0);
switch (shape) {
case 'island':
this.generateIsland();
break;
case 'cave':
this.generateCave();
break;
case 'twoIslands':
this.generateTwoIslands();
break;
}
// Fill edges as solid border
for (let x = 0; x < w; x++) {
this.set(x, 0, 2);
this.set(x, h - 1, 2);
}
for (let y = 0; y < h; y++) {
this.set(0, y, 2);
this.set(w - 1, y, 2);
}
}
generateIsland() {
const w = this.width;
const h = this.height;
const baseY = Math.floor(h * 0.45);
// Multi-octave noise for terrain height
const heights = [];
for (let x = 0; x < w; x++) {
let val = 0;
val += Math.sin(x * 0.008) * 60;
val += Math.sin(x * 0.02 + 1.3) * 30;
val += Math.sin(x * 0.05 + 2.7) * 15;
val += Math.sin(x * 0.1 + 4.1) * 8;
heights[x] = baseY - val;
}
// Fill terrain below height curve
for (let x = 0; x < w; x++) {
const surfaceY = Math.floor(heights[x]);
for (let y = surfaceY; y < h; y++) {
if (y === surfaceY)
this.set(x, y, 1); // grass layer
else if (y < surfaceY + 8)
this.set(x, y, 3); // dirt
else
this.set(x, y, 4); // rock
}
}
// Smooth terrain edges
for (let pass = 0; pass < 2; pass++) {
const newGrid = new Uint8Array(this.grid);
for (let x = 1; x < w - 1; x++) {
for (let y = 1; y < h - 1; y++) {
if (this.get(x, y) === 0) {
let solidCount = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++)
if (this.get(x + dx, y + dy) > 0)
solidCount++;
if (solidCount >= 5)
newGrid[y * w + x] = this.get(x, y + 1) || 3;
}
}
}
this.grid.set(newGrid);
}
}
generateCave() {
const w = this.width;
const h = this.height;
// Fill everything solid first
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
this.set(x, y, 4);
// Carve out cave using noise threshold
const carveY = Math.floor(h * 0.5);
const caveHeight = Math.floor(h * 0.35);
for (let x = 1; x < w - 1; x++) {
let offset = 0;
offset += Math.sin(x * 0.01) * 40;
offset += Math.sin(x * 0.025 + 2) * 25;
offset += Math.sin(x * 0.06 + 3) * 12;
const topY = carveY - caveHeight / 2 + offset;
const bottomY = carveY + caveHeight / 2 + offset;
for (let y = Math.floor(topY); y < Math.floor(bottomY); y++) {
this.set(x, y, 0);
}
}
// Add some stalactites/stalagmites
for (let x = 5; x < w - 5; x += Math.floor(Math.random() * 30) + 15) {
const len = Math.floor(Math.random() * 40) + 10;
let offset = 0;
offset += Math.sin(x * 0.01) * 40;
offset += Math.sin(x * 0.025 + 2) * 25;
// Stalactite from ceiling
const ceilY = Math.floor(carveY - caveHeight / 2 + offset);
for (let y = ceilY; y < ceilY + len && y < h; y++) {
if (this.get(x, y) === 0)
this.set(x, y, 4);
}
// Stalagmite from floor
const floorY = Math.floor(carveY + caveHeight / 2 + offset);
for (let y = floorY; y > floorY - len && y >= 0; y--) {
if (this.get(x, y) === 0)
this.set(x, y, 4);
}
}
// Smooth
for (let pass = 0; pass < 3; pass++) {
const newGrid = new Uint8Array(this.grid);
for (let x = 1; x < w - 1; x++) {
for (let y = 1; y < h - 1; y++) {
if (this.get(x, y) === 0) {
let solidCount = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++)
if (this.get(x + dx, y + dy) > 0)
solidCount++;
if (solidCount >= 6)
newGrid[y * w + x] = 4;
}
else {
let airCount = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++)
if (this.get(x + dx, y + dy) === 0)
airCount++;
if (airCount >= 6)
newGrid[y * w + x] = 0;
}
}
}
this.grid.set(newGrid);
}
// Add grass on top surfaces
for (let x = 1; x < w - 1; x++) {
for (let y = 1; y < h - 1; y++) {
if (this.get(x, y) === 4 && this.get(x, y - 1) === 0) {
this.set(x, y, 1);
}
}
}
}
generateTwoIslands() {
const w = this.width;
const h = this.height;
// Generate two separate island profiles
for (let x = 0; x < w; x++) {
let val1 = 0, val2 = 0;
// Island 1 (left)
const center1 = w * 0.3;
const dist1 = Math.abs(x - center1);
if (dist1 < w * 0.25) {
val1 += Math.sin((x - center1) * 0.015 + 1) * 40;
val1 += Math.sin((x - center1) * 0.04 + 2) * 20;
val1 *= (1 - dist1 / (w * 0.25)); // taper edges
}
// Island 2 (right)
const center2 = w * 0.7;
const dist2 = Math.abs(x - center2);
if (dist2 < w * 0.25) {
val2 += Math.sin((x - center2) * 0.015 + 3) * 40;
val2 += Math.sin((x - center2) * 0.04 + 4) * 20;
val2 *= (1 - dist2 / (w * 0.25)); // taper edges
}
const baseY = h * 0.5;
const surfaceY = Math.floor(baseY - Math.max(val1, val2));
for (let y = surfaceY; y < h; y++) {
if (y === surfaceY)
this.set(x, y, 1);
else if (y < surfaceY + 8)
this.set(x, y, 3);
else
this.set(x, y, 4);
}
}
// Smooth edges
for (let pass = 0; pass < 2; pass++) {
const newGrid = new Uint8Array(this.grid);
for (let x = 1; x < w - 1; x++) {
for (let y = 1; y < h - 1; y++) {
if (this.get(x, y) === 0) {
let solidCount = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++)
if (this.get(x + dx, y + dy) > 0)
solidCount++;
if (solidCount >= 5)
newGrid[y * w + x] = this.get(x, y + 1) || 3;
}
}
}
this.grid.set(newGrid);
}
}
// Destroy terrain in a circular area - returns destroyed pixels for debris
destroy(cx, cy, radius) {
const debris = [];
const r2 = radius * radius;
for (let dy = -Math.ceil(radius); dy <= Math.ceil(radius); dy++) {
for (let dx = -Math.ceil(radius); dx <= Math.ceil(radius); dx++) {
if (dx * dx + dy * dy > r2)
continue;
const px = Math.floor(cx + dx);
const py = Math.floor(cy + dy);
if (this.get(px, py) > 0) {
debris.push({ x: px, y: py, type: this.get(px, py) });
this.set(px, py, 0);
}
}
}
return debris;
}
// Find surface Y at given X position (for unit placement)
findSurfaceY(x) {
const fx = Math.floor(Math.max(1, Math.min(this.width - 2, x)));
for (let y = 0; y < this.height; y++) {
if (this.get(fx, y) > 0)
return y;
}
return this.height - 1;
}
// Find a safe spot near X position (not in water, on solid ground)
findSafeSpot(x, waterLevel) {
const fx = Math.floor(Math.max(5, Math.min(this.width - 6, x)));
for (let y = 0; y < this.height; y++) {
if (this.get(fx, y) > 0 && y < waterLevel - 2) {
// Check it's a flat surface
let solidLeft = false, solidRight = false;
for (let dy = 1; dy <= 3; dy++) {
if (this.get(fx - 2, y + dy) > 0)
solidLeft = true;
if (this.get(fx + 2, y + dy) > 0)
solidRight = true;
}
if (solidLeft && solidRight)
return { x: fx, y: y - 1 };
}
}
// Fallback: just find any surface above water
for (let y = 0; y < waterLevel - 2; y++) {
if (this.get(fx, y) > 0)
return { x: fx, y: y - 1 };
}
return null;
}
// Get terrain color for rendering with variation
static getTerrainColor(type, x = 0, y = 0) {
const noise = ((x * 7 + y * 13) % 20) - 10;
switch (type) {
case 1: return `rgb(${60 + noise},${140 + noise},${55 + noise})`; // grass with variation
case 2: return '#333333'; // border
case 3: return `rgb(${139 + noise},${105 + noise},${20 + noise / 2})`; // dirt
case 4: return `rgb(${100 + noise},${100 + noise},${100 + noise})`; // rock
default: return 'transparent';
}
}
// Render terrain to canvas context at given scale
render(ctx, scale) {
const w = this.width;
const h = this.height;
for (let y = 0; y < h; y++) {
let currentType = -1;
let startX = 0;
for (let x = 0; x <= w; x++) {
const type = x < w ? this.get(x, y) : -1;
if (type !== currentType) {
if (currentType > 0 && x - startX >= 2) {
ctx.fillStyle = Terrain.getTerrainColor(currentType, Math.floor((startX + x) / 2), y);
ctx.fillRect(startX * scale, y * scale, (x - startX) * scale, scale);
}
currentType = type;
startX = x;
}
}
}
}
// Apply gravity to loose terrain pixels after destruction
settleGravity() {
const w = this.width;
const h = this.height;
let changed = true;
while (changed) {
changed = false;
for (let y = h - 2; y >= 0; y--) {
for (let x = 1; x < w - 1; x++) {
const val = this.get(x, y);
if (val > 0 && this.get(x, y + 1) === 0) {
// Check if supported on sides
const leftSupport = this.get(x - 1, y) > 0 || this.get(x - 1, y + 1) > 0;
const rightSupport = this.get(x + 1, y) > 0 || this.get(x + 1, y + 1) > 0;
if (!leftSupport && !rightSupport) {
this.set(x, y + 1, val);
this.set(x, y, 0);
changed = true;
}
else if (val !== 4) { // rock doesn't fall as easily
const leftSolid = this.get(x - 1, y) > 0;
const rightSolid = this.get(x + 1, y) > 0;
if (!leftSolid && !rightSolid) {
this.set(x, y + 1, val);
this.set(x, y, 0);
changed = true;
}
}
}
}
}
}
}
}
exports.Terrain = Terrain;
};
// ── module: src/physics.ts ──
__mods["src/physics.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Physics = exports.FALL_DAMAGE_FACTOR = exports.MAX_FALL_DAMAGE_HEIGHT = exports.MOVE_SPEED = exports.JUMP_FORCE = exports.UNIT_RADIUS = exports.GRAVITY = void 0;
exports.GRAVITY = 0.35;
exports.UNIT_RADIUS = 6;
exports.JUMP_FORCE = -7;
exports.MOVE_SPEED = 2.5;
exports.MAX_FALL_DAMAGE_HEIGHT = 120; // pixels of fall before damage starts
exports.FALL_DAMAGE_FACTOR = 0.4;
class Physics {
constructor(terrain) {
this.terrain = terrain;
}
updateUnit(unit, dt, wind, waterLevel) {
if (!unit.alive)
return false;
// Apply gravity
unit.vel.y += exports.GRAVITY * dt;
// Wind effect on airborne units (slight push)
if (!unit.onGround) {
unit.vel.x += wind * 0.01 * dt;
}
// Move X
let newX = unit.pos.x + unit.vel.x * dt;
const px = Math.floor(newX);
const py = Math.floor(unit.pos.y);
if (this.terrain.isSolid(px, py)) {
// Try to slide along wall
for (let step = 1; step <= Math.abs(Math.round(unit.vel.x * dt)); step++) {
const testX = unit.pos.x + Math.sign(unit.vel.x) * step;
if (!this.terrain.isSolid(Math.floor(testX), py)) {
newX = testX;
break;
}
}
unit.vel.x = 0;
}
// Move Y
let newY = unit.pos.y + unit.vel.y * dt;
const newPx = Math.floor(newX);
const newPy = Math.floor(newY);
if (this.terrain.isSolid(newPx, newPy)) {
if (unit.vel.y > 0) {
// Landing - check for fall damage
const fallDist = unit.pos.y - unit.fallStartY;
if (fallDist > exports.MAX_FALL_DAMAGE_HEIGHT && !unit.inWater) {
const dmg = Math.floor((fallDist - exports.MAX_FALL_DAMAGE_HEIGHT) * exports.FALL_DAMAGE_FACTOR);
unit.hp -= dmg;
if (dmg > 0)
return true; // had fall damage
}
// Snap to surface
while (this.terrain.isSolid(newPx, Math.floor(newY)) && newY > 0) {
newY--;
}
}
else {
// Hit ceiling
while (this.terrain.isSolid(newPx, Math.floor(newY)) && newY < this.terrain.height - 1) {
newY++;
}
}
unit.vel.y = 0;
unit.onGround = true;
unit.jumpCount = 0;
}
else {
if (unit.onGround && unit.vel.y > 0) {
unit.fallStartY = unit.pos.y;
}
unit.onGround = false;
}
// Water check
const wasInWater = unit.inWater;
unit.inWater = newY >= waterLevel - exports.UNIT_RADIUS;
if (unit.inWater && !wasInWater) {
// Just entered water - slow down
unit.vel.y *= 0.3;
}
if (!unit.onGround && !this.terrain.isSolid(newPx, newPy)) {
// Check if floating on water
if (newY >= waterLevel - exports.UNIT_RADIUS) {
newY = waterLevel - exports.UNIT_RADIUS - 1;
unit.vel.y = 0;
unit.onGround = true;
unit.jumpCount = 0;
}
}
unit.pos.x = newX;
unit.pos.y = newY;
// Keep in bounds
unit.pos.x = Math.max(exports.UNIT_RADIUS, Math.min(this.terrain.width - exports.UNIT_RADIUS, unit.pos.x));
unit.pos.y = Math.max(exports.UNIT_RADIUS, Math.min(this.terrain.height - exports.UNIT_RADIUS, unit.pos.y));
return false;
}
updateProjectile(proj, dt) {
if (!proj.alive)
return false;
proj.vel.y += proj.gravity * dt;
const newX = proj.pos.x + proj.vel.x * dt;
const newY = proj.pos.y + proj.vel.y * dt;
// Trail
proj.trail.push({ x: proj.pos.x, y: proj.pos.y });
if (proj.trail.length > 20)
proj.trail.shift();
// Terrain collision
if (this.terrain.isSolid(Math.floor(newX), Math.floor(newY))) {
proj.pos.x = newX;
proj.pos.y = newY;
return true; // hit terrain
}
// Out of bounds
if (newX < 0 || newX >= this.terrain.width || newY < 0 || newY >= this.terrain.height) {
return true; // out of bounds - explode anyway
}
proj.pos.x = newX;
proj.pos.y = newY;
return false;
}
checkUnitCollision(unit, proj) {
if (!unit.alive || !proj.alive)
return false;
const dx = unit.pos.x - proj.pos.x;
const dy = unit.pos.y - proj.pos.y;
return (dx * dx + dy * dy) < (exports.UNIT_RADIUS + 3) * (exports.UNIT_RADIUS + 3);
}
// Check if position is safe from environmental hazards
assessRisk(x, y, waterLevel) {
const risks = [];
let totalRisk = 0;
// Water proximity
const distToWater = Math.max(0, waterLevel - y);
if (distToWater < 30) {
totalRisk += (1 - distToWater / 30) * 50;
risks.push('near_water');
}
// Cliff edge check
const fx = Math.floor(x);
let groundBelow = false;
for (let dy = 1; dy <= 20; dy++) {
if (this.terrain.isSolid(fx, Math.floor(y) + dy)) {
groundBelow = true;
break;
}
}
if (!groundBelow) {
totalRisk += 40;
risks.push('no_ground');
}
// Check for overhangs (cave danger)
let airAbove = false;
for (let dy = -1; dy >= -15; dy--) {
if (!this.terrain.isSolid(fx, Math.floor(y) + dy)) {
airAbove = true;
break;
}
}
return { risk: totalRisk, reasons: risks };
}
}
exports.Physics = Physics;
};
// ── module: src/weapons.ts ──
__mods["src/weapons.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WEAPONS = void 0;
exports.getWeaponById = getWeaponById;
exports.createAmmoPool = createAmmoPool;
exports.WEAPONS = [
{
id: 'rocket', name: 'Rocket', icon: '🚀', category: 'indirect',
damage: 35, aoeRadius: 25, power: 10, ammo: 4,
description: 'Classic rocket with moderate AoE'
},
{
id: 'dynamite', name: 'Dynamite', icon: '🧨', category: 'indirect',
damage: 50, aoeRadius: 30, power: 8, ammo: 2,
description: 'Timed explosive - click to detonate'
},
{
id: 'homing_missile', name: 'Homing Missile', icon: '🎯', category: 'indirect',
damage: 40, aoeRadius: 20, power: 9, ammo: 2,
description: 'Locks onto nearest enemy'
},
{
id: 'minigun', name: 'Minigun', icon: '🔫', category: 'direct',
damage: 3, aoeRadius: 5, power: 14, ammo: 60,
description: 'Rapid fire direct hits'
},
{
id: 'super_size', name: 'Super Size', icon: '💊', category: 'direct',
damage: 0, aoeRadius: 0, power: 0, ammo: 1,
description: 'Grow to giant size (trample enemies!)'
},
{
id: 'cluster_bomb', name: 'Cluster Bomb', icon: '💣', category: 'aoe',
damage: 25, aoeRadius: 35, power: 11, ammo: 1,
description: 'Splits into sub-munitions mid-air'
},
{
id: 'quake_grenade', name: 'Quake Grenade', icon: '🌋', category: 'aoe',
damage: 20, aoeRadius: 60, power: 5, ammo: 1,
description: 'Massive AoE terrain destruction'
},
{
id: 'bananapower', name: 'Banana Power', icon: '🍌', category: 'direct',
damage: 0, aoeRadius: 0, power: 0, ammo: 1,
description: '+50% damage for this turn'
},
];
function getWeaponById(id) {
return exports.WEAPONS.find(w => w.id === id);
}
function createAmmoPool() {
const pool = new Map();
for (const w of exports.WEAPONS)
pool.set(w.id, w.ammo);
return pool;
}
};
// ── module: src/particles.ts ──
__mods["src/particles.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ParticleSystem = void 0;
class ParticleSystem {
constructor() {
this.particles = [];
this.floatingTexts = [];
}
update(dt) {
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.type === 'debris' || p.type === 'spark') {
p.vy += 0.2 * dt; // gravity on debris
}
p.life -= dt;
}
this.particles = this.particles.filter(p => p.life > 0);
for (const ft of this.floatingTexts) {
ft.y += ft.vy * dt;
ft.life -= dt;
}
this.floatingTexts = this.floatingTexts.filter(ft => ft.life > 0);
}
spawnExplosion(x, y, radius) {
const count = Math.floor(radius * 1.5);
// Fire particles
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * radius * 0.3 + 1;
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 1,
life: 30 + Math.random() * 20,
maxLife: 50,
color: ['#FF4400', '#FF8800', '#FFCC00', '#FFFF00'][Math.floor(Math.random() * 4)],
size: Math.random() * radius * 0.15 + 2,
type: 'explosion'
});
}
// Smoke particles
for (let i = 0; i < count / 3; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * radius * 0.15 + 0.5;
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: -Math.random() * 2 - 0.5,
life: 40 + Math.random() * 30,
maxLife: 70,
color: `rgba(80,80,80,${0.3 + Math.random() * 0.4})`,
size: Math.random() * radius * 0.2 + 5,
type: 'smoke'
});
}
// Sparks
for (let i = 0; i < count / 2; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * radius * 0.5 + 3;
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 2,
life: 15 + Math.random() * 15,
maxLife: 30,
color: '#FFFFFF',
size: 1 + Math.random() * 2,
type: 'spark'
});
}
}
spawnDebris(x, y, count) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 4 + 1;
this.particles.push({
x: x + (Math.random() - 0.5) * 10,
y: y + (Math.random() - 0.5) * 10,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 3,
life: 40 + Math.random() * 20,
maxLife: 60,
color: ['#8B6914', '#666666', '#5a4a2a'][Math.floor(Math.random() * 3)],
size: 2 + Math.random() * 4,
type: 'debris'
});
}
}
spawnDust(x, y) {
for (let i = 0; i < 8; i++) {
this.particles.push({
x: x + (Math.random() - 0.5) * 10,
y,
vx: (Math.random() - 0.5) * 2,
vy: -Math.random() * 1.5,
life: 20 + Math.random() * 10,
maxLife: 30,
color: 'rgba(180,160,120,0.6)',
size: 2 + Math.random() * 3,
type: 'dust'
});
}
}
spawnWaterSplash(x, y) {
for (let i = 0; i < 15; i++) {
const angle = -Math.PI + Math.random() * Math.PI; // upward arc
const speed = Math.random() * 4 + 2;
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 20 + Math.random() * 15,
maxLife: 35,
color: '#4488FF',
size: 2 + Math.random() * 3,
type: 'water'
});
}
}
addFloatingText(x, y, text, color = '#FFFFFF', size = 16) {
this.floatingTexts.push({
x, y, text,
life: 60, maxLife: 60,
color, size,
vy: -1.5
});
}
get particlesList() { return this.particles; }
get floatingTextsList() { return this.floatingTexts; }
render(ctx) {
// Render particles
for (const p of this.particles) {
const alpha = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = alpha;
if (p.type === 'smoke') {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (1 + (1 - alpha) * 0.5), 0, Math.PI * 2);
ctx.fill();
}
else if (p.type === 'spark') {
ctx.fillStyle = p.color;
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
}
else {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
// Render floating texts
for (const ft of this.floatingTexts) {
const alpha = Math.max(0, ft.life / ft.maxLife);
ctx.globalAlpha = alpha;
ctx.font = `bold ${ft.size}px Arial`;
ctx.textAlign = 'center';
// Shadow
ctx.fillStyle = '#000';
ctx.fillText(ft.text, ft.x + 1, ft.y + 1);
// Text
ctx.fillStyle = ft.color;
ctx.fillText(ft.text, ft.x, ft.y);
}
ctx.globalAlpha = 1;
}
}
exports.ParticleSystem = ParticleSystem;
};
// ── module: src/sounds.ts ──
__mods["src/sounds.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SoundSystem = void 0;
// Procedural sound effects using Web Audio API
class SoundSystem {
constructor() {
this.ctx = null;
this.masterGain = null;
}
init() {
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.3;
this.masterGain.connect(this.ctx.destination);
}
catch (e) {
console.warn('Web Audio not available');
}
}
resume() {
if (this.ctx?.state === 'suspended')
this.ctx.resume();
}
createOsc(type, freq, duration, volume = 0.3) {
if (!this.ctx || !this.masterGain)
return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
createNoise(duration, volume = 0.2) {
if (!this.ctx || !this.masterGain)
return;
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * volume;
}
const source = this.ctx.createBufferSource();
source.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration);
// Low-pass filter for explosion sound
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2000, this.ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(100, this.ctx.currentTime + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
source.start();
}
playExplosion(size) {
const dur = size === 'large' ? 0.6 : size === 'medium' ? 0.4 : 0.2;
this.createNoise(dur, size === 'large' ? 0.5 : 0.3);
// Add a low boom
this.createOsc('sine', size === 'large' ? 60 : 100, dur * 0.8, 0.4);
if (size !== 'small') {
setTimeout(() => this.createNoise(dur * 0.5, 0.2), 50);
}
}
playLaunch() {
if (!this.ctx || !this.masterGain)
return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(300, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(150, this.ctx.currentTime + 0.3);
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.3);
}
playHit() {
this.createOsc('square', 200, 0.1, 0.2);
this.createNoise(0.08, 0.15);
}
playJump() {
if (!this.ctx || !this.masterGain)
return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(300, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(500, this.ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.15);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.15);
}
playMenuClick() {
this.createOsc('sine', 800, 0.05, 0.2);
}
playMenuHover() {
this.createOsc('sine', 600, 0.03, 0.1);
}
playTurnStart() {
if (!this.ctx || !this.masterGain)
return;
const notes = [523, 659, 784]; // C E G
notes.forEach((freq, i) => {
setTimeout(() => this.createOsc('sine', freq, 0.15, 0.2), i * 80);
});
}
playDeath() {
if (!this.ctx || !this.masterGain)
return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(400, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(50, this.ctx.currentTime + 0.8);
gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.8);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.8);
}
playWaterSplash() {
this.createNoise(0.3, 0.2);
this.createOsc('sine', 400, 0.15, 0.1);
}
playTick() {
this.createOsc('square', 1000, 0.02, 0.1);
}
playPowerUp() {
if (!this.ctx || !this.masterGain)
return;
const notes = [400, 500, 600, 800];
notes.forEach((freq, i) => {
setTimeout(() => this.createOsc('sine', freq, 0.1, 0.2), i * 60);
});
}
playDamage(amount) {
const freq = Math.max(100, 400 - amount * 5);
this.createOsc('square', freq, 0.1, Math.min(0.3, amount * 0.01));
}
// Ticking sound for turn timer urgency
playUrgentTick(intensity) {
const freq = 800 + intensity * 400;
this.createOsc('square', freq, 0.03, 0.15);
}
}
exports.SoundSystem = SoundSystem;
};
// ── module: src/ai.ts ──
__mods["src/ai.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AIController = void 0;
const weapons_1 = require("./weapons");
class AIController {
constructor(physics, terrain, difficulty) {
this.physics = physics;
this.terrain = terrain;
this.difficulty = difficulty;
this.thinkTimer = 0;
this.actionQueue = [];
this.isThinking = false;
}
get accuracy() {
switch (this.difficulty) {
case 'easy': return 0.5;
case 'medium': return 0.7;
case 'hard': return 0.9;
}
}
get reactionTime() {
switch (this.difficulty) {
case 'easy': return 60;
case 'medium': return 40;
case 'hard': return 25;
}
}
// Main AI decision making for a turn
async executeTurn(unit, teammates, enemies, ammoPool, wind, waterLevel, onAction) {
if (!unit.alive || this.isThinking)
return;
this.isThinking = true;
await this.delay(this.reactionTime);
// Phase 1: Assess situation and move to safe position
const risk = this.physics.assessRisk(unit.pos.x, unit.pos.y, waterLevel);
if (risk.risk > 30) {
await this.moveToSafeSpot(unit, teammates, ammoPool, wind, waterLevel, onAction);
}
await this.delay(this.reactionTime * 0.5);
// Phase 2: Select weapon and target
const weaponChoice = this.selectWeapon(enemies, unit, ammoPool, wind);
if (weaponChoice) {
onAction('selectWeapon', weaponChoice.id);
await this.delay(this.reactionTime * 0.3);
// Phase 3: Aim and fire
const target = this.selectTarget(enemies, weaponChoice, unit);
if (target) {
const aimData = this.calculateAim(unit, target, weaponChoice, wind);
onAction('aim', aimData);
await this.delay(this.reactionTime * 0.5);
// Dynamite: detonate manually
if (weaponChoice.id === 'dynamite') {
await this.delay(30 + Math.random() * 20);
onAction('detonate');
}
else {
onAction('fire');
}
}
}
// Phase 4: Use utility items if available
if (unit.hp < unit.maxHp * 0.3) {
const hasBanana = ammoPool.get('bananapower') || 0 > 0;
if (hasBanana && enemies.length > 0) {
onAction('selectWeapon', 'bananapower');
await this.delay(20);
onAction('fire');
}
}
// Phase 5: End turn
onAction('endTurn');
this.isThinking = false;
}
selectTarget(enemies, weapon, shooterUnit) {
if (enemies.length === 0)
return null;
// Prioritize low HP targets for finishing blows
const aliveEnemies = enemies.filter(e => e.alive);
if (aliveEnemies.length === 0)
return null;
// Hard AI: target lowest HP enemy
if (this.difficulty === 'hard') {
return [...aliveEnemies].sort((a, b) => a.hp - b.hp)[0];
}
// Medium/easy: random with slight preference for closest
const refPos = shooterUnit.pos;
const weights = aliveEnemies.map(e => 1 / Math.max(1, this.distanceTo(refPos, e.pos)));
const totalWeight = weights.reduce((s, w) => s + w, 0);
let r = Math.random() * totalWeight;
for (let i = 0; i < aliveEnemies.length; i++) {
r -= weights[i];
if (r <= 0)
return aliveEnemies[i];
}
return aliveEnemies[0];
}
selectWeapon(enemies, unit, ammoPool, wind) {
const aliveEnemies = enemies.filter(e => e.alive);
if (aliveEnemies.length === 0)
return null;
// Score each weapon
let bestWeapon = null;
let bestScore = -Infinity;
for (const w of weapons_1.WEAPONS) {
const ammoLeft = ammoPool.get(w.id) || 0;
if (ammoLeft <= 0)
continue;
// Skip utility weapons unless needed
if (w.damage === 0 && unit.hp >= unit.maxHp * 0.3)
continue;
let score = 0;
// Distance-based scoring
const minDist = Math.min(...aliveEnemies.map(e => this.distanceTo(unit.pos, e.pos)));
if (w.category === 'direct' && w.id !== 'super_size') {
score = minDist < 150 ? 80 : 20;
}
else if (w.category === 'indirect') {
score = minDist > 50 ? 70 : 30;
}
else if (w.category === 'aoe') {
// AoE is good when enemies are clustered
const avgDist = aliveEnemies.reduce((s, e) => s + this.distanceTo(unit.pos, e.pos), 0) / aliveEnemies.length;
score = avgDist < 200 ? 90 : 40;
}
// Wind penalty for indirect weapons
if (w.category === 'indirect' && Math.abs(wind) > 3) {
score -= Math.abs(wind) * 5;
}
// Accuracy factor
score *= this.accuracy;
if (score > bestScore) {
bestScore = score;
bestWeapon = w;
}
}
return bestWeapon;
}
calculateAim(shooter, target, weapon, wind) {
const dx = target.pos.x - shooter.pos.x;
const dy = target.pos.y - shooter.pos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
let angle, power;
if (weapon.category === 'direct') {
// Direct fire: aim straight at target
angle = Math.atan2(dy, dx);
power = weapon.power || 14;
}
else {
// Indirect/aoe: parabolic trajectory
// Calculate launch angle for given distance
const g = 0.35;
const v = weapon.power * (0.8 + Math.random() * 0.4);
// Ideal angle for maximum range is 45 degrees
let idealAngle = Math.PI / 4;
if (dist > 0) {
// Adjust based on distance and height difference
const heightDiff = -dy; // positive means target is above shooter
idealAngle = Math.atan2(v * v - Math.sqrt(Math.max(1, v * v * v * v - g * (g * dist * dist + 2 * heightDiff * v * v))), g * dist);
// Clamp to reasonable range
idealAngle = Math.max(0.3, Math.min(1.4, idealAngle));
}
angle = dx >= 0 ? -idealAngle : -(Math.PI - idealAngle);
// Wind compensation
const windCompensation = wind * 0.02;
angle += windCompensation;
power = v / weapon.power;
}
// Add inaccuracy based on difficulty
const inaccuracy = (1 - this.accuracy) * 0.3;
angle += (Math.random() - 0.5) * inaccuracy;
return {
angle: Math.max(-Math.PI / 2, Math.min(0, angle)), // Only upward angles
power: Math.max(0.3, Math.min(1, power))
};
}
async moveToSafeSpot(unit, teammates, ammoPool, wind, waterLevel, onAction) {
// Find a safer position nearby
const currentRisk = this.physics.assessRisk(unit.pos.x, unit.pos.y, waterLevel).risk;
const directions = [-1, 1];
for (const dir of directions.sort(() => Math.random() - 0.5)) {
const targetX = unit.pos.x + dir * (30 + Math.random() * 40);
const newRisk = this.physics.assessRisk(targetX, unit.pos.y, waterLevel).risk;
if (newRisk < currentRisk) {
// Move toward safer spot
onAction('move', dir > 0 ? 'right' : 'left');
await this.delay(20);
// Jump if needed
const surfaceY = this.terrain.findSurfaceY(targetX);
if (surfaceY < unit.pos.y - 10) {
onAction('jump');
await this.delay(15);
}
}
}
}
distanceTo(a, b) {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}
unitPos() {
// Placeholder - will be set by caller
return { x: 0, y: 0 };
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
exports.AIController = AIController;
};
// ── module: src/camera.ts ──
__mods["src/camera.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Camera = void 0;
class Camera {
constructor(canvasWidth, canvasHeight) {
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
this.x = 0;
this.y = 0;
this.zoom = 1;
this.targetX = 0;
this.targetY = 0;
this.targetZoom = 1;
this.shakeAmount = 0;
this.shakeDecay = 0.9;
}
follow(targetX, targetY, mapW, mapH) {
this.targetX = targetX;
this.targetY = targetY;
}
setZoom(zoom) {
this.targetZoom = Math.max(0.3, Math.min(2, zoom));
}
addShake(amount) {
this.shakeAmount = Math.min(this.shakeAmount + amount, 20);
}
update(smoothing = 0.08) {
// Smooth follow
this.x += (this.targetX - this.x) * smoothing;
this.y += (this.targetY - this.y) * smoothing;
// Smooth zoom
this.zoom += (this.targetZoom - this.zoom) * smoothing * 0.5;
// Screen shake
if (this.shakeAmount > 0.1) {
this.x += (Math.random() - 0.5) * this.shakeAmount;
this.y += (Math.random() - 0.5) * this.shakeAmount;
this.shakeAmount *= this.shakeDecay;
}
else {
this.shakeAmount = 0;
}
}
// Get transform for canvas context
getTransform() {
return {
tx: this.canvasWidth / 2 - this.x * this.zoom,
ty: this.canvasHeight / 2 - this.y * this.zoom,
scale: this.zoom
};
}
// Convert screen coords to world coords
screenToWorld(sx, sy) {
const t = this.getTransform();
return {
x: (sx - t.tx) / t.scale,
y: (sy - t.ty) / t.scale
};
}
// Convert world coords to screen coords
worldToScreen(wx, wy) {
const t = this.getTransform();
return {
x: wx * t.scale + t.tx,
y: wy * t.scale + t.ty
};
}
reset() {
this.x = 0;
this.y = 0;
this.zoom = 1;
this.targetX = 0;
this.targetY = 0;
this.targetZoom = 1;
this.shakeAmount = 0;
}
}
exports.Camera = Camera;
};
// ── module: src/game.ts ──
__mods["src/game.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Game = void 0;
const types_1 = require("./types");
const terrain_1 = require("./terrain");
const physics_1 = require("./physics");
const weapons_1 = require("./weapons");
const particles_1 = require("./particles");
const sounds_1 = require("./sounds");
const ai_1 = require("./ai");
const camera_1 = require("./camera");
class Game {
constructor() {
// Game state
this.units = [];
this.currentTeamIndex = 0;
this.turnTime = 30;
this.maxTurnTime = 30;
this.wind = 0;
this.phase = 'menu';
this.mapWidth = 1200;
this.mapHeight = 600;
this.waterLevel = 580;
// Turn state
this.selectedWeaponIndex = 0;
this.aimAngle = -Math.PI / 4;
this.aimPower = 0.7;
this.turnCount = 0;
// Projectiles & effects
this.projectiles = [];
this.explosionEffects = [];
this.impactFreezeTimer = 0;
this.turnAnnounceTimer = 0;
// Team config
this.teamCount = 2;
this.cpuTeams = new Set();
this.terrainShape = 'island';
this.difficulty = 'medium';
// Ammo pools per team
this.ammoPools = new Map();
// Banana power bonus
this.bananaBonus = new Map();
// AI controllers
this.aiControllers = new Map();
// Input state
this.keysDown = new Set();
this.mousePos = { x: 0, y: 0 };
this.mouseWorld = { x: 0, y: 0 };
this.mouseClicked = false;
this.mouseRightClicked = false;
this.terrain = new terrain_1.Terrain(this.mapWidth, this.mapHeight);
this.physics = new physics_1.Physics(this.terrain);
this.particles = new particles_1.ParticleSystem();
this.sounds = new sounds_1.SoundSystem();
this.camera = new camera_1.Camera(1280, 720);
}
init() {
this.sounds.init();
this.setupInput();
}
startGame(teamCount, cpuTeams, shape, difficulty) {
this.teamCount = teamCount;
this.cpuTeams = cpuTeams;
this.terrainShape = shape;
this.difficulty = difficulty;
// Generate terrain
this.terrain.generate(shape);
// Place units
this.placeUnits();
// Create ammo pools
for (let i = 0; i < teamCount; i++) {
this.ammoPools.set(i, (0, weapons_1.createAmmoPool)());
this.bananaBonus.set(i, 1);
if (cpuTeams.has(i)) {
const ai = new ai_1.AIController(this.physics, this.terrain, this.difficulty);
this.aiControllers.set(i, ai);
}
}
// Random initial wind
this.wind = (Math.random() - 0.5) * 6;
this.phase = 'playing';
this.turnCount = 0;
this.currentTeamIndex = 0;
this.startTurn();
}
placeUnits() {
const positions = [];
const teamSpacing = this.mapWidth / (this.teamCount + 1);
for (let t = 0; t < this.teamCount; t++) {
const centerX = teamSpacing * (t + 1);
for (let u = 0; u < 4; u++) {
const offsetX = (u - 1.5) * 25;
const spotX = centerX + offsetX;
const spot = this.terrain.findSafeSpot(spotX, this.waterLevel);
if (spot) {
positions.push(spot);
this.units.push({
id: `team${t}_unit${u}`,
teamId: t,
name: types_1.WORM_NAMES[t * 4 + u] || `Worm ${u + 1}`,
pos: { x: spot.x, y: spot.y },
vel: { x: 0, y: 0 },
hp: 100,
maxHp: 100,
alive: true,
facing: u < 2 ? 1 : -1,
onGround: false,
jumpCount: 0,
fallStartY: spot.y,
inWater: false
});
}
}
}
// Center camera
this.camera.targetX = this.mapWidth / 2;
this.camera.targetY = this.mapHeight / 3;
}
startTurn() {
const teamUnits = this.getTeamAliveUnits(this.currentTeamIndex);
if (teamUnits.length === 0) {
// Team eliminated - check for winner
this.checkWinCondition();
return;
}
this.turnTime = this.maxTurnTime;
this.aimAngle = -Math.PI / 4;
this.aimPower = 0.7;
this.selectedWeaponIndex = 0;
// Change wind slightly each turn
this.wind += (Math.random() - 0.5) * 2;
this.wind = Math.max(-8, Math.min(8, this.wind));
// Reset banana bonus
this.bananaBonus.set(this.currentTeamIndex, 1);
this.turnCount++;
this.phase = 'weaponSelect';
this.turnAnnounceTimer = 90; // frames to show announcement
this.sounds.playTurnStart();
// If CPU team, start AI turn
if (this.cpuTeams.has(this.currentTeamIndex)) {
setTimeout(() => this.startCPUTurn(), 500);
}
}
async startCPUTurn() {
const unit = this.getCurrentUnit();
if (!unit || !unit.alive)
return;
const teammates = this.getTeamAliveUnits(this.currentTeamIndex).filter(u => u.id !== unit.id);
const enemies = [];
for (let t = 0; t < this.teamCount; t++) {
if (t !== this.currentTeamIndex) {
enemies.push(...this.getTeamAliveUnits(t));
}
}
const ai = this.aiControllers.get(this.currentTeamIndex);
if (!ai)
return;
this.phase = 'firing';
await ai.executeTurn(unit, teammates, enemies, this.ammoPools.get(this.currentTeamIndex), this.wind, this.waterLevel, (action, data) => {
switch (action) {
case 'selectWeapon':
const w = (0, weapons_1.getWeaponById)(data);
if (w)
this.selectedWeaponIndex = weapons_1.WEAPONS.indexOf(w);
break;
case 'aim':
this.aimAngle = data.angle;
this.aimPower = data.power;
break;
case 'fire':
this.fireProjectile();
break;
case 'detonate':
this.detonateDynamite();
break;
case 'endTurn':
this.endTurn();
break;
}
});
}
getCurrentUnit() {
const teamUnits = this.getTeamAliveUnits(this.currentTeamIndex);
return teamUnits.length > 0 ? teamUnits[0] : null;
}
getTeamAliveUnits(teamId) {
return this.units.filter(u => u.teamId === teamId && u.alive);
}
checkWinCondition() {
const aliveTeams = new Set();
for (const unit of this.units) {
if (unit.alive)
aliveTeams.add(unit.teamId);
}
if (aliveTeams.size <= 1) {
this.phase = 'gameOver';
}
else {
// Skip eliminated teams
do {
this.currentTeamIndex = (this.currentTeamIndex + 1) % this.teamCount;
} while (!aliveTeams.has(this.currentTeamIndex));
this.startTurn();
}
}
endTurn() {
const aliveTeams = new Set();
for (const unit of this.units) {
if (unit.alive)
aliveTeams.add(unit.teamId);
}
do {
this.currentTeamIndex = (this.currentTeamIndex + 1) % this.teamCount;
} while (!aliveTeams.has(this.currentTeamIndex));
this.startTurn();
}
fireProjectile() {
const unit = this.getCurrentUnit();
if (!unit || !unit.alive)
return;
const weapon = weapons_1.WEAPONS[this.selectedWeaponIndex];
const ammoPool = this.ammoPools.get(this.currentTeamIndex);
if (!ammoPool || (ammoPool.get(weapon.id) || 0) <= 0)
return;
// Special weapons
if (weapon.id === 'super_size') {
unit.maxHp += 50;
unit.hp = Math.min(unit.hp + 50, unit.maxHp);
this.particles.addFloatingText(unit.pos.x, unit.pos.y - 20, 'SUPER SIZE!', '#FFD700', 20);
this.sounds.playPowerUp();
ammoPool.set(weapon.id, (ammoPool.get(weapon.id) || 1) - 1);
return;
}
if (weapon.id === 'bananapower') {
this.bananaBonus.set(this.currentTeamIndex, 1.5);
this.particles.addFloatingText(unit.pos.x, unit.pos.y - 20, '+50% DAMAGE!', '#FFA500', 18);
this.sounds.playPowerUp();
ammoPool.set(weapon.id, (ammoPool.get(weapon.id) || 1) - 1);
return;
}
// Launch projectile
const launchAngle = unit.facing === 1 ? Math.PI + this.aimAngle : this.aimAngle;
const speed = weapon.power * this.aimPower;
// Muzzle flash particles
this.particles.spawnDust(unit.pos.x, unit.pos.y - 5);
this.projectiles.push({
id: `proj_${Date.now()}`,
pos: { x: unit.pos.x + Math.cos(launchAngle) * 10, y: unit.pos.y - 10 + Math.sin(launchAngle) * 10 },
vel: {
x: Math.cos(launchAngle) * speed + ((weapon.category === 'indirect' || weapon.category === 'aoe') ? this.wind * 0.1 : 0),
y: Math.sin(launchAngle) * speed
},
weaponId: weapon.id,
teamId: this.currentTeamIndex,
alive: true,
trail: [],
gravity: physics_1.GRAVITY,
isHoming: weapon.id === 'homing_missile',
homingTarget: null,
fuseTimer: weapon.id === 'dynamite' ? 180 : undefined,
});
ammoPool.set(weapon.id, (ammoPool.get(weapon.id) || 1) - 1);
this.phase = 'firing';
this.sounds.playLaunch();
}
detonateDynamite() {
for (const proj of this.projectiles) {
if (proj.alive && proj.weaponId === 'dynamite' && proj.teamId === this.currentTeamIndex) {
this.explodeProjectile(proj);
}
}
}
explodeProjectile(proj) {
const weapon = (0, weapons_1.getWeaponById)(proj.weaponId);
if (!weapon)
return;
proj.alive = false;
const damageMult = this.bananaBonus.get(this.currentTeamIndex) || 1;
const damage = Math.floor(weapon.damage * damageMult);
const radius = weapon.aoeRadius;
// Cluster bomb: spawn sub-munitions
if (weapon.id === 'cluster_bomb') {
for (let i = 0; i < 5; i++) {
this.projectiles.push({
id: `sub_${Date.now()}_${i}`,
pos: { x: proj.pos.x, y: proj.pos.y },
vel: {
x: (Math.random() - 0.5) * 6,
y: -Math.random() * 4 - 2
},
weaponId: 'cluster_sub',
teamId: this.currentTeamIndex,
alive: true,
trail: [],
gravity: physics_1.GRAVITY,
subDamage: Math.floor(damage / 3),
subRadius: radius * 0.5,
});
}
}
// Apply damage to units
for (const unit of this.units) {
if (!unit.alive)
continue;
const dx = unit.pos.x - proj.pos.x;
const dy = unit.pos.y - proj.pos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
const falloff = 1 - dist / radius;
const dmg = Math.floor(damage * falloff);
// Friendly fire reduced by 50%
const actualDmg = unit.teamId === this.currentTeamIndex ? Math.floor(dmg * 0.5) : dmg;
if (actualDmg > 0) {
unit.hp -= actualDmg;
// Knockback
const kbStrength = radius * 0.3;
unit.vel.x += (dx / dist) * kbStrength * falloff;
unit.vel.y += (dy / dist) * kbStrength * falloff - 2;
this.particles.addFloatingText(unit.pos.x, unit.pos.y - 15, `-${actualDmg}`, '#FF4444', 18);
this.sounds.playDamage(actualDmg);
if (unit.hp <= 0) {
unit.alive = false;
this.particles.addFloatingText(unit.pos.x, unit.pos.y - 25, '💀', '#FF0000', 24);
this.sounds.playDeath();
// Check win condition after delay
setTimeout(() => {
if (this.phase !== 'gameOver')
this.checkWinCondition();
}, 1000);
}
}
}
}
// Destroy terrain
const debris = this.terrain.destroy(proj.pos.x, proj.pos.y, radius);
// Spawn particles
const size = radius > 40 ? 'large' : radius > 25 ? 'medium' : 'small';
this.particles.spawnExplosion(proj.pos.x, proj.pos.y, radius);
this.particles.spawnDebris(proj.pos.x, proj.pos.y, Math.min(debris.length / 3, 30));
this.sounds.playExplosion(size);
// Water splash if near water level
if (proj.pos.y > this.waterLevel - radius) {
this.particles.spawnWaterSplash(proj.pos.x, this.waterLevel);
this.sounds.playWaterSplash();
}
// Explosion effect for visual feedback
this.explosionEffects.push({
x: proj.pos.x, y: proj.pos.y,
radius, progress: 0,
shakeAmount: size === 'large' ? 12 : size === 'medium' ? 8 : 4,
freezeFrames: size === 'large' ? 5 : size === 'medium' ? 3 : 0
});
// Screen shake and impact freeze
this.camera.addShake(size === 'large' ? 12 : size === 'medium' ? 8 : 4);
if (size === 'large') {
this.impactFreezeTimer = 5;
}
// Settle terrain gravity
this.terrain.settleGravity();
}
update(dt) {
if (this.phase === 'menu' || this.phase === 'gameOver')
return;
// Impact freeze
if (this.impactFreezeTimer > 0) {
this.impactFreezeTimer--;
return;
}
const effectiveDt = dt * (1 / 60);
// Turn announcement countdown
if (this.turnAnnounceTimer > 0) {
this.turnAnnounceTimer--;
}
// Update turn timer
if (this.phase === 'weaponSelect' || this.phase === 'aiming') {
this.turnTime -= effectiveDt;
// Urgent ticking sound
if (this.turnTime < 5 && Math.floor(this.turnTime * 4) !== Math.floor((this.turnTime + effectiveDt) * 4)) {
this.sounds.playUrgentTick(1 - this.turnTime / 5);
}
if (this.turnTime <= 0) {
this.endTurn();
return;
}
}
// Update units
for (const unit of this.units) {
if (!unit.alive)
continue;
const hadFallDamage = this.physics.updateUnit(unit, effectiveDt, this.wind, this.waterLevel);
if (hadFallDamage) {
this.particles.addFloatingText(unit.pos.x, unit.pos.y - 15, 'FALL DAMAGE!', '#FF6600', 14);
this.sounds.playHit();
if (unit.hp <= 0 && unit.alive) {
unit.alive = false;
this.particles.addFloatingText(unit.pos.x, unit.pos.y - 25, '💀', '#FF0000', 24);
this.sounds.playDeath();
}
}
// Drowning damage
if (unit.inWater && unit.onGround) {
unit.hp -= effectiveDt * 2;
if (Math.floor(unit.pos.y) % 30 === 0) {
this.particles.spawnWaterSplash(unit.pos.x, unit.pos.y);
}
if (unit.hp <= 0 && unit.alive) {
unit.alive = false;
this.particles.addFloatingText(unit.pos.x, unit.pos.y - 25, 'DROWNED!', '#4488FF', 16);
this.sounds.playDeath();
}
}
// Input handling for current team's unit (human only)
if (!this.cpuTeams.has(this.currentTeamIndex) &&
this.phase !== 'firing' && this.phase !== 'explosion') {
let moved = false;
if (this.keysDown.has('ArrowLeft') || this.keysDown.has('a')) {
unit.vel.x = -physics_1.MOVE_SPEED;
unit.facing = -1;
moved = true;
}
else if (this.keysDown.has('ArrowRight') || this.keysDown.has('d')) {
unit.vel.x = physics_1.MOVE_SPEED;
unit.facing = 1;
moved = true;
}
else {
unit.vel.x *= 0.8; // friction
}
if ((this.keysDown.has('ArrowUp') || this.keysDown.has('w') || this.keysDown.has(' ')) &&
unit.onGround && unit.jumpCount < 2) {
unit.vel.y = physics_1.JUMP_FORCE;
unit.jumpCount++;
this.particles.spawnDust(unit.pos.x, unit.pos.y + physics_1.UNIT_RADIUS);
this.sounds.playJump();
}
}
}
// Update projectiles
for (const proj of this.projectiles) {
if (!proj.alive)
continue;
// Homing missile logic
if (proj.isHoming && !proj.homingTarget) {
// Find nearest enemy
let closest = null;
let closestDist = Infinity;
for (const unit of this.units) {
if (!unit.alive || unit.teamId === proj.teamId)
continue;
const d = Math.sqrt((unit.pos.x - proj.pos.x) ** 2 + (unit.pos.y - proj.pos.y) ** 2);
if (d < closestDist) {
closestDist = d;
closest = unit;
}
}
proj.homingTarget = closest;
}
if (proj.isHoming && proj.homingTarget?.alive) {
const dx = proj.homingTarget.pos.x - proj.pos.x;
const dy = proj.homingTarget.pos.y - proj.pos.y;
const angle = Math.atan2(dy, dx);
const speed = Math.sqrt(proj.vel.x ** 2 + proj.vel.y ** 2);
// Gradually steer toward target
const currentAngle = Math.atan2(proj.vel.y, proj.vel.x);
let diff = angle - currentAngle;
while (diff > Math.PI)
diff -= Math.PI * 2;
while (diff < -Math.PI)
diff += Math.PI * 2;
const turnRate = 0.05;
const newAngle = currentAngle + Math.sign(diff) * Math.min(Math.abs(diff), turnRate);
proj.vel.x = Math.cos(newAngle) * speed;
proj.vel.y = Math.sin(newAngle) * speed;
}
// Dynamite fuse timer
if (proj.fuseTimer !== undefined) {
proj.fuseTimer -= effectiveDt;
if (proj.fuseTimer <= 0) {
this.explodeProjectile(proj);
continue;
}
}
const hit = this.physics.updateProjectile(proj, effectiveDt);
// Check unit collision
for (const unit of this.units) {
if (!unit.alive)
continue;
if (this.physics.checkUnitCollision(unit, proj)) {
this.explodeProjectile(proj);
break;
}
}
if (hit && proj.alive) {
this.explodeProjectile(proj);
}
}
// Update particles
this.particles.update(effectiveDt * 60);
// Update explosion effects
for (const eff of this.explosionEffects) {
eff.progress += effectiveDt * 3;
}
this.explosionEffects = this.explosionEffects.filter(e => e.progress < 1);
// Update camera to follow current unit
const currentUnit = this.getCurrentUnit();
if (currentUnit && currentUnit.alive) {
this.camera.follow(currentUnit.pos.x, currentUnit.pos.y - 50, this.mapWidth, this.mapHeight);
}
this.camera.update(0.06);
// Clean up dead projectiles
this.projectiles = this.projectiles.filter(p => p.alive || p.trail.length > 0);
}
setupInput() {
window.addEventListener('keydown', (e) => {
this.keysDown.add(e.key);
if (this.phase === 'weaponSelect') {
if (e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key) - 1;
if (idx < weapons_1.WEAPONS.length) {
this.selectedWeaponIndex = idx;
this.sounds.playMenuClick();
}
}
if (e.key === 'Enter' || e.key === ' ') {
this.phase = 'aiming';
this.sounds.playMenuClick();
}
}
else if (this.phase === 'aiming') {
if (e.key === 'Enter' || e.key === ' ') {
this.fireProjectile();
}
if (e.key === 'Escape') {
this.endTurn();
}
}
});
window.addEventListener('keyup', (e) => {
this.keysDown.delete(e.key);
});
// Mouse handling for aiming
const handleMouseMove = (e) => {
if (!this.canvas)
return;
const rect = this.canvas.getBoundingClientRect();
this.mousePos.x = e.clientX - rect.left;
this.mousePos.y = e.clientY - rect.top;
// Convert to world coords for aiming
const t = this.camera.getTransform();
this.mouseWorld.x = (this.mousePos.x - t.tx) / t.scale;
this.mouseWorld.y = (this.mousePos.y - t.ty) / t.scale;
if (this.phase === 'aiming') {
const unit = this.getCurrentUnit();
if (unit && unit.alive) {
const dx = this.mouseWorld.x - unit.pos.x;
const dy = this.mouseWorld.y - unit.pos.y;
// Calculate angle from unit to mouse
let angle = Math.atan2(dy, dx);
// Only allow upward angles
if (angle > 0) {
angle = -Math.abs(angle);
}
this.aimAngle = Math.max(-Math.PI / 2 + 0.1, Math.min(-0.1, angle));
}
}
};
const handleClick = (e) => {
if (!this.canvas)
return;
// Left click
if (e.button === 0) {
this.mouseClicked = true;
if (this.phase === 'weaponSelect') {
// Check weapon button clicks - handled by HUD rendering
}
else if (this.phase === 'aiming') {
this.fireProjectile();
}
}
// Right click
if (e.button === 2) {
this.mouseRightClicked = true;
if (this.phase === 'aiming') {
this.endTurn();
}
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleClick);
window.addEventListener('contextmenu', e => e.preventDefault());
}
// Getters for rendering
get currentTeamColor() {
return types_1.TEAM_COLORS[this.currentTeamIndex] || '#FFFFFF';
}
get weaponAmmoCount() {
const pool = this.ammoPools.get(this.currentTeamIndex);
if (!pool)
return weapons_1.WEAPONS.map(() => 0);
return weapons_1.WEAPONS.map(w => pool.get(w.id) || 0);
}
}
exports.Game = Game;
};
// ── module: src/rendering.ts ──
__mods["src/rendering.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
const types_1 = require("./types");
const weapons_1 = require("./weapons");
const physics_1 = require("./physics");
class Renderer {
constructor(game) {
this.game = game;
}
render() {
const ctx = this.game.ctx;
if (!ctx)
return;
const t = this.game.camera.getTransform();
// Clear background with sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, this.game.canvas.height);
skyGrad.addColorStop(0, '#1a1a3e');
skyGrad.addColorStop(0.4, '#2d4a7a');
skyGrad.addColorStop(0.85, '#4a7ab5');
skyGrad.addColorStop(1, '#6ba3d6');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, this.game.canvas.width, this.game.canvas.height);
// Stars (subtle)
if (this.game.phase !== 'menu') {
this.drawStars(ctx);
}
ctx.save();
ctx.translate(t.tx, t.ty);
ctx.scale(t.scale, t.scale);
// Render terrain
this.game.terrain.render(ctx, 1);
// Render grass tufts on surface
this.renderGrassTufts(ctx);
// Render water
this.renderWater(ctx);
// Render explosion effects (flash)
for (const eff of this.game.explosionEffects) {
this.renderExplosionFlash(ctx, eff);
}
// Render projectile trails and projectiles
this.renderProjectiles(ctx);
// Render units
this.renderUnits(ctx);
// Render particles
this.game.particles.render(ctx);
ctx.restore();
// Turn announcement overlay
if (this.game.turnAnnounceTimer > 0) {
this.renderTurnAnnouncement(ctx);
}
// HUD (screen-space)
if (this.game.phase !== 'menu') {
this.renderHUD(ctx);
}
// Menu overlay
if (this.game.phase === 'menu') {
this.renderMenu(ctx);
}
// Game over overlay
if (this.game.phase === 'gameOver') {
this.renderGameOver(ctx);
}
// Vignette effect for atmosphere
this.renderVignette(ctx);
}
renderVignette(ctx) {
const w = this.game.canvas.width;
const h = this.game.canvas.height;
const grad = ctx.createRadialGradient(w / 2, h / 2, w * 0.3, w / 2, h / 2, w * 0.7);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.4)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
}
drawStars(ctx) {
// Stars with twinkling effect
const time = Date.now() * 0.001;
for (let i = 0; i < 80; i++) {
const x = ((i * 137 + 42) % this.game.mapWidth);
const y = ((i * 97 + 13) % (this.game.mapHeight * 0.5));
const size = (i % 3) + 1;
const twinkle = 0.2 + Math.sin(time + i) * 0.15;
ctx.fillStyle = `rgba(255,255,255,${twinkle})`;
ctx.fillRect(x, y, size, size);
}
// Clouds
this.drawClouds(ctx, time);
}
drawClouds(ctx, time) {
const cloudAlpha = 0.15;
for (let i = 0; i < 6; i++) {
const cx = ((i * 237 + time * 8) % (this.game.mapWidth + 200)) - 100;
const cy = 40 + i * 50 + Math.sin(i * 2.3) * 20;
ctx.fillStyle = `rgba(200,220,255,${cloudAlpha})`;
for (let j = 0; j < 5; j++) {
const px = cx + j * 18 - 36;
const py = cy + Math.sin(j * 1.5) * 8;
ctx.beginPath();
ctx.arc(px, py, 20 + Math.sin(i + j) * 5, 0, Math.PI * 2);
ctx.fill();
}
}
}
renderGrassTufts(ctx) {
const time = Date.now() * 0.001;
// Draw grass tufts on terrain surface
for (let x = 5; x < this.game.mapWidth - 5; x += 7) {
const surfaceY = this.game.terrain.findSurfaceY(x);
// Only draw if above water and not in cave ceiling area
if (surfaceY > this.game.waterLevel - 10)
continue;
// Check it's a grass block
if (this.game.terrain.get(x, surfaceY) !== 1)
continue;
const sway = Math.sin(time * 2 + x * 0.3) * 2;
ctx.strokeStyle = '#5a9e48';
ctx.lineWidth = 1;
// Small grass tuft
for (let g = -1; g <= 1; g++) {
ctx.beginPath();
ctx.moveTo(x + g, surfaceY);
ctx.quadraticCurveTo(x + g + sway * 0.5, surfaceY - 4, x + g + sway, surfaceY - 6 - Math.random() * 2);
ctx.stroke();
}
}
}
renderWater(ctx) {
const waterY = this.game.waterLevel;
// Water body
const waterGrad = ctx.createLinearGradient(0, waterY, 0, this.game.mapHeight);
waterGrad.addColorStop(0, 'rgba(30,80,160,0.7)');
waterGrad.addColorStop(0.5, 'rgba(20,60,140,0.8)');
waterGrad.addColorStop(1, 'rgba(10,40,100,0.9)');
ctx.fillStyle = waterGrad;
ctx.fillRect(0, waterY, this.game.mapWidth, this.game.mapHeight - waterY);
// Water surface wave effect
const time = Date.now() * 0.002;
ctx.strokeStyle = 'rgba(100,180,255,0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < this.game.mapWidth; x += 3) {
const waveY = waterY + Math.sin(x * 0.05 + time) * 2 + Math.sin(x * 0.1 + time * 1.5) * 1;
if (x === 0)
ctx.moveTo(x, waveY);
else
ctx.lineTo(x, waveY);
}
ctx.stroke();
// Foam line
ctx.strokeStyle = 'rgba(200,230,255,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x < this.game.mapWidth; x += 3) {
const waveY = waterY + Math.sin(x * 0.08 + time * 0.7) * 1.5;
if (x === 0)
ctx.moveTo(x, waveY);
else
ctx.lineTo(x, waveY);
}
ctx.stroke();
}
renderExplosionFlash(ctx, eff) {
const alpha = Math.max(0, 1 - eff.progress);
// Expanding ring
const ringRadius = eff.radius * (0.5 + eff.progress * 1.5);
ctx.strokeStyle = `rgba(255,200,100,${alpha * 0.6})`;
ctx.lineWidth = Math.max(1, 4 * alpha);
ctx.beginPath();
ctx.arc(eff.x, eff.y, ringRadius, 0, Math.PI * 2);
ctx.stroke();
// Inner bright flash (only at start)
if (eff.progress < 0.3) {
const innerAlpha = (1 - eff.progress / 0.3) * 0.9;
const grad = ctx.createRadialGradient(eff.x, eff.y, 0, eff.x, eff.y, eff.radius);
grad.addColorStop(0, `rgba(255,255,255,${innerAlpha})`);
grad.addColorStop(0.3, `rgba(255,255,200,${innerAlpha * 0.8})`);
grad.addColorStop(0.6, `rgba(255,150,50,${innerAlpha * 0.4})`);
grad.addColorStop(1, 'rgba(255,80,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(eff.x, eff.y, eff.radius, 0, Math.PI * 2);
ctx.fill();
}
// Outer glow (fades slower)
const outerAlpha = alpha * 0.4;
if (outerAlpha > 0.01) {
const grad = ctx.createRadialGradient(eff.x, eff.y, 0, eff.x, eff.y, eff.radius * 2);
grad.addColorStop(0, `rgba(255,150,50,${outerAlpha})`);
grad.addColorStop(0.5, `rgba(255,80,20,${outerAlpha * 0.5})`);
grad.addColorStop(1, 'rgba(255,40,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(eff.x, eff.y, eff.radius * 2, 0, Math.PI * 2);
ctx.fill();
}
}
renderProjectiles(ctx) {
for (const proj of this.game.projectiles) {
// Trail
if (proj.trail.length > 1) {
ctx.strokeStyle = 'rgba(255,200,100,0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < proj.trail.length; i++) {
const alpha = i / proj.trail.length;
if (i === 0)
ctx.moveTo(proj.trail[i].x, proj.trail[i].y);
else
ctx.lineTo(proj.trail[i].x, proj.trail[i].y);
}
ctx.stroke();
}
// Projectile body
if (proj.alive) {
const weapon = weapons_1.WEAPONS.find(w => w.id === proj.weaponId);
ctx.save();
ctx.translate(proj.pos.x, proj.pos.y);
const angle = Math.atan2(proj.vel.y, proj.vel.x);
ctx.rotate(angle);
if (weapon?.id === 'rocket') {
// Rocket shape
ctx.fillStyle = '#FF6600';
ctx.beginPath();
ctx.ellipse(0, 0, 8, 3, 0, 0, Math.PI * 2);
ctx.fill();
// Flame
ctx.fillStyle = '#FFFF00';
ctx.beginPath();
ctx.moveTo(-6, -2);
ctx.lineTo(-12 - Math.random() * 4, 0);
ctx.lineTo(-6, 2);
ctx.fill();
}
else if (weapon?.id === 'dynamite') {
// Dynamite stick
ctx.fillStyle = '#CC3333';
ctx.fillRect(-5, -2, 10, 4);
// Fuse spark
ctx.fillStyle = '#FFFF00';
ctx.beginPath();
ctx.arc(6, 0, 2 + Math.random(), 0, Math.PI * 2);
ctx.fill();
}
else if (weapon?.id === 'homing_missile') {
// Missile shape
ctx.fillStyle = '#88CCFF';
ctx.beginPath();
ctx.moveTo(10, 0);
ctx.lineTo(-5, -4);
ctx.lineTo(-3, 0);
ctx.lineTo(-5, 4);
ctx.closePath();
ctx.fill();
}
else if (weapon?.id === 'minigun') {
// Bullet
ctx.fillStyle = '#FFFF00';
ctx.beginPath();
ctx.arc(0, 0, 2, 0, Math.PI * 2);
ctx.fill();
}
else {
// Generic projectile
ctx.fillStyle = '#FFAA44';
ctx.beginPath();
ctx.arc(0, 0, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
}
renderUnits(ctx) {
const time = Date.now() * 0.003;
for (const unit of this.game.units) {
if (!unit.alive)
continue;
const color = types_1.TEAM_COLORS[unit.teamId] || '#FFFFFF';
const isSuperSized = unit.maxHp > 150;
const bodyScale = isSuperSized ? 2 : 1;
ctx.save();
ctx.translate(unit.pos.x, unit.pos.y);
// Shadow (scales with size)
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, physics_1.UNIT_RADIUS * bodyScale + 2, physics_1.UNIT_RADIUS * 1.5 * bodyScale, 4 * bodyScale, 0, 0, Math.PI * 2);
ctx.fill();
// Worm body - segmented appearance
const segments = isSuperSized ? 7 : 5;
const segWidth = (physics_1.UNIT_RADIUS + 2) * bodyScale / segments;
for (let s = 0; s < segments; s++) {
const sx = (s - (segments - 1) / 2) * segWidth * 0.85;
// Slight wave animation
const waveY = Math.sin(time + s * 0.8) * 1.5 * bodyScale;
// Segment color with gradient effect
const brightness = 1 - Math.abs(s - (segments - 1) / 2) / segments * 0.3;
ctx.fillStyle = this.adjustBrightness(color, brightness);
ctx.beginPath();
ctx.arc(sx, waveY, segWidth * 0.9, 0, Math.PI * 2);
ctx.fill();
// Segment outline
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// Head (front segment with face)
const headX = unit.facing === 1 ? physics_1.UNIT_RADIUS * bodyScale : -physics_1.UNIT_RADIUS * bodyScale;
// Eyes on head
const eyeSize = 3 * bodyScale;
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.arc(headX + 2, -2 * bodyScale, eyeSize, 0, Math.PI * 2);
ctx.arc(headX - 2, -2 * bodyScale, eyeSize, 0, Math.PI * 2);
ctx.fill();
// Pupils (look in facing direction)
const pupilOffset = unit.facing === 1 ? 1.5 : -1;
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(headX + 2 + pupilOffset, -2 * bodyScale, eyeSize * 0.5, 0, Math.PI * 2);
ctx.arc(headX - 2 + pupilOffset, -2 * bodyScale, eyeSize * 0.5, 0, Math.PI * 2);
ctx.fill();
// Mouth expression based on HP
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.beginPath();
if (unit.hp > unit.maxHp * 0.6) {
// Happy smile
ctx.arc(headX, 2 * bodyScale, 3 * bodyScale, 0.1, Math.PI - 0.1);
}
else if (unit.hp > unit.maxHp * 0.3) {
// Neutral
ctx.moveTo(headX - 2, 2 * bodyScale);
ctx.lineTo(headX + 2, 2 * bodyScale);
}
else {
// Worried frown
ctx.arc(headX, 5 * bodyScale, 2.5 * bodyScale, Math.PI + 0.3, -0.3);
}
ctx.stroke();
// Health bar above unit
const hpWidth = physics_1.UNIT_RADIUS * 3 * bodyScale;
const hpHeight = 4 * bodyScale;
const hpY = -(physics_1.UNIT_RADIUS + 8) * bodyScale;
// HP bar background with rounded corners
ctx.fillStyle = 'rgba(0,0,0,0.6)';
this.roundRect(ctx, -hpWidth / 2, hpY, hpWidth, hpHeight, 2);
ctx.fill();
const hpRatio = Math.max(0, unit.hp / unit.maxHp);
const hpColor = hpRatio > 0.6 ? '#44FF44' : hpRatio > 0.3 ? '#FFFF44' : '#FF4444';
ctx.fillStyle = hpColor;
this.roundRect(ctx, -hpWidth / 2, hpY, hpWidth * Math.max(0.05, hpRatio), hpHeight, 2);
ctx.fill();
// HP text
if (bodyScale > 1) {
ctx.font = `bold ${8 * bodyScale}px Arial`;
ctx.textAlign = 'center';
ctx.fillStyle = '#FFF';
ctx.fillText(`${Math.max(0, unit.hp)}`, 0, hpY + hpHeight - 1);
}
// Name tag
ctx.font = `${10 * bodyScale}px Arial`;
ctx.textAlign = 'center';
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.strokeText(unit.name, 0, hpY - 4);
ctx.fillText(unit.name, 0, hpY - 4);
// Active unit indicator (pulsing star)
if (!this.game.cpuTeams.has(unit.teamId) &&
this.game.getCurrentUnit()?.id === unit.id) {
const starPulse = Math.sin(Date.now() * 0.005) * 0.3 + 0.7;
ctx.fillStyle = `rgba(255,255,100,${starPulse})`;
this.drawStar(ctx, 0, -(physics_1.UNIT_RADIUS + 18) * bodyScale, 6);
}
// Water indicator
if (unit.inWater) {
ctx.font = `${14 * bodyScale}px Arial`;
ctx.fillText('💧', 0, -(physics_1.UNIT_RADIUS + 25) * bodyScale);
}
ctx.restore();
}
}
adjustBrightness(hexColor, factor) {
// Parse hex color and adjust brightness
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
return `rgb(${Math.min(255, Math.floor(r * factor))},${Math.min(255, Math.floor(g * factor))},${Math.min(255, Math.floor(b * factor))})`;
}
roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
renderTurnAnnouncement(ctx) {
const w = this.game.canvas.width;
const h = this.game.canvas.height;
const timerRatio = this.game.turnAnnounceTimer / 90;
const alpha = Math.min(1, timerRatio * 3); // Fade in quickly, fade out slowly
ctx.save();
ctx.globalAlpha = alpha;
// Dark overlay
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, h / 2 - 60, w, 120);
// Team color bar
const teamColor = this.game.currentTeamColor;
ctx.fillStyle = teamColor;
ctx.globalAlpha = alpha * 0.3;
ctx.fillRect(0, h / 2 - 60, w, 120);
ctx.globalAlpha = alpha;
// Team name
const cpuLabel = this.game.cpuTeams.has(this.game.currentTeamIndex) ? ' (CPU)' : '';
const teamNames = ['Red', 'Blue', 'Green', 'Orange'];
ctx.font = 'bold 36px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#000';
ctx.fillText(`${teamNames[this.game.currentTeamIndex]} Team${cpuLabel}`, w / 2 + 2, h / 2 + 12);
// Glow effect
ctx.shadowColor = teamColor;
ctx.shadowBlur = 20;
ctx.fillStyle = '#FFFFFF';
ctx.fillText(`${teamNames[this.game.currentTeamIndex]} Team${cpuLabel}`, w / 2, h / 2 + 10);
ctx.shadowBlur = 0;
// Turn number
ctx.font = '16px Arial';
ctx.fillStyle = '#CCCCCC';
ctx.fillText(`Turn ${this.game.turnCount}`, w / 2, h / 2 + 40);
ctx.restore();
}
drawStar(ctx, cx, cy, r) {
const spikes = 5;
const innerR = r * 0.4;
ctx.beginPath();
for (let i = 0; i < spikes * 2; i++) {
const radius = i % 2 === 0 ? r : innerR;
const angle = (i * Math.PI / spikes) - Math.PI / 2;
const x = cx + Math.cos(angle) * radius;
const y = cy + Math.sin(angle) * radius;
if (i === 0)
ctx.moveTo(x, y);
else
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
}
renderHUD(ctx) {
const w = this.game.canvas.width;
const h = this.game.canvas.height;
// Top bar background
const topGrad = ctx.createLinearGradient(0, 0, 0, 60);
topGrad.addColorStop(0, 'rgba(0,0,0,0.7)');
topGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = topGrad;
ctx.fillRect(0, 0, w, 60);
// Team indicators
const teamBarWidth = Math.min(w * 0.4, 300);
const teamBarX = (w - teamBarWidth) / 2;
for (let i = 0; i < this.game.teamCount; i++) {
const aliveUnits = this.game.getTeamAliveUnits(i).length;
const isActive = i === this.game.currentTeamIndex;
// Team box
ctx.fillStyle = isActive ? types_1.TEAM_COLORS[i] : 'rgba(255,255,255,0.1)';
if (isActive) {
ctx.shadowColor = types_1.TEAM_COLORS[i];
ctx.shadowBlur = 10;
}
const boxX = teamBarX + i * (teamBarWidth / this.game.teamCount);
const boxW = teamBarWidth / this.game.teamCount - 4;
ctx.fillRect(boxX, 5, boxW, 20);
// Unit dots
for (let u = 0; u < 4; u++) {
const unit = this.game.units.find(un => un.teamId === i &&
parseInt(un.id.split('_')[1].replace('unit', '')) === u);
ctx.fillStyle = unit?.alive ? types_1.TEAM_COLORS[i] : 'rgba(255,255,255,0.2)';
ctx.beginPath();
ctx.arc(boxX + 8 + u * 14, 15, 4, 0, Math.PI * 2);
ctx.fill();
}
// CPU indicator
if (this.game.cpuTeams.has(i)) {
ctx.font = '9px Arial';
ctx.fillStyle = '#FFA500';
ctx.textAlign = 'center';
ctx.fillText('CPU', boxX + boxW / 2, 38);
}
ctx.shadowBlur = 0;
}
// Turn timer
const timerRatio = this.game.turnTime / this.game.maxTurnTime;
const timerWidth = Math.min(w * 0.3, 250);
const timerX = (w - timerWidth) / 2;
// Timer bar background
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(timerX, 48, timerWidth, 6);
// Timer fill with color based on urgency
const timerColor = timerRatio > 0.5 ? '#44FF44' : timerRatio > 0.25 ? '#FFFF44' : '#FF4444';
ctx.fillStyle = timerColor;
ctx.fillRect(timerX, 48, timerWidth * timerRatio, 6);
// Timer text (pulsing when low)
const pulseScale = this.game.turnTime < 5 ? 1 + Math.sin(Date.now() * 0.015) * 0.3 : 1;
ctx.font = `bold ${Math.floor(14 * pulseScale)}px Arial`;
ctx.textAlign = 'center';
// Timer glow when urgent
if (this.game.turnTime < 5) {
ctx.shadowColor = timerColor;
ctx.shadowBlur = 8 + Math.sin(Date.now() * 0.015) * 4;
}
ctx.fillStyle = timerColor;
ctx.fillText(`${this.game.turnTime.toFixed(1)}s`, w / 2, 46);
ctx.shadowBlur = 0;
// Urgency warning text when very low
if (this.game.turnTime < 3) {
const warnAlpha = Math.sin(Date.now() * 0.01) * 0.5 + 0.5;
ctx.font = 'bold 12px Arial';
ctx.fillStyle = `rgba(255,50,50,${warnAlpha})`;
ctx.fillText('⚠ TIME RUNNING OUT ⚠', w / 2, 38);
}
// Wind indicator
this.renderWindIndicator(ctx, w - 80, 35);
// Minimap
this.renderMinimap(ctx, w, h);
// Turn info display
if (!this.game.cpuTeams.has(this.game.currentTeamIndex)) {
const unit = this.game.getCurrentUnit();
if (unit) {
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillStyle = '#CCCCCC';
ctx.fillText(`Turn ${this.game.turnCount} • ${unit.name}`, 10, 55);
}
}
// Weapon selection panel (bottom)
if (this.game.phase === 'weaponSelect' || this.game.phase === 'aiming') {
this.renderWeaponPanel(ctx, w, h);
}
// Aiming UI
if (this.game.phase === 'aiming') {
this.renderAimUI(ctx);
}
}
renderWindIndicator(ctx, x, y) {
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#AAAAAA';
ctx.fillText('WIND', x, y - 8);
// Wind arrow
const windStrength = Math.abs(this.game.wind);
const windDir = this.game.wind >= 0 ? 1 : -1;
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - windStrength * 3 * windDir, y + 5);
ctx.lineTo(x + windStrength * 3 * windDir, y + 5);
ctx.stroke();
// Arrow head
if (windStrength > 0.5) {
ctx.beginPath();
const tipX = x + windStrength * 3 * windDir;
ctx.moveTo(tipX, y + 5);
ctx.lineTo(tipX - 4 * windDir, y + 2);
ctx.lineTo(tipX - 4 * windDir, y + 8);
ctx.closePath();
ctx.fillStyle = '#FFFFFF';
ctx.fill();
}
// Wind value
ctx.font = '9px Arial';
ctx.fillStyle = '#CCCCCC';
ctx.fillText(windStrength.toFixed(1), x, y + 20);
}
renderWeaponPanel(ctx, w, h) {
const panelH = 80;
const panelY = h - panelH;
// Panel background
ctx.fillStyle = 'rgba(0,0,0,0.75)';
ctx.fillRect(0, panelY, w, panelH);
// Top border glow
ctx.strokeStyle = this.game.currentTeamColor;
ctx.lineWidth = 2;
ctx.shadowColor = this.game.currentTeamColor;
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(0, panelY);
ctx.lineTo(w, panelY);
ctx.stroke();
ctx.shadowBlur = 0;
const weaponW = Math.min(100, (w - 40) / weapons_1.WEAPONS.length);
const startX = (w - weaponW * weapons_1.WEAPONS.length) / 2;
for (let i = 0; i < weapons_1.WEAPONS.length; i++) {
const wx = startX + i * weaponW;
const wy = panelY + 5;
const isSelected = this.game.selectedWeaponIndex === i;
const ammoCount = this.game.weaponAmmoCount[i];
// Weapon button background
ctx.fillStyle = isSelected ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.05)';
if (isSelected) {
ctx.strokeStyle = this.game.currentTeamColor;
ctx.lineWidth = 2;
ctx.strokeRect(wx + 2, wy + 2, weaponW - 4, panelH - 10);
}
// Weapon icon
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.fillText(weapons_1.WEAPONS[i].icon, wx + weaponW / 2, wy + 30);
// Weapon name
ctx.font = '9px Arial';
ctx.fillStyle = '#CCCCCC';
ctx.fillText(weapons_1.WEAPONS[i].name, wx + weaponW / 2, wy + 48);
// Ammo count
ctx.font = 'bold 11px Arial';
ctx.fillStyle = ammoCount > 0 ? '#FFFFFF' : '#666666';
ctx.fillText(`${ammoCount}`, wx + weaponW / 2, wy + 63);
// Store button bounds for click detection (we'll use a global)
weapons_1.WEAPONS[i]._buttonRect = { x: wx, y: wy, w: weaponW, h: panelH - 10 };
}
// Weapon description for selected weapon
const selWeapon = weapons_1.WEAPONS[this.game.selectedWeaponIndex];
ctx.font = '10px Arial';
ctx.fillStyle = '#AAAAAA';
ctx.textAlign = 'center';
ctx.fillText(selWeapon.description, w / 2, panelY - 5);
// Instructions
ctx.font = '9px Arial';
ctx.fillStyle = '#666666';
if (this.game.phase === 'weaponSelect') {
ctx.fillText('Click weapon or press 1-9, then Enter/Space to aim', w / 2, panelY - 18);
}
else {
ctx.fillText('Aim with mouse • Click/Enter to fire • Right-click/ESC to cancel', w / 2, panelY - 18);
}
// Category badge for selected weapon
const catColors = { indirect: '#FFAA44', direct: '#44AAFF', aoe: '#FF4444' };
ctx.font = 'bold 9px Arial';
ctx.fillStyle = catColors[selWeapon.category] || '#FFFFFF';
ctx.textAlign = 'left';
ctx.fillText(selWeapon.category.toUpperCase(), startX, panelY + panelH - 5);
}
renderAimUI(ctx) {
const unit = this.game.getCurrentUnit();
if (!unit || !unit.alive)
return;
// Draw trajectory prediction line
const weapon = weapons_1.WEAPONS[this.game.selectedWeaponIndex];
if (weapon.category === 'direct')
return; // No trajectory for direct weapons
const launchAngle = unit.facing === 1 ? Math.PI + this.game.aimAngle : this.game.aimAngle;
const speed = weapon.power * this.game.aimPower;
ctx.strokeStyle = `rgba(255,255,255,0.3)`;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
let px = unit.pos.x;
let py = unit.pos.y - 10;
let vx = Math.cos(launchAngle) * speed + this.game.wind * 0.1;
let vy = Math.sin(launchAngle) * speed;
ctx.moveTo(px, py);
for (let i = 0; i < 80; i++) {
vy += 0.35; // gravity
px += vx;
py += vy;
if (py > this.game.waterLevel || px < 0 || px > this.game.mapWidth)
break;
ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
// Power indicator on the line
const powerDotX = unit.pos.x + Math.cos(launchAngle) * speed * 15;
const powerDotY = (unit.pos.y - 10) + Math.sin(launchAngle) * speed * 15;
ctx.fillStyle = this.game.currentTeamColor;
ctx.beginPath();
ctx.arc(powerDotX, powerDotY, 4, 0, Math.PI * 2);
ctx.fill();
// Crosshair at mouse position (in world space)
const mw = this.game.mouseWorld;
ctx.strokeStyle = 'rgba(255,255,100,0.6)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(mw.x, mw.y, 8, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(mw.x - 12, mw.y);
ctx.lineTo(mw.x + 12, mw.y);
ctx.moveTo(mw.x, mw.y - 12);
ctx.lineTo(mw.x, mw.y + 12);
ctx.stroke();
// Power meter on side of screen
this.renderPowerMeter(ctx);
}
renderMinimap(ctx, w, h) {
const mmW = 150;
const mmH = 75;
const mmX = w - mmW - 10;
const mmY = h - mmH - 90;
// Background
ctx.fillStyle = 'rgba(0,0,0,0.6)';
this.roundRect(ctx, mmX, mmY, mmW, mmH, 4);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
this.roundRect(ctx, mmX, mmY, mmW, mmH, 4);
ctx.stroke();
const scaleX = mmW / this.game.mapWidth;
const scaleY = mmH / this.game.mapHeight;
// Draw terrain outline (simplified)
for (let x = 0; x < this.game.mapWidth; x += 8) {
const surfaceY = this.game.terrain.findSurfaceY(x);
ctx.fillStyle = 'rgba(100,150,80,0.4)';
ctx.fillRect(mmX + x * scaleX, mmY + surfaceY * scaleY, Math.max(1, 8 * scaleX), (this.game.mapHeight - surfaceY) * scaleY);
}
// Water line
ctx.fillStyle = 'rgba(50,100,200,0.3)';
ctx.fillRect(mmX, mmY + this.game.waterLevel * scaleY, mmW, (this.game.mapHeight - this.game.waterLevel) * scaleY);
// Draw units as dots
for (const unit of this.game.units) {
if (!unit.alive)
continue;
const color = types_1.TEAM_COLORS[unit.teamId];
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(mmX + unit.pos.x * scaleX, mmY + unit.pos.y * scaleY, 2, 0, Math.PI * 2);
ctx.fill();
}
// Camera viewport indicator
const cam = this.game.camera;
const viewW = (w / cam.zoom) * scaleX;
const viewH = (h / cam.zoom) * scaleY;
const viewX = mmX + cam.x * scaleX - viewW / 2;
const viewY = mmY + cam.y * scaleY - viewH / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 1;
ctx.strokeRect(viewX, viewY, viewW, viewH);
// Label
ctx.font = '8px Arial';
ctx.fillStyle = '#AAAAAA';
ctx.textAlign = 'center';
ctx.fillText('MAP', mmX + mmW / 2, mmY - 3);
}
renderPowerMeter(ctx) {
const x = 30;
const y = 80;
const w = 15;
const h = 200;
// Background
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(x - 2, y - 2, w + 4, h + 4);
// Fill based on power
const fillH = h * this.game.aimPower;
const grad = ctx.createLinearGradient(0, y + h, 0, y);
grad.addColorStop(0, '#44FF44');
grad.addColorStop(0.5, '#FFFF44');
grad.addColorStop(1, '#FF4444');
ctx.fillStyle = grad;
ctx.fillRect(x, y + h - fillH, w, fillH);
// Border
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
// Label
ctx.font = '9px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#FFFFFF';
ctx.fillText('POWER', x + w / 2, y - 6);
ctx.fillText(`${Math.floor(this.game.aimPower * 100)}%`, x + w / 2, y + h + 14);
// Scroll wheel hint
ctx.font = '8px Arial';
ctx.fillStyle = '#888888';
ctx.fillText('Scroll', x + w / 2, y + h + 26);
}
renderMenu(ctx) {
const w = this.game.canvas.width;
const h = this.game.canvas.height;
const time = Date.now() * 0.001;
// Animated background with gradient
const bgGrad = ctx.createRadialGradient(w / 2, h / 2, 50, w / 2, h / 2, w * 0.7);
bgGrad.addColorStop(0, '#1a1a3e');
bgGrad.addColorStop(0.5, '#0d0d2b');
bgGrad.addColorStop(1, '#050510');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, w, h);
// Animated particles in background
for (let i = 0; i < 30; i++) {
const px = ((i * 97 + time * 20) % w);
const py = ((i * 53 + Math.sin(time + i) * 30) % h);
const size = 1 + Math.sin(time * 2 + i) * 0.5;
ctx.fillStyle = `rgba(255,200,100,${0.1 + Math.sin(time + i * 0.7) * 0.05})`;
ctx.beginPath();
ctx.arc(px, py, size, 0, Math.PI * 2);
ctx.fill();
}
// Dark overlay for readability
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.fillRect(0, 0, w, h);
// Title
ctx.textAlign = 'center';
// Title shadow
ctx.font = 'bold 48px Arial';
ctx.fillStyle = '#000';
ctx.fillText('WORMS BATTLE', w / 2 + 2, 122);
// Title text with gradient
const titleGrad = ctx.createLinearGradient(w / 2 - 150, 80, w / 2 + 150, 130);
titleGrad.addColorStop(0, '#FF6B4A');
titleGrad.addColorStop(0.5, '#FFD700');
titleGrad.addColorStop(1, '#FF6B4A');
ctx.fillStyle = titleGrad;
ctx.fillText('WORMS BATTLE', w / 2, 120);
// Subtitle
ctx.font = '18px Arial';
ctx.fillStyle = '#AAAAAA';
ctx.fillText('Artillery Warfare', w / 2, 150);
// Menu options
const menuItems = [
{ label: '🎮 Start Game (2 Teams)', action: () => this.game.startGame(2, new Set(), this.game.terrainShape, this.game.difficulty) },
{ label: '🤖 vs CPU (1v1)', action: () => this.game.startGame(2, new Set([1]), this.game.terrainShape, this.game.difficulty) },
{ label: '⚔️ 4 Teams Battle', action: () => this.game.startGame(4, new Set(), this.game.terrainShape, this.game.difficulty) },
{ label: '🧠 vs CPU (2v2)', action: () => this.game.startGame(4, new Set([2, 3]), this.game.terrainShape, this.game.difficulty) },
];
const startY = 200;
const itemHeight = 50;
for (let i = 0; i < menuItems.length; i++) {
const y = startY + i * (itemHeight + 15);
// Button background
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(w / 2 - 160, y, 320, itemHeight);
// Hover highlight (we'll track mouse position)
const mx = this.game.mousePos.x;
const my = this.game.mousePos.y;
if (mx > w / 2 - 160 && mx < w / 2 + 160 && my > y && my < y + itemHeight) {
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(w / 2 - 160, y, 320, itemHeight);
// Glow border
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.strokeRect(w / 2 - 160, y, 320, itemHeight);
}
// Button text
ctx.font = '18px Arial';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.fillText(menuItems[i].label, w / 2, y + 32);
// Store button bounds for click detection
menuItems[i]._buttonRect = { x: w / 2 - 160, y, w: 320, h: itemHeight };
}
// Terrain selector
ctx.font = '14px Arial';
ctx.fillStyle = '#AAAAAA';
ctx.fillText('Terrain:', w / 2, startY + menuItems.length * (itemHeight + 15) + 20);
const terrainTypes = [
{ label: '🏝️ Island', value: 'island' },
{ label: '🕳️ Cave', value: 'cave' },
{ label: '⛰️ Two Islands', value: 'twoIslands' },
];
const terrainY = startY + menuItems.length * (itemHeight + 15) + 35;
for (let i = 0; i < terrainTypes.length; i++) {
const tx = w / 2 - 140 + i * 100;
ctx.fillStyle = this.game.terrainShape === terrainTypes[i].value ?
'rgba(255,215,0,0.3)' : 'rgba(255,255,255,0.08)';
ctx.fillRect(tx, terrainY, 80, 35);
if (this.game.terrainShape === terrainTypes[i].value) {
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 1;
ctx.strokeRect(tx, terrainY, 80, 35);
}
ctx.font = '12px Arial';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.fillText(terrainTypes[i].label, tx + 40, terrainY + 22);
terrainTypes[i]._buttonRect = { x: tx, y: terrainY, w: 80, h: 35 };
}
// Difficulty selector
ctx.font = '14px Arial';
ctx.fillStyle = '#AAAAAA';
ctx.fillText('Difficulty:', w / 2, terrainY + 55);
const diffTypes = [
{ label: 'Easy', value: 'easy' },
{ label: 'Medium', value: 'medium' },
{ label: 'Hard', value: 'hard' },
];
const diffY = terrainY + 70;
for (let i = 0; i < diffTypes.length; i++) {
const tx = w / 2 - 140 + i * 100;
ctx.fillStyle = this.game.difficulty === diffTypes[i].value ?
'rgba(255,215,0,0.3)' : 'rgba(255,255,255,0.08)';
ctx.fillRect(tx, diffY, 80, 35);
if (this.game.difficulty === diffTypes[i].value) {
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 1;
ctx.strokeRect(tx, diffY, 80, 35);
}
ctx.font = '12px Arial';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.fillText(diffTypes[i].label, tx + 40, diffY + 22);
diffTypes[i]._buttonRect = { x: tx, y: diffY, w: 80, h: 35 };
}
// Controls info
ctx.font = '11px Arial';
ctx.fillStyle = '#666666';
ctx.fillText('Arrow Keys/WASD: Move | Space/Up: Jump | Scroll: Power', w / 2, h - 30);
ctx.fillText('Click to fire | Right-click/ESC: End turn', w / 2, h - 15);
}
renderGameOver(ctx) {
const w = this.game.canvas.width;
const h = this.game.canvas.height;
const time = Date.now() * 0.001;
// Dark overlay with gradient
const bgGrad = ctx.createRadialGradient(w / 2, h / 2, 50, w / 2, h / 2, w * 0.7);
bgGrad.addColorStop(0, 'rgba(20,20,40,0.9)');
bgGrad.addColorStop(1, 'rgba(5,5,15,0.95)');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, w, h);
// Find winner
let winnerTeam = -1;
for (let t = 0; t < this.game.teamCount; t++) {
if (this.game.getTeamAliveUnits(t).length > 0) {
winnerTeam = t;
break;
}
}
ctx.textAlign = 'center';
// Animated confetti particles for victory
if (winnerTeam >= 0) {
const teamColor = types_1.TEAM_COLORS[winnerTeam];
// Confetti
for (let i = 0; i < 40; i++) {
const cx = ((i * 73 + time * 50) % w);
const cy = ((i * 47 + Math.sin(time + i) * 20 + time * 30) % h);
ctx.fillStyle = [teamColor, '#FFD700', '#FFFFFF', '#FF6B4A'][i % 4];
ctx.globalAlpha = 0.5;
ctx.fillRect(cx, cy, 4, 4);
}
ctx.globalAlpha = 1;
// Winner announcement with glow
ctx.font = 'bold 52px Arial';
ctx.fillStyle = '#000';
ctx.fillText('VICTORY!', w / 2 + 2, h / 2 - 48);
ctx.shadowColor = teamColor;
ctx.shadowBlur = 25;
const titleGrad = ctx.createLinearGradient(w / 2 - 100, h / 2 - 60, w / 2 + 100, h / 2 - 30);
titleGrad.addColorStop(0, teamColor);
titleGrad.addColorStop(0.5, '#FFD700');
titleGrad.addColorStop(1, teamColor);
ctx.fillStyle = titleGrad;
ctx.fillText('VICTORY!', w / 2, h / 2 - 50);
ctx.shadowBlur = 0;
// Team name with CPU label
const cpuLabel = this.game.cpuTeams.has(winnerTeam) ? ' (CPU)' : '';
ctx.font = 'bold 30px Arial';
ctx.fillStyle = '#FFFFFF';
ctx.fillText(`Team ${['Red', 'Blue', 'Green', 'Orange'][winnerTeam]}${cpuLabel}`, w / 2, h / 2 + 5);
// Trophy with animation
const trophyBounce = Math.sin(time * 3) * 5;
ctx.font = '70px Arial';
ctx.fillText('🏆', w / 2, h / 2 - 110 + trophyBounce);
// Stats summary
ctx.font = '14px Arial';
ctx.fillStyle = '#AAAAAA';
ctx.fillText(`Game lasted ${this.game.turnCount} turns`, w / 2, h / 2 + 35);
}
else {
ctx.font = 'bold 48px Arial';
ctx.fillStyle = '#FF4444';
ctx.fillText('DRAW!', w / 2, h / 2 - 10);
}
// Restart button with hover effect
const btnY = h / 2 + 65;
const mx = this.game.mousePos.x;
const my = this.game.mousePos.y;
const isHovered = mx > w / 2 - 100 && mx < w / 2 + 100 && my > btnY && my < btnY + 45;
ctx.fillStyle = isHovered ? 'rgba(255,215,0,0.3)' : 'rgba(255,255,255,0.1)';
this.roundRect(ctx, w / 2 - 100, btnY, 200, 45, 8);
ctx.fill();
if (isHovered) {
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
this.roundRect(ctx, w / 2 - 100, btnY, 200, 45, 8);
ctx.stroke();
}
ctx.font = 'bold 18px Arial';
ctx.fillStyle = '#FFFFFF';
ctx.fillText('Play Again', w / 2, btnY + 30);
// Menu button
const menuBtnY = btnY + 55;
const isMenuHovered = mx > w / 2 - 100 && mx < w / 2 + 100 && my > menuBtnY && my < menuBtnY + 45;
ctx.fillStyle = isMenuHovered ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.05)';
this.roundRect(ctx, w / 2 - 100, menuBtnY, 200, 45, 8);
ctx.fill();
if (isMenuHovered) {
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 1;
this.roundRect(ctx, w / 2 - 100, menuBtnY, 200, 45, 8);
ctx.stroke();
}
ctx.font = '16px Arial';
ctx.fillStyle = '#CCCCCC';
ctx.fillText('Main Menu', w / 2, menuBtnY + 30);
// Store buttons for click detection
this._restartButton = { x: w / 2 - 100, y: btnY, w: 200, h: 45 };
this._menuButton = { x: w / 2 - 100, y: menuBtnY, w: 200, h: 45 };
}
// Handle menu clicks
handleMenuClick(mx, my) {
const menuItems = [
{ action: () => this.game.startGame(2, new Set(), this.game.terrainShape, this.game.difficulty), rect: null },
{ action: () => this.game.startGame(2, new Set([1]), this.game.terrainShape, this.game.difficulty), rect: null },
{ action: () => this.game.startGame(4, new Set(), this.game.terrainShape, this.game.difficulty), rect: null },
{ action: () => this.game.startGame(4, new Set([2, 3]), this.game.terrainShape, this.game.difficulty), rect: null },
];
// Check menu buttons (we need to recalculate positions)
const w = this.game.canvas.width;
const h = this.game.canvas.height;
const startY = 200;
const itemHeight = 50;
for (let i = 0; i < 4; i++) {
const y = startY + i * (itemHeight + 15);
if (mx > w / 2 - 160 && mx < w / 2 + 160 && my > y && my < y + itemHeight) {
this.game.sounds.playMenuClick();
menuItems[i].action();
return;
}
}
// Check terrain buttons
const terrainTypes = [
{ value: 'island' }, { value: 'cave' }, { value: 'twoIslands' },
];
const terrainY = startY + 4 * (itemHeight + 15) + 35;
for (let i = 0; i < 3; i++) {
const tx = w / 2 - 140 + i * 100;
if (mx > tx && mx < tx + 80 && my > terrainY && my < terrainY + 35) {
this.game.sounds.playMenuClick();
this.game.terrainShape = terrainTypes[i].value;
return;
}
}
// Check difficulty buttons
const diffTypes = [
{ value: 'easy' }, { value: 'medium' }, { value: 'hard' },
];
const diffY = terrainY + 70;
for (let i = 0; i < 3; i++) {
const tx = w / 2 - 140 + i * 100;
if (mx > tx && mx < tx + 80 && my > diffY && my < diffY + 35) {
this.game.sounds.playMenuClick();
this.game.difficulty = diffTypes[i].value;
return;
}
}
}
handleGameOverClick(mx, my) {
const w = this.game.canvas.width;
const h = this.game.canvas.height;
// Restart button
const restartY = h / 2 + 65;
if (mx > w / 2 - 100 && mx < w / 2 + 100 && my > restartY && my < restartY + 45) {
this.game.sounds.playMenuClick();
this.game.startGame(this.game.teamCount, new Set(this.game.cpuTeams), this.game.terrainShape, this.game.difficulty);
return;
}
// Menu button
const menuBtnY = restartY + 55;
if (mx > w / 2 - 100 && mx < w / 2 + 100 && my > menuBtnY && my < menuBtnY + 45) {
this.game.sounds.playMenuClick();
this.game.phase = 'menu';
}
}
handleWeaponPanelClick(mx, my) {
if (this.game.phase !== 'weaponSelect')
return;
const w = this.game.canvas.width;
const h = this.game.canvas.height;
const panelH = 80;
const panelY = h - panelH;
if (my < panelY || my > h)
return;
const weaponW = Math.min(100, (w - 40) / weapons_1.WEAPONS.length);
const startX = (w - weaponW * weapons_1.WEAPONS.length) / 2;
for (let i = 0; i < weapons_1.WEAPONS.length; i++) {
const wx = startX + i * weaponW;
if (mx > wx && mx < wx + weaponW) {
this.game.selectedWeaponIndex = i;
this.game.sounds.playMenuClick();
return;
}
}
}
handlePowerScroll(delta) {
if (this.game.phase !== 'aiming')
return;
this.game.aimPower = Math.max(0.1, Math.min(1, this.game.aimPower - delta * 0.02));
}
}
exports.Renderer = Renderer;
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"typescript","sessionId":"42238400-5229-4aed-974b-44511a39e143","tokensIn":2937067,"tokensOut":51369,"tokensTotal":2988436,"turns":67,"toolCalls":66,"failedToolCalls":1,"timestamp":"2026-07-31T16:45:20.515Z"} -->