ゲーム制作勉強中!あこがれだったプログラマーに今からなろう!

昔、あこがれていたプログラマー。今からでも勉強してみようと思い立ち、チャレンジ開始! 勉強メモや、悪戦苦闘な日々の記録です。

「自分で書いてこそ」派のcodexとの付き合い方

正直、ちょっと前まで、僕は codex にはかなり否定的でした。
プログラミングテクニックもろくにないくせに、「自分で書いてこそのプログラミング」などと言っていたのです。

それが、腕の骨折やら色々なこともあり、素材作成で AI を活用するために ChatGPT を使ってみようと有償サービスに登録し、
せっかく有償にしたのだから、噂の codex とやらも少し触ってみよう。という完全に興味本位で導入してみたところ、あまりの便利さにズッポリとハマってしまいました。

とはいえ、コードを全面的に書いてもらうような使い方は、後で述べる理由もあり、まだまだ怖くてできません。
今のところは、もっぱら
デバッグ(原因調査)、
バグフィックス(修正)、
・部分的なリファクタリング(コードの最適化)

といった用途で活用しているのが実情です。

例えば、デバッグ(原因調査)。

エラー落ちするレベルのバグであれば、人間でも比較的すぐに発生箇所を特定できますが、
大変なのは、むしろその先。

原因となる数値や設定の変化、コンポーネントの取り込み異常などが、
「どの class の、どの段階で起きているのか」
を特定するフェーズになると、debug.log を入れまくってテストを繰り返すことになり、それなりの手間と時間がかかります。

これが、エラー落ちしない類いのバグであれば、まず状況確認のためのテストを繰り返さなければならず、さらに多くの時間を要します。

ところが、VS Code に搭載された codex であれば、
そこにあるコードの処理の流れを読み取った上で、僅かな時間で原因箇所の推定や修正案を提示してくれます。
しかも、Web 版の ChatGPT と違い、コード全体をいちいちコピペする必要もありません。

一昨日にやっていた作業なのですが、

・戦闘後の宝箱メニューで罠を調べるとき、必ずではないがキャラ選択メニューが表示されたままになる。
・Dungeon シーンで戦闘ログの一行が長すぎると、ログが一気に上へ流れてしまい、下げようとしても最終行が見えない。
・Town シーンで魔術師ギルドのステータス画面がスクロールインしてこない。
・Dungeon シーンで宝箱を Pool 返却すると、床として返却されることがある。

といった、いわゆる「エラー落ちしない類いのバグ」すら、
codex と相談しながらデバッグ(原因調査)・バグフィックス(修正)を行ったところ、半日もかからずにすべて解決してしまいました。

もちろん、

発生した状況、
その時の操作、
自分なりの推測、
どうなってほしいのか、

といった情報を適切に指示しなければ、
完全に的外れな回答や、明らかに間違った修正案、果ては基本設計からやり直す羽目になるような提案が返ってくることもあり、
使い方には、まだまだ十分な注意が必要そうです。

これはおそらく、
・AI が持っているプログラミングテクニック
C# や Unity の仕様理解レベル
・ネット上に存在する既存情報
そして何より、こちらの指示の曖昧さ
――それらから AI が導き出す「ベストソリューション」と、こちらが求めている「ベストソリューション」との間に生じる乖離が原因なのだと思います。

これも一昨日前のことですが、
以前このブログに書いた MenuController(かなりの改変が加わり、掲載当時からは激変していますが……)に、その後、スクロールイン・アウト機能を追加してい他のですが
その処理を他の class でも使いたくなり、せっかくなので独立 class に分離しようと、コード修正を任せてみたところ、
MenuController 全体のリファクタリングや、他機能の改変まで勝手に行われてしまい、
結果として他の class との連携が崩れ、まともに動かなくなってしまいました。

さらに一番困ったのが、
「AI が持っているプログラミングテクニックC# や Unity の仕様理解レベル」が、明らかに自分より上だったため、
書き換えられたコードの理解が追いつかず、すぐに元に戻せなくなったことです。

結局、念のためメモ帳にコピペしておいた改造前のコードで上書きして、元に戻すことになりましたが、
これ以降、全面的な改修やコード生成はすっかり怖くなり、一切やらせていません。

それでも、自分では到底思いつかないレベルのテクニックを目の当たりにできるため、
一部分のリファクタリングだけでも十分に勉強になります。

今後も、距離感には気をつけつつ、しっかり活用していこうと思います。


最後に、codex のリファクタリング提案の一例を挙げておきます。
(先達の皆様からすれば、「この程度のテクニックも持っていないのか」と呆れられそうですが……)


//リファクタリング前
public void LearningSkillCheck(StatType statType, int amount)
{
 switch(statType)
 {
  case StatType.Str:
   makingAbility[0]=amount;
   break;

  case StatType.Con:
   makingAbility[1]=amount;
   break;

  case StatType.Int:
   makingAbility[2]=amount;
   break;

  case StatType.Wil:
   makingAbility[3]=amount;
   break;

  case StatType.Spd:
   makingAbility[4]=amount;
   break;

  case StatType.Dex:
   makingAbility[5]=amount;
   break;
 }        
//リファクタリング後
public void LearningSkillCheck(StatType statType, int amount)
{
 int idx = statType switch
 {
  StatType.Str => 0,
  StatType.Con => 1,
  StatType.Int => 2,
  StatType.Wil => 3,
  StatType.Spd => 4,
  StatType.Dex => 5,
  _ => -1
 };

 if (idx >= 0)
 {
  makingAbility[idx] = amount;
 }

//リファクタリング前
if(makingAbility[0] >9)
{
 skillsLevel[0]++;
 LearnedSkills.Add(MasterSkills[2].Clone());
}

if(makingAbility[1]>9)
{
 skillsLevel[0]++;
 LearnedSkills.Add(MasterSkills[3].Clone());
}

if(makingAbility[2]>9)
{
 skillsLevel[1]++;
 LearnedSkills.Add(MasterSkills[0].Clone());
}

if(makingAbility[3]>9)
{
 skillsLevel[1]++;
 LearnedSkills.Add(MasterSkills[1].Clone());
}

if(makingAbility[4] >9)
{
 skillsLevel[2]++;
 trapCount++;
}

if (makingAbility[5]>9)
{
 skillsLevel[2]++;
trapCount++;
}
//リファクタリング後
(int abilityIdx, int skillLevelIdx, int masterSkillIdx)[] skillAwards =
{
 (0, 0, 2),
 (1, 0, 3),
 (2, 1, 0),
 (3, 1, 1),
};

foreach (var a in skillAwards)
{
 if (makingAbility[a.abilityIdx] > 9)
 {
  skillsLevel[a.skillLevelIdx]++;
  LearnedSkills.Add(MasterSkills[a.masterSkillIdx].Clone());
 }
}

int[] trapAwardAbilities = { 4, 5 };
foreach (var idxx in trapAwardAbilities)
{
 if (makingAbility[idxx] > 9)
 {
  skillsLevel[2]++;
  trapCount++;
 }
}










Unity:RenderSettings.fogDensity が即時反映されない理由と回避策

11月下旬に負った左橈骨遠位端骨折ですが、経過は順調なようで、先日ようやくギプスが外れました。
とはいえ、左手はまだ指をグーにできず、手首もほとんど動かせません。無理に動かそうとするとかなり痛むため、しばらくはリハビリをしっかり続けていく必要がありそうです。

それはさておき、
ウィザードリィライク・ダンジョンRPG 「Abyss Bounder」 の開発は、日々コツコツと進めています。
システム的にまだ未実装の要素も多く、道のりは決して短くありませんが、焦らず一歩ずつ積み上げていくつもりです。

今回は、ダンジョンの雰囲気作りと照明表現についてのお話です。
Unityで照明効果を表現する方法はいくつかありますが、現在は Lighting の Fog(フォグ) を使って実装しています。

先日、このフォグをスクリプトから動的に変更しようとしたところ、少しハマった点があったので、備忘録としてまとめておきます。


フォグをスクリプトで制御する方法

フォグのON / OFFは、以下で制御できます。

RenderSettings.fog = true; // trueでON


フォグの濃度を変更する場合はこちら。

RenderSettings.fogDensity = 0.15f; // 数値が大きいほど濃くなる(最大1)


フォグの色を変更する場合は、

RenderSettings.fogColor = new Color(r, g, b, a);

フォグ濃度が即時反映されない問題

問題だったのはフォグの濃度変更です。
Lighting ウィンドウから直接数値を変更した場合は即時反映されるのに、

スクリプトRenderSettings.fogDensity を変更しても、その場では見た目が変わらないという現象が起きました。

実際には、
・カメラが移動する
・描画対象のオブジェクトやマテリアルが変化する

といった「描画の更新」が発生したタイミングで、初めてフォグ濃度の変更が反映されるようでした。
どうやら、フォグ濃度の変更自体は描画更新のトリガーにならないようです。

ただし、松明を使った瞬間などに照明が切り替わる設計なので、
そのタイミングでカメラを動かしたり、マテリアルを変更したりするのは避けたいところ。


解決方法

色々と試した結果、フォグの色を一瞬だけ変更することで、
フォグ濃度の変更を即時反映させられることが分かりました。

RenderSettings.fogDensity = 0.15f;

var fogColor = RenderSettings.fogColor;
RenderSettings.fogColor = new Color(
    fogColor.r + 0.001f,
    fogColor.g,
    fogColor.b,
    fogColor.a
);

await UniTask.Yield();

RenderSettings.fogColor = fogColor;


ごく僅かに色を変更し、1フレーム待って元に戻すだけですが、
これでフォグ濃度の変更が確実に反映されるようになります。
youtu.be
これにて、照明効果まわりの実装も一段落。
さて、次は何を実装しようか……。


2026年中リリース予定:ダンジョンRPG「Abyss Bounder」
今の「ざっくり残課題」


<<ダンジョン内関係>>
Ⅰ. 宝箱システム

・宝箱の罠づくり →🔺:暫定完成
・罠解除の仕組みづくり →🔺:暫定完成
・中身を決める仕組みづくり →🔺:暫定完成

Ⅱ. フロアの罠
・罠の3Dモデリング
・罠出現の仕組みづくり

Ⅲ. 階段
・階段の3Dモデリング
・階段移動の仕組みづくり

IV. 戦闘演出
・攻撃エフェクト →🏃‍♂️‍➡️;作成途中


<<町関係>>
Ⅴ. 町

・キャラクター作成システム →🔺:暫定完成
・店の画像作成 →🔺:暫定完成
・店のシステム
 ・宿屋 →🏃‍♂️‍➡️;作成途中
 ・販売店 →⭕️:完成
 ・教会
 ・ギルド関係 →🏃‍♂️‍➡️;作成途中
 ・訓練場 →🏃‍♂️‍➡️;作成途中

Ⅵ. レベルアップシステム
Ⅶ. セーブ・ロード機能



<<その他・データ関係>>
Ⅶ. モンスターデザイン

・画像作成(外注、Unity AI…?)
・データデザイン

Ⅷ. 武器・防具デザイン →🏃‍♂️‍➡️;作成途中
Ⅸ. 魔法デザイン →🏃‍♂️‍➡️;作成途中
Ⅹ. アイテムデザイン →🏃‍♂️‍➡️;作成途中
Ⅺ. 全体数値バランス調整
Ⅻ. オープニング作成
 →🔺:暫定完成
ⅩⅢ. エンディング作成








ウィザードリィライクのダンジョンゲームを作る!(9):戦闘の流れをつくる。



今回から戦闘、BattleSystemを作っていきます。
基本的な形は、しまづさんの、
youtube.com
がベースになっています。

大きな違いは、ソロ戦闘ではなくパーティ戦なので、Player側もEnemy側も複数いるため、かなり処理が複雑になっている事と、各Manager・ControllerとUniRXを使って連携しているところでしょうか。
いわゆる、Model-View-Presenterパターンに似た構造を取っています。(厳密にはちょっと違うけれど・・・)
また、しまづさんのyoutubeは、作成時期のせいだと思いますが、Stateパターンの組み方をenumをSwitch分岐していますが、
今回作るゲームについては、最近、しまづさんに教わった、Battle SystemをOwnerにしたState単位に分けています。
かなり色々と変えていますが、基礎の考え方は、しまづさんのYouTubeが下敷きになっている事には変わりありません。

処理の流れは、ざっくりこんなイメージになっています。


まず、PlayerController.csから戦闘開始のSignalを受信したGameManager.csは、EnemyManager.csに敵パーティを生成させて受け取ります。
次に、 PlayerController.csのPlayerUnitと、生成された敵パーティをBattleSystem.csに渡して、戦闘開始する流れです。

ちょっとまどろっこしいですが、この様に、各Manager・Controllerは極力、互いに直接接続させず、GameManager.csに処理を集中させる作り方にしています。

さて、今回ですが、結局、まだ戦闘準備になります。
というのも、前まででCharacter.csは作成したものの、実際にそれをどのように運用するかを決めていないので、今回はその辺りになります。
まず、Playerですが、これはシンプルにPlayerController.csに持たせようと思います。

using System;
using UnityEngine;
using Cysharp.Threading.Tasks;
using R3;

public class PlayerController : MonoBehaviour
{
    [SerializeField] public Character[] players = new Character[2];
    [SerializeField] public PlayerUnit[] playerUnits = new PlayerUnit[2];
・
・
・

次に、PlayerのCharacter.csが搭載されるPlayerUnit.csですが、

using System.Collections.Generic;
using UnityEngine;

public class PlayerUnit : CharacterUnit
{

    public List<Item> bkpack_items{ get => Character.bkpack_items; set => Character.bkpack_items = value; }  

    [SerializeField] TMPro.TextMeshProUGUI nameText;    
    [SerializeField] TMPro.TextMeshProUGUI hpText;
    [SerializeField] TMPro.TextMeshProUGUI mpText;
    [SerializeField] TMPro.TextMeshProUGUI StatusText;
・
・
・

この様に、UIの設定が格納されていますので、いっそ、UIに搭載してしまおうと思います。

どうも、僕が作ると面白味のないデザインになってしまうので、UIのデザインはお好みで🤣

次に、Enemy側の所在ですがEnemyManager.csを作って、そこに管理を任せてしまいます。
EnemyManager.csは、EnemyのListを持ち、そこから敵パーティを生成します。

using UnityEngine;

public class EnemyManager : MonoBehaviour
{
    //EnemyのListを持つ
    [SerializeField] public Character[] enemys;

    public Character[] CreateEnemysParty(int floor)//現在はfloorは未使用。
    {
        int enemyCount = Random.Range(1, 4);//出現するEnemyの数を決める。

        Character[] enemysParty = new Character[enemyCount];

        for (int i = 0; i < enemyCount; i++)
        {
            int enemyIndex = Random.Range(0, enemys.Length);
            enemysParty[i] = enemys[enemyIndex].Clone(1);//将来的にはここでEnemyのレベルも変動させる。
        }

        return enemysParty;
    }
}

いずれはダンジョンの階層によって、Enemyの数や種類、Enemyのレベルも調整させようと考えてはいますが、現時点ではシンプルな作りです。
戦闘が開始されると、GameManager.csEnemyManager.csを使って敵パーティ(enemysParty)を生成し、BattleSystem.csに受け渡します。
Enemyも搭載されるべきCharacterUnit.cs(EnemyUnit.cs)が必要ですが、それは戦闘でしか使わないので、BattleSystem.csに持たせることにします。

メインのBattleSystem.csですが、正直、まだまだ開発途中なので、大幅に変わる可能性大です。😅

using UnityEngine;
using R3;
using System.Collections.Generic;
using System;

public class BattleSystem : MonoBehaviour
{
    public BatteleStateBase CurrentState;
    public BatteleState_Idle Idle;
    public BattleState_Setup Setup;
    public BattleState_ActionSelection ActionSelection;
    public BattleState_EnemyActionSelection EnemyActionSelection;
    public BattleState_ActionTurn ActionTurn;
    public BattleState_TurnEnd BattleTurnEnd;

    [SerializeField] public GameObject EnemyImages;//Enemyの絵が表示される土台
    [SerializeField] public MenuController battle_Command_menu;

    public PlayerUnit[] playerUnits;//戦闘中のPlayerUnitへの参照を保存する。

    public GameObject[] enemyUnits;//EnemyManagerで作成された敵パーティを格納

    [SerializeField] public GameObject plafab;//EnemyUnitのPrefab

    private void Awake() 
    {
        Idle = new BatteleState_Idle(this);
        Setup = new BattleState_Setup(this);
        ActionSelection = new BattleState_ActionSelection(this);
        EnemyActionSelection = new BattleState_EnemyActionSelection(this);
        ActionTurn = new BattleState_ActionTurn(this);
        BattleTurnEnd = new BattleState_TurnEnd(this);
        CurrentState = Idle;
    }

    void Update()
    {
        CurrentState.OnUpdate();   
    }

    public void ChangeState(BatteleStateBase state)
    {
        if(CurrentState != null)
        {
            CurrentState.OnExit();
        }

        CurrentState = state;
        CurrentState.OnEnter();
    }

    public void Battle_Start(Character[] enemys,PlayerUnit[] get_playerUnits)
    {
        enemyUnits = new GameObject[enemys.Length];
        playerUnits = new PlayerUnit[get_playerUnits.Length];
        playerUnits = get_playerUnits;

        //同じ種類の敵がいた場合、名前の後ろにA,B,C...をつける
        {
            Dictionary<string, int> nameCount = new Dictionary<string, int>();

            foreach (var enemy in enemys)
            {
                string enemyName = enemy.CharacterName;
                if (!nameCount.ContainsKey(enemyName))
                {
                    nameCount[enemyName] = 0;
                }

                char plus = (char)('A' + nameCount[enemyName]);
                enemy.CharacterName = $"{enemyName}{" "}{plus}";
                nameCount[enemyName]++;
            }
        }

        //エネミーリストをBattlerNameBaseでソート
        System.Array.Sort(enemys, (a, b) => a.CharacterName.CompareTo(b.CharacterName));

        for (int i = 0; i < enemys.Length; i++)
        {
            enemyUnits[i] = Instantiate(plafab, new Vector3(0, 0, 0), Quaternion.identity);
            enemyUnits[i].transform.SetParent(EnemyImages.transform);
            enemyUnits[i].GetComponent<EnemyUnit>().Setup(enemys[i], i + 1, this);
        }

        ChangeState(Setup);
    }
}

細かい説明はおいおいしていくとして、今日のところはBattle_Start関数について
GameManager.csから、このBattle_Start関数が呼ばれ、同時にEnemyManager.csで生成されたenemysPartyと、PlayerContorollerが管理しているPlayerUnitへの参照がBattleSystem.csに受け渡されます。

Battle_Start関数は最初に受け取ったenemysPartyの敵の名前を確認して、敵の識別用に名前の後ろにA,B,C・・・を足しています。
まず、敵の名前と数という辞書型変数を作成して、

Dictionary<string, int> nameCount = new Dictionary<string, int>();

次に、enemysPartyに登録されているCharacter.csを一つずつ取り出し、名前を確認して、
辞書型変数にすでにその名前が登録されていれば、該当の名前の数を増やしています。

foreach (var enemy in enemys)
{
   string enemyName = enemy.CharacterName;
   if (!nameCount.ContainsKey(enemyName))//辞書型変数に名前が含まれているか確認。
   {
      nameCount[enemyName] = 0;//なければ、新たに、その名前と0を登録する。
   }

そして、辞書型変数の数を参照して名前の後ろにA,B,C・・・を足しています。
これはchar型の足し算という方法で、
ざっくり言えば、char型の「A」という文字に1を足してやると「B」になる。という、一瞬、頭が😵‍💫するような法則に則っています。

   char plus = (char)('A' + nameCount[enemyName]);
   enemy.CharacterName = $"{enemyName}{" "}{plus}";
   nameCount[enemyName]++;
}


敵パーティの名前の整理が終わったけれど、enemysPartyはEnemyManager.csがランダムにEnemyListから選んで作成しているので、名前ごとに並んでいるわけではないです。
今のところは、このままでも支障はないけれど、後々、魔法とかで範囲攻撃を導入する際に、敵が名前ごとに並んでいる方が都合が良いかもしれないので、並び替えることにします。

System.Array.Sort(enemys, (a, b) => a.CharacterName.CompareTo(b.CharacterName));

そして、最後にEnemyUnit.csに搭載してやり、これでようやく戦闘準備が完了です。

for (int i = 0; i < enemys.Length; i++)
{
    enemyUnits[i] = Instantiate(plafab, new Vector3(0, 0, 0), Quaternion.identity);
    enemyUnits[i].transform.SetParent(EnemyImages.transform);
    enemyUnits[i].GetComponent<EnemyUnit>().Setup(enemys[i], i + 1, this);
}


次から、各Stateの説明に行けるかなぁ・・・








ウィザードリィライクのダンジョンゲームを作る!(8):キャラクターの設計をつくる。



今回から戦闘、BattleSystemの本体を作っていきます。
基本的な形は、しまづさんの、
youtube.com
がベースになっています。

あたりまえですが、しまづさんのYouTubeは基礎の解説なので、ソロ戦闘です。
自分としては、いずれはウィザードリィみたいに、自由にキャラクターを入れ替えて、 好きなパーティ構成でダンジョンを探索するゲームを目指したいので、
今回作るゲームも、プレイヤー側二人、敵は複数、というパーティ戦で頑張りたいと考えています。
他にも、パーティ戦なので、コマンドを選択したら即実行ではなく、全キャラクターの行動選択→キャラクターの速度順に実行
という形にしていきます。

まずは、何はなくともキャラクターがいなくては話が始まらないので、しまづさんのYouTube
【Unity】ドラクエ風シンプル2DRPGの作り方! #19 戦闘ファイルの全体把握【シーズン2】 - YouTube
(メンバー限定の領域なので見れないかもしれませんが・・・😅)
こちらを、参考にキャラクターの基礎設計図をつくります。

全体像はこんなイメージです。

キャラクターの設計図になるCharacterBaseがあり、それを基にCharacter.csを作ります。
基本的にはPlayerもEnemyも同じ構造です。
次にこのCharacter.csを分類しやすくするためにCharacterUnit.csを作成して、これにCharacter.csを搭載します。
これで、Character.csは、PlayerUnit.csEnemyUnit.csにカテゴリーでき、実際に各SystemやManagerで、Classの種類を気にせずに使用できるようになります。

まずは、すべてのキャラクターの基礎設計図のCharacterBaseです。
要はどういうステータスを作るか、という話なので、「自分流」を出しやすい領域です。

自分は基本のHP、MP (SP)、攻撃力、防御力の他に、魔法攻撃力、魔法防御力。他にも戦闘中の行動をキャラクターの速度順にしたいので、速度。
さらに、攻撃の命中率や攻撃をうまく捌く力などとして器用度(Dexterity)などを作っています。

他にも毒にかかりやすいか判定する抵抗力(Resistance)、運の良さ(Lcck)などもあっても良いかもしれませんが、抑え気味にしておかないとキリがなくなるので😅最初はこんなもので良いと思います。

using UnityEngine;
using System;

[CreateAssetMenu(fileName = "New Character", menuName = "Character")]
[Serializable]
public class CharacterBase : ScriptableObject
{
    [SerializeField] private string characterName;
    [SerializeField] private Sprite battlerSprite;
    [SerializeField] private int maxHP;
    [SerializeField] private int maxSP;
    [SerializeField] private int speed;
    [SerializeField] private int dexterity;
    [SerializeField] private int attack;
    [SerializeField] private int defense;
    [SerializeField] private int specialAttackPower;
    [SerializeField] private int specialDefense;
    [SerializeField] private int exp;
    [SerializeField] private int gold;

    public string CharacterName{ get => characterName; }
    public Sprite BattlerSprite { get => battlerSprite; }
    public int MaxHP { get => maxHP; }
    public int MaxSP { get => maxSP; }
    public int Speed { get => speed; }
    public int Dexterity { get => dexterity; }
    public int Attack { get => attack; }
    public int Defense { get => defense; }
    public int SpecialAttackPower { get => specialAttackPower; }
    public int SpecialDefense { get => specialDefense; }
    public int Exp { get => exp; }
    public int Gold { get => gold; }
}


次に、この設計図を基に、キャラクターの本体部分になるCharacter.csを作ります。
ここには先ほどの設計図に基づいた各能力値のほか、アイテムや装備、魔法などによる能力値のブーストまで、ありとあらゆる数値が入っています。
こちらもアイデア次第でどこまでも膨らんでしまうので、抑え気味に・・・😅
それでも、アイテムによる能力値ブーストと魔法による能力値ブーストは分けたかったので、そうしています。

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

[Serializable]
public class Character
{
    public Character(CharacterBase _base)
    {
        this._base = _base;
    }

    public Character Clone(int level)
    {
        return new Character(_base)
        {
            Level = level

            //将来、ここにレベルに応じてステータスを設定する処理を作る

        };
    }

    [SerializeField] CharacterBase _base;
    [SerializeField] int level;
    public CharacterBase Base { get => _base; }
    public int Level { get => level; set => level = value; }

    public string BaseName { get => _base.CharacterName; }
    private string characterName;
    public string CharacterName { get => characterName; set => characterName = value; }

    public Sprite Sprite { get; set; }

    public int MaxHP { get; set; }
    public int HP{ get; set; }
    public int MaxSP { get; set; }
    public int SP { get; set; }
    public int Speed { get; set; }
    public int Dexterity { get; set; }
    public int Attack { get; set; }
    public int Defense { get; set; }
    public int SpecialAttackPower { get; set; }
    public int SpecialDefense { get; set; }
    public int Exp { get; set; }
    public int Gold { get; set; }
    public List<StatusCondition> status { get; set; }

#region Buffs
    private int magicBoostedAtk;
    public int MagicBoostedAtk { get => magicBoostedAtk; set => magicBoostedAtk = value; }
    private int magicBoostedDef;
    public int MagicBoostedDef { get => magicBoostedDef; set => magicBoostedDef = value; }
    private int magicBoostedSpAtk;
    public int MagicBoostedSpAtk { get => magicBoostedSpAtk; set => magicBoostedSpAtk = value; }
    private int magicBoostedSpDef;
    public int MagicBoostedSpDef { get => magicBoostedSpDef; set => magicBoostedSpDef = value; }
    private int magicBoostedSpeed;
    public int MagicBoostedSpeed { get => magicBoostedSpeed; set => magicBoostedSpeed = value; }
    private int magicBoostedDexterity;
    public int MagicBoostedDexterity { get => magicBoostedDexterity; set => magicBoostedDexterity = value; }
    private int itemBoostededAtk;
    public int ItemBoostedAtk { get => itemBoostededAtk; set => itemBoostededAtk = value; }
    private int itemBoostedDef;
    public int ItemBoostedDef { get => itemBoostedDef; set => itemBoostedDef = value; }
    private int itemBoostedSpAtk;
    public int ItemBoostedSpAtk { get => itemBoostedSpAtk; set => itemBoostedSpAtk = value; }
    private int itemBoostedSpDef;
    public int ItemBoostedSpDef { get => itemBoostedSpDef; set => itemBoostedSpDef = value; }
    private int itemBoostedSpeed;
    public int ItemBoostedSpeed { get => itemBoostedSpeed; set => itemBoostedSpeed = value; }
    private int itemBoostedDexterity;
    public int ItemBoostedDexterity { get => itemBoostedDexterity; set => itemBoostedDexterity = value; }

#endregion

#region Items
    public List<Item> bkpack_items = new List<Item>();

    [SerializeField] public ItemBase weapon;
    [SerializeField] public ItemBase armor;
    [SerializeField] public ItemBase shield;
    [SerializeField] public ItemBase helmet;
    [SerializeField] public ItemBase accessory;

#endregion

    public void Init()
    {
        Sprite = _base.BattlerSprite;
        characterName = _base.CharacterName;
        MaxHP = _base.MaxHP;
        HP = MaxHP;
        MaxSP = _base.MaxSP;
        SP = MaxSP;
        Speed = _base.Speed;
        Dexterity = _base.Dexterity;
        Attack = _base.Attack;
        Defense = _base.Defense;
        SpecialAttackPower = _base.SpecialAttackPower;
        SpecialDefense = _base.SpecialDefense;
        Exp = _base.Exp;
        Gold = _base.Gold;
        status = new List<StatusCondition>();
        status.Add(StatusCondition.Normal);
    }
}

この辺りまでは、面白みのない、ただの能力値の羅列ですね🤣
これで兎にも角にも、キャラクターができました。

ただ、このままではキャラクターの種類ごとにclassが増えてしまいますし、PlayerとEnemyの区別もつき難いので、カテゴリー整理と各UIの機能も含めたCharacterUnit.csを作ります。
各Managerは、このCharacterUnit.csを経由してCharacter.csから必要な能力値を取得するので、定型の加工などもここで行い、結果を各Managerに提供するようにしています。
なので、戦闘の根幹に当たるダメージ計算もここで行ないます。
ウィザードリィライクなゲームではあるのですが、本家のようなACによる判定はちょっと難しかったので、

ドラクエ4~6の計算式:(攻撃力/2-守備力/4)×変数(7/8~9/8)

を参考(丸コピー)にしています。

using System.Collections.Generic;
using UnityEngine;

public class CharacterUnit : MonoBehaviour
{
    public Character Character;
    
    public virtual void Setup(Character character)
    {
        Character = character;
    }

    public string characterName
    {
        get
        {
            return Character.CharacterName;
        }

        set
        {
            Character.CharacterName = value;
        }
    }

    public int MaxHP
    {
        get
        {
            return Character.MaxHP;
        }
    }

    public int HP
    {
        get
        {
            return Character.HP;
        }

        set
        {
            Character.HP = value;
        }
    }

    public int MaxSP
    {
        get
        {
            return Character.MaxSP;
        }
    }

    public int SP
    {
        get
        {
            return Character.SP;
        }

        set
        {
            Character.SP = value;
        }
    }

    public List<StatusCondition> status
    {
        get
        {
            return Character.status;
        }

        set
        {
            Character.status = value;
        }
    }

    public int Turn_Speed
    {
        get
        {
            return Character.Speed + (int)Random.Range(0, 18);
        }
    }

    public virtual int Atk_Dexterity
    {
        get
        {
            return Character.Dexterity +
                         (int)Random.Range(2, 12) + Character.Level;
        }
    }

    public virtual int Def_Dexterity
    {
        get
        {
            return Mathf.CeilToInt(
                    (Character.Speed*3/4)+(Character.Dexterity*1/4)+
                    (int)Random.Range(1, 6) + Character.Level
                );
        }
    }

    public virtual int Battle_Attack
    {
        get
        {
            return Mathf.CeilToInt(
                (Character.Attack
                + (int)Random.Range(1, 6)
                ) / 2
            );
        }
    }
  
    public virtual int Battle_Defense
    {
        get
        {
            return Mathf.CeilToInt(
                (Character.Defense
                + (int)Random.Range(1, 6)
                ) / 4
            );
        }
    }

    public int Exp
    {
        get
        {
            return Character.Exp;
        }
    }

    public int Gold
    {
        get
        {
            return Character.Gold;
        }
    }

    public virtual void UpdateUI()
    {
    }
}

次にplayerとenemyを差別化します。
playerには装備による加算があるので、それもここで反映しています。
他にはUIとの連携もここで行なっています。これにより、特にPlayerUnit.csを各Managerで連携する際、各ManagerはプレイヤーのUI更新のために、いちいち「専用のUIを特定する」という手間を省くことができます。

using System.Collections.Generic;
using UnityEngine;

public class PlayerUnit : CharacterUnit
{
    //ステータスパネルの各UIへの連携を格納する。
    [SerializeField] TMPro.TextMeshProUGUI nameText;    
    [SerializeField] TMPro.TextMeshProUGUI hpText;
    [SerializeField] TMPro.TextMeshProUGUI mpText;
    [SerializeField] TMPro.TextMeshProUGUI StatusText;

    public override void Setup(Character character)
    {
        base.Setup(character);
        nameText.text = character.CharacterName;
        hpText.text = "HP :" + character.HP + "/" + character.MaxHP;
        mpText.text = "SP :" + character.SP + "/" + character.MaxSP;

        if(character.status.Count == 1)
        {
            StatusText.text = "Status: " + character.status[0].ToString();
        }
        else
        {
            StatusText.text = "Status: " + character.status[0].ToString() + " " + character.status[1].ToString();
        }
    }

    public override void UpdateUI()
    {
        Character.HP= Mathf.Clamp(Character.HP, 0, Character.MaxHP);

        hpText.text = "HP :" + Character.HP + "/" + Character.MaxHP;
        mpText.text = "SP :" + Character.SP + "/" + Character.MaxSP;

        if(Character.status.Count == 1)
        {
            StatusText.text = "Status: " + Character.status[0].ToString();
        }
        else
        {
            StatusText.text = "Status: " + Character.status[0].ToString() + " " + Character.status[1].ToString();
        }
    }

    public List<Item> bkpack_items{ get => Character.bkpack_items; set => Character.bkpack_items = value; }  
    
    public void EquipWeapon(ItemBase weapon)
    {
        Character.weapon = weapon;
    }

    public void EquipArmor(ItemBase armor)
    {
        Character.armor = armor;
    }

    public void EquipShield(ItemBase shield)
    {
        Character.shield = shield;
    }

    public void EquipHelmet(ItemBase helmet)
    {
        Character.helmet = helmet;
    }

    public void EquipAccessory(ItemBase accessory)
    {
        Character.accessory = accessory;
    }

    public override int Def_Dexterity
    {
        get
        {
            int dp=0;
            if (Character.shield == null)
            {
                dp = 0;
            }
            else
            {
                dp = Character.shield.ItemPower;
            }

            return Mathf.CeilToInt(
                    (Character.Speed * 3 / 4) + (Character.Dexterity * 1 / 4) +
                    (int)Random.Range(1, 6) + Character.Level + dp
                ); 
        }
    }

    public override int Battle_Attack
    {
        get
        {
            int wp = 0;   
            if (Character.weapon == null)
            {
                wp = 0;
            }
            else
            {
                wp = Character.weapon.ItemPower;
            }

            return Mathf.CeilToInt(
                (Character.Attack
                + wp
                + (int)Random.Range(1, 6)
                )/2
            );
        }
    }

    public override int Battle_Defense
    {
        get
        {
            int ar = 0;
            if (Character.armor == null)
            {
                ar = 0;
            }
            else
            {
                ar = Character.armor.ItemPower;
            }

            if(Character.helmet == null)
            {
                ar += 0;
            }
            else
            {
                ar += Character.helmet.ItemPower;
            }

            return Mathf.CeilToInt(
                (Character.Defense
                + ar
                + (int)Random.Range(1, 6)
                ) / 4
            );
        }
    }
}

まだまだ作成途中なので、ここは今後、思いっきり変わる可能性が高いです。
デバッグやバランス調整で各計算式は今とガラリと変わるかも・・・😆

さて、残すはEnemyUnit.csですが、こちらは現時点では、あまりCharacterUnit.csから変えていません。
もちろん、戦闘用にUIとの連携は入っているのですが、PlayerUnit.csとは異なり、戦闘でしか使わないため、それほど項目を追加しなくても良い予定です。
それでも、詳細、魔法をしっかり作り込んでいく段階で、Enemy側もCharacter.csとの連携を作ってやらないといけないので、やはり、こちらも「現時点のもの」になると思います。

using UnityEngine;
using UnityEngine.UI;
using R3;

public class EnemyUnit : CharacterUnit
{
    [SerializeField] Image image;//モンスターの画像
    [SerializeField] TMPro.TextMeshProUGUI nameText;//モンスターの名前
    [SerializeField] Image cursor;//選択用のカーソル
    [SerializeField] BattleSystem battleSystem;

    public void Setup(Character character, int cursor_index, BattleSystem battlesystem)
    {
        base.Setup(character);

        image.sprite = character.Base.BattlerSprite;
        nameText.text = character.CharacterName;
        battleSystem = battlesystem;

        battleSystem.attackCursor.Subscribe(index =>
        {
            if (index == cursor_index)
            {
                cursor.enabled = true;
            }
            else
            {
                cursor.enabled = false;
            }

        }).AddTo(this);
    }

    public override void UpdateUI()
    {
        if (Character.HP <= 0)
        {
            //imageを消す
            image.enabled = false;
            nameText.enabled = false;
        }
    }
}

これで、キャラクターはできました。
次は戦闘の処理の流れを作る予定です。








ウィザードリィライクのダンジョンゲームを作る!(7):カスタマイズ可能なコマンドメニューを作る。



前回までで移動は一段落したので、いよいよ戦闘ができるようにしていきます。

ウィザードリィライクという事で、ターン制のコマンド型の戦闘になるのですが、
そのためには、当然ですが、コマンドを選択するためのコマンドメニューが必要です。

基本形は、しまづさんのYouTube
youtube.com
の#15で説明されている方法になりますが、
こちらを応用して、汎用性を高めていこうと思います。

尚、
上記のしまづさんのYouTubeシリーズは、一部チャンネルメンバー限定になっています。
ウィザードリィライクのダンジョンゲームを作る! 』 を書くに当たって、チャンネルメンバー限定部分の内容まで踏み込んで書く事を、しまづさんからお許し頂いています。🙇


さて、応用の内容ですが、
今後、色々な機能やシステムを追加していく事になり、色々な選択項目がでてくるのですが、その全てのコマンドメニューを個別に作成するのではなく、
スクリプトでメニューをカスタマイズできるようにして、後から使用用途に応じたコマンドメニューに簡単に変えられるようにしたいと思います。

というわけなので、メニュー側には自分がどこで使われても問題なく各Managerと連絡できるようにする手段を考える必要があります。

いろんな方法が考えられますが、今回は勉強中のリアクティブプログラミング(Reactive Programming)の実践として、UniRx、 せっかくなので、改版されたR3を使うことにしました。

R3の導入方法は、こちらで詳しく説明されています。
zenn.dev

UniRxからR3に変わったとはいえ、基本的な部分はあまり変わっていない様で
今回は、変数の値が変わると自動的に通知するReactivePropertyと、特定の型の変数を任意のタイミングで通知するSubjectを使いたいと思います。

ざっくりとした構成は、こんなイメージです。


しまづさんのコマンドメニューでは、各選択肢のテキストの色を変える指示をメニュー側のUpdate関数で毎フレーム行なっています。

これをUniRxを使って、選択されている項目が変わった時だけReactivePropertyによって指示が通知し、選択肢側で自分のindexと比較して色を変えるようにしました。
また、
必要な時に生成して使う予定なので、使い終わった時にメニューは破壊する前提にしているので、破壊時、購読関係を終了する

select_Button_index.OnCompleted();
Action_Buton.OnCompleted();

を加えてあります。
全体像としては、

using UnityEngine;
using R3;
using TMPro;
using UnityEngine.Events;

public class MenuController : MonoBehaviour
{

/// <summary>
/// メニューの設定用の変数群
/// </summary>

    bool Title = false;
    public UnityAction ButtonSetupAction;
    [SerializeField] int button_Count;
    [SerializeField] public int[] button_Index;//選択したボタンの番号として通知されるindex
    [SerializeField] public string[] button_Texts;
    [SerializeField] public bool[] button_Sleep;
    [SerializeField] public int[] button_Action_Types;//選択したボタンのindexに割り当てた機能番号
    RectTransform rectTransform;
    int button_index_max = 0;

/// <summary>
/// 連携システム
/// </summary>
    [SerializeField] public InputManager inputManager;
    [SerializeField] public SoundManager soundManager;

/// <summary>
/// メニューの表示・機能用の変数群
/// </summary>
    [SerializeField] CanvasRenderer canvasRenderer;
    [SerializeField] public Sprite OnClickSprite;//選択肢をクリックした時の画像
    [SerializeField] public Sprite NormalSprite;//選択肢をクリックしていない時の画像
    [SerializeField] public TMP_Text MenuTitle;
    public bool MenuSleep = true;
    //色を変えるボタンの番号を通知する。
    public ReactiveProperty<int> select_Button_index = new ReactiveProperty<int>(1);
    //上位のManagerに選択されたボタンの機能番号を通知する。
    public Subject<int> Action_Buton = new Subject<int>();


    public void Update(){
        if(MenuSleep)
        {
            return;
        }
        inputCheck();

    }

    public void inputCheck(){
        if(inputManager.IsPush)
        {
            inputManager.IsPush2 = false;
            inputManager.IsWait = true;
            Button_Action(select_Button_index.Value);
        }
    
        if(!inputManager.IsWait)
        {
            int cursor = select_Button_index.Value;
            bool isChanged = false;

            if(inputManager.LeftDirction.y > 0.4f)
            {
                cursor --;
                if(cursor < 1)
                {
                    cursor = button_index_max ;
                }
                isChanged = true;
            }
            else if(inputManager.LeftDirction.y < -0.4f)
            {
                cursor ++;
                if(cursor > button_index_max)
                {
                    cursor = 1;
                }
                isChanged = true;
            }

            if(isChanged)
            {
                this.select_Button_index.Value = cursor;
                inputManager.IsWait = true;
                soundManager.PlaySE(0);
            }
        }
    }

    public void Button_Action(int index){
        select_Button_index.Value = index;//ボタン選択状態を判定するindex

        int PushNumber = 0;
        //選択したボタンの番号から、対応する機能番号の位置を取得する。
        for(int i = 0; i < button_Count; i++)
        {
            if(button_Index[i] == index)
            {
                PushNumber = i;
            }
        }

        Action_Buton.OnNext(button_Action_Types[PushNumber]);
        MenuSleep = true;
    }

    public void MenuSetUp(MenuSetting menuSetting)
    {
        rectTransform = GetComponent<RectTransform>();

        this.Title = menuSetting.title;
        this.button_Count = menuSetting.button_Count;

        this.button_Texts = new string[button_Count];
        this.button_Index = new int[button_Count];
        this.button_Action_Types = new int[button_Count];
        this.button_Sleep = new bool[button_Count];

        this.button_Texts = menuSetting.button_Texts;
        this.button_Index = menuSetting.button_Index;
        this.button_Action_Types = menuSetting.button_Action_Types;
        this.inputManager = menuSetting.inputManager;
        this.soundManager = menuSetting.soundManager;


        canvasRenderer = GetComponent<CanvasRenderer>();

        if(menuSetting.title)
        {
            GameObject titleObj = Instantiate(menuSetting.textPrefab);
            titleObj.transform.SetParent(this.transform);
            MenuTitle = titleObj.GetComponent<TMP_Text>();
            MenuTitle.text = menuSetting.titletext;
        }

        for (int i = 0; i < button_Count; i++)
        {
            GameObject buttonObj = Instantiate(menuSetting.buttonPrefab);
            buttonObj.transform.SetParent(this.transform);
            buttonObj.GetComponent<SelectableText>().index = i + 1;
            buttonObj.GetComponent<SelectableText>().menuController = this;

            //選択肢を作成した時点で、ButtonSetupActionに選択肢のButton_Setup関数を登録する。
            this.ButtonSetupAction += buttonObj.GetComponent<SelectableText>().Button_Setup;

        }

        int sizeY = 20+(50 * button_Count)+(menuSetting.title ? 50 : 0);//タイトルがある場合は50を追加

        rectTransform.sizeDelta = new Vector2(220, sizeY);


        //button_indexに格納されている配列の中で最大値を取得する
        button_index_max = 0;
        for(int i = 0; i < button_Count; i++)
        {
            if(button_Index[i] > button_index_max)
            {
                button_index_max = button_Index[i];
            }
        }

        Button_SetUp();
    }

    public virtual void Button_SetUp(){
        ButtonSetupAction?.Invoke();

       MenuSleep = false;
    }

    //メニューが不要になったら、上位のManagerから呼ばれて、購読関係を終了する。
    public virtual void Menu_Close(){
        select_Button_index.OnCompleted();
        Action_Buton.OnCompleted();
        Destroy(this.gameObject);
    }
}

メニューの作り方に自由性を持たせるため、選択肢自体の番号(button_Index)と選択肢に割り当てられる機能(button_Action_Type)を分ける仕組みにしています。
そのため、選択肢が押された際には、button_Indexからbutton_Action_Typeを特定して、上位のManagerに通知するようになっています。

    public void Button_Action(int index){
        select_Button_index.Value = index;//選択されたボタンに変更する。

        int PushNumber = 0;
        //選択肢の番号(button_Index)から、対応する機能番号(button_Action_Type)の位置を取得する。
        for(int i = 0; i < button_Count; i++)
        {
            if(button_Index[i] == index)
            {
                PushNumber = i;
            }
        }


メニューのカスタマイズ機能は後にして、先に選択肢にあたる SelectableText.csです。 さきほど記載した通り、MenuController.csselect_Button_indexを購読して、自分のテキストの色を変える機能と、
選択肢は、ボタンではなくComponentを少しでも減らしたかったので、UnityEngine.EventSystemsの、IPointerClickHandler,IPointerDownHandler,IPointerUpHandlerを使うようにしています。

using UnityEngine;
using R3;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using System;

public class SelectableText : MonoBehaviour,
    IPointerClickHandler,IPointerDownHandler,IPointerUpHandler

{
    [SerializeField] public MenuController menuController;

    public bool button_sleep = false;
    [SerializeField] public int button_index = 1;//選択したボタンの番号として通知されるindex

    public Image image;

    [NonSerialized] public TMPro.TextMeshProUGUI tMp;
    [SerializeField] public int index;//ボタンのシリアル番号(不変)

    private void Awake(){
        image = GetComponent<Image>();
        tMp = GetComponentInChildren<TMPro.TextMeshProUGUI>();
    }

    public virtual void Start(){
        menuController.select_Button_index.Subscribe(index =>
        {
            if (index == this.button_index)
            {
                tMp.color = Color.yellow;
            }
            else
            {
                tMp.color = Color.white;
            }
        }); 
    }

    //選択肢のカスタマイズ機能
    public virtual void Button_Setup(){
        button_index = menuController.button_Index[index - 1];
        tMp.text = menuController.button_Texts[index - 1];
        button_sleep = menuController.button_Sleep[index - 1];
    }

    public virtual void OnPointerClick(PointerEventData eventData){  
        if(button_sleep || menuController.MenuSleep)
        {
            return;
        }

        menuController.Button_Action(button_index);
    }

    public virtual void OnPointerDown(PointerEventData eventData){
        if(button_sleep || menuController.MenuSleep)
        {
            return;
        }
        //クリックした時に画像を変える。
        image.sprite = menuController.OnClickSprite;
    }

    public virtual void OnPointerUp(PointerEventData eventData){
        if(button_sleep || menuController.MenuSleep)
        {
            return;
        }
        //クリックを離した時に元の画像に戻す。
        image.sprite = menuController.NormalSprite;
    }

}


最後に、今回の目玉機能のメニューのカスタマイズ機能です。
まず、メニュー設定を分かりやすくするため、専用Classを作成します。

using UnityEngine;

public class Menu_Blueprint
{
    public Menu_Blueprint(GameObject buttonPrefab, 
                        GameObject textPrefab,
                        bool title,//タイトルを作るか否か
                        string titletext,//作る時のタイトルテキスト
                        int button_Count,//作るボタンの数
                        string[] button_Texts,//ボタンのテキスト
                        int[] button_Index,//ボタンに割り当てられる番号
                        int[] button_Action_Types,//ボタンの機能番号
                        InputManager inputManager, SoundManager soundManager)
    {
        this.buttonPrefab = buttonPrefab;
        this.textPrefab = textPrefab;
        this.title = title;
        this.titletext = titletext;
        this.button_Count = button_Count;
        this.button_Texts = button_Texts;
        this.button_Index = button_Index;
        this.button_Action_Types = button_Action_Types;
        this.inputManager = inputManager;
        this.soundManager = soundManager;
    }

    public GameObject buttonPrefab;
    public GameObject textPrefab;
    public bool title;
    public string titletext;
    public int button_Count;
    public string[] button_Texts;
    public int[] button_Index;
    public int[] button_Action_Types;
    public InputManager inputManager;
    public SoundManager soundManager;
}


他のManagerなどから、メニューのカスタマイズをする時は、こんな感じで設定してやるようになります。

        string[] b_text = new string[5]{"攻撃","魔法・スキル","アイテム","防御","逃げる"}; ;
        int[] b_index = new int[5]{1,2,3,4,5};
        int[] b_type = new int[5]{1,2,3,4,5};

        Menu_Blueprint menu_Blueprint = 
            new Menu_Blueprint(button_prefab,
                        text_prefab,
                        true,player.Character.battlerName,
                        5,
                        b_text,b_index,
                        b_type,inputManager,
                        soundManager);

        Menu.MenuSetUp(menu_Blueprint);
}

ボタンの数や、タイトルをつけるかどうか、各ボタンにどういう機能(int)を割り振るか等を専用class Menu_Blueprint.csに登録して、MenuController.csMenuSetUp関数を使って設定しています。
とは言うものの、やってることはかなり力技ですが・・・😅

this.ButtonSetupAction += buttonObj.GetComponent<SelectableText>().Button_Setup;

選択肢を作成するタイミングで、SelectableText.csButton_Setup関数を自身のUnityActionに登録して実行しています。

        int sizeY = 20+(50 * button_Count)+(menu_Blueprint.title ? 50 : 0);//タイトルがある場合は50を追加

        rectTransform.sizeDelta = new Vector2(220, sizeY);


        //button_indexに格納されている配列の中で最大値を取得する
        button_index_max = 0;
        for(int i = 0; i < button_Count; i++)
        {
            if(button_Index[i] > button_index_max)
            {
                button_index_max = button_Index[i];
            }
        }
}

そして、タイトルや選択肢の数によってメニューのサイズを変えています。


今後、戦闘やらアイテムやら魔法やらでメニューは色々と出てくることになりますが、これでメニューの基本形は出来上がりました。

次から、いよいよ戦闘。最初は殴り合いができるようにしていきたいと思います。








ウィザードリィライクのダンジョンゲームを作る!(6):壁判定と扉を開ける仕組みを作る。



今回こそは壁判定と、扉を開けるところまで進みます。🤣
というか、実はほぼ基礎的な仕組みはできていて、要は、特定の座標がなんなのか調べる仕組みを描画に使うのか、移動判定に使うのか。という違いなだけです。

移動する方向・移動先の座標が必要になるのは、前々回くらいの通りです。
なので流れとしては、PlayerController-classで、移動方向や移動先を計算しているWalk関数と、移動に関する描画と消去・実際の移動を行っているWalking関数との間に、壁チェックの関数を追加するイメージになります。

    public void Walk(float y)
    {
        if(y > 0.4f)
        {
            targetPosition = 
                        new Vector3Int((int)transform.position.x,0,(int)transform.position.z) + 
                        new Vector3Int((int)Math.Round(transform.forward.x * 2), 0,
                        ((int)Math.Round(transform.forward.z * 2)));

            //↓今までWalking関数に送っていた移動方向を壁チェック用の関数に送るように変える。
            CheckDirection(player_Direction);
        }
        else if(y < -0.4f)
        {
            int Walking_Direction = (player_Direction -2);//

            if(Walking_Direction < 0)
            {
                Walking_Direction = 4 + Walking_Direction;
            }

            targetPosition = 
                        new Vector3Int((int)transform.position.x,0,(int)transform.position.z) - 
                        new Vector3Int((int)Math.Round(transform.forward.x * 2), 0,
                        ((int)Math.Round(transform.forward.z * 2)));

            //↓今までWalking関数に送っていた移動方向を壁チェック用の関数に送るように変える。
            CheckDirection(Walking_Direction);
        }
    }

この時点で、移動方向と移動先の座標(targetPosition)は計算済みなので、
その数値を使って、新しいCheckDirection関数で移動方向の壁の座標と、その先のfloor(2n+1、2n+1)の座標を計算し、その座標の情報をmap配列から取得して判定すれば良い。

座標の情報の取得はGet関数を使えば良いので

public class DungeonMap

    public int Get(int x, int y)
    {
        if (x < 0 || x >= map.GetLength(0) || y < 0 || y >= map.GetLength(1))
        {
            return -1;
        }

        return map[x, y];
    }


移動方向と移動元座標から、移動方向の壁の座標と、その先のfloor(2n+1、2n+1)の座標を計算して、このGet関数にかければ良い。

    async void CheckDirection(int player_direction)
    {
        int NextPointWall;
        int NextPointFloor;
        Vector2Int targetWall;

        switch(player_direction)
        {
            case 0://北
                NextPointWall = dmanager.map.Get(targetPosition.x, targetPosition.z -1);
                NextPointFloor = dmanager.map.Get(targetPosition.x, targetPosition.z );

                targetWall = new Vector2Int(targetPosition.x, targetPosition.z -1);
                break;
                
            case 1://東
                NextPointWall = dmanager.map.Get(targetPosition.x -1, targetPosition.z);
                NextPointFloor = dmanager.map.Get(targetPosition.x , targetPosition.z);

                targetWall = new Vector2Int(targetPosition.x -1, targetPosition.z);
                break;

            case 2://南
                NextPointWall = dmanager.map.Get(targetPosition.x, targetPosition.z + 1);
                NextPointFloor = dmanager.map.Get(targetPosition.x, targetPosition.z );

                targetWall = new Vector2Int(targetPosition.x, targetPosition.z +1);
                break;

            case 3://西
                NextPointWall = dmanager.map.Get(targetPosition.x + 1, targetPosition.z);
                NextPointFloor = dmanager.map.Get(targetPosition.x , targetPosition.z);

                targetWall= new Vector2Int(targetPosition.x +1, targetPosition.z);
                break;

            default:
                NextPointWall = 9;
                NextPointFloor = 9;
                targetWall = new Vector2Int(-1,-1);
                break;   
        }

扉の先が壁という性悪なダンジョンを作ることがあるかもしれないので、壁だけではなくその先のエリアまでmap配列の情報を取得し、
それとは別に、扉を開くアクションのために必要なので、壁の座標自体も保存しておきます。

map配列の設計図ですが、いずれ他の要素も追加する事になりますが、とりあえず現時点では、0は通路・扉は98・壁は99になっています。
つまり、壁の座標のmap配列の情報(NextPointWall)が97以下なら通行可能という判定になりますので、ここで今まで通りのWalking関数に移動を指示できます。

        if(NextPointWall < 98 && NextPointFloor < 98)
        {
            Walking(
                
                        player_direction
            );
        }


次に、98(扉)の場合ですが、扉なので扉を開ける必要があります。
DungeonManager.csMap(DungeonMap.cs)objects配列変数を使って

var doorObj =dmanager.map.GetObject(targetWall.x, targetWall.y);

98(扉)の座標の位置のobjectを特定できるので、
後は、そのobjectに扉を開くアクションを盛り込んだクラスを持たせて実行するなど、色々な方法があると思いますが、今回はそういうクラスを使わず、animation機能も使わず、直接、扉自体のobjecを捕まえて回転させようと思います。

まず前提として、自分が用意した扉のprefabは、このような作りにしています。


壁部分のObjectの子として扉部分のObjectがある作りです。

つまり、先ほどのdoorObjの子Objectが実際の扉部分になります。
あとはこれを回転させてやれば良いわけです。

        else if(NextPointWall == 98 && NextPointFloor < 98) //扉         
        {
            var doorObj =dmanager.map.GetObject(targetWall.x, targetWall.y);

            if(doorObj == null)//例外発生、Objectが見つからない
            {
                Debug.Log("Object is not found");
                IsCheck = false;
                return;
            }
            else if(doorObj.Count == 1)
            {
                //ここで実際の扉のObjectを取得する。
                GameObject door = doorObj[0].transform.GetChild(0).gameObject;

                //getchild できたか確認
                if(door == null)//例外発生、扉が見つからない
                {
                    Debug.Log("Door is not found");
                    IsCheck = false;
                    return;
                }
              
               //扉を開けて移動して扉が閉まるまで待機させるのでawaitを使う。 
               await Open_Door_And_Walk(door, player_direction);
            }
        }
        else
        {
            //壁にぶつかった時のアクション

        }   

        IsCheck = false;

    }

    async UniTask Open_Door_And_Walk(GameObject door, int player_direction)
    {
        Vector3 StartAngles = door.transform.eulerAngles;
        Vector3 targetAngles = door.transform.eulerAngles - new Vector3(0, 90, 0);
        float duration = 0.6f;

        Quaternion startRotation = door.transform.rotation;
        Quaternion endRotation = Quaternion.Euler(targetAngles);

        float time = 0.0f;

        while (time < duration)
        {
            door.transform.rotation = Quaternion.Slerp(startRotation, endRotation, time / duration);
            time += Time.deltaTime;
            await UniTask.Yield();
        }

        door.transform.rotation = endRotation;

                    //扉が開いたので移動させる。
                    Walking(
                         player_direction
            );

        //移動時間の待機(いずれ移動速度から待機する時間を計算するようにする。)
        await UniTask.Delay(800);

        startRotation = door.transform.rotation;
        endRotation = Quaternion.Euler(StartAngles);

        time = 0.0f;

        while (time < duration)
        {
            door.transform.rotation = Quaternion.Slerp(startRotation, endRotation, time / duration);
            time += Time.deltaTime;
            await UniTask.Yield();
        }

        door.transform.rotation = endRotation;
    }


さて、これで移動関係はほぼ一段落できたので、次は、いよいよ戦闘システムを作っていこうと思います。








ウィザードリィライクのダンジョンゲームを作る!(5):ステートパターン導入



前回で、ダンジョンの描画範囲の仕組みまで作成できたけれど、まだ壁や扉お構いなしにどんどん貫通して歩けてしまう🤣
ので、そろそろこれを解決しておこうと思いましたが、
まずはPlayerController-classでしか使っていなかったInputSystemを、今後のためにManagerを作って独立させるのと、
ついでに、習ったばかりのStateパターンを、PlayerController-classに組み込んでおこうと思います。

今後、戦闘やらなんやらで、InputSystemはあちこちのclassで使うようになりますし、PlayerController-classDungeonManager-classもずっと待機状態になることがあります。
この待機状態の時、enumなどでStateを示す変数を作って、update関数switch分岐させて待機しても良いのですが(今までは、そうしていました。)
せっかくなので、習ったばかりのStateパターンを実践していこうと思います。

まずは、InputSystemInputManagerにサクッと移して、以降は、キー入力を利用したいときは、他の各種ManagerからこのInputManager経由で利用させるつもりです。

using System;
using UnityEngine;

public class InputManager : MonoBehaviour
{
    public float Interval_Span = 0.2f;
    public bool IsWait = false;
    float Interval_time = 0;
    [NonSerialized] public bool IsPush = false;
    [NonSerialized] public bool IsPush2 = false;
    [NonSerialized] public Vector2 LeftDirction;

    public InputSystem_Actions ISystem; //InputSystem用

    private void Awake() 
    {
        ISystem = new InputSystem_Actions();
        ISystem.Enable();    
    }

    void Update()
    {
        LeftDirction=ISystem.Player.Move.ReadValue<Vector2>();
        IsPush = ISystem.Player.Attack.triggered;
        IsPush2 = ISystem.Player.Cancel.triggered;

        if(IsWait)
        {
            Interval_time += Time.deltaTime;
            if(Interval_time > Interval_Span)
            {
                IsWait = false;
                Interval_time = 0;
            }
        }
    }
}


これは、正直、やる必要があるのかは良く分かりません🤣

今回のゲームはかなりの数のclassを作成して、相互に連携させる必要があるので、複雑化させているだけかもしれません😅
ただ、自分はこちらの方がしっくりきたので、こうしています。

次に、Stateパターンの基本になるStateBaseという、ほぼ何もないマスターファイルを作成します。

using UnityEngine;

public class MasterStateBase
{
    protected PlayerController Owner;

    public MasterStateBase(PlayerController owner)
    {
        Owner = owner;
    }

    public virtual void OnEnter()
    {
    }

    public virtual void OnUpdate()
    {
    }

    public virtual void OnExit()
    {
    }
}


これを継承して、各State-classを作成します。
今後、とりあえず必要になるのは、歩いている状態と、戦闘中の状態の2つなので、このStateファイルを作成します。

using UnityEngine;

public class MasterState_Walk : MasterStateBase
{
    public MasterState_Walk(PlayerController owner) : base(owner)
    {
    }

    public override void OnEnter()
    {

    }

    public override void OnUpdate()
    {
        if(!Owner.IsRotate && !Owner.IsWalk)
        {
            Owner.Rotation(Owner.imanager.LeftDirction.x);

            if(!Owner.IsCheck)
            {
                Owner.Walk(Owner.imanager.LeftDirction.y);
            }
        }
    }

    public override void OnExit()
    {

    }

}


OnUpdate()はこのState状態の時のUpdateで実行され続ける処理になり、今までPlayerController-classのUpdate関数にあった処理が、こちらに移植されています。

(Owner.imanager.LeftDirction.x);

ここで、先ほどのInputManagerへの参照のimagaer経由でキーボード入力にアクセスしています。

次は戦闘中のStateですが、正直、ここは現時点では待機しているだけなので、このようなすっからかんのままで十分です。

using UnityEngine;

public class MasterState_Battle : MasterStateBase
{
    public MasterState_Battle(PlayerController owner) : base(owner)
    {

    }

    public override void OnEnter()
    {

    }

    public override void OnUpdate()
    {

    }

    public override void OnExit()
    {

    }
}


これで各Stateファイルは作成できたので、これをPlayerController-classに組み込んで、使用できるようにしていきます。

public class PlayerController : MonoBehaviour
{
    public ParameterManager pmanager; //ParameterManager用
    public DungeonManager dmanager; //DungeonManager用
    public InputManager imanager; //InputManager用

    public MasterState_Walk walkState;//通常時のState
    public MasterState_Battle battleState;//戦闘中State
    public MasterStateBase currentState;//現在のSTateを管理

    private void Awake() 
    {
        //各Stateファイルをインスタンス化し、OwnerにPlayerControllerを設定する。
        walkState = new MasterState_Walk(this);
        battleState = new MasterState_Battle(this);
    }


    private void Start() 
    {
        //初期Stateの設定
        currentState = walkState;
    }

    void Update()
    {
        if (currentState != null)
        {
            currentState.OnUpdate();
        }
    }

    public void ChangeState(MasterStateBase newState)
    {
        if (currentState != null)
        {
            currentState.OnExit();
        }

        currentState = newState;
        currentState.OnEnter();
    }

これで、walk-Stateの時は以前と同じUpdateがMasterState_WalkOnUpdateとして実行され、
battle-Stateの時は空のOnUpdateが実行される状態にすることができました。

次回は今度こそ、壁と扉の判定まで進みたいと思います😅