Add PreconController.cs - precon browser scene (U12)

This commit is contained in:
KoalaTrapLord authored and GitHub committed 2026-03-18 16:57:01 -04:00
1 parent 9b1654a81e
commit d21928da53
1 file changed
+403
@@ -0,0 +1,403 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using TMPro;
using Newtonsoft.Json;
using CommanderAILab.Models;
using CommanderAILab.Services;
namespace CommanderAILab.UI
{
/// <summary>
/// Precon browser scene — browse, search, and install preconstructed Commander decks.
/// Grid display with card images, deck details panel, install/install-all buttons,
/// and refresh from GitHub functionality.
/// </summary>
public class PreconController : MonoBehaviour
{
// -- Header ——————————————————————————————————
[Header("Navigation")]
[SerializeField] private Button backButton;
[Header("Search & Filter")]
[SerializeField] private TMP_InputField searchInput;
[SerializeField] private TMP_Dropdown colorFilterDropdown;
[SerializeField] private Button searchButton;
[SerializeField] private Button clearButton;
[Header("Deck Grid")]
[SerializeField] private Transform deckGridParent;
[SerializeField] private GameObject deckCardPrefab;
[SerializeField] private ScrollRect gridScrollRect;
[Header("Deck Detail Panel")]
[SerializeField] private GameObject detailPanel;
[SerializeField] private Image detailCommanderImage;
[SerializeField] private TMP_Text detailDeckName;
[SerializeField] private TMP_Text detailColorIdentity;
[SerializeField] private TMP_Text detailCardCount;
[SerializeField] private TMP_Text detailThemeText;
[SerializeField] private Button detailInstallButton;
[SerializeField] private Button detailCloseButton;
[Header("Batch Actions")]
[SerializeField] private Button installAllButton;
[SerializeField] private Button refreshButton;
[SerializeField] private TMP_Text statusText;
[Header("Loading & Error")]
[SerializeField] private GameObject loadingSpinner;
[SerializeField] private GameObject errorPanel;
[SerializeField] private TMP_Text errorText;
[SerializeField] private Button retryButton;
// -- State ——————————————————————————————————
private List<PreconDeck> _allPrecons = new();
private List<PreconDeck> _filteredPrecons = new();
private PreconDeck _selectedPrecon;
private bool _isLoading = false;
private HashSet<string> _installedDecks = new();
// -- Serializable response wrappers ———————————————————
[Serializable]
private class PreconListResponse
{
public List<PreconDeck> precons;
}
[Serializable]
public class PreconDeck
{
public string fileName;
public string name;
public string commander;
public string colorIdentity;
public int cardCount;
public string theme;
public string imageUrl;
}
[Serializable]
private class InstallRequest
{
public string fileName;
}
[Serializable]
private class InstallResponse
{
public bool installed;
public string deckName;
public string destination;
public string message;
}
[Serializable]
private class BatchInstallRequest
{
public List<string> fileNames;
}
[Serializable]
private class BatchInstallResponse
{
public List<BatchInstallResult> results;
}
[Serializable]
private class BatchInstallResult
{
public string fileName;
public bool installed;
public string deckName;
public string error;
}
[Serializable]
private class RefreshResponse
{
public string message;
public int total;
}
// ========================================================
// Lifecycle
// ========================================================
private void Start()
{
backButton.onClick.AddListener(OnBack);
searchButton.onClick.AddListener(OnSearch);
clearButton.onClick.AddListener(OnClear);
retryButton.onClick.AddListener(LoadPrecons);
installAllButton.onClick.AddListener(OnInstallAll);
refreshButton.onClick.AddListener(OnRefresh);
detailInstallButton.onClick.AddListener(OnInstallSelected);
detailCloseButton.onClick.AddListener(() => detailPanel.SetActive(false));
if (searchInput != null)
searchInput.onSubmit.AddListener(_ => OnSearch());
detailPanel.SetActive(false);
errorPanel.SetActive(false);
LoadPrecons();
}
// ========================================================
// Data Loading
// ========================================================
private void LoadPrecons()
{
if (_isLoading) return;
_isLoading = true;
loadingSpinner.SetActive(true);
errorPanel.SetActive(false);
statusText.text = "Loading precons...";
ApiClient.Instance.Get<PreconListResponse>(
"/api/lab/precons",
OnPreconsLoaded,
OnError
);
}
private void OnPreconsLoaded(PreconListResponse response)
{
_isLoading = false;
loadingSpinner.SetActive(false);
_allPrecons = response.precons ?? new List<PreconDeck>();
statusText.text = $"{_allPrecons.Count} precon deck(s) available";
ApplyFilter();
}
// ========================================================
// Search & Filter
// ========================================================
private void OnSearch()
{
ApplyFilter();
}
private void OnClear()
{
searchInput.text = "";
if (colorFilterDropdown != null)
colorFilterDropdown.value = 0;
ApplyFilter();
}
private void ApplyFilter()
{
string query = searchInput.text.Trim().ToLowerInvariant();
string colorFilter = "";
if (colorFilterDropdown != null && colorFilterDropdown.value > 0)
colorFilter = colorFilterDropdown.options[colorFilterDropdown.value].text;
_filteredPrecons = _allPrecons.FindAll(p =>
{
bool matchesSearch = string.IsNullOrEmpty(query)
|| (p.name != null && p.name.ToLowerInvariant().Contains(query))
|| (p.commander != null && p.commander.ToLowerInvariant().Contains(query))
|| (p.theme != null && p.theme.ToLowerInvariant().Contains(query));
bool matchesColor = string.IsNullOrEmpty(colorFilter)
|| (p.colorIdentity != null && p.colorIdentity.Contains(colorFilter));
return matchesSearch && matchesColor;
});
RenderGrid();
}
// ========================================================
// Grid Rendering
// ========================================================
private void RenderGrid()
{
// Clear existing children
for (int i = deckGridParent.childCount - 1; i >= 0; i--)
Destroy(deckGridParent.GetChild(i).gameObject);
foreach (var precon in _filteredPrecons)
{
var card = Instantiate(deckCardPrefab, deckGridParent);
var texts = card.GetComponentsInChildren<TMP_Text>(true);
if (texts.Length > 0) texts[0].text = precon.name ?? precon.fileName;
if (texts.Length > 1) texts[1].text = precon.commander ?? "";
if (texts.Length > 2) texts[2].text = precon.colorIdentity ?? "";
// Installed badge
bool installed = _installedDecks.Contains(precon.fileName);
var badge = card.transform.Find("InstalledBadge");
if (badge != null) badge.gameObject.SetActive(installed);
// Click to show detail
var btn = card.GetComponent<Button>() ?? card.AddComponent<Button>();
var captured = precon;
btn.onClick.AddListener(() => ShowDetail(captured));
// Load commander image async
if (!string.IsNullOrEmpty(precon.imageUrl))
{
var img = card.GetComponentInChildren<Image>();
if (img != null)
StartCoroutine(LoadImageAsync(precon.imageUrl, img));
}
}
statusText.text = $"Showing {_filteredPrecons.Count} of {_allPrecons.Count} precon(s)";
}
private IEnumerator LoadImageAsync(string url, Image target)
{
using var request = UnityEngine.Networking.UnityWebRequestTexture.GetTexture(url);
yield return request.SendWebRequest();
if (request.result == UnityEngine.Networking.UnityWebRequest.Result.Success)
{
var tex = UnityEngine.Networking.DownloadHandlerTexture.GetContent(request);
target.sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), Vector2.one * 0.5f);
}
}
// ========================================================
// Detail Panel
// ========================================================
private void ShowDetail(PreconDeck precon)
{
_selectedPrecon = precon;
detailPanel.SetActive(true);
detailDeckName.text = precon.name ?? precon.fileName;
detailColorIdentity.text = $"Colors: {precon.colorIdentity ?? "N/A"}";
detailCardCount.text = $"Cards: {precon.cardCount}";
detailThemeText.text = $"Theme: {precon.theme ?? "N/A"}";
bool installed = _installedDecks.Contains(precon.fileName);
detailInstallButton.interactable = !installed;
detailInstallButton.GetComponentInChildren<TMP_Text>().text =
installed ? "Installed" : "Install to Forge";
if (!string.IsNullOrEmpty(precon.imageUrl))
StartCoroutine(LoadImageAsync(precon.imageUrl, detailCommanderImage));
}
// ========================================================
// Install Actions
// ========================================================
private void OnInstallSelected()
{
if (_selectedPrecon == null) return;
detailInstallButton.interactable = false;
statusText.text = $"Installing {_selectedPrecon.name}...";
var req = new InstallRequest { fileName = _selectedPrecon.fileName };
ApiClient.Instance.Post<InstallRequest, InstallResponse>(
"/api/lab/precons/install", req,
res =>
{
_installedDecks.Add(_selectedPrecon.fileName);
statusText.text = res.message;
detailInstallButton.GetComponentInChildren<TMP_Text>().text = "Installed";
RenderGrid();
},
OnError
);
}
private void OnInstallAll()
{
var toInstall = _filteredPrecons.FindAll(p => !_installedDecks.Contains(p.fileName));
if (toInstall.Count == 0)
{
statusText.text = "All visible precons already installed.";
return;
}
installAllButton.interactable = false;
statusText.text = $"Installing {toInstall.Count} precon(s)...";
var req = new BatchInstallRequest
{
fileNames = toInstall.ConvertAll(p => p.fileName)
};
ApiClient.Instance.Post<BatchInstallRequest, BatchInstallResponse>(
"/api/lab/precons/install-batch", req,
res =>
{
int successCount = 0;
foreach (var r in res.results)
{
if (r.installed)
{
_installedDecks.Add(r.fileName);
successCount++;
}
}
statusText.text = $"Installed {successCount} of {res.results.Count} precon(s).";
installAllButton.interactable = true;
RenderGrid();
},
OnError
);
}
private void OnRefresh()
{
refreshButton.interactable = false;
statusText.text = "Refreshing precon database from GitHub...";
loadingSpinner.SetActive(true);
ApiClient.Instance.PostRaw(
"/api/lab/precons/refresh", "{}",
json =>
{
var res = JsonConvert.DeserializeObject<RefreshResponse>(json);
statusText.text = res.message;
refreshButton.interactable = true;
LoadPrecons();
},
err =>
{
refreshButton.interactable = true;
loadingSpinner.SetActive(false);
OnError(err);
}
);
}
// ========================================================
// Navigation
// ========================================================
private void OnBack()
{
SceneManager.LoadScene("MainMenu");
}
// ========================================================
// Error Handling
// ========================================================
private void OnError(string error)
{
_isLoading = false;
loadingSpinner.SetActive(false);
errorPanel.SetActive(true);
errorText.text = $"Error: {error}";
statusText.text = "An error occurred.";
Debug.LogWarning($"[PreconController] {error}");
}
}
}