デッキ構築カードゲームの作り方#5|手札を扇形に並べて、配る・持ち上げる動きを付けよう

ターン終了ボタンを押すと、山札から5枚が1枚ずつ飛んできて扇形に並ぶ。マウスを左から右へ動かすと、乗せたカードが持ち上がって大きくなり、両どなりがよける

前回で、山札から5枚引いて手札にするところまで作りました。でも、カードが横一列にパッと出てくるだけでは、まだ「カードゲームっぽさ」が足りません・・・。今回は、手札を扇形に並べて、山札から1枚ずつ配られる動きと、マウスを乗せたカードがふわっと持ち上がる動きを付けてみたいと思います!

目次

今回作るもの

  • 手札を扇形に並べる(真ん中が高く、端ほど下がって傾く)
  • カードが目標の位置へ、すべるように動く
  • 山札から1枚ずつ配る
  • マウスを乗せたカードが持ち上がって大きくなり、両どなりがよける
スクリプト役割付ける場所
HandLayout手札のカードの位置・角度・大きさを計算して、なめらかに動かすHandArea
CardHoverマウスが乗った・外れたをHandLayoutに知らせるCard(プレハブ)
Deck(直す)配るときに、1枚ずつ間をあけて手札に加えるDeck

前回の続きから

前回の最後の状態から始めます。今回は、前回作ったDeckのLayoutHand(横一列に並べる処理)を、新しく作るHandLayoutに置きかえます。

扇形の位置を計算しよう

扇形に並べるには、カードごとに「どこに・どれだけ傾けて置くか」を計算します。ポイントは、真ん中のカードから何枚目か(t)で考えることです。

扇形に並んだ5枚のカードに、左から t=-2・-1・0・1・2 の番号。横は t×間隔、高さは t×t×12 だけ下げる、角度は t×5度 傾ける
5枚のとき左端真ん中右端
何枚目(i)01234
真ん中からの数(t)-2-1012
  • 横の位置:t × 間隔。真ん中が0で、左右に同じだけ広がる
  • 高さ:t × t × arcHeightだけ下げる。tを2回かけるとマイナスが消えるので、左右どちらの端も同じように下がって、弧になる
  • 角度:t × angleStepだけ傾ける。左は左に、右は右に倒れる

手札の枚数が変わっても、真ん中は(枚数 - 1) ÷ 2で求められるので、同じ計算で並べられます。

目標の位置へLerpで近づけよう

計算した位置にカードをいきなり置くのではなく、毎フレーム少しずつ近づけると、すべるように動きます。これにはLerpを使います。

  • Vector2.Lerp(今の位置, 目標の位置, 割合)は、今の位置と目標の位置の間の点を返す
  • 割合を0.2にすると「残りの距離の2割だけ進む」。毎フレームくり返すと、最初は速く、目標に近づくほどゆっくりになる

では、HandLayoutを作りましょう。

  • DeckBuilding/Scriptsに「HandLayout」スクリプトを作る
using System.Collections.Generic;
using UnityEngine;

// 手札のカードを扇形に並べて、目標の位置へなめらかに動かす
public class HandLayout : MonoBehaviour
{
    [Header("扇形")]
    [SerializeField] private float cardSpacing = 150f; // となりのカードとの間隔
    [SerializeField] private float arcHeight = 12f;    // 端のカードほど下げる量
    [SerializeField] private float angleStep = 5f;     // 1枚ごとに傾ける角度
    [SerializeField] private float moveSpeed = 12f;    // 目標へ近づく速さ

    [Header("マウスを乗せたカード")]
    [SerializeField] private float hoverLift = 110f;   // 持ち上げる高さ
    [SerializeField] private float hoverScale = 1.25f; // 大きくする倍率
    [SerializeField] private float hoverPush = 50f;    // 両どなりがよける距離

    private List<RectTransform> cards = new List<RectTransform>();
    private RectTransform hoveredCard; // マウスが乗っているカード(無ければ null)

    // 手札にカードを加える・外す
    public void Add(RectTransform card)
    {
        cards.Add(card);
    }

    public void Remove(RectTransform card)
    {
        cards.Remove(card);
        if (hoveredCard == card)
        {
            hoveredCard = null;
        }
    }

    // マウスが乗った・外れた(CardHover から呼ばれる)
    public void SetHovered(RectTransform card)
    {
        hoveredCard = card;
    }

    public void ClearHovered(RectTransform card)
    {
        if (hoveredCard == card)
        {
            hoveredCard = null;
        }
    }

    private void Update()
    {
        float center = (cards.Count - 1) / 2f;
        int hoveredIndex = cards.IndexOf(hoveredCard);
        float rate = Mathf.Min(1f, moveSpeed * Time.deltaTime);

        for (int i = 0; i < cards.Count; i++)
        {
            // 扇形の目標(真ん中から何枚目か=t で決める)
            float t = i - center;
            Vector2 targetPosition = new Vector2(t * cardSpacing, -t * t * arcHeight);
            float targetAngle = -t * angleStep;
            float targetScale = 1f;

            // マウスが乗っているカードは持ち上げて、両どなりはよける
            if (hoveredIndex >= 0)
            {
                if (i == hoveredIndex)
                {
                    targetPosition.y += hoverLift;
                    targetAngle = 0f;
                    targetScale = hoverScale;
                }
                else if (i < hoveredIndex)
                {
                    targetPosition.x -= hoverPush;
                }
                else
                {
                    targetPosition.x += hoverPush;
                }
            }

            // 目標へ少しずつ近づける
            RectTransform card = cards[i];
            card.anchoredPosition = Vector2.Lerp(card.anchoredPosition, targetPosition, rate);
            card.localRotation = Quaternion.Lerp(card.localRotation, Quaternion.Euler(0f, 0f, targetAngle), rate);
            card.localScale = Vector3.Lerp(card.localScale, Vector3.one * targetScale, rate);

            // 左のカードほど奥に描く
            card.SetSiblingIndex(i);
        }

        // マウスが乗っているカードは、一番手前に描く
        if (hoveredCard != null)
        {
            hoveredCard.SetAsLastSibling();
        }
    }
}
  • center
    • 真ん中のカードの番号。5枚なら2、4枚なら1.5(真ん中が2枚の間になる)
  • targetPosition・targetAngle・targetScale
    • そのカードの「目標」。毎フレーム計算し直すので、手札の枚数が変わっても自動で並び直す
  • rate
    • moveSpeed × Time.deltaTimeで、1フレームに進む割合を決める。Time.deltaTimeをかけておくと、フレームレートが違っても同じぐらいの速さになる
    • Mathf.Min(1f, …)で1より大きくならないようにしている(1をこえると、目標を通りこしてしまう)
  • Quaternion.Lerp
    • 角度(回転)をなめらかに変える、回転用のLerp
  • SetSiblingIndex(i)
    • Hierarchyの順番を、左のカードから順にそろえる。下にあるものほど手前に描かれるので、右のカードが左のカードに重なる並びになる

このHandLayoutは「目標を決めて、そこへ近づける」だけで、カードを直接どこかへ飛ばす処理は書いていません。なので、配るときもマウスを乗せたときも、目標が変わるだけで、あとは勝手にすべるように動いてくれます。

Deckを直そう

HandLayoutができたので、Deckを直します。変わるのは3つです。

  • 前回のLayoutHandとcardSpacingを消して、HandLayoutに手札を加える
  • 引くときに、カードを山札の位置に作ってから手札に加える(山札から飛んでくるように見える)
  • 1枚ずつ、少し間をあけて配る(コルーチン)

直したところには// ★#5と書いてあります。

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;

// 山札・手札・捨て札を List で持って、カードを引いたり捨てたりする
public class Deck : MonoBehaviour
{
    // 最初のデッキの1行分(どのカードを何枚入れるか)
    [System.Serializable]
    public class DeckEntry
    {
        public CardData card;
        [Min(1)] public int count = 1;
    }

    [Header("最初のデッキ")]
    [SerializeField] private List<DeckEntry> startingDeck = new List<DeckEntry>();

    [Header("表示")]
    [SerializeField] private CardView cardPrefab;               // 手札に作るカード(#3のプレハブ)
    [SerializeField] private HandLayout handLayout;             // ★#5 手札を並べる HandLayout
    [SerializeField] private RectTransform drawPileArea;        // ★#5 山札の場所(ここからカードが飛んでくる)
    [SerializeField] private TextMeshProUGUI drawPileText;      // 山札の枚数
    [SerializeField] private TextMeshProUGUI discardPileText;   // 捨て札の枚数
    [SerializeField] private float dealInterval = 0.08f;        // ★#5 1枚配るごとの間(秒)

    [Header("確かめるとき用")]
    [SerializeField] private int randomSeed = 0; // 0以外にすると、毎回同じ順番に混ざる

    private List<CardData> drawPile = new List<CardData>();    // 山札
    private List<CardData> hand = new List<CardData>();        // 手札
    private List<CardData> discardPile = new List<CardData>(); // 捨て札
    private List<CardView> handViews = new List<CardView>();   // 手札のカードの見た目

    private void Start()
    {
        if (randomSeed != 0)
        {
            Random.InitState(randomSeed);
        }

        // 最初のデッキを、枚数ぶん山札に入れる
        foreach (DeckEntry entry in startingDeck)
        {
            for (int i = 0; i < entry.count; i++)
            {
                drawPile.Add(entry.card);
            }
        }
        Shuffle(drawPile);
        Draw(5);
    }

    // 山札から count 枚引いて、手札にする
    public void Draw(int count)
    {
        StartCoroutine(DrawRoutine(count)); // ★#5 1枚ずつ間をあけて配る
    }

    // ★#5 1枚ずつ、dealInterval 秒おきに配る
    private IEnumerator DrawRoutine(int count)
    {
        for (int i = 0; i < count; i++)
        {
            if (drawPile.Count == 0)
            {
                if (discardPile.Count == 0)
                {
                    yield break; // 山札にも捨て札にもカードが無い
                }
                RefillFromDiscard();
            }

            // 山札の一番うしろ(上)から1枚取る
            int last = drawPile.Count - 1;
            CardData card = drawPile[last];
            drawPile.RemoveAt(last);
            hand.Add(card);

            // 山札の場所に小さく作って、手札に加える(あとは HandLayout が動かしてくれる)
            CardView view = Instantiate(cardPrefab, handLayout.transform);
            view.Setup(card);
            RectTransform rt = (RectTransform)view.transform;
            rt.position = drawPileArea.position;
            rt.localScale = Vector3.one * 0.5f;
            handLayout.Add(rt);
            handViews.Add(view);
            UpdateCounts();

            yield return new WaitForSeconds(dealInterval);
        }
    }

    // 手札を全部、捨て札へ送る
    public void DiscardHand()
    {
        discardPile.AddRange(hand);
        hand.Clear();
        foreach (CardView view in handViews)
        {
            handLayout.Remove((RectTransform)view.transform); // ★#5
            Destroy(view.gameObject);
        }
        handViews.Clear();
        UpdateCounts();
    }

    // 捨て札をシャッフルして、山札に戻す
    private void RefillFromDiscard()
    {
        drawPile.AddRange(discardPile);
        discardPile.Clear();
        Shuffle(drawPile);
        Debug.Log($"山札がなくなったので、捨て札{drawPile.Count}枚をシャッフルして山札に戻した");
    }

    // リストの順番をランダムに入れかえる(Fisher-Yates のシャッフル)
    private void Shuffle(List<CardData> cards)
    {
        for (int i = cards.Count - 1; i > 0; i--)
        {
            int j = Random.Range(0, i + 1);
            CardData temp = cards[i];
            cards[i] = cards[j];
            cards[j] = temp;
        }
    }

    // 山札と捨て札の枚数を表示する
    private void UpdateCounts()
    {
        drawPileText.text = $"山札\n{drawPile.Count}"; // \n は改行
        discardPileText.text = $"捨て札\n{discardPile.Count}";
    }
}

山札から1枚ずつ配る

  • Draw → DrawRoutine
    • Drawは、中身をコルーチン(DrawRoutine)にして始めるだけにした。ターン終了ボタンからは、今までどおりDraw(5)で呼べる
    • コルーチンの基本は6-11 時間を扱う処理で紹介しています
  • yield return new WaitForSeconds(dealInterval)
    • 1枚配るごとに0.08秒待つ。この間に前のカードが飛んでいくので、1枚ずつ配っているように見える
  • yield break
    • コルーチンを途中で終わらせる。forの中のbreakの代わり
  • rt.position = drawPileArea.position
    • positionは、画面の中の本当の位置(ワールド座標)。親がちがっても同じ場所に置けるので、Hudにある山札の位置に、手札のカードを作れる
    • 小さく(0.5倍)作っておくと、飛んでくる間に大きくなって、山札から出てきた感じが出ます
スライム

anchoredPositionじゃなくてpositionなんですね・・・

フェアリー

anchoredPositionは「親の中での位置」だから、親がちがうものどうしだと合わないんだ。別の親の場所に合わせたいときはpositionを使うといいよ!

マウスを乗せたら持ち上げよう

最後に、マウスを乗せたカードを持ち上げます。マウスが乗った・外れたを受け取る方法は、9-7 UIをドラッグ&ドロップしようで紹介したIPointerEnterHandler・IPointerExitHandlerです。

  • DeckBuilding/Scriptsに「CardHover」スクリプトを作る
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

// マウスが乗った・外れたを、手札の HandLayout に知らせる
[RequireComponent(typeof(Image))]
public class CardHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
    [SerializeField] private float extendBottom = 120f; // 持ち上がっている間、当たり判定を下に伸ばす量

    private HandLayout handLayout;
    private Image image;
    private RectTransform rectTransform;

    private void Awake()
    {
        image = GetComponent<Image>();
        rectTransform = (RectTransform)transform;
    }

    private void Start()
    {
        // 親(HandArea)に付いている HandLayout を探す
        handLayout = GetComponentInParent<HandLayout>();
    }

    public void OnPointerEnter(PointerEventData eventData)
    {
        if (handLayout == null)
        {
            return;
        }
        handLayout.SetHovered(rectTransform);
        // 持ち上がっても、マウスの下から当たり判定が逃げないようにする
        image.raycastPadding = new Vector4(0f, -extendBottom, 0f, 0f);
    }

    public void OnPointerExit(PointerEventData eventData)
    {
        if (handLayout == null)
        {
            return;
        }
        handLayout.ClearHovered(rectTransform);
        image.raycastPadding = Vector4.zero;
    }
}
  • GetComponentInParent()
    • 自分から親の方へたどって、HandLayoutを探す。カードはHandAreaの子として作られるので、HandAreaのHandLayoutが見つかる
  • image.raycastPadding
    • マウスに反応する範囲(当たり判定)を、見た目より広くしたり狭くしたりする設定。値をマイナスにすると広がる
    • 持ち上がったカードは上に動くので、カードの下の方にマウスがあると、カードがマウスの下から逃げて「外れた」になり、下がるとまた「乗った」になる・・・をくり返して、カードがガタガタします
    • 持ち上がっている間だけ当たり判定を下に伸ばしておくと、元の場所にマウスがあっても「乗ったまま」になります

カードの中の名前や説明のテキストも、マウスに反応します(Raycast Targetがオン)。テキストの上にマウスが移っても、親のCardの「乗った」は続くので、今回はそのままでOKです。

スクリプトを付けよう

  • HierarchyのHandAreaにHandLayoutを付ける(値は最初のままでOK)
  • ProjectウインドのPrefabs>Cardをダブルクリックしてプレハブを開き、CardにCardHoverを付ける。付けたら、Sceneビュー左上の「<」でプレハブの編集を終える
  • HierarchyのDeckを選んで、Deckの欄を設定する
    • Hand Layout:HandArea
    • Draw Pile Area:DrawPile(Hudの中の山札)
    • Deal Interval:0.08
HandAreaのHand Layout。Card Spacing 150、Arc Height 12、Angle Step 5、Move Speed 12、Hover Lift 110、Hover Scale 1.25、Hover Push 50
DeckのInspector。Hand LayoutにHandArea、Draw Pile AreaにDrawPile、Deal Intervalが0.08

プレハブを開かずに、Hierarchyにあるカードに付けても、そのカードにしか付きません。今回のカードはスクリプトからプレハブを使って作るので、プレハブそのものに付けておきましょう。

動かして確認!

再生してみましょう。

ターン終了ボタンを押すと、手札が消えて、左下の山札から5枚が1枚ずつ飛んできて扇形に並ぶ
マウス(矢印)を左のカードから右のカードへ動かすと、乗せたカードが持ち上がって大きく・まっすぐになり、両どなりが少しよける

#5の動作確認

  • 再生すると、山札から5枚が1枚ずつ飛んできて、扇形に並ぶ
  • 真ん中のカードが一番高く、端のカードほど下がって傾いている
  • マウスを乗せたカードが持ち上がって大きくなり、一番手前に出る。両どなりは少しよける
  • マウスを外すと、元の位置にすべるように戻る
  • ターン終了ボタンを押すと、手札が捨て札へ行き、新しい5枚が配られる

扇形の開き具合は、HandLayoutの数字で好みに変えられます。再生中にInspectorで変えると、その場で並び方が変わるので、いろいろ試してみてください。

動きをもっとキビキビさせたいときはMove Speedを大きく、ゆったりさせたいときは小さくします。カードが多くなって画面からはみ出すときは、Card Spacingを小さくすると、重なりが増えて収まります。

スライム

Card Spacingを大きくしすぎたら、カードがバラバラに離れちゃいました・・・

あとがき

今回は、手札を扇形に並べて、配る・持ち上げる動きを付けました。同じ5枚のカードでも、動きが付くだけでぐっと「カードゲームの手札」らしくなりますね。次回は、カードをドラッグして敵の上で離すと使える、という操作を作りたいと思います!

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

コメント

コメント一覧 (1件)

コメントする

目次