unity复刻艾尔登法环(1)
\(^o^)/~🔥欢迎来到我的博文,本文主要内容是记录我对游戏开发过程中的学习和总结
一、角色控制和动画
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)动作动画调用流程
-
本地玩家 在
CharacterAnimatorManager.PlayerTargetActionAnimation()中,先在本地执行动画并设置状态; -
立即调用
NotifyTheServerOfActionAnimationServerRpc(),把自己的clientID和animationID发给服务器; -
服务器 收到后,在
NotifyTheServerOfActionAnimationServerRpc内部调用PlayActionAnimationForAllClientsClientRpc(); -
所有其他客户端 在
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(删除存档确认)
-
它们的初始状态和切换流程如下:
-
初始
-
Background:
active = true,仅显示 Title Screen Banner 与 Press Start Button; -
Main Menu、Load Menu、各 PopUp:
active = false。
-
-
按下 Start
-
Press Start Button.SetActive(false) -
Title Screen Main Menu.SetActive(true)
-
-
Main Menu 操作
-
New Game →
StartNewGame():创建新存档并加载场景 -
Load Game →
-
若无存档:
DisplayNoFreeCharacterSlotsPopUp()弹窗提示,确认后CloseNoFreeCharacterSlotsPopUp()回到 Main Menu; -
若有存档:
OpenLoadGameMenu()切换到 Title Screen Load Menu,聚焦第一个存档按钮。
-
-
-
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 限制赋值范围。
更多推荐



所有评论(0)