技术笔记(9)MMORPG人物操作系统
-
希望实现的功能或目标:
- 实现人物在场景内的移动、转向、跳跃、落地判断
- 实现有限状态机
-
学习笔记:
-
PlayerMovementController类
-
作用:负责玩家的行为控制
-
挂载到Player游戏物体身上,Player游戏物体没有刚体和碰撞体,取而代之的是CharacterController组件。
-
当前类声明使用到的变量:
-
组件
-
private CharacterController characterController;
-
public Transform groundCheckPointTrans;
-
-
旋转
-
public float rotateSpeed = 120;
-
-
移动
-
public float moveSpeed = 3;
-
-
跳跃
-
public float gravity = 9.8f;
-
public float verticalVelocity = 0;
-
public float MaxJumpHeight = 1.7f;
-
-
判断接地
-
public float checkSphereRadius = 0.1f;
-
public LayerMask groundLayer;
-
public bool isGround;
-
-
状态机相关
-
private InputController ic;
-
private CharacterFSM characterFSM;
-
-
-
事件函数:
-
Awake方法中,去拿到游戏物体的CharacterController组件、判断接地的transform、当前角色模型;然后为其挂载上InputController类和CharacterFSM类脚本组件,并把角色的动画控制器和输入管理ic传进去,初始化有限状态机。
-
private void Awake() { characterController = GetComponent<CharacterController>(); groundCheckPointTrans = transform.Find("GroundCheckPoint"); playerModel = transform.GetChild(0).gameObject; ic = gameObject.AddComponent<InputController>(); characterFSM = gameObject.AddComponent<CharacterFSM>(); characterFSM.InitFSM(playerModel.GetComponent<Animator>(), ic); }
-
-
Update方法中调用写好的旋转视角和人物移动跳跃方法
-
void Update() { PlayerRotateViewControl(); PlayerMoveAndJumpControl(); }
-
-
-
旋转视角方法:
- 调用transform的Rotate方法,由于玩家模型和摄像机都归属于Player,作为其子物体,故而人物和摄像机会随着父物体一起旋转.
-
private void PlayerRotateViewControl() { transform.Rotate(Vector3.up * Input.GetAxis("Mouse X") * rotateSpeed * Time.deltaTime); }
-
移动和跳跃方法:
-
防止角色下沉,如果角色跳跃后,身体陷入到地板里,就帮他恢复到地板上
-
首先将移动向量motionVector设置为Vector3.zero,由于本方法放在Update函数中,每帧都会被调用,且后续计算前后、左右的移动和上下跳跃时用的是向量相加。所以需要每帧先归零再去与各个向量相加。
-
获取Horizontal和Vertical的AxisRaw输入,用浮点类型变量h和v接收(后续添加InputController类之后,改为调用ic.GetFloatInputValue()来获取这两个输入)
-
拿到h和v之后,先判断一下是让状态机进IDLE状态还是MOVE状态
-
motionVector先加等transform.forward * 移速 * v * Time.deltaTime
再加等transform.right * moveSpeed * h * Time.deltaTime
-
在角色脚底放个空物体,使用射线检测,在空物体的位置做一个球体的检测,若球半径内有目标层级的物品,则能获得一个true的返回值
-
判断如果不在地上,就将垂直速度减等 重力加速度 * 间隔时间
-
motionVector此时再加等 Vector3.up * 垂直方向速度 * 间隔时间
-
如果按下跳跃键(更改为从ic获取跳跃输入,且状态机当前状态不是跳跃状态),并且判断还在地上,就套用物理公式 v2 = 2gh 来算想要达到某个目标高度,需要给到多少的初速度。
-
调用characterController的Move方法,将motionVector作为参数传入
-
判断如果到了地上,且垂直速度还小于0。就将垂直速度归零,并将物体的position的y也归零。
这部分判断和处理放到Move方法之后,主要是为了处理其造成的人物跳跃后接触不到地面或是陷入地面的bug,强行把position的y归零。
-
private void PlayerMoveAndJumpControl() { if (transform.position.y < 0) { transform.position = new Vector3(transform.position.x, 0f, transform.position.z); } Vector3 motionVector = Vector3.zero; //float h = Input.GetAxisRaw("Horizontal"); //float v = Input.GetAxisRaw("Vertical"); float h = ic.GetFloatInputValue(InputCode.HorizontalMoveValue); float v = ic.GetFloatInputValue(InputCode.VerticalMoveValue); if (characterFSM.GetCurrentState() != CHARACTERSTATE.JUMP) { JudgeAndChangeStateIdleOrMove(h, v); } motionVector += transform.forward * moveSpeed * v * Time.deltaTime; motionVector += transform.right * moveSpeed * h * Time.deltaTime; isGround = Physics.CheckSphere(groundCheckPointTrans.position, checkSphereRadius, groundLayer); if(!isGround) { verticalVelocity -= gravity * Time.deltaTime; } motionVector += Vector3.up * verticalVelocity * Time.deltaTime; //if (Input.GetButtonDown("Jump")) if (ic.GetBoolInputValue(InputCode.JumpState)&& characterFSM.GetCurrentState() != CHARACTERSTATE.JUMP) { if (isGround) { verticalVelocity = Mathf.Sqrt(2 * gravity * MaxJumpHeight); } } characterController.Move(motionVector); if (isGround) { if (verticalVelocity < 0) { verticalVelocity = 0; transform.position = new Vector3(transform.position.x, 0f, transform.position.z); } } }
-
-
判断进IDLE还是进MOVE状态的方法:
- 如果h和v有一个不为0,进MOVE;否则进IDLE
-
private void JudgeAndChangeStateIdleOrMove(float h,float v) { if (v != 0 || h != 0) { characterFSM.ChangeState(CHARACTERSTATE.MOVE); } else { characterFSM.ChangeState(CHARACTERSTATE.IDLE); } }
-
-
状态枚举类型CHARACTERSTATE
-
public enum CHARACTERSTATE { //NONE, IDLE, MOVE, RUN, JUMP, ATTACK, HIT, DEAD }
-
-
状态基类BaseState
-
存储与自己相关联的有限状态机CharacterFSM
protected CharacterFSM cfsm;
-
存储要去调整的Animator
protected Animator animator;
-
存储与当前状态类相关联的状态枚举
public CHARACTERSTATE stateType;
-
当前状态要做的事:
- 初始化:
public abstract void InitState();
- 进入时:
public abstract void EnterState();
- 退出时:
public abstract void ExitState();
- 持续运行:
public abstract void UpdateState();
- 初始化:
-
-
静态输入字符类InputCode
- 存储几个const常量和一个静态字符串数组,记录输入的名称
-
public const string HorizontalMoveValue = "HorizontalMoveValue"; public const string VerticalMoveValue = "VerticalMoveValue"; public const string MoveRotateState = "MoveRotateState"; public const string HorizontalRotateValue = "HorizontalRotateValue"; public const string JumpState = "JumpState"; public const string EquipState = "EquipState"; public const string AttackState = "AttackState"; public static string[] skillsState = new string[] {"SkillState0", "SkillState1", "SkillState2","SkillState3","SkillState4","SkillState5","SkillState6" };
-
输入管理类InputController
-
变量
- <string, bool>字典
private Dictionary<string, bool> inputBoolValueDict;
- <string, float>字典
private Dictionary<string, float> inputFloatValueDict;
- <string, bool>字典
-
事件函数:
-
在Start中把会用到的输入值存到两个字典中并设默认值
-
void Start() { inputBoolValueDict = new Dictionary<string, bool>() { { InputCode.JumpState,false } }; inputFloatValueDict = new Dictionary<string, float>() { { InputCode.HorizontalRotateValue,0 }, { InputCode.HorizontalMoveValue,0 }, { InputCode.VerticalMoveValue,0 } }; }
-
-
在Update中多次调用SetInputValue方法将InputCode和对应的输入相关联并存入字典
-
void Update() { SetInputValue(InputCode.HorizontalMoveValue, Input.GetAxis("Horizontal")); SetInputValue(InputCode.VerticalMoveValue, Input.GetAxis("Vertical")); SetInputValue(InputCode.HorizontalRotateValue, Input.GetAxisRaw("Mouse X")); SetInputValue(InputCode.JumpState,Input.GetButtonDown("Jump")); }
-
-
-
方法:
-
SetInputValue方法:有两个重载,一是对bool型的,一是对float型的
- 都是先查一下字典中有没有这个名字的输入,有的话就更新那个值,没有的话就输出错误日志信息
-
public void SetInputValue(string inputCode, bool inputValue) { if (inputBoolValueDict.ContainsKey(inputCode)) { inputBoolValueDict[inputCode] = inputValue; } else { Debug.Log("设置输入码错误,错误码为" + inputCode); } } public void SetInputValue(string inputCode, float inputValue) { if (inputFloatValueDict.ContainsKey(inputCode)) { inputFloatValueDict[inputCode] = inputValue; } else { Debug.Log("设置输入码错误,错误码为" + inputCode); } }
-
GetBoolnputValue()、GetFloatInputValue():查一下字典里有没有,有就返回出来,没有就日志报错
-
-
-
有限状态机CharacterFSM类
-
变量:
-
记录<状态枚举,状态类>键值对的字典
private Dictionary<CHARACTERSTATE, BaseState> statesDict;
-
当前状态
private BaseState currentState;
-
上一个状态
private BaseState lastState;
-
输入管理
private InputController ic;
-
-
方法:
-
初始化有限状态机:把会用到的状态以<状态枚举,状态类对象>键值对形式存进字典里,获取外界的InputController,把各个状态和相关参数置默认值初始化
-
public void InitFSM(Animator currentAnimator,InputController inputController) { statesDict = new Dictionary<CHARACTERSTATE, BaseState>() { {CHARACTERSTATE.IDLE, new IdleState(this,currentAnimator,CHARACTERSTATE.IDLE) }, {CHARACTERSTATE.MOVE, new MoveState(this,currentAnimator,CHARACTERSTATE.MOVE) }, {CHARACTERSTATE.JUMP, new JumpState(this,currentAnimator,CHARACTERSTATE.JUMP) }, }; ic = inputController; SetDefaultState(); }
-
-
状态设默认值:调用字典里所有状态类的初始化方法,并把当前状态设置为IDLE,并调用当前状态的EnterState方法
-
private void SetDefaultState() { foreach(var item in statesDict) { item.Value.InitState(); } currentState = statesDict[CHARACTERSTATE.IDLE]; currentState.EnterState(); }
-
-
改变状态:参数传入一个状态枚举changeState,先检查一下,要改变到的状态在字典中是否存在。如果在,就把字典中,这个状态枚举键对应的状态类对象拿出来,和FSM记录的当前状态currentState对比,如果当前不在这个状态。就调用当前所已记录状态的Exit方法,并用lastState记录,currentState替换为changeState,并调用其Enter方法。
-
public void ChangeState(CHARACTERSTATE newStateType) { if(statesDict.ContainsKey(newStateType)) { BaseState changeState = statesDict[newStateType]; if(changeState != currentState) { currentState.ExitState(); lastState = currentState; currentState = changeState; currentState.EnterState(); } } }
-
-
获取当前状态:返回当前状态类对象的状态枚举,而非直接返回状态对象
-
public CHARACTERSTATE GetCurrentState() { return currentState.stateType; }
-
-
输入值的设置和获取:调用InputContraoller对象中的设置和获取输入值方法
-
public void SetInputValue(string inputCode, bool inputValue) { ic.SetInputValue(inputCode, inputValue); } public bool GetBoolInputValue(string inputCode) { return ic.GetBoolInputValue(inputCode); } public void SetInputValue(string inputCode, float inputValue) { ic.SetInputValue(inputCode, inputValue); } public float GetFloatInputValue(string inputCode) { return ic.GetFloatInputValue(inputCode); }
-
-
-
事件函数:
-
在Update中调用当前状态类对象的UpdateState方法
-
void Update() { if(currentState != null) { currentState.UpdateState(); } }
-
-
-
-
关系图
-
- PlayerMovementController - InputController - inputBoolDic<InputCode.string , bool> - Jump - inputFloatDic<InputCode.string , float> - HorizontalRotate - HorizontalMove - VertivalMove - CharacterFSM - currentState - lastState - ic(InputController) - stateDic<CHARACTERSTATE , BaseState> - IdleState - MoveState - JumpState
-
-
-
实现过程中产生的疑惑:
-
transform.Rotate()传入的参数为什么有Vector3.up即(0,1,0)?这不是指向场景正上方的吗?难道是作为旋转轴?
-
为什么人物的移动的方向向量、跳跃的垂直方向速度等都要乘Time.deltaTime?不乘会怎么样?
-
CharacterController组件
- stepoffset
- Move()
-
每次跳跃后与地面不完全接触?
-
-
对疑惑的解答:
-
往transform.Rotate()传入Vector3.up,是围绕y轴旋转的意思,例如(0,90,0)就是根据左手规则围绕y轴旋转90度;而只传了一个参数,意味着是以物体本身的坐标系为基准。其内部具体实现时,先将Euler形式的向量转化为四元数,如果以自身坐标系为参考,则直接将四元数累乘;如果是以别的空间坐标系作为参考,则需要先逆转原本的旋转,累乘新旋转后再累乘会原有的旋转。
-
public void Rotate(Vector3 eulers, [DefaultValue("Space.Self")] Space relativeTo) { Quaternion quaternion = Quaternion.Euler(eulers.x, eulers.y, eulers.z); if (relativeTo == Space.Self) { localRotation *= quaternion; } else { rotation *= Quaternion.Inverse(rotation) * quaternion * rotation; } }
-
-
乘Time.deltaTime是为了确保游戏运动平滑且与帧率无关,这意味着无论游戏运行得多块或多慢,游物体的移动速度和加速度都会保持一致。确保所有玩家都有相同的游戏体验
这位这种放在Update事件函数中的方法,每帧调用一次,意味着如果不乘上Time.deltaTime的话:
- 帧率高,那就调用得多,运动得远;帧率低,就调用得少,运动得短
- 于是移动速度也随帧率的高而高,低而低
- 在不同的硬件和性能条件下,就会表现出较大的差距
-
CharacterController用于简化角色控制,允许轻松创建出受碰撞约束的移动,而无需处理刚体
-
关于该组件的部分属性理解:
Slope Limit:斜坡限制,以度为单位,限制角色能够爬升的最大斜坡角度。
Step Offset:步长偏移,指定角色能够步越的最大高度。如果障碍物的高度低于此值,角色可以步越它。
Skin Width:皮肤宽度,表示角色碰撞器在碰撞时可以穿透的深度,它像一个围绕角色的层。
Min Move Distance:最小移动距离,如果角色尝试移动的距离小于此值,它将不会移动。这可以用来减少抖动。
Center:中心,指定角色胶囊碰撞器相对于变换位置的中心偏移。
Radius:半径,胶囊碰撞器的半径,本质上是碰撞器的宽度。
Height:高度,角色胶囊碰撞器的高度。改变这个值将沿Y轴正负方向缩放碰撞器。
Layer Overrides:层覆盖,允许你为CharacterController指定额外的层,以决定它可以与哪些其他碰撞器接触。
-
-
关于跳跃后与地面的接触问题,可能造成的原因有:
- Physics.CheckSphere地面检测没有正确设置,导致检测不够准确
- 角色与地面碰撞器交互有问题
- 帧率波动导致物理更新没有与帧正确同步
-