デッキ構築カードゲームの作り方|番外編②エディタのウインドで何千回も戦わせよう

「バトルシミュレーター」のウインド。条件(回数1000・最初のシード1・始める戦い・始めるHP)と「実行」ボタン。実行すると、勝率92.9%、勝ったときのターン数・残りHP、残りHPの分布の青い棒グラフ、負けた戦いの回数が出る。始める戦いを「ボス戦」、HPを40に変えて実行すると、勝率99.8%に変わる

前回は、画面を使わずに戦いを回すシミュレーターを作り、ゲームとまったく同じ結果になることを確かめました。でも、メニューから回すだけだと、条件を変えるたびにシーンの値を書きかえる必要があって、ちょっと不便です・・・。今回は、条件を選んでボタンを押すだけで、何千回も戦わせて結果を表とグラフで見られるエディタのウインドを作ってみたいと思います!

目次

今回作るもの

  • メニューのDeckBuilding>シミュレーター>ウインドを開くで開く「バトルシミュレーター」のウインド
  • 条件:回数・最初のシード・始める戦い・始めるHP
  • 結果:勝率、勝ったときのターン数と残りHP、残りHPの分布(棒グラフ)、どの戦いで負けたか、かかった時間
  • 1回ずつの結果をCSVに書き出す
スクリプト役割置く場所
BattleSimulatorWindowシミュレーターのウインドDeckBuilding/Editor

前回作ったシミュレーター(SimRunなど)と、シーンから条件を読み込むSimMenu.ReadSceneを、そのまま使います。

1戦目から1000回実行したあとのウインド。勝率92.9%(929/1000回)、勝ったときのターン数は平均30.5、残りHPは平均18.2・いちばん少ないとき1、かかった時間155ミリ秒。残りHPの棒グラフは10〜19と20〜29が長い。負けた戦いは3戦目3回・ボス戦68回

エディタのウインドの作り方

エディタのウインドは、EditorWindowを受け継いだクラスで作ります。Inspectorにボタンを足す方法は「Unityエディタ拡張: インスペクタにボタンを追加する方法」で紹介しましたが、ウインドも考え方は同じです。

  • [MenuItem("…")]を付けたメソッドから、GetWindow<クラス>("タイトル")で開く
  • ウインドの中身はOnGUIに書く
    • OnGUIは、マウスを動かしたり、欄に入力したりするたびに何度も呼ばれて、毎回描き直されます
    • そのため、「ボタンを押したか」はif (GUILayout.Button(…))で、その場で調べます
  • EditorGUILayoutの部品(数字の欄、スライダー、選ぶ欄、文字)を上から順に呼ぶと、上から順に並びます

Editorフォルダに置くのを忘れずに。EditorWindowはUnityEditorの機能なので、Editorフォルダの外に置くと、ビルドのときにエラーになります(前回のSimMenuと同じです)。

ウインドを作ろう

  • DeckBuilding/Editorに「BattleSimulatorWindow」スクリプトを作る
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

// バトルのシミュレーターのウインド:条件を選んで何千回も戦わせ、結果を表とグラフで見る
public class BattleSimulatorWindow : EditorWindow
{
    // 条件
    private int runs = 1000;       // 回数
    private int firstSeed = 1;     // 最初のシード(ここから1ずつ増やす)
    private int startBattle = 0;   // 何番目の戦いから
    private int startHp = 0;       // 始める HP(0なら満タン)

    // 結果
    private List<SimResult> results = new List<SimResult>();
    private SimSetup lastSetup;
    private long elapsedMs;

    private static readonly Color barColor = new Color(0.36f, 0.61f, 0.88f);

    [MenuItem("DeckBuilding/シミュレーター/ウインドを開く")]
    private static void Open()
    {
        GetWindow<BattleSimulatorWindow>("バトルシミュレーター");
    }

    // ウインドの中身を描く(表示が変わるたびに何度も呼ばれる)
    private void OnGUI()
    {
        EditorGUILayout.LabelField("条件", EditorStyles.boldLabel);
        runs = EditorGUILayout.IntSlider("回数", runs, 1, 10000);
        firstSeed = Mathf.Max(1, EditorGUILayout.IntField("最初のシード", firstSeed));
        startBattle = EditorGUILayout.Popup("始める戦い", startBattle, new[] { "1戦目", "2戦目", "3戦目", "ボス戦" });
        startHp = EditorGUILayout.IntSlider("始める HP(0で満タン)", startHp, 0, 60);

        if (GUILayout.Button("実行", GUILayout.Height(32)))
        {
            Run();
        }

        if (results.Count == 0)
        {
            EditorGUILayout.HelpBox("Battle シーンを開いて、「実行」を押してください。", MessageType.Info);
            return;
        }

        EditorGUILayout.Space();
        DrawSummary();
        DrawHpGraph();
        DrawLostBattles();

        EditorGUILayout.Space();
        if (GUILayout.Button("CSV に書き出す"))
        {
            ExportCsv();
        }
    }

    // シード firstSeed から runs 回、戦わせる
    private void Run()
    {
        SimSetup setup = SimMenu.ReadScene();
        if (setup == null)
        {
            return;
        }
        setup.startBattle = Mathf.Min(startBattle, setup.battles.Count - 1);
        setup.startHp = startHp;

        results.Clear();
        SimPolicy policy = new SimBasicPolicy();
        System.Diagnostics.Stopwatch watch = System.Diagnostics.Stopwatch.StartNew();
        for (int i = 0; i < runs; i++)
        {
            results.Add(SimRun.Play(setup, firstSeed + i, policy));
        }
        watch.Stop();
        elapsedMs = watch.ElapsedMilliseconds;
        lastSetup = setup;
    }

    // 勝率・ターン数・残り HP
    private void DrawSummary()
    {
        int wins = 0;
        int winTurns = 0;
        int winHp = 0;
        int minHp = int.MaxValue;
        foreach (SimResult result in results)
        {
            if (result.won)
            {
                wins++;
                winTurns += result.turns;
                winHp += result.finalHp;
                minHp = Mathf.Min(minHp, result.finalHp);
            }
        }

        EditorGUILayout.LabelField("結果", EditorStyles.boldLabel);
        EditorGUILayout.LabelField("勝率", $"{wins * 100f / results.Count:F1}%({wins} / {results.Count}回)");
        if (wins > 0)
        {
            EditorGUILayout.LabelField("勝ったときのターン数", $"平均 {(float)winTurns / wins:F1}");
            EditorGUILayout.LabelField("勝ったときの残り HP", $"平均 {(float)winHp / wins:F1} いちばん少ないとき {minHp}");
        }
        EditorGUILayout.LabelField("かかった時間", $"{elapsedMs} ミリ秒");
    }

    // 勝ったときの残り HP を、10ずつの区切りで棒グラフにする
    private void DrawHpGraph()
    {
        int binCount = 7; // 1〜9、10〜19、…、60
        int[] bins = new int[binCount];
        int max = 1;
        foreach (SimResult result in results)
        {
            if (result.won)
            {
                int bin = Mathf.Min(result.finalHp / 10, binCount - 1);
                bins[bin]++;
                max = Mathf.Max(max, bins[bin]);
            }
        }

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("勝ったときの残り HP(回数)", EditorStyles.boldLabel);
        for (int i = 0; i < binCount; i++)
        {
            string label = i == binCount - 1 ? "60" : $"{i * 10}〜{i * 10 + 9}";
            Rect row = EditorGUILayout.GetControlRect();
            Rect labelRect = new Rect(row.x, row.y, 70f, row.height);
            Rect barArea = new Rect(row.x + 75f, row.y + 2f, row.width - 130f, row.height - 4f);
            Rect bar = new Rect(barArea.x, barArea.y, barArea.width * bins[i] / max, barArea.height);
            EditorGUI.LabelField(labelRect, label);
            EditorGUI.DrawRect(barArea, new Color(0f, 0f, 0f, 0.15f)); // 棒の背景
            EditorGUI.DrawRect(bar, barColor);                        // 棒
            EditorGUI.LabelField(new Rect(barArea.xMax + 5f, row.y, 50f, row.height), bins[i].ToString());
        }
    }

    // どの戦いで負けたか
    private void DrawLostBattles()
    {
        if (lastSetup == null)
        {
            return;
        }
        int[] lost = new int[lastSetup.battles.Count];
        foreach (SimResult result in results)
        {
            if (result.lostBattle >= 0)
            {
                lost[result.lostBattle]++;
            }
        }

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("負けた戦い(回数)", EditorStyles.boldLabel);
        for (int i = lastSetup.startBattle; i < lost.Length; i++)
        {
            EditorGUILayout.LabelField(lastSetup.battles[i].battleName, lost[i].ToString());
        }
    }

    // 1回ずつの結果を、プロジェクトの Simulations フォルダに CSV で書き出す
    private void ExportCsv()
    {
        string folder = Path.Combine(Application.dataPath, "..", "Simulations");
        Directory.CreateDirectory(folder);
        string path = Path.Combine(folder, $"sim_{System.DateTime.Now:yyyyMMdd_HHmmss}.csv");

        StringBuilder csv = new StringBuilder();
        csv.AppendLine("seed,won,lostBattle,turns,finalHp,rewards");
        foreach (SimResult result in results)
        {
            csv.AppendLine($"{result.seed},{result.won},{result.lostBattle + 1},{result.turns},{result.finalHp},{string.Join(" / ", result.rewards)}");
        }
        // Excel で開いても文字化けしないように、BOM 付きの UTF-8 で書く
        File.WriteAllText(path, csv.ToString(), new UTF8Encoding(true));
        Debug.Log("書き出しました:" + Path.GetFullPath(path));
        EditorUtility.RevealInFinder(path);
    }
}

上から順に見ていきます。

条件の欄

  • IntSlider("回数", runs, 1, 10000):スライダーと数字の欄。戻り値を同じ変数に入れ直すと、入力した値が残ります
  • Popup("始める戦い", startBattle, new[] { … }):選ぶ欄。選んだのが何番目かが返ってきます
  • Mathf.Max(1, …):最初のシードが0以下にならないようにします(0は「シードなし」なので、毎回ちがう結果になってしまいます)

実行

  • SimMenu.ReadScene()で、開いているBattleシーンから戦いの並び・デッキ・報酬の候補を読み込む
  • 始める戦い・始めるHPだけ、ウインドで選んだ値に上書きする
    • シーンのRunManagerの値は書きかえないので、ゲームの設定はそのままです
  • シードを1つずつ増やしながらSimRun.Playを回し、結果をresultsのリストにためる

結果の表

  • LabelField("勝率", "…"):左に名前、右に値の2列で表示されます
  • 勝ったときだけのターン数と残りHPを、合計して回数で割って平均にします
    • いちばん少ない残りHPも見ると、「ぎりぎりの勝ち」がどれぐらいあるか分かります

残りHPの棒グラフ

  • 残りHPを10ずつに区切って、それぞれ何回あったかを数える(finalHp / 10で何番目の区切りかが分かります)
  • EditorGUILayout.GetControlRect()で1行分の場所をもらい、その中を「区切りの名前」「棒」「回数」に分ける
  • EditorGUI.DrawRect(場所, 色)で、四角を塗る
    • いちばん多い区切りを棒の長さいっぱいにして、ほかはその割合の長さにします
    • 後ろにうすい四角を先に描いておくと、棒の長さの「満タン」が分かりやすくなります

勝率だけだと「勝てるかどうか」しか分かりませんが、残りHPの分布を見ると「どれぐらい余裕があるか」が分かります。0〜9の棒が長ければ、ぎりぎりの勝ちが多い=少しの運で負けに変わる難しさです。

負けた戦い

  • 負けた戦いの番号(lostBattle)ごとに数える
  • どこで負けているかが分かると、どの戦いの数字を直せばいいかが分かります(#15で3戦目のぬいぐるみを直したときのように)

CSVに書き出す

  • プロジェクトのフォルダの中にSimulationsフォルダを作り、日時の付いた名前で書き出す
    • Application.dataPathはAssetsフォルダの場所なので、".."で1つ上(プロジェクトのフォルダ)にしています。Assetsの外なので、Unityに読み込まれません
  • 1行目が見出し、2行目からは1回ずつ「シード・勝ったか・負けた戦い・ターン数・残りHP・取った報酬」
    • 負けた戦いは、何戦目かの番号(勝ったときは0)
  • new UTF8Encoding(true):先頭に印(BOM)を付けたUTF-8。Excelで開いても、日本語が文字化けしません
  • EditorUtility.RevealInFinder:書き出したファイルを、エクスプローラーで開いて見せてくれます

gitなどでプロジェクトを管理している場合は、Simulationsフォルダを.gitignoreに足しておくと、実行するたびにCSVが増えて混ざることがありません。

動かして確認!

  • Battleシーンを開いて、メニューのDeckBuilding>シミュレーター>ウインドを開く
  • 条件を選んで実行
開いたばかりのバトルシミュレーターのウインド。条件の欄(回数1000、最初のシード1、始める戦い1戦目、始めるHP 0)と「実行」ボタン、「Battleシーンを開いて、「実行」を押してください。」の案内

いくつかの条件で、1000回ずつ回してみました(デッキは最初の10枚、#15で直した数字)。

条件勝率かかった時間
1戦目から通し==orange92.9%==約0.2秒
ボス戦から、HP 40==orange99.8%==約0.07秒
ボス戦から、HP 60100%約0.06秒
始める戦いを「ボス戦」、始めるHPを40にして実行したウインド。勝率99.8%(998/1000回)。残りHPの棒グラフは10〜29あたりが長い

おもしろいのは、ボス戦だけをHP 40で始めるとほぼ必ず勝てるのに、1戦目から通すと7%ほど負けることです。負けた戦いを見ると、ほとんどがボス戦でした。通しで遊ぶと、3戦の間に取った報酬のカードがデッキに入っていて、HPもボス戦の前に40より少ないことがあります。どのカードを取ったときに負けやすいのかは、CSVの報酬の列を見ると調べられます。次回、もう少しくわしく見てみます。

書き出したCSVは、こんな形です。

seed,won,lostBattle,turns,finalHp,rewards
1,True,0,29,12,分度器ブーメラン / えんぴつをけずる / えんぴつをけずる
2,True,0,28,35,コンパスでチクッ / ふでばこの壁 / ハサミでチョキチョキ
3,True,0,32,26,分度器ブーメラン / ふでばこの壁 / ホチキスでパチン
4,True,0,30,5,ホチキスでパチン / ハサミでチョキチョキ / えんぴつをけずる
  • シード1と4は、前回ゲームと照らし合わせた結果と同じです
何回回せば十分?

1000回で勝率92.9%のとき、もう一度ちがうシードで1000回回しても、勝率はだいたい±1.6%の幅に入ります(回数を4倍にすると、幅は半分になります)。「90%か95%か」を見分けたいなら1000回、「92%か93%か」まで見分けたいなら1万回ぐらいが目安です。1万回でも数秒なので、迷ったら多めに回しましょう。

番外編②の動作確認

  • メニューのDeckBuilding>シミュレーター>ウインドを開くで、ウインドが開く
  • Battleシーンを開いていないと、Consoleに「Battle シーンを開いてから実行してください」と出る
  • 1000回を実行すると、1秒かからずに勝率・ターン数・残りHP・棒グラフ・負けた戦いが出る
  • 始める戦い・始めるHPを変えると、結果が変わる。シーンのRunManagerの値は変わらない
  • 「CSVに書き出す」で、プロジェクトのSimulationsフォルダにCSVが出来て、エクスプローラーで開く
スライム

通しの方が負けやすいなんて、ボス戦だけ試していたら気づきませんでした・・・

フェアリー

それがシミュレーターのいいところ!次回は、数字を変えながら比べて、どこを直せばいいかを探してみよう

あとがき

今回は、エディタのウインドを作って、条件を選ぶだけで何千回も戦わせて、勝率や残りHPの分布を見られるようにしました。ボス戦だけを試すのと、1戦目から通すのとで結果がちがうことも分かりましたね。次回は最終回。アセットを書きかえずに敵の数字を上書きして比べ、#15の調整を勝率で振り返りながら、バランスの決め方をまとめたいと思います!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメント一覧 (1件)

コメントする

目次