
本編の#15では、強すぎたボスを調整するために、ゲームを再生して自動でカードを使わせ、何度も戦わせました。でも、画面の上で1枚ずつカードを動かすので、1回通すのに15分ほどかかります・・・。この番外編では、画面を使わずにルールだけで戦いを回す「バトルのシミュレーター」を作ってみたいと思います!1回目の今回は、シミュレーターの本体を作って、ゲームとまったく同じ結果になるかを確かめます。
- シリーズの目次:デッキ構築カードゲームの作り方
- 本編の最終回:#16 効果音とBGM、タイトルとクリア画面で完成
番外編で作るもの
| 回 | 作るもの |
|---|---|
| ①(今回) | 画面なしで戦いを回すシミュレーター。ゲームと結果が一致するかを確かめる |
| ② | エディタのウインドで、条件を選んで何千回も戦わせ、勝率・ターン数・残りHPを表とグラフで見る |
| ③ | 敵の数字を上書きして比べ、バランスを決める |
今回作るもの
| スクリプト | 役割 | 写すもの(本編) |
|---|---|---|
| SimFighter | HP・防御・状態 | Fighter |
| SimEnemy | 敵1体と、行動の並び | EnemyView |
| SimDeck | 山札・手札・捨て札と乱数 | Deck |
| SimEffects | カードの効果を当てる | 各CardEffect |
| SimBattle | 1回の戦い(ターンの流れ) | BattleManager・PlayZone・EnemyParty |
| SimPolicy・SimBasicPolicy | カードの選び方(考え方) | (#15で使った自動のプレイ) |
| SimSetup・SimResult・SimRun | 条件・結果・戦いの並び | RunManager・RewardView |
| SimMenu | メニューから回す(エディタ専用) | - |
本編のスクリプトは、効果のクラスに「数字を読むプロパティ」を足すだけです。
シミュレーターの考え方

本編のスクリプトは、ルール(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・SharpenEffect | public int Amount => amount; // ★番外編 シミュレーターで読む |
| DrawEffect | public 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と同じ乱数の使い方にします

メニューから回そう: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と同じように読めます。本編のスクリプトに、読むためのプロパティを足さずに済みます
- RunManagerの
- シードは、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%です(この選び方で、最初のデッキから始めた場合)。

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



あの「ゲームで実際に戦わせた結果」があったから、シミュレーターが正しいって確かめられたんだよ!次回は、条件を選んで何千回も回せるウインドを作ろう
あとがき
今回は、ゲームのルールだけを写したシミュレーターを作り、同じシードでゲームとまったく同じ結果になることを確かめました。これで、カードや敵の数字を変えたときに「勝てるようになったか」を、1秒もかからずに確かめられます。次回は、エディタのウインドを作って、条件を選んで何千回も戦わせ、勝率やHPの分布を表とグラフで見られるようにしたいと思います!
コメント
コメント一覧 (1件)
[…] 前回:番外編①バトルのシミュレーターを作ろう:画面なしで戦いを回す […]