不自己独立做一整个儿游戏,就不知道里面的细节(坑)数不胜数。正像鲁迅说的那句名言——不亲自吃螃蟹,就不知道螃蟹有多好吃(x)。
虽然只是一个简单的2D横版过关游戏,还是在方方面面让我焦头烂额。现在快做完了,回过头看看,简直是目不忍视,各种沙雕代码充斥其中。不管了,能跑就行(不是)。
今天记录一下游戏的核心,如何实现2D人物的跑、跳、攀爬等等状态。
有限状态机,也称为FSM(Finite State Machine),其在任意时刻都处于有限状态集合中的某一状态。当其获得一个输入字符时,将从当前状态转换到另一个状态,或者仍然保持在当前状态。
人物的运动状态用FSM实现还是比较合适的。
这里因为状态简单,就用了个超简易有限状态机。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class State<T>
{
public T owner;
public abstract void Enter();
public abstract void Execute();
public abstract void Exit();
}
public class StateMachine<T>
{
//当前状态
public State<T> CurrentState { get; private set; }
//上一个状态
//public State PreviousState { get; private set; }
//全局状态
public State<T> GlobalState { get; private set; }
public StateMachine(T owner ,State<T> firstState, State<T> globalState = null)
{
if(globalState != null)
{
GlobalState = globalState;
GlobalState.owner = owner;
GlobalState.Enter();
}
CurrentState = firstState;
CurrentState.owner = owner;
CurrentState.Enter();
}
public void ChangeState(State<T> targetState)
{
if(targetState == null)
throw new System.Exception("Can't find target state!");
targetState.owner = CurrentState.owner;
CurrentState.Exit();
CurrentState = targetState;
CurrentState.Enter();
}
public void SMUpDate()
{
if (GlobalState != null)
GlobalState.Execute();
if (CurrentState != null)
CurrentState.Execute();
}
}
可以看到,状态机里只有一个全局状态和一个当前状态,全局状态用来检测player的通关和死亡。player的各种数据通过状态机的owner传给状态里。
在PlayerControl里创建一台状态机,然后把状态机的SMUpdate()放进FixedUpdate()里就行了。因为运动大部分涉及到物理检测,所以放进FixedUpdate()里用稳定的帧率刷新。
同时也在FixedUpdate()里把人物动画需要的各项参数传进去,就可以让运动和人物动画完美契合了。
另开了一个继承自ScriptableObject的类playerInfo用来储存人物的信息,这样不同的人物使用不同的PlayerInfo和同一playerControl就可以有不同的操作体验了。
using System.Collections;
using UnityEngine;
public class PlayerControl : MonoBehaviour
{
private StateMachine<PlayerControl> _stateMachine;
public StateMachine<PlayerControl> StateMachine
{
get
{
if (_stateMachine == null)
_stateMachine = new StateMachine<PlayerControl>(this, PlayerIdleState.Instance, PlayerGlobalState.Instance);
return _stateMachine;
}
}
public PlayerInfo playerInfo;
[HideInInspector]
public Ctrl ctrl;
[HideInInspector]
public Rigidbody2D rgd2D;
[HideInInspector]
public AudioSource audioSource;
private Animator anim;
[HideInInspector]
public int HP;
[HideInInspector]
public float speedX;
[HideInInspector]
public float speedY;
[HideInInspector]
public Transform ladderTriggle;
[HideInInspector]
public bool isGrounded = true;
[HideInInspector]
public bool isJump = false;
[HideInInspector]
public bool isClimb = false;
[HideInInspector]
public bool isCrouch = false;
[HideInInspector]
public bool isHurt = false;
[HideInInspector]
public bool isEnding = false;
[HideInInspector]
public bool isPlayHurtAnim = false;
[HideInInspector]
public bool isLadderTop = false;
private bool isFacingRight = true;
private Transform groundCheck;
private readonly float groundRadius = 0.1f;
private Transform ladderCheck;
private readonly float ladderRadius = 0.6f;
private void Awake()
{
ctrl = transform.parent.GetComponent<Ctrl>();
rgd2D = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
audioSource = GetComponent<AudioSource>();
groundCheck = transform.Find("GroundCheck");
ladderCheck = transform.Find("LadderCheck");
}
private void Start()
{
HP = playerInfo.maxHP;
}
private void FixedUpdate()
{
StateMachine.SMUpDate();
anim.SetFloat("SpeedX", Mathf.Abs(rgd2D.velocity.x));
anim.SetFloat("SpeedY", speedY);
anim.SetBool("isGrounded", isGrounded);
anim.SetBool("isJump", isJump);
anim.SetBool("isClimb", isClimb);
anim.SetBool("isLadderTop", isLadderTop);
anim.SetBool("isCrouch", isCrouch);
anim.SetBool("isEnding", isEnding);
}
这几个状态是参考unity standardasset包里那个2d角色控制的跑跳蹲检测方法写的,就是创造几个空物体,设为player的子物体,然后使用OverlapCircleAll进行检测是否接地等等。
我把检测是否接地和其他的一些要在状态类里做的事情,在playerControl里写成了方法,在状态类里直接调用就好啦。还尝试写了蹩脚的英文注释,我的英文真的很烂……不过万事还是要尝试,不试试怎么知道不行呢?
using UnityEngine;
public class PlayerIdleState : State<PlayerControl>
{
private static PlayerIdleState _instance;
public static PlayerIdleState Instance
{
get
{
if (_instance == null)
_instance = new PlayerIdleState();
return _instance;
}
}
private PlayerIdleState()
{
}
public override void Enter()
{
}
public override void Execute()
{
#region check Ground
owner.CheckLadderTopForGround();
owner.CheckGrounded();
#endregion
#region change state
if (!owner.isEnding)
{
if (Input.GetKey(KeyCode.LeftArrow) || Input.GetKey(KeyCode.RightArrow) || Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.RightArrow))
{
owner.StateMachine.ChangeState(PlayerRunState.Instance);
}
else if (owner.isLadderTop == false && (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKey(KeyCode.UpArrow)))
{
owner.StateMachine.ChangeState(PlayerJumpState.Instance);
}
else if (owner.isLadderTop == false && Input.GetKey(KeyCode.DownArrow))
{
owner.StateMachine.ChangeState(PlayerCrouchState.Instance);
}
}
#endregion
}
public override void Exit()
{
}
}
//Check is Grounded or not in generally
public void CheckGrounded()
{
Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheck.position, groundRadius, playerInfo.Ground);
foreach (var item in colliders)
{
if (item != gameObject && item.tag != "CheckLadderTop")
{
isGrounded = true;
isLadderTop = false;
}
}
}
//Check is it necessary to change to ClimbState in the IdleState
public void CheckLadderTopForGround()
{
Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheck.position, groundRadius, playerInfo.Ground);
foreach (var item in colliders)
{
if (item.tag == "CheckLadderTop")
{
isLadderTop = true;
ladderTriggle = item.transform.parent.Find("LadderTrigger");
if (Input.GetKeyDown(KeyCode.DownArrow) || Input.GetKey(KeyCode.DownArrow))
{
//Let groundCheck(a gameObject,player's childObject) far from CheckLadderTopForGround
//let player at the middle of the ladder
Vector2 newVec = transform.position;
newVec.x = ladderTriggle.position.x;
newVec.y -= 0.5f;
transform.position = newVec;
isClimb = true;
StateMachine.ChangeState(PlayerClimbState.Instance);
}
}
}
}
using UnityEngine;
public class PlayerRunState : State<PlayerControl>
{
private static PlayerRunState _instance;
public static PlayerRunState Instance
{
get
{
if (_instance == null)
_instance = new PlayerRunState();
return _instance;
}
}
private PlayerRunState()
{
}
public override void Enter()
{
}
public override void Execute()
{
owner.HorizontalMove();
#region change state
if (Mathf.Abs(owner.speedX) < 0.1f || Input.GetKeyUp(KeyCode.LeftArrow) || Input.GetKeyUp(KeyCode.RightArrow))
{
owner.StateMachine.ChangeState(PlayerIdleState.Instance);
}
else if (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKey(KeyCode.UpArrow))
{
owner.StateMachine.ChangeState(PlayerJumpState.Instance);
}
else if(owner.isLadderTop == false && Input.GetKey(KeyCode.DownArrow))
{
owner.StateMachine.ChangeState(PlayerCrouchState.Instance);
}
#endregion
}
public override void Exit()
{
}
}
//Control player' Horizontal move when state is Run and Crouch
public void HorizontalMove()
{
float moveFactor = 1;
if(StateMachine.CurrentState == PlayerRunState.Instance)
{
moveFactor = 1;
}
else if (StateMachine.CurrentState == PlayerCrouchState.Instance)
{
moveFactor = playerInfo.crouchSpeedFactor;
}
if (Input.GetKey(KeyCode.LeftArrow))
{
speedX = -playerInfo.maxSpeed * moveFactor;
}
else if (Input.GetKey(KeyCode.RightArrow))
{
speedX = playerInfo.maxSpeed * moveFactor;
}
else
{
speedX = 0;
}
rgd2D.velocity = new Vector2(speedX, rgd2D.velocity.y);
if (speedX > 0 && isFacingRight == false)
{
HorizontalFilp();
}
else if (speedX < 0 && isFacingRight == true)
{
HorizontalFilp();
}
}
//Control player' horizontal flip when state is Run and Crouch
private void HorizontalFilp()
{
GetComponent<SpriteRenderer>().flipX = !GetComponent<SpriteRenderer>().flipX;
isFacingRight = !isFacingRight;
}
using UnityEngine;
public class PlayerCrouchState : State<PlayerControl>
{
private static PlayerCrouchState _instance;
public static PlayerCrouchState Instance
{
get
{
if (_instance == null)
_instance = new PlayerCrouchState();
return _instance;
}
}
Collider2D[] colliders = null;
private PlayerCrouchState()
{
}
public override void Enter()
{
owner.isCrouch = true;
if(colliders == null)
colliders = owner.GetComponents<BoxCollider2D>();
foreach (var item in colliders)
item.enabled = false;
}
public override void Execute()
{
owner.HorizontalMove();
if(!Input.GetKey(KeyCode.DownArrow) || Input.GetKeyUp(KeyCode.DownArrow))
{
owner.StateMachine.ChangeState(PlayerIdleState.Instance);
}
}
public override void Exit()
{
owner.isCrouch = false;
foreach (var item in colliders)
item.enabled = true;
}
}
using UnityEngine;
public class PlayerJumpState : State<PlayerControl>
{
private static PlayerJumpState _instance;
public static PlayerJumpState Instance
{
get
{
if (_instance == null)
_instance = new PlayerJumpState();
return _instance;
}
}
private PlayerJumpState()
{
}
public override void Enter()
{
owner.ctrl.audioManager.Play(owner.ctrl.audioManager.jump, owner.audioSource);
owner.rgd2D.AddForce(owner.playerInfo.jumpForce * Vector2.up);
owner.isJump = true;
owner.isGrounded = false;
}
public override void Execute()
{
#region check Grounded
owner.CheckGrounded();
owner.CheckLadderTopForLadder();
if(owner.isGrounded)
{
owner.StateMachine.ChangeState(PlayerIdleState.Instance);
}
#endregion
#region check ladder
owner.CheckLadderTriggleForJumpState();
if(owner.isClimb)
{
owner.StateMachine.ChangeState(PlayerClimbState.Instance);
}
#endregion
}
public override void Exit()
{
//Clear the rest of force
owner.rgd2D.Sleep();
owner.isJump = false;
}
}
//Help change to IdleState when player in the top of ladder
public void CheckLadderTopForLadder()
{
Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheck.position, groundRadius, playerInfo.Ladder);
foreach (var item in colliders)
{
//Check Player is already in the top of ladder
if (item.tag == "CheckLadderTop")
{
isLadderTop = true;
isGrounded = true;
}
}
}
//Check is it necessary to change to ClimbState in the JumpState
public void CheckLadderTriggleForJumpState()
{
Collider2D[] colliders = Physics2D.OverlapCircleAll(ladderCheck.position, ladderRadius, playerInfo.Ladder);
foreach (var item in colliders)
{
if (item.tag == "LadderTrigger")
{
ladderTriggle = item.transform;
if (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKey(KeyCode.UpArrow))
{
isClimb = true;
//let player at the middle of the ladder
Vector3 newVec = transform.position;
newVec.x = item.transform.position.x;
transform.position = newVec;
}
}
}
}
攀爬状态可以说是花费时间最多的了,因为standardasset包里的2d人物控制没有攀爬……
在网上找了很久也没有找到满意的2D人物爬梯子实现代码,最后只能自己硬着头皮写了。
爬梯子真的复杂很多,要注意诸多细节:
把梯子做好后弄成prefab,以后哪里要哪里搬,就不用再想这么多让人头大的问题了。层级的设置我也放在了playerInfo里。
using UnityEngine;
public class PlayerClimbState:State<PlayerControl>
{
private static PlayerClimbState _instance;
public static PlayerClimbState Instance
{
get
{
if (_instance == null)
_instance = new PlayerClimbState();
return _instance;
}
}
private PlayerClimbState()
{
}
public override void Enter()
{
owner.isGrounded = false;
owner.speedY = owner.playerInfo.climbSpeed;
owner.ladderTriggle.parent.Find("LadderTop").GetComponent<BoxCollider2D>().enabled = false;
}
public override void Execute()
{
//remove player's gravity
owner.rgd2D.constraints |= RigidbodyConstraints2D.FreezePositionY;
owner.CheckGrounded();
owner.CheckLadderTopForLadder();
#region control vertical move when player in the ladder
if (Input.GetKey(KeyCode.UpArrow))
{
Vector2 tempVec = owner.transform.position;
tempVec.y += Time.deltaTime * owner.playerInfo.climbSpeed;
owner.transform.position = tempVec;
owner.speedY = owner.playerInfo.climbSpeed;
}
else if (Input.GetKey(KeyCode.DownArrow))
{
Vector2 tempVec = owner.transform.position;
tempVec.y -= Time.deltaTime * owner.playerInfo.climbSpeed;
owner.transform.position = tempVec;
owner.speedY = owner.playerInfo.climbSpeed;
}
else
{
owner.speedY = 0;
}
#endregion
if (owner.isGrounded)
{
owner.rgd2D.constraints = RigidbodyConstraints2D.FreezeRotation;
owner.StateMachine.ChangeState(PlayerIdleState.Instance);
}
}
public override void Exit()
{
owner.speedY = 0;
owner.isClimb = false;
owner.ladderTriggle.parent.Find("LadderTop").GetComponent<BoxCollider2D>().enabled = true;
}
}
这一部分挺简单的,放在了全局状态里,因为无论什么状态,碰到怪物都会受伤。总而言之就是碰到怪物就减一条命并进入无敌状态一段时间,用协程做了个闪烁动画。碰撞检测和减命在工程其他部分脚本里。
public class PlayerGlobalState : State<PlayerControl>
{
private static PlayerGlobalState _instance;
public static PlayerGlobalState Instance
{
get
{
if (_instance == null)
_instance = new PlayerGlobalState();
return _instance;
}
}
private PlayerGlobalState()
{
}
public override void Enter()
{
}
public override void Execute()
{
if (!owner.isPlayHurtAnim && owner.isHurt == true)
{
owner.HP--;
owner.isPlayHurtAnim = true;
owner.isHurt = false;
owner.StartCoroutine(owner.HurtAnim());
}
if(owner.isEnding)
{
}
}
public override void Exit()
{
}
}
//Player's hurt Animation with Coroutine
//Player can't hurt when the hurt Animation play
public IEnumerator HurtAnim()
{
Vector4 color = GetComponent<SpriteRenderer>().color;
for (int i = 0; i < 20; i++)
{
if (i % 2 == 0)
color.w = 0f;
else
color.w = 1f;
GetComponent<SpriteRenderer>().color = color;
yield return new WaitForSeconds(0.2f);
}
StopCoroutine(HurtAnim());
isPlayHurtAnim = false;
}
当通关的时候,屏幕除了player周围都变黑,此时player不能控制且处于idle状态,写了一个OnPassLevel(),在工程其他部分调用。
public void OnPassLevel()
{
rgd2D.velocity = Vector2.zero;
anim.SetFloat("SpeedX", 0);
anim.SetFloat("SpeedY", 0);
rgd2D.constraints |= RigidbodyConstraints2D.FreezePositionX;
}
大概就这些了,其他碰撞受伤死亡检测都是在GameManager里写的,这里就不写了。放一下Unity动画状态机的图:
希望我能尽快完成这个游戏吧……写UI脚本真的好心累啊啊啊!!!