🐷

Unityでステートマシンを活用してRPGゲームを作ってみた

に公開

はじめに

こんにちは、enari_Kと申します。普段は東京科学大学(旧東工大)で材料工学を専攻する傍らtraPというサークルでゲーム制作、競プロ、kaggle、web開発などに取り組んでいます。今回は、traPの1-Monthonというイベントで制作した「君をむしばむこの力で」について話していきたいと思います。

メンバー

この1-Monthonというイベントは夏季休暇中の1ヶ月に及ぶハッカソンで通常はチームで挑むものなのですが、今回私は個人開発に挑戦してみたかったので、一人で挑みました。

設計

個人開発である都合上、デバッグにあまり時間を割くわけにはいかなかったので、設計にはかなり時間をかけました。結果以下のような設計が出来上がりました。

実際の設計文書

ゲームマネージャー(タイトルシーンで出現)

  • カメラのモノクロ度管理
  • 魔王化ゲージ管理
  • 進行度管理
  • trueフラグ管理
  • hard,normal,easy管理

戦闘シーン

単一シーンで作る
koudouで敵行動管理
進行度で、出現敵を変える。
基本的にturnというpublic staticな変数を作って管理する。

入場

  • 戦闘シーンへ切り替わった演出
    ①遷移演出
    ②背景がフェードイン
    ③キャラクターが左右から入場
    ④UIがフェードイン
    ⑤koudou初期化
    ⑥player_HP初期化(hard,normalで値変更)
    ⑦enemy_HP初期化(sinkoudoによって、変更)
    ⑧break値初期化
    ⑨dawn値初期化
    ⑦sinkoudo=0or1なら、tyutoriaru初期化
    ⑧sinkoudo=0or1なら、チュートリアルのポップアップ出現
    ⑨turn初期化(turn=1)

turn=1

  • 自ターン
    ①自ターン専用のUI(どのキーがどの行動を示すか)表示(sinkoudo=0なら別UI)
    ②ボタン押されるまで待機(sinkoudo=0なら変身はできない感じで)
    ③もしSキーが押されたなら、状態の変数を書き換え、UIも書き換える
    ③自分の状態を確認し、おかしかったら、変身する自キャラが行動を行い、演出が出て、ダメージ処理、魔王化ゲージ処理、ブレイク処理,行動終了後、魔王化の行動をとったなら元に戻る(break=0なら、dawn=2,koudou=0へ)も行われる。
    ④敵キャラのenemy_HP==0が判定Trueならturn=3へ
    ⑤何もなかったらturn=2へ

turn=2

  • 敵ターン
    ①敵ターン専用のUI(ジャスト回避について)表示
    ②(shinkoudo=0かつ、tyutoriaru=10なら、ポップアップ出現)
    ③ポップアップのOK押されたら、tyutoriaru=11へ
    ④ボタン入力受付開始
    ⑤敵行動(koudouとdawn参照)
    ⑥演出が出て、スコア用加算処理、ダメージ処理、ヒットストップ、自キャラHP判定、ブレイク処理(break=0なら、dawn=2,koudou=0へ)、魔王化ゲージ処理、player_HP==0ならturn=4へ
    ⑦ボタン入力受付終了
    ⑧ブレイク処理、スコア用加算処理
    ⑨turn=1へ

turn=3

  • クリア演出
    ①左からクリア画面が入場
    ②クリアの帯が真ん中に流れてくる
    ③クリアの帯が上に移動してそのスペースにスコアが計算されていく
    ④表示されていったスコアを最後筆算して最終スコアが出る
    ⑤進行度+1
    ⑤この時、出たスコアがハイスコア以上なら、おめでとう、あなたはハイスコア更新しましたみたいなポップアップ表示
    ⑦「Dキーで次へ」を表示する
    ⑧Dキー押されたらシーン遷移

turn=4

  • ゲームオーバー演出
    ①左から黒背景でる
    ②ゲームオーバーの帯表示
    ③ボタン入力受付開始
    ④Dキーでリトライ
    ⑤Aキーでゲームマネージャー消してタイトルへ
    ⑥Sキーで難易度、ノーマルorイージーへ変更してリトライ

実際書いてみてどうだったか

まず、設計では関数の数字で状態を管理することになっていましたが、実際に書き始めてみて可読性が悪いのと、バグの温床になることに気がついたので、enumの列挙型の関数で状態を管理することになりました。また、書いてみるとBattleStateはもっと増やした方がよさそうということに気付き、最終的に7つの状態が出来ました。以下が実際の状態を管理しているBattleManagerのコードになります。

実際のコード

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.SceneManagement;

public class BattleManager : MonoBehaviour
{
    public static BattleManager instance;
    public enum BattleState { START, PLAYERTURN, ENEMYTURN, ACTION, WIN, LOSE, EVENT }
    public BattleState currentState;

    [Header("データ")]
    public EnemyData[] enemyStageData;

    [Header("プレハブ")]
    public GameObject playerPrefab;

    [Header("シーン内オブジェクト")]
    public Transform playerSpawnPoint;
    public Transform enemySpawnPoint;
    public Transform playerBattlePosition;
    public Transform enemyBattlePosition;
    public BattleUIController uiController;
    public GameManagerCaller caller;
    public SceneFader sceneFader;

    public PlayerController player;
    private EnemyController enemy;

    public bool wasEnemyBrokenBeforePlayerTurn { get; private set; } = false;

    void Awake() { instance = this; }
    void Start() { currentState = BattleState.START; StartCoroutine(SetupBattle()); }
    public PlayerController GetPlayer() { return player; }
    public EnemyController GetEnemy() { return enemy; }

    IEnumerator SetupBattle()
{
    int stageIndex = GameManager.instance.progress.currentStageIndex;
    EnemyData currentEnemyData = enemyStageData[stageIndex];

    GameObject playerObj = Instantiate(playerPrefab, playerSpawnPoint.position, Quaternion.identity);
    player = playerObj.GetComponent<PlayerController>();
    
    GameObject enemyObj = Instantiate(currentEnemyData.battlePrefab, enemySpawnPoint.position, Quaternion.identity);
    enemy = enemyObj.GetComponent<EnemyController>();
    enemy.Setup(currentEnemyData);

    // 難易度反映
    ApplyDifficultySettings(GameManager.instance.progress.currentDifficulty);

    // ★ Human/Demon固定処理を追加 ★
    if (stageIndex == 0)
    {
        player.isDemonForm = false;  // Human固定
        player.isOvercharged = false;
    }
    else if (stageIndex == 1)
    {
        player.isDemonForm = true;   // Demon固定
        player.isOvercharged = false;
    }

    uiController.SetupUI(player, enemy);
    
    yield return StartCoroutine(sceneFader.Fade(1f, 0f, sceneFader.defaultFadeTime));
    yield return StartCoroutine(AnimateCharacterIn(player.transform, playerBattlePosition));
    yield return StartCoroutine(AnimateCharacterIn(enemy.transform, enemyBattlePosition));
    yield return StartCoroutine(uiController.AnimateAllUIIn());

    uiController.ShowMessage("戦闘開始!");
    yield return new WaitForSeconds(1.5f);
    
    StartPlayerTurn();
}
    
    private IEnumerator AnimateCharacterIn(Transform character, Transform targetPos) 
    {
        float duration = 0.5f;
        float timer = 0f;
        Vector3 startPos = character.position;
        while(timer < duration)
        {
            character.position = Vector3.Lerp(startPos, targetPos.position, timer / duration);
            timer += Time.deltaTime;
            yield return null;
        }
        character.position = targetPos.position;
    }

    void ApplyDifficultySettings(GameProgress.Difficulty difficulty) 
    {
        int startingHP = 20;
        switch (difficulty)
        {
            case GameProgress.Difficulty.Easy: startingHP = 30; break;
            case GameProgress.Difficulty.Normal: startingHP = 20; break;
            case GameProgress.Difficulty.Hard: startingHP = 15; break;
        }
        player.Setup(startingHP);
    }

    void Update() 
    { 
        if (currentState == BattleState.PLAYERTURN)
        {
            HandlePlayerInput();
        }
        else if (currentState == BattleState.LOSE) 
        {
            HandleGameOverInput();
        }
    }

void HandlePlayerInput()
{
    int stageIndex = GameManager.instance.progress.currentStageIndex;

    // ★ Stage0と1では切り替え禁止 ★
    if (stageIndex > 1 && Input.GetKeyDown(KeyCode.S)) 
    {
        player.isDemonForm = !player.isDemonForm;
        player.isOvercharged = false;
        uiController.UpdateCommandGuide(player);
        return;
    }

    string actionType = "";

    if (player.isDemonForm)
    {
        if (Input.GetKeyDown(KeyCode.W)) { actionType = "DemonAttack"; }
        else if (Input.GetKeyDown(KeyCode.D)) { actionType = "DemonSkill1"; }
        else if (Input.GetKeyDown(KeyCode.A)) { actionType = "DemonSkill2"; }
    }
    else
    {
        if (Input.GetKeyDown(KeyCode.W)) { actionType = "HumanAttack"; }
        else if (Input.GetKeyDown(KeyCode.D)) { actionType = "HumanSkill1"; }
        else if (Input.GetKeyDown(KeyCode.A)&&(stageIndex!=0)) { actionType = "HumanSkill2"; }
    }

    if (actionType != "")
    {
        InitiatePlayerAction(actionType);
    }
}

    
    void InitiatePlayerAction(string actionType) 
    {
        if (currentState != BattleState.PLAYERTURN) return;
        currentState = BattleState.ACTION;
        uiController.SetCommandGuideActive(false);
        player.PerformAction(actionType, enemy);
    }

    void StartPlayerTurn() 
    {
        currentState = BattleState.PLAYERTURN;
        wasEnemyBrokenBeforePlayerTurn = enemy.isBroken;
        uiController.ShowMessage("プレイヤーのターン");
        uiController.SetCommandGuideActive(true);
        uiController.SetEvadeInfoActive(false);
        uiController.UpdateCommandGuide(player);
    }

    public void PlayerActionFinished() 
    {
        if (enemy.currentHP <= 0)
        {
            currentState = BattleState.WIN;
            StartCoroutine(WinCoroutine());
        }
        else
        {
            currentState = BattleState.ENEMYTURN;
            StartCoroutine(EnemyTurnCoroutine());
        }
    }

    IEnumerator EnemyTurnCoroutine()
    {
        uiController.SetCommandGuideActive(false);
        uiController.ShowMessage("敵のターン");
        yield return new WaitForSeconds(1.5f);

        if (enemy.isBroken)
        {
            if (wasEnemyBrokenBeforePlayerTurn)
            {
                uiController.ShowMessage(enemy.data.enemyName + "は体勢を立て直した!");
                enemy.RecoverFromBreak();
                yield return new WaitForSeconds(1.5f);
            }
            else
            {
                uiController.ShowMessage(enemy.data.enemyName + "は動けない!");
                yield return new WaitForSeconds(1.5f);
            }
        }
        else
        {
            if (enemy.data.gimmick == GimmickType.Regeneration && enemy.turnCount > 0 && enemy.turnCount % 5 == 0)
            {
                uiController.ShowMessage(enemy.data.enemyName + "が大回復の詠唱を始めた!");
                yield return StartCoroutine(enemy.PerformActionSequence(new EnemyAction { type = EnemyAction.ActionType.Charge }));
                enemy.RegenerateHP();
            }

            EnemyAction action = enemy.data.actions[enemy.turnCount % enemy.data.actions.Count];
            enemy.turnCount++;
            uiController.ShowMessage(action.message);
            
            StartCoroutine(enemy.PerformActionSequence(action));
            
            if (action.type == EnemyAction.ActionType.Attack)
            {
                
                uiController.SetEvadeInfoActive(true);
                yield return new WaitForSeconds(action.animationLeadIn);

                // ★変更後:UIコントローラーにリングの開始を指示★
                // animationLeadIn: リングが動き出すまでの待機時間
                // evadeWindow: リングが0から1になるまでの時間
                uiController.StartEvadeRing(action.animationLeadIn, action.evadeWindow);

                // animationLeadInの時間だけ待機してから、回避入力受付を開始
                yield return new WaitForSeconds(action.animationLeadIn);

                string evadeResult = "Fail";
                // JustEvadeCoroutineには回避受付時間(evadeWindow)を渡す
                yield return StartCoroutine(JustEvadeCoroutine(action.evadeWindow, (result) => { evadeResult = result; }));
                
                if (evadeResult == "Fail")
                {
                    int finalDamage = action.damage + enemy.rageStacks;
                    player.TakeDamage(finalDamage);
                }
                else
                {
                    // 成功メッセージはJustEvadeCoroutine内で表示されるため、ここでは不要
                    // uiController.ShowMessage("回避成功!");
                }
            }
            else
            {
                yield return new WaitForSeconds(1.5f); // Chargeモーションなどを待つ
            }
        }
        
        if (player.currentHP <= 0)
        {
            currentState = BattleState.LOSE;
            StartCoroutine(LoseCoroutine());
        }
        else
        {
            StartPlayerTurn();
        }
    }
    
IEnumerator JustEvadeCoroutine(float window, System.Action<string> onResult)
{
    float timer = 0f;
    string result = "Fail"; // デフォルトはタイムアップによる失敗
    bool inputReceived = false;

        // window秒間、プレイヤーの入力を待ち受ける
        while (timer < window && !inputReceived)
        {
            if (Input.GetKeyDown(KeyCode.Space))
            {
                inputReceived = true; // 入力があったのでループを抜ける

                // ★UIコントローラーに判定を問い合わせる★
                result = uiController.GetEvadeRingResult();

                if (result == "Fail")
                {
                    // 早すぎたり遅すぎたりした場合のメッセージ
                    uiController.ShowMessage("タイミングが違う!");
                }
            }

            timer += Time.deltaTime;
            yield return null;
        
    }
    
    // --- ループ後の処理 ---

    // 成功時(Perfect or Just)の演出
    if (result != "Fail")
    {
        player.PlayDodgeAnimation();
        // 成功音やフィードバックイメージの表示
        if(BattleSFXManager.instance != null)
        {
            BattleSFXManager.instance.PlaySound(BattleSFXManager.instance.evadeSuccessClip);
        }
        uiController.ShowFeedbackImage(result); 
    }
    uiController.SetEvadeInfoActive(false);

    // ★重要★ リングの終了処理はBattleUIControllerのEvadeRingRoutineに任せる
        // このコルーチンではUIの表示/非表示を直接操作しない

        // 最終的な結果をEnemyTurnCoroutineに返す
        onResult(result);
}
    IEnumerator WinCoroutine()
    {
        currentState = BattleState.WIN;
        uiController.SetCommandGuideActive(false);
        uiController.SetEvadeInfoActive(false);
        
        yield return StartCoroutine(enemy.PlayDefeatSequence());

        player.PlayVictoryAnimation();
        BattleSFXManager.instance.PlaySound(BattleSFXManager.instance.victoryFanfare);
        uiController.ShowMessage(enemy.data.enemyName + "を倒した!");
        yield return new WaitForSeconds(3.0f);
        caller.CallGoToNextAdvScene();
    }

    IEnumerator LoseCoroutine()
    {
        uiController.SetCommandGuideActive(false);
        uiController.SetEvadeInfoActive(false);
        yield return StartCoroutine(player.PlayDefeatSequence());
        uiController.ShowGameOverUI();
    }

    void HandleGameOverInput()
    {
        if (Input.GetKeyDown(KeyCode.D))
        {
            GameManager.instance.RetryBattle();
        }
        else if (Input.GetKeyDown(KeyCode.A))
        {
            GameManager.instance.ReturnToTitle();
        }
        else if (Input.GetKeyDown(KeyCode.S))
        {
            if (GameManager.instance.progress.currentDifficulty == GameProgress.Difficulty.Hard)
            {
                GameManager.instance.progress.currentDifficulty = GameProgress.Difficulty.Normal;
            }
            else
            {
                GameManager.instance.progress.currentDifficulty = GameProgress.Difficulty.Easy;
            }
            GameManager.instance.RetryBattle();
        }
    }
}

完成したゲーム

このようにして1ヶ月かけてゲームが完成しました。実際のゲームは以下のunityroomのリンクから遊べるのでぜひ遊んでみてください。

君を蝕むこの力で-unityroom

結果

1-Monthonはハッカソンなので、賞を受賞することがあり得るのですが、残念ながら賞はいただけませんでした。また、cygamesのコンテストにも提出したのですが残念ながら賞はいただけなかったのですが、下記のようなフィードバックをcygames様から頂きました。

enari_K 様

はじめまして
Cygamesクリエイティブコンテスト運営事務局です。
この度は当コンテストへご応募いただきありがとうございました。

以下、ご提出いただいた作品への
Cygamesデザイナー本部からの講評・アドバイスとなります。
今後の作品作りの一助となれば幸いです。
~~~~~~~~~~~~~~~~~~~~~~~~~~
主人公の力とヒロインのHPが連動して進めていく設定はお話を進めるほどにキャラクターへの思い入れがより出てきてで面白いなと思いました。
段々とプレイヤーの動作が増えたり、毎度どのボタンが何に対応しているのかもわかりやすく表現されておりプレイしやすかったです。
シナリオ部分に関してもかなりの枚数描きおろしていてすごいです!
シーンの切り替わりによってテキストが見づらくなってしまう点が気になったのでどの背景でも見やすいフォント、配置など意識してみると印象が変わってくるかなと思います。
戦闘画面に関して、わかりやすさに特化している分、各UIやフォントが簡素に、没入感が薄れてしまうようにも感じるため、
世界観に合わせたものにしていくとより楽しさが増していくのかなと感じます。
~~~~~~~~~~~~~~~~~~~~~~~~~~
以上となります。

Cygamesクリエイティブコンテスト2025受賞者の発表は、
2026年春ごろを予定しております。
発表まで今しばらくお待ちください。
また、ブラッシュアップ作品や新規作品のご応募も引き続き受付けておりますので
何卒よろしくお願い申し上げます。

これから

ということで、デザインや視認性についてのフィードバックをもらったのですが、これを実現するには大幅な改修が必要になると思ったので、次回作に活かそうと思います。ということで、次回作ぜひお楽しみにしていてください!

Discussion