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 / spreadRotateEase(0~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 _cards = new(); private readonly Dictionary _fanPositions = new(); private readonly Dictionary _fanRotations = new(); private TarotCard _selectedCard; private TarotCard _hoveredCard; private bool _isInteractable; private readonly Dictionary _hoverTweensByCard = new(); private TarotCard _pointerDownCard; public TarotCard SelectedCard => _selectedCard; public bool HasSelection => _selectedCard != null; public event Action 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(); if (card == null) card = go.AddComponent(); 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 /// /// Fan layout angle in degrees for the i-th card (same as ). /// 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; } /// /// Local position of the i-th card in fan layout (same math as ). /// 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 ); } /// /// 扇形圆弧所在圆的圆心(本地空间),与 使用的几何一致。 /// public Vector3 GetFanCircleCenterLocal() => new Vector3(0f, -fanRadius, 0f); /// /// 从圆心指向牌位的外侧方向(用于 hover 沿径向推出)。 /// 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); // 立即通知 Yarn(wait_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 /// /// 叠牌出现后从透明淡入(调用前需已 )。 /// 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); } /// 1 = 选中预览;较小值用于「始终显示」时弱化线条 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 } }