Merge a card refresh (#59)

- **Refactored Card Placement Flow**
  - Separated card presentation from orchestration logic
  - Extracted `CornerCardManager` for pending card lifecycle (spawn, shuffle, rebuild)
  - Extracted `AlbumNavigationService` for book page navigation and zone mapping
  - Extracted `CardEnlargeController` for backdrop management and card reparenting
  - Implemented controller pattern (non-MonoBehaviour) for complex logic
  - Cards now unparent from slots before rebuild to prevent premature destruction

- **Improved Corner Card Display**
  - Fixed cards spawning on top of each other during rebuild
  - Implemented shuffle-to-front logic (remaining cards occupy slots 0→1→2)
  - Added smart card selection (prioritizes cards matching current album page)
  - Pending cards now removed from queue immediately on drag start
  - Corner cards rebuild after each placement with proper slot reassignment

- **Enhanced Album Card Viewing**
  - Added dramatic scale increase when viewing cards from album slots
  - Implemented shrink animation when dismissing enlarged cards
  - Cards transition: `PlacedInSlotState` → `AlbumEnlargedState` → `PlacedInSlotState`
  - Backdrop shows/hides with card enlarge/shrink cycle
  - Cards reparent to enlarged container while viewing, return to slot after

- **State Machine Improvements**
  - Added `CardStateNames` constants for type-safe state transitions
  - Implemented `ICardClickHandler` and `ICardStateDragHandler` interfaces
  - State transitions now use cached property indices
  - `BoosterCardContext` separated from `CardContext` for single responsibility

- **Code Cleanup**
  - Identified unused `SlotContainerHelper.cs` (superseded by `CornerCardManager`)
  - Identified unused `BoosterPackDraggable.canOpenOnDrop` field
  - Identified unused `AlbumViewPage._previousInputMode` (hardcoded value)
  - Identified unused `Card.OnPlacedInAlbumSlot` event (no subscribers)

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Co-authored-by: Michal Pikulski <michal@foolhardyhorizons.com>
Reviewed-on: #59
This commit is contained in:
2025-11-18 08:40:59 +00:00
parent 06cc3bde3b
commit 235fa04eba
161 changed files with 18057 additions and 5743 deletions

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: abf7a5def55e43178a4c88caf5686cc9
timeCreated: 1763454388

View File

@@ -0,0 +1,172 @@
using System.Collections.Generic;
using AppleHills.Data.CardSystem;
using Core;
using UnityEngine;
namespace UI.CardSystem
{
/// <summary>
/// Manages album page navigation, zone mapping, and page flip tracking.
/// Created and owned by AlbumViewPage (not a Unity component).
/// </summary>
public class AlbumNavigationService
{
private readonly BookCurlPro.BookPro _book;
private readonly BookTabButton[] _zoneTabs;
private bool _isPageFlipping = false;
/// <summary>
/// Is the book currently flipping to a page?
/// </summary>
public bool IsPageFlipping => _isPageFlipping;
/// <summary>
/// Constructor - called by AlbumViewPage's lazy property
/// </summary>
public AlbumNavigationService(BookCurlPro.BookPro book, BookTabButton[] zoneTabs)
{
_book = book;
_zoneTabs = zoneTabs;
}
/// <summary>
/// Check if we're currently viewing the album proper (not the menu page)
/// </summary>
public bool IsInAlbumProper()
{
if (_book == null)
{
Logging.Warning("[AlbumNavigationService] Book reference is null");
return false;
}
// Page 1 is the menu/cover, page 2+ are album pages with card slots
return _book.CurrentPaper > 1;
}
/// <summary>
/// Navigate to the page where a specific card belongs
/// </summary>
public void NavigateToCardPage(CardData cardData, System.Action onComplete)
{
if (cardData == null || _book == null)
{
onComplete?.Invoke();
return;
}
// Find target page based on card's zone
int targetPage = FindPageForZone(cardData.Zone);
if (targetPage < 0)
{
Logging.Warning($"[AlbumNavigationService] No page found for zone {cardData.Zone}");
onComplete?.Invoke();
return;
}
// Mark as flipping
_isPageFlipping = true;
Logging.Debug($"[AlbumNavigationService] Starting page flip to page {targetPage}");
// Get or add AutoFlip component
BookCurlPro.AutoFlip autoFlip = _book.GetComponent<BookCurlPro.AutoFlip>();
if (autoFlip == null)
{
autoFlip = _book.gameObject.AddComponent<BookCurlPro.AutoFlip>();
}
// Start flipping with callback
autoFlip.enabled = true;
autoFlip.StartFlipping(targetPage, () =>
{
// Mark as complete
_isPageFlipping = false;
Logging.Debug($"[AlbumNavigationService] Page flip to {targetPage} completed");
// Call original callback if provided
onComplete?.Invoke();
});
}
/// <summary>
/// Get list of card definition IDs on the current page
/// </summary>
public List<string> GetDefinitionsOnCurrentPage()
{
var result = new List<string>();
if (_book == null) return result;
int currentPage = _book.CurrentPaper;
// Find all AlbumCardSlot in scene
var allSlots = Object.FindObjectsByType<AlbumCardSlot>(FindObjectsSortMode.None);
foreach (var slot in allSlots)
{
if (IsSlotOnPage(slot.transform, currentPage))
{
if (slot.TargetCardDefinition != null && !string.IsNullOrEmpty(slot.TargetCardDefinition.Id))
{
result.Add(slot.TargetCardDefinition.Id);
}
}
}
return result;
}
#region Private Helpers
/// <summary>
/// Find the target page for a card zone using BookTabButtons
/// </summary>
private int FindPageForZone(CardZone zone)
{
if (_zoneTabs == null || _zoneTabs.Length == 0)
{
Logging.Warning("[AlbumNavigationService] No zone tabs available!");
return -1;
}
foreach (var tab in _zoneTabs)
{
if (tab.Zone == zone)
{
return tab.TargetPage;
}
}
Logging.Warning($"[AlbumNavigationService] No BookTabButton found for zone {zone}");
return -1;
}
/// <summary>
/// Check if a slot's transform is on a specific book page
/// </summary>
private bool IsSlotOnPage(Transform slotTransform, int pageIndex)
{
if (_book == null || _book.papers == null || pageIndex < 0 || pageIndex >= _book.papers.Length)
return false;
var paper = _book.papers[pageIndex];
if (paper == null) return false;
// Check if slotTransform parent hierarchy contains paper.Front or paper.Back
Transform current = slotTransform;
while (current != null)
{
if ((paper.Front != null && current.gameObject == paper.Front) ||
(paper.Back != null && current.gameObject == paper.Back))
return true;
current = current.parent;
}
return false;
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5dce94e276b8456996d9ddacef25c744
timeCreated: 1763425777

View File

@@ -0,0 +1,128 @@
using Core;
using UI.CardSystem.StateMachine;
using UnityEngine;
namespace UI.CardSystem
{
/// <summary>
/// Manages card enlarge system: backdrop display and card reparenting.
/// Created and owned by AlbumViewPage (not a Unity component).
/// </summary>
public class CardEnlargeController
{
private readonly GameObject _backdrop;
private readonly Transform _enlargedContainer;
/// <summary>
/// Constructor - called by AlbumViewPage's lazy property
/// </summary>
public CardEnlargeController(GameObject backdrop, Transform enlargedContainer)
{
_backdrop = backdrop;
_enlargedContainer = enlargedContainer;
}
/// <summary>
/// Subscribe to a placed card's enlarged state events
/// </summary>
public void RegisterCard(StateMachine.Card card)
{
if (card == null) return;
var enlargeState = card.GetStateComponent<StateMachine.States.CardAlbumEnlargedState>(CardStateNames.AlbumEnlarged);
if (enlargeState != null)
{
enlargeState.OnEnlargeRequested += OnCardEnlargeRequested;
enlargeState.OnShrinkRequested += OnCardShrinkRequested;
}
}
/// <summary>
/// Unsubscribe from a card's enlarged state events
/// </summary>
public void UnregisterCard(StateMachine.Card card)
{
if (card == null) return;
var enlargeState = card.GetStateComponent<StateMachine.States.CardAlbumEnlargedState>(CardStateNames.AlbumEnlarged);
if (enlargeState != null)
{
enlargeState.OnEnlargeRequested -= OnCardEnlargeRequested;
enlargeState.OnShrinkRequested -= OnCardShrinkRequested;
}
}
/// <summary>
/// Clean up enlarged card state (on page close)
/// </summary>
public void CleanupEnlargedState()
{
// Hide backdrop if visible
if (_backdrop != null && _backdrop.activeSelf)
{
_backdrop.SetActive(false);
}
// If there's an enlarged card in the container, return it to its slot
if (_enlargedContainer != null && _enlargedContainer.childCount > 0)
{
for (int i = _enlargedContainer.childCount - 1; i >= 0; i--)
{
Transform cardTransform = _enlargedContainer.GetChild(i);
var card = cardTransform.GetComponent<StateMachine.Card>();
var state = cardTransform.GetComponentInChildren<StateMachine.States.CardAlbumEnlargedState>(true);
if (card != null && state != null)
{
Transform originalParent = state.GetOriginalParent();
if (originalParent != null)
{
cardTransform.SetParent(originalParent, true);
cardTransform.localPosition = state.GetOriginalLocalPosition();
cardTransform.localRotation = state.GetOriginalLocalRotation();
}
}
}
}
}
#region Event Handlers
private void OnCardEnlargeRequested(StateMachine.States.CardAlbumEnlargedState state)
{
if (state == null) return;
// Show backdrop
if (_backdrop != null)
{
_backdrop.SetActive(true);
}
// Reparent card root to enlarged container preserving world transform
if (_enlargedContainer != null)
{
var ctx = state.GetComponentInParent<StateMachine.CardContext>();
if (ctx != null)
{
ctx.RootTransform.SetParent(_enlargedContainer, true);
ctx.RootTransform.SetAsLastSibling();
}
}
}
private void OnCardShrinkRequested(StateMachine.States.CardAlbumEnlargedState state)
{
if (state == null) return;
// Hide backdrop; state will animate back to slot and reparent on completion
if (_backdrop != null)
{
_backdrop.SetActive(false);
}
// Do not reparent here; reverse animation is orchestrated by the state
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f0c2c6c12ee64ccabe778e848ac9c822
timeCreated: 1763425800

View File

@@ -0,0 +1,392 @@
using System.Collections.Generic;
using System.Linq;
using AppleHills.Data.CardSystem;
using Core;
using Data.CardSystem;
using UI.DragAndDrop.Core;
using UnityEngine;
namespace UI.CardSystem
{
/// <summary>
/// Manages corner card spawning, selection, shuffling, and rebuilding.
/// Created and owned by AlbumViewPage (not a Unity component).
/// </summary>
public class CornerCardManager
{
private readonly SlotContainer _slots;
private readonly GameObject _cardPrefab;
private readonly AlbumViewPage _owner;
private List<StateMachine.Card> _pendingCards = new List<StateMachine.Card>();
private const int MaxVisible = 3;
/// <summary>
/// Constructor - called by AlbumViewPage's lazy property
/// </summary>
public CornerCardManager(SlotContainer slots, GameObject cardPrefab, AlbumViewPage owner)
{
_slots = slots;
_cardPrefab = cardPrefab;
_owner = owner;
}
/// <summary>
/// Get the list of currently spawned corner cards
/// </summary>
public List<StateMachine.Card> PendingCards => _pendingCards;
/// <summary>
/// Spawn pending corner cards with initial setup
/// </summary>
public void SpawnCards()
{
RebuildCards();
}
/// <summary>
/// Rebuild corner card display incrementally.
/// Flow: 1) Shuffle remaining cards to front slots (0→1→2)
/// 2) Spawn new card in last slot if needed
/// Called on initial spawn and after card is removed.
/// </summary>
public void RebuildCards()
{
if (_cardPrefab == null || _slots == null) return;
// Step 1: Determine how many cards should be displayed
var uniquePending = GetUniquePendingCards();
int totalPendingCards = uniquePending.Count;
int cardsToDisplay = Mathf.Min(totalPendingCards, MaxVisible);
int currentCardCount = _pendingCards.Count;
Logging.Debug($"[CornerCardManager] RebuildCards: current={currentCardCount}, target={cardsToDisplay}");
// Step 2: Remove excess cards if we have too many
while (_pendingCards.Count > cardsToDisplay)
{
int lastIndex = _pendingCards.Count - 1;
var cardToRemove = _pendingCards[lastIndex];
if (cardToRemove != null)
{
if (cardToRemove.Context != null)
{
cardToRemove.Context.OnDragStarted -= OnCardDragStarted;
}
Object.Destroy(cardToRemove.gameObject);
}
_pendingCards.RemoveAt(lastIndex);
Logging.Debug($"[CornerCardManager] Removed excess card, now have {_pendingCards.Count}");
}
// Step 3: Shuffle remaining cards to occupy first slots (0, 1, 2)
ShuffleCardsToFrontSlots();
// Step 4: Spawn new cards in remaining slots if needed
while (_pendingCards.Count < cardsToDisplay)
{
int slotIndex = _pendingCards.Count; // Next available slot
var slot = FindSlotByIndex(slotIndex);
if (slot == null)
{
Logging.Warning($"[CornerCardManager] Slot {slotIndex} not found, stopping spawn");
break;
}
SpawnCardInSlot(slot);
Logging.Debug($"[CornerCardManager] Added new card in slot {slotIndex}, now have {_pendingCards.Count}");
}
if (cardsToDisplay == 0)
{
Logging.Debug("[CornerCardManager] No pending cards to display in corner");
}
}
/// <summary>
/// Smart card selection from pending queue.
/// Prioritizes cards that belong on current album page, falls back to random.
/// Removes selected card from pending and rebuilds corner.
/// </summary>
public CardData GetSmartSelection()
{
if (CardSystemManager.Instance == null) return null;
var pending = CardSystemManager.Instance.GetPendingRevealCards();
if (pending.Count == 0) return null;
// Try current page match
var pageDefs = _owner.GetDefinitionsOnCurrentPage();
var match = pending.Find(c => pageDefs.Contains(c.DefinitionId));
// If no match, use random
if (match == null)
{
int idx = Random.Range(0, pending.Count);
match = pending[idx];
}
// IMPORTANT: Remove from pending list immediately
// Card is now in "reveal flow" and will be added to collection when placed
if (match != null)
{
// Remove from pending using the manager (fires OnPendingCardRemoved event)
CardSystemManager.Instance.RemoveFromPending(match);
Logging.Debug($"[CornerCardManager] Removed '{match.Name}' from pending cards, starting reveal flow");
// Rebuild corner cards AFTER removing from pending list
// This ensures the removed card doesn't get re-spawned
RebuildCards();
}
return match;
}
/// <summary>
/// Cleanup all corner cards (on page close)
/// </summary>
public void CleanupAllCards()
{
// First, unsubscribe and destroy tracked cards
foreach (var c in _pendingCards)
{
if (c != null)
{
if (c.Context != null)
{
c.Context.OnDragStarted -= OnCardDragStarted;
}
Object.Destroy(c.gameObject);
}
}
_pendingCards.Clear();
// IMPORTANT: Also clear ALL children from corner slots
// This catches cards that were removed from tracking but not destroyed
// (e.g., cards being dragged to album)
if (_slots != null)
{
foreach (var slot in _slots.Slots)
{
if (slot == null || slot.transform == null) continue;
// Destroy all card children in this slot
for (int i = slot.transform.childCount - 1; i >= 0; i--)
{
var child = slot.transform.GetChild(i);
var card = child.GetComponent<StateMachine.Card>();
if (card != null)
{
// Unsubscribe if somehow still subscribed
if (card.Context != null)
{
card.Context.OnDragStarted -= OnCardDragStarted;
}
Object.Destroy(child.gameObject);
Logging.Debug($"[CornerCardManager] Cleaned up orphaned card from slot {slot.SlotIndex}");
}
}
}
}
}
/// <summary>
/// Notify that a card has been placed in album (remove from tracking)
/// </summary>
public void NotifyCardPlaced(StateMachine.Card card)
{
if (card != null)
{
// Remove from tracking list
_pendingCards.Remove(card);
// IMPORTANT: Unsubscribe from drag events
// Placed cards should never respond to corner drag events
if (card.Context != null)
{
card.Context.OnDragStarted -= OnCardDragStarted;
}
Logging.Debug($"[CornerCardManager] Card placed and unsubscribed from corner events: {card.CardData?.Name}");
}
}
#region Private Helpers
/// <summary>
/// Shuffle remaining cards to occupy the first available slots (0 → 1 → 2).
/// Example: If we have 2 cards in slots 0 and 2, move the card from slot 2 to slot 1.
/// </summary>
private void ShuffleCardsToFrontSlots()
{
if (_pendingCards.Count == 0) return;
Logging.Debug($"[CornerCardManager] Shuffling {_pendingCards.Count} cards to front slots");
// Reassign each card to the first N slots (0, 1, 2...)
for (int i = 0; i < _pendingCards.Count; i++)
{
var card = _pendingCards[i];
var targetSlot = FindSlotByIndex(i);
if (targetSlot == null)
{
Logging.Warning($"[CornerCardManager] Could not find slot with index {i} during shuffle");
continue;
}
// Check if card is already in the correct slot
if (card.CurrentSlot == targetSlot)
{
// Already in correct slot, skip
continue;
}
// Vacate current slot if occupied
if (card.CurrentSlot != null)
{
card.CurrentSlot.Vacate();
}
// Assign to target slot with animation
card.AssignToSlot(targetSlot, true); // true = animate
Logging.Debug($"[CornerCardManager] Shuffled card {i} to slot {targetSlot.SlotIndex}");
}
}
/// <summary>
/// Spawn a single card in the specified slot.
/// Card will be in PendingFaceDownState and match slot transform.
/// </summary>
private void SpawnCardInSlot(DraggableSlot slot)
{
if (slot == null || _cardPrefab == null) return;
// Instantiate card as child of slot (not container)
GameObject cardObj = Object.Instantiate(_cardPrefab, slot.transform);
var card = cardObj.GetComponent<StateMachine.Card>();
if (card == null)
{
Logging.Warning("[CornerCardManager] Card prefab missing Card component!");
Object.Destroy(cardObj);
return;
}
// IMPORTANT: Assign to slot FIRST (establishes correct transform)
card.AssignToSlot(slot, false); // false = instant, no animation
// Inject AlbumViewPage dependency
card.Context.SetAlbumViewPage(_owner);
// THEN setup card for pending state (transitions to PendingFaceDownState)
// This ensures OriginalScale is captured AFTER slot assignment
card.SetupForAlbumPending();
// Subscribe to drag events for reorganization
card.Context.OnDragStarted += OnCardDragStarted;
// Track in list
_pendingCards.Add(card);
Logging.Debug($"[CornerCardManager] Spawned card in slot {slot.SlotIndex}, state: {card.GetCurrentStateName()}");
}
/// <summary>
/// Handle card drag started - cleanup and unparent from corner slot
/// Rebuild happens in GetSmartSelection after pending list is updated
/// </summary>
private void OnCardDragStarted(StateMachine.CardContext context)
{
if (context == null) return;
var card = context.GetComponent<StateMachine.Card>();
if (card == null) return;
// Only handle pending corner cards
if (!_pendingCards.Contains(card)) return;
Logging.Debug($"[CornerCardManager] Card drag started, removing from corner");
// 1. Remove from tracking (card is transitioning to placement flow)
_pendingCards.Remove(card);
// 2. Unsubscribe from this card's events
if (card.Context != null)
{
card.Context.OnDragStarted -= OnCardDragStarted;
}
// 3. CRITICAL: Unparent from corner slot BEFORE rebuild happens
// This prevents the card from being destroyed when CleanupAllCards runs
// Reparent to page's transform to keep it alive during drag
if (card.transform.parent != null)
{
card.transform.SetParent(_owner.transform, true); // Keep world position
Logging.Debug($"[CornerCardManager] Card unparented from corner slot - safe for rebuild");
}
// Note: RebuildCards() is called in GetSmartSelection()
// after the card is removed from CardSystemManager's pending list
// The card is now safe from being destroyed since it's no longer a child of corner slots
}
/// <summary>
/// Get unique pending cards (one per definition+rarity combo)
/// </summary>
private List<CardData> GetUniquePendingCards()
{
if (CardSystemManager.Instance == null) return new List<CardData>();
var pending = CardSystemManager.Instance.GetPendingRevealCards();
// Group by definition+rarity, take first of each group
var uniqueDict = new Dictionary<string, CardData>();
int duplicateCount = 0;
foreach (var card in pending)
{
string key = $"{card.DefinitionId}_{card.Rarity}";
if (!uniqueDict.ContainsKey(key))
{
uniqueDict[key] = card;
}
else
{
duplicateCount++;
}
}
if (duplicateCount > 0)
{
Logging.Warning($"[CornerCardManager] Found {duplicateCount} duplicate pending cards (same definition+rarity combo)");
}
return new List<CardData>(uniqueDict.Values);
}
/// <summary>
/// Find a slot by its SlotIndex property
/// </summary>
private DraggableSlot FindSlotByIndex(int slotIndex)
{
if (_slots == null)
return null;
foreach (var slot in _slots.Slots)
{
if (slot.SlotIndex == slotIndex)
{
return slot;
}
}
return null;
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c7c68770a6974aa4a22c645fe0517fe2
timeCreated: 1763425113

View File

@@ -0,0 +1,205 @@
using System.Collections;
using Core;
using UnityEngine;
using UnityEngine.UI;
using AppleHills.Core.Settings;
namespace UI.CardSystem
{
/// <summary>
/// Controls a vertical progress bar made of individual Image elements.
/// Fills from bottom to top and animates the newest element with a blink effect.
///
/// Setup: Place on GameObject with VerticalLayoutGroup (Reverse Arrangement enabled).
/// First child = first progress element (1/5), last child = last progress element (5/5).
/// </summary>
public class ProgressBarController : MonoBehaviour
{
[Header("Progress Elements")]
[Tooltip("The individual Image components representing progress segments (auto-detected from children)")]
private Image[] _progressElements;
private ICardSystemSettings _settings;
private Coroutine _currentBlinkCoroutine;
private void Awake()
{
_settings = GameManager.GetSettingsObject<ICardSystemSettings>();
// Auto-detect all child Image components
_progressElements = GetComponentsInChildren<Image>(true);
if (_progressElements.Length == 0)
{
Logging.Warning("[ProgressBarController] No child Image components found! Add Image children to this GridLayout.");
}
}
/// <summary>
/// Show progress and animate the newest element with a blink effect.
/// </summary>
/// <param name="currentCount">Current progress (1 to maxCount)</param>
/// <param name="maxCount">Maximum progress value (typically 5)</param>
/// <param name="onComplete">Callback invoked after blink animation completes</param>
public void ShowProgress(int currentCount, int maxCount, System.Action onComplete)
{
// Validate input
if (currentCount < 0 || currentCount > maxCount)
{
Logging.Warning($"[ProgressBarController] Invalid progress: {currentCount}/{maxCount}");
onComplete?.Invoke();
return;
}
// Validate element count
if (_progressElements.Length < maxCount)
{
Logging.Warning($"[ProgressBarController] Not enough progress elements! Expected {maxCount}, found {_progressElements.Length}");
onComplete?.Invoke();
return;
}
// Stop any existing blink animation
if (_currentBlinkCoroutine != null)
{
StopCoroutine(_currentBlinkCoroutine);
_currentBlinkCoroutine = null;
}
// Disable all elements first
foreach (var element in _progressElements)
{
element.enabled = false;
}
// Enable first N elements (since first child = 1/5, last child = 5/5)
// If currentCount = 3, enable elements [0], [1], [2] (first 3 elements)
for (int i = 0; i < currentCount && i < _progressElements.Length; i++)
{
_progressElements[i].enabled = true;
}
Logging.Debug($"[ProgressBarController] Showing progress {currentCount}/{maxCount}");
// Blink the NEWEST element (the last one we just enabled)
// Newest element is at index (currentCount - 1)
int newestIndex = currentCount - 1;
if (newestIndex >= 0 && newestIndex < _progressElements.Length && currentCount > 0)
{
_currentBlinkCoroutine = StartCoroutine(BlinkElement(newestIndex, onComplete));
}
else
{
// No element to blink (e.g., currentCount = 0)
onComplete?.Invoke();
}
}
/// <summary>
/// Show progress without blink animation (instant display).
/// </summary>
/// <param name="currentCount">Current progress (1 to maxCount)</param>
/// <param name="maxCount">Maximum progress value</param>
public void ShowProgressInstant(int currentCount, int maxCount)
{
// Validate
if (currentCount < 0 || currentCount > maxCount || _progressElements.Length < maxCount)
{
return;
}
// Disable all
foreach (var element in _progressElements)
{
element.enabled = false;
}
// Enable first N elements
for (int i = 0; i < currentCount && i < _progressElements.Length; i++)
{
_progressElements[i].enabled = true;
}
}
/// <summary>
/// Hide all progress elements.
/// </summary>
public void HideProgress()
{
foreach (var element in _progressElements)
{
element.enabled = false;
}
}
/// <summary>
/// Blink a specific element by toggling enabled/disabled.
/// </summary>
private IEnumerator BlinkElement(int index, System.Action onComplete)
{
if (index < 0 || index >= _progressElements.Length)
{
onComplete?.Invoke();
yield break;
}
Image element = _progressElements[index];
// Get blink settings (or use defaults if not available)
float blinkDuration = 0.15f; // Duration for each on/off toggle
int blinkCount = 3; // Number of full blinks (on->off->on = 1 blink)
// Try to get settings if available
if (_settings != null)
{
// Settings could expose these if needed:
// blinkDuration = _settings.ProgressBlinkDuration;
// blinkCount = _settings.ProgressBlinkCount;
}
Logging.Debug($"[ProgressBarController] Blinking element {index} ({blinkCount} times)");
// Wait a brief moment before starting blink
yield return new WaitForSeconds(0.3f);
// Perform blinks (on->off->on = 1 full blink)
for (int i = 0; i < blinkCount; i++)
{
// Off
element.enabled = false;
yield return new WaitForSeconds(blinkDuration);
// On
element.enabled = true;
yield return new WaitForSeconds(blinkDuration);
}
// Ensure element is enabled at the end
element.enabled = true;
Logging.Debug($"[ProgressBarController] Blink complete for element {index}");
_currentBlinkCoroutine = null;
onComplete?.Invoke();
}
/// <summary>
/// Get the total number of progress elements available.
/// </summary>
public int GetElementCount()
{
return _progressElements?.Length ?? 0;
}
private void OnDestroy()
{
// Clean up any running coroutines
if (_currentBlinkCoroutine != null)
{
StopCoroutine(_currentBlinkCoroutine);
_currentBlinkCoroutine = null;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e91de41001c14101b8fa4216d6c7888b
timeCreated: 1762939781

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 619e69d1b6e44ecabc40657b2bcd13f9
timeCreated: 1763454353

View File

@@ -0,0 +1,211 @@
using AppleHills.Data.CardSystem;
using Core;
using Core.SaveLoad;
using UI.DragAndDrop.Core;
using UnityEngine;
namespace UI.CardSystem.StateMachine
{
// ...existing code...
public class Card : DraggableObject
{
[Header("Components")]
[SerializeField] private CardContext context;
[SerializeField] private CardAnimator animator;
[SerializeField] private AppleMachine stateMachine;
[Header("Configuration")]
[SerializeField] private string initialState = "IdleState";
// Public accessors
public CardContext Context => context;
public CardAnimator Animator => animator;
public AppleMachine StateMachine => stateMachine;
public CardData CardData => context?.CardData;
// State inspection properties for booster flow
public bool IsIdle => GetCurrentStateName() == CardStateNames.Idle;
public bool IsRevealing => !IsIdle && !IsComplete;
public bool IsComplete => context?.BoosterContext.HasCompletedReveal ?? false;
// Event fired when this card is successfully placed into an AlbumCardSlot
public event System.Action<Card, AlbumCardSlot> OnPlacedInAlbumSlot;
protected override void Initialize()
{
base.Initialize(); // Call DraggableObject initialization
// Auto-find components if not assigned
if (context == null)
context = GetComponent<CardContext>();
if (animator == null)
animator = GetComponent<CardAnimator>();
if (stateMachine == null)
stateMachine = GetComponentInChildren<AppleMachine>();
}
#region DraggableObject Hooks - Trigger State Transitions
protected override void OnDragStartedHook()
{
base.OnDragStartedHook();
// Always emit the generic drag started event for external consumers (AlbumViewPage, etc.)
context?.NotifyDragStarted();
// Check if current state wants to handle drag behavior
if (stateMachine?.currentState != null)
{
var dragHandler = stateMachine.currentState.GetComponent<ICardStateDragHandler>();
if (dragHandler != null && dragHandler.OnCardDragStarted(context))
{
return; // State handled it, don't do default behavior
}
}
// Default behavior: transition to DraggingState
// Logging.Debug($"[Card] Drag started on {CardData?.Name}, transitioning to DraggingState");
// ChangeState("DraggingState");
}
protected override void OnDragEndedHook()
{
base.OnDragEndedHook();
// Always emit the generic drag ended event for external consumers
context?.NotifyDragEnded();
// Check if current state wants to handle drag end
if (stateMachine?.currentState != null)
{
var dragHandler = stateMachine.currentState.GetComponent<ICardStateDragHandler>();
if (dragHandler != null && dragHandler.OnCardDragEnded(context))
{
return; // State handled it
}
}
// Default behavior for states that don't implement custom drag end
string current = GetCurrentStateName();
if (current == CardStateNames.Dragging)
{
if (CurrentSlot is AlbumCardSlot albumSlot)
{
Logging.Debug($"[Card] Dropped in album slot, transitioning to PlacedInSlotState");
var placedState = GetStateComponent<States.CardPlacedInSlotState>(CardStateNames.PlacedInSlot);
if (placedState != null)
{
placedState.SetParentSlot(albumSlot);
}
ChangeState(CardStateNames.PlacedInSlot);
OnPlacedInAlbumSlot?.Invoke(this, albumSlot);
}
else
{
Logging.Debug("[Card] Dropped outside valid slot, returning to RevealedState");
ChangeState(CardStateNames.Revealed);
}
}
}
#endregion
/// <summary>
/// Setup the card with data and optional initial state
/// </summary>
public void SetupCard(CardData data, string startState = null)
{
if (context != null)
{
context.SetupCard(data);
}
// Start the state machine with specified or default state
string targetState = startState ?? initialState;
if (stateMachine != null && !string.IsNullOrEmpty(targetState))
{
stateMachine.ChangeState(targetState);
}
}
/// <summary>
/// Setup for booster reveal flow (starts at IdleState, will flip on click)
/// Dragging is DISABLED for booster cards
/// States will query CardSystemManager for collection state as needed
/// </summary>
public void SetupForBoosterReveal(CardData data, bool isNew)
{
SetupCard(data, CardStateNames.Idle);
SetDraggingEnabled(false); // Booster cards cannot be dragged
}
/// <summary>
/// Setup for album placement (starts at PlacedInSlotState)
/// Dragging is DISABLED once placed in slot
/// </summary>
public void SetupForAlbumSlot(CardData data, AlbumCardSlot slot)
{
SetupCard(data, CardStateNames.PlacedInSlot);
SetDraggingEnabled(false); // Cards in slots cannot be dragged out
// Set the parent slot on the PlacedInSlotState
var placedState = GetStateComponent<States.CardPlacedInSlotState>(CardStateNames.PlacedInSlot);
if (placedState != null)
{
placedState.SetParentSlot(slot);
}
}
/// <summary>
/// Setup for album pending state (starts at PendingFaceDownState)
/// Dragging is ENABLED; state will assign data when dragged
/// </summary>
public void SetupForAlbumPending()
{
// Start with no data; state will assign when dragged
SetupCard(null, CardStateNames.PendingFaceDown);
SetDraggingEnabled(true);
}
/// <summary>
/// Transition to a specific state
/// </summary>
public void ChangeState(string stateName)
{
if (stateMachine != null)
{
stateMachine.ChangeState(stateName);
}
}
/// <summary>
/// Get a specific state component by name
/// </summary>
public T GetStateComponent<T>(string stateName) where T : AppleState
{
if (stateMachine == null) return null;
Transform stateTransform = stateMachine.transform.Find(stateName);
if (stateTransform != null)
{
return stateTransform.GetComponent<T>();
}
return null;
}
/// <summary>
/// Get the current active state name
/// </summary>
public string GetCurrentStateName()
{
if (stateMachine?.currentState != null)
{
return stateMachine.currentState.name;
}
return "None";
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d97dd4e4bc3246e9bed5ac227f13de10
timeCreated: 1762884900

View File

@@ -0,0 +1,23 @@
using UnityEngine;
using UnityEngine.UI;
namespace UI.CardSystem
{
/// <summary>
/// Simple component representing card back visuals; toggle visibility.
/// </summary>
public class CardBack : MonoBehaviour
{
[SerializeField] private Image backImage;
public void Show()
{
gameObject.SetActive(true);
}
public void Hide()
{
gameObject.SetActive(false);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 37d815ba7b02481786cc1953678a3e8e
timeCreated: 1763322207

View File

@@ -0,0 +1,188 @@
using System;
using AppleHills.Data.CardSystem;
using Core.SaveLoad;
using UnityEngine;
namespace UI.CardSystem.StateMachine
{
/// <summary>
/// Shared context for card states.
/// Provides access to common components and data that states need.
/// </summary>
public class CardContext : MonoBehaviour
{
[Header("Core Components")]
[SerializeField] private CardDisplay cardDisplay;
[SerializeField] private CardAnimator cardAnimator;
private AppleMachine stateMachine;
[Header("Card Data")]
private CardData cardData;
// Injected dependencies
private AlbumViewPage albumViewPage;
// Public accessors
public CardDisplay CardDisplay => cardDisplay;
public CardAnimator Animator => cardAnimator;
public AppleMachine StateMachine => stateMachine;
public Transform RootTransform => transform;
public CardData CardData => cardData;
/// <summary>
/// Get the injected AlbumViewPage (null if not set)
/// </summary>
public AlbumViewPage AlbumViewPage => albumViewPage;
// Runtime state
public bool IsClickable { get; set; } = true;
// Original transform data (captured on spawn for shrink animations)
public Vector3 OriginalScale { get; private set; }
public Vector3 OriginalPosition { get; private set; }
public Quaternion OriginalRotation { get; private set; }
// Generic drag event - fired when drag starts, consumers decide how to handle based on current state
public event Action<CardContext> OnDragStarted;
// Generic drag end event - fired when drag ends, consumers decide how to handle based on current state
public event Action<CardContext> OnDragEnded;
// Booster-specific context (optional, only set for booster flow cards)
private BoosterCardContext boosterContext;
public BoosterCardContext BoosterContext
{
get
{
if (boosterContext == null)
boosterContext = new BoosterCardContext();
return boosterContext;
}
}
/// <summary>
/// Inject AlbumViewPage dependency
/// </summary>
public void SetAlbumViewPage(AlbumViewPage page)
{
albumViewPage = page;
}
// Helper method for states/card to signal drag started
public void NotifyDragStarted()
{
OnDragStarted?.Invoke(this);
}
// Helper method for states/card to signal drag ended
public void NotifyDragEnded()
{
OnDragEnded?.Invoke(this);
}
private void Awake()
{
// Auto-find components if not assigned
if (cardDisplay == null)
cardDisplay = GetComponentInChildren<CardDisplay>();
if (cardAnimator == null)
cardAnimator = GetComponent<CardAnimator>();
if (stateMachine == null)
stateMachine = GetComponentInChildren<AppleMachine>();
}
private void OnEnable()
{
// Subscribe to CardDisplay click and route to active state
if (cardDisplay != null)
{
cardDisplay.OnCardClicked += HandleCardDisplayClicked;
}
}
private void OnDisable()
{
if (cardDisplay != null)
{
cardDisplay.OnCardClicked -= HandleCardDisplayClicked;
}
}
private void HandleCardDisplayClicked(CardDisplay _)
{
// Gate by clickability
if (!IsClickable) return;
if (stateMachine == null || stateMachine.currentState == null) return;
var handler = stateMachine.currentState.GetComponent<ICardClickHandler>();
if (handler != null)
{
handler.OnCardClicked(this);
}
}
/// <summary>
/// Setup the card with data
/// </summary>
public void SetupCard(CardData data)
{
cardData = data;
// Reset booster context if it exists
if (boosterContext != null)
{
boosterContext.Reset();
}
// Capture original transform for shrink animations.
// If current scale is ~0 (pop-in staging), default to Vector3.one.
var currentScale = transform.localScale;
if (currentScale.x < 0.01f && currentScale.y < 0.01f && currentScale.z < 0.01f)
{
OriginalScale = Vector3.one;
}
else
{
OriginalScale = currentScale;
}
OriginalPosition = transform.localPosition;
OriginalRotation = transform.localRotation;
if (cardDisplay != null)
{
cardDisplay.SetupCard(data);
}
}
/// <summary>
/// Update card data without re-capturing original transform values.
/// Use this when assigning data to an already-initialized card (e.g., pending cards on drag).
/// This prevents changing OriginalScale when the card is already correctly sized.
/// </summary>
public void UpdateCardData(CardData data)
{
cardData = data;
// Reset booster context if it exists
if (boosterContext != null)
{
boosterContext.Reset();
}
// Don't re-capture OriginalScale/Position/Rotation
// This preserves the transform values captured during initial setup
if (cardDisplay != null)
{
cardDisplay.SetupCard(data);
}
}
/// <summary>
/// Get the card display component
/// </summary>
public CardDisplay GetCardDisplay() => cardDisplay;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3b3126aaaa66448fa3d5bd772aaf5784
timeCreated: 1762884650

View File

@@ -0,0 +1,311 @@
using System;
using AppleHills.Data.CardSystem;
using Core;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UI.CardSystem
{
/// <summary>
/// Displays a single card using the new visual system with frames, overlays, backgrounds, and zone shapes.
/// </summary>
public class CardDisplay : MonoBehaviour, IPointerClickHandler
{
[Header("UI Elements")]
[SerializeField] private TextMeshProUGUI cardNameText;
[SerializeField] private Image cardImage;
[SerializeField] private Image frameImage;
[SerializeField] private Image overlayImage;
[SerializeField] private Image backgroundImage;
[SerializeField] private Image zoneShapeImage;
[Header("Card Data")]
[SerializeField] private CardData cardData;
[Header("Visual Settings")]
[SerializeField] private CardVisualConfig visualConfig;
#if UNITY_EDITOR
[Header("Editor Tools")]
[SerializeField] private CardDefinition editorCardDefinition;
#endif
// Events
public event Action<CardDisplay> OnCardClicked;
/// <summary>
/// Sets up the card display with the given card data
/// </summary>
public void SetupCard(CardData data)
{
if (data == null)
{
Logging.Warning("[CardDisplay] Attempted to setup card with null data");
return;
}
cardData = data;
UpdateCardVisuals();
}
/// <summary>
/// Updates all visual elements based on current card data
/// </summary>
private void UpdateCardVisuals()
{
if (cardData == null)
{
ClearCard();
return;
}
if (visualConfig == null)
{
Logging.Warning("[CardDisplay] No CardVisualConfig assigned. Visuals may not display correctly.");
}
// Update text
UpdateCardName();
// Update main card image
UpdateCardImage();
// Update rarity-based visuals (frame and overlay)
UpdateRarityVisuals();
// Update zone-based visuals (background and shape)
UpdateZoneVisuals();
Logging.Debug($"[CardDisplay] Updated visuals for card: {cardData.Name} (Rarity: {cardData.Rarity}, Zone: {cardData.Zone})");
}
/// <summary>
/// Updates the card name text
/// </summary>
private void UpdateCardName()
{
if (cardNameText != null)
{
cardNameText.text = cardData.Name ?? "Unknown Card";
}
}
/// <summary>
/// Updates the main card image
/// </summary>
private void UpdateCardImage()
{
if (cardImage != null)
{
if (cardData.CardImage != null)
{
cardImage.sprite = cardData.CardImage;
cardImage.enabled = true;
}
else
{
cardImage.sprite = null;
cardImage.enabled = false;
}
}
}
/// <summary>
/// Updates frame and overlay based on card rarity
/// </summary>
private void UpdateRarityVisuals()
{
if (visualConfig == null) return;
// Update frame
if (frameImage != null)
{
Sprite frameSprite = visualConfig.GetRarityFrame(cardData.Rarity);
if (frameSprite != null)
{
frameImage.sprite = frameSprite;
frameImage.enabled = true;
}
else
{
frameImage.enabled = false;
}
}
// Update overlay (can be null for some rarities)
if (overlayImage != null)
{
Sprite overlaySprite = visualConfig.GetRarityOverlay(cardData.Rarity);
if (overlaySprite != null)
{
overlayImage.sprite = overlaySprite;
overlayImage.enabled = true;
}
else
{
overlayImage.enabled = false;
}
}
}
/// <summary>
/// Updates background and zone shape based on card zone and rarity
/// Special handling for Legendary cards
/// </summary>
private void UpdateZoneVisuals()
{
if (visualConfig == null) return;
// Update background
// Legendary cards get special background, others get zone background
if (backgroundImage != null)
{
Sprite bgSprite = visualConfig.GetBackground(cardData.Zone, cardData.Rarity);
if (bgSprite != null)
{
backgroundImage.sprite = bgSprite;
backgroundImage.enabled = true;
}
else
{
backgroundImage.enabled = false;
}
}
// Update zone shape
// Legendary cards don't show shapes
if (zoneShapeImage != null)
{
Sprite shapeSprite = visualConfig.GetZoneShape(cardData.Zone, cardData.Rarity);
if (shapeSprite != null)
{
zoneShapeImage.sprite = shapeSprite;
zoneShapeImage.enabled = true;
}
else
{
zoneShapeImage.enabled = false;
}
}
}
/// <summary>
/// Clears all visual elements
/// </summary>
private void ClearCard()
{
if (cardNameText != null) cardNameText.text = "";
if (cardImage != null) cardImage.enabled = false;
if (frameImage != null) frameImage.enabled = false;
if (overlayImage != null) overlayImage.enabled = false;
if (backgroundImage != null) backgroundImage.enabled = false;
if (zoneShapeImage != null) zoneShapeImage.enabled = false;
}
/// <summary>
/// Called when the card is clicked
/// </summary>
public void OnClick()
{
OnCardClicked?.Invoke(this);
}
/// <summary>
/// Returns the card data associated with this display
/// </summary>
public CardData GetCardData()
{
return cardData;
}
/// <summary>
/// Updates the visual config reference
/// </summary>
public void SetVisualConfig(CardVisualConfig config)
{
visualConfig = config;
if (cardData != null)
{
UpdateCardVisuals();
}
}
/// <summary>
/// Handle pointer click - simply emit the click event
/// </summary>
public void OnPointerClick(PointerEventData eventData)
{
OnCardClicked?.Invoke(this);
}
#if UNITY_EDITOR
/// <summary>
/// Editor-only: Preview a card from the assigned definition
/// </summary>
public void PreviewFromDefinition()
{
if (editorCardDefinition == null)
{
Debug.LogWarning("[CardDisplay] No Card Definition assigned in Editor Tools.");
return;
}
CardData previewData = editorCardDefinition.CreateCardData();
SetupCard(previewData);
}
/// <summary>
/// Editor-only: Clear the preview
/// </summary>
public void ClearPreview()
{
cardData = null;
ClearCard();
}
#endif
}
#if UNITY_EDITOR
[CustomEditor(typeof(CardDisplay))]
public class CardDisplayEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
CardDisplay display = (CardDisplay)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("Editor Preview Tools", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Preview Card"))
{
display.PreviewFromDefinition();
EditorUtility.SetDirty(display);
}
if (GUILayout.Button("Clear Preview"))
{
display.ClearPreview();
EditorUtility.SetDirty(display);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
EditorGUILayout.HelpBox(
"Assign a Card Definition in 'Editor Tools' section, then click 'Preview Card' to see how it will look at runtime.",
MessageType.Info
);
}
}
#endif
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 72cb26621865420aa763a66c06eb7f6d
timeCreated: 1762380447

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 52a47d9c5b29456faca1c9b43f8f4750
timeCreated: 1759923654

View File

@@ -0,0 +1,28 @@
using System.Collections.Generic;
using AppleHills.Data.CardSystem;
namespace Data.CardSystem
{
/// <summary>
/// Serializable snapshot of the card collection state for save/load operations.
/// </summary>
[System.Serializable]
public class CardCollectionState
{
public int boosterPackCount;
public List<SavedCardEntry> cards = new List<SavedCardEntry>();
public List<SavedCardEntry> pendingRevealCards = new List<SavedCardEntry>();
public List<string> placedInAlbumCardIds = new List<string>();
}
/// <summary>
/// Serializable representation of a single card's saved data.
/// </summary>
[System.Serializable]
public class SavedCardEntry
{
public string definitionId;
public CardRarity rarity;
public int copiesOwned;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e552abbd5bd74192840939e499372ff2
timeCreated: 1761830599

View File

@@ -0,0 +1,106 @@
using System;
using UnityEngine;
namespace AppleHills.Data.CardSystem
{
[Serializable]
public class CardData
{
// Core data (serialized)
public string Id; // Auto-generated unique ID (GUID)
public string DefinitionId; // ID of the card definition this instance was created from
public CardRarity Rarity; // Current rarity (may be upgraded from original)
public int CopiesOwned; // Number of copies the player has (for stacking)
// Reference back to the definition (not serialized)
[NonSerialized]
private CardDefinition _definition;
// Properties that reference definition data
public string Name => _definition?.Name;
public string Description => _definition?.Description;
public CardZone Zone => _definition?.Zone ?? CardZone.AppleHills;
public int CollectionIndex => _definition?.CollectionIndex ?? 0;
public Sprite CardImage => _definition?.CardImage;
// Default constructor
public CardData()
{
Id = Guid.NewGuid().ToString();
CopiesOwned = 0;
}
// Constructor from definition
public CardData(CardDefinition definition)
{
Id = Guid.NewGuid().ToString();
DefinitionId = definition.Id;
Rarity = definition.Rarity;
CopiesOwned = 1;
_definition = definition;
}
// Copy constructor
public CardData(CardData other)
{
Id = other.Id;
DefinitionId = other.DefinitionId;
Rarity = other.Rarity;
CopiesOwned = other.CopiesOwned;
_definition = other._definition;
}
// Method to link this card data to its definition
public void SetDefinition(CardDefinition definition)
{
if (definition != null)
{
_definition = definition;
DefinitionId = definition.Id;
}
}
// Method to upgrade rarity when enough copies are collected
public bool TryUpgradeRarity()
{
// Simple implementation - each rarity needs twice as many copies to upgrade
int requiredCopies = (int)Rarity * 2 + 1;
if (CopiesOwned >= requiredCopies && Rarity < CardRarity.Legendary)
{
Rarity += 1;
CopiesOwned -= requiredCopies;
return true;
}
return false;
}
// ToString method for debugging
public override string ToString()
{
return $"CardData [ID: {Id}, Name: {Name}, Rarity: {Rarity}, Zone: {Zone}, " +
$"DefinitionID: {DefinitionId}, Copies: {CopiesOwned}, " +
$"Has Definition: {_definition != null}, Has Image: {CardImage != null}]";
}
}
// Enums for card attributes
public enum CardRarity
{
Normal = 0,
Rare = 1,
Legendary = 2
}
public enum CardZone
{
NotApplicable,
AppleHills,
Quarry,
CementFactory,
CentralStreet,
Valentine,
Dump
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 812f681e555841c584d5791cb66278de
timeCreated: 1759923654

View File

@@ -0,0 +1,59 @@
using System;
using UnityEngine;
namespace AppleHills.Data.CardSystem
{
/// <summary>
/// Scriptable object defining a collectible card's properties.
/// Used as a template for generating CardData instances.
/// </summary>
[CreateAssetMenu(fileName = "New Card", menuName = "AppleHills/Card System/Card Definition")]
public class CardDefinition : ScriptableObject
{
[Header("Identification")]
[Tooltip("Unique identifier for this card definition")]
public string Id = Guid.NewGuid().ToString();
[Header("Basic Info")]
public string Name;
[Tooltip("Use a custom file name instead of the card name")]
public bool UseCustomFileName = false;
[Tooltip("Custom file name (only used if UseCustomFileName is true)")]
public string CustomFileName = "";
[TextArea(3, 5)]
public string Description;
public CardRarity Rarity;
public CardZone Zone;
[Header("Visual Elements")]
public Sprite CardImage; // The actual card image
[Header("Collection Info")]
public int CollectionIndex; // Position in the album
/// <summary>
/// Creates a new CardData instance from this definition
/// </summary>
public CardData CreateCardData()
{
return new CardData(this);
}
public override bool Equals(object obj)
{
if (obj is CardDefinition other)
{
return string.Equals(Id, other.Id, StringComparison.Ordinal);
}
return false;
}
public override int GetHashCode()
{
return Id != null ? Id.GetHashCode() : 0;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2a80cc88c9884512b8b633110d838780
timeCreated: 1759923702

View File

@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace AppleHills.Data.CardSystem
{
/// <summary>
/// Manages the player's collection of cards and booster packs
/// </summary>
[Serializable]
public class CardInventory
{
// Dictionary of collected cards indexed by definition ID + rarity (e.g., "Pikachu_Normal", "Pikachu_Rare")
[SerializeField] private Dictionary<string, CardData> collectedCards = new Dictionary<string, CardData>();
/// <summary>
/// Generate a unique key for a card based on definition ID and rarity
/// </summary>
private string GetCardKey(string definitionId, CardRarity rarity)
{
return $"{definitionId}_{rarity}";
}
// Number of unopened booster packs the player has
[SerializeField] private int boosterPackCount;
// Additional lookup dictionaries (not serialized)
[NonSerialized] private Dictionary<CardZone, List<CardData>> cardsByZone = new Dictionary<CardZone, List<CardData>>();
[NonSerialized] private Dictionary<CardRarity, List<CardData>> cardsByRarity = new Dictionary<CardRarity, List<CardData>>();
// Properties with public getters
public Dictionary<string, CardData> CollectedCards => collectedCards;
public int BoosterPackCount
{
get => boosterPackCount;
set => boosterPackCount = value;
}
// Constructor initializes empty dictionaries
public CardInventory()
{
// Initialize dictionaries for all enum values so we never have to check for null
foreach (CardZone zone in Enum.GetValues(typeof(CardZone)))
{
cardsByZone[zone] = new List<CardData>();
}
foreach (CardRarity rarity in Enum.GetValues(typeof(CardRarity)))
{
cardsByRarity[rarity] = new List<CardData>();
}
}
/// <summary>
/// Get all cards in the player's collection as a list
/// </summary>
public List<CardData> GetAllCards()
{
return collectedCards.Values.ToList();
}
/// <summary>
/// Clears all cards from the inventory
/// Primarily used for testing
/// </summary>
public void ClearAllCards()
{
collectedCards.Clear();
// Clear lookup dictionaries
foreach (var zone in cardsByZone.Keys)
{
cardsByZone[zone].Clear();
}
foreach (var rarity in cardsByRarity.Keys)
{
cardsByRarity[rarity].Clear();
}
}
/// <summary>
/// Get cards filtered by zone
/// </summary>
public List<CardData> GetCardsByZone(CardZone zone)
{
return new List<CardData>(cardsByZone[zone]);
}
/// <summary>
/// Get cards filtered by rarity
/// </summary>
public List<CardData> GetCardsByRarity(CardRarity rarity)
{
return new List<CardData>(cardsByRarity[rarity]);
}
/// <summary>
/// Add a card to the inventory (or increase the copies if already owned)
/// </summary>
public void AddCard(CardData card)
{
if (card == null) return;
string key = GetCardKey(card.DefinitionId, card.Rarity);
if (collectedCards.TryGetValue(key, out CardData existingCard))
{
// Increase copies of existing card
existingCard.CopiesOwned++;
}
else
{
// Add new card to collection
var newCard = new CardData(card);
collectedCards[key] = newCard;
// Add to lookup dictionaries
cardsByZone[newCard.Zone].Add(newCard);
cardsByRarity[newCard.Rarity].Add(newCard);
}
}
/// <summary>
/// Update card zone and rarity indexes when a card changes
/// </summary>
public void UpdateCardProperties(CardData card, CardZone oldZone, CardRarity oldRarity)
{
// Only needed if the card's zone or rarity actually changed
if (oldZone != card.Zone)
{
cardsByZone[oldZone].Remove(card);
cardsByZone[card.Zone].Add(card);
}
if (oldRarity != card.Rarity)
{
cardsByRarity[oldRarity].Remove(card);
cardsByRarity[card.Rarity].Add(card);
}
}
/// <summary>
/// Get a specific card from the collection by definition ID and rarity
/// </summary>
public CardData GetCard(string definitionId, CardRarity rarity)
{
string key = GetCardKey(definitionId, rarity);
return collectedCards.TryGetValue(key, out CardData card) ? card : null;
}
/// <summary>
/// Check if the player has a specific card at a specific rarity
/// </summary>
public bool HasCard(string definitionId, CardRarity rarity)
{
string key = GetCardKey(definitionId, rarity);
return collectedCards.ContainsKey(key);
}
/// <summary>
/// Get total number of unique cards in collection
/// </summary>
public int GetUniqueCardCount()
{
return collectedCards.Count;
}
/// <summary>
/// Get total number of cards including copies
/// </summary>
public int GetTotalCardCount()
{
return collectedCards.Values.Sum(card => card.CopiesOwned);
}
/// <summary>
/// Get number of cards in a specific zone
/// </summary>
public int GetZoneCardCount(CardZone zone)
{
return cardsByZone[zone].Count;
}
/// <summary>
/// Get number of cards of a specific rarity
/// </summary>
public int GetRarityCardCount(CardRarity rarity)
{
return cardsByRarity[rarity].Count;
}
/// <summary>
/// Get cards sorted by collection index (for album view)
/// </summary>
public List<CardData> GetCardsSortedByIndex()
{
return collectedCards.Values
.OrderBy(card => card.CollectionIndex)
.ToList();
}
/// <summary>
/// Check if there's a complete collection for a specific zone
/// </summary>
public bool IsZoneCollectionComplete(CardZone zone, List<CardDefinition> allAvailableCards)
{
int availableInZone = allAvailableCards.Count(card => card.Zone == zone);
int collectedInZone = cardsByZone[zone].Count;
return availableInZone > 0 && collectedInZone >= availableInZone;
}
/// <summary>
/// Adds booster packs to the inventory
/// </summary>
public void AddBoosterPacks(int count)
{
boosterPackCount += count;
}
/// <summary>
/// Use a single booster pack (returns true if successful)
/// </summary>
public bool UseBoosterPack()
{
if (boosterPackCount <= 0) return false;
boosterPackCount--;
return true;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f5b1aa91590d48a1a4c426f3cd4aa103
timeCreated: 1760445622

View File

@@ -0,0 +1,790 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AppleHills.Data.CardSystem;
using Core;
using Core.Lifecycle;
using UnityEngine;
namespace Data.CardSystem
{
/// <summary>
/// Manages the player's card collection, booster packs, and related operations.
/// Manages the card collection system for the game.
/// Handles unlocking cards, tracking collections, and integrating with the save/load system.
/// </summary>
public class CardSystemManager : ManagedBehaviour
{
private static CardSystemManager _instance;
public static CardSystemManager Instance => _instance;
// Save system configuration
public override bool AutoRegisterForSave => true;
public override string SaveId => "CardSystemManager";
[Header("Card Collection")]
[SerializeField] private List<CardDefinition> availableCards = new List<CardDefinition>();
// Runtime data - will be serialized for save/load
[SerializeField] private CardInventory playerInventory = new CardInventory();
// Album system - cards waiting to be placed in album
private List<CardData> _pendingRevealCards = new List<CardData>();
private HashSet<string> _placedInAlbumCardIds = new HashSet<string>();
// Dictionary to quickly look up card definitions by ID
private Dictionary<string, CardDefinition> _definitionLookup;
private bool _lookupInitialized = false;
// Event callbacks using System.Action
public event Action<List<CardData>> OnBoosterOpened;
public event Action<CardData> OnCardCollected;
public event Action<int> OnBoosterCountChanged;
public event Action<CardData> OnPendingCardAdded;
public event Action<CardData> OnPendingCardRemoved;
public event Action<CardData> OnCardPlacedInAlbum;
internal override void OnManagedAwake()
{
// Set instance immediately (early initialization)
_instance = this;
// Load card definitions from Addressables, then register with save system
LoadCardDefinitionsFromAddressables();
}
internal override void OnManagedStart()
{
Logging.Debug("[CardSystemManager] Initialized");
}
/// <summary>
/// Loads all card definitions from Addressables using the "BlokkemonCard" label
/// </summary>
private async void LoadCardDefinitionsFromAddressables()
{
availableCards = new List<CardDefinition>();
// Load by label instead of group name for better flexibility
var handle = UnityEngine.AddressableAssets.Addressables.LoadResourceLocationsAsync(new string[] { "BlokkemonCard" }, UnityEngine.AddressableAssets.Addressables.MergeMode.Union);
await handle.Task;
if (handle.Status == UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationStatus.Succeeded)
{
var locations = handle.Result;
var loadedIds = new HashSet<string>();
foreach (var loc in locations)
{
var cardHandle = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync<CardDefinition>(loc);
await cardHandle.Task;
if (cardHandle.Status == UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationStatus.Succeeded)
{
var cardDef = cardHandle.Result;
if (cardDef != null && !string.IsNullOrEmpty(cardDef.Id) && !loadedIds.Contains(cardDef.Id))
{
availableCards.Add(cardDef);
loadedIds.Add(cardDef.Id);
}
}
}
// Build lookup now that cards are loaded
BuildDefinitionLookup();
Logging.Debug($"[CardSystemManager] Loaded {availableCards.Count} card definitions from Addressables");
}
else
{
Logging.Warning("[CardSystemManager] Failed to load card definitions from Addressables");
}
}
/// <summary>
/// Builds a lookup dictionary for quick access to card definitions by ID
/// </summary>
private void BuildDefinitionLookup()
{
if (_definitionLookup == null)
{
_definitionLookup = new Dictionary<string, CardDefinition>();
}
_definitionLookup.Clear();
foreach (var cardDef in availableCards)
{
if (cardDef != null && !string.IsNullOrEmpty(cardDef.Id))
{
_definitionLookup[cardDef.Id] = cardDef;
}
}
// Link existing card data to their definitions
foreach (var cardData in playerInventory.CollectedCards.Values)
{
if (!string.IsNullOrEmpty(cardData.DefinitionId) &&
_definitionLookup.TryGetValue(cardData.DefinitionId, out CardDefinition def))
{
cardData.SetDefinition(def);
}
}
_lookupInitialized = true;
}
/// <summary>
/// Adds a booster pack to the player's inventory
/// </summary>
public void AddBoosterPack(int count = 1)
{
playerInventory.BoosterPackCount += count;
OnBoosterCountChanged?.Invoke(playerInventory.BoosterPackCount);
Logging.Debug($"[CardSystemManager] Added {count} booster pack(s). Total: {playerInventory.BoosterPackCount}");
}
/// <summary>
/// Opens a booster pack and returns the newly obtained cards
/// NOTE: Cards are NOT added to inventory immediately - they're added after the reveal interaction
/// </summary>
public List<CardData> OpenBoosterPack()
{
if (playerInventory.BoosterPackCount <= 0)
{
Logging.Warning("[CardSystemManager] Attempted to open a booster pack, but none are available.");
return new List<CardData>();
}
playerInventory.BoosterPackCount--;
OnBoosterCountChanged?.Invoke(playerInventory.BoosterPackCount);
// Draw 3 cards based on rarity distribution
List<CardData> drawnCards = DrawRandomCards(3);
// NOTE: Cards are NOT added to inventory here anymore
// They will be added after the player interacts with each revealed card
// This allows us to show new/repeat status before adding to collection
// Notify listeners
OnBoosterOpened?.Invoke(drawnCards);
Logging.Debug($"[CardSystemManager] Opened a booster pack and obtained {drawnCards.Count} cards. Remaining boosters: {playerInventory.BoosterPackCount}");
return drawnCards;
}
/// <summary>
/// Check if a card is new to the player's collection at the specified rarity
/// Checks both owned inventory and pending reveal queue
/// </summary>
/// <param name="cardData">The card to check</param>
/// <param name="existingCard">Out parameter - the existing card if found, null otherwise</param>
/// <returns>True if this is a new card at this rarity, false if already owned or pending</returns>
public bool IsCardNew(CardData cardData, out CardData existingCard)
{
// First check inventory (cards already placed in album)
if (playerInventory.HasCard(cardData.DefinitionId, cardData.Rarity))
{
existingCard = playerInventory.GetCard(cardData.DefinitionId, cardData.Rarity);
return false;
}
// Then check pending reveal queue (cards waiting to be placed)
CardData pendingCard = _pendingRevealCards.FirstOrDefault(c =>
c.DefinitionId == cardData.DefinitionId && c.Rarity == cardData.Rarity);
if (pendingCard != null)
{
// Return the actual pending card with its real CopiesOwned count
// Pending status is just about placement location, not copy count
existingCard = pendingCard;
return false; // Not new - already in pending queue
}
existingCard = null;
return true; // Truly new - not in inventory or pending
}
/// <summary>
/// Adds a card to the player's inventory after reveal (delayed add)
/// Public wrapper for AddCardToInventory to support delayed inventory updates
/// </summary>
public void AddCardToInventoryDelayed(CardData card)
{
AddCardToInventory(card);
}
/// <summary>
/// Adds a card to the player's inventory, handles duplicates
/// Checks both inventory and pending lists to find existing cards
/// </summary>
private void AddCardToInventory(CardData card)
{
// Guard: Ensure card has at least 1 copy
if (card.CopiesOwned <= 0)
{
card.CopiesOwned = 1;
Logging.Warning($"[CardSystemManager] Card '{card.Name}' had {card.CopiesOwned} copies, setting to 1");
}
// First check inventory (cards already placed in album)
if (playerInventory.HasCard(card.DefinitionId, card.Rarity))
{
CardData existingCard = playerInventory.GetCard(card.DefinitionId, card.Rarity);
existingCard.CopiesOwned++;
Logging.Debug($"[CardSystemManager] Added duplicate card '{card.Name}' ({card.Rarity}) to INVENTORY. Now have {existingCard.CopiesOwned} copies.");
return;
}
// Then check pending reveal queue
CardData pendingCard = _pendingRevealCards.FirstOrDefault(c =>
c.DefinitionId == card.DefinitionId && c.Rarity == card.Rarity);
if (pendingCard != null)
{
// Card already in pending - increment its copy count
pendingCard.CopiesOwned++;
Logging.Debug($"[CardSystemManager] Added duplicate card '{card.Name}' ({card.Rarity}) to PENDING. Now have {pendingCard.CopiesOwned} copies pending.");
return;
}
// This is a NEW card (never owned at this rarity before)
// Add to pending reveal list instead of inventory
_pendingRevealCards.Add(card);
OnPendingCardAdded?.Invoke(card);
Logging.Debug($"[CardSystemManager] Added new card '{card.Name}' ({card.Rarity}) to pending reveal queue.");
}
/// <summary>
/// Draws random cards based on rarity distribution
/// </summary>
private List<CardData> DrawRandomCards(int count)
{
List<CardData> result = new List<CardData>();
if (availableCards.Count == 0)
{
Debug.LogError("[CardSystemManager] No available cards defined!");
return result;
}
// Simple weighted random selection based on rarity
for (int i = 0; i < count; i++)
{
// Determine card rarity first
CardRarity rarity = DetermineRandomRarity();
// Filter cards by the selected rarity
List<CardDefinition> cardsOfRarity = availableCards.FindAll(c => c.Rarity == rarity);
if (cardsOfRarity.Count > 0)
{
// Select a random card of this rarity
int randomIndex = UnityEngine.Random.Range(0, cardsOfRarity.Count);
CardDefinition selectedDef = cardsOfRarity[randomIndex];
// Create card data from definition
CardData newCard = selectedDef.CreateCardData();
result.Add(newCard);
}
else
{
// Fallback if no cards of the selected rarity
Logging.Warning($"[CardSystemManager] No cards of rarity {rarity} available, selecting a random card instead.");
int randomIndex = UnityEngine.Random.Range(0, availableCards.Count);
CardDefinition randomDef = availableCards[randomIndex];
CardData newCard = randomDef.CreateCardData();
result.Add(newCard);
}
}
return result;
}
/// <summary>
/// Determines a random card rarity with appropriate weighting
/// </summary>
private CardRarity DetermineRandomRarity()
{
// Weighted random for 3 rarities
float rand = UnityEngine.Random.value;
if (rand < 0.70f) return CardRarity.Normal; // 70% chance
if (rand < 0.95f) return CardRarity.Rare; // 25% chance
return CardRarity.Legendary; // 5% chance
}
/// <summary>
/// Returns all cards from the player's collection (both owned and pending)
/// </summary>
public List<CardData> GetAllCollectedCards()
{
List<CardData> allCards = new List<CardData>(playerInventory.GetAllCards());
allCards.AddRange(_pendingRevealCards);
return allCards;
}
/// <summary>
/// Returns only owned/collected cards (excludes pending reveal cards)
/// </summary>
public List<CardData> GetCollectionOnly()
{
return new List<CardData>(playerInventory.GetAllCards());
}
/// <summary>
/// Returns cards from a specific zone (both owned and pending)
/// </summary>
public List<CardData> GetCardsByZone(CardZone zone)
{
List<CardData> zoneCards = new List<CardData>(playerInventory.GetCardsByZone(zone));
zoneCards.AddRange(_pendingRevealCards.Where(c => c.Zone == zone));
return zoneCards;
}
/// <summary>
/// Returns cards of a specific rarity (both owned and pending)
/// </summary>
public List<CardData> GetCardsByRarity(CardRarity rarity)
{
List<CardData> rarityCards = new List<CardData>(playerInventory.GetCardsByRarity(rarity));
rarityCards.AddRange(_pendingRevealCards.Where(c => c.Rarity == rarity));
return rarityCards;
}
/// <summary>
/// Returns the number of booster packs the player has
/// </summary>
public int GetBoosterPackCount()
{
return playerInventory.BoosterPackCount;
}
/// <summary>
/// Returns whether a specific card definition has been collected (at any rarity, in inventory or pending)
/// </summary>
public bool IsCardCollected(string definitionId)
{
// Check inventory at any rarity
foreach (CardRarity rarity in System.Enum.GetValues(typeof(CardRarity)))
{
if (playerInventory.HasCard(definitionId, rarity))
return true;
}
// Check pending reveal queue
if (_pendingRevealCards.Any(c => c.DefinitionId == definitionId))
return true;
return false;
}
/// <summary>
/// Gets total unique card count (both owned and pending)
/// </summary>
public int GetUniqueCardCount()
{
int inventoryCount = playerInventory.GetUniqueCardCount();
// Count unique cards in pending that aren't already in inventory
int pendingUniqueCount = _pendingRevealCards
.Select(c => new { c.DefinitionId, c.Rarity })
.Distinct()
.Count(pc => !playerInventory.HasCard(pc.DefinitionId, pc.Rarity));
return inventoryCount + pendingUniqueCount;
}
/// <summary>
/// Gets completion percentage for a specific zone (0-100)
/// </summary>
public float GetZoneCompletionPercentage(CardZone zone)
{
// Count available cards in this zone
int totalInZone = availableCards.FindAll(c => c.Zone == zone).Count;
if (totalInZone == 0) return 0;
// Count collected cards in this zone
int collectedInZone = playerInventory.GetCardsByZone(zone).Count;
return (float)collectedInZone / totalInZone * 100f;
}
/// <summary>
/// Returns all available card definitions in the system
/// </summary>
public List<CardDefinition> GetAllCardDefinitions()
{
return new List<CardDefinition>(availableCards);
}
/// <summary>
/// Returns direct access to the player's card inventory
/// For advanced operations and testing
/// </summary>
public CardInventory GetCardInventory()
{
return playerInventory;
}
/// <summary>
/// Returns cards filtered by both zone and rarity
/// </summary>
public List<CardData> GetCardsByZoneAndRarity(CardZone zone, CardRarity rarity)
{
List<CardData> zoneCards = GetCardsByZone(zone);
return zoneCards.FindAll(c => c.Rarity == rarity);
}
/// <summary>
/// Returns the count of cards by rarity (both owned and pending)
/// </summary>
public int GetCardCountByRarity(CardRarity rarity)
{
int inventoryCount = playerInventory.GetCardsByRarity(rarity).Count;
int pendingCount = _pendingRevealCards.Count(c => c.Rarity == rarity);
return inventoryCount + pendingCount;
}
/// <summary>
/// Returns the count of cards by zone (both owned and pending)
/// </summary>
public int GetCardCountByZone(CardZone zone)
{
int inventoryCount = playerInventory.GetCardsByZone(zone).Count;
int pendingCount = _pendingRevealCards.Count(c => c.Zone == zone);
return inventoryCount + pendingCount;
}
/// <summary>
/// Gets the total number of card definitions available in the system
/// </summary>
public int GetTotalCardDefinitionsCount()
{
return availableCards.Count;
}
/// <summary>
/// Gets the total collection completion percentage (0-100)
/// </summary>
public float GetTotalCompletionPercentage()
{
if (availableCards.Count == 0) return 0;
return (float)GetUniqueCardCount() / availableCards.Count * 100f;
}
/// <summary>
/// Gets total completion percentage for a specific rarity (0-100)
/// </summary>
public float GetRarityCompletionPercentage(CardRarity rarity)
{
// Count available cards of this rarity
int totalOfRarity = availableCards.FindAll(c => c.Rarity == rarity).Count;
if (totalOfRarity == 0) return 0;
// Count collected cards of this rarity
int collectedOfRarity = playerInventory.GetCardsByRarity(rarity).Count;
return (float)collectedOfRarity / totalOfRarity * 100f;
}
#region Album System
/// <summary>
/// Returns all pending reveal cards (cards waiting to be placed in album)
/// </summary>
public List<CardData> GetPendingRevealCards()
{
return _pendingRevealCards;
}
/// <summary>
/// Remove a card from the pending reveal list and fire event.
/// Called when a card starts being dragged to album slot.
/// </summary>
public bool RemoveFromPending(CardData card)
{
if (card == null) return false;
bool removed = _pendingRevealCards.Remove(card);
if (removed)
{
OnPendingCardRemoved?.Invoke(card);
Logging.Debug($"[CardSystemManager] Removed '{card.Name}' from pending reveal cards");
}
return removed;
}
/// <summary>
/// Get a card by definition ID and rarity from either inventory or pending
/// Returns the actual card reference so changes persist
/// </summary>
/// <param name="definitionId">Card definition ID</param>
/// <param name="rarity">Card rarity</param>
/// <param name="isFromPending">Out parameter - true if card is from pending, false if from inventory</param>
/// <returns>The card data if found, null otherwise</returns>
public CardData GetCard(string definitionId, CardRarity rarity, out bool isFromPending)
{
// Check inventory first
if (playerInventory.HasCard(definitionId, rarity))
{
isFromPending = false;
return playerInventory.GetCard(definitionId, rarity);
}
// Check pending
CardData pendingCard = _pendingRevealCards.FirstOrDefault(c =>
c.DefinitionId == definitionId && c.Rarity == rarity);
if (pendingCard != null)
{
isFromPending = true;
return pendingCard;
}
isFromPending = false;
return null;
}
/// <summary>
/// Get a card by definition ID and rarity from either inventory or pending (simplified overload)
/// </summary>
public CardData GetCard(string definitionId, CardRarity rarity)
{
return GetCard(definitionId, rarity, out _);
}
/// <summary>
/// Update a card's data in whichever list it's in (inventory or pending)
/// Useful for incrementing CopiesOwned, upgrading rarity, etc.
/// </summary>
/// <param name="definitionId">Card definition ID</param>
/// <param name="rarity">Card rarity</param>
/// <param name="updateAction">Action to perform on the card</param>
/// <returns>True if card was found and updated, false otherwise</returns>
public bool UpdateCard(string definitionId, CardRarity rarity, System.Action<CardData> updateAction)
{
CardData card = GetCard(definitionId, rarity, out bool isFromPending);
if (card != null)
{
updateAction?.Invoke(card);
Logging.Debug($"[CardSystemManager] Updated card '{card.Name}' in {(isFromPending ? "pending" : "inventory")}");
return true;
}
Logging.Warning($"[CardSystemManager] Could not find card with ID '{definitionId}' and rarity '{rarity}' to update");
return false;
}
/// <summary>
/// Marks a card as placed in the album
/// Adds card to owned inventory and tracks as placed
/// Note: Card may have already been removed from pending list during drag
/// </summary>
public void MarkCardAsPlaced(CardData card)
{
if (card == null)
{
Logging.Warning("[CardSystemManager] Attempted to place null card");
return;
}
// Try to remove from pending (may already be removed during drag)
bool wasInPending = _pendingRevealCards.Remove(card);
// Add to owned inventory (regardless of whether it was in pending)
playerInventory.AddCard(card);
// Track as placed
_placedInAlbumCardIds.Add(card.Id);
// Fire events
OnCardPlacedInAlbum?.Invoke(card);
OnCardCollected?.Invoke(card);
if (wasInPending)
{
Logging.Debug($"[CardSystemManager] Card '{card.Name}' removed from pending and added to inventory");
}
else
{
Logging.Debug($"[CardSystemManager] Card '{card.Name}' added to inventory (was already removed from pending)");
}
}
/// <summary>
/// Checks if a card has been placed in the album
/// </summary>
public bool IsCardPlacedInAlbum(string cardId)
{
return _placedInAlbumCardIds.Contains(cardId);
}
/// <summary>
/// Gets count of cards waiting to be revealed
/// </summary>
public int GetPendingRevealCount()
{
return _pendingRevealCards.Count;
}
#endregion
/// <summary>
/// Export current card collection to a serializable snapshot
/// </summary>
public CardCollectionState ExportCardCollectionState()
{
var state = new CardCollectionState
{
boosterPackCount = playerInventory.BoosterPackCount,
cards = new List<SavedCardEntry>(),
pendingRevealCards = new List<SavedCardEntry>(),
placedInAlbumCardIds = new List<string>(_placedInAlbumCardIds)
};
foreach (var card in playerInventory.CollectedCards.Values)
{
if (string.IsNullOrEmpty(card.DefinitionId)) continue;
state.cards.Add(new SavedCardEntry
{
definitionId = card.DefinitionId,
rarity = card.Rarity,
copiesOwned = card.CopiesOwned
});
}
foreach (var card in _pendingRevealCards)
{
if (string.IsNullOrEmpty(card.DefinitionId)) continue;
state.pendingRevealCards.Add(new SavedCardEntry
{
definitionId = card.DefinitionId,
rarity = card.Rarity,
copiesOwned = card.CopiesOwned
});
}
return state;
}
/// <summary>
/// Apply a previously saved snapshot to the runtime inventory
/// </summary>
public async void ApplyCardCollectionState(CardCollectionState state)
{
if (state == null) return;
// Wait for lookup to be initialized before loading
while (!_lookupInitialized)
{
await System.Threading.Tasks.Task.Yield();
}
playerInventory.ClearAllCards();
_pendingRevealCards.Clear();
_placedInAlbumCardIds.Clear();
playerInventory.BoosterPackCount = state.boosterPackCount;
OnBoosterCountChanged?.Invoke(playerInventory.BoosterPackCount);
foreach (var entry in state.cards)
{
if (string.IsNullOrEmpty(entry.definitionId)) continue;
if (_definitionLookup.TryGetValue(entry.definitionId, out var def))
{
// Create from definition to ensure links, then overwrite runtime fields
var cd = def.CreateCardData();
cd.Rarity = entry.rarity;
cd.CopiesOwned = entry.copiesOwned;
playerInventory.AddCard(cd);
}
else
{
Logging.Warning($"[CardSystemManager] Saved card definition not found: {entry.definitionId}");
}
}
// Restore pending reveal cards
if (state.pendingRevealCards != null)
{
foreach (var entry in state.pendingRevealCards)
{
if (string.IsNullOrEmpty(entry.definitionId)) continue;
if (_definitionLookup.TryGetValue(entry.definitionId, out var def))
{
var cd = def.CreateCardData();
cd.Rarity = entry.rarity;
cd.CopiesOwned = entry.copiesOwned;
_pendingRevealCards.Add(cd);
}
}
}
// Restore placed in album tracking
if (state.placedInAlbumCardIds != null)
{
foreach (var cardId in state.placedInAlbumCardIds)
{
_placedInAlbumCardIds.Add(cardId);
}
}
}
/// <summary>
/// Clears all card collection data - inventory, pending cards, boosters, and placement tracking
/// Used for dev reset functionality
/// </summary>
public void ClearAllCollectionData()
{
playerInventory.ClearAllCards();
playerInventory.BoosterPackCount = 0;
_pendingRevealCards.Clear();
_placedInAlbumCardIds.Clear();
OnBoosterCountChanged?.Invoke(0);
Logging.Debug("[CardSystemManager] Cleared all collection data (inventory, boosters, pending, placement tracking)");
}
#region Save/Load Lifecycle Hooks
internal override string OnGlobalSaveRequested()
{
var state = ExportCardCollectionState();
return JsonUtility.ToJson(state);
}
internal override void OnGlobalRestoreRequested(string serializedData)
{
if (string.IsNullOrEmpty(serializedData))
{
Logging.Debug("[CardSystemManager] No saved state to restore, using defaults");
return;
}
try
{
var state = JsonUtility.FromJson<CardCollectionState>(serializedData);
if (state != null)
{
ApplyCardCollectionState(state);
Logging.Debug("[CardSystemManager] Successfully restored card collection state");
}
else
{
Logging.Warning("[CardSystemManager] Failed to deserialize card collection state");
}
}
catch (Exception ex)
{
Logging.Warning($"[CardSystemManager] Exception while restoring card collection state: {ex}");
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8d80347e4bd04c87be23a9399860783d
timeCreated: 1759923691

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using Core;
using UnityEngine;
namespace AppleHills.Data.CardSystem
{
/// <summary>
/// ScriptableObject containing visual configuration for card display
/// Maps card rarities to frames/overlays and zones to backgrounds/shapes
/// </summary>
[CreateAssetMenu(fileName = "CardVisualConfig", menuName = "AppleHills/Card System/Visual Config")]
public class CardVisualConfig : ScriptableObject
{
[Serializable]
public class RarityVisualMapping
{
public CardRarity rarity;
[Tooltip("Frame image for this rarity")]
public Sprite frame;
[Tooltip("Overlay image for this rarity (can be null)")]
public Sprite overlay;
}
[Serializable]
public class ZoneVisualMapping
{
public CardZone zone;
[Tooltip("Background image for this zone")]
public Sprite background;
[Tooltip("Shape sprite for Normal rarity cards in this zone")]
public Sprite shapeNormal;
[Tooltip("Shape sprite for Rare rarity cards in this zone")]
public Sprite shapeRare;
}
[Header("Rarity Configuration")]
[Tooltip("Visual mappings for different card rarities (frames and overlays)")]
[SerializeField] private List<RarityVisualMapping> rarityVisuals = new List<RarityVisualMapping>();
[Header("Zone Configuration")]
[Tooltip("Visual mappings for different card zones (backgrounds and shapes)")]
[SerializeField] private List<ZoneVisualMapping> zoneVisuals = new List<ZoneVisualMapping>();
[Header("Legendary Override")]
[Tooltip("Background used for all Legendary cards, regardless of zone")]
[SerializeField] private Sprite legendaryBackground;
private Dictionary<CardRarity, RarityVisualMapping> _rarityLookup;
private Dictionary<CardZone, ZoneVisualMapping> _zoneLookup;
/// <summary>
/// Initialize the lookup dictionaries when the asset is loaded
/// </summary>
private void OnEnable()
{
InitializeLookups();
}
/// <summary>
/// Builds the lookup dictionaries from the serialized lists
/// </summary>
private void InitializeLookups()
{
// Build rarity visual lookup
_rarityLookup = new Dictionary<CardRarity, RarityVisualMapping>();
foreach (var mapping in rarityVisuals)
{
_rarityLookup[mapping.rarity] = mapping;
}
// Build zone visual lookup
_zoneLookup = new Dictionary<CardZone, ZoneVisualMapping>();
foreach (var mapping in zoneVisuals)
{
_zoneLookup[mapping.zone] = mapping;
}
}
/// <summary>
/// Get the frame sprite for a specific card rarity
/// </summary>
public Sprite GetRarityFrame(CardRarity rarity)
{
if (_rarityLookup == null) InitializeLookups();
if (_rarityLookup.TryGetValue(rarity, out RarityVisualMapping mapping))
{
return mapping.frame;
}
Logging.Warning($"[CardVisualConfig] No frame mapping found for rarity {rarity}");
return null;
}
/// <summary>
/// Get the overlay sprite for a specific card rarity (can be null)
/// </summary>
public Sprite GetRarityOverlay(CardRarity rarity)
{
if (_rarityLookup == null) InitializeLookups();
if (_rarityLookup.TryGetValue(rarity, out RarityVisualMapping mapping))
{
return mapping.overlay;
}
return null;
}
/// <summary>
/// Get the background sprite for a card based on zone and rarity
/// Legendary cards always use the legendary background override
/// </summary>
public Sprite GetBackground(CardZone zone, CardRarity rarity)
{
if (_zoneLookup == null) InitializeLookups();
// Legendary cards use special background
if (rarity == CardRarity.Legendary)
{
return legendaryBackground;
}
// Normal and Rare cards use zone background
if (_zoneLookup.TryGetValue(zone, out ZoneVisualMapping mapping))
{
return mapping.background;
}
Logging.Warning($"[CardVisualConfig] No background mapping found for zone {zone}");
return null;
}
/// <summary>
/// Get the shape sprite for a card based on zone and rarity
/// Legendary cards don't display shapes (returns null)
/// </summary>
public Sprite GetZoneShape(CardZone zone, CardRarity rarity)
{
if (_zoneLookup == null) InitializeLookups();
// Legendary cards don't have shapes
if (rarity == CardRarity.Legendary)
{
return null;
}
if (_zoneLookup.TryGetValue(zone, out ZoneVisualMapping mapping))
{
// Return shape based on rarity
return rarity == CardRarity.Rare ? mapping.shapeRare : mapping.shapeNormal;
}
Logging.Warning($"[CardVisualConfig] No shape mapping found for zone {zone}");
return null;
}
#if UNITY_EDITOR
/// <summary>
/// Editor-only utility to initialize the config with default structure
/// </summary>
public void InitializeDefaults()
{
// Clear existing mappings
rarityVisuals.Clear();
zoneVisuals.Clear();
// Add entries for all rarities
foreach (CardRarity rarity in Enum.GetValues(typeof(CardRarity)))
{
rarityVisuals.Add(new RarityVisualMapping { rarity = rarity });
}
// Add entries for all zones
foreach (CardZone zone in Enum.GetValues(typeof(CardZone)))
{
zoneVisuals.Add(new ZoneVisualMapping { zone = zone });
}
// Initialize the lookups
InitializeLookups();
}
#endif
}
#if UNITY_EDITOR
[UnityEditor.CustomEditor(typeof(CardVisualConfig))]
public class CardVisualConfigEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
CardVisualConfig config = (CardVisualConfig)target;
UnityEditor.EditorGUILayout.Space();
if (GUILayout.Button("Initialize Default Structure"))
{
config.InitializeDefaults();
UnityEditor.EditorUtility.SetDirty(config);
}
}
}
#endif
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a82f88f485b4410e9eb7c383b44557cf
timeCreated: 1759931508

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 053a2ff2538541699b134b07a07edecb
timeCreated: 1762420654

View File

@@ -0,0 +1,165 @@
using AppleHills.Data.CardSystem;
using Core;
using Data.CardSystem;
using UI.DragAndDrop.Core;
using UnityEngine;
namespace UI.CardSystem
{
/// <summary>
/// Specialized slot for album pages that only accepts a specific card.
/// Empty by default, auto-spawns owned cards on enable.
/// </summary>
public class AlbumCardSlot : DraggableSlot
{
[Header("Album Slot Configuration")]
[SerializeField] private CardDefinition targetCardDefinition; // Which card this slot accepts
[SerializeField] private GameObject cardPrefab; // Card prefab to spawn when card is owned
private StateMachine.Card _assignedCard; // The card currently in this slot (if any)
/// <summary>
/// Get the target card definition for this slot
/// </summary>
public CardDefinition TargetCardDefinition => targetCardDefinition;
/// <summary>
/// Check if this slot has a card assigned to it
/// </summary>
public bool HasCardAssigned => _assignedCard != null;
/// <summary>
/// Get the card currently assigned to this slot
/// </summary>
public StateMachine.Card AssignedCard => _assignedCard;
private void OnEnable()
{
// Check if we should spawn a card for this slot
CheckAndSpawnOwnedCard();
}
/// <summary>
/// Assign a card to this slot (called by AlbumViewPage after placement animation)
/// </summary>
public void AssignCard(StateMachine.Card card)
{
if (card == null)
{
Logging.Warning("[AlbumCardSlot] Attempted to assign null card to slot");
return;
}
if (_assignedCard != null && _assignedCard != card)
{
Logging.Warning($"[AlbumCardSlot] Slot already has a card assigned, replacing with new card");
// Clean up old card
if (_assignedCard.gameObject != null)
{
Destroy(_assignedCard.gameObject);
}
}
_assignedCard = card;
Logging.Debug($"[AlbumCardSlot] Card '{card.CardData?.Name}' assigned to slot for {targetCardDefinition?.name}");
}
/// <summary>
/// Check if player owns the card for this slot and spawn it if so
/// (Called on OnEnable to handle game reload scenarios)
/// </summary>
private void CheckAndSpawnOwnedCard()
{
// Guard: need CardSystemManager and target definition
if (CardSystemManager.Instance == null || targetCardDefinition == null)
return;
// Guard: don't spawn if already has a card assigned
if (_assignedCard != null)
{
Logging.Debug($"[AlbumCardSlot] Slot for {targetCardDefinition.name} already has card assigned, skipping spawn");
return;
}
// Guard: need prefab to spawn
if (cardPrefab == null)
{
Logging.Warning($"[AlbumCardSlot] No cardPrefab assigned for slot targeting {targetCardDefinition.name}");
return;
}
// Check if player owns this card in COLLECTION (not pending)
CardData ownedCard = FindOwnedCardForSlot();
// Only spawn if owned (not pending)
if (ownedCard != null)
{
Logging.Debug($"[AlbumCardSlot] Found owned card for {targetCardDefinition.name}, spawning");
SpawnOwnedCard(ownedCard);
}
}
/// <summary>
/// Find owned card for this slot (checks collection only, not pending)
/// </summary>
private CardData FindOwnedCardForSlot()
{
var inventory = CardSystemManager.Instance.GetCardInventory();
// Check in order: Legendary > Rare > Normal (prioritize highest rarity)
foreach (CardRarity rarity in new[] { CardRarity.Legendary, CardRarity.Rare, CardRarity.Normal })
{
CardData card = inventory.GetCard(targetCardDefinition.Id, rarity);
if (card != null)
{
return card; // Found highest rarity owned
}
}
return null; // Not owned
}
/// <summary>
/// Spawn a card that the player already owns (for reload scenarios)
/// </summary>
private void SpawnOwnedCard(CardData cardData)
{
GameObject cardObj = Instantiate(cardPrefab, transform);
var card = cardObj.GetComponent<StateMachine.Card>();
if (card != null)
{
// Setup card for album slot (starts in PlacedInSlotState)
card.SetupForAlbumSlot(cardData, this);
// Resize the card to match the slot size
RectTransform cardRect = card.transform as RectTransform;
RectTransform slotRect = transform as RectTransform;
if (cardRect != null && slotRect != null)
{
float targetHeight = slotRect.rect.height;
cardRect.sizeDelta = new Vector2(cardRect.sizeDelta.x, targetHeight);
cardRect.localPosition = Vector3.zero;
cardRect.localRotation = Quaternion.identity;
}
// Assign card to this slot
_assignedCard = card;
// Register with AlbumViewPage for enlarge/shrink handling
AlbumViewPage albumPage = FindFirstObjectByType<AlbumViewPage>();
if (albumPage != null)
{
albumPage.RegisterCardInAlbum(card);
}
Logging.Debug($"[AlbumCardSlot] Spawned owned card '{cardData.Name}' ({cardData.Rarity}) in slot");
}
else
{
Logging.Warning($"[AlbumCardSlot] Spawned prefab has no Card component!");
Destroy(cardObj);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 514a349ba18d4842bc4292cb034f0d76
timeCreated: 1762470924

View File

@@ -0,0 +1,225 @@
using Core;
using UI.DragAndDrop.Core;
using UnityEngine;
namespace UI.CardSystem.DragDrop
{
/// <summary>
/// Booster pack specific implementation of DraggableObject.
/// Manages booster pack behavior and opening logic.
/// </summary>
public class BoosterPackDraggable : DraggableObject
{
[Header("Booster Pack Settings")]
[SerializeField] private bool canOpenOnDrop = true;
[SerializeField] private bool canOpenOnDoubleClick = true;
[Header("Tap to Open")]
[SerializeField] private bool canTapToOpen = true;
[SerializeField] private int maxTapsToOpen = 3;
[SerializeField] private float tapPulseScale = 1.15f;
[SerializeField] private float tapPulseDuration = 0.2f;
// ...existing code...
public event System.Action<BoosterPackDraggable> OnBoosterOpened;
public event System.Action<BoosterPackDraggable, int, int> OnTapped; // (booster, currentTap, maxTaps)
public event System.Action<BoosterPackDraggable> OnReadyToOpen; // Final tap reached
private bool _isOpening;
private float _lastClickTime;
private int _currentTapCount;
public bool IsOpening => _isOpening;
public int CurrentTapCount => _currentTapCount;
protected override void OnPointerUpHook(bool longPress)
{
base.OnPointerUpHook(longPress);
// Handle tap-to-open logic (only when in slot and not a long press)
if (canTapToOpen && !longPress && CurrentSlot != null)
{
_currentTapCount++;
// Pulse effect on tap (scales visual up and back down)
if (Visual != null)
{
// Calculate pulse intensity based on tap progress
float tapProgress = _currentTapCount / (float)maxTapsToOpen;
float currentPulseScale = 1f + (tapPulseScale - 1f) * (0.5f + tapProgress * 0.5f); // Increases from 1.075 to 1.15
// Save the current scale before pulsing
Vector3 baseScale = Visual.transform.localScale;
Pixelplacement.Tween.Cancel(Visual.transform.GetInstanceID());
Pixelplacement.Tween.LocalScale(Visual.transform, baseScale * currentPulseScale, tapPulseDuration * 0.5f, 0f,
Pixelplacement.Tween.EaseOutBack, completeCallback: () =>
{
// Return to the base scale we had before pulsing
Pixelplacement.Tween.LocalScale(Visual.transform, baseScale, tapPulseDuration * 0.5f, 0f, Pixelplacement.Tween.EaseInBack);
});
}
OnTapped?.Invoke(this, _currentTapCount, maxTapsToOpen);
if (_currentTapCount >= maxTapsToOpen)
{
OnReadyToOpen?.Invoke(this);
}
return; // Don't process double-click if tap-to-open is active
}
// ...existing code...
if (canOpenOnDoubleClick && !longPress)
{
float timeSinceLastClick = Time.time - _lastClickTime;
if (timeSinceLastClick < 0.3f) // Double click threshold
{
TriggerOpen();
}
_lastClickTime = Time.time;
}
}
protected override void OnDragEndedHook()
{
base.OnDragEndedHook();
// Find closest slot and assign to it (replaces removed auto-slotting from base class)
SlotContainer[] containers = FindObjectsByType<SlotContainer>(FindObjectsSortMode.None);
DraggableSlot closestSlot = null;
float closestDistance = float.MaxValue;
// Get position (handle both overlay and world space canvas)
Vector3 myPosition = (RectTransform != null &&
GetComponentInParent<Canvas>()?.renderMode == RenderMode.ScreenSpaceOverlay)
? RectTransform.position
: transform.position;
// Find closest slot among all containers
foreach (var container in containers)
{
DraggableSlot slot = container.FindClosestSlot(myPosition, this);
if (slot != null)
{
Vector3 slotPosition = slot.RectTransform != null ? slot.RectTransform.position : slot.transform.position;
float distance = Vector3.Distance(myPosition, slotPosition);
if (distance < closestDistance)
{
closestDistance = distance;
closestSlot = slot;
}
}
}
// Assign to closest slot if found
if (closestSlot != null)
{
Logging.Debug($"[BoosterPackDraggable] Drag ended, assigning to closest slot: {closestSlot.name}");
AssignToSlot(closestSlot, true);
}
else if (CurrentSlot != null)
{
// No valid slot found, return to current slot
Logging.Debug($"[BoosterPackDraggable] No valid slot found, snapping back to current slot");
AssignToSlot(CurrentSlot, true);
}
}
/// <summary>
/// Trigger the booster pack opening animation and logic
/// </summary>
public void TriggerOpen()
{
if (_isOpening)
return;
_isOpening = true;
OnBoosterOpened?.Invoke(this);
// The actual opening logic (calling CardSystemManager) should be handled
// by the UI page or controller that manages this booster pack
// Visual feedback would be handled by the BoosterPackVisual
}
/// <summary>
/// Reset the opening state
/// </summary>
public void ResetOpeningState()
{
_isOpening = false;
_currentTapCount = 0;
}
/// <summary>
/// Set whether this booster is in the opening slot (disables dragging, enables tapping)
/// </summary>
public void SetInOpeningSlot(bool inSlot)
{
Logging.Debug($"[BoosterPackDraggable] SetInOpeningSlot({inSlot}) called on {name}");
SetDraggingEnabled(!inSlot); // Disable dragging when in opening slot
Logging.Debug($"[BoosterPackDraggable] SetDraggingEnabled({!inSlot}) called");
canTapToOpen = inSlot; // Enable tap-to-open when in opening slot
if (inSlot)
{
_currentTapCount = 0; // Reset tap counter when placed
// Suppress visual effects (idle animations, glow, etc.) when in opening slot
// But allow slot tween and tap pulse to still work
if (Visual != null)
{
Visual.SuppressEffects();
// Play one-time placement tween to animate into slot
// The visual will follow the parent to its slot position, then lock in place
// Get target scale from current slot if it has scale mode
Vector3 targetScale = Vector3.one;
if (CurrentSlot != null && CurrentSlot.GetComponent<DraggableSlot>() != null)
{
// Access the slot's occupant scale if it's in Scale mode
// For now, use Vector3.one as default
targetScale = Vector3.one;
}
Visual.PlayPlacementTween(0.3f, targetScale);
}
}
else
{
ResetOpeningState(); // Reset completely when removed
// Resume visual effects when removed from opening slot
if (Visual != null)
{
Visual.StopPlacementTween(); // Stop any ongoing placement tween
Visual.ResumeEffects();
}
}
}
/// <summary>
/// Reset tap count (useful when starting a new opening sequence)
/// </summary>
public void ResetTapCount()
{
_currentTapCount = 0;
}
/// <summary>
/// Enable or disable tap-to-open functionality at runtime
/// </summary>
public void SetTapToOpenEnabled(bool enabled)
{
canTapToOpen = enabled;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f95c1542aaa549d1867b43f6dc21e90f
timeCreated: 1762420681

View File

@@ -0,0 +1,322 @@
using Pixelplacement;
using Pixelplacement.TweenSystem;
using UI.DragAndDrop.Core;
using UnityEngine;
using UnityEngine.UI;
namespace UI.CardSystem.DragDrop
{
/// <summary>
/// Visual representation for BoosterPackDraggable.
/// Displays the booster pack sprite and handles opening animations.
/// </summary>
public class BoosterPackVisual : DraggableVisual
{
[Header("Booster Pack Visual")]
[SerializeField] private Image packImage;
[SerializeField] private Sprite packSprite;
[SerializeField] private ParticleSystem glowEffect;
[SerializeField] private Transform glowTransform;
[Header("Opening Animation")]
[SerializeField] private float openingScalePunch = 0.5f;
[SerializeField] private float openingRotationPunch = 360f;
[SerializeField] private float openingDuration = 0.5f;
private BoosterPackDraggable _boosterDraggable;
// Effect tracking
private int _glowRotationTweenId = -1;
private float _defaultGlowRate = 10f;
public override void Initialize(DraggableObject parent)
{
base.Initialize(parent);
_boosterDraggable = parent as BoosterPackDraggable;
// Get pack image if not assigned
if (packImage == null)
{
packImage = GetComponentInChildren<Image>();
}
// Set initial sprite
if (packImage != null && packSprite != null)
{
packImage.sprite = packSprite;
}
// Subscribe to booster events
if (_boosterDraggable != null)
{
_boosterDraggable.OnBoosterOpened += HandleBoosterOpened;
_boosterDraggable.OnTapped += HandleTapped;
}
// Start glow effect if available
if (glowEffect != null && !glowEffect.isPlaying)
{
glowEffect.Play();
}
}
protected override void UpdateVisualContent()
{
// Update glow rotation for visual interest (skip if effects suppressed)
if (glowTransform != null && !_effectsSuppressed)
{
glowTransform.Rotate(Vector3.forward * 30f * Time.deltaTime);
}
}
private void HandleBoosterOpened(BoosterPackDraggable booster)
{
PlayOpeningAnimation();
}
private void HandleTapped(BoosterPackDraggable booster, int currentTap, int maxTaps)
{
PlayShakeAnimation(currentTap, maxTaps);
}
/// <summary>
/// Play progressive shake animation based on tap intensity
/// This is always allowed, even when effects are suppressed
/// </summary>
public void PlayShakeAnimation(int intensity, int maxIntensity)
{
float normalizedIntensity = (float)intensity / maxIntensity;
float shakeAmount = Mathf.Lerp(5f, 30f, normalizedIntensity);
float shakeDuration = 0.15f;
// Shake rotation
Vector3 shakeRotation = new Vector3(
Random.Range(-shakeAmount, shakeAmount),
Random.Range(-shakeAmount, shakeAmount),
Random.Range(-shakeAmount, shakeAmount)
);
Tween.Rotation(transform, transform.eulerAngles + shakeRotation,
shakeDuration, 0f, Tween.EaseOutBack,
completeCallback: () => {
Tween.Rotation(transform, Vector3.zero,
shakeDuration, 0f, Tween.EaseInBack);
});
// NOTE: Scale pulse is handled by BoosterPackDraggable.OnPointerUpHook()
// We don't need a duplicate scale tween here - it would conflict!
// Extra glow burst on final tap (only if effects not suppressed)
if (intensity == maxIntensity && glowEffect != null && !_effectsSuppressed)
{
var emission = glowEffect.emission;
emission.rateOverTimeMultiplier = 50f;
}
}
/// <summary>
/// Play the booster pack opening animation
/// </summary>
public void PlayOpeningAnimation()
{
// Scale punch
Vector3 targetScale = transform.localScale * (1f + openingScalePunch);
Tween.LocalScale(transform, targetScale, openingDuration / 2f, 0f, Tween.EaseOutBack,
completeCallback: () => {
Tween.LocalScale(transform, Vector3.one, openingDuration / 2f, 0f, Tween.EaseInBack);
});
// Rotation
Tween.Rotation(transform, transform.eulerAngles + Vector3.forward * openingRotationPunch,
openingDuration, 0f, Tween.EaseOutBack);
// Glow burst
if (glowEffect != null)
{
glowEffect.Stop();
glowEffect.Play();
}
}
/// <summary>
/// Set the booster pack sprite
/// </summary>
public void SetPackSprite(Sprite sprite)
{
packSprite = sprite;
if (packImage != null)
{
packImage.sprite = packSprite;
}
}
protected override void OnPointerEnterVisual()
{
base.OnPointerEnterVisual();
// Extra glow when hovering (skip if effects suppressed)
if (glowEffect != null && !_effectsSuppressed)
{
var emission = glowEffect.emission;
emission.rateOverTimeMultiplier = 20f;
}
}
protected override void OnPointerExitVisual()
{
base.OnPointerExitVisual();
// Restore normal glow (skip if effects suppressed)
if (glowEffect != null && !_effectsSuppressed)
{
var emission = glowEffect.emission;
emission.rateOverTimeMultiplier = _defaultGlowRate;
}
}
#region Effect Management Overrides
protected override void OnEffectsSuppressed()
{
base.OnEffectsSuppressed();
// Stop glow effect
if (glowEffect != null)
{
glowEffect.Stop();
}
// Cancel glow rotation tween if tracked
if (_glowRotationTweenId != -1)
{
Tween.Stop(_glowRotationTweenId);
_glowRotationTweenId = -1;
}
// Reset glow rotation to zero
if (glowTransform != null)
{
glowTransform.localRotation = Quaternion.identity;
}
}
protected override void OnEffectsResumed()
{
base.OnEffectsResumed();
// Resume glow effect
if (glowEffect != null && !glowEffect.isPlaying)
{
glowEffect.Play();
}
}
protected override void OnEffectsReset()
{
base.OnEffectsReset();
// Stop glow effect
if (glowEffect != null)
{
glowEffect.Stop();
}
// Reset glow rotation
if (glowTransform != null)
{
glowTransform.localRotation = Quaternion.identity;
}
// NOTE: Tap pulse scale is handled by BoosterPackDraggable, not here
}
#endregion
#region Event Handler Overrides (Block when in Opening Slot)
protected override void HandleDragStarted(DraggableObject draggable)
{
// Don't call base if effects are suppressed (in opening slot)
if (_effectsSuppressed)
return;
base.HandleDragStarted(draggable);
}
protected override void HandleDragEnded(DraggableObject draggable)
{
// ALWAYS reset canvas sorting, even when effects are suppressed
if (canvas != null)
{
canvas.overrideSorting = false;
}
// Skip other drag end effects if suppressed (in opening slot)
if (_effectsSuppressed)
return;
base.HandleDragEnded(draggable);
}
protected override void HandlePointerEnter(DraggableObject draggable)
{
// Don't call base if effects are suppressed (in opening slot)
if (_effectsSuppressed)
return;
base.HandlePointerEnter(draggable);
}
protected override void HandlePointerExit(DraggableObject draggable)
{
// Don't call base if effects are suppressed (in opening slot)
if (_effectsSuppressed)
return;
base.HandlePointerExit(draggable);
}
protected override void HandlePointerDown(DraggableObject draggable)
{
// Don't call base if effects are suppressed (in opening slot)
// This allows the BoosterPackDraggable to handle tap pulse itself
if (_effectsSuppressed)
return;
base.HandlePointerDown(draggable);
}
protected override void HandlePointerUp(DraggableObject draggable, bool longPress)
{
// Don't call base if effects are suppressed (in opening slot)
// This allows the BoosterPackDraggable to handle tap pulse itself
if (_effectsSuppressed)
return;
base.HandlePointerUp(draggable, longPress);
}
protected override void HandleSelection(DraggableObject draggable, bool selected)
{
// Don't call base if effects are suppressed (in opening slot)
if (_effectsSuppressed)
return;
base.HandleSelection(draggable, selected);
}
#endregion
protected override void OnDestroy()
{
base.OnDestroy();
if (_boosterDraggable != null)
{
_boosterDraggable.OnBoosterOpened -= HandleBoosterOpened;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a7d9474ece3b4d2ebad19ae178b22f4d
timeCreated: 1762420699

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 80f8dd01edcd4742b3edbb5c7fcecd12
timeCreated: 1762884650

View File

@@ -0,0 +1,51 @@
using System;
namespace UI.CardSystem.StateMachine
{
/// <summary>
/// Booster-specific card context for reveal flow coordination.
/// Separated from generic CardContext to maintain single responsibility.
/// </summary>
public class BoosterCardContext
{
// Booster reveal workflow state
private bool _hasCompletedReveal = false;
/// <summary>
/// Has this card completed its booster reveal flow?
/// </summary>
public bool HasCompletedReveal => _hasCompletedReveal;
/// <summary>
/// Suppress NEW/REPEAT badges in revealed state
/// </summary>
public bool SuppressRevealBadges { get; set; } = false;
/// <summary>
/// Event fired when reveal flow is complete (card dismissed)
/// </summary>
public event Action OnRevealFlowComplete;
/// <summary>
/// Signal that this card has completed its reveal flow
/// </summary>
public void NotifyRevealComplete()
{
if (!_hasCompletedReveal)
{
_hasCompletedReveal = true;
OnRevealFlowComplete?.Invoke();
}
}
/// <summary>
/// Reset reveal state (for card reuse/pooling)
/// </summary>
public void Reset()
{
_hasCompletedReveal = false;
SuppressRevealBadges = false;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 484a792a835a418bb690947b82e45da7
timeCreated: 1763421968

View File

@@ -0,0 +1,406 @@
using Pixelplacement;
using Pixelplacement.TweenSystem;
using UnityEngine;
using System;
using AppleHills.Core.Settings;
using Core;
namespace UI.CardSystem.StateMachine
{
/// <summary>
/// Handles common card animations that can be reused across states.
/// Centralizes animation logic to avoid duplication.
/// Animates the CARD ROOT TRANSFORM (all states follow the card).
/// </summary>
public class CardAnimator : MonoBehaviour
{
private Transform _transform;
private RectTransform _rectTransform;
private ICardSystemSettings _settings;
private TweenBase _activeIdleHoverTween;
private void Awake()
{
_transform = transform;
_rectTransform = GetComponent<RectTransform>();
_settings = GameManager.GetSettingsObject<ICardSystemSettings>();
}
#region Scale Animations
/// <summary>
/// Animate scale to target value
/// </summary>
public TweenBase AnimateScale(Vector3 targetScale, float? duration = null, Action onComplete = null)
{
return Tween.LocalScale(_transform, targetScale, duration ?? _settings.DefaultAnimationDuration, 0f,
Tween.EaseInOut, completeCallback: onComplete);
}
/// <summary>
/// Pulse scale animation (scale up then back to normal)
/// </summary>
public void PulseScale(float pulseAmount = 1.1f, float duration = 0.2f, Action onComplete = null)
{
Vector3 originalScale = _transform.localScale;
Vector3 pulseScale = originalScale * pulseAmount;
Tween.LocalScale(_transform, pulseScale, duration, 0f, Tween.EaseOutBack,
completeCallback: () =>
{
Tween.LocalScale(_transform, originalScale, duration, 0f, Tween.EaseInBack,
completeCallback: onComplete);
});
}
/// <summary>
/// Pop-in animation (scale from 0 to 1 with overshoot)
/// </summary>
public TweenBase PopIn(float duration = 0.5f, Action onComplete = null)
{
_transform.localScale = Vector3.zero;
return Tween.LocalScale(_transform, Vector3.one, duration, 0f,
Tween.EaseOutBack, completeCallback: onComplete);
}
/// <summary>
/// Pop-out animation (scale from current to 0)
/// </summary>
public TweenBase PopOut(float duration = 0.3f, Action onComplete = null)
{
return Tween.LocalScale(_transform, Vector3.zero, duration, 0f,
Tween.EaseInBack, completeCallback: onComplete);
}
#endregion
#region Position Animations (RectTransform)
/// <summary>
/// Animate anchored position (for UI elements)
/// </summary>
public TweenBase AnimateAnchoredPosition(Vector2 targetPosition, float? duration = null, Action onComplete = null)
{
if (_rectTransform == null)
{
Debug.LogWarning("CardAnimator: No RectTransform found for anchored position animation");
return null;
}
return Tween.AnchoredPosition(_rectTransform, targetPosition, duration ?? _settings.DefaultAnimationDuration, 0f,
Tween.EaseInOut, completeCallback: onComplete);
}
/// <summary>
/// Animate local position
/// </summary>
public TweenBase AnimateLocalPosition(Vector3 targetPosition, float? duration = null, Action onComplete = null)
{
return Tween.LocalPosition(_transform, targetPosition, duration ?? _settings.DefaultAnimationDuration, 0f,
Tween.EaseInOut, completeCallback: onComplete);
}
#endregion
#region Rotation Animations
/// <summary>
/// Animate local rotation to target
/// </summary>
public TweenBase AnimateLocalRotation(Quaternion targetRotation, float? duration = null, Action onComplete = null)
{
return Tween.LocalRotation(_transform, targetRotation, duration ?? _settings.DefaultAnimationDuration, 0f,
Tween.EaseInOut, completeCallback: onComplete);
}
/// <summary>
/// Rotate a child object (typically used by states for CardBackVisual, etc.)
/// </summary>
public TweenBase AnimateChildRotation(Transform childTransform, Quaternion targetRotation,
float duration, Action onComplete = null)
{
return Tween.LocalRotation(childTransform, targetRotation, duration, 0f,
Tween.EaseInOut, completeCallback: onComplete);
}
#endregion
#region Flip Animations
/// <summary>
/// Play card flip animation - rotates card back from 0° to 90°, then card front from 180° to 0°
/// Based on FlippableCard.FlipToReveal()
/// </summary>
public void PlayFlip(Transform cardBack, Transform cardFront, float? duration = null, Action onComplete = null)
{
float flipDuration = duration ?? _settings.FlipDuration;
// Phase 1: Rotate both to 90 degrees (edge view)
if (cardBack != null)
{
Tween.LocalRotation(cardBack, Quaternion.Euler(0, 90, 0), flipDuration * 0.5f, 0f, Tween.EaseInOut);
}
if (cardFront != null)
{
Tween.LocalRotation(cardFront, Quaternion.Euler(0, 90, 0), flipDuration * 0.5f, 0f, Tween.EaseInOut,
completeCallback: () =>
{
// At edge (90°), switch visibility
if (cardBack != null)
cardBack.gameObject.SetActive(false);
if (cardFront != null)
cardFront.gameObject.SetActive(true);
// Phase 2: Rotate front from 90 to 0 (show at correct orientation)
Tween.LocalRotation(cardFront, Quaternion.Euler(0, 0, 0), flipDuration * 0.5f, 0f, Tween.EaseInOut,
completeCallback: onComplete);
});
}
}
/// <summary>
/// Play scale punch during flip animation for extra juice
/// Based on FlippableCard.FlipToReveal()
/// </summary>
public void PlayFlipScalePunch(float? punchScale = null, float? duration = null)
{
float punch = punchScale ?? _settings.FlipScalePunch;
float flipDuration = duration ?? _settings.FlipDuration;
Vector3 originalScale = _transform.localScale;
Tween.LocalScale(_transform, originalScale * punch, flipDuration * 0.5f, 0f, Tween.EaseOutBack,
completeCallback: () =>
{
Tween.LocalScale(_transform, originalScale, flipDuration * 0.5f, 0f, Tween.EaseInBack);
});
}
#endregion
#region Enlarge/Shrink Animations
/// <summary>
/// Enlarge card to specified scale
/// Based on FlippableCard.EnlargeCard() and AlbumCard.EnlargeCard()
/// </summary>
public void PlayEnlarge(float? targetScale = null, float? duration = null, Action onComplete = null)
{
float scale = targetScale ?? _settings.NewCardEnlargedScale;
float scaleDuration = duration ?? _settings.ScaleDuration;
Tween.LocalScale(_transform, Vector3.one * scale, scaleDuration, 0f, Tween.EaseOutBack,
completeCallback: onComplete);
}
/// <summary>
/// Shrink card back to original scale
/// Based on AlbumCard.ShrinkCard() and FlippableCard.ReturnToNormalSize()
/// </summary>
public void PlayShrink(Vector3 targetScale, float? duration = null, Action onComplete = null)
{
float scaleDuration = duration ?? _settings.ScaleDuration;
Tween.LocalScale(_transform, targetScale, scaleDuration, 0f, Tween.EaseInBack,
completeCallback: onComplete);
}
#endregion
#region Combined Animations
/// <summary>
/// Hover enter animation (lift and scale)
/// For RectTransform UI elements
/// </summary>
public void HoverEnter(float liftAmount = 20f, float scaleMultiplier = 1.05f,
float duration = 0.2f, Action onComplete = null)
{
if (_rectTransform != null)
{
Vector2 currentPos = _rectTransform.anchoredPosition;
Vector2 targetPos = currentPos + Vector2.up * liftAmount;
Tween.AnchoredPosition(_rectTransform, targetPos, duration, 0f, Tween.EaseOutBack);
Tween.LocalScale(_transform, Vector3.one * scaleMultiplier, duration, 0f,
Tween.EaseOutBack, completeCallback: onComplete);
}
else
{
// Fallback for non-RectTransform
Vector3 currentPos = _transform.localPosition;
Vector3 targetPos = currentPos + Vector3.up * liftAmount;
Tween.LocalPosition(_transform, targetPos, duration, 0f, Tween.EaseOutBack);
Tween.LocalScale(_transform, Vector3.one * scaleMultiplier, duration, 0f,
Tween.EaseOutBack, completeCallback: onComplete);
}
}
/// <summary>
/// Hover exit animation (return to original position and scale)
/// </summary>
public void HoverExit(Vector2 originalPosition, float duration = 0.2f, Action onComplete = null)
{
if (_rectTransform != null)
{
Tween.AnchoredPosition(_rectTransform, originalPosition, duration, 0f, Tween.EaseInBack);
Tween.LocalScale(_transform, Vector3.one, duration, 0f,
Tween.EaseInBack, completeCallback: onComplete);
}
else
{
Tween.LocalPosition(_transform, originalPosition, duration, 0f, Tween.EaseInBack);
Tween.LocalScale(_transform, Vector3.one, duration, 0f,
Tween.EaseInBack, completeCallback: onComplete);
}
}
/// <summary>
/// Idle hover animation (gentle bobbing loop)
/// Returns the TweenBase so caller can stop it later.
/// Only starts if not already running, or kills and restarts.
/// </summary>
public TweenBase StartIdleHover(float hoverHeight = 10f, float duration = 1.5f, bool restartIfActive = false)
{
// If already running, either skip or restart
if (_activeIdleHoverTween != null)
{
if (!restartIfActive)
{
// Already running, skip
return _activeIdleHoverTween;
}
// Kill existing and restart
_activeIdleHoverTween.Stop();
_activeIdleHoverTween = null;
}
if (_rectTransform != null)
{
Vector2 originalPos = _rectTransform.anchoredPosition;
Vector2 targetPos = originalPos + Vector2.up * hoverHeight;
_activeIdleHoverTween = Tween.Value(0f, 1f,
(val) =>
{
if (_rectTransform != null)
{
float t = Mathf.Sin(val * Mathf.PI * 2f) * 0.5f + 0.5f;
_rectTransform.anchoredPosition = Vector2.Lerp(originalPos, targetPos, t);
}
},
duration, 0f, Tween.EaseInOut, Tween.LoopType.Loop);
return _activeIdleHoverTween;
}
return null;
}
/// <summary>
/// Stop idle hover animation and return to original position
/// </summary>
public void StopIdleHover(Vector2 originalPosition, float duration = 0.3f)
{
// Stop the tracked tween if it exists
if (_activeIdleHoverTween != null)
{
_activeIdleHoverTween.Stop();
_activeIdleHoverTween = null;
}
// Return to original position
if (_rectTransform != null)
{
Tween.AnchoredPosition(_rectTransform, originalPosition, duration, 0f, Tween.EaseInOut);
}
}
#endregion
#region Flip Animations (Two-Phase)
/// <summary>
/// Flip animation: Phase 1 - Rotate card back to edge (0° to 90°)
/// Used by FlippingState to hide the back
/// </summary>
public void FlipPhase1_HideBack(Transform cardBackTransform, float duration, Action onHalfwayComplete)
{
Tween.LocalRotation(cardBackTransform, Quaternion.Euler(0, 90, 0), duration, 0f,
Tween.EaseInOut, completeCallback: onHalfwayComplete);
}
/// <summary>
/// Flip animation: Phase 2 - Rotate card front from back to face (180° to 90° to 0°)
/// Used by FlippingState to reveal the front
/// </summary>
public void FlipPhase2_RevealFront(Transform cardFrontTransform, float duration, Action onComplete)
{
// First rotate from 180 to 90 (edge)
Tween.LocalRotation(cardFrontTransform, Quaternion.Euler(0, 90, 0), duration, 0f,
Tween.EaseInOut,
completeCallback: () =>
{
// Then rotate from 90 to 0 (face)
Tween.LocalRotation(cardFrontTransform, Quaternion.Euler(0, 0, 0), duration, 0f,
Tween.EaseInOut, completeCallback: onComplete);
});
}
/// <summary>
/// Scale punch during flip (makes flip more juicy)
/// </summary>
public void FlipScalePunch(float punchMultiplier = 1.1f, float totalDuration = 0.6f)
{
Vector3 originalScale = _transform.localScale;
Vector3 punchScale = originalScale * punchMultiplier;
Tween.LocalScale(_transform, punchScale, totalDuration * 0.5f, 0f, Tween.EaseOutBack,
completeCallback: () =>
{
Tween.LocalScale(_transform, originalScale, totalDuration * 0.5f, 0f, Tween.EaseInBack);
});
}
#endregion
#region Utility
/// <summary>
/// Stop all active tweens on this transform
/// </summary>
public void StopAllAnimations()
{
Tween.Stop(_transform.GetInstanceID());
if (_rectTransform != null)
Tween.Stop(_rectTransform.GetInstanceID());
}
/// <summary>
/// Reset transform to default values
/// </summary>
public void ResetTransform()
{
StopAllAnimations();
_transform.localPosition = Vector3.zero;
_transform.localRotation = Quaternion.identity;
_transform.localScale = Vector3.one;
if (_rectTransform != null)
_rectTransform.anchoredPosition = Vector2.zero;
}
/// <summary>
/// Get current anchored position (useful for saving before hover)
/// </summary>
public Vector2 GetAnchoredPosition()
{
return _rectTransform != null ? _rectTransform.anchoredPosition : Vector2.zero;
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5eacab725f4346d091696042b9cd2a82
timeCreated: 1762887143

View File

@@ -0,0 +1,19 @@
using Core.SaveLoad;
namespace UI.CardSystem.StateMachine
{
/// <summary>
/// Card state machine that opts out of save system.
/// Cards are transient UI elements that don't need persistence.
/// </summary>
public class CardStateMachine : AppleMachine
{
/// <summary>
/// Opt out of save/load system - cards are transient and spawned from data.
/// </summary>
public override bool ShouldParticipateInSave()
{
return false;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 87ed5616041a4d878f452a8741e1eeab
timeCreated: 1763385180

View File

@@ -0,0 +1,26 @@
namespace UI.CardSystem.StateMachine
{
/// <summary>
/// Centralized string constants for card state names.
/// Prevents typos and provides compile-time checking for state transitions.
/// </summary>
public static class CardStateNames
{
// Booster Flow States
public const string Idle = "IdleState";
public const string EnlargedNew = "EnlargedNewState";
public const string EnlargedRepeat = "EnlargedRepeatState";
public const string EnlargedLegendaryRepeat = "EnlargedLegendaryRepeatState";
public const string Revealed = "RevealedState";
// Album Placement Flow States
public const string PendingFaceDown = "PendingFaceDownState";
public const string DraggingRevealed = "DraggingRevealedState";
public const string PlacedInSlot = "PlacedInSlotState";
public const string AlbumEnlarged = "AlbumEnlargedState";
// Generic Drag State
public const string Dragging = "DraggingState";
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: de659f0ceb6f4ef6bab333d5f7de00ec
timeCreated: 1763421948

View File

@@ -0,0 +1,11 @@
namespace UI.CardSystem.StateMachine
{
/// <summary>
/// Implement on a state component to receive routed click events
/// from CardContext/CardDisplay.
/// </summary>
public interface ICardClickHandler
{
void OnCardClicked(CardContext context);
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fadf99afe6cc4785a6f45a47b4463923
timeCreated: 1763307472

View File

@@ -0,0 +1,20 @@
namespace UI.CardSystem.StateMachine
{
/// <summary>
/// Implement on a state component to receive routed drag events from Card.
/// Similar to ICardClickHandler but for drag behavior.
/// </summary>
public interface ICardStateDragHandler
{
/// <summary>
/// Called when drag starts. Return true to handle drag (prevent default DraggingState transition).
/// </summary>
bool OnCardDragStarted(CardContext context);
/// <summary>
/// Called when drag ends. Return true to handle drag end (prevent default state transitions).
/// </summary>
bool OnCardDragEnded(CardContext context);
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fc610b791f43409e8231085a70514e2c
timeCreated: 1763374419

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 43f3b0a00e934598a6a58abad11930a4
timeCreated: 1762884650

View File

@@ -0,0 +1,126 @@
using Core;
using Core.SaveLoad;
using UnityEngine;
using AppleHills.Core.Settings;
using Pixelplacement;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Album enlarged state - card is enlarged when clicked from album slot.
/// Different from EnlargedNewState as it doesn't show "NEW" badge.
/// </summary>
public class CardAlbumEnlargedState : AppleState, ICardClickHandler
{
private CardContext _context;
private ICardSystemSettings _settings;
private Vector3 _originalScale;
private Transform _originalParent;
private Vector3 _originalLocalPosition;
private Quaternion _originalLocalRotation;
private Vector3 _originalWorldPosition;
// Events for page to manage backdrop and reparenting
public event System.Action<CardAlbumEnlargedState> OnEnlargeRequested;
public event System.Action<CardAlbumEnlargedState> OnShrinkRequested;
private void Awake()
{
_context = GetComponentInParent<CardContext>();
_settings = GameManager.GetSettingsObject<ICardSystemSettings>();
}
public override void OnEnterState()
{
// Ensure card front is visible and facing camera
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
// Store original transform for restoration
_originalScale = _context.RootTransform.localScale;
_originalParent = _context.RootTransform.parent;
_originalLocalPosition = _context.RootTransform.localPosition;
_originalLocalRotation = _context.RootTransform.localRotation;
_originalWorldPosition = _context.RootTransform.position;
// Notify page to show backdrop and reparent card to top layer (preserve world transform)
OnEnlargeRequested?.Invoke(this);
// Animate into center and scale up significantly
if (_context.Animator != null)
{
// Move to center of enlarged container (assumes zero is centered)
_context.Animator.AnimateLocalPosition(Vector3.zero, _settings.ScaleDuration);
// Enlarge using settings-controlled scale
_context.Animator.PlayEnlarge(_settings.AlbumCardEnlargedScale, _settings.ScaleDuration);
}
Logging.Debug($"[CardAlbumEnlargedState] Card enlarged from album: {_context.CardData?.Name}");
}
public void OnCardClicked(CardContext context)
{
// Click to shrink back (play reverse of opening animation)
Logging.Debug($"[CardAlbumEnlargedState] Card clicked while enlarged, shrinking back");
// Ask page to hide backdrop (state will handle moving back & reparenting)
OnShrinkRequested?.Invoke(this);
// Animate back to original world position + shrink to original scale
float duration = _settings.ScaleDuration;
// Move using world-space tween so we land exactly on the original position
Tween.Position(context.RootTransform, _originalWorldPosition, duration, 0f, Tween.EaseInOut);
if (context.Animator != null)
{
context.Animator.PlayShrink(_originalScale, duration, onComplete: () =>
{
// Reparent back to original hierarchy and restore local transform
context.RootTransform.SetParent(_originalParent, true);
context.RootTransform.localPosition = _originalLocalPosition;
context.RootTransform.localRotation = _originalLocalRotation;
// Transition back to placed state
context.StateMachine.ChangeState(CardStateNames.PlacedInSlot);
});
}
else
{
// Fallback if no animator: snap back
context.RootTransform.position = _originalWorldPosition;
context.RootTransform.SetParent(_originalParent, true);
context.RootTransform.localPosition = _originalLocalPosition;
context.RootTransform.localRotation = _originalLocalRotation;
context.StateMachine.ChangeState(CardStateNames.PlacedInSlot);
}
}
/// <summary>
/// Get original parent for restoration
/// </summary>
public Transform GetOriginalParent() => _originalParent;
/// <summary>
/// Get original local position for restoration
/// </summary>
public Vector3 GetOriginalLocalPosition() => _originalLocalPosition;
/// <summary>
/// Get original local rotation for restoration
/// </summary>
public Quaternion GetOriginalLocalRotation() => _originalLocalRotation;
private void OnDisable()
{
// Restore original scale when exiting
if (_context?.RootTransform != null)
{
_context.RootTransform.localScale = _originalScale;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5f33526d9bb8458d8dc5ba41a88561da
timeCreated: 1762884900

View File

@@ -0,0 +1,169 @@
using Core;
using Core.SaveLoad;
using UnityEngine;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Dragging revealed state for pending cards after flip.
/// Shows card front without badges, handles transition to placement after drag release.
/// Queries AlbumViewPage for page flip status instead of tracking state internally.
/// </summary>
public class CardDraggingRevealedState : AppleState, ICardStateDragHandler
{
private CardContext _context;
private Vector3 _originalScale;
// Placement info passed from PendingFaceDownState
private AlbumCardSlot _targetSlot;
private bool _dragAlreadyEnded = false; // Track if drag ended before we entered this state (instant-release case)
private void Awake()
{
_context = GetComponentInParent<CardContext>();
}
/// <summary>
/// Set target slot from previous state
/// Called by PendingFaceDownState before transition
/// </summary>
public void SetTargetSlot(AlbumCardSlot targetSlot)
{
_targetSlot = targetSlot;
}
/// <summary>
/// Set flag indicating drag already ended before entering this state
/// Called by PendingFaceDownState for instant-release case
/// </summary>
public void SetDragAlreadyEnded(bool ended)
{
_dragAlreadyEnded = ended;
}
public override void OnEnterState()
{
if (_context == null) return;
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0,0,0);
}
_originalScale = _context.RootTransform.localScale;
_context.RootTransform.localScale = _originalScale * 1.15f;
// Check if drag already ended before we entered this state (instant-release case)
if (_dragAlreadyEnded)
{
Logging.Debug("[CardDraggingRevealedState] Drag ended before state entry - handling placement immediately");
_dragAlreadyEnded = false; // Clear flag
HandlePlacement();
}
}
/// <summary>
/// Already in dragging state, nothing to do
/// </summary>
public bool OnCardDragStarted(CardContext ctx)
{
return true; // Prevent default DraggingState transition
}
/// <summary>
/// Handle drag end - query AlbumViewPage for page flip status and place accordingly
/// </summary>
public bool OnCardDragEnded(CardContext ctx)
{
HandlePlacement();
return true;
}
/// <summary>
/// Handle card placement logic - called from OnCardDragEnded or OnEnterState (instant-release)
/// </summary>
private void HandlePlacement()
{
if (_targetSlot == null)
{
Logging.Warning("[CardDraggingRevealedState] No target slot set - cannot place card");
// Return to corner
_context.StateMachine.ChangeState(CardStateNames.PendingFaceDown);
return;
}
// Query AlbumViewPage for page flip status
var albumPage = _context.AlbumViewPage;
if (albumPage == null)
{
Logging.Warning("[CardDraggingRevealedState] AlbumViewPage not injected - placing immediately");
TransitionToPlacement(_context);
return;
}
// Check if page is still flipping
if (albumPage.IsPageFlipping)
{
// Wait for flip to complete
Logging.Debug("[CardDraggingRevealedState] Page still flipping - waiting before placement");
StartCoroutine(WaitForPageFlipThenPlace(_context, albumPage));
}
else
{
// Flip already done - place immediately
Logging.Debug("[CardDraggingRevealedState] Page flip complete - placing card immediately");
TransitionToPlacement(_context);
}
}
private System.Collections.IEnumerator WaitForPageFlipThenPlace(CardContext ctx, AlbumViewPage albumPage)
{
// Wait until page flip completes (max 0.5 seconds timeout)
float timeout = 0.5f;
float elapsed = 0f;
while (albumPage.IsPageFlipping && elapsed < timeout)
{
yield return null;
elapsed += Time.deltaTime;
}
if (elapsed >= timeout)
{
Logging.Warning("[CardDraggingRevealedState] Page flip wait timed out");
}
else
{
Logging.Debug("[CardDraggingRevealedState] Page flip completed, placing card");
}
// Now place the card
TransitionToPlacement(ctx);
}
private void TransitionToPlacement(CardContext ctx)
{
// Pass target slot to PlacedInSlotState
var card = ctx.GetComponent<Card>();
if (card != null)
{
var placedState = card.GetStateComponent<CardPlacedInSlotState>(CardStateNames.PlacedInSlot);
if (placedState != null)
{
placedState.SetPlacementInfo(_targetSlot);
}
}
// Transition to PlacedInSlotState
// The state will handle animation and finalization in OnEnterState
ctx.StateMachine.ChangeState(CardStateNames.PlacedInSlot);
}
private void OnDisable()
{
if (_context?.RootTransform != null)
{
_context.RootTransform.localScale = _originalScale;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ce2483293cdd4680b5095afc1fcb2fde
timeCreated: 1763322199

View File

@@ -0,0 +1,54 @@
using Core.SaveLoad;
using UnityEngine;
using Core;
using AppleHills.Core.Settings;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Dragging state - provides visual feedback when card is being dragged.
/// The actual drag logic is handled by Card.cs (inherits from DraggableObject).
/// This state only manages visual scaling during drag.
/// </summary>
public class CardDraggingState : AppleState
{
private CardContext _context;
private ICardSystemSettings _settings;
private Vector3 _originalScale;
private void Awake()
{
_context = GetComponentInParent<CardContext>();
_settings = GameManager.GetSettingsObject<ICardSystemSettings>();
}
public override void OnEnterState()
{
// Ensure card front is visible and facing camera (in case we transitioned from an unexpected state)
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
// Store original scale
_originalScale = _context.RootTransform.localScale;
// Scale up slightly during drag for visual feedback
// DraggableObject handles actual position updates
_context.RootTransform.localScale = _originalScale * _settings.DragScale;
Logging.Debug($"[CardDraggingState] Entered drag state for card: {_context.CardData?.Name}, scale: {_settings.DragScale}");
}
private void OnDisable()
{
// Restore original scale when exiting drag
if (_context?.RootTransform != null)
{
_context.RootTransform.localScale = _originalScale;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b17e4e1d7139446c9c4e0a813067331c
timeCreated: 1762884899

View File

@@ -0,0 +1,53 @@
// filepath: Assets/Scripts/UI/CardSystem/StateMachine/States/CardEnlargedLegendaryRepeatState.cs
using Core;
using Core.SaveLoad;
using UnityEngine;
using AppleHills.Core.Settings;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Enlarged state specifically for Legendary rarity presentation after an upgrade.
/// Shows the legendary card enlarged and awaits a click to shrink back to revealed state.
/// </summary>
public class CardEnlargedLegendaryRepeatState : AppleState, ICardClickHandler
{
private CardContext _context;
private void Awake()
{
_context = GetComponentInParent<CardContext>();
}
public override void OnEnterState()
{
// Ensure card front is visible and facing camera
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
// Card is already enlarged from EnlargedRepeatState, so no need to enlarge again
// Just await click to dismiss
Logging.Debug($"[CardEnlargedLegendaryRepeatState] Legendary card enlarged: {_context.CardData?.Name}");
}
public void OnCardClicked(CardContext context)
{
// Click to shrink to original scale and go to revealed state
if (context.Animator != null)
{
context.Animator.PlayShrink(context.OriginalScale, onComplete: () =>
{
context.StateMachine.ChangeState("RevealedState");
});
}
else
{
context.StateMachine.ChangeState("RevealedState");
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 874e5574663a48b8a4feb3192821679a
timeCreated: 1763319614

View File

@@ -0,0 +1,80 @@
using Core.SaveLoad;
using UnityEngine;
using AppleHills.Core.Settings;
using Core;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Enlarged state for NEW cards - shows "NEW CARD" badge and waits for tap to dismiss.
/// Owns the NewCardBadge as a child GameObject.
/// </summary>
public class CardEnlargedNewState : AppleState, ICardClickHandler
{
[Header("State-Owned Visuals")]
[SerializeField] private GameObject newCardBadge;
private CardContext _context;
private ICardSystemSettings _settings;
private void Awake()
{
_context = GetComponentInParent<CardContext>();
_settings = GameManager.GetSettingsObject<ICardSystemSettings>();
}
public override void OnEnterState()
{
// Ensure card front is visible and facing camera
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
// Check if we're already enlarged (coming from upgrade flow)
bool alreadyEnlarged = _context.RootTransform.localScale.x >= _settings.NewCardEnlargedScale * 0.9f;
if (!alreadyEnlarged)
{
// Normal flow - enlarge the card
if (_context.Animator != null)
{
_context.Animator.PlayEnlarge(_settings.NewCardEnlargedScale);
}
}
// Show NEW badge
if (newCardBadge != null)
{
newCardBadge.SetActive(true);
}
}
public void OnCardClicked(CardContext context)
{
// Tap to dismiss - shrink back to original scale and transition to revealed state
if (context.Animator != null)
{
context.Animator.PlayShrink(context.OriginalScale, onComplete: () =>
{
context.StateMachine.ChangeState(CardStateNames.Revealed);
});
}
else
{
// Fallback if no animator
context.StateMachine.ChangeState(CardStateNames.Revealed);
}
}
private void OnDisable()
{
// Hide NEW badge when leaving state
if (newCardBadge != null)
{
newCardBadge.SetActive(false);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 698741a53f314b598af359a81d914ed3
timeCreated: 1762884651

View File

@@ -0,0 +1,217 @@
using Core.SaveLoad;
using UnityEngine;
using AppleHills.Core.Settings;
using Core;
using AppleHills.Data.CardSystem;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Enlarged state for REPEAT cards - shows progress bar toward next rarity upgrade.
/// Uses ProgressBarController to animate progress filling.
/// Auto-upgrades card when threshold is reached.
/// </summary>
public class CardEnlargedRepeatState : AppleState, ICardClickHandler
{
[Header("State-Owned Visuals")]
[SerializeField] private ProgressBarController progressBar;
private CardContext _context;
private ICardSystemSettings _settings;
private bool _waitingForTap = false;
private void Awake()
{
_context = GetComponentInParent<CardContext>();
_settings = GameManager.GetSettingsObject<ICardSystemSettings>();
}
public override void OnEnterState()
{
// Ensure card front is visible and facing camera
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
_waitingForTap = false;
// Query current collection state for this card
bool isNew = Data.CardSystem.CardSystemManager.Instance.IsCardNew(_context.CardData, out CardData existingCard);
int currentOwnedCount = (existingCard != null) ? existingCard.CopiesOwned : 0;
// Show progress bar
if (progressBar != null)
{
progressBar.gameObject.SetActive(true);
int currentCount = currentOwnedCount + 1; // +1 because we just got this card
int maxCount = _settings.CardsToUpgrade;
progressBar.ShowProgress(currentCount, maxCount, OnProgressComplete);
}
else
{
Logging.Warning("[CardEnlargedRepeatState] ProgressBar component not assigned!");
OnProgressComplete();
}
// Enlarge the card
if (_context.Animator != null)
{
_context.Animator.PlayEnlarge(_settings.NewCardEnlargedScale);
}
}
private void OnProgressComplete()
{
// Query current state again to determine if upgrade is triggered
Data.CardSystem.CardSystemManager.Instance.IsCardNew(_context.CardData, out CardData existingCard);
int currentOwnedCount = (existingCard != null) ? existingCard.CopiesOwned : 0;
int countWithThisCard = currentOwnedCount + 1;
bool willUpgrade = (_context.CardData.Rarity < AppleHills.Data.CardSystem.CardRarity.Legendary) &&
(countWithThisCard >= _settings.CardsToUpgrade);
if (willUpgrade)
{
Logging.Debug($"[CardEnlargedRepeatState] Card will trigger upgrade! ({countWithThisCard}/{_settings.CardsToUpgrade})");
TriggerUpgrade();
}
else
{
// No upgrade - just wait for tap to dismiss
Logging.Debug($"[CardEnlargedRepeatState] Progress shown ({countWithThisCard}/{_settings.CardsToUpgrade}), waiting for tap to dismiss");
_waitingForTap = true;
}
}
private void TriggerUpgrade()
{
CardData cardData = _context.CardData;
CardRarity oldRarity = cardData.Rarity;
CardRarity newRarity = oldRarity + 1;
Logging.Debug($"[CardEnlargedRepeatState] Upgrading card from {oldRarity} to {newRarity}");
var inventory = Data.CardSystem.CardSystemManager.Instance.GetCardInventory();
// Remove lower rarity card counts (set to 1 per new rule instead of zeroing out)
CardRarity clearRarity = cardData.Rarity;
while (clearRarity < newRarity)
{
var lower = inventory.GetCard(cardData.DefinitionId, clearRarity);
if (lower != null) lower.CopiesOwned = 1; // changed from 0 to 1
clearRarity += 1;
}
// Check if higher rarity already exists BEFORE adding
CardData existingHigher = inventory.GetCard(cardData.DefinitionId, newRarity);
bool higherExists = existingHigher != null;
if (higherExists)
{
// Increment existing higher rarity copies
existingHigher.CopiesOwned += 1;
// Update our displayed card to new rarity
cardData.Rarity = newRarity;
cardData.CopiesOwned = existingHigher.CopiesOwned; // reflect correct count
if (_context.CardDisplay != null)
{
_context.CardDisplay.SetupCard(cardData);
}
// For repeat-at-higher-rarity: show a brief progress update at higher rarity while enlarged
int ownedAtHigher = existingHigher.CopiesOwned;
if (progressBar != null)
{
progressBar.ShowProgress(ownedAtHigher, _settings.CardsToUpgrade, () =>
{
// After showing higher-rarity progress, wait for tap to dismiss
_waitingForTap = true;
});
}
else
{
_waitingForTap = true;
}
}
else
{
// Create upgraded card as new rarity
CardData upgradedCard = new CardData(cardData);
upgradedCard.Rarity = newRarity;
upgradedCard.CopiesOwned = 1;
// Add to inventory
inventory.AddCard(upgradedCard);
// Update current display card to new rarity
cardData.Rarity = newRarity;
cardData.CopiesOwned = upgradedCard.CopiesOwned;
if (_context.CardDisplay != null)
{
_context.CardDisplay.SetupCard(cardData);
}
// Branch based on whether legendary or not
if (newRarity == CardRarity.Legendary)
{
// Show special enlarged legendary presentation, await click to shrink to revealed
_context.StateMachine.ChangeState(CardStateNames.EnlargedLegendaryRepeat);
}
else
{
// Treat as NEW at higher rarity (enlarged with NEW visuals handled there)
_context.StateMachine.ChangeState(CardStateNames.EnlargedNew);
}
}
}
private void TransitionToNewCardView()
{
// Hide progress bar before transitioning
if (progressBar != null)
{
progressBar.gameObject.SetActive(false);
}
// Transition to EnlargedNewState (card is already enlarged, will show NEW badge)
// State will query fresh collection data to determine if truly new
_context.StateMachine.ChangeState(CardStateNames.EnlargedNew);
}
public void OnCardClicked(CardContext context)
{
if (!_waitingForTap)
return;
// Tap to dismiss - shrink back to original scale and transition to revealed state
if (context.Animator != null)
{
context.Animator.PlayShrink(context.OriginalScale, onComplete: () =>
{
context.StateMachine.ChangeState(CardStateNames.Revealed);
});
}
else
{
context.StateMachine.ChangeState("RevealedState");
}
}
private void OnDisable()
{
// Hide progress bar when leaving state
if (progressBar != null)
{
progressBar.gameObject.SetActive(false);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 257f0c81caa14481812a8ca0397bf567
timeCreated: 1762884651

View File

@@ -0,0 +1,177 @@
using Core.SaveLoad;
using Pixelplacement.TweenSystem;
using UnityEngine;
using UnityEngine.EventSystems;
using AppleHills.Core.Settings;
using AppleHills.Data.CardSystem;
using Core;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Idle state - card back is showing with gentle hover animation.
/// Waiting for click to flip and reveal.
/// Based on FlippableCard's idle behavior.
/// </summary>
public class CardIdleState : AppleState, ICardClickHandler, IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler
{
[Header("State-Owned Visuals")]
[SerializeField] private GameObject cardBackVisual;
[Header("Idle Hover Settings")]
[SerializeField] private bool enableIdleHover = true;
private CardContext _context;
private ICardSystemSettings _settings;
private TweenBase _idleHoverTween;
private Vector2 _originalPosition;
private void Awake()
{
_context = GetComponentInParent<CardContext>();
_settings = GameManager.GetSettingsObject<ICardSystemSettings>();
}
public override void OnEnterState()
{
// Show card back, hide card front
if (cardBackVisual != null)
{
cardBackVisual.SetActive(true);
// Ensure card back is at 0° rotation (facing camera)
cardBackVisual.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(false);
// Ensure card front starts at 180° rotation (flipped away)
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 180, 0);
}
// Save original position for hover animation
RectTransform rectTransform = _context.RootTransform.GetComponent<RectTransform>();
if (rectTransform != null)
{
_originalPosition = rectTransform.anchoredPosition;
}
// Start idle hover animation
if (enableIdleHover && _context.Animator != null)
{
_idleHoverTween = _context.Animator.StartIdleHover(_settings.IdleHoverHeight, _settings.IdleHoverDuration);
}
}
public void OnPointerEnter(PointerEventData eventData)
{
// Scale up slightly on hover
if (_context.Animator != null)
{
_context.Animator.AnimateScale(Vector3.one * _settings.HoverScaleMultiplier, 0.2f);
}
}
public void OnPointerExit(PointerEventData eventData)
{
// Scale back to normal
if (_context.Animator != null)
{
_context.Animator.AnimateScale(Vector3.one, 0.2f);
}
}
public void OnCardClicked(CardContext context)
{
// Check if card is clickable (prevents multi-flip in booster opening)
if (!context.IsClickable)
{
Logging.Debug($"[CardIdleState] Card is not clickable, ignoring click");
return;
}
// Stop idle hover and pointer interactions
StopIdleHover();
// Play flip animation directly
if (context.Animator != null)
{
context.Animator.PlayFlip(
cardBack: cardBackVisual != null ? cardBackVisual.transform : null,
cardFront: context.CardDisplay != null ? context.CardDisplay.transform : null,
onComplete: OnFlipComplete
);
context.Animator.PlayFlipScalePunch();
}
}
public void OnPointerClick(PointerEventData eventData)
{
// Forward to same logic as routed click to keep behavior unified
OnCardClicked(_context);
}
private void OnFlipComplete()
{
// Query current collection state from CardSystemManager (don't use cached values)
bool isNew = Data.CardSystem.CardSystemManager.Instance.IsCardNew(_context.CardData, out CardData existingCard);
// Transition based on whether this is a new card or repeat
if (isNew)
{
// New card - show "NEW" badge and enlarge
_context.StateMachine.ChangeState(CardStateNames.EnlargedNew);
}
else if (_context.CardData != null && _context.CardData.Rarity == AppleHills.Data.CardSystem.CardRarity.Legendary)
{
// Legendary repeat - skip enlarge, they can't upgrade
// Add to inventory and move to revealed state
if (Data.CardSystem.CardSystemManager.Instance != null)
{
Data.CardSystem.CardSystemManager.Instance.AddCardToInventoryDelayed(_context.CardData);
}
_context.StateMachine.ChangeState(CardStateNames.Revealed);
}
else
{
// Repeat card - show progress toward upgrade
_context.StateMachine.ChangeState(CardStateNames.EnlargedRepeat);
}
}
private void StopIdleHover()
{
if (_idleHoverTween != null)
{
_idleHoverTween.Stop();
_idleHoverTween = null;
// Return to original position
RectTransform rectTransform = _context.RootTransform.GetComponent<RectTransform>();
if (rectTransform != null && _context.Animator != null)
{
_context.Animator.AnimateAnchoredPosition(_originalPosition, 0.3f);
}
}
}
private void OnDisable()
{
// Stop idle hover animation when leaving state
StopIdleHover();
// Reset scale
if (_context?.Animator != null)
{
_context.Animator.AnimateScale(Vector3.one, 0.2f);
}
// Hide card back when leaving state
if (cardBackVisual != null)
{
cardBackVisual.SetActive(false);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7da1bdc06be348f2979d3b92cc7ce723
timeCreated: 1762884650

View File

@@ -0,0 +1,176 @@
using Core;
using Core.SaveLoad;
using UnityEngine;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Card is in pending face-down state in corner, awaiting drag.
/// On drag start, queries AlbumViewPage for card data and slot, triggers page navigation,
/// then flips and transitions to dragging revealed state.
/// </summary>
public class CardPendingFaceDownState : AppleState, ICardStateDragHandler
{
[Header("State-Owned Visuals")]
[SerializeField] private GameObject cardBackVisual;
private CardContext _context;
private bool _isFlipping;
private AlbumCardSlot _targetSlot;
private bool _dragEndedDuringFlip = false; // Track if user released before card flip animation completed
private void Awake()
{
_context = GetComponentInParent<CardContext>();
}
public override void OnEnterState()
{
if (_context == null) return;
_isFlipping = false;
_targetSlot = null;
_dragEndedDuringFlip = false;
// Reset scale to normal (in case transitioning from scaled state)
_context.RootTransform.localScale = Vector3.one;
// Show card back, hide card front
if (cardBackVisual != null)
{
cardBackVisual.SetActive(true);
cardBackVisual.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(false);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 180, 0);
}
}
/// <summary>
/// Handle drag start - STATE ORCHESTRATES ITS OWN FLOW
/// </summary>
public bool OnCardDragStarted(CardContext context)
{
if (_isFlipping) return true; // Already handling
// Get AlbumViewPage from context (injected dependency)
var albumPage = context.AlbumViewPage;
if (albumPage == null)
{
Logging.Warning("[CardPendingFaceDownState] AlbumViewPage not injected!");
return true;
}
// Step 2: Ask AlbumViewPage what card to display and prompt it to rebuild
var cardData = albumPage.GetCardForPendingSlot();
if (cardData == null)
{
Logging.Warning("[CardPendingFaceDownState] No card data available from AlbumViewPage!");
return true;
}
// Step 3: Apply card data to context
// Use UpdateCardData instead of SetupCard to preserve OriginalScale
// (card was already initialized with correct scale in SpawnCardInSlot)
context.UpdateCardData(cardData);
Logging.Debug($"[CardPendingFaceDownState] Assigned card data: {cardData.Name} ({cardData.Zone})");
// Step 4: Ask AlbumViewPage for target slot
_targetSlot = albumPage.GetTargetSlotForCard(cardData);
if (_targetSlot == null)
{
Logging.Warning($"[CardPendingFaceDownState] No slot found for card {cardData.DefinitionId}");
// Still flip and show card, but won't be able to place it
}
// Step 5: Request page navigation (no callback needed - AlbumViewPage tracks state)
albumPage.NavigateToCardPage(cardData, null);
// Step 6: Start card flip animation
StartFlipAnimation();
return true; // We handled it, prevent default DraggingState transition
}
/// <summary>
/// Handle drag end - if card flip animation still in progress, flag it for next state
/// </summary>
public bool OnCardDragEnded(CardContext context)
{
if (_isFlipping)
{
// Card flip animation still in progress - user released immediately
_dragEndedDuringFlip = true;
Logging.Debug("[CardPendingFaceDownState] Drag ended during card flip - will pass to next state");
return true; // We handled it
}
return false; // Already transitioned to DraggingRevealedState, let it handle
}
private void StartFlipAnimation()
{
_isFlipping = true;
// Scale up from corner size to normal dragging size
if (_context.Animator != null)
{
_context.Animator.AnimateScale(_context.OriginalScale * 1.15f, 0.3f);
}
// Play flip animation
if (_context.Animator != null)
{
_context.Animator.PlayFlip(
cardBack: cardBackVisual != null ? cardBackVisual.transform : null,
cardFront: _context.CardDisplay != null ? _context.CardDisplay.transform : null,
onComplete: OnFlipComplete
);
}
else
{
// No animator, just switch visibility immediately
if (cardBackVisual != null) cardBackVisual.SetActive(false);
if (_context.CardDisplay != null) _context.CardDisplay.gameObject.SetActive(true);
OnFlipComplete();
}
}
private void OnFlipComplete()
{
// Transition to dragging revealed state
// Pass target slot to next state (it will query AlbumService for flip status)
var card = _context.GetComponent<Card>();
if (card != null)
{
var draggingState = card.GetStateComponent<CardDraggingRevealedState>(CardStateNames.DraggingRevealed);
if (draggingState != null)
{
draggingState.SetTargetSlot(_targetSlot);
// If drag ended before we transitioned, tell next state to handle placement immediately
if (_dragEndedDuringFlip)
{
draggingState.SetDragAlreadyEnded(true);
Logging.Debug("[CardPendingFaceDownState] Passing drag-ended flag to DraggingRevealedState");
}
}
}
_context.StateMachine.ChangeState(CardStateNames.DraggingRevealed);
}
private void OnDisable()
{
// Hide card back when leaving state
if (cardBackVisual != null)
{
cardBackVisual.SetActive(false);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6fab9d595905435b82253cd4d1bf49de
timeCreated: 1763322180

View File

@@ -0,0 +1,150 @@
using Core;
using Core.SaveLoad;
using Data.CardSystem;
using UnityEngine;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Placed in slot state - card is being/has been placed in an album slot.
/// SMART STATE: Handles snap-to-slot animation on entry, then finalizes placement.
/// </summary>
public class CardPlacedInSlotState : AppleState, ICardClickHandler
{
private CardContext _context;
private AlbumCardSlot _parentSlot;
private AlbumCardSlot _targetSlotForAnimation; // Set by DraggingRevealedState for animated placement
private void Awake()
{
_context = GetComponentInParent<CardContext>();
}
/// <summary>
/// Set placement info from previous state (for animated placement from drag)
/// </summary>
public void SetPlacementInfo(AlbumCardSlot targetSlot)
{
_targetSlotForAnimation = targetSlot;
}
public override void OnEnterState()
{
// Ensure card front is visible and facing camera
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
// Check if this is animated placement (from drag) or direct placement (from spawn)
if (_targetSlotForAnimation != null)
{
// Animated placement - play tween to slot
Logging.Debug($"[CardPlacedInSlotState] Animating card '{_context.CardData?.Name}' to slot");
AnimateToSlot(_targetSlotForAnimation);
}
else
{
// Direct placement (spawned in slot) - already positioned correctly
// Disable dragging for spawned cards too
var card = _context.GetComponent<Card>();
if (card != null)
{
card.SetDraggingEnabled(false);
}
Logging.Debug($"[CardPlacedInSlotState] Card '{_context.CardData?.Name}' directly placed in slot");
}
}
/// <summary>
/// Animate card to slot position and finalize placement
/// </summary>
private void AnimateToSlot(AlbumCardSlot slot)
{
var card = _context.GetComponent<Card>();
if (card == null) return;
// Reparent to slot immediately, keeping world position
card.transform.SetParent(slot.transform, true);
// Tween position, scale, rotation simultaneously
float tweenDuration = 0.4f;
Pixelplacement.Tween.LocalPosition(card.transform, Vector3.zero, tweenDuration, 0f, Pixelplacement.Tween.EaseOutBack);
Pixelplacement.Tween.LocalScale(card.transform, Vector3.one, tweenDuration, 0f, Pixelplacement.Tween.EaseOutBack);
Pixelplacement.Tween.LocalRotation(card.transform, Quaternion.identity, tweenDuration, 0f, Pixelplacement.Tween.EaseOutBack,
completeCallback: () => FinalizePlacement(card, slot));
}
/// <summary>
/// Finalize placement after animation completes
/// </summary>
private void FinalizePlacement(Card card, AlbumCardSlot slot)
{
// Ensure final position/rotation
card.transform.localPosition = Vector3.zero;
card.transform.localRotation = Quaternion.identity;
// Resize to match slot
RectTransform cardRect = card.transform as RectTransform;
RectTransform slotRect = slot.transform as RectTransform;
if (cardRect != null && slotRect != null)
{
float targetHeight = slotRect.rect.height;
cardRect.sizeDelta = new Vector2(cardRect.sizeDelta.x, targetHeight);
}
// Set parent slot
_parentSlot = slot;
// Disable dragging - cards in slots should only respond to clicks for enlargement
card.SetDraggingEnabled(false);
// Notify slot
slot.AssignCard(card);
// Mark as placed in inventory
if (CardSystemManager.Instance != null)
{
CardSystemManager.Instance.MarkCardAsPlaced(card.CardData);
}
// Notify AlbumViewPage for registration
var albumPage = Object.FindFirstObjectByType<AlbumViewPage>();
if (albumPage != null)
{
albumPage.NotifyCardPlaced(card);
}
Logging.Debug($"[CardPlacedInSlotState] Card placement finalized: {card.CardData?.Name}");
// Clear animation target
_targetSlotForAnimation = null;
}
/// <summary>
/// Set the parent slot this card belongs to (for direct placement without animation)
/// </summary>
public void SetParentSlot(AlbumCardSlot slot)
{
_parentSlot = slot;
}
/// <summary>
/// Get the parent slot
/// </summary>
public AlbumCardSlot GetParentSlot()
{
return _parentSlot;
}
public void OnCardClicked(CardContext context)
{
// Click to enlarge when in album
Logging.Debug($"[CardPlacedInSlotState] Card clicked in slot, transitioning to enlarged state");
context.StateMachine.ChangeState(CardStateNames.AlbumEnlarged);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 11a4dc9bbeed4623baf1675ab5679bd9
timeCreated: 1762884899

View File

@@ -0,0 +1,76 @@
using AppleHills.Data.CardSystem;
using Core.SaveLoad;
using UnityEngine;
namespace UI.CardSystem.StateMachine.States
{
/// <summary>
/// Revealed state - card is flipped and visible at normal size.
/// This is the "waiting" state:
/// - In booster flow: waiting for all cards to finish before animating to album
/// - In album placement flow: waiting to be dragged to a slot
/// Shows small idle badges for NEW or REPEAT cards.
/// </summary>
public class CardRevealedState : AppleState
{
[Header("State-Owned Visuals")]
[SerializeField] private UnityEngine.GameObject newCardIdleBadge;
[SerializeField] private UnityEngine.GameObject repeatCardIdleBadge;
private CardContext _context;
private void Awake()
{
_context = GetComponentInParent<CardContext>();
}
public override void OnEnterState()
{
// Ensure card front is visible and facing camera
if (_context.CardDisplay != null)
{
_context.CardDisplay.gameObject.SetActive(true);
_context.CardDisplay.transform.localRotation = Quaternion.Euler(0, 0, 0);
}
// Show appropriate idle badge unless suppressed
if (_context.BoosterContext.SuppressRevealBadges)
{
if (newCardIdleBadge != null) newCardIdleBadge.SetActive(false);
if (repeatCardIdleBadge != null) repeatCardIdleBadge.SetActive(false);
}
else
{
bool isNew = Data.CardSystem.CardSystemManager.Instance.IsCardNew(_context.CardData, out CardData existingCard);
int currentOwnedCount = (existingCard != null) ? existingCard.CopiesOwned : 0;
if (isNew)
{
if (newCardIdleBadge != null) newCardIdleBadge.SetActive(true);
if (repeatCardIdleBadge != null) repeatCardIdleBadge.SetActive(false);
}
else if (currentOwnedCount > 0)
{
if (newCardIdleBadge != null) newCardIdleBadge.SetActive(false);
if (repeatCardIdleBadge != null) repeatCardIdleBadge.SetActive(true);
}
else
{
if (newCardIdleBadge != null) newCardIdleBadge.SetActive(false);
if (repeatCardIdleBadge != null) repeatCardIdleBadge.SetActive(false);
}
}
// Fire reveal flow complete event (signals booster page that this card is done)
_context.BoosterContext.NotifyRevealComplete();
}
private void OnDisable()
{
// Hide badges when leaving state
if (newCardIdleBadge != null)
newCardIdleBadge.SetActive(false);
if (repeatCardIdleBadge != null)
repeatCardIdleBadge.SetActive(false);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 891aad90d6cc41869e497f94d1408859
timeCreated: 1762884650

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: be244d3b69267554682b35f0c9d12151
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,454 @@
using System.Collections.Generic;
using AppleHills.Data.CardSystem;
using Core;
using Core.Lifecycle;
using Input;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UI.CardSystem.StateMachine;
using UI.CardSystem.StateMachine.States;
namespace UI.CardSystem.Testing
{
/// <summary>
/// Test controller for card state machine testing.
/// Provides UI controls to manually test state transitions, animations, and flows.
/// </summary>
public class CardTestController : ManagedBehaviour
{
[Header("Test Card")]
[SerializeField] private Card testCard;
[SerializeField] private CardData testCardData;
[Header("UI References")]
[SerializeField] private TextMeshProUGUI eventLogText;
[SerializeField] private Toggle isNewToggle;
[SerializeField] private Slider repeatCountSlider;
[SerializeField] private TextMeshProUGUI repeatCountLabel;
[SerializeField] private TMP_Dropdown rarityDropdown;
[SerializeField] private Toggle isClickableToggle;
[SerializeField] private TextMeshProUGUI currentStateText;
private List<string> _eventLog = new List<string>();
private CardContext _cardContext;
private Vector3 _originalCardPosition;
private Vector3 _originalCardScale;
private Vector2 _originalAnchoredPosition;
private void Awake()
{
if (testCard != null)
{
_cardContext = testCard.GetComponent<CardContext>();
_originalCardPosition = testCard.transform.position;
_originalCardScale = testCard.transform.localScale;
// Store original anchored position if it's a RectTransform
RectTransform rectTransform = testCard.GetComponent<RectTransform>();
if (rectTransform != null)
{
_originalAnchoredPosition = rectTransform.anchoredPosition;
}
// Subscribe to card events (new simplified event model)
if (_cardContext != null)
{
// TODO: FIX
// _cardContext.OnRevealFlowComplete += OnCardRevealFlowComplete;
}
// Subscribe to drag events to ensure card snaps back when released
testCard.OnDragStarted += OnCardDragStarted;
testCard.OnDragEnded += OnCardDragEnded;
}
// Setup UI listeners
if (repeatCountSlider != null)
{
repeatCountSlider.onValueChanged.AddListener(OnRepeatCountChanged);
}
if (isClickableToggle != null)
{
isClickableToggle.onValueChanged.AddListener(OnIsClickableToggled);
}
}
internal override void OnManagedAwake()
{
base.OnManagedAwake();
InputManager.Instance.SetInputMode(InputMode.UI);
}
private void Start()
{
// Initialize card with test data
if (testCard != null && testCardData != null && _cardContext != null)
{
_cardContext.SetupCard(testCardData);
}
LogEvent("Card Test Scene Initialized");
UpdateCurrentStateDisplay();
}
private void Update()
{
// Update current state display every frame
if (Time.frameCount % 30 == 0) // Every 0.5 seconds at 60fps
{
UpdateCurrentStateDisplay();
}
}
#region State Transition Buttons
/// <summary>
/// Reset card to default state (position, scale) before transitioning to a new state.
/// This prevents accumulation of tweens and ensures animations play correctly.
/// </summary>
private void ResetCardToDefault()
{
if (testCard == null || _cardContext == null) return;
// Stop all animations
if (_cardContext.Animator != null)
{
_cardContext.Animator.StopAllAnimations();
}
// Reset transform immediately
testCard.transform.localScale = _originalCardScale;
testCard.transform.position = _originalCardPosition;
// Reset anchored position if it's a RectTransform
RectTransform rectTransform = testCard.GetComponent<RectTransform>();
if (rectTransform != null)
{
rectTransform.anchoredPosition = _originalAnchoredPosition;
}
LogEvent("Card reset to default state");
}
public void TransitionToIdleState()
{
ResetCardToDefault();
_cardContext?.StateMachine.ChangeState("IdleState");
LogEvent("Transitioned to IdleState");
}
public void TransitionToRevealedState()
{
ResetCardToDefault();
_cardContext?.StateMachine.ChangeState("RevealedState");
LogEvent("Transitioned to RevealedState");
}
public void TransitionToEnlargedNewState()
{
ResetCardToDefault();
_cardContext?.StateMachine.ChangeState("EnlargedNewState");
LogEvent("Transitioned to EnlargedNewState");
}
public void TransitionToEnlargedRepeatState()
{
ResetCardToDefault();
_cardContext?.StateMachine.ChangeState("EnlargedRepeatState");
LogEvent("Transitioned to EnlargedRepeatState");
}
public void TransitionToDraggingState()
{
ResetCardToDefault();
_cardContext?.StateMachine.ChangeState("DraggingState");
LogEvent("Transitioned to DraggingState");
}
public void TransitionToAlbumEnlargedState()
{
ResetCardToDefault();
_cardContext?.StateMachine.ChangeState("AlbumEnlargedState");
LogEvent("Transitioned to AlbumEnlargedState");
}
#endregion
#region Simulation Buttons
public void SimulateNewCardFlow()
{
if (_cardContext == null) return;
// NOTE: These properties no longer exist in CardContext (removed to prevent stale data)
// States now query CardSystemManager directly
// This test controller manually manipulates state machine for testing only
_cardContext.IsClickable = true;
TransitionToIdleState();
LogEvent("Simulating NEW CARD flow - click card to flip (test bypasses collection checks)");
}
public void SimulateRepeatCardFlow()
{
if (_cardContext == null) return;
// NOTE: RepeatCardCount removed from CardContext
// Test directly transitions to state for visual testing
_cardContext.IsClickable = true;
TransitionToIdleState();
LogEvent($"Simulating REPEAT CARD flow (test bypasses collection checks)");
}
public void SimulateUpgradeFlow()
{
if (_cardContext == null) return;
// NOTE: WillTriggerUpgrade removed from CardContext
// Test directly transitions to state for visual testing
_cardContext.IsClickable = true;
TransitionToIdleState();
LogEvent("Simulating UPGRADE flow (test bypasses collection checks)");
}
public void TestDragAndSnap()
{
if (testCard == null) return;
// Enable dragging for the test
testCard.SetDraggingEnabled(true);
TransitionToRevealedState();
LogEvent("DRAG TEST enabled - drag the card and release to see it snap back");
}
#endregion
#region Card Setup Controls
public void ApplyCardSetup()
{
if (_cardContext == null) return;
bool isNew = isNewToggle != null && isNewToggle.isOn;
int repeatCount = repeatCountSlider != null ? Mathf.RoundToInt(repeatCountSlider.value) : 0;
// Apply rarity if needed
if (rarityDropdown != null && testCardData != null)
{
testCardData.Rarity = (CardRarity)rarityDropdown.value;
}
LogEvent($"Card setup applied: IsNew={isNew}, RepeatCount={repeatCount}");
}
private void OnRepeatCountChanged(float value)
{
if (repeatCountLabel != null)
{
repeatCountLabel.text = $"{Mathf.RoundToInt(value)}/5";
}
}
private void OnIsClickableToggled(bool isClickable)
{
if (_cardContext != null)
{
_cardContext.IsClickable = isClickable;
LogEvent($"Card clickable: {isClickable}");
}
}
#endregion
#region Animation Test Buttons
public void PlayFlipAnimation()
{
// Reset card first to prevent accumulation
ResetCardToDefault();
// Transition to IdleState and programmatically trigger flip
TransitionToIdleState();
// Get IdleState and trigger click
var idleState = testCard.GetComponentInChildren<CardIdleState>();
if (idleState != null)
{
idleState.OnPointerClick(null);
LogEvent("Playing flip animation");
}
}
public void PlayEnlargeAnimation()
{
if (_cardContext?.Animator != null)
{
ResetCardToDefault();
_cardContext.Animator.PlayEnlarge(1.5f);
LogEvent("Playing enlarge animation");
}
}
public void PlayShrinkAnimation()
{
if (_cardContext?.Animator != null)
{
// Don't reset for shrink - we want to shrink from current state
_cardContext.Animator.PlayShrink(Vector3.one, null);
LogEvent("Playing shrink animation");
}
}
public void StartIdleHoverAnimation()
{
if (_cardContext?.Animator != null)
{
// Reset card position first to prevent accumulation
ResetCardToDefault();
_cardContext.Animator.StartIdleHover(10f, 1.5f, restartIfActive: true);
LogEvent("Started idle hover animation");
}
}
public void StopIdleHoverAnimation()
{
if (_cardContext?.Animator != null)
{
_cardContext.Animator.StopIdleHover(_originalAnchoredPosition);
LogEvent("Stopped idle hover animation");
}
}
#endregion
#region Utility Buttons
public void ResetCardPosition()
{
if (testCard != null)
{
testCard.transform.position = _originalCardPosition;
testCard.transform.localScale = _originalCardScale;
// Reset anchored position if it's a RectTransform
RectTransform rectTransform = testCard.GetComponent<RectTransform>();
if (rectTransform != null)
{
rectTransform.anchoredPosition = _originalAnchoredPosition;
}
LogEvent("Card position reset");
}
}
public void ClearEventLog()
{
_eventLog.Clear();
UpdateEventLog();
LogEvent("Event log cleared");
}
#endregion
#region Event Handlers
private void OnCardRevealFlowComplete(CardContext context)
{
LogEvent($"Event: OnRevealFlowComplete - Card reveal complete for {context.CardData?.Name}");
}
private void OnCardDragStarted(UI.DragAndDrop.Core.DraggableObject draggable)
{
LogEvent("Event: OnDragStarted - Card is being dragged");
}
private void OnCardDragEnded(UI.DragAndDrop.Core.DraggableObject draggable)
{
LogEvent("Event: OnDragEnded - Snapping card back to spawn point");
// Snap card back to original position (no slotting in test scene)
if (testCard != null)
{
testCard.transform.position = _originalCardPosition;
// Return to idle state after drag
TransitionToIdleState();
}
}
#endregion
#region Event Log
private void LogEvent(string message)
{
string timestamp = $"[{Time.time:F2}s]";
_eventLog.Add($"{timestamp} {message}");
// Keep only last 20 events
if (_eventLog.Count > 20)
{
_eventLog.RemoveAt(0);
}
UpdateEventLog();
Logging.Debug($"[CardTest] {message}");
}
private void UpdateEventLog()
{
if (eventLogText != null)
{
eventLogText.text = string.Join("\n", _eventLog);
}
}
private void UpdateCurrentStateDisplay()
{
if (currentStateText != null && _cardContext != null && _cardContext.StateMachine != null)
{
// Get the active state by checking which child state GameObject is active
string stateName = "Unknown";
Transform stateMachineTransform = _cardContext.StateMachine.transform;
for (int i = 0; i < stateMachineTransform.childCount; i++)
{
Transform child = stateMachineTransform.GetChild(i);
if (child.gameObject.activeSelf)
{
stateName = child.name;
break;
}
}
currentStateText.text = $"Current State: {stateName}";
}
}
#endregion
private void OnDestroy()
{
// Unsubscribe from events
if (_cardContext != null)
{
// TODO: FIX
// _cardContext.OnRevealFlowComplete -= OnCardRevealFlowComplete;
}
if (testCard != null)
{
testCard.OnDragStarted -= OnCardDragStarted;
testCard.OnDragEnded -= OnCardDragEnded;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d6de9a5b64e791409043fb8c858bda2

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2a6295f642a94601ada9c21dc400d180
timeCreated: 1763454480

View File

@@ -0,0 +1,220 @@
using System.Collections;
using Core;
using Data.CardSystem;
using UnityEngine;
using UnityEngine.Events;
namespace UI.CardSystem
{
/// <summary>
/// One-off helper to visually grant a booster pack.
/// Place this on a UI GameObject with two Image children (a background "glow" and a booster pack image).
/// Access via BoosterPackGiver.Instance and call GiveBoosterPack().
/// The sequence:
/// 1) Shows the object (enables children)
/// 2) Pulses the glow scale for a fixed duration
/// 3) Hides the glow, tweens the booster image towards the backpack icon and scales to zero
/// 4) Invokes OnCompleted
/// </summary>
public class BoosterPackGiver : MonoBehaviour
{
public static BoosterPackGiver Instance { get; private set; }
[Header("References")]
[Tooltip("Canvas that contains these UI elements. If null, will search up the hierarchy.")]
[SerializeField] private Canvas canvas;
[Tooltip("Background glow RectTransform (child image)")]
[SerializeField] private RectTransform backgroundGlow;
[Tooltip("Booster pack image RectTransform (child image)")]
[SerializeField] private RectTransform boosterImage;
[Tooltip("Target RectTransform for the backpack icon (where the booster flies to)")]
[SerializeField] private RectTransform targetBackpackIcon;
[Header("Timing")]
[Tooltip("How long the glow should pulse before the booster flies to the backpack")]
[SerializeField] private float pulseDuration = 2.0f;
[Tooltip("Duration of the flight/scale-down animation")]
[SerializeField] private float moveDuration = 0.6f;
[Header("Glow Pulse")]
[Tooltip("Minimum scale during pulse")] [SerializeField] private float glowScaleMin = 0.9f;
[Tooltip("Maximum scale during pulse")] [SerializeField] private float glowScaleMax = 1.1f;
[Tooltip("Pulse speed in cycles per second")] [SerializeField] private float glowPulseSpeed = 2.0f;
[Header("Move/Scale Easing")]
[SerializeField] private AnimationCurve moveCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
[SerializeField] private AnimationCurve scaleCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
[Header("Behaviour")]
[Tooltip("Hide visuals when the sequence completes")] [SerializeField] private bool hideOnComplete = true;
[Header("Events")]
public UnityEvent OnCompleted;
private Coroutine _sequenceCoroutine;
private Vector3 _boosterInitialScale;
private Vector2 _boosterInitialAnchoredPos;
private IEnumerator Start()
{
if (Instance != null && Instance != this)
{
Logging.Warning("[BoosterPackGiver] Duplicate instance detected. Destroying this component.");
Destroy(this);
yield break;
}
Instance = this;
if (canvas == null)
{
canvas = GetComponentInParent<Canvas>();
}
CacheInitialBoosterState();
// Start hidden (keep GameObject active so the singleton remains accessible)
SetVisualsActive(false);
// yield return new WaitForSeconds(1f);
// GiveBoosterPack();
}
private void OnDestroy()
{
if (Instance == this)
Instance = null;
}
private void CacheInitialBoosterState()
{
if (boosterImage != null)
{
_boosterInitialScale = boosterImage.localScale;
_boosterInitialAnchoredPos = boosterImage.anchoredPosition;
}
}
/// <summary>
/// Public entry point: run the grant animation.
/// </summary>
public void GiveBoosterPack()
{
if (backgroundGlow == null || boosterImage == null)
{
Debug.LogError("[BoosterPackGiver] Missing references. Assign Background Glow and Booster Image in the inspector.");
return;
}
// Reset and start fresh
if (_sequenceCoroutine != null)
{
StopCoroutine(_sequenceCoroutine);
_sequenceCoroutine = null;
}
// Ensure canvas reference
if (canvas == null)
{
canvas = GetComponentInParent<Canvas>();
}
// Reset booster transform
boosterImage.localScale = _boosterInitialScale;
boosterImage.anchoredPosition = _boosterInitialAnchoredPos;
// Show visuals
SetVisualsActive(true);
_sequenceCoroutine = StartCoroutine(RunSequence());
}
private IEnumerator RunSequence()
{
// 1) Pulse the glow
float elapsed = 0f;
Vector3 baseGlowScale = backgroundGlow.localScale;
while (elapsed < pulseDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Sin(elapsed * Mathf.PI * 2f * glowPulseSpeed) * 0.5f + 0.5f; // 0..1
float s = Mathf.Lerp(glowScaleMin, glowScaleMax, t);
backgroundGlow.localScale = baseGlowScale * s;
yield return null;
}
// 2) Hide glow
backgroundGlow.gameObject.SetActive(false);
// 3) Move booster to backpack icon and scale to zero
Vector2 startPos = boosterImage.anchoredPosition;
Vector2 targetPos = startPos;
// Convert target to booster parent space if available
if (targetBackpackIcon != null)
{
var parentRect = boosterImage.parent as RectTransform;
if (parentRect != null)
{
Vector2 localPoint;
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(canvas != null ? canvas.worldCamera : null, targetBackpackIcon.position);
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(parentRect, screenPoint, canvas != null ? canvas.worldCamera : null, out localPoint))
{
targetPos = localPoint;
}
}
}
elapsed = 0f;
while (elapsed < moveDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / moveDuration);
float mt = moveCurve != null ? moveCurve.Evaluate(t) : t;
float st = scaleCurve != null ? scaleCurve.Evaluate(t) : t;
boosterImage.anchoredPosition = Vector2.LerpUnclamped(startPos, targetPos, mt);
boosterImage.localScale = Vector3.LerpUnclamped(_boosterInitialScale, Vector3.zero, st);
yield return null;
}
// Ensure final state
boosterImage.anchoredPosition = targetPos;
boosterImage.localScale = Vector3.zero;
if (hideOnComplete)
{
SetVisualsActive(false);
// Restore booster for the next run
boosterImage.localScale = _boosterInitialScale;
boosterImage.anchoredPosition = _boosterInitialAnchoredPos;
backgroundGlow.localScale = Vector3.one; // reset pulse scaling
}
_sequenceCoroutine = null;
OnCompleted?.Invoke();
CardSystemManager.Instance.AddBoosterPack(1);
}
private void SetVisualsActive(bool active)
{
if (backgroundGlow != null) backgroundGlow.gameObject.SetActive(active);
if (boosterImage != null) boosterImage.gameObject.SetActive(active);
}
// Optional: quick editor hookup to validate references
#if UNITY_EDITOR
private void OnValidate()
{
if (canvas == null)
{
canvas = GetComponentInParent<Canvas>();
}
if (boosterImage != null && _boosterInitialScale == Vector3.zero)
{
_boosterInitialScale = boosterImage.localScale;
_boosterInitialAnchoredPos = boosterImage.anchoredPosition;
}
}
#endif
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e805057df6a34bd4b881031b5f460fe5
timeCreated: 1761053022

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7cb791415c884e97ac181816424200e4
timeCreated: 1763454497

View File

@@ -0,0 +1,135 @@
using System;
using AppleHills.Data.CardSystem;
using BookCurlPro;
using Core;
using UnityEngine;
using UnityEngine.UI;
using Tween = Pixelplacement.Tween;
namespace UI.CardSystem
{
/// <summary>
/// Tab button for navigating to specific pages in the card album book.
/// Coordinates with other tabs via static events for visual feedback.
/// </summary>
[RequireComponent(typeof(Button))]
public class BookTabButton : MonoBehaviour
{
[Header("Book Reference")]
[SerializeField] private BookPro book;
[Header("Tab Configuration")]
[SerializeField] private int targetPage;
[SerializeField] private CardZone zone;
[Header("Visual Settings")]
[SerializeField] private bool enableScaling = true;
[SerializeField] private float selectedScale = 2.0f;
[SerializeField] private float normalScale = 1.0f;
[SerializeField] private float scaleTransitionDuration = 0.2f;
private Button button;
private RectTransform rectTransform;
private Vector2 originalSize;
// Static dispatcher for coordinating all tabs
private static event Action<BookTabButton> OnTabClicked;
// Public properties to access this tab's configuration
public CardZone Zone => zone;
public int TargetPage => targetPage;
private void Awake()
{
// Get required components
button = GetComponent<Button>();
rectTransform = GetComponent<RectTransform>();
// Cache original size
originalSize = rectTransform.sizeDelta;
// Register button click
button.onClick.AddListener(OnButtonClicked);
// Subscribe to static tab event
OnTabClicked += OnAnyTabClicked;
}
private void OnDestroy()
{
// Cleanup listeners
if (button != null)
{
button.onClick.RemoveListener(OnButtonClicked);
}
OnTabClicked -= OnAnyTabClicked;
}
private void OnButtonClicked()
{
if (book == null)
{
Logging.Warning($"[BookTabButton] No BookPro reference assigned on {gameObject.name}");
return;
}
// Notify all tabs that this one was clicked
OnTabClicked?.Invoke(this);
// Flip to target page using AutoFlip
BookCurlPro.AutoFlip autoFlip = book.GetComponent<BookCurlPro.AutoFlip>();
if (autoFlip == null)
{
autoFlip = book.gameObject.AddComponent<BookCurlPro.AutoFlip>();
}
autoFlip.enabled = true;
autoFlip.StartFlipping(targetPage);
}
private void OnAnyTabClicked(BookTabButton clickedTab)
{
// Skip scaling if disabled
if (!enableScaling) return;
// Scale this tab based on whether it was clicked
if (clickedTab == this)
{
SetScale(selectedScale);
}
else
{
SetScale(normalScale);
}
}
private void SetScale(float targetScale)
{
Vector2 targetSize = originalSize * targetScale;
// Use Pixelplacement Tween for smooth size change
Tween.Value(rectTransform.sizeDelta, targetSize,
(Vector2 value) => rectTransform.sizeDelta = value,
scaleTransitionDuration, 0f, Tween.EaseInOut);
}
// Public method to programmatically trigger this tab
public void ActivateTab()
{
OnButtonClicked();
}
#if UNITY_EDITOR
private void OnValidate()
{
// Ensure target page is non-negative
if (targetPage < 0)
{
targetPage = 0;
}
}
#endif
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ff50caabb55742bc8d24a6ddffeda815
timeCreated: 1762385754

View File

@@ -0,0 +1,219 @@
using Core.Lifecycle;
using Data.CardSystem;
using Pixelplacement;
using Pixelplacement.TweenSystem;
using TMPro;
using UnityEngine;
namespace UI.CardSystem
{
/// <summary>
/// Manages a notification dot that displays a count (e.g., booster packs)
/// Can be reused across different UI elements that need to show numeric notifications
/// Automatically syncs with CardSystemManager to display booster pack count
/// </summary>
public class BoosterNotificationDot : ManagedBehaviour
{
[Header("UI References")]
[SerializeField] private GameObject dotBackground;
[SerializeField] private TextMeshProUGUI countText;
[Header("Settings")]
[SerializeField] private bool hideWhenZero = true;
[SerializeField] private bool useAnimation = false;
[SerializeField] private string textPrefix = "";
[SerializeField] private string textSuffix = "";
[SerializeField] private Color textColor = Color.white;
[Header("Animation")]
[SerializeField] private bool useTween = true;
[SerializeField] private float pulseDuration = 0.3f;
[SerializeField] private float pulseScale = 1.2f;
// Optional animator reference
[SerializeField] private Animator animator;
[SerializeField] private string animationTrigger = "Update";
// Current count value
private int _currentCount;
private Vector3 _originalScale;
private TweenBase _activeTween;
internal override void OnManagedStart()
{
// Store original scale for pulse animation
if (dotBackground != null)
{
_originalScale = dotBackground.transform.localScale;
}
// Apply text color
if (countText != null)
{
countText.color = textColor;
}
// Subscribe to CardSystemManager events (managers are guaranteed to be initialized)
if (CardSystemManager.Instance != null)
{
CardSystemManager.Instance.OnBoosterCountChanged += OnBoosterCountChanged;
// Poll initial count and display it
int initialCount = CardSystemManager.Instance.GetBoosterPackCount();
SetCount(initialCount);
}
else
{
// If CardSystemManager isn't available yet, set to default count
SetCount(_currentCount);
}
}
internal override void OnManagedDestroy()
{
// Unsubscribe from CardSystemManager events to prevent memory leaks
if (CardSystemManager.Instance != null)
{
CardSystemManager.Instance.OnBoosterCountChanged -= OnBoosterCountChanged;
}
}
/// <summary>
/// Callback when booster count changes in CardSystemManager
/// </summary>
private void OnBoosterCountChanged(int newCount)
{
SetCount(newCount);
}
/// <summary>
/// Sets the count displayed on the notification dot
/// Also handles visibility based on settings
/// </summary>
public void SetCount(int count)
{
bool countChanged = count != _currentCount;
_currentCount = count;
// Update text
if (countText != null)
{
countText.text = textPrefix + count.ToString() + textSuffix;
}
// Handle visibility
if (hideWhenZero)
{
SetVisibility(count > 0);
}
// Play animation if value changed and animation is enabled
if (countChanged && count > 0)
{
if (useAnimation)
{
Animate();
}
}
}
/// <summary>
/// Gets the current count value
/// </summary>
public int GetCount()
{
return _currentCount;
}
/// <summary>
/// Set text formatting options
/// </summary>
public void SetFormatting(string prefix, string suffix, Color color)
{
textPrefix = prefix;
textSuffix = suffix;
textColor = color;
if (countText != null)
{
countText.color = color;
// Update text with new formatting
countText.text = textPrefix + _currentCount.ToString() + textSuffix;
}
}
/// <summary>
/// Explicitly control the notification dot visibility
/// </summary>
public void SetVisibility(bool isVisible)
{
if (dotBackground != null)
{
dotBackground.SetActive(isVisible);
}
if (countText != null)
{
countText.gameObject.SetActive(isVisible);
}
}
/// <summary>
/// Show the notification dot
/// </summary>
public void Show()
{
SetVisibility(true);
}
/// <summary>
/// Hide the notification dot
/// </summary>
public void Hide()
{
SetVisibility(false);
}
/// <summary>
/// Play animation manually - either using Animator or Tween
/// </summary>
public void Animate()
{
if (useAnimation)
{
if (animator != null)
{
animator.SetTrigger(animationTrigger);
}
else if (useTween && dotBackground != null)
{
// Cancel any existing tweens on this transform
if(_activeTween != null)
_activeTween.Cancel();
// Reset to original scale
dotBackground.transform.localScale = _originalScale;
// Pulse animation using Tween
_activeTween = Tween.LocalScale(dotBackground.transform,
_originalScale * pulseScale,
pulseDuration/2,
0,
Tween.EaseOut,
Tween.LoopType.None,
null,
() => {
// Scale back to original size
Tween.LocalScale(dotBackground.transform,
_originalScale,
pulseDuration/2,
0,
Tween.EaseIn);
},
obeyTimescale: false);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5845ed3764635fe429b6f1063effdd8a

View File

@@ -0,0 +1,92 @@
using Core;
using UI.Core;
using UnityEngine;
using UnityEngine.UI;
namespace UI.CardSystem
{
/// <summary>
/// Opens the card album view when the button is pressed.
/// Attach this to a top-level GameObject in the scene.
/// </summary>
public class CardAlbumOpener : MonoBehaviour
{
[Header("References")]
[SerializeField] private Button openAlbumButton;
[SerializeField] private AlbumViewPage albumViewPage;
[SerializeField] private BoosterOpeningPage boosterOpeningPage;
private void Awake()
{
if (openAlbumButton != null)
{
openAlbumButton.onClick.AddListener(OnOpenAlbumClicked);
}
}
private void Start()
{
if (UIPageController.Instance != null)
{
UIPageController.Instance.OnPageChanged += OnPageChanged;
}
}
private void OnDisable()
{
if (UIPageController.Instance != null)
{
UIPageController.Instance.OnPageChanged -= OnPageChanged;
}
}
private void OnDestroy()
{
if (openAlbumButton != null)
{
openAlbumButton.onClick.RemoveListener(OnOpenAlbumClicked);
}
Logging.Debug("ALBUM: CardAlbumDestroyed");
}
private void OnOpenAlbumClicked()
{
if (UIPageController.Instance == null) return;
// Check if we're currently on the booster opening page
if (UIPageController.Instance.CurrentPage == boosterOpeningPage)
{
// We're in booster opening page, pop back to album main page
UIPageController.Instance.PopPage();
}
else if (UIPageController.Instance.CurrentPage != albumViewPage)
{
// We're not in the album at all, open it
if (openAlbumButton != null)
{
openAlbumButton.gameObject.SetActive(false);
}
if (albumViewPage != null)
{
UIPageController.Instance.PushPage(albumViewPage);
}
}
}
private void OnPageChanged(UIPage currentPage)
{
if (openAlbumButton == null) return;
// Show the button when:
// 1. We're on the booster opening page (acts as "back to album" button)
// 2. We're NOT on the album main page (acts as "open album" button)
// Hide the button only when we're on the album main page
bool shouldShowButton = currentPage == boosterOpeningPage || currentPage != albumViewPage;
openAlbumButton.gameObject.SetActive(shouldShowButton);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4b3b63118ebc48d6b8f28cd69d96191e
timeCreated: 1762384087

View File

@@ -0,0 +1,300 @@
using System;
using System.Collections;
using Core;
using Data.CardSystem;
using Pixelplacement;
using UnityEngine;
using UnityEngine.UI;
namespace UI.CardSystem
{
/// <summary>
/// Singleton UI component for granting booster packs from minigames.
/// Displays a booster pack with glow effect, waits for user to click continue,
/// then shows the scrapbook button and animates the pack flying to it before granting the reward.
/// The scrapbook button is automatically hidden after the animation completes.
/// </summary>
public class MinigameBoosterGiver : MonoBehaviour
{
public static MinigameBoosterGiver Instance { get; private set; }
[Header("Visual References")]
[SerializeField] private GameObject visualContainer;
[SerializeField] private RectTransform boosterImage;
[SerializeField] private RectTransform glowImage;
[SerializeField] private Button continueButton;
[Header("Animation Settings")]
[SerializeField] private float hoverAmount = 20f;
[SerializeField] private float hoverDuration = 1.5f;
[SerializeField] private float glowPulseMax = 1.1f;
[SerializeField] private float glowPulseDuration = 1.2f;
[Header("Disappear Animation")]
[SerializeField] private Vector2 targetBottomLeftOffset = new Vector2(100f, 100f);
[SerializeField] private float disappearDuration = 0.8f;
[SerializeField] private float disappearScale = 0.2f;
private Vector3 _boosterInitialPosition;
private Vector3 _boosterInitialScale;
private Vector3 _glowInitialScale;
private Coroutine _currentSequence;
private Action _onCompleteCallback;
private void Awake()
{
// Singleton pattern
if (Instance != null && Instance != this)
{
Logging.Warning("[MinigameBoosterGiver] Duplicate instance found. Destroying.");
Destroy(gameObject);
return;
}
Instance = this;
// Cache initial values
if (boosterImage != null)
{
_boosterInitialPosition = boosterImage.localPosition;
_boosterInitialScale = boosterImage.localScale;
}
if (glowImage != null)
{
_glowInitialScale = glowImage.localScale;
}
// Setup button listener
if (continueButton != null)
{
continueButton.onClick.AddListener(OnContinueClicked);
}
// Start hidden
if (visualContainer != null)
{
visualContainer.SetActive(false);
}
}
private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
if (continueButton != null)
{
continueButton.onClick.RemoveListener(OnContinueClicked);
}
}
/// <summary>
/// Public API to give a booster pack. Displays UI, starts animations, and waits for user interaction.
/// </summary>
/// <param name="onComplete">Optional callback when the sequence completes and pack is granted</param>
public void GiveBooster(Action onComplete = null)
{
if (_currentSequence != null)
{
Logging.Warning("[MinigameBoosterGiver] Already running a sequence. Ignoring new request.");
return;
}
_onCompleteCallback = onComplete;
_currentSequence = StartCoroutine(GiveBoosterSequence());
}
private IEnumerator GiveBoosterSequence()
{
// Show the visual
if (visualContainer != null)
{
visualContainer.SetActive(true);
}
// Reset positions and scales
if (boosterImage != null)
{
boosterImage.localPosition = _boosterInitialPosition;
boosterImage.localScale = _boosterInitialScale;
}
if (glowImage != null)
{
glowImage.localScale = _glowInitialScale;
}
// Enable the continue button
if (continueButton != null)
{
continueButton.interactable = true;
}
// Start idle hovering animation on booster (ping-pong)
if (boosterImage != null)
{
Vector3 hoverTarget = _boosterInitialPosition + Vector3.up * hoverAmount;
Tween.LocalPosition(boosterImage, hoverTarget, hoverDuration, 0f, Tween.EaseLinear, Tween.LoopType.PingPong);
}
// Start pulsing animation on glow (ping-pong scale)
if (glowImage != null)
{
Vector3 glowPulseScale = _glowInitialScale * glowPulseMax;
Tween.LocalScale(glowImage, glowPulseScale, glowPulseDuration, 0f, Tween.EaseOut, Tween.LoopType.PingPong);
}
// Wait for button click (handled by OnContinueClicked)
yield return null;
}
private void OnContinueClicked()
{
if (_currentSequence == null)
{
return; // Not in a sequence
}
// Disable button to prevent double-clicks
if (continueButton != null)
{
continueButton.interactable = false;
}
// Stop the ongoing animations by stopping all tweens on these objects
if (boosterImage != null)
{
Tween.Stop(boosterImage.GetInstanceID());
}
if (glowImage != null)
{
Tween.Stop(glowImage.GetInstanceID());
// Fade out the glow
Tween.LocalScale(glowImage, Vector3.zero, disappearDuration * 0.5f, 0f, Tween.EaseInBack);
}
// Start disappear animation
StartCoroutine(DisappearSequence());
}
private IEnumerator DisappearSequence()
{
if (boosterImage == null)
{
yield break;
}
// Show scrapbook button temporarily using HUD visibility context
PlayerHudManager.HudVisibilityContext hudContext = null;
GameObject scrapbookButton = null;
scrapbookButton = PlayerHudManager.Instance.GetScrabookButton();
if (scrapbookButton != null)
{
hudContext = PlayerHudManager.Instance.ShowElementTemporarily(scrapbookButton);
}
else
{
Logging.Warning("[MinigameBoosterGiver] Scrapbook button not found in PlayerHudManager.");
}
// Calculate target position - use scrapbook button position if available
Vector3 targetPosition;
if (scrapbookButton != null)
{
// Get the scrapbook button's position in the same coordinate space as boosterImage
RectTransform scrapbookRect = scrapbookButton.GetComponent<RectTransform>();
if (scrapbookRect != null)
{
// Convert scrapbook button's world position to local position relative to boosterImage's parent
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas != null && canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
// For overlay canvas, convert screen position to local position
Vector2 screenPos = RectTransformUtility.WorldToScreenPoint(null, scrapbookRect.position);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
boosterImage.parent as RectTransform,
screenPos,
null,
out Vector2 localPoint);
targetPosition = localPoint;
}
else
{
// For world space or camera canvas
targetPosition = boosterImage.parent.InverseTransformPoint(scrapbookRect.position);
}
}
else
{
Logging.Warning("[MinigameBoosterGiver] Scrapbook button has no RectTransform, using fallback position.");
targetPosition = GetFallbackPosition();
}
}
else
{
// Fallback to bottom-left corner
targetPosition = GetFallbackPosition();
}
// Tween to scrapbook button position
Tween.LocalPosition(boosterImage, targetPosition, disappearDuration, 0f, Tween.EaseInBack);
// Scale down
Vector3 targetScale = _boosterInitialScale * disappearScale;
Tween.LocalScale(boosterImage, targetScale, disappearDuration, 0f, Tween.EaseInBack);
// Wait for animation to complete
yield return new WaitForSeconds(disappearDuration);
// Grant the booster pack
if (CardSystemManager.Instance != null)
{
CardSystemManager.Instance.AddBoosterPack(1);
Logging.Debug("[MinigameBoosterGiver] Booster pack granted!");
}
else
{
Logging.Warning("[MinigameBoosterGiver] CardSystemManager not found, cannot grant booster pack.");
}
// Hide scrapbook button by disposing the context
hudContext?.Dispose();
// Hide the visual
if (visualContainer != null)
{
visualContainer.SetActive(false);
}
// Invoke completion callback
_onCompleteCallback?.Invoke();
_onCompleteCallback = null;
// Clear sequence reference
_currentSequence = null;
}
private Vector3 GetFallbackPosition()
{
RectTransform canvasRect = GetComponentInParent<Canvas>()?.GetComponent<RectTransform>();
if (canvasRect != null)
{
// Convert bottom-left corner with offset to local position
Vector2 bottomLeft = new Vector2(-canvasRect.rect.width / 2f, -canvasRect.rect.height / 2f);
return bottomLeft + targetBottomLeftOffset;
}
else
{
// Ultimate fallback if no canvas found
return _boosterInitialPosition + new Vector3(-500f, -500f, 0f);
}
}
}
}

View File

@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: 7d8f3e9a2b4c5f6d1a8e9c0b3d4f5a6b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c548995da9c746d1916b79304734c1c9
timeCreated: 1763454486

View File

@@ -0,0 +1,491 @@
using System.Collections.Generic;
using AppleHills.Data.CardSystem;
using Core;
using Data.CardSystem;
using Pixelplacement;
using UI.Core;
using UI.DragAndDrop.Core;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Serialization;
namespace UI.CardSystem
{
/// <summary>
/// UI page for viewing the player's card collection in an album.
/// Manages booster pack button visibility and opening flow.
/// </summary>
public class AlbumViewPage : UIPage
{
[Header("UI References")]
[SerializeField] private CanvasGroup canvasGroup;
[SerializeField] private Button exitButton;
[SerializeField] private BookCurlPro.BookPro book;
[Header("Zone Navigation")]
[SerializeField] private Transform tabContainer; // Container holding all BookTabButton children
private BookTabButton[] _zoneTabs; // Discovered zone tab buttons
[Header("Album Card Reveal")]
[SerializeField] private SlotContainer bottomRightSlots;
[FormerlySerializedAs("albumCardPlacementPrefab")]
[SerializeField] private GameObject cardPrefab; // New Card prefab for placement
[Header("Card Enlarge System")]
[SerializeField] private GameObject cardEnlargedBackdrop; // Backdrop to block interactions
[SerializeField] private Transform cardEnlargedContainer; // Container for enlarged cards (sits above backdrop)
[Header("Booster Pack UI")]
[SerializeField] private GameObject[] boosterPackButtons;
[SerializeField] private BoosterOpeningPage boosterOpeningPage;
private Input.InputMode _previousInputMode;
// Controllers: Lazy-initialized services (auto-created on first use)
private CornerCardManager _cornerCardManager;
private CornerCardManager CornerCards => _cornerCardManager ??= new CornerCardManager(
bottomRightSlots,
cardPrefab,
this
);
private AlbumNavigationService _navigationService;
private AlbumNavigationService Navigation => _navigationService ??= new AlbumNavigationService(
book,
_zoneTabs
);
private CardEnlargeController _enlargeController;
private CardEnlargeController Enlarge => _enlargeController ??= new CardEnlargeController(
cardEnlargedBackdrop,
cardEnlargedContainer
);
/// <summary>
/// Query method: Check if the book is currently flipping to a page.
/// Used by card states to know if they should wait before placing.
/// </summary>
public bool IsPageFlipping => Navigation.IsPageFlipping;
internal override void OnManagedStart()
{
// Discover zone tabs from container
DiscoverZoneTabs();
// Make sure we have a CanvasGroup for transitions
if (canvasGroup == null)
canvasGroup = GetComponent<CanvasGroup>();
if (canvasGroup == null)
canvasGroup = gameObject.AddComponent<CanvasGroup>();
// Hide backdrop initially
if (cardEnlargedBackdrop != null)
{
cardEnlargedBackdrop.SetActive(false);
}
// Set up exit button
if (exitButton != null)
{
exitButton.onClick.AddListener(OnExitButtonClicked);
}
// Set up booster pack button listeners
SetupBoosterButtonListeners();
// Subscribe to book page flip events
if (book != null)
{
book.OnFlip.AddListener(OnPageFlipped);
Logging.Debug("[AlbumViewPage] Subscribed to book.OnFlip event");
}
else
{
Logging.Warning("[AlbumViewPage] Book reference is null, cannot subscribe to OnFlip event!");
}
// Subscribe to CardSystemManager events (managers are guaranteed to be initialized)
if (CardSystemManager.Instance != null)
{
CardSystemManager.Instance.OnBoosterCountChanged += OnBoosterCountChanged;
// NOTE: OnPendingCardAdded is subscribed in TransitionIn, not here
// This prevents spawning cards when page is not active
// Update initial button visibility
int initialCount = CardSystemManager.Instance.GetBoosterPackCount();
UpdateBoosterButtons(initialCount);
}
// UI pages should start disabled
gameObject.SetActive(false);
}
/// <summary>
/// Discover all BookTabButton components from the tab container
/// </summary>
private void DiscoverZoneTabs()
{
if (tabContainer == null)
{
Debug.LogError("[AlbumViewPage] Tab container is not assigned! Cannot discover zone tabs.");
_zoneTabs = new BookTabButton[0];
return;
}
// Get all BookTabButton components from children
_zoneTabs = tabContainer.GetComponentsInChildren<BookTabButton>(includeInactive: false);
if (_zoneTabs == null || _zoneTabs.Length == 0)
{
Logging.Warning($"[AlbumViewPage] No BookTabButton components found in tab container '{tabContainer.name}'!");
_zoneTabs = new BookTabButton[0];
}
else
{
Logging.Debug($"[AlbumViewPage] Discovered {_zoneTabs.Length} zone tabs from container '{tabContainer.name}'");
foreach (var tab in _zoneTabs)
{
Logging.Debug($" - Tab: {tab.name}, Zone: {tab.Zone}, TargetPage: {tab.TargetPage}");
}
}
}
private void SetupBoosterButtonListeners()
{
if (boosterPackButtons == null) return;
for (int i = 0; i < boosterPackButtons.Length; i++)
{
if (boosterPackButtons[i] == null) continue;
Button button = boosterPackButtons[i].GetComponent<Button>();
if (button != null)
{
button.onClick.AddListener(OnBoosterButtonClicked);
}
}
}
internal override void OnManagedDestroy()
{
// Unsubscribe from book events
if (book != null)
{
book.OnFlip.RemoveListener(OnPageFlipped);
}
// Unsubscribe from CardSystemManager
if (CardSystemManager.Instance != null)
{
CardSystemManager.Instance.OnBoosterCountChanged -= OnBoosterCountChanged;
}
// Clean up exit button
if (exitButton != null)
{
exitButton.onClick.RemoveListener(OnExitButtonClicked);
}
// Clean up booster button listeners
if (boosterPackButtons != null)
{
foreach (var buttonObj in boosterPackButtons)
{
if (buttonObj == null) continue;
Button button = buttonObj.GetComponent<Button>();
if (button != null)
{
button.onClick.RemoveListener(OnBoosterButtonClicked);
}
}
}
// Clean up pending corner cards
CleanupPendingCornerCards();
}
private void OnExitButtonClicked()
{
if (book != null && book.CurrentPaper != 1)
{
// Not on page 0, flip to page 0 first
BookCurlPro.AutoFlip autoFlip = book.GetComponent<BookCurlPro.AutoFlip>();
if (autoFlip == null)
{
autoFlip = book.gameObject.AddComponent<BookCurlPro.AutoFlip>();
}
autoFlip.enabled = true;
autoFlip.StartFlipping(1);
}
else
{
// Already on page 0 or no book reference, exit
// Restore input mode before popping
if (Input.InputManager.Instance != null)
{
Input.InputManager.Instance.SetInputMode(_previousInputMode);
Logging.Debug($"[AlbumViewPage] Restored input mode to {_previousInputMode} on exit");
}
if (UIPageController.Instance != null)
{
UIPageController.Instance.PopPage();
}
}
}
private void OnBoosterCountChanged(int newCount)
{
UpdateBoosterButtons(newCount);
}
private void UpdateBoosterButtons(int boosterCount)
{
if (boosterPackButtons == null || boosterPackButtons.Length == 0) return;
int visibleCount = Mathf.Min(boosterCount, boosterPackButtons.Length);
for (int i = 0; i < boosterPackButtons.Length; i++)
{
if (boosterPackButtons[i] != null)
{
boosterPackButtons[i].SetActive(i < visibleCount);
}
}
}
private void OnBoosterButtonClicked()
{
if (boosterOpeningPage != null && UIPageController.Instance != null)
{
// Pass current booster count to the opening page
int boosterCount = CardSystemManager.Instance?.GetBoosterPackCount() ?? 0;
boosterOpeningPage.SetAvailableBoosterCount(boosterCount);
UIPageController.Instance.PushPage(boosterOpeningPage);
}
}
public override void TransitionIn()
{
// Only store and switch input mode if this is the first time entering
if (Input.InputManager.Instance != null)
{
// Store the current input mode before switching
_previousInputMode = Input.InputMode.GameAndUI;
Input.InputManager.Instance.SetInputMode(Input.InputMode.UI);
Logging.Debug("[AlbumViewPage] Switched to UI-only input mode on first entry");
}
// Only spawn pending cards if we're already on an album page (not the menu)
if (IsInAlbumProper())
{
Logging.Debug("[AlbumViewPage] Opening directly to album page - spawning cards immediately");
SpawnPendingCornerCards();
}
else
{
Logging.Debug("[AlbumViewPage] Opening to menu page - cards will spawn when entering album");
}
base.TransitionIn();
}
public override void TransitionOut()
{
// Clean up active pending cards to prevent duplicates on next opening
CleanupPendingCornerCards();
// Don't restore input mode here - only restore when actually exiting (in OnExitButtonClicked)
base.TransitionOut();
}
protected override void DoTransitionIn(System.Action onComplete)
{
// Simple fade in animation
if (canvasGroup != null)
{
canvasGroup.alpha = 0f;
Tween.Value(0f, 1f, (value) => canvasGroup.alpha = value, transitionDuration, 0f, Tween.EaseInOut, Tween.LoopType.None, null, onComplete, obeyTimescale: false);
}
else
{
// Fallback if no CanvasGroup
onComplete?.Invoke();
}
}
protected override void DoTransitionOut(System.Action onComplete)
{
// Clean up any enlarged card state before closing
CleanupEnlargedCardState();
// Simple fade out animation
if (canvasGroup != null)
{
Tween.Value(canvasGroup.alpha, 0f, (value) => canvasGroup.alpha = value, transitionDuration, 0f, Tween.EaseInOut, Tween.LoopType.None, null, onComplete, obeyTimescale: false);
}
else
{
// Fallback if no CanvasGroup
onComplete?.Invoke();
}
}
/// <summary>
/// Clean up enlarged card state when closing the album
/// </summary>
private void CleanupEnlargedCardState()
{
Enlarge.CleanupEnlargedState();
}
/// <summary>
/// Check if we're currently viewing the album proper (not the menu page)
/// </summary>
private bool IsInAlbumProper()
{
return Navigation.IsInAlbumProper();
}
/// <summary>
/// Called when book page flips - show/hide pending cards based on whether we're in the album proper
/// </summary>
private void OnPageFlipped()
{
bool isInAlbum = IsInAlbumProper();
if (isInAlbum && CornerCards.PendingCards.Count == 0)
{
// Entering album proper and no cards spawned yet - spawn them with animation
Logging.Debug("[AlbumViewPage] Entering album proper - spawning pending cards with animation");
SpawnPendingCornerCards();
}
else if (!isInAlbum && CornerCards.PendingCards.Count > 0)
{
// Returning to menu page - cleanup cards
Logging.Debug("[AlbumViewPage] Returning to menu page - cleaning up pending cards");
CleanupPendingCornerCards();
}
else
{
Logging.Debug($"[AlbumViewPage] Page flipped but no card state change needed (already in correct state)");
}
}
#region Card Enlarge System (Album Slots)
/// <summary>
/// Subscribe to a placed card's enlarged state events to manage backdrop and reparenting.
/// Called by AlbumCardSlot when it spawns an owned card in a slot.
/// </summary>
public void RegisterCardInAlbum(StateMachine.Card card)
{
Enlarge.RegisterCard(card);
}
public void UnregisterCardInAlbum(StateMachine.Card card)
{
Enlarge.UnregisterCard(card);
}
#endregion
/// <summary>
/// Find a slot by its SlotIndex property
/// </summary>
private DraggableSlot FindSlotByIndex(int slotIndex)
{
if (bottomRightSlots == null)
return null;
foreach (var slot in bottomRightSlots.Slots)
{
if (slot.SlotIndex == slotIndex)
{
return slot;
}
}
return null;
}
public void SpawnPendingCornerCards()
{
CornerCards.SpawnCards();
}
private void CleanupPendingCornerCards()
{
CornerCards.CleanupAllCards();
}
#region Query Methods for Card States (Data Providers)
/// <summary>
/// Query method: Get card data for a pending slot.
/// Called by PendingFaceDownState when drag starts.
/// IMPORTANT: This removes the card from pending list immediately, then rebuilds corner.
/// </summary>
public CardData GetCardForPendingSlot()
{
return CornerCards.GetSmartSelection();
}
/// <summary>
/// Query method: Get target slot for a card.
/// Called by PendingFaceDownState to find where card should go.
/// </summary>
public AlbumCardSlot GetTargetSlotForCard(CardData cardData)
{
if (cardData == null) return null;
var allSlots = FindObjectsByType<AlbumCardSlot>(FindObjectsSortMode.None);
foreach (var slot in allSlots)
{
if (slot.TargetCardDefinition != null &&
slot.TargetCardDefinition.Id == cardData.DefinitionId)
{
return slot;
}
}
return null;
}
/// <summary>
/// Service method: Navigate to the page for a specific card.
/// Called by PendingFaceDownState to flip book to correct zone page.
/// </summary>
public void NavigateToCardPage(CardData cardData, System.Action onComplete)
{
Navigation.NavigateToCardPage(cardData, onComplete);
}
/// <summary>
/// Notify that a card has been placed (for cleanup).
/// Called by PlacedInSlotState after placement is complete.
/// </summary>
public void NotifyCardPlaced(StateMachine.Card card)
{
// Delegate to corner card manager for tracking removal
CornerCards.NotifyCardPlaced(card);
// Register for enlarge/shrink functionality
RegisterCardInAlbum(card);
}
#endregion
#region Helper Methods
public List<string> GetDefinitionsOnCurrentPage()
{
return Navigation.GetDefinitionsOnCurrentPage();
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 59ff936424a34ce3937299c66232bf7a
timeCreated: 1759923921

View File

@@ -0,0 +1,817 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using AppleHills.Data.CardSystem;
using Core;
using Data.CardSystem;
using Pixelplacement;
using UI.Core;
using UI.CardSystem.DragDrop;
using UI.DragAndDrop.Core;
using Unity.Cinemachine;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace UI.CardSystem
{
/// <summary>
/// UI page for opening booster packs and displaying the cards received.
/// Manages the entire booster opening flow with drag-and-drop interaction.
/// </summary>
public class BoosterOpeningPage : UIPage
{
[Header("UI References")]
[SerializeField] private CanvasGroup canvasGroup;
[Header("Booster Management")]
[SerializeField] private GameObject boosterPackPrefab; // Prefab to instantiate new boosters
[SerializeField] private SlotContainer bottomRightSlots; // Holds waiting boosters (max 3)
[SerializeField] private DraggableSlot centerOpeningSlot; // Where booster goes to open
[Header("Card Display")]
[SerializeField] private Transform cardDisplayContainer;
[FormerlySerializedAs("flippableCardPrefab")]
[SerializeField] private GameObject cardPrefab; // New Card prefab using state machine
[SerializeField] private float cardSpacing = 150f;
[SerializeField] private float cardWidth = 400f;
[SerializeField] private float cardHeight = 540f;
[Header("Settings")]
[SerializeField] private float boosterDisappearDuration = 0.5f;
[SerializeField] private CinemachineImpulseSource impulseSource;
[SerializeField] private ParticleSystem openingParticleSystem;
[SerializeField] private GameObject albumIcon; // Target for card fly-away animation and dismiss button
private Button _dismissButton; // Button to close/dismiss the booster opening page
private int _availableBoosterCount;
private BoosterPackDraggable _currentBoosterInCenter;
private List<BoosterPackDraggable> _activeBoostersInSlots = new List<BoosterPackDraggable>();
private List<GameObject> _currentRevealedCards = new List<GameObject>();
private List<StateMachine.Card> _currentCards = new List<StateMachine.Card>();
private CardData[] _currentCardData;
private StateMachine.Card _activeCard; // Currently selected/revealing card
private int _cardsCompletedInteraction; // Track how many cards finished their reveal flow
private bool _isProcessingOpening;
private const int MAX_VISIBLE_BOOSTERS = 3;
private void Awake()
{
// Make sure we have a CanvasGroup for transitions
if (canvasGroup == null)
canvasGroup = GetComponent<CanvasGroup>();
if (canvasGroup == null)
canvasGroup = gameObject.AddComponent<CanvasGroup>();
// Get dismiss button from albumIcon GameObject
if (albumIcon != null)
{
_dismissButton = albumIcon.GetComponent<Button>();
if (_dismissButton != null)
{
_dismissButton.onClick.AddListener(OnDismissButtonClicked);
}
else
{
Logging.Warning("[BoosterOpeningPage] albumIcon does not have a Button component!");
}
}
// UI pages should start disabled
gameObject.SetActive(false);
}
internal override void OnManagedDestroy()
{
// Unsubscribe from dismiss button
if (_dismissButton != null)
{
_dismissButton.onClick.RemoveListener(OnDismissButtonClicked);
}
// Unsubscribe from slot events
if (centerOpeningSlot != null)
{
centerOpeningSlot.OnOccupied -= OnBoosterPlacedInCenter;
centerOpeningSlot.OnVacated -= OnBoosterRemovedFromCenter;
}
// Unsubscribe from booster events
UnsubscribeFromAllBoosters();
}
/// <summary>
/// Set the number of available booster packs before showing the page
/// </summary>
public void SetAvailableBoosterCount(int count)
{
_availableBoosterCount = count;
}
/// <summary>
/// Called when the dismiss button (albumIcon) is clicked
/// </summary>
private void OnDismissButtonClicked()
{
if (UIPageController.Instance != null)
{
UIPageController.Instance.PopPage();
Logging.Debug("[BoosterOpeningPage] Dismiss button clicked, popping page from stack");
}
}
public override void TransitionIn()
{
base.TransitionIn();
// Ensure album icon is visible when page opens
if (albumIcon != null)
{
albumIcon.SetActive(true);
}
InitializeBoosterDisplay();
}
public override void TransitionOut()
{
CleanupPage();
base.TransitionOut();
}
/// <summary>
/// Initialize the booster pack display based on available count
/// </summary>
private void InitializeBoosterDisplay()
{
Logging.Debug($"[BoosterOpeningPage] InitializeBoosterDisplay called with {_availableBoosterCount} boosters available");
if (boosterPackPrefab == null)
{
Logging.Warning("BoosterOpeningPage: No booster pack prefab assigned!");
return;
}
if (bottomRightSlots == null || bottomRightSlots.SlotCount == 0)
{
Logging.Warning("BoosterOpeningPage: No slots available!");
return;
}
// Clear any existing boosters
_activeBoostersInSlots.Clear();
// Calculate how many boosters to show (max 3, or available count, whichever is lower)
int visibleCount = Mathf.Min(_availableBoosterCount, MAX_VISIBLE_BOOSTERS);
Logging.Debug($"[BoosterOpeningPage] Will spawn {visibleCount} boosters");
// Spawn boosters and assign to slots
for (int i = 0; i < visibleCount; i++)
{
SpawnBoosterInSlot(i);
}
// Subscribe to center slot events
if (centerOpeningSlot != null)
{
centerOpeningSlot.OnOccupied += OnBoosterPlacedInCenter;
centerOpeningSlot.OnVacated += OnBoosterRemovedFromCenter;
Logging.Debug($"[BoosterOpeningPage] Subscribed to center slot events");
}
else
{
Logging.Warning("[BoosterOpeningPage] centerOpeningSlot is null!");
}
}
/// <summary>
/// Spawn a new booster and place it in the specified slot index
/// </summary>
private void SpawnBoosterInSlot(int slotIndex)
{
DraggableSlot slot = FindSlotByIndex(slotIndex);
if (slot == null)
{
Logging.Warning($"[BoosterOpeningPage] Could not find slot with SlotIndex {slotIndex}!");
return;
}
// Instantiate booster
GameObject boosterObj = Instantiate(boosterPackPrefab, slot.transform);
BoosterPackDraggable booster = boosterObj.GetComponent<BoosterPackDraggable>();
if (booster != null)
{
// Reset state
booster.ResetTapCount();
booster.SetTapToOpenEnabled(false);
// Subscribe to events
booster.OnReadyToOpen += OnBoosterReadyToOpen;
// Assign to slot with animation
booster.AssignToSlot(slot, true);
// Track it
_activeBoostersInSlots.Add(booster);
Logging.Debug($"[BoosterOpeningPage] Spawned booster in slot with SlotIndex {slotIndex}");
}
else
{
Logging.Warning($"[BoosterOpeningPage] Spawned booster has no BoosterPackDraggable component!");
Destroy(boosterObj);
}
}
/// <summary>
/// Remove and destroy the booster from the specified slot
/// </summary>
private void RemoveBoosterFromSlot(int slotIndex)
{
if (slotIndex >= _activeBoostersInSlots.Count)
return;
BoosterPackDraggable booster = _activeBoostersInSlots[slotIndex];
if (booster != null)
{
// Unsubscribe from events
booster.OnReadyToOpen -= OnBoosterReadyToOpen;
// Animate out and destroy
Transform boosterTransform = booster.transform;
Tween.LocalScale(boosterTransform, Vector3.zero, 0.3f, 0f, Tween.EaseInBack,
completeCallback: () =>
{
if (booster != null && booster.gameObject != null)
{
Destroy(booster.gameObject);
}
});
// Remove from slot
if (booster.CurrentSlot != null)
{
booster.CurrentSlot.Vacate();
}
Logging.Debug($"[BoosterOpeningPage] Removed booster from slot {slotIndex}");
}
_activeBoostersInSlots.RemoveAt(slotIndex);
}
/// <summary>
/// Update visible boosters based on available count
/// </summary>
private void UpdateVisibleBoosters()
{
int targetCount = Mathf.Min(_availableBoosterCount, MAX_VISIBLE_BOOSTERS);
// Remove excess boosters (from the end)
while (_activeBoostersInSlots.Count > targetCount)
{
int lastIndex = _activeBoostersInSlots.Count - 1;
RemoveBoosterFromSlot(lastIndex);
}
Logging.Debug($"[BoosterOpeningPage] Updated visible boosters: {_activeBoostersInSlots.Count}/{targetCount}");
}
/// <summary>
/// Shuffle boosters so they always occupy the first available slots
/// </summary>
private void ShuffleBoostersToFront()
{
if (_activeBoostersInSlots.Count == 0) return;
Logging.Debug($"[BoosterOpeningPage] Shuffling {_activeBoostersInSlots.Count} boosters to front slots");
// Unassign all boosters from their current slots
foreach (var booster in _activeBoostersInSlots)
{
if (booster.CurrentSlot != null)
{
booster.CurrentSlot.Vacate();
}
}
// Reassign boosters to first N slots starting from slot with SlotIndex 0
for (int i = 0; i < _activeBoostersInSlots.Count; i++)
{
// Find slot by its actual SlotIndex property
DraggableSlot targetSlot = FindSlotByIndex(i);
BoosterPackDraggable booster = _activeBoostersInSlots[i];
if (targetSlot != null)
{
Logging.Debug($"[BoosterOpeningPage] Assigning booster to slot with SlotIndex {i} {targetSlot.name}");
booster.AssignToSlot(targetSlot, true); // Animate the move
}
else
{
Logging.Warning($"[BoosterOpeningPage] Could not find slot with SlotIndex {i} {targetSlot.name}");
}
}
}
/// <summary>
/// Find a slot by its SlotIndex property (not list position)
/// </summary>
private DraggableSlot FindSlotByIndex(int slotIndex)
{
foreach (var slot in bottomRightSlots.Slots)
{
if (slot.SlotIndex == slotIndex)
{
return slot;
}
}
return null;
}
/// <summary>
/// Try to spawn a new booster to maintain up to 3 visible
/// Pass decrementCount=true when called after placing a booster in center slot
/// (accounts for the booster that will be consumed)
/// </summary>
private void TrySpawnNewBooster(bool decrementCount = false)
{
// Can we spawn more boosters?
if (_activeBoostersInSlots.Count >= MAX_VISIBLE_BOOSTERS)
return; // Already at max
// Use decremented count if this is called after placing in center
// (the booster in center will be consumed, so we check against count - 1)
int effectiveCount = decrementCount ? _availableBoosterCount - 1 : _availableBoosterCount;
if (_activeBoostersInSlots.Count >= effectiveCount)
return; // No more boosters available
// Find first available slot by SlotIndex (0, 1, 2)
for (int i = 0; i < MAX_VISIBLE_BOOSTERS; i++)
{
DraggableSlot slot = FindSlotByIndex(i);
if (slot != null && !slot.IsOccupied)
{
SpawnBoosterInSlot(i);
Logging.Debug($"[BoosterOpeningPage] Spawned new booster in slot with SlotIndex {i}");
break;
}
}
}
/// <summary>
/// Handle when a booster is placed in the center opening slot
/// </summary>
private void OnBoosterPlacedInCenter(DraggableObject draggable)
{
BoosterPackDraggable booster = draggable as BoosterPackDraggable;
if (booster == null) return;
_currentBoosterInCenter = booster;
// Remove from active slots list
_activeBoostersInSlots.Remove(booster);
// Hide album icon when booster is placed in center
if (albumIcon != null)
{
albumIcon.SetActive(false);
Logging.Debug($"[BoosterOpeningPage] Album icon hidden");
}
// Lock the slot so it can't be dragged out
Logging.Debug($"[BoosterOpeningPage] Locking center slot. IsLocked before: {centerOpeningSlot.IsLocked}");
centerOpeningSlot.SetLocked(true);
Logging.Debug($"[BoosterOpeningPage] IsLocked after: {centerOpeningSlot.IsLocked}");
// Configure booster for opening (disables drag, enables tapping, resets tap count)
Logging.Debug($"[BoosterOpeningPage] Calling SetInOpeningSlot(true) on booster");
booster.SetInOpeningSlot(true);
// Subscribe to tap events for visual feedback
booster.OnTapped += OnBoosterTapped;
booster.OnReadyToOpen += OnBoosterReadyToOpen;
booster.OnBoosterOpened += OnBoosterOpened;
// Try to spawn a new booster to maintain 3 visible
// Use decrementCount=true because this booster will be consumed
TrySpawnNewBooster(decrementCount: true);
// Shuffle remaining boosters to occupy the first slots
ShuffleBoostersToFront();
Logging.Debug($"[BoosterOpeningPage] Booster placed in center, ready for taps. Active boosters in slots: {_activeBoostersInSlots.Count}");
}
/// <summary>
/// Handle when a booster is removed from the center opening slot
/// </summary>
private void OnBoosterRemovedFromCenter(DraggableObject draggable)
{
BoosterPackDraggable booster = draggable as BoosterPackDraggable;
if (booster == null) return;
// If it's being removed back to a corner slot, add it back to tracking
if (booster.CurrentSlot != null && bottomRightSlots.HasSlot(booster.CurrentSlot))
{
_activeBoostersInSlots.Add(booster);
booster.SetInOpeningSlot(false);
}
_currentBoosterInCenter = null;
Logging.Debug($"[BoosterOpeningPage] Booster removed from center");
}
private void OnBoosterTapped(BoosterPackDraggable booster, int currentTaps, int maxTaps)
{
Logging.Debug($"[BoosterOpeningPage] Booster tapped: {currentTaps}/{maxTaps}");
// Fire Cinemachine impulse with random velocity (excluding Z)
if (impulseSource != null)
{
// Generate random velocity vector (X and Y only, Z = 0)
Vector3 randomVelocity = new Vector3(
Random.Range(-1f, 1f),
Random.Range(-1f, 1f),
0f
);
// Normalize to ensure consistent strength
randomVelocity.Normalize();
// Generate the impulse with strength 1 and random velocity
impulseSource.GenerateImpulse(randomVelocity);
}
}
/// <summary>
/// Handle when booster is opened - play particle effects
/// </summary>
private void OnBoosterOpened(BoosterPackDraggable booster)
{
Logging.Debug($"[BoosterOpeningPage] Booster opened, playing particle effect");
// Reset and play particle system
if (openingParticleSystem != null)
{
// Stop any existing playback
if (openingParticleSystem.isPlaying)
{
openingParticleSystem.Stop();
}
// Clear existing particles
openingParticleSystem.Clear();
// Play the particle system
openingParticleSystem.Play();
}
}
/// <summary>
/// Handle tap-to-place: When player taps a booster in bottom slots, move it to center
/// </summary>
public void OnBoosterTappedInBottomSlot(BoosterPackDraggable booster)
{
if (_currentBoosterInCenter != null || centerOpeningSlot == null)
return; // Center slot already occupied
// Move booster to center slot
booster.AssignToSlot(centerOpeningSlot, true);
}
/// <summary>
/// Handle when booster is ready to open (after max taps)
/// </summary>
private void OnBoosterReadyToOpen(BoosterPackDraggable booster)
{
if (_isProcessingOpening) return;
Logging.Debug($"[BoosterOpeningPage] Booster ready to open!");
// Trigger the actual opening sequence
booster.TriggerOpen();
StartCoroutine(ProcessBoosterOpening(booster));
}
/// <summary>
/// Process the booster opening sequence
/// </summary>
private IEnumerator ProcessBoosterOpening(BoosterPackDraggable booster)
{
_isProcessingOpening = true;
// Call CardSystemManager to open the pack
if (CardSystemManager.Instance != null)
{
List<CardData> revealedCardsList = CardSystemManager.Instance.OpenBoosterPack();
_currentCardData = revealedCardsList.ToArray();
// Animate booster disappearing
yield return StartCoroutine(AnimateBoosterDisappear(booster));
// Decrement available count
_availableBoosterCount--;
// Update visible boosters (remove from end if we drop below thresholds)
UpdateVisibleBoosters();
// Show cards using new Card prefab
SpawnBoosterCards(_currentCardData);
// Wait for player to reveal all cards
bool isLastBooster = _availableBoosterCount <= 0;
yield return StartCoroutine(WaitForCardReveals());
// Check if this was the last booster pack
if (isLastBooster)
{
// See earlier comment for timing
Logging.Debug("[BoosterOpeningPage] Last booster opened, auto-transitioning to album main page");
if (UIPageController.Instance != null)
{
UIPageController.Instance.PopPage();
}
}
}
_isProcessingOpening = false;
}
/// <summary>
/// Spawn cards for booster opening flow using the new Card prefab and state machine.
/// </summary>
private void SpawnBoosterCards(CardData[] cards)
{
if (cardPrefab == null || cardDisplayContainer == null)
{
Logging.Warning("BoosterOpeningPage: Missing card prefab or container!");
return;
}
_currentRevealedCards.Clear();
_currentCards.Clear();
_cardsCompletedInteraction = 0;
_activeCard = null;
int count = cards.Length;
float totalWidth = (count - 1) * cardSpacing;
float startX = -totalWidth / 2f;
for (int i = 0; i < count; i++)
{
GameObject cardObj = Instantiate(cardPrefab, cardDisplayContainer);
RectTransform cardRect = cardObj.GetComponent<RectTransform>();
if (cardRect != null)
{
cardRect.anchoredPosition = new Vector2(startX + (i * cardSpacing), 0);
cardRect.sizeDelta = new Vector2(cardWidth, cardHeight); // Set card size
cardRect.localScale = Vector3.zero; // for pop-in
}
var card = cardObj.GetComponent<StateMachine.Card>();
var context = cardObj.GetComponent<StateMachine.CardContext>();
if (card != null && context != null)
{
// Setup card for booster reveal
// States will query CardSystemManager for current collection state as needed
context.SetupCard(cards[i]);
card.SetupForBoosterReveal(cards[i], false); // isNew parameter not used anymore
card.SetDraggingEnabled(false);
// Subscribe to CardDisplay click for selection
context.CardDisplay.OnCardClicked += (_) => OnCardClicked(card);
// Subscribe to reveal flow complete event from booster context
context.BoosterContext.OnRevealFlowComplete += () => OnCardRevealComplete(card);
// Track the card
_currentCards.Add(card);
// Tween in
Tween.LocalScale(cardObj.transform, Vector3.one, 0.3f, i * 0.1f, Tween.EaseOutBack);
}
else
{
Logging.Warning($"[BoosterOpeningPage] Card component or context missing on spawned card {i}!");
}
_currentRevealedCards.Add(cardObj);
}
}
/// <summary>
/// Handle when a card is clicked - start reveal flow if conditions are met
/// </summary>
private void OnCardClicked(StateMachine.Card card)
{
// Only allow clicking idle cards when no other card is active
if (_activeCard == null && card.IsIdle && card.Context.IsClickable)
{
Logging.Debug($"[BoosterOpeningPage] Card {card.CardData?.Name} selected for reveal");
// Set as active and disable all other idle cards
_activeCard = card;
foreach (var otherCard in _currentCards)
{
if (otherCard != card && otherCard.IsIdle)
{
otherCard.Context.IsClickable = false;
}
}
// Click will route to IdleState automatically and trigger flip
}
}
/// <summary>
/// Handle when a card completes its reveal flow
/// </summary>
private void OnCardRevealComplete(StateMachine.Card card)
{
_cardsCompletedInteraction++;
Logging.Debug($"[BoosterOpeningPage] Card {card.CardData?.Name} reveal complete ({_cardsCompletedInteraction}/{_currentCardData.Length})");
// Add card to inventory NOW (after player saw it)
if (card.CardData != null)
{
Data.CardSystem.CardSystemManager.Instance.AddCardToInventoryDelayed(card.CardData);
}
// Clear active card and re-enable remaining idle cards
if (_activeCard == card)
{
_activeCard = null;
foreach (var otherCard in _currentCards)
{
if (otherCard.IsIdle)
{
otherCard.Context.IsClickable = true;
}
}
}
}
/// <summary>
/// Animate the booster pack disappearing
/// </summary>
private IEnumerator AnimateBoosterDisappear(BoosterPackDraggable booster)
{
if (booster == null) yield break;
// Scale down and fade out
Transform boosterTransform = booster.transform;
Tween.LocalScale(boosterTransform, Vector3.zero, boosterDisappearDuration, 0f, Tween.EaseInBack);
// Also fade the visual if it has a CanvasGroup
CanvasGroup boosterCg = booster.GetComponentInChildren<CanvasGroup>();
if (boosterCg != null)
{
Tween.Value(1f, 0f, (val) => boosterCg.alpha = val, boosterDisappearDuration, 0f);
}
yield return new WaitForSeconds(boosterDisappearDuration);
// Destroy the booster
Destroy(booster.gameObject);
_currentBoosterInCenter = null;
// Unlock center slot
centerOpeningSlot.SetLocked(false);
}
/// <summary>
/// Wait until all cards complete their reveal flow
/// </summary>
private IEnumerator WaitForCardReveals()
{
// Wait until all cards have completed their reveal flow
while (_cardsCompletedInteraction < _currentCardData.Length)
{
yield return null;
}
Logging.Debug($"[BoosterOpeningPage] All cards revealed! Animating cards to album...");
// Small pause
yield return new WaitForSeconds(0.5f);
// Show album icon before cards start tweening to it
if (albumIcon != null)
{
albumIcon.SetActive(true);
Logging.Debug($"[BoosterOpeningPage] Album icon shown for card tween target");
}
// Animate cards to album icon (or center if no icon assigned) with staggered delays
Vector3 targetPosition = albumIcon != null ? albumIcon.transform.position : Vector3.zero;
int cardIndex = 0;
foreach (GameObject cardObj in _currentRevealedCards)
{
if (cardObj != null)
{
float delay = cardIndex * 0.5f;
// Use world space position tween for root transform
Tween.Position(cardObj.transform, targetPosition, 0.5f, delay, Tween.EaseInBack);
Tween.LocalScale(cardObj.transform, Vector3.zero, 0.5f, delay, Tween.EaseInBack,
completeCallback: () => { if (cardObj != null) Destroy(cardObj); });
cardIndex++;
}
}
float totalAnimationTime = _currentCardData.Length * 0.5f;
_currentRevealedCards.Clear();
_currentCards.Clear();
yield return new WaitForSeconds(totalAnimationTime);
}
/// <summary>
/// Clean up the page when hidden
/// </summary>
private void CleanupPage()
{
UnsubscribeFromAllBoosters();
// Destroy all active boosters
foreach (var booster in _activeBoostersInSlots.ToList())
{
if (booster != null && booster.gameObject != null)
Destroy(booster.gameObject);
}
_activeBoostersInSlots.Clear();
// Clear any remaining cards
foreach (GameObject card in _currentRevealedCards)
{
if (card != null)
Destroy(card);
}
_currentRevealedCards.Clear();
_currentBoosterInCenter = null;
_isProcessingOpening = false;
}
/// <summary>
/// Unsubscribe from all booster events
/// </summary>
private void UnsubscribeFromAllBoosters()
{
// Unsubscribe from active boosters in slots
foreach (var booster in _activeBoostersInSlots)
{
if (booster != null)
{
booster.OnReadyToOpen -= OnBoosterReadyToOpen;
booster.OnTapped -= OnBoosterTapped;
booster.OnBoosterOpened -= OnBoosterOpened;
}
}
// Unsubscribe from center booster
if (_currentBoosterInCenter != null)
{
_currentBoosterInCenter.OnReadyToOpen -= OnBoosterReadyToOpen;
_currentBoosterInCenter.OnTapped -= OnBoosterTapped;
_currentBoosterInCenter.OnBoosterOpened -= OnBoosterOpened;
}
}
protected override void DoTransitionIn(System.Action onComplete)
{
// Simple fade in animation
if (canvasGroup != null)
{
canvasGroup.alpha = 0f;
Tween.Value(0f, 1f, (value) => canvasGroup.alpha = value, transitionDuration, 0f, Tween.EaseInOut, Tween.LoopType.None, null, onComplete, obeyTimescale: false);
}
else
{
// Fallback if no CanvasGroup
onComplete?.Invoke();
}
}
protected override void DoTransitionOut(System.Action onComplete)
{
// Simple fade out animation
if (canvasGroup != null)
{
Tween.Value(canvasGroup.alpha, 0f, (value) => canvasGroup.alpha = value, transitionDuration, 0f, Tween.EaseInOut, Tween.LoopType.None, null, onComplete, obeyTimescale: false);
}
else
{
// Fallback if no CanvasGroup
onComplete?.Invoke();
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 91691a5efb1346b5b34482dd8200c868
timeCreated: 1762418615