feat: RenewalScene — Great Renewal visual event between cycles

After every 7th run (Great Renewal), players see a special scene:
- Pulsing Mycelium particle animation (breathing spiral)
- Staged text reveals: title, flavor, cycle counter, theme, lore, maturation
- "Цикл N → Цикл N+1" transition with narrative theme name
- Lore fragment from the new cycle's theme (Russian)
- Mycelium maturation percentage display
- FractalScene routes to RenewalScene when renewal detected
- Scene flow: Fractal → Renewal → Cradle (on renewal) or Fractal → Cradle (normal)
- Registered in game config

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 18:48:12 +03:00
parent b295f3e1fd
commit 91d4e4d730
3 changed files with 281 additions and 4 deletions

View File

@@ -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: {

View File

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

265
src/scenes/RenewalScene.ts Normal file
View File

@@ -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<string, ThemeNarrative>;
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 });
});
}
}