メインコンテンツへスキップ
見出し画像

【プロジェクト:Neith】戦闘ロジック作成〜みんなで作るゲーム制作【5/1-5/2 14,15日目】

    ■今回やった事

    5/1

    ・移動処理を実装


    ■攻撃処理実装動画

    ■アイテム使用

    5/2

    ・セーブ周りのバグを修正

    ■募集中のアイディア
    ①「樹海編」で発生しそうなイベント
    (例:森の中、クマさんに出会った。お化けと遭遇 など)
    ※期限はありません!皆さんの自由でユニークな発想をお待ちしています。

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

    ③ゲームで流す著作権フリーのBGMを教えてください
    無料のフリーのBGMを探してます!
    あなたのお気に入りを教えてください!
    (生成AIで自作したものも可能。
    その場合は、コメントやDMでお知らせください)



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


    今回のネイトちゃんは、
    戦闘システムの構築と現在発生しているバグを改修したようです。


    徐々にゲーム全体の輪郭が見え始めてきた印象があります😎


    かなり、長期戦だったので今回の記事はほぼダイジェストになりますが…どうか応援して頂けると嬉しいです!


    それでは、激闘の開発14、15日目…スタートです!


    二人の茶番

    🎧今日の作業用BGM

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

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

    「よお、おまえら!オレだよ、ポマイラ様だ!
    おまえらが好きなお菓子って何かあるか?
    オレ様は、ブラックサンダーと最近では湖池屋のスコーンばかり食ってるぞ!
    あれ、辞めらんねぇんだよな!」

    画像
    ネイト(幻の星の精霊)

    「あら、良いじゃない。
    ちなみに、私はどら焼きと温かいお茶の組み合わせが好きよ!
    やっぱり、お茶に合う和菓子は最高よね」

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

    「おいおい、ネイト。
    お前はおばあちゃんか?
    お菓子は味が濃くてパンチの効いたものこそ至高だろ」

    画像
    ネイト(幻の星の精霊)

    「おば…失礼ね!
    今、画面の向こうで多くの人を敵に回したわよ!
    それに和菓子に年齢は関係ないでしょ」

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

    「まぁ、確かに和菓子も旨いっちゃ旨いけどな!
    糖分をしっかり取って、頭働かせて行こうぜ!」


    戦闘システム:移動、攻撃、アイテム、バトル終了処理(8時間くらい?)

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

    「それで、今日も作業の振り返りか?」

    画像
    ネイト(幻の星の精霊)

    「そうね。
    昨日も結構ガッツリと作業に集中させてもらったから、サクサクと流れを解説していく感じにしていくわね」

    画像
    ネイト(幻の星の精霊)

    「5/1(金)…開発14日目に進めた内容はこんな感じよ」

    ・移動処理を実装


    ■攻撃処理実装動画

    ■アイテム使用確認動画


    画像

    「おお!
    戦闘システム、結構ガッツリ出来て来てるじゃねぇか!」

    画像
    ネイト(幻の星の精霊)

    「まぁ、この日に照準を合わせてシステム自体は準備してきたからね。
    あとはそれを反映させる感じだったから、実装自体はすんなりいったわ」

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

    「なるほどな。
    でも、結構時間かかったんだろ?」

    画像
    ネイト(幻の星の精霊)

    「そうね。
    まぁ、殆どがアイテムの使用に関する処理で躓いた感じね」

    画像
    ネイト(幻の星の精霊)

    「でも!今はご覧の通り問題なく動くようになったわ!」

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

    「苦労した甲斐があったよな!
    そんじゃ、簡単にどう作ったのか教えてくれよ!」


    コマンドパネルの実装

    画像
    ネイト(幻の星の精霊)

    「まずは、プレイヤーが選択するコマンドや、
    キャラクターたちの体力・行動順を表す全体的なUIを作成していくわ。
    こんな感じで作ってみたの」


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

    「UI自体はシンプルなんだな。 何かこだわった点とかはあるのか?」

    画像
    ネイト(幻の星の精霊)

    「そうね…出来るだけ画面中央部にUIを置かないようにしたことかしら。
    コマンドパネルについてはちょっと大きすぎる気もするけど、
    誤操作が起こらないように、お試しで大きめに作ってみたの」

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

    「なるほどな。 ちなみに、今の時点で見えてる課題とかはあるのか?」

    画像
    ネイト(幻の星の精霊)

    「まず、コマンドの数が多い事ね。
    これが画面に圧迫感を与えてしまって、プレイヤーに与える情報量も多くなっているわ。

    『アストラルコード』というのが必殺技なんだけど、
    これは使えるタイミングが限られているのよね。

    だから、常に表示しておくのではなく、必要な場面でのみアイコンとして表示しても良いかも知れないわ。
    このあたりは、アストラルコードを実装する時にまた改めて方針を考えるわ」


    移動処理

    ■移動処理の動画

    画像
    ネイト(幻の星の精霊)

    「次に、移動の処理ね!
    これはもう、テスト段階で動くことが確認できていたものだから、今回は少しだけ演出面をアレンジすることに留めておいたわ!」

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

    「まぁ、実は一週間前くらいから裏でコツコツ作ってたもんな!
    その『演出面』ってのはどんな感じで向上したんだ?」

    画像
    ネイト(幻の星の精霊)

    「主に、カメラワークね。ポイントは二つよ。

    一つは、マウスホイールでのズーム処理の他に、
    右クリックを押しながらマウスを動かすことでカメラを360度回転できるようにした事。
    もう一つが、キャラクターの移動後に、カメラが自動でそのキャラクターを追従するようにした事よ」

    画像
    ネイト(幻の星の精霊)

    「個人的に、シミュレーションRPGはカメラワークが命だと思っているの!

    だって、広大な戦場を俯瞰して戦術を組み立てるのが面白い要素でしょ?
    だったら、プレイヤーには戦場全体を見渡せるように工夫したかったのよ」

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

    「なるほどな!
    ちなみに、この移動処理はどういう感じで作られてるんだ?」

    画像
    ネイト(幻の星の精霊)

    「結構、難しいわよ?
    まず、『PathFinder(パスファインダー)』というプログラムが、移動できる範囲を計算しているの」

    // PathFinder.cs
    // 移動範囲の計算(BFS)と経路探索
    // GridMap を参照して「どこに移動できるか」「どう移動するか」を計算する。
    // ステートを持たない静的クラスとして実装し、キャッシュは外部で持つ設計。
    
    using System.Collections.Generic;
    using UnityEngine;
    
    namespace TrainingGame.Battle
    {
        /// <summary>
        /// BFS(幅優先探索)を用いた移動範囲・経路計算クラス。
        ///
        /// 【設計方針】
        ///   - ステートなしの静的クラス。毎ターン呼び出して結果を使い捨てる。
        ///   - 移動力(moveRange)分のコストまで到達できるタイルをすべて返す。
        ///   - 敵ユニットのタイルは「通過不可」として扱う(友軍は通過可・停止不可)。
        /// </summary>
        public static class PathFinder
        {
            // ================================================================
            //  移動範囲の計算(BFS)
            // ================================================================
    
            /// <summary>
            /// 指定ユニットの移動可能タイル一覧を返す。
            ///
            /// 【アルゴリズム: BFS + コスト管理】
            ///   各タイルへの最小コストを記録しながら幅優先で探索。
            ///   移動コストが moveRange を超えるタイルは探索を打ち切る。
            /// </summary>
            /// <param name="map">グリッドマップ</param>
            /// <param name="unit">移動するユニット</param>
            /// <param name="allies">同じ陣営のユニット一覧(通過可・停止不可)</param>
            /// <param name="enemies">敵陣営のユニット一覧(通過不可)</param>
            /// <returns>移動可能なタイルのセット(出発地点を含む)</returns>
            public static HashSet<TileData> GetReachableTiles(
                GridMap map,
                UnitBattleState unit,
                IEnumerable<UnitBattleState> allies,
                IEnumerable<UnitBattleState> enemies)
            {
                int moveRange = unit.BaseStats.moveRange;
    
                // 敵・味方のタイルセットを事前に構築
                var enemyTiles = BuildTileSet(map, enemies);
                var allyTiles  = BuildTileSet(map, allies);
                // 自分自身は通過可能にする
                allyTiles.Remove(map.GetTile(unit.GridX, unit.GridY));
    
                // BFS
                // cost[tile] = そのタイルに到達するための最小コスト
                var cost    = new Dictionary<TileData, int>();
                var queue   = new Queue<TileData>();
                var result  = new HashSet<TileData>();
    
                var startTile = map.GetTile(unit.GridX, unit.GridY);
                cost[startTile] = 0;
                queue.Enqueue(startTile);
    
                while (queue.Count > 0)
                {
                    var current    = queue.Dequeue();
                    int currentCost = cost[current];
    
                    // 停止可能かどうか(敵タイル・味方タイルは停止不可)
                    bool canStop = !enemyTiles.Contains(current) && !allyTiles.Contains(current);
                    if (canStop)
                        result.Add(current);
    
                    // 隣接タイルを探索
                    foreach (var neighbor in map.GetNeighbors(current.X, current.Y))
                    {
                        if (!neighbor.IsPassable)     continue; // 通行不可地形
                        if (enemyTiles.Contains(neighbor)) continue; // 敵ユニットは通過不可
    
                        int newCost = currentCost + neighbor.MoveCost;
                        if (newCost > moveRange)          continue; // 移動力オーバー
    
                        // より低コストで到達できる場合のみ更新
                        if (!cost.TryGetValue(neighbor, out int existing) || newCost < existing)
                        {
                            cost[neighbor] = newCost;
                            queue.Enqueue(neighbor);
                        }
                    }
                }
    
                return result;
            }
    
            // ================================================================
            //  経路の取得(A* / BFS で最短経路を返す)
            // ================================================================
    
            /// <summary>
            /// 出発地点からゴールまでの最短経路をタイルのリストで返す。
            /// 到達不可の場合は空リストを返す。
            ///
            /// 【アルゴリズム: Dijkstra(コスト考慮BFS)】
            ///   地形コストを考慮した最短経路を求める。
            ///   ヒューリスティックなしの Dijkstra で確実な最短経路を保証する。
            /// </summary>
            /// <param name="map">グリッドマップ</param>
            /// <param name="startX">出発X座標</param>
            /// <param name="startY">出発Y座標</param>
            /// <param name="goalX">目標X座標</param>
            /// <param name="goalY">目標Y座標</param>
            /// <param name="moveRange">最大移動コスト(到達判定に使用)</param>
            /// <param name="blockingUnits">通過不可ユニット一覧(敵ユニットを渡す)</param>
            /// <returns>出発タイルを含む経路リスト。到達不可なら空リスト。</returns>
            public static List<TileData> FindPath(
                GridMap map,
                int startX, int startY,
                int goalX,  int goalY,
                int moveRange,
                IEnumerable<UnitBattleState> blockingUnits = null)
            {
                var blockingTiles = BuildTileSet(map, blockingUnits);
    
                // 優先度付きキュー(コスト昇順)— SortedList で代用
                var openSet  = new SortedList<int, Queue<TileData>>();
                var cameFrom = new Dictionary<TileData, TileData>();
                var costSoFar = new Dictionary<TileData, int>();
    
                var startTile = map.GetTile(startX, startY);
                var goalTile  = map.GetTile(goalX,  goalY);
    
                if (startTile == null || goalTile == null)
                    return new List<TileData>();
    
                Enqueue(openSet, 0, startTile);
                costSoFar[startTile] = 0;
    
                while (openSet.Count > 0)
                {
                    var current = Dequeue(openSet);
    
                    if (current == goalTile)
                        return ReconstructPath(cameFrom, startTile, goalTile);
    
                    foreach (var neighbor in map.GetNeighbors(current.X, current.Y))
                    {
                        if (!neighbor.IsPassable)           continue;
                        if (blockingTiles.Contains(neighbor)) continue;
    
                        int newCost = costSoFar[current] + neighbor.MoveCost;
                        if (newCost > moveRange)            continue;
    
                        if (!costSoFar.TryGetValue(neighbor, out int existing) || newCost < existing)
                        {
                            costSoFar[neighbor] = newCost;
                            cameFrom[neighbor]  = current;
                            // ヒューリスティック(マンハッタン距離)を加算して優先度を決定
                            int priority = newCost + GridMap.ManhattanDistance(neighbor.X, neighbor.Y, goalX, goalY);
                            Enqueue(openSet, priority, neighbor);
                        }
                    }
                }
    
                return new List<TileData>(); // 到達不可
            }
    
            // ================================================================
            //  射程内ユニットの検索
            // ================================================================
    
            /// <summary>
            /// 指定座標から射程(range マス)内にいるユニットを返す。
            /// マンハッタン距離で判定する。
            /// </summary>
            /// <param name="map">グリッドマップ</param>
            /// <param name="originX">攻撃起点のX座標</param>
            /// <param name="originY">攻撃起点のY座標</param>
            /// <param name="range">射程(マス数)</param>
            /// <param name="targets">対象ユニット一覧</param>
            /// <returns>射程内にいるユニットのリスト</returns>
            public static List<UnitBattleState> GetUnitsInRange(
                GridMap map,
                int originX, int originY,
                int range,
                IEnumerable<UnitBattleState> targets)
            {
                var result = new List<UnitBattleState>();
                foreach (var unit in targets)
                {
                    if (unit.IsDefeated) continue;
                    int dist = GridMap.ManhattanDistance(originX, originY, unit.GridX, unit.GridY);
                    if (dist <= range)
                        result.Add(unit);
                }
                return result;
            }
    
            // ================================================================
            //  ユーティリティ
            // ================================================================
    
            /// <summary>ユニット一覧からタイルのセットを構築する</summary>
            private static HashSet<TileData> BuildTileSet(
                GridMap map,
                IEnumerable<UnitBattleState> units)
            {
                var set = new HashSet<TileData>();
                if (units == null) return set;
    
                foreach (var u in units)
                {
                    var tile = map.GetTile(u.GridX, u.GridY);
                    if (tile != null) set.Add(tile);
                }
                return set;
            }
    
            /// <summary>経路を再構築する(ゴールから辿って逆順にする)</summary>
            private static List<TileData> ReconstructPath(
                Dictionary<TileData, TileData> cameFrom,
                TileData start,
                TileData goal)
            {
                var path    = new List<TileData>();
                var current = goal;
    
                while (current != start)
                {
                    path.Add(current);
                    if (!cameFrom.TryGetValue(current, out current))
                        return new List<TileData>(); // 異常(通常は到達しない)
                }
                path.Add(start);
                path.Reverse();
                return path;
            }
    
            /// <summary>優先度付きキューへの追加(SortedList + Queue でコリジョン対応)</summary>
            private static void Enqueue(SortedList<int, Queue<TileData>> openSet, int priority, TileData tile)
            {
                if (!openSet.TryGetValue(priority, out var queue))
                {
                    queue = new Queue<TileData>();
                    openSet.Add(priority, queue);
                }
                queue.Enqueue(tile);
            }
    
            /// <summary>優先度付きキューからの取り出し</summary>
            private static TileData Dequeue(SortedList<int, Queue<TileData>> openSet)
            {
                var first = openSet.Values[0];
                var tile  = first.Dequeue();
                if (first.Count == 0)
                    openSet.RemoveAt(0);
                return tile;
            }
        }
    }
    画像
    ポマイラ(自称ネイトの使い魔)

    「な、なんだこれ…
    今までで一番難しそうだぞ」

    画像
    ネイト(幻の星の精霊)

    「本当、その通りだと思うわ。
    SRPGの一番難しい点って、この『経路や射程を計算する処理』だと私は思ってるわ。

    簡単に言うとね、ユニットが居るマスを起点にして、
    そのユニットの『移動力』のステータスから隣接するマスをどんどん計算していくの。

    そこに障害物や他のユニットがある場合は、そのマスをスキップして再計算を行うわ。
    結果として、移動可能なマスだけを導き出して返す感じね。

    図にするとこんな感じかしら」

    ■移動経路

    画像
    画像
    ネイト(幻の星の精霊)

    「私を起点に、どのマスが何マス離れているかを表しているわ。

    移動力は私が歩ける歩数ね。

    ポイントは、私は直角にしか動くことが出来ず、斜めに移動することは出来ないの。

    それを踏まえて、もし隣に障害物があった場合、
    まっすぐにしか移動できない私はどうやって3歩歩けると思う?
    考えてみましょう」

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

    「えっと…斜めに移動できないなら、迂回したりして移動するしかないよな」

    画像
    ネイト(幻の星の精霊)

    「その通り!
    そうなると、遠回りした分、私が移動できる距離が変わって来ちゃうのよ」


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

    「隣に岩があるせいで、岩の向こうに行けなくなったな!」

    画像
    ネイト(幻の星の精霊)

    「そういうことよ。

    実際に現実世界でやって見ると直感的に分かることだけど、
    プログラムの世界ではこれも一つ一つ計算してあげないといけないのよ。

    『ここに障害物があるから、その上で3歩歩くならこういう経路になるよね』って。

    これがあの難しそうなコードの中でやっている基本的な動きよ」

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

    「なるほどな!
    実際に図にしてみると案外わかるもんだな!」


    攻撃処理

    ■攻撃処理実装動画



    画像
    ネイト(幻の星の精霊)

    「次に、攻撃の処理も作成したわ。
    これもさっきの『PathFinder』の応用で射程を計算してくれているから、
    あと必要になってくるのは『ダメージ計算』と『命中判定』、そして『HPを減らす処理』になるわね」

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

    「これも実は事前に組み立ててたんだよな?」

    画像
    ネイト(幻の星の精霊)

    「そうよ!
    まぁ、難しい話は一旦置いといて…まずは演出面をどうしたかをお話するわね」


    ■エフェクトのアセット

    画像
    ネイト(幻の星の精霊)

    「ダメージを受けた時の演出として、こちらのフリーアセットを使わせていただいたわ。
    あいにく、私にはVFXをゼロから作成する技術も時間もないからね。
    時短よ、時短」

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

    「おお!
    これ、無料なのにすげえカッコいいエフェクトがたくさん入ってるな!:

    画像
    ネイト(幻の星の精霊)

    「そうよ!
    実は、昔Unityを触っていた時代からずっとダウンロードはしていたんだけど、一回も使ったことがなかったの。
    今回ようやく日の目を見て嬉しいわ!」

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

    「なぁなぁ! それはそうと、肝心の『攻撃』はどうやって作ったんだよ?」

    画像
    ネイト(幻の星の精霊)

    「これも難しいわよ? ダメージ計算用のスクリプトをお見せするわね」

    // BattleCalculator.cs
    // バトル計算ロジック(純粋な静的クラス)
    // UnityEngine に最小限しか依存しないため、単体テストが容易。
    
    using UnityEngine;
    
    namespace TrainingGame.Battle
    {
        /// <summary>
        /// バトルにおける各種計算を行う静的クラス。
        /// ステートを持たず、入力値から結果を返すだけの純粋な計算器。
        /// </summary>
        public static class BattleCalculator
        {
            // ================================================================
            //  定数
            // ================================================================
    
            /// <summary>物理・魔法攻撃の最低ダメージ</summary>
            public const int MIN_DAMAGE = 1;
    
            /// <summary>基本命中率(%)</summary>
            public const int BASE_HIT_RATE = 80;
    
            /// <summary>命中率の上限(%)</summary>
            public const int MAX_HIT_RATE = 100;
    
            /// <summary>クリティカル発生時のダメージ倍率</summary>
            public const float CRITICAL_MULTIPLIER = 1.5f;
    
    
            // ================================================================
            //  命中判定
            // ================================================================
    
            /// <summary>
            /// 最終命中率を計算する。
            /// 計算式: Min(80 + 器用さ, 100)
            /// 器用さが20で命中率100%(必中)になる。
            /// </summary>
            /// <param name="dexterity">攻撃側の器用さ</param>
            /// <returns>命中率(0〜100の整数)</returns>
            public static int CalcHitRate(int dexterity)
            {
                return Mathf.Min(BASE_HIT_RATE + dexterity, MAX_HIT_RATE);
            }
    
            /// <summary>
            /// 命中したかどうかを判定する。
            /// </summary>
            /// <param name="dexterity">攻撃側の器用さ</param>
            /// <returns>命中したなら true</returns>
            public static bool RollHit(int dexterity)
            {
                int rate = CalcHitRate(dexterity);
                return Random.Range(0, 100) < rate;
            }
    
    
            // ================================================================
            //  クリティカル判定
            // ================================================================
    
            /// <summary>
            /// クリティカル発生率を計算する。
            /// 計算式: 運 / 2(%、小数点切り捨て)
            /// </summary>
            /// <param name="luck">攻撃側の運</param>
            /// <returns>クリティカル率(0〜100の整数)</returns>
            public static int CalcCriticalRate(int luck)
            {
                return Mathf.Clamp(luck / 2, 0, 100);
            }
    
            /// <summary>
            /// クリティカルが発生したかどうかを判定する。
            /// </summary>
            /// <param name="luck">攻撃側の運</param>
            /// <returns>クリティカルなら true</returns>
            public static bool RollCritical(int luck)
            {
                int rate = CalcCriticalRate(luck);
                return Random.Range(0, 100) < rate;
            }
    
    
            // ================================================================
            //  物理ダメージ計算
            // ================================================================
    
            /// <summary>
            /// 物理攻撃のダメージを計算する。
            /// 計算式: Max(ATK - DEF, MIN_DAMAGE)
            /// </summary>
            /// <param name="atk">攻撃側のATK</param>
            /// <param name="def">防御側のDEF</param>
            /// <returns>確定ダメージ量(最低 MIN_DAMAGE)</returns>
            public static int CalcPhysicalDamage(int atk, int def)
            {
                return Mathf.Max(atk - def, MIN_DAMAGE);
            }
    
            /// <summary>
            /// 物理攻撃の一連の流れ(命中→クリティカル→ダメージ)を計算する。
            /// </summary>
            /// <param name="attacker">攻撃側のバトル状態</param>
            /// <param name="defender">防御側のバトル状態</param>
            /// <returns>計算結果</returns>
            public static AttackResult CalcPhysicalAttack(UnitBattleState attacker, UnitBattleState defender)
            {
                var result = new AttackResult();
    
                // 命中判定(器用さで命中率が上昇)
                result.IsHit = RollHit(attacker.BaseStats.dexterity);
                if (!result.IsHit)
                    return result; // ミス:ダメージなし
    
                // ★修正:基礎ダメージの計算に、バフ込みの「ActualAtk」と「ActualDef」を使う
                int baseDamage = CalcPhysicalDamage(attacker.ActualAtk, defender.ActualDef);
    
                // クリティカル判定(運でクリティカル率が上昇)
                result.IsCritical = RollCritical(attacker.BaseStats.luck);
                if (result.IsCritical)
                    baseDamage = Mathf.FloorToInt(baseDamage * CRITICAL_MULTIPLIER);
    
                result.Damage = baseDamage;
                return result;
            }
    
    
            // ================================================================
            //  魔法(スキル)ダメージ計算
            // ================================================================
    
            /// <summary>
            /// 魔法攻撃のダメージを計算する。
            /// 計算式: Max(魔力 - 耐魔力, MIN_DAMAGE)
            /// </summary>
            /// <param name="magic">攻撃側の魔力</param>
            /// <param name="magicDef">防御側の耐魔力</param>
            /// <returns>確定ダメージ量(最低 MIN_DAMAGE)</returns>
            public static int CalcMagicDamage(int magic, int magicDef)
            {
                return Mathf.Max(magic - magicDef, MIN_DAMAGE);
            }
    
            /// <summary>
            /// 回復量を計算する。
            /// 計算式: 魔力 × healMultiplier(小数点切り捨て)
            /// </summary>
            /// <param name="magic">使用者の魔力</param>
            /// <param name="healMultiplier">回復倍率(例: 1.0f or 1.5f)</param>
            /// <returns>回復量</returns>
            public static int CalcHealAmount(int magic, float healMultiplier = 1.0f)
            {
                return Mathf.FloorToInt(magic * healMultiplier);
            }
    
            /// <summary>
            /// スキル使用の一連の流れを計算する。
            /// ※魔法攻撃は命中率の概念を持たない(必中)設計。
            ///   変更する場合はここに命中判定を追加する。
            /// </summary>
            /// <param name="attacker">使用者のバトル状態</param>
            /// <param name="defender">対象のバトル状態(回復の場合は使用者自身を渡すことも可)</param>
            /// <param name="skill">使用するスキル</param>
            /// <returns>計算結果</returns>
            public static SkillResult CalcSkillEffect(UnitBattleState attacker, UnitBattleState defender, SkillData skill)
            {
                var result = new SkillResult { Skill = skill };
    
                switch (skill.skillType)
                {
                    case SkillType.MagicAttack:
                        result.IsHit = true; // 魔法は必中
                        result.Damage = CalcMagicDamage(attacker.BaseStats.magic, defender.BaseStats.magicDef);
                        break;
    
                    case SkillType.Heal:
                        result.IsHit = true;
                        result.HealAmount = CalcHealAmount(attacker.BaseStats.magic, skill.healMultiplier);
                        break;
    
                    case SkillType.Buff:
                    case SkillType.Debuff:
                        // バフ・デバフの詳細はPhase3以降に実装
                        result.IsHit = true;
                        break;
                }
    
                return result;
            }
    
    
            // ================================================================
            //  アストラルコード計算
            // ================================================================
    
            /// <summary>
            /// アストラルコードの効果量を計算する(必中・クリティカルなし)。
            ///
            /// 全体系(AllEnemy / AllAlly)の場合、BattleManager 側でこの結果を
            /// 各ターゲットに個別適用する。defender には null を渡してよい。
            /// </summary>
            /// <param name="attacker">使用者</param>
            /// <param name="defender">単体対象(全体系は null でよい)</param>
            /// <param name="entry">アストラルコードエントリー定義</param>
            /// <returns>計算結果</returns>
            public static AstralCodeResult CalcAstralCode(
                UnitBattleState attacker,
                UnitBattleState defender,
                AstralCodeEntry entry)
            {
                var result = new AstralCodeResult { Entry = entry };
                var stats = attacker.BaseStats;
    
                switch (entry.effectType)
                {
                    case AstralCodeEffectType.PhysicalDamage:
                        // ★修正:バフ込みの ActualAtk と ActualDef を使う
                        result.Damage = Mathf.Max(
                            Mathf.FloorToInt(attacker.ActualAtk * entry.powerMultiplier)
                            - (defender?.ActualDef ?? 0),
                            MIN_DAMAGE);
                        result.IsAoE = false;
                        break;
    
                    case AstralCodeEffectType.MagicDamage:
                        result.Damage = Mathf.Max(
                            Mathf.FloorToInt(stats.magic * entry.powerMultiplier)
                            - (defender?.BaseStats.magicDef ?? 0),
                            MIN_DAMAGE);
                        result.IsAoE = false;
                        break;
    
                    case AstralCodeEffectType.SelfHeal:
                        // MaxHP の割合回復
                        result.HealAmount = Mathf.FloorToInt(stats.maxHp * entry.powerMultiplier);
                        result.IsAoE = false;
                        break;
    
                    case AstralCodeEffectType.AllEnemyPhysical:
                        // ★修正:全体物理もバフ込みの ActualAtk を使う
                        result.Damage = Mathf.Max(Mathf.FloorToInt(attacker.ActualAtk * entry.powerMultiplier), MIN_DAMAGE);
                        result.IsAoE = true;
                        break;
    
                    case AstralCodeEffectType.AllEnemyMagic:
                        // 全体魔法:基礎値のみ
                        result.Damage = Mathf.Max(Mathf.FloorToInt(stats.magic * entry.powerMultiplier), MIN_DAMAGE);
                        result.IsAoE = true;
                        break;
    
                    case AstralCodeEffectType.AllAllyHeal:
                        result.HealAmount = Mathf.FloorToInt(stats.magic * entry.powerMultiplier);
                        result.IsAoE = true;
                        break;
    
                    case AstralCodeEffectType.InstantKill:
                        result.Damage = 9999; // BattleManager 側で IsDefeated チェック
                        result.IsAoE = false;
                        break;
                }
    
                return result;
            }
        }
    
    
            // ================================================================
            //  結果データ構造体
            // ================================================================
    
            /// <summary>通常攻撃の計算結果</summary>
            public struct AttackResult
        {
            /// <summary>命中したか</summary>
            public bool IsHit;
    
            /// <summary>クリティカルだったか</summary>
            public bool IsCritical;
    
            /// <summary>最終ダメージ量(ミスなら0)</summary>
            public int Damage;
    
            public override string ToString()
            {
                if (!IsHit) return "ミス!";
                return IsCritical
                    ? $"クリティカル! {Damage} ダメージ"
                    : $"{Damage} ダメージ";
            }
        }
    
        /// <summary>スキル使用の計算結果</summary>
        public struct SkillResult
        {
            /// <summary>使用したスキル</summary>
            public SkillData Skill;
    
            /// <summary>効果が発動したか(必中スキルは常にtrue)</summary>
            public bool IsHit;
    
            /// <summary>与えるダメージ量(攻撃スキルのみ)</summary>
            public int Damage;
    
            /// <summary>回復量(回復スキルのみ)</summary>
            public int HealAmount;
        }
    
        // ================================================================
        //  アストラルコード計算結果
        // ================================================================
    
        /// <summary>アストラルコード使用の計算結果</summary>
        public struct AstralCodeResult
        {
            /// <summary>使用したエントリー定義</summary>
            public AstralCodeEntry Entry;
    
            /// <summary>与えるダメージ量(攻撃系のみ)</summary>
            public int Damage;
    
            /// <summary>回復量(回復系のみ)</summary>
            public int HealAmount;
    
            /// <summary>全体攻撃・全体回復の場合 true</summary>
            public bool IsAoE;
    
            public override string ToString()
            {
                if (Entry == null) return "(null)";
                return IsAoE
                    ? $"【アストラルコード】{Entry.skillName} → 全体{Damage}ダメージ"
                    : HealAmount > 0
                        ? $"【アストラルコード】{Entry.skillName} → {HealAmount}回復"
                        : $"【アストラルコード】{Entry.skillName} → {Damage}ダメージ";
            }
        }
    }
    画像
    ポマイラ(自称ネイトの使い魔)

    「…分かりやすく説明してくれ。」

    画像
    ネイト(幻の星の精霊)

    「もう!ポマイラったら横着して!
    まぁ、やってる計算は前に決めた仕様をそのまま踏襲しているわね」

    🔢 バトル計算式の定義
    1. 物理ダメージ計算
    ダメージ = ATK - DEF (※最低ダメージを1に設定)
    例:ネイトのATKが30、敵のDEFが15の場合、

    30 - 15 = 15ダメージ。

    非常にシンプルで、「特訓でATKを1上げれば、ダメージが1増える」という成長の実感がダイレクトに伝わります。

    2. 魔法(スキル)ダメージ計算
    ダメージ = 魔力 - 耐魔力
    物理と同様の計算です。
    回復スキルの場合は、そのまま「魔力の数値=回復量」とするか、

    「魔力 × 1.5」など少しボーナスをつけるとバランスが取りやすいかも。

    3. 命中率計算
    最終命中率(%) = 80 + 器用さ (※上限100%)
    基本命中率を80%とし、「器用さ1ポイントにつき、命中率が1%上がる」仕様です。

    これにより、「器用さが20になれば命中率100%(必中)になる」という明確な特訓の目標が生まれます。

    4. クリティカル計算
    クリティカル率(%) = 運 / 2
    クリティカル発生時のダメージ = 通常ダメージ × 1.5倍 (小数点切り捨て)

    発動したらラッキー。

    画像
    ネイト(幻の星の精霊)

    「器用さと運の関係は、
    直感的な分かりやすさを重視して『器用さを命中率』『運をクリティカル率』に変更したの。

    そしてこれを基に、ClaudeAIさんにコードを作ってもらったのよ!」

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

    「なるほどな!
    …てことは、ネイトも殆ど計算式見てないんじゃないか」

    画像
    ネイト(幻の星の精霊)

    「ま、まぁ、私は明らかにおかしい数値になった時にここを調整できればそれでいいのよ!」

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

    「ネイトも人のこと言えねぇじゃねぇか…」


    戦闘用アイテムの実装

    ■アイテム使用

    画像
    ネイト(幻の星の精霊)

    「次に戦闘用アイテムの実装ね。
    これは本当に苦労したわ…」

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

    「まぁ、横で見てて相当大変そうだったもんな。
    何が大変だったか聞かせてくれよ」

    画像
    ネイト(幻の星の精霊)

    「そうねぇ…これは私の環境固有の問題だったのだけど、
    宴3にデータ管理を任せている以上、まずはアイテムの情報を戦闘シーンに引き渡すのが大変だったわね。
    結局、前回同様スクリプタブルオブジェクトで引き渡すようにしたのだけど。
    あとは、ここのアイテム効果を追加して、バグを取り除いてって作業が地道で大変だったわ」

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

    「まぁ、確かに宴3に無理やりデータ管理任せてる運用だったもんな。
    だったらいっそのこと宴から切り離してデータ管理すればよかったんじゃねぇか?」

    画像
    ネイト(幻の星の精霊)

    「正直、プロジェクトの初期ならその方法もありだったと今では思うわ。
    でも、私が一番気にしてるのは保存するデータを宴とC#でそれぞれ持ってしまった場合、バグが発生した時にそれがどこが原因なのか分からなくなるし、何のデータどこで保存されているのか分からなくなると思ったの。

    それに、宴には元々セーブ・ロード機能があるからそれを使う運用にすれば私がわざわざセーブ機能やUIを作る手間もなくなるわ。
    だから、データ管理は全部宴の管轄にしようって決めたの」

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

    「なるほどな。
    作業スピードと効率を考えた訳か」

    画像
    ネイト(幻の星の精霊)

    「うん、でもそのせいで今日は痛い目にあったのだけどね」


    セーブ機能のバグ修正(5/2 8時間くらい?)

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

    「今日のネイト、めちゃくちゃ忙しそうだったよな。
    何があったのか教えてくれよ」

    画像
    ネイト(幻の星の精霊)

    「うん……。
    実はね、『宴(Utage)』の機能を使ってセーブ・ロードしても、
    ちゃんとゲーム上ではデータがロードされてないって現象を見つけたの」

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

    「それ、そもそもゲームが出来ないレベルの大問題じゃねぇか!
    原因は何だったんだ?」

    画像
    ネイト(幻の星の精霊)

    「原因はシンプルだったわ。
    そもそも、自作のメニューからロードした段階では『宴』側からデータを共有してないからしていないから、宴側で保存したデータを取り込めていなかったのよ」


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

    「なんだ、そんなことか…じゃあ、なんでこんなに苦労したんだ?」

    画像
    ネイト(幻の星の精霊)

    「ほら、私基本的にはコーディングは生成AIに任せてるじゃない?
    だから、ファイルをClaudeAIさんやGemini先生に見せて修正してもらってたの。」

    画像
    ネイト(幻の星の精霊)

    「でも、色んなAIに聞いても結局、本当の原因には誰も気づかなかったわ。
    『ロードした段階では宴からデータを貰っていない』という構造的な発想にまで至らなくて、中々解決できなかったのよね」

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

    「なるほどな。
    そこで、ネイトはどうしたんだ?」

    画像
    ネイト(幻の星の精霊)

    「これ以上AIに聞いても解決しないと思ったから、
    敢えて生成AIの助言は全部忘れて、自分で一つずつ『データがどの段階まで保持されているか』を確認してみたの。」

    画像
    ネイト(幻の星の精霊)

    「例えば、ロードした後に宴が持ってる変数を呼び出したら値は入っているのか?

    そうやって調べてみると、宴側での変数はちゃんとロードした後も残ってたの。

    そうなると、『ロード実行後に、ちゃんと宴側を参照出来ていないC#側のメニュー生成ロジック』に問題がある。

    それで改めてコードを確認してみたら、
    そもそもロードした後に宴の変数を読みに行ってないから、ロードも何もないってことが分かったのよ!」

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

    「自力で原因が分かったなんてすげぇじゃねぇか!
    それで、どうやって直そうと思ったんだ?」

    画像
    ネイト(幻の星の精霊)

    「そのことをGemini先生に伝えたら、コードを渡してくれたわ。
    『ロードした時に宴側から情報をキャッチしようとする新しいロジック』ね。
    でも、正直私はそのやり方は非効率だと思ったの。」

    画像
    ネイト(幻の星の精霊)

    「だって、セーブ・ロードする前はちゃんとデータの引継ぎが行われているのだから、わざわざロジックの根本を変える必要はないのよ。

    ロードした時に宴のシーンを呼び出して、そこから従来通り育成メニューを呼び出せば、これまで上手く行った方法を踏襲しつつ運用できるでしょ!」

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

    「なるほどな!
    それじゃあ、すんなり解決したのか!」

    画像
    ネイト(幻の星の精霊)

    「いや……ここからが本当の地獄だったわ。
    というか、今回のバグ以上のひと悶着があったのよ」

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

    「な、なにがあったんだよ」

    画像
    ネイト(幻の星の精霊)

    「その時、私は結構疲れてたから、
    修正パッチ用のスクリプトの組み込みをGemini先生に任せて、ファイルをそのまま上書きしたのよ。」

    画像
    ネイト(幻の星の精霊)

    「そしたら……アイテム関連の機能やキャラクター情報など、ゲームの根幹になる機能が殆ど壊れたわ」

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

    「…はぁ!?
    なにやってんだよ!」

    画像
    ネイト(幻の星の精霊)

    「まず、バックアップを取ってなかった私に非はあるわ。
    ゲーム開発のバックアップは基本的にGitHub Desktopを使ってるんだけど……
    『HorizonNotes』本編だったり、
    この企画以外にも色々なもののバックアップを取ってて、通信の枠をケチってしまって……この企画のバックアップをあまり取ってなかったの」

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

    「まぁ、それは確かにネイトも悪いところがあるが……
    それで、結局なんでそんな状況になったんだ?」

    画像
    ネイト(幻の星の精霊)

    「問題は大きく分けて二つよ。

    一つは、Gemini先生がコードを作成した際に、どうしてか『宴』を参照していた既存の正しい書き方を逸脱して、別の参照記述に書き換えてしまった事。


    もう一つが、いくつかの既存のロジックを勝手に省略して消してしまった事ね。


    消されてしまったロジックは、Gemini先生も私も覚えてない。

    ……というか、作ってる最中は把握していても、全ての機能を一言一句覚えているわけじゃないからね。
    ある意味、一から作り直すより大変だったわ」

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

    「割と致命的じゃねぇか…
    バックアップがないならほぼ作り直しじゃねぇかそんなの」

    画像
    ネイト(幻の星の精霊)

    「正直、企画を諦めそうになるくらいこころ折れかけたわ。
    …でもね、せっかくここまで頑張ったのだからやれるところまでは頑張ろうって思ったの」

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

    「それで、ネイトはどうやって直したんだ?」

    画像
    ネイト(幻の星の精霊)

    「一応ね、要所要所で大事なファイルは別に保管してたの。
    抜けてる部分もあるけど、何が抜けたかの参考になるわ。」

    画像
    ネイト(幻の星の精霊)

    「あと、本当に助かったのはClaudeAIさんね!

    ClaudeAIは過去に共有したファイルもチャット履歴に残してくれていたから、そこから復元も出来たわ!
    復元できた功績の8割はClaudeAIさんよ!」

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

    「そいつは良かったじゃねぇか!
    ……それで、今回なんでそんなGeminiの暴走が起きちまったんだ?」

    画像
    ネイト(幻の星の精霊)

    「まず、ロジックが消えてしまった理由だけど、
    Gemini先生はAPIの使用量(トークン)を削減する為に、
    修正不要な個所はコメントアウトして省略してしまう癖があるの。


    これは『プロンプトで省略しないように』とお願いしても発生することがある問題だから、
    もしAIに『このまま上書きしてください』と言われても、必ずコードの全体を確認することをおすすめするわ」

    画像
    ネイト(幻の星の精霊)

    「次に、勝手にロジックの文法を書き換えてしまった件ね。

    これは正直私にも良く分からないのだけど、該当箇所は『宴3』固有のコマンドが必要な場面だったから、
    きっと一般的なC#の知識で気を利かせたつもりになって、間違ったコードに書き換えてしまったのね」

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

    「何というか…割と人間みたいなミスをするんだな。
    Geminiは。
    それで、ネイトが思う対策方法はあるのか?」

    画像
    ネイト(幻の星の精霊)

    「生成AIを過信しすぎないことね。

    プロジェクトの内容が複雑化してコードの分量が多くなると、こういう問題は結構発生するのよ。

    だから、めんどくさがってそのままコピペして適用するのは危険だわ。

    あと、バックアップをしっかり取ること。
    これは本当に大事。
    そして、AIはあくまで『サポート』として運用することね」

    画像
    ネイト(幻の星の精霊)

    「今回のことで私が学んだのは、人が主導して生成AIに的確に指示(プロンプト)を出さないと、その便利な機能を100%引き出せないってこと。

    だから、生成AIに与える情報は極力しぼる必要があると思うわ。
    主導権は常にユーザーが握ってないと、むしろ解決まで時間が掛かることもあるわよ」

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

    「なるほどな。
    生成AIは便利なツールだけど、使いこなすにはやっぱり人間の知恵と管理が不可欠ってことだな」

    画像
    ネイト(幻の星の精霊)

    「あと、自分のニーズに合わせて『どのAIを活用するか』を適材適所で検討するのもお勧めよ!

    私は、コーディングの精度よりも『Googleの機能との連携』や『画像生成・BGM生成・動画生成』、あと『ブラウザを使った最新情報の収集』に優位性があると思ったからGeminiに課金してるけど……。

    正直、コーディングの正確さではClaudeAIの方が優秀な印象だし、ChatGPTの方が使いやすいと感じる場面もあるわ」


    最後に

    かくして、移動や攻撃といった戦闘システムの基礎が組み上がり、いよいよ「ゲーム」としての力強い産声を上げ始めたプロジェクト:ネイト。


    データ消失の危機という「本当の地獄」を見ながらも、
    決して諦めなかった選択が、また一つこの世界を前に進めました。


    しかし、開発の道はまだまだ険しく、未踏の領域が広がっています。


    次回、果たしてネイトは未完成のスキルの実装に辿り着けるのか!?

    それとも、またしても予期せぬバグの魔物がコードの裏側から牙を剥くのか……!?


    まだまだ続く、笑いあり涙あり(?)のゲーム開発奮闘記。


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


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

     
     
    読者参加型でゲームを作っている、まる。です。 UE5でオリジナルのカードゲームRPG『HorizonNotes』を開発してます 育成SRPG『ネイト育成計画』体験版、無料公開中です! ■公式サイト https://horizonnotes.netlify.app/

    あなたへのおすすめ