1. 程式人生 > >基於有限狀態機在Unity3D中實現的簡單搓招系統

基於有限狀態機在Unity3D中實現的簡單搓招系統

在諸如街霸、拳皇等格鬥遊戲中,搓招指的是玩家通過在短時間內連續輸入特定的指令來釋放角色的招式(比如右下右拳釋放升龍拳)

那麼如何通過狀態機來實現搓招呢?

我們可以讓每個招式都持有一個狀態機,把這個招式要求的輸入指令作為狀態機的狀態而存在。依然以升龍拳為例,我們可以把升龍拳的4個指令視為4個狀態,每個狀態都對應一種輸入指令,在玩家輸入指定指令後,切換到下一個指令的狀態(如果超時或輸入的指令不是特定指令則切換回初始狀態),在最後一個指令狀態中來處理招式釋放的邏輯,並切換回初始狀態

首先從一個十分簡單的狀態機開始,新建一個Fsm資料夾,在其中新建2個類檔案

程式碼非常簡單:

/// <summary>
/// 狀態基類
/// </summary>
public abstract class FsmState {

    /// <summary>
    /// 狀態ID
    /// </summary>
    public int stateID;

    protected FsmState(int stateID)
    {
        this.stateID = stateID;
    }

    public abstract void OnEnter(Fsm fsm);

    public abstract void OnUpdate(Fsm fsm,float deltaTime);

    public abstract void OnLeave(Fsm fsm);

    /// <summary>
    ///  切換狀態
    /// </summary>
    public void ChangeState<TState>(int stateID,Fsm fsm) where TState : FsmState
    {
        fsm.ChangeState(stateID);
    }
}
/// <summary>
/// 狀態機
/// </summary>
public class Fsm{

    /// <summary>
    /// 狀態字典
    /// </summary>
    private Dictionary<int, FsmState> states = new Dictionary<int, FsmState>();

    /// <summary>
    /// 當前狀態
    /// </summary>
    private FsmState currentState;

    /// <summary>
    /// 新增狀態
    /// </summary>
    public void AddState(FsmState state)
    {
        if (!states.ContainsKey(state.stateID))
        {
            states.Add(state.stateID, state);
        }
    }


    /// <summary>
    /// 切換狀態
    /// </summary>
    public void ChangeState(int stateID) 
    {
        if (states.ContainsKey(stateID))
        {
            FsmState state = states[stateID];

            currentState.OnLeave(this);
            currentState = state;
            currentState.OnEnter(this);
        }
       
    }

    /// <summary>
    /// 開始狀態機
    /// </summary>
    public void Start(int stateID)
    {
        if (states.ContainsKey(stateID))
        {
            FsmState state = states[stateID];

            currentState = state;
            currentState.OnEnter(this);
        }

    }

    /// <summary>
    /// 輪詢狀態機
    /// </summary>
    public void Update(float deltaTime)
    {
        currentState.OnUpdate(this, deltaTime);
    }
}

狀態機的輪詢將交給持有該狀態機的招式類呼叫,而招式的輪詢最終會由持有該招式的Player類(繼承MonoBehaviour)在Update中進行

接下來新建一個Skill資料夾,在其中新建一個代表招式指令狀態的InstructionsState檔案,繼承FsmState類

為其新增要用到的欄位,並在構造方法裡進行初始化

/// <summary>
/// 招式指令狀態
/// </summary>
public class InstructionsState : FsmState
{
    
    /// <summary>
    /// 輸入等待時間
    /// </summary>
    private float inputWaitTime;

    /// <summary>
    /// 計時器
    /// </summary>
    private float timer;

    /// <summary>
    /// 招式指令狀態對應的按鍵指令
    /// </summary>
    private KeyCode keyCode;

    /// <summary>
    /// 指令狀態要執行的方法
    /// </summary>
    private UnityAction action;

    /// <summary>
    /// 最大招式指令狀態ID
    /// </summary>
    private int maxStateID;

    public InstructionsState(int stateID, float inputWaitTime, KeyCode keyCode, UnityAction action, int maxStateID)
        : base(stateID)
    {
        this.inputWaitTime = inputWaitTime;
        timer = 0;
        this.keyCode = keyCode;
        this.action = action;
        this.maxStateID = maxStateID;
    }

}

這個類的重點在於狀態的輪詢方法

public override void OnUpdate(Fsm fsm, float deltaTime)
    {
        timer += deltaTime;
        if (timer >= inputWaitTime)
        {
            //指令輸入等待時間耗盡,重置指令狀態
            timer = 0;
            fsm.ChangeState(1);
        }

        //按下任意按鍵
        if (Input.anyKeyDown)
        {
            //重置計時器
            timer = 0;

            //按下了指令狀態的對應按鍵
            if (Input.GetKeyDown(keyCode))
            {
                Debug.Log("玩家按下了" + keyCode.ToString());

                //執行該指令要執行的方法
                if (action != null)
                {
                    action();
                }

                //最後一個指令狀態
                if (stateID == maxStateID)
                {
                    //重置指令狀態
                    fsm.ChangeState(1);
                }
                else
                {
                    //不是最後一個指令狀態,切換到下一個指令狀態
                    fsm.ChangeState(stateID + 1);
                }


            }
            //未按下指令狀態的對應指令,重置指令狀態
            else
            {
                fsm.ChangeState(1);
            }
        }
    }

在指令狀態輪詢時,有3種可能的情況需要切換回初始狀態

1.玩家在限定時間內未輸入

2.玩家的輸入不是該指令狀態對應的指令

3.該指令狀態是最後一個指令狀態

在指令狀態編寫完畢後,我們需要編寫一個招式資料類,在其中使用一個招式ID與指令陣列的字典儲存資料

/// <summary>
/// 招式資料
/// </summary>
public static class SkillData{

    /// <summary>
    /// 招式ID與指令的字典
    /// </summary>
    public static Dictionary<int, int[]> instructions = new Dictionary<int, int[]>();

    static SkillData()
    {
        //100-D 115-S 106-J
        instructions.Add(1, new int[] { 100, 115 ,100 ,106});
        instructions.Add(2, new int[] { 115, 100, 106 });
    }

}

指令陣列中儲存的是指令對應按鍵的KeyCode列舉所對應的整數

這裡的字典資料可以根據需求從檔案中讀取,為了方便就先直接寫到靜態構造方法裡

接著同樣是在Skill資料夾下,新建一個代表招式基類的Skill類,為其新增需要的欄位與方法,並在構造方法裡進行初始化

/// <summary>
/// 招式基類
/// </summary>
public class Skill
{

    /// <summary>
    /// 招式ID
    /// </summary>
    protected int skillID;

    /// <summary>
    /// 招式指令的狀態機
    /// </summary>
    protected Fsm fsm = new Fsm();

    /// <summary>
    /// 招式持有者
    /// </summary>
    protected Player player;

    /// <summary>
    /// 最大指令狀態ID
    /// </summary>
    protected int maxSkillStateID;

    /// <summary>
    /// 指令狀態ID
    /// </summary>
    protected int stateID = 0;

    public Skill(Player player)
    {
        this.player = player;
        skillID = GetSkillID();
        Init();
        fsm.Start(1);
    }

    /// <summary>
    /// 初始化
    /// </summary>
    protected void Init()
    {
        //從字典中讀取到招式指令資料,然後根據資料新增指令狀態
        if (SkillData.instructions.ContainsKey(skillID))
        {
            int[] instructions = null;
            SkillData.instructions.TryGetValue(skillID, out instructions);

            maxSkillStateID = instructions.Length;

            for (int i = 0; i < instructions.Length; i++)
            {
                if (i == instructions.Length - 1)
                {
                    //最後一個指令需要執行招式的出招處理
                    AddInstructionsState((KeyCode)instructions[i], SkillFight);
                }
                else
                {
                    AddInstructionsState((KeyCode)instructions[i]);
                }
            }
        }
    }
    

    /// <summary>
    /// 招式的出招處理(使用模板方法模式,延遲給子類實現)
    /// </summary>
    protected virtual void SkillFight()
    {
        player.ResetSkill();
    }


    /// <summary>
    /// 獲取招式ID(使用模板方法模式,延遲給子類實現)
    /// </summary>
    protected virtual int GetSkillID()
    {
        return -1;
    }

    /// <summary>
    /// 新增指令狀態
    /// </summary>
    /// <param name="keyCode">指令對應的按鍵</param>
    /// <param name="action">指令對應的方法</param>
    /// <param name="inputWaitTime">指令的輸入等待時間</param>
    protected void AddInstructionsState(KeyCode keyCode, UnityAction action = null, float inputWaitTime = 0.5f)
    {
        fsm.AddState(new InstructionsState(++stateID, inputWaitTime, keyCode, action, maxSkillStateID));
    }

}

在上面的程式碼裡呼叫了兩個虛方法GetSkillID與SkillFight,這兩個方法都需要由子類進行重寫

接下來為該類新增狀態機的重置與輪詢方法

    /// <summary>
    /// 指令狀態機重置
    /// </summary>
    public void Reset()
    {
        fsm.ChangeState(1);
    }

    /// <summary>
    /// 輪詢指令狀態機
    /// </summary>
    public void Update(float deltaTime)
    {
        fsm.Update(deltaTime);
    }

這裡重置狀態機的方法是由持有該招式的Player類來呼叫的,其意義在於,我們在通過輸入指令成功出招後,需要讓其他可能也響應了部分指令的招式回到初始狀態,以免出現玩家在釋放了一個招式後繼續操作卻無法釋放或意外釋放另一個招式的情況

現在招式類已經完成,可以開始編寫Player指令碼了

新建Player指令碼,在其中使用一個列表維護該Player的所有招式,並新增招式的輪詢與重置

/// <summary>
/// 可以搓招的玩家類
/// </summary>
public class Player : MonoBehaviour
{

    /// <summary>
    /// 所有招式的列表
    /// </summary>
    private List<Skill> skills = new List<Skill>();


    void Update()
    {
        //輪詢招式列表
        foreach (Skill skill in skills)
        {
            skill.Update(Time.deltaTime);
        }
    }

    /// <summary>
    /// 重置所有招式
    /// </summary>
    public void ResetSkill()
    {
        foreach (Skill skill in skills)
        {
            skill.Reset();
        }
        Debug.Log("所有招式都被重置了");
    }
}

先到這裡,想要往Skills裡新增招式需要等到實現了具體的招式類以後

以升龍拳和氣功波為例,在Skill資料夾下新建FirePunch類,繼承Skill類,重寫GetSkillID與出招邏輯的方法

/// <summary>
/// 升龍拳
/// </summary>
public class FirePunch : Skill{

    public FirePunch(Player player) : base(player)
    {
    }


    protected override int GetSkillID()
    {
        return 1;
    }


    protected override void SkillFight()
    {
        base.SkillFight();
        Debug.Log("升龍拳!");
    }

}

整個類都非常簡單,我們以同樣簡單的方式來編寫KiBlast類

/// <summary>
/// 氣功波
/// </summary>
public class KiBlast : Skill
{
    public KiBlast(Player player) : base(player)
    {
    }

    protected override int GetSkillID()
    {
        return 2;
    }

    protected override void SkillFight()
    {
        base.SkillFight();
        Debug.Log("氣功波!");
    }


}

OK,現在讓我們回到Player類裡,在Start方法中為Player新增招式

 void Start()
    {
        //新增招式 新增順序決定搓招優先順序
        skills.Add(new FirePunch(this));
        skills.Add(new KiBlast(this));
    }

這裡優先順序的意義在於,如果兩個招式中,指令較短的那個招式重合了指令較長的招式(比如在上面編寫的兩個招式指令升龍拳-DSDJ與氣功波-SDJ),那麼Player將優先釋放在列表中靠前的那個招式

現在可以開始測試我們的搓招系統了,在場景中新建一個遊戲物體,將Player指令碼掛載上去,執行Unity,快速輸入DSDJ

在測試中可以看到,第二次按鍵輸入的D被Log了兩次,這是因為我們在按下一次D以後再按S時,升龍拳與氣功波同時響應了S的輸入,進行了狀態切換,升龍拳的第3個指令狀態與氣功波的第2個指令狀態對應的指令都是D,所以第二次輸入的D被Log了兩次

因為招式優先順序的關係,即使我們在輸入S後輸入過SDJ,搓出的也是升龍拳而不是氣功波,如果在新增招式時讓氣功波排在升龍拳前面,那麼我們搓出的就是氣功波了(而且將無論如何都搓不出升龍拳,這點讀者可以自行測試)