🌊

【ステラソラ】イベントミニゲームのお宝が眠っている確率を算出するツール作ってみた

に公開

結論!

こんな感じで、盤面のどこにお宝が置いてある可能性が高いかを可視化するツールを作りました!
結局ゲームの盤面が増えたりお宝の形が増えたりすることがなかったり、キャラクターのスキルのほうが大事だったりしてほとんど使わずに終わりましたが。。。


はじめに

こんにちは。nemuneko です=^_^=

今回は私がプレイしている「ステラソラ」というゲームに登場する、イベント用ミニゲームの補助ツールを自作した話をします!
結果的には作らなくてもよかったですが、ハニカム構造の処理方法などの知見も得られたのでご紹介します。

それではどうぞ。

ステラソラのミニゲームって?

画面のようにハニカム構造で作られた宝探し系のミニゲームです。
ルールはシンプルで、「シャベル」というアイテムを使ってマスを掘り、隠されたお宝を見つけるという、いわゆるマインスイーパ系のゲームです。
埋まっているお宝は「シグナル探測」で、どんな形で埋まっているかが示唆されています。
他にも、掘り当てる際のお助けとしてキャラクターのスキルがありますが、今回は気にしないため割愛します。

ゲーム画面

作成したいツールの目的

このゲームでは、探すべきお宝の形と個数はステージ開始時に決まっています。ここで、「これらのお宝が、盤面上の配置可能なすべての場所に、一様で分布している」と仮定します。

そう考えると、盤面のマスごとにお宝が存在する確率が均一ではないことに気づきます。
そこで、「マスごとのお宝が眠っている確率の違い」を計算して、ヒートマップのように可視化してくれる補助ツールを作成してみました。

まずはハニカム構造を表現する

そもそもハニカム構造をプログラムでどう表現すればいいのでしょうか?
二次元配列でやるにしても隣接関係をどう表現すればいいのか…それとももっといい表現方法があるのか。

何も知らずに迷走しても仕方がないので、Hexagonal Gridsを参照してみました。
このサイトには、ハニカム構造の扱い方についてさまざまな手法を紹介しています。
今回はその中でも、三変数を用いて管理する「Cube coordinates」を使用して実装していきます!

初めてこの構造を見た時どうなっているのかわからなかったのですが、ベクトルとして捉えれば問題なく処理が可能です。
他にも3軸を意識して座標系を構築すれば処理可能です。

今回はHexというクラスを定義して座標を扱っていきます。

class Hex:
    """立方体座標系(Cube Coordinates)でハニカムの1マスを表現するクラス"""
    q: int
    r: int
    s: int
    # __init__や__eq__, __add__なども定義

あまり使いませんでしたが、一応基底ベクトルとその逆ベクトルも定義しておきました。

class HexDirection(Hex, Enum):
    """ハニカムの6つの方向を定義する方向ベクトル"""
    SOUTH_WEST = (-1, 1, 0)
    SOUTH_EAST = (1, 0, -1)
    SOUTH = (0, 1, -1)
    NORTH_WEST = (-1, 0, 1)
    NORTH_EAST = (1, -1, 0)
    NORTH = (0, -1, 1)

お宝の形を定義するPieceクラスも定義します。

class Piece:
    """
    複数のHexからなるピースを管理するクラス
    形状は原点を基準とした相対座標のセットとして保持する
    """
    name: str
    hexes: frozenset[Hex]

ピースの形は固定であるため、相対座標を用いてあらかじめ定義しておきます

PIECE_CATALOG = {
    "I1": Piece("I1", [Hex(0, 0, 0)]),
    "I2": Piece("I2", [Hex(-1, 1, 0), Hex(0, 0, 0)]),
    "I3": Piece("I3", [Hex(-1, 1, 0), Hex(0, 0, 0), Hex(1, -1, 0)]),
    "TRI": Piece("Triangle", [Hex(0, 0, 0), Hex(1, 0, -1), Hex(0, 1, -1)]),
}

盤面と状態を管理する

ハニカム構造の処理は決まりました。ピースも表現可能です。
次に盤面の表現とその状態をどう管理するかを決めます。
HexGridクラスで盤面を定義し、Hexの状態をCellクラスとの1対1対応で表現します。
Cellクラスにはマスの状態stateと、マスの後ろにお宝が何回あったかをカウントするplacement_countを持たせています。
stateにあるHexStateはお宝が置けるかどうかを判定するためのEnumです。

class Cell:
    """ハニカムグリッドの1マスの状態を管理するクラス"""
    total_count: ClassVar[int] = 1 # 確率を出すときの分母を入れるクラス変数

    state: HexState
    placement_count: int # 配置候補になった回数のカウンター
class HexGrid:
    """ハニカム盤面を管理するクラス"""
    cells: dict[Hex, Cell]
class HexState(StrEnum):
    PLACED = "placed"
    EMPTY = "empty"

ピースの配置を探索する

今回は盤面が小さいため、単純なバックトラック法を用いてお宝の配置を全探索しました。
ここのアルゴリズムについては改良の余地がありそうです。。。

アルゴリズムの全体像は、大きく分けて以下の3ステップで構成されます。

  1. 事前準備: それぞれのお宝ピースについて、盤面に配置可能な全パターンをあらかじめリストアップしておく。
  2. 再帰探索: 1つ目のピースから順番に、他のピースと重ならないように配置していく。もし途中で置けなくなったら、一つ前に戻って別の配置を試す。
  3. 集計: すべてのピースを置ききれたら、それを「有効な配置パターン」として1つカウントし、そのパターンで使われたマスのカウント数を増やしていく。

1. ピースごとの配置可能パターンを列挙する

まず、探索を始める前の下準備です。
お宝A、お宝B、…といった各ピースが、盤面のどの位置に、どの向き(回転)で配置できるのか、そのすべての組み合わせを事前に計算し、リストとして保持しておきます。

例えば「I字型の2マスピース」があれば、それを盤面の(0,0)に横向きで置くパターン、(0,0)に縦向きで置くパターン、(0,1)に横向きで置くパターン…といった具合に、考えうるすべての配置をリストアップします。
これが本文で言うところの find_all_possible_placements(piece)関数が担当します。

2. バックトラック法で配置を探索する

再帰関数を使って、パズルを解くように一つずつピースをはめていきます。

  1. まず、1番目のお宝ピース(ピースA)の配置リストから、パターンを1つ取り出し、盤面に配置したと仮定します。

  2. 次に、2番目のお宝ピース(ピースB)を配置します。このとき、先ほど置いたピースAと座標が重なるような配置パターンはすべて除外します。残った配置パターンの中から1つを取り出し、盤面に配置したと仮定します。

  3. この処理を、用意したすべてのピースがなくなるまで繰り返します。

この方法ですべての有効な配置パターンを網羅的に探索することができます。
重なるような配置を除外する関数はfilter_non_colliding_placementsです。

3. 配置カウントを集計する

バックトラック探索の末、すべてのピースを盤面上に重なることなく配置できたら、お宝が置かれたすべてのマスに対して、「このマスは有効な配置に1回使われた」という記録を付けていきます。
具体的には、各マスの placement_countをインクリメントします。

def solve(
    depth: int,
    all_possible_placements: list[list[frozenset[Hex]]], # 各ピースにおける配置可能なパターンのリスト
    states: list[HexState], 
    placed: set[Hex], # すでに盤面にピースが置かれている座標
    last_depth: int, # 再帰回数の上限(ピースの数)
) -> int:
    # すでに配置されたマスと被っている配置可能候補を削除する
    non_colliding_placements = filter_non_colliding_placements(
        all_possible_placements[depth], placed
    )

    # 最後のピースを置く場合
    if depth == last_depth:
        for hexes in non_colliding_placements:
            for hex in placed.union(hexes):
                # 配置したマスのplacement_countをインクリメント
                grid.cells[hex].placement_count += 1
        return len(non_colliding_placements)

    count = 0
    for hexes in non_colliding_placements:
        new_placed = placed.union(hexes)
        # 再帰的に処理を行う
        count += solve(depth + 1, all_possible_placements, states, new_placed, last_depth)

    return count

これは盤面が7マスで、お宝ピースが三角形型とI型の2つの場合の具体的な実行の様子です。
全パターンを探索しているのが見えると思います。
途中出てきている数字は、その時点でのマスのplacement_countを表示しています。

実際にミニゲームの盤面でやってみる!

まずは盤面を用意する。
盤面はこんな感じで定義しました。めっちゃわかりづらい。。。

o_hex = Hex(0, 0, 0)
grids = [
    o_hex,
    o_hex + HexDirection.NORTH,
    o_hex + HexDirection.NORTH + HexDirection.NORTH_EAST,
    o_hex + HexDirection.NORTH_EAST,
    o_hex + HexDirection.NORTH_EAST + HexDirection.NORTH_EAST,
    o_hex + HexDirection.NORTH_EAST + HexDirection.NORTH_EAST + HexDirection.SOUTH_EAST,
    o_hex + HexDirection.NORTH_EAST + HexDirection.SOUTH_EAST,
    o_hex + HexDirection.NORTH_EAST + HexDirection.SOUTH_EAST + HexDirection.SOUTH_EAST,
    o_hex + HexDirection.SOUTH_EAST,
    o_hex + HexDirection.SOUTH_EAST + HexDirection.SOUTH_EAST,
    o_hex + HexDirection.SOUTH,
    o_hex + HexDirection.SOUTH + HexDirection.SOUTH_EAST,
    o_hex + HexDirection.NORTH + HexDirection.NORTH_WEST,
    o_hex + HexDirection.NORTH_WEST,
    o_hex + HexDirection.NORTH_WEST + HexDirection.NORTH_WEST,
    o_hex + HexDirection.NORTH_WEST + HexDirection.NORTH_WEST + HexDirection.SOUTH_WEST,
    o_hex + HexDirection.NORTH_WEST + HexDirection.SOUTH_WEST,
    o_hex + HexDirection.NORTH_WEST + HexDirection.SOUTH_WEST + HexDirection.SOUTH_WEST,
    o_hex + HexDirection.SOUTH_WEST,
    o_hex + HexDirection.SOUTH_WEST + HexDirection.SOUTH_WEST,
    o_hex + HexDirection.SOUTH + HexDirection.SOUTH_WEST,
]

あとはピースを指定して実行するだけ!

おおーー!どのマスにお宝がありそうかが可視化できました!!!
ちなみに、一部マスを開けた状態で再計算することも可能です!!

まとめ

いやーなんとか作りたいものはできました!
ただ実のところ、このゲームにはキャラクターのスキルやマスごとの耐久度といった概念が存在しており、確率の高いマスを単純にたたき続ければいいわけでもなかったんですよね。。。
さらに言えば、このゲームは盤面の大きさや使われるピースが変わらないため補助ツールを使うまでもないという。。。。
まぁ作りたいものが作れたのでOKですね!
今後さらに複雑で大きな盤面とかが現れた際は、アルゴリズムの見直しも検討しつつ、今回の実装はこれにて終了です。

それでは、また次の記事で。nemunekoでした=^_^=

Discussion