场景管理

一个真实的游戏不可能只有一屏内容,主菜单、游戏画面、结算界面等,这些都是游戏的不同「场景」。原生MonoGame并没有场景的概念,所有逻辑都写在Game1类里,随着功能增加,如果不合理规划工程结构,Update和Draw里将很快塞满各种类似if (isInMenu) ... else if (isPlaying) ...的状态分支,代码会迅速失控。这篇笔记我们先介绍场景管理的几个核心概念,然后讲解MonoGame.Extended提供的Screens模块,它已经把这些概念都封装好了,可以作为一个不错的参考实现供我们学习。

场景管理的核心概念

什么是场景

场景(Screen)是一段自包含的游戏状态。它有自己的游戏逻辑(Update方法)、自己的渲染逻辑(Draw方法)、自己的资源管理逻辑(LoadContent方法和UnloadContent方法),我们可以把每个场景理解成一个“小Game”,主菜单场景只关心菜单的绘制和点击响应,游戏场景只关心地图和角色绘制、输入控制和角色移动和碰撞,二者互不干扰。这样划分的好处很直接,每个场景的代码可以独立成类,新增一个界面就是新增一个场景类,不需要再触碰已有场景的代码,场景的资源也可以随场景加载、随场景卸载,内存占用更可控。

场景栈

游戏在任意时刻可能叠加多个场景,比如主菜单在最底层,进入游戏后游戏画面场景加入一层,游戏中呼出暂停菜单再加入一层,这个结构天然就是一个栈(Stack),我们称之为场景栈,这个结构中,通常只有栈顶的场景处于激活状态。

场景栈结构对应两种基本操作:

Push(压栈):在当前场景之上显示新场景,比如游戏中呼出暂停界面,原场景还留在栈里,Pop之后就能回来。

Pop(弹栈):关闭当前场景,回到之前的场景,比如关闭暂停界面回到游戏。

不过在场景栈中,我们有时还需要一种替换语义,例如从主菜单进入游戏时,我们并不想保留主菜单,此时直接关掉主菜单换成游戏场景即可,它等价于Pop加Push的组合。

场景的生命周期

场景和Game类似,应当有一套自己的生命周期钩子,这套钩子是资源管理和状态复位的关键。

时机 说明
Initialize / LoadContent 场景被显示时调用,用于初始化和加载资源
OnActivated 场景成为栈顶激活场景时调用
OnDeactivated 场景不再是栈顶时调用(被压栈或关闭)
UnloadContent / Dispose 场景被关闭时调用,用于卸载和释放资源

MonoGame.Extended的Screens模块

之前章节我们曾介绍过MonoGame.Extended这个标准补充库,它的Screens模块就是上述概念的一套完整实现,包括抽象基类Screen、场景栈管理器ScreenManager,此外它还内置了场景切换的过渡功能,这里我们简单介绍一下。

Screen与GameScreen

Screen是所有场景的抽象基类,它提供了前面介绍的生命周期钩子,以及两个必须实现的抽象方法Update和Draw。实际使用中我们一般继承它的子类GameScreen,后者额外提供了几个便捷的属性,在场景内可以直接使用,不用每次都写Game.Content这样的长链。

属性 说明
Game 关联的Game实例
Content 等价于Game.Content,用于加载资源
GraphicsDevice 等价于Game.GraphicsDevice
Services 等价于Game.Services

ScreenManager

ScreenManager是场景栈管理器,它本身是一个DrawableGameComponent,这和第6章InputListenerComponent的思路一样,它注册到游戏组件集合的Components后,每帧的Update和Draw都由Game框架自动驱动,这样通过ScreenManager,栈中的场景也就被间接的自动驱动了。下面例子代码中,我们实现两个场景,主菜单场景点击鼠标进入游戏场景,游戏场景按ESC返回主菜单,主菜单场景按ESC退出游戏。

首先是Game1,它现在非常干净,只负责创建和注册ScreenManager,以及统一每帧推进输入状态。

using Microsoft.Xna.Framework;
using MonoGame.Extended.Input;
using MonoGame.Extended.Screens;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private GraphicsDeviceManager _graphics;
    private readonly ScreenManager _screenManager;

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;

        _screenManager = new ScreenManager();
        Components.Add(_screenManager);
    }

    protected override void Initialize()
    {
        base.Initialize();

        // 游戏启动后显示主菜单场景
        _screenManager.ShowScreen(new MainMenuScreen(this));
    }

    protected override void Update(GameTime gameTime)
    {
        // 每帧推进输入状态(具体判断逻辑在各场景中)
        KeyboardExtended.Update();
        MouseExtended.Update();

        base.Update(gameTime);
    }
}

主菜单场景代码如下,我们继承了GameScreen,场景类中Content、GraphicsDevice等属性可以直接使用。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGame.Extended.Input;
using MonoGame.Extended.Screens;

namespace Gacfox.Demo.DemoGame;

public class MainMenuScreen : GameScreen
{
    private SpriteBatch _spriteBatch;
    private SpriteFont _font;

    public MainMenuScreen(Game game) : base(game)
    {
    }

    public override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
        _font = Content.Load<SpriteFont>("Consolas");
    }

    public override void Update(GameTime gameTime)
    {
        if (KeyboardExtended.GetState().WasKeyPressed(Keys.Escape))
        {
            Game.Exit();
        }

        if (MouseExtended.GetState().WasButtonPressed(MouseButton.Left))
        {
            // 进入游戏场景
            ScreenManager.ReplaceScreen(new GameplayScreen(Game));
        }
    }

    public override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.DarkSlateBlue);

        _spriteBatch.Begin();
        _spriteBatch.DrawString(_font, "Main Menu: Click to Start, ESC to Exit", new Vector2(100, 100), Color.White);
        _spriteBatch.End();
    }
}

游戏场景结构完全一样,只是背景和输入响应不同。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGame.Extended.Input;
using MonoGame.Extended.Screens;

namespace Gacfox.Demo.DemoGame;

public class GameplayScreen : GameScreen
{
    private SpriteBatch _spriteBatch;
    private SpriteFont _font;

    public GameplayScreen(Game game) : base(game)
    {
    }

    public override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
        _font = Content.Load<SpriteFont>("Consolas");
    }

    public override void Update(GameTime gameTime)
    {
        if (KeyboardExtended.GetState().WasKeyPressed(Keys.Escape))
        {
            // 返回主菜单
            ScreenManager.ReplaceScreen(new MainMenuScreen(Game));
        }
    }

    public override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);

        _spriteBatch.Begin();
        _spriteBatch.DrawString(_font, "Gameplay: ESC to Main Menu", new Vector2(100, 100), Color.White);
        _spriteBatch.End();
    }
}

我们可以看到,Game1中的输入判断、渲染代码全部消失了,相关代码逻辑都内聚在所属场景中,这正是场景管理带来的工程结构收益。

ScreenManager的常用API如下。

成员 说明
ShowScreen(Screen) 压栈:显示新场景,原场景保留在栈中
CloseScreen() 弹栈:关闭栈顶场景并释放,回到下面的场景
ReplaceScreen(Screen) 替换:关闭当前场景并显示新场景
ClearScreens() 清空场景栈
ActiveScreen 当前栈顶场景
Screens 场景栈,按从栈底到栈顶排列

场景基类还有两个实用属性UpdateWhenInactive和DrawWhenInactive,默认值为false,即非栈顶场景既不更新也不绘制。某些场景下我们希望下层界面继续可见,比如半透明的暂停菜单叠加在游戏画面上,这时把游戏场景的DrawWhenInactive设为true即可。

场景过渡

Screens模块内置了场景的过渡动画支持,ShowScreen、CloseScreen、ReplaceScreen都有带Transition参数的重载。内置的过渡效果有两个,FadeTransition(淡入淡出)和ExpandTransition(从屏幕中心扩散收缩的矩形遮罩),它们都在命名空间MonoGame.Extended.Screens.Transitions下。

我们把上面的场景切换加上黑色淡入淡出效果,这只需要在切换时多传一个参数就能实现。框架中,同一时间只能运行一个过渡,且过渡播放期间的ShowScreen、CloseScreen等切换调用会被直接忽略,即使玩家在过渡动画期间疯狂点击,也不用担心场景栈被搞乱。

ScreenManager.ReplaceScreen(
    new GameplayScreen(Game),
    new FadeTransition(GraphicsDevice, Color.Black, 0.5f) // 0.5秒的黑色淡入淡出
);

如果内置的两种过渡效果不能满足需求,我们也可以继承Transition基类实现自定义过渡,这需要重写Draw方法,在其中根据进度属性Value(取值0到1)绘制遮罩,实现我们想要的过渡效果。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。