Files
aibis-dream/Assets/Scripts/MiniGame/Tarot/TarotDeck.cs
T
2026-07-28 03:47:00 +08:00

602 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using AibisDream.Kit;
using DG.Tweening;
using UnityEngine;
namespace AibisDream.MiniGame.Tarot
{
public class TarotDeck : MonoBehaviour
{
[Header("Fan Spread")]
[Tooltip("Total angle of the fan arc (degrees)")]
[SerializeField] private float fanAngleRange = 120f;
[Tooltip("Radius of the fan arc")]
[SerializeField] private float fanRadius = 3f;
[Tooltip("Fan spread animation duration")]
[SerializeField] private float spreadDuration = 0.6f;
[Tooltip("相邻牌启动时间差;0 = 同时展开。中心向外错开时仍乘以此值")]
[SerializeField] private float spreadStagger = 0.028f;
[Tooltip("true:中间牌先动、向两侧铺开;false:从左到右依次展开")]
[SerializeField] private bool spreadFromCenterOut = true;
[Tooltip("位移缓动;OutBack 易过冲显乱,默认 InOutQuint 更稳")]
[SerializeField] private Ease spreadMoveEase = Ease.InOutQuint;
[Tooltip("旋转缓动,可与位移略不同以增加层次")]
[SerializeField] private Ease spreadRotateEase = Ease.InOutQuint;
[Tooltip("可选:非空时优先于 spreadMoveEase / spreadRotateEase0~1 时间映射)")]
[SerializeField] private AnimationCurve spreadEaseCurve;
[Header("Hover")]
[Tooltip("Distance a card moves outward on hover")]
[SerializeField] private float hoverOffset = 0.5f;
[Tooltip("Hover animation duration")]
[SerializeField] private float hoverDuration = 0.18f;
[Tooltip("Hover 推出缓动")]
[SerializeField] private Ease hoverOutEase = Ease.OutQuint;
[Tooltip("Hover 收回缓动")]
[SerializeField] private Ease hoverInEase = Ease.InOutQuart;
[Tooltip("可选:覆盖 hover 推出/收回的 Ease")]
[SerializeField] private AnimationCurve hoverOutCurve;
[SerializeField] private AnimationCurve hoverInCurve;
[Header("Inspect")]
[Tooltip("Anchor transform for the inspect position")]
[SerializeField] private Transform inspectAnchor;
[Tooltip("检视 / 初显叠牌缩放;扇形摊开后回到 1")]
[SerializeField] private float inspectScale = 1.8f;
[Tooltip("摊开前从检视缩放到扇形尺寸的时长;0 = 与位移同步缩小")]
[SerializeField] private float shrinkBeforeSpreadDuration = 0.28f;
[Header("Animation Durations")]
[Tooltip("叠牌出现时整体淡入时长(与 Hide 对称)")]
[SerializeField] private float fadeInDuration = 0.35f;
[Tooltip("Fade-out duration for unselected cards")]
[SerializeField] private float fadeOutDuration = 0.3f;
[Tooltip("Duration for selected card to move to inspect position")]
[SerializeField] private float moveToInspectDuration = 0.4f;
[Header("Card Prefab")]
[Tooltip("Prefab with SpriteRenderer + BoxCollider2D + EventTriggerEx + TarotCard")]
[SerializeField] private GameObject cardPrefab;
[Header("Gizmo Preview (Scene View)")]
[Tooltip("关闭则不绘制任何 Gizmo")]
[SerializeField] private bool showGizmos = true;
[Tooltip("勾选:仅选中 TarotDeck 时显示;不勾选:始终显示(半透明,便于摆位时参考)")]
[SerializeField] private bool gizmoOnlyWhenSelected = true;
[Tooltip("Number of card slots to preview when no cards are spawned (edit mode)")]
[SerializeField] [Min(1)] private int gizmoPreviewCardCount = 5;
[SerializeField] private Color gizmoFanArcColor = new Color(0.35f, 0.85f, 1f, 0.9f);
[SerializeField] private Color gizmoCardSlotColor = new Color(0.35f, 0.85f, 1f, 0.75f);
[SerializeField] private Color gizmoHoverColor = new Color(1f, 0.65f, 0.2f, 0.85f);
[SerializeField] private Color gizmoInspectColor = new Color(0.45f, 1f, 0.55f, 0.9f);
[Tooltip("gizmoOnlyWhenSelected=false 时,与选中态相比的颜色透明度比例")]
[SerializeField] [Range(0.15f, 1f)] private float gizmoUnselectedAlphaScale = 0.35f;
private readonly List<TarotCard> _cards = new();
private readonly Dictionary<TarotCard, Vector3> _fanPositions = new();
private readonly Dictionary<TarotCard, Quaternion> _fanRotations = new();
private TarotCard _selectedCard;
private TarotCard _hoveredCard;
private bool _isInteractable;
private readonly Dictionary<TarotCard, Tweener> _hoverTweensByCard = new();
private TarotCard _pointerDownCard;
public TarotCard SelectedCard => _selectedCard;
public bool HasSelection => _selectedCard != null;
public event Action<TarotCard> OnCardSelected;
private void Awake()
{
if (inspectAnchor == null)
{
var t = transform.Find("InspectAnchor");
if (t != null) inspectAnchor = t;
}
}
#region Setup & Teardown
public void Setup(int cardCount, Sprite frontSprite, Sprite backSprite)
{
ClearCards();
if (cardPrefab == null)
{
Debug.LogError("[TarotDeck] cardPrefab is not assigned!", this);
return;
}
for (int i = 0; i < cardCount; i++)
{
var go = Instantiate(cardPrefab, transform);
go.name = $"TarotCard_{i}";
var card = go.GetComponent<TarotCard>();
if (card == null) card = go.AddComponent<TarotCard>();
card.Init(i, frontSprite, backSprite);
card.SetInteractable(false);
card.SetSortingOrder(i);
// 初显叠成一张背面:落在检视位并以 inspectScale 放大
Vector3 stackPos = GetInspectLocalPosition();
go.transform.localPosition = stackPos;
go.transform.localRotation = Quaternion.identity;
go.transform.localScale = Vector3.one * inspectScale;
card.OnHoverEnter = () => HandleHoverEnter(card);
card.OnHoverExit = () => HandleHoverExit(card);
card.OnPointerDown = () => HandleCardPointerDown(card);
_cards.Add(card);
}
}
private Vector3 GetInspectLocalPosition()
{
return inspectAnchor != null ? inspectAnchor.localPosition : Vector3.zero;
}
public void ClearCards()
{
foreach (var kv in _hoverTweensByCard)
kv.Value?.Kill();
_hoverTweensByCard.Clear();
_pointerDownCard = null;
_selectedCard = null;
_hoveredCard = null;
_isInteractable = false;
foreach (var card in _cards)
{
if (card != null && card.gameObject != null)
Destroy(card.gameObject);
}
_cards.Clear();
_fanPositions.Clear();
_fanRotations.Clear();
}
#endregion
#region Fan Spread
/// <summary>
/// Fan layout angle in degrees for the i-th card (same as <see cref="SpreadCards"/>).
/// </summary>
public float GetFanSlotAngleDegrees(int index, int count)
{
if (count <= 0) return 0f;
// 单张牌落在弧中心(90°),否则 angleStep=0 时会落在 90°+fanRange/2 的端点,视觉上偏一侧
if (count == 1) return 90f; // 弧段中心(对称轴)
float angleStep = fanAngleRange / (count - 1);
float startAngle = 90f + fanAngleRange * 0.5f;
return startAngle - index * angleStep;
}
/// <summary>
/// Local position of the i-th card in fan layout (same math as <see cref="SpreadCards"/>).
/// </summary>
public Vector3 GetLocalFanSlotPosition(int index, int count)
{
if (count <= 0) return Vector3.zero;
float angle = GetFanSlotAngleDegrees(index, count);
float rad = angle * Mathf.Deg2Rad;
return new Vector3(
Mathf.Cos(rad) * fanRadius,
Mathf.Sin(rad) * fanRadius - fanRadius,
0f
);
}
/// <summary>
/// 扇形圆弧所在圆的圆心(本地空间),与 <see cref="GetLocalFanSlotPosition"/> 使用的几何一致。
/// </summary>
public Vector3 GetFanCircleCenterLocal() => new Vector3(0f, -fanRadius, 0f);
/// <summary>
/// 从圆心指向牌位的外侧方向(用于 hover 沿径向推出)。
/// </summary>
private Vector3 GetFanRadialOutDirection(Vector3 slotLocalPos)
{
Vector3 fromCenter = slotLocalPos - GetFanCircleCenterLocal();
if (fromCenter.sqrMagnitude < 0.0001f) return Vector3.up;
return fromCenter.normalized;
}
public IEnumerator SpreadCards()
{
if (_cards.Count == 0) yield break;
int count = _cards.Count;
Vector3 stackPos = GetInspectLocalPosition();
// 先缩小到扇形尺寸(可叠回牌堆中心),再摊开
if (shrinkBeforeSpreadDuration > 0.001f)
{
var shrinkSeq = DOTween.Sequence();
foreach (var card in _cards)
{
shrinkSeq.Join(
card.transform.DOScale(Vector3.one, shrinkBeforeSpreadDuration)
.SetEase(Ease.InOutSine));
shrinkSeq.Join(
card.transform.DOLocalMove(stackPos, shrinkBeforeSpreadDuration)
.SetEase(Ease.InOutSine));
}
yield return shrinkSeq.WaitForCompletion();
}
var seq = DOTween.Sequence();
for (int i = 0; i < count; i++)
{
var card = _cards[i];
Vector3 pos = GetLocalFanSlotPosition(i, count);
float rotZ = GetFanSlotAngleDegrees(i, count) - 90f;
Quaternion rot = Quaternion.Euler(0f, 0f, rotZ);
_fanPositions[card] = pos;
_fanRotations[card] = rot;
float delay = GetSpreadDelayForIndex(i, count);
var moveTw = card.transform.DOLocalMove(pos, spreadDuration);
ApplySpreadMoveEase(moveTw);
var rotTw = card.transform.DOLocalRotateQuaternion(rot, spreadDuration);
ApplySpreadRotateEase(rotTw);
seq.Insert(delay, moveTw);
seq.Insert(delay, rotTw);
// 若未单独缩小时,与位移同步缩回 1
if (shrinkBeforeSpreadDuration <= 0.001f)
{
var scaleTw = card.transform.DOScale(Vector3.one, spreadDuration)
.SetEase(Ease.InOutSine);
seq.Insert(delay, scaleTw);
}
}
yield return seq.WaitForCompletion();
}
private float GetSpreadDelayForIndex(int index, int count)
{
if (count <= 1 || spreadStagger <= 0f) return 0f;
if (spreadFromCenterOut)
{
float mid = (count - 1) * 0.5f;
return Mathf.Abs(index - mid) * spreadStagger;
}
return index * spreadStagger;
}
private void ApplySpreadMoveEase(Tween tween)
{
if (spreadEaseCurve != null && spreadEaseCurve.keys.Length > 0)
tween.SetEase(spreadEaseCurve);
else
tween.SetEase(spreadMoveEase);
}
private void ApplySpreadRotateEase(Tween tween)
{
if (spreadEaseCurve != null && spreadEaseCurve.keys.Length > 0)
tween.SetEase(spreadEaseCurve);
else
tween.SetEase(spreadRotateEase);
}
#endregion
#region Interaction
public void SetInteractable(bool interactable)
{
_isInteractable = interactable;
if (!interactable)
_pointerDownCard = null;
foreach (var card in _cards)
card.SetInteractable(interactable);
}
private void LateUpdate()
{
if (_pointerDownCard == null) return;
if (!Input.GetMouseButtonUp(0)) return;
var card = _pointerDownCard;
_pointerDownCard = null;
if (card == null) return;
if (!card.gameObject) return;
HandleCardClicked(card);
}
private void HandleCardPointerDown(TarotCard card)
{
if (!_isInteractable || _selectedCard != null) return;
_pointerDownCard = card;
}
private void KillHoverTween(TarotCard card)
{
if (card == null) return;
if (_hoverTweensByCard.TryGetValue(card, out var tw))
{
tw?.Kill();
_hoverTweensByCard.Remove(card);
}
}
private void RestoreHoverToFanSlot(TarotCard card)
{
if (card == null) return;
if (!_fanPositions.TryGetValue(card, out var basePos)) return;
KillHoverTween(card);
var backTw = card.transform.DOLocalMove(basePos, hoverDuration);
ApplyHoverInEase(backTw);
_hoverTweensByCard[card] = backTw;
}
private void HandleHoverEnter(TarotCard card)
{
if (!_isInteractable || _selectedCard != null) return;
// 直接 hover 到下一张时,Enter 可能先于上一张的 Exit:先强制收回其它牌
foreach (var c in _cards)
{
if (c != card)
RestoreHoverToFanSlot(c);
}
_hoveredCard = card;
KillHoverTween(card);
AudioManager.Instance?.PlaySfx("event:/FollowInput/cardPick");
if (!_fanPositions.TryGetValue(card, out var basePos)) return;
Vector3 pushDir = GetFanRadialOutDirection(basePos);
Vector3 targetPos = basePos + pushDir * hoverOffset;
var hoverTw = card.transform.DOLocalMove(targetPos, hoverDuration);
ApplyHoverOutEase(hoverTw);
_hoverTweensByCard[card] = hoverTw;
}
private void HandleHoverExit(TarotCard card)
{
if (_selectedCard != null) return;
if (_hoveredCard == card) _hoveredCard = null;
if (!_fanPositions.TryGetValue(card, out var basePos)) return;
KillHoverTween(card);
var backTw = card.transform.DOLocalMove(basePos, hoverDuration);
ApplyHoverInEase(backTw);
_hoverTweensByCard[card] = backTw;
}
private void ApplyHoverOutEase(Tween tween)
{
if (hoverOutCurve != null && hoverOutCurve.keys.Length > 0)
tween.SetEase(hoverOutCurve);
else
tween.SetEase(hoverOutEase);
}
private void ApplyHoverInEase(Tween tween)
{
if (hoverInCurve != null && hoverInCurve.keys.Length > 0)
tween.SetEase(hoverInCurve);
else
tween.SetEase(hoverInEase);
}
private void HandleCardClicked(TarotCard card)
{
if (!_isInteractable || _selectedCard != null) return;
_pointerDownCard = null;
foreach (var c in _cards)
KillHoverTween(c);
_selectedCard = card;
SetInteractable(false);
// 立即通知 Yarnwait_tarot_select 结束);fade + 移至检视位在后台播放,不阻塞对话
OnCardSelected?.Invoke(card);
StartCoroutine(SelectCardSequence(card));
}
#endregion
#region Select Sequence
private IEnumerator SelectCardSequence(TarotCard selected)
{
selected.SetSortingOrder(200);
var fadeSeq = DOTween.Sequence();
foreach (var card in _cards)
{
if (card == selected) continue;
fadeSeq.Join(card.DOFade(0f, fadeOutDuration));
}
Vector3 inspectPos = GetInspectLocalPosition();
fadeSeq.Join(
selected.transform.DOLocalMove(inspectPos, moveToInspectDuration).SetEase(Ease.InOutCubic)
);
fadeSeq.Join(
selected.transform.DOLocalRotateQuaternion(Quaternion.identity, moveToInspectDuration)
.SetEase(Ease.InOutCubic)
);
fadeSeq.Join(
selected.transform.DOScale(Vector3.one * inspectScale, moveToInspectDuration)
.SetEase(Ease.InOutCubic)
);
yield return fadeSeq.WaitForCompletion();
foreach (var card in _cards)
{
if (card == selected) continue;
card.gameObject.SetActive(false);
}
}
#endregion
#region Show / Hide
/// <summary>
/// 叠牌出现后从透明淡入(调用前需已 <see cref="Setup"/>)。
/// </summary>
public IEnumerator FadeInStack(float? duration = null)
{
float d = duration ?? fadeInDuration;
if (_cards.Count == 0) yield break;
foreach (var card in _cards)
card.SetAlpha(0f);
var seq = DOTween.Sequence();
foreach (var card in _cards)
seq.Join(card.DOFade(1f, d));
yield return seq.WaitForCompletion();
}
public IEnumerator HideAll(float duration = 0.3f)
{
var seq = DOTween.Sequence();
foreach (var card in _cards)
{
if (!card.gameObject.activeSelf) continue;
seq.Join(card.DOFade(0f, duration));
}
yield return seq.WaitForCompletion();
foreach (var card in _cards)
card.gameObject.SetActive(false);
}
#endregion
#region Gizmos
private void OnDrawGizmos()
{
if (!showGizmos || gizmoOnlyWhenSelected) return;
DrawTarotPreviewGizmos(gizmoUnselectedAlphaScale);
}
private void OnDrawGizmosSelected()
{
if (!showGizmos) return;
if (!gizmoOnlyWhenSelected) return;
DrawTarotPreviewGizmos(1f);
}
/// <param name="alphaScale">1 = 选中预览;较小值用于「始终显示」时弱化线条</param>
private void DrawTarotPreviewGizmos(float alphaScale)
{
int count = _cards.Count > 0 ? _cards.Count : gizmoPreviewCardCount;
if (count < 1) count = 1;
var anchor = inspectAnchor != null ? inspectAnchor : transform.Find("InspectAnchor");
Matrix4x4 old = Gizmos.matrix;
Gizmos.matrix = transform.localToWorldMatrix;
// Fan arc (circle center matches card layout: radius fanRadius around (0,-fanRadius,0))
Vector3 circleCenter = GetFanCircleCenterLocal();
Gizmos.color = ScaleColorAlpha(gizmoFanArcColor, alphaScale);
const int arcSegments = 48;
float angleMin = 90f + fanAngleRange * 0.5f - fanAngleRange;
float angleMax = 90f + fanAngleRange * 0.5f;
Vector3 prev = FanPointOnCircle(circleCenter, fanRadius, angleMin);
for (int s = 1; s <= arcSegments; s++)
{
float t = s / (float)arcSegments;
float a = Mathf.Lerp(angleMin, angleMax, t);
Vector3 cur = FanPointOnCircle(circleCenter, fanRadius, a);
Gizmos.DrawLine(prev, cur);
prev = cur;
}
// Card slots + hover offset
float slotRadius = 0.08f * Mathf.Min(fanRadius, 2f);
for (int i = 0; i < count; i++)
{
Vector3 slot = GetLocalFanSlotPosition(i, count);
Gizmos.color = ScaleColorAlpha(gizmoCardSlotColor, alphaScale);
Gizmos.DrawWireSphere(slot, slotRadius);
Vector3 pushDir = GetFanRadialOutDirection(slot);
Vector3 hoverEnd = slot + pushDir * hoverOffset;
Gizmos.color = ScaleColorAlpha(gizmoHoverColor, alphaScale);
Gizmos.DrawLine(slot, hoverEnd);
Gizmos.DrawWireSphere(hoverEnd, slotRadius * 0.65f);
}
// Inspect anchor
Vector3 inspectLocal = anchor != null ? anchor.localPosition : Vector3.zero;
Gizmos.color = ScaleColorAlpha(gizmoInspectColor, alphaScale);
float inspectR = 0.12f * inspectScale;
Gizmos.DrawWireSphere(inspectLocal, inspectR);
Gizmos.DrawWireCube(inspectLocal, Vector3.one * (0.25f * inspectScale));
Gizmos.matrix = old;
#if UNITY_EDITOR
if (alphaScale >= 0.99f)
{
UnityEditor.Handles.Label(
transform.TransformPoint(inspectLocal) + Vector3.up * 0.15f,
"InspectAnchor");
}
#endif
}
private static Color ScaleColorAlpha(Color c, float scale)
{
c.a *= scale;
return c;
}
private static Vector3 FanPointOnCircle(Vector3 circleCenter, float radius, float angleDeg)
{
float rad = angleDeg * Mathf.Deg2Rad;
return circleCenter + new Vector3(Mathf.Cos(rad) * radius, Mathf.Sin(rad) * radius, 0f);
}
#endregion
}
}