diff --git a/src/games/defender/DefenderGame.js b/src/games/defender/DefenderGame.js index 4b75ba4..a73f857 100644 --- a/src/games/defender/DefenderGame.js +++ b/src/games/defender/DefenderGame.js @@ -289,7 +289,7 @@ export default class DefenderGame extends Phaser.Scene { break; case 'bossSpawn': playSound(this, SFX.SCIFI_REVEAL); - this.showBanner('WARNING', 0xff4040, 1800); + this.showBanner(`WARNING: ${e.kind}`, 0xff4040, 2000); break; case 'bossPhaseChange': playSound(this, SFX.EIGHTBIT_EXPLODE_2); diff --git a/src/games/defender/DefenderLogic.js b/src/games/defender/DefenderLogic.js index 250cb13..59094b7 100644 --- a/src/games/defender/DefenderLogic.js +++ b/src/games/defender/DefenderLogic.js @@ -168,13 +168,40 @@ export function waveSpec(level, wave) { }; } -const BOSS_KINDS = ['sentinel', 'crusher', 'swarmlord']; +// 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) { - const kind = BOSS_KINDS[(level - 1) % BOSS_KINDS.length]; - const dual = level >= 4; - const secondaryKind = dual ? BOSS_KINDS[level % BOSS_KINDS.length] : null; return { - kind, secondaryKind, + name: bossProfileFor(level).name, hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP, }; } @@ -623,50 +650,92 @@ function handleCollisions(state, events) { function spawnBoss(state, events) { const spec = bossSpec(state.level); state.boss = { - kind: spec.kind, secondaryKind: spec.secondaryKind, - hp: spec.hp, maxHp: spec.hp, + 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: TUNE.BOSS_ATTACK_COOLDOWN_MS, phase: 1, + dir: 1, attackCooldownMs: bossProfileFor(state.level).cooldownMs, phase: 1, + moveIdx: 0, spiralAngle: 0, }; - events.push({ type: 'bossSpawn', kind: spec.kind }); + events.push({ type: 'bossSpawn', kind: spec.name }); } -function activeBossKind(boss) { - if (!boss.secondaryKind) return boss.kind; - return boss.hp > boss.maxHp / 2 ? boss.kind : boss.secondaryKind; +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: activeBossKind(boss) }); + if (boss.phase !== wasPhase) events.push({ type: 'bossPhaseChange', phase: boss.phase, kind: boss.name }); - const kind = activeBossKind(boss); - boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * dtS * (kind === 'crusher' ? 2.4 : 1)); + 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) { - boss.attackCooldownMs = TUNE.BOSS_ATTACK_COOLDOWN_MS; - if (kind === 'sentinel' || kind === 'swarmlord') { - for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) { - const a = (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2; - state.enemyShots.push({ - x: boss.x, y: boss.y, vx: Math.cos(a) * TUNE.BOSS_SHOT_SPEED, vy: Math.sin(a) * TUNE.BOSS_SHOT_SPEED, - radius: 8, ttlMs: 2600, - }); - } - events.push({ type: 'shotFired', enemy: true, boss: true }); - } - if (kind === 'swarmlord') spawnSwarmerPack(state, 6); + // 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.kind }); + events.push({ type: 'bossDefeated', kind: boss.name }); state.score += TUNE.BOSS_KILL_SCORE; state.boss = null; } diff --git a/tools/verifyDefender.js b/tools/verifyDefender.js index edf5be9..3f02480 100644 --- a/tools/verifyDefender.js +++ b/tools/verifyDefender.js @@ -12,10 +12,12 @@ // 7. Spiral-of-death guard on a huge injected deltaMs. // 8. Determinism — same seed + same input sequence replayed twice. // 9. Monte-carlo bot soak across many seeds through all 5 levels. +// 10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety. import { WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE, wrap, tdelta, tdist, createGame, setInput, step, + BOSS_PROFILES, bossSpec, } from '../src/games/defender/DefenderLogic.js'; let failures = 0; @@ -367,6 +369,65 @@ console.log('9. Monte-carlo bot soak (all 5 levels)'); console.log(` info: ${victories}/${SEEDS} bot runs reached victory, ${gameOvers}/${SEEDS} ended in game over`); } +// --------------------------------------------------------------------------- +console.log('10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety'); +{ + const VALID_MOVES = new Set(['ring', 'spread', 'spiral', 'aimed', 'wall', 'reinforce']); + check('one boss profile authored per level', BOSS_PROFILES.length === TUNE.LEVEL_COUNT, + `${BOSS_PROFILES.length} profiles vs ${TUNE.LEVEL_COUNT} levels`); + + let prevHp = -Infinity; let prevCooldown = Infinity; + let hpMonotonic = true; let cooldownMonotonic = true; let allMovesValid = true; let anyDualPhase = false; + for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) { + const spec = bossSpec(level); + const profile = BOSS_PROFILES[level - 1]; + if (spec.hp <= prevHp) hpMonotonic = false; + prevHp = spec.hp; + if (profile.cooldownMs >= prevCooldown) cooldownMonotonic = false; + prevCooldown = profile.cooldownMs; + for (const m of profile.moves) if (!VALID_MOVES.has(m)) allMovesValid = false; + if (profile.phase2Moves) { + anyDualPhase = true; + for (const m of profile.phase2Moves) if (!VALID_MOVES.has(m)) allMovesValid = false; + } + } + check('boss HP strictly increases level over level', hpMonotonic); + check('boss attack cooldown strictly shortens level over level (attacks come faster)', cooldownMonotonic); + check('every authored move name is a recognized attack pattern', allMovesValid); + check('later levels introduce a second, harder attack phase', anyDualPhase); + + // Live-sim: spawn each level's boss directly and confirm its phase-1 moves cycle + // in the declared round-robin order, and phase-2 moves take over once wounded. + for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) { + const profile = BOSS_PROFILES[level - 1]; + const state = createGame({ seed: 900 + level, startLevel: level }); + state.player.invulnMs = 999999; // isolate attack-pattern dispatch from collision outcomes + state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = []; + runTicks(state, 1); // spawns the boss + + const ticksPerCycle = Math.ceil(profile.cooldownMs / STEP_MS) + 2; + const observed = []; + for (let i = 0; i < profile.moves.length; i += 1) { + const ev = runTicks(state, ticksPerCycle); + const mv = ev.find((e) => e.type === 'bossMove'); + if (mv) observed.push(mv.move); + } + const matches = observed.length === profile.moves.length && observed.every((m, i) => m === profile.moves[i]); + check(`level ${level} (${profile.name}) phase-1 moves cycle in declared order`, + matches, `expected ${JSON.stringify(profile.moves)}, observed ${JSON.stringify(observed)}`); + + if (profile.phase2Moves) { + state.boss.hp = 1; // force the phase-2 threshold on the next tick + const ev2 = runTicks(state, ticksPerCycle); + const phaseEv = ev2.find((e) => e.type === 'bossPhaseChange'); + const mv2 = ev2.find((e) => e.type === 'bossMove'); + check(`level ${level} (${profile.name}) drops into phase 2 when wounded`, !!phaseEv && phaseEv.phase === 2); + check(`level ${level} (${profile.name}) phase-2 move comes from its harder move set`, + !!mv2 && profile.phase2Moves.includes(mv2.move), `got ${mv2 && mv2.move}`); + } + } +} + // --------------------------------------------------------------------------- if (failures > 0) { console.error(`\n${failures} check(s) FAILED`);