Files
synthesis/src/scenes/GameScene.ts
Денис Шкабатур d9213b6be0 feat: CradleScene + UIScene + GameScene cycle integration
Scene integration for the Great Cycle system:
- CradleScene: shows "Великий Цикл N: Тема | Ран X/7", narrative quote
- UIScene: cycle info bar and run phase display below health
- GameScene: world trace spawning (ruins/markers from past runs),
  trace glow rendering, death position recording, cycle info to registry,
  biomeId + worldSeed passed to RunState
- Scene flow: Fractal → RenewalScene (on 7th run) → Cradle

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:51:19 +03:00

975 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Phaser from 'phaser';
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
import { Health, Position, Creature, LifeCycle } 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 speciesDataArray from '../data/creatures.json';
import schoolsData from '../data/schools.json';
import type { BiomeData } from '../world/types';
import { generateWorld } from '../world/generator';
import { createWorldTilemap } from '../world/tilemap';
import { setupPlayerCamera } from '../world/camera';
import { Minimap } from '../world/minimap';
import { playerInputSystem } from '../player/input';
import { tileCollisionSystem, buildWalkableSet } from '../player/collision';
import { findSpawnPosition } from '../player/spawn';
import { createPlayerEntity } from '../player/factory';
import { Inventory } from '../player/inventory';
import { interactionSystem, type ResourceInfo } from '../player/interaction';
import { spawnResources } from '../world/resources';
import {
launchProjectile,
projectileSystem,
type ProjectileData,
} from '../player/projectile';
import { QuickSlots } from '../player/quickslots';
import type { InputState } from '../player/types';
import type { SpeciesData, CreatureInfo } from '../creatures/types';
import { SpeciesRegistry } from '../creatures/types';
import { aiSystem } from '../creatures/ai';
import { metabolismSystem, clearMetabolismTracking } from '../creatures/metabolism';
import { lifeCycleSystem } from '../creatures/lifecycle';
import {
countPopulations,
spawnInitialCreatures,
reproduce,
} from '../creatures/population';
import {
creatureProjectileSystem,
getObservableCreatures,
creatureAttackPlayerSystem,
} from '../creatures/interaction';
import { query } from 'bitecs';
// Run cycle imports
import type { MetaState, SchoolData, RunState, ResolvedSchoolBonuses } from '../run/types';
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
import { getSchoolBonuses } from '../run/meta';
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
getCrisisPlayerDamage,
getCrisisTint,
CHEMICAL_PLAGUE,
type CrisisState,
} from '../run/crisis';
import { getEscalationEffects } from '../run/escalation';
// UI zoom compensation
import { fixToScreen } from '../ui/screen-fix';
// Mycelium imports
import { FungalNode } from '../ecs/components';
import { spawnFungalNodes } from '../mycelium/nodes';
import { depositKnowledge } from '../mycelium/graph';
import { extractMemoryFlashes } from '../mycelium/knowledge';
import { createMycosisState, updateMycosis, getMycosisVisuals } from '../mycelium/mycosis';
import type { FungalNodeInfo, MycosisState, MemoryFlash } from '../mycelium/types';
import { FUNGAL_NODE_CONFIG, MYCOSIS_CONFIG } from '../mycelium/types';
// World traces (Great Cycle)
import { spawnWorldTraces, updateTraceGlow, type WorldTraceInfo } from '../world/traces';
import { CYCLE_THEME_NAMES_RU } from '../run/types';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
private minimap!: Minimap;
private statsText!: Phaser.GameObjects.Text;
private worldSeed!: number;
// Player state
private playerEid!: number;
private inventory!: Inventory;
private walkableSet!: Set<number>;
private worldGrid!: number[][];
private tileSize!: number;
private resourceData!: Map<number, ResourceInfo>;
private projectileData!: Map<number, ProjectileData>;
private quickSlots!: QuickSlots;
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;
};
// Creature state
private speciesRegistry!: SpeciesRegistry;
private speciesLookup!: Map<number, SpeciesData>;
private creatureData!: Map<number, CreatureInfo>;
// Interaction feedback
private interactionText!: Phaser.GameObjects.Text;
private interactionTimer = 0;
private wasEDown = false;
private wasFDown = false;
// Run cycle state
private meta!: MetaState;
private runState!: RunState;
private crisisState: CrisisState | null = null;
private phaseText!: Phaser.GameObjects.Text;
private playerDead = false;
private crisisOverlay!: Phaser.GameObjects.Rectangle;
// Mycelium state
private fungalNodeData!: Map<number, FungalNodeInfo>;
private mycosisState!: MycosisState;
private mycosisOverlay!: Phaser.GameObjects.Rectangle;
private memoryFlashText!: Phaser.GameObjects.Text;
private memoryFlashTimer = 0;
private fungalNodeGlowGraphics!: Phaser.GameObjects.Graphics;
private hasDepositedThisRun = false;
// World traces from past runs
private worldTraceData: WorldTraceInfo[] = [];
private worldTraceGraphics!: Phaser.GameObjects.Graphics;
constructor() {
super({ key: 'GameScene' });
}
// Purchased bonuses from Cradle shop
private purchasedEffects: import('../mycelium/types').BonusEffect[] = [];
// Biome selection
private biomeId = 'catalytic-wastes';
// School bonuses (resolved from school data)
private schoolBonuses!: ResolvedSchoolBonuses;
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[]; biomeId?: string }): void {
this.meta = data.meta;
this.biomeId = data.biomeId ?? 'catalytic-wastes';
this.runState = createRunState(data.runId, data.schoolId, this.biomeId);
this.crisisState = null;
this.playerDead = false;
this.mycosisState = createMycosisState();
this.hasDepositedThisRun = false;
this.memoryFlashTimer = 0;
this.purchasedEffects = data.purchasedEffects ?? [];
this.schoolBonuses = getSchoolBonuses(data.schoolId);
}
create(): void {
// 1. Initialize ECS
this.gameWorld = createGameWorld();
this.bridge = new PhaserBridge(this);
this.projectileData = new Map();
// 2. Generate world — use selected biome
const biome = (biomeDataArray as BiomeData[]).find(b => b.id === this.biomeId) ?? biomeDataArray[0] as BiomeData;
this.worldSeed = Date.now() % 1000000;
this.runState.worldSeed = this.worldSeed;
const worldData = generateWorld(biome, this.worldSeed);
// 3. Create tilemap
createWorldTilemap(this, worldData);
// 4. Build walkable set + store world data for collision
this.walkableSet = buildWalkableSet(biome.tiles);
this.worldGrid = worldData.grid;
this.tileSize = biome.tileSize;
// 5. Spawn resource entities (mineral veins, geysers)
this.resourceData = spawnResources(
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
);
// 5b. Spawn fungal nodes (Mycelium surface points)
this.fungalNodeData = spawnFungalNodes(
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
);
// 5c. Spawn world traces from past runs (Great Cycle)
this.worldTraceData = spawnWorldTraces(
this.gameWorld.world, this.meta.greatCycle, this.biomeId, biome,
);
this.worldTraceGraphics = this.add.graphics();
this.worldTraceGraphics.setDepth(4); // above tiles, below entities
// 6. Initialize creature systems — filter by biome
const allSpecies = speciesDataArray as SpeciesData[];
const biomeSpecies = allSpecies.filter(s => s.biome === biome.id);
this.speciesRegistry = new SpeciesRegistry(biomeSpecies);
this.speciesLookup = new Map<number, SpeciesData>();
for (const s of biomeSpecies) {
this.speciesLookup.set(s.speciesId, s);
}
// 7. Spawn creatures across the map
this.creatureData = spawnInitialCreatures(
this.gameWorld.world, worldData.grid, biome, this.worldSeed, biomeSpecies,
);
// 8. Create player at spawn position + inventory with starting kit
const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
this.inventory = new Inventory(500, 20);
this.quickSlots = new QuickSlots();
// Give starting elements from chosen school + apply purchased bonuses
this.giveStartingKit();
this.applyPurchasedEffects();
// 9. Camera — follow player, zoom via scroll wheel
const worldPixelW = biome.mapWidth * biome.tileSize;
const worldPixelH = biome.mapHeight * biome.tileSize;
setupPlayerCamera(this, worldPixelW, worldPixelH);
// Sync bridge to create sprites, then attach camera follow to 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);
}
// 8. 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),
};
// 9. Minimap
this.minimap = new Minimap(this, worldData);
// 10. UI overlay
this.statsText = this.add.text(10, 10, '', {
fontSize: '12px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 4, y: 2 },
});
this.statsText.setScrollFactor(0);
this.statsText.setDepth(100);
// Interaction feedback text (center-bottom of screen)
this.interactionText = this.add.text(
this.cameras.main.width / 2, this.cameras.main.height - 40, '', {
fontSize: '14px',
color: '#ffdd44',
fontFamily: 'monospace',
backgroundColor: '#000000cc',
padding: { x: 6, y: 3 },
},
);
this.interactionText.setScrollFactor(0);
this.interactionText.setOrigin(0.5);
this.interactionText.setDepth(100);
this.interactionText.setAlpha(0);
// Phase indicator (top-center)
this.phaseText = this.add.text(
this.cameras.main.width / 2, 12, '', {
fontSize: '12px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 6, y: 2 },
},
);
this.phaseText.setScrollFactor(0);
this.phaseText.setOrigin(0.5, 0);
this.phaseText.setDepth(100);
// Crisis overlay (full-screen tinted rectangle, hidden by default)
this.crisisOverlay = this.add.rectangle(
this.cameras.main.width / 2, this.cameras.main.height / 2,
this.cameras.main.width, this.cameras.main.height,
0x88ff88, 0,
);
this.crisisOverlay.setScrollFactor(0);
this.crisisOverlay.setDepth(90);
// 10b. Mycosis overlay (full-screen tinted rectangle, hidden by default)
this.mycosisOverlay = this.add.rectangle(
this.cameras.main.width / 2, this.cameras.main.height / 2,
this.cameras.main.width, this.cameras.main.height,
MYCOSIS_CONFIG.tintColor, 0,
);
this.mycosisOverlay.setScrollFactor(0);
this.mycosisOverlay.setDepth(91);
// 10c. Fungal node glow graphics (world-space, under entities)
this.fungalNodeGlowGraphics = this.add.graphics();
this.fungalNodeGlowGraphics.setDepth(2);
// 10d. Memory flash text (center of screen, fades in/out)
this.memoryFlashText = this.add.text(
this.cameras.main.width / 2, this.cameras.main.height / 2, '', {
fontSize: '16px',
color: '#88ffaa',
fontFamily: 'monospace',
backgroundColor: '#0a1a0fcc',
padding: { x: 12, y: 8 },
align: 'center',
wordWrap: { width: 400 },
},
);
this.memoryFlashText.setScrollFactor(0);
this.memoryFlashText.setOrigin(0.5);
this.memoryFlashText.setDepth(105);
this.memoryFlashText.setAlpha(0);
// 11. Launch UIScene overlay
this.scene.launch('UIScene');
// Transition from Awakening to Exploration after a moment
this.time.delayedCall(500, () => {
advancePhase(this.runState); // Awakening → Exploration
});
// Debug: expose kill method for testing death cycle
(this as unknown as Record<string, unknown>).__debugKill = () => {
Health.current[this.playerEid] = 0;
};
}
/** Apply purchased spore bonuses from the Cradle shop */
private applyPurchasedEffects(): void {
for (const effect of this.purchasedEffects) {
switch (effect.type) {
case 'extra_health':
Health.max[this.playerEid] = (Health.max[this.playerEid] ?? 100) + effect.amount;
Health.current[this.playerEid] = Health.max[this.playerEid];
break;
case 'extra_element':
for (let i = 0; i < effect.quantity; i++) {
this.inventory.addItem(effect.symbol);
}
this.quickSlots.autoAssign(effect.symbol);
break;
case 'knowledge_boost':
// Stored for use when extracting memory flashes
// (increases clarity of extracted flashes)
break;
}
}
}
/** Give the player their school's starting elements */
private giveStartingKit(): void {
const schools = schoolsData as unknown as SchoolData[];
const school = schools.find(s => s.id === this.runState.schoolId);
if (!school) return;
for (const symbol of school.startingElements) {
const qty = school.startingQuantities[symbol] ?? 1;
for (let i = 0; i < qty; i++) {
this.inventory.addItem(symbol);
}
this.quickSlots.autoAssign(symbol);
// Record discovery of starting elements
recordDiscovery(this.runState, 'element', symbol);
}
}
update(_time: number, delta: number): void {
// Skip updates if death transition is in progress
if (this.playerDead) return;
// 1. Update world time
updateTime(this.gameWorld, delta);
// 1a. Update run state timers
this.runState.elapsed += delta;
this.runState.phaseTimer += delta;
this.updateRunPhase(delta);
// 2. Read keyboard → InputState
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,
};
// 3. Player input → velocity (Navigator school gets speed bonus)
playerInputSystem(this.gameWorld.world, input, this.schoolBonuses.movementSpeed);
// 4. Movement (all entities including projectiles)
movementSystem(this.gameWorld.world, delta);
// 5. Tile collision (player only)
tileCollisionSystem(
this.gameWorld.world,
delta,
this.worldGrid,
this.tileSize,
this.walkableSet,
);
// 6. Projectile system (lifetime + tile collision)
projectileSystem(
this.gameWorld.world,
delta,
this.worldGrid,
this.tileSize,
this.walkableSet,
this.projectileData,
);
// 7. E key interaction (debounced) — fungal node has priority, then resources
const isEDown = this.keys.E.isDown;
const justPressedE = isEDown && !this.wasEDown;
this.wasEDown = isEDown;
// Check if player is near a fungal node first
const nearbyFungalNode = this.getNearestFungalNode();
if (justPressedE && nearbyFungalNode !== null) {
// Fungal node interaction takes priority
this.interactWithFungalNode(nearbyFungalNode);
} else {
// Normal resource interaction
const interaction = interactionSystem(
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
);
if (interaction) {
if (interaction.type === 'collected' || interaction.type === 'depleted') {
if (interaction.itemId) {
this.quickSlots.autoAssign(interaction.itemId);
recordDiscovery(this.runState, 'element', interaction.itemId);
}
}
this.showInteractionFeedback(interaction.type, interaction.itemId);
}
}
// 8. Quick slot selection (1-4 keys)
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);
// 9. Throw projectile (F key, debounced) — uses active quick slot
const isFDown = this.keys.F.isDown;
const justPressedF = isFDown && !this.wasFDown;
this.wasFDown = isFDown;
if (justPressedF) {
this.tryLaunchProjectile();
}
// 9a. Creature AI — aggro range affected by Naturalist school bonus
aiSystem(
this.gameWorld.world, delta,
this.speciesLookup, this.gameWorld.time.tick,
this.schoolBonuses.creatureAggroRange,
);
// 9b. Creature metabolism (feeding, energy drain)
metabolismSystem(
this.gameWorld.world, delta,
this.resourceData, this.gameWorld.time.elapsed,
);
// 9c. Creature life cycle (aging, stage transitions)
const lcEvents = lifeCycleSystem(
this.gameWorld.world, delta, this.speciesLookup,
);
// 9d. Handle reproduction events
const populations = countPopulations(this.gameWorld.world);
for (const event of lcEvents) {
if (event.type === 'ready_to_reproduce') {
const species = this.speciesLookup.get(event.speciesId);
if (species) {
const currentPop = populations.get(event.speciesId) ?? 0;
reproduce(
this.gameWorld.world, event.eid, species,
currentPop, this.creatureData,
);
}
}
}
// 9e. Projectile-creature collision (Mechanic school gets damage bonus)
creatureProjectileSystem(
this.gameWorld.world, this.projectileData, this.speciesLookup,
this.schoolBonuses.projectileDamage,
);
// 9f. Creature attacks on player
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
// 9g. Mycelium — fungal node glow, interaction, mycosis
this.updateMycelium(delta);
// 9h. Crisis damage (if active)
if (this.crisisState?.active) {
applyCrisisDamage(this.crisisState, delta);
const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta);
if (crisisDmg > 0) {
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - crisisDmg,
);
}
// Update crisis visual tint
const tint = getCrisisTint(this.crisisState);
this.crisisOverlay.setFillStyle(tint.color, tint.alpha);
}
// 9i. Environmental damage from high escalation
const escalationFx = getEscalationEffects(this.runState.escalation);
if (escalationFx.environmentalDamage > 0) {
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - envDmg,
);
}
// 10. Health / death
const dead = healthSystem(this.gameWorld.world);
let playerDied = false;
for (const eid of dead) {
if (eid === this.playerEid) {
playerDied = true;
continue; // Don't remove player entity yet
}
// Clean up creature tracking
if (this.creatureData.has(eid)) {
clearMetabolismTracking(eid);
this.creatureData.delete(eid);
}
removeGameEntity(this.gameWorld.world, eid);
}
// Handle player death → transition to DeathScene
if (playerDied) {
this.onPlayerDeath();
return;
}
// 10. Render sync
this.bridge.sync(this.gameWorld.world);
// 11. Minimap viewport
this.minimap.update(this.cameras.main);
// 12. Fade interaction text
if (this.interactionTimer > 0) {
this.interactionTimer -= delta;
if (this.interactionTimer <= 0) {
this.interactionText.setAlpha(0);
}
}
// 13. Push shared 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);
// Build counts map for UIScene
const counts = new Map<string, number>();
for (const item of this.inventory.getItems()) {
counts.set(item.id, item.count);
}
this.registry.set('invCounts', counts);
// 14. Push cycle info to registry for UIScene
this.registry.set('cycleNumber', this.meta.greatCycle.cycleNumber);
this.registry.set('runInCycle', this.meta.greatCycle.runInCycle);
this.registry.set('cycleThemeRu', CYCLE_THEME_NAMES_RU[this.meta.greatCycle.theme]);
this.registry.set('runPhaseRu', RUN_PHASE_NAMES_RU[this.runState.phase]);
// 15. Creature observation for UIScene
const nearbyCreatures = getObservableCreatures(this.gameWorld.world);
if (nearbyCreatures.length > 0) {
const closest = nearbyCreatures[0];
const species = this.speciesLookup.get(closest.speciesId);
this.registry.set('observedCreature', {
name: species?.name ?? 'Unknown',
healthPercent: closest.healthPercent,
energyPercent: closest.energyPercent,
stage: closest.stage,
});
// Record creature observation as discovery
if (species) {
recordDiscovery(this.runState, 'creature', species.id);
}
} else {
this.registry.set('observedCreature', null);
}
// 16. Population counts for debug
const popCounts = countPopulations(this.gameWorld.world);
this.registry.set('creaturePopulations', popCounts);
// 17. Debug stats overlay + phase indicator
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
const px = Math.round(Position.x[this.playerEid]);
const py = Math.round(Position.y[this.playerEid]);
const creatureCount = [...popCounts.values()].reduce((a, b) => a + b, 0);
this.statsText.setText(
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`,
);
// Update phase indicator
const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase];
const escalationPct = Math.round(this.runState.escalation * 100);
const crisisInfo = this.crisisState?.active
? ` | ☠ ЧУМА: ${Math.round(this.crisisState.progress * 100)}%`
: this.runState.crisisResolved ? ' | ✓ Чума нейтрализована' : '';
const escFx = getEscalationEffects(this.runState.escalation);
const speedInfo = escFx.creatureSpeedMultiplier > 1.05
? ` | Агрессия: ×${escFx.creatureAttackMultiplier.toFixed(1)}`
: '';
this.phaseText.setText(`${phaseName} | Энтропия: ${escalationPct}%${speedInfo}${crisisInfo}`);
// Color phase text based on danger
if (this.runState.phase >= RunPhase.Crisis) {
this.phaseText.setColor('#ff4444');
} else if (this.runState.phase >= RunPhase.Escalation) {
this.phaseText.setColor('#ffaa00');
} else {
this.phaseText.setColor('#00ff88');
}
// Fix UI element positions for current camera zoom.
// setScrollFactor(0) prevents scroll but NOT zoom displacement.
const cam = this.cameras.main;
fixToScreen(this.statsText, 10, 30, cam);
fixToScreen(this.interactionText, cam.width / 2, cam.height - 40, cam);
fixToScreen(this.phaseText, cam.width / 2, 12, cam);
fixToScreen(this.memoryFlashText, cam.width / 2, cam.height / 2, cam);
fixToScreen(this.crisisOverlay, cam.width / 2, cam.height / 2, cam);
fixToScreen(this.mycosisOverlay, cam.width / 2, cam.height / 2, cam);
}
/** Find the nearest fungal node within interaction range, or null */
private getNearestFungalNode(): number | null {
const px = Position.x[this.playerEid];
const py = Position.y[this.playerEid];
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
let nearestEid: number | null = null;
let nearestDist = Infinity;
for (const eid of nodeEids) {
const dx = Position.x[eid] - px;
const dy = Position.y[eid] - py;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= FungalNode.interactRange[eid] && dist < nearestDist) {
nearestEid = eid;
nearestDist = dist;
}
}
return nearestEid;
}
/** Mycelium system — glow animation, mycosis update, memory flash fade */
private updateMycelium(delta: number): void {
// Check proximity to any fungal node (for mycosis)
const nearNode = this.getNearestFungalNode() !== null;
// Update mycosis
updateMycosis(this.mycosisState, delta, nearNode);
const visuals = getMycosisVisuals(this.mycosisState);
this.mycosisOverlay.setFillStyle(visuals.tintColor, visuals.tintAlpha);
// Render fungal node glows
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
this.fungalNodeGlowGraphics.clear();
for (const eid of nodeEids) {
FungalNode.glowPhase[eid] += FUNGAL_NODE_CONFIG.glowPulseSpeed * (delta / 1000);
const phase = FungalNode.glowPhase[eid];
const pulse = 0.4 + 0.6 * Math.sin(phase);
const radius = FUNGAL_NODE_CONFIG.glowRadius * (0.8 + 0.4 * pulse);
const alpha = 0.15 + 0.15 * pulse;
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha);
this.fungalNodeGlowGraphics.fillCircle(
Position.x[eid], Position.y[eid], radius,
);
// Inner brighter glow
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha * 1.5);
this.fungalNodeGlowGraphics.fillCircle(
Position.x[eid], Position.y[eid], radius * 0.5,
);
}
// Render world trace glows (past run markers)
this.worldTraceGraphics.clear();
updateTraceGlow(this.worldTraceData, delta);
for (const trace of this.worldTraceData) {
const eid = trace.eid;
const phase = (this.worldTraceGraphics as unknown as { _phase?: number })._phase ?? 0;
const glowPhase = phase + Position.x[eid] * 0.01; // offset per position
const pulse = 0.3 + 0.7 * Math.sin(glowPhase + this.runState.elapsed * 0.001);
const color = trace.traceType === 'death_site' ? 0xaa3333 : 0x4488aa;
const alpha = 0.1 + 0.1 * pulse;
const radius = 8 * (0.7 + 0.3 * pulse);
this.worldTraceGraphics.fillStyle(color, alpha);
this.worldTraceGraphics.fillCircle(Position.x[eid], Position.y[eid], radius);
// Inner core
this.worldTraceGraphics.fillStyle(color, alpha * 2);
this.worldTraceGraphics.fillCircle(Position.x[eid], Position.y[eid], radius * 0.4);
}
// Fade memory flash text
if (this.memoryFlashTimer > 0) {
this.memoryFlashTimer -= delta;
if (this.memoryFlashTimer <= 500) {
this.memoryFlashText.setAlpha(this.memoryFlashTimer / 500);
}
if (this.memoryFlashTimer <= 0) {
this.memoryFlashText.setAlpha(0);
}
}
// Push mycosis data to registry for UIScene
this.registry.set('mycosisLevel', this.mycosisState.level);
this.registry.set('mycosisRevealing', this.mycosisState.revealing);
}
/** Handle interaction with a fungal node */
private interactWithFungalNode(nodeEid: number): void {
// First time interacting in this run → deposit discoveries
if (!this.hasDepositedThisRun) {
const result = depositKnowledge(this.meta.mycelium, this.runState);
this.hasDepositedThisRun = true;
const msg = result.newNodes > 0
? `🍄 Мицелий принял ${result.newNodes} знани${result.newNodes === 1 ? 'е' : 'й'}`
: `🍄 Мицелий укрепил ${result.strengthened} связ${result.strengthened === 1 ? 'ь' : 'ей'}`;
this.showInteractionFeedback('collected', msg);
}
// Extract memory flashes from past runs
if (this.meta.mycelium.nodes.length > 0) {
const flashes = extractMemoryFlashes(this.meta.mycelium, 2);
if (flashes.length > 0) {
this.showMemoryFlash(flashes[0]);
}
} else {
this.showInteractionFeedback('collected', '🍄 Мицелий пуст... пока.');
}
}
/** Display a memory flash on screen */
private showMemoryFlash(flash: MemoryFlash): void {
// Show Russian text with clarity indicator
const clarityStars = flash.clarity >= 0.7 ? '★★★' : flash.clarity >= 0.4 ? '★★☆' : '★☆☆';
const text = `${flash.textRu}\n\n[${clarityStars}]`;
this.memoryFlashText.setText(text);
this.memoryFlashText.setAlpha(1);
this.memoryFlashTimer = 4000; // Show for 4 seconds
}
/** Manage run phase transitions and escalation */
private updateRunPhase(delta: number): void {
const phase = this.runState.phase;
const timer = this.runState.phaseTimer;
const duration = PHASE_DURATIONS[phase];
// Update escalation
updateEscalation(this.runState, delta);
// Auto-advance timed phases
if (duration > 0 && timer >= duration) {
if (phase === RunPhase.Exploration) {
advancePhase(this.runState); // → Escalation
} else if (phase === RunPhase.Escalation) {
advancePhase(this.runState); // → Crisis
this.triggerCrisis();
} else if (phase === RunPhase.Resolution) {
// Resolution phase complete → enter boss arena
this.enterBossArena();
}
}
// Trigger crisis when escalation hits threshold (even before phase ends)
if (
phase === RunPhase.Escalation &&
!this.crisisState &&
this.runState.escalation >= CHEMICAL_PLAGUE.triggerThreshold
) {
advancePhase(this.runState); // → Crisis
this.triggerCrisis();
}
}
/** Activate the Chemical Plague crisis */
private triggerCrisis(): void {
this.crisisState = createCrisisState(CHEMICAL_PLAGUE);
this.runState.crisisActive = true;
this.showInteractionFeedback('collected', '⚠ ХИМИЧЕСКАЯ ЧУМА! Создай CaO для нейтрализации!');
// Tint the world slightly toxic via overlay
this.crisisOverlay.setFillStyle(0x88ff88, 0.08);
}
/** Handle player death — start death sequence */
private onPlayerDeath(): void {
this.playerDead = true;
this.runState.alive = false;
// Record death position for great cycle traces
const px = Position.x[this.playerEid] ?? 0;
const py = Position.y[this.playerEid] ?? 0;
this.runState.deathPosition = {
tileX: Math.floor(px / this.tileSize),
tileY: Math.floor(py / this.tileSize),
};
// Auto-deposit all discoveries into Mycelium on death
if (!this.hasDepositedThisRun) {
depositKnowledge(this.meta.mycelium, this.runState);
this.hasDepositedThisRun = true;
}
// Stop UIScene
this.scene.stop('UIScene');
// Slow-motion effect
this.cameras.main.fadeOut(2000, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('DeathScene', {
meta: this.meta,
runState: this.runState,
});
});
}
/** Try to launch a projectile from active quick slot toward mouse */
private tryLaunchProjectile(): void {
const itemId = this.quickSlots.getActive();
if (!itemId || !this.inventory.hasItem(itemId)) {
this.showInteractionFeedback('nothing_nearby');
return;
}
const removed = this.inventory.removeItem(itemId, 1);
if (removed === 0) return;
// Get mouse world position for direction
const pointer = this.input.activePointer;
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
const px = Position.x[this.playerEid];
const py = Position.y[this.playerEid];
launchProjectile(
this.gameWorld.world,
this.projectileData,
px, py,
worldPoint.x, worldPoint.y,
itemId,
);
// Check if this compound neutralizes the crisis
if (this.crisisState?.active && itemId === CHEMICAL_PLAGUE.neutralizer) {
attemptNeutralize(this.crisisState, itemId, 1);
if (this.crisisState.resolved) {
this.runState.crisisResolved = true;
this.runState.crisisActive = false;
this.crisisOverlay.setFillStyle(0x88ff88, 0);
this.showInteractionFeedback('collected', '✓ Чума нейтрализована!');
advancePhase(this.runState); // Crisis → Resolution
} else {
this.showInteractionFeedback('collected', `Нейтрализация: ${this.crisisState.neutralizeApplied}/${CHEMICAL_PLAGUE.neutralizeAmount}`);
}
}
// Clear quick slot if inventory is now empty for this item
if (!this.inventory.hasItem(itemId)) {
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
}
if (!this.crisisState?.active || itemId !== CHEMICAL_PLAGUE.neutralizer) {
this.showInteractionFeedback('collected', `Threw ${itemId}`);
}
}
/** 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) {
case 'collected':
msg = itemId?.startsWith('Threw') || itemId?.startsWith('⚠') || itemId?.startsWith('✓') || itemId?.startsWith('Нейтрализация')
? itemId
: `+1 ${itemId ?? ''}`;
break;
case 'depleted':
msg = `+1 ${itemId ?? ''} (depleted)`;
break;
case 'inventory_full':
msg = `Inventory full! Can't pick up ${itemId ?? ''}`;
break;
case 'nothing_nearby':
msg = 'Nothing to throw / interact with';
break;
}
this.interactionText.setText(msg);
this.interactionText.setAlpha(1);
this.interactionTimer = 1500;
}
}