简易联机 FPS 游戏 Demo

简介

这次试用期 demo 的目标是做一个简单的多人在线 fps 游戏.

  1. 移动方面除了 XZ 平面移动之外, Y 轴方向的移动是使用了一种类似机甲喷射上升的行为模式, 这样玩家可以有更久的滞空时间, 更大的操作空间, 当然更多的还是因为我是一名高达粉. 设定上喷射行为具有燃料值, 喷射时会快速消耗燃料值, 其他时刻都会缓慢恢复.

  2. 枪支方面目前实现了步枪和狙击枪, 步枪是连射模式, 狙击枪为单发模式, 另外狙击枪可以开镜瞄准.

  3. 本来打算添加几个简单的 AI, 目标是做成在视野范围内, AI 会主动追击或射击玩家, 没有检测到玩家时, AI 便会在给定的坐标来回巡逻的效果, 目前基本效果已在客户端实现, 但是同步还没有实现.

制作 demo 需要的素材

资源目录

demo 中用到的模型以及 UI 资源是在 Kenney 网站以及 Unity Asset Store 下载的免费资源.

目前 demo 中使用到的包括火焰特效, UI 图标 (FPS), 场景模型 (FPS), 枪械模型 (FPS).

  • AssetsPackages: 存放引用的资源包
    • Fire Effect: 火焰特效
    • FPS Icons: FPS UI 图标
    • FPS Terrains: FPS 场景模型
    • FPS Weapons: FPS 枪械模型
  • Resources: 存放动态资源文件
    • Audios: 音频文件
    • Materials: 材质球
    • Prefabs: 预制体
    • Sprites: 精灵图
  • Scenes: 存放场景文件
  • Scripts: 存放脚本文件

场景构成

场景构成

demo 中的脚本构成

脚本构成

当时写脚本之前, 为了方便后期的维护, 于是尽量将各个功能独立编写, 单独创建了音频管理器, 特效管理器, 游戏管理器, 服务器逻辑等脚本, 下面是每个脚本具体的行为概括.

  • AudioManager: 负责音效资源的管理以及音效资源池中资源的生成与回收.
  • EffectManager: 负责特效资源的管理以及特效资源池中资源的生成与回收.
  • Player: 玩家相关脚本
    • Player: 保存玩家的生命值数据以及与生命值相关的逻辑处理, 包括受伤, 死亡, 重生等.
    • PlayerController: 玩家的控制脚本, 保存玩家运动相关的数据, 包括移动速度, 镜头旋转速度, 燃料量, 燃料的消耗速度与补充速度, 并负责检测玩家的所有输入, 与其他脚本配合共同实现用户逻辑, 是运动逻辑的核心.
    • PlayerMotor: 玩家运动的实质驱动脚本, 包括玩家的 XZ 平面移动, 镜头的上下及左右旋转, 玩家的喷射上升运动等.
    • PlayerSetup: 玩家初始化脚本, 主要功能有对场景摄像机的控制, 取消远程玩家身上的组件, 设置玩家 Layer, 固定鼠标, UI 初始化, 玩家注册等, 由于初始化项较多, 所以有很多与其他脚本的交互.
    • PlayerShoot: 负责玩家射击的相关逻辑, 包括射线检测, 射击特效的控制, 击中特效的控制等.
    • PlayerUI: 负责更新玩家 UI.
    • Weapon: 武器的数据类, 只存储武器数据, 同时关联武器的模型和击中特效, 以达到不同的武器可以使用不同的模型以及击中特效, 方便武器的扩展.
    • PlayerWeapon: 玩家的武器管理类, 管理玩家手中的武器, 并向外部提供与武器交互的方法.
  • ServerManager: 服务器逻辑脚本, 所有和服务器相关的逻辑都在这个脚本中, 主要功能有调用 Client 的受伤方法, 调用 Client 生成射击特效和击中特效的方法等.
  • GameManager: 游戏管理器, 负责初始化地图场景, 管理所有的玩家, 提供玩家注册和取消注册方法, 另外也用来存储游戏的全局数据以及提供一些通用的工具方法.

类图

demo 类图

学习基础知识: UNet

在做自己的 demo 之前学习了如何使用 UNet 编写网络游戏, 使用的是 Unity 官方的 UNet 教程, 做了下面一个简单的 demo.

UNet官方教程

总结一下用到的知识:

  • 远程调用

    • [Command] : CCSR, Client Call Server Run, 使用 Command 修饰的方法是在客户端被调用, 之后在服务器端执行.
    • [ClientRpc]: SCCR, Server Call Client Run, 使用 ClientRpc 修饰的方法是在服务器端被调用, 之后在所有的客户端执行.
  • 数据同步

    使用 [SyncVar] 实现基本类型的数据同步. 这类数据需要在服务器逻辑中进行修改, 之后服务器才能将数据同步给所有 Client. 另外 SyncVar 还可以使用钩子方便地触发方法.

  • isLocalPlayer: 区分当前执行逻辑的是否是本地玩家.

  • isServer: 区分当前执行逻辑的是否是服务器端.

  • [Client]: 可以修饰代码, 使其仅在 Client 执行.

第一视角的实现

从网上查阅资料得知, 可以通过让玩家携带一个用于渲染玩家第一视角的相机来实现第一视角. 将相机设置为玩家的子物体, 这样只要对玩家模型进行左右旋转时, 其下的子物体相机也会跟着左右旋转.

上下视角的旋转就是通过旋转相机来实现. 同时 demo 中将武器设置为玩家视角相机的子物体, 这样上下旋转相机时武器也会跟着进行旋转.

实现第一视角时遇到的问题

  1. 为了实现较好的第一视角, 枪支和玩家重合了, 虽然本地玩家自身的视角没有问题, 但是联机之后其他玩家看到自己时就会相当奇怪.

  2. 当人物离墙面特别近的时候, 枪管会出现穿模现象. []

解决这两个问题的办法是使用了两个摄像机, 这两个摄像机一个渲染除武器以外的第一视角, 另一个仅渲染武器.

运动逻辑的实现和同步

之后实现运动逻辑, 运动逻辑的运行时序大概是这样子的:

运动逻辑

喷射效果实现

喷射效果使用了 Configuration Joint 组件实现的. 很早之前做的一个愤怒的小鸟 demo 中用到过, 这里就拿来直接用了. 设置好锚点, 拉力, 阻力.

实现喷射效果时遇到的问题

最开始的代码是下面这个样子写的, 出现了一个不该出现的效果: 玩家只要一直按着喷射按钮, 没有燃料的时候, 玩家便会一直悬浮在那个高度.

喷射物理效果

排查了好久才彻底理清思路, 在赋予上升力之前再加一层判断就解决了.

喷射物理效果

喷射物理效果

射击逻辑的实现和同步

射击逻辑

问题

由于直接使用的射线检测, 并没有使用子弹, 所以接下来需要添加子弹的实现.

简易资源池实现

实现射击音效的时候, 由于每次开枪的音效必须独立播放, 所以最开始使用的是 AudioSource.PlayClipAtPoint() 方法实现的. 不过发现每次播放音效都会生成和销毁游戏物体, 比较耗资源, 同样在生成击中特效的时候也会出现同样大量创建销毁的情况, 于是学习了一下如何设计一个简单的资源池.

资源池流程图

  • 使用 Queue 队列保存所有的资源, 初始化时设置好初始容量, 同时也可以创建一部分资源或不创建.

  • 实现两个方法: 获取资源 (Get), 回收资源 (ExeRecycle).

    • GetAudioSource()

      首先检测队列中是否还具有资源, 如果有, 则直接取出并启用资源, 没有则新建资源.

      GetAudioSource

    • ExeRecycleCoroutine()

      首先使用协程等待一段时间, 之后将资源禁用, 回收至队列中.

      ExeRecycleCoroutine

[] 协程中的 WaitForSeconds 最好提前定义, 否则每次迭代进来都会重新申请一次内存, 造成内存泄漏.

UI 系统的实现

我是用的方法是 UI 类中有一个 Update 方法以及一整份所有需要展示的数据, 同时具有 Player, PlayerWeapon, PlayerController 的引用, 所以所有的逻辑都是只在 PlayerUI 中实现的.

问题是 UI 的展示并不需要每时每刻进行更新, 这里可以改进为触发时更新, PlayerUI 向外提供修改 UI 数据的方法, 外部只要在响应的时机使用相应的方法修改数据即可, 数据的更新会直接反映到 UI 上.

喷射器 UI 的实现

本来打算使用 Slider 组件实现的, 后来在网上看教程的时候偶尔遇到了类似 UI 的制作, 教程并没有使用 Slider, 而是使用两张图片, 通过修改子图片的 Pivot 实现的, 我也是使用了这种方法.

狙击镜 UI 的实现

狙击镜是通过修改相机的 fieldOfView 实现的, fieldOfView 值越小, 看到的物体越大.

[] 实现的时候需要注意的是当开镜之后, 相机的旋转速度就必须变小, 不然过于灵敏, 玩家很难瞄准.

其他的一些问题

  • NetworkAnimator 不同步触发器类型数据.

    • 解决: 将射击的触发改为使用 bool 类型, 因为射击的逻辑是使用动画事件触发的, 所以在触发的事件中将 bool 数据设置回 false, 实现一个简易的触发器.
  • 将 ServerManager 独立至一个新的游戏物体上, 并挂载 NetworkIdentity, Host 上的 Client 数据可以正常同步, 但是其他 Client 无法进行 [Command] 调用.

    • 未解决. 目前是将 ServerManager 挂载到了 Player 身上.
  • 无论是 Host 还是 Client, Stop 之后无法重新连接.

    • 未解决.

杂项

  • [SerializeField] 特性可以将私有的基础字段显示在 Inspector 面板中.
  • [Header("")] 特性可以在 Unity 的 Inspector 面板中显示标签信息.
  • [Tooltip("")] 特性可以让 Inspector 面板中的数据当鼠标悬浮时出现提示.
  • [RequireComponent(typeof(Animator))] 特性可以让脚本被挂载时必须同时挂载指定的组件.
  • 属性和字典无法在 Inspector 面板中序列化显示.
  • 对 N 取余可以将一个整型数限制在 [0, N]] 之间.