【unity游戏开发——网络】unity+PurrNet联机实战,实现一个多人对战类《CS/CSGO》《CF/穿越火线》《PUBG/吃鸡》的FPS射击游戏

文章目录

一、前言

如果你还不了解PurrNet的可以先去看:【unity游戏开发------网络】一个免费、强大、性能优越且高度模块化的 Unity 网络库 ------ PurrNet的使用介绍

二、环境配置

环境这种很简单,大家自由发货就行了

三、第一人称人物控制

1、配置玩家

新增一个空物体,命名为Player,在Player下面新增胶囊体,定义为身体body,记得去掉碰撞体,我没不需要

再将主相机Main Camera移动到人物头部位置

2、角色移动跳跃、视角控制脚本

这里我们使用Character Controller进行角色移动控制,如果还不了解的可以去查看:【零基础入门unity游戏开发------unity3D篇】unity CharacterController 3D角色控制器最详细的使用介绍,并实现俯视角、第三人称角色控制(附项目源码)

csharp 复制代码
using System;
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class PlayerController : MonoBehaviour
{
    [Header("移动设置")]
    [SerializeField] private float moveSpeed = 5f; // 基础移动速度
    [SerializeField] private float sprintSpeed = 8f; // 冲刺速度
    [SerializeField] private float jumpForce = 1f; // 跳跃力度
    [SerializeField] private float gravity = -9.81f; // 重力值
    [SerializeField] private float groundCheckDistance = 0.2f; // 地面检测距离

    [Header("视角设置")]
    [SerializeField] private float lookSensitivity = 2f; // 视角灵敏度
    [SerializeField] private float maxLookAngle = 80f; // 最大视角仰角/俯角

    [Header("引用组件")]
    [SerializeField] private Camera playerCamera; // 玩家摄像机

    private CharacterController characterController; // 角色控制器组件
    private Vector3 velocity; // 当前速度(包含垂直方向的速度)
    private float verticalRotation = 0f; // 垂直方向视角旋转累计值

    private void OnDisable()
    {
        // 当脚本被禁用时:解锁鼠标并显示
        Cursor.lockState = CursorLockMode.None;
        Cursor.visible = true;
    }

    private void Start()
    {
        // 初始化:锁定鼠标到屏幕中心并隐藏
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
        characterController = GetComponent<CharacterController>();

        // 安全检查:确保摄像机已赋值
        if (playerCamera == null)
        {
            enabled = false; // 禁用脚本
            return;
        }
    }

    private void Update()
    {
        // 每帧更新移动和视角
        HandleMovement();
        HandleRotation();
    }

    /// <summary>
    /// 处理玩家移动(包括跳跃、重力)
    /// </summary>
    private void HandleMovement()
    {
        // 地面检测
        bool isGrounded = IsGrounded();
        
        // 如果在地面且y轴速度为负值(下降中),重置垂直速度
        if (isGrounded && velocity.y < 0)
        {
            velocity.y = -2f; // 轻微负值确保贴地
        }

        // 获取WASD输入
        float horizontal = Input.GetAxisRaw("Horizontal"); // A/D 左右
        float vertical = Input.GetAxisRaw("Vertical"); // W/S 前后

        // 计算移动方向(基于玩家当前朝向)
        Vector3 moveDirection = transform.right * horizontal + transform.forward * vertical;
        moveDirection = Vector3.ClampMagnitude(moveDirection, 1f); // 限制对角线移动速度,限制向量的最大长度(模长)

        // 冲刺检测:按住左Shift时使用冲刺速度
        float currentSpeed = Input.GetKey(KeyCode.LeftShift) ? sprintSpeed : moveSpeed;
        
        // 执行水平移动
        characterController.Move(moveDirection * currentSpeed * Time.deltaTime);

        // 跳跃检测:按下空格键且在地面时执行跳跃
        if (Input.GetButtonDown("Jump") && isGrounded)
        {
            // 根据跳跃力度和重力计算初始垂直速度
            velocity.y = Mathf.Sqrt(jumpForce * -2f * gravity);
        }

        // 应用重力
        velocity.y += gravity * Time.deltaTime;
        
        // 执行垂直方向移动(跳跃/下落)
        characterController.Move(velocity * Time.deltaTime);
    }

    /// <summary>
    /// 处理鼠标视角旋转
    /// </summary>
    private void HandleRotation()
    {
        // 获取鼠标输入
        float mouseX = Input.GetAxis("Mouse X") * lookSensitivity; // 水平旋转
        float mouseY = Input.GetAxis("Mouse Y") * lookSensitivity; // 垂直旋转

        // 计算垂直旋转并限制角度
        verticalRotation -= mouseY; // 注意:减号用于反转Y轴
        verticalRotation = Mathf.Clamp(verticalRotation, -maxLookAngle, maxLookAngle);
        
        // 应用垂直旋转到摄像机(只旋转摄像机,不旋转玩家身体)
        playerCamera.transform.localRotation = Quaternion.Euler(verticalRotation, 0f, 0f);

        // 应用水平旋转到玩家游戏对象(整个玩家身体旋转)
        transform.Rotate(Vector3.up * mouseX);
    }

    /// <summary>
    /// 检测玩家是否在地面
    /// </summary>
    /// <returns>是否在地面</returns>
    private bool IsGrounded()
    {
        // 从玩家脚部位置向下发射射线检测地面
        return Physics.Raycast(transform.position + Vector3.up * 0.03f, Vector3.down, groundCheckDistance);
    }

#if UNITY_EDITOR
    /// <summary>
    /// 在编辑器中绘制地面检测射线的可视化(仅选中对象时显示)
    /// </summary>
    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawRay(transform.position + Vector3.up * 0.03f, Vector3.down * groundCheckDistance);
    }
#endif
}

3、配置

4、运行查看效果

四、配置联网

1、添加NetworkManager组件

配置

Network Prefabs可以查找任何非网络对象并将其添加进来,因此即使是没有网络标识脚本的对象也可以通过网络生成。这将自动为其添加 PrefabLink 脚本。

2、修改角色控制代码,变成网络角色,限制只控制自己的玩家,且不是自己的玩家去掉相机


注意:

  • OnSpawned 会在对象首次被网络看到时调用。如果你是主机(既是服务端又做客户端),这个 OnSpawned会被调用两次,一次作为服务器,一次作为客户端,所以通常我们会引入asServer参数,不过这里加不加其实都无所谓。

    csharp 复制代码
    protected override void OnSpawned(bool asServer)
    {
    	base.OnSpawned(asServer);
    	
        // 在主机设置的情况下,我们不希望这运行两次。
        if (asServer)
            return;
    }

    注意asServer要和isServer做好区分,主机调用的两次isServer都返回true,所以这里用isServer不太合适,主机会出现问题。

  • isOwner:轻松检查自己是否是所有者

    csharp 复制代码
    // 轻松检查自己是否是所有者
    if(isOwner) 
    {
        // 如果进入这里,说明我们是此对象的所有者
    }
  • 只有所有者启用此脚本以运行 Update()

    csharp 复制代码
    enabled = isOwner;

3、将玩家做成预制体,并删除场景中的玩,放在Prefabs里,它就会自动出现在NetworkPrefabs这里了

添加玩家生成组件

随便配置几个配置生成点

4、给玩家添加Network Transform网络变换组件

这个组件能自动同步所附加游戏对象的位置、旋转、缩放和父级变换。以便其他玩家能够看到我们移动,添加给玩家,同步位置

5、效果

运行,就可以看到自动生成了玩家,可以看到客户端和服务端都开启正常

如果想在本地快速测试联网效果,最好的办法就是使用ParrelSync插件,新开一个编辑器模拟客户端效果,具体使用可以参考:【推荐100个unity插件】在unity中多开同一个游戏项目,无需构建项目即可测试多人游戏------ParrelSync插件

五、使用Cinemachine虚拟相机

如果你还不懂了解如何使用虚拟相机,可以参考:【unity知识】最新的虚拟相机Cinemachine3简单使用介绍

1、安装Cinemachine插件

2、配置

把主相机拿出来,

在角色身上新增Cinemachine,一直放在头部,默认禁用

3、修改人物控制代码,改成虚拟相机的控制

不要忘记配置虚拟相机

4、运行效果,和之前一样

六、动画同步

1、替换成人物模型

将body替换成人物模型,模型这里就不提供了,网上有很多,查找自己喜欢的就行

2、配置动画控制器

记得取消根运动

添加混合树,不懂混合树的可以参考:【unity游戏开发入门到精通------动画篇】动画混合(Blend Tree)

3、添加Network Animator网络动画同步插件

同步方式其实有两种:

  • 方式一(自动同步):如果你的动画系统(Animator)已经在本地更新参数,开启 AutoSyncParameters 会让 Network Animator 自动检测并同步这些变化到网络。不过它不会自动同步"Trigger触发器"参数。
  • 方式二(手动同步):关闭 AutoSyncParameters,然后通过代码显式调用 Network Animator 的方法来同步动画状态。

方式一很方便吗,但是有一些缺点,所以一般我没都会选择方式二,关闭 AutoSyncParameters,保持 Owner Auth开启。(Owner 有权限触发动画)

注意:Network Animator组件允许你自动同步动画器中的任何参数值。该组件需要与动画器放在同一个游戏对象上,否则无法工作。

4、修改PlayerController角色控制代码

csharp 复制代码
[SerializeField] private NetworkAnimator animator;

private void HandleMovement()
{
	//...
	//动画
    animator.SetFloat("Horizontal", Input.GetKey(KeyCode.LeftShift) ? horizontal * 2 : horizontal);
    animator.SetFloat("Vertical", Input.GetKey(KeyCode.LeftShift) ? vertical * 2 : vertical);
}

记得配置

5、效果

七、不渲染自己的角色,只透射阴影

目前我们的角色,在别的玩家视角渲染没有问题,但是我们的视角会有点遮挡

1、我们可以动态的修改网格的shadowCastingMode参数

2、修改PlayerController脚本

csharp 复制代码
[SerializeField] private List<Renderer> renderers;

// OnSpawned 会在对象首次被网络看到时调用。如果你是主机,这个 OnSpawned会被调用两次,一次作为服务器,一次作为客户端
protected override void OnSpawned(bool asServer)
{
    base.OnSpawned(asServer);

    // 在主机设置的情况下,我们不希望这运行两次。
	if (asServer) return;

    // 只有所有者启用此脚本以运行 Update()
    enabled = isOwner;
    
    playerCamera.gameObject.SetActive(isOwner);

    if (isOwner)
    {
        // 本机仅渲染阴影
        foreach (Renderer renderer in renderers)
        {
            renderer.shadowCastingMode = ShadowCastingMode.ShadowsOnly;
        }
    }
}

3、将人物身体网格配置给renderers

4、效果

八、射击与同步血量系统

1、创建两个层级,自己和别的玩家做区分

2、新增玩家生命值脚本

csharp 复制代码
using PurrNet;
using UnityEngine;

// 玩家生命值组件 - 基于网络的NetworkBehaviour
public class PlayerHealth : NetworkBehaviour
{
    // 网络同步变量:生命值,初始值为100
    // 该值在服务端修改后会自动同步到所有客户端
    [SerializeField] private SyncVar<int> health = new(100);
    
    // 层级设置:用于区分本地玩家和其他玩家,避免对自己造成伤害
    // selfLayer: 本地玩家所在的层级
    // otherLayer: 其他玩家所在的层级
    [SerializeField] private int selfLayer, otherLayer;
    
    // 只读属性:提供外部访问生命值的接口
    public int Health => health;
    
    // 重写OnSpawned方法:当玩家对象在网络上生成时调用
    protected override void OnSpawned(bool asServer)
    {
        base.OnSpawned(asServer);

        if (asServer) return;

        // 确定实际层级:如果是本地玩家则用selfLayer,否则用otherLayer
        // 这通常用于摄像机剔除、碰撞过滤等
        var actualLayer = isOwner ? selfLayer : otherLayer;
        
        // 递归设置整个游戏对象及其所有子对象的层级
        SetLayerRecursive(gameObject, actualLayer);
    }
    
    // 递归设置层级方法
    // obj: 要设置层级的游戏对象
    // layer: 要设置的层级ID
    private void SetLayerRecursive(GameObject obj, int layer)
    {
        // 设置当前对象的层级
        obj.layer = layer;
        
        // 遍历所有子对象并递归设置层级
        foreach (Transform child in obj.transform)
        {
            SetLayerRecursive(child.gameObject, layer);
        }
    }
    
    // 服务器远程过程调用:客户端调用此方法来修改生命值
    // requireOwnership:false 表示任何客户端都可以调用,无需对象所有权
    [ServerRpc(requireOwnership: false)]
    public void ChangeHealth(int amount)
    {
        // 在服务端修改生命值
        health.value += amount;

        //生命值范围限制
        health.value = Mathf.Clamp(health.value, 0, 100);

        //死亡
        if (health.value <= 0) Die();
    }

    private void Die()
    {
        Debug.Log("死亡");
        //销毁玩家
        Destroy(gameObject);
    }
}

3、配置

注意图层id要对应上

4、运行游戏发现

自动配置对应的层级

九、射击

1、新增枪代码

csharp 复制代码
using PurrNet;
using UnityEngine;

/// <summary>
/// 枪支网络行为组件
/// 负责处理枪支的射击逻辑
/// </summary>
public class Gun : NetworkBehaviour
{
    [SerializeField] private Transform cameraTransform;// 主相机变换组件,用于确定射击方向
    [SerializeField] private LayerMask hitLayer;// 可击中层的掩码,用于射线检测
    [SerializeField] private float range = 20f;// 射击射程(单位:米)
    [SerializeField] private int damage = 10;// 伤害值

    /// <summary>
    /// 当网络对象生成时调用
    /// </summary>
    protected override void OnSpawned()
    {
        base.OnSpawned();

        // 只允许本地玩家控制自己的枪支
        enabled = isOwner;
    }

    private void Update()
    {
        // 检测鼠标左键是否按下(射击输入)
        if (!Input.GetKeyDown(KeyCode.Mouse0))
            return;

        // 执行射线检测,从相机位置向前方发射射线
        if (!Physics.Raycast(cameraTransform.position, cameraTransform.forward, out var hit, range, hitLayer))
        {
            // 如果没有击中任何物体,直接返回
            return;
        }

        if(!hit.transform.TryGetComponent(out PlayerHealth playerHealth)) return;

        //扣血
        playerHealth.ChangeHealth(-damage);
        
        // 输出调试信息:显示击中的物体名称
        Debug.Log($"击中: {hit.transform.name}");
    }
} 

2、添加枪,放在合适的位置

3、挂载Gun脚本到枪手臂上

记得排除自己的玩家层

4、手臂控制和相机的旋转同步

目前的手臂Y轴视角,没有跟随我们相机旋转,我们可以给手臂新增一个父级,新增脚本控制它和相机的旋转同步

模拟相机的旋转

csharp 复制代码
using PurrNet;
using UnityEngine;

/// <summary>
/// 旋转同步组件
/// 用于将指定对象的旋转同步到当前对象
/// </summary>
public class RotationMimic : NetworkBehaviour
{
    
    // 要同步旋转的目标对象
    [SerializeField] private Transform mimicObject;
    
    /// <summary>
    /// 当网络对象生成时调用
    /// </summary>
    protected override void OnSpawned()
    {
        base.OnSpawned();
        
        // 只允许对象的所有者(本地玩家)执行同步逻辑
        // 这样可以避免多个客户端同时修改同一对象
        enabled = isOwner;
    }

    private void Update()
    {
        if (!mimicObject)
        {
            Debug.LogWarning($"RotationMimic: {gameObject.name} 的 mimicObject 未分配!");
            return;
        }
        
        transform.rotation = mimicObject.rotation;
    }
}

5、挂载脚本

注意设置y轴和相机一样的高度,枪设为它的子集

6、效果

这样枪就跟着视角旋转了

7、网络同步

为了让网络同步,我们可以给手臂父物体添加Network Transform组件,我们不需要同步位置和缩放比例

记得去除枪和手臂的碰撞体(如果有的话),防止干扰,比如打中了枪

8、效果

点击鼠标左键射击,会打印击中的物体名称

十、玩家生命值 UI 与玩家死亡

1、游戏视图管理器

csharp 复制代码
using UnityEngine;
using System.Collections.Generic;
using PurrNet;

/// <summary>
/// 游戏视图管理器 - 管理所有UI视图的显示和隐藏
/// 单例模式,通过InstanceHandler管理
/// </summary>
public class GameViewManager : MonoBehaviour
{   
    /// <summary>
    /// 所有可管理的视图列表
    /// </summary>
    [Header("视图设置")]
    [Tooltip("需要管理的所有视图列表")]
    [SerializeField] private List<View> allViews = new List<View>(); // 所有视图列表

    /// <summary>
    /// 视图类型到视图实例的映射(提高查找效率)
    /// </summary>
    private Dictionary<System.Type, View> _viewDictionary = new Dictionary<System.Type, View>();
    
    /// <summary>
    /// 默认显示的视图
    /// </summary>
    [Header("默认视图")]
    [Tooltip("游戏开始时默认显示的视图")]
    [SerializeField] private View defaultView; // 默认视图
    
    
    
    #region Unity事件函数
    
    /// <summary>
    /// 初始化函数 - 物体创建时调用
    /// </summary>
    private void Awake()
    {
        // 注册当前实例到实例处理器(单例模式)
        InstanceHandler.RegisterInstance(this);
        
        // 初始化所有视图:隐藏所有视图,然后显示默认视图
        foreach (var view in allViews)
        {
            _viewDictionary.Add(view.GetType(), view);
            
            HideViewInternal(view); // 先隐藏所有视图
        }
        
        // 显示默认视图
        ShowViewInternal(defaultView);
    }
    
    /// <summary>
    /// 销毁函数 - 物体销毁时调用
    /// </summary>
    private void OnDestroy()
    {
        // 从实例处理器中注销当前实例
        InstanceHandler.UnregisterInstance<GameViewManager>();

        _viewDictionary.Clear();
    }
    
    #endregion
    
    #region 公共接口方法
    
    /// <summary>
    /// 显示指定类型的视图
    /// </summary>
    /// <typeparam name="T">视图类型(必须继承自View)</typeparam>
    /// <param name="hideOthers">是否隐藏其他视图,默认为true</param>
    public void ShowView<T>(bool hideOthers = true) where T : View
    {
        // 检查是否有该类型的视图
        if (GetView<T>() == null)
        {
            Debug.LogWarning($"视图管理器中没有注册类型为 {typeof(T).Name} 的视图");
            return;
        }

        foreach (var view in allViews)
        {
            if (view.GetType() == typeof(T))
            {
                // 显示目标视图
                ShowViewInternal(view);
            }
            else
            {
                // 如果需要隐藏其他视图
                if (hideOthers)
                {
                    HideViewInternal(view);
                }
            }
        }
    }
    
    /// <summary>
    /// 隐藏指定类型的视图
    /// </summary>
    /// <typeparam name="T">视图类型(必须继承自View)</typeparam>
    public void HideView<T>() where T : View
    {
        foreach (var view in allViews)
        {
            if (view.GetType() == typeof(T))
            {
                // 隐藏目标视图
                HideViewInternal(view);
                break; // 找到并隐藏后可以提前退出循环
            }
        }
    }

    /// <summary>
    /// 获取指定类型的视图实例
    /// </summary>
    /// <typeparam name="T">视图类型</typeparam>
    /// <returns>视图实例,如果不存在则返回null</returns>
    public T GetView<T>() where T : View
    {
        if (_viewDictionary.TryGetValue(typeof(T), out View view))
        {
            return view as T;
        }
        return null;
    }
    
    #endregion
    
    #region 内部辅助方法
    
    /// <summary>
    /// 内部方法 - 显示视图
    /// </summary>
    /// <param name="view">要显示的视图</param>
    private void ShowViewInternal(View view)
    {
        if (view == null)
        {
            Debug.LogWarning("尝试显示一个空视图");
            return;
        }
        
        // 设置CanvasGroup的Alpha为1(完全显示)
        view.canvasGroup.alpha = 1;
        
        // 调用视图的显示回调
        view.OnShow();
    }
    
    /// <summary>
    /// 内部方法 - 隐藏视图
    /// </summary>
    /// <param name="view">要隐藏的视图</param>
    private void HideViewInternal(View view)
    {
        if (view == null)
        {
            Debug.LogWarning("尝试隐藏一个空视图");
            return;
        }
        
        // 设置CanvasGroup的Alpha为0(完全隐藏)
        view.canvasGroup.alpha = 0;
        
        // 调用视图的隐藏回调
        view.OnHide();
    }
    
    #endregion
}

/// <summary>
/// 视图基类 - 所有UI视图都应继承此类
/// </summary>
public abstract class View : MonoBehaviour
{
    /// <summary>
    /// 控制视图显示/隐藏的CanvasGroup组件
    /// 通过Alpha值控制可见性,可扩展交互性控制
    /// </summary>
    [Header("视图组件")]
    [Tooltip("控制视图显示和隐藏的CanvasGroup组件")]
    public CanvasGroup canvasGroup;
    
    /// <summary>
    /// 视图显示时的回调函数 - 子类必须实现
    /// 用于处理视图显示时的初始化或动画
    /// </summary>
    public abstract void OnShow();
    
    /// <summary>
    /// 视图隐藏时的回调函数 - 子类必须实现
    /// 用于处理视图隐藏时的清理或动画
    /// </summary>
    public abstract void OnHide();
    
    /// <summary>
    /// 便捷方法 - 显示此视图
    /// </summary>
    public void Show()
    {
        // 这里可以添加动画或过渡效果
        if (canvasGroup != null)
        {
            canvasGroup.alpha = 1;
            canvasGroup.interactable = true;
            canvasGroup.blocksRaycasts = true;
        }
        OnShow();
    }
    
    /// <summary>
    /// 便捷方法 - 隐藏此视图
    /// </summary>
    public void Hide()
    {
        // 这里可以添加动画或过渡效果
        if (canvasGroup != null)
        {
            canvasGroup.alpha = 0;
            canvasGroup.interactable = false;
            canvasGroup.blocksRaycasts = false;
        }
        OnHide();
    }
}

2、新增主游戏界面视图类

csharp 复制代码
using UnityEngine;
using TMPro;
using PurrNet;

/// <summary>
/// 主游戏界面视图类 - 继承自View基类
/// 负责显示游戏过程中的主要UI,如玩家血量等
/// </summary>
public class MainGameView : View
{
    /// <summary>
    /// 血量显示文本 - 使用TextMeshPro实现高质量文本渲染
    /// </summary>
    [Header("UI组件")]
    [Tooltip("显示玩家血量的TextMeshPro文本组件")]
    [SerializeField] private TMP_Text healthText; // 血量文本显示
    
    #region Unity生命周期函数
    
    /// <summary>
    /// 初始化函数 - 物体创建时调用
    /// </summary>
    private void Awake()
    {
        // 注册当前实例到实例处理器,便于全局访问
        InstanceHandler.RegisterInstance(this);
    }
    
    /// <summary>
    /// 销毁函数 - 物体销毁时调用
    /// </summary>
    private void OnDestroy()
    {
        // 从实例处理器中注销当前实例
        InstanceHandler.UnregisterInstance<MainGameView>();
    }
    
    #endregion
    
    #region View基类方法实现
    
    /// <summary>
    /// 视图显示时的回调 - 当此视图被GameViewManager显示时调用
    /// </summary>
    public override void OnShow()
    {   
        Debug.Log("主游戏界面已显示");
    }
    
    /// <summary>
    /// 视图隐藏时的回调 - 当此视图被GameViewManager隐藏时调用
    /// </summary>
    public override void OnHide()
    {
        Debug.Log("主游戏界面已隐藏");
    }
    
    #endregion
    
    #region 公共接口方法
    
    /// <summary>
    /// 更新血量显示
    /// </summary>
    /// <param name="health">当前血量值</param>
    public void UpdateHealth(int health)
    {
        // 空值检查
        if (healthText == null)
        {
            Debug.LogWarning("MainGameView: healthText为null,无法更新血量显示");
            return;
        }
        
        // 更新文本内容
        healthText.text = health.ToString();
        
        // 可选的:根据血量值改变文本颜色
        UpdateHealthColor(health);
    }
    
    #endregion
    
    #region 内部辅助方法
    
    /// <summary>
    /// 根据血量值更新文本颜色(示例扩展功能)
    /// </summary>
    /// <param name="health">当前血量值</param>
    private void UpdateHealthColor(int health)
    {
        // 示例:血量低于30%时显示为红色,否则为绿色
        if (health <= 30)
        {
            healthText.color = Color.red;
        }
        else
        {
            healthText.color = Color.green;
        }
    }
    #endregion
}

配置

3、玩家生命值变化修改UI显示

修改PlayerHealth,监听生命值变化事件

csharp 复制代码
// 重写OnSpawned方法:当玩家对象在网络上生成时调用
protected override void OnSpawned(bool asServer)
{
    //...
    
    // 仅当是本地客户端拥有此物体时,才订阅血量变化事件
    // 这是关键优化:避免所有客户端都订阅事件,减少不必要的网络开销
    if (isOwner)
    {
    	//生成时默认重置生命值
        InstanceHandler.GetInstance<MainGameView>().UpdateHealth(health.value);
            
        // 订阅血量变化事件
        // 当health属性变化时,会触发onChanged事件
        health.onChanged += OnHealthChanged;
    }
}

/// <summary>
/// 物体销毁时的回调函数
/// </summary>
protected override void OnDestroy()
{
    // 调用基类方法,确保网络基础功能正常清理
    base.OnDestroy();

    // 取消订阅血量变化事件
    // 注意:这里没有检查isOwner,因为OnDestroy时订阅状态已经不重要
    // 且多次取消订阅是安全的(不会报错)
    health.onChanged -= OnHealthChanged;
}

/// <summary>
/// 血量变化事件处理函数
/// </summary>
/// <param name="newHealth">新的血量值</param>
private void OnHealthChanged(int newHealth)
{   
    // 通过InstanceHandler获取MainGameView单例实例
    // 并调用UpdateHealth方法更新UI显示
    InstanceHandler.GetInstance<MainGameView>().UpdateHealth(newHealth);
}

4、效果

玩家被其他玩家攻击,扣血

十一、游戏状态与玩家重生

1、新增不同的状态

1.1 等待玩家加入状态类

开始不会生成玩家,等待别的玩家加入才行

csharp 复制代码
using UnityEngine;
using PurrNet.StateMachine;
using System.Collections;

// 等待玩家加入状态类
public class WaitForPlayersState : StateNode
{
    // 游戏开始所需的最小玩家数量
    [SerializeField] private int minPlayers = 2;
    
    public override void Enter(bool asServer)
    {
        base.Enter(asServer);
        
        // 仅在服务器端执行等待逻辑
        if (!asServer)
            return;
            
        StartCoroutine(WaitForPlayers());
    }
    
    // 协程:等待足够玩家加入
    private IEnumerator WaitForPlayers()
    {
        // 循环检查直到满足最小玩家数要求
        while (networkManager.players.Count < minPlayers)
            yield return null; // 每帧等待一次
            
        // 条件满足,切换到下一个状态
        machine.Next();
    }
}

配置最少两个玩家才开始游戏,进入下一状态

1.2 玩家生成状态类

csharp 复制代码
using System.Collections.Generic;
using PurrNet.StateMachine;
using UnityEngine;

// 玩家生成状态类
public class PlayerSpawningState : StateNode
{
    // Player (PlayerHealth)预制体
    [SerializeField] private PlayerHealth playerPrefab; 
    
    // 生成点列表
    [SerializeField] private List<Transform> spawnPoints = new();
    
    public override void Enter(bool asServer)
    {
        base.Enter(asServer);
        
        // 仅在服务器端执行生成逻辑
        if (!asServer)
            return;

        // 步骤1:销毁现有的所有玩家
        DespawnPlayers();

        // 步骤2:生成新的玩家
        List<PlayerHealth> spawnedPlayers = SpawnPlayers();
        
        // 步骤3:切换到下一个状态,并传递生成的玩家列表
        machine.Next(spawnedPlayers);
    }

    // 生成玩家方法
    private List<PlayerHealth> SpawnPlayers()
    {
        // 创建列表存储生成的玩家对象
        var spawnedPlayers = new List<PlayerHealth>();
        
        // 当前使用的生成点索引
        int currentSpawnIndex = 0;
        
        // 遍历所有已连接的玩家
        foreach (var player in networkManager.players)
        {
            // 获取当前生成点
            Transform spawnPoint = spawnPoints[currentSpawnIndex];
            
            // 在生成点位置实例化玩家预制体
            var newPlayer = Instantiate(playerPrefab, spawnPoint.position, spawnPoint.rotation);
            
            // 将玩家对象的所有权授予对应的网络玩家
            newPlayer.GiveOwnership(player);
            
            // 添加到生成列表
            spawnedPlayers.Add(newPlayer);
            
            // 移动到下一个生成点
            currentSpawnIndex++;
            
            // 如果超出生成点数量,重置索引(循环使用生成点)
            if (currentSpawnIndex >= spawnPoints.Count)
                currentSpawnIndex = 0;
        }
        
        // 返回生成的玩家列表
        return spawnedPlayers;
    }
    
    // 销毁现有玩家方法
    private void DespawnPlayers()
    {
        // 查找场景中所有激活的PlayerHealth对象
        PlayerHealth[] allPlayers = FindObjectsByType<PlayerHealth>(
            FindObjectsInactive.Exclude, // 只查找激活的对象
            FindObjectsSortMode.None     // 不进行排序
        );
        
        // 遍历并销毁所有找到的玩家对象
        foreach (var player in allPlayers)
        {
            // 销毁玩家游戏对象
            Destroy(player.gameObject);
        }
    }
    
    public override void Exit(bool asServer)
    {
        base.Exit(asServer);
    }
}

移除之前的玩家自动生成器,用我们自定义的

配置我们自定义的玩家生成器

1.3 回合运行状态类

这里我觉得有必要先解释一些参数的含义:

  • PlayerID :网络系统中唯一标识玩家的类型,通常是服务器分配的ID,用于在网络通信中区分不同玩家
  • owner :是 NetworkBehaviour 或 NetworkObject 的属性,表示当前网络对象被哪个玩家控制/拥有
    • owner.HasValue 检查对象是否有所有者
    • owner.Value 获取此对象有所有者

首先修改PlayerHealth脚本,定义服务器端玩家死亡事件,并执行死亡事件,通知订阅者该玩家已死亡

csharp 复制代码
// 服务器端死亡事件,参数为死亡玩家的ID
public Action<PlayerID> OnDeath_Server;

// 死亡处理方法
private void Die()
{
    Debug.Log("死亡");
    
    // 执行死亡事件,通知订阅者该玩家已死亡
    OnDeath_Server?.Invoke(owner.Value);
    //销毁玩家
    Destroy(gameObject);
}

这里owner.Value其实获取的就是死亡玩家的ID

如果只剩1个或更少玩家存活,则本回合有人获得胜利,进入下一个状态

csharp 复制代码
using PurrNet.StateMachine;
using System.Collections.Generic;
using PurrNet;

// 回合运行状态类(接受玩家列表作为参数)
public class RoundRunningState : StateNode<List<PlayerHealth>>
{
    // 存储当前回合中存活的玩家ID列表
    private List<PlayerID> _players = new();

    // 存储当前回合的所有玩家引用,用于Exit时清理事件订阅
    private List<PlayerHealth> _currentPlayers = new();

    /// <summary>
    /// 进入状态时的回调函数
    /// </summary>
    /// <param name="data">玩家生命值组件列表</param>
    /// <param name="asServer">是否在服务端执行</param>
    public override void Enter(List<PlayerHealth> data, bool asServer)
    {
        base.Enter(data, asServer);

        // 仅在服务器端执行回合管理逻辑
        if (!asServer)
            return;

        // 清空玩家列表,准备重新记录
        _players.Clear();

        _currentPlayers = data;

        // 遍历所有玩家生命值组件
        foreach (var player in data)
        {
            // 如果玩家有所有者(玩家ID),则添加到存活玩家列表
            if (player.owner.HasValue) _players.Add(player.owner.Value);

            // 注册死亡事件监听器
            player.OnDeath_Server += OnPlayerDeath;
        }
    }

    /// <summary>
    /// 玩家死亡事件处理函数
    /// </summary>
    /// <param name="deadPlayer">死亡玩家的ID</param>
    private void OnPlayerDeath(PlayerID deadPlayer)
    {
         // 从存活玩家列表中移除死亡玩家
        _players.Remove(deadPlayer);

        // 检查回合是否结束(只剩1个或更少玩家存活)
        if (_players.Count <= 1)
        {
            // 切换到下一个状态
            machine.Next();
        }
    }

    public override void Exit(bool asServer)
    {
        base.Exit(asServer);

        // 仅在服务器端执行清理逻辑
        if (!asServer)
            return;
        
        // 清理所有玩家的死亡事件订阅
        foreach (var player in _currentPlayers)
        {
            // 安全取消订阅(即使未订阅也不会报错)
            player.OnDeath_Server -= OnPlayerDeath;
        }

        // 清空列表,释放引用
        _players.Clear();
        _currentPlayers.Clear();
    }
}

配置

1.4 回合结束状态类

csharp 复制代码
using System.Collections;
using PurrNet.StateMachine;
using UnityEngine;

// 回合结束状态类
public class RoundEndState : StateNode
{
    // 总回合数
    [SerializeField] private int amountOfRounds = 3;
    
    // 生成状态引用
    [SerializeField] private StateNode spawningState;
    
    // 当前回合计数
    private int _roundCount = 0;
    
    // 延迟等待时间(3秒)
    private WaitForSeconds _delay = new(3f);

    public override void Enter(bool asServer)
    {
        base.Enter(asServer);

        // 仅在服务器端执行回合管理逻辑
        if (!asServer)
            return;

        CheckForGameEnd();
    }

    //检测游戏是否结束
    private void CheckForGameEnd()
    {
        // 增加回合计数
        _roundCount++;

        // 回合都已经完成
        if (_roundCount >= amountOfRounds)
        {
            //进入下一状态
            machine.Next();
            return;
        }
        
        // 启动延迟协程,等待后进入下一状态
        StartCoroutine(DelayNextState());
    }

    // 延迟协程(频繁调用)
    private IEnumerator DelayNextState()
    {
        // 等待指定延迟时间
        yield return _delay;
        
        // 设置状态机到生成状态
        machine.SetState(spawningState);
    }
}

3秒后回到生成玩家状态,就是下一回合,这里配置玩家会经过3回合的pk,然后才进入下一回合,也就是最终胜利。

1.5 游戏结束状态类

csharp 复制代码
using PurrNet.StateMachine;
using UnityEngine;

// 游戏结束状态类
public class GameEndState : StateNode
{
    public override void Enter(bool asServer)
    {
        base.Enter(asServer);

        Debug.Log("游戏结束!");
    }
}

配置

2、在状态机里注册各个状态,注意顺序不要错

3、效果

允许服务端和一个客户端测试效果

十二、击杀死亡计分系统

这里我觉得有必要先解释一些参数的含义:

  • PlayerID :网络系统中唯一标识玩家的类型,通常是服务器分配的ID,用于在网络通信中区分不同玩家
  • owner :是 NetworkBehaviour 或 NetworkObject 的属性,表示当前网络对象被哪个玩家控制/拥有
    • owner.HasValue 检查对象是否有所有者
    • owner.Value 获取此对象有所有者
  • RPCInfo:用于获取刚刚发送的 RPC 的信息,你只需将 RPCInfo 作为 RPC 的最后一个参数添加,并将其默认值设为 default,它就会在收到 RPC 时自动填充。这个值主要用于获取发送者。
    • info.sender:获取发送者。

1、分数管理器

新增分数管理器脚本

csharp 复制代码
using UnityEngine;
using PurrNet;

// 分数管理器类 - 网络行为组件,用于同步分数数据
public class ScoreManager : NetworkBehaviour
{
    // 同步字典:存储玩家ID对应的分数数据
    // 这个字典会自动在服务器和客户端之间同步
    [SerializeField] private SyncDictionary<PlayerID, ScoreData> scores = new();
    
    private void Awake()
    {
        // 注册当前实例到实例处理器(通常用于全局访问)
        InstanceHandler.RegisterInstance(this);

        // 订阅分数字典变化事件,当字典内容改变时触发
        scores.onChanged += OnScoresChanged;
    }

    protected override void OnDestroy()
    {
        base.OnDestroy();
        
        // 从实例处理器中注销当前类型的实例
        InstanceHandler.UnregisterInstance<ScoreManager>();

        // 取消订阅分数字典变化事件,避免内存泄漏
        scores.onChanged -= OnScoresChanged;
    }

    /// <summary>
    /// 分数字典变化事件处理函数
    /// </summary>
    /// <param name="change">字典变化信息</param>
    private void OnScoresChanged(SyncDictionaryChange<PlayerID, ScoreData> change)
    {
        // 尝试获取计分板视图实例
        if(InstanceHandler.TryGetInstance(out ScoreboardView scoreboardView))
        {
            // 将同步字典转换为普通字典并更新计分板视图
            // ToDictionary()方法将SyncDictionary转换为Dictionary<PlayerID, ScoreData>
            scoreboardView.SetData(scores.ToDictionary());
        }else
        {
            Debug.LogWarning("找不到ScoreboardView实例,无法更新计分板");
        }
    }
    
    // 添加击杀数的方法
    public void AddKill(PlayerID playerID)
    {
        // 确保字典中存在该玩家的条目
        CheckForDictionaryEntry(playerID);
        
        // 获取玩家的分数数据
        var scoreData = scores[playerID];
        
        // 增加击杀数
        scoreData.kills++;
        
        // 更新回字典(由于是值类型,需要重新赋值)
        scores[playerID] = scoreData;
    }
    
    // 添加死亡数的方法
    public void AddDeath(PlayerID playerID)
    {
        // 确保字典中存在该玩家的条目
        CheckForDictionaryEntry(playerID);
        
        // 获取玩家的分数数据
        var scoreData = scores[playerID];
        
        // 增加死亡数
        scoreData.deaths++;
        
        // 更新回字典
        scores[playerID] = scoreData;
    }

    /// <summary>
    /// 获取当前获胜者(击杀数最高的玩家)
    /// </summary>
    /// <returns>获胜玩家的ID,如果没有玩家则返回默认值</returns>
    public PlayerID GetWinner()
    {
        PlayerID winner = default;// 获胜者ID,默认为default
        var highestKills = 0;// 当前最高击杀数
        // 遍历所有玩家的分数数据
        foreach (var score in scores)
        {
            // 如果当前玩家的击杀数比已记录的最高击杀数高
            if(score.Value.kills > highestKills)
            {
                // 更新最高击杀数和获胜者
                highestKills = score.Value.kills;
                winner = score.Key;
            }
        }
        // 返回获胜者(如果有平局,这里只返回第一个达到最高击杀的玩家)
        return winner;
    }
    
    // 检查字典中是否存在指定玩家的条目,不存在则创建
    private void CheckForDictionaryEntry(PlayerID playerID)
    {
        if (!scores.ContainsKey(playerID))
        {
            scores[playerID] = new ScoreData(); // 创建新的分数数据实例
        }
    }
}

[System.Serializable]
public struct ScoreData
{
    public int kills;   // 击杀数
    public int deaths;  // 死亡数

    // 还可以添加其他字段如:助攻数、得分等
}

2、添加击杀和死亡记录

修改PlayerHealth,添加击杀和死亡记录

csharp 复制代码
// 服务器远程过程调用:客户端调用此方法来修改生命值
// requireOwnership:false 表示任何客户端都可以调用,无需对象所有权
[ServerRpc(requireOwnership: false)]
public void ChangeHealth(int amount, RPCInfo info = default)
{
    // 在服务端修改生命值
    health.value += amount;

    //生命值范围限制
    health.value = Mathf.Clamp(health.value, 0, 100);

    //死亡
    if (health.value <= 0) Die(info);
}

// 死亡处理方法
private void Die(RPCInfo info)
{
    Debug.Log($"{owner.Value}被{info.sender}击杀");

    // 尝试获取分数管理器实例
    if (InstanceHandler.TryGetInstance(out ScoreManager scoreManager))
    {
        // 添加击杀记录(攻击者的ID)
        scoreManager.AddKill(info.sender);
        // 添加死亡记录(死亡玩家的ID)
        if(owner.HasValue) scoreManager.AddDeath(owner.Value);
    }

    // 执行死亡事件,通知订阅者该玩家已死亡
    OnDeath_Server?.Invoke(owner.Value);
    //销毁玩家
    Destroy(gameObject);
}

效果

3、绘制结算面板UI

4、计分板条目

新增计分板条目控制脚本

csharp 复制代码
using UnityEngine;
using TMPro;

// 计分板条目UI组件,显示单个玩家的分数信息
public class ScoreboardEntry : MonoBehaviour
{
    // UI文本引用 - 分别显示玩家名字、击杀数和死亡数
    [SerializeField] private TMP_Text nameText, killsText, deathsText;
    
    /// <summary>
    /// 设置计分板条目的数据
    /// </summary>
    /// <param name="playerName">玩家名称</param>
    /// <param name="kills">击杀数</param>
    /// <param name="deaths">死亡数</param>
    public void SetData(string playerName, int kills, int deaths)
    {
        nameText.text = playerName;      // 设置玩家名字文本
        killsText.text = kills.ToString();  // 设置击杀数文本
        deathsText.text = deaths.ToString(); // 设置死亡数文本
    }
}

挂载脚本,并作成预制体

5、计分板视图

新增计分板视图类脚本

csharp 复制代码
using System.Collections.Generic;
using PurrNet;
using UnityEngine;

// 计分板视图类,继承自View基类
public class ScoreboardView : View
{
    // 计分板条目父节点 - 所有分数条目的容器
    [SerializeField] private Transform scoreboardEntriesParent;
    
    // 计分板条目预制体 - 用于实例化每个玩家的分数条目
    [SerializeField] private ScoreboardEntry scoreboardEntryPrefab;

    // 获取游戏视图管理器
    private GameViewManager _gameViewManager;
    
    private void Awake()
    {
        // 注册当前实例到全局实例处理器
        InstanceHandler.RegisterInstance(this);
    }

    void Start()
    {
        _gameViewManager = InstanceHandler.GetInstance<GameViewManager>();
    }

    void Update()
    {
        // 控制计分板视图显示隐藏
        if(Input.GetKeyDown(KeyCode.Tab)) _gameViewManager.ShowView<ScoreboardView>(false);
        if(Input.GetKeyUp(KeyCode.Tab)) _gameViewManager.HideView<ScoreboardView>();
    }

    private void OnDestroy()
    {
        // 从实例处理器中注销当前类型的实例
        InstanceHandler.UnregisterInstance<ScoreboardView>();
    }
    
    /// <summary>
    /// 设置计分板数据并更新UI显示
    /// </summary>
    /// <param name="data">玩家分数数据字典(玩家ID -> 分数数据)</param>
    public void SetData(Dictionary<PlayerID, ScoreData> data)
    {
        //先清空计分板条目的所有子集
        foreach (Transform child in scoreboardEntriesParent.transform)
        {
            Destroy(child.gameObject);
        }

        // 遍历所有玩家的分数数据
        foreach (var playerScore in data)
        {
            // 实例化一个新的计分板条目
            var entry = Instantiate(scoreboardEntryPrefab, scoreboardEntriesParent);
            
            // 设置条目数据:
            // playerScore.Key.id.ToString() - 玩家ID转换为字符串作为名字
            // playerScore.Value.kills - 玩家的击杀数
            // playerScore.Value.deaths - 玩家的死亡数
            entry.SetData(
                playerName: playerScore.Key.id.ToString(), 
                playerScore.Value.kills, 
                playerScore.Value.deaths
            );
        }
    }
    
    /// <summary>
    /// 重写View基类的OnShow方法 - 视图显示时调用
    /// </summary>
    public override void OnShow()
    {
       
    }

    /// <summary>
    /// 重写View基类的OnHide方法 - 视图隐藏时调用
    /// </summary>
    public override void OnHide()
    {
        
    }
}

挂载脚本

别忘记将视图加入进GameViewManager游戏视图管理器里

6、效果

按tab按键可以开启关闭记分面板

十三、显示最终的胜利者

1、打印最终的胜利者

修改GameEndState游戏结束状态类,打印胜利者

csharp 复制代码
using PurrNet;
using PurrNet.StateMachine;
using UnityEngine;

// 游戏结束状态类
public class GameEndState : StateNode
{
    public override void Enter(bool asServer)
    {
        base.Enter(asServer);

        Debug.Log($"游戏结束");

        if (!InstanceHandler.TryGetInstance(out ScoreManager scoreManager))
        {
            Debug.LogError($"GameEndState无法获取scoremanager!", this);
        }

        var winner = scoreManager.GetWinner();
        if(winner == default)
        {
            Debug.LogError($"GameEndState无法获取winner!", this);
        }

        Debug.Log($"游戏现已结束,{ winner }成为我们的冠军!");
    }
}

效果

2、新增游戏结束界面视图类

csharp 复制代码
// 游戏结束界面视图类
public class EndGameView : View
{
    [SerializeField] 
    private float fadeDuration = 1f; // 淡入淡出动画持续时间(秒)
    
    [SerializeField]
    private TMP_Text winnerText; // 显示获胜者信息的TextMeshPro文本组件
    
    // 事件函数:在对象创建时调用
    private void Awake()
    {
        // 注册当前实例到实例处理器
        InstanceHandler.RegisterInstance(this);
    }
    
    // 事件函数:在对象销毁时调用
    private void OnDestroy()
    {
        // 从实例处理器中注销当前类型实例
        InstanceHandler.UnregisterInstance<EndGameView>();
    }
    
    // 设置获胜者信息
    public void SetWinner(PlayerID winner)
    {
        // 设置获胜者文本内容
        winnerText.text = $"玩家 {winner.id} 获得游戏胜利!";
    }
    
    // 重写方法:显示界面时调用
    public override void OnShow()
    {
    }
    
    // 重写方法:隐藏界面时调用
    public override void OnHide()
    {
    }
}

配置UI界面

3、修改GameEndState调用

csharp 复制代码
using PurrNet;
using PurrNet.StateMachine;
using UnityEngine;

// 游戏结束状态类
public class GameEndState : StateNode
{
    public override void Enter(bool asServer)
    {
        base.Enter(asServer);

        if (!InstanceHandler.TryGetInstance(out ScoreManager scoreManager))
        {
            Debug.LogError($"GameEndState无法获取scoremanager!", this);
            return;
        }

        var winner = scoreManager.GetWinner();
        if(winner == default)
        {
            Debug.LogError($"GameEndState无法获取winner!", this);
            return;
        }

        if (!InstanceHandler.TryGetInstance(out EndGameView endGameView))
        {
            Debug.LogError($"GameEndState无法获取endGameView!", this);
            return;
        }

        if (!InstanceHandler.TryGetInstance(out GameViewManager gameViewManager))
        {
            Debug.LogError($"GameEndState无法获取gameViewManager!", this);
            return;
        }

        endGameView.SetWinner(winner);
        gameViewManager.ShowView<EndGameView>();

        Debug.Log($"游戏现已结束,{ winner }成为我们的冠军!");
    }
}

十四、控制枪的射速和枪口特效

修改Gun

csharp 复制代码
[Header("射速")]
[SerializeField] private float fireRate = 0.5f;// 射击间隔时间(秒)
[SerializeField] private bool automatic; // 是否为自动射击模式
private float _lastFireTime; // 上次射击的时间记录

[Header("特效")]
[SerializeField] private ParticleSystem muzzleFlash;// 枪口闪光粒子特效

private void Update()
{
    // 射击输入检测(自动/半自动模式)
    bool fireInput = automatic 
        ? Input.GetKey(KeyCode.Mouse0)        // 自动模式:按住左键持续射击
        : Input.GetKeyDown(KeyCode.Mouse0);   // 半自动模式:点击左键单发

    if (!fireInput) return;

    // 射击冷却检查
    if (Time.unscaledTime - _lastFireTime < fireRate) return;

    // 播放射击特效
    PlayShotEffect();

    _lastFireTime = Time.unscaledTime;

    // 执行射线检测,从相机位置向前方发射射线
    if (!Physics.Raycast(cameraTransform.position, cameraTransform.forward, out RaycastHit hit, range, hitLayer))
    {
        // 如果没有击中任何物体,直接返回
        return;
    }

    if(!hit.transform.TryGetComponent(out PlayerHealth playerHealth)) return;

    //扣血
    playerHealth.ChangeHealth(-damage);
    
    // 输出调试信息:显示击中的物体名称
    Debug.Log($"击中: {hit.transform.name}");
}

/// <summary>
/// 观察者RPC:在所有客户端播放射击特效
/// runLocally:true 表示本地客户端也执行此方法
/// </summary>
[ObserversRpc(runLocally: true)]
private void PlayShotEffect()
{
    // 安全检查后播放枪口闪光特效
    if (muzzleFlash != null)
    {
        muzzleFlash.Play();
    }
}

配置

效果

十五、正确的持枪设置

参考:

十六、使用状态机切换不同的武器

1、修改Gun继承StateNode

csharp 复制代码
public class Gun : StateNode
{
    // ... 其他代码 ...
    
    /// <summary>
    /// 存储所有渲染器组件的列表,用于控制枪械模型的显示/隐藏
    /// </summary>
    [SerializeField] private List<Renderer> renderers = new List<Renderer>();
    
    /// <summary>
    /// 游戏对象初始化时调用,初始状态下隐藏枪械模型
    /// </summary>
    private void Awake() {
       ToggleVisuals(false);
   }

    /// <summary>
    /// 进入状态时调用,显示枪械模型
    /// </summary>
    /// <param name="asServer">是否作为服务端执行</param>
   public override void Enter(bool asServer)
   {
       base.Enter(asServer);
       ToggleVisuals(true); // 进入状态时显示枪械
   }

    /// <summary>
    /// 退出状态时调用,隐藏枪械模型
    /// </summary>
    /// <param name="asServer">是否作为服务端执行</param>
   public override void Exit(bool asServer)
   {
       base.Exit(asServer);
       ToggleVisuals(false); // 退出状态时隐藏枪械
   }

    /// <summary>
    /// 切换枪械模型的可视状态
    /// </summary>
    /// <param name="toggle">true: 显示模型,false: 隐藏模型</param>
    private void ToggleVisuals(bool toggle)
    {
       foreach (var renderer in renderers)
       {
           renderer.enabled = toggle; // 启用或禁用所有渲染器
       }
    }
    
    /// <summary>
    /// 状态更新逻辑,每帧调用
    /// </summary>
    /// <param name="asServer">是否作为服务端执行</param>
    public override void StateUpdate(bool asServer)
    {
        base.StateUpdate(asServer);

        // 仅所有者(本地玩家)执行后续逻辑
        if(!isOwner) return;
			
        // ... 其他游戏逻辑 ...
     }
}

注意:这里使用StateUpdate替换原来的Update,StateUpdate只在状态活动时才更新,这意味着当我们更换武器时,武器的行为也会随之改变。不会出现两把枪一起同时执行的情况Update更新的。

配置枪网格

默认隐藏枪网格

2、配置不同的枪

给人物添加State Machine组件

配置两把枪状态

3、玩家控制里新增切换武器方法



十七、命中效果与伤害反馈

1、打中墙壁特效同步到网络

2、击中玩家特效

击中玩家特效也同步,但是玩家不一样,因为玩家是会移动的,所以要防止延迟,比如玩家走开了,导致看到特效出现在空气中

3、镜头泛红

如果你还想加受击镜头泛红边缘效果,可以类似这样,在扣血这里加会更好

十九、添加音频/声音

1、新增音效播放器脚本

csharp 复制代码
using UnityEngine;

// 音效播放器组件
public class SoundPlayer : MonoBehaviour
{
    [SerializeField] private AudioSource audioSource; // 音频源组件引用
    
    // 播放音效方法
    // clip: 要播放的音频片段
    // volume: 音量大小(默认1.0,范围0.0-1.0)
    public void PlaySound(AudioClip clip, float volume = 1f)
    {
        // 设置音频片段
        audioSource.clip = clip;
        
        // 设置音量
        audioSource.volume = volume;
        
        // 开始播放
        audioSource.Play();
        
        // 音频播放完成后销毁GameObject(额外增加0.1秒延迟确保音频完整播放)
        Destroy(gameObject, clip.length + 0.1f);
    }
}

配置

2、调用


配置

3、目前音调太高了,可以新增变量方便配置处理

4、射击声音也一样,我们希望自己声音小点,其他的人正常

配置

5、死亡音效也一样

二十、死亡观战模式

1、新增玩家摄像机管理器脚本

csharp 复制代码
using UnityEngine;
using System.Collections.Generic;

// 玩家摄像机管理器 - 管理多个玩家摄像机的切换
public class PlayerCameraManager : MonoBehaviour
{
    private List<PlayerCamera> _allPlayerCameras = new(); // 存储所有玩家摄像机
    private bool _canSwitchCamera; // 是否允许切换摄像机
    private int _currentCameraIndex; // 当前激活的摄像机索引

    // 初始化方法
    private void Awake()
    {
        // 注册到单例管理器(假设InstanceHandler是单例管理器)
        InstanceHandler.RegisterInstance(this);
    }

    // 销毁时清理
    private void OnDestroy()
    {
        // 从单例管理器中注销
        InstanceHandler.UnregisterInstance<PlayerCameraManager>();
    }

    // 注册新的玩家摄像机
    public void RegisterCamera(PlayerCamera cam)
    {
        // 如果已存在则跳过
        if (_allPlayerCameras.Contains(cam))
            return;
            
        _allPlayerCameras.Add(cam);
        
        // 如果注册的是本地玩家的摄像机
        if (cam.isOwner)
        {
            _canSwitchCamera = false; // 本地玩家摄像机不允许切换
            cam.ToggleCamera(true);   // 激活本地玩家摄像机
        }
    }

    // 注销玩家摄像机
    public void UnregisterCamera(PlayerCamera cam)
    {
        // 如果存在则移除
        if (_allPlayerCameras.Contains(cam))
            _allPlayerCameras.Remove(cam);
            
        // 如果移除的是本地玩家的摄像机
        if (cam.isOwner)
        {
            // 切换到下一个摄像机
            SwitchNext();
        }
    }

    // 每帧更新
    private void Update()
    {
        // 如果不允许切换摄像机,直接返回
        if (!_canSwitchCamera)
            return;
            
        // 鼠标左键 - 切换到下一个摄像机
        if (Input.GetKeyDown(KeyCode.Mouse0))
            SwitchNext();
            
        // 鼠标右键 - 切换到上一个摄像机
        if (Input.GetKeyDown(KeyCode.Mouse1))
            SwitchPrevious();
    }

    // 切换到下一个摄像机
    private void SwitchNext()
    {
        // 如果没有摄像机,直接返回
        if (_allPlayerCameras.Count <= 0)
            return;
            
        // 关闭当前摄像机
        _allPlayerCameras[_currentCameraIndex].ToggleCamera(false);
        
        // 索引递增(循环)
        _currentCameraIndex++;
        if (_currentCameraIndex >= _allPlayerCameras.Count)
            _currentCameraIndex = 0;
            
        // 激活新摄像机
        _allPlayerCameras[_currentCameraIndex].ToggleCamera(true);
    }

    // 切换到上一个摄像机
    private void SwitchPrevious()
    {
        // 如果没有摄像机,直接返回
        if (_allPlayerCameras.Count <= 0)
            return;
            
        // 关闭当前摄像机
        _allPlayerCameras[_currentCameraIndex].ToggleCamera(false);
        
        // 索引递减(循环)
        _currentCameraIndex--;
        if (_currentCameraIndex < 0)
            _currentCameraIndex = _allPlayerCameras.Count - 1;
            
        // 激活新摄像机
        _allPlayerCameras[_currentCameraIndex].ToggleCamera(true);
    }
}

2、把人物的相机控制,单独用一个脚本控制

csharp 复制代码
public class Playercamera : NetworkBehaviour  // 玩家摄像机网络行为组件
{
    // 摄像机引用 - 使用Cinemachine摄像机系统
    public CinemachineCamera playerCamera; // serializable
    
     // 摄像机旋转模仿组件,用于同步其他玩家的摄像机旋转
    [SerializeField] private RotationMimic cameraMimic;
    
    // 需要控制阴影渲染的渲染器列表
    [SerializeField] private List<Renderer> renderers = new(); // serializable
    
    // 当对象在网络上生成时调用
    protected override void OnSpawned()
    {
        base.OnSpawned();
        // 注册此摄像机到摄像机管理器
        InstanceHandler.GetInstance<PlayerCameraManager>().RegisterCamera(cam: this);
    }
    
    // 当对象在网络上销毁时调用
    protected override void OnDespawned(bool asServer)
    {
        base.OnDespawned(asServer);
        // 从摄像机管理器注销此摄像机
        InstanceHandler.GetInstance<PlayercameraManager>().UnregisterCamera(cam: this);
    }
    
    // 切换玩家身体渲染模式
    private void TogglePlayerBody(bool toggle)
    {
        // 遍历所有渲染器,根据toggle值设置阴影投射模式
        foreach (var rend in renderers)
        {
            // 如果toggle为true,显示正常阴影;如果为false,只显示阴影(透明身体)
            rend.shadowCastingMode = toggle ? ShadowCastingMode.On : ShadowCastingMode.ShadowsOnly;
        }
    }
    
    // 切换摄像机状态
    public void Togglecamera(bool toggle)
    {
        // 设置摄像机优先级:启用时为10,禁用时为0
        playerCamera.Priority = toggle ? 10 : 0;
        
        // 切换玩家身体显示状态(摄像机启用时隐藏身体,摄像机禁用时显示身体)
        TogglePlayerBody(!toggle);
    }

		private void Update()
    {
        // 如果是本地玩家控制的摄像机,不执行同步(因为本地玩家自己控制旋转)
        if (isOwner)
            return;
        
        // 同步非本地玩家的摄像机旋转,使其与cameraMimic的旋转保持一致
        // 这用于实现多人游戏中其他玩家摄像机的视觉同步
        transform.rotation = cameraMimic.transform.rotation;
    }
}

配置

修改虚拟相机为直接切换模式

二十一、使用 PurrNet 进行碰撞体回滚

通过添加碰撞体回滚来增强命中判定。这项技术通过在命中检测期间将玩家的碰撞体回退到之前的位置,来补偿网络延迟,从而确保多人对战中的公平和准确战斗。

参考:https://www.youtube.com/watch?v=_eNWOpZlrbI\&list=PLF6lFlLzb6CTq16028FAVkMQEgeTXLk8L\&index=17

二十二、使用PurrNet的中继服务器

前面的开发基本都是在本地调试,PurrNet为了帮我们进行真正的快速联机测试,提供了免费的中继服务器。

1、使用Purr Transport

使用Purr Transport替换UDP Transport,我们看到房间名,注意不要忘记修改Network ManagerTransport

注意:如提示介绍的,该服务器仅供开发使用。严禁在生产中使用。您需要为生产托管自己的中继服务器。

这时候你就可以打包发给朋友,一起进行联机测试了

2、显示延迟

我们还可添加Statistics Manager组件,用于事实显示游戏的ping值

运行可以看到延迟

二十三、发布到自己的linux服务器

前面介绍了使用PurrNet的服务器测试,如果我们想上线使用自己的服务器怎么做呢?

1、准备好linux服务器

至于怎么购买和使用服务器,我这里就不介绍了。

2、打包服务端程序

修改Network Manager,关闭客户端启动

修改UDP Transport的配置,改成自己的服务器ip和端口号(注意要记得要去服务器安全组放行你配置的端口号)

将unity程序打包成linux server服务器包

然后将打包好的全部文件上传到服务器

3、运行服务端

使用命令行进入刚才的游戏目录,然后执行比如./test.x86_64 ,运行游戏服务端端,这样就运行成功了

注意:不要用太新的unity打包,比如我用最新的unity6.3版本,运行时大概率会报错:而安装又是很麻烦的事情

我使用unity6lts版本打包,运行就没有什么问题

但是这样一直要开着控制台窗,一但我们关闭输入界面,服务端就停止了,所以我们需要让他在后台运行。

让Unity程序在后台运行的方法有很多,最简单的应该就是使用 nohup

csharp 复制代码
# 后台运行,忽略挂起信号,输出重定向到文件
nohup ./test.x86_64 > game.log 2>&1 &

# 查看进程
ps aux | grep test.x86_64

# 查看输出日志
tail -f game.log

# 杀死进程
kill 进程id

可以看到在后台运行成功了

4、打包客户端

修改Network Manager,关闭服务端启动,UDP Transport配置需要和服务端一样,所以我们不需要修改

然后正常打包,比如我这里打win包,把打包后的文件发个你的朋友,你们就可以联机游玩了

二十四、使用 PurrNet 设置 Steam 大厅

1、安装PurrNet Lobby插件

2、打开LobbySample场景,添加steamworks.net插件

如果你有自己的appid,修改记得重启unity生效

你已经有另一个不使用steamworks的设置,例如已经安装了heathe插件,估计你就不用引入这个包了,他已经安装好了。

3、初始化

你也可能不希望让大厅来处理这个队伍的初始化,但在这个情况下我没有其他的设置,所以我确实希望这个队伍大厅来处理初始化,所以勾选它

运行效果

现在,比如我创建一个大厅,然后点击准备,你可以看到它现在会尝试将我们带入场景中,如果你报如下错误

我们要先添加场景,把大厅场景放在顶部

我们再进入就进入了等待场景

现在大厅设置启动连接的方式实际上是存在一个大厅数据持有者对象,它存在于不要销毁和加载中

你可以看到如果我们打开序列化的大厅,它实际上会显示这个大厅的详细信息,它将使用Lobby ld来连接

现在这只是使用了purr transport

现在如果你已经在使用steam,我实际上建议你使用steam transport,你在address这里基本上只需输入owner id或者房间主人的owner id

记得修改

这里就使用purr transport吧,这样测试比较快,它只是PurrNet提供的一个免费的中继服务器,你可以作为开发者使用

4、游戏场景替换成我们真实的

我们已经通过构建标志自动处理了连接的启动,现在我们不需要这些了,所以我将禁用所有标志

添加Connection Starter新组件

把下一步场景替换成我们的新场景

运行发现已经连接上了

5、Network Manager加载时还保存

现在我也不希望这里的Network Manager加载时还保存,比如我想要能够返回大厅,而不需要网络管理器在那里,记得去除勾选

6、然后可以在两台电脑上运行测试,看看是否成功

7、现在如果我们不经过大厅,直接运行游戏场景会出错

新增脚本,其实就是修改Connection Starter脚本,我们自定义一个



Connection Starter替换成我们自己的

直接运行游戏场景,发现就正常了

8、Steam Relay 设置

改成steam Transport传输方式

记得配置Network Manager的Transport参数

修改Connectionstarter

从大厅进来发现成功了

另一台电脑加入

一切正常,连接的是同一地址

源码

https://gitee.com/xiangyuphp/purr-net-fps


专栏推荐

地址
【unity游戏开发入门到精通------C#篇】
【unity游戏开发入门到精通------unity通用篇】
【unity游戏开发入门到精通------unity3D篇】
【unity游戏开发入门到精通------unity2D篇】
【unity实战】
【制作100个Unity游戏】
【推荐100个unity插件】
【实现100个unity特效】
【unity框架/工具集开发】
【unity游戏开发------模型篇】
【unity游戏开发------InputSystem】
【unity游戏开发------Animator动画】
【unity游戏开发------UGUI】
【unity游戏开发------联网篇】
【unity游戏开发------优化篇】
【unity游戏开发------shader篇】
【unity游戏开发------编辑器扩展】
【unity游戏开发------热更新】
【unity游戏开发------网络】

完结

好了,我是向宇,博客地址:https://xiangyu.blog.csdn.net,如果学习过程中遇到任何问题,也欢迎你评论私信找我。

赠人玫瑰,手有余香!如果文章内容对你有所帮助,请不要吝啬你的点赞评论和关注,你的每一次支持都是我不断创作的最大动力。当然如果你发现了文章中存在错误或者有更好的解决方法,也欢迎评论私信告诉我哦!

相关推荐
旧梦吟2 小时前
脚本网页 双子星棋
算法·flask·游戏引擎·css3·html5
2401_841495643 小时前
【游戏开发】坦克大战
python·游戏·socket·pygame·tkinter·pyinstaller·坦克大战
顾安r3 小时前
1.1 脚本网页 战推棋
java·前端·游戏·html·virtualenv
lxysbly13 小时前
安卓玩MRP冒泡游戏:模拟器下载与使用方法
android·游戏
gshh__13 小时前
SuperMap Hi-Fi 3D SDK for Unreal 如何修改模型选中高亮颜色
ue5·游戏引擎·supermap
梓贤Vigo13 小时前
【Axure视频教程】制作动态排名图并导入Axure
交互·产品经理·axure·原型·教程
武汉唯众智创16 小时前
唯众数字人系统:以智慧交互、微课制作、专属分身三大功能重构教学场景,赋能智慧教学从概念到实践
重构·交互·easyui·数字人系统·专属分身·微课制作·智慧交互
LYOBOYI12318 小时前
qml练习:实现游戏相机(3)
数码相机·游戏
沉默金鱼1 天前
Unity实用技能-GM命令
unity·游戏引擎