fertig-classic-games/src/games/defender/DefenderLogic.js

883 lines
32 KiB
JavaScript

// Pure simulation for Defender (Resogun-style wireframe swarm shooter). No
// Phaser dependency — fully unit-testable headlessly via tools/verifyDefender.js.
//
// World model: a single 2D plane that WRAPS horizontally (a "ring", like a
// side view of a cylinder) and is bounded vertically. Every position/velocity
// update and every AI distance/heading calc that touches X must go through
// wrap()/tdelta() below — a naive `dx = a.x - b.x` will make swarms visibly
// split at the wrap seam. Same toroidal-math idiom as Star Control
// (src/games/starcontrol/StarControlLogic.js), reduced to one wrapped axis.
//
// Fixed-tick loop: same accumulator/spiral-of-death-guard pattern as Total
// Annihilation (src/games/totalannihilation/TALogic.js) rather than a raw
// per-frame delta — swarm flocking and rescue timers behave identically
// regardless of render framerate. state.alpha is the leftover fraction the
// view uses to interpolate between the last two ticks.
// ---------------------------------------------------------------------------
// Seeded RNG (same generator every other game in this repo uses).
export function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------------------------------------------------------------------------
// World / wrap math
export const WORLD_W = 6000;
export const Y_SKY = 60; // an abductor carrying a humanoid past this height = escaped
export const Y_MIN = 90; // top of the flight band
export const Y_GROUND = 900; // ground band: walkers, idle humanoids, falling humanoids land here
export const Y_MAX = 940; // bottom clamp for the player
export const STEP_MS = 1000 / 60;
export const MAX_STEPS = 4;
export function wrap(v, size = WORLD_W) {
return ((v % size) + size) % size;
}
// Shortest signed delta from a to b on the wrapped X axis.
export function tdelta(a, b, size = WORLD_W) {
let d = (b - a) % size;
if (d > size / 2) d -= size;
else if (d < -size / 2) d += size;
return d;
}
export function tdist(ax, ay, bx, by) {
const dx = tdelta(ax, bx);
const dy = ay - by;
return Math.hypot(dx, dy);
}
// ---------------------------------------------------------------------------
// Tuning — every gameplay constant lives here, named, so feel-tuning never
// touches game logic (same convention as Tempest's TUNE table).
export const TUNE = {
// Horizontal flight is deliberately much quicker than vertical — this is a
// side-view wraparound shooter, so covering ground left/right is the ship's
// primary job and should feel snappy; vertical is fine-grained dodging.
PLAYER_ACCEL_X: 3600,
PLAYER_MAX_SPEED_X: 1150,
PLAYER_ACCEL_Y: 1400,
PLAYER_MAX_SPEED_Y: 460,
PLAYER_DRAG: 5.2,
PLAYER_RADIUS: 18,
PLAYER_FIRE_COOLDOWN_MS: 130,
// Fast + long-lived enough to cross from the player (screen-centered by the
// camera) all the way past either screen edge before expiring. TTL carries
// extra margin over the bare half-screen distance because at top horizontal
// speed the camera's smoothed follow lags the ship by ~100+px.
PLAYER_SHOT_SPEED: 1100,
PLAYER_SHOT_TTL_MS: 1100,
PLAYER_SHOT_RADIUS: 6,
RESPAWN_DELAY_MS: 1500,
RESPAWN_INVULN_MS: 1200,
PLAYER_LIVES_START: 3,
SWARMER_RADIUS: 14,
SWARMER_SPEED: 220,
SWARMER_HP: 1,
SEPARATION_RADIUS: 30,
COHESION_RADIUS: 130,
ALIGNMENT_RADIUS: 100,
SEPARATION_W: 1.5,
COHESION_W: 0.45,
ALIGNMENT_W: 0.6,
SEEK_PLAYER_W: 0.55,
SEEK_RADIUS: 520,
SWARM_PACK_SIZE_MIN: 10,
SWARM_PACK_SIZE_MAX: 16,
SWARM_MAX_CONCURRENT: 48,
WALKER_RADIUS: 24,
WALKER_HP: 3,
WALKER_SPEED: 70,
WALKER_PATROL_RANGE: 260,
WALKER_FIRE_COOLDOWN_MS: 1500,
WALKER_SHOT_SPEED: 360,
WALKER_SHOT_RADIUS: 7,
WALKER_AIM_RANGE: 640,
ABDUCTOR_RADIUS: 26,
ABDUCTOR_HP: 2,
ABDUCTOR_SPEED: 160,
ABDUCTOR_RISE_SPEED: 65,
ABDUCTOR_GRAB_RADIUS: 46,
HUMANOID_RADIUS: 14,
PICKUP_RADIUS: 46,
// A freed humanoid falls from roughly Y_SKY..Y_MIN down to Y_GROUND (~790-840px)
// at this constant speed, so a typical fall takes ~5.5-6s; GRAB_WINDOW_MS sits
// comfortably above that so hitting the ground — not the window — is normally
// what ends an uncaught fall, with the window only as a backstop.
FALL_SPEED: 140,
GRAB_WINDOW_MS: 8000,
CARRY_TIMEOUT_MS: 12000,
EXTRACT_RADIUS: 80,
EXTRACTION_ZONES_PER_LEVEL: 2,
HUMANOIDS_PER_LEVEL: 6,
COMBO_WINDOW_MS: 1800,
MULT_MAX: 8,
OVERDRIVE_FILL_PER_KILL: 0.04,
OVERDRIVE_DURATION_MS: 8000,
OVERDRIVE_TIMESCALE: 0.35,
OVERDRIVE_SCORE_MULT: 5,
LEVEL_COUNT: 5,
WAVES_PER_LEVEL: 4,
WAVE_BREATHER_MS: 2200,
BOSS_INTRO_MS: 2600,
BOSS_OUTRO_MS: 1800,
BOSS_HP_BASE: 60,
BOSS_HP_STEP: 30,
BOSS_RADIUS: 70,
BOSS_SPEED: 90,
BOSS_SHOT_SPEED: 300,
BOSS_ATTACK_COOLDOWN_MS: 1600,
BOSS_SPOKE_COUNT: 10,
FULL_RESCUE_BONUS: 5000,
ENEMY_KILL_SCORE: { swarmer: 50, walker: 150, abductor: 120 },
BOSS_KILL_SCORE: 3000,
HUMANOID_RESCUE_SCORE: 400,
};
// Entity ids are assigned from a per-state counter (not a module-level one) so
// that replaying the same seed from a fresh createGame() is fully
// reproducible regardless of how many other games ran earlier in the process.
function nid(state) { return state.nextId += 1; }
// ---------------------------------------------------------------------------
// Wave / boss authoring — formula-based per (level, wave), same idiom as
// Tempest's per-level tuning functions (flipperSpeed(level), spawnInterval(level), …).
export function waveSpec(level, wave) {
const p = (level - 1) * TUNE.WAVES_PER_LEVEL + (wave - 1); // 0..19 overall progress index
return {
swarmerCount: 8 + p * 3,
packSize: Math.min(TUNE.SWARM_PACK_SIZE_MAX, TUNE.SWARM_PACK_SIZE_MIN + Math.floor(p / 2)),
walkerCount: Math.max(0, Math.floor((p - 1) / 3)),
abductorCount: Math.max(1, Math.floor(p / 3) + 1),
spawnIntervalMs: Math.max(260, 900 - p * 26),
};
}
// One distinct, escalating boss encounter per level — not just more HP, but a
// genuinely different attack repertoire each time. `moves` is round-robined
// every attack (so a fight never just repeats one pattern), `phase2Moves`
// (if present) takes over once the boss drops below half health, and
// `speedMult`/`cooldownMs` layer movement and attack-rate pressure on top so
// later fights are harder along every axis at once, not just bullet variety.
export const BOSS_PROFILES = [
{ // Level 1 — Sentinel: a single steady ring burst. The introduction.
name: 'SENTINEL', moves: ['ring'], speedMult: 1, cooldownMs: 1700,
},
{ // Level 2 — Ravager: rams hard and sprays a forward shotgun spread.
name: 'RAVAGER', moves: ['spread'], speedMult: 1.8, cooldownMs: 1450,
},
{ // Level 3 — Swarmlord: a rotating bullet spiral plus reinforcement waves —
// now you're managing adds and dodging a moving pattern at the same time.
name: 'SWARMLORD', moves: ['spiral', 'reinforce'], speedMult: 1.2, cooldownMs: 1300,
},
{ // Level 4 — Warden: alternates area-denial and player-tracking fire; past
// half health it adds the spiral and reinforcements too. First two-phase fight.
name: 'WARDEN', moves: ['ring', 'aimed'], phase2Moves: ['spiral', 'aimed', 'reinforce'],
speedMult: 1.4, cooldownMs: 1150,
},
{ // Level 5 — Overlord: the full arsenal from every earlier fight, fastest
// base cooldown, and a dense bullet-wall move once it's wounded.
name: 'OVERLORD', moves: ['ring', 'spread', 'aimed'], phase2Moves: ['spiral', 'wall', 'aimed', 'reinforce'],
speedMult: 1.6, cooldownMs: 950,
},
];
export function bossProfileFor(level) { return BOSS_PROFILES[(level - 1) % BOSS_PROFILES.length]; }
export function bossSpec(level) {
return {
name: bossProfileFor(level).name,
hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP,
};
}
// ---------------------------------------------------------------------------
// Entity factories
function makePlayer() {
return {
x: WORLD_W / 2, y: (Y_MIN + Y_GROUND) / 2, vx: 0, vy: 0, facing: 1,
alive: true, invulnMs: 0, respawnMs: 0,
fireCooldownMs: 0, carrying: null,
};
}
function makeExtractionZones(rng) {
const zones = [];
const spacing = WORLD_W / TUNE.EXTRACTION_ZONES_PER_LEVEL;
for (let i = 0; i < TUNE.EXTRACTION_ZONES_PER_LEVEL; i += 1) {
zones.push({ x: wrap(spacing * i + spacing * 0.5 + (rng() - 0.5) * spacing * 0.3) });
}
return zones;
}
function spawnSwarmerPack(state, count) {
const cx = wrap(state.rng() * WORLD_W);
const cy = Y_MIN + state.rng() * (Y_GROUND - Y_MIN - 200);
for (let i = 0; i < count && state.enemies.filter((e) => e.type === 'swarmer').length < TUNE.SWARM_MAX_CONCURRENT; i += 1) {
state.enemies.push({
id: nid(state), type: 'swarmer', hp: TUNE.SWARMER_HP, radius: TUNE.SWARMER_RADIUS,
x: wrap(cx + (state.rng() - 0.5) * 80),
y: cy + (state.rng() - 0.5) * 80,
vx: (state.rng() - 0.5) * 40, vy: (state.rng() - 0.5) * 40,
});
}
}
function spawnWalker(state) {
const x = wrap(state.rng() * WORLD_W);
state.enemies.push({
id: nid(state), type: 'walker', hp: TUNE.WALKER_HP, radius: TUNE.WALKER_RADIUS,
x, y: Y_GROUND, vx: 0, vy: 0,
homeX: x, dir: state.rng() < 0.5 ? -1 : 1, fireCooldownMs: TUNE.WALKER_FIRE_COOLDOWN_MS * state.rng(),
});
}
function spawnAbductor(state) {
state.enemies.push({
id: nid(state), type: 'abductor', hp: TUNE.ABDUCTOR_HP, radius: TUNE.ABDUCTOR_RADIUS,
x: wrap(state.rng() * WORLD_W), y: Y_MIN + 20, vx: 0, vy: 0,
targetHumanoidId: null, carryingId: null,
});
}
function spawnHumanoids(state, count) {
const used = new Set();
for (let i = 0; i < count; i += 1) {
let x;
do { x = wrap(state.rng() * WORLD_W); } while (used.has(Math.floor(x / 120)));
used.add(Math.floor(x / 120));
state.humanoids.push({
id: nid(state), status: 'idle', x, y: Y_GROUND, vx: 0, vy: 0,
grabberId: null, carrierIsPlayer: false, timerMs: 0,
});
}
}
function startWave(state, wave) {
state.wave = wave;
state.phase = 'waveIntro';
state.phaseMs = 0;
const spec = waveSpec(state.level, wave);
const queue = [];
for (let i = 0; i < Math.ceil(spec.swarmerCount / spec.packSize); i += 1) {
queue.push({ kind: 'swarmerPack', count: Math.min(spec.packSize, spec.swarmerCount - i * spec.packSize) });
}
for (let i = 0; i < spec.walkerCount; i += 1) queue.push({ kind: 'walker' });
for (let i = 0; i < spec.abductorCount; i += 1) queue.push({ kind: 'abductor' });
state.spawnQueue = queue;
state.spawnTimerMs = 0;
state.spawnIntervalMs = spec.spawnIntervalMs;
}
export function createGame(opts = {}) {
const seed = opts.seed ?? 1;
const rng = mulberry32(seed);
const state = {
seed, rng, nextId: 1,
level: opts.startLevel ?? 1,
wave: 1, phase: 'waveIntro', phaseMs: 0,
accumulatorMs: 0, alpha: 0, timeMs: 0,
player: makePlayer(),
enemies: [], humanoids: [], shots: [], enemyShots: [], boss: null,
score: 0, lives: TUNE.PLAYER_LIVES_START,
multiplier: 1, lastKillMs: -Infinity,
overdriveMeter: 0, overdriveActive: false, overdriveMsLeft: 0,
rescuedThisLevel: 0, lostThisLevel: 0,
extractionZones: makeExtractionZones(rng),
spawnQueue: [], spawnTimerMs: 0, spawnIntervalMs: 800,
input: { up: false, down: false, left: false, right: false, fire: false, overdrive: false },
over: false, victory: false,
};
spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL);
startWave(state, 1);
return state;
}
export function setInput(state, patch) {
Object.assign(state.input, patch);
}
// ---------------------------------------------------------------------------
// Per-tick subsystems
function timescale(state) {
return state.overdriveActive ? TUNE.OVERDRIVE_TIMESCALE : 1;
}
function updatePlayer(state, dt, events) {
const p = state.player;
if (!p.alive) {
p.respawnMs -= dt;
if (p.respawnMs <= 0) {
p.alive = true;
p.x = wrap(p.x);
p.y = (Y_MIN + Y_GROUND) / 2;
p.vx = 0; p.vy = 0;
p.invulnMs = TUNE.RESPAWN_INVULN_MS;
p.carrying = null;
events.push({ type: 'playerRespawned' });
}
return;
}
if (p.invulnMs > 0) p.invulnMs -= dt;
const { input } = state;
const ax = (input.right ? 1 : 0) - (input.left ? 1 : 0);
const ay = (input.down ? 1 : 0) - (input.up ? 1 : 0);
const dtS = dt / 1000;
p.vx += ax * TUNE.PLAYER_ACCEL_X * dtS;
p.vy += ay * TUNE.PLAYER_ACCEL_Y * dtS;
const drag = 1 / (1 + TUNE.PLAYER_DRAG * dtS);
p.vx *= drag; p.vy *= drag;
// Independent per-axis clamps (not a combined-magnitude clamp) so the much
// higher horizontal cap isn't diluted whenever the player is also holding
// a vertical direction.
p.vx = Math.max(-TUNE.PLAYER_MAX_SPEED_X, Math.min(TUNE.PLAYER_MAX_SPEED_X, p.vx));
p.vy = Math.max(-TUNE.PLAYER_MAX_SPEED_Y, Math.min(TUNE.PLAYER_MAX_SPEED_Y, p.vy));
p.x = wrap(p.x + p.vx * dtS);
p.y = Math.min(Y_MAX, Math.max(Y_MIN, p.y + p.vy * dtS));
if (ax > 0) p.facing = 1; else if (ax < 0) p.facing = -1;
p.fireCooldownMs -= dt;
if (input.fire && p.fireCooldownMs <= 0) {
p.fireCooldownMs = TUNE.PLAYER_FIRE_COOLDOWN_MS;
state.shots.push({
x: p.x, y: p.y, vx: TUNE.PLAYER_SHOT_SPEED * p.facing, vy: 0, ttlMs: TUNE.PLAYER_SHOT_TTL_MS,
});
events.push({ type: 'shotFired' });
}
// Overdrive trigger
if (input.overdrive && !state.overdriveActive && state.overdriveMeter >= 1) {
state.overdriveActive = true;
state.overdriveMsLeft = TUNE.OVERDRIVE_DURATION_MS;
events.push({ type: 'overdriveStart' });
}
// Carried humanoid follows the player, and can be dropped at an extraction zone.
if (p.carrying != null) {
const h = state.humanoids.find((hh) => hh.id === p.carrying);
if (h) {
h.x = wrap(p.x - p.facing * 24);
h.y = p.y + 24;
h.timerMs += dt;
const nearZone = state.extractionZones.some((z) => Math.abs(tdelta(p.x, z.x)) < TUNE.EXTRACT_RADIUS);
if (nearZone) {
h.status = 'rescued';
p.carrying = null;
state.rescuedThisLevel += 1;
state.score += TUNE.HUMANOID_RESCUE_SCORE;
events.push({ type: 'humanoidRescued', id: h.id });
} else if (h.timerMs >= TUNE.CARRY_TIMEOUT_MS) {
h.status = 'lost';
p.carrying = null;
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'carryTimeout' });
}
} else {
p.carrying = null;
}
} else {
// Auto-pickup: a falling humanoid within pickup radius, if not already carried.
for (const h of state.humanoids) {
if (h.status !== 'falling') continue;
if (tdist(p.x, p.y, h.x, h.y) <= TUNE.PICKUP_RADIUS) {
h.status = 'carried';
h.timerMs = 0;
p.carrying = h.id;
events.push({ type: 'humanoidPickedUp', id: h.id });
break;
}
}
}
}
function killPlayer(state, events) {
const p = state.player;
if (!p.alive || p.invulnMs > 0) return;
p.alive = false;
p.respawnMs = TUNE.RESPAWN_DELAY_MS;
if (p.carrying != null) {
const h = state.humanoids.find((hh) => hh.id === p.carrying);
if (h) {
h.status = 'lost';
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'playerDied' });
}
p.carrying = null;
}
state.lives -= 1;
events.push({ type: 'playerDied' });
if (state.lives < 0) {
state.over = true;
state.phase = 'gameOver';
events.push({ type: 'gameOver', score: state.score, level: state.level });
}
}
function neighborForces(e, list, dt) {
let sepX = 0; let sepY = 0; let cohX = 0; let cohY = 0; let aliX = 0; let aliY = 0; let n = 0;
for (const o of list) {
if (o === e) continue;
const dx = tdelta(e.x, o.x);
const dy = o.y - e.y;
const d = Math.hypot(dx, dy) || 0.001;
if (d < TUNE.SEPARATION_RADIUS) { sepX -= dx / d; sepY -= dy / d; }
if (d < TUNE.COHESION_RADIUS) { cohX += dx; cohY += dy; n += 1; }
if (d < TUNE.ALIGNMENT_RADIUS) { aliX += o.vx; aliY += o.vy; }
}
if (n > 0) { cohX /= n; cohY /= n; aliX /= n; aliY /= n; }
return {
fx: sepX * TUNE.SEPARATION_W + cohX * TUNE.COHESION_W * 0.02 + aliX * TUNE.ALIGNMENT_W * 0.02,
fy: sepY * TUNE.SEPARATION_W + cohY * TUNE.COHESION_W * 0.02 + aliY * TUNE.ALIGNMENT_W * 0.02,
};
}
function updateSwarmers(state, dt) {
const dtS = dt / 1000;
const swarmers = state.enemies.filter((e) => e.type === 'swarmer');
const p = state.player;
for (const e of swarmers) {
const { fx, fy } = neighborForces(e, swarmers, dt);
e.vx += fx * dt; e.vy += fy * dt;
const dToPlayer = tdist(e.x, e.y, p.x, p.y);
if (dToPlayer < TUNE.SEEK_RADIUS && p.alive) {
const dx = tdelta(e.x, p.x); const dy = p.y - e.y;
const d = Math.hypot(dx, dy) || 1;
e.vx += (dx / d) * TUNE.SEEK_PLAYER_W * dt;
e.vy += (dy / d) * TUNE.SEEK_PLAYER_W * dt;
}
const sp = Math.hypot(e.vx, e.vy);
if (sp > TUNE.SWARMER_SPEED) { const k = TUNE.SWARMER_SPEED / sp; e.vx *= k; e.vy *= k; }
e.x = wrap(e.x + e.vx * dtS);
e.y = Math.min(Y_GROUND - 40, Math.max(Y_MIN, e.y + e.vy * dtS));
}
}
function updateWalkers(state, dt, events) {
const dtS = dt / 1000;
for (const e of state.enemies) {
if (e.type !== 'walker') continue;
const dHome = tdelta(e.homeX, e.x);
if (Math.abs(dHome) > TUNE.WALKER_PATROL_RANGE) e.dir = dHome > 0 ? -1 : 1;
e.x = wrap(e.x + e.dir * TUNE.WALKER_SPEED * dtS);
e.fireCooldownMs -= dt;
const dToPlayer = tdist(e.x, e.y, state.player.x, state.player.y);
if (state.player.alive && dToPlayer < TUNE.WALKER_AIM_RANGE && e.fireCooldownMs <= 0) {
e.fireCooldownMs = TUNE.WALKER_FIRE_COOLDOWN_MS;
const dx = tdelta(e.x, state.player.x); const dy = state.player.y - e.y;
const d = Math.hypot(dx, dy) || 1;
state.enemyShots.push({
x: e.x, y: e.y, vx: (dx / d) * TUNE.WALKER_SHOT_SPEED, vy: (dy / d) * TUNE.WALKER_SHOT_SPEED,
radius: TUNE.WALKER_SHOT_RADIUS, ttlMs: 2200,
});
events.push({ type: 'shotFired', enemy: true });
}
}
}
function updateAbductors(state, dt, events) {
const dtS = dt / 1000;
for (const e of state.enemies) {
if (e.type !== 'abductor') continue;
if (e.carryingId == null) {
// seek an idle humanoid
if (e.targetHumanoidId == null) {
const idle = state.humanoids.filter((h) => h.status === 'idle');
if (idle.length) {
idle.sort((a, b) => Math.abs(tdelta(e.x, a.x)) - Math.abs(tdelta(e.x, b.x)));
e.targetHumanoidId = idle[0].id;
}
}
const target = state.humanoids.find((h) => h.id === e.targetHumanoidId && h.status === 'idle');
if (target) {
const dx = tdelta(e.x, target.x); const dy = target.y - e.y;
const d = Math.hypot(dx, dy) || 1;
e.x = wrap(e.x + (dx / d) * TUNE.ABDUCTOR_SPEED * dtS);
e.y += (dy / d) * TUNE.ABDUCTOR_SPEED * dtS;
if (d < TUNE.ABDUCTOR_GRAB_RADIUS) {
target.status = 'grabbed';
target.grabberId = e.id;
e.carryingId = target.id;
events.push({ type: 'humanoidGrabbed', id: target.id });
}
} else {
e.targetHumanoidId = null;
}
} else {
const h = state.humanoids.find((hh) => hh.id === e.carryingId);
if (!h || h.status !== 'grabbed') { e.carryingId = null; continue; }
e.y -= TUNE.ABDUCTOR_RISE_SPEED * dtS;
h.x = e.x; h.y = e.y + 20;
if (e.y <= Y_SKY) {
h.status = 'lost';
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'escaped' });
e.carryingId = null;
e.targetHumanoidId = null;
// The abductor escapes with its prize — remove it from play.
e.dead = true;
}
}
}
}
function updateHumanoids(state, dt, events) {
const dtS = dt / 1000;
for (const h of state.humanoids) {
if (h.status === 'falling') {
h.timerMs += dt;
// Constant, gentle descent — no acceleration, so a catch attempt is just as
// makeable in the last moment as it was at the start of the fall.
h.y += TUNE.FALL_SPEED * dtS;
if (h.y >= Y_GROUND || h.timerMs >= TUNE.GRAB_WINDOW_MS) {
h.status = 'lost';
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: h.y >= Y_GROUND ? 'hitGround' : 'grabWindow' });
}
}
}
}
// A grabbed humanoid's carrying abductor may die mid-carry — releases it to fall.
function releaseGrabbedHumanoids(state, deadAbductorIds, events) {
if (!deadAbductorIds.size) return;
for (const h of state.humanoids) {
if (h.status === 'grabbed' && deadAbductorIds.has(h.grabberId)) {
h.status = 'falling';
h.vy = TUNE.FALL_SPEED;
h.timerMs = 0;
events.push({ type: 'humanoidFreed', id: h.id });
}
}
}
function updateShots(state, dt) {
const dtS = dt / 1000;
for (const s of state.shots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; }
state.shots = state.shots.filter((s) => s.ttlMs > 0);
for (const s of state.enemyShots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; }
state.enemyShots = state.enemyShots.filter((s) => s.ttlMs > 0);
}
function circleHit(ax, ay, ar, bx, by, br) {
const dx = tdelta(ax, bx); const dy = ay - by;
const r = ar + br;
return dx * dx + dy * dy <= r * r;
}
function registerKill(state, enemy, events) {
const now = state.timeMs;
if (now - state.lastKillMs <= TUNE.COMBO_WINDOW_MS) {
state.multiplier = Math.min(TUNE.MULT_MAX, state.multiplier + 1);
} else {
state.multiplier = 1;
}
state.lastKillMs = now;
state.overdriveMeter = Math.min(1, state.overdriveMeter + TUNE.OVERDRIVE_FILL_PER_KILL);
if (state.overdriveMeter >= 1) events.push({ type: 'overdriveReady' });
const base = TUNE.ENEMY_KILL_SCORE[enemy.type] ?? 50;
const mult = state.overdriveActive ? TUNE.OVERDRIVE_SCORE_MULT : state.multiplier;
state.score += base * mult;
events.push({ type: 'enemyKilled', enemyType: enemy.type, x: enemy.x, y: enemy.y, multiplier: state.multiplier });
}
function handleCollisions(state, events) {
const p = state.player;
const deadAbductorIds = new Set();
// player shots vs enemies
for (const s of state.shots) {
for (const e of state.enemies) {
if (e.dead || s.dead) continue;
if (circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, e.x, e.y, e.radius)) {
s.dead = true;
e.hp -= 1;
events.push({ type: 'enemyHit', id: e.id });
if (e.hp <= 0) {
e.dead = true;
if (e.type === 'abductor') deadAbductorIds.add(e.id);
registerKill(state, e, events);
}
}
}
// player shots vs boss
if (state.boss && !s.dead && circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) {
s.dead = true;
state.boss.hp -= 1;
events.push({ type: 'enemyHit', boss: true });
}
}
state.shots = state.shots.filter((s) => !s.dead);
releaseGrabbedHumanoids(state, deadAbductorIds, events);
state.enemies = state.enemies.filter((e) => !e.dead);
// enemy bodies / enemy shots vs player
if (p.alive && p.invulnMs <= 0 && !state.overdriveActive) {
for (const e of state.enemies) {
if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, e.x, e.y, e.radius)) { killPlayer(state, events); break; }
}
if (p.alive) {
for (const s of state.enemyShots) {
if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, s.x, s.y, s.radius)) { s.dead = true; killPlayer(state, events); break; }
}
}
if (p.alive && state.boss && circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) {
killPlayer(state, events);
}
}
state.enemyShots = state.enemyShots.filter((s) => !s.dead);
}
// ---------------------------------------------------------------------------
// Boss
function spawnBoss(state, events) {
const spec = bossSpec(state.level);
state.boss = {
name: spec.name, hp: spec.hp, maxHp: spec.hp,
x: wrap(state.player.x + WORLD_W / 2), y: (Y_MIN + Y_GROUND) / 2,
dir: 1, attackCooldownMs: bossProfileFor(state.level).cooldownMs, phase: 1,
moveIdx: 0, spiralAngle: 0,
};
events.push({ type: 'bossSpawn', kind: spec.name });
}
function fireBossShot(state, boss, angle, speed) {
state.enemyShots.push({
x: boss.x, y: boss.y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
radius: 8, ttlMs: 2600,
});
}
// Every attack pattern a boss can draw from. Each is a genuinely different
// shape/behavior (not a recolor of another), so "harder boss" means "new
// things to read and dodge", not just "more of the same bullets".
function performBossMove(state, boss, move, events) {
const p = state.player;
switch (move) {
case 'ring': // static radial burst — the baseline area-denial pattern
for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) {
fireBossShot(state, boss, (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED);
}
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
case 'spread': { // a forward shotgun cone toward the player's general side
const base = tdelta(boss.x, p.x) >= 0 ? 0 : Math.PI;
for (const off of [-0.5, -0.25, 0, 0.25, 0.5]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.1);
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
}
case 'spiral': // three arms that rotate a bit further each time this move fires
for (const off of [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]) {
fireBossShot(state, boss, boss.spiralAngle + off, TUNE.BOSS_SHOT_SPEED * 0.85);
}
boss.spiralAngle += 0.5;
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
case 'aimed': { // tracks the player directly — punishes standing still
const base = Math.atan2(p.y - boss.y, tdelta(boss.x, p.x));
for (const off of [-0.12, 0, 0.12]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.3);
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
}
case 'wall': { // a dense ring, twice the density of 'ring' — find the gap
const count = TUNE.BOSS_SPOKE_COUNT * 2;
for (let i = 0; i < count; i += 1) fireBossShot(state, boss, (i / count) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED * 0.9);
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
}
case 'reinforce': // calls in a swarmer pack — now you're managing adds too
spawnSwarmerPack(state, 6);
break;
default: break;
}
}
function updateBoss(state, dt, events) {
const boss = state.boss;
if (!boss) return;
const dtS = dt / 1000;
const profile = bossProfileFor(state.level);
const wasPhase = boss.phase;
boss.phase = boss.hp > boss.maxHp / 2 ? 1 : 2;
if (boss.phase !== wasPhase) events.push({ type: 'bossPhaseChange', phase: boss.phase, kind: boss.name });
const moves = (boss.phase === 2 && profile.phase2Moves) ? profile.phase2Moves : profile.moves;
boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * profile.speedMult * dtS);
const dHome = tdelta(state.player.x - WORLD_W / 2, boss.x); // roam the far side of the ring
if (Math.abs(dHome) > WORLD_W * 0.3) boss.dir *= -1;
boss.attackCooldownMs -= dt;
if (boss.attackCooldownMs <= 0 && state.player.alive) {
// Phase 2 also attacks a little faster, on top of whatever new moves it unlocked.
boss.attackCooldownMs = profile.cooldownMs * (boss.phase === 2 ? 0.8 : 1);
const move = moves[boss.moveIdx % moves.length];
boss.moveIdx += 1;
events.push({ type: 'bossMove', move, phase: boss.phase });
performBossMove(state, boss, move, events);
}
if (boss.hp <= 0) {
events.push({ type: 'bossDefeated', kind: boss.name });
state.score += TUNE.BOSS_KILL_SCORE;
state.boss = null;
}
}
// ---------------------------------------------------------------------------
// Wave / level progression
function trySpawnFromQueue(state, dt) {
if (!state.spawnQueue.length) return;
state.spawnTimerMs -= dt;
if (state.spawnTimerMs > 0) return;
state.spawnTimerMs = state.spawnIntervalMs;
const next = state.spawnQueue.shift();
if (next.kind === 'swarmerPack') spawnSwarmerPack(state, next.count);
else if (next.kind === 'walker') spawnWalker(state);
else if (next.kind === 'abductor') spawnAbductor(state);
}
function updatePhase(state, dt, events) {
state.phaseMs += dt;
switch (state.phase) {
case 'waveIntro':
if (state.phaseMs >= TUNE.WAVE_BREATHER_MS * 0.4) {
state.phase = 'wave';
state.phaseMs = 0;
events.push({ type: 'waveStart', level: state.level, wave: state.wave });
}
break;
case 'wave':
trySpawnFromQueue(state, dt);
if (!state.spawnQueue.length && state.enemies.length === 0) {
state.phase = 'waveClear';
state.phaseMs = 0;
events.push({ type: 'waveClear', level: state.level, wave: state.wave });
}
break;
case 'waveClear':
if (state.phaseMs >= TUNE.WAVE_BREATHER_MS) {
if (state.wave < TUNE.WAVES_PER_LEVEL) {
startWave(state, state.wave + 1);
} else {
state.phase = 'bossIntro';
state.phaseMs = 0;
}
}
break;
case 'bossIntro':
if (state.phaseMs >= TUNE.BOSS_INTRO_MS) {
spawnBoss(state, events);
state.phase = 'boss';
state.phaseMs = 0;
}
break;
case 'boss':
updateBoss(state, dt, events);
if (!state.boss) {
state.phase = 'levelComplete';
state.phaseMs = 0;
const fullRescue = state.lostThisLevel === 0 && state.rescuedThisLevel === TUNE.HUMANOIDS_PER_LEVEL;
if (fullRescue) {
state.score += TUNE.FULL_RESCUE_BONUS;
state.lives += 1;
}
events.push({
type: 'levelComplete', level: state.level,
rescued: state.rescuedThisLevel, lost: state.lostThisLevel, fullRescue,
});
}
break;
case 'levelComplete':
if (state.phaseMs >= TUNE.BOSS_OUTRO_MS) {
if (state.level >= TUNE.LEVEL_COUNT) {
state.victory = true;
state.over = true;
state.phase = 'victory';
events.push({ type: 'victory', score: state.score });
} else {
state.level += 1;
state.rescuedThisLevel = 0;
state.lostThisLevel = 0;
state.humanoids = state.humanoids.filter((h) => h.status !== 'rescued' && h.status !== 'lost');
spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL);
state.extractionZones = makeExtractionZones(state.rng);
startWave(state, 1);
}
}
break;
default: break;
}
}
function updateOverdrive(state, dt, events) {
if (!state.overdriveActive) return;
state.overdriveMsLeft -= dt;
state.overdriveMeter = Math.max(0, state.overdriveMeter - dt / TUNE.OVERDRIVE_DURATION_MS);
if (state.overdriveMsLeft <= 0 || state.overdriveMeter <= 0) {
state.overdriveActive = false;
state.overdriveMeter = 0;
events.push({ type: 'overdriveEnd' });
}
}
function updateCombo(state) {
if (state.timeMs - state.lastKillMs > TUNE.COMBO_WINDOW_MS) state.multiplier = 1;
}
// ---------------------------------------------------------------------------
// Master tick — one fixed STEP_MS of simulation.
function tick(state) {
const events = [];
if (state.over) return events;
const dt = STEP_MS * timescale(state);
state.timeMs += dt;
updatePlayer(state, state.player.alive ? dt : STEP_MS, events);
updateSwarmers(state, dt);
updateWalkers(state, dt, events);
updateAbductors(state, dt, events);
updateHumanoids(state, dt, events);
updateShots(state, dt);
handleCollisions(state, events);
updatePhase(state, dt, events);
updateOverdrive(state, dt, events);
updateCombo(state);
return events;
}
export function step(state, deltaMs) {
const out = [];
state.accumulatorMs += deltaMs;
let n = 0;
while (state.accumulatorMs >= STEP_MS && n < MAX_STEPS) {
state.accumulatorMs -= STEP_MS;
const ev = tick(state);
for (let i = 0; i < ev.length; i += 1) out.push(ev[i]);
n += 1;
if (state.over) break;
}
if (n === MAX_STEPS && state.accumulatorMs >= STEP_MS) state.accumulatorMs = 0; // spiral-of-death guard
state.alpha = Math.min(1, state.accumulatorMs / STEP_MS);
return out;
}