\(^o^)/~🔥欢迎来到我的博文,本文主要内容是记录我对游戏开发过程中的学习和总结

文章目录

  • 一、角色控制和动画
    • 1.Input System
    • 2.Player Manager
    • 3.Animator
  • 二、多人联机
  • 三、UI


一、角色控制和动画

1.Input System

        使用新的输入系统,本质上与旧的Input Manager一样,作用也是GetKey,只不过以图形化界面的形式,对于多平台的适配性很好,但是使用起来也更加繁琐,需要更多的配置,但配置好之后对于下一步的维护和使用就非常方便了。

        新的Input System需要在包管理器中下载,下载好之后在Assets目录下就能新建一个Input Action,双击点开,就能打开一个配置输入的图形界面。

        最左边是人物,比如Player,Enemy,中间是动作名称,最右边是具体的配置信息

        在最左边新建Player,包括移动,摄影机,以及动作。

        注意右侧的类型要更改,Movement这种有轴上数值的(相当于老版的Horizontal),用Vector 2。Action比如翻滚,奔跑这种一触即发的是Button类型。

        然后在中间对应的动作处新建键位,键盘找KeyBoard,手柄找GamePad,可以很方便的时候同一个动作的不同设备的适配。

        配置完成之后,在编译器点击这个Input Action,点击Generate会自动生成一个脚本,就OK了。

2.Player Manager 

        这里采用主控制类Player Manager+多个分支控制类的方式,只有主控制类的Update中处理逻辑,其余各个分支控制类都以函数的方式被主控制类调用,各个分支控制类之间不能信息交流,所有获取的信息都从Player Manager中获得。符合设计模式中的中介者模式,即

  • 中介者(Mediator):PlayerManager 就是中介者,集中管理各个子系统(各个分支控制类)的生命周期和数据流,所有的逻辑更新都在它的 Update 中发生。

  • 同事类(Colleague):各个分支控制类是“同事”,它们不直接互相通信,也不保留共享状态,所有需要的数据都从 PlayerManager 这个中介者那里获取。

3.Animator

        使用混合树实现多种动作的切换,混合树可以根据数值的大小,切换不同动作,在Base Layer中,根据x轴和y轴的数值大小,设置八个方向的角色走路、奔跑动画,还有Idle和冲刺动画。

二、多人联机

1.Netcode

(1)继承NetworkBehaviour。

        要使用 Netcode,不同于普通 MonoBehaviour,需要让网络管理类继承自 NetworkBehaviour。这样就拥有了判断“我是本地客户端”还是“远程客户端”的 IsOwner、IsServer、IsClient 等属性。

(2)NetworkVariable:网络同步的基本单位

        [Header("Position")]
        public NetworkVariable<Vector3> networkPosition = new NetworkVariable<Vector3>(Vector3.zero, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
        public NetworkVariable<Quaternion> networkRotation = new NetworkVariable<Quaternion>(Quaternion.identity, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
        public Vector3 networkPositionVelocity;
        public float networkPositionSmoothTime = 0.02f;
        public float networkRotationSmoothTime = 0.1f;

        [Header("Animator")]
        public NetworkVariable<float> horizontalMovement = new NetworkVariable<float>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
        public NetworkVariable<float> verticalMovement = new NetworkVariable<float>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
        public NetworkVariable<float> moveAmount = new NetworkVariable<float>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);

        [Header("Flags")]
        public NetworkVariable<bool> isSprinting = new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
   
  • DefaultValue 初始值

  • ReadPermission 决定谁可以读这个变量;

  • WritePermission 决定谁可以写。
    一般将写权限给 Owner 客户端(角色的操作者),读权限给所有人。

(3)位置与旋转的同步逻辑

        在本地玩家 (IsOwner == true) 的 Update() 中,把 Transform 的最新值写入 NetworkVariable:

protected override void Update()
{
    if (IsOwner)
    {
        characterNetworkManager.networkPosition.Value = transform.position;
        characterNetworkManager.networkRotation.Value = transform.rotation;
    }
}

        非本地玩家在 OnNetworkSpawn() 注册回调,收到远端数据变化后应用到本地 Transform;可以选择直接赋值,也可以使用插值/平滑过渡:

public override void OnNetworkSpawn()
{
    if (!IsOwner)
        characterNetworkManager.networkPosition.OnValueChanged += HandleRemotePositionChanged;
}

private void HandleRemotePositionChanged(Vector3 oldPos, Vector3 newPos)
{
    // 立即同步
    transform.position = newPos;
    transform.rotation = characterNetworkManager.networkRotation.Value;
    
    // 或者用平滑运动
    // transform.position = Vector3.SmoothDamp(transform.position, newPos, ref networkPositionVelocity, networkPositionSmoothTime);
    // transform.rotation = Quaternion.Slerp(transform.rotation, networkRotation.Value, networkRotationSmoothTime);
}
  • Owner 负责写入:只在本地玩家上写 NetworkVariable。

  • 其他客户端负责读回调:在收到新值时更新显示。

  • 可选:插值/缓动:避免瞬移带来的跳帧不连贯。

2.Rpc:跨客户端调用方法

        在很多情况下,仅同步数值还不够,比如“播放一次动作动画”的时机型事件,就需要通过 RPC(Remote Procedure Call)来通知其他客户端执行某段逻辑。

(1)ServerRpc:客户端→服务器
[ServerRpc]
public void NotifyTheServerOfActionAnimationServerRpc(
    ulong clientID, string animationID, bool applyRootMotion)
{
    if (IsServer)
    {
        // 收到请求后,广播给所有客户端
        PlayActionAnimationForAllClientsClientRpc(clientID, animationID, applyRootMotion);
    }
}
  • 调用方:任何拥有写权限的客户端。

  • 执行方:运行在服务器端的方法体里。

  • 典型用途:客户端请求服务器进行鉴权、判定后再广播事件。

(2)ClientRpc:服务器→客户端

        用 [ClientRpc] 特性标记的方法,可让服务器向所有(或指定)客户端广播调用:

[ClientRpc]
public void PlayActionAnimationForAllClientsClientRpc(
    ulong clientID, string animationID, bool applyRootMotion)
{
    // 过滤掉调用者自己,避免重复执行
    if (clientID != NetworkManager.Singleton.LocalClientId)
    {
        PerformActionAnimationFromServer(animationID, applyRootMotion);
    }
}

private void PerformActionAnimationFromServer(string animationID, bool applyRootMotion)
{
    character.animator.applyRootMotion = applyRootMotion;
    character.animator.CrossFade(animationID, 0.2f);
}
  • 调用方:运行在服务器的代码,通过 ServerRpc 或服务器本身触发。

  • 执行方:所有客户端(可选者过滤掉 Owner 自己)。

  • 典型用途:播放一次性事件(如攻击特效、技能释放、聊天气泡等)。

(3)动作动画调用流程

  1. 本地玩家 在 CharacterAnimatorManager.PlayerTargetActionAnimation() 中,先在本地执行动画并设置状态;

  2. 立即调用 NotifyTheServerOfActionAnimationServerRpc(),把自己的 clientID 和 animationID 发给服务器;

  3. 服务器 收到后,在 NotifyTheServerOfActionAnimationServerRpc 内部调用 PlayActionAnimationForAllClientsClientRpc();

  4. 所有其他客户端 在 PlayActionAnimationForAllClientsClientRpc 中,过滤掉本地玩家自身,再执行 PerformActionAnimationFromServer,同步播放动画。

        这样就保证了:

  • 本地玩家的操作可以立刻看到自己播放动画;

  • 其他玩家也能以最小延迟看到该动作;

  • 所有动画逻辑只由服务器来转发,避免客户端篡改或不同步。

小结:

通过 NetworkVariable + 插值同步,配合 ServerRpc/ClientRpc 事件广播,就能搭建出一个功能完善的多人联机同步框架:

  • 连续状态同步(位置、速度、动画参数、属性等)交给 NetworkVariable;

  • 离散事件通知(攻击、施法、拾取道具等)交给 RPC;

  • 所有同步逻辑 写在继承自 NetworkBehaviour 的管理类里,方便集中维护。

三、UI

在游戏中,UI 承载着玩家与系统交互的全部入口:主菜单、存档、提示窗乃至游戏内状态条都由它来呈现与驱动。下面我们分三部分来讲述如何搭建与管理这些 UI 界面。

1. Title Screen(开场界面)

开场界面由一个根 Canvas 管理,包含以下几个子页面:

  • Title Screen Background

    • Banner(游戏标题)

    • Press Start Button(初始化按钮)

    • Title Screen Main Menu(主菜单)

    • No Character Slots PopUp(无存档提示)

  • Title Screen Load Menu(存档选择界面)

    • 主加载面板(Scrollbar + Mask + 存档槽位列表 + 返回按钮)

    • Delete Character Slot PopUp(删除存档确认)

它们的初始状态和切换流程如下:

  1. 初始

    • Background:active = true,仅显示 Title Screen Banner 与 Press Start Button;

    • Main Menu、Load Menu、各 PopUp:active = false。

  2. 按下 Start

    • Press Start Button.SetActive(false)

    • Title Screen Main Menu.SetActive(true)

  3. Main Menu 操作

    • New Game → StartNewGame():创建新存档并加载场景

    • Load Game →

      • 若无存档:DisplayNoFreeCharacterSlotsPopUp() 弹窗提示,确认后 CloseNoFreeCharacterSlotsPopUp() 回到 Main Menu;

      • 若有存档:OpenLoadGameMenu() 切换到 Title Screen Load Menu,聚焦第一个存档按钮。

  4. Load Menu 操作

    • 选择存档槽位 + 回车 → LoadGameFromCharacterSlot():载入相应角色存档并进入游戏场景;

    • Delete(按特定键)→ AttemptToDeleteCharacterSlot() 弹出确认框,确认后 DeleteCharacterSlot() 删除存档;

    • 返回按钮 → CloseLoadGameMenu() 回到 Main Menu。

2. 存档槽位 UI

每个存档槽位由 UI_Character_Save_Slot 组件控制,包括角色名与游玩时长展示,以及加载、选择和删除逻辑:

  • OnEnable → LoadSavaSlots():读取对应槽位文件,若存在则显示角色名;否则隐藏该槽位。

  • LoadGameFromCharacterSlot() → 通过 WorldSaveGameManager 载入存档并切换场景。

  • SelectedCurrentSlot() → 调用 TitleScreenManager.instance.SelectedCharacterSlot(...),设置当前选中状态,供删除等操作使用。

public class UI_Character_Save_Slot : MonoBehaviour {
    public CharacterSlot characterSlot;
    public TextMeshProUGUI characterName, timePlayed;

    private void OnEnable() {
        LoadSavaSlots();
    }

    private void LoadSavaSlots() {
        saveFileWriter = new SaveFileDataWriter();
        saveFileWriter.saveDataDirectoryPath = Application.persistentDataPath;
        saveFileWriter.saveFileName = WorldSaveGameManager.instance.DecideCharacterFileNameBasedOnCharacterBeingUsed(characterSlot);

        if (saveFileWriter.CheckToSeeIfFileExists()) {
            characterName.text = WorldSaveGameManager.instance
                                      .GetCharacterNameBySlot(characterSlot);
        } else {
            gameObject.SetActive(false);
        }
    }

    public void LoadGameFromCharacterSlot() {
        WorldSaveGameManager.instance.currentCharacterSlotBeingUsed = characterSlot;
        WorldSaveGameManager.instance.LoadGame();
    }

    public void SelectedCurrentSlot() {
        TitleScreenManager.instance.SelectedCharacterSlot(characterSlot);
    }
}
3. 游戏内 HUD:状态条(Stat Bar)

进入游戏后,我们往往需要展示角色的核心属性(如耐力、生命、魔法等),这时用 UI_StatBar 与 PlayerUIHudManager 非常方便:

  • UI_StatBar (继承自 Slider)

    • SetMaxStat(int maxValue):设置最大值并初始化当前值;

    • SetStat(int newValue):动态更新条的填充量。

  • PlayerUIHudManager

    • 持有一组 UI_StatBar 组件引用(如 staminaBar)。

    • 在 TitleScreenManager.OnNetworkSpawn() 或角色初始化时,订阅 playerNetworkManager.currentStamina.OnValueChanged:

playerNetworkManager.currentStamina.OnValueChanged += PlayerUIHudManager.SetNewStaminaValue;
playerNetworkManager.currentStamina.OnValueChanged += playerStatsManager.ResetStaminaRegenerationTimer;
PlayerUIHudManager.SetMaxStaminaValue(playerNetworkManager.maxStamina.Value);

当数值改变时,自动调用:

public void SetNewStaminaValue(float oldValue, float newValue) {
    staminaBar.SetStat(Mathf.RoundToInt(newValue));
}
public void SetMaxStaminaValue(float maxStamina) {
    staminaBar.SetMaxStat(Mathf.RoundToInt(maxStamina));
}

这样,无论是网络同步后的数值变化,还是本地逻辑驱动的属性增长、消耗,都能实时反映到屏幕上的状态条上,为玩家提供直观反馈。

小结:

  • 开场界面:结构化管理各菜单与弹窗,利用 SetActive + Button.Select() 实现无缝导航;

  • 存档槽位:封装成组件,动态读档/隐藏,不存在的槽位自动消失;

  • 游戏内 HUD:用 Slider + 事件订阅,做到数值与 UI 的松耦合同步。


总结

开发过程中遇到的问题:
        1.如果在Update()中,同时实现Owner写入和非Owner读取的操作,由于在同一帧里既写入又读取 NetworkVariable ,会因 Netcode 的网络心跳时序冲突导致非Owner永远拿到旧值、角色静止不动,无法获取新写入的值。

解决:只在拥有者端写入 networkPosition.Value,把非拥有者端的读取改到 OnValueChanged 回调或放到 LateUpdate(),确保读到的是网络同步后的最新值。

        2.在后面的存档系统中,角色从js文件中读到了初始化坐标,但transform.position=targetPosition并无法移动角色,这是因为在使用 CharacterController 的前提下,直接改 transform.position 会被CharacterController自身的内部位置状态覆盖。

解决:在修改 transform.position 前先 characterController.enabled = false,改完再 enabled = true,或使用 characterController.Move()/SimpleMove() 接口来驱动角色移动。

        3.订阅 currentStamina.OnValueChanged 时 Netcode 会立即补发一次当前值,如果此时 UI Slider 的 maxValue 还没设置好,就会因超出范围抛出异常。

解决:先用 SetMaxStaminaValue() 设置好 slider.maxValue,再订阅 currentStamina.OnValueChanged;或在 SetNewStaminaValue 中用 Mathf.Clamp 限制赋值范围。

更多推荐