游戏循环与Game类
上一章我们创建的第一个工程里,模板代码其实已经大致给出了MonoGame游戏的基本骨架。这一章我们继续深入介绍,游戏循环到底是如何运转的,Game类的各个生命周期方法分别在什么时候被调用,时间步长如何控制,以及如何用GameComponent把代码组织得更加模块化。
游戏循环
所有的游戏,从俄罗斯方块到3A大作,本质上都是一个循环,游戏循环(Game Loop):
启动游戏
└─ 初始化
└─ 加载资源
└─ while (游戏未退出)
| ├─ 处理输入
| ├─ 更新游戏逻辑(Update)
| └─ 绘制画面(Draw)
└─ 释放资源,退出
游戏世界的一切变化都发生在这个循环里,每循环一圈,我们读取一次输入、推进一次游戏逻辑(角色移动、碰撞检测、AI思考)、绘制一帧画面。一次绘制就是一帧(Frame),每秒绘制的次数就是帧率(FPS)。
在MonoGame中,循环是由框架驱动的,Game类就是这个循环的宿主,我们的游戏类需要继承它,并通过重写几个虚方法来参与到循环的每一个环节。
Game类生命周期
一个MonoGame游戏从启动到退出,Game类的方法会按照固定的顺序被调用,完整生命周期如下:
new Game1() 构造函数:设置图形设备、内容目录等
└─ Initialize() 初始化:只调用一次,初始化游戏状态
└─ LoadContent() 加载资源:加载纹理、音频、字体等
└─ 游戏循环
| ├─ Update(GameTime) 每帧调用:更新游戏逻辑
| └─ Draw(GameTime) 每帧调用:绘制画面
└─ UnloadContent() 卸载资源
└─ Dispose() 释放非托管资源(图形设备等)
构造函数
项目模板默认生成的工程中,构造函数是下面这样的。
public Game1()
{
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
IsMouseVisible = true;
}
构造函数里一般只做设置性的工作,而不做真正的初始化和资源加载,这是MonoGame约定俗成的规矩:
GraphicsDeviceManager:图形设备管理器,负责创建和管理GraphicsDevice,它是对底层GPU的抽象,分辨率、全屏等设置都通过它来完成。Content.RootDirectory:内容根目录,告诉内容管理器去哪里找资源文件,默认就是工程里的Content文件夹。IsMouseVisible:是否在窗口中显示系统鼠标指针,游戏中我们如果要隐藏鼠标指针或自己绘制准星,那就要把它设为false。
提醒:不要试图在构造函数里访问GraphicsDevice或加载资源,此时图形设备根本还没创建出来,访问只会得到空引用异常,这类工作要放到Initialize和LoadContent中。
Initialize和LoadContent
Initialize和LoadContent用于执行初始化操作和加载资源。
protected override void Initialize()
{
// TODO: Add your initialization logic here
base.Initialize();
}
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
// TODO: use this.Content to load your game content here
}
Initialize在整个游戏生命周期中只调用一次,它用于初始化游戏逻辑状态,和构造函数中不同,此时GraphicsDevice已经可用了。LoadContent紧随其后,专职负责加载游戏资源。
初学者的常见疑问是base.Initialize()这行调用是干嘛的,能不能删?答案是能删,但不建议。Game基类的Initialize实现里会遍历调用所有已注册GameComponent的Initialize方法,删掉了它组件机制就失效了。同理,Update和Draw里的base调用也是在驱动组件的更新和绘制,也需要保留,这一点看完后面的GameComponent部分就明白了。
Update和Draw
Update和Draw是游戏循环的主体,每帧交替调用。
protected override void Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
Exit();
// TODO: Add your update logic here
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
// TODO: Add your drawing code here
base.Draw(gameTime);
}
Update和Draw的职责划分需要注意:Update里不写绘制代码,Draw里不写逻辑代码。Update关心的是游戏世界的状态如何随时间演化,Draw关心的只是把当前状态画出来,把两者混在一起是新手最容易犯的结构性错误,随着项目变大,这种混乱会以指数级放大维护成本。
Update和Draw都接收一个GameTime参数,它是我们代码里感知时间流逝的重要途径,非常重要,下面会专门讲解。Draw里的GraphicsDevice.Clear(Color.CornflowerBlue)表示用纯色清空整个屏幕,如果不清屏,上一帧的画面会残留在缓冲里,形成诡异的拖影。
GameTime时间度量
GameTime类中包含以下几个重要属性。
public class GameTime
{
public TimeSpan TotalGameTime { get; } // 游戏启动至今的总时长
public TimeSpan ElapsedGameTime { get; } // 距上一帧经过的时长
public bool IsRunningSlowly { get; } // 本帧是否“掉帧”了
}
其中ElapsedGameTime帧间隔时间(常简写为dt,即delta time)是用得最多的。为什么需要它?一个常见的错误是如下写法:
protected override void Update(GameTime gameTime)
{
// 试图实现每帧移动若干固定像素,虽然能运行,但这是典型反模式!
_position.X += 5;
}
这段代码的问题在于,它假设了游戏永远以固定帧率运行,帧率60时每秒移动300像素,帧率144时每秒就移动720像素,同一份代码在不同机器上游戏速度可能完全不同,《Fallout 4》就存在这个问题,结果显然是十分搞笑的,那些试图解锁帧率提升游戏体验的玩家发现世界的运行变得极快。正确的做法是让位移与时间挂钩,单位从像素/帧变成像素/秒。
protected override void Update(GameTime gameTime)
{
// 正确姿势:速度 × 帧间隔时间
float speed = 300f; // 300像素/秒
_position.X += speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
}
这样一来,无论帧率是30、60还是240,角色现实时间每秒移动的距离都是恒定的,这也是游戏开发中最重要的基础概念之一。
固定时间步与可变时间步
默认情况下,MonoGame采用固定时间步(Fixed Timestep),Update每1/60秒被精确调用一次,Game类的IsFixedTimeStep默认值即为true表示开启固定时间步,Game类的TargetElapsedTime属性(默认TimeSpan.FromTicks(166667))控制这个间隔。
固定时间步下,如果机器性能不足,绘制一帧的实际耗时超过了1/60秒,框架不会让游戏变快来“追帧”,而是默认在一帧里最多调用一次Update,此时游戏看起来会以慢动作运行,且GameTime.IsRunningSlowly为true,这么做是为了保证物理和逻辑的一致性,宁可慢动作,也不能让两次Update之间的间隔忽大忽小导致模拟失真。
代码中,我们可以在构造函数中调整时间步设置。
IsFixedTimeStep = true; // 开启固定时间步(默认)
TargetElapsedTime = TimeSpan.FromSeconds(1.0 / 60.0); // 目标60帧
_graphics.SynchronizeWithVerticalRetrace = true; // 垂直同步(默认开)
如果我们想要更高的帧率,比如想在144Hz显示器上跑144帧,可以把目标帧时间改为1.0/144,或者干脆设置IsFixedTimeStep = false并关闭垂直同步,让游戏循环“尽情”的跑,此时Update会以尽可能高的频率被调用,ElapsedGameTime则每帧都不同,此时就变成了可变时间步。对于学习阶段,保持固定时间步即可,它理解起来比较简单,且行为可预测。
GraphicsDeviceManager与窗口管理
GraphicsDeviceManager是图形设备管理器,常用设置如下。
_graphics = new GraphicsDeviceManager(this);
_graphics.PreferredBackBufferWidth = 1280; // 后备缓冲宽度(可理解为窗口宽度)
_graphics.PreferredBackBufferHeight = 720; // 后备缓冲高度
_graphics.IsFullScreen = false; // 是否全屏
_graphics.ApplyChanges(); // 应用更改,构造函数中可省略
操作图形设备管理器时,设置完属性后需要调用ApplyChanges()才会生效,唯一的例外是构造函数,因为此时图形设备还没创建,这些值会在设备创建时自动被采用。此外,GraphicsDeviceManager的配置推荐写在构造函数中,因为MonoGame的底层图形设备是在构造函数执行完毕之后、Initialize()方法调用之前被创建并初始化的,把这些配置写在构造函数中,可以确保图形设备在第一次创建时就直接应用这些参数,避免不必要的设备重置导致闪烁。
注意:PreferredBackBufferWidth和PreferredBackBufferHeight设置的是渲染缓冲区的尺寸,绝大多数情况下等同于窗口客户区尺寸,但在高DPI或全屏独占模式下两者可能不一致。做分辨率适配时这是一个关键的区分点,在后续章节我们还会详细介绍相关内容。
窗口相关的设置则挂在Game类的Window属性上,它是GameWindow类型。
Window.Title = "我的游戏"; // 窗口标题
Window.AllowUserResizing = true; // 是否允许用户拖拽调整窗口大小
Window.ClientSizeChanged += (s, e) => // 窗口尺寸变化事件
{
// 在这里响应分辨率变化,例如调整摄像机
};
Game.Window中的静态设置写在构造函数或Initialize()中均可,像ClientSizeChanged这样的事件绑定和依赖窗口大小的逻辑推荐写在Initialize()里,这是因为在Initialize()阶段,整个游戏窗口和底层上下文已经完全建立并准备就绪,此时去绑定事件或查询视口尺寸更加稳妥,能避免某些平台上构造函数里窗口对象尚未完全初始化的问题。
GameComponent模块化
随着游戏变复杂,如果所有逻辑都堆在Game1里,这个类很快就会膨胀成几千行的“上帝类”。MonoGame官方给出的一个简易方案是GameComponent游戏组件类,它可以把相对独立的功能(例如音频管理、输入管理、调试面板等)封装成组件并注册到游戏中,框架会统一驱动其生命周期。
MonoGame内置的组件分为两种,只参与逻辑更新的继承GameComponent,它只重写Update,还需要绘制的继承DrawableGameComponent,它多一个Draw和LoadContent。下面例子中,我们制作一个显示FPS的组件,由于需要绘制内容,因此继承了DrawableGameComponent。
FpsCounter.cs
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Gacfox.Demo.DemoGame;
public class FpsCounter : DrawableGameComponent
{
private SpriteBatch _spriteBatch;
private SpriteFont _font;
private int _frames;
private float _elapsed;
private int _fps;
public int Fps => _fps;
public FpsCounter(Game game) : base(game) { }
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(Game.GraphicsDevice);
_font = Game.Content.Load<SpriteFont>("Consolas");
}
public override void Update(GameTime gameTime)
{
_frames++;
_elapsed += (float)gameTime.ElapsedGameTime.TotalSeconds;
if (_elapsed >= 1f)
{
_fps = _frames;
_frames = 0;
_elapsed = 0;
}
}
public override void Draw(GameTime gameTime)
{
_spriteBatch.Begin();
_spriteBatch.DrawString(_font, $"FPS: {_fps}", new Vector2(10, 10), Color.White);
_spriteBatch.End();
}
}
由于需要绘制文字,我们这里在MGCB中配置了.spritefont并设置使用Consolas字体,框架会将Consolas字体光栅化为纹理图集并绘制,这部分可以参考后面文字绘制相关章节内容。
其余代码都很容易理解,Update中,每调用一次就累加一次_frames字段做FPS计数,现实中超过1秒则将累加结果移入_fps字段,重新开始累加_frames,Draw每次被调用时,则将_fps的值绘制到屏幕上。
只定义FpsCounter它还不会生效,我们还需要在Game1的构造函数中注册它。
Components.Add(new FpsCounter(this));
注册之后,框架会在合适的时机自动调用组件的Initialize、LoadContent,并在每帧的Update和Draw中(也就是我们前面说的base.Update(gameTime)调用里)驱动组件,我们完全不需要手动管理它们。运行程序效果如下。

Game.Services服务容器
MonoGame中,Game类还提供了Game.Services服务容器,它允许游戏中的任何组件注册、共享和获取全局对象,而不需要通过层层参数传递引用或手写丑陋的单例模式。
注册服务:
Game.Services.AddService<T>(T serviceInstance);
获取服务:
T service = Game.Services.GetService<T>();
移除服务:
Game.Services.RemoveService(typeof(FpsCounter));
前面例子代码中,我们的FpsCounter有一个Fps属性可供外部读取,因此我们可将FpsCounter注册到Game.Services,这样其它组件或Game1中都能很方便的获取FpsCounter组件的实例。
具体到代码中,我们修改之前的FpsCounter组件,在构造函数中注册自己。
public FpsCounter(Game game) : base(game)
{
Game.Services.AddService<FpsCounter>(this);
}
在Game1或其它任何持有Game引用的地方,都可以直接从Game.Services中拿到FpsCounter了。
_fpsCounter = Game.Services.GetService<FpsCounter>();
当组件不再被需要时,可以通过类型移除。
Game.Services.RemoveService(typeof(FpsCounter));
总结来说,推荐在以下场景应该使用Game.Services:
- 全局核心系统:例如音频管理器、资源管理代理、存档系统、网络客户端等。
- 解耦组件通信:当组件
A需要组件B,但又不想在编写组件A时强依赖组件B的具体实例时,可以通过定义一个接口(Interface),然后将接口实现注册到Game.Services中。
注意:被放入Game.Services的对象不会自动随游戏销毁而销毁(除非它本身继承自GameComponent且被加到了Components列表里)。如果需要清理,记得在游戏退出或切换场景时手动从Game.Services移除。
GameComponent的适用场景
GameComponent适合封装与游戏平行的横切功能(即类似FPS计数、音频管理、物理系统、输入轮询、粒子系统等),不要试图把游戏内的实体(例如角色、子弹)都做成GameComponent,它的设计初衷并不是为了管理具体的游戏世界实体。游戏实体的组织属于场景系统的范畴,它通常基于场景树、ECS或二者结合实现,这部分内容我们会在场景管理相关章节专门讨论。