第 39 章 资源加载、对象池与状态机:主程的日常三件套

上一章我们扒开了游戏那颗”心脏”——主循环。你看到它一圈圈地”输入→更新→渲染”,驱动着所有 Update、FixedUpdate。

但心脏要跳得稳,还得有”血”供着。游戏里这”血”就是三类你每天睁眼都要打交道的东西:

第一,资源(美术模型、贴图、音效、预制体)从哪来、怎么进内存?第二,满屏的子弹、怪物、特效频繁生灭,内存和卡顿怎么治?第三,敌人”待机/巡逻/追击/攻击/死亡”、游戏”菜单/对局/结算”这种状态来回切,代码怎么写得不糊?

这三件事,第 35 章主程全景图里被明确定义为”引擎架构模块”的核心,也是面试官最爱刨的坑(你还记得第 21 章面试篇提过”对象池治 GC”吧)。这一章我就把这”三件套”一次讲透:资源加载、对象池、状态机。它们仨不是孤立的——结尾你会看到,它们是怎么在主循环里咬合在一起的。

老规矩:现象 → 现实问题 → 工程师方案 → 落进引擎成了什么。你作为 Go 程序员,我会一路用你最熟的后端词汇搭桥。


39.1 资源加载:从硬盘到内存,那条”取货”的路

先建立一个现实模型。你游戏里一个”敌方小兵”,背后其实挂着一堆”资源”:它的 3D 模型(网格数据,第 4 章)、它的皮(材质贴图,第 5 章)、它的死亡音效(第 12 章)、以及把它组装好的那个”预制体(Prefab,第 17 章说过,就是把一堆组件拼好存成模板)“。这些东西平时躺在硬盘(或手机存储)里,游戏运行时得”取”进内存才能用。

“取”这件事,就是资源加载(asset loading)。

39.1.1 最简单也最坑的:Resources.Load

Unity 早期给了一个傻瓜式入口:Resources.Load("Enemy")——你把所有资源往一个叫 Resources 的文件夹里一扔,代码里直接按名字取。

它的问题在哪?你作为后端一眼能看穿:

  • 所有 Resources 里的东西,会被无条件打包进主包。 不管你这关用不用得到,只要丢进 Resources 文件夹,构建时就全塞进安装包。包体暴涨、且无法单独热更新(想改个贴图得重新发整个包)。
  • 运行时按字符串名字找,零编译期检查。 名字拼错,运行时才崩,不像你 Go 里 struct 字段拼错编译就报错。
  • 所有资源一股脑加载,启动慢。 没有”按需取”的机制。

所以它只适合做原型、做极小的 demo。真项目里主程会避开它做大体量管理。

39.1.2 正道:Addressables / AssetBundle(按需 + 可热更)

现代 Unity 主推的是 Addressables(可寻址资源系统),底层基于 AssetBundle(资源包)。思想是这样的:

把资源按”区块”打成独立的包(bundle),每个包有个”地址(address)“。游戏运行时,按地址按需去取——要出小兵了,才去加载小兵那个包;不用的包可以卸掉,腾内存。

这跟你后端做”按需加载、懒加载”一个道理。而且好处巨大:

  • 按需加载:不用的资源不进内存,启动快、内存省。
  • 可热更新:bundle 可以放在远端服务器,发新版游戏时只下发改动的 bundle,不用重发整个安装包。这直接连回你第 19 章的”联机/后端”思路——资源也能像数据一样”走服务端下发”。
  • 引用计数与依赖管理:Addressables 会帮你记”这个包被几个地方用着”,没人用了才卸,不会误删正在用的资源。

生活化类比:Resources.Load 像你出门把所有衣服全塞行李箱(不管穿不穿都带上,箱子巨重);Addressables 像你只带常穿的,冷门衣服存在家里衣柜(远端),需要时叫快递(网络)送一件来。前者笨重但随时有,后者轻便但要等”快递”(加载时间)。

39.1.3 同步 vs 异步:卡帧的由来

前面第 38 章讲了主循环每帧只有 16 毫秒。那”加载资源”这件事如果同步做(代码停在那儿,等硬盘把几十兆模型读完再继续),会怎样?——主循环被你这一句卡住几百毫秒,画面直接冻住,玩家看到”游戏卡死”。这就是同步加载卡帧。

所以资源加载几乎必须异步:发个”去加载”的请求,主循环继续跑(画面照常),加载好了再用回调/await 把资源接进来。你写 Go 时对”同步阻塞 vs 异步非阻塞”门儿清——游戏里完全同构:同步加载 = 在 handler 里 time.Sleep 等 IO;异步加载 = 丢给 goroutine,完成 chan 通知。第 19.4 节讲的”游戏里网络必须异步”也是同一个底层原因:主循环碰不得阻塞。

连回第 43 章预告:Addressables 这套”按地址取、可热更”的机制,和我们后面要讲的热更新(第 43 章左右)是亲兄弟——你的 Go 后端负责把新 bundle 放到 CDN,客户端启动时检查版本、拉取差异。你做联机塔防时,这正好复用你第 19 章的 HTTP 思路。


39.2 对象池:治”频繁生灭”带来的 GC 卡顿

资源进内存了,接下来是游戏运行时最高频的动作——生成(Instantiate)和销毁(Destroy)物体。塔防里子弹满天飞、怪一波波刷,想想这个频率:你每帧 new 几十个子弹、又 Destroy 几十个,一局下来几十万次分配与释放。

39.2.1 问题:每次 new 都问内存要地,销毁了还要 GC 来收

你 Go 程序员对”堆分配”和”GC”太熟了。C# 在 Unity 里也一样:每次 Instantiate 一个物体,引擎要在托管堆上分配内存、构造组件;每次 Destroy,那块内存变成”垃圾”,等垃圾回收(GC,Garbage Collection) 来清扫。GC 一启动,要扫描堆、搬运存活对象——这一顿操作可能吃掉好几毫秒甚至十几毫秒,正好撞上你 16 毫秒的帧预算,于是掉帧、卡顿(业内叫 GC 尖峰 / GC spike)。子弹越多,掉帧越凶。

更糟的是:频繁分配又释放,会让堆出现”碎片”(像你磁盘碎片),后续分配越来越慢。这和你后端服务”频繁创建销毁对象导致 GC 压力大、延迟毛刺”是完全一样的病。

39.2.2 方案:对象池——预new一批,用取回收,不真删

工程师的招数,你闭着眼都能想到,因为你后端天天用:连接池(connection pool)。

连回你的 Go 经验:你不会每来一个请求就 sql.Open 建一条新数据库连接、用完就 Close 关掉——那数据库早被你压垮了。你建一个池,预连 N 条,请求来了从池里取一条,用完还回去,下个请求接着用。池满了再等或扩容。

对象池(Object Pool) 就是游戏版的”连接池”:

  1. 预热:游戏开始(或关卡加载时),预先 Instantiate 一批子弹/怪物,放进一个”池子”(一个队列或列表),全部设为”未激活”(隐藏、不更新)。
  2. 取(Get):需要一颗子弹时,不从零 Instantiate,而是从池里”借”一个出来,激活、摆到发射位置、归位速度。
  3. 回收(Release):子弹打中目标或飞出屏幕,不 Destroy,而是”还”回池子——设为未激活、移出视野,等着下次被借走。
  4. 永不真删:整个对局里,子弹的总数在池的容量内浮动,内存只在预热时分配一次,运行期零分配、零 GC。

效果立竿见影:掉帧消失,因为不再有”每帧 new/delete 触发 GC”。

我再给你两个主程级的池化心法,免得你以为”建个队列就完事”。

第一,池容量怎么定? 这本质是”用内存换时间”的权衡,和你在 Go 里给连接池设 maxOpen=100 一模一样。定小了,高峰期不够用、被迫临时 new(又触发 GC,池的意义打折);定大了,平时闲置占内存。靠谱的做法是按”峰值同屏数量”估算 + 留出余量:比如你的塔防一局同屏最多 80 颗子弹,池就预热 100。更讲究的,可以做”自适应池”——运行时统计峰值,下次启动按历史峰值上浮 20% 预热。你后端做连接池调参也是这套思路:先估峰值,再留 buffer,最后用监控数据回填。

第二,池里的物体也要”分门别类”。 子弹池和怪物池不能混——你不能从子弹池里借出一颗”子弹”当”怪物”用,它们的预制体、组件、行为完全不同。所以真实项目里你会有一个 PoolManager(池管理器),按”资源类型”登记一堆子池,Get 时指定类型去对应子池取。这和你 Go 里”按业务分多个连接池(用户库池、订单库池分开)“完全一致:混用一个大池会互相挤占、且类型不安全。第 17 章讲的”组合优于继承”在这里也成立——池不关心物体”是什么类”,它只管”借出一个 GameObject、还回来”,类型安全由调用方和子池划分来保证。

生活化类比再补一刀:对象池像水龙头后的蓄水池。你家用水(游戏要子弹),不是每次都去远处水库现引(Instantiate),而是蓄水池(池)里早放好了,拧开就用(Get),用完的水(回收)净化后回池接着用。蓄水池太小,高峰停水(不够用被迫 new);太大,平时白占地方(闲置内存)。你做主程的功夫,一半就在”把蓄水池调到刚好够用又不浪费”。

39.2.3 一个极简 C# 对象池骨架

给你一段能直接用的最小实现,注意看它和”连接池”长得有多像:

using System.Collections.Generic;
using UnityEngine;
 
public class BulletPool {
    private Queue<GameObject> pool = new Queue<GameObject>();
    private GameObject prefab;   // 子弹预制体(第 17 章:拼好的模板)
 
    // 预热:一次性 new 一批,全藏起来
    public void Init(GameObject prefab, int count) {
        this.prefab = prefab;
        for (int i = 0; i < count; i++) {
            GameObject b = Object.Instantiate(prefab);
            b.SetActive(false);          // 未激活,不占更新、不渲染
            pool.Enqueue(b);
        }
    }
 
    // 取:借一颗(池空了就临时补一颗,或返回 null 由调用方决定)
    public GameObject Get() {
        if (pool.Count == 0) return null;
        GameObject b = pool.Dequeue();
        b.SetActive(true);
        return b;
    }
 
    // 回收:还回去,别真 Destroy
    public void Release(GameObject b) {
        b.SetActive(false);
        pool.Enqueue(b);
    }
}

你看到 Queue、Enqueue、Dequeue 没?这就是你 Go 里 list.Push/Pop 或 chan 的兄弟。区别在于:连接池还回去的是”连接”(状态要重置,比如清空事务),对象池还回去的是”物体”(状态也要重置,比如位置归零、速度清零、血量回满),否则下次借到一颗”还带着上一发余毒”的子弹就出 bug 了。

反问:池子大小怎么定?如果一帧突然要 200 颗子弹,但池只有 100,怎么办?——常见做法是”池空时动态扩容(再 new 一些补进去)“或”直接丢弃最老的一颗”。这和你连接池”满了排队/扩容”的策略一模一样。关键是:别让池成为新的瓶颈。

生活化类比:对象池像自动贩卖机后面的补货仓。顾客(游戏逻辑)要一瓶饮料(一颗子弹),你不是现做一瓶(Instantiate),而是从仓库(池)里拿出一瓶递给他;喝完的空瓶(回收)你洗净摆回仓库,下个顾客接着拿。你要是每卖一瓶就现做、卖完就扔瓶子,仓库(内存)迟早被你搞崩溃,还得雇个清洁工(GC)天天来收拾——那清洁工一干活,店里就停摆(卡顿)。

连回第 21 章面试:面试官问”Unity 怎么做性能优化”,你答”对象池治频繁 Instantiate/Destroy 引起的 GC 卡顿”,这一句就证明你懂主程级的事,而不是只会拖组件。


39.3 有限状态机 FSM:让”状态乱跳”的代码不糊

有了资源、有了池,物体能生能灭了。但一个敌人从”待机”到”巡逻”到”发现玩家追击”到”攻击”到”死亡”,这个行为随状态切换的过程,怎么写才不乱?

39.3.1 反面教材:满屏 if-else

新手最容易这么写:

void Update() {
    if (hp <= 0) { Die(); }
    else if (sawPlayer) { Chase(); }
    else if (inAttackRange) { Attack(); }
    else if (patrolPointReached) { PickNextPatrol(); }
    else { Idle(); }
}

一开始还行。可一旦加”受击硬直""逃跑""召唤同伴”,这些 if 会层层嵌套、相互打架:追击时能不能被吓退?攻击时能不能被中断?状态之间”能不能转、怎么转”全糊在这堆布尔判断里,改一个漏一个,bug 满天飞。这就是”状态爆炸”。

39.3.2 方案:有限状态机(FSM)

有限状态机(Finite State Machine,FSM) 是个计算机科学的老概念,你写 Go 时其实用过——HTTP 服务的 handler 分发就是个状态机:请求进来,按”方法+路径”这个”状态”分发到不同 handler。游戏的 FSM 思路完全同构。

FSM 干两件事:

  1. 定义一组”状态”:待机(Idle)、巡逻(Patrol)、追击(Chase)、攻击(Attack)、死亡(Dead)。每个状态管”我在这个状态下每帧干啥”。
  2. 定义”转移条件”:什么情况下从 A 状态跳到 B 状态。比如”看到玩家”让 Patrol→Chase;“血量归零”让任何状态→Dead。

关键心法:同一时刻只处于一个状态,状态之间靠明确的”转移条件”切换,且不是任意两个状态都能互转。 这把”混乱的 if”收编成了”有向图”——谁能动到谁,一眼看清。

39.3.3 两种写法:switch 版 与 状态类版

写法一:轻量 switch 版(小敌人够用):

enum EnemyState { Idle, Patrol, Chase, Attack, Dead }
EnemyState state = EnemyState.Patrol;
 
void Update() {
    switch (state) {
        case EnemyState.Patrol:
            PatrolMove();
            if (sawPlayer) state = EnemyState.Chase;   // 转移
            break;
        case EnemyState.Chase:
            ChaseMove();
            if (inAttackRange) state = EnemyState.Attack;
            else if (!sawPlayer) state = EnemyState.Patrol;
            break;
        case EnemyState.Attack:
            DoAttack();
            if (!inAttackRange) state = EnemyState.Chase;
            break;
        case EnemyState.Dead:
            // 死亡态什么都不做,等对象池回收
            break;
    }
}

写法二:状态类版(更工程化,推荐)——每个状态是一个类,实现统一接口,敌人持有一个”当前状态”引用:

interface IEnemyState {
    void Enter(Enemy e);    // 进入这状态时做啥(比如切动画)
    void Update(Enemy e);   // 这状态下每帧做啥
    void Exit(Enemy e);     // 离开时做啥(清理)
}
 
class ChaseState : IEnemyState {
    public void Enter(Enemy e) { e.anim.Play("Run"); }
    public void Update(Enemy e) {
        e.MoveToward(e.player);
        if (e.InAttackRange()) e.state = new AttackState();  // 转移
        else if (!e.SawPlayer()) e.state = new PatrolState();
    }
    public void Exit(Enemy e) { }
}

第二种和你 Go 里”策略模式 / 状态模式”一模一样——把你第 17 章学的”组合优于继承”也用上了:敌人本身不写死行为,而是”持有一个当前状态对象”,行为由状态对象决定。要加”受击硬直”?写个 StunState 类,从受击处转移过去即可,不动别的代码。

连回第 17 章:状态类 Enter 里 e.anim.Play("Run") 就是切换 Animator 的动画(第 18.9 节)——你发现没,游戏里”逻辑状态机”和”动画状态机”天然是一对,敌人逻辑切到 Chase,动画就播 Run。它们俩经常并排工作。 连回第 35 章:FSM 正是第 35 章主程全景图”模块二”里点名的”状态机/行为树”的入门。行为树(Behavior Tree)是 FSM 的升级版,适合更复杂的 AI,但 FSM 是地基,先吃透它。

生活化类比:FSM 像红绿灯。灯只有”红/黄/绿”几个有限状态,且转换有规矩——绿→黄→红→绿,不能红直接跳绿(除非坏了)。每个状态下车的行为明确:绿灯行、红灯停。你要是把”该不该走”写成”如果前面车动了而且没警车而且……”一堆乱条件,路口早堵死了。红绿灯用”有限状态+转移规则”把混乱管得服服帖帖——敌人 AI 也一样。

反问:游戏流程本身也是个 FSM——菜单(MainMenu)→对局(Playing)→暂停(Paused)→结算(Result)。你能画出”暂停”这个状态能从哪些状态进、能转到哪些状态吗?为什么”结算”不能再跳回”对局”重打(除非重开)?这就是状态机帮你”锁死非法转移”的价值。

我再多给你两样主程级的 FSM 实战心法,免得你只会写个 switch 就以为懂了。

第一,把”转移条件”单独画成一张表,而不是散在代码里。 一个敌人 AI 的状态转移,本质是一张有向图,我建议你动笔(或动白板)画出这张表,比看代码清楚十倍:

当前状态    触发条件          目标状态
Patrol     看到玩家           Chase
Chase      进入攻击范围        Attack
Chase      丢失玩家视线        Patrol
Attack     目标离开范围        Chase
Attack     自身血量<=0         Dead
任何状态    血量<=0             Dead
Dead       (动画播完)           (回收进对象池)

你看,一眼就能发现漏洞:Chase 状态下”被击退”要不要转 Stun?如果表里没写,那这转移就不存在,敌人被击退时还会傻傻继续追——这就是 FSM 帮你”显式穷举所有合法转移”的价值。你写 Go 时用状态图(state diagram)设计支付流水(待支付→已支付→已退款→已关闭)也是同一招,支付系统的”状态机”和你敌人 AI 的”状态机”是同一个脑子。

第二,状态可以”嵌套(层级状态机 HFSM)”。 真实敌人往往不是平铺的几个状态,而是”大状态套小状态”:比如大状态是”战斗态”,战斗态内部又套着”走位/技能/防御”三个子状态;大状态”非战斗态”套着”巡逻/休息/回血”。平铺 FSM 写这种会爆炸成十几个状态,用层级(HFSM)就能把”战斗”当成一个整体去转进转出,内部再各自管理。再往上走就是行为树(Behavior Tree)——它不用”转移”的概念,而是用”选择器/序列/条件节点”像搭积木一样组合行为,更适合”一群敌人协同、优先级复杂”的 AI。行为树是 FSM 的升华,但请你务必先吃透 FSM,因为行为树底层的”节点执行、优先级、打断”思想,全是从 FSM 长出来的。你第 35 章主程全景图把”状态机/行为树”并列点名,正是这个递进关系。


39.4 三者如何协作:加载→池→状态机,一条龙的活

单讲完觉得各管各的,但落到真实游戏里,这三件套是一条流水线咬合的。以你塔防为例:

[资源加载]       启动时 Addressables 把"子弹预制体""小兵预制体"按需取进内存
      │
      ▼
[对象池]         预热时 Instantiate 一批子弹/小兵进池;运行时从池借、用完还
      │
      ▼
[状态机]         小兵借出后,FSM 驱动它 Idle→Patrol→Chase→Attack→Dead
      │                                  │
      ▼                                  ▼
[死亡] 小兵 Dead 态 → 播放死亡动画 → 还回对象池(Release)等下次波次再借

具体走一遍”一波敌人”的生命周期,你就有画面了:

  1. 加载:关卡开始,Addressables 加载”小兵预制体”和”子弹预制体”进内存(异步,不卡帧)。
  2. 建池:初始化时,对象池预热 50 个小兵、200 颗子弹,全隐藏待用。
  3. 波次状态机:整个关卡有个”波次状态机”——WaveIdle→Spawning(刷怪)→WaveClearing(清场)→下一波 / Victory。Spawning 态里,按节奏从池里 Get 小兵,摆到出生点。
  4. 敌人状态机:每个小兵被借出后,自己的 FSM 开始跑:Patrol 沿路(第 18.12 节 NavMeshAgent)→ 看到玩家 Chase → 进塔范围 Attack。
  5. 子弹对象池:小兵攻击时,从子弹池 Get 一颗子弹飞向玩家;命中或越界,Release 回池。全程零 GC。
  6. 回收:小兵血量归零→Dead 态播死亡动画→动画结束 Release 回池,等下一波再被借走。一波波下来,内存稳如老狗,不掉帧。

你看,资源加载负责”把料备好”,对象池负责”料循环用不浪费”,状态机负责”料按规矩动”。三者少一个都别扭:没加载,料都没有;没池,料用一次就扔、GC 卡死;没状态机,料乱动、行为糊成一片。

连回第 20 章项目蓝图:你第 20.6 节拆解过”怪物走到基地扣血、玩家建塔花钱”。现在你有了更锋利的工具——“怪物”用对象池管生灭、“怪物行为”用 FSM 管状态、“怪物模型与塔模型”用 Addressables 管加载。蓝图里那些”组件+参数”的描述,现在有了”怎么高效运转”的引擎级支撑。这就是主程视角的补全:不只会拼,还会让它跑得不卡。

连回第 38 章主循环:这三件套全跑在主循环里。资源加载的异步回调在主循环的”事件/协程”阶段被派发;对象池的 Get/Release 在你 Update/FixedUpdate 里被调用;状态机的 Update 就在每个敌人的 Update 钩子里跑(第 38.1 节说的”更新世界”展开的一堆钩子之一)。所以你前一章学的”心脏”,正是这一章”三件套”跳动的节拍器。


39.5 这一章你该带走的

  • 资源加载:Resources.Load 简单但有坑(全进主包、不可热更、无编译检查);正道是 Addressables/AssetBundle,按需取、可热更、走远端。加载务必异步,否则卡主循环掉帧(同第 38 章”主循环不得阻塞”)。
  • 对象池:频繁 Instantiate/Destroy 触发 GC 尖峰掉帧。池 = 游戏版连接池:预热一批、Get 借、Release 还、永不真删,运行期零分配。记得回收时重置状态。
  • 状态机 FSM:用”有限状态 + 转移条件”取代混乱 if-else。switch 版够用小敌人,状态类版(策略模式)适合扩展。敌人 AI、游戏流程都是 FSM。逻辑状态机常与动画状态机并排。
  • 三者协作:加载备料 → 池循环用料 → 状态机驱料动。呼应塔防:波次 FSM 刷怪、敌人 FSM 行为、子弹对象池零 GC。
  • Go 类比贯穿:资源热更≈你后端下发配置;对象池≈连接池;状态机≈handler 分发 / 策略模式;异步加载≈goroutine+chan。你后端肌肉全能用上。

最后提醒一句:这三件套不是”学会了就完事”,而是”天天要调”的活。同一个塔防,资源加载策略决定了你的包体大小和启动速度,对象池容量决定了你的同屏上限和内存占用,状态机设计决定了你的 AI 可维护到多复杂——它们共同决定了”你的游戏能不能既好看又不卡还能一直迭代”。主程的日常,就是在这三者之间反复权衡、用 Profiler(第 35 章模块三)数据说话,而不是凭感觉拍脑袋。

下一章我们会顺着”资源”这条线,往更深的”热更新与配置表”走(第 43 章预告过),也会把”状态机”升级成”行为树”去扛更复杂的 AI——但今天这三件套,已经是你能 daily 用、面试能讲、做项目能救命的主程基本功了。

【思考题】

  1. Resources.Load 和 Addressables 最大的区别在哪?为什么说”把资源全丢进 Resources 文件夹”对真项目是坑?它和第 19 章讲的”服务器权威/热更”思路有什么共通的矛盾?
  2. 对象池为什么能治 GC 卡顿?如果池子预热了 100 颗子弹,但某一帧游戏要同时发射 150 颗,常见的处理策略有哪两种?各有什么代价?
  3. 状态机比”满屏 if-else”好在哪?请举一个”用 if-else 会出 bug、用 FSM 能避免”的具体场景(敌人 AI 或游戏流程都行)。
  4. 对象池回收一个敌人时,如果不”重置状态”(比如没把血量回满、没把位置归零),下次从池里借出这颗敌人会发生什么?这和你 Go 连接池”借出前要重置连接状态”是同一个坑吗?

【参考答案】

  1. 最大区别:Resources.Load 把所有资源无脑打进主包、无法按需、无法单独热更;Addressables 按地址按需加载、可走远端热更。坑在于:Resources 文件夹里任何东西都会被强制打包,包体暴涨且改一个贴图得重发整包,丧失灵活性与热更能力。这和第 19 章矛盾同源——第 19 章强调”逻辑/数据走服务器权威、可下发”,Resources 却把资源”焊死在客户端主包”,违背了”服务端可更新”的原则。Addressables 把资源也变成”可走服务端下发”的资产,正好和你 Go 后端的”配置/数据下发”思路一致。
  2. 对象池预热时一次性分配内存、运行期只借还不 new/delete,所以不再产生”垃圾”,GC 无事可做,自然不掉帧。池不够时的两种策略:① 动态扩容——临时再 Instantiate 一些补进池,代价是扩容那一刻仍有一次分配(可能小卡顿),但之后回归零分配;② 复用最老的——直接回收当前最老的活动物体顶上,代价是可能”抢走”还在用的子弹造成视觉跳变。取舍同连接池:满了是排队等、扩容、还是丢弃 oldest,看业务容忍度。游戏里通常选①(扩容)更稳。
  3. if-else 的坑:假设敌人”攻击中”被玩家击退,if 写法里”Attack 分支”和”被击退分支”容易同时成立或相互覆盖,状态乱跳、动画卡死。FSM 用”明确转移条件+同一时刻仅一态”规避:你定义”Attack→Stun(受击硬直)“这条合法转移,且 Attack 态内不响应”击退”以外的逻辑,状态清清楚楚。游戏流程例子更典型:结算(Result)态用 if 写可能被误触发”再来一局”而跳过菜单,FSM 直接锁死”Result 只能转到 MainMenu 或重开”,非法转移根本写不进去。
  4. 不重置就借出,会借到一颗”带着上一发余毒”的敌人:血量还是 0(直接又死)、位置还在上次死亡点(凭空出现在怪堆里)、速度没清零(乱飞)。这是经典 bug。和你 Go 连接池”借连接前要重置事务/清空上次的查询结果”是同一个坑——池化复用的第一铁律就是”归还时必须清洗干净,借出时才干净”。对象池 Release 时除了 SetActive(false),一定要 reset 血量/位置/速度/状态机回到初始态,否则下一次 Get 就是灾难。