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:
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) {
|
||||
|
||||
Reference in New Issue
Block a user