phase 8: Ouroboros boss fight — pattern puzzle with 3 victory paths
First Archon encounter: a cyclical pattern-recognition puzzle. Boss AI: 4-phase cycle (Coil → Spray → Lash → Digest) with escalating difficulty (10% faster per cycle, caps at 5 cycles). Victory paths (all based on real chemistry): - Chemical: NaOH during Spray phase (acid-base neutralization, 3x dmg) - Direct: any projectile during Digest vulnerability window - Catalytic: Hg poison stacks (mercury poisons catalytic sites, reduces regen+armor permanently) New files: src/boss/ (types, ai, victory, arena, factory, reward), src/data/bosses.json, src/scenes/BossArenaScene.ts, tests/boss.test.ts Extended: ECS Boss component, CodexEntry 'boss' type, GameScene triggers arena on Resolution phase completion. 70 new tests (455 total), all passing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
152
src/boss/ai.ts
Normal file
152
src/boss/ai.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Boss AI System — cyclical phase logic for Archon encounters
|
||||
*
|
||||
* The boss cycles through 4 phases (Coil → Spray → Lash → Digest),
|
||||
* each cycle becoming faster. During Digest, the boss is vulnerable.
|
||||
* Between cycles, the boss regenerates health (self-catalysis).
|
||||
*/
|
||||
|
||||
import {
|
||||
BossPhase,
|
||||
BOSS_PHASE_COUNT,
|
||||
type BossData,
|
||||
type BossState,
|
||||
type BossPhaseEvent,
|
||||
} from './types';
|
||||
|
||||
/** Create initial boss state from boss data */
|
||||
export function createBossState(boss: BossData): BossState {
|
||||
const initialDuration = getEffectivePhaseDuration(
|
||||
{ cycleCount: 0 } as BossState, boss, BossPhase.Coil,
|
||||
);
|
||||
|
||||
return {
|
||||
bossId: boss.id,
|
||||
health: boss.health,
|
||||
maxHealth: boss.health,
|
||||
currentPhase: BossPhase.Coil,
|
||||
phaseTimer: initialDuration,
|
||||
cycleCount: 0,
|
||||
catalystStacks: 0,
|
||||
defeated: false,
|
||||
victoryMethod: null,
|
||||
totalDamageDealt: 0,
|
||||
chemicalDamageDealt: 0,
|
||||
directDamageDealt: 0,
|
||||
catalystDamageDealt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective phase duration, accounting for cycle speedup.
|
||||
* Each completed cycle multiplies duration by phaseSpeedupPerCycle.
|
||||
* Caps at maxCycles to prevent impossibly short phases.
|
||||
*/
|
||||
export function getEffectivePhaseDuration(
|
||||
state: Pick<BossState, 'cycleCount'>,
|
||||
boss: BossData,
|
||||
phase: BossPhase,
|
||||
): number {
|
||||
const baseDuration = boss.phaseDurations[phase];
|
||||
const effectiveCycles = Math.min(state.cycleCount, boss.maxCycles);
|
||||
return baseDuration * Math.pow(boss.phaseSpeedupPerCycle, effectiveCycles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective armor value, accounting for catalyst poison stacks.
|
||||
* During vulnerable phases: uses armorVulnerable as base.
|
||||
* During normal phases: uses armor as base.
|
||||
* Catalyst stacks reduce armor further.
|
||||
*/
|
||||
export function getEffectiveArmor(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
): number {
|
||||
const isVulnerable = boss.vulnerablePhases.includes(state.currentPhase);
|
||||
const baseArmor = isVulnerable ? boss.armorVulnerable : boss.armor;
|
||||
const reduction = state.catalystStacks * boss.catalystArmorReduction;
|
||||
return Math.max(0, baseArmor - reduction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective regeneration rate, accounting for catalyst poison.
|
||||
* Each catalyst stack permanently reduces regen.
|
||||
*/
|
||||
export function getEffectiveRegen(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
): number {
|
||||
const reduction = state.catalystStacks * boss.catalystRegenReduction;
|
||||
return Math.max(0, boss.regenPerSecond - reduction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the boss is currently in a vulnerable phase.
|
||||
*/
|
||||
export function isVulnerable(state: BossState, boss: BossData): boolean {
|
||||
return boss.vulnerablePhases.includes(state.currentPhase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update boss phase timer and cycle through phases.
|
||||
* Returns events for phase changes, cycle completions, and attacks.
|
||||
*
|
||||
* @param state - Mutable boss state
|
||||
* @param boss - Immutable boss data
|
||||
* @param delta - Time elapsed in ms
|
||||
* @returns Array of events that occurred this tick
|
||||
*/
|
||||
export function updateBossPhase(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
delta: number,
|
||||
): BossPhaseEvent[] {
|
||||
if (state.defeated) return [];
|
||||
|
||||
const events: BossPhaseEvent[] = [];
|
||||
|
||||
// Apply regeneration
|
||||
const regen = getEffectiveRegen(state, boss);
|
||||
if (regen > 0) {
|
||||
state.health = Math.min(state.maxHealth, state.health + regen * (delta / 1000));
|
||||
}
|
||||
|
||||
// Count down phase timer
|
||||
state.phaseTimer -= delta;
|
||||
|
||||
// Phase transition
|
||||
while (state.phaseTimer <= 0) {
|
||||
const nextPhase = ((state.currentPhase + 1) % BOSS_PHASE_COUNT) as BossPhase;
|
||||
|
||||
// Cycle completion: when Digest ends (wrapping back to Coil)
|
||||
if (state.currentPhase === BossPhase.Digest) {
|
||||
state.cycleCount += 1;
|
||||
events.push({
|
||||
type: 'cycle_complete',
|
||||
phase: nextPhase,
|
||||
cycleCount: state.cycleCount,
|
||||
});
|
||||
}
|
||||
|
||||
state.currentPhase = nextPhase;
|
||||
const newDuration = getEffectivePhaseDuration(state, boss, nextPhase);
|
||||
state.phaseTimer += newDuration;
|
||||
|
||||
events.push({
|
||||
type: 'phase_change',
|
||||
phase: nextPhase,
|
||||
cycleCount: state.cycleCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Boss attacks during Spray and Lash phases
|
||||
if (state.currentPhase === BossPhase.Spray || state.currentPhase === BossPhase.Lash) {
|
||||
events.push({
|
||||
type: 'boss_attack',
|
||||
phase: state.currentPhase,
|
||||
cycleCount: state.cycleCount,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
136
src/boss/arena.ts
Normal file
136
src/boss/arena.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Boss Arena Generator — creates a circular arena for Archon encounters
|
||||
*
|
||||
* The arena is a special room with:
|
||||
* - Circular walkable area surrounded by crystal walls
|
||||
* - Acid pools near the edges (hazards)
|
||||
* - 4 mineral deposits at cardinal positions (for ammo)
|
||||
* - Boss spawns at center, player enters from south
|
||||
*
|
||||
* Uses the same tile index system as the main world generator.
|
||||
*/
|
||||
|
||||
import type { BossData, ArenaData } from './types';
|
||||
import type { BiomeData } from '../world/types';
|
||||
|
||||
/** Tile indices matching biomes.json tile order */
|
||||
const TILE = {
|
||||
GROUND: 0,
|
||||
SCORCHED_EARTH: 1,
|
||||
ACID_SHALLOW: 2,
|
||||
ACID_DEEP: 3,
|
||||
CRYSTAL_FORMATION: 4,
|
||||
GEYSER: 5,
|
||||
MINERAL_VEIN: 6,
|
||||
BEDROCK: 7,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Generate a circular boss arena.
|
||||
*
|
||||
* @param boss - Boss data (determines arena size)
|
||||
* @param biome - Biome data (for tile size)
|
||||
* @returns Arena data with grid, spawn positions, and resource locations
|
||||
*/
|
||||
export function generateArena(boss: BossData, biome: BiomeData): ArenaData {
|
||||
const radius = boss.arenaRadius;
|
||||
const diameter = radius * 2 + 1;
|
||||
const centerTile = radius;
|
||||
const tileSize = biome.tileSize;
|
||||
|
||||
// Initialize grid with bedrock (impassable)
|
||||
const grid: number[][] = [];
|
||||
for (let y = 0; y < diameter; y++) {
|
||||
grid[y] = [];
|
||||
for (let x = 0; x < diameter; x++) {
|
||||
grid[y][x] = TILE.BEDROCK;
|
||||
}
|
||||
}
|
||||
|
||||
const resourcePositions: { x: number; y: number }[] = [];
|
||||
|
||||
// Carve circular arena
|
||||
for (let y = 0; y < diameter; y++) {
|
||||
for (let x = 0; x < diameter; x++) {
|
||||
const dx = x - centerTile;
|
||||
const dy = y - centerTile;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist <= radius - 1) {
|
||||
// Inner arena: walkable ground
|
||||
grid[y][x] = TILE.GROUND;
|
||||
|
||||
// Acid pools ring at 60-75% of radius
|
||||
if (dist > radius * 0.6 && dist <= radius * 0.75) {
|
||||
// Place acid in a pattern (not solid ring — alternating)
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const sector = Math.floor((angle + Math.PI) / (Math.PI / 4));
|
||||
if (sector % 2 === 0) {
|
||||
grid[y][x] = TILE.ACID_SHALLOW;
|
||||
}
|
||||
}
|
||||
|
||||
// Scorched earth ring at 75-90% of radius
|
||||
if (dist > radius * 0.75 && dist <= radius * 0.9) {
|
||||
grid[y][x] = TILE.SCORCHED_EARTH;
|
||||
}
|
||||
} else if (dist <= radius) {
|
||||
// Crystal wall border
|
||||
grid[y][x] = TILE.CRYSTAL_FORMATION;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place 4 mineral deposits at cardinal positions (40% of radius from center)
|
||||
const mineralDist = Math.round(radius * 0.4);
|
||||
const cardinalOffsets = [
|
||||
{ dx: 0, dy: -mineralDist }, // North
|
||||
{ dx: mineralDist, dy: 0 }, // East
|
||||
{ dx: 0, dy: mineralDist }, // South (near player spawn)
|
||||
{ dx: -mineralDist, dy: 0 }, // West
|
||||
];
|
||||
|
||||
for (const offset of cardinalOffsets) {
|
||||
const mx = centerTile + offset.dx;
|
||||
const my = centerTile + offset.dy;
|
||||
if (mx >= 0 && mx < diameter && my >= 0 && my < diameter) {
|
||||
grid[my][mx] = TILE.MINERAL_VEIN;
|
||||
resourcePositions.push({
|
||||
x: mx * tileSize + tileSize / 2,
|
||||
y: my * tileSize + tileSize / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Boss spawns at center
|
||||
const bossSpawnX = centerTile * tileSize + tileSize / 2;
|
||||
const bossSpawnY = centerTile * tileSize + tileSize / 2;
|
||||
|
||||
// Player spawns at south edge (80% of radius from center)
|
||||
const playerSpawnX = centerTile * tileSize + tileSize / 2;
|
||||
const playerSpawnY = (centerTile + Math.round(radius * 0.8)) * tileSize + tileSize / 2;
|
||||
|
||||
return {
|
||||
grid,
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
bossSpawnX,
|
||||
bossSpawnY,
|
||||
playerSpawnX,
|
||||
playerSpawnY,
|
||||
resourcePositions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a set of walkable tile indices for the arena.
|
||||
* Matches the pattern used in the main world's collision system.
|
||||
*/
|
||||
export function buildArenaWalkableSet(): Set<number> {
|
||||
return new Set([
|
||||
TILE.GROUND,
|
||||
TILE.SCORCHED_EARTH,
|
||||
TILE.MINERAL_VEIN,
|
||||
TILE.GEYSER,
|
||||
]);
|
||||
}
|
||||
59
src/boss/factory.ts
Normal file
59
src/boss/factory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Boss Entity Factory — creates boss entities in the ECS world
|
||||
*
|
||||
* The boss gets: Position, Velocity, Health, SpriteRef, Boss components.
|
||||
* Rich state (phase, cycle, stacks) is stored in BossState, not ECS.
|
||||
*/
|
||||
|
||||
import { addEntity, addComponent } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import {
|
||||
Position,
|
||||
Velocity,
|
||||
Health,
|
||||
SpriteRef,
|
||||
Boss,
|
||||
} from '../ecs/components';
|
||||
import type { BossData } from './types';
|
||||
|
||||
/**
|
||||
* Create a boss entity at the given position.
|
||||
*
|
||||
* @returns Entity ID
|
||||
*/
|
||||
export function createBossEntity(
|
||||
world: World,
|
||||
boss: BossData,
|
||||
x: number,
|
||||
y: number,
|
||||
): number {
|
||||
const eid = addEntity(world);
|
||||
|
||||
// Position
|
||||
addComponent(world, eid, Position);
|
||||
Position.x[eid] = x;
|
||||
Position.y[eid] = y;
|
||||
|
||||
// Velocity (boss controls its own movement via AI)
|
||||
addComponent(world, eid, Velocity);
|
||||
Velocity.vx[eid] = 0;
|
||||
Velocity.vy[eid] = 0;
|
||||
|
||||
// Health
|
||||
addComponent(world, eid, Health);
|
||||
Health.current[eid] = boss.health;
|
||||
Health.max[eid] = boss.health;
|
||||
|
||||
// Visual
|
||||
addComponent(world, eid, SpriteRef);
|
||||
SpriteRef.color[eid] = parseInt(boss.color.replace('#', ''), 16);
|
||||
SpriteRef.radius[eid] = boss.radius;
|
||||
|
||||
// Boss tag
|
||||
addComponent(world, eid, Boss);
|
||||
Boss.dataIndex[eid] = 0; // Index in bosses.json array (only Ouroboros for now)
|
||||
Boss.phase[eid] = 0; // BossPhase.Coil
|
||||
Boss.cycleCount[eid] = 0;
|
||||
|
||||
return eid;
|
||||
}
|
||||
19
src/boss/index.ts
Normal file
19
src/boss/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Boss module — Archon encounters
|
||||
*/
|
||||
|
||||
export { BossPhase, VictoryMethod } from './types';
|
||||
export type {
|
||||
BossData,
|
||||
BossState,
|
||||
BossDamageResult,
|
||||
BossPhaseEvent,
|
||||
ArenaData,
|
||||
BossReward,
|
||||
} from './types';
|
||||
|
||||
export { createBossState, updateBossPhase, getEffectiveArmor, getEffectiveRegen, isVulnerable, getEffectivePhaseDuration } from './ai';
|
||||
export { applyBossDamage, isBossDefeated } from './victory';
|
||||
export { generateArena, buildArenaWalkableSet } from './arena';
|
||||
export { calculateBossReward, applyBossReward } from './reward';
|
||||
export { createBossEntity } from './factory';
|
||||
86
src/boss/reward.ts
Normal file
86
src/boss/reward.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Boss Reward System — Archont's Memory and spore rewards
|
||||
*
|
||||
* Defeating a boss grants:
|
||||
* 1. Spores (currency for meta-progression)
|
||||
* 2. Archont's Memory — a lore entry added to the Codex
|
||||
* 3. Victory flavor based on the method used
|
||||
*/
|
||||
|
||||
import { VictoryMethod, type BossData, type BossState, type BossReward } from './types';
|
||||
import type { MetaState, CodexEntry } from '../run/types';
|
||||
|
||||
/**
|
||||
* Calculate the reward for defeating a boss.
|
||||
*
|
||||
* @param state - Boss state at time of defeat
|
||||
* @param boss - Boss configuration data
|
||||
* @returns Reward with spores, lore, and flavor
|
||||
*/
|
||||
export function calculateBossReward(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
): BossReward {
|
||||
if (!state.defeated || !state.victoryMethod) {
|
||||
return {
|
||||
spores: 0,
|
||||
loreId: boss.id,
|
||||
loreText: boss.loreEntry,
|
||||
loreTextRu: boss.loreEntryRu,
|
||||
victoryMethod: VictoryMethod.Direct,
|
||||
};
|
||||
}
|
||||
|
||||
// Base reward + bonus for elegant victory methods
|
||||
let spores = boss.sporeReward;
|
||||
|
||||
switch (state.victoryMethod) {
|
||||
case VictoryMethod.Chemical:
|
||||
// Chemical victory: 50% bonus (requires crafting NaOH + timing)
|
||||
spores = Math.round(spores * 1.5);
|
||||
break;
|
||||
case VictoryMethod.Catalytic:
|
||||
// Catalytic victory: 100% bonus (requires observation + Hg)
|
||||
spores = Math.round(spores * 2.0);
|
||||
break;
|
||||
case VictoryMethod.Direct:
|
||||
// Direct victory: base reward
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
spores,
|
||||
loreId: boss.id,
|
||||
loreText: boss.loreEntry,
|
||||
loreTextRu: boss.loreEntryRu,
|
||||
victoryMethod: state.victoryMethod,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply boss defeat to meta state: add codex entry and spores.
|
||||
*
|
||||
* @param meta - Mutable meta state
|
||||
* @param reward - Boss reward to apply
|
||||
* @param runId - Current run ID
|
||||
*/
|
||||
export function applyBossReward(
|
||||
meta: MetaState,
|
||||
reward: BossReward,
|
||||
runId: number,
|
||||
): void {
|
||||
// Add spores
|
||||
meta.spores += reward.spores;
|
||||
|
||||
// Add boss lore to codex (skip if already exists)
|
||||
const key = `boss:${reward.loreId}`;
|
||||
const exists = meta.codex.some(e => `${e.type}:${e.id}` === key);
|
||||
if (!exists) {
|
||||
const entry: CodexEntry = {
|
||||
id: reward.loreId,
|
||||
type: 'boss' as CodexEntry['type'],
|
||||
discoveredOnRun: runId,
|
||||
};
|
||||
meta.codex.push(entry);
|
||||
}
|
||||
}
|
||||
202
src/boss/types.ts
Normal file
202
src/boss/types.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Boss Fight Types — Archon encounters
|
||||
*
|
||||
* Ouroboros: The Archon of Cycles — a serpent consuming its own tail.
|
||||
* The fight is a cyclical pattern-recognition puzzle with 3 victory paths:
|
||||
* 1. Chemical — NaOH neutralization during acid phase (real acid-base chemistry)
|
||||
* 2. Direct — projectile damage during vulnerability window
|
||||
* 3. Catalytic — Hg catalyst poisoning (real: mercury poisons catalytic sites)
|
||||
*/
|
||||
|
||||
// ─── Boss Phases ─────────────────────────────────────────────────
|
||||
|
||||
/** Cyclical boss attack phases (repeat with escalation) */
|
||||
export enum BossPhase {
|
||||
/** Arena constricts — boss coils, safe zone shrinks */
|
||||
Coil = 0,
|
||||
/** Acid projectile spray — rotating pattern */
|
||||
Spray = 1,
|
||||
/** Tail sweep — sector damage arcs */
|
||||
Lash = 2,
|
||||
/** Self-consumption pause — vulnerability window */
|
||||
Digest = 3,
|
||||
}
|
||||
|
||||
export const BOSS_PHASE_COUNT = 4;
|
||||
|
||||
export const BOSS_PHASE_NAMES: Record<BossPhase, string> = {
|
||||
[BossPhase.Coil]: 'Coil',
|
||||
[BossPhase.Spray]: 'Acid Spray',
|
||||
[BossPhase.Lash]: 'Tail Lash',
|
||||
[BossPhase.Digest]: 'Digest',
|
||||
};
|
||||
|
||||
export const BOSS_PHASE_NAMES_RU: Record<BossPhase, string> = {
|
||||
[BossPhase.Coil]: 'Сжатие',
|
||||
[BossPhase.Spray]: 'Кислотный Залп',
|
||||
[BossPhase.Lash]: 'Удар Хвостом',
|
||||
[BossPhase.Digest]: 'Переваривание',
|
||||
};
|
||||
|
||||
// ─── Victory Methods ─────────────────────────────────────────────
|
||||
|
||||
/** How the boss was defeated (determines reward flavor) */
|
||||
export enum VictoryMethod {
|
||||
/** NaOH acid-base neutralization during Spray phase */
|
||||
Chemical = 'chemical',
|
||||
/** Brute force projectile damage during Digest */
|
||||
Direct = 'direct',
|
||||
/** Hg catalyst poisoning — disrupts self-catalysis */
|
||||
Catalytic = 'catalytic',
|
||||
}
|
||||
|
||||
// ─── Boss Data (from JSON) ──────────────────────────────────────
|
||||
|
||||
/** Boss configuration loaded from bosses.json */
|
||||
export interface BossData {
|
||||
id: string;
|
||||
name: string;
|
||||
nameRu: string;
|
||||
description: string;
|
||||
descriptionRu: string;
|
||||
|
||||
/** Hex color string for rendering */
|
||||
color: string;
|
||||
/** Base radius in pixels */
|
||||
radius: number;
|
||||
|
||||
/** Maximum health points */
|
||||
health: number;
|
||||
/** Damage reduction 0–1 during non-vulnerable phases */
|
||||
armor: number;
|
||||
/** Damage reduction 0–1 during vulnerable phases */
|
||||
armorVulnerable: number;
|
||||
/** HP regeneration per second (self-catalysis) */
|
||||
regenPerSecond: number;
|
||||
/** Base attack damage to player */
|
||||
damage: number;
|
||||
|
||||
/** Phase durations in ms: [Coil, Spray, Lash, Digest] */
|
||||
phaseDurations: [number, number, number, number];
|
||||
/** Duration multiplier per completed cycle (< 1 = faster) */
|
||||
phaseSpeedupPerCycle: number;
|
||||
/** Difficulty caps after this many cycles */
|
||||
maxCycles: number;
|
||||
|
||||
/** BossPhase values where boss takes full damage */
|
||||
vulnerablePhases: number[];
|
||||
/** Compound ID that deals bonus chemical damage */
|
||||
chemicalWeakness: string;
|
||||
/** Damage multiplier for chemical weakness */
|
||||
chemicalDamageMultiplier: number;
|
||||
/** Phases where chemical weakness applies (e.g. Spray for NaOH) */
|
||||
chemicalEffectivePhases: number[];
|
||||
/** Element symbol that acts as catalyst poison */
|
||||
catalystPoison: string;
|
||||
/** Regen reduction per catalyst stack (HP/s) */
|
||||
catalystRegenReduction: number;
|
||||
/** Armor reduction per catalyst stack (absolute) */
|
||||
catalystArmorReduction: number;
|
||||
/** Maximum catalyst stacks */
|
||||
maxCatalystStacks: number;
|
||||
|
||||
/** Arena radius in tiles */
|
||||
arenaRadius: number;
|
||||
|
||||
/** Lore text revealed on victory (English) */
|
||||
loreEntry: string;
|
||||
/** Lore text revealed on victory (Russian) */
|
||||
loreEntryRu: string;
|
||||
|
||||
/** Spore reward for defeating the boss */
|
||||
sporeReward: number;
|
||||
}
|
||||
|
||||
// ─── Runtime Boss State ──────────────────────────────────────────
|
||||
|
||||
/** Runtime state of the boss fight (stored in scene, not ECS) */
|
||||
export interface BossState {
|
||||
/** Boss data ID */
|
||||
bossId: string;
|
||||
/** Current health */
|
||||
health: number;
|
||||
/** Maximum health */
|
||||
maxHealth: number;
|
||||
/** Current attack phase */
|
||||
currentPhase: BossPhase;
|
||||
/** Time remaining in current phase (ms) */
|
||||
phaseTimer: number;
|
||||
/** Number of completed full cycles (Coil→Spray→Lash→Digest) */
|
||||
cycleCount: number;
|
||||
/** Number of catalyst poison (Hg) applications */
|
||||
catalystStacks: number;
|
||||
/** Whether the boss has been defeated */
|
||||
defeated: boolean;
|
||||
/** How the boss was defeated (null if not yet) */
|
||||
victoryMethod: VictoryMethod | null;
|
||||
/** Running totals by damage type */
|
||||
totalDamageDealt: number;
|
||||
chemicalDamageDealt: number;
|
||||
directDamageDealt: number;
|
||||
catalystDamageDealt: number;
|
||||
}
|
||||
|
||||
// ─── Damage Result ──────────────────────────────────────────────
|
||||
|
||||
/** Result of applying damage to the boss */
|
||||
export interface BossDamageResult {
|
||||
/** Actual damage dealt after armor */
|
||||
damageDealt: number;
|
||||
/** Type of damage */
|
||||
damageType: VictoryMethod;
|
||||
/** Whether this was a killing blow */
|
||||
killingBlow: boolean;
|
||||
/** Whether a catalyst stack was applied */
|
||||
catalystApplied: boolean;
|
||||
/** Feedback message for player */
|
||||
message: string;
|
||||
messageRu: string;
|
||||
}
|
||||
|
||||
// ─── Boss Phase Event ───────────────────────────────────────────
|
||||
|
||||
/** Events emitted during boss phase updates */
|
||||
export interface BossPhaseEvent {
|
||||
type: 'phase_change' | 'cycle_complete' | 'boss_attack';
|
||||
phase: BossPhase;
|
||||
cycleCount: number;
|
||||
}
|
||||
|
||||
// ─── Arena Data ─────────────────────────────────────────────────
|
||||
|
||||
/** Generated arena tile layout */
|
||||
export interface ArenaData {
|
||||
/** Tile grid (same format as WorldData.grid) */
|
||||
grid: number[][];
|
||||
/** Arena width in tiles */
|
||||
width: number;
|
||||
/** Arena height in tiles */
|
||||
height: number;
|
||||
/** Boss spawn position in pixels */
|
||||
bossSpawnX: number;
|
||||
bossSpawnY: number;
|
||||
/** Player spawn position in pixels */
|
||||
playerSpawnX: number;
|
||||
playerSpawnY: number;
|
||||
/** Resource deposit positions in pixels */
|
||||
resourcePositions: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
// ─── Boss Reward ────────────────────────────────────────────────
|
||||
|
||||
/** Reward for defeating the boss */
|
||||
export interface BossReward {
|
||||
/** Spores earned */
|
||||
spores: number;
|
||||
/** Lore text for Codex */
|
||||
loreId: string;
|
||||
loreText: string;
|
||||
loreTextRu: string;
|
||||
/** Victory method determines flavor */
|
||||
victoryMethod: VictoryMethod;
|
||||
}
|
||||
178
src/boss/victory.ts
Normal file
178
src/boss/victory.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Boss Victory Conditions — 3 paths to defeating an Archon
|
||||
*
|
||||
* 1. Chemical: NaOH during Spray phase (acid-base neutralization)
|
||||
* 2. Direct: Any projectile during Digest vulnerability window
|
||||
* 3. Catalytic: Hg poison stacks (disrupts self-catalysis)
|
||||
*
|
||||
* Real science behind each method:
|
||||
* - NaOH + HCl → NaCl + H₂O (neutralization of Ouroboros acid)
|
||||
* - Mercury poisons catalytic sites (real catalysis chemistry)
|
||||
* - Direct damage exploits the brief self-consumption pause
|
||||
*/
|
||||
|
||||
import { VictoryMethod, type BossData, type BossState, type BossDamageResult } from './types';
|
||||
import { getEffectiveArmor, isVulnerable } from './ai';
|
||||
|
||||
/** Base damage from a projectile hit */
|
||||
const PROJECTILE_BASE_DAMAGE = 15;
|
||||
|
||||
/** Damage dealt by catalyst poison application */
|
||||
const CATALYST_DAMAGE = 10;
|
||||
|
||||
/**
|
||||
* Apply damage to the boss from a projectile hit.
|
||||
* Determines damage type based on the item used and current boss phase.
|
||||
*
|
||||
* @param state - Mutable boss state
|
||||
* @param boss - Immutable boss data
|
||||
* @param itemId - The element/compound symbol used (e.g. "NaOH", "Hg", "Fe")
|
||||
* @returns Damage result with feedback
|
||||
*/
|
||||
export function applyBossDamage(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
itemId: string,
|
||||
): BossDamageResult {
|
||||
if (state.defeated) {
|
||||
return {
|
||||
damageDealt: 0,
|
||||
damageType: VictoryMethod.Direct,
|
||||
killingBlow: false,
|
||||
catalystApplied: false,
|
||||
message: 'Boss already defeated',
|
||||
messageRu: 'Босс уже побеждён',
|
||||
};
|
||||
}
|
||||
|
||||
const armor = getEffectiveArmor(state, boss);
|
||||
|
||||
// Path 3: Catalyst poison (Hg)
|
||||
if (itemId === boss.catalystPoison) {
|
||||
return applyCatalystDamage(state, boss, armor);
|
||||
}
|
||||
|
||||
// Path 1: Chemical weakness (NaOH during Spray)
|
||||
if (
|
||||
itemId === boss.chemicalWeakness &&
|
||||
boss.chemicalEffectivePhases.includes(state.currentPhase)
|
||||
) {
|
||||
return applyChemicalDamage(state, boss, armor);
|
||||
}
|
||||
|
||||
// Path 2: Direct damage (any projectile during vulnerable phase)
|
||||
if (isVulnerable(state, boss)) {
|
||||
return applyDirectDamage(state, boss, armor);
|
||||
}
|
||||
|
||||
// Non-vulnerable phase, non-special item → heavily reduced damage
|
||||
const reducedDamage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor)));
|
||||
return dealDamage(state, reducedDamage, VictoryMethod.Direct, false, {
|
||||
message: `Ouroboros absorbs the blow (${reducedDamage} dmg)`,
|
||||
messageRu: `Уроборос поглощает удар (${reducedDamage} урона)`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply chemical damage (NaOH neutralization) */
|
||||
function applyChemicalDamage(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
armor: number,
|
||||
): BossDamageResult {
|
||||
const rawDamage = PROJECTILE_BASE_DAMAGE * boss.chemicalDamageMultiplier;
|
||||
const damage = Math.max(1, Math.round(rawDamage * (1 - armor)));
|
||||
|
||||
return dealDamage(state, damage, VictoryMethod.Chemical, false, {
|
||||
message: `Acid neutralized! NaOH + acid → salt + water (${damage} dmg)`,
|
||||
messageRu: `Кислота нейтрализована! NaOH + кислота → соль + вода (${damage} урона)`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply direct projectile damage during vulnerability */
|
||||
function applyDirectDamage(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
armor: number,
|
||||
): BossDamageResult {
|
||||
const damage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor)));
|
||||
|
||||
return dealDamage(state, damage, VictoryMethod.Direct, false, {
|
||||
message: `Hit during digestion! (${damage} dmg)`,
|
||||
messageRu: `Попадание во время переваривания! (${damage} урона)`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply catalyst poison (Hg) */
|
||||
function applyCatalystDamage(
|
||||
state: BossState,
|
||||
boss: BossData,
|
||||
armor: number,
|
||||
): BossDamageResult {
|
||||
let catalystApplied = false;
|
||||
let message: string;
|
||||
let messageRu: string;
|
||||
|
||||
if (state.catalystStacks < boss.maxCatalystStacks) {
|
||||
state.catalystStacks += 1;
|
||||
catalystApplied = true;
|
||||
message = `Mercury poisons catalytic site! Stack ${state.catalystStacks}/${boss.maxCatalystStacks} — regen and armor permanently reduced`;
|
||||
messageRu = `Ртуть отравляет каталитический центр! Стак ${state.catalystStacks}/${boss.maxCatalystStacks} — регенерация и броня снижены навсегда`;
|
||||
} else {
|
||||
message = `Catalyst fully poisoned (${CATALYST_DAMAGE} dmg)`;
|
||||
messageRu = `Катализатор полностью отравлен (${CATALYST_DAMAGE} урона)`;
|
||||
}
|
||||
|
||||
const damage = Math.max(1, Math.round(CATALYST_DAMAGE * (1 - armor)));
|
||||
|
||||
return dealDamage(state, damage, VictoryMethod.Catalytic, catalystApplied, {
|
||||
message,
|
||||
messageRu,
|
||||
});
|
||||
}
|
||||
|
||||
/** Common damage application logic */
|
||||
function dealDamage(
|
||||
state: BossState,
|
||||
damage: number,
|
||||
damageType: VictoryMethod,
|
||||
catalystApplied: boolean,
|
||||
messages: { message: string; messageRu: string },
|
||||
): BossDamageResult {
|
||||
state.health = Math.max(0, state.health - damage);
|
||||
state.totalDamageDealt += damage;
|
||||
|
||||
// Track damage by type
|
||||
switch (damageType) {
|
||||
case VictoryMethod.Chemical:
|
||||
state.chemicalDamageDealt += damage;
|
||||
break;
|
||||
case VictoryMethod.Direct:
|
||||
state.directDamageDealt += damage;
|
||||
break;
|
||||
case VictoryMethod.Catalytic:
|
||||
state.catalystDamageDealt += damage;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for killing blow
|
||||
const killingBlow = state.health <= 0;
|
||||
if (killingBlow) {
|
||||
state.defeated = true;
|
||||
state.victoryMethod = damageType;
|
||||
}
|
||||
|
||||
return {
|
||||
damageDealt: damage,
|
||||
damageType,
|
||||
killingBlow,
|
||||
catalystApplied,
|
||||
...messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the boss is defeated.
|
||||
*/
|
||||
export function isBossDefeated(state: BossState): boolean {
|
||||
return state.defeated;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { GameScene } from './scenes/GameScene';
|
||||
import { UIScene } from './scenes/UIScene';
|
||||
import { DeathScene } from './scenes/DeathScene';
|
||||
import { FractalScene } from './scenes/FractalScene';
|
||||
import { BossArenaScene } from './scenes/BossArenaScene';
|
||||
|
||||
export const GAME_WIDTH = 1280;
|
||||
export const GAME_HEIGHT = 720;
|
||||
@@ -15,7 +16,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
|
||||
height: GAME_HEIGHT,
|
||||
backgroundColor: '#0a0a0a',
|
||||
parent: document.body,
|
||||
scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene],
|
||||
scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene, BossArenaScene],
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
|
||||
31
src/data/bosses.json
Normal file
31
src/data/bosses.json
Normal file
@@ -0,0 +1,31 @@
|
||||
[
|
||||
{
|
||||
"id": "ouroboros",
|
||||
"name": "Ouroboros",
|
||||
"nameRu": "Уроборос",
|
||||
"description": "The Archon of Cycles — a serpent consuming its own tail in endless self-catalysis. Its body is both catalyst and substrate.",
|
||||
"descriptionRu": "Архонт Циклов — змей, пожирающий собственный хвост в бесконечном самокатализе. Его тело — и катализатор, и субстрат.",
|
||||
"color": "#cc44ff",
|
||||
"radius": 20,
|
||||
"health": 300,
|
||||
"armor": 0.5,
|
||||
"armorVulnerable": 0.1,
|
||||
"regenPerSecond": 5,
|
||||
"damage": 15,
|
||||
"phaseDurations": [5000, 8000, 6000, 4000],
|
||||
"phaseSpeedupPerCycle": 0.9,
|
||||
"maxCycles": 5,
|
||||
"vulnerablePhases": [3],
|
||||
"chemicalWeakness": "NaOH",
|
||||
"chemicalDamageMultiplier": 3.0,
|
||||
"chemicalEffectivePhases": [1],
|
||||
"catalystPoison": "Hg",
|
||||
"catalystRegenReduction": 2.0,
|
||||
"catalystArmorReduction": 0.1,
|
||||
"maxCatalystStacks": 3,
|
||||
"arenaRadius": 10,
|
||||
"loreEntry": "The first Archon awakens. The Ouroboros — an eternal cycle of self-consumption. Its body is both catalyst and substrate, endlessly breaking itself down and rebuilding. To break its cycle is to understand catalysis itself: that which accelerates change without being consumed... until a poison finds the active site.",
|
||||
"loreEntryRu": "Первый Архонт пробуждается. Уроборос — вечный цикл самопоглощения. Его тело — одновременно катализатор и субстрат, бесконечно разрушающее и восстанавливающее себя. Разорвать его цикл — значит постичь суть катализа: то, что ускоряет изменение, не расходуясь... пока яд не найдёт активный центр.",
|
||||
"sporeReward": 100
|
||||
}
|
||||
]
|
||||
@@ -84,6 +84,15 @@ export const LifeCycle = {
|
||||
age: [] as number[], // total age in ms
|
||||
};
|
||||
|
||||
// ─── Boss Components ─────────────────────────────────────────────
|
||||
|
||||
/** Boss entity tag + phase info for bridge rendering */
|
||||
export const Boss = {
|
||||
dataIndex: [] as number[], // index in bosses.json array
|
||||
phase: [] as number[], // BossPhase enum value (synced from BossState)
|
||||
cycleCount: [] as number[], // completed cycles (synced from BossState)
|
||||
};
|
||||
|
||||
// ─── Mycelium Components ─────────────────────────────────────────
|
||||
|
||||
/** Fungal node — a point where the Mycelium surfaces on the map */
|
||||
|
||||
@@ -110,7 +110,7 @@ export interface RunDiscoveries {
|
||||
|
||||
export interface CodexEntry {
|
||||
id: string;
|
||||
type: 'element' | 'reaction' | 'compound' | 'creature';
|
||||
type: 'element' | 'reaction' | 'compound' | 'creature' | 'boss';
|
||||
discoveredOnRun: number;
|
||||
}
|
||||
|
||||
|
||||
761
src/scenes/BossArenaScene.ts
Normal file
761
src/scenes/BossArenaScene.ts
Normal file
@@ -0,0 +1,761 @@
|
||||
/**
|
||||
* BossArenaScene — Archon boss fight in a circular arena
|
||||
*
|
||||
* Features:
|
||||
* - Circular arena tilemap with hazards
|
||||
* - Boss entity with cyclical phase AI
|
||||
* - 3 victory paths (chemical, direct, catalytic)
|
||||
* - Boss health bar and phase indicator
|
||||
* - Reward on victory → transition to CradleScene
|
||||
* - Death → normal DeathScene flow
|
||||
*/
|
||||
|
||||
import Phaser from 'phaser';
|
||||
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
||||
import { Health, Position, Boss as BossComponent, Velocity } from '../ecs/components';
|
||||
import { movementSystem } from '../ecs/systems/movement';
|
||||
import { healthSystem } from '../ecs/systems/health';
|
||||
import { removeGameEntity } from '../ecs/factory';
|
||||
import { PhaserBridge } from '../ecs/bridge';
|
||||
import biomeDataArray from '../data/biomes.json';
|
||||
import bossDataArray from '../data/bosses.json';
|
||||
import type { BiomeData } from '../world/types';
|
||||
import { createWorldTilemap } from '../world/tilemap';
|
||||
import { buildWalkableSet } from '../player/collision';
|
||||
import { createPlayerEntity } from '../player/factory';
|
||||
import { Inventory } from '../player/inventory';
|
||||
import {
|
||||
launchProjectile,
|
||||
projectileSystem,
|
||||
type ProjectileData,
|
||||
} from '../player/projectile';
|
||||
import { playerInputSystem } from '../player/input';
|
||||
import { tileCollisionSystem } from '../player/collision';
|
||||
import { QuickSlots } from '../player/quickslots';
|
||||
import type { InputState } from '../player/types';
|
||||
|
||||
// Boss imports
|
||||
import type { BossData, BossState, BossPhaseEvent } from '../boss/types';
|
||||
import { BossPhase, BOSS_PHASE_NAMES_RU } from '../boss/types';
|
||||
import { createBossState, updateBossPhase, getEffectiveArmor, isVulnerable } from '../boss/ai';
|
||||
import { applyBossDamage, isBossDefeated } from '../boss/victory';
|
||||
import { generateArena, buildArenaWalkableSet } from '../boss/arena';
|
||||
import { createBossEntity } from '../boss/factory';
|
||||
import { calculateBossReward, applyBossReward } from '../boss/reward';
|
||||
|
||||
// Run cycle imports
|
||||
import type { MetaState, RunState } from '../run/types';
|
||||
import { query } from 'bitecs';
|
||||
import { Projectile } from '../ecs/components';
|
||||
|
||||
/** Data passed from GameScene to BossArenaScene */
|
||||
interface BossArenaInitData {
|
||||
meta: MetaState;
|
||||
runState: RunState;
|
||||
inventoryItems: { id: string; count: number }[];
|
||||
quickSlotItems: (string | null)[];
|
||||
activeSlot: number;
|
||||
playerHealth: number;
|
||||
playerMaxHealth: number;
|
||||
}
|
||||
|
||||
export class BossArenaScene extends Phaser.Scene {
|
||||
private gameWorld!: GameWorld;
|
||||
private bridge!: PhaserBridge;
|
||||
|
||||
// Player
|
||||
private playerEid!: number;
|
||||
private inventory!: Inventory;
|
||||
private quickSlots!: QuickSlots;
|
||||
private walkableSet!: Set<number>;
|
||||
private worldGrid!: number[][];
|
||||
private tileSize!: number;
|
||||
private projectileData!: Map<number, ProjectileData>;
|
||||
private keys!: {
|
||||
W: Phaser.Input.Keyboard.Key;
|
||||
A: Phaser.Input.Keyboard.Key;
|
||||
S: Phaser.Input.Keyboard.Key;
|
||||
D: Phaser.Input.Keyboard.Key;
|
||||
E: Phaser.Input.Keyboard.Key;
|
||||
F: Phaser.Input.Keyboard.Key;
|
||||
ONE: Phaser.Input.Keyboard.Key;
|
||||
TWO: Phaser.Input.Keyboard.Key;
|
||||
THREE: Phaser.Input.Keyboard.Key;
|
||||
FOUR: Phaser.Input.Keyboard.Key;
|
||||
};
|
||||
|
||||
// Boss
|
||||
private bossEid!: number;
|
||||
private bossData!: BossData;
|
||||
private bossState!: BossState;
|
||||
private bossProjectiles: number[] = [];
|
||||
private bossAttackTimer = 0;
|
||||
|
||||
// Run state
|
||||
private meta!: MetaState;
|
||||
private runState!: RunState;
|
||||
private playerDead = false;
|
||||
private victoryAchieved = false;
|
||||
|
||||
// UI elements
|
||||
private bossHealthBar!: Phaser.GameObjects.Graphics;
|
||||
private bossHealthText!: Phaser.GameObjects.Text;
|
||||
private phaseText!: Phaser.GameObjects.Text;
|
||||
private feedbackText!: Phaser.GameObjects.Text;
|
||||
private feedbackTimer = 0;
|
||||
private bossGlowGraphics!: Phaser.GameObjects.Graphics;
|
||||
|
||||
// Input debounce
|
||||
private wasFDown = false;
|
||||
|
||||
constructor() {
|
||||
super({ key: 'BossArenaScene' });
|
||||
}
|
||||
|
||||
init(data: BossArenaInitData): void {
|
||||
this.meta = data.meta;
|
||||
this.runState = data.runState;
|
||||
this.playerDead = false;
|
||||
this.victoryAchieved = false;
|
||||
this.bossProjectiles = [];
|
||||
this.bossAttackTimer = 0;
|
||||
|
||||
// Restore inventory
|
||||
this.inventory = new Inventory(500, 20);
|
||||
for (const item of data.inventoryItems) {
|
||||
for (let i = 0; i < item.count; i++) {
|
||||
this.inventory.addItem(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore quick slots
|
||||
this.quickSlots = new QuickSlots();
|
||||
for (let i = 0; i < data.quickSlotItems.length; i++) {
|
||||
this.quickSlots.assign(i, data.quickSlotItems[i]);
|
||||
}
|
||||
this.quickSlots.setActive(data.activeSlot);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const biome = biomeDataArray[0] as BiomeData;
|
||||
this.bossData = bossDataArray[0] as BossData;
|
||||
this.tileSize = biome.tileSize;
|
||||
|
||||
// 1. Initialize ECS
|
||||
this.gameWorld = createGameWorld();
|
||||
this.bridge = new PhaserBridge(this);
|
||||
this.projectileData = new Map();
|
||||
|
||||
// 2. Generate arena
|
||||
const arena = generateArena(this.bossData, biome);
|
||||
this.worldGrid = arena.grid;
|
||||
this.walkableSet = buildArenaWalkableSet();
|
||||
|
||||
// 3. Create tilemap (reuse world tilemap system)
|
||||
const arenaWorldData = {
|
||||
grid: arena.grid,
|
||||
biome: biome,
|
||||
seed: 0,
|
||||
};
|
||||
createWorldTilemap(this, arenaWorldData);
|
||||
|
||||
// 4. Create player entity at arena entrance
|
||||
this.playerEid = createPlayerEntity(
|
||||
this.gameWorld.world, arena.playerSpawnX, arena.playerSpawnY,
|
||||
);
|
||||
|
||||
// Apply player health from GameScene
|
||||
const initData = this.scene.settings.data as BossArenaInitData;
|
||||
if (initData?.playerHealth !== undefined) {
|
||||
Health.current[this.playerEid] = initData.playerHealth;
|
||||
Health.max[this.playerEid] = initData.playerMaxHealth;
|
||||
}
|
||||
|
||||
// 5. Create boss entity at center
|
||||
this.bossEid = createBossEntity(
|
||||
this.gameWorld.world, this.bossData, arena.bossSpawnX, arena.bossSpawnY,
|
||||
);
|
||||
this.bossState = createBossState(this.bossData);
|
||||
|
||||
// 6. Camera setup
|
||||
const worldPixelW = arena.width * this.tileSize;
|
||||
const worldPixelH = arena.height * this.tileSize;
|
||||
this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH);
|
||||
this.cameras.main.setZoom(2.0); // Closer zoom for arena
|
||||
|
||||
// Sync bridge to create sprites, then follow player
|
||||
this.bridge.sync(this.gameWorld.world);
|
||||
const playerSprite = this.bridge.getSprite(this.playerEid);
|
||||
if (playerSprite) {
|
||||
playerSprite.setDepth(10);
|
||||
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
|
||||
}
|
||||
|
||||
// 7. Keyboard input
|
||||
const keyboard = this.input.keyboard;
|
||||
if (!keyboard) throw new Error('Keyboard plugin not available');
|
||||
this.keys = {
|
||||
W: keyboard.addKey('W'),
|
||||
A: keyboard.addKey('A'),
|
||||
S: keyboard.addKey('S'),
|
||||
D: keyboard.addKey('D'),
|
||||
E: keyboard.addKey('E'),
|
||||
F: keyboard.addKey('F'),
|
||||
ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE),
|
||||
TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO),
|
||||
THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE),
|
||||
FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR),
|
||||
};
|
||||
|
||||
// 8. Boss glow graphics (world-space)
|
||||
this.bossGlowGraphics = this.add.graphics();
|
||||
this.bossGlowGraphics.setDepth(5);
|
||||
|
||||
// 9. UI elements (screen-space)
|
||||
this.createBossUI();
|
||||
|
||||
// 10. Launch UIScene for player HUD
|
||||
this.scene.launch('UIScene');
|
||||
|
||||
// Entry announcement
|
||||
this.showFeedback('⚔ УРОБОРОС — Архонт Циклов');
|
||||
}
|
||||
|
||||
update(_time: number, delta: number): void {
|
||||
if (this.playerDead || this.victoryAchieved) return;
|
||||
|
||||
// 1. Update world time
|
||||
updateTime(this.gameWorld, delta);
|
||||
|
||||
// 2. Player input
|
||||
const input: InputState = {
|
||||
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
|
||||
moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0),
|
||||
interact: this.keys.E.isDown,
|
||||
};
|
||||
playerInputSystem(this.gameWorld.world, input);
|
||||
|
||||
// 3. Movement
|
||||
movementSystem(this.gameWorld.world, delta);
|
||||
|
||||
// 4. Tile collision (player)
|
||||
tileCollisionSystem(
|
||||
this.gameWorld.world, delta,
|
||||
this.worldGrid, this.tileSize, this.walkableSet,
|
||||
);
|
||||
|
||||
// 5. Projectile system
|
||||
projectileSystem(
|
||||
this.gameWorld.world, delta,
|
||||
this.worldGrid, this.tileSize, this.walkableSet,
|
||||
this.projectileData,
|
||||
);
|
||||
|
||||
// 6. Quick slots
|
||||
if (this.keys.ONE.isDown) this.quickSlots.setActive(0);
|
||||
if (this.keys.TWO.isDown) this.quickSlots.setActive(1);
|
||||
if (this.keys.THREE.isDown) this.quickSlots.setActive(2);
|
||||
if (this.keys.FOUR.isDown) this.quickSlots.setActive(3);
|
||||
|
||||
// 7. Throw projectile (F key)
|
||||
const isFDown = this.keys.F.isDown;
|
||||
const justPressedF = isFDown && !this.wasFDown;
|
||||
this.wasFDown = isFDown;
|
||||
if (justPressedF) {
|
||||
this.tryLaunchProjectile();
|
||||
}
|
||||
|
||||
// 8. Boss AI phase update
|
||||
const bossEvents = updateBossPhase(this.bossState, this.bossData, delta);
|
||||
this.handleBossEvents(bossEvents, delta);
|
||||
|
||||
// Sync boss ECS component with runtime state
|
||||
BossComponent.phase[this.bossEid] = this.bossState.currentPhase;
|
||||
BossComponent.cycleCount[this.bossEid] = this.bossState.cycleCount;
|
||||
|
||||
// 9. Boss attack behavior
|
||||
this.updateBossAttacks(delta);
|
||||
|
||||
// 10. Check projectile → boss collision
|
||||
this.checkProjectileBossCollision();
|
||||
|
||||
// 11. Health system
|
||||
const dead = healthSystem(this.gameWorld.world);
|
||||
let playerDied = false;
|
||||
for (const eid of dead) {
|
||||
if (eid === this.playerEid) {
|
||||
playerDied = true;
|
||||
continue;
|
||||
}
|
||||
if (eid === this.bossEid) {
|
||||
continue; // Boss death handled by victory system
|
||||
}
|
||||
removeGameEntity(this.gameWorld.world, eid);
|
||||
}
|
||||
|
||||
if (playerDied) {
|
||||
this.onPlayerDeath();
|
||||
return;
|
||||
}
|
||||
|
||||
// 12. Check victory
|
||||
if (isBossDefeated(this.bossState)) {
|
||||
this.onBossDefeated();
|
||||
return;
|
||||
}
|
||||
|
||||
// 13. Render sync
|
||||
this.bridge.sync(this.gameWorld.world);
|
||||
|
||||
// 14. Update boss visuals
|
||||
this.updateBossVisuals(delta);
|
||||
|
||||
// 15. Update UI
|
||||
this.updateBossUI();
|
||||
|
||||
// 16. Feedback text fade
|
||||
if (this.feedbackTimer > 0) {
|
||||
this.feedbackTimer -= delta;
|
||||
if (this.feedbackTimer <= 500) {
|
||||
this.feedbackText.setAlpha(this.feedbackTimer / 500);
|
||||
}
|
||||
if (this.feedbackTimer <= 0) {
|
||||
this.feedbackText.setAlpha(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 17. Push state to registry for UIScene
|
||||
this.registry.set('health', Health.current[this.playerEid] ?? 100);
|
||||
this.registry.set('healthMax', Health.max[this.playerEid] ?? 100);
|
||||
this.registry.set('quickSlots', this.quickSlots.getAll());
|
||||
this.registry.set('activeSlot', this.quickSlots.activeIndex);
|
||||
this.registry.set('invWeight', this.inventory.getTotalWeight());
|
||||
this.registry.set('invMaxWeight', this.inventory.maxWeight);
|
||||
this.registry.set('invSlots', this.inventory.slotCount);
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of this.inventory.getItems()) {
|
||||
counts.set(item.id, item.count);
|
||||
}
|
||||
this.registry.set('invCounts', counts);
|
||||
}
|
||||
|
||||
// ─── Boss Attack Logic ─────────────────────────────────────────
|
||||
|
||||
private handleBossEvents(events: BossPhaseEvent[], _delta: number): void {
|
||||
for (const event of events) {
|
||||
if (event.type === 'phase_change') {
|
||||
const phaseName = BOSS_PHASE_NAMES_RU[event.phase];
|
||||
this.showFeedback(`Фаза: ${phaseName}`);
|
||||
}
|
||||
if (event.type === 'cycle_complete') {
|
||||
this.showFeedback(`Цикл ${event.cycleCount} завершён — Уроборос ускоряется!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateBossAttacks(delta: number): void {
|
||||
const phase = this.bossState.currentPhase;
|
||||
const bx = Position.x[this.bossEid];
|
||||
const by = Position.y[this.bossEid];
|
||||
const px = Position.x[this.playerEid];
|
||||
const py = Position.y[this.playerEid];
|
||||
|
||||
switch (phase) {
|
||||
case BossPhase.Coil: {
|
||||
// Boss slowly moves toward player
|
||||
const dx = px - bx;
|
||||
const dy = py - by;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > 30) {
|
||||
const speed = 20 + this.bossState.cycleCount * 5;
|
||||
Velocity.vx[this.bossEid] = (dx / dist) * speed;
|
||||
Velocity.vy[this.bossEid] = (dy / dist) * speed;
|
||||
} else {
|
||||
Velocity.vx[this.bossEid] = 0;
|
||||
Velocity.vy[this.bossEid] = 0;
|
||||
// Close-range damage
|
||||
this.bossAttackTimer -= delta;
|
||||
if (this.bossAttackTimer <= 0) {
|
||||
Health.current[this.playerEid] = Math.max(
|
||||
0, (Health.current[this.playerEid] ?? 0) - this.bossData.damage * 0.5,
|
||||
);
|
||||
this.bossAttackTimer = 1500;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case BossPhase.Spray: {
|
||||
// Boss stays still, shoots acid projectiles in rotating pattern
|
||||
Velocity.vx[this.bossEid] = 0;
|
||||
Velocity.vy[this.bossEid] = 0;
|
||||
|
||||
this.bossAttackTimer -= delta;
|
||||
if (this.bossAttackTimer <= 0) {
|
||||
const sprayCount = 4 + this.bossState.cycleCount;
|
||||
const baseAngle = (Date.now() / 1000) * 2; // Rotating
|
||||
for (let i = 0; i < sprayCount; i++) {
|
||||
const angle = baseAngle + (i * 2 * Math.PI / sprayCount);
|
||||
const targetX = bx + Math.cos(angle) * 200;
|
||||
const targetY = by + Math.sin(angle) * 200;
|
||||
this.spawnBossProjectile(bx, by, targetX, targetY);
|
||||
}
|
||||
this.bossAttackTimer = 1500 - this.bossState.cycleCount * 150;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case BossPhase.Lash: {
|
||||
// Boss sweeps tail — area damage if player is in range + angle
|
||||
Velocity.vx[this.bossEid] = 0;
|
||||
Velocity.vy[this.bossEid] = 0;
|
||||
|
||||
this.bossAttackTimer -= delta;
|
||||
if (this.bossAttackTimer <= 0) {
|
||||
const dx = px - bx;
|
||||
const dy = py - by;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 80 + this.bossState.cycleCount * 10) {
|
||||
const damage = this.bossData.damage * (1 + this.bossState.cycleCount * 0.2);
|
||||
Health.current[this.playerEid] = Math.max(
|
||||
0, (Health.current[this.playerEid] ?? 0) - damage,
|
||||
);
|
||||
this.showFeedback(`Удар хвостом! (${Math.round(damage)} урона)`);
|
||||
}
|
||||
this.bossAttackTimer = 2000 - this.bossState.cycleCount * 200;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case BossPhase.Digest: {
|
||||
// Boss is immobile and vulnerable
|
||||
Velocity.vx[this.bossEid] = 0;
|
||||
Velocity.vy[this.bossEid] = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private spawnBossProjectile(fromX: number, fromY: number, toX: number, toY: number): void {
|
||||
launchProjectile(
|
||||
this.gameWorld.world,
|
||||
this.projectileData,
|
||||
fromX, fromY,
|
||||
toX, toY,
|
||||
'__boss_acid__',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Projectile → Boss Collision ───────────────────────────────
|
||||
|
||||
private checkProjectileBossCollision(): void {
|
||||
const projEntities = query(this.gameWorld.world, [Position, Projectile]);
|
||||
const bx = Position.x[this.bossEid];
|
||||
const by = Position.y[this.bossEid];
|
||||
const hitRadiusSq = (this.bossData.radius + 5) * (this.bossData.radius + 5);
|
||||
|
||||
for (const projEid of projEntities) {
|
||||
const projInfo = this.projectileData.get(projEid);
|
||||
if (!projInfo || projInfo.itemId === '__boss_acid__') continue; // Skip boss projectiles
|
||||
|
||||
const dx = Position.x[projEid] - bx;
|
||||
const dy = Position.y[projEid] - by;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
if (distSq <= hitRadiusSq) {
|
||||
// Hit the boss!
|
||||
const result = applyBossDamage(this.bossState, this.bossData, projInfo.itemId);
|
||||
|
||||
// Sync health to ECS
|
||||
Health.current[this.bossEid] = this.bossState.health;
|
||||
|
||||
// Remove projectile
|
||||
removeGameEntity(this.gameWorld.world, projEid);
|
||||
this.projectileData.delete(projEid);
|
||||
|
||||
// Show feedback
|
||||
this.showFeedback(result.messageRu);
|
||||
}
|
||||
}
|
||||
|
||||
// Check boss projectile → player collision
|
||||
const px = Position.x[this.playerEid];
|
||||
const py = Position.y[this.playerEid];
|
||||
const playerHitRadiusSq = 15 * 15;
|
||||
|
||||
for (const projEid of projEntities) {
|
||||
const projInfo = this.projectileData.get(projEid);
|
||||
if (!projInfo || projInfo.itemId !== '__boss_acid__') continue;
|
||||
|
||||
const dx = Position.x[projEid] - px;
|
||||
const dy = Position.y[projEid] - py;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
if (distSq <= playerHitRadiusSq) {
|
||||
// Boss projectile hits player
|
||||
const damage = this.bossData.damage * 0.7;
|
||||
Health.current[this.playerEid] = Math.max(
|
||||
0, (Health.current[this.playerEid] ?? 0) - damage,
|
||||
);
|
||||
removeGameEntity(this.gameWorld.world, projEid);
|
||||
this.projectileData.delete(projEid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Boss Visual Effects ───────────────────────────────────────
|
||||
|
||||
private updateBossVisuals(_delta: number): void {
|
||||
const bx = Position.x[this.bossEid];
|
||||
const by = Position.y[this.bossEid];
|
||||
|
||||
this.bossGlowGraphics.clear();
|
||||
|
||||
// Phase-based glow
|
||||
const phase = this.bossState.currentPhase;
|
||||
let glowColor = 0xcc44ff;
|
||||
let glowAlpha = 0.15;
|
||||
let glowRadius = this.bossData.radius * 2;
|
||||
|
||||
switch (phase) {
|
||||
case BossPhase.Coil:
|
||||
glowColor = 0xff4444; // Red — danger
|
||||
glowAlpha = 0.2;
|
||||
break;
|
||||
case BossPhase.Spray:
|
||||
glowColor = 0x88ff00; // Acid green
|
||||
glowAlpha = 0.25;
|
||||
glowRadius = this.bossData.radius * 2.5;
|
||||
break;
|
||||
case BossPhase.Lash:
|
||||
glowColor = 0xffaa00; // Orange — warning
|
||||
glowAlpha = 0.2;
|
||||
break;
|
||||
case BossPhase.Digest:
|
||||
glowColor = 0x4488ff; // Blue — vulnerable
|
||||
glowAlpha = 0.3;
|
||||
glowRadius = this.bossData.radius * 1.5;
|
||||
break;
|
||||
}
|
||||
|
||||
// Pulsing glow
|
||||
const pulse = 0.7 + 0.3 * Math.sin(Date.now() / 300);
|
||||
this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse);
|
||||
this.bossGlowGraphics.fillCircle(bx, by, glowRadius);
|
||||
|
||||
// Inner glow
|
||||
this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse * 1.5);
|
||||
this.bossGlowGraphics.fillCircle(bx, by, glowRadius * 0.5);
|
||||
|
||||
// Catalyst poison visual: darkening stacks
|
||||
if (this.bossState.catalystStacks > 0) {
|
||||
const poisonAlpha = 0.1 * this.bossState.catalystStacks;
|
||||
this.bossGlowGraphics.fillStyle(0x888888, poisonAlpha);
|
||||
this.bossGlowGraphics.fillCircle(bx, by, this.bossData.radius * 1.2);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UI ────────────────────────────────────────────────────────
|
||||
|
||||
private createBossUI(): void {
|
||||
const cam = this.cameras.main;
|
||||
|
||||
// Boss health bar background
|
||||
this.bossHealthBar = this.add.graphics();
|
||||
this.bossHealthBar.setScrollFactor(0);
|
||||
this.bossHealthBar.setDepth(100);
|
||||
|
||||
// Boss name + health text
|
||||
this.bossHealthText = this.add.text(cam.width / 2, 20, '', {
|
||||
fontSize: '14px',
|
||||
color: '#cc44ff',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#000000cc',
|
||||
padding: { x: 8, y: 4 },
|
||||
});
|
||||
this.bossHealthText.setScrollFactor(0);
|
||||
this.bossHealthText.setOrigin(0.5, 0);
|
||||
this.bossHealthText.setDepth(101);
|
||||
|
||||
// Phase indicator
|
||||
this.phaseText = this.add.text(cam.width / 2, 55, '', {
|
||||
fontSize: '12px',
|
||||
color: '#ffdd44',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#000000aa',
|
||||
padding: { x: 6, y: 2 },
|
||||
});
|
||||
this.phaseText.setScrollFactor(0);
|
||||
this.phaseText.setOrigin(0.5, 0);
|
||||
this.phaseText.setDepth(101);
|
||||
|
||||
// Feedback text (center)
|
||||
this.feedbackText = this.add.text(cam.width / 2, cam.height - 60, '', {
|
||||
fontSize: '14px',
|
||||
color: '#ffdd44',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#000000cc',
|
||||
padding: { x: 8, y: 4 },
|
||||
align: 'center',
|
||||
});
|
||||
this.feedbackText.setScrollFactor(0);
|
||||
this.feedbackText.setOrigin(0.5);
|
||||
this.feedbackText.setDepth(101);
|
||||
this.feedbackText.setAlpha(0);
|
||||
}
|
||||
|
||||
private updateBossUI(): void {
|
||||
const cam = this.cameras.main;
|
||||
const healthPct = this.bossState.health / this.bossState.maxHealth;
|
||||
|
||||
// Health bar
|
||||
const barWidth = 300;
|
||||
const barHeight = 8;
|
||||
const barX = (cam.width - barWidth) / 2;
|
||||
const barY = 42;
|
||||
|
||||
this.bossHealthBar.clear();
|
||||
// Background
|
||||
this.bossHealthBar.fillStyle(0x333333, 0.8);
|
||||
this.bossHealthBar.fillRect(barX, barY, barWidth, barHeight);
|
||||
// Fill
|
||||
const fillColor = healthPct > 0.5 ? 0xcc44ff : healthPct > 0.25 ? 0xff8800 : 0xff2222;
|
||||
this.bossHealthBar.fillStyle(fillColor, 1);
|
||||
this.bossHealthBar.fillRect(barX, barY, barWidth * healthPct, barHeight);
|
||||
// Border
|
||||
this.bossHealthBar.lineStyle(1, 0xffffff, 0.3);
|
||||
this.bossHealthBar.strokeRect(barX, barY, barWidth, barHeight);
|
||||
|
||||
// Health text
|
||||
const healthStr = `УРОБОРОС — ${Math.ceil(this.bossState.health)}/${this.bossState.maxHealth}`;
|
||||
this.bossHealthText.setText(healthStr);
|
||||
|
||||
// Phase text
|
||||
const phaseName = BOSS_PHASE_NAMES_RU[this.bossState.currentPhase];
|
||||
const cycleStr = this.bossState.cycleCount > 0 ? ` | Цикл ${this.bossState.cycleCount}` : '';
|
||||
const catalystStr = this.bossState.catalystStacks > 0
|
||||
? ` | ☠ Яд: ${this.bossState.catalystStacks}/${this.bossData.maxCatalystStacks}`
|
||||
: '';
|
||||
const vulnStr = isVulnerable(this.bossState, this.bossData) ? ' | ★ УЯЗВИМ' : '';
|
||||
this.phaseText.setText(`${phaseName}${vulnStr}${cycleStr}${catalystStr}`);
|
||||
|
||||
// Phase color
|
||||
switch (this.bossState.currentPhase) {
|
||||
case BossPhase.Coil:
|
||||
this.phaseText.setColor('#ff4444');
|
||||
break;
|
||||
case BossPhase.Spray:
|
||||
this.phaseText.setColor('#88ff00');
|
||||
break;
|
||||
case BossPhase.Lash:
|
||||
this.phaseText.setColor('#ffaa00');
|
||||
break;
|
||||
case BossPhase.Digest:
|
||||
this.phaseText.setColor('#4488ff');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Victory & Death ───────────────────────────────────────────
|
||||
|
||||
private onBossDefeated(): void {
|
||||
this.victoryAchieved = true;
|
||||
|
||||
// Calculate reward
|
||||
const reward = calculateBossReward(this.bossState, this.bossData);
|
||||
applyBossReward(this.meta, reward, this.runState.runId);
|
||||
|
||||
// Show victory message
|
||||
const cam = this.cameras.main;
|
||||
const victoryText = this.add.text(cam.width / 2, cam.height / 2, '', {
|
||||
fontSize: '20px',
|
||||
color: '#cc44ff',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#000000ee',
|
||||
padding: { x: 16, y: 12 },
|
||||
align: 'center',
|
||||
wordWrap: { width: 500 },
|
||||
});
|
||||
victoryText.setScrollFactor(0);
|
||||
victoryText.setOrigin(0.5);
|
||||
victoryText.setDepth(200);
|
||||
|
||||
const methodNames: Record<string, string> = {
|
||||
chemical: 'Алхимическая победа (NaOH)',
|
||||
direct: 'Прямая победа',
|
||||
catalytic: 'Каталитическая победа (Hg)',
|
||||
};
|
||||
const method = this.bossState.victoryMethod ?? 'direct';
|
||||
const methodName = methodNames[method] ?? method;
|
||||
|
||||
victoryText.setText(
|
||||
`★ УРОБОРОС ПОВЕРЖЕН ★\n\n${methodName}\n+${reward.spores} спор\n\nАрхонтова Память добавлена в Кодекс`,
|
||||
);
|
||||
|
||||
// Stop UIScene
|
||||
this.scene.stop('UIScene');
|
||||
|
||||
// Transition after delay
|
||||
this.time.delayedCall(4000, () => {
|
||||
this.cameras.main.fadeOut(1500, 0, 0, 0);
|
||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||
this.scene.start('DeathScene', {
|
||||
meta: this.meta,
|
||||
runState: this.runState,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private onPlayerDeath(): void {
|
||||
this.playerDead = true;
|
||||
this.runState.alive = false;
|
||||
|
||||
this.scene.stop('UIScene');
|
||||
|
||||
this.cameras.main.fadeOut(2000, 0, 0, 0);
|
||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||
this.scene.start('DeathScene', {
|
||||
meta: this.meta,
|
||||
runState: this.runState,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Projectile Launch ─────────────────────────────────────────
|
||||
|
||||
private tryLaunchProjectile(): void {
|
||||
const itemId = this.quickSlots.getActive();
|
||||
if (!itemId || !this.inventory.hasItem(itemId)) return;
|
||||
|
||||
const removed = this.inventory.removeItem(itemId, 1);
|
||||
if (removed === 0) return;
|
||||
|
||||
const pointer = this.input.activePointer;
|
||||
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
|
||||
|
||||
launchProjectile(
|
||||
this.gameWorld.world,
|
||||
this.projectileData,
|
||||
Position.x[this.playerEid],
|
||||
Position.y[this.playerEid],
|
||||
worldPoint.x,
|
||||
worldPoint.y,
|
||||
itemId,
|
||||
);
|
||||
|
||||
// Clear quick slot if empty
|
||||
if (!this.inventory.hasItem(itemId)) {
|
||||
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
|
||||
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Feedback ──────────────────────────────────────────────────
|
||||
|
||||
private showFeedback(message: string): void {
|
||||
this.feedbackText.setText(message);
|
||||
this.feedbackText.setAlpha(1);
|
||||
this.feedbackTimer = 2500;
|
||||
}
|
||||
}
|
||||
@@ -740,8 +740,8 @@ export class GameScene extends Phaser.Scene {
|
||||
advancePhase(this.runState); // → Crisis
|
||||
this.triggerCrisis();
|
||||
} else if (phase === RunPhase.Resolution) {
|
||||
// Run complete — could transition to a victory scene
|
||||
// For now, just keep playing
|
||||
// Resolution phase complete → enter boss arena
|
||||
this.enterBossArena();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,6 +842,39 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/** Transition to the boss arena (triggered when Resolution phase ends) */
|
||||
private enterBossArena(): void {
|
||||
if (this.playerDead) return;
|
||||
|
||||
// Prevent re-entry
|
||||
this.playerDead = true; // Reuse flag to stop updates
|
||||
|
||||
// Auto-deposit discoveries before leaving
|
||||
if (!this.hasDepositedThisRun) {
|
||||
depositKnowledge(this.meta.mycelium, this.runState);
|
||||
this.hasDepositedThisRun = true;
|
||||
}
|
||||
|
||||
this.showInteractionFeedback('collected', '⚔ Вход в арену Уробороса...');
|
||||
|
||||
// Fade out and transition
|
||||
this.time.delayedCall(1500, () => {
|
||||
this.scene.stop('UIScene');
|
||||
this.cameras.main.fadeOut(1000, 0, 0, 0);
|
||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||
this.scene.start('BossArenaScene', {
|
||||
meta: this.meta,
|
||||
runState: this.runState,
|
||||
inventoryItems: this.inventory.getItems(),
|
||||
quickSlotItems: this.quickSlots.getAll(),
|
||||
activeSlot: this.quickSlots.activeIndex,
|
||||
playerHealth: Health.current[this.playerEid] ?? 100,
|
||||
playerMaxHealth: Health.max[this.playerEid] ?? 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private showInteractionFeedback(type: string, itemId?: string): void {
|
||||
let msg = '';
|
||||
switch (type) {
|
||||
|
||||
758
tests/boss.test.ts
Normal file
758
tests/boss.test.ts
Normal file
@@ -0,0 +1,758 @@
|
||||
/**
|
||||
* Boss System Tests — Phase 8: First Archont (Ouroboros)
|
||||
*
|
||||
* Tests cover:
|
||||
* - Boss state creation and initialization
|
||||
* - Phase cycling (Coil → Spray → Lash → Digest → repeat)
|
||||
* - Phase speedup per cycle
|
||||
* - Damage mechanics (vulnerability windows, armor)
|
||||
* - Chemical damage (NaOH during Spray)
|
||||
* - Catalyst poison (Hg stacks, regen/armor reduction)
|
||||
* - Victory detection and method determination
|
||||
* - Arena generation (circular layout, features)
|
||||
* - Reward calculation (spores, lore)
|
||||
* - Boss entity factory (ECS)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||
import type { World } from '../src/ecs/world';
|
||||
import { Position, Health, SpriteRef, Boss } from '../src/ecs/components';
|
||||
|
||||
// Boss system imports
|
||||
import {
|
||||
createBossState,
|
||||
updateBossPhase,
|
||||
getEffectiveArmor,
|
||||
getEffectiveRegen,
|
||||
isVulnerable,
|
||||
getEffectivePhaseDuration,
|
||||
} from '../src/boss/ai';
|
||||
import {
|
||||
applyBossDamage,
|
||||
isBossDefeated,
|
||||
} from '../src/boss/victory';
|
||||
import {
|
||||
generateArena,
|
||||
buildArenaWalkableSet,
|
||||
} from '../src/boss/arena';
|
||||
import {
|
||||
calculateBossReward,
|
||||
applyBossReward,
|
||||
} from '../src/boss/reward';
|
||||
import { createBossEntity } from '../src/boss/factory';
|
||||
|
||||
// Types
|
||||
import {
|
||||
BossPhase,
|
||||
VictoryMethod,
|
||||
type BossData,
|
||||
type BossState,
|
||||
} from '../src/boss/types';
|
||||
import type { BiomeData } from '../src/world/types';
|
||||
import { createMetaState } from '../src/run/meta';
|
||||
|
||||
// Load boss data
|
||||
import bossDataArray from '../src/data/bosses.json';
|
||||
import biomeDataArray from '../src/data/biomes.json';
|
||||
|
||||
const ouroboros = bossDataArray[0] as BossData;
|
||||
const biome = biomeDataArray[0] as BiomeData;
|
||||
|
||||
// ─── Boss State Creation ─────────────────────────────────────────
|
||||
|
||||
describe('Boss State Creation', () => {
|
||||
it('creates initial boss state from data', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
expect(state.bossId).toBe('ouroboros');
|
||||
expect(state.health).toBe(300);
|
||||
expect(state.maxHealth).toBe(300);
|
||||
expect(state.currentPhase).toBe(BossPhase.Coil);
|
||||
expect(state.cycleCount).toBe(0);
|
||||
expect(state.catalystStacks).toBe(0);
|
||||
expect(state.defeated).toBe(false);
|
||||
expect(state.victoryMethod).toBeNull();
|
||||
});
|
||||
|
||||
it('starts with phase timer set to first phase duration', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
expect(state.phaseTimer).toBe(ouroboros.phaseDurations[0]); // 5000ms
|
||||
});
|
||||
|
||||
it('starts with zero damage counters', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
expect(state.totalDamageDealt).toBe(0);
|
||||
expect(state.chemicalDamageDealt).toBe(0);
|
||||
expect(state.directDamageDealt).toBe(0);
|
||||
expect(state.catalystDamageDealt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase Cycling ───────────────────────────────────────────────
|
||||
|
||||
describe('Boss Phase Cycling', () => {
|
||||
let state: BossState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createBossState(ouroboros);
|
||||
});
|
||||
|
||||
it('stays in current phase while timer has not expired', () => {
|
||||
updateBossPhase(state, ouroboros, 1000);
|
||||
expect(state.currentPhase).toBe(BossPhase.Coil);
|
||||
expect(state.phaseTimer).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('transitions from Coil to Spray when timer expires', () => {
|
||||
updateBossPhase(state, ouroboros, 5000); // Full Coil duration
|
||||
expect(state.currentPhase).toBe(BossPhase.Spray);
|
||||
});
|
||||
|
||||
it('transitions through full cycle: Coil → Spray → Lash → Digest', () => {
|
||||
// Coil (5s) → Spray
|
||||
updateBossPhase(state, ouroboros, 5000);
|
||||
expect(state.currentPhase).toBe(BossPhase.Spray);
|
||||
|
||||
// Spray (8s) → Lash
|
||||
updateBossPhase(state, ouroboros, 8000);
|
||||
expect(state.currentPhase).toBe(BossPhase.Lash);
|
||||
|
||||
// Lash (6s) → Digest
|
||||
updateBossPhase(state, ouroboros, 6000);
|
||||
expect(state.currentPhase).toBe(BossPhase.Digest);
|
||||
});
|
||||
|
||||
it('completes a full cycle and increments cycleCount', () => {
|
||||
// Full cycle: 5000 + 8000 + 6000 + 4000 = 23000ms
|
||||
const events = [];
|
||||
events.push(...updateBossPhase(state, ouroboros, 5000)); // Coil → Spray
|
||||
events.push(...updateBossPhase(state, ouroboros, 8000)); // Spray → Lash
|
||||
events.push(...updateBossPhase(state, ouroboros, 6000)); // Lash → Digest
|
||||
events.push(...updateBossPhase(state, ouroboros, 4000)); // Digest → Coil (cycle 1)
|
||||
|
||||
expect(state.currentPhase).toBe(BossPhase.Coil);
|
||||
expect(state.cycleCount).toBe(1);
|
||||
|
||||
const cycleEvent = events.find(e => e.type === 'cycle_complete');
|
||||
expect(cycleEvent).toBeDefined();
|
||||
expect(cycleEvent?.cycleCount).toBe(1);
|
||||
});
|
||||
|
||||
it('emits phase_change events', () => {
|
||||
const events = updateBossPhase(state, ouroboros, 5000); // Coil → Spray
|
||||
const phaseChange = events.find(e => e.type === 'phase_change');
|
||||
expect(phaseChange).toBeDefined();
|
||||
expect(phaseChange?.phase).toBe(BossPhase.Spray);
|
||||
});
|
||||
|
||||
it('emits boss_attack during Spray phase', () => {
|
||||
updateBossPhase(state, ouroboros, 5000); // → Spray
|
||||
const events = updateBossPhase(state, ouroboros, 100); // Tick in Spray
|
||||
const attack = events.find(e => e.type === 'boss_attack');
|
||||
expect(attack).toBeDefined();
|
||||
expect(attack?.phase).toBe(BossPhase.Spray);
|
||||
});
|
||||
|
||||
it('emits boss_attack during Lash phase', () => {
|
||||
updateBossPhase(state, ouroboros, 5000 + 8000); // → Lash
|
||||
const events = updateBossPhase(state, ouroboros, 100); // Tick in Lash
|
||||
const attack = events.find(e => e.type === 'boss_attack');
|
||||
expect(attack).toBeDefined();
|
||||
expect(attack?.phase).toBe(BossPhase.Lash);
|
||||
});
|
||||
|
||||
it('does not emit boss_attack during Coil or Digest', () => {
|
||||
// During Coil
|
||||
const coilEvents = updateBossPhase(state, ouroboros, 100);
|
||||
expect(coilEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0);
|
||||
|
||||
// Advance to Digest
|
||||
updateBossPhase(state, ouroboros, 4900 + 8000 + 6000); // Coil → Spray → Lash → Digest
|
||||
const digestEvents = updateBossPhase(state, ouroboros, 100);
|
||||
expect(digestEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not update when defeated', () => {
|
||||
state.defeated = true;
|
||||
const events = updateBossPhase(state, ouroboros, 10000);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase Speedup ───────────────────────────────────────────────
|
||||
|
||||
describe('Boss Phase Speedup', () => {
|
||||
it('returns base duration for cycle 0', () => {
|
||||
const duration = getEffectivePhaseDuration(
|
||||
{ cycleCount: 0 } as BossState,
|
||||
ouroboros,
|
||||
BossPhase.Coil,
|
||||
);
|
||||
expect(duration).toBe(5000);
|
||||
});
|
||||
|
||||
it('reduces duration by speedup factor per cycle', () => {
|
||||
// Cycle 1: 5000 * 0.9 = 4500
|
||||
const duration = getEffectivePhaseDuration(
|
||||
{ cycleCount: 1 } as BossState,
|
||||
ouroboros,
|
||||
BossPhase.Coil,
|
||||
);
|
||||
expect(duration).toBeCloseTo(4500);
|
||||
});
|
||||
|
||||
it('caps speedup at maxCycles', () => {
|
||||
// Cycle 5 (maxCycles): 5000 * 0.9^5 = 2952.45
|
||||
const duration5 = getEffectivePhaseDuration(
|
||||
{ cycleCount: 5 } as BossState,
|
||||
ouroboros,
|
||||
BossPhase.Coil,
|
||||
);
|
||||
// Cycle 10 (should cap at 5): same
|
||||
const duration10 = getEffectivePhaseDuration(
|
||||
{ cycleCount: 10 } as BossState,
|
||||
ouroboros,
|
||||
BossPhase.Coil,
|
||||
);
|
||||
expect(duration10).toBeCloseTo(duration5);
|
||||
});
|
||||
|
||||
it('applies speedup to all phases', () => {
|
||||
const state = { cycleCount: 2 } as BossState;
|
||||
const coil = getEffectivePhaseDuration(state, ouroboros, BossPhase.Coil);
|
||||
const spray = getEffectivePhaseDuration(state, ouroboros, BossPhase.Spray);
|
||||
const lash = getEffectivePhaseDuration(state, ouroboros, BossPhase.Lash);
|
||||
const digest = getEffectivePhaseDuration(state, ouroboros, BossPhase.Digest);
|
||||
|
||||
expect(coil).toBeCloseTo(5000 * 0.81); // 0.9^2
|
||||
expect(spray).toBeCloseTo(8000 * 0.81);
|
||||
expect(lash).toBeCloseTo(6000 * 0.81);
|
||||
expect(digest).toBeCloseTo(4000 * 0.81);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Armor & Regeneration ────────────────────────────────────────
|
||||
|
||||
describe('Boss Armor and Regeneration', () => {
|
||||
let state: BossState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createBossState(ouroboros);
|
||||
});
|
||||
|
||||
it('has full armor during non-vulnerable phases', () => {
|
||||
state.currentPhase = BossPhase.Coil;
|
||||
expect(getEffectiveArmor(state, ouroboros)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('has reduced armor during vulnerable phases', () => {
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
expect(getEffectiveArmor(state, ouroboros)).toBe(0.1);
|
||||
});
|
||||
|
||||
it('has full regen rate with 0 catalyst stacks', () => {
|
||||
expect(getEffectiveRegen(state, ouroboros)).toBe(5);
|
||||
});
|
||||
|
||||
it('reduces regen per catalyst stack', () => {
|
||||
state.catalystStacks = 1;
|
||||
expect(getEffectiveRegen(state, ouroboros)).toBe(3); // 5 - 1*2 = 3
|
||||
});
|
||||
|
||||
it('regen does not go below 0', () => {
|
||||
state.catalystStacks = 3;
|
||||
expect(getEffectiveRegen(state, ouroboros)).toBe(0); // 5 - 3*2 = -1 → 0
|
||||
});
|
||||
|
||||
it('reduces armor per catalyst stack', () => {
|
||||
state.catalystStacks = 2;
|
||||
state.currentPhase = BossPhase.Coil;
|
||||
expect(getEffectiveArmor(state, ouroboros)).toBe(0.3); // 0.5 - 2*0.1
|
||||
});
|
||||
|
||||
it('armor does not go below 0', () => {
|
||||
state.catalystStacks = 10; // Way over max stacks
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
expect(getEffectiveArmor(state, ouroboros)).toBe(0); // 0.1 - 10*0.1 → 0
|
||||
});
|
||||
|
||||
it('identifies vulnerable phases correctly', () => {
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
expect(isVulnerable(state, ouroboros)).toBe(true);
|
||||
|
||||
state.currentPhase = BossPhase.Coil;
|
||||
expect(isVulnerable(state, ouroboros)).toBe(false);
|
||||
|
||||
state.currentPhase = BossPhase.Spray;
|
||||
expect(isVulnerable(state, ouroboros)).toBe(false);
|
||||
|
||||
state.currentPhase = BossPhase.Lash;
|
||||
expect(isVulnerable(state, ouroboros)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies regeneration over time', () => {
|
||||
state.health = 250; // Damaged
|
||||
updateBossPhase(state, ouroboros, 2000); // 2 seconds → 10 HP regen
|
||||
expect(state.health).toBeCloseTo(260);
|
||||
});
|
||||
|
||||
it('does not regenerate past max health', () => {
|
||||
state.health = 298;
|
||||
updateBossPhase(state, ouroboros, 2000); // Would regen 10, capped at 300
|
||||
expect(state.health).toBe(300);
|
||||
});
|
||||
|
||||
it('regeneration is reduced by catalyst stacks', () => {
|
||||
state.health = 250;
|
||||
state.catalystStacks = 2; // regen = 5 - 2*2 = 1 HP/s
|
||||
updateBossPhase(state, ouroboros, 2000); // 2 seconds → 2 HP regen
|
||||
expect(state.health).toBeCloseTo(252);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Damage: Direct (Victory Path 2) ────────────────────────────
|
||||
|
||||
describe('Boss Damage — Direct', () => {
|
||||
let state: BossState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createBossState(ouroboros);
|
||||
});
|
||||
|
||||
it('deals full damage during Digest (vulnerable) phase', () => {
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||
// 15 * (1 - 0.1) = 13.5 → 14 rounded
|
||||
expect(result.damageDealt).toBe(14);
|
||||
expect(result.damageType).toBe(VictoryMethod.Direct);
|
||||
});
|
||||
|
||||
it('deals reduced damage during non-vulnerable phases', () => {
|
||||
state.currentPhase = BossPhase.Coil;
|
||||
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||
// 15 * (1 - 0.5) = 7.5 → 8 rounded
|
||||
expect(result.damageDealt).toBe(8);
|
||||
});
|
||||
|
||||
it('tracks direct damage dealt', () => {
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
applyBossDamage(state, ouroboros, 'Fe');
|
||||
expect(state.directDamageDealt).toBeGreaterThan(0);
|
||||
expect(state.totalDamageDealt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not deal damage to defeated boss', () => {
|
||||
state.defeated = true;
|
||||
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||
expect(result.damageDealt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Damage: Chemical (Victory Path 1) ──────────────────────────
|
||||
|
||||
describe('Boss Damage — Chemical (NaOH)', () => {
|
||||
let state: BossState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createBossState(ouroboros);
|
||||
});
|
||||
|
||||
it('deals multiplied damage with NaOH during Spray phase', () => {
|
||||
state.currentPhase = BossPhase.Spray;
|
||||
const result = applyBossDamage(state, ouroboros, 'NaOH');
|
||||
// 15 * 3.0 * (1 - 0.5) = 22.5 → 23 rounded
|
||||
expect(result.damageDealt).toBe(23);
|
||||
expect(result.damageType).toBe(VictoryMethod.Chemical);
|
||||
});
|
||||
|
||||
it('treats NaOH as normal projectile outside effective phases', () => {
|
||||
state.currentPhase = BossPhase.Coil; // Not a chemical-effective phase
|
||||
const result = applyBossDamage(state, ouroboros, 'NaOH');
|
||||
// Normal reduced damage: 15 * (1 - 0.5) = 8
|
||||
expect(result.damageDealt).toBe(8);
|
||||
expect(result.damageType).toBe(VictoryMethod.Direct);
|
||||
});
|
||||
|
||||
it('tracks chemical damage separately', () => {
|
||||
state.currentPhase = BossPhase.Spray;
|
||||
applyBossDamage(state, ouroboros, 'NaOH');
|
||||
expect(state.chemicalDamageDealt).toBeGreaterThan(0);
|
||||
expect(state.directDamageDealt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Damage: Catalytic Poison (Victory Path 3) ──────────────────
|
||||
|
||||
describe('Boss Damage — Catalytic Poison (Hg)', () => {
|
||||
let state: BossState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createBossState(ouroboros);
|
||||
});
|
||||
|
||||
it('applies catalyst stack on first Hg hit', () => {
|
||||
const result = applyBossDamage(state, ouroboros, 'Hg');
|
||||
expect(result.catalystApplied).toBe(true);
|
||||
expect(state.catalystStacks).toBe(1);
|
||||
expect(result.damageType).toBe(VictoryMethod.Catalytic);
|
||||
});
|
||||
|
||||
it('caps at maxCatalystStacks', () => {
|
||||
state.catalystStacks = 3; // Already at max
|
||||
const result = applyBossDamage(state, ouroboros, 'Hg');
|
||||
expect(result.catalystApplied).toBe(false);
|
||||
expect(state.catalystStacks).toBe(3);
|
||||
});
|
||||
|
||||
it('deals damage on every Hg application', () => {
|
||||
const result = applyBossDamage(state, ouroboros, 'Hg');
|
||||
expect(result.damageDealt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('tracks catalyst damage separately', () => {
|
||||
applyBossDamage(state, ouroboros, 'Hg');
|
||||
expect(state.catalystDamageDealt).toBeGreaterThan(0);
|
||||
expect(state.chemicalDamageDealt).toBe(0);
|
||||
expect(state.directDamageDealt).toBe(0);
|
||||
});
|
||||
|
||||
it('Hg works in any phase (not phase-dependent)', () => {
|
||||
for (const phase of [BossPhase.Coil, BossPhase.Spray, BossPhase.Lash, BossPhase.Digest]) {
|
||||
const s = createBossState(ouroboros);
|
||||
s.currentPhase = phase;
|
||||
const result = applyBossDamage(s, ouroboros, 'Hg');
|
||||
expect(result.catalystApplied).toBe(true);
|
||||
expect(result.damageType).toBe(VictoryMethod.Catalytic);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Victory Detection ───────────────────────────────────────────
|
||||
|
||||
describe('Boss Victory Detection', () => {
|
||||
let state: BossState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createBossState(ouroboros);
|
||||
});
|
||||
|
||||
it('detects killing blow', () => {
|
||||
state.health = 5;
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
const result = applyBossDamage(state, ouroboros, 'Fe');
|
||||
expect(result.killingBlow).toBe(true);
|
||||
expect(state.defeated).toBe(true);
|
||||
});
|
||||
|
||||
it('sets victoryMethod based on killing blow type', () => {
|
||||
state.health = 5;
|
||||
state.currentPhase = BossPhase.Spray;
|
||||
applyBossDamage(state, ouroboros, 'NaOH');
|
||||
expect(state.victoryMethod).toBe(VictoryMethod.Chemical);
|
||||
});
|
||||
|
||||
it('sets Catalytic victory on Hg killing blow', () => {
|
||||
state.health = 3;
|
||||
applyBossDamage(state, ouroboros, 'Hg');
|
||||
expect(state.victoryMethod).toBe(VictoryMethod.Catalytic);
|
||||
});
|
||||
|
||||
it('isBossDefeated returns true when defeated', () => {
|
||||
expect(isBossDefeated(state)).toBe(false);
|
||||
state.health = 1;
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
applyBossDamage(state, ouroboros, 'Fe');
|
||||
expect(isBossDefeated(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('health does not go below 0', () => {
|
||||
state.health = 1;
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
applyBossDamage(state, ouroboros, 'Fe');
|
||||
expect(state.health).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Arena Generation ────────────────────────────────────────────
|
||||
|
||||
describe('Arena Generation', () => {
|
||||
it('generates a grid of correct size', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
const diameter = ouroboros.arenaRadius * 2 + 1;
|
||||
expect(arena.width).toBe(diameter);
|
||||
expect(arena.height).toBe(diameter);
|
||||
expect(arena.grid.length).toBe(diameter);
|
||||
expect(arena.grid[0].length).toBe(diameter);
|
||||
});
|
||||
|
||||
it('has walkable ground in the center', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
const center = ouroboros.arenaRadius;
|
||||
// Center tile should be walkable ground (0)
|
||||
expect(arena.grid[center][center]).toBe(0);
|
||||
});
|
||||
|
||||
it('has crystal wall border', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
const r = ouroboros.arenaRadius;
|
||||
// Top edge should be bedrock (7) or crystal (4)
|
||||
// Very top-left corner is outside circle → bedrock
|
||||
expect(arena.grid[0][0]).toBe(7);
|
||||
});
|
||||
|
||||
it('has boss spawn at center', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
const center = ouroboros.arenaRadius;
|
||||
const tileSize = biome.tileSize;
|
||||
expect(arena.bossSpawnX).toBe(center * tileSize + tileSize / 2);
|
||||
expect(arena.bossSpawnY).toBe(center * tileSize + tileSize / 2);
|
||||
});
|
||||
|
||||
it('has player spawn south of center', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
expect(arena.playerSpawnY).toBeGreaterThan(arena.bossSpawnY);
|
||||
});
|
||||
|
||||
it('places 4 resource deposits', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
expect(arena.resourcePositions).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('resource positions are within arena bounds', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
const maxPixel = arena.width * biome.tileSize;
|
||||
for (const pos of arena.resourcePositions) {
|
||||
expect(pos.x).toBeGreaterThan(0);
|
||||
expect(pos.x).toBeLessThan(maxPixel);
|
||||
expect(pos.y).toBeGreaterThan(0);
|
||||
expect(pos.y).toBeLessThan(maxPixel);
|
||||
}
|
||||
});
|
||||
|
||||
it('has acid tiles in the arena', () => {
|
||||
const arena = generateArena(ouroboros, biome);
|
||||
let acidCount = 0;
|
||||
for (const row of arena.grid) {
|
||||
for (const tile of row) {
|
||||
if (tile === 2) acidCount++; // ACID_SHALLOW
|
||||
}
|
||||
}
|
||||
expect(acidCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('buildArenaWalkableSet includes expected tiles', () => {
|
||||
const walkable = buildArenaWalkableSet();
|
||||
expect(walkable.has(0)).toBe(true); // GROUND
|
||||
expect(walkable.has(1)).toBe(true); // SCORCHED_EARTH
|
||||
expect(walkable.has(6)).toBe(true); // MINERAL_VEIN
|
||||
expect(walkable.has(7)).toBe(false); // BEDROCK (not walkable)
|
||||
expect(walkable.has(4)).toBe(false); // CRYSTAL (not walkable)
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Reward System ───────────────────────────────────────────────
|
||||
|
||||
describe('Boss Reward System', () => {
|
||||
it('calculates base reward for direct victory', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
state.defeated = true;
|
||||
state.victoryMethod = VictoryMethod.Direct;
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
expect(reward.spores).toBe(100); // Base
|
||||
expect(reward.loreId).toBe('ouroboros');
|
||||
expect(reward.victoryMethod).toBe(VictoryMethod.Direct);
|
||||
});
|
||||
|
||||
it('gives 50% bonus for chemical victory', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
state.defeated = true;
|
||||
state.victoryMethod = VictoryMethod.Chemical;
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
expect(reward.spores).toBe(150); // 100 * 1.5
|
||||
});
|
||||
|
||||
it('gives 100% bonus for catalytic victory', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
state.defeated = true;
|
||||
state.victoryMethod = VictoryMethod.Catalytic;
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
expect(reward.spores).toBe(200); // 100 * 2.0
|
||||
});
|
||||
|
||||
it('returns no spores if boss not defeated', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
expect(reward.spores).toBe(0);
|
||||
});
|
||||
|
||||
it('includes lore text in reward', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
state.defeated = true;
|
||||
state.victoryMethod = VictoryMethod.Direct;
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
expect(reward.loreText.length).toBeGreaterThan(0);
|
||||
expect(reward.loreTextRu.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('applyBossReward adds spores to meta', () => {
|
||||
const meta = createMetaState();
|
||||
const initialSpores = meta.spores;
|
||||
const state = createBossState(ouroboros);
|
||||
state.defeated = true;
|
||||
state.victoryMethod = VictoryMethod.Direct;
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
applyBossReward(meta, reward, 1);
|
||||
expect(meta.spores).toBe(initialSpores + reward.spores);
|
||||
});
|
||||
|
||||
it('applyBossReward adds lore to codex', () => {
|
||||
const meta = createMetaState();
|
||||
const state = createBossState(ouroboros);
|
||||
state.defeated = true;
|
||||
state.victoryMethod = VictoryMethod.Direct;
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
applyBossReward(meta, reward, 1);
|
||||
const bossEntry = meta.codex.find(e => e.id === 'ouroboros');
|
||||
expect(bossEntry).toBeDefined();
|
||||
expect(bossEntry?.type).toBe('boss');
|
||||
expect(bossEntry?.discoveredOnRun).toBe(1);
|
||||
});
|
||||
|
||||
it('does not add duplicate codex entries', () => {
|
||||
const meta = createMetaState();
|
||||
const state = createBossState(ouroboros);
|
||||
state.defeated = true;
|
||||
state.victoryMethod = VictoryMethod.Direct;
|
||||
const reward = calculateBossReward(state, ouroboros);
|
||||
applyBossReward(meta, reward, 1);
|
||||
applyBossReward(meta, reward, 2);
|
||||
const bossEntries = meta.codex.filter(e => e.id === 'ouroboros');
|
||||
expect(bossEntries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Boss Entity Factory ─────────────────────────────────────────
|
||||
|
||||
describe('Boss Entity Factory', () => {
|
||||
let world: World;
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('creates an entity with Position component', () => {
|
||||
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||
expect(Position.x[eid]).toBe(400);
|
||||
expect(Position.y[eid]).toBe(300);
|
||||
});
|
||||
|
||||
it('creates an entity with Health component', () => {
|
||||
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||
expect(Health.current[eid]).toBe(300);
|
||||
expect(Health.max[eid]).toBe(300);
|
||||
});
|
||||
|
||||
it('creates an entity with SpriteRef component', () => {
|
||||
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||
expect(SpriteRef.color[eid]).toBe(0xcc44ff);
|
||||
expect(SpriteRef.radius[eid]).toBe(20);
|
||||
});
|
||||
|
||||
it('creates an entity with Boss component', () => {
|
||||
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||
expect(Boss.dataIndex[eid]).toBe(0);
|
||||
expect(Boss.phase[eid]).toBe(0); // BossPhase.Coil
|
||||
expect(Boss.cycleCount[eid]).toBe(0);
|
||||
});
|
||||
|
||||
it('entity is queryable by Boss component', () => {
|
||||
const eid = createBossEntity(world, ouroboros, 400, 300);
|
||||
const bosses = query(world, [Boss]);
|
||||
expect(bosses).toContain(eid);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: Full Boss Fight ────────────────────────────────
|
||||
|
||||
describe('Boss Fight Integration', () => {
|
||||
it('can defeat boss with direct damage during Digest windows', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
let totalCycles = 0;
|
||||
|
||||
// Simulate multiple cycles of hitting during Digest
|
||||
// In a 4s Digest window, player can throw ~10 projectiles
|
||||
while (!state.defeated && totalCycles < 30) {
|
||||
// Advance through Coil → Spray → Lash → Digest
|
||||
updateBossPhase(state, ouroboros, 5000); // Coil (+ regen)
|
||||
updateBossPhase(state, ouroboros, 8000); // Spray (+ regen)
|
||||
updateBossPhase(state, ouroboros, 6000); // Lash (+ regen)
|
||||
|
||||
// Now in Digest — rapid-fire attacks (10 per 4s window is realistic)
|
||||
for (let i = 0; i < 10 && !state.defeated; i++) {
|
||||
state.currentPhase = BossPhase.Digest; // Ensure in Digest
|
||||
applyBossDamage(state, ouroboros, 'Fe');
|
||||
}
|
||||
|
||||
updateBossPhase(state, ouroboros, 4000); // Complete Digest
|
||||
totalCycles++;
|
||||
}
|
||||
|
||||
expect(state.defeated).toBe(true);
|
||||
expect(state.victoryMethod).toBe(VictoryMethod.Direct);
|
||||
// Direct path is slow but viable (boss regens 5 HP/s between windows)
|
||||
expect(totalCycles).toBeLessThan(30);
|
||||
});
|
||||
|
||||
it('can defeat boss faster with NaOH during Spray', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
let hits = 0;
|
||||
|
||||
// Advance to Spray phase and keep hitting with NaOH
|
||||
state.currentPhase = BossPhase.Spray;
|
||||
|
||||
while (!state.defeated && hits < 50) {
|
||||
applyBossDamage(state, ouroboros, 'NaOH');
|
||||
hits++;
|
||||
}
|
||||
|
||||
expect(state.defeated).toBe(true);
|
||||
expect(state.victoryMethod).toBe(VictoryMethod.Chemical);
|
||||
expect(hits).toBeLessThan(20); // NaOH should be efficient
|
||||
});
|
||||
|
||||
it('Hg makes boss progressively weaker', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
|
||||
// Apply 3 Hg stacks
|
||||
for (let i = 0; i < 3; i++) {
|
||||
applyBossDamage(state, ouroboros, 'Hg');
|
||||
}
|
||||
|
||||
expect(state.catalystStacks).toBe(3);
|
||||
// Regen should be 0 (5 - 3*2 = -1 → 0)
|
||||
expect(getEffectiveRegen(state, ouroboros)).toBe(0);
|
||||
// Armor should be reduced: 0.5 - 3*0.1 = 0.2
|
||||
expect(getEffectiveArmor(state, ouroboros)).toBeCloseTo(0.2);
|
||||
});
|
||||
|
||||
it('catalyst poison + direct damage is a viable strategy', () => {
|
||||
const state = createBossState(ouroboros);
|
||||
|
||||
// Apply 3 Hg stacks to maximize weakness
|
||||
for (let i = 0; i < 3; i++) {
|
||||
applyBossDamage(state, ouroboros, 'Hg');
|
||||
}
|
||||
|
||||
// Now kill with direct damage during Digest
|
||||
state.currentPhase = BossPhase.Digest;
|
||||
let hits = 0;
|
||||
while (!state.defeated && hits < 50) {
|
||||
applyBossDamage(state, ouroboros, 'Fe');
|
||||
hits++;
|
||||
}
|
||||
|
||||
expect(state.defeated).toBe(true);
|
||||
// Victory method should be Direct (killing blow was Fe)
|
||||
expect(state.victoryMethod).toBe(VictoryMethod.Direct);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user