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

@@ -1,7 +1,10 @@
import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene';
import { CradleScene } from './scenes/CradleScene';
import { GameScene } from './scenes/GameScene';
import { UIScene } from './scenes/UIScene';
import { DeathScene } from './scenes/DeathScene';
import { FractalScene } from './scenes/FractalScene';
export const GAME_WIDTH = 1280;
export const GAME_HEIGHT = 720;
@@ -12,7 +15,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
height: GAME_HEIGHT,
backgroundColor: '#0a0a0a',
parent: document.body,
scene: [BootScene, GameScene, UIScene],
scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene],
physics: {
default: 'arcade',
arcade: {

View File

@@ -1,4 +1,6 @@
import Phaser from 'phaser';
import { loadMetaState } from '../run/persistence';
import { createMetaState } from '../run/meta';
export class BootScene extends Phaser.Scene {
constructor() {
@@ -30,7 +32,7 @@ export class BootScene extends Phaser.Scene {
// Version
this.add
.text(cx, cy + 80, 'v0.5.0 — Phase 5: Creatures & Ecology', {
.text(cx, cy + 80, 'v0.6.0 — Phase 6: Run Cycle', {
fontSize: '12px',
color: '#333333',
fontFamily: 'monospace',
@@ -56,7 +58,15 @@ export class BootScene extends Phaser.Scene {
});
this.input.once('pointerdown', () => {
this.scene.start('GameScene');
// Load meta-progression from IndexedDB, then go to Cradle
loadMetaState()
.then(meta => {
this.scene.start('CradleScene', { meta });
})
.catch(() => {
// Fallback to fresh meta if persistence fails
this.scene.start('CradleScene', { meta: createMetaState() });
});
});
}
}

296
src/scenes/CradleScene.ts Normal file
View File

@@ -0,0 +1,296 @@
/**
* CradleScene — Spore Cradle (Споровая Колыбель)
*
* Awakening scene where the player "wakes up" inside a giant mushroom.
* Displays school selection and starts the run.
* Receives meta state from BootScene or FractalScene.
*/
import Phaser from 'phaser';
import schoolsData from '../data/schools.json';
import type { SchoolData, MetaState } from '../run/types';
import { isSchoolUnlocked } from '../run/meta';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
const schools = schoolsData as SchoolData[];
export class CradleScene extends Phaser.Scene {
private meta!: MetaState;
private selectedIndex = 0;
private schoolCards: Phaser.GameObjects.Container[] = [];
private particles: { x: number; y: number; vx: number; vy: number; alpha: number; radius: number }[] = [];
private particleGraphics!: Phaser.GameObjects.Graphics;
private introTimer = 0;
private introComplete = false;
constructor() {
super({ key: 'CradleScene' });
}
init(data: { meta: MetaState }): void {
this.meta = data.meta;
this.selectedIndex = 0;
this.schoolCards = [];
this.introTimer = 0;
this.introComplete = false;
}
create(): void {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
// Background — dark organic interior
this.cameras.main.setBackgroundColor('#050808');
// Ambient spore particles
this.particles = [];
for (let i = 0; i < 60; i++) {
this.particles.push({
x: Math.random() * GAME_WIDTH,
y: Math.random() * GAME_HEIGHT,
vx: (Math.random() - 0.5) * 15,
vy: -Math.random() * 20 - 5,
alpha: Math.random() * 0.4 + 0.1,
radius: Math.random() * 2 + 1,
});
}
this.particleGraphics = this.add.graphics();
this.particleGraphics.setDepth(0);
// Intro text — fades in
const introText = this.add.text(cx, cy - 60, '...пробуждение...', {
fontSize: '24px',
color: '#2a6644',
fontFamily: 'monospace',
});
introText.setOrigin(0.5);
introText.setAlpha(0);
introText.setDepth(10);
// Fade in intro
this.tweens.add({
targets: introText,
alpha: 1,
duration: 2000,
ease: 'Sine.easeIn',
onComplete: () => {
// After intro text appears, fade it out and show school selection
this.tweens.add({
targets: introText,
alpha: 0,
duration: 1500,
delay: 1000,
ease: 'Sine.easeOut',
onComplete: () => {
introText.destroy();
this.showSchoolSelection();
this.introComplete = true;
},
});
},
});
// Meta info (top-right)
const metaInfo = [
`Споры: ${this.meta.spores}`,
`Раны: ${this.meta.totalRuns}`,
`Кодекс: ${this.meta.codex.length}`,
].join(' | ');
this.add.text(GAME_WIDTH - 12, 12, metaInfo, {
fontSize: '11px',
color: '#334433',
fontFamily: 'monospace',
}).setOrigin(1, 0).setDepth(10);
}
private showSchoolSelection(): void {
const cx = GAME_WIDTH / 2;
// Title
const title = this.add.text(cx, 60, 'СПОРОВАЯ КОЛЫБЕЛЬ', {
fontSize: '28px',
color: '#00ff88',
fontFamily: 'monospace',
fontStyle: 'bold',
});
title.setOrigin(0.5);
title.setAlpha(0);
title.setDepth(10);
const subtitle = this.add.text(cx, 100, 'Выбери свой путь', {
fontSize: '14px',
color: '#557755',
fontFamily: 'monospace',
});
subtitle.setOrigin(0.5);
subtitle.setAlpha(0);
subtitle.setDepth(10);
this.tweens.add({ targets: [title, subtitle], alpha: 1, duration: 800 });
// School cards
const cardWidth = 320;
const cardHeight = 260;
const startY = 160;
const unlockedSchools = schools.filter(s => isSchoolUnlocked(this.meta, s.id));
for (let i = 0; i < unlockedSchools.length; i++) {
const school = unlockedSchools[i];
const cardX = cx - (unlockedSchools.length - 1) * (cardWidth + 20) / 2 + i * (cardWidth + 20);
const cardY = startY + cardHeight / 2;
const container = this.add.container(cardX, cardY);
container.setDepth(10);
// Card background
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight, 0x0a1a0f, 0.9);
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
container.add(bg);
// School name
const nameText = this.add.text(0, -cardHeight / 2 + 20, school.nameRu, {
fontSize: '22px',
color: school.color,
fontFamily: 'monospace',
fontStyle: 'bold',
});
nameText.setOrigin(0.5);
container.add(nameText);
// Principle
const principleText = this.add.text(0, -cardHeight / 2 + 50, school.principleRu, {
fontSize: '12px',
color: '#88aa88',
fontFamily: 'monospace',
});
principleText.setOrigin(0.5);
container.add(principleText);
// Starting elements
const elemList = school.startingElements.map(sym => {
const qty = school.startingQuantities[sym] ?? 1;
return `${sym} ×${qty}`;
}).join(' ');
const elemText = this.add.text(0, -cardHeight / 2 + 80, elemList, {
fontSize: '13px',
color: '#aaffaa',
fontFamily: 'monospace',
});
elemText.setOrigin(0.5);
container.add(elemText);
// Playstyle
const playText = this.add.text(0, -cardHeight / 2 + 120,
this.wrapText(school.playstyleRu, 34), {
fontSize: '12px',
color: '#778877',
fontFamily: 'monospace',
lineSpacing: 4,
});
playText.setOrigin(0.5, 0);
container.add(playText);
// Description
const descText = this.add.text(0, cardHeight / 2 - 60,
this.wrapText(school.descriptionRu, 34), {
fontSize: '11px',
color: '#556655',
fontFamily: 'monospace',
lineSpacing: 3,
});
descText.setOrigin(0.5, 0);
container.add(descText);
// Fade in
container.setAlpha(0);
this.tweens.add({
targets: container,
alpha: 1,
duration: 600,
delay: 200 + i * 150,
});
// Click to select
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
bg.setStrokeStyle(3, 0x00ff88);
this.selectedIndex = i;
});
bg.on('pointerout', () => {
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
});
bg.on('pointerdown', () => {
this.startRun(school);
});
this.schoolCards.push(container);
}
// Start button hint
const hintText = this.add.text(cx, GAME_HEIGHT - 40, '[ Нажми на школу, чтобы начать ран ]', {
fontSize: '13px',
color: '#445544',
fontFamily: 'monospace',
});
hintText.setOrigin(0.5);
hintText.setAlpha(0);
hintText.setDepth(10);
this.tweens.add({
targets: hintText,
alpha: 0.8,
duration: 600,
delay: 600,
yoyo: true,
repeat: -1,
});
}
private startRun(school: SchoolData): void {
// Flash effect
this.cameras.main.flash(300, 0, 255, 136);
this.time.delayedCall(400, () => {
this.scene.start('GameScene', {
meta: this.meta,
schoolId: school.id,
runId: this.meta.totalRuns + 1,
});
});
}
update(_time: number, delta: number): void {
// Animate spore particles
this.particleGraphics.clear();
for (const p of this.particles) {
p.x += p.vx * (delta / 1000);
p.y += p.vy * (delta / 1000);
p.alpha += (Math.random() - 0.5) * 0.01;
p.alpha = Math.max(0.05, Math.min(0.5, p.alpha));
// Wrap around
if (p.y < -5) p.y = GAME_HEIGHT + 5;
if (p.x < -5) p.x = GAME_WIDTH + 5;
if (p.x > GAME_WIDTH + 5) p.x = -5;
this.particleGraphics.fillStyle(0x00ff88, p.alpha);
this.particleGraphics.fillCircle(p.x, p.y, p.radius);
}
}
/** Simple word wrap by character count */
private wrapText(text: string, maxChars: number): string {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
if (currentLine.length + word.length + 1 > maxChars) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine += (currentLine.length > 0 ? ' ' : '') + word;
}
}
if (currentLine.length > 0) lines.push(currentLine);
return lines.join('\n');
}
}

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();
}
}
}
}

324
src/scenes/FractalScene.ts Normal file
View File

@@ -0,0 +1,324 @@
/**
* FractalScene — "Момент между" (Moment Between)
*
* GDD spec:
* 10-30 seconds of pure fractal visual. Myriad cycles flash before eyes.
* Looping patterns of births and deaths, infinitely nested.
* This is a REWARD, not punishment for dying.
*
* Uses a WebGL shader (Mandelbrot/Julia set hybrid) with cycling parameters.
* Falls back to a simpler canvas animation if WebGL pipeline unavailable.
*/
import Phaser from 'phaser';
import type { MetaState, RunState } from '../run/types';
import { applyRunResults } from '../run/meta';
import { saveMetaState } from '../run/persistence';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
// ─── Fractal Fragment Shader ─────────────────────────────────────
const FRACTAL_FRAG = `
precision mediump float;
uniform float uTime;
uniform vec2 uResolution;
uniform float uZoom;
uniform vec2 uCenter;
uniform float uMorphFactor;
vec3 palette(float t) {
// Cyclic color palette — greens/teals/purples (Synthesis color scheme)
vec3 a = vec3(0.02, 0.05, 0.03);
vec3 b = vec3(0.0, 0.6, 0.4);
vec3 c = vec3(1.0, 1.0, 1.0);
vec3 d = vec3(0.0, 0.33, 0.53);
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / min(uResolution.x, uResolution.y);
uv = uv / uZoom + uCenter;
// Morph between Mandelbrot and Julia set
vec2 c = mix(uv, vec2(
0.355 + sin(uTime * 0.1) * 0.1,
0.355 + cos(uTime * 0.13) * 0.1
), uMorphFactor);
vec2 z = mix(vec2(0.0), uv, uMorphFactor);
z = mix(uv, z, uMorphFactor);
float iterations = 0.0;
const float maxIter = 128.0;
for (float i = 0.0; i < maxIter; i++) {
// z = z^2 + c with slight twist
float xTemp = z.x * z.x - z.y * z.y + c.x;
z.y = 2.0 * z.x * z.y + c.y;
z.x = xTemp;
if (dot(z, z) > 4.0) {
iterations = i;
break;
}
iterations = i;
}
// Smooth coloring
float smoothIter = iterations;
if (iterations < maxIter - 1.0) {
float logZn = log(dot(z, z)) / 2.0;
float nu = log(logZn / log(2.0)) / log(2.0);
smoothIter = iterations + 1.0 - nu;
}
float t = smoothIter / maxIter;
t = t + uTime * 0.03; // Slow color cycling
vec3 color = palette(t);
// Vignette — darker at edges
vec2 vigUv = gl_FragCoord.xy / uResolution;
float vig = 1.0 - dot(vigUv - 0.5, vigUv - 0.5) * 1.5;
color *= vig;
// Inside the set — deep dark with faint glow
if (iterations >= maxIter - 1.0) {
color = vec3(0.0, 0.02, 0.01) + 0.03 * sin(uTime * vec3(0.7, 1.1, 0.9));
}
gl_FragColor = vec4(color, 1.0);
}
`;
/** Minimal vertex shader — just pass through */
const FRACTAL_VERT = `
precision mediump float;
attribute vec2 inPosition;
void main() {
gl_Position = vec4(inPosition, 0.0, 1.0);
}
`;
export class FractalScene extends Phaser.Scene {
private meta!: MetaState;
private runState!: RunState;
private elapsed = 0;
private duration = 12000; // 12 seconds default
private shaderEnabled = false;
private customPipeline: Phaser.Renderer.WebGL.WebGLPipeline | null = null;
// Fallback canvas animation state
private fallbackGraphics!: Phaser.GameObjects.Graphics;
private fallbackParticles: { x: number; y: number; r: number; angle: number; speed: number; color: number }[] = [];
constructor() {
super({ key: 'FractalScene' });
}
init(data: { meta: MetaState; runState: RunState }): void {
this.meta = data.meta;
this.runState = data.runState;
this.elapsed = 0;
// More discoveries → longer fractal (reward for exploration)
const discCount = this.runState.discoveries.elements.size
+ this.runState.discoveries.reactions.size
+ this.runState.discoveries.compounds.size
+ this.runState.discoveries.creatures.size;
this.duration = Math.min(25000, 10000 + discCount * 1000);
}
create(): void {
this.cameras.main.setBackgroundColor('#000000');
// Apply run results to meta and save
applyRunResults(this.meta, this.runState);
saveMetaState(this.meta).catch(() => {
// Silently fail — game continues even if persistence fails
});
// Try to create WebGL shader
this.shaderEnabled = this.tryCreateShader();
if (!this.shaderEnabled) {
this.createFallbackAnimation();
}
// "Spores earned" text (appears after a moment)
const sporesText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 60,
`+${this.meta.runHistory[this.meta.runHistory.length - 1]?.sporesEarned ?? 0} спор`, {
fontSize: '16px',
color: '#00ff88',
fontFamily: 'monospace',
});
sporesText.setOrigin(0.5);
sporesText.setAlpha(0);
sporesText.setDepth(100);
this.tweens.add({
targets: sporesText,
alpha: 0.8,
duration: 1000,
delay: 3000,
onComplete: () => {
this.tweens.add({
targets: sporesText,
alpha: 0,
duration: 2000,
delay: 3000,
});
},
});
// Skip hint
const skipText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 25,
'[ click to skip ]', {
fontSize: '10px',
color: '#333333',
fontFamily: 'monospace',
});
skipText.setOrigin(0.5);
skipText.setDepth(100);
// Click to skip (after minimum 3 seconds)
this.input.on('pointerdown', () => {
if (this.elapsed > 3000) {
this.transitionToCradle();
}
});
}
private tryCreateShader(): boolean {
try {
const renderer = this.renderer;
if (!(renderer instanceof Phaser.Renderer.WebGL.WebGLRenderer)) {
return false;
}
// Use Phaser's shader support via a fullscreen quad
const baseShader = new Phaser.Display.BaseShader(
'fractal',
FRACTAL_FRAG,
FRACTAL_VERT,
{
uTime: { type: '1f', value: 0.0 },
uResolution: { type: '2f', value: { x: GAME_WIDTH, y: GAME_HEIGHT } },
uZoom: { type: '1f', value: 0.5 },
uCenter: { type: '2f', value: { x: -0.5, y: 0.0 } },
uMorphFactor: { type: '1f', value: 0.0 },
},
);
const shader = this.add.shader(baseShader, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT);
shader.setDepth(0);
return true;
} catch {
return false;
}
}
private createFallbackAnimation(): void {
// Canvas-based fractal-ish animation as fallback
this.fallbackGraphics = this.add.graphics();
this.fallbackParticles = [];
for (let i = 0; i < 200; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = 20 + Math.random() * 300;
this.fallbackParticles.push({
x: GAME_WIDTH / 2 + Math.cos(angle) * dist,
y: GAME_HEIGHT / 2 + Math.sin(angle) * dist,
r: 1 + Math.random() * 4,
angle,
speed: 0.2 + Math.random() * 0.8,
color: Phaser.Display.Color.HSLToColor(
0.3 + Math.random() * 0.3,
0.6,
0.3 + Math.random() * 0.3,
).color,
});
}
}
update(_time: number, delta: number): void {
this.elapsed += delta;
if (this.shaderEnabled) {
// Update shader uniforms via the shader game object
const shaders = this.children.list.filter(
(child): child is Phaser.GameObjects.Shader => child instanceof Phaser.GameObjects.Shader,
);
if (shaders.length > 0) {
const shader = shaders[0];
const t = this.elapsed / 1000;
shader.setUniform('uTime.value', t);
// Slowly zoom in
const zoom = 0.5 + t * 0.03;
shader.setUniform('uZoom.value', zoom);
// Slowly morph from Mandelbrot to Julia
const morph = Math.min(1.0, t / (this.duration / 1000) * 2);
shader.setUniform('uMorphFactor.value', morph);
// Pan slowly
shader.setUniform('uCenter.value.x', -0.5 + Math.sin(t * 0.05) * 0.2);
shader.setUniform('uCenter.value.y', Math.cos(t * 0.07) * 0.15);
}
} else {
// Fallback animation
this.updateFallbackAnimation(delta);
}
// Auto-transition after duration
if (this.elapsed >= this.duration) {
this.transitionToCradle();
}
}
private updateFallbackAnimation(delta: number): void {
if (!this.fallbackGraphics) return;
this.fallbackGraphics.clear();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const t = this.elapsed / 1000;
for (const p of this.fallbackParticles) {
// Spiral motion
p.angle += p.speed * (delta / 1000);
const dist = 50 + Math.sin(t * 0.5 + p.angle * 3) * 100 + Math.cos(t * 0.3) * 50;
p.x = cx + Math.cos(p.angle) * dist;
p.y = cy + Math.sin(p.angle) * dist;
const alpha = 0.3 + Math.sin(t + p.angle * 2) * 0.3;
this.fallbackGraphics.fillStyle(p.color, Math.max(0.05, alpha));
this.fallbackGraphics.fillCircle(p.x, p.y, p.r);
// Connect nearby particles with dim lines
if (Math.random() < 0.02) {
const other = this.fallbackParticles[Math.floor(Math.random() * this.fallbackParticles.length)];
this.fallbackGraphics.lineStyle(1, 0x00ff88, 0.05);
this.fallbackGraphics.beginPath();
this.fallbackGraphics.moveTo(p.x, p.y);
this.fallbackGraphics.lineTo(other.x, other.y);
this.fallbackGraphics.strokePath();
}
}
}
private transitionToCradle(): void {
// Prevent double-transition
if ((this as unknown as { _transitioning?: boolean })._transitioning) return;
(this as unknown as { _transitioning?: boolean })._transitioning = true;
this.cameras.main.fadeOut(1500, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('CradleScene', { meta: this.meta });
});
}
}

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);
}
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)`;