Skip to content

游戏编程模式 — 深度解析

基于 Robert Nystrom《Game Programming Patterns》(gameprogrammingpatterns.com)整理,面向游戏服务端开发。

本文档的核心目标:解释每个模式为什么存在,而不是堆砌代码。Nystrom 在书中说过:好的模式文档应该让读者理解"在什么情况下用,什么情况下不用",而不是"怎么实现"。实现有很多种,但决策逻辑只有一个。


目录


第一部分:架构、性能与游戏

Robert Nystrom 在书的开篇提出了两个困扰所有游戏开发者的核心问题:

代码腐烂:随着项目规模增长,代码变得纠缠不清。修改一个功能会引发连锁反应,就像你试图修一根水管,结果发现它和整栋楼的管道都连在一起。Nystrom 说,这通常不是因为程序员不够聪明,而是因为代码没有在合理的抽象层次上组织。

性能陷阱:开发者过度关注微优化("这行代码能省 3 纳秒吗?"),而忽视了更高层次的架构决策。Nystrom 指出,一个好的架构决策带来的性能提升,往往比手写的位操作优化大几个数量级。

书中强调,设计模式不是银弹。Nystrom 反复告诫:模式是工具,不是目标。如果一段简单的 if-else 就能解决问题,那就用 if-else。引入模式的主要理由是:它能让你的代码在未来更容易修改、更容易理解。

游戏开发与传统软件开发有三个关键差异:

  1. 实时性:游戏需要在 16ms 内完成一帧的更新和渲染。任何阻塞操作都会导致卡顿。
  2. 资源受限:游戏运行在内存有限、CPU 有限的设备上,不能像 Web 服务那样随意分配对象。
  3. 确定性需求:在帧同步架构中,相同的输入需要产生完全相同的结果。这要求代码有极高的可预测性。

Nystrom 将他的 19 个模式分为六类,每一类解决一个特定的问题域。理解这个分类比值得注意的是,每个模式的名字更重要——当你遇到问题时,你首先需要判断"这是哪一类问题",然后才能选择合适的模式。


第二部分:设计模式回顾

这一部分覆盖了六个经典的面向对象设计模式在游戏中的特殊应用。Nystrom 指出,虽然这些模式来自 Gang of Four,但游戏开发对它们的使用方式与传统企业软件有很大不同。


1. 命令模式(Command)

为什么需要这个模式?

想象你在开发一款塔防游戏(TD)。玩家可以放置炮塔、升级炮塔、出售炮塔。如果用最简单的方式实现:

go
func handleClick(x, y int) {
    if isTowerAt(x, y) {
        showUpgradeMenu(x, y)    // 升级菜单
    } else if canPlaceHere(x, y) {
        placeTower(x, y)         // 放置炮塔
    } else if isUIElement(x, y) {
        handleUIClick(x, y)      // UI交互
    }
}

这段代码看起来没问题,但当你需要实现"撤销上次操作"功能时,你会发现困难重重——placeTower 已经执行了,你无法回退。当你需要实现"回放录像"功能时,你需要记录每个玩家的每一步操作,但 placeTower 只是一个函数调用,你没法序列化一个函数调用。

命令模式解决的核心问题是:把"做什么"从"怎么做"中分离出来。 Nystrom 对命令模式的精炼定义是:"命令是被实体化(reified)的方法调用"。"实体化"意味着把一个动作变成一个可以存储、传递、比较的数据对象。

Nystrom 的核心洞察

Nystrom 指出,命令模式和回调(callback)、闭包(closure)、一等函数(first-class function)本质上是同一类东西——把行为当作数据传递。但命令模式的独特之处在于:命令对象可以被序列化。这一点在游戏开发中至关重要,因为:

  • 帧同步(Lockstep):客户端把每帧的操作打包成命令对象,发送给服务器,服务器再广播给所有客户端。所有客户端按相同顺序执行相同的命令,就能保持同步。
  • 录像回放:把所有命令按顺序存储,重放时依次执行。
  • 反作弊:服务器可以验证每条命令的合法性——玩家在第 100 帧发送了"移动到 (500, 500)",但地图边界只有 (200, 200),这条命令就是非法的。

游戏服务器的真实场景

挂机游戏(Idle)中的命令队列:挂机游戏的核心是"离线收益计算"。玩家下线后,服务器需要模拟玩家离线期间的所有操作。如果用命令模式,可以把离线期间的操作(打怪、收集资源、升级)记录为命令队列。玩家上线时,服务器按顺序执行这些命令,就能精确计算离线收益。

卡牌游戏(Card)中的回放系统:自走棋对局中,每个玩家在每个回合的商店刷新、购买棋子、站位调整都是一个命令。把这些命令序列化后存储,就可以实现对局回放——其他玩家可以观看你的对局录像。

什么时候不该用?

Nystrom 特别强调:不要为了用命令模式而用命令模式。如果:

  • 你不需要撤销/回放功能
  • 你不需要序列化操作
  • 你的输入处理非常简单(直接调用函数就够了)

那就直接写函数调用。引入命令模式会增加不必要的抽象层,让代码更难理解。

代码示例:帧同步中的命令

go
// 每个游戏操作都是一个命令
type GameCommand struct {
    PlayerID uint32
    Tick     uint32
    Type     CommandType    // MOVE, ATTACK, BUY, SELL
    Data     []byte         // 序列化的参数
}

// 服务端执行命令并广播
func (s *Server) ProcessCommand(cmd GameCommand) {
    if !s.validate(cmd) { return }  // 反作弊校验
    result := s.execute(cmd)         // 执行命令
    s.broadcast(cmd, result)         // 广播给所有客户端
}

代码只有十几行,但关键在于:GameCommand 是一个数据结构,可以存储在数据库中、通过网络发送、用于回放校验。

常见错误

  1. 粒度太细:把每次鼠标移动都封装为命令。这会产生海量对象,拖垮性能。
  2. 粒度太粗:把整个回合的操作封装为一条命令。这让你无法撤销单步操作。
  3. 忘记序列化约束:命令对象中引用了不可序列化的对象(如数据库连接、内存指针),导致无法通过网络发送。
  4. 命令持有太多状态:命令应该只持有"做什么"的信息,不应该持有"怎么做"的实现细节。

2. 享元模式(Flyweight)

为什么需要这个模式?

想象你在开发一款 MMO 游戏。世界里有 10 万个怪物,它们分属 50 种类型。如果每种怪物类型都创建一个完整的对象(包含攻击力、防御力、血量、技能列表、AI 行为树、动画数据等),这些静态数据会重复存储 10 万次。

享元模式解决的核心问题是:当大量对象共享相同的不变数据时,如何避免内存浪费。 Nystrom 用了一个生动的比喻:想象一个文字处理器,文档中有 10 万行文字,每行 50 个字符。如果每个字符对象都存储自己的字体、字号、颜色信息,内存会爆炸。但如果 10 万个字符对象都指向同一个"宋体 12号 黑色"的字体对象,内存就节省了 10 万倍。

Nystrom 的核心洞察

Nystrom 指出,享元模式的关键在于区分"内在状态"(intrinsic state)和"外在状态"(extrinsic state)

  • 内在状态:对象中不变的、可共享的部分。比如怪物的类型属性(名称、基础攻击力、外观)。
  • 外在状态:对象中变化的、不可共享的部分。比如怪物的当前位置、当前血量、buff 状态。

享元模式把内在状态提取出来共享,外在状态由外部传入。这样,10 万个怪物可能只需要 50 个享元对象。

游戏服务器的真实场景

自走棋(Auto-Chess)中的棋子模板:棋盘上有 8 个玩家,每个玩家场上最多 9 个棋子,总共可能有 100+ 个棋子。但这些棋子只属于几十种类型。如果每种棋子的攻击力、技能、羁绊效果都复制一份,就是内存浪费。用享元模式,所有同类型的棋子共享一个模板对象,每个棋子实例只存储位置、血量、星级等动态数据。

链游(Blockchain)中的 NFT 属性:链游中的 NFT 道具,同类道具有相同的元数据(名称、描述、稀有度),但每个 NFT 有唯一的 ID 和持有者。把公共属性作为享元对象,每个 NFT 实例只持有唯一 ID 和当前状态。

什么时候不该用?

  • 对象数量不多(几十个),不需要优化
  • 对象的大部分数据都是变化的,没有多少可共享的内容
  • 享元模式会增加代码复杂度,如果对象本身就很轻量,不值得

代码示例:自走棋棋子模板

go
// 享元:所有同名棋子共享
type ChessPieceTemplate struct {
    Name    string
    ATK     int
    DEF     int
    Skills  []Skill
    Cost    int  // 购买费用
}

// 实例:每个棋子有独立状态
type ChessPieceInstance struct {
    Template *ChessPieceTemplate  // 共享模板
    Star     int                  // 星级:1/2/3
    HP       int                  // 当前血量
    Position GridPos              // 棋盘位置
}

// 模板池:存储所有可用模板
var templatePool = map[string]*ChessPieceTemplate{}

常见错误

  1. 共享了应该变化的数据:把怪物的当前位置也作为共享数据,导致所有同类型怪物都移动到同一个位置。
  2. 过度优化:对于只有几百个对象的场景,享元模式带来的内存节省远不及它增加的代码复杂度。
  3. 线程安全问题:多个线程同时读取享元对象时没有问题,但如果享元对象被意外修改,所有引用它的实例都会受影响。

3. 观察者模式(Observer)

为什么需要这个模式?

假设你在开发一款塔防游戏。当一个怪物被击杀时,需要触发以下连锁反应:

  • UI 更新怪物数量显示
  • 播放击杀音效
  • 更新玩家金币
  • 检查是否完成成就("击杀 100 个怪物")
  • 检查波次是否结束
  • 如果是链游,还需要更新链上数据

如果用最直接的方式实现:

go
func onMonsterKilled(monster Monster) {
    ui.UpdateCount()        // UI 模块
    audio.PlayKillSound()   // 音频模块
    player.AddGold()        // 经济模块
    achievement.Check()     // 成就模块
    wave.CheckComplete()    // 波次模块
    blockchain.Update()     // 链游模块
}

问题显而易见:击杀怪物这个逻辑,和其他所有模块都产生了直接依赖。如果明天策划说"击杀怪物时还要掉落经验药水",需要注意修改 onMonsterKilled 函数。如果成就模块被移除了,你也要修改这个函数。

观察者模式解决的核心问题是:让一个对象在状态变化时通知其他对象,而不需要知道谁在监听。 Nystrom 用了成就系统的例子来说明这一点——物理引擎在处理"坠落"事件时,不需要知道成就系统在监听这个事件。它只需要说"嘿,有个东西掉下去了",感兴趣的人自己来订阅。

Nystrom 的核心洞察

Nystrom 指出了观察者模式的几个关键特征:

  1. 松耦合:发布者(Subject)不知道订阅者(Observer)的具体类型,甚至不知道有多少个订阅者。这让两个系统可以独立演化。
  2. 一对多通知:一个事件可以通知多个订阅者,而且订阅者之间互不干扰。Nystrom 特别强调了这一点——如果只支持一个观察者,那么新注册的观察者会覆盖旧的,导致系统间互相干扰。
  3. 关注点分离:物理引擎只关心"物理模拟",不关心"成就系统"如何响应坠落事件。

Nystrom 还指出了一个重要的区别:"观察"一个对象 vs "观察"一个事件。前者你观察的是"做事情的东西",后者你观察的是"发生的事情"。后者更灵活,因为你可以为不同的事件创建不同的订阅通道。

游戏服务器的真实场景

MMO 中的 AOI(Area of Interest)通知:当玩家移动时,需要通知视野范围内的其他玩家。但玩家不应该直接调用其他玩家的更新方法。通过观察者模式,玩家移动时发出"位置变化"事件,其他玩家的客户端自动收到通知。

挂机游戏中的离线事件:玩家离线后,如果他的角色在副本中被击杀,需要通知好友列表、公会系统、排行榜系统。每个系统独立订阅"玩家死亡"事件,互不影响。

卡牌游戏中的回合通知:每个回合开始时,发出"回合开始"事件,触发所有被动技能、buff 检查、环境效果。每个技能独立监听,不需要在主循环中硬编码所有技能检查。

什么时候不该用?

  • 只有 1-2 个明确的监听者,直接调用更简单
  • 需要知道事件的处理结果(观察者模式是单向通知,不返回值)
  • 事件的触发频率极高(每帧上万次),观察者的遍历开销会成为瓶颈

代码示例:简洁的事件发布

go
// 事件类型
type Event struct {
    Type string
    Data interface{}
}

// 发布者
type EventEmitter struct {
    listeners map[string][]func(Event)
}

func (e *EventEmitter) On(eventType string, fn func(Event)) {
    e.listeners[eventType] = append(e.listeners[eventType], fn)
}

func (e *EventEmitter) Emit(eventType string, data interface{}) {
    for _, fn := range e.listeners[eventType] {
        fn(Event{Type: eventType, Data: data})
    }
}

// 使用:解耦的击杀事件
emitter.On("monster_killed", func(e Event) {
    achievement.Check(e.Data.(MonsterID))
})
emitter.On("monster_killed", func(e Event) {
    audio.PlayKillSound()
})

常见错误

  1. 内存泄漏:注册了观察者但忘记取消注册,尤其是对象被销毁后观察者列表仍然持有它的引用。
  2. 通知顺序依赖:假设观察者的执行顺序是固定的,但实际上注册顺序决定了执行顺序。
  3. 在通知回调中修改观察者列表:导致迭代器失效或死循环。
  4. 过度使用:把所有交互都变成事件通知,导致调试时无法追踪调用链。

4. 原型模式(Prototype)

为什么需要这个模式?

在游戏开发中,你经常需要创建大量相似的对象。比如在一款卡牌游戏中,有几百种卡牌。如果每种卡牌都用 new 关键字手动构造,需要写几百行初始化代码。如果卡牌的属性存储在配置文件中(JSON、CSV),你需要从配置读取数据,然后手动填充到对象中。

原型模式解决的核心问题是:当你需要大量相似对象时,用一个已有的对象作为模板来复制,而不是从头构建。 Nystrom 指出,原型模式在游戏中的最大价值是解耦对象创建和类定义。创建对象的代码不需要知道具体的类名,只需要克隆一个已有的实例。

Nystrom 的核心洞察

Nystrom 强调了原型模式与"克隆"的区别。克隆只是简单地复制一个对象,但原型模式的关键在于:你从一个原型出发,可以修改克隆出来的对象来创建变体。就像基因克隆——克隆体和原型相同,但克隆体可以独立演化。

在游戏服务器中,原型模式最常见的应用是怪物波次配置。每波怪物的配置(类型、数量、属性修正)存储在一个原型对象中。当波次开始时,从原型克隆出所有怪物实例,然后对每个实例应用随机修正(如 +10% 血量、+5% 攻击力)。

游戏服务器的真实场景

塔防游戏中的怪物波次:每波怪物用一个原型对象定义。克隆时,根据当前关卡难度应用属性缩放。

自走棋中的回合生成:每回合的商店刷新、野怪波次,都可以从原型配置中克隆生成。这样策划只需要修改原型配置文件,不需要改代码。

链游中的道具合成:合成系统需要从两个低级道具创建一个高级道具。高级道具的属性 = 基础属性 + 素材加成。这个过程本质上就是"从原型克隆 + 修改"。

什么时候不该用?

  • 对象创建很简单(直接 new 一个空对象然后赋值几个字段)
  • 你不需要动态创建对象(所有对象类型在编译时就确定了)
  • 克隆操作本身很重(对象持有数据库连接、网络连接等不可克隆的资源)

代码示例:怪物原型克隆

go
// 原型:存储基础配置
type MonsterPrototype struct {
    Type     string
    BaseHP   int
    BaseATK  int
    Skills   []string
}

// 从原型克隆,应用难度修正
func (p *MonsterPrototype) Spawn(level int) *Monster {
    m := &Monster{
        Type:  p.Type,
        HP:    int(float64(p.BaseHP) * (1.0 + float64(level)*0.1)),
        ATK:   int(float64(p.BaseATK) * (1.0 + float64(level)*0.08)),
        Skills: p.Skills,
    }
    return m
}

// 使用:从配置加载原型
prototypes := loadPrototypesFromJSON("monsters.json")
monster := prototypes["goblin"].Spawn(currentLevel)

常见错误

  1. 深拷贝 vs 浅拷贝:克隆对象时,如果只做了浅拷贝,修改克隆体的内部对象(如 slice、map)会影响原型。
  2. 克隆不可变对象:单例对象不应该被克隆,否则会出现多个"同一个"单例。
  3. 忘记处理引用类型:原型中的指针、切片、映射等引用类型,克隆时需要特别处理。

5. 单例模式(Singleton)

为什么需要这个模式?

有些系统在游戏服务器中全局只需要一个实例:日志系统、配置管理器、数据库连接池、全局事件总线。如果每个模块都自己创建一个日志实例,就会出现多个日志文件、配置不一致、连接池资源浪费等问题。

单例模式解决的核心问题是:确保一个类只有一个实例,并提供全局访问点。 但 Nystrom 对单例模式的态度是谨慎的。他在书中明确指出:单例模式被过度使用了,它本质上是全局状态的一种伪装。

Nystrom 的核心洞察

Nystrom 的警告非常直接:单例模式的问题不在于"只有一个实例",而在于"全局可访问"。 全局状态让代码的依赖关系变得不透明——你看到一个函数调用,不知道它内部访问了哪些全局状态。这让调试变得极其困难。

Nystrom 建议的替代方案是依赖注入(Dependency Injection):把需要的依赖作为参数传入,而不是从全局获取。这样代码的依赖关系是显式的,测试时可以轻松替换依赖。

但在游戏服务器中,有些系统确实是全局唯一的(如数据库连接池),此时单例是合理的。关键是:用依赖注入的方式注入这个单例,而不是让代码自己去获取单例

游戏服务器的真实场景

配置管理器:整个服务器只需要一个配置实例,所有模块共享。但更好的做法是:启动时创建配置实例,通过依赖注入传递给需要的模块。

数据库连接池:全局唯一的连接池,所有业务逻辑共享。这是合理的单例使用场景。

排行榜服务:全局唯一的排行榜服务,所有玩家的排名查询都通过它。但要注意线程安全——多个玩家同时更新排名时,需要有锁保护。

什么时候不该用?

  • 你只是想方便地全局访问某个对象(用依赖注入更好)
  • 对象需要在测试中被替换(单例很难 mock)
  • 对象有状态需要重置(单例的状态在测试间会泄露)

代码示例:合理的单例用法

go
// 不好的方式:直接全局访问
func ProcessPlayer(player *Player) {
    db := GetGlobalDB()  // 隐式依赖,测试时无法替换
    db.Save(player)
}

// 好的方式:依赖注入
type Server struct {
    db     Database
    config *Config
    logger Logger
}

func (s *Server) ProcessPlayer(player *Player) {
    s.db.Save(player)  // 显式依赖,测试时可以注入 mock
}

常见错误

  1. 滥用全局状态:把所有服务都做成单例,导致代码变成一坨全局状态的集合。
  2. 忘记线程安全:在多线程环境下访问单例,没有加锁保护。
  3. 在测试中无法替换:单例一旦创建就无法替换,导致单元测试依赖真实数据库。

6. 状态模式(State)

为什么需要这个模式?

Nystrom 在书中用了一个非常经典的例子来说明状态模式的必要性:假设你在开发一款横版过关游戏,主角可以站立、跳跃、下蹲、俯冲。如果用布尔标志来管理状态:

go
isJumping, isDucking, isCharging bool

你很快就会发现布尔标志的组合爆炸问题——isJumpingisDucking 同时为 true 是什么情况?isChargingisJumping 同时为 true 呢?每增加一个新状态,你需要检查所有现有状态的组合是否合法。

Nystrom 用了一个绝妙的比喻:有限状态机(FSM)就像老式文字冒险游戏《Zork》中的房间导航。每个房间是一个状态,房间的出口是状态转移,玩家的移动命令是输入。你不可能同时在两个房间里——同样,角色也不可能同时处于"跳跃"和"站立"状态。

状态模式解决的核心问题是:用有限状态机替代大量的 if-else 和布尔标志,让状态管理变得清晰、可扩展。

Nystrom 的核心洞察

Nystrom 介绍了三个层次的状态机实现:

  1. 枚举 + Switch:最简单,适合状态少、转移逻辑简单的场景。
  2. 状态类(State Pattern):每个状态是一个类,状态转移通过切换当前状态对象实现。适合状态有各自独立行为的场景。
  3. 层级状态机(HFSM):状态可以嵌套。比如"战斗"状态可以包含"攻击"、"防御"、"施法"子状态。

Nystrom 特别强调了状态转移的合法性:状态机的最大价值不是减少代码量,而是让"哪些转移是合法的"变得一目了然。在枚举+switch的方式中,你可以清楚地看到每个状态下可以接受哪些输入、转移到哪些状态。

游戏服务器的真实场景

挂机游戏中的角色状态:角色有"空闲"、"战斗"、"休息"、"副本"、"离线"等状态。每种状态下的行为完全不同:空闲时可以接收新任务,战斗中不能接受组队邀请,离线时按离线收益规则计算资源。

自走棋中的棋子状态:每个棋子有"待选"、"备战区"、"棋盘上"、"已死亡"、"已出售"等状态。状态转移有严格规则:只有"待选"状态的棋子可以被购买,"棋盘上"的棋子死亡后进入"已死亡"状态,"已死亡"的棋子在回合结束后复活。

卡牌游戏中的卡牌状态:卡牌在"牌库"、"手牌"、"场上"、"墓地"、"除外"之间转移。某些状态转移有严格限制:从手牌打出到场上需要消耗法力值,从墓地复活可能需要特定技能。

什么时候不该用?

  • 状态只有 2-3 个,用简单的 if-else 就够了
  • 状态转移逻辑极其复杂,状态机本身会变成一团乱麻
  • 你需要频繁添加新状态,但不想修改现有代码(这时考虑状态表驱动的方式)

代码示例:挂机游戏角色状态

go
type PlayerState string

const (
    StateIdle    PlayerState = "idle"
    StateBattle  PlayerState = "battle"
    StateRest    PlayerState = "rest"
    StateOffline PlayerState = "offline"
)

// 状态转移表:从哪个状态可以转移到哪个状态
var transitions = map[PlayerState][]PlayerState{
    StateIdle:    {StateBattle, StateRest, StateOffline},
    StateBattle:  {StateIdle, StateRest},
    StateRest:    {StateIdle, StateBattle},
    StateOffline: {StateIdle},
}

func (p *Player) TransitionTo(newState PlayerState) bool {
    for _, valid := range transitions[p.State] {
        if valid == newState {
            p.State = newState
            return true
        }
    }
    return false  // 非法转移
}

常见错误

  1. 状态爆炸:为每个微小差异都创建一个新状态,导致状态数量失控。应该合并相似状态。
  2. 忘记处理非法输入:状态机遇到不合法的输入时应该有明确的处理(忽略、报错、转到默认状态),而不是静默忽略。
  3. 状态转移时忘记清理:从"战斗"状态切换到"休息"状态时,忘记清除战斗相关的临时数据。
  4. 层级过深:层级状态机嵌套超过 3-4 层就很难维护了,考虑拆分为多个独立的状态机。

第三部分:序列型模式

这三个模式关注的是游戏执行的时间维度——游戏如何随时间推进,以及如何管理随时间变化的数据。


7. 双缓冲(Double Buffer)

为什么需要这个模式?

假设你在开发一款 MMO 游戏。游戏世界中有一万个实体,它们的位置、状态在每一帧都在变化。如果在同一帧内,你一边更新实体的位置,一边把实体的位置发送给客户端,就会出现撕裂(Tearing)——部分实体已经更新到新位置,部分实体还在旧位置。客户端收到的世界状态是不一致的。

双缓冲模式解决的核心问题是:读写同时进行时的数据一致性。 Nystrom 用了一个非常直观的比喻:想象你在画画,同时有人在拍照。如果画还没画完就拍了,照片里的画面就是半成品。解决方案是准备两块画布——画师在一块上画,摄影师只拍另一块。画完后交换两块画布的位置。

Nystrom 的核心洞察

Nystrom 指出,双缓冲在游戏开发中有两个核心应用场景:

  1. 渲染双缓冲:GPU 在后台缓冲区渲染下一帧,前台缓冲区显示当前帧。渲染完成后交换缓冲区,避免画面撕裂。
  2. 逻辑双缓冲:游戏逻辑在"写缓冲区"更新状态,网络层从"读缓冲区"发送数据。一帧结束后交换缓冲区。

关键在于交换时机:需要在所有写操作完成后、所有读操作开始前完成交换。如果交换时机不对,就会出现一帧的延迟或数据不一致。

游戏服务器的真实场景

帧同步(Lockstep)游戏:在自走棋中,每个回合开始前,所有玩家的棋子位置锁定在"读缓冲区"。回合逻辑在"写缓冲区"中计算所有棋子的移动和战斗。回合结束后交换缓冲区,客户端从新的读缓冲区获取最终状态。

MMO 中的世界状态快照:服务器维护两个世界状态副本。游戏逻辑在副本 A 中更新,AOI 系统从副本 B 中读取位置信息计算视野。帧结束后交换。这确保了视野计算使用的是上一帧的一致状态。

什么时候不该用?

  • 读写操作本身是线程安全的(如使用原子操作)
  • 只有单线程读写,不需要保护
  • 内存紧张,无法承受双倍的状态存储

代码示例:世界状态双缓冲

go
type DoubleBuffer struct {
    current *WorldState  // 当前帧(读)
    next    *WorldState  // 下一帧(写)
}

func (db *DoubleBuffer) GetState() *WorldState {
    return db.current  // 读操作:总是读 current
}

func (db *DoubleBuffer) Update(fn func(*WorldState)) {
    fn(db.next)  // 写操作:总是写 next
}

func (db *DoubleBuffer) Swap() {
    db.current, db.next = db.next, db.current  // 交换
}

常见错误

  1. 交换时机错误:在写操作还未完成时就交换,导致读到不完整的数据。
  2. 忘记交换:写了一帧但忘了交换,导致读到的永远是旧数据。
  3. 在写缓冲区上执行读操作:违反了双缓冲的基本约束。

8. 游戏循环(Game Loop)

为什么需要这个模式?

Nystrom 在书中说:"如果这本书只能有一个模式,那就是游戏循环。" 游戏循环是所有实时游戏的心跳——它控制着游戏的每一帧如何推进。

普通程序的执行流程是:输入 → 处理 → 输出 → 结束。但游戏不能结束(除非玩家退出),它需要不断地:处理输入 → 更新游戏状态 → 渲染画面。这个无限循环就是游戏循环。

游戏循环解决的核心问题是:如何让游戏以稳定的节奏推进,不受硬件性能和用户输入速度的影响。 Nystrom 指出了最关键的挑战——帧率不稳定。同一段代码在高端 PC 上可能 16ms 完成一帧(60fps),在低端手机上可能 32ms 一帧(30fps)。如果游戏逻辑直接和帧率绑定,游戏在快机器上会加速,在慢机器上会减速。

Nystrom 的核心洞察

Nystrom 介绍了三种主要的游戏循环变体:

固定时间步长(Fixed Time Step):游戏逻辑以固定的时间间隔更新(如每秒 20 次)。如果一帧实际耗时 50ms,就执行 1 次逻辑更新(20ms 的步长),剩余 30ms 累积到下一帧。在很多项目中,这算是比较常用的方式,因为它保证了物理模拟的确定性。

可变时间步长(Variable Time Step):游戏逻辑用实际的帧间隔作为步长。Nystrom 强烈反对这种方式,因为物理公式中有二次项(加速度 × 时间²),时间步长的微小变化会导致完全不同的模拟结果。一个球在 16ms 内下落 1 像素,在 32ms 内下落的不是 2 像素,而是 4 像素。

半固定时间步长(Semi-fixed):逻辑更新用固定步长,渲染尽可能快地执行。这是折中方案,适合对帧率要求不高的游戏。

Nystrom 还提到了死亡螺旋(Spiral of Death):当一帧处理时间过长时,累积器积压了大量未处理的时间,导致下一帧需要执行更多次逻辑更新,进一步延长处理时间,形成恶性循环。解决方案是限制累积器的最大值。

游戏服务器的真实场景

服务端游戏循环:与客户端不同,服务端不需要渲染,但需要以固定的 tick rate 执行游戏逻辑(如每秒 20 次)。每次 tick 需要:收集玩家输入 → 处理游戏逻辑 → 同步状态给客户端。

挂机游戏的离线结算:玩家离线 8 小时,服务端需要模拟 8 小时的游戏进程。如果用固定时间步长(每步 1 秒),需要执行 28800 次逻辑更新。这时需要"加速模拟"——跳过不必要的计算,只处理关键事件。

自走棋的回合循环:每个回合有固定的时间限制(如 30 秒准备阶段)。服务端需要在回合结束时精确地执行所有棋子的战斗逻辑。

什么时候不该用?

  • 纯事件驱动的程序(如 Web 服务),不需要持续运行的循环
  • 批处理程序,输入→处理→输出→结束
  • Turn-based 游戏的客户端,不需要持续循环(但服务端仍然需要)

代码示例:固定时间步长的服务端循环

go
func (s *Server) Run() {
    ticker := time.NewTicker(50 * time.Millisecond)  // 20 TPS
    defer ticker.Stop()
    accumulator := time.Duration(0)

    for {
        now := <-ticker.C
        accumulator += now.Sub(s.lastTick)
        s.lastTick = now

        // 限制累积量,防止死亡螺旋
        if accumulator > 200*time.Millisecond {
            accumulator = 200*time.Millisecond
        }

        for accumulator >= 50*time.Millisecond {
            s.processInputs()       // 处理玩家输入
            s.updateGameLogic()     // 更新游戏逻辑
            s.syncToClients()       // 同步到客户端
            accumulator -= 50*time.Millisecond
        }
    }
}

常见错误

  1. 使用可变时间步长:导致物理模拟不稳定,尤其在帧率波动大时。
  2. 忘记处理死亡螺旋:服务器负载突增时,tick 处理时间超过 tick 间隔,游戏逻辑越积越多,最终崩溃。
  3. tick rate 选择不当:tick rate 太低导致操作延迟高,太高导致 CPU 开销大。一般 15-30 TPS 适合大多数游戏。
  4. 在 tick 中做阻塞操作:如果在游戏逻辑更新中做了数据库查询或网络 IO,会阻塞整个 tick。

9. 更新方法(Update Method)

为什么需要这个模式?

游戏世界中有成千上万个对象——怪物、子弹、特效、粒子、NPC。每个对象每帧都需要更新自己的状态。如果在游戏循环中手动调用每个对象的更新方法:

go
for _, monster := range monsters { monster.Update() }
for _, bullet := range bullets { bullet.Update() }
for _, effect := range effects { effect.Update() }

代码变得冗长且脆弱——每次添加新类型的对象,都要修改游戏循环。

更新方法模式解决的核心问题是:让每个对象自己知道如何更新自己,游戏循环只需要遍历所有对象并调用它们的 Update 方法。 Nystrom 指出,这本质上是将"更新逻辑"分散到各个对象中,而不是集中在一个巨大的 switch-case 中。

Nystrom 的核心洞察

Nystrom 强调,更新方法模式的关键价值是扩展性:添加新类型的对象时,不需要修改游戏循环。你只需要创建新的类型并实现 Update 方法,然后把它加入对象列表。

但 Nystrom 也警告了性能陷阱:每个对象每帧都调用一次 Update,即使它什么也没做。对于一万个对象,这意味着一万个虚函数调用。在性能敏感的场景中,应该考虑只更新活跃的对象(如使用脏标记模式)。

游戏服务器的真实场景

塔防游戏中的子弹系统:每帧需要更新所有飞行中的子弹位置,检查碰撞。每个子弹自己知道如何移动(直线、追踪、弹道),游戏循环只需要遍历所有子弹并调用 Update。

自走棋中的棋子 AI:每帧需要更新所有棋子的 AI 决策(寻找目标、选择技能、移动)。每个棋子的 AI 是独立的,游戏循环不需要知道棋子的具体类型。

MMO 中的实体更新:每帧需要更新所有在线玩家的位置同步、怪物 AI、NPC 行为。通过更新方法模式,每个实体类型自己实现 Update 逻辑。

什么时候不该用?

  • 对象数量极少,手动调用更简单
  • 对象的更新逻辑高度同质化,可以用一个统一的函数处理所有对象
  • 性能要求极高,需要避免虚函数调用开销

代码示例:简洁的更新列表

go
// 可更新的接口
type Updatable interface {
    Update(dt float64)
}

// 游戏世界持有所有可更新对象
type World struct {
    entities []Updatable
}

func (w *World) Update(dt float64) {
    for _, e := range w.entities {
        e.Update(dt)  // 每个对象自己知道怎么更新
    }
}

常见错误

  1. 在 Update 中做重操作:Update 方法应该尽量轻量,避免在其中做 IO、复杂计算。
  2. 忘记移除已销毁的对象:对象被销毁后仍然在更新列表中,导致空指针或逻辑错误。
  3. 更新顺序依赖:假设对象的更新顺序是固定的,但实际上是不确定的。

第四部分:行为型模式

这三个模式关注的是游戏对象的行为如何被定义和执行——特别是当行为需要在运行时改变或由数据驱动时。


10. 字节码(Bytecode)

为什么需要这个模式?

在传统游戏开发中,AI 行为用代码写死在程序中。策划要调整怪物的 AI,需要让程序员修改代码、重新编译、重新部署。这个流程太慢了。

字节码模式解决的核心问题是:把游戏行为(AI、技能、任务流程)从编译型代码中解放出来,变成可以动态加载、修改的数据。 Nystrom 指出,这本质上是构建一个小型虚拟机——定义一套指令集,把行为编译成指令序列,然后在运行时解释执行。

这个模式与"脚本语言"的核心区别在于:脚本语言是一门完整的编程语言(如 Lua),而字节码模式通常是一套精简的、针对特定领域的指令集。它更安全(不能执行任意代码)、更可预测(指令集有限)、更容易审计。

Nystrom 的核心洞察

Nystrom 强调了字节码模式的两个核心价值:

  1. 热更新:修改字节码不需要重新编译游戏服务器。策划可以在不重启服务的情况下调整 AI 行为。
  2. 安全性:字节码在一个受控的虚拟机中执行,不能访问系统资源。这对于链游尤其重要——链上执行的逻辑需要是安全的、可验证的。

Nystrom 还指出了字节码模式的缺点:性能开销。解释执行比原生代码慢得多。但对于 AI、技能逻辑等非性能关键路径,这个开销是可以接受的。

游戏服务器的真实场景

链游(Blockchain)中的智能合约:链游的核心逻辑运行在智能合约上,智能合约本质上就是字节码。所有玩家的行为通过智能合约执行,确保规则的透明性和不可篡改性。

塔防游戏中的技能系统:每种炮塔的技能效果用字节码定义。策划可以在配置文件中定义"发射 3 枚导弹,每枚造成 50 点伤害,优先攻击血量最低的目标",服务器解释执行这些指令。

MMO 中的任务系统:任务流程(接取条件、完成条件、奖励发放)用字节码定义。新任务只需要添加新的字节码序列,不需要修改服务器代码。

什么时候不该用?

  • 行为逻辑是固定的,不需要在运行时修改
  • 性能要求极高,解释执行的开销不可接受
  • 行为逻辑非常简单,直接用代码写更清晰

代码示例:简单的技能字节码

go
// 字节码指令
type Instruction struct {
    Op   Opcode
    Args []interface{}
}

// 操作码
const (
    OpSpawnBullet  Opcode = iota  // 生成子弹
    OpDamage                       // 造成伤害
    OpHeal                         // 治疗
    OpBuff                         // 添加buff
)

// 技能定义:一条指令序列
var FireSkill = []Instruction{
    {Op: OpSpawnBullet, Args: []interface{}{"fireball", 3}},
    {Op: OpDamage, Args: []interface{}{50}},
}

// 虚拟机执行
func Execute(instructions []Instruction, caster *Unit) {
    for _, inst := range instructions {
        switch inst.Op {
        case OpSpawnBullet:
            spawnBullet(caster, inst.Args[0].(string), inst.Args[1].(int))
        case OpDamage:
            dealDamage(caster, inst.Args[0].(int))
        }
    }
}

常见错误

  1. 指令集设计不当:指令太粗粒度,灵活性不够;太细粒度,解释器复杂度高。
  2. 没有沙箱保护:字节码可以访问外部资源(文件、网络),导致安全漏洞。
  3. 性能瓶颈:在热路径(如每帧执行的 AI)中使用字节码,解释执行的开销成为瓶颈。

11. 子类沙盒(Subclass Sandbox)

为什么需要这个模式?

假设你在开发一款 MMO 游戏,有几百种怪物。每种怪物都有独特的技能和行为。如果每个怪物类都直接调用底层 API(播放动画、生成特效、播放音效、修改状态):

go
class Goblin : Monster {
    void useSpecialAbility() {
        graphics.playAnimation("fireball");
        audio.playSound("explosion");
        world.spawnParticle("fire", position);
        target.takeDamage(50);
    }
}

当底层 API 变化时(比如 graphics 改名为 renderer),你需要修改所有怪物类。

子类沙盒模式解决的核心问题是:把底层 API 封装在基类中,子类只能通过基类提供的"沙盒"方法来访问底层系统。 Nystrom 的比喻是:就像一个沙盒游戏——你可以在里面自由玩耍,但不能翻出沙盒去碰外面的东西。

Nystrom 的核心洞察

Nystrom 指出,子类沙盒模式的关键价值是控制依赖。基类决定了子类可以使用哪些底层能力,子类只能在这些能力的范围内实现自己的行为。这有两个好处:

  1. API 稳定性:底层 API 的变化只需要修改基类,子类不受影响。
  2. 行为约束:子类不能做超出沙盒范围的事情(比如直接修改数据库),所有操作都通过基类的受控接口进行。

Nystrom 强调,这个模式与"模板方法模式"有相似之处,但区别在于:模板方法定义了算法的骨架,子类填充细节;子类沙盒提供了能力集合,子类自由组合这些能力。

游戏服务器的真实场景

塔防游戏中的炮塔技能:每种炮塔的技能不同(单体攻击、范围攻击、减速、治疗),但它们都需要调用"生成子弹"、"播放音效"、"应用 debuff"等底层能力。把能力封装在基类中,每种炮塔只需要组合这些能力。

自走棋中的棋子技能:每个棋子的技能效果不同,但底层能力是有限的(生成弹道、造成伤害、添加 buff、召唤单位)。通过子类沙盒,策划可以安全地定义新技能。

链游中的合约行为:链上合约的行为受限于预定义的操作(转账、铸造 NFT、修改状态),不能执行任意代码。这本质上就是子类沙盒的思想——合约只能在沙盒允许的范围内操作。

什么时候不该用?

  • 类的层次结构很简单,只有 1-2 个子类
  • 底层 API 很稳定,不太可能变化
  • 需要的高度灵活性超过了沙盒能提供的

代码示例:怪物技能沙盒

go
// 基类:定义沙盒能力
type MonsterBase struct {
    Position  Vector2
    Target    *Unit
}

// 沙盒方法:子类只能通过这些方法与世界交互
func (m *MonsterBase) SpawnBullet(name string, target *Unit, dmg int) {
    bullet := NewBullet(m.Position, target, name, dmg)
    world.AddBullet(bullet)
}

func (m *MonsterBase) ApplyBuff(target *Unit, buff Buff) {
    target.AddBuff(buff)
}

// 子类:在沙盒内自由组合
type Goblin struct{ MonsterBase }

func (g *Goblin) SpecialAbility() {
    // 只能调用沙盒方法,不能直接操作底层系统
    g.SpawnBullet("fireball", g.Target, 30)
    g.ApplyBuff(g.Target, Buff{Type: "burn", Duration: 3})
}

常见错误

  1. 沙盒太宽泛:提供了太多底层能力,子类可以做危险的事情。
  2. 沙盒太狭窄:限制太多,子类无法实现需要的行为,不得不绕过沙盒。
  3. 基类变得臃肿:随着子类需求增加,基类的沙盒方法越来越多,最终变成一个 God Class。

12. 类型对象(Type Object)

为什么需要这个模式?

在游戏开发中,你经常需要创建大量"同类型但不同属性"的对象。比如卡牌游戏中有几百种卡牌,每种卡牌有不同的攻击力、防御力、技能、描述。如果每种卡牌都创建一个类:

go
class FireballCard : Card { ... }
class IceBlastCard : Card { ... }
class ThunderStrikeCard : Card { ... }
// ... 300 个类

这显然不可行——你不可能为每种卡牌写一个类。

类型对象模式解决的核心问题是:用"对象"代替"类"来表示类型差异。 Nystrom 指出,这本质上是在运行时构建类型系统。不是在编译时定义 300 个类,而是在运行时从配置数据创建 300 个类型对象。每个游戏实体持有一个"类型对象"的引用,类型对象定义了这个实体的属性和行为。

Nystrom 的核心洞察

Nystrom 强调了类型对象模式与继承的区别:

  • 继承:在编译时确定类型层次,修改类型需要重新编译。
  • 类型对象:在运行时从数据创建类型,修改类型只需要修改配置文件。

Nystrom 的比喻是:类型对象就像模具。一个模具可以生产无数个相同形状的产品,但每个产品的颜色、材质可以不同。修改模具的形状,就改变了所有产品的形状。

游戏服务器的真实场景

卡牌游戏中的卡牌定义:每种卡牌用一个 JSON 配置定义属性,服务器运行时加载配置并创建类型对象。新卡牌只需要添加 JSON 文件,不需要改代码。

自走棋中的棋子模板:每种棋子的属性、技能、羁绊效果存储在配置中。通过类型对象模式,策划可以在不改代码的情况下添加新棋子。

塔防游戏中的炮塔配置:每种炮塔的攻击力、射程、攻速、特殊效果存储在配置中。通过类型对象模式,可以在运行时热更新炮塔属性。

什么时候不该用?

  • 类型数量很少(<10),直接用类继承更清晰
  • 类型的行为差异很大,无法用数据驱动
  • 需要编译时类型检查(类型对象在运行时解析,编译器无法检查)

代码示例:卡牌类型对象

go
// 类型对象:定义卡牌属性
type CardType struct {
    Name    string
    ATK     int
    DEF     int
    Cost    int
    Effect  string  // 效果描述
}

// 实例:持有类型引用
type Card struct {
    Type    *CardType  // 共享类型信息
    Level   int        // 独立状态
    Owner   *Player
}

// 从配置创建类型对象
func LoadCardTypes(path string) map[string]*CardType {
    data, _ := os.ReadFile(path)
    var types map[string]*CardType
    json.Unmarshal(data, &types)
    return types
}

常见错误

  1. 类型对象过于复杂:把太多行为逻辑放在类型对象中,导致类型对象变成 God Object。
  2. 混淆类型和实例:把应该属于实例的状态(如当前血量)放在类型对象中。
  3. 缺少验证:从配置加载类型对象时没有验证数据的合法性,导致运行时错误。

第五部分:解耦型模式

这三个模式关注的是如何让游戏系统的各个部分松耦合地协作——系统之间通过抽象接口交互,而不是直接依赖具体实现。


13. 组件模式(Component)

为什么需要这个模式?

在传统面向对象设计中,游戏实体用继承来组织:

Entity
├── Character
│   ├── Player
│   ├── NPC
│   └── Monster
├── Item
│   ├── Weapon
│   └── Potion
└── Projectile

问题很快暴露出来:一个"会飞的宝箱"应该继承 Projectile 还是 Item?一个"会施法的 NPC"应该继承 Character 还是拥有 Spellcaster 能力?继承层次越深,修改基类的影响范围越大。

组件模式解决的核心问题是:用"组合"替代"继承"来构建复杂的游戏实体。 Nystrom 指出,这与"组合优于继承"的设计原则一脉相承,但组件模式在游戏中的应用更加彻底——每个游戏实体本质上是一个"空壳",它的行为完全由它挂载的组件决定。

Nystrom 的核心洞察

Nystrom 强调了组件模式的两个核心价值:

  1. 灵活性:同一个实体可以拥有不同的组件组合。一个 NPC 可以有"战斗组件"变成战士,有"对话组件"变成商人,同时有两者变成战斗商人。
  2. 数据驱动:组件可以从配置文件加载。策划可以在不改代码的情况下,通过配置文件定义新类型的实体。

Nystrom 也指出了组件模式的缺点:间接层增加。访问组件需要通过实体查找,比直接调用方法慢。在性能敏感的场景中(如每帧更新上万个实体),这个开销可能成为问题。

游戏服务器的真实场景

MMO 中的实体系统:玩家、怪物、NPC 都是实体,它们的差异由组件决定。玩家有"背包组件"、"任务组件"、"社交组件";怪物有"AI 组件"、"战斗组件";NPC 有"对话组件"、"商店组件"。通过组合不同的组件,可以用少量的基础类型构建出丰富的实体类型。

自走棋中的棋子能力:每个棋子的能力由组件决定。"攻击组件"定义普攻逻辑,"技能组件"定义主动技能,"羁绊组件"定义种族/职业效果。通过组合不同的组件,策划可以创造新棋子。

塔防游戏中的炮塔类型:每种炮塔由"攻击组件"、"特效组件"、"范围组件"组合而成。通过替换组件,可以在不改代码的情况下改变炮塔行为。

什么时候不该用?

  • 实体类型很少且固定,继承层次简单
  • 性能要求极高,组件查找的开销不可接受
  • 团队不熟悉组件化架构,学习成本高

代码示例:组件化实体

go
// 组件接口
type Component interface {
    Update(dt float64)
    Entity() *Entity
}

// 实体:组件容器
type Entity struct {
    id         uint64
    components map[reflect.Type]Component
}

func (e *Entity) AddComponent(c Component) {
    e.components[reflect.TypeOf(c)] = c
}

func (e *Entity) GetComponent(cType reflect.Type) Component {
    return e.components[cType]
}

// 具体组件:战斗
type CombatComponent struct {
    entity *Entity
    ATK    int
    DEF    int
}

func (c *CombatComponent) Update(dt float64) {
    // 战斗逻辑
}

// 使用:组合组件构建实体
player := NewEntity()
player.AddComponent(&CombatComponent{ATK: 100, DEF: 50})
player.AddComponent(&InventoryComponent{Capacity: 20})

常见错误

  1. 组件之间产生循环依赖:组件 A 调用组件 B,组件 B 又调用组件 A。
  2. 组件粒度不当:组件太细(每个属性一个组件),导致实体挂载几十个组件;组件太粗,失去灵活性。
  3. 过度工程化:对于简单游戏,组件模式增加的复杂度远超它带来的好处。

14. 事件队列(Event Queue)

为什么需要这个模式?

在游戏服务器中,不同的系统需要相互通信:网络层收到玩家操作后需要通知逻辑层,逻辑层处理完后需要通知网络层发送结果,战斗系统需要通知 UI 系统更新显示。如果所有通信都是直接调用:

网络层 → 逻辑层 → 战斗系统 → UI系统
网络层 → 经济系统 → 数据库
网络层 → 社交系统 → 好友系统

系统之间形成了复杂的调用图,任何一个系统的延迟都会阻塞整个调用链。

事件队列解决的核心问题是:将同步调用变为异步通信,让系统之间通过消息传递解耦。 Nystrom 指出,事件队列是观察者模式的异步版本——观察者是同步通知(调用者等待所有观察者处理完毕),事件队列是异步通知(调用者把事件放入队列后立即返回,消费者在自己的时间处理)。

Nystrom 的核心洞察

Nystrom 强调了事件队列的几个关键优势:

  1. 解耦生产者和消费者:生产者不需要知道谁在消费事件,甚至不需要知道消费者是否存在。
  2. 缓冲突发流量:当一瞬间产生大量事件时(如百人团战),队列可以缓冲这些事件,避免消费者被压垮。
  3. 控制处理顺序:队列保证 FIFO 顺序,消费者可以按顺序处理事件。

但 Nystrom 也指出了事件队列的陷阱

  • 延迟增加:事件不是立即处理的,而是等待消费者轮询。这增加了响应延迟。
  • 调试困难:事件的生产者和消费者在不同的时间和代码路径中执行,追踪事件流比追踪直接调用困难得多。
  • 内存压力:大量事件堆积在队列中,消耗内存。

游戏服务器的真实场景

帧同步游戏的输入收集:所有玩家的操作通过事件队列收集,在每个 tick 开始时批量处理。这避免了玩家操作到达时间不一致导致的同步问题。

MMO 的世界事件:玩家击杀 Boss、完成任务、交易物品等事件放入全局事件队列。各系统(成就、排行榜、邮件)异步消费这些事件,互不阻塞。

链游的链上交互:玩家的链上操作(铸造 NFT、转移资产)通过事件队列发送到链上合约。队列缓冲了突发的链上请求,避免 RPC 节点被压垮。

什么时候不该用?

  • 需要立即处理的事件(如同步的攻击判定)
  • 事件量极少,队列的开销不值得
  • 需要获取处理结果(事件队列是单向通信)

代码示例:线程安全的事件队列

go
type EventQueue struct {
    ch chan Event  // 有界 channel 作为队列
}

func NewEventQueue(capacity int) *EventQueue {
    return &EventQueue{ch: make(chan Event, capacity)}
}

// 生产者:非阻塞发送
func (eq *EventQueue) Push(e Event) bool {
    select {
    case eq.ch <- e:
        return true
    default:
        return false  // 队列满了,丢弃事件
    }
}

// 消费者:阻塞接收
func (eq *EventQueue) Pop() Event {
    return <-eq.ch
}

常见错误

  1. 队列无界增长:消费者处理速度跟不上生产者,队列无限膨胀导致 OOM。
  2. 事件丢失:队列满了后丢弃事件,没有记录日志或重试机制。
  3. 死锁:消费者在处理事件时又往队列中推事件,导致队列满后互相阻塞。
  4. 顺序依赖:假设事件的处理顺序和入队顺序一致,但多消费者并发处理时顺序可能被打乱。

15. 服务定位器(Service Locator)

为什么需要这个模式?

游戏服务器中有许多全局性服务:日志、配置、数据库、缓存、消息队列。每个业务模块都需要使用这些服务。如果每个模块都自己创建服务实例:

go
func handlePlayerLogin(playerID uint64) {
    db := NewMySQLConnection()       // 每次都创建新连接
    cache := NewRedisClient()        // 每次都创建新连接
    log := NewFileLogger()           // 每次都创建新文件
    // ...
}

这显然不可行——资源浪费且性能极差。

服务定位器解决的核心问题是:提供一个全局的服务注册和查找机制,让代码可以按名称获取服务实例,而不需要知道服务的具体实现。 Nystrom 用了一个优美的比喻:与其给一百个人你的家庭地址,不如给他们一个电话簿的条目。当你搬家时,只需要更新电话簿,所有人都自动获得新地址。

Nystrom 的核心洞察

Nystrom 对服务定位器的态度是谨慎推荐。他指出了服务定位器的几个问题:

  1. 隐藏依赖:函数签名中看不出它需要哪些服务。processPlayer() 内部可能依赖数据库、缓存、日志三个服务,但调用者完全不知道。
  2. 运行时错误:如果忘记注册某个服务,编译时不会报错,运行时才会 panic。
  3. 全局状态:服务定位器本质上是全局状态的容器,只是比单例更灵活。

Nystrom 建议:优先使用依赖注入(DI),只在确实需要全局访问时才用服务定位器。 在 Go 语言中,通常通过 context.Context 或结构体字段注入依赖,而不是用全局服务定位器。

游戏服务器的真实场景

微服务架构的游戏后端:每个微服务(登录服务、匹配服务、战斗服务)通过服务发现机制(如 Consul、Etcd)注册和查找。客户端不需要知道服务的具体地址,只需要通过服务名称查找。

本地服务容器:游戏服务器内部的日志、配置、数据库连接池通过服务容器管理。启动时注册所有服务,运行时按名称查找。

测试时的服务替换:在单元测试中,可以通过服务定位器注入 mock 服务,替换真实的数据库和网络连接。

什么时候不该用?

  • 可以通过依赖注入解决(参数传递、结构体字段)
  • 服务数量很少,直接传递引用更简单
  • 需要编译时的依赖检查

代码示例:简洁的服务容器

go
// 服务容器
type ServiceContainer struct {
    mu       sync.RWMutex
    services map[string]interface{}
}

var container = &ServiceContainer{
    services: make(map[string]interface{}),
}

func Register(name string, svc interface{}) {
    container.mu.Lock()
    defer container.mu.Unlock()
    container.services[name] = svc
}

func GetService(name string) interface{} {
    container.mu.RLock()
    defer container.mu.RUnlock()
    return container.services[name]
}

// 使用
Register("db", NewDatabase())
Register("cache", NewCache())

db := GetService("db").(Database)

常见错误

  1. 服务名称硬编码:用字符串作为服务名,拼写错误只在运行时发现。
  2. 忘记注册服务:启动时忘记注册某个服务,运行时 panic。
  3. 循环依赖:服务 A 依赖服务 B,服务 B 又依赖服务 A。
  4. 全局状态泛滥:把所有东西都注册为服务,导致代码变成一堆全局访问。

第六部分:优化型模式

这三个模式关注的是性能优化——当游戏遇到性能瓶颈时,如何用特定的技术手段提升效率。


16. 数据局部性(Data Locality)

为什么需要这个模式?

现代 CPU 的性能瓶颈不在计算速度,而在内存访问速度。CPU 计算一个数值可能只需要 1 纳秒,但从主存中读取一个数值可能需要 100 纳秒。如果数据在内存中散乱分布(如链表、树结构),CPU 每次访问都需要等待内存,导致"CPU 空等数据"。

数据局部性模式解决的核心问题是:让相关数据在内存中连续存储,最大化 CPU 缓存命中率。 Nystrom 用了一个非常生动的比喻:想象你在图书馆找书。如果所有书按主题分类、按书架连续摆放,找到一本后就能很快找到相关的书。但如果书随机散落在图书馆各处,每次找书都要跑遍整个图书馆。

Nystrom 的核心洞察

Nystrom 指出,数据局部性的核心思想是从"对象导向"转向"数据导向"

  • 对象导向:每个对象包含自己的数据和方法。对象在内存中分散存储,CPU 缓存无法有效工作。
  • 数据导向:所有相同类型的数据连续存储在数组中。遍历时,CPU 可以预取下一批数据,大幅减少缓存未命中。

Nystrom 特别强调,这不意味着要放弃面向对象设计。你可以在逻辑上保持面向对象的架构(用组件、接口等),但在物理存储上用数组代替对象图。

游戏服务器的真实场景

自走棋中的棋子批量更新:每帧需要更新所有棋子的位置和状态。如果棋子对象散落在堆内存中,每次访问都需要等待内存。如果把所有棋子的位置数据连续存储在一个数组中,CPU 可以高效地批量读取。

MMO 中的实体同步:每帧需要同步所有玩家的位置给 AOI 系统。把所有玩家的位置数据存储在连续数组中,可以批量读取和计算。

塔防游戏中的子弹管理:每帧需要更新所有飞行中的子弹。把子弹数据连续存储,可以高效地遍历和碰撞检测。

什么时候不该用?

  • 对象数量很少(<1000),CPU 缓存足够
  • 对象的内存布局已经很紧凑(如简单的 struct)
  • 需要频繁插入和删除对象(数组的插入删除开销大)

代码示例:连续存储 vs 散乱存储

go
// 散乱存储:每个对象独立分配内存
type BadStorage struct {
    entities map[uint64]*Entity  // 随机内存分布
}

// 连续存储:所有数据在一个数组中
type GoodStorage struct {
    positions []Vector3  // 连续内存
    healths   []int      // 连续内存
    count     int
}

// 遍历连续数据:CPU 缓存友好
func (s *GoodStorage) UpdateAll(dt float64) {
    for i := 0; i < s.count; i++ {
        s.positions[i].X += dt
        if s.healths[i] <= 0 {
            s.RemoveAt(i)
        }
    }
}

常见错误

  1. 过早优化:在不需要高性能的场景中引入数据局部性优化,增加代码复杂度。
  2. 混淆逻辑结构和物理结构:逻辑上保持组件化架构,物理上用数组存储。不要为了数据局部性而破坏代码可读性。
  3. 忘记处理删除:从数组中间删除元素会导致后续元素位移,需要特殊处理(如 swap-and-pop)。

17. 脏标记(Dirty Flag)

为什么需要这个模式?

在游戏服务器中,很多计算是"增量"的——只在数据发生变化时才需要重新计算。比如排行榜系统:只有当玩家的分数发生变化时,才需要重新排序。如果每帧都重新排序,大量 CPU 时间被浪费在无变化的数据上。

脏标记模式解决的核心问题是:避免不必要的重复计算。 Nystrom 的比喻是:你不会每分钟都去检查冰箱里的牛奶是否变质。你只在需要喝牛奶时才检查。脏标记就是"是否需要重新检查"的标志——数据变化时标记为"脏",需要时检查脏标记,只有脏了才重新计算。

Nystrom 的核心洞察

Nystrom 指出,脏标记模式的关键权衡是内存 vs 计算

  • 不用脏标记:每帧都重新计算,消耗 CPU 但不需要额外内存。
  • 用脏标记:只在数据变化时重新计算,节省 CPU 但需要维护脏标记状态。

Nystrom 强调,脏标记特别适合以下场景:

  1. 数据变化频率远低于读取频率:排行榜每秒可能被读取 100 次,但分数变化可能每 10 秒才一次。
  2. 重新计算的开销很大:如路径寻找、视野计算、碰撞检测。
  3. 可以接受一帧的延迟:脏标记通常在下一帧才生效,这意味着读取到的数据可能延迟一帧。

游戏服务器的真实场景

挂机游戏中的离线收益:玩家离线期间,不需要每秒都计算收益。只在玩家上线时,根据离线时长一次性计算。脏标记记录"是否有未计算的离线时间"。

自走棋中的羁绊效果:棋盘上的羁绊效果只在棋子变化时(购买、出售、升星)才需要重新计算。每次战斗回合中,如果棋子没有变化,直接使用缓存的羁绊结果。

MMO 中的 AOI 更新:玩家的位置信息只在移动时才需要更新到空间分区中。如果玩家站着不动,不需要每帧都重新计算视野。

什么时候不该用?

  • 数据每帧都变化,脏标记永远不会是 false
  • 重新计算的开销很小,比维护脏标记还便宜
  • 需要实时响应,不能接受一帧的延迟

代码示例:排行榜脏标记

go
type Leaderboard struct {
    entries   []Entry
    dirty     bool  // 脏标记
    sorted    []Entry  // 缓存的排序结果
}

func (lb *Leaderboard) UpdateScore(playerID uint64, score int) {
    for i := range lb.entries {
        if lb.entries[i].PlayerID == playerID {
            lb.entries[i].Score = score
            lb.dirty = true  // 标记为脏
            return
        }
    }
}

func (lb *Leaderboard) GetTop10() []Entry {
    if lb.dirty {
        lb.sorted = sortEntries(lb.entries)  // 只在脏时重新排序
        lb.dirty = false
    }
    return lb.sorted[:10]
}

常见错误

  1. 忘记标记脏:修改数据后忘记设置脏标记,导致读取到过期的缓存数据。
  2. 过早清除脏标记:在计算还未完成时就清除脏标记,导致后续读取使用未完成的结果。
  3. 脏标记粒度不当:全局脏标记太粗(一个字段变化就重算所有),局部脏标记太细(维护成本高)。

18. 对象池(Object Pool)

为什么需要这个模式?

在游戏运行过程中,需要频繁创建和销毁对象:子弹、粒子、伤害数字、网络包。每次创建对象都需要分配内存,每次销毁都需要释放内存。在高频场景下(如每秒生成 1000 颗子弹),内存分配和 GC 压力会成为严重的性能瓶颈。

对象池模式解决的核心问题是:预分配一批对象,重复使用,避免频繁的内存分配和释放。 Nystrom 的比喻是:就像保龄球馆的球——球馆不会每局都制造新球,而是维护一批球,玩家用完后放回球架,下一个人继续用。

Nystrom 的核心洞察

Nystrom 强调了对象池的两个关键使用场景:

  1. GC 敏感的语言:在 Go、Java、C# 等有垃圾回收的语言中,频繁创建和销毁小对象会导致 GC 压力,造成帧率卡顿。对象池减少了 GC 需要追踪的对象数量。
  2. 分配开销大的对象:有些对象的构造函数很重(如数据库连接、网络连接),预创建一批比每次新建更高效。

Nystrom 同时警告:不要盲目使用对象池。对于创建和销毁都很轻量的对象,对象池带来的收益可能不值得它的复杂度。现代 GC 的性能已经很好了,很多时候直接分配比用对象池更简单、更快。

游戏服务器的真实场景

塔防游戏中的子弹管理:每座炮塔每秒发射 1-5 颗子弹,一场游戏可能同时有上千颗子弹飞行。使用对象池避免频繁的内存分配。

MMO 中的网络包缓冲区:每个玩家每秒发送几十个网络包,每个包需要一个缓冲区。使用对象池管理缓冲区,避免 GC 压力。

自走棋中的战斗日志:每回合的战斗可能产生上千条日志条目。使用对象池管理日志对象。

什么时候不该用?

  • 对象创建和销毁很轻量,GC 能很好处理
  • 对象有复杂的重置逻辑,重置成本比创建新对象还高
  • 对象池的管理代码引入的复杂度不值得

代码示例:简单的对象池

go
type Pool struct {
    mu    sync.Mutex
    pool  []interface{}
    New   func() interface{}
    Reset func(interface{})
}

func (p *Pool) Get() interface{} {
    p.mu.Lock()
    defer p.mu.Unlock()
    if len(p.pool) > 0 {
        obj := p.pool[len(p.pool)-1]
        p.pool = p.pool[:len(p.pool)-1]
        return obj
    }
    return p.New()  // 池空了,创建新对象
}

func (p *Pool) Put(obj interface{}) {
    if p.Reset != nil {
        p.Reset(obj)  // 重置对象状态
    }
    p.mu.Lock()
    defer p.mu.Unlock()
    p.pool = append(p.pool, obj)
}

// 使用
bulletPool := &Pool{
    New:   func() interface{} { return &Bullet{} },
    Reset: func(obj interface{}) { obj.(*Bullet).Reset() },
}

常见错误

  1. 忘记归还对象:从池中取出对象后忘记放回,导致池逐渐耗尽。
  2. 对象未重置:归还对象时没有重置状态,导致下次使用时携带了上次的残留数据。
  3. 线程安全问题:多线程同时访问对象池时没有加锁,导致竞态条件。
  4. 池大小不当:池太小,频繁创建新对象;池太大,浪费内存。

19. 空间分区(Spatial Partition)

为什么需要这个模式?

在 MMO 游戏中,地图上有几万个实体(玩家、怪物、NPC、道具)。如果要计算"谁在谁的视野范围内",最简单的方式是两两比较:

go
for a := 0; a < count; a++ {
    for b := a + 1; b < count; b++ {
        if distance(entities[a], entities[b]) < AOI_RADIUS {
            // 在视野内
        }
    }
}

这是 O(n²) 复杂度。当 n=10000 时,每帧需要比较 5000 万次。当 n=50000 时,比较次数达到 12.5 亿次。这显然不可接受。

空间分区模式解决的核心问题是:把 O(n²) 的空间查询降低到 O(n log n) 甚至 O(n)。 Nystrom 用了一个直观的比喻:想象你在图书馆找书。如果没有分类,你只能一本本翻——O(n)。但如果书按主题分类在不同书架上,你只需要去"游戏开发"书架找——O(1) 或 O(log n)。

Nystrom 的核心洞察

Nystrom 介绍了几种常见的空间分区策略:

  1. 网格(Grid):把地图分成等大的格子,每个格子存储其中的实体。查询时只需要检查附近的格子。适合实体分布均匀的场景。
  2. 四叉树(Quadtree):递归地把空间分成四个象限,每个象限再细分。适合实体分布不均匀的场景。
  3. 八叉树(Octree):四叉树的三维版本,适合 3D 游戏。
  4. BVH(Bounding Volume Hierarchy):用层次化的包围盒组织实体。适合需要精确碰撞检测的场景。

Nystrom 强调,选择哪种策略取决于实体的分布特征:如果实体均匀分布(如塔防游戏中的怪物沿固定路线移动),网格最简单高效;如果实体分布不均匀(如 MMO 中玩家集中在城镇),四叉树更合适。

游戏服务器的真实场景

MMO 的 AOI 系统:地图上几万个实体,每个玩家只需要知道视野范围内的其他实体。用网格空间分区,把地图分成 100x100 的格子,每个格子存储其中的实体。玩家查询视野时,只需要检查周围 9 个格子。

自走棋的碰撞检测:棋盘上的棋子在战斗时需要检测技能范围内的目标。用网格分区,每格大小等于技能范围,快速找到范围内的棋子。

塔防游戏的路径规划:怪物需要找到从起点到终点的路径。用网格分区表示地图,A* 算法在网格上搜索路径。

什么时候不该用?

  • 实体数量很少(<100),暴力遍历就够了
  • 实体几乎不动(静态场景),不需要高效的空间查询
  • 查询频率很低(每秒只查询几次),优化收益不大

代码示例:网格空间分区

go
type SpatialGrid struct {
    cellSize float64
    cells    map[int64][]*Entity
}

func (g *SpatialGrid) cellKey(x, y float64) int64 {
    cx := int64(math.Floor(x / g.cellSize))
    cy := int64(math.Floor(y / g.cellSize))
    return cx*100000 + cy
}

func (g *SpatialGrid) Insert(e *Entity) {
    key := g.cellKey(e.X, e.Y)
    g.cells[key] = append(g.cells[key], e)
}

// 查询:只检查附近 9 个格子
func (g *SpatialGrid) Query(x, y, radius float64) []*Entity {
    var result []*Entity
    minCx := int64(math.Floor((x - radius) / g.cellSize))
    maxCx := int64(math.Floor((x + radius) / g.cellSize))
    minCy := int64(math.Floor((y - radius) / g.cellSize))
    maxCy := int64(math.Floor((y + radius) / g.cellSize))

    for cx := minCx; cx <= maxCx; cx++ {
        for cy := minCy; cy <= maxCy; cy++ {
            key := cx*100000 + cy
            for _, e := range g.cells[key] {
                dx, dy := e.X-x, e.Y-y
                if dx*dx+dy*dy <= radius*radius {
                    result = append(result, e)
                }
            }
        }
    }
    return result
}

常见错误

  1. 格子大小选择不当:格子太小,查询时需要检查太多格子;格子太大,每个格子中实体太多。
  2. 实体移动时未更新分区:实体移动后没有从旧格子删除、插入新格子,导致查询结果不准确。
  3. 边界情况处理不当:实体在格子边界附近时,需要同时存在于相邻格子中,否则查询会遗漏。
  4. 过度设计:对于简单场景,直接用数组存储所有实体,暴力遍历就够了。

总结:模式选择决策表

问题场景推荐模式为什么
需要撤销/回放/网络同步命令模式把操作变成可序列化的数据
大量同类型对象共享数据享元模式分离内在状态和外在状态
系统间松耦合通信观察者模式发布者不知道订阅者
需要从配置动态创建对象原型模式 / 类型对象数据驱动的对象创建
全局唯一的服务单例(谨慎)/ 依赖注入优先用 DI
对象有多种状态和转移状态模式替代布尔标志的爆炸
读写并发的数据一致性双缓冲分离读写缓冲区
游戏的时间推进游戏循环固定时间步长是关键
大量对象各自更新更新方法每个对象自己管理自己
AI/技能需要动态修改字节码可热更新的指令集
控制子类的能力范围子类沙盒封装底层 API
用数据定义游戏类型类型对象运行时的类型系统
复杂实体的灵活组合组件模式组合优于继承
异步解耦系统通信事件队列生产者和消费者独立
全局服务的注册查找服务定位器按名称查找服务
内存访问性能优化数据局部性连续存储提升缓存命中
避免重复计算脏标记只在变化时重算
频繁创建销毁对象对象池复用而非分配
空间查询优化空间分区O(n²) 降到 O(n log n)

Nystrom 的终极建议:模式是工具,不是目标。先理解你的问题,再选择合适的模式。如果一个简单的 if-else 就能解决问题,那就用 if-else。引入模式的主要理由是:它能让你的代码在未来更容易修改、更容易理解。


参考

  • Robert Nystrom, Game Programming Patterns, gameprogrammingpatterns.com
  • 本文档中的模式分类和核心思想来源于原书,游戏服务器场景根据实际开发经验补充

游戏后端知识体系