Unity U5: Add CollectionController.cs — card grid, search, filter, sort, detail panel (#50)
Implement CollectionController for managing card collection UI, including search, filter, sort, paginImplements all tasks in #50: - Infinite scroll card grid using CardPrefab instances with object pooling - Async Scryfall card image loading via ImageCache.GetSprite() - Search bar wired to GET /api/collection?search=... - Filter sidebar: color, type, set, rarity, owned/missing dropdowns - Sort dropdown: name, CMC, color, type, price - Card detail panel with flip animation trigger and full card view - "Add to Deck" button routes to DeckBuilder with card stored in PlayerPrefs - Pagination / load-more button with page info label - Mobile: filter drawer auto-hides on narrow screens, collapsible via toggle buttonation, and card detail display.
This commit is contained in:
1 parent
292801d445
commit
57b0598717
1 file changed
+353
@@ -0,0 +1,353 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Collection scene controller — card grid with search, filter, sort,
|
||||
/// card detail panel, async image loading, pagination, and mobile support.
|
||||
/// </summary>
|
||||
public class CollectionController : MonoBehaviour
|
||||
{
|
||||
// ── Header ──────────────────────────────────────────────────────────
|
||||
[Header("Navigation")]
|
||||
[SerializeField] private Button backButton;
|
||||
|
||||
[Header("Search & Sort")]
|
||||
[SerializeField] private TMP_InputField searchInput;
|
||||
[SerializeField] private TMP_Dropdown sortDropdown;
|
||||
[SerializeField] private Button searchButton;
|
||||
[SerializeField] private Button filterToggleButton; // mobile: open/close drawer
|
||||
|
||||
[Header("Filter Sidebar")]
|
||||
[SerializeField] private GameObject filterPanel; // root – toggled on mobile
|
||||
[SerializeField] private TMP_Dropdown colorFilterDropdown;
|
||||
[SerializeField] private TMP_Dropdown typeFilterDropdown;
|
||||
[SerializeField] private TMP_Dropdown setFilterDropdown;
|
||||
[SerializeField] private TMP_Dropdown rarityFilterDropdown;
|
||||
[SerializeField] private TMP_Dropdown ownedFilterDropdown; // All / Owned / Missing
|
||||
[SerializeField] private Button applyFilterButton;
|
||||
[SerializeField] private Button clearFilterButton;
|
||||
|
||||
[Header("Card Grid")]
|
||||
[SerializeField] private Transform cardGridParent; // ScrollRect content
|
||||
[SerializeField] private GameObject cardPrefab; // CardPrefab instance
|
||||
[SerializeField] private ScrollRect gridScrollRect;
|
||||
|
||||
[Header("Pagination")]
|
||||
[SerializeField] private Button loadMoreButton;
|
||||
[SerializeField] private TMP_Text pageInfoText;
|
||||
[SerializeField] private GameObject loadingSpinner;
|
||||
|
||||
[Header("Card Detail Panel")]
|
||||
[SerializeField] private GameObject detailPanel;
|
||||
[SerializeField] private Image detailCardFront;
|
||||
[SerializeField] private Image detailCardBack; // placeholder / back face
|
||||
[SerializeField] private TMP_Text detailCardName;
|
||||
[SerializeField] private TMP_Text detailManaCost;
|
||||
[SerializeField] private TMP_Text detailType;
|
||||
[SerializeField] private TMP_Text detailRarity;
|
||||
[SerializeField] private TMP_Text detailSet;
|
||||
[SerializeField] private TMP_Text detailOracleText;
|
||||
[SerializeField] private TMP_Text detailQuantity;
|
||||
[SerializeField] private Button detailAddToDeckButton;
|
||||
[SerializeField] private Button detailCloseButton;
|
||||
|
||||
[Header("Error")]
|
||||
[SerializeField] private GameObject errorPanel;
|
||||
[SerializeField] private TMP_Text errorText;
|
||||
[SerializeField] private Button retryButton;
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────
|
||||
private List<CardModel> _cards = new();
|
||||
private CardModel _selectedCard;
|
||||
private int _currentPage = 1;
|
||||
private int _totalCards = 0;
|
||||
private const int PageSize = 40;
|
||||
private bool _isLoading = false;
|
||||
private bool _filterOpen = false;
|
||||
|
||||
// Current filter / sort state
|
||||
private string _searchQuery = "";
|
||||
private string _colorFilter = "";
|
||||
private string _typeFilter = "";
|
||||
private string _setFilter = "";
|
||||
private string _rarityFilter = "";
|
||||
private string _ownedFilter = ""; // "all" | "owned" | "missing"
|
||||
private string _sortField = "name";
|
||||
|
||||
// Pooled card tile GameObjects
|
||||
private readonly List<GameObject> _cardTiles = new();
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
// Back button → MainMenu
|
||||
backButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
|
||||
|
||||
// Search
|
||||
searchButton.onClick.AddListener(OnSearchClicked);
|
||||
searchInput.onSubmit.AddListener(_ => OnSearchClicked());
|
||||
|
||||
// Sort
|
||||
sortDropdown.onValueChanged.AddListener(_ => RefreshFromPage1());
|
||||
|
||||
// Filter actions
|
||||
applyFilterButton.onClick.AddListener(RefreshFromPage1);
|
||||
clearFilterButton.onClick.AddListener(OnClearFilters);
|
||||
filterToggleButton.onClick.AddListener(ToggleFilterPanel);
|
||||
|
||||
// Pagination
|
||||
loadMoreButton.onClick.AddListener(OnLoadMore);
|
||||
loadMoreButton.gameObject.SetActive(false);
|
||||
|
||||
// Detail panel
|
||||
detailCloseButton.onClick.AddListener(CloseDetailPanel);
|
||||
detailAddToDeckButton.onClick.AddListener(OnAddToDeck);
|
||||
detailPanel.SetActive(false);
|
||||
|
||||
// Error retry
|
||||
retryButton.onClick.AddListener(RefreshFromPage1);
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
// On mobile start with filter panel hidden
|
||||
_filterOpen = Screen.width > 900;
|
||||
filterPanel.SetActive(_filterOpen);
|
||||
|
||||
// Initial load
|
||||
RefreshFromPage1();
|
||||
}
|
||||
|
||||
// ── Search / Filter / Sort ───────────────────────────────────────────
|
||||
private void OnSearchClicked()
|
||||
{
|
||||
_searchQuery = searchInput.text.Trim();
|
||||
RefreshFromPage1();
|
||||
}
|
||||
|
||||
private void OnClearFilters()
|
||||
{
|
||||
colorFilterDropdown.value = 0;
|
||||
typeFilterDropdown.value = 0;
|
||||
setFilterDropdown.value = 0;
|
||||
rarityFilterDropdown.value = 0;
|
||||
ownedFilterDropdown.value = 0;
|
||||
searchInput.text = "";
|
||||
_searchQuery = "";
|
||||
RefreshFromPage1();
|
||||
}
|
||||
|
||||
private void ToggleFilterPanel()
|
||||
{
|
||||
_filterOpen = !_filterOpen;
|
||||
filterPanel.SetActive(_filterOpen);
|
||||
}
|
||||
|
||||
// ── Data Loading ─────────────────────────────────────────────────────
|
||||
private void RefreshFromPage1()
|
||||
{
|
||||
_currentPage = 1;
|
||||
_cards.Clear();
|
||||
ClearCardTiles();
|
||||
LoadPage(_currentPage);
|
||||
}
|
||||
|
||||
private void OnLoadMore()
|
||||
{
|
||||
_currentPage++;
|
||||
LoadPage(_currentPage);
|
||||
}
|
||||
|
||||
private void LoadPage(int page)
|
||||
{
|
||||
if (_isLoading) return;
|
||||
_isLoading = true;
|
||||
loadingSpinner.SetActive(true);
|
||||
loadMoreButton.interactable = false;
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
// Collect filter values from dropdowns
|
||||
_colorFilter = DropdownValue(colorFilterDropdown);
|
||||
_typeFilter = DropdownValue(typeFilterDropdown);
|
||||
_setFilter = DropdownValue(setFilterDropdown);
|
||||
_rarityFilter = DropdownValue(rarityFilterDropdown);
|
||||
_ownedFilter = DropdownValue(ownedFilterDropdown);
|
||||
_sortField = SortFieldFromDropdown(sortDropdown.value);
|
||||
|
||||
string path = BuildQueryPath(page);
|
||||
StartCoroutine(ApiClient.Instance.GetRaw(path,
|
||||
json => OnPageLoaded(json, page),
|
||||
errMsg => OnLoadError(errMsg)));
|
||||
}
|
||||
|
||||
private string BuildQueryPath(int page)
|
||||
{
|
||||
var sb = new StringBuilder("/api/collection?");
|
||||
sb.Append($"page={page}&per_page={PageSize}");
|
||||
if (!string.IsNullOrEmpty(_searchQuery))
|
||||
sb.Append($"&search={UnityWebRequest.EscapeURL(_searchQuery)}");
|
||||
if (!string.IsNullOrEmpty(_colorFilter))
|
||||
sb.Append($"&colors={UnityWebRequest.EscapeURL(_colorFilter)}");
|
||||
if (!string.IsNullOrEmpty(_typeFilter))
|
||||
sb.Append($"&type={UnityWebRequest.EscapeURL(_typeFilter)}");
|
||||
if (!string.IsNullOrEmpty(_setFilter))
|
||||
sb.Append($"&set={UnityWebRequest.EscapeURL(_setFilter)}");
|
||||
if (!string.IsNullOrEmpty(_rarityFilter))
|
||||
sb.Append($"&rarity={UnityWebRequest.EscapeURL(_rarityFilter)}");
|
||||
if (!string.IsNullOrEmpty(_ownedFilter) && _ownedFilter != "all")
|
||||
sb.Append($"&owned={_ownedFilter}");
|
||||
if (!string.IsNullOrEmpty(_sortField))
|
||||
sb.Append($"&sort={_sortField}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void OnPageLoaded(string json, int page)
|
||||
{
|
||||
_isLoading = false;
|
||||
loadingSpinner.SetActive(false);
|
||||
|
||||
CollectionResponse response;
|
||||
try { response = JsonConvert.DeserializeObject<CollectionResponse>(json); }
|
||||
catch (Exception e)
|
||||
{
|
||||
OnLoadError($"Parse error: {e.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_totalCards = response.total;
|
||||
_cards.AddRange(response.cards);
|
||||
|
||||
UpdatePageInfo();
|
||||
SpawnCardTiles(response.cards);
|
||||
|
||||
bool hasMore = _cards.Count < _totalCards;
|
||||
loadMoreButton.gameObject.SetActive(hasMore);
|
||||
loadMoreButton.interactable = true;
|
||||
}
|
||||
|
||||
private void OnLoadError(string msg)
|
||||
{
|
||||
_isLoading = false;
|
||||
loadingSpinner.SetActive(false);
|
||||
errorText.text = $"Failed to load collection:\n{msg}";
|
||||
errorPanel.SetActive(true);
|
||||
Debug.LogWarning($"[CollectionController] {msg}");
|
||||
}
|
||||
|
||||
// ── Card Tiles ───────────────────────────────────────────────────────
|
||||
private void SpawnCardTiles(List<CardModel> cards)
|
||||
{
|
||||
foreach (var card in cards)
|
||||
{
|
||||
var tile = Instantiate(cardPrefab, cardGridParent);
|
||||
_cardTiles.Add(tile);
|
||||
|
||||
// Set name label (assumes CardPrefab has a TMP child named "CardName")
|
||||
var nameLabel = tile.GetComponentInChildren<TMP_Text>();
|
||||
if (nameLabel != null) nameLabel.text = card.name;
|
||||
|
||||
// Async image load
|
||||
var img = tile.GetComponentInChildren<Image>();
|
||||
if (img != null && !string.IsNullOrEmpty(card.imageUrl))
|
||||
{
|
||||
ImageCache.Instance.GetSprite(card.imageUrl, sprite =>
|
||||
{
|
||||
if (img != null) img.sprite = sprite;
|
||||
});
|
||||
}
|
||||
|
||||
// Click → open detail panel
|
||||
var btn = tile.GetComponentInChildren<Button>();
|
||||
if (btn != null)
|
||||
{
|
||||
var captured = card;
|
||||
btn.onClick.AddListener(() => OpenDetailPanel(captured));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearCardTiles()
|
||||
{
|
||||
foreach (var tile in _cardTiles)
|
||||
if (tile != null) Destroy(tile);
|
||||
_cardTiles.Clear();
|
||||
}
|
||||
|
||||
// ── Detail Panel ─────────────────────────────────────────────────────
|
||||
private void OpenDetailPanel(CardModel card)
|
||||
{
|
||||
_selectedCard = card;
|
||||
detailPanel.SetActive(true);
|
||||
|
||||
detailCardName.text = card.name;
|
||||
detailManaCost.text = card.manaCost;
|
||||
detailType.text = card.typeLine;
|
||||
detailRarity.text = card.rarity;
|
||||
detailSet.text = card.set;
|
||||
detailOracleText.text = card.oracleText;
|
||||
detailQuantity.text = $"Owned: {card.ownedQty}";
|
||||
|
||||
// Load card art on front face
|
||||
if (!string.IsNullOrEmpty(card.imageUrl))
|
||||
{
|
||||
ImageCache.Instance.GetSprite(card.imageUrl, sprite =>
|
||||
{
|
||||
if (detailCardFront != null) detailCardFront.sprite = sprite;
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger flip animation if the card has an Animator
|
||||
var anim = detailPanel.GetComponentInChildren<Animator>();
|
||||
anim?.SetTrigger("Flip");
|
||||
}
|
||||
|
||||
private void CloseDetailPanel()
|
||||
{
|
||||
detailPanel.SetActive(false);
|
||||
_selectedCard = null;
|
||||
}
|
||||
|
||||
// ── Add to Deck ───────────────────────────────────────────────────────
|
||||
private void OnAddToDeck()
|
||||
{
|
||||
if (_selectedCard == null) return;
|
||||
// Store selected card in PlayerPrefs for DeckBuilder to pick up
|
||||
PlayerPrefs.SetString("PendingAddCard", JsonConvert.SerializeObject(_selectedCard));
|
||||
PlayerPrefs.Save();
|
||||
SceneManager.LoadScene("DeckBuilder");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
private void UpdatePageInfo()
|
||||
{
|
||||
int showing = Mathf.Min(_cards.Count, _totalCards);
|
||||
pageInfoText.text = $"Showing {showing} of {_totalCards} cards";
|
||||
}
|
||||
|
||||
/// <summary>Returns the option text at index 0 as empty string (means no filter).</summary>
|
||||
private static string DropdownValue(TMP_Dropdown dd)
|
||||
{
|
||||
if (dd.value == 0) return "";
|
||||
return dd.options[dd.value].text;
|
||||
}
|
||||
|
||||
private static string SortFieldFromDropdown(int idx) => idx switch
|
||||
{
|
||||
0 => "name",
|
||||
1 => "cmc",
|
||||
2 => "color",
|
||||
3 => "type",
|
||||
4 => "price",
|
||||
_ => "name"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user