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 { DeathScene } from './scenes/DeathScene';
|
||||||
import { FractalScene } from './scenes/FractalScene';
|
import { FractalScene } from './scenes/FractalScene';
|
||||||
import { BossArenaScene } from './scenes/BossArenaScene';
|
import { BossArenaScene } from './scenes/BossArenaScene';
|
||||||
|
import { RenewalScene } from './scenes/RenewalScene';
|
||||||
|
|
||||||
export const GAME_WIDTH = 1280;
|
export const GAME_WIDTH = 1280;
|
||||||
export const GAME_HEIGHT = 720;
|
export const GAME_HEIGHT = 720;
|
||||||
@@ -16,7 +17,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, CradleScene, GameScene, UIScene, DeathScene, FractalScene, BossArenaScene],
|
scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene, RenewalScene, BossArenaScene],
|
||||||
physics: {
|
physics: {
|
||||||
default: 'arcade',
|
default: 'arcade',
|
||||||
arcade: {
|
arcade: {
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export class FractalScene extends Phaser.Scene {
|
|||||||
private duration = 12000; // 12 seconds default
|
private duration = 12000; // 12 seconds default
|
||||||
private shaderEnabled = false;
|
private shaderEnabled = false;
|
||||||
private customPipeline: Phaser.Renderer.WebGL.WebGLPipeline | null = null;
|
private customPipeline: Phaser.Renderer.WebGL.WebGLPipeline | null = null;
|
||||||
|
private isRenewal = false;
|
||||||
|
private previousCycle = 1;
|
||||||
|
|
||||||
// Fallback canvas animation state
|
// Fallback canvas animation state
|
||||||
private fallbackGraphics!: Phaser.GameObjects.Graphics;
|
private fallbackGraphics!: Phaser.GameObjects.Graphics;
|
||||||
@@ -133,8 +135,9 @@ export class FractalScene extends Phaser.Scene {
|
|||||||
create(): void {
|
create(): void {
|
||||||
this.cameras.main.setBackgroundColor('#000000');
|
this.cameras.main.setBackgroundColor('#000000');
|
||||||
|
|
||||||
// Apply run results to meta and save
|
// Apply run results to meta and save; check for Great Renewal
|
||||||
applyRunResults(this.meta, this.runState);
|
this.previousCycle = this.meta.greatCycle.cycleNumber;
|
||||||
|
this.isRenewal = applyRunResults(this.meta, this.runState);
|
||||||
saveMetaState(this.meta).catch(() => {
|
saveMetaState(this.meta).catch(() => {
|
||||||
// Silently fail — game continues even if persistence fails
|
// 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.fadeOut(1500, 0, 0, 0);
|
||||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
|
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 });
|
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