Update card sorting MVP

This commit is contained in:
Michal Pikulski
2025-11-19 23:47:43 +01:00
parent b2c61125ef
commit 6b8420c8c7
22 changed files with 1010 additions and 244 deletions

View File

@@ -4,6 +4,7 @@ using Data.CardSystem;
using Minigames.CardSorting.Core;
using Minigames.CardSorting.Data;
using System.Collections.Generic;
using UI.DragAndDrop.Core;
using UnityEngine;
namespace Minigames.CardSorting.Controllers
@@ -27,6 +28,8 @@ namespace Minigames.CardSorting.Controllers
private HashSet<SortableItem> missedItems = new HashSet<SortableItem>(); // Items past visual end, moving to despawn
private float currentSpeed;
private SortableItem lastSpawnedItem; // Track last spawned item for distance-based spawning
private float cachedSpawnOffsetX; // Cached random offset for next spawn
private bool isGameOver = false; // Flag to stop conveyor when game ends
// Events - conveyor owns item lifecycle
public event System.Action<SortableItem> OnItemSpawned; // Fired when new item spawns
@@ -37,6 +40,7 @@ namespace Minigames.CardSorting.Controllers
public float CurrentSpeed => currentSpeed;
public int ActiveItemCount => activeItems.Count;
public bool IsGameOver => isGameOver;
public ConveyorBeltController(
Transform spawnPoint,
@@ -55,6 +59,9 @@ namespace Minigames.CardSorting.Controllers
this.currentSpeed = settings.InitialBeltSpeed;
this.lastSpawnedItem = null; // No items spawned yet
// Initialize first cached offset
this.cachedSpawnOffsetX = Random.Range(settings.SpawnOffsetX.x, settings.SpawnOffsetX.y);
}
/// <summary>
@@ -62,6 +69,9 @@ namespace Minigames.CardSorting.Controllers
/// </summary>
public void Update(float deltaTime, float gameProgress)
{
// Stop processing if game is over
if (isGameOver) return;
UpdateBeltSpeed(gameProgress);
CheckItemsOffBelt();
CheckDistanceBasedSpawn(gameProgress);
@@ -70,6 +80,7 @@ namespace Minigames.CardSorting.Controllers
/// <summary>
/// Check if we should spawn a new item based on distance from last spawn.
/// Items spawn when last item has moved far enough from spawn point.
/// Uses cached random X offset for spawn distance variation.
/// </summary>
private void CheckDistanceBasedSpawn(float gameProgress)
{
@@ -77,15 +88,24 @@ namespace Minigames.CardSorting.Controllers
if (lastSpawnedItem == null)
{
SpawnNewItem(gameProgress);
// Generate new offset for next spawn
cachedSpawnOffsetX = Random.Range(settings.SpawnOffsetX.x, settings.SpawnOffsetX.y);
return;
}
// Check if last spawned item is far enough from spawn point
float distanceFromSpawn = Mathf.Abs(lastSpawnedItem.transform.position.x - spawnPoint.position.x);
if (distanceFromSpawn >= settings.SpawnDistance) // Using InitialSpawnInterval as distance threshold
// Use cached offset for required distance
float requiredDistance = settings.SpawnDistance + cachedSpawnOffsetX;
if (distanceFromSpawn >= requiredDistance)
{
SpawnNewItem(gameProgress);
// Generate new offset for next spawn
cachedSpawnOffsetX = Random.Range(settings.SpawnOffsetX.x, settings.SpawnOffsetX.y);
}
}
@@ -145,16 +165,27 @@ namespace Minigames.CardSorting.Controllers
GarbageItemDefinition garbage = SelectRandomGarbage();
GameObject obj = Object.Instantiate(garbagePrefab, spawnPoint.position, Quaternion.identity);
// Apply random Y offset to spawn position
float randomOffsetY = Random.Range(settings.SpawnOffsetY.x, settings.SpawnOffsetY.y);
Vector3 spawnPos = spawnPoint.position + new Vector3(0f, randomOffsetY, 0f);
GameObject obj = Object.Instantiate(garbagePrefab, spawnPos, Quaternion.identity);
SortableItem item = obj.GetComponent<SortableItem>();
if (item != null)
{
item.SetupAsGarbage(garbage);
// Apply card size (garbage items use same size as cards)
ApplyCardSize(item);
// Subscribe to item events
item.OnItemDroppedInBox += HandleItemDroppedInBox;
item.OnItemDroppedOnFloor += HandleItemDroppedOnFloor;
item.OnItemReturnedToConveyor += HandleItemReturnedToConveyor;
// Subscribe to drag events to remove from tracking
item.OnDragStarted += HandleItemDragStarted;
}
else
{
@@ -177,16 +208,27 @@ namespace Minigames.CardSorting.Controllers
return null;
}
GameObject obj = Object.Instantiate(cardPrefab, spawnPoint.position, Quaternion.identity);
// Apply random Y offset to spawn position
float randomOffsetY = Random.Range(settings.SpawnOffsetY.x, settings.SpawnOffsetY.y);
Vector3 spawnPos = spawnPoint.position + new Vector3(0f, randomOffsetY, 0f);
GameObject obj = Object.Instantiate(cardPrefab, spawnPos, Quaternion.identity);
SortableItem item = obj.GetComponent<SortableItem>();
if (item != null)
{
item.SetupAsCard(cardData);
// Apply card size
ApplyCardSize(item);
// Subscribe to item events
item.OnItemDroppedInBox += HandleItemDroppedInBox;
item.OnItemDroppedOnFloor += HandleItemDroppedOnFloor;
item.OnItemReturnedToConveyor += HandleItemReturnedToConveyor;
// Subscribe to drag events to remove from tracking
item.OnDragStarted += HandleItemDragStarted;
}
else
{
@@ -296,55 +338,86 @@ namespace Minigames.CardSorting.Controllers
}
/// <summary>
/// Handle when an item starts being dragged.
/// Remove from tracking to prevent "fell off belt" detection while dragging.
/// </summary>
private void HandleItemDragStarted(DraggableObject draggableObj)
{
SortableItem item = draggableObj as SortableItem;
if (item == null) return;
RemoveItemFromTracking(item);
}
/// <summary>
/// Handle when an item is dropped in a box (correct or incorrect).
/// Note: Item was already removed from activeItems when dragging started (HandleItemDragStarted).
/// </summary>
private void HandleItemDroppedInBox(SortableItem item, SortingBox box, bool correct)
{
// Remove from tracking and unsubscribe
if (activeItems.Remove(item))
// Clean up tracking (item already removed from activeItems when picked up)
activeItems.Remove(item);
missedItems.Remove(item);
// Clear lastSpawnedItem reference if this was it
if (lastSpawnedItem == item)
{
// Also remove from missed items if it was there
missedItems.Remove(item);
// Clear lastSpawnedItem reference if this was it
if (lastSpawnedItem == item)
{
lastSpawnedItem = null;
}
item.OnItemDroppedInBox -= HandleItemDroppedInBox;
item.OnItemReturnedToConveyor -= HandleItemReturnedToConveyor;
// Emit event for game manager to handle scoring, passing box and correctness
OnItemSorted?.Invoke(item, box, correct);
lastSpawnedItem = null;
}
// Unsubscribe from events
item.OnItemDroppedInBox -= HandleItemDroppedInBox;
item.OnItemDroppedOnFloor -= HandleItemDroppedOnFloor;
item.OnItemReturnedToConveyor -= HandleItemReturnedToConveyor;
item.OnDragStarted -= HandleItemDragStarted;
// Emit event for game manager to handle scoring, passing box and correctness
OnItemSorted?.Invoke(item, box, correct);
}
/// <summary>
/// Handle when an item is returned to conveyor (dropped back on conveyor belt).
/// Re-adds item to tracking so it continues moving along the belt.
/// </summary>
private void HandleItemReturnedToConveyor(SortableItem item)
{
if (item == null) return;
// Re-add to active tracking
if (!activeItems.Contains(item))
{
activeItems.Add(item);
Debug.Log($"[ConveyorBeltController] Item returned to conveyor: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
}
}
/// <summary>
/// Handle when an item is returned to conveyor (dropped outside box).
/// Handle when an item is dropped on floor (dropped outside box).
/// Item transitions to DroppedOnFloorState and gets destroyed.
/// Note: Item was already removed from activeItems when dragging started (HandleItemDragStarted).
/// </summary>
private void HandleItemReturnedToConveyor(SortableItem item)
private void HandleItemDroppedOnFloor(SortableItem item)
{
// Remove from tracking and unsubscribe (item will be destroyed)
if (activeItems.Remove(item))
// Clean up tracking (item already removed from activeItems when picked up)
activeItems.Remove(item);
missedItems.Remove(item);
if (lastSpawnedItem == item)
{
missedItems.Remove(item);
if (lastSpawnedItem == item)
{
lastSpawnedItem = null;
}
item.OnItemDroppedInBox -= HandleItemDroppedInBox;
item.OnItemReturnedToConveyor -= HandleItemReturnedToConveyor;
// Emit event for scoring
OnItemDroppedOnFloor?.Invoke(item);
Debug.Log($"[ConveyorBeltController] Item dropped on floor: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
lastSpawnedItem = null;
}
// Unsubscribe from events
item.OnItemDroppedInBox -= HandleItemDroppedInBox;
item.OnItemDroppedOnFloor -= HandleItemDroppedOnFloor;
item.OnItemReturnedToConveyor -= HandleItemReturnedToConveyor;
item.OnDragStarted -= HandleItemDragStarted;
// Emit event for scoring
OnItemDroppedOnFloor?.Invoke(item);
Debug.Log($"[ConveyorBeltController] Item dropped on floor: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
}
private CardRarity DetermineRarity(float roll)
@@ -365,6 +438,65 @@ namespace Minigames.CardSorting.Controllers
{
return settings.GarbageItems[Random.Range(0, settings.GarbageItems.Length)];
}
/// <summary>
/// Apply configured card size to spawned item.
/// </summary>
private void ApplyCardSize(SortableItem item)
{
if (item == null || item.Context == null || item.Context.RootTransform == null)
return;
// Get the RectTransform to resize (root object)
var rectTransform = item.Context.RootTransform.GetComponent<RectTransform>();
if (rectTransform != null)
{
rectTransform.sizeDelta = settings.CardSize;
}
}
/// <summary>
/// Remove an item from tracking (called when picked up via event).
/// Prevents item from being subject to belt end detection while being dragged.
/// </summary>
private void RemoveItemFromTracking(SortableItem item)
{
if (item == null) return;
bool wasTracked = activeItems.Remove(item);
missedItems.Remove(item);
// Clear lastSpawnedItem reference if this was it
if (lastSpawnedItem == item)
{
lastSpawnedItem = null;
}
if (wasTracked)
{
Debug.Log($"[ConveyorBeltController] Item removed from tracking (picked up): {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
}
}
/// <summary>
/// Stop the conveyor belt when game ends.
/// Disables dragging on all active items so they can't be interacted with.
/// </summary>
public void StopConveyor()
{
isGameOver = true;
// Disable dragging on all active items
foreach (var item in activeItems)
{
if (item != null)
{
item.SetDraggingEnabled(false);
}
}
Debug.Log("[ConveyorBeltController] Conveyor stopped - all items disabled");
}
}
}

View File

@@ -0,0 +1,35 @@
using UI.DragAndDrop.Core;
using UnityEngine;
namespace Minigames.CardSorting.Core
{
/// <summary>
/// Slot component for the conveyor belt.
/// Allows detection when items are dropped back onto the conveyor.
/// Place this on a UI element (Image/Panel) that covers the conveyor belt area.
/// </summary>
public class ConveyorBeltSlot : DraggableSlot
{
/// <summary>
/// Check if this slot can accept a specific draggable type.
/// ConveyorBeltSlot accepts all SortableItems.
/// </summary>
public new bool CanAccept(DraggableObject draggable)
{
// Accept all sortable items
return draggable is SortableItem;
}
/// <summary>
/// Called when an item is dropped on the conveyor.
/// Items dropped here should return to their conveyor state.
/// </summary>
public void OnItemDroppedOnConveyor(SortableItem item)
{
if (item == null) return;
Debug.Log($"[ConveyorBeltSlot] Item dropped back on conveyor: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9304d17587314133a4d8d1e582cfbf81
timeCreated: 1763590821

View File

@@ -28,7 +28,8 @@ namespace Minigames.CardSorting.Core
// Events - item emits notifications, conveyor subscribes
public event System.Action<SortableItem, SortingBox, bool> OnItemDroppedInBox;
public event System.Action<SortableItem> OnItemReturnedToConveyor;
public event System.Action<SortableItem> OnItemDroppedOnFloor;
public event System.Action<SortableItem> OnItemReturnedToConveyor; // Fired when dropped back on conveyor
// Public accessors
public SortableItemContext Context => context;
@@ -142,9 +143,10 @@ namespace Minigames.CardSorting.Core
{
base.OnDragEndedHook();
// Validate drop on sorting box
// Check what type of slot we're over
if (CurrentSlot is SortingBox box)
{
// Dropped in sorting box
bool correctSort = box.ValidateItem(this);
// Fire event IMMEDIATELY when card is released over bin
@@ -162,10 +164,28 @@ namespace Minigames.CardSorting.Core
ChangeState("SortedIncorrectlyState");
}
}
else if (CurrentSlot is ConveyorBeltSlot conveyorSlot)
{
// Dropped back on conveyor - return to conveyor state
Logging.Debug("[SortableItem] Dropped back on conveyor, returning to OnConveyorState");
// Notify conveyor slot
conveyorSlot.OnItemDroppedOnConveyor(this);
// Fire event for conveyor controller to re-add to tracking
OnItemReturnedToConveyor?.Invoke(this);
// Return to conveyor state
ChangeState("OnConveyorState");
}
else
{
// Dropped outside valid box - transition to dropped on floor state
Logging.Debug("[SortableItem] Dropped outside box, transitioning to floor state");
// Dropped outside valid box or conveyor - fire event then transition to dropped on floor state
Logging.Debug("[SortableItem] Dropped outside box/conveyor, transitioning to floor state");
// Fire event for conveyor controller to handle scoring
OnItemDroppedOnFloor?.Invoke(this);
ChangeState("DroppedOnFloorState");
}
}
@@ -174,6 +194,7 @@ namespace Minigames.CardSorting.Core
/// <summary>
/// Detect which slot (if any) is under the pointer during drag.
/// Updates CurrentSlot for drop detection.
/// Scans for both SortingBox and ConveyorBeltSlot.
/// </summary>
private void DetectSlotUnderPointer(UnityEngine.EventSystems.PointerEventData eventData)
{
@@ -181,29 +202,46 @@ namespace Minigames.CardSorting.Core
var raycastResults = new System.Collections.Generic.List<UnityEngine.EventSystems.RaycastResult>();
UnityEngine.EventSystems.EventSystem.current.RaycastAll(eventData, raycastResults);
SortingBox hoveredBox = null;
DraggableSlot hoveredSlot = null;
// Find first SortingBox in raycast results
// Find first slot (SortingBox or ConveyorBeltSlot) in raycast results
foreach (var result in raycastResults)
{
// Check for SortingBox first (higher priority)
var box = result.gameObject.GetComponentInParent<SortingBox>();
if (box != null)
{
hoveredBox = box;
hoveredSlot = box;
break;
}
// Check for ConveyorBeltSlot if no box found
var conveyorSlot = result.gameObject.GetComponentInParent<ConveyorBeltSlot>();
if (conveyorSlot != null)
{
hoveredSlot = conveyorSlot;
break;
}
}
// Update current slot (used in OnDragEndedHook)
if (hoveredBox != null && hoveredBox != CurrentSlot)
if (hoveredSlot != null && hoveredSlot != CurrentSlot)
{
_currentSlot = hoveredBox;
Logging.Debug($"[SortableItem] Now hovering over {hoveredBox.BoxType} box");
_currentSlot = hoveredSlot;
if (hoveredSlot is SortingBox sortBox)
{
Logging.Debug($"[SortableItem] Now hovering over {sortBox.BoxType} box");
}
else if (hoveredSlot is ConveyorBeltSlot)
{
Logging.Debug("[SortableItem] Now hovering over conveyor belt");
}
}
else if (hoveredBox == null && CurrentSlot != null)
else if (hoveredSlot == null && CurrentSlot != null)
{
_currentSlot = null;
Logging.Debug("[SortableItem] No longer over any box");
Logging.Debug("[SortableItem] No longer over any slot");
}
}

View File

@@ -29,9 +29,15 @@ namespace Minigames.CardSorting.Core
[Header("Effects")]
[SerializeField] private CinemachineImpulseSource impulseSource; // Screen shake on incorrect sort
[Header("UI References")]
[SerializeField] private UI.LivesDisplay livesDisplay;
// Settings
private ICardSortingSettings _settings;
// Lives tracking
private int currentLives;
// Controllers (lazy init)
private ConveyorBeltController _conveyorController;
private ConveyorBeltController Conveyor => _conveyorController ??= new ConveyorBeltController(
@@ -43,6 +49,9 @@ namespace Minigames.CardSorting.Core
_settings
);
// Public accessor for states to check game over status
public ConveyorBeltController ConveyorController => Conveyor;
private SortingScoreController _scoreController;
private SortingScoreController Score => _scoreController ??= new SortingScoreController(_settings);
@@ -152,6 +161,13 @@ namespace Minigames.CardSorting.Core
// Reset score
Score.Reset();
// Initialize lives
currentLives = _settings.MaxLives;
if (livesDisplay != null)
{
livesDisplay.Initialize(currentLives);
}
OnGameStarted?.Invoke();
// Set input mode to game
@@ -163,6 +179,61 @@ namespace Minigames.CardSorting.Core
Logging.Debug("[SortingGameManager] Game started!");
}
/// <summary>
/// Unified feedback for wrong actions (trash penalties).
/// Plays blink animation on item and triggers camera shake.
/// </summary>
public void PlayWrongStateFeedback(SortableItem item)
{
// Camera shake
if (impulseSource != null)
{
impulseSource.GenerateImpulse();
}
// Blink the item red (if it still exists)
if (item != null && item.Context != null && item.Context.Animator != null)
{
UnityEngine.UI.Image imageToBlink = null;
if (item.Context.CardDisplay != null)
{
imageToBlink = item.Context.CardDisplay.GetComponent<UnityEngine.UI.Image>();
}
else if (item.Context.GarbageVisual != null)
{
imageToBlink = item.Context.GarbageVisual.GetComponent<UnityEngine.UI.Image>();
}
if (imageToBlink != null)
{
item.Context.Animator.BlinkRed(imageToBlink, 0.15f);
}
}
}
/// <summary>
/// Lose a life. If lives reach 0, end the game.
/// </summary>
private void LoseLife()
{
currentLives--;
if (livesDisplay != null)
{
livesDisplay.RemoveLife();
}
Logging.Debug($"[SortingGameManager] Life lost! Remaining lives: {currentLives}");
// Check for game over
if (currentLives <= 0)
{
Logging.Debug("[SortingGameManager] No lives remaining - Game Over!");
EndGame();
}
}
public void EndGame()
{
if (isGameOver) return;
@@ -170,6 +241,9 @@ namespace Minigames.CardSorting.Core
isGameOver = true;
isGameActive = false;
// Stop the conveyor and disable all items
Conveyor.StopConveyor();
// Calculate rewards
int boosterReward = Score.CalculateBoosterReward();
@@ -201,7 +275,7 @@ namespace Minigames.CardSorting.Core
/// Called when item reaches visual end of belt (via conveyor event).
/// Item continues moving off-screen until despawn point.
/// Scoring rules:
/// - Trash fell off: Negative score (penalty)
/// - Trash fell off: Negative score (penalty) + lose life
/// - Card fell off: Neutral (no score change)
/// </summary>
private void OnConveyorItemFellOff(SortableItem item)
@@ -211,6 +285,8 @@ namespace Minigames.CardSorting.Core
if (item.IsGarbage)
{
Score.RecordMissedItem();
PlayWrongStateFeedback(item);
LoseLife();
Logging.Debug($"[SortingGameManager] Trash fell off belt! {item.GarbageItem?.DisplayName} - PENALTY");
}
else
@@ -228,7 +304,7 @@ namespace Minigames.CardSorting.Core
/// <summary>
/// Called when item is dropped on floor (via conveyor event).
/// Scoring rules:
/// - Trash dropped on floor: Negative score (penalty)
/// - Trash dropped on floor: Negative score (penalty) + lose life
/// - Card dropped on floor: Neutral (no score change)
/// </summary>
private void OnConveyorItemDroppedOnFloor(SortableItem item)
@@ -238,13 +314,9 @@ namespace Minigames.CardSorting.Core
if (item.IsGarbage)
{
Score.RecordIncorrectSort();
PlayWrongStateFeedback(item);
LoseLife();
Logging.Debug($"[SortingGameManager] Trash dropped on floor! {item.GarbageItem?.DisplayName} - PENALTY");
// Trigger screen shake for trash dropped on floor
if (impulseSource != null)
{
impulseSource.GenerateImpulse();
}
}
else
{
@@ -270,7 +342,7 @@ namespace Minigames.CardSorting.Core
/// Handles scoring only - the state (SortedCorrectlyState/SortedIncorrectlyState) handles animation and destruction.
/// Scoring rules:
/// - Correct sort: Positive score (cards or trash in correct box)
/// - Incorrect trash: Negative score (trash in wrong box)
/// - Incorrect trash: Negative score (trash in wrong box) + lose life
/// - Incorrect card: Neutral (no score change)
/// </summary>
private void OnConveyorItemSorted(SortableItem item, SortingBox box, bool correct)
@@ -290,16 +362,12 @@ namespace Minigames.CardSorting.Core
if (item.IsGarbage)
{
Score.RecordIncorrectSort();
PlayWrongStateFeedback(item);
LoseLife();
Logging.Debug($"[SortingGameManager] Incorrect trash sort! {item.GarbageItem?.DisplayName} - PENALTY");
// Fire global incorrect sort event for effects
OnItemSortedIncorrectly?.Invoke(item);
// Trigger screen shake
if (impulseSource != null)
{
impulseSource.GenerateImpulse();
}
}
else
{

View File

@@ -13,10 +13,12 @@ namespace Minigames.CardSorting.StateMachine.States
public class BeingDraggedState : AppleState
{
private SortableItemContext _context;
private SortableItem _item;
private void Awake()
{
_context = GetComponentInParent<SortableItemContext>();
_item = GetComponentInParent<SortableItem>();
}
public override void OnEnterState()
@@ -24,9 +26,7 @@ namespace Minigames.CardSorting.StateMachine.States
if (_context == null) return;
_context.IsOnConveyor = false;
// Visual feedback: scale up root transform by 10%
// Use OriginalScale from context (captured at spawn, preserves world-space Canvas scale)
if (_context.RootTransform != null && _context.Animator != null)
{
Vector3 targetScale = _context.OriginalScale * 1.1f;

View File

@@ -25,45 +25,11 @@ namespace Minigames.CardSorting.StateMachine.States
_context.IsOnConveyor = false;
Logging.Debug("[DroppedOnFloorState] Item dropped on floor, blinking red then disappearing");
Logging.Debug("[DroppedOnFloorState] Item dropped on floor, disappearing");
// Blink red briefly, then play disappear animation
StartBlinkThenDisappear();
}
private void StartBlinkThenDisappear()
{
if (_context.Animator == null || _item == null) return;
// Get the image to blink
UnityEngine.UI.Image imageToBlink = null;
if (_context.CardDisplay != null)
{
imageToBlink = _context.CardDisplay.GetComponent<UnityEngine.UI.Image>();
}
else if (_context.GarbageVisual != null)
{
imageToBlink = _context.GarbageVisual.GetComponent<UnityEngine.UI.Image>();
}
if (imageToBlink != null)
{
// Blink red briefly (2-3 times), then stop and disappear
_context.Animator.BlinkRed(imageToBlink, 0.15f); // Fast blink
// After brief delay, stop blinking and play disappear animation
_context.Animator.AnimateScale(_context.RootTransform.localScale, 0.5f, () =>
{
_context.Animator.StopBlinking();
PlayDisappearAnimation();
});
}
else
{
// No image found, just disappear directly
PlayDisappearAnimation();
}
// Feedback (blink + shake) already triggered by game manager when scoring (for trash)
// Just play disappear animation
PlayDisappearAnimation();
}
private void PlayDisappearAnimation()

View File

@@ -44,6 +44,12 @@ namespace Minigames.CardSorting.StateMachine.States
{
if (_context == null || !_context.IsOnConveyor) return;
// Stop moving if game is over
if (SortingGameManager.Instance.ConveyorController.IsGameOver)
{
return;
}
// Continue moving item toward despawn point (same logic as OnConveyorState)
Vector3 movement = Vector3.right * _context.ConveyorSpeed * Time.deltaTime;
_context.RootTransform.position += movement;

View File

@@ -40,6 +40,12 @@ namespace Minigames.CardSorting.StateMachine.States
{
if (_context == null || !_context.IsOnConveyor) return;
// Stop moving if game is over
if (SortingGameManager.Instance.ConveyorController.IsGameOver)
{
return;
}
// Move item along conveyor (right direction)
Vector3 movement = Vector3.right * _context.ConveyorSpeed * Time.deltaTime;
_context.RootTransform.position += movement;

View File

@@ -26,45 +26,11 @@ namespace Minigames.CardSorting.StateMachine.States
_context.IsOnConveyor = false;
Logging.Debug("[SortedIncorrectlyState] Item incorrectly sorted, blinking red then tweening to box");
Logging.Debug("[SortedIncorrectlyState] Item incorrectly sorted, tweening to box then falling in");
// Start blinking red briefly, then tween to box
StartBlinkThenTween();
}
private void StartBlinkThenTween()
{
if (_context.Animator == null || _item == null) return;
// Get the image to blink
UnityEngine.UI.Image imageToBlink = null;
if (_context.CardDisplay != null)
{
imageToBlink = _context.CardDisplay.GetComponent<UnityEngine.UI.Image>();
}
else if (_context.GarbageVisual != null)
{
imageToBlink = _context.GarbageVisual.GetComponent<UnityEngine.UI.Image>();
}
if (imageToBlink != null)
{
// Blink red briefly (2-3 times), then stop and continue with tween
_context.Animator.BlinkRed(imageToBlink, 0.15f); // Fast blink
// After brief delay, stop blinking and tween to box
_context.Animator.AnimateScale(_context.RootTransform.localScale, 0.5f, () =>
{
_context.Animator.StopBlinking();
TweenToBoxThenFall();
});
}
else
{
// No image found, just tween directly
TweenToBoxThenFall();
}
// Feedback (blink + shake) already triggered by game manager when scoring
// Just proceed with animation
TweenToBoxThenFall();
}
private void TweenToBoxThenFall()

View File

@@ -0,0 +1,208 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace Minigames.CardSorting.UI
{
/// <summary>
/// Displays and animates lives for the card sorting minigame.
/// Manages a layout group of life icons with add/remove animations.
/// </summary>
public class LivesDisplay : MonoBehaviour
{
[Header("Configuration")]
[SerializeField] private LayoutGroup layoutGroup;
[Header("Animation Settings")]
[SerializeField] private float animationDuration = 0.3f;
[SerializeField] private float scaleMultiplier = 1.5f;
private GameObject lifeIconPrefab;
private List<GameObject> lifeIcons = new List<GameObject>();
private void Awake()
{
if (layoutGroup == null)
{
layoutGroup = GetComponent<LayoutGroup>();
}
// Find the prefab (first child) if not assigned
if (lifeIconPrefab == null && layoutGroup != null && layoutGroup.transform.childCount > 0)
{
lifeIconPrefab = layoutGroup.transform.GetChild(0).gameObject;
}
}
/// <summary>
/// Initialize the display with the maximum number of lives.
/// Clones the prefab element multiple times.
/// </summary>
public void Initialize(int maxLives)
{
// Clear existing lives
ClearAllLives();
if (lifeIconPrefab == null)
{
Debug.LogError("[LivesDisplay] No life icon prefab assigned!");
return;
}
if (layoutGroup == null)
{
Debug.LogError("[LivesDisplay] No layout group assigned!");
return;
}
// Ensure prefab is active to use as template
bool wasActive = lifeIconPrefab.activeSelf;
lifeIconPrefab.SetActive(true);
lifeIcons.Add(lifeIconPrefab);
// Create life icons
for (int i = 0; i < maxLives - 1; i++)
{
GameObject icon = Instantiate(lifeIconPrefab, layoutGroup.transform);
icon.SetActive(true);
lifeIcons.Add(icon);
}
// Hide the original prefab
lifeIconPrefab.SetActive(wasActive);
}
/// <summary>
/// Remove a life with animation.
/// </summary>
public void RemoveLife()
{
if (lifeIcons.Count == 0)
{
Debug.LogWarning("[LivesDisplay] No lives left to remove!");
return;
}
GameObject icon = lifeIcons[0];
lifeIcons.RemoveAt(0);
// Play removal animation
StartCoroutine(AnimateLifeRemoval(icon));
}
/// <summary>
/// Add a life with animation.
/// </summary>
public void AddLife()
{
if (lifeIconPrefab == null || layoutGroup == null)
{
Debug.LogError("[LivesDisplay] Cannot add life - missing prefab or layout group!");
return;
}
// Create new life icon
GameObject icon = Instantiate(lifeIconPrefab, layoutGroup.transform);
icon.SetActive(true);
lifeIcons.Add(icon);
// Play addition animation
StartCoroutine(AnimateLifeAddition(icon));
}
/// <summary>
/// Clear all lives (for reset).
/// </summary>
private void ClearAllLives()
{
foreach (var icon in lifeIcons)
{
if (icon != null)
{
Destroy(icon);
}
}
lifeIcons.Clear();
}
/// <summary>
/// Animate life removal: scale up, then scale down and destroy.
/// </summary>
private IEnumerator AnimateLifeRemoval(GameObject icon)
{
if (icon == null) yield break;
Vector3 originalScale = icon.transform.localScale;
Vector3 targetScale = originalScale * scaleMultiplier;
// Scale up
yield return AnimateScale(icon.transform, originalScale, targetScale, animationDuration * 0.5f);
// Scale down to zero
yield return AnimateScale(icon.transform, targetScale, Vector3.zero, animationDuration * 0.5f);
// Destroy
if (icon != null)
{
Destroy(icon);
}
}
/// <summary>
/// Animate life addition: start at zero, scale up beyond target, then settle to normal.
/// </summary>
private IEnumerator AnimateLifeAddition(GameObject icon)
{
if (icon == null) yield break;
Vector3 originalScale = icon.transform.localScale;
Vector3 overshootScale = originalScale * scaleMultiplier;
// Start at zero
icon.transform.localScale = Vector3.zero;
// Scale up to overshoot
yield return AnimateScale(icon.transform, Vector3.zero, overshootScale, animationDuration * 0.5f);
// Settle back to original scale
yield return AnimateScale(icon.transform, overshootScale, originalScale, animationDuration * 0.5f);
}
/// <summary>
/// Generic scale animation coroutine.
/// </summary>
private IEnumerator AnimateScale(Transform target, Vector3 from, Vector3 to, float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
// Use ease-in-out curve for smooth animation
t = t * t * (3f - 2f * t); // Smoothstep
if (target != null)
{
target.localScale = Vector3.Lerp(from, to, t);
}
yield return null;
}
// Ensure final scale is set
if (target != null)
{
target.localScale = to;
}
}
/// <summary>
/// Get the current number of lives displayed.
/// </summary>
public int CurrentLives => lifeIcons.Count;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e8bcb252540442e593cc6467c518ff75
timeCreated: 1763586518

View File

@@ -99,7 +99,8 @@ namespace Minigames.CardSorting.UI
{
if (scoreText == null) return;
scoreText.text = $"Score: {newScore}";
// Only display numerical value (label is separate UI element)
scoreText.text = $"Score: {newScore.ToString()}";
// Color based on positive/negative
if (newScore >= 0)

View File

@@ -1,4 +1,6 @@
using Minigames.CardSorting.Core;
using System;
using Core;
using Minigames.CardSorting.Core;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
@@ -70,26 +72,26 @@ namespace Minigames.CardSorting.UI
if (gameManager == null) return;
// Populate data
// Populate data - only numerical values (labels are separate UI elements)
if (finalScoreText != null)
finalScoreText.text = $"Final Score: {gameManager.CurrentScore}";
finalScoreText.text = gameManager.CurrentScore.ToString();
if (correctSortsText != null)
correctSortsText.text = $"Correct: {gameManager.CorrectSorts}";
correctSortsText.text = gameManager.CorrectSorts.ToString();
if (incorrectSortsText != null)
incorrectSortsText.text = $"Incorrect: {gameManager.IncorrectSorts}";
incorrectSortsText.text = gameManager.IncorrectSorts.ToString();
if (missedItemsText != null)
missedItemsText.text = $"Missed: {gameManager.MissedItems}";
missedItemsText.text = gameManager.MissedItems.ToString();
if (accuracyText != null)
accuracyText.text = $"Accuracy: {gameManager.Accuracy:P0}";
accuracyText.text = gameManager.Accuracy.ToString("P0");
// Calculate boosters (already granted by manager)
int boosters = gameManager.CorrectSorts; // Simple 1:1 ratio
if (boostersEarnedText != null)
boostersEarnedText.text = $"Boosters Earned: {boosters}";
boostersEarnedText.text = boosters.ToString();
// Show screen
if (canvasGroup != null)
@@ -104,7 +106,7 @@ namespace Minigames.CardSorting.UI
}
}
private void OnCloseClicked()
private async void OnCloseClicked()
{
// Hide screen
if (canvasGroup != null)
@@ -118,8 +120,9 @@ namespace Minigames.CardSorting.UI
gameObject.SetActive(false);
}
// Could also trigger scene transition, return to menu, etc.
Debug.Log("[SortingResultsScreen] Closed results screen");
// TODO: Smarter replay?
var progress = new Progress<float>(p => Logging.Debug($"Loading progress: {p * 100:F0}%"));
await SceneManagerService.Instance.ReloadCurrentScene(progress);
}
}
}