phase 6: scene flow — Cradle, Death, Fractal scenes + run integration

- Add CradleScene (Spore Cradle): school selection UI with spore particles
- Add DeathScene: body decomposes into real elements with labels
- Add FractalScene: WebGL Mandelbrot/Julia shader + canvas fallback
- Integrate RunState into GameScene: phase management, escalation, crisis
- Give starting kit from chosen school on run start
- Player death triggers DeathScene → FractalScene → CradleScene loop
- Track element/creature discoveries during gameplay
- Chemical Plague crisis: tinted overlay, player damage, neutralization
- BootScene loads meta from IndexedDB, goes to CradleScene
- Update version to v0.6.0

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 15:15:17 +03:00
parent 5b7dbb4df3
commit 56c96798e3
6 changed files with 1112 additions and 8 deletions

View File

@@ -7,6 +7,7 @@ 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';
@@ -43,6 +44,18 @@ import {
} from '../creatures/interaction';
import { query } from 'bitecs';
// Run cycle imports
import type { MetaState, SchoolData, RunState } from '../run/types';
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
CHEMICAL_PLAGUE,
type CrisisState,
} from '../run/crisis';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
@@ -83,10 +96,25 @@ export class GameScene extends Phaser.Scene {
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;
constructor() {
super({ key: 'GameScene' });
}
init(data: { meta: MetaState; schoolId: string; runId: number }): void {
this.meta = data.meta;
this.runState = createRunState(data.runId, data.schoolId);
this.crisisState = null;
this.playerDead = false;
}
create(): void {
// 1. Initialize ECS
this.gameWorld = createGameWorld();
@@ -124,7 +152,7 @@ export class GameScene extends Phaser.Scene {
this.gameWorld.world, worldData.grid, biome, this.worldSeed, allSpecies,
);
// 8. Create player at spawn position + inventory
// 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;
@@ -132,6 +160,9 @@ export class GameScene extends Phaser.Scene {
this.inventory = new Inventory(500, 20);
this.quickSlots = new QuickSlots();
// Give starting elements from chosen school
this.giveStartingKit();
// 9. Camera — follow player, zoom via scroll wheel
const worldPixelW = biome.mapWidth * biome.tileSize;
const worldPixelH = biome.mapHeight * biome.tileSize;
@@ -190,14 +221,68 @@ export class GameScene extends Phaser.Scene {
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);
// 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
});
}
/** Give the player their school's starting elements */
private giveStartingKit(): void {
const schools = schoolsData 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),
@@ -242,6 +327,8 @@ export class GameScene extends Phaser.Scene {
if (interaction.type === 'collected' || interaction.type === 'depleted') {
if (interaction.itemId) {
this.quickSlots.autoAssign(interaction.itemId);
// Record element discovery
recordDiscovery(this.runState, 'element', interaction.itemId);
}
}
this.showInteractionFeedback(interaction.type, interaction.itemId);
@@ -261,7 +348,7 @@ export class GameScene extends Phaser.Scene {
this.tryLaunchProjectile();
}
// 9a. Creature AI
// 9a. Creature AI — adjust aggression based on escalation
aiSystem(
this.gameWorld.world, delta,
this.speciesLookup, this.gameWorld.time.tick,
@@ -301,9 +388,27 @@ export class GameScene extends Phaser.Scene {
// 9f. Creature attacks on player
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
// 9g. Crisis damage (if active)
if (this.crisisState?.active) {
applyCrisisDamage(this.crisisState, delta);
// Crisis damages player slowly
if (this.crisisState.progress > 0.3) {
const crisisDmg = this.crisisState.progress * 0.5 * (delta / 1000);
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - crisisDmg,
);
}
}
// 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);
@@ -312,6 +417,12 @@ export class GameScene extends Phaser.Scene {
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);
@@ -353,6 +464,10 @@ export class GameScene extends Phaser.Scene {
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);
}
@@ -361,7 +476,7 @@ export class GameScene extends Phaser.Scene {
const popCounts = countPopulations(this.gameWorld.world);
this.registry.set('creaturePopulations', popCounts);
// 17. Debug stats overlay
// 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]);
@@ -369,6 +484,83 @@ export class GameScene extends Phaser.Scene {
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.phaseText.setText(`${phaseName} | Эскалация: ${escalationPct}%${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');
}
}
/** 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) {
// Run complete — could transition to a victory scene
// For now, just keep playing
}
}
// 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;
// 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 */
@@ -397,20 +589,38 @@ export class GameScene extends Phaser.Scene {
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);
}
this.showInteractionFeedback('collected', `Threw ${itemId}`);
if (!this.crisisState?.active || itemId !== CHEMICAL_PLAGUE.neutralizer) {
this.showInteractionFeedback('collected', `Threw ${itemId}`);
}
}
private showInteractionFeedback(type: string, itemId?: string): void {
let msg = '';
switch (type) {
case 'collected':
msg = itemId?.startsWith('Threw') ? itemId : `+1 ${itemId ?? ''}`;
msg = itemId?.startsWith('Threw') || itemId?.startsWith('⚠') || itemId?.startsWith('') || itemId?.startsWith('Нейтрализация')
? itemId
: `+1 ${itemId ?? ''}`;
break;
case 'depleted':
msg = `+1 ${itemId ?? ''} (depleted)`;