Defender #12

Merged
brianfertig merged 3 commits from Defender into main 2026-09-06 23:45:43 +00:00
3 changed files with 159 additions and 29 deletions
Showing only changes of commit 03d35b0b00 - Show all commits

View File

@ -289,7 +289,7 @@ export default class DefenderGame extends Phaser.Scene {
break; break;
case 'bossSpawn': case 'bossSpawn':
playSound(this, SFX.SCIFI_REVEAL); playSound(this, SFX.SCIFI_REVEAL);
this.showBanner('WARNING', 0xff4040, 1800); this.showBanner(`WARNING: ${e.kind}`, 0xff4040, 2000);
break; break;
case 'bossPhaseChange': case 'bossPhaseChange':
playSound(this, SFX.EIGHTBIT_EXPLODE_2); playSound(this, SFX.EIGHTBIT_EXPLODE_2);

View File

@ -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) { 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 { return {
kind, secondaryKind, name: bossProfileFor(level).name,
hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP, hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP,
}; };
} }
@ -623,50 +650,92 @@ function handleCollisions(state, events) {
function spawnBoss(state, events) { function spawnBoss(state, events) {
const spec = bossSpec(state.level); const spec = bossSpec(state.level);
state.boss = { state.boss = {
kind: spec.kind, secondaryKind: spec.secondaryKind, name: spec.name, hp: spec.hp, maxHp: spec.hp,
hp: spec.hp, maxHp: spec.hp,
x: wrap(state.player.x + WORLD_W / 2), y: (Y_MIN + Y_GROUND) / 2, 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) { function fireBossShot(state, boss, angle, speed) {
if (!boss.secondaryKind) return boss.kind; state.enemyShots.push({
return boss.hp > boss.maxHp / 2 ? boss.kind : boss.secondaryKind; 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) { function updateBoss(state, dt, events) {
const boss = state.boss; const boss = state.boss;
if (!boss) return; if (!boss) return;
const dtS = dt / 1000; const dtS = dt / 1000;
const profile = bossProfileFor(state.level);
const wasPhase = boss.phase; const wasPhase = boss.phase;
boss.phase = boss.hp > boss.maxHp / 2 ? 1 : 2; 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); const moves = (boss.phase === 2 && profile.phase2Moves) ? profile.phase2Moves : profile.moves;
boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * dtS * (kind === 'crusher' ? 2.4 : 1));
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 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; if (Math.abs(dHome) > WORLD_W * 0.3) boss.dir *= -1;
boss.attackCooldownMs -= dt; boss.attackCooldownMs -= dt;
if (boss.attackCooldownMs <= 0 && state.player.alive) { if (boss.attackCooldownMs <= 0 && state.player.alive) {
boss.attackCooldownMs = TUNE.BOSS_ATTACK_COOLDOWN_MS; // Phase 2 also attacks a little faster, on top of whatever new moves it unlocked.
if (kind === 'sentinel' || kind === 'swarmlord') { boss.attackCooldownMs = profile.cooldownMs * (boss.phase === 2 ? 0.8 : 1);
for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) { const move = moves[boss.moveIdx % moves.length];
const a = (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2; boss.moveIdx += 1;
state.enemyShots.push({ events.push({ type: 'bossMove', move, phase: boss.phase });
x: boss.x, y: boss.y, vx: Math.cos(a) * TUNE.BOSS_SHOT_SPEED, vy: Math.sin(a) * TUNE.BOSS_SHOT_SPEED, performBossMove(state, boss, move, events);
radius: 8, ttlMs: 2600,
});
}
events.push({ type: 'shotFired', enemy: true, boss: true });
}
if (kind === 'swarmlord') spawnSwarmerPack(state, 6);
} }
if (boss.hp <= 0) { 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.score += TUNE.BOSS_KILL_SCORE;
state.boss = null; state.boss = null;
} }

View File

@ -12,10 +12,12 @@
// 7. Spiral-of-death guard on a huge injected deltaMs. // 7. Spiral-of-death guard on a huge injected deltaMs.
// 8. Determinism — same seed + same input sequence replayed twice. // 8. Determinism — same seed + same input sequence replayed twice.
// 9. Monte-carlo bot soak across many seeds through all 5 levels. // 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 { import {
WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE, WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE,
wrap, tdelta, tdist, createGame, setInput, step, wrap, tdelta, tdist, createGame, setInput, step,
BOSS_PROFILES, bossSpec,
} from '../src/games/defender/DefenderLogic.js'; } from '../src/games/defender/DefenderLogic.js';
let failures = 0; 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(` 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) { if (failures > 0) {
console.error(`\n${failures} check(s) FAILED`); console.error(`\n${failures} check(s) FAILED`);