第 42 章 · 加入 NPC 与对象池
这一章解决:把闭环 4 的训练目标替换成真正的池化敌人,同时保留迟入恢复、Owner 边界和池耗尽降级。
先看一下
闭环 4 的 TrainingTarget 是固定摆在场景里的目标。它能验证流程,但不能代表合作防守世界的敌人:敌人要按波次出现,要有 HP,要死亡归还,还要让迟入玩家看到哪些敌人仍然活着。第 42 章把这套生命周期接上。
这一章会拿到什么
- 一份
TrainingTarget → EnemyObject + VRCObjectPool的替换表 EnemyObject的最小同步字段:HP、状态、所属波次、目标玩家EnemyPool的生成、初始化、归还边界GameState和敌人对象之间的死亡回报流程- 对象池耗尽和迟入恢复测试矩阵
依赖前面
- 闭环 4 的
targetClearedMask和enemyAliveCount - 第 34 章的 Owner 驱动 NPC
- 第 36 章的对象池生命周期
- 第 39 章的 Quest 与性能预算
- 理解章 G 的降级优先级
从训练目标换成敌人对象
Section titled “从训练目标换成敌人对象”闭环 4 的 targetClearedMask 只适合 5 个固定目标。进入真实项目后,敌人数量、位置和波次都要变化,状态归属也要拆开。
| 闭环 4 | 第 42 章替换成 | 原因 |
|---|---|---|
TrainingTarget 常驻场景 | EnemyObject 从池里取出 | 敌人需要按波次生成和归还 |
targetClearedMask | VRCObjectPool active 状态 + EnemyObject 字段 | bitmask 容量有限,不能承载大量敌人 |
targetId | enemyId / poolIndex | 池化对象需要稳定编号 |
| 命中目标请求 | 命中敌人请求 | 请求里带敌人编号和伤害 |
enemyAliveCount | 继续保留在 GameState | UI 和结算只需要聚合数量 |
GameState 仍然不保存每只敌人的完整状态。它只保存波次、剩余敌人数、分数和结算。单体敌人状态进入 EnemyObject,存在与否由 VRCObjectPool 的 active 状态恢复。
场景结构:按对象类型拆池
Section titled “场景结构:按对象类型拆池”第 42 章只建敌人池。子弹池、掉落物池、特效池先留给后续扩展。
GameRoot├─ GameState├─ EnemyPool│ ├─ Enemy_00│ ├─ Enemy_01│ ├─ Enemy_02│ └─ Enemy_19└─ SpawnPoints ├─ Spawn_A ├─ Spawn_B └─ Spawn_CEnemyPool 物体上挂 VRCObjectPool 和一个自写的 EnemyPoolController。每个 Enemy_XX 上挂 EnemyObject。池里对象初始 inactive。
对象池数量先按目标人数和目标平台定。2–4 人合作防守第一版可以从 20 个敌人开始:同屏 active 不超过 8 个,池里留出下一波缓冲。这个数字写进关卡配置,不写进 ADR。
EnemyObject 只保存单体状态
Section titled “EnemyObject 只保存单体状态”EnemyObject 是 Owner 驱动 NPC 的最小版本。它先不做复杂 AI,只做「活着、受击、死亡、归还」。
[UdonSynced] private int enemyId;[UdonSynced] private int matchId;[UdonSynced] private int waveIndex;[UdonSynced] private int hp;[UdonSynced] private byte state;[UdonSynced] private int targetPlayerId;
public const byte STATE_INACTIVE = 0;public const byte STATE_ALIVE = 1;public const byte STATE_DEAD = 2;enemyId 是池内稳定编号。matchId 用来挡上一局延迟事件。waveIndex 用来判断这个敌人属于哪一波。hp 和 state 用于迟入恢复。targetPlayerId 可以先填最近玩家或 -1,第 43 章体感优化会用它做 UI 提示。
初始化入口不依赖 Start(),由池控制器显式调用。
public void OnSpawned(int newMatchId, int newWaveIndex, int newEnemyId, int maxHp){ if (!Networking.IsOwner(gameObject)) return;
matchId = newMatchId; waveIndex = newWaveIndex; enemyId = newEnemyId; hp = maxHp; state = STATE_ALIVE; targetPlayerId = -1;
RequestSerialization(); ApplyView();}归还前先把状态写成死亡或 inactive,再交回池。这样迟入玩家不会在极短窗口里看到「active 但字段还是上一只怪」。
EnemyPool 只负责取出和归还
Section titled “EnemyPool 只负责取出和归还”池控制器是对象生命周期入口。它不裁决分数,也不裁决胜负。
public VRCObjectPool objectPool;public EnemyObject[] enemies;public Transform[] spawnPoints;
public EnemyObject TrySpawnEnemy(int matchId, int waveIndex, int enemyId, int hp){ if (!Networking.IsOwner(objectPool.gameObject)) return null;
GameObject obj = objectPool.TryToSpawn(); if (obj == null) return null;
EnemyObject enemy = obj.GetComponent<EnemyObject>(); if (enemy == null) return null;
Transform point = spawnPoints[enemyId % spawnPoints.Length]; obj.transform.SetPositionAndRotation(point.position, point.rotation); enemy.OnSpawned(matchId, waveIndex, enemyId, hp); return enemy;}TryToSpawn() 返回 null 是正常分支。池耗尽时,GameState 要决定这波少刷、延迟刷,还是直接进入降级。
private int SpawnWaveEnemies(int count){ int spawned = 0; for (int i = 0; i < count; i++) { EnemyObject enemy = enemyPool.TrySpawnEnemy(matchId, waveIndex, i, enemyHp); if (enemy == null) break; spawned += 1; } return spawned;}第一版推荐「少刷并记录日志」。合作防守少刷一两只敌人,玩法仍然成立;无限重试生成会制造网络和脚本压力。
命中流程:玩家请求,GameState 裁决,EnemyObject 落地
Section titled “命中流程:玩家请求,GameState 裁决,EnemyObject 落地”命中请求仍从 PlayerObject 发出。payload 暂时打包两个小整数:敌人编号和伤害。第 23 章已经讲过更严肃的字节打包;本章只保留接口意图。
public const byte REQ_HIT_ENEMY = 7;
public void RequestHitEnemy(int enemyId, int damage){ int payload = enemyId * 1000 + damage; IssueRequest(REQ_HIT_ENEMY, payload);}GameState 解出请求,检查阶段、matchId、敌人是否 active,再把伤害交给对应 EnemyObject。EnemyObject 的 Owner 写 HP。
private void HandleHitEnemy(VRCPlayerApi who, int payload){ if (phase != PHASE_INGAME) return;
int enemyId = payload / 1000; int damage = payload % 1000; EnemyObject enemy = enemyPool.GetEnemy(enemyId); if (enemy == null) return; if (!enemy.CanAcceptHit(matchId, waveIndex)) return;
enemy.ApplyDamageFromGameState(who.playerId, damage);}ApplyDamageFromGameState 仍然需要 Owner 边界。最小版本让 GameState Owner 同时拥有 EnemyPool 和 EnemyObject,减少跨 Owner 协议。第七部讲过分片 Owner,这里先不启用。
这个选择不是性能最优,而是协议最短。只要 EnemyObject 分给其他 Owner,命中链路就会多一跳:GameState 接受请求,EnemyObject Owner 扣 HP,EnemyObject 再回报死亡。多出来的每一跳都要处理回执丢失、Owner 离开和重复死亡回报。第一版先把这些复杂度关掉。
public void ApplyDamageFromGameState(int attackerPlayerId, int damage){ if (!Networking.IsOwner(gameObject)) return; if (state != STATE_ALIVE) return;
hp = Mathf.Max(0, hp - damage); if (hp == 0) Die(attackerPlayerId);
RequestSerialization(); ApplyView();}死亡时回报给 GameState,由 GameState 加分、减少 enemyAliveCount,再让池控制器归还对象。
private void Die(int killerPlayerId){ state = STATE_DEAD; gameState.OnEnemyKilled(enemyId, killerPlayerId);}OnEnemyKilled 必须幂等。EnemyObject 可能因为重复命中、Owner 转移或延迟事件多次调用死亡回报。
public void OnEnemyKilled(int enemyId, int killerPlayerId){ if (!Networking.IsOwner(gameObject)) return; if (!MarkEnemyKilledOnce(enemyId)) return;
enemyAliveCount = Mathf.Max(0, enemyAliveCount - 1); AddScoreForKill(killerPlayerId); RequestSerialization(); ApplyState();}MarkEnemyKilledOnce 可以先用 killedEnemyMask 支撑一波少量敌人。敌人数量超过 32 后,改成 bool[] killedEnemySlots 或 EnemyObject 自己的 state 检查。第一版按 20 个敌人,bitmask 够用。
迟入恢复拆成三层
Section titled “迟入恢复拆成三层”第 36 章已经给过原则。第 42 章落到主项目。
| 层级 | 字段或组件 | 迟入看到什么 |
|---|---|---|
| 是否存在 | VRCObjectPool active 状态 | 哪些敌人仍在场上 |
| 单体状态 | EnemyObject 的 hp / state / waveIndex | 每只敌人是否活着、剩多少 HP、属于哪一波 |
| 全局进度 | GameState.enemyAliveCount / waveIndex / phase | 当前波次、剩余敌人数、是否已结算 |
这三层需要互相校验。迟入玩家如果看到某个 EnemyObject active,但 state=DEAD,说明归还顺序或同步时序有问题。测试矩阵里要单独跑这一行。
实现上不要在 active 到达的一帧立刻播放完整敌人表现。先检查 matchId、waveIndex 和 state:active 但 state=INACTIVE 或 matchId 不匹配时,先隐藏模型或显示占位,等 EnemyObject 字段反序列化后再进入 ALIVE 表现。VRCObjectPool 只保证 active 状态,单体语义仍然靠 EnemyObject 字段。
池耗尽的降级入口
Section titled “池耗尽的降级入口”对象池耗尽时,项目不崩。三种处理按语义从稳到激进排列。
| 处理 | 适合 | 代价 |
|---|---|---|
| 少刷并记录 | 合作 PvE 普通波次 | 难度略降 |
| 延迟刷 | 需要固定敌人数的关卡 | 波次节奏变慢 |
| 本地影子敌人 | 只制造视觉压力的敌群 | 多人不一致,但不影响分数 |
第 42 章默认少刷并记录:requested=10, spawned=8, reason=pool_full。第 44 章会把这类记录整理成内测日志格式。
池耗尽不触发运行时 Instantiate。运行时临时创建能让单次测试看起来过了,但迟入恢复、Quest 性能和对象 Owner 都会变得不可控。
挑一条试。
- 把敌人池容量从 20 改成 5,并让一波尝试生成 8 个敌人。记录少刷、延迟刷和本地影子三种处理分别会怎样影响体验。
- 给 EnemyObject 加一个
armor字段。它属于 EnemyObject、GameState 还是本地表现?迟入玩家是否需要看到它?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 开局生成一波敌人 | GameState Owner 从池里取出敌人,enemyAliveCount 等于实际生成数 | … |
| 2. A 命中敌人 | EnemyObject HP 下降,未死亡时不加分 | … |
| 3. A 击杀敌人 | EnemyObject state=DEAD,GameState.enemyAliveCount -1,分数增加一次 | … |
| 4. A 连点同一死亡敌人 | MarkEnemyKilledOnce 阻止重复加分 | … |
| 5. 迟入玩家加入 | active 敌人存在,HP / state / waveIndex 和 enemyAliveCount 对得上 | … |
| 6. 敌人池耗尽 | 少刷或延迟,系统记录原因,不运行时创建新对象 | … |
7. GameState Owner 离开 | 新 Owner 接管后仍能处理剩余敌人死亡 | … |
| 8. 重开下一局 | 旧敌人归还,下一局敌人 matchId 更新,没有上一局状态残留 | … |
- 能解释
VRCObjectPool只恢复 active 状态,不能恢复 HP - 能说出 EnemyObject 和 GameState 的状态边界
- 能说明为什么第一版让 GameState Owner 同时拥有敌人对象
- 能给池耗尽设计一个可见降级入口
- 能指出第 43 章会接入哪些体感表现
- 第 34 章 · NPC 的三种架构:Owner 驱动 NPC 的状态边界。
- 第 36 章 · 对象池与生成系统:
VRCObjectPool生命周期和池耗尽处理。 - 第 39 章 · Quest 与性能预算:同屏敌人数量和降级优先级。
- 闭环 4 · 最小可玩版本:本章替换的
TrainingTarget来源。 - VRChat Creator Docs · Network Components:
VRCObjectPool和网络回调的官方说明。