This commit is contained in:
journaliciouz
2025-12-18 19:53:37 +01:00
89 changed files with 4391 additions and 1480 deletions

View File

@@ -1,5 +1,4 @@
using AppleHills.Core.Settings;
using Minigames.CardSorting.Data;
using UnityEngine;
namespace Core.Settings
@@ -29,21 +28,21 @@ namespace Core.Settings
[SerializeField] private AnimationCurve speedCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);
[Header("Item Pools")]
[Tooltip("Garbage items that can spawn (banana peels, cans, receipts, etc.)")]
[SerializeField] private GarbageItemDefinition[] garbageItems = new GarbageItemDefinition[0];
[Tooltip("Normal rarity card prefabs that can spawn")]
[SerializeField] private GameObject[] normalCardPrefabs = new GameObject[0];
[Tooltip("Rare rarity card prefabs that can spawn")]
[SerializeField] private GameObject[] rareCardPrefabs = new GameObject[0];
[Tooltip("Legendary rarity card prefabs that can spawn")]
[SerializeField] private GameObject[] legendaryCardPrefabs = new GameObject[0];
[Tooltip("Garbage prefabs that can spawn")]
[SerializeField] private GameObject[] garbagePrefabs = new GameObject[0];
[Header("Spawn Weights")]
[Tooltip("Weight for spawning normal rarity cards")]
[Range(0, 100)] [SerializeField] private float normalCardWeight = 40f;
[Tooltip("Weight for spawning rare rarity cards")]
[Range(0, 100)] [SerializeField] private float rareCardWeight = 30f;
[Tooltip("Weight for spawning legendary rarity cards")]
[Range(0, 100)] [SerializeField] private float legendCardWeight = 20f;
[Tooltip("Weight for spawning garbage items")]
[Range(0, 100)] [SerializeField] private float garbageWeight = 10f;
[Tooltip("Ratio of cards to garbage (0 = all garbage, 0.5 = 50/50 split, 1 = all cards)")]
[Range(0, 1)] [SerializeField] private float cardToGarbageRatio = 0.5f;
[Header("Scoring")]
[Tooltip("Points awarded for correct sort")]
@@ -81,11 +80,11 @@ namespace Core.Settings
public float InitialBeltSpeed => initialBeltSpeed;
public float MaxBeltSpeed => maxBeltSpeed;
public AnimationCurve SpeedCurve => speedCurve;
public GarbageItemDefinition[] GarbageItems => garbageItems;
public float NormalCardWeight => normalCardWeight;
public float RareCardWeight => rareCardWeight;
public float LegendCardWeight => legendCardWeight;
public float GarbageWeight => garbageWeight;
public GameObject[] NormalCardPrefabs => normalCardPrefabs;
public GameObject[] RareCardPrefabs => rareCardPrefabs;
public GameObject[] LegendaryCardPrefabs => legendaryCardPrefabs;
public GameObject[] GarbagePrefabs => garbagePrefabs;
public float CardToGarbageRatio => cardToGarbageRatio;
public int CorrectSortPoints => correctSortPoints;
public int IncorrectSortPenalty => incorrectSortPenalty;
public int MissedItemPenalty => missedItemPenalty;

View File

@@ -18,14 +18,14 @@ namespace Core.Settings
float MaxBeltSpeed { get; }
AnimationCurve SpeedCurve { get; }
// Item Pools
GarbageItemDefinition[] GarbageItems { get; }
// Item Pools - Arrays of prefabs
GameObject[] NormalCardPrefabs { get; }
GameObject[] RareCardPrefabs { get; }
GameObject[] LegendaryCardPrefabs { get; }
GameObject[] GarbagePrefabs { get; }
// Spawn Weights
float NormalCardWeight { get; }
float RareCardWeight { get; }
float LegendCardWeight { get; }
float GarbageWeight { get; }
// Spawn Ratio (0 = all garbage, 1 = all cards)
float CardToGarbageRatio { get; }
// Scoring
int CorrectSortPoints { get; }

View File

@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: d5112d15beb144088e8b8752879deda3
timeCreated: 1760105142

View File

@@ -1,86 +0,0 @@
using UnityEngine;
using AppleHills.Core;
using Core;
namespace AppleHills.Examples
{
/// <summary>
/// Example script demonstrating how to use QuickAccess to quickly retrieve and use game objects
/// </summary>
public class QuickAccessExample : MonoBehaviour
{
void Start()
{
// Retrieve player and follower objects using QuickAccess
GameObject player = QuickAccess.Instance.PlayerGameObject;
GameObject follower = QuickAccess.Instance.FollowerGameObject;
// Print info about the player
if (player != null)
{
Logging.Debug($"[QuickAccessExample] Player found: {player.name}");
Logging.Debug($"[QuickAccessExample] Player position: {player.transform.position}");
// Access player controller
var playerController = QuickAccess.Instance.PlayerController;
if (playerController != null)
{
Logging.Debug($"[QuickAccessExample] Player controller found on object");
}
else
{
Logging.Warning($"[QuickAccessExample] Player controller not found");
}
}
else
{
Logging.Warning($"[QuickAccessExample] Player not found in scene");
}
// Print info about the follower (Pulver)
if (follower != null)
{
Logging.Debug($"[QuickAccessExample] Follower found: {follower.name}");
Logging.Debug($"[QuickAccessExample] Follower position: {follower.transform.position}");
// Access follower controller
var followerController = QuickAccess.Instance.FollowerController;
if (followerController != null)
{
Logging.Debug($"[QuickAccessExample] Follower controller found on object");
}
else
{
Logging.Warning($"[QuickAccessExample] Follower controller not found");
}
}
else
{
Logging.Warning($"[QuickAccessExample] Follower not found in scene");
}
// Access camera
var camera = QuickAccess.Instance.MainCamera;
if (camera != null)
{
Logging.Debug($"[QuickAccessExample] Main camera found: {camera.name}");
Logging.Debug($"[QuickAccessExample] Camera position: {camera.transform.position}");
}
else
{
Logging.Warning($"[QuickAccessExample] Main camera not found");
}
// Access managers
try
{
Logging.Debug($"[QuickAccessExample] Game Manager instance accessed: {QuickAccess.Instance.GameManager != null}");
Logging.Debug($"[QuickAccessExample] Input Manager instance accessed: {QuickAccess.Instance.InputManager != null}");
}
catch (System.Exception e)
{
Debug.LogError($"[QuickAccessExample] Error accessing managers: {e.Message}");
}
}
}
}

View File

@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: c6086c2645c14cad92be0a9c7c191fdc
timeCreated: 1760105142

View File

@@ -1,8 +1,6 @@
using AppleHills.Data.CardSystem;
using Core.Settings;
using Data.CardSystem;
using Minigames.CardSorting.Core;
using Minigames.CardSorting.Data;
using System.Collections.Generic;
using UI.DragAndDrop.Core;
using UnityEngine;
@@ -20,8 +18,6 @@ namespace Minigames.CardSorting.Controllers
private readonly Transform spawnPoint;
private readonly Transform endPoint; // Visual end - scoring happens here
private readonly Transform despawnPoint; // Off-screen - destruction happens here
private readonly GameObject cardPrefab;
private readonly GameObject garbagePrefab;
private readonly ICardSortingSettings settings;
private readonly Transform spawnContainer; // Container for all spawned items
@@ -30,7 +26,7 @@ namespace Minigames.CardSorting.Controllers
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
private bool isGameOver; // Flag to stop conveyor when game ends
// Events - conveyor owns item lifecycle
public event System.Action<SortableItem> OnItemSpawned; // Fired when new item spawns
@@ -47,16 +43,12 @@ namespace Minigames.CardSorting.Controllers
Transform spawnPoint,
Transform endPoint,
Transform despawnPoint,
GameObject cardPrefab,
GameObject garbagePrefab,
ICardSortingSettings settings,
Transform spawnContainer)
{
this.spawnPoint = spawnPoint;
this.endPoint = endPoint;
this.despawnPoint = despawnPoint;
this.cardPrefab = cardPrefab;
this.garbagePrefab = garbagePrefab;
this.settings = settings;
this.spawnContainer = spawnContainer;
@@ -114,33 +106,60 @@ namespace Minigames.CardSorting.Controllers
/// <summary>
/// Spawn a new item at the spawn point.
/// Uses cardToGarbageRatio (0-1) to determine card vs garbage spawn chance.
/// If cards spawn, rarity is determined by array lengths.
/// </summary>
private SortableItem SpawnNewItem(float gameProgress)
private void SpawnNewItem(float gameProgress)
{
// Weighted random: card or garbage?
float totalWeight = settings.NormalCardWeight + settings.RareCardWeight +
settings.LegendCardWeight + settings.GarbageWeight;
if (totalWeight <= 0f)
{
Debug.LogWarning("[ConveyorBeltController] Total spawn weight is 0, cannot spawn items!");
return null;
}
float roll = Random.Range(0f, totalWeight);
// Use ratio to decide: card or garbage?
float cardChance = settings.CardToGarbageRatio; // 0 = all garbage, 1 = all cards
float roll = Random.Range(0f, 1f);
SortableItem item;
if (roll < settings.GarbageWeight)
if (roll < cardChance)
{
// Spawn garbage
item = SpawnGarbageItem();
// Spawn card - determine rarity based on array lengths
int normalWeight = settings.NormalCardPrefabs?.Length ?? 0;
int rareWeight = settings.RareCardPrefabs?.Length ?? 0;
int legendWeight = settings.LegendaryCardPrefabs?.Length ?? 0;
int totalCardWeight = normalWeight + rareWeight + legendWeight;
if (totalCardWeight <= 0)
{
Debug.LogWarning("[ConveyorBeltController] No card prefabs configured, spawning garbage instead");
item = SpawnRandomPrefabFromArray(settings.GarbagePrefabs, true, CardRarity.Normal);
}
else
{
float rarityRoll = Random.Range(0f, totalCardWeight);
CardRarity rarity;
GameObject[] targetArray;
if (rarityRoll < normalWeight)
{
rarity = CardRarity.Normal;
targetArray = settings.NormalCardPrefabs;
}
else if (rarityRoll < normalWeight + rareWeight)
{
rarity = CardRarity.Rare;
targetArray = settings.RareCardPrefabs;
}
else
{
rarity = CardRarity.Legendary;
targetArray = settings.LegendaryCardPrefabs;
}
item = SpawnRandomPrefabFromArray(targetArray, false, rarity);
}
}
else
{
// Spawn card - determine rarity, get random card from CardSystemManager
CardRarity rarity = DetermineRarity(roll);
item = SpawnCardItem(rarity);
// Spawn garbage
item = SpawnRandomPrefabFromArray(settings.GarbagePrefabs, true, CardRarity.Normal);
}
if (item != null)
@@ -154,114 +173,61 @@ namespace Minigames.CardSorting.Controllers
// Emit spawn event
OnItemSpawned?.Invoke(item);
}
return item;
}
private SortableItem SpawnGarbageItem()
{
if (settings.GarbageItems == null || settings.GarbageItems.Length == 0)
{
Debug.LogWarning("[ConveyorBeltController] No garbage items configured!");
return null;
}
GarbageItemDefinition garbage = SelectRandomGarbage();
// 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, spawnContainer);
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
{
Debug.LogError("[ConveyorBeltController] Garbage prefab missing SortableItem component!");
Object.Destroy(obj);
return null;
}
return item;
}
private SortableItem SpawnCardItem(CardRarity rarity)
{
// Get a random card of the specified rarity
CardData cardData = GetRandomCardDataByRarity(rarity);
if (cardData == null)
{
Debug.LogWarning($"[ConveyorBeltController] No card data found for rarity {rarity}");
return null;
}
// 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, spawnContainer);
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
{
Debug.LogError("[ConveyorBeltController] Card prefab missing SortableItem component!");
Object.Destroy(obj);
return null;
}
return item;
}
/// <summary>
/// Helper method to get a random card of a specific rarity.
/// Gets a CardDefinition from CardSystemManager and converts to CardData.
/// Does NOT affect player's collection or open boosters.
/// Spawn a random prefab from an array.
/// Prefab is assumed to have SortableItem component and be visually pre-configured.
/// </summary>
private CardData GetRandomCardDataByRarity(CardRarity targetRarity)
private SortableItem SpawnRandomPrefabFromArray(GameObject[] prefabArray, bool isGarbage, CardRarity rarity)
{
// Get random card definition from manager
var definition = CardSystemManager.Instance.GetRandomCardDefinitionByRarity(targetRarity);
if (definition == null)
if (prefabArray == null || prefabArray.Length == 0)
{
Debug.LogWarning($"[ConveyorBeltController] No card definition found for rarity {targetRarity}");
Debug.LogWarning($"[ConveyorBeltController] No prefabs configured for {(isGarbage ? "garbage" : rarity.ToString())}!");
return null;
}
// Create CardData from definition using constructor
// This properly links the definition and sets all properties
return new CardData(definition);
// Pick random prefab
GameObject prefab = prefabArray[Random.Range(0, prefabArray.Length)];
if (prefab == null)
{
Debug.LogWarning($"[ConveyorBeltController] Null prefab in array for {(isGarbage ? "garbage" : rarity.ToString())}!");
return null;
}
// 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);
// Instantiate prefab
GameObject obj = Object.Instantiate(prefab, spawnPos, Quaternion.identity, spawnContainer);
SortableItem item = obj.GetComponent<SortableItem>();
if (item == null)
{
Debug.LogError($"[ConveyorBeltController] Prefab missing SortableItem component: {prefab.name}");
Object.Destroy(obj);
return null;
}
// Initialize item based on type (just sets flags and state machine)
if (isGarbage)
{
item.SetupAsGarbage();
}
else
{
item.SetupAsCard(rarity);
}
// Subscribe to item events
item.OnItemDroppedInBox += HandleItemDroppedInBox;
item.OnItemDroppedOnFloor += HandleItemDroppedOnFloor;
item.OnItemReturnedToConveyor += HandleItemReturnedToConveyor;
item.OnDragStarted += HandleItemDragStarted;
return item;
}
private void UpdateBeltSpeed(float gameProgress)
@@ -391,7 +357,7 @@ namespace Minigames.CardSorting.Controllers
if (!activeItems.Contains(item))
{
activeItems.Add(item);
Debug.Log($"[ConveyorBeltController] Item returned to conveyor: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
Debug.Log($"[ConveyorBeltController] Item returned to conveyor: {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
}
}
@@ -420,42 +386,7 @@ namespace Minigames.CardSorting.Controllers
// 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)
{
// Adjust roll to be relative to card weights only (subtract garbage weight)
float adjusted = roll - settings.GarbageWeight;
if (adjusted < settings.NormalCardWeight)
return CardRarity.Normal;
if (adjusted < settings.NormalCardWeight + settings.RareCardWeight)
return CardRarity.Rare;
return CardRarity.Legendary;
}
private GarbageItemDefinition SelectRandomGarbage()
{
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;
}
Debug.Log($"[ConveyorBeltController] Item dropped on floor: {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
}
/// <summary>
@@ -477,7 +408,7 @@ namespace Minigames.CardSorting.Controllers
if (wasTracked)
{
Debug.Log($"[ConveyorBeltController] Item removed from tracking (picked up): {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
Debug.Log($"[ConveyorBeltController] Item removed from tracking (picked up): {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
}
}

View File

@@ -28,7 +28,7 @@ namespace Minigames.CardSorting.Core
{
if (item == null) return;
Debug.Log($"[ConveyorBeltSlot] Item dropped back on conveyor: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
Debug.Log($"[ConveyorBeltSlot] Item dropped back on conveyor: {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
}
}
}

View File

@@ -1,36 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
namespace Minigames.CardSorting.Core
{
/// <summary>
/// Simple sprite renderer for garbage items.
/// Parallel to CardDisplay for cards.
/// </summary>
public class GarbageVisual : MonoBehaviour
{
[Header("Visual Components")]
[SerializeField] private Image spriteRenderer;
/// <summary>
/// Update the displayed sprite.
/// </summary>
public void UpdateDisplay(Sprite sprite)
{
if (spriteRenderer != null)
{
spriteRenderer.sprite = sprite;
}
}
private void Awake()
{
// Auto-find Image component if not assigned
if (spriteRenderer == null)
{
spriteRenderer = GetComponent<Image>();
}
}
}
}

View File

@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: b707770fc3a6448ea0dcd1b2fbf41e00
timeCreated: 1763461824

View File

@@ -1,4 +1,4 @@
using AppleHills.Data.CardSystem;
using AppleHills.Data.CardSystem;
using Core;
using Core.SaveLoad;
using Minigames.CardSorting.Data;
@@ -21,10 +21,12 @@ namespace Minigames.CardSorting.Core
[Header("Configuration")]
[SerializeField] private string initialState = "OnConveyorState";
// Data tracking
private bool isGarbage;
private CardData cardData;
private GarbageItemDefinition garbageItem;
// Data tracking - set during spawn, no complex data initialization
private bool _isGarbage;
private CardRarity _rarity; // Only relevant for cards
// Track last hovered box for hover indicator feedback
private SortingBox _lastHoveredBox;
// Events - item emits notifications, conveyor subscribes
public event System.Action<SortableItem, SortingBox, bool> OnItemDroppedInBox;
@@ -34,9 +36,8 @@ namespace Minigames.CardSorting.Core
// Public accessors
public SortableItemContext Context => context;
public AppleMachine StateMachine => stateMachine;
public bool IsGarbage => isGarbage;
public CardData CardData => cardData;
public GarbageItemDefinition GarbageItem => garbageItem;
public bool IsGarbage => _isGarbage;
public CardRarity Rarity => _rarity;
/// <summary>
/// Get the correct box type for this item.
@@ -45,10 +46,10 @@ namespace Minigames.CardSorting.Core
{
get
{
if (isGarbage)
if (_isGarbage)
return BoxType.Trash;
return cardData.Rarity switch
return _rarity switch
{
CardRarity.Normal => BoxType.Normal,
CardRarity.Rare => BoxType.Rare,
@@ -71,43 +72,23 @@ namespace Minigames.CardSorting.Core
}
/// <summary>
/// Setup item as a card.
/// Setup item as a card. Prefab is already visually configured.
/// State machine auto-starts via Initialization component (calls StartMachine in Start).
/// </summary>
public void SetupAsCard(CardData data)
public void SetupAsCard(CardRarity rarity)
{
isGarbage = false;
cardData = data;
garbageItem = null;
if (context != null)
{
context.SetupAsCard(data);
}
if (stateMachine != null && !string.IsNullOrEmpty(initialState))
{
stateMachine.ChangeState(initialState);
}
_isGarbage = false;
_rarity = rarity;
}
/// <summary>
/// Setup item as garbage.
/// Setup item as garbage. Prefab is already visually configured.
/// State machine auto-starts via Initialization component (calls StartMachine in Start).
/// </summary>
public void SetupAsGarbage(GarbageItemDefinition garbage)
public void SetupAsGarbage()
{
isGarbage = true;
cardData = default;
garbageItem = garbage;
if (context != null)
{
context.SetupAsGarbage(garbage.Sprite);
}
if (stateMachine != null && !string.IsNullOrEmpty(initialState))
{
stateMachine.ChangeState(initialState);
}
_isGarbage = true;
_rarity = CardRarity.Normal; // Default, not used for garbage
}
protected override void OnDragStartedHook()
@@ -125,7 +106,7 @@ namespace Minigames.CardSorting.Core
}
// Default behavior if state doesn't handle
Logging.Debug($"[SortableItem] Drag started on {(isGarbage ? garbageItem.DisplayName : cardData.Name)}");
Logging.Debug($"[SortableItem] Drag started on {(_isGarbage ? "Garbage" : $"{_rarity} Card")}");
}
// TODO: Fixed when base slot/draggable reworked
@@ -143,6 +124,13 @@ namespace Minigames.CardSorting.Core
{
base.OnDragEndedHook();
// Hide hover indicator on any previously hovered box
if (_lastHoveredBox != null)
{
_lastHoveredBox.HideHoverIndicator();
_lastHoveredBox = null;
}
// Check what type of slot we're over
if (CurrentSlot is SortingBox box)
{
@@ -203,6 +191,7 @@ namespace Minigames.CardSorting.Core
UnityEngine.EventSystems.EventSystem.current.RaycastAll(eventData, raycastResults);
DraggableSlot hoveredSlot = null;
SortingBox hoveredBox = null;
// Find first slot (SortingBox or ConveyorBeltSlot) in raycast results
foreach (var result in raycastResults)
@@ -212,6 +201,7 @@ namespace Minigames.CardSorting.Core
if (box != null)
{
hoveredSlot = box;
hoveredBox = box;
break;
}
@@ -224,6 +214,26 @@ namespace Minigames.CardSorting.Core
}
}
// Update hover indicator on boxes
if (hoveredBox != _lastHoveredBox)
{
// Hide indicator on previously hovered box
if (_lastHoveredBox != null)
{
_lastHoveredBox.HideHoverIndicator();
}
// Show indicator on newly hovered box
if (hoveredBox != null)
{
// Check if this is the correct box for visual feedback
bool isCorrectBox = hoveredBox.ValidateItem(this);
hoveredBox.ShowHoverIndicator(isCorrectBox);
}
_lastHoveredBox = hoveredBox;
}
// Update current slot (used in OnDragEndedHook)
if (hoveredSlot != null && hoveredSlot != CurrentSlot)
{

View File

@@ -1,6 +1,5 @@
using AppleHills.Data.CardSystem;
using Core.SaveLoad;
using UI.CardSystem;
using Core.SaveLoad;
using Minigames.CardSorting.Data;
using UI.CardSystem.StateMachine;
using UnityEngine;
@@ -9,13 +8,13 @@ namespace Minigames.CardSorting.Core
/// <summary>
/// Shared context for sortable item states.
/// Provides access to common components and data that states need.
/// Routes data to appropriate visual component (CardDisplay for cards, GarbageVisual for garbage).
/// Prefabs handle their own visual setup - no runtime initialization needed.
/// </summary>
public class SortableItemContext : MonoBehaviour
{
[Header("Visual Components (one or the other)")]
[SerializeField] private CardDisplay cardDisplay; // For cards
[SerializeField] private GarbageVisual garbageVisual; // For garbage
[Header("Effect Components")]
[Tooltip("Top-level overlay image for visual effects (blink red, etc). Should be hidden by default.")]
[SerializeField] private UnityEngine.UI.Image blinkOverlayImage;
[Header("Shared Components")]
[SerializeField] private CardAnimator animator;
@@ -24,8 +23,7 @@ namespace Minigames.CardSorting.Core
private AppleMachine stateMachine;
// Public accessors
public CardDisplay CardDisplay => cardDisplay;
public GarbageVisual GarbageVisual => garbageVisual;
public UnityEngine.UI.Image BlinkOverlayImage => blinkOverlayImage;
public CardAnimator Animator => animator;
public Transform VisualTransform => visualTransform;
public AppleMachine StateMachine => stateMachine;
@@ -42,6 +40,20 @@ namespace Minigames.CardSorting.Core
private void Awake()
{
// Capture original transform for drag animations
// This preserves the prefab's configured scale (e.g., 0.05 for world-space Canvas)
var currentScale = transform.localScale;
if (currentScale.x < 0.01f && currentScale.y < 0.01f && currentScale.z < 0.01f)
{
OriginalScale = Vector3.one; // Fallback if scale is ~0
}
else
{
OriginalScale = currentScale;
}
OriginalPosition = transform.localPosition;
OriginalRotation = transform.localRotation;
// Auto-find components if not assigned
if (visualTransform == null)
{
@@ -64,76 +76,16 @@ namespace Minigames.CardSorting.Core
}
}
if (cardDisplay == null && visualTransform != null)
{
cardDisplay = visualTransform.GetComponentInChildren<CardDisplay>();
}
if (garbageVisual == null && visualTransform != null)
{
garbageVisual = visualTransform.GetComponentInChildren<GarbageVisual>();
}
stateMachine = GetComponentInChildren<AppleMachine>();
}
/// <summary>
/// Setup as card item - CardDisplay handles all rendering.
/// </summary>
public void SetupAsCard(CardData cardData)
{
// Capture original root transform for drag animations
// This preserves the tiny world-space Canvas scale (e.g., 0.05)
var currentScale = transform.localScale;
if (currentScale.x < 0.01f && currentScale.y < 0.01f && currentScale.z < 0.01f)
{
OriginalScale = Vector3.one; // Fallback if scale is ~0
}
else
{
OriginalScale = currentScale;
}
OriginalPosition = transform.localPosition;
OriginalRotation = transform.localRotation;
if (cardDisplay != null)
// Hide blink overlay initially
if (blinkOverlayImage != null)
{
cardDisplay.SetupCard(cardData);
}
else
{
Debug.LogError($"[SortableItemContext] CardDisplay not found on {name}");
blinkOverlayImage.enabled = false;
}
}
/// <summary>
/// Setup as garbage item - simple sprite display.
/// </summary>
public void SetupAsGarbage(Sprite sprite)
{
// Capture original root transform for drag animations
// This preserves the tiny world-space Canvas scale (e.g., 0.05)
var currentScale = transform.localScale;
if (currentScale.x < 0.01f && currentScale.y < 0.01f && currentScale.z < 0.01f)
{
OriginalScale = Vector3.one; // Fallback if scale is ~0
}
else
{
OriginalScale = currentScale;
}
OriginalPosition = transform.localPosition;
OriginalRotation = transform.localRotation;
if (garbageVisual != null)
{
garbageVisual.UpdateDisplay(sprite);
}
else
{
Debug.LogError($"[SortableItemContext] GarbageVisual not found on {name}");
}
}
}
}

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0ebfb8d9e40f4532b3a3919ced988330
timeCreated: 1766069349

View File

@@ -13,8 +13,47 @@ namespace Minigames.CardSorting.Core
[Header("Box Configuration")]
[SerializeField] private BoxType boxType;
[Header("Visual Feedback")]
[Tooltip("Sprite renderer to show when an item is hovering over this box")]
[SerializeField] private SpriteRenderer hoverIndicator;
public BoxType BoxType => boxType;
private void Start()
{
// Hide hover indicator on start
if (hoverIndicator != null)
{
hoverIndicator.enabled = false;
}
}
/// <summary>
/// Show the hover indicator when an item is hovering over this box.
/// </summary>
/// <param name="isCorrect">If true, tints the indicator green. Otherwise uses default color.</param>
public void ShowHoverIndicator(bool isCorrect = false)
{
if (hoverIndicator != null)
{
hoverIndicator.enabled = true;
// Tint green if correct box, white otherwise
hoverIndicator.color = isCorrect ? Color.green : Color.white;
}
}
/// <summary>
/// Hide the hover indicator when an item stops hovering.
/// </summary>
public void HideHoverIndicator()
{
if (hoverIndicator != null)
{
hoverIndicator.enabled = false;
}
}
/// <summary>
/// Check if item belongs in this box.
/// </summary>

View File

@@ -21,8 +21,6 @@ namespace Minigames.CardSorting.Core
[SerializeField] private Transform conveyorSpawnPoint;
[SerializeField] private Transform conveyorEndPoint; // Visual end - items scored as missed here
[SerializeField] private Transform conveyorDespawnPoint; // Off-screen - items destroyed here
[SerializeField] private GameObject sortableCardPrefab;
[SerializeField] private GameObject sortableGarbagePrefab;
[SerializeField] private SortingBox[] sortingBoxes;
[SerializeField] private Transform spawnedItemsContainer; // Container for all spawned items (optional, will auto-create if null)
@@ -44,8 +42,6 @@ namespace Minigames.CardSorting.Core
conveyorSpawnPoint,
conveyorEndPoint,
conveyorDespawnPoint,
sortableCardPrefab,
sortableGarbagePrefab,
_settings,
GetOrCreateSpawnContainer()
);
@@ -213,23 +209,24 @@ namespace Minigames.CardSorting.Core
}
// Blink the item red (if it still exists)
if (item != null && item.Context != null && item.Context.Animator != null)
if (item != null && item.Context != null && item.Context.Animator != null && item.Context.BlinkOverlayImage != null)
{
UnityEngine.UI.Image imageToBlink = null;
// Show overlay before blinking
item.Context.BlinkOverlayImage.enabled = true;
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>();
}
item.Context.Animator.BlinkRed(item.Context.BlinkOverlayImage, 0.15f);
if (imageToBlink != null)
{
item.Context.Animator.BlinkRed(imageToBlink, 0.15f);
}
// Hide overlay after blink duration (assuming blink duration + buffer)
StartCoroutine(HideOverlayAfterDelay(item.Context.BlinkOverlayImage, 0.5f));
}
}
private System.Collections.IEnumerator HideOverlayAfterDelay(UnityEngine.UI.Image overlay, float delay)
{
yield return new WaitForSeconds(delay);
if (overlay != null)
{
overlay.enabled = false;
}
}
@@ -310,7 +307,7 @@ namespace Minigames.CardSorting.Core
{
if (item != null && item.gameObject != null)
{
Logging.Debug($"[SortingGameManager] Destroying orphaned item: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
Logging.Debug($"[SortingGameManager] Destroying orphaned item: {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
Destroy(item.gameObject);
}
}
@@ -364,7 +361,7 @@ namespace Minigames.CardSorting.Core
// Forward to public event for UI/other systems
OnItemSpawned?.Invoke(item);
Logging.Debug($"[SortingGameManager] Item spawned: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
Logging.Debug($"[SortingGameManager] Item spawned: {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
}
/// <summary>
@@ -383,11 +380,11 @@ namespace Minigames.CardSorting.Core
Score.RecordMissedItem();
PlayWrongStateFeedback(item);
LoseLife();
Logging.Debug($"[SortingGameManager] Trash fell off belt! {item.GarbageItem?.DisplayName} - PENALTY");
Logging.Debug($"[SortingGameManager] Trash fell off belt! Garbage - PENALTY");
}
else
{
Logging.Debug($"[SortingGameManager] Card fell off belt: {item.CardData?.Name} - no penalty");
Logging.Debug($"[SortingGameManager] Card fell off belt: {item.Rarity} Card - no penalty");
}
// Fire global fell off belt event for effects
@@ -412,11 +409,11 @@ namespace Minigames.CardSorting.Core
Score.RecordIncorrectSort();
PlayWrongStateFeedback(item);
LoseLife();
Logging.Debug($"[SortingGameManager] Trash dropped on floor! {item.GarbageItem?.DisplayName} - PENALTY");
Logging.Debug($"[SortingGameManager] Trash dropped on floor! Garbage - PENALTY");
}
else
{
Logging.Debug($"[SortingGameManager] Card dropped on floor: {item.CardData?.Name} - no penalty");
Logging.Debug($"[SortingGameManager] Card dropped on floor: {item.Rarity} Card - no penalty");
}
}
@@ -426,7 +423,8 @@ namespace Minigames.CardSorting.Core
/// </summary>
private void OnConveyorItemDespawned(SortableItem item)
{
Logging.Debug($"[SortingGameManager] Item despawned: {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
Logging.Debug($"[SortingGameManager] Item despawned: {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
// Destroy the item
if (item != null)
@@ -446,7 +444,7 @@ namespace Minigames.CardSorting.Core
if (correct)
{
Score.RecordCorrectSort();
Logging.Debug($"[SortingGameManager] Correct sort! {item.CardData?.Name ?? item.GarbageItem?.DisplayName}");
Logging.Debug($"[SortingGameManager] Correct sort! {(item.IsGarbage ? "Garbage" : $"{item.Rarity} Card")}");
// Fire global correct sort event for effects
OnItemSortedCorrectly?.Invoke(item);
@@ -460,14 +458,14 @@ namespace Minigames.CardSorting.Core
Score.RecordIncorrectSort();
PlayWrongStateFeedback(item);
LoseLife();
Logging.Debug($"[SortingGameManager] Incorrect trash sort! {item.GarbageItem?.DisplayName} - PENALTY");
Logging.Debug($"[SortingGameManager] Incorrect trash sort! Garbage - PENALTY");
// Fire global incorrect sort event for effects
OnItemSortedIncorrectly?.Invoke(item);
}
else
{
Logging.Debug($"[SortingGameManager] Card sorted incorrectly: {item.CardData?.Name} - no penalty");
Logging.Debug($"[SortingGameManager] Card sorted incorrectly: {item.Rarity} Card - no penalty");
}
}

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d88deb3df9e54bdb83b9a7ed1c7e3e27
timeCreated: 1766069228

View File

@@ -1,38 +0,0 @@
using UnityEngine;
namespace Minigames.CardSorting.Data
{
/// <summary>
/// Definition for garbage items (banana peels, cans, receipts, etc.).
/// Cards use existing CardDefinition from CardSystemManager.
/// </summary>
[CreateAssetMenu(fileName = "GarbageItem", menuName = "Minigames/CardSorting/GarbageItem", order = 0)]
public class GarbageItemDefinition : ScriptableObject
{
[Tooltip("Unique identifier for this garbage item")]
[SerializeField] private string itemId;
[Tooltip("Display name for debugging")]
[SerializeField] private string displayName;
[Tooltip("Sprite to display for this garbage item")]
[SerializeField] private Sprite sprite;
// Public accessors
public string ItemId => itemId;
public string DisplayName => displayName;
public Sprite Sprite => sprite;
#if UNITY_EDITOR
private void OnValidate()
{
// Auto-generate itemId from asset name if empty
if (string.IsNullOrEmpty(itemId))
{
itemId = name;
}
}
#endif
}
}

View File

@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 2e69a2167710437798b1980126d5a4f6
timeCreated: 1763461765

View File

@@ -57,28 +57,12 @@ namespace Minigames.CardSorting.StateMachine.States
private void StartBlinkingRed()
{
if (_context.Animator == null) return;
if (_context.Animator == null || _context.BlinkOverlayImage == null) return;
// Get the image to tint (CardDisplay or GarbageVisual)
UnityEngine.UI.Image imageToBlink = null;
// Show overlay before blinking
_context.BlinkOverlayImage.enabled = true;
if (_context.CardDisplay != null)
{
imageToBlink =
_context.CardDisplay.GetComponent<UnityEngine.UI.Image>()
?? _context.CardDisplay.GetComponentInChildren<UnityEngine.UI.Image>();
}
else if (_context.GarbageVisual != null)
{
imageToBlink =
_context.GarbageVisual.GetComponent<UnityEngine.UI.Image>()
?? _context.GarbageVisual.GetComponentInChildren<UnityEngine.UI.Image>();
}
if (imageToBlink != null)
{
_context.Animator.BlinkRed(imageToBlink);
}
_context.Animator.BlinkRed(_context.BlinkOverlayImage);
}
private void OnDisable()

View File

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

View File

@@ -0,0 +1,237 @@
using UnityEngine;
using UnityEngine.UI;
using AppleHills.Core;
namespace UI.Tracking
{
/// <summary>
/// UI pin that displays on screen edges pointing to off-screen targets.
/// Consists of a static icon in the center and a rotatable frame that points toward the target.
/// Optionally displays distance to target if enabled.
/// Updates every frame for smooth tracking.
/// </summary>
public class OffScreenPin : MonoBehaviour
{
[Header("References")]
[Tooltip("The image component that displays the target's icon (static, in center)")]
[SerializeField] private Image iconImage;
[Tooltip("The image component that rotates to point toward the target (default: pointing downward)")]
[SerializeField] private Image frameImage;
[Tooltip("Optional: Text component to display distance to target")]
[SerializeField] private TMPro.TextMeshProUGUI distanceText;
private RectTransform _rectTransform;
private TrackableTarget _target;
private bool _isInitialized;
private float _screenPadding;
private bool _trackDistance;
private void Awake()
{
_rectTransform = GetComponent<RectTransform>();
}
private void Update()
{
// Update position and rotation every frame for smooth tracking
if (_isInitialized && _target != null)
{
Camera mainCamera = QuickAccess.Instance?.MainCamera;
if (mainCamera != null)
{
UpdatePositionAndRotation(mainCamera, _screenPadding);
}
// Update distance display if enabled
if (_trackDistance && distanceText != null)
{
UpdateDistanceDisplay();
}
}
}
/// <summary>
/// Initialize the pin with a target reference and set the icon
/// </summary>
public void Initialize(TrackableTarget target, float screenPadding)
{
_target = target;
_isInitialized = true;
_screenPadding = screenPadding;
_trackDistance = target.TrackDistance;
// Set the icon sprite if available
if (iconImage != null && target.Icon != null)
{
iconImage.sprite = target.Icon;
iconImage.enabled = true;
}
else if (iconImage != null)
{
iconImage.enabled = false;
}
// Configure distance text
if (distanceText != null)
{
distanceText.gameObject.SetActive(_trackDistance);
}
}
/// <summary>
/// Update the pin's position and rotation based on the target's world position.
/// Called by the manager each update cycle.
/// </summary>
public void UpdatePositionAndRotation(Camera cam, float screenPadding)
{
if (!_isInitialized || _target == null)
return;
// Get target position in screen space
Vector3 targetScreenPos = cam.WorldToScreenPoint(_target.WorldPosition);
// Calculate direction from screen center to target
Vector2 screenCenter = new Vector2(Screen.width / 2f, Screen.height / 2f);
Vector2 targetScreenPos2D = new Vector2(targetScreenPos.x, targetScreenPos.y);
Vector2 directionToTarget = (targetScreenPos2D - screenCenter).normalized;
// Calculate screen bounds with padding (inset from edges)
float minX = screenPadding;
float maxX = Screen.width - screenPadding;
float minY = screenPadding;
float maxY = Screen.height - screenPadding;
// Find intersection point with screen bounds
Vector2 intersectionPoint = CalculateScreenEdgeIntersection(
screenCenter,
directionToTarget,
minX, maxX, minY, maxY
);
// Offset the intersection point slightly toward the center to ensure pin is fully visible
Vector2 offsetTowardCenter = -directionToTarget * screenPadding * 0.5f;
Vector2 finalPosition = intersectionPoint + offsetTowardCenter;
// Update pin position
_rectTransform.position = finalPosition;
// Update frame rotation to point toward target
// Frame's default orientation points downward (0 degrees in UI space)
// In UI space: 0° = down, 90° = right, 180° = up, 270° = left
// Atan2(y, x) gives angle from right (+X axis), so we need to adjust
float angle = Mathf.Atan2(directionToTarget.y, directionToTarget.x) * Mathf.Rad2Deg;
// Add 90 to convert from "right is 0°" to align with down-pointing sprite
angle = angle + 90f;
if (frameImage != null)
{
frameImage.rectTransform.localRotation = Quaternion.Euler(0, 0, angle);
}
}
/// <summary>
/// Update the distance display text
/// </summary>
private void UpdateDistanceDisplay()
{
// Get distance source (typically the player)
if (TrackingDistanceSource.Instance == null)
{
distanceText.text = "???";
return;
}
// Calculate distance between source and target
float distance = Vector3.Distance(TrackingDistanceSource.Instance.WorldPosition, _target.WorldPosition);
// Format distance nicely (meters with 1 decimal place)
distanceText.text = $"{distance:F1}m";
}
/// <summary>
/// Calculate the intersection point of a ray from center in given direction with screen bounds
/// </summary>
private Vector2 CalculateScreenEdgeIntersection(
Vector2 center,
Vector2 direction,
float minX, float maxX, float minY, float maxY)
{
// Calculate intersection with each edge and find the closest one
float tMin = float.MaxValue;
Vector2 intersection = center;
// Check intersection with right edge (x = maxX)
if (direction.x > 0.001f)
{
float t = (maxX - center.x) / direction.x;
float y = center.y + t * direction.y;
if (y >= minY && y <= maxY && t < tMin)
{
tMin = t;
intersection = new Vector2(maxX, y);
}
}
// Check intersection with left edge (x = minX)
if (direction.x < -0.001f)
{
float t = (minX - center.x) / direction.x;
float y = center.y + t * direction.y;
if (y >= minY && y <= maxY && t < tMin)
{
tMin = t;
intersection = new Vector2(minX, y);
}
}
// Check intersection with top edge (y = maxY)
if (direction.y > 0.001f)
{
float t = (maxY - center.y) / direction.y;
float x = center.x + t * direction.x;
if (x >= minX && x <= maxX && t < tMin)
{
tMin = t;
intersection = new Vector2(x, maxY);
}
}
// Check intersection with bottom edge (y = minY)
if (direction.y < -0.001f)
{
float t = (minY - center.y) / direction.y;
float x = center.x + t * direction.x;
if (x >= minX && x <= maxX && t < tMin)
{
tMin = t;
intersection = new Vector2(x, minY);
}
}
return intersection;
}
/// <summary>
/// Reset the pin for pooling reuse
/// </summary>
public void ResetPin()
{
_target = null;
_isInitialized = false;
if (iconImage != null)
{
iconImage.sprite = null;
iconImage.enabled = false;
}
}
/// <summary>
/// Get the current target (for null checking)
/// </summary>
public TrackableTarget Target => _target;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 548d13ffdad349b6939e2b873a39b54e
timeCreated: 1766074498

View File

@@ -0,0 +1,494 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Core;
using Core.Lifecycle;
using AppleHills.Core;
namespace UI.Tracking
{
/// <summary>
/// Singleton manager that tracks off-screen targets and displays directional pins.
/// Uses ManagedBehaviour pattern and lazily accesses camera via QuickAccess (like AudioManager).
/// </summary>
public class OffScreenTrackerManager : ManagedBehaviour
{
[Header("Configuration")]
[Tooltip("Prefab for the off-screen tracking pin")]
[SerializeField] private OffScreenPin pinPrefab;
[Tooltip("Pixel padding from screen edges (pins appear this many pixels from edge)")]
[SerializeField] private float screenPadding = 50f;
[Tooltip("Buffer zone in pixels to prevent spawn/despawn flickering (spawn is stricter than despawn)")]
[SerializeField] private float bufferZone = 100f;
[Tooltip("Time in seconds a target must be off-screen before pin appears")]
[SerializeField] private float spawnDebounceDelay = 0.3f;
[Tooltip("Time in seconds a target must be on-screen before pin disappears")]
[SerializeField] private float despawnDebounceDelay = 0.2f;
[Tooltip("Update interval in seconds for checking target visibility")]
[SerializeField] private float updateInterval = 0.1f;
// Singleton instance
private static OffScreenTrackerManager _instance;
public static OffScreenTrackerManager Instance => _instance;
// Tracking data
private Dictionary<TrackableTarget, TargetTrackingData> _trackedTargets = new Dictionary<TrackableTarget, TargetTrackingData>();
// Pin pooling
private List<OffScreenPin> _inactivePins = new List<OffScreenPin>();
// Coroutine tracking
private Coroutine _updateCoroutine;
// Auto-created canvas for pins
private Canvas _pinCanvas;
private RectTransform _pinContainer;
/// <summary>
/// Nested class to track per-target state and timers
/// </summary>
private class TargetTrackingData
{
public TrackableTarget Target;
public OffScreenPin ActivePin;
public float OffScreenTimer;
public float OnScreenTimer;
public bool IsCurrentlyOffScreen;
public TargetTrackingData(TrackableTarget target)
{
Target = target;
ActivePin = null;
OffScreenTimer = 0f;
OnScreenTimer = 0f;
IsCurrentlyOffScreen = false;
}
}
internal override void OnManagedAwake()
{
// Set singleton instance
_instance = this;
}
internal override void OnManagedStart()
{
// Validate configuration
if (pinPrefab == null)
{
Logging.Error("[OffScreenTrackerManager] Pin prefab not assigned!");
return;
}
// Create dedicated canvas for pins
CreatePinCanvas();
// Subscribe to scene load events from SceneManagerService (like InputManager does)
// This must happen in ManagedStart because SceneManagerService instance needs to be set first
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted += OnSceneLoadCompleted;
Logging.Debug("[OffScreenTrackerManager] Subscribed to SceneLoadCompleted events");
}
// Initialize for current scene and start coroutine
InitializeForCurrentScene();
}
/// <summary>
/// Called when any scene finishes loading. Refreshes camera and restarts coroutine.
/// </summary>
private void OnSceneLoadCompleted(string sceneName)
{
Logging.Debug($"[OffScreenTrackerManager] Scene loaded: {sceneName}, reinitializing camera and coroutine");
InitializeForCurrentScene();
}
/// <summary>
/// Initialize camera reference and start/restart tracking coroutine for current scene
/// </summary>
private void InitializeForCurrentScene()
{
// Stop existing coroutine if running
if (_updateCoroutine != null)
{
StopCoroutine(_updateCoroutine);
_updateCoroutine = null;
Logging.Debug("[OffScreenTrackerManager] Stopped previous coroutine");
}
// Start the tracking coroutine (camera accessed lazily via QuickAccess)
_updateCoroutine = StartCoroutine(UpdateTrackingCoroutine());
Logging.Debug("[OffScreenTrackerManager] Started tracking coroutine");
}
internal override void OnManagedDestroy()
{
// Unsubscribe from SceneManagerService events (like InputManager does)
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted -= OnSceneLoadCompleted;
}
}
private void OnDestroy()
{
// Stop coroutine
if (_updateCoroutine != null)
{
StopCoroutine(_updateCoroutine);
}
// Clean up pooled pins
foreach (var pin in _inactivePins)
{
if (pin != null)
{
Destroy(pin.gameObject);
}
}
_inactivePins.Clear();
// Clean up active pins
foreach (var data in _trackedTargets.Values)
{
if (data.ActivePin != null)
{
Destroy(data.ActivePin.gameObject);
}
}
_trackedTargets.Clear();
// Clean up canvas
if (_pinCanvas != null)
{
Destroy(_pinCanvas.gameObject);
}
// Clear singleton
if (_instance == this)
{
_instance = null;
}
}
/// <summary>
/// Create a dedicated canvas for pins with sort order 50
/// </summary>
private void CreatePinCanvas()
{
// Create a new GameObject for the canvas
GameObject canvasObj = new GameObject("OffScreenPinCanvas");
canvasObj.transform.SetParent(transform, false);
// Add and configure Canvas
_pinCanvas = canvasObj.AddComponent<Canvas>();
_pinCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
_pinCanvas.sortingOrder = 50;
// Add CanvasScaler for consistent sizing
var scaler = canvasObj.AddComponent<UnityEngine.UI.CanvasScaler>();
scaler.uiScaleMode = UnityEngine.UI.CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = UnityEngine.UI.CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
// Add GraphicRaycaster (required for UI)
canvasObj.AddComponent<UnityEngine.UI.GraphicRaycaster>();
// Get RectTransform for pin container
_pinContainer = canvasObj.GetComponent<RectTransform>();
Logging.Debug("[OffScreenTrackerManager] Created dedicated pin canvas with sort order 50");
}
/// <summary>
/// Register a target for tracking
/// </summary>
public void RegisterTarget(TrackableTarget target)
{
if (target == null)
return;
if (_trackedTargets.ContainsKey(target))
{
Logging.Warning($"[OffScreenTrackerManager] Target {target.name} is already registered");
return;
}
_trackedTargets.Add(target, new TargetTrackingData(target));
Logging.Debug($"[OffScreenTrackerManager] Registered target: {target.name}");
}
/// <summary>
/// Unregister a target from tracking
/// </summary>
public void UnregisterTarget(TrackableTarget target)
{
if (target == null)
return;
if (_trackedTargets.TryGetValue(target, out TargetTrackingData data))
{
// Despawn pin if active
if (data.ActivePin != null)
{
DespawnPin(data);
}
_trackedTargets.Remove(target);
Logging.Debug($"[OffScreenTrackerManager] Unregistered target: {target.name}");
}
}
/// <summary>
/// Main update coroutine that runs every updateInterval seconds
/// </summary>
private IEnumerator UpdateTrackingCoroutine()
{
Logging.Debug("[OffScreenTrackerManager] Tracking coroutine started");
WaitForSeconds wait = new WaitForSeconds(updateInterval);
while (true)
{
yield return wait;
// Get camera lazily via QuickAccess (like AudioManager does)
Camera mainCamera = QuickAccess.Instance?.MainCamera;
if (mainCamera == null)
{
// Camera not available yet (early in boot or scene transition)
continue;
}
// Create list of targets to remove (for null cleanup)
List<TrackableTarget> targetsToRemove = new List<TrackableTarget>();
foreach (var kvp in _trackedTargets)
{
TrackableTarget target = kvp.Key;
TargetTrackingData data = kvp.Value;
// Check if target was destroyed
if (target == null)
{
targetsToRemove.Add(target);
if (data.ActivePin != null)
{
DespawnPin(data);
}
continue;
}
// Check if target is off-screen
bool isOffScreen = IsTargetOffScreen(target, mainCamera);
// Update timers and state
if (isOffScreen)
{
// Target is off-screen
data.OffScreenTimer += updateInterval;
data.OnScreenTimer = 0f;
// Check if we should spawn a pin
if (!data.IsCurrentlyOffScreen && data.OffScreenTimer >= spawnDebounceDelay)
{
data.IsCurrentlyOffScreen = true;
SpawnPin(data);
}
// Pin updates itself every frame, no need to call UpdatePositionAndRotation here
}
else
{
// Target is on-screen
data.OnScreenTimer += updateInterval;
data.OffScreenTimer = 0f;
// Check if we should despawn the pin
if (data.IsCurrentlyOffScreen && data.OnScreenTimer >= despawnDebounceDelay)
{
data.IsCurrentlyOffScreen = false;
if (data.ActivePin != null)
{
DespawnPin(data);
}
}
}
}
// Clean up null targets
foreach (var target in targetsToRemove)
{
_trackedTargets.Remove(target);
}
}
}
/// <summary>
/// Check if a target is off-screen by checking its actual bounds.
/// - For SPAWNING: Entire object must be off-screen (all corners outside viewport)
/// - For DESPAWNING: Any part of object on-screen triggers despawn (any corner inside viewport)
/// Uses bufferZone to prevent flickering at boundaries.
/// </summary>
private bool IsTargetOffScreen(TrackableTarget target, Camera cam)
{
// Get the world bounds of the target
Bounds worldBounds = target.GetWorldBounds();
// Get the 8 corners of the bounds (we only need the min/max points in 2D)
Vector3 min = worldBounds.min;
Vector3 max = worldBounds.max;
// Convert corners to screen space
Vector3 minScreen = cam.WorldToScreenPoint(new Vector3(min.x, min.y, worldBounds.center.z));
Vector3 maxScreen = cam.WorldToScreenPoint(new Vector3(max.x, max.y, worldBounds.center.z));
// Check if behind camera
if (minScreen.z < 0 && maxScreen.z < 0)
return true;
// Shrink detection zones to 80% of screen (10% inset on each side)
// This makes spawn/despawn more conservative
float insetPercent = 0.1f; // 10% on each side = 80% total
float horizontalInset = Screen.width * insetPercent;
float verticalInset = Screen.height * insetPercent;
float screenLeft = horizontalInset;
float screenRight = Screen.width - horizontalInset;
float screenBottom = verticalInset;
float screenTop = Screen.height - verticalInset;
// Check if ENTIRELY off-screen (all corners outside viewport)
// This is when we should spawn the pin
bool entirelyOffScreenLeft = maxScreen.x < screenLeft;
bool entirelyOffScreenRight = minScreen.x > screenRight;
bool entirelyOffScreenBottom = maxScreen.y < screenBottom;
bool entirelyOffScreenTop = minScreen.y > screenTop;
bool entirelyOffScreen = entirelyOffScreenLeft || entirelyOffScreenRight ||
entirelyOffScreenBottom || entirelyOffScreenTop;
// Apply buffer zone to prevent flickering
// If already off-screen, require target to move bufferZone pixels on-screen before considering it "on-screen"
// This creates hysteresis to prevent rapid spawn/despawn cycles
if (entirelyOffScreen)
{
return true; // Definitely off-screen
}
// Check if ANY part is on-screen (for despawn logic)
// We add bufferZone to make despawn slightly more eager
bool anyPartOnScreen = !(minScreen.x > screenRight - bufferZone ||
maxScreen.x < screenLeft + bufferZone ||
minScreen.y > screenTop - bufferZone ||
maxScreen.y < screenBottom + bufferZone);
// If any part is on-screen (with buffer), consider it "on-screen" (pin should despawn)
return !anyPartOnScreen;
}
/// <summary>
/// Spawn a pin for the given target data
/// </summary>
private void SpawnPin(TargetTrackingData data)
{
// Try to get pin from pool
OffScreenPin pin = GetPinFromPool();
// Initialize the pin (this also caches the target and settings)
pin.Initialize(data.Target, screenPadding);
// CRITICAL: Update position BEFORE activating to prevent flicker
// Get camera for immediate position update
Camera mainCamera = QuickAccess.Instance?.MainCamera;
if (mainCamera != null)
{
pin.UpdatePositionAndRotation(mainCamera, screenPadding);
}
// Now activate the pin at the correct position
pin.gameObject.SetActive(true);
data.ActivePin = pin;
Logging.Debug($"[OffScreenTrackerManager] Spawned pin for target: {data.Target.name}");
}
/// <summary>
/// Despawn a pin and return it to the pool
/// </summary>
private void DespawnPin(TargetTrackingData data)
{
if (data.ActivePin == null)
return;
OffScreenPin pin = data.ActivePin;
data.ActivePin = null;
// Reset and return to pool
pin.ResetPin();
pin.gameObject.SetActive(false);
_inactivePins.Add(pin);
Logging.Debug($"[OffScreenTrackerManager] Despawned pin for target: {data.Target?.name ?? "null"}");
}
/// <summary>
/// Get a pin from the pool or instantiate a new one
/// </summary>
private OffScreenPin GetPinFromPool()
{
// Try to reuse an inactive pin
if (_inactivePins.Count > 0)
{
OffScreenPin pin = _inactivePins[_inactivePins.Count - 1];
_inactivePins.RemoveAt(_inactivePins.Count - 1);
return pin;
}
// Create a new pin
OffScreenPin newPin = Instantiate(pinPrefab, _pinContainer);
newPin.gameObject.SetActive(false);
return newPin;
}
#region Public Configuration Accessors
/// <summary>
/// Get or set the screen padding in pixels
/// </summary>
public float ScreenPadding
{
get => screenPadding;
set => screenPadding = Mathf.Max(0f, value);
}
/// <summary>
/// Get or set the spawn debounce delay
/// </summary>
public float SpawnDebounceDelay
{
get => spawnDebounceDelay;
set => spawnDebounceDelay = Mathf.Max(0f, value);
}
/// <summary>
/// Get or set the despawn debounce delay
/// </summary>
public float DespawnDebounceDelay
{
get => despawnDebounceDelay;
set => despawnDebounceDelay = Mathf.Max(0f, value);
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8599140fd23e492fa7f14cb7633209fe
timeCreated: 1766074538

View File

@@ -0,0 +1,84 @@
using UnityEngine;
namespace UI.Tracking
{
/// <summary>
/// Component that marks a GameObject as trackable by the OffScreenTrackerManager.
/// Automatically registers/unregisters with the manager when enabled/disabled.
/// </summary>
public class TrackableTarget : MonoBehaviour
{
[Header("Configuration")]
[Tooltip("Icon to display in the off-screen tracking pin")]
[SerializeField] private Sprite icon;
[Tooltip("Should this target display distance to the TrackingDistanceSource (e.g., player)?")]
[SerializeField] private bool trackDistance = false;
/// <summary>
/// The icon to display in the tracking pin
/// </summary>
public Sprite Icon => icon;
/// <summary>
/// Should this target track and display distance?
/// </summary>
public bool TrackDistance => trackDistance;
/// <summary>
/// The world position of this target
/// </summary>
public Vector3 WorldPosition => transform.position;
/// <summary>
/// Get the screen-space bounds of this target's visual representation.
/// Checks for Renderer or Collider2D to determine size.
/// </summary>
public Bounds GetWorldBounds()
{
// Try to get bounds from Renderer first (most accurate for visuals)
Renderer renderer = GetComponentInChildren<Renderer>();
if (renderer != null)
{
return renderer.bounds;
}
// Fallback to Collider2D
Collider2D collider = GetComponent<Collider2D>();
if (collider != null)
{
return collider.bounds;
}
// Last resort: just return position with minimal bounds
return new Bounds(transform.position, Vector3.one * 0.1f);
}
private void OnEnable()
{
// Register with the manager when enabled
if (OffScreenTrackerManager.Instance != null)
{
OffScreenTrackerManager.Instance.RegisterTarget(this);
}
}
private void OnDisable()
{
// Unregister from the manager when disabled
if (OffScreenTrackerManager.Instance != null)
{
OffScreenTrackerManager.Instance.UnregisterTarget(this);
}
}
/// <summary>
/// Allow runtime icon changes
/// </summary>
public void SetIcon(Sprite newIcon)
{
icon = newIcon;
}
}
}

View File

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

View File

@@ -0,0 +1,43 @@
using UnityEngine;
namespace UI.Tracking
{
/// <summary>
/// Marks a GameObject as the source point for distance calculations in the tracking system.
/// Typically attached to the player or camera. Only one should be active at a time.
/// </summary>
public class TrackingDistanceSource : MonoBehaviour
{
private static TrackingDistanceSource _instance;
/// <summary>
/// The currently active distance source (typically the player)
/// </summary>
public static TrackingDistanceSource Instance => _instance;
/// <summary>
/// The world position of this distance source
/// </summary>
public Vector3 WorldPosition => transform.position;
private void OnEnable()
{
// Set as the active instance
if (_instance != null && _instance != this)
{
Debug.LogWarning($"[TrackingDistanceSource] Multiple distance sources detected. Overwriting previous instance ({_instance.name}) with {name}");
}
_instance = this;
}
private void OnDisable()
{
// Clear instance if this was the active one
if (_instance == this)
{
_instance = null;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1788298f42bd40f6b077ca3719861752
timeCreated: 1766077591