13 KiB
Card System Architecture Audit
Date: November 11, 2025
Author: Senior Software Engineer
Status: Critical Review
Executive Summary
The current card UI system suffers from excessive wrapper nesting, duplicated animation logic, and unclear separation of concerns. While functional, it violates DRY principles and creates maintenance overhead. A refactor using composition and state machines is recommended.
Current Architecture
Component Hierarchy
CardDisplay (core visual renderer)
└─ AlbumCard (album-specific wrapper)
└─ FlippableCard (flip animation wrapper)
└─ AlbumCardPlacementDraggable (drag/placement wrapper)
└─ CardDraggable (generic drag wrapper)
└─ CardDraggableVisual (visual for dragging)
Critical Issues
1. Wrapper Hell
- 5 layers of wrappers around a single card display
- Each wrapper duplicates transform/animation state management
- Example:
FlippableCard,AlbumCard, andCardDraggableall manage scales, positions, and parent tracking - Code smell:
AlbumCard.OnPointerClick()forwards clicks to parentFlippableCardduring reveal flow
2. Duplicated Animation Logic
Animation behaviors repeated across multiple components:
| Animation | FlippableCard | AlbumCard | CardDraggable | AlbumCardPlacementDraggable |
|---|---|---|---|---|
| Scale tweens | ✓ (hover, flip punch) | ✓ (enlarge/shrink) | - | - |
| Position tweens | ✓ (idle hover) | - | ✓ (drag) | ✓ (snap to slot) |
| Rotation tweens | ✓ (flip) | - | - | - |
| Transform state tracking | ✓ (_originalPosition, _originalScale) | ✓ (_originalParent, _originalLocalPosition, _originalLocalRotation) | - | - |
Impact: ~150 lines of redundant tween/transform code across 4 files.
3. State Management Chaos
Multiple boolean flags tracking overlapping states:
FlippableCard:_isFlipped,_isFlipping,_isWaitingForTap,_isClickable,_isNewAlbumCard:_isEnlarged,_parentSlot != null(implicit state)AlbumCardPlacementDraggable:_isRevealed,_isDragRevealing,_waitingForPlacementTap,_isHolding
Problems:
- No single source of truth for card state
- Complex conditional logic:
if (_parentSlot == null) { forward to FlippableCard } - State transitions scattered across 3+ classes
4. Unclear Responsibilities
CardDisplay: Pure renderer ✓ (well-designed)AlbumCard: Handles enlargement + slot parenting + click forwardingFlippableCard: Handles flipping + hover animations + new/repeat UI + waiting for tapsAlbumCardPlacementDraggable: Handles drag + flip triggering + slot snapping
Each wrapper blurs the line between "what" (state) and "how" (presentation).
5. Event Callback Spaghetti
- 12+ events across components (
OnEnlargeRequested,OnShrinkRequested,OnCardRevealed,OnCardTappedAfterReveal,OnFlipStarted,OnClickedWhileInactive, etc.) - Events chained:
AlbumCard.OnEnlargeRequested→AlbumViewPage→ reparent →AlbumCard.EnlargeCard() - Brittle: Changing card flow requires updating 3-4 components + page controllers
Recommended Architecture
Principles
- Composition over inheritance/wrapping
- Single Responsibility: Card visuals ≠ card behavior ≠ card state
- State machines for clear state transitions
- Reusable animation system instead of per-component tweens
Proposed Design
Using Pixelplacement StateMachine (already in project) with isolated state-owned visuals:
Card (root GameObject with RectTransform)
├─ CardDisplay (always visible core visual)
├─ CardContext (component - shared data/references)
├─ CardAnimator (component - reusable animations)
└─ CardStateMachine (AppleMachine component)
├─ IdleState (GameObject + CardIdleState component)
├─ FlippingState (GameObject + CardFlippingState component)
│ └─ CardBackVisual (child GameObject - owned by this state)
├─ RevealedState (GameObject + CardRevealedState component)
├─ EnlargedNewState (GameObject + CardEnlargedNewState component)
│ └─ NewCardBadge (child GameObject - owned by this state)
├─ EnlargedRepeatState (GameObject + CardEnlargedRepeatState component)
│ └─ ProgressBarUI (child GameObject - owned by this state)
├─ DraggingState (GameObject + CardDraggingState component)
└─ PlacedInSlotState (GameObject + CardPlacedInSlotState component)
Key Architecture Decisions:
-
State Isolation: Each state is a GameObject child of the StateMachine. State-specific visual elements (CardBackVisual, NewCardBadge, ProgressBarUI) are children of their state GameObject. When a state activates, its children activate automatically.
-
Transform Animation Target: The root Card.transform is the primary animation target. All position/scale animations affect the root, and children inherit transforms naturally. States can also animate their own child visuals independently (e.g., rotating CardBackVisual during flip).
-
Shared Resources via CardContext: States access common components (CardDisplay, CardAnimator, StateMachine, CardData) through
CardContext, avoiding tight coupling. -
Reusable Animations:
CardAnimatorprovides animation methods (PlayFlip, PlayEnlarge, etc.) that states invoke. No duplicate tween code across states. -
State Transitions: States call
context.StateMachine.ChangeState("NextState")to transition. Example flow:IdleState [click] → FlippingState [flip complete] → EnlargedNewState [tap] → RevealedState
Benefits
- 60% less code: Shared animation system, no wrapper components
- True state isolation: Each state owns its visuals, no global visibility management
- Clear state transitions: Explicit state machine flow instead of boolean flag soup
- Extensible: Add new states without touching existing ones (e.g.,
TradingState,BattleState) - Designer-friendly: States are visible GameObjects in hierarchy, easy to understand
- No prefab nesting: Single Card prefab with state children, not 5 nested prefabs
Concrete Refactor Plan
Phase 1: Implement State Machine Architecture ✅ COMPLETE
Created Files:
CardContext.cs- Shared context componentCardAnimator.cs- Reusable animation controllerCardAnimationConfig.cs- ScriptableObject for animation settingsStates/CardIdleState.cs- Idle state with hoverStates/CardFlippingState.cs- Flip animation state (owns CardBackVisual)States/CardRevealedState.cs- Revealed/interactable stateStates/CardEnlargedNewState.cs- Enlarged new card state (owns NewCardBadge)States/CardEnlargedRepeatState.cs- Enlarged repeat state (owns ProgressBarUI)
Example State Implementation:
public class CardFlippingState : AppleState
{
[SerializeField] private GameObject cardBackVisual; // State owns this visual
private CardContext _context;
void Awake() => _context = GetComponentInParent<CardContext>();
public override void OnEnterState()
{
// Show card back (owned by this state)
cardBackVisual.SetActive(true);
_context.CardDisplay.gameObject.SetActive(false);
// Use shared animator
_context.Animator.PlayFlip(
cardBackVisual.transform,
_context.CardDisplay.transform,
onComplete: () => {
// Transition to next state
string nextState = _context.IsNewCard ? "EnlargedNewState" : "RevealedState";
_context.StateMachine.ChangeState(nextState);
}
);
}
void OnDisable()
{
// Hide card back when leaving state
cardBackVisual.SetActive(false);
_context.CardDisplay.gameObject.SetActive(true);
}
}
Prefab Structure:
Card.prefab
├─ CardDisplay
├─ CardContext (component)
├─ CardAnimator (component)
└─ CardStateMachine (AppleMachine)
├─ IdleState/
├─ FlippingState/
│ └─ CardBackVisual (Image)
├─ RevealedState/
├─ EnlargedNewState/
│ └─ NewCardBadge (GameObject)
└─ EnlargedRepeatState/
└─ ProgressBarUI (GameObject with Image/Text)
Impact: Foundation complete. States are isolated, visuals are state-owned, animations are shared.
Phase 2: Create Remaining States (Low Risk)
Additional states needed:
CardDraggingState.cs- Handles drag interaction for album placementCardPlacedInSlotState.cs- Card placed in album slot, handles enlarge on clickCardAlbumEnlargedState.cs- Enlarged view when clicking card in album
Example - Album Placed State:
public class CardPlacedInSlotState : AppleState, IPointerClickHandler
{
private CardContext _context;
private AlbumCardSlot _parentSlot;
public void SetParentSlot(AlbumCardSlot slot) => _parentSlot = slot;
public void OnPointerClick(PointerEventData eventData)
{
_context.StateMachine.ChangeState("AlbumEnlargedState");
}
}
Time: 2-3 days
Phase 3: Migrate Existing Prefabs (Medium Risk)
Steps:
- Create new
Card.prefabwith state machine structure - Build migration tool to convert old prefabs → new structure:
- Copy CardDisplay references
- Setup CardContext with data
- Create state GameObjects
- Update scenes one at a time:
- Replace
FlippableCardspawns withCardspawns - Update
BoosterOpeningPageto use new Card system - Update
AlbumViewPageto use new Card system
- Replace
- Remove old wrapper scripts once migration complete
Migration Helper Script:
// Editor tool to convert old card prefabs
[MenuItem("AppleHills/Convert Old Card to New Card")]
static void ConvertCard()
{
// Find old FlippableCard
var oldCard = Selection.activeGameObject.GetComponent<FlippableCard>();
// Extract data, create new Card with states
// ...
}
Time: 1-2 weeks (includes testing)
Migration Strategy
Option A: Incremental (Recommended)
- Create
CardAnimatoralongside existing code (2-3 days) - Refactor one wrapper at a time to use
CardAnimator(1 week) - Test each step with existing scenes
- Introduce state machine once animations are consolidated (3-5 days)
- Collapse wrappers last, update prefabs (2-3 days)
Total: ~3 weeks, low risk
Option B: Parallel Track
- Build new
Cardsystem in separate namespace (1 week) - Create migration tools to convert old prefabs → new prefabs (2-3 days)
- Switch one scene at a time (1 week)
- Delete old system once migration complete
Total: ~3 weeks, higher risk but cleaner result
Immediate Wins (Low-Hanging Fruit)
Even without full refactor, these changes reduce pain:
1. Extract Common Transform Tracking
// Assets/Scripts/UI/CardSystem/TransformMemento.cs
public class TransformMemento {
public Vector3 LocalPosition;
public Quaternion LocalRotation;
public Vector3 LocalScale;
public Transform Parent;
public static TransformMemento Capture(Transform t) { ... }
public void Restore(Transform t) { ... }
}
Usage: Replace 8+ _originalX fields across components with single TransformMemento.
2. Shared Animation Config ScriptableObject
// Assets/Scripts/UI/CardSystem/CardAnimationConfig.asset
[CreateAssetMenu]
public class CardAnimationConfig : ScriptableObject {
public float flipDuration = 0.6f;
public float enlargedScale = 2.5f;
public float hoverHeight = 10f;
// etc.
}
Impact: Tweak all card animations from one asset instead of searching 5 prefabs.
3. Document State Transitions
Add state diagram to FlippableCard.cs:
/// State Flow:
/// Unflipped → [Click] → Flipping → Revealed → [IsNew] → EnlargedNew → [Tap] → Revealed
/// → [IsRepeat] → ShowingProgress → Revealed
/// → [Tap during drag] → PlacementMode → PlacedInSlot
Impact: Future devs understand flow without debugging.
Metrics
| Metric | Current | After Refactor |
|---|---|---|
| Lines of code (card UI) | ~1,200 | ~500 |
| Animation logic locations | 4 files | 1 file |
| State tracking booleans | 12+ | 0 (enum-based) |
| Prefab nesting depth | 5 layers | 1 layer |
| Event callback chains | 12 events | ~3-4 events |
| Time to add new card state | 4-6 hours | ~30 min |
Conclusion
The current system works but is expensive to maintain and extend. The root cause is wrapping components instead of composing behavior.
Recommendation: Approve Phase 1 (Animation System) immediately as it has zero breaking changes and reduces code by 20%. Schedule Phase 2-3 (State Machine + Wrapper Collapse) for next sprint based on team bandwidth.
Risk Assessment: Medium. Prefab changes require thorough testing, but state machine pattern is battle-tested.
ROI: High. Estimated 70% reduction in time to add new card interactions (e.g., trading, upgrading, battling).