
前回で、山札から5枚引いて手札にするところまで作りました。でも、カードが横一列にパッと出てくるだけでは、まだ「カードゲームっぽさ」が足りません・・・。今回は、手札を扇形に並べて、山札から1枚ずつ配られる動きと、マウスを乗せたカードがふわっと持ち上がる動きを付けてみたいと思います!
- シリーズの目次:デッキ構築カードゲームの作り方
- 前回:#4 山札・手札・捨て札をListで作ろう
今回作るもの
- 手札を扇形に並べる(真ん中が高く、端ほど下がって傾く)
- カードが目標の位置へ、すべるように動く
- 山札から1枚ずつ配る
- マウスを乗せたカードが持ち上がって大きくなり、両どなりがよける
| スクリプト | 役割 | 付ける場所 |
|---|---|---|
| HandLayout | 手札のカードの位置・角度・大きさを計算して、なめらかに動かす | HandArea |
| CardHover | マウスが乗った・外れたをHandLayoutに知らせる | Card(プレハブ) |
| Deck(直す) | 配るときに、1枚ずつ間をあけて手札に加える | Deck |
前回の続きから
前回の最後の状態から始めます。今回は、前回作ったDeckのLayoutHand(横一列に並べる処理)を、新しく作るHandLayoutに置きかえます。
扇形の位置を計算しよう
扇形に並べるには、カードごとに「どこに・どれだけ傾けて置くか」を計算します。ポイントは、真ん中のカードから何枚目か(t)で考えることです。

| 5枚のとき | 左端 | 真ん中 | 右端 | ||
|---|---|---|---|---|---|
| 何枚目(i) | 0 | 1 | 2 | 3 | 4 |
| 真ん中からの数(t) | -2 | -1 | 0 | 1 | 2 |
- 横の位置:
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 時間を扱う処理で紹介しています
- Drawは、中身をコルーチン(DrawRoutine)にして始めるだけにした。ターン終了ボタンからは、今までどおり
- yield return new WaitForSeconds(dealInterval)
- 1枚配るごとに0.08秒待つ。この間に前のカードが飛んでいくので、1枚ずつ配っているように見える
- yield break
- コルーチンを途中で終わらせる。forの中の
breakの代わり
- コルーチンを途中で終わらせる。forの中の
- 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




動かして確認!
再生してみましょう。




#5の動作確認
- 再生すると、山札から5枚が1枚ずつ飛んできて、扇形に並ぶ
- 真ん中のカードが一番高く、端のカードほど下がって傾いている
- マウスを乗せたカードが持ち上がって大きくなり、一番手前に出る。両どなりは少しよける
- マウスを外すと、元の位置にすべるように戻る
- ターン終了ボタンを押すと、手札が捨て札へ行き、新しい5枚が配られる
扇形の開き具合は、HandLayoutの数字で好みに変えられます。再生中にInspectorで変えると、その場で並び方が変わるので、いろいろ試してみてください。



Card Spacingを大きくしすぎたら、カードがバラバラに離れちゃいました・・・
あとがき
今回は、手札を扇形に並べて、配る・持ち上げる動きを付けました。同じ5枚のカードでも、動きが付くだけでぐっと「カードゲームの手札」らしくなりますね。次回は、カードをドラッグして敵の上で離すと使える、という操作を作りたいと思います!
コメント
コメント一覧 (1件)
[…] 前回:#5 手札を扇形に並べて、配る・持ち上げる動きを付けよう […]