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

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

経路探索アルゴリズムを噛み砕いて理解する:A*(A-star)

A*(A-star)


代表的な迷路探索アルゴリズムには、

・BFS
・Dijkstra’s Algorithm
・A(A-star)

があります。

BFSは最小歩数を探すアルゴリズムでした。

Dijkstra’s Algorithmはそこに通路コストの概念を追加し、

・ダークゾーンを避ける
・罠を避ける

といった判断が可能になりました。

ただし、ダイクストラも本質的には総当たり探索です。

そのため、

・ゴールとは真逆の方向

についても探索してしまいます。

そこで登場するのが、

A
(A-star)

です。

A*は、

「スタート地点からのコスト」

に加えて、

「ゴールまでどれくらい近いか」

という評価も行います。

つまり、

「安全な経路を探す」

だけではなく、

「ゴールに近づく方向を優先して探す」

という考え方が追加されています。

ダイクストラをさらに賢くしたもの。

と言うとイメージしやすいかもしれません。


A*の基本概念


ダイクストラが評価していたのは、

スタート地点からここまで来るためのコスト

だけでした。

例えば、この様に右方向にゴールがある場合でも、

・右へ向かう道
・左に向かう道

両方の道のコストが同じなら、
両方を同じ優先度で探索してしまいます。

A*ではここに、

ゴールまでの予想距離

という考え方を追加します。

そのため、

ゴールから遠ざかる方向よりも、

ゴールへ近づく方向を優先的に探索するようになります。

基本構造はダイクストラと同じ


前回説明した

・これから調べる座標のリスト
・どこから来たかの記録
・調査済み座標の記録

そして

・スタート地点からその座標までの最小コスト

という考え方は、Aでもそのまま使います。

A
になっても、
基本的な流れはBFSやダイクストラと同じです。

BFSやダイクストラ、A*の違いは、

ざっくり言ってしまえば、

「queueの中からどの座標を優先して取り出して探索するか」

という部分だけです。

全体像


サンプルコードはこんな感じになります。

public class PathNode
{
    public Vector2Int pos;
    public int cost;

    public PathNode(Vector2Int pos, int cost)
    {
        this.pos = pos;
        this.cost = cost;
    }
}

public List<Vector2Int> FindPathAStar(
    int[,] map,
    Vector2Int start,
    Vector2Int goal)
{
    int width = map.GetLength(0);
    int height = map.GetLength(1);

    List<PathNode> queue =
        new List<PathNode>();

    bool[,] visited =
        new bool[width, height];

    Dictionary<Vector2Int, Vector2Int> cameFrom =
        new Dictionary<Vector2Int, Vector2Int>();

    int[,] costSoFar =
        new int[width, height];

    for (int x = 0; x < width; x++)
    {
        for (int y = 0; y < height; y++)
        {
            costSoFar[x, y] = int.MaxValue;
        }
    }

    queue.Add(new PathNode(start, 0));

    costSoFar[start.x, start.y] = 0;

    Vector2Int[] dirs =
    {
        Vector2Int.up,
        Vector2Int.right,
        Vector2Int.down,
        Vector2Int.left
    };

    while (queue.Count > 0)
    {
        // cost最小を探す
        int bestIndex = 0;

        for (int i = 1; i < queue.Count; i++)
        {
            if (queue[i].cost <
                queue[bestIndex].cost)
            {
                bestIndex = i;
            }
        }

        PathNode currentNode =
            queue[bestIndex];

        queue.RemoveAt(bestIndex);

        Vector2Int current =
            currentNode.pos;

        if (visited[current.x, current.y])
            continue;

        if (current == goal)
        {
            List<Vector2Int> path =
                new List<Vector2Int>();

            Vector2Int p = goal;

            while (p != start)
            {
                path.Add(p);
                p = cameFrom[p];
            }

            path.Reverse();

            return path;
        }

        foreach (var dir in dirs)
        {
            Vector2Int next =
                current + dir;

            // 範囲外
            if (next.x < 0 ||
                next.x >= width ||
                next.y < 0 ||
                next.y >= height)
                continue;

            // 通行不能
            if (map[next.x, next.y] == -9)
                continue;

            // 確定済み
            if (visited[next.x, next.y])
                continue;

            int moveCost =
                map[next.x, next.y];

            int newCost =
                costSoFar[current.x, current.y]
                + moveCost;

            if (newCost <
                costSoFar[next.x, next.y])
            {
                costSoFar[next.x, next.y]
                    = newCost;

                cameFrom[next]
                    = current;

                // ゴールまでの予想距離
                int heuristic =
                    Mathf.Abs(goal.x - next.x)
                    + Mathf.Abs(goal.y - next.y);

                // A*評価値
                int priority =
                    newCost + heuristic;

                queue.Add(
                    new PathNode(
                        next,
                        priority
                    )
                );
            }
        }

        visited[current.x, current.y]
            = true;
    }

    return null;
}

今回のサンプルダンジョン


ダイクストラとの違いが分かりやすいように、
ダイクストラの時と同じダンジョンです。

スタート地点は(x3,y5)、ゴール地点は(x10,y10)。

コストも前回と同じ

・99:通行不可
・1:通常通路
・10:ダークゾーン
・50:罠

と言う設定です。

ダイクストラとの違い


ダイクストラでは、

スタート地点からの合計コスト

が、一番低い座標をqueueから取り出し、

その座標の四方向を調査して、
有効な座標があれば移動コストを加算してqueueへ登録する。

という探索方法でした。

A*では、この合計コストに

『ゴールまでの予想距離』

を加えます。

つまり、queueに登録する値は、

・スタート地点からの合計コスト
・次の座標に進むために必要なコスト
・ゴールまでの予想距離  

の加算になります。(priority)

『ゴールまでの予想距離』を出す方法は
vectorの差からvector.lengthを出すなど色々ありますが、
今回はシンプルに

int heuristic = Mathf.Abs(goal.x - next.x)
                      + Mathf.Abs(goal.y - next.y);

で求める事にします。

それでは、具体的な流れを見ていきましょう。

まず、queueから登録されている値(priority)が一番低い調査対象座標を取り出すわけですが、

・スタート地点からの合計コスト
・ゴールまでの予想距離

の合計が格納されています。

この『ゴールまでの予想距離』は調査対象座標とゴール座標との相対的な値で、
調査対象座標の四方の計算時には邪魔になりますので

調査対象座標への『スタート地点からの合計コスト』は
costSoFarから取得してきます。

costSoFar[current.x, current.y]

queueは、あくまで調査対象座標を決めるために用い、
調査の判断としてのコストはcostSoFarから取得してくるわけです。

では、続きを見ていきましょう。

調査対象座標への 『スタート地点からの合計コスト』は取得できました。

そこに、『次の座標に進むために必要なコスト』

int moveCost =
                map[next.x, next.y];

を加算します。

int newCost =
                costSoFar[current.x, current.y]
                + moveCost;

そこに『ゴールまでの予想距離』

int heuristic = Mathf.Abs(goal.x - next.x)
                      + Mathf.Abs(goal.y - next.y);

を加算します。

サンプルダンジョンのスタート位置でいうと、

上下と右方向は、

『スタート地点からの合計コスト』と

『次の座標に進むために必要なコスト』の合計

はダイクストラと同じで、いずれも1になります。

違いがでるのは、
『ゴールまでの予想距離』(heuristic)で、

int heuristic = Mathf.Abs(goal.x - next.x)
                      + Mathf.Abs(goal.y - next.y);


上方向:(10-3)+(10-6)=7+4=11
右方向:(10-4)+(10-5)=6+5=11
下方向:(10-3)+(10-4)=7+6=13

となります。

つまり、queueに登録される値(priority)
・スタート地点からの合計コスト
・次の座標に進むために必要なコスト
・ゴールまでの予想距離  

は、

上方向:0+1+11=12
右方向:0+1+11=12
下方向:0+1+13=14

となり、
ゴールから遠ざかる方向の下方向の優先度が下がる事になります。

後はダイクストラと同じ

後のフローはBFSやダイクストラの時と同じです。

後はqueueが空になるか(ゴールに辿り着けない。)、ゴールに辿り着くまで、
ひたすら調査を継続することになります。

ゴールに至った時、各記録はこんな感じになります。




A*は最強なのか?


ここまでで、

・BFS
・Dijkstra’s Algorithm
・A*(A-star)

と代表的な3つのアルゴリズムを噛み砕いてきました。

BFS、ダイクストラに比べて更に無駄な探索が減り、
明らかにゴール地点に向かっているのが判るかと思います。

ちなみに、今回のマップの各アルゴリズムでの探索回数(queueに格納される座標の数)
はこんな感じになります。

BFS Dijkstra’s Algorithm A*
78 60 22

Aではしっかりとゴールに向かって探索されました。

他のアルゴリズムに比べると、最強なのでは・・・?と思ってしまいますが、

実は必ずしもそうではなかったりします。

ウィザードリィライクのダンジョンで、
自動移動の経路探索であればA
は最強のアルゴリズムと言えるかもしれません。

しかし、
これがもっと広大なダンジョンで、

しかもストラテジーゲームの様に、
敵が数十、数百、千以上いて、
それらが、プレイヤーへの経路探索に全員がA*を使う

となると

これは流石に負荷が大きくなります。

そんな場合の解決手段の一つとしてあるのが、

フローフィールドアルゴリズム

です。

詳細は今回は書きませんが、

簡単に言うと、フローフィールドとは

「ゴールから逆算して全マスに矢印を書く」

手法です。

・まずゴール(プレイヤー位置)から全マスまでのコストマップを作ります。

これは実質Dijkstraから利用しているcostSoFarです。

次に、

・各マスに「一番数字の小さい隣」への移動指示を書きます。

これがフローフィールドです。

敵はこのフローフィールドの、自分のいる座標の指示を見て移動する。

と言うイメージです。

同じフローフィールドを全ての敵が利用するので、

プレイヤーの移動後にフローフィールドを更新するだけでよく、

敵が何体いても問題ありません。

まとめ

つまり、

BFSは、最小歩数で探す。

ダイクストラは、コストを見て探す。

Aは、コストに加えてゴールの方向も見て探す。

そして、フローフィールドは、多数のユニットが同じ目的地へ向かう時に強い。

ということになります。

どのアルゴリズムが常に最強というより、

「何をしたいのか」
「どれくらいの規模で使うのか」
「何体が同時に経路探索するのか」

によって、向き不向きが変わってくるわけですね。

今回のAbyssBounderのような、
ウィザードリィライクのダンジョンでの自動移動なら、

A
はかなり相性の良い選択肢ではないかと思っています。









経路探索アルゴリズムを噛み砕いて理解する:Dijkstra’s Algorithm (ダイクストラ)

Dijkstra’s (ダイクストラ) アルゴリズム


代表的な迷路探索アルゴリズムには、

・BFS
・Dijkstra's Algorithm
・A*(A-star)

があります。

BFSについては前回噛み砕いた通り、

・ゴールへの近さ
・危険地帯
・通路コスト

といった要素を一切考慮しない総当たり探索です。

ただし、結果として得られる経路は「最小歩数」で到達できるルートになります。


BFSではできない事


例えば、

・近道でもダークゾーンは避けたい
・多少遠回りでも安全な道を選びたい
・発見済みの罠を避けたい

といった判断をさせたい場合。

BFSではそれができません。

そこで登場するのが、

Dijkstra's Algorithm

です。


ダイクストラの基本概念

考え方は単純です。

各マスに通過コストを設定し、

「何歩進んだか」

ではなく、

「スタート地点からの合計コスト」

で経路を評価します。

例えば、

通常通路:1
ダークゾーン:10
罠:50

と設定すれば、
歩数が多少増えても安全な道を優先して選ぶようになります。


基本構造はBFSと同じ

前回説明した、

・これから調べる座標のリスト
・どこから来たかの記録
・調査済み座標の記録

という3つの考え方は、ダイクストラでもそのまま使います。
ですが、「コスト」が加わることで、

・調査済み座標の記録

の考え方を変え、

・これから調べる座標のリスト

にも修正を加える必要があります。


visitedの意味が変わる

まず、
・調査済み座標の記録

BFSのvisitedは、

「その座標を調査したか」

を記録するものでした。

一方ダイクストラでは、

「その座標までの最小コストが確定したか」

を記録するものになります。
このため、Visitedに座標を登録するタイミングがBFSとは異なります。

この違いが非常に重要です。

まずは全体像

まずはサンプルコード全体を見てみます。

public class PathNode
{
    public Vector2Int pos;
    public int cost;

    public PathNode(Vector2Int pos, int cost)
    {
        this.pos = pos;
        this.cost = cost;
    }
}

public List<Vector2Int> FindPathDijkstra(
    int[,] map,
    Vector2Int start,
    Vector2Int goal)
{
    int width = map.GetLength(0);
    int height = map.GetLength(1);

    // 優先度付きキューの代用品
    List<PathNode> queue = new List<PathNode>();

    bool[,] visited = new bool[width, height];

    Dictionary<Vector2Int, Vector2Int> cameFrom =
        new Dictionary<Vector2Int, Vector2Int>();

   int[,] costSoFar = new int[width, height];

    for (int x = 0; x < width; x++)
    {
        for (int y = 0; y < height; y++)
        {
            costSoFar[x, y] = int.MaxValue;
        }
    }

    queue.Add(new PathNode(start, 0));

    costSoFar[start.x, start.y] = 0;

    Vector2Int[] dirs =
    {
        Vector2Int.up,
        Vector2Int.right,
        Vector2Int.down,
        Vector2Int.left
    };

    while (queue.Count > 0)
    {
        // queueの中から最小コストを探す
        int bestIndex = 0;

        for (int i = 1; i < queue.Count; i++)
        {
            if (queue[i].cost < queue[bestIndex].cost)
            {
                bestIndex = i;
            }
        }

        PathNode currentNode = queue[bestIndex];

        queue.RemoveAt(bestIndex);

        Vector2Int current = currentNode.pos;

        // 既に確定済みならスキップ
        if (visited[current.x, current.y])
            continue;

        // ゴール到達
        if (current == goal)
        {
            List<Vector2Int> path =
                new List<Vector2Int>();

            Vector2Int p = goal;

            while (p != start)
            {
                path.Add(p);
                p = cameFrom[p];
            }

            path.Reverse();

            return path;
        }

        foreach (var dir in dirs)
        {
            Vector2Int next = current + dir;

            // 範囲外
            if (next.x < 0 || next.x >= width ||
                next.y < 0 || next.y >= height)
                continue;

            // 通行不能
            if (map[next.x, next.y] == -9)
                continue;

            // 確定済み
            if (visited[next.x, next.y])
                continue;

            int moveCost = map[next.x, next.y];

            int newCost =
                costSoFar[current.x, current.y] + moveCost;

            // より安い経路を発見
            if (newCost < costSoFar[next.x, next.y])
            {
                costSoFar[next.x, next.y] = newCost;

                cameFrom[next] = current;

                queue.Add(
                    new PathNode(next, newCost)
                );
            }
        }
  visited[current.x, current.y] = true;
    }

    // 経路なし
    return null;
}

今回のサンプルダンジョン


スタート地点は(x3,y5)、ゴール地点は(x10,y10)です。

BFSの時の0と1のサンプルダンジョンとは異なり、
ダイクストラにはコストの概念があります。

今回のサンプルでは、

・99:通行不可
・1:通常通路
・10:ダークゾーン
・50:罠

と言う設定です。

かなり意図的に経路を絞っているダンジョンですね。

『これから調べる座標のリスト』を改造する。

まず、『これから調べる座標のリスト』であるqueueにコストを追加できるようにします。

C#には二つの項目が使える

PriorityQueue<TElement, TPriority>

が用意されています。

unityでこのPriorityQueueを使う場合は、

・unity6以上
・Unity 2022 LTSでApi Compatibility Levelを ⁠.NET Framework⁠に切り替える

のどちらかで使用可能になります。

ただし、今回はダイクストラの理解のため
PriorityQueueを使わず、あえて馴染み深い普通のListでやってみようと思います。

そのため、まず専用classを定義します。

public class PathNode
{
    public Vector2Int pos;//座標格納用
    public int cost;//コスト格納用

    public PathNode(Vector2Int pos, int cost)
    {
        this.pos = pos;
        this.cost = cost;
    }
}

そして、queueの設定をこのように変えます。

List<PathNode> queue = new List<PathNode>();
bool[,] visited = new bool[width, height];
Dictionary<Vector2Int, Vector2Int> cameFrom =
        new Dictionary<Vector2Int, Vector2Int>();


コスト記録用の配列を追加する


ダイクストラでは、

・各座標へ到達するための最小コスト

を記録する必要があります。

そのため、

int[,] costSoFar

を追加し、

初期値として理論上最大の値である

int.MaxValue

を入れておきます。

ここまでで、

「あれ?コスト格納用にPathNodeのListでqueueを作ったんじゃないの?」

と思うかもしれません。

queueのコストとcostSoFarの違い

ここは少し混乱しやすいポイントです。

どちらもコストを持っていますが役割が違います。

queue:これから調べる候補

costSoFar:その座標まで到達する最良コストの保存場所

という位置付けになります。

queueは取り出すと消えてしまいます。

そのため、

現在判明している最良コストは

costSoFar

に保存しておく必要があるのです。

探索開始

まずスタート地点を登録します。
スタート地点はコスト0で、それ以下にはなり得ませんから、costSoFarにも登録します。

queue.Add(new PathNode(start, 0));
costSoFar[start.x, start.y] = 0;

BFSではここでvisited登録しましたが、

ダイクストラではまだ登録しません。

「最小コストが確定した」

という時に登録になります

スタート地点に限っていえば、ダイクストラでも今の時点でvisited登録可能です。
ただ、Whileループ内で四方を確認してからvisitedを登録するので、あえてこの段階で登録する必要がないのです。


最小コストを座標を取り出す

BFSと同じくqueueから探索対象の座標を持ってきます。

BFSはqueueに登録した順番に取り出します。

しかしダイクストラでは

・queueの中で最もコストが低い座標

を取り出します。
(同じコストの場合は、先に登録された順)

// queueの中から最小コストを探す
int bestIndex = 0;

for (int i = 1; i < queue.Count; i++)
{
    if (queue[i].cost < queue[bestIndex].cost)
    {
        bestIndex = i;
    }
}

PathNode currentNode = queue[bestIndex];

queue.RemoveAt(bestIndex);

Vector2Int current = currentNode.pos;


これがダイクストラの最大の特徴です。

無効座標を除外する

BFSの時と同じく、
取り出した座標から四方向に伸ばした隣接座標について

・範囲外?
・通行不能?
・探索済み?

を判定して除外します。

BFSはこれで有効座標と判定してqueueに登録、次の座標へ、、、とWhileループを回していきましたが、
ダイクストラはここからコストの評価に入ります。

合計コストを計算する

まず、

次のマスへ移動するコストを取得します。

int moveCost = map[next.x, next.y];

そして、

現在地までの合計コストに加算します。

int newCost =
    costSoFar[current.x, current.y] + moveCost;

これが

現在地経由でそのマスへ行った場合の合計コスト

になります。

より安い経路なら更新する

if (newCost < costSoFar[next.x, next.y])

現在の経路の方が安い場合、

costSoFar[next.x, next.y] = newCost;
cameFrom[next] = current;

で情報を更新し、

queue.Add(new PathNode(next, newCost))

queueにも登録します。

つまり、

調査対象の経路の合計コストが、 記録済みの経路より良くなければ、queueに登録せず、調査対象にもしないという事です。

ここで初めてvisited登録

四方向の確認が終わったら、

visited[current.x, current.y] = true;

を実行します。

これで、現在地までの最小コストが確定しました。
あとは再び、

・queueの中で最もコストが低い座標

を取り出していき、

・queueが空になるか
・ゴールに到達するか

まで探索を続けていきます。

経路復元はBFSと同じ

ゴール到達後は、BFSと同じように、cameFromを逆に辿るだけです。

「あれ?コストを使って復元しないの?」

と思うかもしれません。

しかし、

cameFrom自体が常に最良経路へ更新され続けているため、

最終的に逆順で辿ればそれが最良ルートになります。


実際の動き

では、実際の経路探索の流れを追ってみましょう。

スタート地点に対しての四方の探索が終わった時点で、各記録はこうなっています。



この時点では、まだどこに向かっても同じコストです。

queueに登録されているコストが全て同じの場合、
次に取り出される座標は、先に登録されている座標になりますので
この場合、(x3,y6)になります。

そして(x3,y6)の調査を終えると、こうなります。



ここで上方向に向かっていた経路は

・罠(コスト50)
・ダークゾーン(コスト10)

にぶつかりました。

costSoFarには初期設定で

int.MaxValue

が登録されているため、
罠(コスト50)であってもcostSoFarは更新され、
queueにも登録されます。

しかし、queueからは

・queueの中で最もコストが低い座標

が取り出されていくため、
この経路についての探索の順番はしばらく巡ってこないことになります。

そうやって探索が続き、
(x6,y3)の調査が終わった時点でこのようになっています。



次は(x5,y2)を中心に四方を探索しますが、
(x4,y2) は既にVisitedに登録されていて対象外なので、探索できるのは(x6,y2)のみです。

ところが、
この段階でcostSoFarの(x6,y2)には既に合計コスト6が登録されています。

これは事前に(x6,y3)経由で到達した結果ですが、
(x6,y2)には(x6,y3)経由でも(x5,y2)経由でもコスト6で到達できてしまいます。

この場合、より安いコストに更新されないのであれば、
先に到達した経路が優先されます。

つまり、(x6,y2)への経路は(x6,y3)経由で確定します。

そうやって探索が続いていき、やがてゴールに到達した時、各記録はこんな感じになっているはずです。




ダイクストラの強みと弱み

ダイクストラは、

・ダークゾーンを避ける
・発見済みの罠を避ける
・危険地帯を避ける

といった判断が可能です。

そういう意味では、BFSより賢い探索ができます。

ただし、

ダイクストラも本質的には総当たり探索です。

そのため、

ゴールした時の探索範囲の赤いエリアの様に、

ゴールとは真逆の方向

まで探索してしまいます。

次回はA*へ

ウィザードリィライク程度の広さのダンジョンで、
オート移動用に使うのであれば

・危険地帯を避ける。(罠やダークゾーンを避ける。)

という判断が可能なダイクストラは十分実用的です。

しかし、

・広大なマップ
・リアルタイムで動く敵

などでは無駄な探索による負荷が気になってきます。

そこで次回は、コストの概念に更に

「ゴールの位置を意識する」

という考え方を追加した、

A*(A-star)

について、噛み砕いてみようと思います。








経路探索アルゴリズムを噛み砕いて理解する:BFS(幅優先探索)


ウィザードリィライクなダンジョンRPG。

3Dダンジョンを、一マスずつ歩いて探索していくタイプのRPGでは、
移動そのものがゲームの根幹になる。

ただ、探索が深部に及んでくると、
入口付近との往復は、だんだん「探索」ではなく単なる移動作業になってくる。

だからこそ、

・エレベーターなどによる垂直移動短縮
・自動移動による水平移動の簡略化

は、快適性に直結するかなり重要な要素になる。

という訳で、
AbyssBounderでも、自動移動機能はかなり初期から実装したいと思っていた。

ただ、自動移動には大きく3つの段階がある。

① 経路探索に必要な情報を整理する
② 情報から経路を探す
③ 実際に移動させる

そして以前の記事でも書いた通り、
この「① 情報整理」の部分をずっと放置していたため、
自動移動自体も止まっていた。

しかし前回、
マッピングをTexture2D化したことで、
探索情報をかなり整理できた。

つまり、
ようやく①が終わった。

という訳で、
今回はいよいよ、

「② 情報から経路を探す」

つまり、
迷路探索アルゴリズムの話。


迷路探索アルゴリズム

迷路探索アルゴリズムについては、
しまづさんのハイクラス講義でA*アルゴリズムを学んではいた。

ただ、
理解できたか?と言われると、正直かなり怪しい。

世の解説サイトは、
僕にはちょっと難解すぎてスッと頭に入ってこない。

しかし今は、
しつこく聞いても付き合ってくれるChatGPT先生もいる。

という訳で、
一度、自分なりに噛み砕いて整理してみることにした。

代表的な迷路探索アルゴリズムには、

BFS
Dijkstra’s Algorithm
A*(A-star)

などがある。

そして、
ウィザードリィライクのダンジョンは、

マス区切り構造
斜め移動なし

なので、
迷路探索アルゴリズムとかなり相性が良い。

そして、
これらのアルゴリズムには、実は共通する基本概念がある。

それが、

・これから調べる座標のリスト
・どこの座標から来たかの記録
・調査済み座標の記録

の3つ。

アルゴリズムごとの差は、
「これから調べる座標」をどう評価するか。
そこにある。


BFS(Breadth First Search:幅優先探索)


BFSは、
最も基本的な迷路探索アルゴリズムの1つ。

そして面白いことに、
BFSは座標の“評価”をほぼ行わない。

いわゆる総当たり探索。

つまり、
『ゴールに辿り着いたから、それが経路だ。』
という、
かなり豪快な方法になる。

でも、
その豪快さゆえに、とにかく分かりやすい。

迷路探索アルゴリズムの入門としては、かなり優秀だと思う。

必要なマップ情報も単純。
・通れる
・通れない

これだけ。

サンプルコードはこんな感じになる。

public List<Vector2Int> FindPathBFS(
    int[,] map,//0は通れる。1は通れない。というマップ
    Vector2Int start,//map上のスタート地点
    Vector2Int goal)//map上のゴール地点
{
    int width = map.GetLength(0);//mapのxの長さ
    int height = map.GetLength(1);//mapのyの長さ

    Queue<Vector2Int> queue = new Queue<Vector2Int>();//これがいわゆる、『これから調べる座標のリスト』
    bool[,] visited = new bool[width, height];//これが、『調査済み座標の記録』

    Dictionary<Vector2Int, Vector2Int> cameFrom =
        new Dictionary<Vector2Int, Vector2Int>();//『どこの座標から来たかの記録』

    queue.Enqueue(start);//『これから調べる座標のリスト』の末尾にスタート地点を登録
    visited[start.x, start.y] = true;//スタート地点を『調査済み座標の記録』扱いにする。

    Vector2Int[] dirs =
    {
        Vector2Int.up,
        Vector2Int.right,
        Vector2Int.down,
        Vector2Int.left
    };

    while (queue.Count > 0)
    {
        Vector2Int current = queue.Dequeue();//『これから調べる座標のリスト』の先頭の座標を抽出。

        // ゴール到達
        if (current == goal)
        {
            List<Vector2Int> path =
                new List<Vector2Int>();

            Vector2Int p = goal;

            while (p != start)
            {
                path.Add(p);
                p = cameFrom[p];
            }

            path.Reverse();

            return path;
        }

        foreach (var dir in dirs)
        {
            Vector2Int next = current + dir;

            // 範囲外
            if (next.x < 0 || next.x >= width ||
                next.y < 0 || next.y >= height)
                continue;

            // 壁
            if (map[next.x, next.y] == 1)
                continue;

            // 訪問済み
            if (visited[next.x, next.y])
                continue;

            visited[next.x, next.y] = true;

            queue.Enqueue(next);

            cameFrom[next] = current;
        }
    }

    // 経路なし
    return null;
}



コードを噛み砕いてみる。


サンプルダンジョンはこんな感じ。

int[,] map =
{
    {0,0,0,0,0},
    {0,1,1,0,1},
    {0,0,0,1,1},
    {1,1,0,1,1},
    {0,0,0,0,0}
};

Queueとは何か

Queue<Vector2Int> queue = new Queue<Vector2Int>();

Queueという見慣れない型式だが、
これはFIFO構造を持つデータ型です。

FIFO構造とは

First-In, First-Out

つまり、
「先に入れたものから先に出る」構造。

イメージとしては筒みたいな感じ。

最初に入れたものが最初に取り出される。
逆に、途中のデータを抜き出すのは苦手。

でも、この構造が迷路探索アルゴリズムとめちゃくちゃ相性が良い。

もちろん普通のListでも似たことができるが、
Queueの方が軽いので、わざわざListを使う理由はあまりないと思う。

cameFromの役割

Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();

これが
「その座標に、どこの座標から来たか」
を記録するリストになる。

サンプルダンジョンの例で言うと、

cameFrom[new Vector2Int(1,0)] = new Vector2Int(0,0);

なら、「(1,0)には(0,0)から来た」
という意味になる。

まずはスタート地点を登録する

queue.Enqueue(start);
visited[start.x, start.y] = true;

まずはスタート地点を
・queue(『これから調べる座標のリスト』)
・visited(『調査済み座標の記録』)
の両方に登録する。

「あれ?まだ何も調べてないのにvisited?」
と思うかもしれない。

ただ、ここで言う”調査”とは、
「その座標へどこから到達したか」
を記録する工程。

スタート地点には、
当然「どこから来たか」は存在しない。

だから最初から調査済みの扱いになる。

いよいよ探索開始

まずは、queueの先頭を取り出す。

Vector2Int current = queue.Dequeue();


そして、
まずゴールかどうか確認する。

if (current == goal)

ゴールでなければ、
今度はその座標の四方向を調べる。

Vector2Int[] dirs =
{
    Vector2Int.up,
    Vector2Int.right,
    Vector2Int.down,
    Vector2Int.left
};

    foreach (var dir in dirs)
    {
        Vector2Int next = current + dir;

つまり、
・上(Vector2Int.up:0,1)
・右(Vector2Int.right:1,0)
・下(Vector2Int.down:0,-1)
・左(Vector2Int.left:-1,0)
を順番に足して、
隣接マスをチェックしていく。


まずは範囲外判定

if (next.x < 0 || next.x >= width ||
    next.y < 0 || next.y >= height)
    continue;

次に壁判定

if (map[next.x, next.y] == 1)
    continue;

最後に訪問済み判定

if (visited[next.x, next.y])
    continue;

ここまで通過したなら、
この座標は有効。通行可能な座標という判定になる。

    visited[next.x, next.y] = true;

    queue.Enqueue(next);

    cameFrom[next] = current;
}

つまり、
・visited登録
・queue登録
・cameFrom登録
を行う。

実際の動き

まず、
スタート地点(0,0)+Vector2Int.up(0,1)=(0,1)を判定。
有効座標なので、
・visited登録
・queue登録
・cameFrom登録:[(0,1)]=(0,0)
を行う。

次に右方向
スタート地点(0,0)+Vector2Int.right(1,0)=(1,0)の判定。
こちらも有効座標なので、
・visited登録
・queue登録
・cameFrom登録:[(1,0)]=(0,0)
を行う。

下方向
スタート地点(0,0)+Vector2Int.down(0,-1)=(0,-1)の判定。
範囲外なので無視。

左方向
スタート地点(0,0)+Vector2Int.left(0,-1)=(-1,0)の判定。
こちらも範囲外なので無視。


スタート地点の四方のチェックが終わった時点で各リストはこんな感じになっている。
queue

Visited CameFrom
(0,0) (0,1) <- (0,0)
(0,1) (1,0) <- (0,0)
(1,0)


そして次のループでは、
queueの先頭、
つまり(0,1)が取り出されて、同じ様にチェックしていく。

これを

・queueの中身が空になる
・ゴールに到達する

のどちらかになるまで、ひたすら繰り返すことになる。

ちなみに、「queueの中身が空になる」までチェックしても、ゴールに到達できない場合は
この迷路ではスタート地点からはゴールに辿り着けない。
つまり、「経路なし」ということになる。

ゴール到達後

最終的に、
currentが(4,4)になった時点でゴール到達。
その時点の各リストはこんな感じになっている。
queue:

Visited CameFrom
(0,0) (0,1) <- (0,0)
(0,1) (1,0) <- (0,0)
(1,0) (0,2) <- (0,1)
(0,2) (2,0) <- (1,0)
(2,0) (1,2) <- (0,2)
(1,2) (3,0) <- (2,0)
(3,0) (2,2) <- (1,2)
(2,2) (3,1) <- (3,0)
(3,1) (4,0) <- (3,0)
(4,0) (2,3) <- (2,2)
(2,3) (2,4) <- (2,3)
(2,4) (3,4) <- (2,4)
(3,4) (1,4) <- (2,4)
(1,4) (4,4) <- (3,4)
(4,4) (0,4) <- (1,4)
(0,4)

今度は蓄積されたcameFromから経路を復元していく。

まずは、経路格納用のリスト(path)を作り、

List<Vector2Int> path = new List<Vector2Int>();

cameFromから経路座標を抽出するターゲット座標(p)にゴール座標を指定。

Vector2Int p = goal;

pathの先頭はゴール座標。
次に、cameFromでゴール座標がkeyになっている座標を抽出してターゲット座標(p)に指定。

while (p != start)
{
    path.Add(p);
    p = cameFrom[p];
}
(4,4) <- (3,4) 

なので、次のターゲット座標(p)は(3,4)
更に、

(3,4) <- (2,4) 

これを、whileループでターゲット座標(p)がスタート地点になるまで繰り返す。
すると、pathの中身はこうなる。

path
(4,4)
(3,4)
(2,4)
(2,3)
(2,2)
(1,2)
(0,2)
(0,1)

ただし、これではゴール→スタートという逆の順番になっているので
最後に、

path.Reverse();

でリストの順番を反転させると、
完成した経路になる。


結局、BFSとは・・・

経路は完成した。
ただ、
最初に言った通り、BFSは総当たり探索

つまり、
・ゴールへの近さ
・危険地帯
・通路コスト
といった評価を一切行わない。

本当に、
『ゴールに辿り着いたからそれが経路』
というアルゴリズム。

とはいえ、サンプルダンジョンの様な小規模ダンジョンであればこれで十分。

実際、ウィザードリィライクの様な20×20程度のダンジョンであれば、
通路の四方に壁座標を追加したとしても41×41程度。

この規模ならBFSでも十分実用的だと思う。

ただ例えば、
「ダークゾーンはなるべく避けたい。」
みたいな経路選択はできない。

それでは少し味気ないので、
次回は、BFSに”コスト”の概念を加えた

Dijkstra’sアルゴリズム

を噛み砕いてみようと思います。








Texture2Dでミニマップを作る。

ダンジョンRPGでのミニマップ表示。

昨今のダンジョンRPGでは、ほぼ必須と言って良いこの機能。

作り方については色々な方法があるけれど、僕の「AbyssBounder」では、Imageを表示ドットに見立てて大量に並べ、各表示ドットを個別に表示・非表示・色変更する事でミニマップを表現していた。

AbyssBounderのミニマップは、プレイヤー周辺だけを表示する15×15の小規模なもの。
この程度なら必要なImage数も知れているし、二次元配列(x,y)で管理できるので、実装も直感的で分かりやすい。

なので、当初は「ミニマップ程度ならこの方式で十分だろう」と思っていた。

ただ、その拡大版である「フロア全体マップ」を作ろうとした時、問題に気づいた。

全体マップになると、必要な表示ドット数がミニマップとは比較にならないくらい増えてしまう。

一般的に、UnityのUI用GameObject(Image)は、

* RectTransform
* Image
* CanvasRenderer

などのコンポーネントを持っていて、1オブジェクトあたり数百Byte〜数KB程度のメモリを消費すると言われている。

さらに単純なメモリだけではなく、Unity内部ではヒエラルキー管理やCanvas管理なども行われているため、GameObject数が増えるほど管理コストも増加する。

そして、個人的に一番気になっていたのが描画更新コスト。

UIは、一部の変更でもCanvas全体の再描画が発生する場合がある。

つまり、表示ドットを1個変更するたびにCanvas更新が走る可能性がある。

例えば15×15のミニマップなら225セル。

プレイヤーが一歩歩くたびに、最大225回近いUI更新が発生する可能性がある……という事になる。

とは言え、正直に言えば、昨今の一般的なPCスペックなら、この程度の負荷はそこまで致命的ではないとも思う。

ただ、AbyssBounderは他にも比較的重めの処理(ダンジョン生成や各種マッピング処理など)を行っているので、軽量化できる部分は軽量化しておきたい。

しかし、当時は上手い代替案も思いつかず、全体マップ表示はそのまま放置されていた。

その後、開発が進み、ダンジョン上に罠を実装。

さらに、その罠情報をマッピングへ表示する必要が出てきた。

「どうせマッピング周りを再構築するなら、この際、表示方法も見直そう」

……という流れで、ようやく重い腰を上げる事になった。

まあ、マッピング繋がりという事で、流れとしては悪くないと思っている。


という事で、Texture2D方式

そこで見つけたのが、Texture2Dを使う方法。

これは、GameObjectを大量に並べるのではなく、メモリ上で一枚の画像としてマップを描き、それをTexture2Dへ反映する方式。

つまり、

* GameObject大量生成が不要
* 描画更新は最後に1回だけ
* UI管理コストも大幅減

と、かなり良い事づくし。

せっかくなので、全体マップだけでなく、ミニマップもこの方式へ切り替える事にした。


Texture2Dでの描画の流れ

Texture2Dでマップを描く流れはざっくりこんな感じ。

① 必要サイズのTexture2Dを作る
② 同サイズのColor32配列(Buffer)を作る
③ Bufferへマップを描く
④ BufferをTexture2Dへ適用する

まず、ミニマップの定義を整理する。

* ミニマップは15×15マス
* 通路は18pixel四方
* Grid線は1pixel
* 壁は幅3pixel
* Grid線部分に壁や扉がある場合は上書きする

これを元にTextureサイズを計算すると、

床7枚 + 壁8枚
= 18pixel×7 + 1pixel×8
= 134pixel

つまり、134×134pixelのTexture2Dを作れば良い。
コードはこんな感じ。

[SerializeField] RawImage rawImage;

const int MiniMapSize = 15;
const int GridLinePixelSize = 1;
const int WallDrawPixelSize = 3;
const int FloorPixelSize = 18;

Texture2D tex;
Color32[] buffer;

int textureWidth;
int textureHeight;

void CreateTexture()
{

        textureWidth=0

       forint i=0; i< MiniMapSize; i++)
    {

        textureWidth +=i % 2 == 0
            ? GridLinePixelSize
            : FloorPixelSize;

    }

        textureHeight=textureWidth

        tex = new Texture2D(
            textureWidth,
            textureHeight,
            TextureFormat.RGBA32,
            false
        );

        tex.filterMode = FilterMode.Point;
        tex.wrapMode = TextureWrapMode.Clamp;

        buffer = new Color32[
            textureWidth * textureHeight
        ];

        rawImage.texture = tex;
}



Texture2DとBuffer座標

ここで少し厄介なのが、Texture2DとBufferの座標関係。

Texture2Dは二次元座標で扱うが、Color32配列(Buffer)は一次元配列になっている。

pixel座標をBuffer座標に変換する式は

Y座標 × Texture横幅 + X座標=Buffer座標
になる。

例えば10pixel四方のTextureなら、
pixel座標1,1は、

Y座標(1)×+Texture横幅 (10)+X座標(1)=11になり、Buffer[11]が該当座標になる。


ミニマップ座標で扱えるようにする

このBufferの中に床や壁を描いていくが、毎回pixel座標を直接扱うのは流石に面倒。

なので、ミニマップ座標→pixel座標変換テーブルを作る事にした。

int[] startX;
int[] startY;

const int MiniMapSize =15;
const int GridLinePixelSize = 1;
const int WallDrawPixelSize = 3;
const int FloorPixelSize = 18;

void PositionTableMaking()
{
     startX = new int[MiniMapSize];
     startY = new int[MiniMapSize];

     int currentX = 0;

     for (int x = 0; x < MiniMapSize; x++)
     {
         startX[x] = currentX;
         currentX += GetCellWidth(x);
    }

     int currentY = 0;

     for (int y = 0; y < MiniMapSize; y++)
     {
         startY[y] = currentY;
         currentY += GetCellHeight(y);
    }
}


int GetCellWidth(int x)
{
     return x % 2 == 0
         ? GridLinePixelSize
         : FloorPixelSize;
}

int GetCellHeight(int y)
{
     return y % 2 == 0
         ? GridLinePixelSize
         : FloorPixelSize;
}

これでstartX[x] 、startY[y]の形でミニマップ座標を指定すれば、pixel座標を簡単に取得でき、このpixel座標を始点に床や壁を描けば良くなる。

つまり、ミニマップ座標7,7に床を描きたければ、
startX[7] 、startY[7]に格納されているpixel座標を始点にxy方向にFloorPixelSize = 18pixelの正方形を描けば良い。

さらに、

* x,y両方奇数 → 床
* どちらか偶数 → 壁

というルールにしておくと、床か壁かの判定も非常に楽になる。


実際の描画

実際の描画はこんな感じ。

public void DrawCell(
        int miniX,
        int miniY,
        Color32 color)
{
    int width = GetCellWidth(miniX);
    int height = GetCellHeight(miniY);

    int pixelX = startX[miniX];
    int pixelY = startY[miniY];

    DrawRect(
        pixelX,
        pixelY,
        width,
        height,
        color
    );
}

void DrawRect(
    int x,
    int y,
    int width,
    int height,
    Color32 color
)
{
    for (int py = y; py < y + height; py++)
    {
        for (int px = x; px < x + width; px++)
        {
            if (
                px < 0 ||
                px >= textureWidth ||
                py < 0 ||
                py >= textureHeight
            )
            {
                continue;
            }

            buffer[
                py * textureWidth + px
            ] = color;
        }
    }
}

ミニマップ座標7,7を白にしたければ、

DrawCell(7,7,new color32(255,255,255,255))


grid線描画の仕組みは割愛したけれど、
これで、Buffer上の目的の座標に描画情報を描く事ができた。

そして最後に、

tex.SetPixels32(buffer);
tex.Apply();

を呼ぶ事で、Buffer内容がTexture2Dへ反映され、GPUへ描画更新が通知される。


Texture2D方式のメリット

この方式のメリットは、やはり負荷軽減。

従来のGameObject方式では、

* 常時225個のUIオブジェクト
* 一歩ごとに大量のCanvas更新

が発生していた。

それがTexture2D方式では、

* GameObjectはRawImage1枚
* 描画更新もtex.Apply()時の1回

だけになる。

かなり大きい。

デメリットを挙げるなら、座標計算が少し直感的ではない事くらいだろうか。

ただ、その辺りはラッパー関数や座標テーブルを作ればかなり改善できるし、慣れてしまえばそこまで問題にはならなそう。

良い方式を学べたので、今後色々応用できそうだ。

やはり、Unity C#はまだまだ奥深いなあ。








Abyss Bounder制作ログ:ChatGPTでモンスターを描く:ブライトゲイザー

自作のダンジョンRPG「Abyss Bounder」
開発は着々と進んでいますが、そろそろモンスター画像が大量に必要になってきました。

デザイナーさんに伝手があれば良いのですが、残念ながら身近に知己がいないので、ひとまず生成AIに頼ることにしています。


AI画像生成とリスクの話


AI生成は基本的に学習データに依存します。
つまり、既に世に公開されている作品に“似たもの”になる可能性はどうしてもあります。

さらにモンスターというジャンル自体、
例えばゴブリンなら「それっぽさ」を出すほど似てしまう、という難しさがあります。

実際、ファンタジー界で有名な“目玉+触手のあのモンスター”なんかは、名前すら扱いに気を遣うレベルだったりしますよね。

なので、

- 既存作品に似すぎていないか
- 意図せず元ネタ感が出ていないか

このあたりは最低限チェックしています。

具体的には、Googleレンズなどの画像検索にかけて軽く確認するくらいですが、

何もやらないよりはだいぶマシかなと思っています。

ついでに、後から見返せるように

「どういう指示でこの絵になったのか」

というログも残しておくことにしました。


AI画像は一発では出てこない


「AIで画像を作った」と言うと、

一発でポンと完成品が出てくるように見えるかもしれませんが、実際はそんなことはありません。

どちらかというと、

ラフを見ながら、横で絵描きさんに修正をお願いしていく感覚

に近いです。


ゴーレム(うまくいった例)


まずはゴーレム系。

大型モンスターを出したかったので、少し横長のキャンバス指定で描いてもらいました。

これはかなりあっさりイメージ通りの絵が出てきた、嬉しいパターンです。
(ChatGPTの仕様かログがそのまま公開できず、スクリーンショットなのでちょっと見づらいです)

背景を最初から透明にしているのは、後加工が意外とうまくいかず、泣く泣くボツにした絵が何枚かあったためですw

あと、苔がちょっと髪の毛っぽく見える感じとか、
こういう「意図していなかったけど良い感じになる」パターンもあります。

ただ、この手の“良い感じの偶然”は、逆に言うと
既存作品に似てしまうリスクと表裏一体でもあるのかな、とは思っています。

ですので凄く気に入ってはいますが、最終的に製品版で使うかはちょっと未定です。


触手モンスター(苦戦した例)


次は石化系モンスターの実験も兼ねて、触手系のデザイン。

これがかなり苦戦しました。
途中ちょっと言葉が荒くなったりもしてます…w
元ネタのイメージはチンアナゴ。
地面から触手がにょきっと出ている感じです。

この手のモンスターはどうしても“あの有名なやつ”に引っ張られるので、

- 浮遊させない
- 中央の本体を作らない

- 触手はバラバラ

といった感じで、意識的に差別化しています。

あと地味に大変だったのが「触手の本数」。

今回は6本固定にしたのですが、これが途中で

「たくさんの触手」

に勝手に解釈されがちで、なかなか安定しません。

最終的には
「触手は6本。“6本は絶対条件”。”「たくさんの触手」という抽象表現ではなく6本”。」
とかなり強めに指定して、ようやく形になりました。

ちなみに、チャットを途中で分けているのは、
同じやり取りを繰り返して詰まったときにリセットすると、うまくいくことが多いからです。

兎にも角にも、この子はかなりお気に入りです。
Googleレンズでも類似は見つからなかったので、この子は採用になるでしょう。

石化を使わせたいので、、、それっぽい名前。
石化を大地の呪い的に捉えると、闇、復讐、奈落、堕落、腐敗・・・

腐敗(Blight)+睨む者(Gazer)で、Blightgazer(ブライトゲイザー)かな。


AIは万能ではないけど便利


もちろん、人間のイラストレーターさんのように
意図を完全に汲み取ってくれるわけではありません。

同じミスを繰り返すことも普通にあります。

ただ、

- 条件を整理する
- 絶対に外せないポイントを明確にする

このあたりをしっかりやると、ちゃんと使える素材は出てきます。

個人開発的には、このスピード感はかなりありがたいです。


ただし最終チェックは必要


AIで作った画像を使う場合でも、

- 既存作品に似すぎていないか
- ゲーム内でテイストが浮いていないか

このあたりの確認は必須です。

特にAbyss BounderはレトロなダンジョンRPG寄りなので、
画像が綺麗すぎたり、写真っぽすぎたりすると逆に浮きます。

最近は「それっぽい雰囲気」はかなり合わせてくれるのですが、
最後の調整はやっぱり人間側の仕事ですね。


まとめ


AI画像生成は、うまく使えば個人開発の強い味方です。

ただし完全に任せるのではなく、

世界観や仕様は自分で決めて、その範囲内でAIに描かせる

という使い方がしっくり来ています。

Abyss Bounderでは、今後もこの形でモンスター素材を少しずつ増やしていく予定です。

とはいえ……

腕の良いデザイナーさん、どこかにいませんかねぇ…w











Unity+JSONセーブのセキュリティ強化(バイナリ化+AES暗号化)

Unity+JSONのセーブ/ロードについては、前回の記事で「暗号化を行わずに保存する方法」をまとめました。

ただ、この状態だと 保存場所さえ分かれば誰でも簡単に内容を読めてしまい、さらに書き換えも可能 です。
ゲームのセーブデータとしては、正直ちょっとお粗末と言わざるを得ません。

そこで今回は、セキュリティレベルをもう一段階上げるために
・バイナリデータ化
・暗号化(AES)
を組み合わせた方法についてまとめてみようと思います。


まずはバイナリデータ化


バイナリ化の方法はいくつかありますが、オーソドックスなのは UTF-8バイト配列に変換する方法でしょう。

UTF-8とは、ASCIIコードより多くの文字を扱える文字コード方式です。
実は有名なASCIIはUTF-8の一部です。

例えば、

文字 UTF-8
A 41
B 42
という具合です。

例えば

{"name":"Hero","level":10}

というJSONデータは、UTF-8に変換すると

7B 22 6E 61 6D 65 22 3A 22 48 65 72 6F 22 2C 22 6C 65 76 65 6C 22 3A 31 30 7D

のようなバイト列になり、この時点で、セーブファイルを直接見ても
普通の人には簡単には読めなくなります。


UTF-8化のコード


UTF-8への変換はとてもシンプルです。

using System.Text;
using UnityEngine;

string json = JsonUtility.ToJson(data);

// UTF-8バイト配列に変換
byte[] utf8Bytes = Encoding.UTF8.GetBytes(json);

たったこれだけです。

逆に元に戻す場合は

string json = Encoding.UTF8.GetString(utf8Bytes);

これだけで復元できます。


バイナリ化だけでは弱い

UTF-8化する事でパッと見は読めなくなりましたが、
例えばバイナリエディタなどのアプリを使うだけで、簡単に内容を確認できてしまいます。

そのため、さらに暗号化を加えてセキュリティを強化します。


AES暗号化

暗号化の方式はいろいろありますが、今回は例として AES暗号を使います。

ここから難易度がグッと上がります。
正直、僕自身も完全に理解しているとは言えない部分もあるので、もし微妙に違っていたらご容赦ください。


AESの準備

まずAES暗号の準備として、次の3つを用意します。
・パスワード
・Salt
・IV


パスワード

まずはパスワードを決めます。
例えば

abyss bounder password

のような文字列です。
このパスワードが一番重要な鍵になります。
AES暗号では後述する色々な要素が加わって暗号化されますが、
結局のところ、このパスワードが守られている事が大前提になります。


Salt

Saltとは、パスワードからAES鍵を作るときに使うランダム値です。

Saltを使うことで
同じパスワードでも毎回違うAES鍵を生成できるようになります。


IV(初期ベクトル)

IVはAES暗号化の初期値として使われるランダムデータです。

AESではデータをブロック単位で暗号化します。
ブロック?初期ベクトル?と、いきなり???です。
ここら辺は後ほど説明しますが、今は簡単に言ってしまえば、これを混ぜ込む事で、 同じデータを暗号化しても毎回違う暗号結果になる
くらいに考えておけば大丈夫です。


SaltとIVの生成

これらの生成はとてもシンプルです。

private const byte FormatVersion = 1;

private const string Password = "TEST PASSWORD";
private const int Pbkdf2Iterations = 100000;
private const int SaltSize = 16;
private const int IvSize = 16;
private const int AesKeySize = 32;

byte[] salt = RandomBytes(SaltSize);
byte[] iv = RandomBytes(IvSize);

乱数生成は以下のコードです。

private static byte[] RandomBytes(int length)
{
    byte[] b = new byte[length];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(b);
    return b;
}

AES鍵生成(PBKDF2)

次に、Saltとパスワードを使ってAES鍵を生成します。
ここで使うのがPBKDF2アルゴリズムです。

PBKDF2は
・パスワード
・Salt
・ハッシュ関数
を使って、大量の反復計算を行いながら鍵を生成するアルゴリズムです。

今回のコードでは

100000回

計算を繰り返します。

10万回というと人間感覚ではとんでもない数字ですが、
コンピュータにとっては普通の処理です。

ちなみに現在の推奨は

100000〜600000回

程度と言われていますので、なんと100000回は最低ランクだったりします。


AES鍵導出

これも実装は非常にシンプルです。

byte[] aesKey = DeriveAesKey(Password, salt);
private static byte[] DeriveAesKey(string password, byte[] salt)
{
    using var kdf = new Rfc2898DeriveBytes(
        password,
        salt,
        Pbkdf2Iterations,
        HashAlgorithmName.SHA256
    );

    return kdf.GetBytes(AesKeySize);
}

たったこれだけです。

ここまでで暗号化に必要なAES鍵、AES暗号化で使う初期ベクトルIV
まで準備できましたが、ここでAES暗号についての理解をもう一段階深めておく事にします。


CBCモード

AESにはいくつかの暗号モードがありますが、
今回使うのは CBCモード(Cipher Block Chaining)です。

AESのようなブロック暗号は

16バイト単位

で暗号化されます。

もし単純に暗号化(ECBモード)すると

情報1 → 暗号1
情報2 → 暗号2
情報3 → 暗号3

になります。

このとき

情報1 = 情報3

なら

暗号1 = 暗号3

になってしまいます。
つまり
同じデータは同じ暗号になる
という弱点があります。


CBCモードの仕組み

この対策としてでてくるのが、CBCモードと、簡単に説明した初期ベクトルIVです。
CBCモードではECBモードとは異なり、情報ブロック単体では暗号化せず、直前の暗号ブロックとXORしてから暗号化します。
そして一番最初の暗号ブロック生成時に「直前の暗号ブロック」として使われるのが、初期ベクトルIVになるのです。

       IV
       ↓
情報1 XOR IV  → AES → 暗号1
       ↓
情報2 XOR 暗号1 → AES → 暗号2
       ↓
情報3 XOR 暗号2 → AES → 暗号3

この仕組みによって
・同じデータでも暗号が変わる
・ブロックが連鎖する
という特徴が生まれます。


AES暗号化コード

ここまでの暗号化理論もUnityでは、たった数行のコードで実装できてしまいます。

byte[] cipherBytes = AesCbcEncrypt(utf8Bytes, aesKey, iv);
private static byte[] AesCbcEncrypt(byte[] plain, byte[] key, byte[] iv)
{
    using var aes = Aes.Create();
    aes.Mode = CipherMode.CBC;
    aes.Padding = PaddingMode.PKCS7;
    aes.KeySize = 256;
    aes.Key = key;
    aes.IV = iv;

    using ICryptoTransform enc = aes.CreateEncryptor();
    return enc.TransformFinalBlock(plain, 0, plain.Length);
}

ファイル保存

暗号化された情報をファイルに保存するわけですが、
暗号鍵の材料はランダム生成されたものなので、この材料も保存しておかなければなりません。
・Salt
・IV
をセーブデータに含めて保存します。

byte[] fileBytes = Combine(
     new byte[] { FormatVersion },
     salt,
     iv,
     cipherBytes);
           
File.WriteAllBytes(filePath, fileBytes);
private static byte[] Combine(params byte[][] arrays)
{
   int len = 0;
   for (int i = 0; i < arrays.Length; i++)
   len += arrays[i].Length;

   byte[] result = new byte[len];
   int pos = 0;

   for (int i = 0; i < arrays.Length; i++)
   {
      Buffer.BlockCopy(arrays[i], 0, result, pos, arrays[i].Length);
      pos += arrays[i].Length;
   }

   return result;
}

SaltやIVは暗号化しなくても良いの?

疑問に思うかもしれませんが、これらは単なるランダムデータであり、
これだけで暗号鍵を生成することはできません。

鍵生成には
・パスワード
・反復回数
・ハッシュ関数
などの情報が必要ですが、これらの情報はファイルに保存されないからです。

これで単純にバイナリデータ化した時とは比べ物にならないくらいセキュリティレベルを上げる事ができました。


復号処理

復号は次の流れになります。
1.ファイル読み込み
2.SaltとIV取得
3.AES鍵生成
4.復号
5.JSON復元


ファイル読み込み

byte[] fileBytes = File.ReadAllBytes(filePath);

int offset = 0;

// versionチェック
byte version = fileBytes[offset++];
if (version != FormatVersion)
   throw new CryptographicException($"Unsupported save format version: {version}");

SaltとIV、データの取り出し

byte[] salt = Slice(fileBytes, offset, SaltSize);
offset += SaltSize;
byte[] iv = Slice(fileBytes, offset, IvSize);
offset += IvSize;

// データ部分を読み出して格納
int cipherLen = fileBytes.Length - offset;
if (cipherLen <= 0)
    throw new CryptographicException("Invalid cipher length.");

byte[] cipherBytes = Slice(fileBytes, offset, cipherLen);
private static byte[] Slice(byte[] src, int offset, int length)
{
  byte[] dst = new byte[length];
  Buffer.BlockCopy(src, offset, dst, 0, length);
  return dst;
}

AES鍵生成

暗号鍵生成時のSaltが取り出せれば、暗号化の時と同じ方法で暗号鍵を生成します。
鍵生成には
・パスワード
・反復回数
・ハッシュ関数
が必要で、これらの情報はコード内で定義されています。

byte[] aesKey = DeriveAesKey(Password, salt);

復号

byte[] plainBytes = AesCbcDecrypt(cipherBytes, aesKey, iv);
string json = Encoding.UTF8.GetString(plainBytes);
return JsonUtility.FromJson<T>(json);
private static byte[] AesCbcDecrypt(byte[] cipher, byte[] key, byte[] iv)
{
    using var aes = Aes.Create();
    aes.Mode = CipherMode.CBC;
    aes.Padding = PaddingMode.PKCS7;
    aes.KeySize = 256;
    aes.Key = key;
    aes.IV = iv;

    using ICryptoTransform dec = aes.CreateDecryptor();
    return dec.TransformFinalBlock(cipher, 0, cipher.Length);
}

更に先に

ここまででAES暗号化のセーブロードとしては完成といえます。

テキストをそのまま保存するよりはるかにセキュリティレベルが高いと言えますが、
暗号化の世界はまだまだ果てしなく、実はもう一段階、比較的直ぐに導入できる仕組みがあります。

今の段階でデータは暗号化されています。
しかし、バイナリエディタなどで

7A 8B 9C 21 33 44 ...

というバイナリデータとして読む事は可能です。

もちろん暗号化されているので、

{"gold":100}

という意味を読み取る事はできません。
それでも、
7A 8B 9C 21 33 44 ...を
7A 8B FF 21 33 44 ...に書き換える事は可能なのです。

もちろん、大抵は複合化の時に正しく処理できません。
classに戻す段階かその前にエラー落ちする可能性の方が高いですが、確率的には極小ながら、

{"gold":900}

として成立してしまう可能性は残されます。

HMAC(改竄検知)

今回は詳細は割愛しますが、その可能性を更に下げる
HMAC(Hash-based Message Authentication Code)という仕組みがあります。
簡単に言うと、AES暗号鍵とは別の鍵を使って、保存データのHASH値を計算・一緒に保存。
復号時にHASH値を再生成して保存HASHと同じか比較する事で改竄検知する方法です。
興味があれば調べてみて下さい。

まとめ

JSONセーブや暗号化について色々と調べてみて分かった事ですが、
結局のところ、絶対に突破されない暗号を作成するのは不可能。と言う事らしいです。
コンピュータの速度、AI処理による自動化、といった技術は日々進歩していますので、
むしろ、昔より更に容易になったのかもしれません。

とはいえ、
自作の、しかも課金要素の予定もないゲームのセーブデータとしては、
万が一改竄されたとしても、
そこまでして僕の作ったゲームに取り組んでくれた事に感謝しかないわけで😆

結局のところ、
「何をどのレベルで守りたいのか」
を考えながら導入していくのが良さそうです。











ScriptableObjectによるイベントバス再び・・・ 2026年2月しまづ祭

2月のしまづ祭も無事終了。
youtube.com
……と言いたいところですが、AbyssBounderの進捗は「無事お披露目」とはいきませんでした。

先月に引き続き、バグだらけ。😅

パーティを2人から3人に増やしたことによるメニュー連携バグ。
増員後に一通りチェックしていたつもりだったけれど、やはり甘かった。

さらに宝箱オープン連打バグ。
一応連打防止の仕組みは入れていたのに、それをあっさり突破されるという……。😅

速攻で修正したけれど、やはり他の人に実際に遊んでもらえる機会というのは、本当にありがたい。


■ WebGLとセーブ問題

実は、今回は直前でセーブ機能も盛り込んでいました。

しかし
Application.persistentDataPath と WebGL の組み合わせは、やはり相性があまり良くない様子。
うまくセーブデータを読み込めず、機能していない状態。

絶対パスを指定する方法も考えましたが、
自分の開発環境(Mac)と一般ユーザー環境(Windows)ではパスが異なる。
(Macではc:¥なんて概念じゃないので。)
それでは現実的にテストができない。

残る手段はクラウドセーブ……?

Microsoft PlayFab や Unity Gaming Services といった選択肢はある。
ただ、最終リリース形態はダウンロード版の予定。

お披露目のためだけにクラウドセーブを導入するのも気が進みません。

何より、PlayFabやUGSでのセーブ実装自体まだ未経験。
それがさらに腰を重くする。

ということで、
お披露目環境でのセーブ問題は一旦保留。


■ プレイヤー体験の改善

それよりも優先すべきことがあります。
youtu.be
ここ2ヶ月、しまづさんに遊んでもらって気づいたことですが、
しまづさんは毎回攻撃する敵をクリック選択しようとしていました。

現在の攻撃対象の選択フローはこうなっています。

① 攻撃ボタンをクリック(または決定キー)
② ターゲットカーソルをキーボードで移動 → 決定キー

つまり、①まではマウス操作可能なのに、②はキーボード専用。
現状、クリックだけでは敵を選択できません。

プレイヤーとしては
「そのまま敵をクリックして選びたい」
と感じるのが自然。

実際、
というわけで、まずここを修正。
ついでに、見栄えの悪かったターゲットカーソルも刷新しようと思います。


■ 敵クリック対応

キーボード選択もマウスクリックも、構造的にはこれまで作ってきたメニュー選択と同じ。

要は
「クリックされたことを通知する」
そして
「その結果を監視する」


1️⃣ EnemyClicker 追加

EnemyUnitのGameObjectに追加。
今まで散々作ってきたClicker。これでクリックを検知する。

using UnityEngine;
using UnityEngine.EventSystems;
using Cysharp.Threading.Tasks;

public class EnemyClicker : MonoBehaviour,IPointerClickHandler
{
    [SerializeField] EnemyUnit enemyUnit;

    public void OnPointerClick(PointerEventData eventData)
    {
        if(enemyUnit == null) {
            //Debug.LogError("EnemyUnitがアタッチされていません。");
            return; }
        if(!enemyUnit.TargetSelectMode) {
            //Debug.Log("ターゲット選択モードではありません。");
            return; }

       // Debug.Log("敵がクリックされました。");
        enemyUnit.SendTarget().Forget();
    }
}

2️⃣ EnemyUnit 側

EnemyUnit.csにクリックを受信する関数を追加。

public async UniTask SendTarget()
{
  if (TargetSelectMode)
  {
    battleSystem.attackCursor.Value = cursol_index;
    await UniTask.Yield();
    battleSystem.targetEnemyIndex = cursol_index;
  }
}

3️⃣ BattleSystem 側で監視

で、その状況をBattleSystem.cs側で監視する仕組み。

while (true)
{
  if (Owner.targetEnemyIndex != -99)//EnemyClickerからターゲットが送られてきた場合
  {
    Owner.targets.Clear();
    Owner.targets.Add(Owner.enemyUnits[Owner.targetEnemyIndex-1].GetComponent<EnemyUnit>());
    Owner.TargetSelectMode = false;
    Owner.targetEnemyIndex = -99;
    return Owner.targets;
  }
・
・
・
・
    await UniTask.Yield(PlayerLoopTiming.Update);
}

敵選択については、既存カーソル選択の仕組みがあったので、クリック入力を追加するだけで済みました。


■ 本番はプレイヤー選択

問題は Player 側。
これまでは、
・プレイヤー選択メニュー表示
・カーソル移動+決定キー
・またはボタンクリック

という仕様。
敵側と違ってクリックの仕組みは既にあります。

ただ、テストを重ねる中で感じていたこと。

画面にすでにプレイヤーのステータスパネルが表示されている。
わざわざ別メニューを出すのは煩わしいのでは?
むしろ、そのパネルを直接クリックさせたほうが自然ではないだろうか・・・?


設計の悩み・・・

その場合、
画面に表示されているPlayer(StatusPanel)にターゲットカーソルを追加するのと、
そのターゲットカーソルの監視・制御を誰にさせるかを決める必要があります。

ちなみに、今までのメニュー表示はUI_Managerの管理で、そこにPlayerの情報は全く必要ありませんでした。
(正確にはPlayerの名前は連携していますが....)

しかしながら、今後を考慮すると、Playerの状態によって選択できるPlayerを制限したい場合が出てくると思います。
そうなると、PlayerUnitの状態をチェックできるclass(CharacterUnitManager.cs)に管理させる方が自然です。

Player選択を呼び出す側は、例えば、魔法やアイテムの対象として、つまり、Item_and_Spell_Manager.csになることが多いと思いますが、
問題は、Item_and_Spell_Manager.csはCharacterUnitManager.csを持たせていないので、この連携をどうしようかって話です。

もちろん一番簡単なのは、単純にItem_and_Spell_Manager.csにCharacterUnitManager.cs持たせることですが、
これ以上、class間依存を増やしたくない。(既に十分カオス....)


■ 解決策:ScriptableObjectイベントバス

疎結合化のため、イベントチャネル方式を採用。

using UnityEngine;
using UnityEngine.Events;

[CreateAssetMenu(fileName = "PlayerSelectEventChannel", menuName = "EventChannels/Player Select Request")]
public class PlayerSelectEventChannel : ScriptableObject
{
    public UnityAction OnPlayerSelectEventRaised;
    public void OnPlayerSelectEvent()//PlayerSelectを求めるclassから呼び出される。
    {
        OnPlayerSelectEventRaised?.Invoke();
    }

    public UnityAction OnP_SelectCancelEventRaised;

    public void RaiseCancelEvent()//ChracterUnitManagerでキャンセルが押されたときに呼び出される
    {
        OnP_SelectCancelEventRaised?.Invoke();
    }

    public UnityAction<int> SelectedPlayer;//ChracterUnitManagerで選択されたPlayerのindexを渡すために呼び出される。
    public void RaiseSelectEvent(int index)
    {
        SelectedPlayer?.Invoke(index);
    }
}

これをItem_and_Spell_Manager.csとCharacterUnitManager.csの両方にアタッチ。


CharacterUnitManager 側

CharacterUnitManager.csにPlayer選択に入るためのイベントを登録する。

    private void OnEnable()
    {
        playerSelectEventChannel.OnPlayerSelectEventRaised += PlayerSelectProcess;
    }

    private void OnDisable()
    {
        playerSelectEventChannel.OnPlayerSelectEventRaised -= PlayerSelectProcess;
    }

    void PlayerSelectProcess()
    {
      //ここにPlayer選択の処理を書く。
    }

Item_and_Spell_Manager 側

Player選択を発動したいItem_and_Spell_Manager.cs側に、選択結果を受信するための関数を追加します。

    private void OnEnable()
    {
        playerSelectEventChannel.OnP_SelectCancelEventRaised += PlayerSelectCanseler;
        playerSelectEventChannel.SelectedPlayer += SelectPlayerResister;
    }

    private void OnDisable()
    {
        playerSelectEventChannel.OnP_SelectCancelEventRaised -= PlayerSelectCanseler;
        playerSelectEventChannel.SelectedPlayer -= SelectPlayerResister;
    }

    void SelectPlayerResister(int player_index)
    {
        //PlayerSelectを呼び出すときに、この変数の変化を監視すれば良い。
        SelectPlayerIndex = player_index;
    }

    void PlayerSelectCanseler()
    {
        //-90が帰ってくればキャンセルされたって事にする。
        SelectPlayerIndex = -90;
    }

◼︎処理の流れ

①Player選択を呼び出し。

Player選択をしたいclass(Item_and_Spell_Manager.cs)で
playerSelectEventChannel.OnPlayerSelectEventRaisedを発動。

playerSelectEventChannel.OnPlayerSelectEvent()


②ChracterUnitManager側で受信し、選択処理。

スクリプタブルオブジェクト経由で、
playerSelectEventChannel.OnPlayerSelectEventRaisedに登録されている
CharacterUnitManager.cs側のPlayerSelectProcess関数が実行される。

private void OnEnable()
{
  playerSelectEventChannel.OnPlayerSelectEventRaised += PlayerSelectProcess;//←これが実行される。
}


③結果を通知。

CharacterUnitManager.cs側でのPlayer選択結果(Playerのindex)を
playerSelectEventChannel.SelectedPlayerで通知。

playerSelectEventChannel.SelectedPlayer(2)//2番目のPlayerが選択されたことを通知している。


④Player選択を発動した側で受信。

スクリプタブルオブジェクト経由で、
playerSelectEventChannel.SelectedPlayerに登録されている
Item_and_Spell_Manager.cs側のSelectPlayerResister関数が実行され、Playerのindexを受け取る。

private void OnEnable()
{
  playerSelectEventChannel.SelectedPlayer += SelectPlayerResister;←これが実行される。
}

void SelectPlayerResister(int player_index)
{
  //PlayerSelectを呼び出すときに、この変数の変化を監視すれば良い。
  SelectPlayerIndex = player_index;
}

正直、さらに構造が複雑になった気もするけれど……

少なくとも、見た目と操作体験は大きく改善。
youtu.be
内部設計とプレイヤー体験のバランスは、今後も細めに調整していかないと....