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>
975 lines
35 KiB
TypeScript
975 lines
35 KiB
TypeScript
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;
|
||
}
|
||
}
|