見出し画像

【プロジェクト:Neith】戦闘マップ作成、ユニット移動処理〜みんなで作るゲーム制作【4/28(火)12日目】

■今回やった事

今回は以下の処理を作り込みました!

  • 3Dマップ上へのキャラクター表示

  • アイテムリストの作成

  • マップを自動生成するスクリプトの実装

  • キャラクターを移動する仕組みを実装

  • WebGLでビルド実行確認

■キャラクター移動動画

■WebGLビルド確認



■募集中のアイディア

①「古代エジプト編」で発生しそうなイベント
(例:ピラミッドでの過酷な修行、ナイル川での予期せぬハプニング など)
※期限はありません!皆さんの自由でユニークな発想をお待ちしています。

② 主人公・ネイトの「スキル」(※もう少し延長します!)
(例:ファイアボール / 射程:2マス先の一体 / 効果:対象に小ダメージ)
※王道からちょっと笑えるネタスキルまで、
どんなアイディアでも大歓迎です!


こんにちは、まる。です!


前回は念願だった編成画面の作成が完了し、
今回からはいよいよ、本格的にシミュレーションRPGの「バトルシステム」部分の開発を進めていきます!


1ヶ月という限られた時間の中で、
一からSRPGのバトルシステムを構築する……かなり途方もない挑戦ではあります。


でも実は、裏での仕込み(Claudeによるコーディング支援)はすでに殆ど完了していたり…😏


(ただ、ファイル数が多すぎるのと、
システム全容が複雑すぎて、私自身まだ完璧には理解できていません😓)



今回は、実装を進めながらその「秘伝のシステム」を解剖していきます。

果たして、ネイトと共にこの難解なシステムを攻略することはできるのか……?


ドキドキの開発12日目、スタートです!


二人の茶番

🎧今日の作業用BGM

Tipsy_Dance/作(編)曲 :ヒオ
【民族音楽風/酒場/踊り】Tipsy Dance【フリーBGM】


ポマイラ(自称ネイトの使い魔)

「よお、おまえら!オレだよ、ポマイラ様だ!

ところで、おまえらは今行ってみたい場所とかあるか?

オレ様はな、『福岡』に行ってみたいと思ってるぞ!

何でも、飯が死ぬほど旨いらしいじゃねぇか!

あと、旅行に行ったダチから聞いた話だと、
海の上に鎮座している鳥居がある『櫻井神社の宇良宮(うらみや)』っていう場所があるらしくて、そこがすげぇ幻想的で気になってるんだよな!」


ネイト(幻の星の精霊)

「あら、凄く素敵じゃない!
私も福岡について調べてみたけれど、綺麗な場所が多いわね。
『キャナルシティ博多』とか、見ているだけでワクワクしてきちゃうわ!」


ポマイラ(自称ネイトの使い魔)

「だろ!? ちなみに、ネイトは地元でお気に入りの場所とかあるのか?」

ネイト(幻の星の精霊)

「私の地元って言ったら『宇宙』の話になっちゃうから何とも言えないけど…… まぁ、地球に滞在している時に見つけたお気に入りの場所ならいくつかあるわ!

例えば、歩いて30分くらいの場所に小島に浮かんだ神社があるの。
何かあった時は、気分転換にそこへおさんぽに行ったりしているわね」

ネイト(幻の星の精霊)

「あとは、ふた駅先くらいに海が見える地元の温泉があるのよ!
他にも色々あるけれど……これ以上詳細を話すと、
私の居場所がバレちゃいそうだから、この辺にしておくわね!」


今日やること

ポマイラ(自称ネイトの使い魔)

「それで、今日は何をやるんだ?」

ネイト(幻の星の精霊)

「今日はね、いよいよ『戦闘システム』に挑戦していくわ!」

ネイト(幻の星の精霊)

「ただ、その前にちょっとブラッシュアップしたいことがいくつかあるからそれがまとめて終わったら…ね!」

ポマイラ(自称ネイトの使い魔)

「ついにか!
おいおい、早くやっちまおうぜ! オレ様、待ちきれねぇよ!」

ネイト(幻の星の精霊)

「慌てないの。

……とは言え、実は戦闘システムの仕組み自体は、
裏でClaudeさんに毎日構築してもらっていたから、あとは動かすだけなのよ。
でも、私自身どういう仕組みなのかまだまるで分かってないから……今日は“触り”だけ実装して、本格的に反映させていくのは次回以降にするわね!」


体力バーの作成(21:00-21:15)

ネイト(幻の星の精霊)

「実は、裏でUIやシステムの細かいブラッシュアップは進めていたの。
例えば、パートによって拠点の背景を変えたり、やる気の表示方法を変えたり、私が現在の調子によってコメントしたり…ね」

ネイト(幻の星の精霊)

「記事にするほどの内容でもないのと、こればっかりはゆっくり調整しないといけないからちょっと触らせてもらってたのだけど、
その一つに体力バーに対応するタスクがあったの。
だから、折角ならこれはどういう仕組みで実装してるかをみなさんにお見せするわね!」


体力バーUIの作成

ネイト(幻の星の精霊)

「事前に、体力バー用のUIとして3種類の画像を用意しているわ。

体力の減少(背景)を表す『赤いゲージ』

体力に余裕がある時に表示する『緑のゲージ』

体力が少なくなってきた時に表示する『黄色のゲージ』

の三つね」

ネイト(幻の星の精霊)

「ベースとなる赤いゲージの上にかぶせるような形で、現在の体力を表すゲージ(今回は緑色)を配置するわ。
そして、このゲージが値に応じて横に伸縮するように、
Imageコンポーネントを次のように設定するの」

【設定項目】

  • 画像タイプ: 塗りつぶし

  • 塗りつぶす方法: 水平

  • 塗りつぶす起点: 左

ポマイラ(自称ネイトの使い魔)

「この設定にはどういう意味があるんだ?」

ネイト(幻の星の精霊)

「設定項目の中に『塗りつぶす量』というスライダーがあるでしょ?

これは、最大値の『1』を100%として、
小数点の値に応じた割合で、画像を左から表示させる機能なの。

例えば、ここに『0.5』という値を入れれば、
体力ゲージが左から半分だけ塗りつぶされた状態になるわ」

ポマイラ(自称ネイトの使い魔)

「なるほど!
つまり、この『塗りつぶす量』の値が、そのまま現在の体力の割合になるんだな!」


■ 現在体力とスライダーの連携

ネイト(幻の星の精霊)

「あとは、ゲーム中の体力の値に合わせてスライダーが連動する仕組みを作るの。
こんな感じで、生成AIにスクリプトを作ってもらったわ」

// -------------------------------------------------------
// Inspector設定 の箇所に追加
// -------------------------------------------------------
[Header("パラメーター表示テキスト")]
[SerializeField] private TMP_Text genkiText;
// ... (中略) ...

[Header("パラメーター表示バー")]
[SerializeField] private Image genkiBarImage;       // 前面のバー(Image_GenkiBar_Frontをアサイン)
[SerializeField] private Sprite genkiGreenSprite;   // 緑ゲージの画像素材をアサイン
[SerializeField] private Sprite genkiYellowSprite;  // 黄色ゲージの画像素材をアサイン


// -------------------------------------------------------
// RefreshParams メソッドを修正
// -------------------------------------------------------
public void RefreshParams(TrainingMenuData data)
{
    if (genkiText != null) genkiText.text = $"{data.genki}";

    // ★ ゲージの長さと色の切り替え処理
    if (genkiBarImage != null)
    {
        // 最大値100として割合を計算
        genkiBarImage.fillAmount = (float)data.genki / 100f;

        // 50未満なら黄色、50以上なら緑に画像を差し替える
        if (data.genki < 50)
        {
            genkiBarImage.sprite = genkiYellowSprite;
        }
        else
        {
            genkiBarImage.sprite = genkiGreenSprite;
        }
    }

    if (yarukiText != null) yarukiText.text = $"{data.yaruki}";
    if (turnRemainText != null) turnRemainText.text = $"{data.turnRemain}";
    // ... (以下既存のコード) ...
}
ポマイラ(自称ネイトの使い魔)

「コメントを見る感じ、体力が『50未満』になると、
予め設定しておいた黄色のゲージに差し替えて表示するようにしてるんだな」


ネイト(幻の星の精霊)

「ええ、その通りよ。 ここで一番のポイントになるのが、 genkiBarImage.fillAmount = (float)data.genki / 100f; という部分ね。

体力バーのプロパティにある『fillAmount』というのが、
さっき設定した『塗りつぶす量(割合)』をプログラムから操作するための項目よ。

割合は『0.0〜1.0』の小数で指定する必要があるから、
現在の体力を扱う変数を『float(浮動小数点数)型』にして、
それを100で割ることで体力の割合を計算しているの」

ポマイラ(自称ネイトの使い魔)

「なるほどな!
UIの塗りつぶし(Fill)をスクリプトから操作する時は、
小数(Float)を『fillAmount』に代入すればいいのか。 覚えたぜ!」

ネイト(幻の星の精霊)

「それじゃあ、実際に動かしてみるわね」

ネイト(幻の星の精霊)

「やった!成功だわ!」

ポマイラ(自称ネイトの使い魔)

「こりゃ、一気にゲームらしくなったな!
やったな!ネイト!」


アイテムのリストを作成する(21:15-21:30)

ネイト(幻の星の精霊)

「次に、アイテムの実装準備に取り掛かるわ。
とは言っても、システム部分はまだ作成中だから、
今回は『宴』側に持たせるデータだけを先に用意する形になるわね」

■ParamTbl(宴3)

ポマイラ(自称ネイトの使い魔)

「ん? いつもの『宴』の変数を設定する画面とは違うな」

ネイト(幻の星の精霊)

「ご名答!
これは『ParamTbl(パラメータテーブル)』という、変数をテーブル状(表形式)で管理する機能よ!」

ネイト(幻の星の精霊)

「通常の単一変数だと、一つの変数につき一つの値しか入れることはできないわよね。

でもこれを使えば、一つのアイテムに対して『名前』『効果の種類』『価格』など、
色んな型の値をまとめて持たせることができるの」


ポマイラ(自称ネイトの使い魔)

「なるほど。 プログラミングで言うところの『構造体』みたいなもんか」

ネイト(幻の星の精霊)

「ええ、使い方としてはまさにそんな感じね!」

ネイト(幻の星の精霊)

「最初はね、アルカステラ(キャラクター)のデータもこんな感じで管理しようと思ってたの。
でも、大量のデータをゲーム中に動的に変化させていくにはあまり効率的じゃない気がして……
だから今は、外部のJsonファイルで管理する方法に落ち着いたのよ」

ポマイラ(自称ネイトの使い魔)

「なるほどな!
今回の『アイテム』みたいに、ゲーム中に効果がコロコロ変わったりしない固定データなら、これで安全に読み込みに専念できるってわけか!
お前、結構ちゃんと考えてるじゃねぇか!」


WebGLでビルド確認(21:30-01:30)

ネイト(幻の星の精霊)

「…ちょっとここからは時間外の作業をさせて欲しいのだけどいいかしら?」

ポマイラ(自称ネイトの使い魔)

「残業ってことか? 何をするんだ?」

ネイト(幻の星の精霊)

「私たちのシステム、結構大きくなってきたじゃない?
だからね、実際に『unityroom』への投稿を想定したWeb環境で、ちゃんと動くのかテストしておきたいのよ」

ポマイラ(自称ネイトの使い魔)

「なるほど!
で、わざわざ時間外の相談をするってことは、かなり時間がかかるのか?」

ネイト(幻の星の精霊)

「……かなりね。
まず『ビルド』と言って、ゲームを実際に遊べる形に変換する作業自体にすごく時間が掛かるの」

ネイト(幻の星の精霊)

「それに……私自身、このUnityのビルド作業にはトラウマがあるのよ。
3カ月かけて作ったプロジェクトがビルドを通らなくて一から作り直しになったり、
いざ作り直したら今度はUIのレイアウトが完全に破綻していたり……。
とにかく、Unityでのビルドにはいい思い出がないの」

ポマイラ(自称ネイトの使い魔)

「そ、そりゃヤバそうだな……。今回は行けそうなのか?」

ネイト(幻の星の精霊)

「正直、分からないわ。
とは言え、ここまで作ってきたデータはローカル(自分のPC環境)の特定ファイルに依存するような仕組みにはしていないから、
理論上は問題ないはずなんだけど……。
こればっかりは、実際に試してみないと分からないわね」


ネイト(幻の星の精霊)

「ビルドのやり方についてだけど、まずメニューの『ファイル』から『ビルド設定(Build Settings)』を開くわ」

ネイト(幻の星の精霊)

「unityroomはブラウザ(HTML)上で動かすから、プラットフォームの項目で『WebGL』を選択するの」

ネイト(幻の星の精霊)

「あとは、『ビルドして実行』を押して祈りながら待つだけよ。
……上手くいって! お願い……!」


~~10分後~~

ポマイラ(自称ネイトの使い魔)

「…結構時間かかってんな」

ネイト(幻の星の精霊)

「そうね。
タスクマネージャーを見る限り問題はなさそうだけど…」


ネイト(幻の星の精霊)

「……あ! ビルド通ったわ! 第一関門は突破よ!」


ポマイラ(自称ネイトの使い魔)

「お、おい! 文字が表示されてねぇぞ!?」

ネイト(幻の星の精霊)

「あ、慌てないの。
とにかく、文字は見えないけれどゲームをスタートさせてみるわ」


ポマイラ(自称ネイトの使い魔)

「なんか、背景の後ろに『宴』の画面がちらっと見えちまってるな……」

ネイト(幻の星の精霊)

「Canvas(UIの画面)はフルスクリーンまで拡大するように設定したのだけど、
どうやらブラウザのアスペクト比の関係で、想定した通りには表示されていないみたいね」

ネイト(幻の星の精霊)

「とはいえ!
文字が見えない原因は何となくわかったわ」

ポマイラ(自称ネイトの使い魔)

「本当か? なんで分かったんだ?」

ネイト(幻の星の精霊)

「自作した『メニュー内のテキスト』はちゃんと表示されているでしょ?
これは、私がUIに使っているフォント自体の設定には問題がないことを意味しているの」

ネイト(幻の星の精霊)

「でも、『宴』側でデフォルトで設定されているテキストは文字が消えてしまっている。
つまり、宴で使用しているフォントを、私が作ったフォントアセットに全て差し替えれば解決するはずよ」

ポマイラ(自称ネイトの使い魔)

「なるほどな!
原因が特定できたなら安心だ。 他に不具合はありそうか?」

ネイト(幻の星の精霊)

「私たちがこれまで作ってきたシステム自体は問題なく動いているみたいね。

ただ、トランジション(シーンチェンジ)で使っているmp4の動画ファイルが再生されないわね……。
WebGL環境での動画再生は少し癖があるみたいだから、これは今度調べてみましょう」

ネイト(幻の星の精霊)

「さて、原因は分かったけれど……これを全部直すのは骨が折れるわね。
一個ずつ、『宴』のシステムで使用されているテキストのフォントを差し替える地道な作業に戻るわ……」


ポマイラ(自称ネイトの使い魔)

「え、こんな大量のテキストを全部手作業で直していくのか!?」

ネイト(幻の星の精霊)

「まぁね……。
さぁ、一個ずつしらみつぶしに修正して、再度ビルドするわよ!」


…


ポマイラ(自称ネイトの使い魔)

「おいネイト! まだ、表示が直ってないやつがあるぞ!」

ネイト(幻の星の精霊)

「本当ね……。
うーん、シーン内のオブジェクトは全て直したはずなんだけど、何がいけないのかしら……」


…


ネイト(幻の星の精霊)

「分かったわ! メッセージウィンドウ内のテキストは、
『プレハブ(Prefab)』からオブジェクトを自動生成する仕組みになっているのね!
プレハブ内のテキスト設定も差し替えて……これでどうかしら?」


…


ポマイラ(自称ネイトの使い魔)

「……まだ、一部テキストが表示されない箇所があるな」

ネイト(幻の星の精霊)

「うーん……ゲーム実行中に『ヒエラルキー(Hierarchy)』を確認すれば原因が特定できそうだけど……さすがに疲れたわね。
とりあえず、今日のビルドの成果はこれくらいにして、もう寝ましょうか……」

■WebGLビルド確認



■ 戦闘マップ作成(翌朝 06:00)

ネイト(幻の星の精霊)

「さて! 気を取り直して、ここからは『戦闘システム』を作っていくわよ」

ポマイラ(自称ネイトの使い魔)

「昨晩はマジで大変だったな……。
てか、寝て起きてすぐこの勢いで作っていくのか!?」

ネイト(幻の星の精霊)

「元々、その予定だったしね。
さぁ、まずはバトルの舞台となる『マップ』を作っていくわ!」


新しいシーンの作成と設定(6:00-6:20)

【Unityでのセットアップ手順】

  1. Sceneの作成: Projectウィンドウで右クリック > Create > Scene を選択し、名前を『BattleScene』にします。

  2. 3D環境の準備: Main Cameraの Projection を Orthographic(平行投影)に変更し、Rotation(角度)を (30, 45, 0) に設定します。見下ろし型のシミュレーションRPGっぽい視点になります!

  3. システムの配置: シーン内に『BattleSystem』という名前の空オブジェクトを作り、以下の3つのコンポーネントをアタッチします。

    • BattleManager.cs

    • MapVisualizer(※これから作成するマップ表示用クラス)

    • BattleViewManager(※作成予定の統括クラス)

ポマイラ(自称ネイトの使い魔)

「この『BattleManager.cs』ってのはなんだ?」

ネイト(幻の星の精霊)

「バトル全般の処理を管理しているクラスね。
例えば、バトル開始時の準備、フェーズの切り替わり、
ユニットの回復やダメージ処理、勝敗の判定なんかを管理しているの。
いわばTRPGの『ゲームマスター』のような立ち位置ね」

ポマイラ(自称ネイトの使い魔)

「なるほどな。
どんな風になってるんだ? コードは見せてくれないのか?」

ネイト(幻の星の精霊)

「別に見せても構わないのだけど、
このマネージャークラスの裏には凄く多くのシステムが連携しているから、単体で見てもあまり参考にならないと思うわ。

まぁ、『バトル全体のシステムを統括する親玉を作る必要がある』ってことだけ分かってもらえたら嬉しいわ!」

ネイト(幻の星の精霊)

「そして『MapVisualizer』は、今回大活躍するスクリプトだから後で詳しく解説するわね!
まずは、地面の作成からやっていくわ」

ネイト(幻の星の精霊)

「最初は、パネル1枚当たりとなる『床のプレハブ』を作っていくの。
今回は、以前ご紹介いただいたマップ用アセットから地面のマテリアルをお借りして、平面(Plane)のオブジェクトにアタッチして作成したわ」

ネイト(幻の星の精霊)

「プレハブを作ったら、全自動マップ生成器の『MapVisualizer』にセットするの。
今回のMapVisualizerはこの後も色々改良していくから、コードの紹介は最後に回すわね!」

ネイト(幻の星の精霊)

「実行すると、こんな感じで床のプレハブを元にして『10×10』のマップが自動生成されるわ」

ポマイラ(自称ネイトの使い魔)

「おおっ! すげぇ! ここが戦いの舞台になるんだな!」




マウスホイールでカメラ移動(6:20-6:30)

ネイト(幻の星の精霊)

「次に、マウスのホイールでカメラのズームが調整できるように改良するわ。
一旦、この時点のスクリプトの全容をお見せするわね」

using UnityEngine;
using TrainingGame.Battle;

namespace TrainingGame.View
{
    public class MapVisualizer : MonoBehaviour
    {
        [Header("床のプレハブ(Scaleを1にしたPlane等)")]
        public GameObject groundPrefab;

        [Header("1マスのサイズ")]
        public float tileSize = 10.0f;

        [Header("── カメラ設定 ──")]
        [Range(0.1f, 2.0f)]
        public float cameraZoomOffset = 0.8f;

        [Range(-50f, 50f)]
        public float cameraVerticalOffset = 10f;

        [Header("── プレイヤー操作によるズーム設定 ──")]
        public bool allowPlayerZoom = true; // プレイヤーのズーム操作を許可するか
        public float zoomSpeed = 0.1f;      // 1回のスクロールでのズーム量
        public float minZoomOffset = 0.3f;  // ズームイン(近づく)の限界
        public float maxZoomOffset = 1.5f;  // ズームアウト(離れる)の限界

        // 調整用に現在のマップを保持
        private GridMap _currentMap;

        private void Start()
        {
            _currentMap = new GridMap(10, 10);
            GenerateVisualMap(_currentMap);
        }

        // プレイヤーの入力を受け取る処理
        private void Update()
        {
            if (allowPlayerZoom && _currentMap != null)
            {
                // マウスホイールのスクロール量を取得
                float scroll = Input.mouseScrollDelta.y;
                
                if (scroll != 0)
                {
                    // 上スクロール(正の値)で近づく(Offsetを小さくする)、下スクロールで離れる
                    cameraZoomOffset -= scroll * zoomSpeed;
                    
                    // 限界値を超えないように制限(Clamp)をかける
                    cameraZoomOffset = Mathf.Clamp(cameraZoomOffset, minZoomOffset, maxZoomOffset);
                }
            }
        }

        // カメラの移動は LateUpdate(Updateの直後)で行うのがカメラ制御の鉄則です
        private void LateUpdate()
        {
            if (_currentMap != null)
            {
                CenterCamera(_currentMap);
            }
        }

        public void GenerateVisualMap(GridMap map)
        {
            for (int x = 0; x < map.Width; x++)
            {
                for (int y = 0; y < map.Height; y++)
                {
                    Vector3 worldPos = new Vector3(x * tileSize, 0, y * tileSize);
                    GameObject tile = Instantiate(groundPrefab, worldPos, Quaternion.identity, this.transform);
                    tile.name = $"Tile_{x}_{y}";
                }
            }
        }

        private void CenterCamera(GridMap map)
        {
            Camera mainCam = Camera.main;
            if (mainCam == null) return;

            // 1. マップの中心座標を計算
            float centerX = (map.Width - 1) * tileSize / 2f;
            float centerZ = (map.Height - 1) * tileSize / 2f;
            Vector3 centerPos = new Vector3(centerX, 0f, centerZ);

            // 2. カメラの基本距離を計算
            float maxDimension = Mathf.Max(map.Width, map.Height) * tileSize;
            float distance = (maxDimension * cameraZoomOffset) / Mathf.Tan(mainCam.fieldOfView * 0.5f * Mathf.Deg2Rad);

            // 3. カメラを配置(中心から斜め後ろに引く)
            Vector3 basePos = centerPos - mainCam.transform.forward * distance;

            // 4. 「下が見切れる問題」を解消するための上下オフセットを加算
            mainCam.transform.position = basePos + mainCam.transform.up * cameraVerticalOffset;
        }
    }
}
ポマイラ(自称ネイトの使い魔)

「め、めちゃくちゃ長くて難しそうだぜ……」


ネイト(幻の星の精霊)

「まぁ、このスクリプト一つに『マップ生成』と『カメラ制御』の機能がまとまっているからね。
でも、マップ自動生成の“肝”となるのはこの部分よ」

for (int y = 0; y < map.Height; y++)
{
    // ①タイルの生成位置を計算
    Vector3 worldPos = new Vector3(x * tileSize, 0, y * tileSize);
    
    // ②計算した座標にプレハブを生成
    GameObject tile = Instantiate(groundPrefab, worldPos, Quaternion.identity, this.transform);
    
    // ③生成したオブジェクトの名前を変更
    tile.name = $"Tile_{x}_{y}";
}
ネイト(幻の星の精霊)

「まず、インスペクター上で設定した『tileSize(1マスのサイズ)』を元に、次に生成するタイルの座標(位置)を計算しているわ。

だから、例えばプレハブのサイズに対してモデルの全長が大きい場合は、
tileSizeの値を『2』などに増やしておくと綺麗に並んで収まりそうよね」

ネイト(幻の星の精霊)

「そして、『Instantiate』という命令で、計算した座標にオブジェクトを生成。
最後に『tile.name』で名前を書き換えることで、ヒエラルキー上で重複した名前のオブジェクトが大量発生しないように整理しているってわけ!」

ポマイラ(自称ネイトの使い魔)

「すげぇな……。 お前、もう完璧にコードを理解したのか?」

ネイト(幻の星の精霊)

「そんなんじゃないわよ。
こういうのは、全体を完璧に理解できなくても『要点』だけ押さえておけば間違いないわ。
あとは、気になる部分をその要点を起点にして、後から調べれば良いのだし」

ネイト(幻の星の精霊)

「正直、カメラの複雑な座標計算とかはすごく苦手だから、生成AIが裏で組んでくれて本当に助かっているわ!」


ユニットの表示(6:30-6:45)

ネイト(幻の星の精霊)

「次は、ユニット(キャラクター)と移動範囲を表示するための仕込みをしていくわね。
まず、ユニットの画像を常にカメラと同じ角度に向かせるためのスクリプト『UnitVisualizer』を作成するわ」

using UnityEngine;

namespace TrainingGame.View
{
    public class UnitVisualizer : MonoBehaviour
    {
        private Camera _mainCamera;

        void Start()
        {
            _mainCamera = Camera.main;
        }

        void LateUpdate()
        {
            if (_mainCamera != null)
            {
                // 常にカメラと同じ角度になるように追従する(ビルボード)
                transform.rotation = _mainCamera.transform.rotation;
            }
        }
    }
}
ポマイラ(自称ネイトの使い魔)

「おお、さっきのマップ生成のコードに比べると、かなり短くて見やすいな!」

ネイト(幻の星の精霊)

「ええ。常にカメラの動きを監視して合わせる必要があるから、
Updateの直後に呼ばれる『LateUpdate』という関数を使っているの。

やっていることはすごくシンプルで、
このスクリプトをアタッチしたユニットの角度(rotation)に、
カメラの角度を常に代入し続けているだけね」

ネイト(幻の星の精霊)

「こうすることで、2Dのキャラクター画像が常にカメラの正面を向いてくれるから、3D空間の中で“ペラペラの紙”であることを上手く隠せるのよ。
ゲーム開発の用語で『ビルボード(Billboard)』と呼ばれるテクニックね!」


戦場にユニットを表示する

ネイト(幻の星の精霊)

「次に、マップ上に配置するユニット(キャラクター)と、移動範囲を示すパネル用の2D画像を用意するわ。
ただ、今回は3D空間に配置するから、2D UIの時とは違って『ひと手間』必要なのよ」


【メニューを使わずに Sprite を作る方法】

  1. ヒエラルキー上で右クリックし、「空のオブジェクトを作成(Create Empty)」 を選びます。

  2. インスペクターの一番下にある 「コンポーネントを追加(Add Component)」 ボタンをクリックします。

  3. 検索窓に Sprite と入力し、一覧から 「Sprite Renderer」 を選んで追加します。

  4. 追加された Sprite Renderer の「スプライト(Sprite)」の項目に、キャラクターの画像をセットします。

※これだけで、本来「2Dオブジェクト > スプライト」のメニューから作られるものと完全に同じ機能を持つオブジェクトが完成します!
画像の透過処理なども自動で行ってくれます。


ネイト(幻の星の精霊)

「そして、移動範囲を表すパネルに関しては『Quad(クアッド)』と呼ばれるオブジェクトで作成するわ。

これは3D空間に『平面の板』を作成するオブジェクトなんだけど、
これに色付きのマテリアルを設定するだけで、シミュレーションRPGでおなじみの移動範囲パネルが完成するの」


ネイト(幻の星の精霊)

「ただ、そのまま生成すると立て札みたいに真っ直ぐ立っちゃうから、
地面に対して横にピタッと並べるために『Xの角度(Rotation X)』を90度に寝かせるわね」

ネイト(幻の星の精霊)

「あとは、作成したユニットとパネルをプレハブ化して、
スクリプトにテスト用のコードを書き足せば完了よ!」

[Header("── テスト配置用プレハブ ──")]
        public GameObject unitPrefab;
        public GameObject highlightPanelPrefab;

        private void Start()
        {
            _currentMap = new GridMap(10, 10);
            GenerateVisualMap(_currentMap);

            // ★ テスト配置
            // Y座標は床と重なってチラつく(Zファイティング)のを防ぐため、ほんの少し(0.1f)浮かせます
            Vector3 panelPos = new Vector3(2 * tileSize, 0.1f, 3 * tileSize);
            if (highlightPanelPrefab) Instantiate(highlightPanelPrefab, panelPos, Quaternion.identity, this.transform);

            Vector3 unitPos = new Vector3(2 * tileSize, 0.1f, 2 * tileSize);
            if (unitPrefab) Instantiate(unitPrefab, unitPos, Quaternion.identity, this.transform);
        }


テスト用コード

using UnityEngine;
using TrainingGame.Battle;

namespace TrainingGame.View
{
    public class MapVisualizer : MonoBehaviour
    {
        [Header("床のプレハブ(Scaleを1にしたPlane等)")]
        public GameObject groundPrefab;

        [Header("1マスのサイズ")]
        public float tileSize = 10.0f;

        [Header("── カメラ設定 ──")]
        [Range(0.1f, 2.0f)]
        public float cameraZoomOffset = 0.8f;

        [Range(-50f, 50f)]
        public float cameraVerticalOffset = 10f;

        [Header("── プレイヤー操作によるズーム設定 ──")]
        public bool allowPlayerZoom = true; // プレイヤーのズーム操作を許可するか
        public float zoomSpeed = 0.1f;      // 1回のスクロールでのズーム量
        public float minZoomOffset = 0.3f;  // ズームイン(近づく)の限界
        public float maxZoomOffset = 1.5f;  // ズームアウト(離れる)の限界

        // 調整用に現在のマップを保持
        private GridMap _currentMap;

        [Header("── テスト配置用プレハブ ──")]
        public GameObject unitPrefab;
        public GameObject highlightPanelPrefab;



        private void Start()
        {
            _currentMap = new GridMap(10, 10);
            GenerateVisualMap(_currentMap);

            // ★ テスト配置
            Vector3 panelPos = new Vector3(2 * tileSize, 0.1f, 3 * tileSize);
            if (highlightPanelPrefab)
            {
                // Quaternion.identity を highlightPanelPrefab.transform.rotation に変更!
                Instantiate(highlightPanelPrefab, panelPos, highlightPanelPrefab.transform.rotation, this.transform);
            }

            Vector3 unitPos = new Vector3(2 * tileSize, 0.1f, 2 * tileSize);
            if (unitPrefab)
            {
                Instantiate(unitPrefab, unitPos, unitPrefab.transform.rotation, this.transform);
            }
        }

        // プレイヤーの入力を受け取る処理
        private void Update()
        {
            if (allowPlayerZoom && _currentMap != null)
            {
                // マウスホイールのスクロール量を取得
                float scroll = Input.mouseScrollDelta.y;

                if (scroll != 0)
                {
                    // 上スクロール(正の値)で近づく(Offsetを小さくする)、下スクロールで離れる
                    cameraZoomOffset -= scroll * zoomSpeed;

                    // 限界値を超えないように制限(Clamp)をかける
                    cameraZoomOffset = Mathf.Clamp(cameraZoomOffset, minZoomOffset, maxZoomOffset);
                }
            }
        }

        // カメラの移動は LateUpdate(Updateの直後)で行うのがカメラ制御の鉄則です
        private void LateUpdate()
        {
            if (_currentMap != null)
            {
                CenterCamera(_currentMap);
            }
        }

        public void GenerateVisualMap(GridMap map)
        {
            for (int x = 0; x < map.Width; x++)
            {
                for (int y = 0; y < map.Height; y++)
                {
                    Vector3 worldPos = new Vector3(x * tileSize, 0, y * tileSize);
                    GameObject tile = Instantiate(groundPrefab, worldPos, Quaternion.identity, this.transform);
                    tile.name = $"Tile_{x}_{y}";
                }
            }
        }

        private void CenterCamera(GridMap map)
        {
            Camera mainCam = Camera.main;
            if (mainCam == null) return;

            // 1. マップの中心座標を計算
            float centerX = (map.Width - 1) * tileSize / 2f;
            float centerZ = (map.Height - 1) * tileSize / 2f;
            Vector3 centerPos = new Vector3(centerX, 0f, centerZ);

            // 2. カメラの基本距離を計算
            float maxDimension = Mathf.Max(map.Width, map.Height) * tileSize;
            float distance = (maxDimension * cameraZoomOffset) / Mathf.Tan(mainCam.fieldOfView * 0.5f * Mathf.Deg2Rad);

            // 3. カメラを配置(中心から斜め後ろに引く)
            Vector3 basePos = centerPos - mainCam.transform.forward * distance;

            // 4. 「下が見切れる問題」を解消するための上下オフセットを加算
            mainCam.transform.position = basePos + mainCam.transform.up * cameraVerticalOffset;
        }
    }
}



バトルマネージャーと連携(7:00-7:10)

ネイト(幻の星の精霊)

「さて、いよいよバトルマネージャーと連携してユニットを生成した後キャラクターを移動させることが出来る所まで実装していくわ」

ポマイラ(自称ネイトの使い魔)

「ついにか…上手く行くといいな!」


司令塔 BattleViewManager.cs の作成

ネイト(幻の星の精霊)

「次は、裏側のロジック(BattleManager)と、表側の見た目(MapVisualizer)を繋ぐ新しいスクリプトを作るわ。

これが、今後『キャラが移動する』『ダメージの数字が出る』といった画面の演出を統括する、超・重要なクラスになるの!」

using UnityEngine;
using TrainingGame.Battle;
using System.Collections.Generic;

namespace TrainingGame.View
{
    public class BattleViewManager : MonoBehaviour
    {
        [Header("システム参照")]
        public BattleManager battleManager;
        public MapVisualizer mapVisualizer;

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

        // ロジック(UnitBattleState) と 見た目(UnitVisualizer) を紐づける辞書
        private Dictionary<UnitBattleState, UnitVisualizer> _unitViews = new Dictionary<UnitBattleState, UnitVisualizer>();

        // ===================================================
        // 開発テスト用:シーンを直接再生した時に自動でバトルを組む
        // ===================================================
        private void Start()
        {
            // 10x10のマップと、空の陣営リストを作成
            var map = new GridMap(10, 10);
            var players = new List<UnitBattleState>();
            var enemies = new List<UnitBattleState>();

            // 味方(ネイト)を座標(2, 2)に配置
            var nate = new UnitBattleState(UnitBaseStats.CreateNate(), 2, 2);
            map.PlaceUnit(nate, 2, 2);
            players.Add(nate);

            // 敵(ゴブリン)を座標(7, 7)に配置
            var enemyStats = new UnitBaseStats { unitName = "ゴブリン", maxHp = 30, atk = 10, speed = 5, moveRange = 3 };
            var enemy = new UnitBattleState(enemyStats, 7, 7);
            map.PlaceUnit(enemy, 7, 7);
            enemies.Add(enemy);

            // ① 見た目(3D空間)の生成
            SetupView(map, players, enemies);

            // ② ロジック(システム)の初期化とバトル開始
            battleManager.SetupBattle(map, players, enemies);
            battleManager.StartBattle();
        }

        // ===================================================
        // ビューの生成処理
        // ===================================================
        public void SetupView(GridMap map, List<UnitBattleState> players, List<UnitBattleState> enemies)
        {
            // 1. 床を生成
            mapVisualizer.GenerateVisualMap(map);

            // 2. プレイヤーを生成(テスト用に青っぽい色にする)
            foreach (var p in players) SpawnUnit(p, Color.cyan);

            // 3. 敵を生成(テスト用に赤色にする)
            foreach (var e in enemies) SpawnUnit(e, new Color(1f, 0.5f, 0.5f));
        }

        private void SpawnUnit(UnitBattleState unit, Color tintColor)
        {
            // GridMapの (X, Y) を 3D空間の (X, 0, Z) に変換
            float tileSize = mapVisualizer.tileSize;
            Vector3 pos = new Vector3(unit.GridX * tileSize, 0.1f, unit.GridY * tileSize);

            // プレハブを生成
            GameObject obj = Instantiate(unitViewPrefab, pos, unitViewPrefab.transform.rotation, this.transform);
            var view = obj.GetComponent<UnitVisualizer>();

            // どっちの陣営か分かりやすいように色を付ける
            var spriteRenderer = obj.GetComponentInChildren<SpriteRenderer>();
            if (spriteRenderer != null) spriteRenderer.color = tintColor;

            // 辞書に登録(後で「移動」や「ダメージ」の時にキャラを探すため)
            _unitViews[unit] = view;
        }
    }
}
ポマイラ(自称ネイトの使い魔)

「なるほど、これまで作った見た目の機能と、
裏のロジック(マネージャー)の間を取り持つファイルってわけか」

ネイト(幻の星の精霊)

「そうね。 ポイントはたくさんあるのだけど……
今回は特に、『それぞれのユニットをどこに配置して、どの陣営(味方か敵か)に所属させるか』を定義している部分に注目してほしいわ!」

// 10x10のマップと、空の陣営リストを作成
            var map = new GridMap(10, 10);
            var players = new List<UnitBattleState>();
            var enemies = new List<UnitBattleState>();

            // 味方(ネイト)を座標(2, 2)に配置
            var nate = new UnitBattleState(UnitBaseStats.CreateNate(), 2, 2);
            map.PlaceUnit(nate, 2, 2);
            players.Add(nate);

            // 敵(ゴブリン)を座標(7, 7)に配置
            var enemyStats = new UnitBaseStats { unitName = "ゴブリン", maxHp = 30, atk = 10, speed = 5, moveRange = 3 };
            var enemy = new UnitBattleState(enemyStats, 7, 7);
            map.PlaceUnit(enemy, 7, 7);
            enemies.Add(enemy);
ネイト(幻の星の精霊)

「今は開発用のテストとして、コードの中に直接手打ちで味方(ネイト)と敵(ゴブリン)を設定しているけれど、
今後は前回作った編成画面でプレイヤー自身が設定した『編成情報』を読み込んで配置するように改良していく予定よ!」


エディタでのセットアップ

ネイト(幻の星の精霊)

「最後に、先ほど作った『BattleViewManager』もシーン内のシステムオブジェクトにアタッチして、
インスペクターからそれぞれのプレハブを設定していくわ」

ネイト(幻の星の精霊)

「そして、マウスクリックでキャラクターを移動できるようにスクリプトを改修して……完成よ!」

using UnityEngine;
using TrainingGame.Battle;
using System.Collections.Generic;

namespace TrainingGame.View
{
    public class BattleViewManager : MonoBehaviour
    {
        [Header("システム参照")]
        public BattleManager battleManager;
        public MapVisualizer mapVisualizer;

        [Header("プレハブ")]
        public GameObject unitViewPrefab;
        public GameObject highlightPanelPrefab; // ★追加:移動範囲用の青いパネル

        // キャッシュ用辞書・リスト
        private Dictionary<UnitBattleState, UnitVisualizer> _unitViews = new Dictionary<UnitBattleState, UnitVisualizer>();
        private List<GameObject> _activeHighlightPanels = new List<GameObject>(); // 表示中のパネル一覧

        private void OnEnable()
        {
            // BattleManagerのイベントを購読する
            if (battleManager != null)
            {
                battleManager.OnReachableTilesUpdated += DrawReachableTiles;
            }
        }

        private void OnDisable()
        {
            if (battleManager != null)
            {
                battleManager.OnReachableTilesUpdated -= DrawReachableTiles;
            }
        }

        // ===================================================
        // 開発テスト用の初期化(前回と同じ)
        // ===================================================
        private void Start()
        {
            var map = new GridMap(10, 10);
            var players = new List<UnitBattleState>();
            var enemies = new List<UnitBattleState>();

            var nate = new UnitBattleState(UnitBaseStats.CreateNate(), 2, 2);
            map.PlaceUnit(nate, 2, 2);
            players.Add(nate);

            var enemyStats = new UnitBaseStats { unitName = "ゴブリン", maxHp = 30, atk = 10, speed = 5, moveRange = 3 };
            var enemy = new UnitBattleState(enemyStats, 7, 7);
            map.PlaceUnit(enemy, 7, 7);
            enemies.Add(enemy);

            SetupView(map, players, enemies);
            battleManager.SetupBattle(map, players, enemies);
            battleManager.StartBattle();
        }

        // ===================================================
        // マウスクリック処理(移動コマンドの送信)
        // ===================================================
        private void Update()
        {
            // プレイヤーターン中のみクリックを受け付ける
            if (battleManager.Phase != BattlePhase.PlayerTurn) return;

            // 左クリックされたら
            if (Input.GetMouseButtonDown(0))
            {
                // カメラからマウスの位置に向かって透明なビーム(Ray)を撃つ
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                
                // 床(PlaneやQuad等のコライダー)に当たったか判定
                if (Physics.Raycast(ray, out RaycastHit hit))
                {
                    // 当たった3D座標から、GridMapの (X, Y) 座標を逆算する
                    float tileSize = mapVisualizer.tileSize;
                    int gridX = Mathf.RoundToInt(hit.point.x / tileSize);
                    int gridY = Mathf.RoundToInt(hit.point.z / tileSize);

                    // BattleManagerに移動をリクエストする
                    bool success = battleManager.RequestMove(gridX, gridY);
                    if (success)
                    {
                        // 移動に成功したら、見た目(3Dオブジェクト)も動かす
                        // ※後でDOTween等を使って滑らかに歩かせますが、まずは瞬間移動させます
                        var activeUnit = battleManager.ActiveUnit;
                        if (_unitViews.TryGetValue(activeUnit, out var view))
                        {
                            view.transform.position = new Vector3(gridX * tileSize, 0.1f, gridY * tileSize);
                        }
                    }
                }
            }
        }

        // ===================================================
        // 移動可能パネル(ハイライト)の描画
        // ===================================================
        private void DrawReachableTiles(HashSet<TileData> tiles)
        {
            // まず古いパネルを全部消す
            foreach (var p in _activeHighlightPanels) Destroy(p);
            _activeHighlightPanels.Clear();

            // 新しいパネルを敷き詰める(nullの場合は何もしない=パネル消去)
            if (tiles == null || highlightPanelPrefab == null) return;

            float tileSize = mapVisualizer.tileSize;
            foreach (var tile in tiles)
            {
                Vector3 pos = new Vector3(tile.X * tileSize, 0.11f, tile.Y * tileSize);
                var panel = Instantiate(highlightPanelPrefab, pos, highlightPanelPrefab.transform.rotation, this.transform);
                _activeHighlightPanels.Add(panel);
            }
        }

        // ===================================================
        // 初期ビューの生成処理(前回と同じ)
        // ===================================================
        public void SetupView(GridMap map, List<UnitBattleState> players, List<UnitBattleState> enemies)
        {
            mapVisualizer.GenerateVisualMap(map);
            foreach (var p in players) SpawnUnit(p, Color.cyan);
            foreach (var e in enemies) SpawnUnit(e, new Color(1f, 0.5f, 0.5f));
        }

        private void SpawnUnit(UnitBattleState unit, Color tintColor)
        {
            float tileSize = mapVisualizer.tileSize;
            Vector3 pos = new Vector3(unit.GridX * tileSize, 0.1f, unit.GridY * tileSize);
            GameObject obj = Instantiate(unitViewPrefab, pos, unitViewPrefab.transform.rotation, this.transform);
            var view = obj.GetComponent<UnitVisualizer>();
            var spriteRenderer = obj.GetComponentInChildren<SpriteRenderer>();
            if (spriteRenderer != null) spriteRenderer.color = tintColor;
            _unitViews[unit] = view;
        }
    }
}

■キャラクター移動動画

ポマイラ(自称ネイトの使い魔)

「おお!ネイトが動いたぜ!」

ネイト(幻の星の精霊)

「なんとか形になってよかったわ。
個人的にずっと気になっていたのは、『3D空間でどうやって特定のオブジェクトをクリックしたかを判定するのか』って部分だったのだけど……」

// カメラからマウスの位置に向かって透明なビーム(Ray)を撃つ
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                
                // 床(PlaneやQuad等のコライダー)に当たったか判定
                if (Physics.Raycast(ray, out RaycastHit hit))
                {
                    // 当たった3D座標から、GridMapの (X, Y) 座標を逆算する
                    float tileSize = mapVisualizer.tileSize;
                    int gridX = Mathf.RoundToInt(hit.point.x / tileSize);
                    int gridY = Mathf.RoundToInt(hit.point.z / tileSize);
ネイト(幻の星の精霊)

「なるほど……。
マウスでクリックした座標に向かって『直線のビーム』を放って、
最初に当たったオブジェクトを指定するような作りになっているのね。
『Raycast(レイキャスト)』っていう機能みたいだけど……すごく賢いわね」

ポマイラ(自称ネイトの使い魔)

「本当、生成AIが発展してから、
やりたいことを実現するだけじゃなくて、色んなプログラムの勉強にも繋がるな!」

ネイト(幻の星の精霊)

「そうね!
開発のスピードも上がるし、
プロが書いたような実践的なコードを見て自分の勉強にもなるし……個人開発者にとってはいいことずくめだわ!」


最後に

かくして、無事に戦闘マップの作成に成功し、
「マップ上でのユニット移動」というシミュレーションRPGにおける快挙を成し遂げたネイト。


苦戦しながらも乗り越えたUnityでのWebGLのビルド体験もまた、
彼女の今後のゲーム開発における大きな自信へと繋がったはずだ。


とはいえ、本格的なシステム開発はまだ始まったばかり。


先人達の叡智の結晶たる「生成AI」を強力な相棒に、
ネイトはこの先待ち受ける数々の難解なシステムをどう攻略していくのか……?

次回も、どうぞ乞うご期待!

#プロジェクトNeith #HorizonNotes #ゲーム制作 #個人開発 #Unity #開発日記 #読者参加型 #毎日note

いいなと思ったら応援しよう!

まる。 よろしければ応援お願いします! いただいたチップはクリエイターとしてのゲーム開発に使わせていただきます! ※チップと共にやって欲しい事があったら言ってください! 可能な範囲であれば頑張ります