diff --git a/src/config.ts b/src/config.ts index 8b8a3aa..ef50aec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import { UIScene } from './scenes/UIScene'; import { DeathScene } from './scenes/DeathScene'; import { FractalScene } from './scenes/FractalScene'; import { BossArenaScene } from './scenes/BossArenaScene'; +import { RenewalScene } from './scenes/RenewalScene'; export const GAME_WIDTH = 1280; export const GAME_HEIGHT = 720; @@ -16,7 +17,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = { height: GAME_HEIGHT, backgroundColor: '#0a0a0a', parent: document.body, - scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene, BossArenaScene], + scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene, RenewalScene, BossArenaScene], physics: { default: 'arcade', arcade: { diff --git a/src/scenes/FractalScene.ts b/src/scenes/FractalScene.ts index 2882007..e756226 100644 --- a/src/scenes/FractalScene.ts +++ b/src/scenes/FractalScene.ts @@ -108,6 +108,8 @@ export class FractalScene extends Phaser.Scene { private duration = 12000; // 12 seconds default private shaderEnabled = false; private customPipeline: Phaser.Renderer.WebGL.WebGLPipeline | null = null; + private isRenewal = false; + private previousCycle = 1; // Fallback canvas animation state private fallbackGraphics!: Phaser.GameObjects.Graphics; @@ -133,8 +135,9 @@ export class FractalScene extends Phaser.Scene { create(): void { this.cameras.main.setBackgroundColor('#000000'); - // Apply run results to meta and save - applyRunResults(this.meta, this.runState); + // Apply run results to meta and save; check for Great Renewal + this.previousCycle = this.meta.greatCycle.cycleNumber; + this.isRenewal = applyRunResults(this.meta, this.runState); saveMetaState(this.meta).catch(() => { // Silently fail — game continues even if persistence fails }); @@ -318,7 +321,15 @@ export class FractalScene extends Phaser.Scene { this.cameras.main.fadeOut(1500, 0, 0, 0); this.cameras.main.once('camerafadeoutcomplete', () => { - this.scene.start('CradleScene', { meta: this.meta }); + if (this.isRenewal) { + // Great Renewal — show special scene before returning to Cradle + this.scene.start('RenewalScene', { + meta: this.meta, + previousCycle: this.previousCycle, + }); + } else { + this.scene.start('CradleScene', { meta: this.meta }); + } }); } } diff --git a/src/scenes/RenewalScene.ts b/src/scenes/RenewalScene.ts new file mode 100644 index 0000000..de332f1 --- /dev/null +++ b/src/scenes/RenewalScene.ts @@ -0,0 +1,265 @@ +/** + * RenewalScene — Great Renewal (Великое Обновление) + * + * GDD spec: + * After every 7th run, the world experiences a Great Renewal: + * - World generation fundamentally changes + * - New lore layer unlocks + * - Mycelium "matures" — opens new capabilities + * - Previous 7 runs leave traces in the next cycle + * + * Visual: A special transition between great cycles with + * pulsing Mycelium, cycle counter, and narrative theme reveal. + */ + +import Phaser from 'phaser'; +import type { MetaState, CycleTheme } from '../run/types'; +import { CYCLE_THEME_NAMES_RU } from '../run/types'; +import { GAME_WIDTH, GAME_HEIGHT } from '../config'; +import narrativeData from '../data/cycle-narrative.json'; + +interface ThemeNarrative { + nameRu: string; + cradleQuoteRu: string; + loreFrag: { textRu: string }[]; +} + +export class RenewalScene extends Phaser.Scene { + private meta!: MetaState; + private previousCycle!: number; + private newCycle!: number; + private newTheme!: CycleTheme; + private elapsed = 0; + private readonly duration = 15000; // 15 seconds + private graphics!: Phaser.GameObjects.Graphics; + private particles: { x: number; y: number; vx: number; vy: number; r: number; alpha: number; color: number }[] = []; + + constructor() { + super({ key: 'RenewalScene' }); + } + + init(data: { meta: MetaState; previousCycle: number }): void { + this.meta = data.meta; + this.previousCycle = data.previousCycle; + this.newCycle = this.meta.greatCycle.cycleNumber; + this.newTheme = this.meta.greatCycle.theme; + this.elapsed = 0; + this.particles = []; + } + + create(): void { + this.cameras.main.setBackgroundColor('#010204'); + this.graphics = this.add.graphics(); + + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + + // Create Mycelium pulse particles (more than fractal — representing the network) + for (let i = 0; i < 150; i++) { + const angle = Math.random() * Math.PI * 2; + const dist = Math.random() * 350; + this.particles.push({ + x: cx + Math.cos(angle) * dist, + y: cy + Math.sin(angle) * dist, + vx: (Math.random() - 0.5) * 30, + vy: (Math.random() - 0.5) * 30, + r: 1 + Math.random() * 3, + alpha: Math.random() * 0.4, + color: Phaser.Display.Color.HSLToColor( + 0.35 + Math.random() * 0.15, // green-teal hues + 0.5 + Math.random() * 0.3, + 0.2 + Math.random() * 0.3, + ).color, + }); + } + + // ─── Staged Text Reveals ───────────────────────────────── + + // 1. "Великое Обновление" title (fades in at 0s) + const renewalMsg = this.pickRenewalMessage(); + const titleText = this.add.text(cx, cy - 100, 'ВЕЛИКОЕ ОБНОВЛЕНИЕ', { + fontSize: '30px', + color: '#00ff88', + fontFamily: 'monospace', + fontStyle: 'bold', + }).setOrigin(0.5).setAlpha(0).setDepth(10); + + this.tweens.add({ + targets: titleText, + alpha: 1, + duration: 2000, + }); + + // 2. Renewal flavor message (fades in at 2s) + const flavorText = this.add.text(cx, cy - 55, renewalMsg, { + fontSize: '13px', + color: '#448866', + fontFamily: 'monospace', + }).setOrigin(0.5).setAlpha(0).setDepth(10); + + this.tweens.add({ + targets: flavorText, + alpha: 0.8, + duration: 1500, + delay: 2000, + }); + + // 3. Cycle counter "Цикл 1 → Цикл 2" (fades in at 4s) + const cycleText = this.add.text(cx, cy, + `Цикл ${this.previousCycle} → Цикл ${this.newCycle}`, { + fontSize: '22px', + color: '#ffffff', + fontFamily: 'monospace', + fontStyle: 'bold', + }).setOrigin(0.5).setAlpha(0).setDepth(10); + + this.tweens.add({ + targets: cycleText, + alpha: 1, + duration: 1500, + delay: 4000, + }); + + // 4. New theme name (fades in at 6s) + const themeName = CYCLE_THEME_NAMES_RU[this.newTheme]; + const themeText = this.add.text(cx, cy + 40, + `Тема: ${themeName}`, { + fontSize: '18px', + color: '#44ddaa', + fontFamily: 'monospace', + }).setOrigin(0.5).setAlpha(0).setDepth(10); + + this.tweens.add({ + targets: themeText, + alpha: 1, + duration: 1500, + delay: 6000, + }); + + // 5. Lore fragment from the new theme (fades in at 8s) + const narrative = this.getThemeNarrative(this.newTheme); + if (narrative && narrative.loreFrag.length > 0) { + const fragIdx = this.newCycle % narrative.loreFrag.length; + const loreText = this.add.text(cx, cy + 90, + `«${narrative.loreFrag[fragIdx].textRu}»`, { + fontSize: '12px', + color: '#668866', + fontFamily: 'monospace', + fontStyle: 'italic', + align: 'center', + wordWrap: { width: GAME_WIDTH * 0.7 }, + }).setOrigin(0.5).setAlpha(0).setDepth(10); + + this.tweens.add({ + targets: loreText, + alpha: 0.7, + duration: 2000, + delay: 8000, + }); + } + + // 6. Mycelium maturation info (fades in at 10s) + const matPct = Math.round(this.meta.greatCycle.myceliumMaturation * 100); + const matText = this.add.text(cx, cy + 140, + `Мицелий созревает... (${matPct}%)`, { + fontSize: '12px', + color: '#33aa66', + fontFamily: 'monospace', + }).setOrigin(0.5).setAlpha(0).setDepth(10); + + this.tweens.add({ + targets: matText, + alpha: 0.6, + duration: 1500, + delay: 10000, + }); + + // Skip hint + const skipText = this.add.text(cx, GAME_HEIGHT - 25, + '[ click to skip ]', { + fontSize: '10px', + color: '#333333', + fontFamily: 'monospace', + }).setOrigin(0.5).setDepth(100); + + // Click to skip after minimum 5 seconds + this.input.on('pointerdown', () => { + if (this.elapsed > 5000) { + this.transitionToCradle(); + } + }); + } + + update(_time: number, delta: number): void { + this.elapsed += delta; + + // Animate Mycelium particles + this.graphics.clear(); + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const t = this.elapsed / 1000; + + for (const p of this.particles) { + // Spiral inward then outward (breathing effect) + const dx = cx - p.x; + const dy = cy - p.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const breathe = Math.sin(t * 0.5) * 0.3; + + p.vx += (dx / dist) * breathe; + p.vy += (dy / dist) * breathe; + + p.x += p.vx * (delta / 1000); + p.y += p.vy * (delta / 1000); + + // Damping + p.vx *= 0.98; + p.vy *= 0.98; + + // Pulsing alpha + p.alpha = 0.1 + Math.sin(t * 1.5 + dist * 0.01) * 0.2; + + this.graphics.fillStyle(p.color, Math.max(0.02, p.alpha)); + this.graphics.fillCircle(p.x, p.y, p.r); + + // Mycelium threads — connecting nearby particles + if (Math.random() < 0.01) { + const other = this.particles[Math.floor(Math.random() * this.particles.length)]; + const threadDist = Math.hypot(p.x - other.x, p.y - other.y); + if (threadDist < 80) { + this.graphics.lineStyle(1, 0x33aa66, 0.08); + this.graphics.beginPath(); + this.graphics.moveTo(p.x, p.y); + this.graphics.lineTo(other.x, other.y); + this.graphics.strokePath(); + } + } + } + + // Auto-transition after duration + if (this.elapsed >= this.duration) { + this.transitionToCradle(); + } + } + + private pickRenewalMessage(): string { + const messages = narrativeData.renewalMessages; + const idx = this.previousCycle % messages.length; + return messages[idx].textRu; + } + + private getThemeNarrative(theme: CycleTheme): ThemeNarrative | null { + const themes = narrativeData.themes as Record; + return themes[theme] ?? null; + } + + private transitionToCradle(): void { + if ((this as unknown as { _transitioning?: boolean })._transitioning) return; + (this as unknown as { _transitioning?: boolean })._transitioning = true; + + this.cameras.main.fadeOut(2000, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('CradleScene', { meta: this.meta }); + }); + } +}