用 Unity 制作在线 FPS:多人联机、服务器权威与测试

在线 FPS 让多名玩家通过网络进入同一场比赛。本文使用 Unity 官方网络包 **Netcode for GameObjects**(NGO),制作基础功能:以主机或客户端身份启动、跨设备同步的玩家对象、由服务器判定的射击、复活以及击杀计分。

在线游戏最重要的原则是 **服务器权威(server authority)**。客户端可以声明自己开了枪,但不能决定是否命中,也不能决定玩家的血量。下面的代码遵循这一原则。

重要:比赛规则(伤害、死亡、复活、计分)已在本环境中编译并运行,8 项测试全部通过。网络部分(NGO)需要 Unity 编辑器和至少两名玩家,本环境中未运行。请按照步骤自行验证后再使用。

注:代码保留了原始泰语版本中的注释。

需要准备的东西

第 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 NetworkManager 对象 2 NetworkBoot 对象 3 Start Host 按钮 4 Start Client 按钮 5 服务器地址输入框
场景示意图(不是实际截图):1 NetworkManager 对象 2 NetworkBoot 对象 3 Start Host 按钮 4 Start Client 按钮 5 服务器地址输入框

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 预制体

Player 预制体 Inspector 示意图(不是实际截图):1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 Camera Object 栏
Player 预制体 Inspector 示意图(不是实际截图):1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 Camera Object 栏

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 步:在线玩家脚本

它包括三个部分:

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;
        }
    }
}

要点

第 6 步:仅持有者可操作,并拥有摄像机

示意图(不是实际截图):第 3 步中的 Owner Only 和 Camera Object 栏
示意图(不是实际截图):第 3 步中的 Owner Only 和 Camera Object 栏

每位玩家都有一份 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 步:数据如何流动

流程图(不是实际截图):1 客户端 A 发送开火请求 2 服务器判定命中并施加伤害 3 所有客户端收到新的血量
流程图(不是实际截图):1 客户端 A 发送开火请求 2 服务器判定命中并施加伤害 3 所有客户端收到新的血量

当玩家 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. 确认玩家死亡三秒后复活。

常见问题

本文的局限

在向大量玩家发布游戏之前,请先深入学习这些主题。

总结

在线 FPS 依赖服务器权威:客户端发送请求,服务器做出判定,所有人看到的数值只由服务器写入。规则放在可测试的普通类中,网络部分按上面的方式使用 NGO。规则测试在此通过,联机对战需要在 Unity 编辑器中自行验证。