玩家
如果玩家移动,攻击、冲刺、跳跃的逻辑写在一个脚本中,然后将脚本挂载到玩家身上。本身是没有问题的。但是如果代码中出现bug,就会在这个脚本中找问题,会造成代码冗余。
基本框架的搭建
玩家需要移动、跳跃、攻击等行为,而这些行为都有一些公共的逻辑。如:所有行为在进入状态之前都需要播放该行为的动画。
比如:当我从移动转化为攻击的时候。移动退出(停止播放移动动画),攻击进入(播放攻击动画)
那么怎么做呢?
move(){
playMoveAnimation(); // 播放移动动画
// 处理移动逻辑 -----
-------
// 处理移动逻辑 -----
stopPlayMoveAnimation(); // 停止播放移动动画
}
attack(){
playAttackAnimation(); // 播放移动动画
// 处理攻击逻辑 -----
-------
// 处理攻击逻辑 -----
stopAttackAnimation(); // 停止播放移动动画
}
但是这样会出现一个问题。如果有很多个这样的行为,如跳跃、冲刺、魔法攻击等,都要这两步操作的话,就会让代码变得十分的冗余。有没有更好的办法。让代码变得更加的简单呢?
当然有,我们会发现,都是动画的播放和停止。如果传一个动画参数名,就可以指定的说明播放的是哪一个动画。
playAnimation(aniName);
-- 逻辑处理
stopPlayAnimation(aniName)
然后将这三个拆开,抽象出三个方法(进入、中间、退出),再利用多态的思想,将其抽象成一个类。最终形成这样的一个代码。
public class PlayerState{
private Player player;
private string animBoolName; // 当前状态的动画的名字(必须与外面动画名字相同)
// 构造方法
public PlayerState(Player player, string animBoolName){
this.animBoolName = animBoolName;
this.player = player;
}
public virtual void Enter(){
// 播放动画
// 我们知道播放动画需要Animator组件,那么如何去获得这个组件呢?
// 1. 首先我们需要在人物中挂载一个Player的脚本,用于获取当前游戏对象的所有组件
// 2. 我们这个类需要获得Player的脚本,就得给这个类添加一个属性Player
// 3. 我们在构造函数时获得它
player.animator.setBool(animBoolName, true);
}
public virtual void Update(){
}
public virtual void Exit(){
// 停止播放
player.animator.setBool(animBoolName, false);
}
}
public class player {
public Animator animator;
void Awake(){
animator = this.getCompoent<Animator>();
}
}
接下来,我们将会去思考,如何切换动画呢?
移动动画转化为攻击动画?如何转化呢?
我们可以通过PlayerStateMachine来间接的切换移动状态和攻击状态
PlayerStateMachine脚本:这个用于处理玩家各个状态类的切换
namespace DY.RPG
{
public class PlayerStateMachine
{
public PlayerState currentState { get; private set; }
public void Initialize(PlayerState _startState)
{
this.currentState = _startState;
_startState.Enter();
}
public void ChangeState(PlayerState _newState)
{
this.currentState.Exit();
this.currentState = _newState;
this.currentState.Enter();
}
}
}
人物和动画的层级设计
- Plyaer 空对象(用来控制人物脚本,碰撞器组件)
- Animation 人物模型(Animator组件)
动画状态机的设计
移动和闲置功能思路
需求分析:玩家输入键盘A,人物向左移动,玩家输入键盘D,人物向右移动
问题一: 如何去获得用户的输入呢?
分析:输入肯定在update实时获取。你可以使用新版的,也可以使用老版的。如果我们把用户的输入放在PlayerMoveState的Update里。就会出现这样的问题。我在在冲刺状态时、攻击状态时都要判断玩家的方向,而玩家的方向是由输入决定的,又要在其他的代码中获取输入,这大大的增加了代码的冗余度。所以我们把用户的输入放在基类上,这样只要继承这个基类的类都可以获得用户的输入。所以我们在PlayerState父类中添加下面的代码。
protected float inputX;
public virtual void Update()
{
inputX = Input.GetAxisRaw("Horizontal");
}
所有继承PlayerState的子类,都可以直接使用 inputX
来获取用户的输入
问题二:按下输入的值,如何从闲置转化为移动,移动转化为闲置呢?
这个简单,只需要在PlayerIdleState类中的Update方法中,获得用户的输入,通过用户的输入判断是否切换为移动状态。
if (inputX != 0)
playerStateMachine.ChangeState(player.playerMoveState);
同样的道理,在PlayerMoveState类中的Update方法中,写如下代码
if (inputX == 0)
playerStateMachine.ChangeState(player.playerIdleState);
问题三:既然状态切换完成,我们就可以专注于写移动逻辑?如何设计呢?
根据上面的设计,我们就可以在PlayerMoveState中专注于逻辑代码,包括刚刚进入这个状态需要做什么,一直处于这个状态需要做什么,退出这个状态需要做什么。
我们要知道,移动肯定要产生位移,而产生位移需要RididyBody组件,所以我们需要在玩家上挂载RididyBody组件,通过Player脚本去获取它。(这里的代码太简单,不在叙述)
接下来,我们思考一个问题:移动、跳跃、攻击、冲刺,这些都需要产生位移,所以我们可以将这些提取成一个公共的方法,将其放在Player类中。通过设置x和y的值,来设置速度。代码如下
public void SetVelocity(float xVelocity, float yVelocity)
{
rb.velocity = new Vector2(xVelocity, yVelocity);
}
既然如此,那么移动逻辑就变得非常简单了,在PlayerMoveState中的Update写入下面的代码:
player.SetVelocity(inputX * player.MoveSpeed, player.rb.velocity.y);
问题四:向左移动,切换想左移动的动画,向右移动,切换向右移动的动画。
我们要去思考:什么时候切换?
在切换动画之前,我们需要获得当前人物的朝向,在获得玩家输入的方向,
逻辑:
- 玩家向右输入,当前人物朝向向左,切换动画
- 玩家向左输入,当前人物朝向向右,切换动画
但切换动画的逻辑写在哪里呢?因为切换时通过 transform.Rotate(0, 180, 0);
来的,而这个 transform
必须时人物对象,所以时写在Player脚本中。
我们继续思考一个问题:看下面的代码需要 facingDir
不是多余的吗?为什么要设置这个呢?
这是因为我们在设置位移的时候,向右移动,是正的,向左移动,是负的。我们直接在 可以通过 facingDir * 位移
就可以控制位移的正负了。
最后一个问题:在哪里去调用这个翻转逻辑呢?
输入改变的时候,可以在设置速度之后,调用这个逻辑。
public int facingDir { get; private set; } = 1;
public bool facingRight = true;
public void Flip()
{
facingDir *= -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
public void FlipController(float _x)
{
if (_x > 0 && !facingRight)
Flip();
else if (_x < 0 && facingRight)
Flip();
}
// 之前的代码
public void SetVelocity(float xVelocity, float yVelocity)
{
rb.velocity = new Vector2(xVelocity, yVelocity);
// 添加翻转
FlipController(xVelocity);
}
跳跃功能思路
- 制作跳跃和降落动画
我们通过 yVelocity
来判断是跳跃还是降落动画,通过混合树去制作
- 跳跃思路
分析:按下空格,从地面PlayerGroundedState切换为PlayerJumpState,开始上升,当上升速度为0时,开始下降,进入PlayerAirState状态,当回到地面,进入PlayerIdleState状态
- 防止一直跳
思路:只有检测处于地面的时候,才能跳
- 检测地面思路
[SerializeField] public Transform groundTF;
[SerializeField] public float groundCheckDistance = 0.3f;
public LayerMask whatIsGround;
public bool IsGrounded() => Physics2D.Raycast(groundTF.position, Vector2.down, groundCheckDistance, whatIsGround);
public void OnDrawGizmos()
{
Gizmos.DrawLine(groundTF.position, groundTF.position - new Vector3(0, groundCheckDistance));
}
- 解决空中不能移动的问题
在空中状态,控制移动
if (inputX != 0)
player.SetVelocity(inputX * player.MoveSpeed * .8f, player.rb.velocity.y);
冲刺功能思路
它是一个冲刺的状态:PlayerDashState
它有一个计时器,当进入PlayerDashState这个状态后,将初始化这个计时器,并不断的减少
如果这个计时器小于0,就会退出Dash状态,进入idle状态
- 什么时候,进入dash状态?
在任何状态中,当按shift时,就会进入dash状态。
public void DashForInput()
{
if (Input.GetKeyDown(KeyCode.LeftShift))
{
dashDir = Input.GetAxisRaw("Horizontal");
if (dashDir == 0)
dashDir = facing;
stateMachine.ChangeState(playerDashState);
}
}
- 控制冲刺的移动逻辑
在进入dash状态后,初始化冲刺时间
stateTimer = player.dashDurationTime;
在冲刺过程中,控制他的移动逻辑
player.SetVelocity(player.dashSpeed * player.dashDir, 0);
什么时候退出冲刺状态
if (stateTimer < 0)
playerStateMachine.ChangeState(player.playerIdleState);
为了避免冲刺结束后的惯性,直接设置为0
player.SetVelocity(0, player.rb.velocity.y);
- 解决连续冲刺:添加冲刺冷却时间
[SerializeField]private float dashCoolTime;
private float dashUsageTimer;
....
dashUsageTimer -= Time.deltaTime;
if(dashUsageTimer <0) {
// 才能进入冲刺状态
// 并恢复冲刺冷却
dashUsageTimer = dashCoolTime;
}
- 解决在空中冲刺后,下落播放的是闲置动画问题。
我们之前不是说,冲刺结束后,直接切换闲置状态,如果在空中,那么我想需要从闲置状态切换为空中状态。
我们需要在地面状态时,检测是否在地面
if (!player.IsGourndChecked())
playerStateMachine.ChangeState(player.playerAirState);
滑墙功能思路
- 解决角色粘住墙体问题
给墙体添加材质 Physics Material 2D
,并设置材质的摩擦力为 0
- 制作滑墙动画
- 制作
PlayerWallSlideState
类 - 墙体检测思路
[SerializeField] public LayerMask whatIsGround;
[SerializeField] public Transform wallCheckTF;
[SerializeField] public float wallCheckDistance;
public bool IsWallChecked() => Physics2D.Raycast(wallCheckTF.position, Vector2.right, wallCheckDistance * facing, whatIsGround);
private void OnDrawGizmos()
{
Gizmos.DrawLine(wallCheckTF.position, wallCheckTF.position + new Vector3(wallCheckDistance, 0) * facing);
}
- 滑墙状态逻辑
在空中状态如果检测到墙体,切换到滑墙状态。
if (player.IsWallChecked())
playerStateMachine.ChangeState(player.playerWallSlideState);
如果贴住墙体,他会下降的很慢(原来速度的0.7倍)。如果玩家按下下键,他会滑的更快(恢复下降速度)。
if (inputY < 0)
player.rb.velocity = new Vector2(0, player.rb.velocity.y);
else
player.rb.velocity = new Vector2(0, player.rb.velocity.y * 0.7f);
如果在滑墙状态下,往墙的反方向移动,他会进入idle状态,因为idle检测到不在地面,会进入空中状态。
if (inputX != 0 && player.facing != inputX)
playerStateMachine.ChangeState(player.playerIdleState);
如果检测到地面,就会从滑墙状态切换为idle状态。
if(player.IsGroundDetected())
playerStateMachine.ChangeState(player.playerIdleState);
PlayerWallJumpState 在墙上跳的状态
- 制作
PlayerWallJumpState
脚本 - 在墙上的状态,按住空格,就会进入
PlayerWallJumpState
状态
if(Input.GetKeyDown(KeyCode.Space))
{
playerStateMachine.ChangeState(player.playerWallJumpState);
return;
}
- 进入
PlayerWallJumpState
状态,会有一个计时器,只要计时器小于0,就会立刻进入空中状态
public override void Enter()
{
base.Enter();
stateTimer = 1f;
player.SetVelocity(5 * -player.facingDir, player.jumpForce);
}
public override void Update()
{
base.Update();
if (stateTimer < 0)
playerStateMachine.ChangeState(player.playerAirState);
}
- 解决从墙上跳跃然后落下进入闲置状态之后的滑步问题。
问题一:在 PlayerWallJumpState
状态下,计时器还没有结束,就直接进入地面状态了,没有进入跳跃状态。
解决:
if (player.IsGroundDetected())
playerStateMachine.ChangeState(player.playerIdleState);
问题二:进入空中状态后到闲置状态,有一定的惯性,所有会滑步
解决方案:在闲置状态进入后,直接设置当前的速度为0
public override void Enter()
{
base.Enter();
player.rb.velocity = new Vector2(0,0);
}
- 我们在空中在冲刺的的时候,没有立刻粘住墙体,而是播放完冲刺后,才会粘到墙上的问题。(我们在冲刺状态中添加)
if (!player.IsGroundDetected() && player.IsWallDetected())
playerStateMachine.ChangeState(player.playerWallSlideState);
- PlayerWallSlideState状态下是不能进行冲刺的。(在警车冲刺时进行判断)
public void DashForInput()
{
if (IsWallDetected())
return;
....
}
- 在地面时移动时,检测到墙就不能够移动了。
// 在移动状态下
if (inputX == 0 || player.IsWallDetected())
playerStateMachine.ChangeState(player.playerIdleState);
// 在 闲置状态下
if (player.IsWallDetected() && inputX == player.facingDir)
return;
攻击功能思路
需求:玩家按下鼠标左键,玩家进行攻击。玩家特定的时间连续按下左键,玩家可以实现组合攻击。
我们先创建攻击状态的类 PlayerPrimaryAttackState
继承 PlayerState
(代码比较简单,不演示),以及需要攻击动画。(这里需要设计组合攻击动画的设计,见下)
问题一:什么时候进行攻击(进入攻击状态)?
- 这里设计的是:在地面状态下,鼠标按下左键,切换为攻击状态。
if(Input.GetKeyDown(KeyCode.Mouse0))
playerStateMachine.ChangeState(player.playerPrimaryAttackState);
问题二:攻击什么时候结束?结束后,会干什么?
当然是攻击动画播放完成后,结束攻击状态,并将攻击状态切换为闲置状态。
这里的思路是:在 Animation
组件下添加 AnimationTrigger
脚本,这个脚本用于判断攻击动画是否播放完。
给攻击动画的最后一帧添加帧事件,调用这个脚本的 AnimationTrigger的AnimationTrigger()函数。
public void AnimationTrigger()
{
player.AnimationTrigger();
}
AnimationTrigger的AnimationTrigger函数又去调用Player下的
public void AnimationTrigger() => stateMachine.currentState.AnimationFinishTrigger();
一开始,我们给PlayerState添加一个TriggerCalled(动画结束是否触发),默认是false。如果动画结束要触发,就会调用AnimationFinishTrigger()函数
protected bool triggerCalled;
public virtual void AnimationFinishTrigger()
{
triggerCalled = true;
}
既然触发了,就会在攻击状态,切换为闲置状态
if (triggerCalled)
playerStateMachine.ChangeState(player.playerIdleState);
- 思考:如何制作组合攻击。
评论区