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

261
src/scenes/DeathScene.ts Normal file
View File

@@ -0,0 +1,261 @@
/**
* DeathScene — Body decomposition and transition to fractal
*
* GDD spec:
* 1. World slows, sounds fade
* 2. Body decomposes into real elements (65% O, 18% C, 10% H...)
* 3. Elements absorbed into soil → rush toward Mycelium
* 4. Transition to FractalScene
*/
import Phaser from 'phaser';
import type { MetaState, RunState } from '../run/types';
import { BODY_COMPOSITION } from '../run/types';
import { ElementRegistry } from '../chemistry/elements';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
/** A single element particle flying out during decomposition */
interface DeathParticle {
x: number;
y: number;
vx: number;
vy: number;
color: number;
symbol: string;
alpha: number;
radius: number;
phase: 'explode' | 'settle' | 'absorb';
timer: number;
}
export class DeathScene extends Phaser.Scene {
private meta!: MetaState;
private runState!: RunState;
private particles: DeathParticle[] = [];
private graphics!: Phaser.GameObjects.Graphics;
private elapsed = 0;
private phaseState: 'decompose' | 'absorb' | 'fadeout' = 'decompose';
private labelTexts: Phaser.GameObjects.Text[] = [];
private compositionText!: Phaser.GameObjects.Text;
constructor() {
super({ key: 'DeathScene' });
}
init(data: { meta: MetaState; runState: RunState }): void {
this.meta = data.meta;
this.runState = data.runState;
this.particles = [];
this.elapsed = 0;
this.phaseState = 'decompose';
this.labelTexts = [];
}
create(): void {
this.cameras.main.setBackgroundColor('#020202');
this.graphics = this.add.graphics();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
// Player "body" — starts as a bright circle, then decomposes
const bodyGlow = this.add.circle(cx, cy, 16, 0x00e5ff, 1);
bodyGlow.setDepth(10);
// Title text
const deathText = this.add.text(cx, 60, 'Распад...', {
fontSize: '20px',
color: '#334455',
fontFamily: 'monospace',
});
deathText.setOrigin(0.5);
deathText.setAlpha(0);
// Composition display
this.compositionText = this.add.text(cx, GAME_HEIGHT - 50, '', {
fontSize: '11px',
color: '#445566',
fontFamily: 'monospace',
});
this.compositionText.setOrigin(0.5);
this.compositionText.setAlpha(0);
// Phase 1: Body pulses, then explodes into elements
this.tweens.add({
targets: bodyGlow,
scaleX: 1.5,
scaleY: 1.5,
alpha: 0.7,
duration: 600,
yoyo: true,
repeat: 2,
ease: 'Sine.easeInOut',
onComplete: () => {
// Explode into element particles
this.spawnElementParticles(cx, cy);
bodyGlow.destroy();
// Show death text
this.tweens.add({
targets: deathText,
alpha: 0.6,
duration: 1000,
});
// Show composition
const compText = BODY_COMPOSITION
.map(e => `${e.symbol}: ${(e.fraction * 100).toFixed(1)}%`)
.join(' ');
this.compositionText.setText(`Элементный состав: ${compText}`);
this.tweens.add({
targets: this.compositionText,
alpha: 0.5,
duration: 2000,
delay: 500,
});
},
});
// After 6 seconds, start absorb phase
this.time.delayedCall(5500, () => {
this.phaseState = 'absorb';
this.tweens.add({
targets: deathText,
alpha: 0,
duration: 800,
});
});
// After 8 seconds, transition to fractal
this.time.delayedCall(8000, () => {
this.phaseState = 'fadeout';
this.cameras.main.fadeOut(1500, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('FractalScene', {
meta: this.meta,
runState: this.runState,
});
});
});
}
private spawnElementParticles(cx: number, cy: number): void {
const totalParticles = 80;
for (const comp of BODY_COMPOSITION) {
const count = Math.max(1, Math.round(comp.fraction * totalParticles));
const elem = ElementRegistry.getBySymbol(comp.symbol);
const color = elem ? parseInt(elem.color.replace('#', ''), 16) : 0xffffff;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 40 + Math.random() * 120;
this.particles.push({
x: cx + (Math.random() - 0.5) * 6,
y: cy + (Math.random() - 0.5) * 6,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color,
symbol: comp.symbol,
alpha: 0.8 + Math.random() * 0.2,
radius: 2 + comp.fraction * 6,
phase: 'explode',
timer: 0,
});
}
// Add a visible label for the most abundant elements
if (comp.fraction >= 0.03) {
const labelAngle = Math.random() * Math.PI * 2;
const labelDist = 60 + comp.fraction * 200;
const labelText = this.add.text(
cx + Math.cos(labelAngle) * labelDist,
cy + Math.sin(labelAngle) * labelDist,
`${comp.symbol} ${(comp.fraction * 100).toFixed(0)}%`,
{
fontSize: '13px',
color: `#${color.toString(16).padStart(6, '0')}`,
fontFamily: 'monospace',
},
);
labelText.setOrigin(0.5);
labelText.setAlpha(0);
this.tweens.add({
targets: labelText,
alpha: 0.7,
duration: 800,
delay: 200 + Math.random() * 600,
});
this.labelTexts.push(labelText);
}
}
}
update(_time: number, delta: number): void {
this.elapsed += delta;
const dt = delta / 1000;
this.graphics.clear();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
for (const p of this.particles) {
p.timer += delta;
if (this.phaseState === 'decompose') {
// Particles fly outward, then slow down
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx * dt;
p.y += p.vy * dt;
} else if (this.phaseState === 'absorb') {
// Particles get pulled toward center-bottom (into "soil"/Mycelium)
const targetX = cx + (Math.random() - 0.5) * 20;
const targetY = GAME_HEIGHT + 20;
const dx = targetX - p.x;
const dy = targetY - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 1) {
p.vx += (dx / dist) * 200 * dt;
p.vy += (dy / dist) * 200 * dt;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.alpha *= 0.995;
} else {
// Fadeout — everything fades
p.alpha *= 0.97;
}
// Draw particle
if (p.alpha > 0.02) {
this.graphics.fillStyle(p.color, p.alpha);
this.graphics.fillCircle(p.x, p.y, p.radius);
}
}
// Fade labels during absorb
if (this.phaseState === 'absorb' || this.phaseState === 'fadeout') {
for (const label of this.labelTexts) {
label.setAlpha(label.alpha * 0.98);
}
}
// Draw "Mycelium threads" rushing down during absorb
if (this.phaseState === 'absorb') {
this.graphics.lineStyle(1, 0x00ff88, 0.15);
for (let i = 0; i < 5; i++) {
const tx = cx + (Math.random() - 0.5) * 200;
this.graphics.beginPath();
this.graphics.moveTo(tx, cy);
let y = cy;
for (let j = 0; j < 8; j++) {
y += 30 + Math.random() * 20;
this.graphics.lineTo(tx + (Math.random() - 0.5) * 30, y);
}
this.graphics.strokePath();
}
}
}
}