デッキ構築カードゲームの作り方|番外編①バトルのシミュレーターを作ろう:画面なしで戦いを回す

Unityのメニューから「DeckBuilding>シミュレーター」を実行したConsole。「シード 1:勝ち 29ターン 残りHP 12 報酬 分度器ブーメラン・えんぴつをけずる・えんぴつをけずる」「シード 4:勝ち 30ターン 残りHP 5 報酬 ホチキスでパチン・ハサミでチョキチョキ・えんぴつをけずる」「1000回:勝ち 929回(勝率 92.9%) 152ミリ秒」の3行

本編の#15では、強すぎたボスを調整するために、ゲームを再生して自動でカードを使わせ、何度も戦わせました。でも、画面の上で1枚ずつカードを動かすので、1回通すのに15分ほどかかります・・・。この番外編では、画面を使わずにルールだけで戦いを回す「バトルのシミュレーター」を作ってみたいと思います!1回目の今回は、シミュレーターの本体を作って、ゲームとまったく同じ結果になるかを確かめます。

目次

番外編で作るもの

回作るもの
①(今回)画面なしで戦いを回すシミュレーター。ゲームと結果が一致するかを確かめる
②エディタのウインドで、条件を選んで何千回も戦わせ、勝率・ターン数・残りHPを表とグラフで見る
③敵の数字を上書きして比べ、バランスを決める

今回作るもの

スクリプト役割写すもの(本編)
SimFighterHP・防御・状態Fighter
SimEnemy敵1体と、行動の並びEnemyView
SimDeck山札・手札・捨て札と乱数Deck
SimEffectsカードの効果を当てる各CardEffect
SimBattle1回の戦い(ターンの流れ)BattleManager・PlayZone・EnemyParty
SimPolicy・SimBasicPolicyカードの選び方(考え方)(#15で使った自動のプレイ)
SimSetup・SimResult・SimRun条件・結果・戦いの並びRunManager・RewardView
SimMenuメニューから回す(エディタ専用)-

本編のスクリプトは、効果のクラスに「数字を読むプロパティ」を足すだけです。

シミュレーターの考え方

上に「データ(ScriptableObject)」:CardData・EnemyData・BattleData。そこから「同じデータ」の矢印が2本。左は「ゲーム(画面あり)」:ルールと見た目、マウスで1枚ずつ、1戦に数分。右は「シミュレーター(画面なし)」:ルールだけ、考え方がカードを選ぶ、1000回でも1秒かからない。下に「同じシードなら、同じ結果になるように作る」

本編のスクリプトは、ルール(HPを減らす、山札から引く)と見た目(ゲージ、カードの動き、演出)が、同じスクリプトの中にいっしょに入っています。たとえばDeckは、カードを引くと同時にプレハブを作って手札に並べます。

そのままでは画面なしで回せないので、ルールだけを写した、シミュレーター専用のクラスを作ります。

  • カードや敵のデータ(ScriptableObject)は、ゲームと同じものを使う
  • ルールは、本編のスクリプトから「数字が変わるところ」だけを写す
  • 演出の待ち時間(コルーチン)や、プレハブ・UIは使わない

ルールを2か所に書くと、片方だけ直して食いちがう心配があります。そこで今回は、最後に「同じシードで、ゲームとシミュレーターの結果が完全に一致するか」を照らし合わせます。一致すれば、写し方が正しいと言えます。

前回の続きから

本編#16の最後の状態から始めます。新しい素材はありません。

  • DeckBuilding/Scriptsの中にSimulationフォルダを作る(シミュレーターのクラスを入れる)
  • DeckBuildingの中にEditorフォルダを作る(メニューのスクリプトを入れる)

効果の数字を読めるようにする

シミュレーターは、カードの効果の数字(ダメージ6など)を読む必要があります。効果のクラスの数字はprivateなので、読むだけのプロパティを足します。DamageEffectはこうなります。

    [SerializeField] private int amount = 6;
    [SerializeField] private int hits = 1;

    public int Amount => amount; // ★番外編 シミュレーターで読む
    public int Hits => hits; // ★番外編 シミュレーターで読む

ほかの効果にも、同じように足します。

クラス足すプロパティ
AllDamageEffect・BlockEffect・HealEffect・EnergyEffect・SharpenEffectpublic int Amount => amount; // ★番外編 シミュレーターで読む
DrawEffectpublic int Count => count; // ★番外編 シミュレーターで読む

WeakenEffectとReshuffleHandEffectは数字を持たないので、そのままでOKです。

HP と防御:SimFighter

ここからは、Simulationフォルダにスクリプトを作っていきます。どれもMonoBehaviourを受け継がない、ふつうのC#のクラスです。シーンには置かず、newで作って使います。

using UnityEngine;

// シミュレーター用:HP と防御と状態だけを持つ(Fighter のルールだけを写したもの。画面には何も出さない)
public class SimFighter
{
    public string Name { get; }
    public int MaxHp { get; }
    public int Hp { get; private set; }
    public int Block { get; private set; }
    public int Sharpen { get; private set; }  // 次の攻撃に足すダメージ
    public bool Weakened { get; private set; } // 次の攻撃が半分になる
    public bool IsDead => Hp <= 0;

    public SimFighter(string name, int maxHp)
    {
        Name = name;
        MaxHp = maxHp;
        Hp = maxHp;
    }

    // 先に防御で受けとめて、残りだけ HP を減らす
    public void TakeDamage(int amount)
    {
        int blocked = Mathf.Min(Block, amount);
        Block -= blocked;
        Hp = Mathf.Max(0, Hp - (amount - blocked));
    }

    public void AddBlock(int amount)
    {
        Block += amount;
    }

    public void ResetBlock()
    {
        Block = 0;
    }

    public void Heal(int amount)
    {
        Hp = Mathf.Min(MaxHp, Hp + amount);
    }

    public void SetHp(int newHp)
    {
        Hp = Mathf.Clamp(newHp, 1, MaxHp);
    }

    public void AddSharpen(int amount)
    {
        Sharpen += amount;
    }

    public int UseSharpen()
    {
        int bonus = Sharpen;
        Sharpen = 0;
        return bonus;
    }

    public void Weaken()
    {
        Weakened = true;
    }

    public int UseWeaken(int attack)
    {
        if (!Weakened)
        {
            return attack;
        }
        Weakened = false;
        return attack / 2;
    }
}
  • 中身は、本編のFighterのTakeDamage・AddBlock・Heal・UseSharpen・UseWeakenなどと同じ計算です
  • 画面に何も出さないので、HitReactionやDebug.Logは呼びません
  • { get; private set; }:外からは読めるけれど、書きかえられるのはこのクラスの中だけ、というプロパティです

敵1体:SimEnemy

// シミュレーター用:敵1体(EnemyView のうち、行動の並びのルールだけを写したもの)
public class SimEnemy
{
    public EnemyData Data { get; }
    public SimFighter Fighter { get; }
    public int Slot { get; } // 手下の場所の番号(呼ばれた手下だけ。最初からいる敵は -1)

    private int actionIndex;

    public EnemyData.EnemyAction NextAction => Data.actions[actionIndex];

    public SimEnemy(EnemyData data, int slot)
    {
        Data = data;
        Fighter = new SimFighter(data.enemyName, data.maxHp);
        Slot = slot;
    }

    // 次の行動へ進む(最後まで行ったら最初に戻る)
    public void AdvanceAction()
    {
        actionIndex = (actionIndex + 1) % Data.actions.Count;
    }
}
  • 行動の並びはEnemyDataのものをそのまま使い、何番目の行動かだけを覚えます
  • Slot:#15の手下は、ボスの左右2か所のどちらかに出ました。どこに出た手下かを覚えておきます

山札・手札・捨て札:SimDeck

ここが一番大事なところです。山札の順番がゲームと1枚でもちがうと、その先の結果がすべて変わってしまいます。乱数の使い方を、本編のDeckとまったく同じにします。

using System.Collections.Generic;

// シミュレーター用:山札・手札・捨て札・なくなった札(Deck のルールと、乱数の使い方をそのまま写したもの)
public class SimDeck
{
    public List<CardData> DrawPile { get; } = new List<CardData>();
    public List<CardData> Hand { get; } = new List<CardData>();
    public List<CardData> DiscardPile { get; } = new List<CardData>();
    public List<CardData> ExhaustPile { get; } = new List<CardData>();

    private System.Random random;

    public SimDeck(List<CardData> startingCards, int seed)
    {
        // Deck と同じ:シードがあれば、いつも同じ順番に混ざる乱数
        random = seed != 0 ? new System.Random(seed) : new System.Random();
        DrawPile.AddRange(startingCards);
        Shuffle(DrawPile);
    }

    // 山札の一番うしろ(上)から引く。山札が空なら、捨て札を混ぜて山札に戻す
    public void Draw(int count)
    {
        for (int i = 0; i < count; i++)
        {
            if (DrawPile.Count == 0)
            {
                if (DiscardPile.Count == 0)
                {
                    return;
                }
                DrawPile.AddRange(DiscardPile);
                DiscardPile.Clear();
                Shuffle(DrawPile);
            }
            int last = DrawPile.Count - 1;
            Hand.Add(DrawPile[last]);
            DrawPile.RemoveAt(last);
        }
    }

    public void DiscardHand()
    {
        DiscardPile.AddRange(Hand);
        Hand.Clear();
    }

    // 手札から使う(使ったらなくなる札は、なくなった札へ)
    public void Use(CardData card)
    {
        Hand.Remove(card);
        if (card.exhaust)
        {
            ExhaustPile.Add(card);
        }
        else
        {
            DiscardPile.Add(card);
        }
    }

    public void ReshuffleHand()
    {
        int count = Hand.Count;
        DiscardHand();
        Draw(count);
    }

    public void ResetForBattle()
    {
        DrawPile.AddRange(DiscardPile);
        DrawPile.AddRange(ExhaustPile);
        DiscardPile.Clear();
        ExhaustPile.Clear();
        Shuffle(DrawPile);
    }

    public void AddCard(CardData card)
    {
        DrawPile.Add(card);
        Shuffle(DrawPile);
    }

    public int NextRandom(int max)
    {
        return random.Next(0, max);
    }

    // Fisher-Yates のシャッフル(Deck と同じ)
    private void Shuffle(List<CardData> cards)
    {
        for (int i = cards.Count - 1; i > 0; i--)
        {
            int j = random.Next(0, i + 1);
            CardData temp = cards[i];
            cards[i] = cards[j];
            cards[j] = temp;
        }
    }
}
  • 乱数は、#15でDeckに持たせたのと同じSystem.Random(シード)
  • 同じ場面で、同じ回数だけ乱数を使うようにします
    • 最初に混ぜる、山札が空になったら捨て札を混ぜる、報酬のカードを足したら混ぜる、次の戦いの前に混ぜる
  • 山札の一番うしろから引く、手札の左から順に捨て札へ、なども本編と同じです

Fisher-Yatesのシャッフルは、リストの並び順がちがうと、同じ乱数でもちがう結果になります。「捨て札に入れる順番」まで本編と同じにしているのは、このためです。

カードの効果:SimEffects

// シミュレーター用:カードの効果を、SimFighter と SimDeck に当てる
public static class SimEffects
{
    public static void Apply(CardEffect effect, SimBattle battle, SimEnemy target)
    {
        SimFighter player = battle.Player;

        // 効果のクラス(型)ごとに分ける
        switch (effect)
        {
            case DamageEffect damage:
                if (target == null)
                {
                    break;
                }
                int bonus = player.UseSharpen();
                for (int i = 0; i < damage.Hits; i++)
                {
                    target.Fighter.TakeDamage(damage.Amount + bonus);
                }
                break;

            case AllDamageEffect allDamage:
                int allBonus = player.UseSharpen();
                foreach (SimEnemy enemy in battle.Enemies)
                {
                    enemy.Fighter.TakeDamage(allDamage.Amount + allBonus);
                }
                break;

            case BlockEffect block:
                player.AddBlock(block.Amount);
                break;

            case HealEffect heal:
                player.Heal(heal.Amount);
                break;

            case DrawEffect draw:
                battle.Deck.Draw(draw.Count);
                break;

            case EnergyEffect energy:
                battle.Energy += energy.Amount;
                break;

            case SharpenEffect sharpen:
                player.AddSharpen(sharpen.Amount);
                break;

            case WeakenEffect:
                if (target != null)
                {
                    target.Fighter.Weaken();
                }
                break;

            case ReshuffleHandEffect:
                battle.Deck.ReshuffleHand();
                break;
        }
    }
}
  • switch (effect)のcase DamageEffect damage:は、「effectがDamageEffectなら、damageという名前で使う」という書き方です(型で分けるswitch)
    • #7で効果を継承で作ったので、CardEffectの子クラスの種類ごとに、することを分けられます
  • 中身は、本編の各効果のApplyと同じです。けずった分を先に使う、全体ダメージは敵全員に、なども写しています

1回の戦い:SimBattle

using System.Collections.Generic;

// シミュレーター用:1回の戦い(BattleManager・PlayZone・EnemyParty のルールを写したもの)
public class SimBattle
{
    public SimFighter Player { get; }
    public SimDeck Deck { get; }
    public List<SimEnemy> Enemies { get; } = new List<SimEnemy>();
    public int Energy { get; set; }
    public int Turns { get; private set; }

    private SimEnemy[] summoned = new SimEnemy[2]; // 手下の場所(EnemyParty と同じ2か所)
    private const int MaxTurns = 200;              // 終わらない戦いを止めるための上限

    public SimBattle(SimFighter player, SimDeck deck, BattleData battle)
    {
        Player = player;
        Deck = deck;
        foreach (EnemyData data in battle.enemies)
        {
            Enemies.Add(new SimEnemy(data, -1));
        }
    }

    // 最後まで戦って、勝ったら true
    public bool Run(SimPolicy policy)
    {
        while (Turns < MaxTurns)
        {
            PlayerTurn(policy);
            if (Enemies.Count == 0)
            {
                Deck.DiscardHand(); // 勝ったら手札を捨てる(BattleManager の Victory と同じ)
                return true;
            }
            EnemyTurn();
            if (Player.IsDead)
            {
                return false;
            }
        }
        return false;
    }

    // あなたのターン:防御を0・エネルギーを3・5枚引いて、考え方が選んだカードを使っていく
    private void PlayerTurn(SimPolicy policy)
    {
        Turns++;
        Player.ResetBlock();
        Energy = 3;
        Deck.Draw(5);

        for (int i = 0; i < 12 && Enemies.Count > 0; i++)
        {
            CardData card = policy.Pick(this, out SimEnemy target);
            if (card == null)
            {
                break;
            }
            Play(card, target);
        }
    }

    // カードを使う(PlayZone と同じ順番:エネルギーを払う → 手札から出す → 効果を上から順に)
    public void Play(CardData card, SimEnemy target)
    {
        Energy -= card.cost;
        Deck.Use(card);
        foreach (CardEffect effect in card.effects)
        {
            SimEffects.Apply(effect, this, target);
        }
        RemoveDefeated();
    }

    // 倒れた敵を外す(BattleManager の Update と同じ。うしろから調べる)
    private void RemoveDefeated()
    {
        for (int i = Enemies.Count - 1; i >= 0; i--)
        {
            if (Enemies[i].Fighter.IsDead)
            {
                Enemies.RemoveAt(i);
            }
        }
    }

    // 敵のターン:手札を捨てて、敵が1体ずつ予告どおりに行動する
    private void EnemyTurn()
    {
        Deck.DiscardHand();
        foreach (SimEnemy enemy in new List<SimEnemy>(Enemies))
        {
            enemy.Fighter.ResetBlock();
            EnemyData.EnemyAction action = enemy.NextAction;
            if (action.block > 0)
            {
                enemy.Fighter.AddBlock(action.block);
            }
            if (action.attack > 0)
            {
                Player.TakeDamage(enemy.Fighter.UseWeaken(action.attack));
                if (Player.IsDead)
                {
                    return;
                }
            }
            if (action.summon != null)
            {
                Summon(action.summon);
            }
            enemy.AdvanceAction();
        }
    }

    // 手下を空いている場所に呼ぶ(倒された手下の場所は空きになる)
    private void Summon(EnemyData data)
    {
        for (int i = 0; i < summoned.Length; i++)
        {
            if (summoned[i] == null || summoned[i].Fighter.IsDead)
            {
                summoned[i] = new SimEnemy(data, i);
                Enemies.Add(summoned[i]);
                return;
            }
        }
    }
}
  • Run:あなたのターン → 敵のターン をくり返し、敵がいなくなったら勝ち、HPが0になったら負け
    • 本編のBattleManagerはコルーチンで間をあけていましたが、シミュレーターは待たずに一気に進めます
  • Play:本編のPlayZoneと同じ順番(エネルギーを払う → 手札から出す → 効果を上から順に)
  • 倒れた敵はRemoveDefeatedで、うしろから外す(#12と同じ)
  • 手下は、空いている場所(または倒された手下の場所)に呼ぶ(#15と同じ)
  • MaxTurns:考え方によっては戦いが終わらないこともあるので、200ターンで打ち切ります

カードの選び方:SimPolicy

ゲームではプレイヤーがカードを選びますが、シミュレーターでは「考え方」のクラスが選びます。③で別の考え方を足せるように、#7の効果と同じく継承で作ります。

using System.Collections.Generic;

// カードの選び方(考え方)の共通の形。中身は子クラスで決める
public abstract class SimPolicy
{
    // 次に使うカードを選ぶ(使わないときは null)。ねらう敵は target に入れる
    public abstract CardData Pick(SimBattle battle, out SimEnemy target);

    // 報酬の候補から1枚選ぶ
    public abstract CardData ChooseReward(List<CardData> choices);

    // 手札に names のカードがあって、今のエネルギーで使えれば、最初に見つかったものを返す
    protected CardData Find(SimBattle battle, params string[] names)
    {
        foreach (string name in names)
        {
            foreach (CardData card in battle.Deck.Hand)
            {
                if (card.cardName == name && card.cost <= battle.Energy)
                {
                    return card;
                }
            }
        }
        return null;
    }
}

ふつうの考え方を作ります。#15で、ゲームを自動で遊ばせたときと同じ選び方です。

using System.Collections.Generic;

// ふつうの考え方:危なければ回復・防御、それ以外は手下から攻撃(#15 で使った自動プレイと同じ)
public class SimBasicPolicy : SimPolicy
{
    private static readonly string[] rewardOrder = { "ホチキスでパチン", "ふでばこの壁", "ハサミでチョキチョキ", "えんぴつをけずる", "分度器ブーメラン", "色えんぴつセット" };

    public override CardData Pick(SimBattle battle, out SimEnemy target)
    {
        target = null;
        SimFighter player = battle.Player;
        CardData card;

        // エネルギーが要らない・得をするカードは先に使う
        card = Find(battle, "色えんぴつセット");
        if (card != null)
        {
            return card;
        }
        card = Find(battle, "付せんメモ");
        if (card != null && battle.Energy >= 1 && battle.Deck.Hand.Count <= 4)
        {
            return card;
        }

        // HP が少なければ回復
        if (player.Hp <= 25)
        {
            card = Find(battle, "おやつタイム", "ばんそうこう");
            if (card != null)
            {
                return card;
            }
        }

        // 次に来る攻撃が、今の防御より5以上多ければ守る
        int incoming = 0;
        foreach (SimEnemy enemy in battle.Enemies)
        {
            int attack = enemy.NextAction.attack;
            incoming += enemy.Fighter.Weakened ? attack / 2 : attack;
        }
        if (incoming - player.Block >= 5)
        {
            card = Find(battle, "ふでばこの壁", "下じきガード");
            if (card != null)
            {
                return card;
            }
            card = Find(battle, "修正テープ");
            if (card != null)
            {
                target = Strongest(battle);
                return card;
            }
        }

        // ねらい:HP の一番少ない敵。ただし、一撃で倒せるボスがいればボス
        target = battle.Enemies[0];
        foreach (SimEnemy enemy in battle.Enemies)
        {
            if (enemy.Fighter.Hp < target.Fighter.Hp)
            {
                target = enemy;
            }
        }
        foreach (SimEnemy enemy in battle.Enemies)
        {
            bool isBoss = enemy.Data.scale > 1f;
            if (isBoss && enemy.Fighter.Hp + enemy.Fighter.Block <= 12 + player.Sharpen && Find(battle, "ホチキスでパチン", "ハサミでチョキチョキ", "定規でペシッ") != null)
            {
                target = enemy;
            }
        }

        // 攻撃
        if (battle.Enemies.Count >= 2)
        {
            card = Find(battle, "分度器ブーメラン");
            if (card != null)
            {
                return card;
            }
        }
        card = Find(battle, "えんぴつをけずる");
        if (card != null && battle.Energy >= 2 && Find(battle, "定規でペシッ", "ハサミでチョキチョキ", "コンパスでチクッ") != null)
        {
            target = null; // スライムちゃんに使う
            return card;
        }
        card = Find(battle, "ホチキスでパチン", "ハサミでチョキチョキ", "定規でペシッ", "クリップでとめる", "コンパスでチクッ", "分度器ブーメラン");
        if (card != null)
        {
            return card;
        }

        // 残ったエネルギーで、攻撃が来るなら守る
        target = null;
        card = Find(battle, "下じきガード", "ふでばこの壁");
        if (card != null && incoming > player.Block)
        {
            return card;
        }
        return null;
    }

    public override CardData ChooseReward(List<CardData> choices)
    {
        foreach (string name in rewardOrder)
        {
            foreach (CardData card in choices)
            {
                if (card.cardName == name)
                {
                    return card;
                }
            }
        }
        return choices[0];
    }

    // 次の攻撃が一番大きい敵
    private SimEnemy Strongest(SimBattle battle)
    {
        SimEnemy best = battle.Enemies[0];
        foreach (SimEnemy enemy in battle.Enemies)
        {
            if (enemy.NextAction.attack > best.NextAction.attack)
            {
                best = enemy;
            }
        }
        return best;
    }
}
  • 優先する順番:0コストの得なカード → HPが少なければ回復 → 攻撃が来るなら防御 → 攻撃
  • 攻撃は、HPの一番少ない敵(手下)から。ただし、一撃で倒せるボスがいればボスをねらう
  • 報酬は、決めた順番(ホチキス・ふでばこの壁・ハサミ…)で、出てきた中から選ぶ

この選び方は、プレイヤーの「うまさ」の代わりです。人は予告を見て、もっとかしこく選べます。シミュレーターの勝率は「この選び方なら」の数字だと考えてください。③では、選び方を変えて比べます。

条件・結果・戦いの並び:SimSetup・SimResult・SimRun

using System.Collections.Generic;

// シミュレーターに渡す条件(シーンの RunManager・Deck・RewardView などから読み込んで作る)
public class SimSetup
{
    public List<BattleData> battles = new List<BattleData>();        // 戦いの並び
    public List<CardData> startingCards = new List<CardData>();      // 最初のデッキ(1枚ずつ並べたもの)
    public List<CardData> rewardCandidates = new List<CardData>();   // 報酬の候補
    public int playerMaxHp = 60;
    public int restHeal = 10;
    public int startBattle = 0; // 何番目の戦いから始めるか
    public int startHp = 0;     // 0以外なら、その HP から始める
}
using System.Collections.Generic;

// シミュレーションの結果
public class SimResult
{
    public int seed;
    public bool won;
    public int lostBattle = -1; // 負けた戦いの番号(勝ったら -1)
    public int turns;           // あなたのターンの合計
    public int finalHp;
    public List<string> rewards = new List<string>();

    public override string ToString()
    {
        string result = won ? "勝ち" : $"{lostBattle + 1}戦目で負け";
        return $"シード {seed}:{result} {turns}ターン 残りHP {finalHp} 報酬 {string.Join("・", rewards)}";
    }
}
using System.Collections.Generic;

// シミュレーター用:戦いを順番に続ける(RunManager・RewardView のルールを写したもの)
public static class SimRun
{
    public static SimResult Play(SimSetup setup, int seed, SimPolicy policy)
    {
        SimResult result = new SimResult { seed = seed };
        SimDeck deck = new SimDeck(setup.startingCards, seed);
        SimFighter player = new SimFighter("スライムちゃん", setup.playerMaxHp);
        if (setup.startHp > 0)
        {
            player.SetHp(setup.startHp);
        }

        for (int i = setup.startBattle; i < setup.battles.Count; i++)
        {
            if (i > setup.startBattle)
            {
                player.Heal(setup.restHeal); // 戦いの間に休む
                deck.ResetForBattle();
            }

            SimBattle battle = new SimBattle(player, deck, setup.battles[i]);
            bool won = battle.Run(policy);
            result.turns += battle.Turns;
            if (!won)
            {
                result.lostBattle = i;
                break;
            }
            if (i == setup.battles.Count - 1)
            {
                result.won = true;
                break;
            }

            // 報酬:候補をコピーして混ぜ、先頭の3枚から選ぶ(RewardView と同じ乱数の使い方)
            List<CardData> pool = new List<CardData>(setup.rewardCandidates);
            for (int k = pool.Count - 1; k > 0; k--)
            {
                int j = deck.NextRandom(k + 1);
                CardData temp = pool[k];
                pool[k] = pool[j];
                pool[j] = temp;
            }
            List<CardData> choices = pool.GetRange(0, System.Math.Min(3, pool.Count));
            CardData chosen = policy.ChooseReward(choices);
            deck.AddCard(chosen);
            result.rewards.Add(chosen.cardName);
        }

        result.finalHp = player.Hp;
        return result;
    }
}
  • SimRun.Play:本編のRunManagerと同じ流れ。2戦目からは「休む → カードを山札に戻す」、勝ったら報酬
  • 報酬の候補の混ぜ方も、#12のRewardViewと同じ乱数の使い方にします
ProjectウインドのAssets/DeckBuilding/Scripts/Simulationフォルダ。SimBasicPolicy・SimBattle・SimDeck・SimEffects・SimEnemy・SimFighter・SimPolicy・SimResult・SimRun・SimSetupの10個が並んでいる

メニューから回そう:SimMenu

最後に、メニューから動かせるようにします。メニューを足すのはエディタ専用の機能なので、Editorフォルダに入れます(Editorフォルダの中のスクリプトは、ビルドしたゲームには入りません)。

  • DeckBuilding/Editorに「SimMenu」スクリプトを作る
using UnityEditor;
using UnityEngine;

// メニューからシミュレーターを動かす(エディタ専用。Editor フォルダに置く)
public static class SimMenu
{
    [MenuItem("DeckBuilding/シミュレーター/1回だけ回す")]
    private static void RunOnce()
    {
        SimSetup setup = ReadScene();
        if (setup == null)
        {
            return;
        }
        int seed = ReadSeed();
        SimResult result = SimRun.Play(setup, seed, new SimBasicPolicy());
        Debug.Log(result);
    }

    [MenuItem("DeckBuilding/シミュレーター/1000回回す")]
    private static void RunThousand()
    {
        SimSetup setup = ReadScene();
        if (setup == null)
        {
            return;
        }
        SimPolicy policy = new SimBasicPolicy();
        System.Diagnostics.Stopwatch watch = System.Diagnostics.Stopwatch.StartNew(); // かかった時間を測る
        int wins = 0;
        for (int seed = 1; seed <= 1000; seed++)
        {
            if (SimRun.Play(setup, seed, policy).won)
            {
                wins++;
            }
        }
        watch.Stop();
        Debug.Log($"1000回:勝ち {wins}回(勝率 {wins / 10f}%) {watch.ElapsedMilliseconds}ミリ秒");
    }

    // 開いているシーンから、シミュレーターの条件を読み込む
    public static SimSetup ReadScene()
    {
        RunManager run = Object.FindFirstObjectByType<RunManager>();
        Deck deck = Object.FindFirstObjectByType<Deck>();
        RewardView reward = Object.FindFirstObjectByType<RewardView>(FindObjectsInactive.Include);
        GameObject playerArea = GameObject.Find("PlayerArea");
        if (run == null || deck == null || reward == null || playerArea == null)
        {
            Debug.LogWarning("Battle シーンを開いてから実行してください");
            return null;
        }

        SimSetup setup = new SimSetup();
        // private の項目も、SerializedObject を通すとエディタから読める
        SerializedObject runData = new SerializedObject(run);
        SerializedProperty battles = runData.FindProperty("battles");
        for (int i = 0; i < battles.arraySize; i++)
        {
            setup.battles.Add((BattleData)battles.GetArrayElementAtIndex(i).objectReferenceValue);
        }
        setup.restHeal = runData.FindProperty("restHeal").intValue;
        setup.startBattle = runData.FindProperty("startBattle").intValue;
        setup.startHp = runData.FindProperty("startHp").intValue;

        SerializedProperty entries = new SerializedObject(deck).FindProperty("startingDeck");
        for (int i = 0; i < entries.arraySize; i++)
        {
            SerializedProperty entry = entries.GetArrayElementAtIndex(i);
            CardData card = (CardData)entry.FindPropertyRelative("card").objectReferenceValue;
            int count = entry.FindPropertyRelative("count").intValue;
            for (int k = 0; k < count; k++)
            {
                setup.startingCards.Add(card);
            }
        }

        SerializedProperty candidates = new SerializedObject(reward).FindProperty("candidates");
        for (int i = 0; i < candidates.arraySize; i++)
        {
            setup.rewardCandidates.Add((CardData)candidates.GetArrayElementAtIndex(i).objectReferenceValue);
        }

        setup.playerMaxHp = new SerializedObject(playerArea.GetComponent<Fighter>()).FindProperty("maxHp").intValue;
        return setup;
    }

    // Deck の「確かめるとき用」のシード(0なら1にする)
    public static int ReadSeed()
    {
        Deck deck = Object.FindFirstObjectByType<Deck>();
        int seed = new SerializedObject(deck).FindProperty("randomSeed").intValue;
        return seed != 0 ? seed : 1;
    }
}
  • [MenuItem("DeckBuilding/シミュレーター/1回だけ回す")]:Unityの上のメニューに「DeckBuilding」が増えて、選ぶとこのメソッドが動きます
  • ReadScene:開いているBattleシーンから、戦いの並び・最初のデッキ・報酬の候補・最大HPを読み込みます
    • RunManagerのbattlesなどはprivateですが、SerializedObjectを使うと、エディタからはInspectorと同じように読めます。本編のスクリプトに、読むためのプロパティを足さずに済みます
  • シードは、Deckの「確かめるとき用」のRandom Seedを使います(0なら1)
  • 「1000回回す」は、シード1〜1000で回して、勝った回数と、かかった時間(Stopwatch)を出します

using UnityEditor;を使うスクリプトをEditorフォルダの外に置くと、エディタでは動いても、ビルドするときにエラーになります。SimMenuは必ずEditorフォルダに入れましょう。シミュレーターのクラス(Simulationフォルダ)はUnityEditorを使っていないので、Editorフォルダに入れなくてOKです。

ゲームと照らし合わせよう

いよいよ照らし合わせです。#15では、ゲームを再生して、同じ選び方で自動で戦わせた結果を出していました。

  • Battleシーンを開いて、DeckのRandom Seedを1にする(Starting Deckは最初の3行、RunManagerのStart Battle・Start Hpは0)
  • メニューのDeckBuilding>シミュレーター>1回だけ回す
  • Random Seedを4にして、もう一度
シードゲーム(#15の自動プレイ)シミュレーター
1勝ち・29ターン・残りHP 12・報酬は分度器ブーメラン・えんぴつをけずる・えんぴつをけずる勝ち・29ターン・残りHP 12・報酬は分度器ブーメラン・えんぴつをけずる・えんぴつをけずる
4勝ち・30ターン・残りHP 5・報酬はホチキスでパチン・ハサミでチョキチョキ・えんぴつをけずる勝ち・30ターン・残りHP 5・報酬はホチキスでパチン・ハサミでチョキチョキ・えんぴつをけずる

ターン数・残りHP・取った報酬まで、完全に一致しました!4戦・約30ターンの間に何百回も乱数を使っていますが、1回もずれていないということです。

ずれたときの探し方
  • 最初のターンの手札を見比べる:ちがえば、最初のデッキの並べ方か、最初のシャッフル
  • ターンごとのHPを見比べる:どのターンからずれたかで、そのターンに起きたこと(引き直し・報酬・手下)を疑う
  • 乱数を使う回数をそろえる:シャッフルするリストの長さや順番が1つちがうだけで、その先が全部変わります

どれぐらい速い?

メニューのDeckBuilding>シミュレーター>1000回回すを選ぶと、Consoleに結果が出ます。

  • 1000回(4戦の通し×1000)で、約0.15秒
  • ゲームを再生して自動で戦わせると、1回に15分ほどかかっていました

同じことを、およそ600万倍の速さで確かめられるようになりました。1000回の勝率は92.9%です(この選び方で、最初のデッキから始めた場合)。

ProjectウインドのAssets/DeckBuilding/Editorフォルダに、SimMenuが入っている
スライム

0.15秒・・・。#15で15分ずつ待っていたのは何だったんでしょう・・・

フェアリー

あの「ゲームで実際に戦わせた結果」があったから、シミュレーターが正しいって確かめられたんだよ!次回は、条件を選んで何千回も回せるウインドを作ろう

あとがき

今回は、ゲームのルールだけを写したシミュレーターを作り、同じシードでゲームとまったく同じ結果になることを確かめました。これで、カードや敵の数字を変えたときに「勝てるようになったか」を、1秒もかからずに確かめられます。次回は、エディタのウインドを作って、条件を選んで何千回も戦わせ、勝率やHPの分布を表とグラフで見られるようにしたいと思います!

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

コメント

コメント一覧 (1件)

コメントする

目次