游戏循环与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:

  1. 全局核心系统:例如音频管理器、资源管理代理、存档系统、网络客户端等。
  2. 解耦组件通信:当组件A需要组件B,但又不想在编写组件A时强依赖组件B的具体实例时,可以通过定义一个接口(Interface),然后将接口实现注册到Game.Services中。

注意:被放入Game.Services的对象不会自动随游戏销毁而销毁(除非它本身继承自GameComponent且被加到了Components列表里)。如果需要清理,记得在游戏退出或切换场景时手动从Game.Services移除。

GameComponent的适用场景

GameComponent适合封装与游戏平行的横切功能(即类似FPS计数、音频管理、物理系统、输入轮询、粒子系统等),不要试图把游戏内的实体(例如角色、子弹)都做成GameComponent,它的设计初衷并不是为了管理具体的游戏世界实体。游戏实体的组织属于场景系统的范畴,它通常基于场景树、ECS或二者结合实现,这部分内容我们会在场景管理相关章节专门讨论。

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