用 Unity 制作在线 FPS:多人联机、服务器权威与测试
在线 FPS 让多名玩家通过网络进入同一场比赛。本文使用 Unity 官方网络包 **Netcode for GameObjects**(NGO),制作基础功能:以主机或客户端身份启动、跨设备同步的玩家对象、由服务器判定的射击、复活以及击杀计分。
在线游戏最重要的原则是 **服务器权威(server authority)**。客户端可以声明自己开了枪,但不能决定是否命中,也不能决定玩家的血量。下面的代码遵循这一原则。
重要:比赛规则(伤害、死亡、复活、计分)已在本环境中编译并运行,8 项测试全部通过。网络部分(NGO)需要 Unity 编辑器和至少两名玩家,本环境中未运行。请按照步骤自行验证后再使用。
注:代码保留了原始泰语版本中的注释。
需要准备的东西
- Unity Hub 和最新的 LTS 版 Unity 编辑器
- 按照《用 Unity 制作第一人称射击游戏》完成的 FPS 项目(包含 `MouseLook`、`FPSMovement`、`Weapon`、`Target`)
- 跨设备游玩时需要互联网或同一局域网
第 1 步:安装 Netcode for GameObjects
1. 打开 FPS 项目,进入 **Window → Package Manager**。
2. 选择 **Unity Registry**,搜索 **Netcode for GameObjects**,点击 **Install**。
3. 按 NGO 的建议安装 **Unity Transport**,通常会自动安装。
4. 进入 **Edit → Project Settings → Player**,确认 Active Input Handling 仍为 Both 或 Input Manager (Old),与 FPS 文章一致。
第 2 步:创建 NetworkManager 与开始菜单
1. 在 Hierarchy 中右键,选择 **Create Empty**,命名为 `NetworkManager`。
2. 点击 **Add Component**,添加 **Network Manager** 和 **Unity Transport**。
3. 再创建一个空对象 `NetworkBoot`,添加下面的 `NetworkBoot` 脚本。
4. 按 **Play**,左上角会出现两个按钮。
using Unity.Netcode;
using UnityEngine;
// ปุ่มเริ่มเกม: เลือกเป็นโฮสต์ (เซิร์ฟเวอร์ + ผู้เล่น) หรือไคลเอนต์ (เข้าร่วม)
// วางบน GameObject ชื่อ NetworkBoot ในฉาก และให้มี NetworkManager อยู่ในฉากด้วย
public class NetworkBoot : MonoBehaviour
{
[SerializeField] private string address = "127.0.0.1"; // ที่อยู่ของโฮสต์ (ใส่ IP ของเครื่องโฮสต์เมื่อเล่นข้ามเครื่อง)
private void OnGUI()
{
if (NetworkManager.Singleton.IsClient || NetworkManager.Singleton.IsServer) return;
GUILayout.BeginArea(new Rect(10, 10, 220, 120));
if (GUILayout.Button("Start Host (server + player)")) NetworkManager.Singleton.StartHost();
if (GUILayout.Button("Start Client (join)")) NetworkManager.Singleton.StartClient();
address = GUILayout.TextField(address);
GUILayout.EndArea();
}
}
此输入框只是供测试者填写的文本框。跨设备游玩时,需要在 **Unity Transport** 组件中设置真正的地址。
第 3 步:制作 Player 预制体
1. 从 FPS 项目的 `Player` 对象开始(包含 Character Controller、FPSMovement、MouseLook,以及带 Muzzle 的子摄像机)。
2. 添加 **Network Object**,告诉 NGO 这是需要同步的对象。
3. 添加 **Network Transform**,把位置和旋转同步到每台设备。
4. 删除 `Weapon` 组件,射击逻辑移入 `PlayerNetwork`。
5. 添加 `PlayerNetwork` 和 `OwnerSetup` 脚本(见下文)。
6. 把 `Player/Camera/Muzzle` 拖入 `PlayerNetwork` 的 **Muzzle** 栏,把子摄像机拖入 `OwnerSetup` 的 **Camera Object** 栏。
7. 在 `OwnerSetup` 的 **Owner Only** 中加入 `FPSMovement` 和 `MouseLook`,使其只由本人控制。
8. 把 Hierarchy 中的 `Player` 拖到 `Assets/Prefabs/` 制成预制体,然后从场景中删除。
9. 选中 `NetworkManager`,把该预制体拖入 **Player Prefab** 栏。
第 4 步:用纯 C# 编写比赛规则
规则放在一个不使用 `UnityEngine` 的普通类中,因此无需打开编辑器即可测试。服务器使用的正是这段逻辑。
// กติกาของแมตช์ เขียนเป็น C# ธรรมดา (ไม่ใช้ UnityEngine) เพื่อให้ทดสอบได้โดยไม่ต้องเปิด Unity
// เซิร์ฟเวอร์เป็นผู้ตัดสินทุกอย่างด้วยกติกานี้ ไคลเอนต์ไม่มีสิทธิ์เปลี่ยนเลือดหรือคะแนนเอง
public class MatchRules
{
public const int MaxHealth = 100;
public const int DamagePerShot = 25;
public const int KillsToWin = 10;
public const float RespawnSeconds = 3f;
public int Health { get; private set; }
public int Kills { get; private set; }
public int Deaths { get; private set; }
public float RespawnTimer { get; private set; }
public bool IsDead => Health <= 0;
public MatchRules()
{
Health = MaxHealth;
}
// คืน true ถ้าการยิงนี้ทำให้ผู้ถูกยิงตาย
public bool ApplyDamage(int amount)
{
if (IsDead) return false;
Health -= amount;
if (Health <= 0)
{
Health = 0;
Deaths++;
RespawnTimer = RespawnSeconds;
return true;
}
return false;
}
// เรียกทุกเฟรม (หรือทุกรอบของเซิร์ฟเวอร์) คืน true เมื่อครบเวลาและฟื้นคืนชีพแล้ว
public bool Tick(float deltaSeconds)
{
if (!IsDead) return false;
RespawnTimer -= deltaSeconds;
if (RespawnTimer > 0f) return false;
Health = MaxHealth;
RespawnTimer = 0f;
return true;
}
public void AddKill()
{
Kills++;
}
public bool HasWon()
{
return Kills >= KillsToWin;
}
}
第 5 步:在线玩家脚本
它包括三个部分:
- **持有者一侧** 读取开火按键,并通过 `ServerRpc` 向服务器发送请求。
- **服务器一侧** 进行射线检测并计算伤害。
- **所有人看到的数值** 是 `NetworkVariable`,只有服务器能写入。
using Unity.Netcode;
using UnityEngine;
// ผู้เล่นออนไลน์: ผู้เล่นเจ้าของเครื่องส่งคำสั่งยิง แต่เซิร์ฟเวอร์เป็นคนตรวจการยิง คิดดาเมจ และนับคะแนน
// ต้องติดอยู่บน Player prefab พร้อม NetworkObject และ NetworkTransform
public class PlayerNetwork : NetworkBehaviour
{
[SerializeField] private Transform muzzle; // ปากกระบอกปืน (อยู่ใต้กล้องของผู้เล่น)
[SerializeField] private float range = 100f;
[SerializeField] private float fireInterval = 0.2f;
[SerializeField] private LayerMask hitMask; // เลเยอร์ที่ยิงโดน (ผู้เล่นและกำแพง)
[SerializeField] private Transform[] spawnPoints; // จุดเกิดที่เซิร์ฟเวอร์เลือกตอนฟื้นคืนชีพ
// เลือดและคะแนน: ทุกเครื่องอ่านได้ แต่มีเพียงเซิร์ฟเวอร์ที่เขียนค่าได้
public NetworkVariable<int> Health = new NetworkVariable<int>(
MatchRules.MaxHealth, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
public NetworkVariable<int> Kills = new NetworkVariable<int>(
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
private float nextFireTime;
private float respawnTimer;
public bool IsAlive => Health.Value > 0;
private void Update()
{
// ฝั่งผู้เล่น: รับปุ่มยิง แล้วส่งคำขอไปยังเซิร์ฟเวอร์
if (IsOwner && Input.GetMouseButtonDown(0) && Time.time >= nextFireTime && IsAlive)
{
nextFireTime = Time.time + fireInterval;
FireServerRpc(muzzle.position, muzzle.forward);
}
// ฝั่งเซิร์ฟเวอร์: นับเวลาฟื้นคืนชีพ
if (IsServer && Health.Value <= 0)
{
respawnTimer -= Time.deltaTime;
if (respawnTimer <= 0f) Respawn();
}
}
// ทำงานบนเซิร์ฟเวอร์เท่านั้น เมื่อผู้เล่นส่งคำขอยิง
[ServerRpc]
private void FireServerRpc(Vector3 origin, Vector3 direction)
{
if (!IsAlive) return; // คนตายยิงไม่ได้
if (Physics.Raycast(origin, direction, out RaycastHit hit, range, hitMask))
{
PlayerNetwork target = hit.collider.GetComponentInParent<PlayerNetwork>();
if (target != null && target != this)
{
target.TakeDamageOnServer(MatchRules.DamagePerShot, OwnerClientId);
}
}
}
// เรียกได้เฉพาะบนเซิร์ฟเวอร์ คิดดาเมจและให้คะแนนผู้ยิงเมื่อฆ่าสำเร็จ
public void TakeDamageOnServer(int amount, ulong shooterClientId)
{
if (!IsServer || !IsAlive) return;
int remaining = Health.Value - amount;
Health.Value = remaining < 0 ? 0 : remaining; // ใช้การเช็คเอง เพราะ Mathf.Max รับ float
if (Health.Value > 0) return;
respawnTimer = MatchRules.RespawnSeconds;
var shooter = NetworkManager.Singleton.ConnectedClients[shooterClientId].PlayerObject;
if (shooter != null)
{
shooter.GetComponent<PlayerNetwork>().Kills.Value++;
}
}
private void Respawn()
{
Health.Value = MatchRules.MaxHealth;
if (spawnPoints != null && spawnPoints.Length > 0)
{
transform.position = spawnPoints[Random.Range(0, spawnPoints.Length)].position;
}
}
}
要点
- 方法名必须以 `ServerRpc` 结尾,NGO 凭这个后缀识别发往服务器的请求。
- `NetworkVariableWritePermission.Server` 使客户端即使修改自己的代码,也无法改变血量或分数。
- “死亡的玩家不能开枪”的检查在服务器上进行,而不是客户端,因为客户端可能被修改。
- 伤害使血量归零时,服务器立即把击杀记给开枪的玩家。
- 代码避免对 int 使用 `Mathf.Max`,这是本系列其他文章中出现过的错误。
第 6 步:仅持有者可操作,并拥有摄像机
每位玩家都有一份 Player 预制体。如果不做这一步,一个玩家可能会控制另一个人的角色,并出现多余的摄像机。`OwnerSetup` 负责处理这一点。
using Unity.Netcode;
using UnityEngine;
// เปิดการควบคุมและกล้องเฉพาะผู้เล่นเจ้าของตัวละครนั้น ผู้เล่นคนอื่นที่เห็นตัวละครนี้จะไม่มีกล้องและไม่รับปุ่ม
public class OwnerSetup : NetworkBehaviour
{
[SerializeField] private Behaviour[] ownerOnly; // สคริปต์ที่ต้องเปิดเฉพาะเจ้าของ เช่น FPSMovement และ MouseLook
[SerializeField] private GameObject cameraObject; // กล้องลูกของผู้เล่น
public override void OnNetworkSpawn()
{
foreach (Behaviour script in ownerOnly)
{
script.enabled = IsOwner;
}
if (cameraObject != null)
{
cameraObject.SetActive(IsOwner);
}
}
}
第 7 步:数据如何流动
当玩家 A 开枪并击中玩家 B 时:
1. A 点击鼠标,A 的客户端调用 `FireServerRpc`,并附上枪口位置和方向。
2. 服务器收到后,确认 A 仍然存活,然后从该位置进行射线检测。
3. 若射线击中 B,服务器调用 `TakeDamageOnServer`,减少 B 的血量。
4. B 的血量是 `NetworkVariable`,因此 NGO 会将其同步到每台设备,所有人看到的数字相同。
5. 若 B 的血量归零,服务器把击杀记给 A,并启动 B 的复活计时器。
第 8 步:测试
比赛规则(在本环境中运行)
测试文件 `MatchRulesTests.cs`(见泰语版本)在不使用 Unity 的情况下检查伤害、死亡、复活时间和获胜条件。用较新的 C# 编译器把 `MatchRules.cs` 和 `MatchRulesTests.cs` 一起编译(例如 `csc -langversion:latest -out:MatchRulesTests.exe *.cs`),然后运行生成的程序。
PASS one shot: health drops from 100 to 75
PASS four shots: player dies on the fourth
PASS four shots: death is counted
PASS damage while dead: no extra death counted
PASS respawn: still dead after 1 second
PASS respawn: back to full health after 3 seconds
PASS win: not yet at 9 kills
PASS win: wins at 10 kills
Passed 8 / Failed 0
联机对战(请在 Unity 编辑器中进行)
此项在本环境中未运行,请自行验证:
1. 在编辑器中按 **Play**,点击 **Start Host**,你就是第一名玩家。
2. 在同一项目中再打开一个 Unity 窗口,或使用 **ParrelSync**(一款常用于开启多个编辑器实例的工具),点击 **Start Client**。
3. 确认双方都能看到对方,并且每个人只能移动自己的角色。
4. 对对方开枪四次,确认两个屏幕上的血量都下降,并且开枪者获得一次击杀。
5. 确认玩家死亡三秒后复活。
常见问题
- **另一名玩家没有出现:** 检查 Player 预制体已拖入 NetworkManager 的 Player Prefab 栏,并且带有 Network Object。
- **能控制别人的角色:** 检查 FPSMovement 和 MouseLook 是否在 OwnerSetup 的 Owner Only 列表中。
- **射击没有命中:** 检查 Hit Mask 包含玩家和墙壁,并且 Muzzle 朝前。
- **控制台提示 RPC 名称无效:** 检查方法名是否以 `ServerRpc` 结尾。
- **无法跨设备加入:** 检查 Unity Transport 的地址与主机 IP 一致,并且主机的防火墙允许所用端口。
- **血量不同步:** 检查只有服务器在写入 `Health.Value`。
本文的局限
- 射击是单次射线检测,没有客户端预测。延迟高的玩家会感到反应慢。真实游戏会加入延迟补偿(lag compensation)。
- 移动同步只使用 Network Transform,没有移动预测。
- 没有大厅、房间系统或匹配功能。
- 反作弊并不完整。
在向大量玩家发布游戏之前,请先深入学习这些主题。
总结
在线 FPS 依赖服务器权威:客户端发送请求,服务器做出判定,所有人看到的数值只由服务器写入。规则放在可测试的普通类中,网络部分按上面的方式使用 NGO。规则测试在此通过,联机对战需要在 Unity 编辑器中自行验证。