883 lines
32 KiB
JavaScript
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;
|
|
}
|