From 56c96798e3b5d04a9aeb1044327486f723dc4a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A8=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80?= Date: Thu, 12 Feb 2026 15:15:17 +0300 Subject: [PATCH] =?UTF-8?q?phase=206:=20scene=20flow=20=E2=80=94=20Cradle,?= =?UTF-8?q?=20Death,=20Fractal=20scenes=20+=20run=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/config.ts | 5 +- src/scenes/BootScene.ts | 14 +- src/scenes/CradleScene.ts | 296 +++++++++++++++++++++++++++++++++ src/scenes/DeathScene.ts | 261 ++++++++++++++++++++++++++++++ src/scenes/FractalScene.ts | 324 +++++++++++++++++++++++++++++++++++++ src/scenes/GameScene.ts | 220 ++++++++++++++++++++++++- 6 files changed, 1112 insertions(+), 8 deletions(-) create mode 100644 src/scenes/CradleScene.ts create mode 100644 src/scenes/DeathScene.ts create mode 100644 src/scenes/FractalScene.ts diff --git a/src/config.ts b/src/config.ts index 7ec3bd4..9d49005 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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: { diff --git a/src/scenes/BootScene.ts b/src/scenes/BootScene.ts index 00a78a0..638b33d 100644 --- a/src/scenes/BootScene.ts +++ b/src/scenes/BootScene.ts @@ -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() }); + }); }); } } diff --git a/src/scenes/CradleScene.ts b/src/scenes/CradleScene.ts new file mode 100644 index 0000000..c47d700 --- /dev/null +++ b/src/scenes/CradleScene.ts @@ -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'); + } +} diff --git a/src/scenes/DeathScene.ts b/src/scenes/DeathScene.ts new file mode 100644 index 0000000..b782d8e --- /dev/null +++ b/src/scenes/DeathScene.ts @@ -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(); + } + } + } +} diff --git a/src/scenes/FractalScene.ts b/src/scenes/FractalScene.ts new file mode 100644 index 0000000..2882007 --- /dev/null +++ b/src/scenes/FractalScene.ts @@ -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 }); + }); + } +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 9bf5222..6ad5ecf 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -7,6 +7,7 @@ import { removeGameEntity } from '../ecs/factory'; import { PhaserBridge } from '../ecs/bridge'; import biomeDataArray from '../data/biomes.json'; import speciesDataArray from '../data/creatures.json'; +import schoolsData from '../data/schools.json'; import type { BiomeData } from '../world/types'; import { generateWorld } from '../world/generator'; import { createWorldTilemap } from '../world/tilemap'; @@ -43,6 +44,18 @@ import { } from '../creatures/interaction'; import { query } from 'bitecs'; +// Run cycle imports +import type { MetaState, SchoolData, RunState } from '../run/types'; +import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types'; +import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state'; +import { + createCrisisState, + applyCrisisDamage, + attemptNeutralize, + CHEMICAL_PLAGUE, + type CrisisState, +} from '../run/crisis'; + export class GameScene extends Phaser.Scene { private gameWorld!: GameWorld; private bridge!: PhaserBridge; @@ -83,10 +96,25 @@ export class GameScene extends Phaser.Scene { private wasEDown = false; private wasFDown = false; + // Run cycle state + private meta!: MetaState; + private runState!: RunState; + private crisisState: CrisisState | null = null; + private phaseText!: Phaser.GameObjects.Text; + private playerDead = false; + private crisisOverlay!: Phaser.GameObjects.Rectangle; + constructor() { super({ key: 'GameScene' }); } + init(data: { meta: MetaState; schoolId: string; runId: number }): void { + this.meta = data.meta; + this.runState = createRunState(data.runId, data.schoolId); + this.crisisState = null; + this.playerDead = false; + } + create(): void { // 1. Initialize ECS this.gameWorld = createGameWorld(); @@ -124,7 +152,7 @@ export class GameScene extends Phaser.Scene { this.gameWorld.world, worldData.grid, biome, this.worldSeed, allSpecies, ); - // 8. Create player at spawn position + inventory + // 8. Create player at spawn position + inventory with starting kit const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet); const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2; const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2; @@ -132,6 +160,9 @@ export class GameScene extends Phaser.Scene { this.inventory = new Inventory(500, 20); this.quickSlots = new QuickSlots(); + // Give starting elements from chosen school + this.giveStartingKit(); + // 9. Camera — follow player, zoom via scroll wheel const worldPixelW = biome.mapWidth * biome.tileSize; const worldPixelH = biome.mapHeight * biome.tileSize; @@ -190,14 +221,68 @@ export class GameScene extends Phaser.Scene { this.interactionText.setDepth(100); this.interactionText.setAlpha(0); + // Phase indicator (top-center) + this.phaseText = this.add.text( + this.cameras.main.width / 2, 12, '', { + fontSize: '12px', + color: '#00ff88', + fontFamily: 'monospace', + backgroundColor: '#000000aa', + padding: { x: 6, y: 2 }, + }, + ); + this.phaseText.setScrollFactor(0); + this.phaseText.setOrigin(0.5, 0); + this.phaseText.setDepth(100); + + // Crisis overlay (full-screen tinted rectangle, hidden by default) + this.crisisOverlay = this.add.rectangle( + this.cameras.main.width / 2, this.cameras.main.height / 2, + this.cameras.main.width, this.cameras.main.height, + 0x88ff88, 0, + ); + this.crisisOverlay.setScrollFactor(0); + this.crisisOverlay.setDepth(90); + // 11. Launch UIScene overlay this.scene.launch('UIScene'); + + // Transition from Awakening to Exploration after a moment + this.time.delayedCall(500, () => { + advancePhase(this.runState); // Awakening → Exploration + }); + } + + /** Give the player their school's starting elements */ + private giveStartingKit(): void { + const schools = schoolsData as SchoolData[]; + const school = schools.find(s => s.id === this.runState.schoolId); + if (!school) return; + + for (const symbol of school.startingElements) { + const qty = school.startingQuantities[symbol] ?? 1; + for (let i = 0; i < qty; i++) { + this.inventory.addItem(symbol); + } + this.quickSlots.autoAssign(symbol); + + // Record discovery of starting elements + recordDiscovery(this.runState, 'element', symbol); + } } update(_time: number, delta: number): void { + // Skip updates if death transition is in progress + if (this.playerDead) return; + // 1. Update world time updateTime(this.gameWorld, delta); + // 1a. Update run state timers + this.runState.elapsed += delta; + this.runState.phaseTimer += delta; + this.updateRunPhase(delta); + // 2. Read keyboard → InputState const input: InputState = { moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0), @@ -242,6 +327,8 @@ export class GameScene extends Phaser.Scene { if (interaction.type === 'collected' || interaction.type === 'depleted') { if (interaction.itemId) { this.quickSlots.autoAssign(interaction.itemId); + // Record element discovery + recordDiscovery(this.runState, 'element', interaction.itemId); } } this.showInteractionFeedback(interaction.type, interaction.itemId); @@ -261,7 +348,7 @@ export class GameScene extends Phaser.Scene { this.tryLaunchProjectile(); } - // 9a. Creature AI + // 9a. Creature AI — adjust aggression based on escalation aiSystem( this.gameWorld.world, delta, this.speciesLookup, this.gameWorld.time.tick, @@ -301,9 +388,27 @@ export class GameScene extends Phaser.Scene { // 9f. Creature attacks on player creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup); + // 9g. Crisis damage (if active) + if (this.crisisState?.active) { + applyCrisisDamage(this.crisisState, delta); + // Crisis damages player slowly + if (this.crisisState.progress > 0.3) { + const crisisDmg = this.crisisState.progress * 0.5 * (delta / 1000); + Health.current[this.playerEid] = Math.max( + 0, + (Health.current[this.playerEid] ?? 0) - crisisDmg, + ); + } + } + // 10. Health / death const dead = healthSystem(this.gameWorld.world); + let playerDied = false; for (const eid of dead) { + if (eid === this.playerEid) { + playerDied = true; + continue; // Don't remove player entity yet + } // Clean up creature tracking if (this.creatureData.has(eid)) { clearMetabolismTracking(eid); @@ -312,6 +417,12 @@ export class GameScene extends Phaser.Scene { removeGameEntity(this.gameWorld.world, eid); } + // Handle player death → transition to DeathScene + if (playerDied) { + this.onPlayerDeath(); + return; + } + // 10. Render sync this.bridge.sync(this.gameWorld.world); @@ -353,6 +464,10 @@ export class GameScene extends Phaser.Scene { energyPercent: closest.energyPercent, stage: closest.stage, }); + // Record creature observation as discovery + if (species) { + recordDiscovery(this.runState, 'creature', species.id); + } } else { this.registry.set('observedCreature', null); } @@ -361,7 +476,7 @@ export class GameScene extends Phaser.Scene { const popCounts = countPopulations(this.gameWorld.world); this.registry.set('creaturePopulations', popCounts); - // 17. Debug stats overlay + // 17. Debug stats overlay + phase indicator const fps = delta > 0 ? Math.round(1000 / delta) : 0; const px = Math.round(Position.x[this.playerEid]); const py = Math.round(Position.y[this.playerEid]); @@ -369,6 +484,83 @@ export class GameScene extends Phaser.Scene { this.statsText.setText( `seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`, ); + + // Update phase indicator + const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase]; + const escalationPct = Math.round(this.runState.escalation * 100); + const crisisInfo = this.crisisState?.active ? ` | ЧУМА: ${Math.round(this.crisisState.progress * 100)}%` : ''; + this.phaseText.setText(`${phaseName} | Эскалация: ${escalationPct}%${crisisInfo}`); + + // Color phase text based on danger + if (this.runState.phase >= RunPhase.Crisis) { + this.phaseText.setColor('#ff4444'); + } else if (this.runState.phase >= RunPhase.Escalation) { + this.phaseText.setColor('#ffaa00'); + } else { + this.phaseText.setColor('#00ff88'); + } + } + + /** Manage run phase transitions and escalation */ + private updateRunPhase(delta: number): void { + const phase = this.runState.phase; + const timer = this.runState.phaseTimer; + const duration = PHASE_DURATIONS[phase]; + + // Update escalation + updateEscalation(this.runState, delta); + + // Auto-advance timed phases + if (duration > 0 && timer >= duration) { + if (phase === RunPhase.Exploration) { + advancePhase(this.runState); // → Escalation + } else if (phase === RunPhase.Escalation) { + advancePhase(this.runState); // → Crisis + this.triggerCrisis(); + } else if (phase === RunPhase.Resolution) { + // Run complete — could transition to a victory scene + // For now, just keep playing + } + } + + // Trigger crisis when escalation hits threshold (even before phase ends) + if ( + phase === RunPhase.Escalation && + !this.crisisState && + this.runState.escalation >= CHEMICAL_PLAGUE.triggerThreshold + ) { + advancePhase(this.runState); // → Crisis + this.triggerCrisis(); + } + } + + /** Activate the Chemical Plague crisis */ + private triggerCrisis(): void { + this.crisisState = createCrisisState(CHEMICAL_PLAGUE); + this.runState.crisisActive = true; + + this.showInteractionFeedback('collected', '⚠ ХИМИЧЕСКАЯ ЧУМА! Создай CaO для нейтрализации!'); + + // Tint the world slightly toxic via overlay + this.crisisOverlay.setFillStyle(0x88ff88, 0.08); + } + + /** Handle player death — start death sequence */ + private onPlayerDeath(): void { + this.playerDead = true; + this.runState.alive = false; + + // Stop UIScene + this.scene.stop('UIScene'); + + // Slow-motion effect + this.cameras.main.fadeOut(2000, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('DeathScene', { + meta: this.meta, + runState: this.runState, + }); + }); } /** Try to launch a projectile from active quick slot toward mouse */ @@ -397,20 +589,38 @@ export class GameScene extends Phaser.Scene { itemId, ); + // Check if this compound neutralizes the crisis + if (this.crisisState?.active && itemId === CHEMICAL_PLAGUE.neutralizer) { + attemptNeutralize(this.crisisState, itemId, 1); + if (this.crisisState.resolved) { + this.runState.crisisResolved = true; + this.runState.crisisActive = false; + this.crisisOverlay.setFillStyle(0x88ff88, 0); + this.showInteractionFeedback('collected', '✓ Чума нейтрализована!'); + advancePhase(this.runState); // Crisis → Resolution + } else { + this.showInteractionFeedback('collected', `Нейтрализация: ${this.crisisState.neutralizeApplied}/${CHEMICAL_PLAGUE.neutralizeAmount}`); + } + } + // Clear quick slot if inventory is now empty for this item if (!this.inventory.hasItem(itemId)) { const slotIdx = this.quickSlots.getAll().indexOf(itemId); if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null); } - this.showInteractionFeedback('collected', `Threw ${itemId}`); + if (!this.crisisState?.active || itemId !== CHEMICAL_PLAGUE.neutralizer) { + this.showInteractionFeedback('collected', `Threw ${itemId}`); + } } private showInteractionFeedback(type: string, itemId?: string): void { let msg = ''; switch (type) { case 'collected': - msg = itemId?.startsWith('Threw') ? itemId : `+1 ${itemId ?? ''}`; + msg = itemId?.startsWith('Threw') || itemId?.startsWith('⚠') || itemId?.startsWith('✓') || itemId?.startsWith('Нейтрализация') + ? itemId + : `+1 ${itemId ?? ''}`; break; case 'depleted': msg = `+1 ${itemId ?? ''} (depleted)`;