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:
@@ -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: {
|
||||
|
||||
@@ -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
265
src/scenes/RenewalScene.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user