返回文章归档
UnityC#FPS游戏开发开发

从状态机到对象池:我的 Unity FPS 战斗原型项目复盘

复盘一个 Unity FPS 练习项目中的角色状态、武器抽象、射线命中、僵尸生成与对象池,梳理从可玩原型走向可维护战斗系统的关键边界。

第一人称射击项目很容易在一开始就“跑起来”:角色可以移动,鼠标可以转向,点击鼠标可以播放开火动画。真正值得复盘的地方是,随着手枪、狙击枪、近战武器、弹药、敌人、命中反馈和 UI 一起进入项目后,这些功能怎样仍然能够协作,而不是互相直接调用到难以修改。

这篇文章基于我的 FPS 项目 做一次工程结构复盘。仓库中可以看到 Unity 第一人称控制器、角色状态、武器脚本、僵尸控制和生成管理等模块;同时也包含 Unity Standard Assets 与特效资源。因此本文重点讨论项目内的业务脚本组织和可继续演进的方向,不把第三方资源本身当作自研系统能力。

先确定目标:不是“做一把枪”,而是完成一轮战斗闭环

一个最小的 FPS 战斗闭环至少包含:

输入 -> 角色状态 -> 当前武器 -> 动画/音效/后坐力
     -> 射线命中 -> 敌人受伤 -> 敌人死亡
     -> 生成管理 -> 对象池复用 -> 再次进入战斗

这个链路中每一步都可以单独做出来,但若边界没有先划清,后面增加一把武器或一个敌人类型时,就会出现大量复制粘贴。项目里比较重要的做法是:让角色只负责“当前处于什么状态、现在拿着哪把武器”,而把各类武器自己的攻击细节交给武器脚本处理。

角色状态是武器行为的总开关

Player_Controller 定义了 Move、Shoot 和 Reload 三种状态,并在每帧把当前状态交给当前武器:

weapons[currentWeaponIndex].OnUpdatePlayerState(PlayerState);

这看似只是一行分发代码,实际上把“玩家输入”和“武器实现”隔开了。角色控制器不需要知道手枪如何开火、狙击枪是否要开镜、近战武器是否有子弹;它只需要维护状态流转,并把状态交给当前装备。

这个结构有三个直接收益:

  1. 输入入口集中。 数字键切枪、Q 回切上一把武器等逻辑都在角色侧,避免每把武器各自监听输入。
  2. 行为差异下沉。 OnEnterPlayerState 和 OnUpdatePlayerState 由具体武器实现,手枪、狙击枪和近战武器可以有不同的射击节奏与换弹规则。
  3. UI 同步有统一出口。 切换武器时由角色调用 InitForEnterWeapon 与弹药 UI 更新方法,武器只提供自己是否需要准星、是否展示子弹等配置。

当然,这一版状态机仍是一个轻量枚举。项目继续扩大后,Shoot、Reload、切枪动画、受击硬直和死亡会出现并发关系,仅用枚举容易让状态切换散落在多个脚本中。下一步可以把状态迁移收敛为明确的状态图:例如只有 Idle 才能发起切枪、装填中禁止开火、动画事件结束后才允许回到 Idle。这样就不会靠大量布尔变量维持正确性。

WeaponBase 把共性放到一处

仓库中的 WeaponBase 是整个战斗模块最关键的抽象之一。它统一保留了武器常见能力:

  • 当前弹匣、备弹和容量上限;
  • 攻击数值、是否显示准星、是否显示弹药;
  • 枪口特效、命中特效、音效、后坐力和穿墙开关;
  • 装备、卸下、攻击、换弹完成等生命周期。

换句话说,具体武器不应该重新发明“播放开火音效”“扣除子弹”“刷新弹药 UI”这些流程,而只需描述差异。一个典型的攻击流程是:扣弹、触发动画、显示枪口特效、施加后坐力、播放音效,再用射线判断命中对象。

Attack
  -> 弹药检查与扣减
  -> Animator Trigger
  -> 特效 / 音效 / 准星反馈
  -> Camera 射线检测
  -> ZombieController.Hurt(damage)

这里要注意一个实践细节:项目通过动画事件来调用进入完成、退出完成、攻击结束和换弹结束逻辑。好处是代码不必硬编码“等待 0.8 秒”;状态真正回到可攻击状态的时刻由动画决定。代价是动画片段和脚本方法名形成了隐式契约,后续应把事件命名规范、缺失检测和调试日志补齐,否则动画改名会变成静默故障。

射线命中是“武器逻辑”而不是“敌人逻辑”

FPS 的命中判定起点来自相机中心或鼠标位置。武器基类用 Camera.main.ScreenPointToRay 生成射线,再根据武器属性选择单次 Raycast 或 RaycastAll。命中对象若带有 Zombie 标签,就查找 ZombieController 并调用受伤方法;普通环境则生成弹痕类特效。

这种设计保持了职责方向:

武器决定“如何命中”
敌人决定“受到伤害后做什么”

例如穿透武器可以命中多个目标,霰弹枪可以一次发出多条射线,爆炸武器可以改用范围检测。这些变化大多局限在武器侧,不需要让每个敌人知道攻击来源。

要进一步提升可维护性,我会把标签判断和组件查找抽成 IDamageable 接口:

public interface IDamageable
{
    void TakeDamage(int damage);
}

之后武器只判断“命中的对象是否可受伤”,僵尸、可破坏物、训练靶都可以实现同一个接口。这样能移除对 Zombie 标签和具体 ZombieController 的强耦合。

后坐力不只是相机偏移

项目里的后坐力同时作用在准星和相机:准星缩放用于提供明显的 UI 反馈,相机则施加一个短暂的随机旋转偏移。代码中用协程控制恢复过程,并在下一次射击时停止上一次的相机后坐力协程,避免多个协程叠加造成画面持续漂移。

这个小细节很实用:视觉反馈可以叠加,但相机控制通常需要有唯一的“当前控制者”。如果每次点击都新增一个恢复协程,连续射击时会出现不可预测的回正时机。

后续若做成正式手感系统,可以从一次性偏移演进到“后坐力曲线 + 累积值 + 恢复速度”:

  • 连射时按照武器曲线累积纵向与横向偏移;
  • 停火后以固定速度回正;
  • 瞄准状态使用更小的曲线;
  • 将随机性限制在可控区间,避免纯随机导致手感不稳定。

敌人生成为什么要有对象池

ZombieManager 维护场景中存活僵尸列表,并用协程定时检查数量。当数量不足时,优先从 Queue<ZombieController> 中取出已死亡的对象;池为空才实例化新对象。敌人死亡后从活跃列表移除,放回池子并隐藏。

这就是对象池最直接的价值:高频死亡和重新生成的对象不必反复 Instantiate、Destroy。对于僵尸、子弹痕迹、血液特效、弹壳和音效源,这类策略都很适合。

不过对象池真正难的不是入队和出队,而是复用前状态重置。仓库中重新启用僵尸后会调用 Init(),这正是正确方向。一个完整的重置清单通常还包括:

  1. 血量、动画状态、NavMesh 目标和移动速度;
  2. 刚体速度、碰撞体、触发器和层级;
  3. 攻击冷却、协程、粒子系统和音效;
  4. 订阅的事件和临时引用。

如果不把这些写成明确的 OnSpawn / OnDespawn 契约,对象池只会把旧状态带进下一次战斗。

从可玩原型到可维护项目

这个 FPS 项目已经把第一人称战斗最关键的骨架跑通:角色状态控制装备,武器基类沉淀共性,射线连接攻击与受伤,僵尸管理器维持敌人数量并复用对象。

若继续迭代,我会优先处理四件事:

  1. 用显式状态机替代分散的状态写入,统一切枪、换弹和开火的合法迁移;
  2. 用 IDamageable 和命中信息对象替代对僵尸标签的直接依赖;
  3. 为对象池定义统一的生成与回收生命周期;
  4. 将武器数值、射速、后坐力曲线和特效配置抽为 ScriptableObject,减少 Prefab 上的手工配置耦合。

原型阶段最重要的不是一次把系统做得“很大”,而是让下一把武器、下一个敌人和下一种命中方式都能以较小代价加入。对 FPS 来说,战斗手感来自很多细节,但长期可维护性来自这些细节背后的边界。

Discussion

评论

加载中
正在检查登录状态…