- **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
19 KiB
Card System Guide
Complete guide to the Apple Hills card collecting and album system.
Table of Contents
- Overview
- Quick Start
- System Architecture
- Card Flows
- Key Components
- Working with Cards in Code
- Extending the System
- Troubleshooting
Overview
The Card System manages the player's collectible card journey from booster pack opening to album placement. It consists of:
- Booster Opening Flow: Interactive pack opening with card reveals
- Album Placement Flow: Drag-and-drop cards into album slots
- State Machine: Each card progresses through well-defined states
- Album Navigation: Book-based album with zone tabs
- Notification System: Visual feedback for new boosters/pending cards
Core Concepts
CardData: Runtime data for a single card (definition ID, rarity, zone, image) CardDefinition: ScriptableObject template defining card properties Card States: Cards transition through states (Idle → Revealed → Dragging → Placed) Pending Cards: Cards waiting to be placed in the album (shown in bottom-right corner)
Quick Start
Opening the Album
// From any script
if (UIPageController.Instance != null)
{
UIPageController.Instance.PushPage(albumViewPage);
}
Granting Booster Packs
// Simple API
CardSystemManager.Instance.AddBoosterPack(1);
// Visual grant with animation (from minigames)
MinigameBoosterGiver.Instance.GiveBooster(() => {
Debug.Log("Booster granted and animation complete!");
});
Opening a Booster Pack (Programmatic)
// Returns list of cards in the pack
List<CardData> cards = CardSystemManager.Instance.OpenBoosterPack();
// The cards are automatically added to "pending" state
// Player must place them in album manually
Checking Player's Collection
var inventory = CardSystemManager.Instance.GetCardInventory();
// Check if player owns a specific card
CardData ownedCard = inventory.GetCard("CardDefID", CardRarity.Legendary);
// Get all cards in a zone
List<CardData> townCards = inventory.GetCardsByZone(CardZone.Town);
// Check completion percentage
float completion = inventory.GetCompletionPercentage();
System Architecture
Component Hierarchy
CardSystemManager (Singleton)
├── Inventory Management
├── Booster Pack Logic
└── Pending Card Queue
AlbumViewPage (UI Page)
├── CornerCardManager (Non-Component)
│ └── Manages 3 pending cards in corner
├── AlbumNavigationService (Non-Component)
│ └── Book page flipping & zone navigation
└── CardEnlargeController (Non-Component)
└── Backdrop & enlarge/shrink animations
BoosterOpeningPage (UI Page)
└── Manages pack opening flow & card reveals
Card (MonoBehaviour)
├── CardContext (shared state)
├── CardAnimator (animations)
├── CardDisplay (visuals)
└── StateMachine (10 possible states)
Non-Component Controllers
The system uses Controller pattern for complex logic without Unity lifecycle overhead:
- CornerCardManager: Spawns/despawns pending cards, smart selection, shuffle logic
- AlbumNavigationService: Book page navigation, zone mapping
- CardEnlargeController: Backdrop visibility, card reparenting for enlarge view
- ProgressBarController: Booster opening progress visualization
These are instantiated lazily via C# properties:
private CornerCardManager _cornerCardManager;
private CornerCardManager CornerCards => _cornerCardManager ??= new CornerCardManager(...);
Card Flows
1. Booster Opening Flow
Player opens booster pack
↓
BoosterOpeningPage spawns cards face-down
↓
Player clicks a card (IdleState → EnlargedNewState/EnlargedRepeatState)
↓
Card flips & enlarges, shows NEW or REPEAT badge
↓
Player clicks to dismiss (→ RevealedState)
↓
Repeat until all cards revealed
↓
Cards fly to album icon, added to pending queue
↓
Page auto-closes (or waits for player)
Key States:
- IdleState: Face-down, awaiting click
- EnlargedNewState: NEW badge (first time collection)
- EnlargedRepeatState: REPEAT badge (duplicate)
- EnlargedLegendaryRepeatState: Special legendary repeat state
- RevealedState: Face-up, dismissed from center
2. Album Placement Flow
AlbumViewPage opens
↓
CornerCardManager spawns up to 3 pending cards (face-down)
↓
Player drags card (PendingFaceDownState → DraggingRevealedState)
↓
Card data assigned, flips to reveal, book navigates to correct zone page
↓
Player drops card on matching AlbumCardSlot
↓
Card placed (→ PlacedInSlotState), registered with AlbumViewPage
↓
CornerCardManager rebuilds: shuffles remaining cards, spawns new if available
Key States:
- PendingFaceDownState: Face-down in corner, no data assigned yet
- DraggingRevealedState: Data assigned, flipped, dragging to slot
- PlacedInSlotState: Locked in album slot
- AlbumEnlargedState: Clicked from slot, enlarged for viewing
3. Album Card Viewing
Player clicks placed card in album slot
↓
Card enlarges (PlacedInSlotState → AlbumEnlargedState)
↓
Backdrop shown, card reparented to enlarged container
↓
Card animates to center with dramatic scale increase
↓
Player clicks card or backdrop to dismiss
↓
Card shrinks & animates back to slot (→ PlacedInSlotState)
↓
Backdrop hidden, card reparented back to slot
Key Components
CardSystemManager
Singleton managing all card data and inventory.
// Access
CardSystemManager.Instance
// Key Methods
.AddBoosterPack(int count) // Grant booster packs
.OpenBoosterPack() // Open a pack, returns CardData[]
.GetPendingRevealCards() // Get cards waiting for album placement
.GetCardInventory() // Access player's collection
.AddCardToInventory(CardData) // Add card to collection
.RemoveFromPending(CardData) // Remove from pending queue
// Events
.OnBoosterCountChanged(int newCount)
.OnPendingCardAdded(CardData)
.OnPendingCardRemoved(CardData)
AlbumViewPage
UI Page for viewing and managing the album.
// Setup in scene:
// - Assign book reference (BookPro component)
// - Assign zone tab container
// - Assign card prefab for spawning
// - Assign backdrop & enlarged container for card viewing
// - Link to BoosterOpeningPage
// Query Methods (used by Card states)
public CardData GetCardForPendingSlot() // Smart card selection
public AlbumCardSlot GetTargetSlotForCard(CardData) // Find destination slot
public void NavigateToCardPage(CardData, Action) // Flip to correct page
public void NotifyCardPlaced(Card) // Cleanup after placement
// Public Properties
public bool IsPageFlipping // For state timing checks
BoosterOpeningPage
UI Page for opening booster packs.
// Setup in scene:
// - Assign booster pack prefab
// - Assign corner slots for waiting boosters (max 3)
// - Assign center slot for opening
// - Assign card display container
// - Assign card prefab
// - Assign album icon (dismiss button & tween target)
// Call before showing:
.SetAvailableBoosterCount(int count) // How many boosters player has
// Flow automatically managed by page
Card States
Each card uses AppleMachine state machine with these states:
| State | Purpose | Entry Trigger |
|---|---|---|
| IdleState | Face-down in booster opening | Card spawned for booster reveal |
| EnlargedNewState | Enlarged with NEW badge | Clicked first-time card |
| EnlargedRepeatState | Enlarged with REPEAT badge | Clicked duplicate |
| EnlargedLegendaryRepeatState | Legendary repeat variant | Clicked legendary duplicate |
| RevealedState | Face-up, dismissed | Dismissed from enlarged state |
| PendingFaceDownState | Face-down in corner | Spawned in album corner |
| DraggingRevealedState | Face-up while dragging | Dragged from corner |
| PlacedInSlotState | Locked in album slot | Dropped on correct slot |
| AlbumEnlargedState | Enlarged from album | Clicked while in slot |
| DraggingState | Generic drag state | (unused in current flow) |
Card Context & Components
Every card has:
- CardContext: Shared state, component references, events
- CardAnimator: Centralized animation methods
- CardDisplay: Visual rendering (image, frame, overlay, rarity/zone styling)
- AppleMachine: State machine controller
// Accessing card components
var card = GetComponent<Card>();
card.Context.CardData // CardData
card.Context.Animator // CardAnimator
card.Context.CardDisplay // CardDisplay
card.Context.StateMachine // AppleMachine
card.Context.AlbumViewPage // Injected page reference
Working with Cards in Code
Spawning a Card for Booster Opening
GameObject cardObj = Instantiate(cardPrefab, containerTransform);
var card = cardObj.GetComponent<Card>();
var context = cardObj.GetComponent<CardContext>();
// Setup card data
context.SetupCard(cardData);
// Start in IdleState for booster reveal
card.SetupForBoosterReveal(cardData, isNew); // isNew unused, states query inventory
// Subscribe to reveal complete event
context.BoosterContext.OnRevealFlowComplete += () => {
Debug.Log("Card reveal finished!");
};
Spawning a Card for Album Corner
GameObject cardObj = Instantiate(cardPrefab, slotTransform);
var card = cardObj.GetComponent<Card>();
// Assign to slot FIRST
card.AssignToSlot(slot, animateMove: false);
// Inject AlbumViewPage dependency
card.Context.SetAlbumViewPage(albumViewPage);
// Setup for pending state (no data yet)
card.SetupForAlbumPending(); // Starts in PendingFaceDownState
Spawning a Card Already in Album Slot
GameObject cardObj = Instantiate(cardPrefab, slotTransform);
var card = cardObj.GetComponent<Card>();
// Setup for album slot (already owned)
card.SetupForAlbumSlot(cardData, albumCardSlot); // Starts in PlacedInSlotState
// Register for enlarge/shrink functionality
albumViewPage.RegisterCardInAlbum(card);
Transitioning Card States Manually
// Get card component
var card = GetComponent<Card>();
// Change state
card.ChangeState(CardStateNames.Revealed);
// Check current state
string currentState = card.GetCurrentStateName();
if (currentState == CardStateNames.PlacedInSlot)
{
Debug.Log("Card is placed in album!");
}
// Get specific state component
var enlargedState = card.GetStateComponent<CardAlbumEnlargedState>(
CardStateNames.AlbumEnlarged
);
if (enlargedState != null)
{
// Access state-specific methods/properties
}
Subscribing to Card Events
var context = card.Context;
// Drag events
context.OnDragStarted += (ctx) => Debug.Log("Drag started!");
context.OnDragEnded += (ctx) => Debug.Log("Drag ended!");
// Click events (routed through CardDisplay)
context.CardDisplay.OnCardClicked += (display) => Debug.Log("Card clicked!");
// Booster reveal events
context.BoosterContext.OnRevealFlowComplete += () => {
Debug.Log("Reveal complete!");
CardSystemManager.Instance.AddCardToInventory(context.CardData);
};
// State machine events (if needed)
context.StateMachine.OnStateChange += (newState) => Debug.Log($"State: {newState.name}");
Custom Animations
var animator = card.Animator;
// Built-in animations
animator.PopIn(duration: 0.5f, onComplete: () => Debug.Log("Popped in!"));
animator.PopOut(duration: 0.3f);
animator.PlayEnlarge(targetScale: 2.5f);
animator.PlayShrink(targetScale: Vector3.one);
// Combine animations
animator.AnimateLocalPosition(Vector3.zero, duration: 0.5f);
animator.AnimateScale(Vector3.one * 1.5f, duration: 0.3f);
// Flip animation
Transform cardBack = card.transform.Find("CardBack");
Transform cardFront = card.transform.Find("CardDisplay");
animator.PlayFlip(cardBack, cardFront, duration: 0.6f, onComplete: () => {
Debug.Log("Flip complete!");
});
animator.PlayFlipScalePunch(punchScale: 1.1f);
// Hover effects
Vector2 originalPos = animator.GetAnchoredPosition();
animator.HoverEnter(liftAmount: 20f, scaleMultiplier: 1.05f);
// ... later
animator.HoverExit(originalPos);
Extending the System
Adding a New Card State
-
Create State Script in
StateMachine/States/using Core.SaveLoad; using UI.CardSystem.StateMachine; public class MyNewCardState : AppleState, ICardClickHandler { private CardContext _context; public override void EnterState() { _context = GetComponentInParent<CardContext>(); // Setup animations, visuals, etc. } public void OnCardClicked(CardContext context) { // Handle click behavior } public override void ExitState() { // Cleanup } } -
Add State Name to
CardStateNames.cspublic const string MyNewState = "MyNewCardState"; -
Add GameObject as child of Card prefab's AppleMachine with your state component attached
-
Transition to State
card.ChangeState(CardStateNames.MyNewState);
Creating a Custom Card Visual Effect
public class MyCardEffect : MonoBehaviour
{
private CardContext _context;
void Start()
{
_context = GetComponentInParent<CardContext>();
// Subscribe to state changes
_context.StateMachine.OnStateChange += OnStateChanged;
}
void OnStateChanged(AppleState newState)
{
if (newState.name == CardStateNames.EnlargedNew)
{
// Play custom effect
PlaySparkleEffect();
}
}
void PlaySparkleEffect()
{
// Your custom effect logic
}
}
Adding Custom Card Slots
public class MyCustomCardSlot : AlbumCardSlot
{
protected override void OnCardPlaced(Card card)
{
base.OnCardPlaced(card);
// Custom logic when card placed
PlaySpecialEffect();
}
}
Troubleshooting
Cards Not Showing in Corner
Check:
- AlbumViewPage has
cardPrefabassigned - AlbumViewPage has
bottomRightSlotsassigned (SlotContainer with 3 slots) - CardSystemManager has pending cards:
GetPendingRevealCards().Count > 0 - Page is in album proper (not menu page): Check with
IsInAlbumProper()
Debug:
Debug.Log($"Pending cards: {CardSystemManager.Instance.GetPendingRevealCards().Count}");
Debug.Log($"Is in album: {albumViewPage.IsInAlbumProper()}");
Cards Spawning on Top of Each Other
Cause: Corner slots not properly configured with SlotIndex property
Fix:
- Ensure each slot has unique
SlotIndex(0, 1, 2) - Verify in inspector: Select each slot → Check
SlotIndexfield
Card Won't Flip
Check:
- Card has
CardBackchild GameObject - Card has
CardDisplaychild GameObject - Both have proper hierarchy:
Card → CardBack,Card → CardDisplay - State transitions are correct
Debug:
var cardBack = card.transform.Find("CardBack");
var cardDisplay = card.transform.Find("CardDisplay");
Debug.Log($"Back found: {cardBack != null}, Display found: {cardDisplay != null}");
Debug.Log($"Current state: {card.GetCurrentStateName()}");
Book Won't Flip to Correct Page
Check:
- BookTabButton components configured with correct
zoneandtargetPage - AlbumViewPage has
tabContainerassigned - BookPro component reference assigned on AlbumViewPage
Debug:
// In AlbumViewPage
foreach (var tab in _zoneTabs)
{
Debug.Log($"Tab: {tab.Zone} → Page {tab.TargetPage}");
}
Cards Not Enlarging When Clicked in Album
Check:
- Card is in
PlacedInSlotState - AlbumViewPage has
cardEnlargedBackdropandcardEnlargedContainerassigned - Card was registered:
albumViewPage.RegisterCardInAlbum(card)
Debug:
var enlargedState = card.GetStateComponent<CardAlbumEnlargedState>(CardStateNames.AlbumEnlarged);
Debug.Log($"Enlarged state found: {enlargedState != null}");
Debug.Log($"Current state: {card.GetCurrentStateName()}");
Memory Leaks / Cards Not Destroying
Cause: Event subscriptions not cleaned up
Fix Pattern:
void OnEnable()
{
card.Context.OnDragStarted += HandleDrag;
}
void OnDisable()
{
if (card != null && card.Context != null)
{
card.Context.OnDragStarted -= HandleDrag;
}
}
Booster Packs Not Appearing
Check:
- BoosterOpeningPage has
boosterPackPrefabassigned - BoosterOpeningPage has
bottomRightSlotsassigned - Called
SetAvailableBoosterCount()before showing page
Debug:
Debug.Log($"Booster count: {CardSystemManager.Instance.GetBoosterPackCount()}");
Debug.Log($"Booster prefab: {boosterOpeningPage.boosterPackPrefab != null}");
State Diagram
BOOSTER OPENING FLOW:
IdleState ──click──> EnlargedNewState ──click──> RevealedState
└──> EnlargedRepeatState ──┘
└──> EnlargedLegendaryRepeatState ──┘
ALBUM PLACEMENT FLOW:
PendingFaceDownState ──drag──> DraggingRevealedState ──drop on slot──> PlacedInSlotState
│
click
│
↓
AlbumEnlargedState
│
click
│
↓
PlacedInSlotState
Additional Resources
- Code Location:
Assets/Scripts/UI/CardSystem/ - Card Prefabs:
Assets/Prefabs/UI/Cards/ - Card Definitions:
Assets/Data/CardSystem/Definitions/ - Album Scene:
Assets/Scenes/Album.unity
Related Systems:
- UIPageController Documentation - UI page stack navigation
- DragAndDrop System - Base drag/drop framework
- Settings System - Card system configuration
Last Updated: November 18, 2025
Version: 1.0
Contributors: Development Team