Files
ravaged-planet/src/main.js
T

1675 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {AI_TYPES} from './ai.js?v=15';
import {DEATH_SPECS, EXPLOSION_SHAKE_REDUCTION_FACTOR, H, MAX_EXPLOSION_SHAKE_FACTOR, MAX_WIND, PARTICLE_AMOUNT, PARTICLE_FADE_AMOUNT, PARTICLE_MAX_POWER_FACTOR, PARTICLE_MIN_LIFETIME, PARTICLE_MIN_POWER_FACTOR, PARTICLE_POWER_REDUCTION_FACTOR, PARTICLE_TIME_FACTOR, PARTICLE_WIND_REDUCTION_FACTOR, PLAYER_ANGLE_FAST_INCREMENT, PLAYER_ANGLE_INCREMENT, PLAYER_ANGLE_TICK_SOUND_INTERVAL, PLAYER_COLORS, PLAYER_ENERGY_POWER_MULTIPLIER, PLAYER_EXPLOSION_PARTICLE_POWER, PLAYER_FALL_DAMAGE_FACTOR, PLAYER_FALL_DAMAGE_HEIGHT, PLAYER_INITIAL_POWER, PLAYER_MAX_ENERGY, PLAYER_POWER_FAST_INCREMENT, PLAYER_POWER_INCREMENT, PLAYER_POWER_TICK_SOUND_INTERVAL, PLAYER_STARTING_TOOLS, PLAYER_STARTING_WEAPONS, PLAYER_TANK_BOUNDING_RADIUS, PLAYER_TANK_Y_FOOTPRINT, SHIELD_TYPES, TRAJECTORY_FADE_SPEED, TRAJECTORY_FLOAT_SPEED, W, WEAPON_TYPES, Z, STARTING_SCORE, SCORE_PER_KILL, SCORE_FOR_WIN, MARKET_ITEMS, NAPALM_SPAWN_RATE, FIRE_DURATION, FIRE_DAMAGE} from './constants.js?v=15';
import {createCanvas, drawLine, drawRect, drawSemiCircle, drawText, loop, plot, strokeCircle} from './gfx.js?v=15';
import {afterKeyDelay, key, initClickCanvas, popClick, getPointer} from './input.js?v=15';
import {clamp, deg2rad, distance, parable, random, randomInt, vec, wrap} from './math.js?v=15';
import {PROJECTILE_TYPES} from './projectiles.js?v=15';
import {generateSky} from './sky.js?v=15';
import {playTickSound} from './sound.js?v=15';
import {clipTerrain, closestLand, collapseTerrain, generateTerrain, isTerrain, landHeight, startCollapseTerrain, collapseTerrainStep} from './terrain.js?v=15';
import {sample, shuffle} from './utils.js?v=15';
import {EXPLOSION_TYPES} from './weapons.js?v=15';
import {drawPanelBg, drawPanelTitle, drawPanelTitleFancy, drawPanelText, drawPanelDivider, drawPanelMenu, getPanelBounds, drawButton, checkHit, PANEL_X} from './panel.js?v=15';
let state = 'start-game';
let players = [];
let currentPlayer = 0;
let projectiles = [];
let explosions = [];
let wind = 0;
let particles = [];
let screenShake = 0;
let trajectories = [];
let idle = false;
let dt = 0;
let collapseState = null;
let deathOrderCounter = 0;
let napalmParticles = [];
let fireCells = [];
let smokeParticles = [];
let napalmEmitter = null;
const reservedPositions = new Set();
let winner;
let score = 0;
let round = 0;
let totalRounds = 1;
let selectedPlayers = 5;
let selectedTerrain = null;
let menuState = null;
let tracerMode = true;
export {tracerMode};
// Music
// const music = createAudioLoop('assets/battle.mp3');
// Init layers
const sky = createCanvas(W, H);
const traces = createCanvas(W, H);
const terrain = createCanvas(W, H, true);
const foreground = createCanvas(W, H);
// Composited layer
const framebuffer = createCanvas(W, H);
framebuffer.canvas.style.width = `${W * Z}px`;
framebuffer.canvas.style.height = `${H * Z}px`;
document.body.appendChild(framebuffer.canvas);
initClickCanvas(framebuffer.canvas);
function init() {
currentPlayer = 0;
projectiles = [];
explosions = [];
particles = [];
screenShake = 0;
trajectories = [];
idle = false;
winner = null;
wind = randomInt(-MAX_WIND, +MAX_WIND);
initLevel();
initPlayers();
}
function initNewGame() {
score = STARTING_SCORE;
round = 0;
totalRounds = 1;
selectedPlayers = 5;
selectedTerrain = null;
napalmParticles = [];
fireCells = [];
smokeParticles = [];
napalmEmitter = null;
menuState = {
selected: 0,
values: [1, 0, 0, 0],
};
state = 'start-menu';
}
function commitStartMenu() {
const playerCounts = [3, 4, 5, 6, 7, 8, 9, 10];
const roundCounts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const terrains = ['Random', 'Mountain', 'Sand'];
selectedPlayers = playerCounts[menuState.values[0]];
totalRounds = roundCounts[menuState.values[1]];
selectedTerrain = menuState.values[2] === 0 ? null : terrains[menuState.values[2]].toLowerCase();
tracerMode = menuState.values[3] === 0;
menuState = null;
score = STARTING_SCORE;
initAllPlayerStats();
state = 'market';
}
function updateStartMenu() {
const options = ['Players', 'Rounds', 'Terrain', 'Permanent tracer mode'];
const optionValues = [[3,4,5,6,7,8,9,10], [1,2,3,4,5,6,7,8,9,10], ['Random','Mountain','Sand'], ['On','Off']];
if (!menuState) {
menuState = {
selected: 0,
values: [optionValues[0].indexOf(selectedPlayers), optionValues[1].indexOf(totalRounds), selectedTerrain === null ? 0 : selectedTerrain === 'mountain' ? 1 : selectedTerrain === 'sand' ? 2 : 0, tracerMode ? 0 : 1],
};
}
const cycleValue = (opt, dir) => {
const vals = optionValues[opt];
menuState.values[opt] = (menuState.values[opt] + dir + vals.length) % vals.length;
};
if (key('ArrowUp')) {
if (afterKeyDelay()) {
menuState.selected = (menuState.selected - 1 + options.length) % options.length;
playTickSound();
}
}
else if (key('ArrowDown')) {
if (afterKeyDelay()) {
menuState.selected = (menuState.selected + 1) % options.length;
playTickSound();
}
}
else if (key('ArrowLeft')) {
if (afterKeyDelay()) {
cycleValue(menuState.selected, -1);
playTickSound();
}
}
else if (key('ArrowRight')) {
if (afterKeyDelay()) {
cycleValue(menuState.selected, 1);
playTickSound();
}
}
else if (key('Enter')) {
if (afterKeyDelay()) {
commitStartMenu();
}
}
const click = popClick();
if (click) {
for (let i = 0; i < 4; i++) {
const rowY = 120 + i * 30;
if (checkHit(click.x, click.y, PANEL_X+20, rowY, 168, 20)) {
menuState.selected = i;
playTickSound();
}
if (checkHit(click.x, click.y, PANEL_X+262, rowY, 24, 18)) {
menuState.selected = i;
cycleValue(i, -1);
playTickSound();
}
if (checkHit(click.x, click.y, PANEL_X+355, rowY, 24, 18)) {
menuState.selected = i;
cycleValue(i, 1);
playTickSound();
}
}
if (checkHit(click.x, click.y, PANEL_X+100, 318, 200, 22)) {
commitStartMenu();
}
}
}
function initAllPlayerStats() {
players = [];
const playerCount = selectedPlayers;
for (let i=0; i<playerCount; i++) {
const [color, borderColor] = PLAYER_COLORS[i];
players.push({
name: `Player ${i+1}`,
dead: false,
x:0, y:0, a:0,
c: color, cb: borderColor,
p: PLAYER_INITIAL_POWER,
tools: [],
weapons: (() => {
const w = PLAYER_STARTING_WEAPONS.map(x => ({...x}));
if (tracerMode) w.push({type: 'tracer', ammo: Infinity});
return w;
})(),
currentWeapon: 0,
energy: PLAYER_MAX_ENERGY,
shield: null,
ai: i !== 0 ? sample(Object.keys(AI_TYPES)) : undefined,
parachute: null,
fallHeight: 0,
score: 0,
kills: 0,
deaths: 0,
shotsFired: 0,
wins: 0,
lastDamageSource: null,
});
}
}
function buySelectedItem(item) {
const itemData = MARKET_ITEMS[item];
const humanPlayer = players.find(p => !p.ai);
if (!humanPlayer || score < itemData.price) return;
if (item === 'babyMissile') return;
if (item === 'parachute') {
score -= itemData.price;
let t = humanPlayer.tools.find(x => x.type === 'parachute');
if (t) t.ammo += itemData.ammo;
else humanPlayer.tools.push({type: 'parachute', ammo: itemData.ammo});
playTickSound();
} else if (item === 'shield') {
if (humanPlayer.shield) return;
score -= itemData.price;
humanPlayer.shield = {type:'shield', energy:SHIELD_TYPES.shield.energy};
playTickSound();
} else if (WEAPON_TYPES[item]) {
score -= itemData.price;
let existingWeapon = humanPlayer.weapons.find(w => w.type === item);
if (existingWeapon) {
existingWeapon.ammo += itemData.ammo;
} else {
humanPlayer.weapons.push({type: item, ammo: itemData.ammo});
}
playTickSound();
}
}
function sellSelectedItem(item) {
const itemData = MARKET_ITEMS[item];
const humanPlayer = players.find(p => !p.ai);
if (!humanPlayer) return;
if (item === 'babyMissile') return;
if (item === 'parachute') {
const t = humanPlayer.tools.find(x => x.type === 'parachute');
if (t && t.ammo > 0) {
const soldUnits = itemData.ammo - t.ammo;
const totalRefund = Math.floor((soldUnits + 1) * itemData.price / itemData.ammo);
const refund = totalRefund - (t._refunded || 0);
t._refunded = totalRefund;
score += refund;
t.ammo--;
if (t.ammo <= 0) humanPlayer.tools = humanPlayer.tools.filter(x => x.type !== 'parachute');
playTickSound();
}
} else if (item === 'shield') {
if (humanPlayer.shield) {
score += itemData.price;
humanPlayer.shield = null;
playTickSound();
}
} else {
const weapon = humanPlayer.weapons.find(w => w.type === item);
if (weapon && weapon.ammo > 0) {
const soldUnits = itemData.ammo - weapon.ammo;
const totalRefund = Math.floor((soldUnits + 1) * itemData.price / itemData.ammo);
const refund = totalRefund - (weapon._refunded || 0);
weapon._refunded = totalRefund;
score += refund;
weapon.ammo--;
if (weapon.ammo <= 0) {
humanPlayer.weapons = humanPlayer.weapons.filter(w => w.type !== item);
}
playTickSound();
}
}
}
function startRound() {
menuState = null;
state = 'round-start';
}
function updateMarket() {
const marketItems = Object.keys(MARKET_ITEMS).filter(item => !(tracerMode && item === 'tracer'));
if (!menuState) {
menuState = {
selected: 0,
scrollOffset: 0,
};
players.forEach(p => {
if (p.ai) aiBuy(p);
});
}
if (key('ArrowUp')) {
if (afterKeyDelay()) {
menuState.selected = (menuState.selected - 1 + marketItems.length) % marketItems.length;
if (menuState.selected < menuState.scrollOffset) menuState.scrollOffset = menuState.selected;
if (menuState.selected >= menuState.scrollOffset + 8) menuState.scrollOffset = menuState.selected - 7;
playTickSound();
}
}
else if (key('ArrowDown')) {
if (afterKeyDelay()) {
menuState.selected = (menuState.selected + 1) % marketItems.length;
if (menuState.selected >= menuState.scrollOffset + 8) menuState.scrollOffset = menuState.selected - 7;
if (menuState.selected < menuState.scrollOffset) menuState.scrollOffset = menuState.selected;
playTickSound();
}
}
else if (key('ArrowRight')) {
if (afterKeyDelay()) {
buySelectedItem(marketItems[menuState.selected]);
}
}
else if (key(' ')) {
idle = true;
}
else if (key('Enter')) {
if (afterKeyDelay()) {
startRound();
}
}
else {
idle = true;
}
const click = popClick();
if (click) {
const arrowX = PANEL_X + 15;
const arrowW = 22;
const arrowH = 54;
const listX = PANEL_X + 40;
const listW = 340;
const itemH = 18;
const y = 94;
const maxVisible = 8;
// Up/down arrow buttons (left side) — 3× taller
if (checkHit(click.x, click.y, arrowX, y, arrowW, arrowH)) {
const prev = menuState.selected;
menuState.selected = (menuState.selected - 1 + marketItems.length) % marketItems.length;
if (menuState.selected < menuState.scrollOffset) menuState.scrollOffset = menuState.selected;
if (menuState.selected >= menuState.scrollOffset + maxVisible) menuState.scrollOffset = menuState.selected - (maxVisible - 1);
if (menuState.selected !== prev) playTickSound();
return;
}
if (checkHit(click.x, click.y, arrowX, y + (maxVisible - 1) * itemH - arrowH + itemH, arrowW, arrowH)) {
const prev = menuState.selected;
menuState.selected = (menuState.selected + 1) % marketItems.length;
if (menuState.selected >= menuState.scrollOffset + maxVisible) menuState.scrollOffset = menuState.selected - (maxVisible - 1);
if (menuState.selected < menuState.scrollOffset) menuState.scrollOffset = menuState.selected;
if (menuState.selected !== prev) playTickSound();
return;
}
for (let i = 0; i < Math.min(maxVisible, marketItems.length); i++) {
const itemY = y + i * itemH;
if (checkHit(click.x, click.y, listX, itemY, listW, itemH)) {
const itemIndex = i + menuState.scrollOffset;
if (menuState.selected === itemIndex) {
buySelectedItem(marketItems[itemIndex]);
} else {
menuState.selected = itemIndex;
playTickSound();
}
return;
}
}
// Buy button
const btnY = 246;
const btnH = 22;
if (checkHit(click.x, click.y, listX + 20, btnY, 90, btnH)) {
buySelectedItem(marketItems[menuState.selected]);
return;
}
if (checkHit(click.x, click.y, PANEL_X+100, 318, 200, 22)) {
startRound();
}
}
}
function aiBuy(player) {
player.score = STARTING_SCORE;
const marketItems = Object.keys(MARKET_ITEMS).filter(x => x !== 'babyMissile' && !(tracerMode && x === 'tracer') && (WEAPON_TYPES[x] || x === 'parachute' || x === 'shield'));
let attempts = 0;
while (player.score > 0 && attempts < 20) {
const item = sample(marketItems);
const itemData = MARKET_ITEMS[item];
if (player.score >= itemData.price) {
player.score -= itemData.price;
if (item === 'parachute') {
let t = player.tools.find(x => x.type === 'parachute');
if (t) t.ammo += itemData.ammo;
else player.tools.push({type: 'parachute', ammo: itemData.ammo});
} else if (item === 'shield') {
if (!player.shield) player.shield = {type:'shield', energy:SHIELD_TYPES.shield.energy};
} else {
let existingWeapon = player.weapons.find(w => w.type === item);
if (existingWeapon) {
existingWeapon.ammo += itemData.ammo;
} else {
player.weapons.push({type: item, ammo: itemData.ammo});
}
}
}
attempts++;
}
}
function initPlayers() {
const playerCount = selectedPlayers;
const existingPlayers = players.length === playerCount ? players : [];
if (existingPlayers.length === playerCount) {
for (let i=0; i<playerCount; i++) {
const player = existingPlayers[i];
player.dead = false;
player.x = 0;
player.y = 0;
player.a = 0;
player.p = PLAYER_INITIAL_POWER;
player.currentWeapon = 0;
player.energy = PLAYER_MAX_ENERGY;
if (player.shield) player.shield.energy = SHIELD_TYPES[player.shield.type].energy;
player.parachute = null;
player.fallHeight = 0;
player.kills = 0;
player.deaths = 0;
player.deathOrder = -1;
player.shotsFired = 0;
player.totalEarned = player.totalEarned || 0;
player.lastDamageSource = null;
}
players = existingPlayers;
} else {
players = [];
for (let i=0; i<playerCount; i++) {
const [color, borderColor] = PLAYER_COLORS[i];
players.push({
name: `Player ${i+1}`,
dead: false,
x:0, y:0, a:0,
c: color, cb: borderColor,
p: PLAYER_INITIAL_POWER,
tools: [],
weapons: (() => {
const w = PLAYER_STARTING_WEAPONS.map(x => ({...x}));
if (tracerMode) w.push({type: 'tracer', ammo: Infinity});
return w;
})(),
currentWeapon: 0,
energy: PLAYER_MAX_ENERGY,
shield: null,
ai: i !== 0 ? sample(Object.keys(AI_TYPES)) : undefined,
parachute: null,
fallHeight: 0,
score: 0,
totalEarned: 0,
kills: 0,
deaths: 0,
deathOrder: -1,
shotsFired: 0,
wins: 0,
lastDamageSource: null,
});
}
}
players = shuffle(players);
for (let i=0; i<players.length; i++) {
const player = players[i];
player.x = 50 + (W-100) / (players.length - 1) * i;
player.y = landHeight(terrain, player.x) + 1;
player.a = player.x > W/2 ? 45 : 180-45;
clipTerrain(terrain, (ctx) => drawRect(ctx, player.x-4, 0, 8, player.y, ctx.color));
}
}
function initLevel() {
generateSky(sky);
generateTerrain(terrain);
}
function update() {
idle = false;
updateParticles();
updateNapalm(dt);
if (state === 'start-game') {
initNewGame();
}
else if (state === 'start-menu') {
updateStartMenu();
}
else if (state === 'market') {
updateMarket();
}
else if (state === 'round-start') {
round++;
init();
state = 'start-turn';
}
else if (state === 'start-turn') {
state = 'aim';
}
else if (state === 'aim') {
const player = players[currentPlayer];
const {x, y, a, p, weapons, energy} = player;
const maxPower = energy * PLAYER_ENERGY_POWER_MULTIPLIER;
player.p = clamp(0, player.p, maxPower);
const isPrecise = key('Alt');
const isFast = key('Shift');
const isReverse = key('Shift');
let shoot;
// Check for clicks on controls
const click = popClick();
if (click && !player.ai) {
const maxPower = player.energy * PLAYER_ENERGY_POWER_MULTIPLIER;
// Angle gauge (cx=90, cy=312, r=60)
const gaugeCx = 80, gaugeCy = 357, gaugeR = 60;
const gdx = click.x - gaugeCx, gdy = click.y - gaugeCy;
if (gdx*gdx + gdy*gdy < gaugeR*gaugeR*1.3 && gdy < 0) {
let aDeg = (-Math.atan2(gdy, gdx) + Math.PI) * 180 / Math.PI;
if (aDeg < 0) aDeg += 360;
if (aDeg > 180) aDeg = 360 - aDeg;
player.a = Math.round(clamp(0, aDeg, 180));
return;
}
// Angle < button (left half, 1px gap from center)
if (click.x >= 20 && click.x < 80 && click.y >= 369 && click.y < 395) {
player.a = wrap(0, player.a - 1, 180);
return;
}
// Angle > button (right half, 1px gap from center)
if (click.x >= 81 && click.x < 141 && click.y >= 369 && click.y < 395) {
player.a = wrap(0, player.a + 1, 180);
return;
}
// Power bar (610, 286, 25×80)
if (click.x >= 610 && click.x < 635 && click.y >= 286 && click.y < 366) {
const pRatio = 1 - (click.y - 286) / 80;
player.p = Math.round(clamp(0, pRatio, 1) * maxPower);
return;
}
// Power ▲ (1px gap above bar)
if (click.x >= 610 && click.x < 635 && click.y >= 257 && click.y < 285) {
player.p = clamp(0, player.p + 1, maxPower);
return;
}
// Power ▼ (1px gap below bar)
if (click.x >= 610 && click.x < 635 && click.y >= 367 && click.y < 395) {
player.p = clamp(0, player.p - 1, maxPower);
return;
}
// Weapon switch (385, 360, 140×28)
if (click.x >= 385 && click.x < 525 && click.y >= 360 && click.y < 388) {
player.currentWeapon = wrap(0, player.currentWeapon + 1, player.weapons.length - 1);
playTickSound();
return;
}
// FIRE button (280, 355, 80×40)
if (click.x >= 280 && click.x < 360 && click.y >= 355 && click.y < 395) {
shoot = {a: player.a, p: player.p};
}
}
if (player.ai) {
let ai = AI_TYPES[player.ai];
const plan = ai.decide(player);
player.a = wrap(0, plan.a, 180);
player.p = clamp(0, plan.p, maxPower);
player.currentWeapon = clamp(0, plan.currentWeapon, weapons.length-1);
shoot = true;
}
else if (key('ArrowLeft')) {
if (isPrecise && !afterKeyDelay()) return;
let incr = isFast ? PLAYER_ANGLE_FAST_INCREMENT : PLAYER_ANGLE_INCREMENT;
player.a = wrap(0, a -incr, 180);
if (isPrecise || isFast || a % PLAYER_ANGLE_TICK_SOUND_INTERVAL === 0) playTickSound();
} else if (key('ArrowRight')) {
if (isPrecise && !afterKeyDelay()) return;
let incr = isFast ? PLAYER_ANGLE_FAST_INCREMENT : PLAYER_ANGLE_INCREMENT;
player.a = wrap(0, a +incr, 180);
if (isPrecise || isFast || a % PLAYER_ANGLE_TICK_SOUND_INTERVAL === 0) playTickSound();
} else if (key('ArrowUp')) {
if (isPrecise && !afterKeyDelay()) return;
let incr = isFast ? PLAYER_POWER_FAST_INCREMENT : PLAYER_POWER_INCREMENT;
player.p = clamp(0, p +incr, maxPower);
if (p < maxPower && (isPrecise || isFast || p % PLAYER_POWER_TICK_SOUND_INTERVAL === 0)) playTickSound();
} else if (key('ArrowDown')) {
if (isPrecise && !afterKeyDelay()) return;
let incr = isFast ? PLAYER_POWER_FAST_INCREMENT : PLAYER_POWER_INCREMENT;
player.p = clamp(0, p -incr, maxPower);
if (p > 0 && (isPrecise || isFast || p % PLAYER_POWER_TICK_SOUND_INTERVAL === 0)) playTickSound();
} else if (key('Tab')) {
if (!afterKeyDelay()) return;
const dir = isReverse ? -1 : 1;
player.currentWeapon = wrap(0, player.currentWeapon+dir, player.weapons.length-1);
playTickSound();
} else if (key(' ')) {
if (!afterKeyDelay()) return;
shoot = {a, p};
} else {
idle = true;
}
if (shoot) {
const {a, p, weapons, currentWeapon} = player;
const [px, py] = vec(x, y-3, a+180, 5);
const weapon = weapons[currentWeapon];
const {projectile} = WEAPON_TYPES[weapon.type];
const projectileType = PROJECTILE_TYPES[projectile.type];
weapon.ammo -= 1;
player.shotsFired++;
projectileType.create(projectile, player, weapon, px, py, a, p, wind)
.forEach(x => projectiles.push(x));
state = 'shoot';
}
}
else if (state === 'shoot') {
for (let i=projectiles.length-1; i>=0; i--) {
const projectile = projectiles[i];
const projectileType = PROJECTILE_TYPES[projectile.type];
if (projectileType.update(projectile, terrain, projectiles, trajectories, explosions, dt)) continue;
projectileType.stop(projectile);
projectiles.splice(i, 1);
}
if (projectiles.length === 0) {
state = 'explosions';
}
}
else if (state === 'explosions') {
for (let i=explosions.length-1; i>=0; i--) {
const explosion = explosions[i];
const explosionType = EXPLOSION_TYPES[explosion.type];
screenShake = (
clamp(0, explosion.r, MAX_EXPLOSION_SHAKE_FACTOR) /
EXPLOSION_SHAKE_REDUCTION_FACTOR
);
if (explosionType.update(explosion, dt)) continue;
screenShake = 0;
explosionType.clip(explosion, terrain);
explosionType.stop(explosion);
for (let player of players) if (!player.dead) {
let damage = explosionType.damage(explosion, player);
let remainingDamage = damage;
if (!damage) continue;
if (explosion.source) player.lastDamageSource = explosion.source;
if (player.shield) {
remainingDamage = clamp(0, damage-player.shield.energy, Infinity);
player.shield.energy = clamp(0, player.shield.energy-damage, Infinity);
}
player.energy -= remainingDamage;
}
explosions.splice(i, 1);
}
if (explosions.length === 0 && !napalmEmitter && fireCells.length === 0 && napalmParticles.length === 0) {
state = 'land-collapse';
}
}
else if (state === 'land-collapse') {
if (!collapseState) collapseState = startCollapseTerrain(terrain);
let done = false;
for (let i = 0; i < 2; i++) {
if (collapseTerrainStep(terrain, collapseState)) {
collapseState = null;
state = 'land-players';
done = true;
break;
}
}
}
else if (state === 'land-players') {
let stable = true;
for (let player of players) {
if (player.dead) continue;
const y = closestLand(terrain, player.x, player.y);
if (player.y !== y) {
stable = false;
player.y++;
if (player.fallHeight++ >= PLAYER_FALL_DAMAGE_HEIGHT) {
if (player.energy > 0 && player.parachute) continue;
const parachute = player.tools.find(x => x.type === 'parachute');
if (player.energy > 0 && parachute && parachute.ammo > 0) {
player.parachute = parachute;
parachute.ammo--;
continue;
}
player.energy -= PLAYER_FALL_DAMAGE_FACTOR;
}
} else {
player.parachute = null;
}
}
if (stable) state = 'destroy-players';
}
else if (state === 'destroy-players') {
if (typeof deathOrderCounter === 'undefined') deathOrderCounter = 0;
const dyingPlayer = players.find(x => x.energy<=0 && !x.dead);
if (!dyingPlayer) {deathOrderCounter = undefined; state = 'end-turn'; return}
const {x, y, c} = dyingPlayer;
const explosionSpec = sample(DEATH_SPECS);
const explosionType = EXPLOSION_TYPES[explosionSpec.type];
explosions.push(explosionType.create(explosionSpec, x, y));
createParticles(x, y, PLAYER_EXPLOSION_PARTICLE_POWER, c);
dyingPlayer.dead = true;
dyingPlayer.deaths++;
dyingPlayer.deathOrder = deathOrderCounter++;
if (dyingPlayer.lastDamageSource && dyingPlayer.lastDamageSource !== dyingPlayer) {
dyingPlayer.lastDamageSource.kills++;
}
state = 'explosions';
}
else if (state === 'end-turn') {
const alivePlayers = players.filter(x => !x.dead);
if (alivePlayers.length === 0) {
return state = 'round-end';
} else if (alivePlayers.length === 1) {
winner = alivePlayers[0];
winner.wins++;
return state = 'round-end';
}
for (let player of players) {
player.weapons = player.weapons.filter(x => x.ammo > 0);
player.tools = player.tools.filter(x => x.ammo > 0);
player.currentWeapon = wrap(0, player.currentWeapon, player.weapons.length-1);
if (player.shield && player.shield.energy <= 0) player.shield = null;
player.fallHeight = 0;
}
for (let p=0; p<players.length; p++) {
const i = wrap(0, currentPlayer+p+1, players.length-1);
if (!players[i].dead) {currentPlayer = i; break}
}
fadeTrajectories();
state = 'start-turn';
}
else if (state === 'round-end') {
if (!menuState) {
menuState = {scoreAwarded: false};
for (let player of players) {
player.score += player.kills * SCORE_PER_KILL;
player.totalEarned += player.kills * SCORE_PER_KILL;
}
if (winner) {
winner.score += SCORE_FOR_WIN;
winner.totalEarned += SCORE_FOR_WIN;
}
const humanPlayer = players.find(p => !p.ai);
if (humanPlayer) {
score += humanPlayer.kills * SCORE_PER_KILL;
if (winner === humanPlayer) score += SCORE_FOR_WIN;
}
menuState.scoreAwarded = true;
}
if (key('Enter')) {
if (afterKeyDelay()) {
menuState = null;
if (round < totalRounds) {
state = 'market';
} else {
state = 'game-over';
}
}
}
const click = popClick();
if (click && checkHit(click.x, click.y, PANEL_X+100, 318, 200, 22)) {
menuState = null;
if (round < totalRounds) {
state = 'market';
} else {
state = 'game-over';
}
}
idle = true;
}
else if (state === 'game-over') {
if (key('Enter')) {
if (afterKeyDelay()) {
state = 'start-game';
}
}
const click = popClick();
if (click && checkHit(click.x, click.y, PANEL_X+100, 290, 200, 22)) {
state = 'start-game';
}
idle = true;
}
else {
throw new Error(`Invalid state, ${state}`);
}
}
export function createParticles(x, y, p, c) {
for (let i = 0; i < PARTICLE_AMOUNT; i++) {
particles.push({
t: 0,
ox: x, x: x,
oy: y, y: y,
a: randomInt(0, 359),
p: p * random(PARTICLE_MIN_POWER_FACTOR, PARTICLE_MAX_POWER_FACTOR),
// @ts-ignore: canvas color hack
c, alpha: 255,
});
}
}
function updateParticles() {
for (let i=particles.length-1; i>=0; i--) {
const particle = particles[i];
if (
particle.y > H ||
particle.alpha <= 0 ||
particle.t > PARTICLE_MIN_LIFETIME && isTerrain(terrain, particle.x, particle.y)
) {
particles.splice(i, 1);
continue;
}
const {ox, oy, t, a, p} = particle;
const [tx, ty] = parable(
t / PARTICLE_TIME_FACTOR,
ox, oy, deg2rad(180+a),
p / PARTICLE_POWER_REDUCTION_FACTOR,
wind / PARTICLE_WIND_REDUCTION_FACTOR,
);
particle.t++;
particle.x = tx;
particle.y = ty;
particle.alpha -= PARTICLE_FADE_AMOUNT;
}
}
export function spawnNapalm(x, y, totalParticles, source) {
napalmEmitter = {x, y: y - 1, total: totalParticles, emitted: 0, totalEmitted: 0, source};
}
function tryEmitParticle(x, y, source) {
const p = {x, y, dir: 0, hasDir: false, alive: true, source, emitX: x, emitY: y};
const below = isTerrain(terrain, p.x, p.y + 1);
if (!below) return p;
const fire = hasFireAt(p.x, p.y);
if (!fire) return p;
const canFlowDL = !isTerrain(terrain, p.x - 1, p.y + 1);
const canFlowDR = !isTerrain(terrain, p.x + 1, p.y + 1);
const canSpillL = !isTerrain(terrain, p.x - 1, p.y);
const canSpillR = !isTerrain(terrain, p.x + 1, p.y);
if (canFlowDL || canFlowDR || canSpillL || canSpillR) return p;
const occupied = napalmParticles.some(np => np.alive && np.x === p.x && np.y === p.y) || hasFireAt(p.x, p.y);
if (occupied) {
for (let y = p.y - 1; y >= 0; y--) {
const np = napalmParticles.find(n => n.alive && n.x === p.x && n.y === y);
if (np) np.y--;
else break;
}
if (!isTerrain(terrain, p.x, p.y - 1) && !napalmParticles.some(np => np.alive && np.x === p.x && np.y === p.y - 1)) {
p.y--;
return p;
}
}
return null;
}
function updateNapalm(dt) {
if (napalmEmitter) {
napalmEmitter.emitted += dt * NAPALM_SPAWN_RATE;
while (napalmEmitter.emitted >= 1 && napalmEmitter.totalEmitted < napalmEmitter.total) {
napalmEmitter.emitted--;
napalmEmitter.totalEmitted++;
const p = tryEmitParticle(napalmEmitter.x, napalmEmitter.y, napalmEmitter.source);
if (p) napalmParticles.push(p);
}
if (napalmEmitter.totalEmitted >= napalmEmitter.total) napalmEmitter = null;
}
napalmParticles.sort((a, b) => b.y - a.y);
reservedPositions.clear();
for (const p of napalmParticles) if (p.alive) reservedPositions.add(p.x + ',' + p.y);
for (let p of napalmParticles) updateParticle(p);
napalmParticles = napalmParticles.filter(p => p.alive);
// Settle pass: collapse unsupported columns, then let particles flow/spill
let changed = true;
while (changed) {
changed = false;
reservedPositions.clear();
for (const p of napalmParticles) if (p.alive) reservedPositions.add(p.x + ',' + p.y);
napalmParticles.sort((a, b) => b.y - a.y);
for (let p of napalmParticles) {
if (!p.alive) continue;
const below = isTerrain(terrain, p.x, p.y + 1) || isTank(p.x, p.y + 1);
const napalmBelow = hasNapalmAt(p.x, p.y + 1, p);
if (!below && !napalmBelow && p.y < H - 1) {
claimMove(p, p.x, p.y + 1);
changed = true;
}
}
if (changed) {
reservedPositions.clear();
for (const p of napalmParticles) if (p.alive) reservedPositions.add(p.x + ',' + p.y);
napalmParticles.sort((a, b) => b.y - a.y);
for (let p of napalmParticles) if (p.alive) updateParticle(p);
napalmParticles = napalmParticles.filter(p => p.alive);
}
}
for (let f of fireCells) f.timeLeft -= dt;
const deadFireCells = fireCells.filter(f => f.timeLeft <= 0);
fireCells = fireCells.filter(f => f.timeLeft > 0);
for (let f of deadFireCells) {
clipTerrain(terrain, (ctx) => { ctx.fillStyle = '#000'; ctx.fillRect(f.x, f.y + 1, 1, 1); });
for (let dx = -2; dx <= 2; dx++) {
for (let dy = 0; dy <= 2; dy++) {
if (dx === 0 && dy === 0) continue;
const sx = f.x + dx;
const sy = f.y + 1 + dy;
if (!isTerrain(terrain, sx, sy)) continue;
const dist = Math.max(Math.abs(dx), Math.abs(dy));
const alpha = dist === 1 ? 0.4 : 0.2;
terrain.fillStyle = `rgba(0,0,0,${alpha})`;
terrain.fillRect(sx, sy, 1, 1);
}
}
}
for (let f of fireCells) {
if (Math.random() < dt * 12) smokeParticles.push({
x: f.x + (Math.random() > 0.5 ? 1 : -1) * Math.random(),
y: f.y,
vx: (Math.random() - 0.5) * 5,
vy: -(8 + Math.random() * 12),
alpha: 0.35 + Math.random() * 0.2,
lifetime: 1.2 + Math.random() * 1.0,
});
}
for (let s of smokeParticles) {
s.x += s.vx * dt;
s.y += s.vy * dt;
s.alpha -= dt / s.lifetime;
s.vy *= 0.98;
}
smokeParticles = smokeParticles.filter(s => s.alpha > 0);
applyFireDamage(dt);
}
function hasFireAt(x, y) {
return fireCells.some(f => f.x === x && f.y === y);
}
function createFire(x, y, source) {
if (!hasFireAt(x, y)) fireCells.push({x, y, timeLeft: FIRE_DURATION, source});
}
function hasNapalmAt(x, y, exclude) {
return napalmParticles.some(np => np.alive && np !== exclude && np.x === x && np.y === y);
}
function claimMove(p, nx, ny) {
const key = nx + ',' + ny;
if (reservedPositions.has(key)) return false;
reservedPositions.delete(p.x + ',' + p.y);
reservedPositions.add(key);
p.x = nx;
p.y = ny;
return true;
}
function pushNapalmColumn(x, startY) {
const column = [];
for (let y = startY; y >= 0; y--) {
const np = napalmParticles.find(n => n.alive && n.x === x && n.y === y);
if (np) column.push(np);
else break;
}
for (let i = column.length - 1; i >= 0; i--) {
const np = column[i];
const ok = np.x + ',' + np.y;
np.y--;
reservedPositions.delete(ok);
reservedPositions.add(np.x + ',' + np.y);
}
}
function pushRow(x, y, dir) {
const row = [];
let cx = x + dir;
while (!isTerrain(terrain, cx, y)) {
const np = napalmParticles.find(n => n.alive && n.x === cx && n.y === y);
if (np) {
row.push(np);
cx += dir;
} else break;
}
for (let i = row.length - 1; i >= 0; i--) {
const np = row[i];
const ok = np.x + ',' + np.y;
np.x += dir;
reservedPositions.delete(ok);
reservedPositions.add(np.x + ',' + np.y);
}
}
function tryFlow(p, dir) {
const nx = p.x + dir;
const ny = p.y + 1;
if (!isTerrain(terrain, nx, ny)) {
if (hasNapalmAt(nx, ny, p)) return false;
if (!claimMove(p, nx, ny)) return false;
if (!p.hasDir) { p.dir = dir; p.hasDir = true; }
return true;
}
return false;
}
function trySpill(p, dir) {
const nx = p.x + dir;
if (!isTerrain(terrain, nx, p.y)) {
if (hasNapalmAt(nx, p.y, p)) return false;
if (!claimMove(p, nx, p.y)) return false;
if (!p.hasDir) { p.dir = dir; p.hasDir = true; }
return true;
}
return false;
}
function updateParticle(p) {
const below = isTerrain(terrain, p.x, p.y + 1) || isTank(p.x, p.y + 1);
const napalmBelow = hasNapalmAt(p.x, p.y + 1, p);
if (!below && !napalmBelow) {
if (p.y < H - 1) { claimMove(p, p.x, p.y + 1); return; }
p.alive = false;
reservedPositions.delete(p.x + ',' + p.y);
return;
}
if (!below && napalmBelow) {
// standing on another napalm particle - behave as if on fire terrain
}
if (below && !hasFireAt(p.x, p.y) && !napalmBelow && !hasFireAt(p.x, p.y + 1)) {
createFire(p.x, p.y, p.source);
p.alive = false;
reservedPositions.delete(p.x + ',' + p.y);
return;
}
if (!p.hasDir) {
const flowDirs = [];
if (!isTerrain(terrain, p.x - 1, p.y + 1)) flowDirs.push(-1);
if (!isTerrain(terrain, p.x + 1, p.y + 1)) flowDirs.push(1);
if (flowDirs.length > 0) {
const dir = sample(flowDirs);
if (claimMove(p, p.x + dir, p.y + 1)) {
p.dir = dir;
p.hasDir = true;
return;
}
if (flowDirs.length > 1) {
const other = flowDirs.find(d => d !== dir);
if (claimMove(p, p.x + other, p.y + 1)) {
p.dir = other;
p.hasDir = true;
return;
}
}
}
const spillDirs = [];
if (!isTerrain(terrain, p.x - 1, p.y)) spillDirs.push(-1);
if (!isTerrain(terrain, p.x + 1, p.y)) spillDirs.push(1);
if (spillDirs.length > 0) {
const dir = sample(spillDirs);
if (claimMove(p, p.x + dir, p.y)) {
p.dir = dir;
p.hasDir = true;
return;
}
if (spillDirs.length > 1) {
const other = spillDirs.find(d => d !== dir);
if (claimMove(p, p.x + other, p.y)) {
p.dir = other;
p.hasDir = true;
return;
}
}
}
const canPushLeft = !isTerrain(terrain, p.x - 1, p.y)
&& napalmParticles.some(np => np.alive && np.x === p.x - 1 && np.y === p.y)
&& !isTerrain(terrain, p.x - 2, p.y);
const canPushRight = !isTerrain(terrain, p.x + 1, p.y)
&& napalmParticles.some(np => np.alive && np.x === p.x + 1 && np.y === p.y)
&& !isTerrain(terrain, p.x + 2, p.y);
if (canPushLeft || canPushRight) {
const dir = canPushLeft && canPushRight ? sample([-1, 1]) : (canPushLeft ? -1 : 1);
pushRow(p.x, p.y, dir);
return;
}
if (p.x === p.emitX && p.y === p.emitY) {
pushNapalmColumn(p.x, p.y - 1);
}
return;
}
if (tryFlow(p, p.dir) || trySpill(p, p.dir)) return;
const otherDir = p.dir === -1 ? 1 : -1;
if (tryFlow(p, otherDir)) {
p.dir = otherDir;
return;
}
if (p.x === p.emitX && p.y === p.emitY) {
pushNapalmColumn(p.x, p.y - 1);
}
}
function applyFireDamage(dt) {
for (let player of players) {
if (player.dead) continue;
for (let fire of fireCells) {
if (distance(fire.x, fire.y, player.x, player.y + PLAYER_TANK_Y_FOOTPRINT) <= PLAYER_TANK_BOUNDING_RADIUS + 1) {
player.energy -= FIRE_DAMAGE * dt;
player.lastDamageSource = fire.source;
}
}
}
}
function drawNapalm() {
for (let s of smokeParticles) {
foreground.globalAlpha = s.alpha * 0.7;
drawRect(foreground, s.x, s.y - 1, 3, 3, '#999');
foreground.globalAlpha = 1;
}
for (let f of fireCells) {
const t = f.timeLeft / FIRE_DURATION;
const height = Math.max(1, Math.floor(10 * t * (0.6 + Math.random() * 0.4)));
for (let i = 0; i < height; i++) {
const alpha = 1 - i / height * 0.4;
foreground.globalAlpha = alpha;
const g = Math.floor(30 + 225 * (1 - i / height));
const b = Math.floor(Math.max(0, 40 - i * 8));
const sway = i > 2 ? (Math.random() > 0.5 ? 1 : -1) : 0;
drawRect(foreground, f.x + sway, f.y - i, 1, 1, `rgb(255,${g},${b})`);
}
if (height > 8 && Math.random() > 0.5) {
foreground.globalAlpha = 0.6 + Math.random() * 0.4;
drawRect(foreground, f.x + (Math.random() > 0.5 ? 2 : -2), f.y - height + 1, 1, 1, '#fff');
}
}
foreground.globalAlpha = 1;
for (let p of napalmParticles) {
drawRect(foreground, p.x, p.y, 1, 1, '#ff8800');
}
}
export function isTank(x, y) {
for (let player of players) {
if (player.dead) continue;
if (
distance(x, y, player.x, player.y+PLAYER_TANK_Y_FOOTPRINT) <=
PLAYER_TANK_BOUNDING_RADIUS
) {
return true;
}
}
}
export function isTankShield(x, y) {
for (let player of players) {
if (player.dead) continue;
if (!player.shield) continue;
const shieldType = SHIELD_TYPES[player.shield.type];
const playerDistance = distance(x, y, player.x, player.y+PLAYER_TANK_Y_FOOTPRINT);
if (playerDistance - shieldType.r <= 1) {
return {player, shieldType};
}
}
}
function draw() {
if (idle && particles.length===0 && fireCells.length===0 && smokeParticles.length===0 && napalmParticles.length===0 && state !== 'start-menu' && state !== 'market' && state !== 'round-end' && state !== 'game-over' && state !== 'aim') return;
foreground.clearRect(0, 0, W, H);
drawTrajectories();
drawPlayers();
drawProjectile();
drawExplosions();
drawNapalm();
drawParticles();
drawStatus();
for (let c of [sky, traces, terrain, foreground]) {
framebuffer.drawImage(c.canvas, 0, 0);
}
if (state === 'start-menu') {
drawStartMenuPanel();
} else if (state === 'market') {
drawMarketPanel();
} else if (state === 'round-end') {
drawRoundEndPanel();
} else if (state === 'game-over') {
drawGameOverPanel();
}
drawGameControls();
drawScreenShake();
}
function drawGameControls() {
if (state !== 'aim') return;
const player = players[currentPlayer];
if (player.ai) return;
const maxPower = player.energy * PLAYER_ENERGY_POWER_MULTIPLIER;
// ---- Angle gauge (bottom-left) ----
const cx = 80, cy = 357, r = 60;
framebuffer.globalAlpha = 0.65;
framebuffer.beginPath();
framebuffer.arc(cx, cy, r, Math.PI, 0);
framebuffer.strokeStyle = '#888';
framebuffer.lineWidth = 2;
framebuffer.stroke();
framebuffer.globalAlpha = 1;
// Tick marks every 30 degrees
for (let a = 0; a <= 180; a += 30) {
const ta = Math.PI + a * Math.PI / 180;
const inner = r - 8, outer = r;
drawLine(framebuffer,
cx + Math.cos(ta) * inner, cy + Math.sin(ta) * inner,
cx + Math.cos(ta) * outer, cy + Math.sin(ta) * outer,
'#aaa');
}
// Arrow pointer
const pa = Math.PI + player.a * Math.PI / 180;
const px = cx + Math.cos(pa) * r;
const py = cy + Math.sin(pa) * r;
drawLine(framebuffer, cx, cy, px, py, '#f80');
const arrowAngle = 0.5;
const arrowLen = 12;
const ax1 = px - Math.cos(pa - arrowAngle) * arrowLen;
const ay1 = py - Math.sin(pa - arrowAngle) * arrowLen;
const ax2 = px - Math.cos(pa + arrowAngle) * arrowLen;
const ay2 = py - Math.sin(pa + arrowAngle) * arrowLen;
drawLine(framebuffer, px, py, ax1, ay1, '#f80');
drawLine(framebuffer, px, py, ax2, ay2, '#f80');
framebuffer.beginPath();
framebuffer.arc(cx, cy, 4, 0, Math.PI * 2);
framebuffer.fillStyle = '#fa0';
framebuffer.fill();
// Angle fine-tune buttons (separated by 1px gap)
const btnY = 369;
const btnH = 26;
framebuffer.globalAlpha = 0.5;
drawRect(framebuffer, cx - r, btnY, r, btnH, '#555');
drawRect(framebuffer, cx + 1, btnY, r, btnH, '#555');
framebuffer.globalAlpha = 0.3;
drawRect(framebuffer, cx - r, btnY, r, 1, '#fff');
drawRect(framebuffer, cx + 1, btnY, r, 1, '#fff');
framebuffer.globalAlpha = 1;
framebuffer.font = '14px ibm-vga';
framebuffer.textAlign = 'center';
framebuffer.textBaseline = 'middle';
framebuffer.fillStyle = '#fff';
framebuffer.fillText('<', cx - r/2, btnY + btnH/2);
framebuffer.fillText('>', cx + 1 + r/2, btnY + btnH/2);
// ---- Power gauge (bottom-right corner) ----
const barX = 610, barY = 286, barW = 25, barH = 80;
const powerRatio = maxPower > 0 ? player.p / maxPower : 0;
framebuffer.globalAlpha = 0.4;
drawRect(framebuffer, barX, barY, barW, barH, '#333');
framebuffer.globalAlpha = 0.7;
const fillH = Math.round(barH * powerRatio);
drawRect(framebuffer, barX, barY + barH - fillH, barW, fillH, '#f80');
framebuffer.globalAlpha = 1;
// Power ▲ ▼ buttons (1px gap from gauge)
const btnW = barW;
framebuffer.globalAlpha = 0.5;
drawRect(framebuffer, barX, barY - 28 - 1, btnW, 28, '#555');
drawRect(framebuffer, barX, barY + barH + 1, btnW, 28, '#555');
framebuffer.globalAlpha = 1;
framebuffer.font = '10px ibm-bios';
framebuffer.textAlign = 'center';
framebuffer.textBaseline = 'middle';
framebuffer.fillStyle = '#fff';
framebuffer.fillText('▲', barX + btnW / 2, barY - 14);
framebuffer.fillText('▼', barX + btnW / 2, barY + barH + 14);
// ---- FIRE button (center bottom) ----
framebuffer.globalAlpha = 0.55;
drawRect(framebuffer, 280, 355, 80, 40, '#c00');
framebuffer.globalAlpha = 0.3;
drawRect(framebuffer, 280, 355, 80, 2, '#fff');
drawRect(framebuffer, 280, 355, 2, 40, '#fff');
drawRect(framebuffer, 280, 393, 80, 2, '#600');
drawRect(framebuffer, 358, 355, 2, 40, '#600');
framebuffer.globalAlpha = 1;
framebuffer.font = 'bold 16px ibm-vga';
framebuffer.textAlign = 'center';
framebuffer.textBaseline = 'middle';
framebuffer.fillStyle = '#fff';
framebuffer.fillText('FIRE', 320, 375);
// ---- Weapon switch (between FIRE and power gauge) ----
framebuffer.globalAlpha = 0.5;
drawRect(framebuffer, 385, 360, 140, 28, '#448');
framebuffer.globalAlpha = 1;
const weapon = player.weapons[player.currentWeapon];
const weaponType = WEAPON_TYPES[weapon.type];
framebuffer.font = '8px ibm-bios';
framebuffer.textAlign = 'center';
framebuffer.textBaseline = 'middle';
framebuffer.fillStyle = '#fff';
framebuffer.fillText(weaponType.name, 455, 374);
}
function drawPlayers() {
for (let player of players) {
const {x, y, a, c, cb, energy, shield, dead} = player;
if (dead) continue;
// Shield
if (shield) {
const {type, energy} = shield;
const shieldType = SHIELD_TYPES[type];
foreground.globalAlpha = energy / shieldType.energy;
for (let i=0; i<shieldType.s; i++) {
strokeCircle(foreground, x, y+PLAYER_TANK_Y_FOOTPRINT, shieldType.r+i, shieldType.color);
}
foreground.globalAlpha = 1;
}
// Parachute
if (player.parachute) {
drawSemiCircle(foreground, x, y-15, 10, 'white');
drawLine(foreground, x-10, y-15, x-2, y, 'white');
drawLine(foreground, x-5, y-15, x-1, y, 'white');
drawLine(foreground, x, y-15, x, y, 'white');
drawLine(foreground, x+5, y-15, x+1, y, 'white');
drawLine(foreground, x+10, y-15, x+2, y, 'white');
}
// Cannon
const [px, py] = vec(x, y-3, a+180, 3);
drawLine(foreground, x-1, y-3, px-1, py, cb);
drawLine(foreground, x+1, y-3, px+1, py, cb);
drawLine(foreground, x, y-4, px, py-1, cb);
// Tank
drawRect(foreground, x-4, y-3, 8, 1, cb);
drawRect(foreground, x-5, y-2, 10, 2, cb);
drawRect(foreground, x-4, y-0, 8, 1, cb);
drawRect(foreground, x-3, y+1, 6, 1, cb);
drawLine(foreground, x, y-3, px, py, c);
drawRect(foreground, x-4, y-2, 8, 2, c);
drawRect(foreground, x-3, y-0, 6, 1, c);
// Damage
const damage = clamp(0, 1 - energy/PLAYER_MAX_ENERGY, 1);
foreground.globalAlpha = damage * 0.7;
drawRect(foreground, x-4, y-2, 8, 2, cb);
foreground.globalAlpha = damage;
drawRect(foreground, x-3, y-0, 6, 1, cb);
foreground.globalAlpha = 1;
}
}
function drawTrajectories() {
traces.clearRect(0, 0, W, H);
for (let i=trajectories.length-1; i>=0; i--) {
const trajectory = trajectories[i];
const {x, y, c} = trajectory;
traces.globalAlpha = trajectory.a / 255;
plot(traces, x, y, c);
}
traces.globalAlpha = 1;
}
function fadeTrajectories() {
for (let i=trajectories.length-1; i>=0; i--) {
const trajectory = trajectories[i];
trajectory.a -= TRAJECTORY_FADE_SPEED;
trajectory.y -= TRAJECTORY_FLOAT_SPEED;
if (trajectory.a <= 0 || trajectory.y <= 0) {
trajectories.splice(i, 1);
}
}
}
function drawProjectile() {
if (!projectiles.length) return;
for (let projectile of projectiles) {
const color = projectile.player ? projectile.player.c : 'white';
if (!tracerMode && projectile.weapon?.type !== 'tracer') {
for (let i=0; i < (projectile.trail?.length || 0); i++) {
const t = projectile.trail[i];
const alpha = (i + 1) / (projectile.trail.length + 1);
foreground.globalAlpha = alpha * 0.5;
plot(foreground, clamp(0, Math.round(t.x), W-1), clamp(0, Math.round(t.y), H-1), color);
}
}
foreground.globalAlpha = 1;
plot(foreground, clamp(0, Math.round(projectile.x), W-1), clamp(0, Math.round(projectile.y), H-1), 'white');
}
}
function drawExplosions() {
if (!explosions.length) return;
for (let explosion of explosions) {
const explosionType = EXPLOSION_TYPES[explosion.type];
explosionType.draw(explosion, foreground, terrain);
}
}
function drawParticles() {
for (let particle of particles) {
foreground.globalAlpha = clamp(0, particle.alpha / 255, 255);
plot(foreground, particle.x, particle.y, particle.c);
}
foreground.globalAlpha = 1;
}
function drawStartMenuPanel() {
drawPanelBg(framebuffer);
drawPanelTitleFancy(framebuffer, 'RAVAGED PLANET', performance.now() / 1000);
const optionValues = [[3,4,5,6,7,8,9,10], [1,2,3,4,5,6,7,8,9,10], ['Random','Mountain','Sand'], ['On','Off']];
const labels = ['Players', 'Rounds', 'Terrain', 'Permanent tracer mode'];
const y = 120;
const labelW = 168;
const arrowW = 24, arrowH = 18;
for (let i=0; i<labels.length; i++) {
const rowY = y + i * 30;
const isSelected = menuState.selected === i;
drawPanelText(framebuffer, labels[i], PANEL_X + 30, rowY + 5, isSelected ? 'yellow' : 'white');
const v = optionValues[i][menuState.values[i]];
drawButton(framebuffer, PANEL_X+262, rowY, arrowW, arrowH, '\u25C0', isSelected);
drawPanelText(framebuffer, '' + v, PANEL_X+322, rowY + 5, isSelected ? 'yellow' : 'white', 'center');
drawButton(framebuffer, PANEL_X+355, rowY, arrowW, arrowH, '\u25B6', isSelected);
}
drawPanelDivider(framebuffer, 308);
drawButton(framebuffer, PANEL_X+100, 318, 200, 22, 'START GAME', false);
}
function drawMarketPanel() {
drawPanelBg(framebuffer);
drawPanelTitle(framebuffer, 'MARKET', '#0c8');
const humanPlayer = players.find(p => !p.ai);
const marketItems = Object.keys(MARKET_ITEMS).filter(item => !(tracerMode && item === 'tracer'));
const y = 94;
const maxVisible = 8;
const itemH = 18;
// Up/down arrow buttons (left side) — 3× taller
const arrowX = PANEL_X + 15;
const arrowW = 22;
const arrowH = 54;
drawButton(framebuffer, arrowX, y, arrowW, arrowH, '\u25B2', false);
drawButton(framebuffer, arrowX, y + (maxVisible - 1) * itemH - arrowH + itemH, arrowW, arrowH, '\u25BC', false);
const listX = PANEL_X + 40;
const listW = 340;
for (let i = 0; i < Math.min(maxVisible, marketItems.length); i++) {
const itemIndex = i + (menuState ? menuState.scrollOffset : 0);
if (itemIndex >= marketItems.length) break;
const item = marketItems[itemIndex];
const itemData = MARKET_ITEMS[item];
const isSelected = menuState && menuState.selected === itemIndex;
const color = isSelected ? 'yellow' : 'white';
const rowY = y + i * itemH;
let ammoText = '';
if (humanPlayer) {
if (item === 'parachute') {
const t = humanPlayer.tools.find(x => x.type === 'parachute');
ammoText = t ? `(x${t.ammo})` : '(none)';
} else if (item === 'shield') {
ammoText = humanPlayer.shield ? '(owned)' : '(none)';
} else {
const weapon = humanPlayer.weapons.find(w => w.type === item);
if (weapon) {
ammoText = weapon.type === 'babyMissile' ? '(owned)' : `(x${weapon.ammo})`;
} else {
ammoText = '(none)';
}
}
}
const priceText = itemData.price === 0 ? 'FREE' : `$${itemData.price}`;
if (isSelected) {
drawRect(framebuffer, listX, rowY, listW, itemH, '#558');
}
drawPanelText(framebuffer, WEAPON_TYPES[item] ? WEAPON_TYPES[item].name : item.charAt(0).toUpperCase() + item.slice(1), listX + 12, rowY + 5, color);
drawPanelText(framebuffer, priceText, listX + 150, rowY + 5, isSelected ? 'yellow' : '#aaa');
drawPanelText(framebuffer, ammoText, listX + 230, rowY + 5, isSelected ? 'yellow' : '#aaa');
}
// Buy button
const btnY = 246;
const btnH = 22;
drawButton(framebuffer, listX + 20, btnY, 90, btnH, 'BUY', false);
drawPanelText(framebuffer, `Score: ${score}`, listX + 212, btnY + 1, 'yellow');
drawPanelDivider(framebuffer, 278);
drawButton(framebuffer, PANEL_X+100, 318, 200, 22, 'GO TO BATTLE', false);
}
function drawRoundEndPanel() {
drawPanelBg(framebuffer);
drawPanelTitle(framebuffer, `Round ${round} Complete!`, '#48f');
const y = 100;
const sortedPlayers = [...players].sort((a, b) => {
if (a === winner) return -1;
if (b === winner) return 1;
return b.deathOrder - a.deathOrder;
});
// Column headers
drawPanelText(framebuffer, 'Player', 155, y, '#aaa', 'left');
drawPanelText(framebuffer, 'Kills', 300, y, '#aaa', 'center');
drawPanelText(framebuffer, 'Score', 435, y, '#aaa', 'center');
// Player rows
for (let i=0; i<sortedPlayers.length; i++) {
const p = sortedPlayers[i];
const rowY = y + 20 + i * 16;
const color = p === winner ? 'yellow' : 'white';
const roundScore = p.kills * SCORE_PER_KILL + (p === winner ? SCORE_FOR_WIN : 0);
drawPanelText(framebuffer, p.name, 155, rowY, color, 'left');
drawPanelText(framebuffer, ''+p.kills, 300, rowY, color, 'center');
drawPanelText(framebuffer, ''+roundScore, 435, rowY, color, 'center');
}
drawPanelDivider(framebuffer, 308);
drawButton(framebuffer, PANEL_X+100, 318, 200, 22, 'CONTINUE', false);
}
function drawGameOverPanel() {
drawPanelBg(framebuffer);
drawPanelTitle(framebuffer, 'GAME OVER', '#e22');
const sortedPlayers = [...players].sort((a, b) => b.wins - a.wins || b.kills - a.kills);
const y = 100;
// Column headers
drawPanelText(framebuffer, 'Player', 155, y, '#aaa', 'left');
drawPanelText(framebuffer, 'Wins', 240, y, '#aaa', 'center');
drawPanelText(framebuffer, 'Kills', 310, y, '#aaa', 'center');
drawPanelText(framebuffer, 'Shots', 380, y, '#aaa', 'center');
drawPanelText(framebuffer, 'Score', 450, y, '#aaa', 'center');
// Player rows
for (let i=0; i<sortedPlayers.length; i++) {
const p = sortedPlayers[i];
const rowY = y + 20 + i * 16;
const color = i === 0 ? 'yellow' : 'white';
drawPanelText(framebuffer, p.name, 155, rowY, color, 'left');
drawPanelText(framebuffer, ''+p.wins, 240, rowY, color, 'center');
drawPanelText(framebuffer, ''+p.kills, 310, rowY, color, 'center');
drawPanelText(framebuffer, ''+p.shotsFired, 380, rowY, color, 'center');
drawPanelText(framebuffer, ''+p.totalEarned, 450, rowY, color, 'center');
}
drawPanelDivider(framebuffer, 280);
drawButton(framebuffer, PANEL_X+100, 290, 200, 22, 'START MENU', false);
}
function drawScreenShake() {
const x = randomInt(-screenShake, screenShake);
const y = randomInt(-screenShake, screenShake);
framebuffer.canvas.style.transform = `translate(${x}px, ${y}px)`;
}
function drawStatus() {
if (state === 'start-menu' || state === 'market' || state === 'round-end' || state === 'game-over') {
return;
}
const player = players[currentPlayer];
const {currentWeapon} = player;
const weapon = player.weapons[currentWeapon];
const weaponType = WEAPON_TYPES[weapon.type];
drawText(foreground, `${player.name} NRG:${Math.round(player.energy)} AIM:${player.a} PWR:${player.p} SHD:${player.shield?Math.round(player.shield.energy):0} ${clamp(0, weapon.ammo, 99)} ${weaponType.name}`, 8, 8, player.c, 'left');
drawText(foreground, `WIND: ${wind<=0?'<':''}${Math.abs(wind)}${wind>=0?'>':''}`, W-8, 8, 'white', 'right');
drawText(foreground, `Round: ${round}/${totalRounds} Score: ${score}`, W-8, 18, 'white', 'right');
}
loop((deltaTime) => {
dt = deltaTime;
update();
draw();
});