游戏开发中常用的设计模式

  • 单例模式
  • 模板模式
  • 状态模式
  • 原型模式
  • CRTP 模式
  • 组件模式
  • 观察者模式
  • 发布-订阅模式
  • 访问者模式

单例模式

通常用于游戏中的全局管理类,保证整个程序(进程)中只有一个实例对象存在。有很多种常见的写法:

  1. 作为全局变量(饿汗模式)
Game game;

效果:在程序启动时就会创建 game 对象,之后可以直接使用。

  1. 作为函数内部的 static 变量(懒汗模式)
Game &getGame() {
    static Game game;
    return game;
}

getGame().updatePlayers();

效果:第一次调用 getGame() 时会初始化,之后的调用会直接返回上次创建的实例。

根据你的需要,如果你需要在程序一启动时 game 对象就可用,就用饿汗模式。

如果 game 的初始化需要某些条件,例如创建 Game 类前需要 OpenGL 初始化,那么可用懒汗模式:

int main() {
    glfwInit();                 // 初始化 OpenGL
    getGame().initialize();     // 第一次调用 getGame 会初始化 game 单例
    getGame().updatePlayers();  // 之后的调用总是返回对同一个 game 单例的引用
}

提示:如果要把单例对象的定义放在头文件中,务必添加 inline 修饰符,而不是 static,否则会导致多个 cpp 文件各自有一个 Game 对象。

// Game.hpp

inline Game game;

inline Game &getGame() {
    static Game game;
    return game;
}

封装在类内部

由于所有单例全部暴露在全局名字空间,容易产生混乱。 一般会把单例对象或函数封装在类内部,并且把 Game 的构造函数设为 private,避免用户不慎直接创建出本应只有单个实例的 Game 类。

  1. 作为全局变量(饿汗模式)
struct Game {
    ...

    Game(Game &&) = delete;

private:
    Game() { ... }

public:
    // inline static Game instance;  // 虽然很爽,但不能这样写,因为 Game 在他的 }; 结束前都是不完整类型
    static Game instance;  // 非定义声明,就好比全局变量的 extern Game instance 一样,不需要是完整类型
};

inline Game Game::instance;  // 如果定义在头文件中,需要 inline!

Game::instance.updatePlayers();

警告:只定义在头文件中并使用 inline 这种写法,不适用于多 DLL 的情况!这会使 DLL 和 EXE 各自持有一份互不共通的 instance。如果需要在多 DLL 环境中使用这种饿汗模式单例,请乖乖分离声明和定义,别用 inline 了。

这是因为 Windows 中的每个 DLL 和 EXE 都是一座孤岛,互相不知道对方有没有这个符号,所以 inline 的效果从“全局只保留一份定义”变成在每个“孤岛”内各自在内部只保留一份,从而 DLL 和 EXE 各自一份,总共有两份了,互相内容不互通,从而不是单例模式。而 Linux 没有这个问题,因为 SO 动态库是运行时才由 ld-linux.so 完成链接的,SO 内部仍保有编译时产生的函数符号信息,为的是被可执行 ELF 加载进来以后,ld-linux.so 可以自动根据把可执行 ELF 和 SO 内部的 call 指令后的地址更新为加载后的符号的动态地址。而 Windows 的 DLL 中所有符号在编译时就已经被 ld 已经焊死,无法修改,这就是为什么 Windows 的每个 DLL 都会自动额外生成一个同名 LIB 文件,这个 LIB 里面实际上是一个个“插桩”函数,这些函数名字和 DLL 中的相同,但是函数的内容,是会动态 LoadLibrary 加载同名 DLL,并通过 GetProcAddress 动态获取所有 dllexport 的函数,而链接时候指定的实际上是原 DLL 对应的这个插桩 LIB,DLL 本身是无法被链接器链接的。

  1. 作为函数内部的 static 变量(懒汗模式)
struct Game {
    ...

    Game(Game &&) = delete;

private:
    Game() { ... }

public:
    inline static Game &instance() {  // 这里的 inline 可以省略,因为类体内就地实现的函数自带 inline 效果
        static Game game;
        return game;
    }
};

Game::instance().updatePlayers();

警告:这种写法同样不适用于多 DLL 的情况!如果需要在多 DLL 环境中使用,请乖乖分离声明和定义

通用的单例模式模板

template <class T>
inline T &singleton() {  // 这里的 inline 可以省略,因为就地实现的模板函数自带 inline 效果
    // 只有第一次进入时会构造一遍 T,之后不会再构造
    // 不同的 T 会实例化出不同的 singleton 实例,各自体内的 static 变量独立计算,互不干扰
    static T inst;
    return inst;
}

singleton<Game>().updatePlayers();
singleton<Other>().someMethod();

任何类型 T,只要以 singleton<T>() 形式获取,都能保证每个 T 都只有一份对象。(前提是你不要再 T() 创建对象)

警告:这种写法同样不适用于多 DLL 的情况!如果需要在多 DLL 环境中使用,请乖乖分离模板的声明和定义

模板模式

注意:模板模式和 C++ 的模板并没有必然关系!模板模式只是一种思想,可以用模板实现,也可以用虚函数实现(大多反而是用虚函数实现的)

模板模式用于封装游戏中一些相似的处理逻辑,把共同的部分集中到一个基类,把不同的细节部分留给子类实现。

和策略模式很像,只不过这里接收策略的直接就是基类自己。

例如,一个角色固定每一帧需要移动 3 次,然后绘制 1 次。显然需要把“移动”和“绘制”作为两个虚函数接口,让子类来实现。

struct Character {
    virtual void draw() = 0;
    virtual void move() = 0;
};

struct Player : Character {
    void draw() override {
        drawPlayer();
    }

    void move() override {
        movePlayer();
    }
};

struct Enemy : Character {
    void draw() override {
        drawEnemy();
    }

    void move() override {
        moveEnemy();
    }
};

如果让负责调用 Character 的人来实现每一帧需要移动 3 次 + 绘制 1 次的话,就破坏了开闭原则。

struct Game {
    vector<Character *> chars;

    void update() {
        for (auto &&c: chars) {
            c->move();
            c->move();
            c->move();
            c->draw();
        }
    }
}

改为把移动 3 次 + 绘制 1 次封装为一个 Character 的普通函数 update。

struct Character {
protected:
    virtual void draw() = 0;
    virtual void move() = 0;

public:
    void update() {
        move();
        move();
        move();
        draw();
    }
};

struct Game {
    vector<Character *> chars;

    void update() {
        for (auto &&c: chars) {
            c->update();
        }
    }
}

这样调用者就很轻松了,不必关心底层细节,而 update 也只通过接口和子类通信,满足开闭原则和依赖倒置原则。

模板模式还是策略模式:如何选择?

当一个对象涉及很多策略时,用策略模式;当只需要一个策略,且需要用到基类的成员时,用模板模式。

例如,一个角色的策略有移动策略和攻击策略,移动方式有“走路”、“跑步”两种,攻击策略又有“平A”、“暴击”两种。

那么就用策略模式,让角色分别指向移动策略和攻击策略的指针。

struct Character {
    MoveStrategy *moveStrategy;
    AttackStrategy *attackStrategy;

    void update() {
        if (isKeyPressed(GLFW_KEY_S) {
            moveStrategy->move();
        } else if (isKeyPressed(GLFW_KEY_W)) {
            moveStrategy->run();
        }
        while (auto enemy = Game::instance().findEnemy(range)) {
            attackStrategy->attack(enemy);
        }
    }
};

而如果只有一个策略,比如武器类,只需要攻击策略,并且攻击策略需要知道武器的伤害值、射程、附魔属性等信息,那就适合模板模式。

struct Weapon {
protected:
    double damage;
    double charge;
    MagicFlag magicFlags;
    double range;

    virtual void attack(Enemy *enemy);

public:
    void update() {
        while (auto enemy = Game::instance().findEnemy(range)) {
            attack(enemy);
        }
    }
};

最常见的是 do_xxx 封装

例如,一个处理字符串的虚接口类:

struct Converter {
    virtual void process(const char *s, size_t len) = 0;
};

这个接口是考虑 实现 Converter 子类的方便,对于 调用 Converter 的用户 使用起来可能并不方便。

这时候就可以运用模板模式,把原来的虚函数接口改为 protected 的函数,且名字改为 do_process。

struct Converter {
protected:
    virtual void do_process(const char *s, size_t len) = 0;

public:
    void process(string_view str) {
        return do_process(str.data(), str.size());
    }

    void process(string str) {
        return do_process(str.data(), str.size());
    }

    void process(const char *cstr) {
        return do_process(cstr, strlen(cstr));
    }
};

实现 Converter 的子类时,重写他的 do_process 函数,这些函数是 protected 的,只能被继承了 Converter 的子类访问和重写。

外层用户只能通过 Converter 基类封装好的 process 函数,避免外层用户直接干涉底层细节。

标准库中的 std::pmr::memory_resourcestd::codecvt 等都运用了 do_xxx 式的模板模式封装。

状态模式

游戏中的角色通常有多种状态,例如,一个怪物可能有“待机”、“巡逻”、“追击”、“攻击”等多种状态,而每种状态下的行为都不一样。

如果用一个枚举变量来表示当前状态,那每次就都需要用 switch 来处理不同的状态。

enum MonsterState {
    Idle,
    Chase,
    Attack,
};

struct Monster {
    MonsterState state = Idle;

    void update() {
        switch (state) {
            case Idle:
                if (seesPlayer())
                    state = Chase;
                break;
            case Chase:
                if (canAttack())
                    state = Attack;
                else if (!seesPlayer())
                    state = Idle;
                break;
            case Attack:
                if (!seesPlayer())
                    state = Idle;
                break;
        }
    }
};

这或许性能上有一定优势,缺点是,所有不同状态的处理逻辑堆积在同一个函数中,如果有多个函数(不只是 update),那么每添加一个新状态就需要修改所有函数,不符合开闭原则。

而且如果不同的状态含有不同的额外数值需要存储,比如 Chase 状态需要存储当前速度,那就需要在 Monster 类中添加 speed 成员,而 state 不为 Chase 时又用不到这个成员,非常容易扰乱思维。

状态不是枚举,而是类

为此,提出了状态模式,将不同状态的处理逻辑分离到不同的类中。他把每种状态抽象为一个类,状态是一个对象,让角色持有表示当前状态的对象,用状态对象的虚函数来表示处理逻辑,而不必每次都通过 if 判断来执行不同的行为。

struct Monster;

struct State {
    virtual void update(Monster *monster) = 0;
};

struct Idle : State {
    void update(Monster *monster) override {
        if (monster->seesPlayer()) {
            monster->setState(new Chase());
        }
    }
};

struct Chase : State {
    void update(Monster *monster) override {
        if (monster->canAttack()) {
            monster->setState(new Attack());
        } else if (!monster->seesPlayer()) {
            monster->setState(new Idle());
        }
    }
};

struct Attack : State {
    void update(Monster *monster) override {
        if (!monster->seesPlayer()) {
            monster->setState(new Idle());
        }
    }
};

struct Monster {
    State *state = new Idle();

    void update() {
        state->update(this);
    }

    void setState(State *newState) {
        delete state;
        state = newState;
    }
};

原型模式

原型模式用于复制现有的对象,且新对象的属性类型与原来相同。如何实现?

  1. 为什么拷贝构造函数不行?

拷贝构造函数只能用于类型确定的情况,对于具有虚函数,可能具有额外成员的多态类型,会发生 object-slicing,导致拷贝出来的类型只是基类的部分,而不是完整的子类对象。

RedBall ball;
Ball newball = ball;  // 错误:发生了 object-slicing!现在 newball 的类型只是 Ball 了,丢失了 RedBall 的信息
  1. 为什么拷贝指针不行?

指针的拷贝是浅拷贝,而我们需要的是深拷贝。

Ball *ball = new RedBall();
Ball *newball = ball;  // 错误:指针的拷贝是浅拷贝!newball 和 ball 指向的仍然是同一对象
  1. 需要调用到真正的构造函数,同时又基于指针
Ball *ball = new RedBall();
Ball *newball = new RedBall(*dynamic_cast<RedBall *>(ball));  // 可以,但是这里显式写出了 ball 内部的真正类型,违背了开闭原则
  1. 将拷贝构造函数封装为虚函数

原型模式将对象的拷贝方法作为虚函数,返回一个虚接口的指针,避免了直接拷贝类型。但虚函数内部会调用子类真正的构造函数,实现深拷贝。

对于熟悉工厂模式的同学:原型模式相当于把每个对象变成了自己的工厂,只需要有一个现有的对象,就能不断复制出和他相同类型的对象来。

struct Ball {
    virtual Ball *clone() = 0;
};

struct RedBall : Ball {
    Ball *clone() override {
        return new RedBall(*this);  // 调用 RedBall 的拷贝构造函数
    }
};

struct BlueBall : Ball {
    Ball *clone() override {
        return new BlueBall(*this);  // 调用 BlueBall 的拷贝构造函数
    }

    int someData;  // 如果有成员变量,也会一并被拷贝到
};

好处是,调用者无需知道具体类型,只需要他是 Ball 的子类,就可以克隆出一份完全一样的子类对象来,且返回的也是指针,不会发生 object-slicing。

Ball *ball = new RedBall();
...
Ball *newball = ball->clone();  // newball 的类型仍然是 RedBall

clone 返回为智能指针

struct Ball {
    virtual unique_ptr<Ball> clone() = 0;
};

struct RedBall : Ball {
    unique_ptr<Ball> clone() override {
        return make_unique<RedBall>(*this);  // 调用 RedBall 的拷贝构造函数
    }
};

struct BlueBall : Ball {
    unique_ptr<Ball> clone() override {
        return make_unique<BlueBall>(*this);  // 调用 BlueBall 的拷贝构造函数
    }

    int someData;  // 如果有成员变量,也会一并被拷贝到新对象中
};

这样就保证了内存不会泄漏。

如果调用者需要的是 shared_ptr,怎么办?

答:unique_ptr 可以隐式转换为 shared_ptr。

如果调用者需要的是手动 delete 的原始指针,怎么办?

答:unique_ptr 可以通过 release,故意造成一次内存泄漏,成为需要手动管理的原始指针。

CRTP 模式自动实现 clone

CRTP(Curiously Recurring Template Pattern)是一种模板元编程技术,它可以在编译期间把派生类的类型作为模板参数传递给基类,从而实现一些自动化的功能。

特点是,继承一个 CRTP 类时,需要把子类本身作为基类的模板参数。

并不会出现循环引用是因为,用到子类的具体类型是在基类的成员函数内部,而不是直接在基类内部,而模板类型的成员函数的实例化是惰性的,用到了才会实例化。

template <class Derived>
struct Pet {
    void feed() {
        Derived *that = static_cast<Derived *>(this);
        that->speak();
        that->speak();
    }
};

struct CatPet : Pet<CatPet> {
    void speak() {
        puts("Meow!");
    }
};

struct DogPet : Pet<DogPet> {
    void speak() {
        puts("Bark!");
    }
};

一般的象牙塔理论家教材中都会告诉你,CRTP 是用于取代虚函数,更高效地实现模板模式,好像 CRTP 就和虚函数势不两立。

但小彭老师的编程实践中,CRTP 常常是和虚函数一起出现的好搭档。

例如 CRTP 可以帮助原型模式实现自动化定义 clone 虚函数,稍后介绍的访问者模式中也会用到 CRTP。

struct Ball {
    virtual unique_ptr<Ball> clone() = 0;
};

template <class Derived>
struct BallImpl : Ball {  // 自动实现 clone 的辅助工具类
    unique_ptr<Ball> clone() override {
        Derived *that = static_cast<Derived *>(this);
        return make_unique<Derived>(*that);
    }
};

struct RedBall : BallImpl<RedBall> {
    // unique_ptr<Ball> clone() override {       // BallImpl 自动实现的 clone 等价于
    //     return make_unique<RedBall>(*this);  // 调用 RedBall 的拷贝构造函数
    // }
};

struct BlueBall : BallImpl<BlueBall> {
    // unique_ptr<Ball> clone() override {       // BallImpl 自动实现的 clone 等价于
    //     return make_unique<BlueBall>(*this);  // 调用 BlueBall 的拷贝构造函数
    // }
};

在小彭老师自主研发的 Zeno 中,对象类型 zeno::IObject 的深拷贝就运用了 CRTP 加持的原型模式。

组件模式

游戏中的物体(游戏对象)通常由多个组件组成,例如,一个角色可能由“角色控制器”、“角色外观”、“角色动画”等组件组成,一个子弹可能由“子弹物理”、“子弹外观”等组件组成。

组件模式是游戏开发领域最重要的设计模式,它将游戏对象分为多个组件,每个组件只关心自己的逻辑,而不关心其他组件的逻辑。

蹩脚的游戏开发者(通常是 985 量产出来的象牙塔巨婴)会把每个组件写成一个类,然后使用“多重继承”继承出一个玩家类来,并恬不知耻地声称“我也会组件模式了”。

然而,这样的缺点有:

  1. 游戏开发中普遍涉及到 update 函数,而玩家类的 update 需要轮流调用每个组件的 update 函数。

而多重继承一旦遇到重名的 update 函数,会直接报错 “有歧义的函数名” 摆烂不干了,需要你手写新的 update 函数。

struct Player : PlayerController, PlayerAppearance, PlayerAnimation {
    void update() {
        PlayerController::update();
        PlayerAppearance::update();
        PlayerAnimation::update();
    }
};
  1. C++(和大多数非脚本语言都)不支持运行时添加或删除基类,也就是说,如果要添加一个新角色,或是修改现有角色的逻辑,就需要重新编译一遍整个游戏的源码。

在网络游戏中,更新 DLL 和更新资产(图片、音频、模型等)是完全不同的。

  • 对于服务端而言,更新 DLL 需要停机更新,更新资产不需要,DLL 可以被编程允许动态加载新的贴图。
  • 对于客户端而言,更新 DLL 需要重新走一遍很长的 App 审核流程(因为直接运行于手机上的 C++ 可以轻松植入病毒),而更新资产的审核流程短得多,甚至干脆无需审核。

因此,游戏开发者很少会把游戏逻辑直接写死在 C++ 中,这会让更新游戏逻辑(例如修复 BUG)需要停机更新。(例如明日方舟每次停机更新都会给玩家发 200 合成玉)

你经常看到游戏领域的 “C++ 开发岗” 实际上是 “解释器开发”。

游戏开发者会把经常需要维护和更新的游戏逻辑写在如 Lua、Python 等脚本语言中,然后在 C++ 中集成一个 Lua、Python 解释器,根据解释器的调用结果,动态创建出 C++ 对象,然后把这些 C++ 对象当作组件添加到游戏对象上。

当出现 BUG 时,只需要修改这些脚本语言的代码,然后以“资产”的形式,快速走一遍审核流程,就可以修复 BUG,无需停机更新。(例如明日方舟有时候会“资源已过期”“正在下载资源”,有时是更新了图片资源,也可能是在脚本语言里动态修复了 BUG)

  1. Java 和 C# 都没有多重继承。你让人家基于 C# 的 Unity 怎么活?

因此,真正的组件模式都会允许动态插入组件,而不是编译期写死。除非你是某些象牙塔的一次性沙雕大作业。

游戏对象组件化后,可以灵活地组合出不同的游戏对象,而不必为每一种组合都写一个类。

struct Component {
    virtual void update(GameObject *go) = 0;
    virtual ~Component() = default;  // 注意!
};

struct GameObject {
    vector<Component *> components;

    void add(Component *component) {
        components.push_back(component);
    }

    void update() {
        for (auto &&c: components) {
            c->update(this);
        }
    }
};

注意:Component 的析构函数必须为虚函数。否则,当 Component 被 delete 时,只会调用到 Component 这个基类的析构函数,而不会调用到子类的析构函数。

否则,如果你的子类有 string、vector 这种持有内存资源的容器类,会发生内存泄漏,导致游戏运行越久内存占用越大。

神奇的是,如果你的 Component 全部都是用 make_shared 创建的,那就没有内存泄漏了,这得益于 shared_ptr 会对 deleter 做类型擦除。 make_unique 和 new 创建的就会泄漏,因为他们 delete 时是以基类指针去 delete 的,而 shared_ptr 会在构造时就记住子类的 deleter。

所有组件,都支持 update(每帧更新)操作:

struct Movable : Component {
    glm::vec3 position;
    glm::vec3 velocity;

    void update(GameObject *go) override {
        position += velocity * dt;
    }
};
struct LivingBeing : Component {
    int ageLeft;

    void update(GameObject *go) override {
        if (ageLeft < 0)
            go->kill();
        else
            ageLeft -= 1;
    }
};

组件的创建

组件有两种创建方式:

  1. 组件作为一个普通对象,由 GameObject 的构造函数创建。
struct Player : GameObject {
    Movable *movable;
    LivingBeing *livingBeing;
    PlayerController *playerController;
    PlayerAppearance *playerAppearance;

    Player() {
        movable = new Movable();
        livingBeing = new LivingBeing(42);
        playerController = new PlayerController();
        playerAppearance = new PlayerAppearance();
        add(movable);
        add(livingBeing);
        add(playerController);
        add(playerAppearance);
    }
};
  1. 不再需要定义 Player 类及其构造函数了,只需一个普通函数创建具有 Player 所需所有组件的 GameObject 对象即可。
GameObject *makePlayer() {
    GameObject *go = new GameObject();

    go->add(new Movable());
    go->add(new LivingBeing(42));
    go->add(new PlayerController());
    go->add(new PlayerAppearance());

    return go;
}

正经游戏引擎都采用后者,不用添加 C++ 源码,只是从 xml 等配置文件读取每个类所依赖的组件,就能创建新的玩家类,方便动态更新游戏逻辑而无需重新发布 dll。

组件之间如何通信

缺点是,组件之间的通信需要通过 GameObject 来实现,而 GameObject 并不知道它的组件是什么,这样就无法直接访问组件的成员。

例如,PlayerController 组件想要改变 Movable 组件的 velocity,就无法直接改。

struct PlayerController : Component {
    void update(GameObject *go) override {
        if (isKeyPressed(GLFW_KEY_W)) {
            go->velocity.y += 1; // 错误!velocity 是 Movable 组件的成员,而不是 GameObject 里直接有的
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            go->velocity.y -= 1;
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            go->velocity.x -= 1;
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            go->velocity.x += 1;
        }
    }
};

如何解决组件之间通信难的问题?

  1. 把常用的字段,例如 position 和 velocity 直接放在 GameObject 里,供所有组件直接访问。
struct GameObject {
    glm::vec3 position;
    glm::vec3 velocity;

    ...
};
  1. 允许用户根据其他组件的类型,直接获取出其他组件的指针,即可访问其成员。
struct PlayerController : Component {
    void update(GameObject *go) override {
        Movable *movable = go->getComponent<Movable>();
        if (!movable) {
            throw runtime_error("这个对象似乎不支持移动");
        }
        if (isKeyPressed(GLFW_KEY_W)) {
            movable->velocity.y += 1;
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            movable->velocity.y -= 1;
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            movable->velocity.x -= 1;
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            movable->velocity.x += 1;
        }
    }
};

然而,getComponent 如何实现?

struct GameObject {
    template <class T>
    T *getComponent() {
        for (auto &&c: components) {
            if (T *t = dynamic_cast<T *>(c)) {
                return t;
            }
        }
        return nullptr;
    }
};

用到了 dynamic_cast,这是比较低效的一种实现方式,而且也不符合开闭原则。

更好的实现方式是利用 typeid 做 map 的键,加速查找。没有性能问题,但依然不符合开闭原则。

struct GameObject {
    unordered_map<type_index, Component *> components;

    template <class T>
    T *getComponent() {
        if (auto it = components.find(typeid(T)); it != components.end()) {
            return dynamic_cast<T *>(it->second);
        } else {
            return nullptr;
        }
    }

    void add(Component *component) {
        components[typeid(*component)] = component;
    }
};
  1. 让 PlayerController 发出指定类型的消息对象,由 Movable 检查并处理。

消息类型也是多态的,初学者可以先通过 dynamic_cast 实现类型检查。稍后我们会介绍更专业的访问者模式。

通常来说,我们只能把子类指针转换为基类指针。

而 dynamic_cast 可以把基类指针转换为子类指针。

如果他指向的对象确实就是那个子类类型的话,就正常返回子类指针了。

否则,如果类型不匹配,dynamic_cast 会返回 nullptr。只需判断返回的指针是不是 nullptr 就知道是否类型匹配了。

观察者模式

struct Message {
    virtual ~Message() = default;  // C++ 规定:只有多态类型才能 dynamic_cast,这里我们用不到虚函数,那就只让析构函数为虚函数,即可使 Message 变为多态类型
};

struct MoveMessage : Message {
    glm::vec3 velocityChange;
};

struct Component {
    virtual void update(GameObject *go) = 0;
    virtual void handleMessage(Message *msg) = 0;
    virtual ~Component() = default;
};

struct Movable : Component {
    glm::vec3 position;
    glm::vec3 velocity;

    void handleMessage(Message *msg) override {
        // 所有不同的消息类型都会进入此函数
        if (MoveMessage *mm = dynamic_cast<MoveMessage *>(msg)) {
            // 但只有真正类型为 MoveMessage 的消息会被处理
            velocity += mm->velocityChange;
        }
    }
};

struct PlayerController : Component {
    void update(GameObject *go) override {
        if (isKeyPressed(GLFW_KEY_W)) {
            MoveMessage mm;
            mm.velocityChange.y += 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            MoveMessage mm;
            mm.velocityChange.y -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            MoveMessage mm;
            mm.velocityChange.x -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            MoveMessage mm;
            mm.velocityChange.x += 1;
            go->send(&mm);
        }
    }
};
struct GameObject {
    vector<Component *> components;

    void add(Component *component) {
        components.push_back(component);
    }

    void update() {
        for (auto &&c: components) {
            c->update(this);
        }
    }

    void send(Message *msg) {
        for (auto &&c: components) {
            c->handleMessage(msg);
        }
    }
};

这就是所谓的观察者模式,由于每个组件都可以收到所有消息,因此,可以实现组件之间的通信。

但这样做的缺点是,每个组件都需要处理所有消息,不论是否是自己需要的,如果组件数量多,消息类型又多,就会出现性能问题。

发布-订阅模式

发布-订阅模式是观察者模式的升级版,由一个中心的事件总线来管理消息的分发。事件总线通常作为 GameObject 的成员出现。

每个组件可以订阅自己感兴趣的消息类型,当事件总线收到消息时,只把消息分发给订阅者,而不是所有组件。

struct GameObject {
    vector<Component *> components;
    unordered_map<type_index, vector<Component *>> subscribers;  // 事件总线

    template <class EventType>
    void subscribe(Component *component) {
        subscribers[type_index(typeid(EventType))].push_back(component);
    }

    template <class EventType>
    void send(EventType *msg) {
        for (auto &&c: subscribers[type_index(typeid(EventType))]) {
            c->handleMessage(msg);
        }
    }

    void add(Component *component) {
        components.push_back(component);
        component->subscribeMessages(this);
    }

    void update() {
        for (auto &&c: components) {
            c->update(this);
        }
    }
};

struct Component {
    virtual void update(GameObject *go) = 0;
    virtual void subscribeMessages(GameObject *go) = 0;
    virtual void handleMessage(Message *msg) = 0;
    virtual ~Component() = default;
};

struct Movable : Component {
    glm::vec3 position;
    glm::vec3 velocity;

    void subscribeMessages(GameObject *go) {
        go->subscribe<MoveMessage>(this);
    }

    void handleMessage(Message *msg) override {
        if (MoveMessage *mm = dynamic_cast<MoveMessage *>(msg)) {
            velocity += mm->velocityChange;
        }
    }
};

struct PlayerController : Component {
    void update(GameObject *go) override {
        if (isKeyPressed(GLFW_KEY_W)) {
            MoveMessage mm;
            mm.velocityChange.y += 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            MoveMessage mm;
            mm.velocityChange.y -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            MoveMessage mm;
            mm.velocityChange.x -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            MoveMessage mm;
            mm.velocityChange.x += 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_SPACE)) {
            JumpMessage jm;
            go->send(&jm);
        }
    }
};

这样,就可以实现组件之间的按需通信。

访问者模式

struct Message {
    virtual ~Message() = default;
};

struct MoveMessage {
    glm::vec3 velocityChange;
};

struct JumpMessage {
    double jumpHeight;
};

如何定义对所有不同类型消息的处理方式?

struct MessageVisitor;  // 前向声明

struct Message {
    virtual void accept(MessageVisitor *visitor) = 0;
    virtual ~Message() = default;
};

struct MoveMessage {
    glm::vec3 velocityChange;

    void accept(MessageVisitor *visitor) override {
        visitor->visit(this);  // 会调用到 visit(MoveMessage *mm) 这个重载
    }
};

struct JumpMessage {
    double jumpHeight;

    void accept(MessageVisitor *visitor) override {
        visitor->visit(this);  // 会调用到 visit(JumpMessage *mm) 这个重载
    }
};

struct MessageVisitor {
    virtual void visit(MoveMessage *mm) {}  // 默认不做任何处理
    virtual void visit(JumpMessage *jm) {}  // 默认不做任何处理
};

struct Movable : MessageVisitor {
    glm::vec3 position;
    glm::vec3 velocity;

    void handleMessage(Message *msg) {
        msg->accept(this);
    }

    void visit(MoveMessage *mm) override {
        velocity += mm->velocityChange;
    }

    void visit(JumpMessage *jm) override {
        velocity.y += sqrt(2 * 9.8 * jm->jumpHeight);
    }
};

这就是访问者模式,同时用到了面向对象的虚函数和重载机制,实现了对所有不同类型消息都能定制一个处理方式,而不用通过低效的 dynamic_cast 判断消息类型。

访问者模式是否符合开闭原则呢?

当我们新增一种消息类型时,需要修改的地方有:

  1. 新增消息类型
  2. MessageVisitor 中添加一个 visit 的重载

当我们新增一种组件类型时,需要修改的地方有:

  1. 新增组件类型

这三项修改都是符合开闭原则的,并不会出现牵一发而动全身的情况。

但每个组件都要处理所有消息,这就是一个不符合开闭原则的设计,因此我们让所有的 visit 虚函数有一个默认实现,那就是什么都不做。这样当新增消息类型时,虽然需要每个组件都重新编译了,但是程序员无需修改任何代码,源码级别上,是满足开闭原则的。

访问者模式通常用于 acceptor 数量有限,但 visitor 的组件类型千变万化的情况。

  • 如果消息类型有限,组件类型可能经常增加,那需要把组件类型作为 visitor,消息类型作为 acceptor。
  • 如果组件类型有限,消息类型可能经常增加,那需要把消息类型作为 visitor,组件类型作为 acceptor。

  • 常作为 acceptor 的有:编译器开发中的 IR 节点(代码中间表示),游戏与 UI 开发中的消息类型。

  • 常作为 visitor 的有:编译器开发中的优化 pass(会修改 IR 节点),游戏与 UI 开发中的接受消息组件类型。

但是每个组件都要实现 accept 的重载,内容完全一样,出现了代码重复。

Java 的模板是 type-erasure 的,对此束手无策。而 C++ 的模板是 refined-generic,可以利用 CRTP 自动实现这部分:

struct Message {
    virtual void accept(MessageVisitor *visitor) = 0;
    virtual ~Message() = default;
};

template <class Derived>
struct MessageImpl : Message {
    void accept(MessageVisitor *visitor) override {
        static_assert(std::is_base_of_v<MessageImpl, Derived>);
        visitor->visit(static_cast<Derived *>(this));
    }
};

struct MoveMessage : MessageImpl<MoveMessage> {
    glm::vec3 velocityChange;
    // 自动实现了 accept 函数
};

struct JumpMessage : MessageImpl<JumpMessage> {
    double jumpHeight;
};

在小彭老师自主研发的 Zeno 中,ZFX 编译器的 IR 优化系统就运用了 CRTP 加持的访问者模式。

MVC 模式

设计模式是一个巨大的话题,本期先讲到这里,下集我们继续介绍 UI 开发中大名鼎鼎的 MVC 模式。

MVC 模式是一种架构模式,它将应用程序分为三个核心部分:模型(Model)、视图(View)和控制器(Controller),通过分离应用程序的输入、处理和输出来提高应用程序的可维护性和可扩展性。

  • 模型(Model):负责处理数据和业务逻辑,通常由数据结构和数据库组成。
  • 视图(View):负责展示数据和用户界面,通常由 HTML、CSS 和 JavaScript 组成。
  • 控制器(Controller):负责处理用户交互和调度模型和视图,通常由后端语言(如 PHP、Java 等)实现。

MVC 模式的优点:

  • 低耦合:模型、视图和控制器之间的职责清晰,可以更容易地进行单独的修改和维护。
  • 可扩展性:由于模型、视图和控制器之间的低耦合性,可以更容易地添加新的功能和组件。
  • 可维护性:分离了不同的职责,使得代码更容易理解和维护。