デッキ構築カードゲームの作り方|番外編③数字を変えて比べ、バランスを決めよう

バトルシミュレーターのウインド。条件と「考え方」、「上書き(アセットは変わりません)」の欄(ボスのHP・敵の攻撃%・休んで回復するHP)。「ボスのHPを40〜120で比べる」を押すと、HP 40で99.0%からHP 120で36.4%まで、ボスのHPごとの勝率が青い棒グラフで並ぶ

前回は、エディタのウインドで何千回も戦わせて、勝率や残りHPの分布を見られるようにしました。最終回の今回は、アセットを書きかえずに数字だけを変えて比べる「上書き」と、ボスのHPを一気に比べる機能を足します。#15で手さぐりで決めたボスのHPを勝率で振り返り、カードの選び方(考え方)でも結果が大きく変わることを確かめて、バランスの決め方をまとめてみたいと思います!

目次

今回作るもの

  • 上書き:ボスのHP・敵の攻撃(%)・休んで回復するHP。アセットはそのまま
  • ボスのHPを40〜120で比べて、勝率を棒グラフに
  • 報酬で取ったカードごとの勝率
  • 考え方を3つ(ふつう・攻め重視・守り重視)にして比べる
スクリプト直すところ
SimSetup上書きの数字を持つ
SimEnemy・SimBattle・SimRun上書きした数字で戦う
SimBasicPolicy回復・防御のしきい値をプロパティにする
SimAggressivePolicy・SimDefensivePolicy新しく作る(攻め重視・守り重視)
BattleSimulatorWindow上書きの欄、考え方、比べる機能、報酬の表

上書きできるようにする

数字を変えて試すたびにEnemyDataのアセットを書きかえると、元の数字に戻し忘れることがあります。そこで、シミュレーターの中だけで数字を差しかえる「上書き」を作ります。

SimSetupに上書きの数字を足す

    public int startHp = 0;     // 0以外なら、その HP から始める

    // ★番外編③ アセットを書きかえずに、数字だけ上書きして試す
    public int bossHp = 0;               // 0以外なら、ボス戦の敵の最大 HP をこの値にする
    public int enemyAttackPercent = 100; // 敵の攻撃を何%にするか
  • 休んで回復するHPは、もともとあるrestHealをそのまま上書きします

SimEnemyとSimBattleで使う

SimEnemyは、最大HPを外から受け取れるようにします。

    // ★番外編③ 最大 HP を外から渡せるようにした(上書きするため)
    public SimEnemy(EnemyData data, int slot, int maxHp)
    {
        Data = data;
        Fighter = new SimFighter(data.enemyName, maxHp);
        Slot = slot;
    }

SimBattleは、上書きの条件(SimSetup)も受け取るようにして、ボス戦の敵のHPと、敵の攻撃を変えます。

    private SimEnemy[] summoned = new SimEnemy[2]; // 手下の場所(EnemyParty と同じ2か所)
    private const int MaxTurns = 200;              // 終わらない戦いを止めるための上限
    private int attackPercent;                     // ★番外編③ 敵の攻撃を何%にするか

    // ★番外編③ 上書きの条件(setup)も受け取るようにした
    public SimBattle(SimFighter player, SimDeck deck, BattleData battle, SimSetup setup)
    {
        Player = player;
        Deck = deck;
        attackPercent = setup.enemyAttackPercent;
        foreach (EnemyData data in battle.enemies)
        {
            int maxHp = battle.isBoss && setup.bossHp > 0 ? setup.bossHp : data.maxHp;
            Enemies.Add(new SimEnemy(data, -1, maxHp));
        }
    }

    // ★番外編③ 敵の次の攻撃(上書きの割合をかけたもの。弱った状態は入れない)
    public int AttackOf(SimEnemy enemy)
    {
        return enemy.NextAction.attack * attackPercent / 100;
    }
  • ボスの判定は、#15で作ったBattleDataのisBossを使います
  • 敵の攻撃はAttackOfで、割合をかけた数字にまとめます。考え方(SimBasicPolicy)も同じメソッドを使うので、「上書きした攻撃が来る」と分かって守れます

敵のターンで攻撃するところと、手下を呼ぶところも直します。

            if (action.attack > 0)
            {
                Player.TakeDamage(enemy.Fighter.UseWeaken(AttackOf(enemy))); // ★番外編③ 割合をかけた攻撃
                summoned[i] = new SimEnemy(data, i, data.maxHp); // ★番外編③

SimRunでは、SimBattleを作るところでsetupも渡します。

            SimBattle battle = new SimBattle(player, deck, setup.battles[i], setup); // ★番外編③ setup も渡す

上書きがすべて「そのまま」(ボスのHP 0・攻撃100%・回復10)のときは、前回までと同じ結果になります。シード1・4で、ゲームと照らし合わせた結果(29ターン・残りHP 12/30ターン・残りHP 5)が変わらないことを確かめておきましょう。

考え方を3つにする

カードの選び方を変えると、同じデッキでも勝率が変わるはずです。ふつうの考え方の「どこで回復するか」「どれぐらいの攻撃なら守るか」を、子クラスで変えられるようにします。

SimBasicPolicyの上の方に、2つのプロパティを足します。

    // ★番外編③ 考え方を変えられるように、数字をプロパティにした(子クラスで上書きする)
    protected virtual int HealBelowHp => 25;  // HP がこれ以下なら回復する
    protected virtual int DefendMargin => 5;  // 来る攻撃が、今の防御よりこれ以上多ければ守る

Pickの中の数字を、このプロパティに置きかえます。来る攻撃も、上書きした攻撃(AttackOf)で考えるようにします。

        if (player.Hp <= HealBelowHp) // ★番外編③
            int attack = battle.AttackOf(enemy); // ★番外編③ 上書きした攻撃で考える
            incoming += enemy.Fighter.Weakened ? attack / 2 : attack;
        }
        if (incoming - player.Block >= DefendMargin) // ★番外編③
            if (battle.AttackOf(enemy) > battle.AttackOf(best)) // ★番外編③
  • 最後のものは、Strongest(攻撃の一番大きい敵)の中です
  • virtual:子クラスで中身を上書きしてもいい、という印です(番外編①のSimPolicyで使ったabstractは「子クラスで必ず書く」、virtualは「書いてもいい」)

Simulationフォルダに、2つの考え方を作ります。

// 攻め重視の考え方:HP がかなり減るまで回復せず、大きな攻撃が来るときだけ守る
public class SimAggressivePolicy : SimBasicPolicy
{
    protected override int HealBelowHp => 15;
    protected override int DefendMargin => 12;
}
// 守り重視の考え方:早めに回復して、少しでも攻撃が来るなら守る
public class SimDefensivePolicy : SimBasicPolicy
{
    protected override int HealBelowHp => 35;
    protected override int DefendMargin => 1;
}
  • 選び方の流れは同じで、数字だけがちがう考え方を、2行のoverrideだけで作れます

ウインドに足す

BattleSimulatorWindowに足していきます。

項目

    private int startHp = 0;       // 始める HP(0なら満タン)

    // ★番外編③ 考え方と上書き
    private int policyIndex = 0;          // 0:ふつう 1:攻め重視 2:守り重視
    private int bossHp = 0;               // 0ならデータのまま
    private int enemyAttackPercent = 100;
    private int restHeal = 10;
    private List<Vector2Int> bossSweep = new List<Vector2Int>(); // ボスの HP ごとの勝率(x:HP、y:勝った回数)
  • Vector2Intは、整数2つの組です。ここでは「ボスのHP」と「勝った回数」の組に使っています

欄とボタン

OnGUIの、始めるHPの下に足します。

        startHp = EditorGUILayout.IntSlider("始める HP(0で満タン)", startHp, 0, 60);
        policyIndex = EditorGUILayout.Popup("考え方", policyIndex, new[] { "ふつう", "攻め重視", "守り重視" }); // ★番外編③

        // ★番外編③ アセットを書きかえずに、数字だけ上書きする
        EditorGUILayout.Space();
        EditorGUILayout.LabelField("上書き(アセットは変わりません)", EditorStyles.boldLabel);
        bossHp = EditorGUILayout.IntSlider("ボスの HP(0でデータのまま)", bossHp, 0, 200);
        enemyAttackPercent = EditorGUILayout.IntSlider("敵の攻撃(%)", enemyAttackPercent, 50, 150);
        restHeal = EditorGUILayout.IntSlider("休んで回復する HP", restHeal, 0, 30);

「実行」ボタンの下に、比べるボタンと、そのグラフを足します。

        if (GUILayout.Button("ボスの HP を 40〜120 で比べる")) // ★番外編③
        {
            SweepBossHp();
        }
        DrawBossSweep(); // ★番外編③

負けた戦いの下に、報酬の表を足します。

        DrawLostBattles();
        DrawRewardStats(); // ★番外編③

実行で、条件と考え方を使う

Runの中の、始める戦い・始めるHPを入れていた2行と、new SimBasicPolicy()を直します。

        ApplyConditions(setup); // ★番外編③ 条件と上書きをまとめて入れる

        results.Clear();
        SimPolicy policy = CreatePolicy(); // ★番外編③

DrawSummaryの上に、メソッドを足します。

    // ★番外編③ ウインドで選んだ条件と上書きを、setup に入れる
    private void ApplyConditions(SimSetup setup)
    {
        setup.startBattle = Mathf.Min(startBattle, setup.battles.Count - 1);
        setup.startHp = startHp;
        setup.bossHp = bossHp;
        setup.enemyAttackPercent = enemyAttackPercent;
        setup.restHeal = restHeal;
    }

    // ★番外編③ 選んだ考え方を作る
    private SimPolicy CreatePolicy()
    {
        switch (policyIndex)
        {
            case 1:
                return new SimAggressivePolicy();
            case 2:
                return new SimDefensivePolicy();
            default:
                return new SimBasicPolicy();
        }
    }

    // ★番外編③ ボスの HP だけを 40〜120 で変えて、それぞれ runs 回戦わせる
    private void SweepBossHp()
    {
        SimSetup setup = SimMenu.ReadScene();
        if (setup == null)
        {
            return;
        }
        ApplyConditions(setup);
        SimPolicy policy = CreatePolicy();
        bossSweep.Clear();
        for (int hp = 40; hp <= 120; hp += 8)
        {
            setup.bossHp = hp;
            int wins = 0;
            for (int i = 0; i < runs; i++)
            {
                if (SimRun.Play(setup, firstSeed + i, policy).won)
                {
                    wins++;
                }
            }
            bossSweep.Add(new Vector2Int(hp, wins));
        }
    }

    // ★番外編③ ボスの HP ごとの勝率を、棒グラフにする
    private void DrawBossSweep()
    {
        if (bossSweep.Count == 0)
        {
            return;
        }
        EditorGUILayout.Space();
        EditorGUILayout.LabelField("ボスの HP ごとの勝率", EditorStyles.boldLabel);
        foreach (Vector2Int item in bossSweep)
        {
            float rate = (float)item.y / runs;
            Rect row = EditorGUILayout.GetControlRect();
            Rect barArea = new Rect(row.x + 75f, row.y + 2f, row.width - 140f, row.height - 4f);
            EditorGUI.LabelField(new Rect(row.x, row.y, 70f, row.height), $"HP {item.x}");
            EditorGUI.DrawRect(barArea, new Color(0f, 0f, 0f, 0.15f));
            EditorGUI.DrawRect(new Rect(barArea.x, barArea.y, barArea.width * rate, barArea.height), barColor);
            EditorGUI.LabelField(new Rect(barArea.xMax + 5f, row.y, 60f, row.height), $"{rate * 100f:F1}%");
        }
    }
  • SweepBossHp:ボスのHPを40から8ずつ増やしながら、それぞれruns回ずつ戦わせる(1000回なら全部で11000回)
  • グラフは、前回の残りHPの棒グラフと同じ描き方です。棒の長さは勝率そのもの(100%で満タン)

「比べる」ボタンは、回数の11倍戦わせます。1000回なら約2秒ですが、回数を10000にすると11万回になり、20秒ぐらいエディタが止まります。まずは1000回で試して、気になるところだけ回数を増やしましょう。

ExportCsvの上に、報酬の表を足します。

    // ★番外編③ 報酬で取ったカードごとに、それを取った回の勝率を出す
    private void DrawRewardStats()
    {
        Dictionary<string, int> taken = new Dictionary<string, int>(); // 取った回数
        Dictionary<string, int> won = new Dictionary<string, int>();   // そのうち勝った回数
        foreach (SimResult result in results)
        {
            foreach (string card in new HashSet<string>(result.rewards)) // 同じ回で2枚取っても1回と数える
            {
                taken[card] = taken.TryGetValue(card, out int t) ? t + 1 : 1;
                if (result.won)
                {
                    won[card] = won.TryGetValue(card, out int w) ? w + 1 : 1;
                }
            }
        }
        if (taken.Count == 0)
        {
            return;
        }

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("報酬で取ったカードと、その回の勝率", EditorStyles.boldLabel);
        List<string> cards = new List<string>(taken.Keys);
        cards.Sort((a, b) => taken[b].CompareTo(taken[a])); // 取った回数の多い順
        foreach (string card in cards)
        {
            int wins = won.TryGetValue(card, out int w) ? w : 0;
            EditorGUILayout.LabelField(card, $"{wins * 100f / taken[card]:F1}%({taken[card]}回)");
        }
    }
  • Dictionary<string, int>:カードの名前ごとに、回数を数える入れ物です
  • HashSet:同じものを1つにまとめる入れ物。同じ回で同じカードを2枚取っても、1回と数えます
  • Sort((a, b) => …):取った回数の多い順に並べかえます
ウインドの上の部分。条件の欄の下に「考え方:ふつう」、「上書き(アセットは変わりません)」の見出しと、ボスのHP(0)・敵の攻撃(100%)・休んで回復するHP(10)のスライダー、「実行」と「ボスのHPを40〜120で比べる」ボタン。上書きの欄と比べるボタンを赤枠で囲む

#15のボスを、勝率で振り返ろう

条件は「1戦目から通し・最初のデッキ・ふつうの考え方・1000回ずつ」です。ボスのHPを40〜120で比べるを押すと、約2秒で11000回戦って、HPごとの勝率が出ます。

ボスのHPごとの勝率の棒グラフ。HP 40:99.0%、48:97.8%、56:96.3%、64:92.9%、72:86.0%、80:79.0%、88:70.7%、96:61.9%、104:52.5%、112:43.4%、120:36.4%
ボスのHP405664(今)728096120(最初)
勝率99.0%96.3%==orange92.9%==86.0%79.0%61.9%==orange36.4%==
  • 最初のHP 120では、勝率は約36%。しかもこれは、攻撃の数字を今の弱めた数字にしたままの結果です。#15の最初は攻撃も強かったので、実際はもっと低かったはずです
  • #15では、自分で遊んでみて「勝てない」と感じ、何度か戦わせて120 → 80 → 70 → 64と下げました。シミュレーターで見ると、その判断は数字でも合っていたことが分かります
  • HPを8上げるごとに、勝率は5〜9%ずつ下がっていきます。「ボスのHPを1割上げると、勝率がこれぐらい下がる」という感覚がつかめると、調整がぐっと楽になります
どれぐらいの勝率にする?

正解はありませんが、目安を決めておくと迷いません。

  • 自動の考え方(ふつう)は、人より下手なことが多い。人はもう少し勝てる
  • 1戦目から通して、ふつうの考え方で90%前後なら「ちゃんと遊べば、ほぼ勝てる」
  • 最後のボスで少しドキドキしてほしいので、ボス戦の残りHPの分布が「0〜9」にも少し出るぐらい(前回の棒グラフ)が、ぎりぎりの手応えになります

どの報酬が負けにつながる?

前回、「ボス戦だけならほぼ勝てるのに、通すと7%負ける」ことが分かりました。報酬の表で、取ったカードごとの勝率を見てみます。

報酬で取ったカードと、その回の勝率の表。ホチキスでパチン92.6%(569回)、ふでばこの壁96.2%(497回)、ハサミでチョキチョキ93.2%(425回)、えんぴつをけずる88.7%(346回)、分度器ブーメラン95.5%(247回)、色えんぴつセット97.4%(189回)、コンパスでチクッ100.0%(57回)、大そうじ89.4%(47回)、おやつタイム95.2%(42回)、修正テープ100.0%(37回)、クリップでとめる88.2%(34回)、付せんメモ36.4%(33回)。付せんメモの行を赤枠で囲む
  • ふでばこの壁(96.2%)・色えんぴつセット(97.4%)を取った回は、勝ちやすい
  • えんぴつをけずる(88.7%)・大そうじ(89.4%)・クリップ(88.2%)は、全体(92.9%)より低い
  • 付せんメモは36.4%(33回)と、とても低い

ここで大事なのは、「このカードを取ったから負けた」とは、まだ言えないことです。

  • 付せんメモは、報酬の優先順に入っていないので、3枚の中に優先順のカードが1枚も無いときにだけ取っています。つまり「いいカードが出なかった回」に取っている(コンパスや修正テープも同じ理由で取っていますが、回数が少ないので、100%という数字はあまり当てになりません)
  • 大そうじは、ふつうの考え方が一度も使わないカードです。取っても、山札の中で引いたときに手札を1枚ふさぐだけになります
  • 回数が少ない(33回など)ものは、ぶれも大きい

それでも、「考え方が使わないカードは、デッキの重りになる」ことは分かります。デッキ構築ゲームで「取らない」という選択肢があるのは、このためです。

もっと確かめたいときは、報酬の優先順を変えた考え方を作って、勝率を比べてみましょう。「付せんメモを取らない」考え方で勝率が上がれば、付せんメモ自体が弱い、と言えます。

考え方を変えると?

同じ条件(1戦目から通し・最初のデッキ・1000回)で、考え方だけを変えてみました。

考え方回復するHP守る目安勝率
ふつう25以下攻撃が防御より5以上多い92.9%
攻め重視15以下攻撃が防御より12以上多い==orange58.2%==
守り重視35以下攻撃が防御より1以上多い93.3%
考え方を「攻め重視」にして1戦目から1000回実行したウインド。勝率58.2%(582/1000回)、勝ったときのターン数は平均22.4、残りHPは平均10.8。残りHPの棒グラフは0〜9と10〜19が長い。負けた戦いは3戦目15回・ボス戦403回。考え方・勝率・ボス戦の行を赤枠で囲む
  • 攻め重視は、勝率が大きく下がりました。ボス戦で403回も負けています。6〜11ぐらいの攻撃を防御なしで受け続けて、少しずつHPがけずられていくためです。勝てた回もターン数は平均22.4と短いのですが、残りHPは平均10.8(ふつうは18.2)と、ぎりぎりです
  • 守り重視は、ふつうとほとんど変わりません。予告を見て守るゲームなので、「守るべきときに守る」だけで十分ということですね
  • つまり、このゲームは「予告を見て守る」ことが勝ち負けを大きく分ける作りになっています。ねらいどおりのバランスです
スライム

攻め重視で58%しか勝てないなら、ガンガン攻める人は苦労しそうですね・・・

フェアリー

それがこのゲームの個性なの!もし「攻める遊び方も楽しくしたい」なら、攻撃のカードを強くして、もう一度比べればいいんだよ

動かして確認!

番外編③の動作確認

  • 上書きがすべて「そのまま」なら、シード1・4の結果が前回と同じ(ゲームとも同じ)
  • ボスのHPを上書きして実行すると、勝率が変わる。EnemyDataのアセットのMax Hpは変わらない
  • 「ボスのHPを40〜120で比べる」で、11本の棒グラフが出る。HPが高いほど勝率が下がる
  • 報酬の表に、取ったカードと勝率が、取った回数の多い順に並ぶ
  • 考え方を「攻め重視」にすると、勝率が大きく下がる
スライム

カードや敵の数字を変えたら、また全部比べ直しですか・・・?

フェアリー

そう!でも1000回が0.2秒だから、変えるたびにボタンを押すだけだよ。ゲームを直したら、まずシミュレーターを回すクセをつけよう

番外編のまとめ

回作ったもの分かったこと
①ルールだけを写したシミュレーター同じシードで、ゲームとまったく同じ結果になる
②エディタのウインド1000回が0.2秒。ボス戦だけより、通しの方が負けやすい
③上書き・比べる・考え方#15のボスの調整は数字でも合っていた。守ることが勝ち負けを分ける

次にやってみること

  • カードの数字も上書きできるようにする。ダメージの割合などを足せば、カードの強さも比べられます
  • 報酬の優先順を変えた考え方を作る。どのカードを取るのが一番強いかを、勝率で探せます
  • 新しい敵やカードを足したら、まず1000回回す。作るたびに勝率を見ておくと、強すぎ・弱すぎにすぐ気づけます

あとがき

番外編3回、おつかれさまでした!画面なしで戦いを回すシミュレーターを作り、ゲームと結果が一致することを確かめてから、ウインドで何千回も戦わせ、数字を変えて比べられるようにしました。「遊んでみてなんとなく」だったバランス調整が、勝率という数字で話せるようになると、自信を持ってゲームを仕上げられます。ぜひ、自分で足したカードや敵でも試してみてください!

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

コメント

コメントする

目次