场景管理
一个真实的游戏不可能只有一屏内容,主菜单、游戏画面、结算界面等,这些都是游戏的不同「场景」。原生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)绘制遮罩,实现我们想要的过渡效果。