强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Pixi.js 游戏开发教程 / Pixi.js 场景管理与切换

17. 场景管理与切换

概述

随着游戏规模增长,将不同游戏状态(主菜单、游戏中、暂停、结算)分离为独立场景至关重要。本章实现一个完整的场景管理系统,包括生命周期、切换动画、场景栈和数据传递。

场景管理器设计

核心思路:每个场景是一个独立的 Container,场景管理器负责切换和更新。

// 场景接口
interface IScene extends Container {
    onEnter?(params?: Record<string, unknown>): Promise<void> | void;
    onExit?(): Promise<void> | void;
    onPause?(): void;
    onResume?(params?: Record<string, unknown>): void;
    onUpdate?(dt: number): void;
    onResize?(width: number, height: number): void;
}

场景生命周期

生命周期触发时机用途
onEnter场景进入时初始化资源、绑定事件
onExit场景离开时释放资源、解绑事件
onPause新场景压栈时暂停暂停游戏逻辑、动画
onResume栈顶场景弹出后恢复恢复游戏逻辑
onUpdate每帧更新游戏逻辑、动画
onResize窗口大小变化布局自适应
import { Container, Application } from 'pixi.js';

abstract class Scene extends Container implements IScene {
    protected app: Application;

    constructor(app: Application) {
        super();
        this.app = app;
    }

    async onEnter(_params?: Record<string, unknown>): Promise<void> {}
    async onExit(): Promise<void> {}
    onPause(): void { this.visible = false; }
    onResume(_params?: Record<string, unknown>): void { this.visible = true; }
    onUpdate(_dt: number): void {}
    onResize(_width: number, _height: number): void {}
}

完整 SceneManager 类实现

import { Container, Application, Graphics } from 'pixi.js';

interface SceneConstructor {
    new (app: Application): IScene;
}

class SceneManager {
    private app: Application;
    private scenes: Map<string, SceneConstructor> = new Map();
    private sceneStack: IScene[] = [];
    private transitionContainer: Container;
    private isTransitioning = false;

    constructor(app: Application) {
        this.app = app;
        this.transitionContainer = new Container();
        this.app.stage.addChild(this.transitionContainer);

        // 全局更新循环
        app.ticker.add((ticker) => {
            const dt = ticker.deltaMS * 0.001;
            const current = this.currentScene;
            if (current && 'onUpdate' in current) {
                current.onUpdate(dt);
            }
        });

        // 窗口大小变化
        app.renderer.on('resize', (width: number, height: number) => {
            for (const scene of this.sceneStack) {
                scene.onResize?.(width, height);
            }
        });
    }

    // 注册场景类
    register(name: string, sceneClass: SceneConstructor) {
        this.scenes.set(name, sceneClass);
    }

    // 获取当前场景
    get currentScene(): IScene | null {
        return this.sceneStack.length > 0
            ? this.sceneStack[this.sceneStack.length - 1]
            : null;
    }

    // 切换到新场景(替换栈顶)
    async switchTo(name: string, params?: Record<string, unknown>, transition?: string) {
        if (this.isTransitioning) return;
        this.isTransitioning = true;

        try {
            // 淡出当前场景
            const current = this.currentScene;
            if (current) {
                await this.playTransition(transition || 'fadeOut', 'out');
                await current.onExit();
                this.app.stage.removeChild(current);
                this.sceneStack.pop();
            }

            // 创建并进入新场景
            const SceneClass = this.scenes.get(name);
            if (!SceneClass) throw new Error(`Scene "${name}" not registered`);

            const newScene = new SceneClass(this.app);
            this.sceneStack.push(newScene);
            this.app.stage.addChildAt(newScene, 0);

            await newScene.onEnter(params);

            // 淡入新场景
            await this.playTransition(transition || 'fadeIn', 'in');
        } finally {
            this.isTransitioning = false;
        }
    }

    // 压入场景(当前场景暂停)
    async pushScene(name: string, params?: Record<string, unknown>) {
        if (this.isTransitioning) return;
        this.isTransitioning = true;

        try {
            const current = this.currentScene;
            if (current) current.onPause();

            const SceneClass = this.scenes.get(name);
            if (!SceneClass) throw new Error(`Scene "${name}" not registered`);

            const newScene = new SceneClass(this.app);
            this.sceneStack.push(newScene);
            this.app.stage.addChildAt(newScene, 0);

            await newScene.onEnter(params);
        } finally {
            this.isTransitioning = false;
        }
    }

    // 弹出栈顶场景
    async popScene(params?: Record<string, unknown>) {
        if (this.isTransitioning || this.sceneStack.length <= 1) return;
        this.isTransitioning = true;

        try {
            const current = this.currentScene;
            if (current) {
                await current.onExit();
                this.app.stage.removeChild(current);
                this.sceneStack.pop();
            }

            const resumed = this.currentScene;
            if (resumed) resumed.onResume(params);
        } finally {
            this.isTransitioning = false;
        }
    }

    // 场景切换动画
    private async playTransition(type: string, direction: 'in' | 'out'): Promise<void> {
        return new Promise((resolve) => {
            const overlay = new Graphics();
            const { width, height } = this.app.screen;
            overlay.rect(0, 0, width, height);
            overlay.fill(0x000000);
            this.transitionContainer.addChild(overlay);

            const start = performance.now();
            const duration = 300;

            const tick = () => {
                const elapsed = performance.now() - start;
                const t = Math.min(1, elapsed / duration);

                switch (type) {
                    case 'fadeIn':
                    case 'fadeOut':
                        overlay.alpha = direction === 'out' ? t : 1 - t;
                        break;
                    case 'slideLeft':
                        overlay.x = direction === 'out'
                            ? -width * (1 - t)
                            : -width * t;
                        break;
                    case 'zoom':
                        const scale = direction === 'out' ? 1 + t * 0.5 : 1.5 - t * 0.5;
                        this.app.stage.scale.set(scale);
                        overlay.alpha = direction === 'out' ? t : 1 - t;
                        break;
                }

                if (t < 1) {
                    requestAnimationFrame(tick);
                } else {
                    this.transitionContainer.removeChild(overlay);
                    overlay.destroy();
                    this.app.stage.scale.set(1);
                    resolve();
                }
            };

            tick();
        });
    }
}

场景切换动画

淡入淡出(最常用)

// 使用 SceneManager 内置的 fadeIn/fadeOut
await sceneManager.switchTo('gameplay', { level: 1 }, 'fadeIn');

滑动切换

class SlideTransition {
    static async play(app: Application, direction: 'left' | 'right' | 'up' | 'down') {
        const overlay = new Graphics();
        const { width, height } = app.screen;

        return new Promise<void>((resolve) => {
            const start = performance.now();
            const duration = 400;
            const dx = direction === 'left' ? -1 : direction === 'right' ? 1 : 0;
            const dy = direction === 'up' ? -1 : direction === 'down' ? 1 : 0;

            overlay.rect(0, 0, width, height);
            overlay.fill(0x000000);
            app.stage.addChild(overlay);

            const tick = () => {
                const t = Math.min(1, (performance.now() - start) / duration);
                const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
                overlay.x = dx * width * (1 - eased);
                overlay.y = dy * height * (1 - eased);
                overlay.alpha = t < 0.5 ? t * 2 : (1 - t) * 2;

                if (t < 1) requestAnimationFrame(tick);
                else {
                    app.stage.removeChild(overlay);
                    overlay.destroy();
                    resolve();
                }
            };
            tick();
        });
    }
}

场景栈(推入/弹出)

场景栈适合管理覆盖层场景(如暂停菜单、设置面板):

// 游戏进行中 → 暂停菜单压栈 → 游戏暂停
await sceneManager.pushScene('pauseMenu');
// 暂停菜单弹出 → 游戏恢复
await sceneManager.popScene({ resumed: true });

// 场景栈示例:
// [mainMenu] → switchTo → [gameplay]
// [gameplay] → pushScene → [gameplay, pauseMenu]
// [gameplay, pauseMenu] → popScene → [gameplay]
// [gameplay] → pushScene → [gameplay, settings]
// [gameplay, settings] → pushScene → [gameplay, settings, keyBindings]

场景间数据传递

// 方式一:通过 switchTo/pushScene 的 params 参数
await sceneManager.switchTo('gameplay', {
    level: 3,
    difficulty: 'hard',
    playerData: { hp: 100, score: 5000 },
});

// 在场景中接收
class GameplayScene extends Scene {
    async onEnter(params?: Record<string, unknown>) {
        if (params) {
            this.level = params.level as number;
            this.difficulty = params.difficulty as string;
        }
    }
}

// 方式二:通过全局数据总线
class GameDataBus {
    private data: Map<string, unknown> = new Map();

    set(key: string, value: unknown) {
        this.data.set(key, value);
    }

    get<T>(key: string): T | undefined {
        return this.data.get(key) as T;
    }

    clear() {
        this.data.clear();
    }
}

// 方式三:通过 popScene 回传数据
await sceneManager.popScene({ selectedOption: 'continue' });

class MainMenuScene extends Scene {
    onResume(params?: Record<string, unknown>) {
        if (params?.selectedOption === 'continue') {
            this.continueGame();
        }
    }
}

全局管理器

某些系统需要跨场景持续运行(音频、输入、网络):

class GameManager {
    public sceneManager: SceneManager;
    public audio: AudioManager;
    public input: InputManager;
    public network: NetworkManager;
    public data: GameDataBus;

    constructor(app: Application) {
        this.sceneManager = new SceneManager(app);
        this.audio = new AudioManager();
        this.input = new InputManager(app.canvas);
        this.network = new NetworkManager();
        this.data = new GameDataBus();

        // 注册场景
        this.sceneManager.register('mainMenu', MainMenuScene);
        this.sceneManager.register('gameplay', GameplayScene);
        this.sceneManager.register('pauseMenu', PauseMenuScene);
        this.sceneManager.register('gameOver', GameOverScene);
    }

    async start() {
        await this.sceneManager.switchTo('mainMenu');
    }
}

状态机模式

场景可以内部使用状态机管理子状态:

type GameState = 'playing' | 'paused' | 'frozen' | 'transitioning';

class GameplayScene extends Scene {
    private state: GameState = 'playing';

    onUpdate(dt: number) {
        switch (this.state) {
            case 'playing':
                this.updateGameplay(dt);
                break;
            case 'paused':
                // 不更新游戏逻辑
                break;
            case 'frozen':
                // 完全停止
                break;
            case 'transitioning':
                this.updateTransition(dt);
                break;
        }
    }

    private updateGameplay(dt: number) {
        // 玩家输入、物理、AI、渲染
    }

    private updateTransition(dt: number) {
        // 关卡过渡动画
    }

    onPause() {
        this.state = 'paused';
    }

    onResume() {
        this.state = 'playing';
    }
}

场景预加载

在切换场景前预加载资源,避免切换时出现加载画面:

class PreloadScene extends Scene {
    async onEnter() {
        const progress = this.createProgressBar();
        this.addChild(progress);

        const assets = [
            '/image/hero.png',
            '/image/enemy.png',
            '/image/tileset.png',
            '/image/ui.png',
        ];

        let loaded = 0;
        for (const asset of assets) {
            await Assets.load(asset);
            loaded++;
            progress.update(loaded / assets.length);
        }

        // 加载完成,切换到主菜单
        await this.app.sceneManager.switchTo('mainMenu');
    }

    private createProgressBar() {
        const bar = new Container();
        const bg = new Graphics();
        bg.rect(0, 0, 300, 20);
        bg.fill(0x333333);
        bar.addChild(bg);

        const fill = new Graphics();
        bar.addChild(fill);
        bar.x = (this.app.screen.width - 300) / 2;
        bar.y = (this.app.screen.height - 20) / 2;

        bar.update = (progress: number) => {
            fill.clear();
            fill.rect(0, 0, 300 * progress, 20);
            fill.fill(0x00cc00);
        };

        return bar;
    }
}

💡 提示:对于大型游戏,建议在预加载场景中加载核心资源,其他资源在游戏运行中异步加载(如进入新关卡时加载该关卡资源)。

完整使用示例

import { Application } from 'pixi.js';

const app = new Application();
await app.init({ width: 800, height: 600 });
document.body.appendChild(app.canvas);

const game = new GameManager(app);

// 添加场景
game.sceneManager.register('preload', PreloadScene);
game.sceneManager.register('mainMenu', MainMenuScene);
game.sceneManager.register('gameplay', GameplayScene);
game.sceneManager.register('pauseMenu', PauseMenuScene);

// 启动
await game.sceneManager.switchTo('preload');

扩展阅读