跳转到内容

第 42 章 · 加入 NPC 与对象池

约 7 分钟 难度:5 动手章

这一章解决:把闭环 4 的训练目标替换成真正的池化敌人,同时保留迟入恢复、Owner 边界和池耗尽降级。

先看一下

闭环 4 的 TrainingTarget 是固定摆在场景里的目标。它能验证流程,但不能代表合作防守世界的敌人:敌人要按波次出现,要有 HP,要死亡归还,还要让迟入玩家看到哪些敌人仍然活着。第 42 章把这套生命周期接上。

这一章会拿到什么

  • 一份 TrainingTarget → EnemyObject + VRCObjectPool 的替换表
  • EnemyObject 的最小同步字段:HP、状态、所属波次、目标玩家
  • EnemyPool 的生成、初始化、归还边界
  • GameState 和敌人对象之间的死亡回报流程
  • 对象池耗尽和迟入恢复测试矩阵

依赖前面

  • 闭环 4 的 targetClearedMaskenemyAliveCount
  • 第 34 章的 Owner 驱动 NPC
  • 第 36 章的对象池生命周期
  • 第 39 章的 Quest 与性能预算
  • 理解章 G 的降级优先级

闭环 4 的 targetClearedMask 只适合 5 个固定目标。进入真实项目后,敌人数量、位置和波次都要变化,状态归属也要拆开。

闭环 4第 42 章替换成原因
TrainingTarget 常驻场景EnemyObject 从池里取出敌人需要按波次生成和归还
targetClearedMaskVRCObjectPool active 状态 + EnemyObject 字段bitmask 容量有限,不能承载大量敌人
targetIdenemyId / poolIndex池化对象需要稳定编号
命中目标请求命中敌人请求请求里带敌人编号和伤害
enemyAliveCount继续保留在 GameStateUI 和结算只需要聚合数量

GameState 仍然不保存每只敌人的完整状态。它只保存波次、剩余敌人数、分数和结算。单体敌人状态进入 EnemyObject,存在与否由 VRCObjectPool 的 active 状态恢复。


第 42 章只建敌人池。子弹池、掉落物池、特效池先留给后续扩展。

GameRoot
├─ GameState
├─ EnemyPool
│ ├─ Enemy_00
│ ├─ Enemy_01
│ ├─ Enemy_02
│ └─ Enemy_19
└─ SpawnPoints
├─ Spawn_A
├─ Spawn_B
└─ Spawn_C

EnemyPool 物体上挂 VRCObjectPool 和一个自写的 EnemyPoolController。每个 Enemy_XX 上挂 EnemyObject。池里对象初始 inactive。

对象池数量先按目标人数和目标平台定。2–4 人合作防守第一版可以从 20 个敌人开始:同屏 active 不超过 8 个,池里留出下一波缓冲。这个数字写进关卡配置,不写进 ADR。


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 用来判断这个敌人属于哪一波。hpstate 用于迟入恢复。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 但字段还是上一只怪」。


池控制器是对象生命周期入口。它不裁决分数,也不裁决胜负。

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 够用。


第 36 章已经给过原则。第 42 章落到主项目。

层级字段或组件迟入看到什么
是否存在VRCObjectPool active 状态哪些敌人仍在场上
单体状态EnemyObject 的 hp / state / waveIndex每只敌人是否活着、剩多少 HP、属于哪一波
全局进度GameState.enemyAliveCount / waveIndex / phase当前波次、剩余敌人数、是否已结算

这三层需要互相校验。迟入玩家如果看到某个 EnemyObject active,但 state=DEAD,说明归还顺序或同步时序有问题。测试矩阵里要单独跑这一行。

实现上不要在 active 到达的一帧立刻播放完整敌人表现。先检查 matchIdwaveIndexstate:active 但 state=INACTIVEmatchId 不匹配时,先隐藏模型或显示占位,等 EnemyObject 字段反序列化后再进入 ALIVE 表现。VRCObjectPool 只保证 active 状态,单体语义仍然靠 EnemyObject 字段。


对象池耗尽时,项目不崩。三种处理按语义从稳到激进排列。

处理适合代价
少刷并记录合作 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=DEADGameState.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 章会接入哪些体感表现