デッキ構築カードゲームの作り方#15|ボスと手下

報酬を選んだあと、スライムちゃんが眠って緑の「+10」。上の表示が「ボス戦(4/4)」に変わり、背景が暗くなって、画面が揺れながらおもちゃの王さまが大きくポンと出てくる。「おもちゃの王さま」の帯が出て、背景が明るく戻り、「あなたのターン」の帯。王さまの予告はラッパのアイコン

前回で、1戦目から3戦目まで続けて戦えるようになりました。今回はいよいよ最後の戦い、ボスの「おもちゃの王さま」を作ります。登場の演出と、手下を呼ぶ行動を作ってボス戦らしくしたら、最後に「本当に勝てるのか?」を、シードを固定して確かめてみたいと思います!

目次

今回作るもの

  • ボス「おもちゃの王さま」と、手下「ちびロボ」
  • 敵の大きさをデータで決める(ボスは大きく、手下は小さく)
  • 手下を呼ぶ行動(予告はラッパのアイコン。手下は左右に最大2体)
  • ボスの登場演出:背景が暗くなって、揺れながら大きく出てきて、名前の帯
  • バランスの確認:シードを固定して、何度戦っても同じ条件で比べられるようにする
スクリプト直すところ
EnemyData呼ぶ手下、見た目の大きさ
EnemyView大きさ、呼ぶ予告、ポンと出てくる動き
EnemyParty手下を空いている場所に作る
BattleManager手下を呼ぶ
BattleData・RunManagerボス戦の登場演出。HPが減った状態から試せるように
FighterHPを決まった値にする(確かめるとき用)
Deck・RewardView山札と報酬だけが使う乱数

前回の続きから

前回の最後の状態から始めます。王さまの絵と、呼ぶ予告のアイコンは、素材のzipを入れ直すと入ります。

  • DeckBuildingAssets.zip
    • DeckBuilding/Sprites/Enemies/enemy_king.png(おもちゃの王さま)
    • DeckBuilding/Sprites/Icons/icon_summon.png(ラッパ。手下を呼ぶ予告)
    • 自分で入れた場合は、Sprite Mode:Singleにして Apply

手下の「ちびロボ」は、ブリキロボの絵を小さくして使います。

ボスと手下のデータ

EnemyDataに足す

    // 敵の1回分の行動(攻撃と防御。どちらも0で、呼ぶ手下もいなければ「ためる」)
    [System.Serializable]
    public class EnemyAction
    {
        [Min(0)] public int attack;
        [Min(0)] public int block;
        public EnemyData summon; // ★#15 呼ぶ手下(空なら呼ばない)
    }
    public List<EnemyAction> actions = new List<EnemyAction>(); // 行動の並び(上から順にくり返す)
    [Min(0.1f)] public float scale = 1f; // ★#15 見た目の大きさ(ボスは大きく、手下は小さく)
  • summon:行動の中に「呼ぶ手下」を入れられるようにします。EnemyDataの中にEnemyDataを入れる形です
  • scale:今までの敵は1のままでOK(新しく足した項目は、今あるアセットでは最初の値の1になります)

EnemyViewに足す

予告のアイコンを1つ足します。

    [SerializeField] private Sprite chargeIcon;
    [SerializeField] private Sprite summonIcon; // ★#15 手下を呼ぶ

Setupで、データの大きさにします。

        fighter.Setup(data.enemyName, data.maxHp);
        transform.localScale = Vector3.one * data.scale; // ★#15 データの大きさにする

#12の倒れる動きは、大きさを1として書いていたので、データの大きさをかけます。

            transform.localScale = new Vector3(1f + t * 0.4f, 1f - t, 1f) * data.scale; // ★#15 データの大きさをかける

ShowIntentに、呼ぶ予告を足します(「ためる」のelseの前)。

        else if (action.summon != null)
        {
            intentIcon.sprite = summonIcon; // ★#15 手下を呼ぶ
            intentText.text = "";
        }
  • Prefabs/EnemyのEnemy View:Summon Iconにicon_summonを入れる

Summon Iconを入れ忘れると、呼ぶ行動の予告が白い四角になります。予告がおかしいときは、プレハブのEnemy Viewを見直しましょう。

データを作る

Data/Enemiesに2つ作ります。

ファイル名Enemy NameSpriteMax HpScaleActions
Minionちびロボenemy_tinrobot100.75攻撃3
Kingおもちゃの王さまenemy_king641.3① Summon:Minion ② 攻撃12 ③ 防御8+攻撃6 ④ 攻撃16
ProjectウインドでData/Enemies/Kingを選択したInspector。Enemy Nameがおもちゃの王さま、Max Hpが64、Actionsが4つ(Element 0のSummonにMinion、Element 1がAttack 12、Element 2がAttack 6・Block 8、Element 3がAttack 16)、Scaleが1.3。Element 0のSummonを赤枠で囲む

数字は、このあとの「バランスを考えよう」で決めたものです。最初はもっと強い数字(HP 120など)で作っていました。

手下を呼ぼう

ポンと出てくる動き

呼ばれた手下と、ボスの登場で使う「ポンと出てくる」動きを、EnemyViewに足します(StepOutの下あたり)。

    // ★#15 ポンと出てくる:0 → 1.2倍 → 元の大きさ(ボスの登場と、呼ばれた手下で使う)
    public IEnumerator Appear(float seconds)
    {
        for (float time = 0f; time < seconds; time += Time.deltaTime)
        {
            float t = time / seconds;
            float rate = t < 0.7f ? Mathf.Lerp(0f, 1.2f, t / 0.7f) : Mathf.Lerp(1.2f, 1f, (t - 0.7f) / 0.3f);
            transform.localScale = Vector3.one * data.scale * rate;
            yield return null;
        }
        transform.localScale = Vector3.one * data.scale;
    }

空いている場所に手下を作る(EnemyParty)

手下は、ボスの左右の決まった場所に出します。EnemyPartyを次のように直します。

using System.Collections.Generic;
using UnityEngine;

// 敵のデータのリストから、敵を横に並べて作る
public class EnemyParty : MonoBehaviour
{
    [SerializeField] private EnemyView enemyPrefab;
    [SerializeField] private float spacing = 420f; // となりの敵との間隔
    [SerializeField] private Vector2[] summonSlots = { new Vector2(-330f, 20f), new Vector2(330f, 20f) }; // ★#15 手下が出てくる場所

    private List<EnemyView> views = new List<EnemyView>();
    private EnemyView[] summoned; // ★#15 手下の場所ごとに、今いる手下(いなければ null)

    public List<EnemyView> Views => views;

    // ★#14 敵のリストを受け取って、横に並べて作る(前は Start で、Inspector のリストから作っていた)
    public void Spawn(List<EnemyData> enemies)
    {
        views.Clear();
        summoned = new EnemyView[summonSlots.Length]; // ★#15

        // 真ん中を0にして、左右に同じだけ広げる
        float left = -(enemies.Count - 1) * spacing / 2f;
        for (int i = 0; i < enemies.Count; i++)
        {
            Create(enemies[i], new Vector2(left + i * spacing, 20f)); // ★#15 作るところを Create に分けた
        }
    }

    // ★#15 手下を呼ぶ:あいている場所に1体作る(場所がいっぱいなら呼ばない)
    public EnemyView Summon(EnemyData data)
    {
        for (int i = 0; i < summonSlots.Length; i++)
        {
            // 倒されて Destroy された手下は、null と同じに扱われるので、その場所はあいている
            if (summoned[i] == null)
            {
                summoned[i] = Create(data, summonSlots[i]);
                return summoned[i];
            }
        }
        return null;
    }

    // ★#15 敵を1体作って、指定した場所に置く
    private EnemyView Create(EnemyData data, Vector2 position)
    {
        EnemyView view = Instantiate(enemyPrefab, transform);
        ((RectTransform)view.transform).anchoredPosition = position;
        view.Setup(data);
        views.Add(view);
        return view;
    }
}
  • summonSlots:手下が出てくる場所(ボスの左と右)。場所が2つなので、手下は最大2体です
  • summoned:場所ごとに、今そこにいる手下を覚えておく配列
  • summoned[i] == nullで、場所が空いているか調べる
    • Unityのオブジェクトは、Destroyされるとnullと同じに扱われます。手下が倒されて消えると、その場所はまた空きになります
  • 敵を1体作る処理は、SpawnとSummonの両方で使うので、Createに分けました

BattleManagerで呼ぶ

一番上にusing System.Collections.Generic;を足して、敵のターンを直します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
        // ★#15 行動の途中で手下が増えてもいいように、リストのコピーを回す
        foreach (EnemyView enemy in new List<EnemyView>(enemyParty.Views))
        {
  • foreachの中でViewsに手下が増えると、「回している途中でリストが変わった」というエラーになります。コピー(new List<EnemyView>(…))を回すと、元のリストに足してもエラーになりません
  • 呼ばれた手下は、コピーに入っていないので、次の敵のターンから行動します

AdvanceActionの前に、呼ぶ行動を足して、「ためる」の条件も直します。

            if (action.summon != null)
            {
                EnemyView minion = enemyParty.Summon(action.summon); // ★#15 手下を呼ぶ
                if (minion != null)
                {
                    StartCoroutine(minion.Appear(0.3f));
                    Debug.Log($"{enemy.Fighter.FighterName}は{minion.Fighter.FighterName}を呼んだ!");
                }
            }
            if (action.attack == 0 && action.block == 0 && action.summon == null) // ★#15 呼ぶときは「ためる」ではない
            {
王さまの予告がラッパのアイコンのときに敵のターンになると、王さまの左にちびロボ(小さいブリキロボ)がポンと出てくる。王さまの予告が剣と12に変わる。敵の場所を切り抜き

ボスの登場演出

ボス戦の印と、暗くする幕

BattleDataに、ボス戦の印を足します。

    public List<EnemyData> enemies = new List<EnemyData>(); // 出てくる敵(左から順に並ぶ)
    public bool isBoss; // ★#15 ボス戦(登場の演出をする)
  • Data/BattlesにBattle4を作る:Battle Name:ボス戦、Enemies:King、Is Boss:チェック
  • RunManagerのBattlesを4つにして、4つ目にBattle4を入れる

#14で「勝利!」の表示をRunManagerに移しておいたので、4つ目に足したボス戦が「最後の戦い」になり、王さまを倒すと自動で「ぜんぶクリア!」が出ます。

背景を暗くする幕を作ります。

  • Stageの子にCurtain(UI (Canvas)>Image)
    • Stageの子の一番上(一番うしろに描かれる)
    • アンカー:stretch-stretch、Left・Top・Right・Bottom:-60(揺れてもふちが見えないように、少し大きく)
    • Color:#000000、Alpha:0、Raycast Targetのチェックを外す

Stageの一番うしろに置くので、暗くなるのは背景だけです。スライムちゃんや王さま、手札は明るいまま浮かび上がります。

RunManagerに足す

    [SerializeField] private TextMeshProUGUI runLabel; // 今何戦目か

    [Header("★#15 ボスの登場")]
    [SerializeField] private Image curtain;            // 背景を暗くする幕(Stage の一番うしろ)
    [SerializeField] private ScreenShake screenShake;
    [SerializeField] private TurnBanner turnBanner;
            enemyParty.Spawn(battle.enemies);
            if (battle.isBoss)
            {
                yield return StartCoroutine(BossIntro()); // ★#15 ボスの登場
            }
            battleManager.StartBattle();

Restの上に、2つのコルーチンを足します。

    // ★#15 ボスの登場:背景が暗くなって、揺れながら大きく出てきて、名前の帯
    private IEnumerator BossIntro()
    {
        EnemyView boss = enemyParty.Views[0];
        boss.transform.localScale = Vector3.zero; // 出てくるまで見えないように

        yield return StartCoroutine(FadeCurtain(0f, 0.75f, 0.4f)); // 暗くなる
        screenShake.Play(15);                                       // ダメージ15と同じ強さで揺らす
        yield return StartCoroutine(boss.Appear(0.6f));             // 大きく出てくる
        yield return StartCoroutine(turnBanner.Show(boss.Data.enemyName));
        yield return StartCoroutine(FadeCurtain(0.75f, 0f, 0.4f)); // 明るく戻る
    }

    // ★#15 幕の濃さを from から to へ、seconds 秒で変える
    private IEnumerator FadeCurtain(float from, float to, float seconds)
    {
        for (float time = 0f; time < seconds; time += Time.deltaTime)
        {
            curtain.color = new Color(0f, 0f, 0f, Mathf.Lerp(from, to, time / seconds));
            yield return null;
        }
        curtain.color = new Color(0f, 0f, 0f, to);
    }
  • 演出は、今まで作った部品の組み合わせです
    • 揺れは#10のScreenShake、帯は#9のTurnBanner、ポンと出てくるのは今回のAppear
  • yield return StartCoroutine(…)で1つずつ終わるのを待つので、「暗くなる → 出てくる → 名前 → 明るくなる」の順番が、そのまま上から読めます
  • RunManagerのCurtainにStage>Curtain、Screen ShakeにEffects、Turn BannerにHud>TurnBannerを入れる
「ボス戦(4/4)」になると、背景が暗くなり、画面が揺れながら王さまが0から1.2倍、元の大きさへとポンと出てくる。「おもちゃの王さま」の帯が出て、背景が明るく戻り、「あなたのターン」の帯が出る

バランスを考えよう

ボス戦が出来たので、実際に戦ってみました。最初は王さまを「HP 120、呼ぶ → 攻撃14 → 防御15+攻撃8 → 攻撃22」で作っていましたが、自分では勝てないぐらい強かったです・・・。そこで、条件を固定して何度も戦わせ、数字で比べながら調整しました。

シードを固定しても、順番がずれる問題

#4で、Deckの「確かめるとき用」にシード(Random Seed)を入れると、毎回同じ順番に混ざるようにしていました。ところが、同じシードで2回戦わせると、途中から手札がちがってきました。

原因は、Randomを使っているのが山札だけではないことです。#10の画面の揺れ(Random.insideUnitCircle)や、ダメージの数字の飛び方(Random.Range)も同じRandomを使っています。揺れは毎フレームRandomを使うので、パソコンの速さで使う回数が変わり、そのぶん山札を混ぜるときの数がずれてしまいます。

そこで、山札と報酬だけが使う乱数を、Deckに持たせます。

    private List<CardView> handViews = new List<CardView>();   // 手札のカードの見た目
    private System.Random random; // ★#15 山札と報酬だけが使う乱数(演出の乱数とまざらないように)

Startの始めのRandom.InitStateのところを、次のように直します。

        // ★#15 シードを入れたら、いつも同じ順番に混ざる乱数を作る
        random = randomSeed != 0 ? new System.Random(randomSeed) : new System.Random();

ShuffleのRandom.Rangeを、random.Nextに変えます。

            int j = random.Next(0, i + 1); // ★#15 0 から i までのどれか(Random.Range と同じで、最後の数は入らない)

報酬でも同じ乱数を使えるように、メソッドを足します(ResetForBattleの上)。

    // ★#15 山札と同じ乱数で、0 から max-1 までの数を返す(報酬を選ぶときに使う)
    public int NextRandom(int max)
    {
        return random.Next(0, max);
    }

RewardViewの、候補を混ぜるところも直します。

            int j = deck.NextRandom(i + 1); // ★#15 山札と同じ乱数を使う(シードで毎回同じになる)
  • System.Randomは、C#にもともとある乱数です。new System.Random(シード)で作ると、そのオブジェクトだけの数の並びになります
  • 演出は今までどおりUnityのRandom、山札と報酬はrandomと分けたので、揺れがいくつ動いても、山札の順番はずれません

「同じシードなら同じ結果になる」は、バランスを比べるときの土台です。同じシードで2回戦わせて、まったく同じ結果になることを確かめてから、数字を変えて比べましょう。

HPが減った状態から試す

ボス戦は、3戦を終えて休んだあとの戦いです。HPが満タンのまま試しても、本番とはちがいます。そこで、RunManagerの「確かめるとき用」に、始めるHPを足します。

    [SerializeField] private int startBattle = 0; // 0以外にすると、その番号の戦いから始まる(2 なら3戦目)
    [SerializeField] private int startHp = 0;     // ★#15 0以外にすると、その HP から始まる(ボス戦を、減った HP で試すとき)
        yield return null;
        if (startHp > 0)
        {
            player.SetHp(startHp); // ★#15
        }

Fighterに、HPを決まった値にするメソッドを足します(ResetBlockの上)。

    // ★#15 HP を決まった値にする(確かめるときに、HP が減った状態から始めるため)
    public void SetHp(int newHp)
    {
        hp = Mathf.Clamp(newHp, 1, maxHp);
    }
  • Start Battleを3(ボス戦)、Start Hpを40にすると、「HP 40でボス戦から」を何度でも試せます
  • DeckのStarting Deckに報酬のカードを足しておけば、「報酬を取ったあとのデッキ」も再現できます

確かめた結果

シードを固定して、いつも同じ考え方でカードを選ぶ自動のプレイ(HPが減ったら回復、攻撃が来るなら防御、それ以外は手下から攻撃)で戦わせました。

最初の数字(王さま HP 120):HP 60・報酬3枚(ホチキス・ふでばこの壁・ハサミ)でボス戦から

シード結果
120ターンで負け
216ターンで負け(王さまの残りHP 36)
320ターンで負け(王さまの残りHP 30)

HP満タンで報酬を取っても勝てない、強すぎる数字でした。1ターンに王さまのHPが5ぐらいしか減らず、防御15のターンと、呼ばれた手下の相手で、攻撃が王さまに届かないのが原因でした。

さらに1戦目から通しで戦わせると、ボスの前の3戦目(ほつれたぬいぐるみ×2)で負けました。ぬいぐるみ2体でHPが合計88あり、ここでHPを使い切っていました。

直した数字

敵前後
おもちゃの王さまHP 120:呼ぶ → 攻撃14 → 防御15+攻撃8 → 攻撃22HP 64:呼ぶ → 攻撃12 → 防御8+攻撃6 → 攻撃16
ちびロボHP 12:攻撃4HP 10:攻撃3
ほつれたぬいぐるみHP 44:攻撃5+防御8 → 攻撃10HP 30:攻撃4+防御6 → 攻撃8
  • Data/Enemies/Plushも、この数字に直します

直した数字で、1戦目から通し(報酬も自動で選ぶ)

シード報酬で取ったカードボス戦を始めたHP結果
1分度器ブーメラン・えんぴつをけずる・えんぴつをけずる35勝ち(残りHP 12)
4ホチキスでパチン・ハサミでチョキチョキ・えんぴつをけずる34勝ち(残りHP 5)

(王さまをHP 70にした1つ前の数字では、シード2・3も勝ち、シード1は王さまの残りHP 2で負けでした。そこから、もう一段だけHPを下げています。)

ぎりぎりで勝てる、ボス戦らしい手応えになりました。報酬でどのカードを取るか、どこで防御するかで、勝ち負けが分かれるぐらいがちょうどいいと思います。

ここでの自動のプレイは、決まった順番でカードを選ぶだけの単純なものです。人が遊ぶと、予告を見て防御するタイミングを選べるので、もう少し余裕を持って勝てるはずです。むずかしすぎる・やさしすぎると感じたら、王さまのMax Hpや攻撃の数字を変えて、同じシードで比べてみてください。

動かして確認!

RunManagerのStart Battleを3にして再生すると、ボス戦から始められます(確かめたら0に戻しましょう)。

ボス戦(HP 40から)。王さまに定規でペシッで攻撃し、敵のターンにラッパの予告でちびロボが呼ばれる。ちびロボを倒しながら、攻撃12のターンを下じきガードで受けとめる

#15の動作確認

  • 3戦目に勝って休むと、「ボス戦(4/4)」になる
  • 背景が暗くなり、画面が揺れながら王さまが大きく出てきて、名前の帯が出る
  • 王さまは、ほかの敵より大きい。ちびロボは小さい
  • 予告がラッパのとき、敵のターンにちびロボが左右に出てくる(最大2体。倒すと、その場所にまた呼ばれる)
  • 王さまを倒すと「ぜんぶクリア!」
  • 同じシードで2回遊ぶと、手札の順番が同じになる
スライム

最初の数字だと、何度やっても勝てませんでした・・・

フェアリー

作った人は自分のゲームに慣れているから、思ったより強くなりがちなの。数字で比べて、少し弱めにするぐらいがちょうどいいよ!

あとがき

今回は、ボスの登場演出と手下を呼ぶ行動を作って、4戦を通して遊べるようにしました。そして、シードを固定して数字で比べながら、強すぎたボスを「ぎりぎり勝てる」ところまで調整しました。次回はいよいよ最終回。効果音とBGM、タイトルとクリア画面を付けて、Windowsで遊べるようにビルドして完成させたいと思います!

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

コメント

コメント一覧 (1件)

コメントする

目次