Defender #12
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue