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:
Денис Шкабатур
2026-02-12 16:12:34 +03:00
parent 0d35cdcc73
commit 7d52d749a3
14 changed files with 2429 additions and 4 deletions

View 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;
}
}

View File

@@ -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) {