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:
@@ -1,7 +1,10 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { BootScene } from './scenes/BootScene';
|
import { BootScene } from './scenes/BootScene';
|
||||||
|
import { CradleScene } from './scenes/CradleScene';
|
||||||
import { GameScene } from './scenes/GameScene';
|
import { GameScene } from './scenes/GameScene';
|
||||||
import { UIScene } from './scenes/UIScene';
|
import { UIScene } from './scenes/UIScene';
|
||||||
|
import { DeathScene } from './scenes/DeathScene';
|
||||||
|
import { FractalScene } from './scenes/FractalScene';
|
||||||
|
|
||||||
export const GAME_WIDTH = 1280;
|
export const GAME_WIDTH = 1280;
|
||||||
export const GAME_HEIGHT = 720;
|
export const GAME_HEIGHT = 720;
|
||||||
@@ -12,7 +15,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
|
|||||||
height: GAME_HEIGHT,
|
height: GAME_HEIGHT,
|
||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#0a0a0a',
|
||||||
parent: document.body,
|
parent: document.body,
|
||||||
scene: [BootScene, GameScene, UIScene],
|
scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene],
|
||||||
physics: {
|
physics: {
|
||||||
default: 'arcade',
|
default: 'arcade',
|
||||||
arcade: {
|
arcade: {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
|
import { loadMetaState } from '../run/persistence';
|
||||||
|
import { createMetaState } from '../run/meta';
|
||||||
|
|
||||||
export class BootScene extends Phaser.Scene {
|
export class BootScene extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -30,7 +32,7 @@ export class BootScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Version
|
// Version
|
||||||
this.add
|
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',
|
fontSize: '12px',
|
||||||
color: '#333333',
|
color: '#333333',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
@@ -56,7 +58,15 @@ export class BootScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.input.once('pointerdown', () => {
|
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
296
src/scenes/CradleScene.ts
Normal 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
261
src/scenes/DeathScene.ts
Normal 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
324
src/scenes/FractalScene.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { removeGameEntity } from '../ecs/factory';
|
|||||||
import { PhaserBridge } from '../ecs/bridge';
|
import { PhaserBridge } from '../ecs/bridge';
|
||||||
import biomeDataArray from '../data/biomes.json';
|
import biomeDataArray from '../data/biomes.json';
|
||||||
import speciesDataArray from '../data/creatures.json';
|
import speciesDataArray from '../data/creatures.json';
|
||||||
|
import schoolsData from '../data/schools.json';
|
||||||
import type { BiomeData } from '../world/types';
|
import type { BiomeData } from '../world/types';
|
||||||
import { generateWorld } from '../world/generator';
|
import { generateWorld } from '../world/generator';
|
||||||
import { createWorldTilemap } from '../world/tilemap';
|
import { createWorldTilemap } from '../world/tilemap';
|
||||||
@@ -43,6 +44,18 @@ import {
|
|||||||
} from '../creatures/interaction';
|
} from '../creatures/interaction';
|
||||||
import { query } from 'bitecs';
|
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 {
|
export class GameScene extends Phaser.Scene {
|
||||||
private gameWorld!: GameWorld;
|
private gameWorld!: GameWorld;
|
||||||
private bridge!: PhaserBridge;
|
private bridge!: PhaserBridge;
|
||||||
@@ -83,10 +96,25 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private wasEDown = false;
|
private wasEDown = false;
|
||||||
private wasFDown = 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() {
|
constructor() {
|
||||||
super({ key: 'GameScene' });
|
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 {
|
create(): void {
|
||||||
// 1. Initialize ECS
|
// 1. Initialize ECS
|
||||||
this.gameWorld = createGameWorld();
|
this.gameWorld = createGameWorld();
|
||||||
@@ -124,7 +152,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.gameWorld.world, worldData.grid, biome, this.worldSeed, allSpecies,
|
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 spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
|
||||||
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
|
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
|
||||||
const spawnY = spawn?.y ?? (biome.mapHeight * 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.inventory = new Inventory(500, 20);
|
||||||
this.quickSlots = new QuickSlots();
|
this.quickSlots = new QuickSlots();
|
||||||
|
|
||||||
|
// Give starting elements from chosen school
|
||||||
|
this.giveStartingKit();
|
||||||
|
|
||||||
// 9. Camera — follow player, zoom via scroll wheel
|
// 9. Camera — follow player, zoom via scroll wheel
|
||||||
const worldPixelW = biome.mapWidth * biome.tileSize;
|
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||||
const worldPixelH = biome.mapHeight * biome.tileSize;
|
const worldPixelH = biome.mapHeight * biome.tileSize;
|
||||||
@@ -190,14 +221,68 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.interactionText.setDepth(100);
|
this.interactionText.setDepth(100);
|
||||||
this.interactionText.setAlpha(0);
|
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
|
// 11. Launch UIScene overlay
|
||||||
this.scene.launch('UIScene');
|
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 {
|
update(_time: number, delta: number): void {
|
||||||
|
// Skip updates if death transition is in progress
|
||||||
|
if (this.playerDead) return;
|
||||||
|
|
||||||
// 1. Update world time
|
// 1. Update world time
|
||||||
updateTime(this.gameWorld, delta);
|
updateTime(this.gameWorld, delta);
|
||||||
|
|
||||||
|
// 1a. Update run state timers
|
||||||
|
this.runState.elapsed += delta;
|
||||||
|
this.runState.phaseTimer += delta;
|
||||||
|
this.updateRunPhase(delta);
|
||||||
|
|
||||||
// 2. Read keyboard → InputState
|
// 2. Read keyboard → InputState
|
||||||
const input: InputState = {
|
const input: InputState = {
|
||||||
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
|
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.type === 'collected' || interaction.type === 'depleted') {
|
||||||
if (interaction.itemId) {
|
if (interaction.itemId) {
|
||||||
this.quickSlots.autoAssign(interaction.itemId);
|
this.quickSlots.autoAssign(interaction.itemId);
|
||||||
|
// Record element discovery
|
||||||
|
recordDiscovery(this.runState, 'element', interaction.itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.showInteractionFeedback(interaction.type, interaction.itemId);
|
this.showInteractionFeedback(interaction.type, interaction.itemId);
|
||||||
@@ -261,7 +348,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.tryLaunchProjectile();
|
this.tryLaunchProjectile();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9a. Creature AI
|
// 9a. Creature AI — adjust aggression based on escalation
|
||||||
aiSystem(
|
aiSystem(
|
||||||
this.gameWorld.world, delta,
|
this.gameWorld.world, delta,
|
||||||
this.speciesLookup, this.gameWorld.time.tick,
|
this.speciesLookup, this.gameWorld.time.tick,
|
||||||
@@ -301,9 +388,27 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// 9f. Creature attacks on player
|
// 9f. Creature attacks on player
|
||||||
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
|
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
|
// 10. Health / death
|
||||||
const dead = healthSystem(this.gameWorld.world);
|
const dead = healthSystem(this.gameWorld.world);
|
||||||
|
let playerDied = false;
|
||||||
for (const eid of dead) {
|
for (const eid of dead) {
|
||||||
|
if (eid === this.playerEid) {
|
||||||
|
playerDied = true;
|
||||||
|
continue; // Don't remove player entity yet
|
||||||
|
}
|
||||||
// Clean up creature tracking
|
// Clean up creature tracking
|
||||||
if (this.creatureData.has(eid)) {
|
if (this.creatureData.has(eid)) {
|
||||||
clearMetabolismTracking(eid);
|
clearMetabolismTracking(eid);
|
||||||
@@ -312,6 +417,12 @@ export class GameScene extends Phaser.Scene {
|
|||||||
removeGameEntity(this.gameWorld.world, eid);
|
removeGameEntity(this.gameWorld.world, eid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle player death → transition to DeathScene
|
||||||
|
if (playerDied) {
|
||||||
|
this.onPlayerDeath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 10. Render sync
|
// 10. Render sync
|
||||||
this.bridge.sync(this.gameWorld.world);
|
this.bridge.sync(this.gameWorld.world);
|
||||||
|
|
||||||
@@ -353,6 +464,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
energyPercent: closest.energyPercent,
|
energyPercent: closest.energyPercent,
|
||||||
stage: closest.stage,
|
stage: closest.stage,
|
||||||
});
|
});
|
||||||
|
// Record creature observation as discovery
|
||||||
|
if (species) {
|
||||||
|
recordDiscovery(this.runState, 'creature', species.id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.registry.set('observedCreature', null);
|
this.registry.set('observedCreature', null);
|
||||||
}
|
}
|
||||||
@@ -361,7 +476,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const popCounts = countPopulations(this.gameWorld.world);
|
const popCounts = countPopulations(this.gameWorld.world);
|
||||||
this.registry.set('creaturePopulations', popCounts);
|
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 fps = delta > 0 ? Math.round(1000 / delta) : 0;
|
||||||
const px = Math.round(Position.x[this.playerEid]);
|
const px = Math.round(Position.x[this.playerEid]);
|
||||||
const py = Math.round(Position.y[this.playerEid]);
|
const py = Math.round(Position.y[this.playerEid]);
|
||||||
@@ -369,6 +484,83 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.statsText.setText(
|
this.statsText.setText(
|
||||||
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`,
|
`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 */
|
/** Try to launch a projectile from active quick slot toward mouse */
|
||||||
@@ -397,20 +589,38 @@ export class GameScene extends Phaser.Scene {
|
|||||||
itemId,
|
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
|
// Clear quick slot if inventory is now empty for this item
|
||||||
if (!this.inventory.hasItem(itemId)) {
|
if (!this.inventory.hasItem(itemId)) {
|
||||||
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
|
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
|
||||||
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
|
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.crisisState?.active || itemId !== CHEMICAL_PLAGUE.neutralizer) {
|
||||||
this.showInteractionFeedback('collected', `Threw ${itemId}`);
|
this.showInteractionFeedback('collected', `Threw ${itemId}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private showInteractionFeedback(type: string, itemId?: string): void {
|
private showInteractionFeedback(type: string, itemId?: string): void {
|
||||||
let msg = '';
|
let msg = '';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'collected':
|
case 'collected':
|
||||||
msg = itemId?.startsWith('Threw') ? itemId : `+1 ${itemId ?? ''}`;
|
msg = itemId?.startsWith('Threw') || itemId?.startsWith('⚠') || itemId?.startsWith('✓') || itemId?.startsWith('Нейтрализация')
|
||||||
|
? itemId
|
||||||
|
: `+1 ${itemId ?? ''}`;
|
||||||
break;
|
break;
|
||||||
case 'depleted':
|
case 'depleted':
|
||||||
msg = `+1 ${itemId ?? ''} (depleted)`;
|
msg = `+1 ${itemId ?? ''} (depleted)`;
|
||||||
|
|||||||
Reference in New Issue
Block a user