feat: 4-player Commander Battleground UI (issues #138–#151)
* chore: add battleground UI task tracker (2026-03-28) * feat(#142): add [NonSerialized] battleground runtime state to CardModel * feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache * feat(#148): add GameStateManager singleton and GameState/SeatState POCOs * feat(#138): add BattlegroundController scene orchestrator * feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter * feat(#140): add PhaseTrackerWidget and PhaseStepButton * feat(#141): add TurnOrderBar and TurnOrderPill * feat(#142): add CardZone and CardView battlefield zone components * feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display * feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView * chore: mark issues 1-8 as complete in task tracker * feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay * feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController * feat(#151): add Play Live button to SimulationController * feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch * fix: add PostGameAction to ApiClient + update TODO.md all issues complete - ApiClient.cs: add PostGameAction() wrapper → POST /api/game/action (fixes compile error in HumanActionBar.cs) - TODO.md: mark issues #145, #146, #147, #149, #150, #151 as ✅ all 14 battleground issues now committed * feat(game): add routes/game.py + wire lab_api + fix GameWebSocketClient game_id URL - routes/game.py: POST /api/game/start, POST /api/game/action (new) - lab_api.py: import + register game_router - GameWebSocketClient.cs: read game_id from PlayerPrefs, build ws://{host}/ws/game/{game_id} — fixes URL mismatch with ws_game.py * fix(lobby): wire OnConfirm to POST /api/game/start, save game_id to PlayerPrefs - OnConfirm() now calls ApiClient.PostGameStart() with 4-seat config - On success: saves BattleSeats + GameId + WsUrl to PlayerPrefs, loads Battleground scene - On failure: shows inline error label (red), re-enables Confirm button - Added StartGameRequest/Response POCOs matching routes/game.py schema - Added _errorLabel [SerializeField] for user-facing error feedback
This commit is contained in:
1 parent
915c745911
commit
3f5d6d12e8
31 files changed
+2526
-46
No files matched your search
@@ -33,6 +33,24 @@ namespace CommanderAILab.Models
|
||||
[JsonProperty("roles")] public List<string> roles;
|
||||
[JsonProperty("source")] public string source;
|
||||
|
||||
// ── Battleground runtime state (not serialized to JSON) ───────────────
|
||||
[NonSerialized] public bool isTapped;
|
||||
[NonSerialized] public bool isSummoningSick;
|
||||
[NonSerialized] public int ownerSeat = -1;
|
||||
[NonSerialized] public BattleZone currentZone = BattleZone.Library;
|
||||
[NonSerialized] public Dictionary<string, int> counters = new();
|
||||
|
||||
public override string ToString() => $"{name} ({manaCost}) [{typeLine}]";
|
||||
}
|
||||
|
||||
public enum BattleZone
|
||||
{
|
||||
Library,
|
||||
Hand,
|
||||
Battlefield,
|
||||
Graveyard,
|
||||
Exile,
|
||||
CommandZone,
|
||||
Stack
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.UI;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Full live game state snapshot pushed by the server on state_update events.</summary>
|
||||
[Serializable]
|
||||
public class GameState
|
||||
{
|
||||
[JsonProperty("seats")] public List<SeatState> seats;
|
||||
[JsonProperty("active_seat")] public int activeSeat;
|
||||
[JsonProperty("turn_number")] public int turnNumber;
|
||||
[JsonProperty("phase")] public string phase;
|
||||
[JsonProperty("stack")] public List<StackZoneController.StackItem> stack;
|
||||
}
|
||||
|
||||
/// <summary>Per-seat state within a GameState snapshot.</summary>
|
||||
[Serializable]
|
||||
public class SeatState
|
||||
{
|
||||
[JsonProperty("seat_index")] public int seatIndex;
|
||||
[JsonProperty("player_name")] public string playerName;
|
||||
[JsonProperty("life")] public int life;
|
||||
[JsonProperty("hand")] public List<CardModel> hand;
|
||||
[JsonProperty("battlefield")] public List<CardModel> battlefield;
|
||||
[JsonProperty("graveyard")] public List<CardModel> graveyard;
|
||||
[JsonProperty("exile")] public List<CardModel> exile;
|
||||
[JsonProperty("commander")] public CardModel commander;
|
||||
[JsonProperty("commander_cast_count")] public int commanderCastCount;
|
||||
/// <summary>Cumulative commander damage received from each seat index (length 4).</summary>
|
||||
[JsonProperty("commander_damage_from")] public int[] commanderDamageFrom;
|
||||
[JsonProperty("poison_counters")] public int poisonCounters;
|
||||
[JsonProperty("mana_pool")] public Dictionary<string, int> manaPool;
|
||||
[JsonProperty("is_eliminated")] public bool isEliminated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Runtime data model for a token on the battlefield.</summary>
|
||||
[Serializable]
|
||||
public class TokenModel
|
||||
{
|
||||
public string id;
|
||||
public string name;
|
||||
public string power;
|
||||
public string toughness;
|
||||
/// <summary>Single letter: W U B R G C</summary>
|
||||
public string colorCode = "C";
|
||||
public int ownerSeat;
|
||||
public int qty = 1;
|
||||
public bool isTapped;
|
||||
public Dictionary<string, int> counters = new();
|
||||
|
||||
public string PTString =>
|
||||
(!string.IsNullOrEmpty(power) && !string.IsNullOrEmpty(toughness))
|
||||
? $"{power}/{toughness}" : "";
|
||||
}
|
||||
}
|
||||
@@ -283,5 +283,15 @@ namespace CommanderAILab.Services
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
|
||||
// -- Game (Live Battleground) ---------------------------------------
|
||||
|
||||
/// <summary>POST /api/game/action — submit a human player action during a live game.</summary>
|
||||
public Coroutine PostGameAction(string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
=> PostRaw("/api/game/action", json, onSuccess, onError);
|
||||
|
||||
/// <summary>POST /api/game/start — start a new live game with the given lobby config.</summary>
|
||||
public Coroutine PostGameStart(string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
=> PostRaw("/api/game/start", json, onSuccess, onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.UI;
|
||||
|
||||
namespace CommanderAILab.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// WebSocket client for the live game backend.
|
||||
/// Connects to ws://{serverHost}/ws/game/{gameId}.
|
||||
///
|
||||
/// game_id is written to PlayerPrefs["GameId"] by LobbySetupModal
|
||||
/// after a successful POST /api/game/start response.
|
||||
///
|
||||
/// ServerUrl PlayerPref should be the HTTP base URL, e.g.
|
||||
/// "http://localhost:8080" — this client converts it to ws://.
|
||||
/// </summary>
|
||||
public class GameWebSocketClient : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string defaultServerUrl = "http://localhost:8080";
|
||||
[SerializeField] private float reconnectDelay = 3f;
|
||||
[SerializeField] private float pingIntervalSec = 15f;
|
||||
|
||||
private System.Net.WebSockets.ClientWebSocket _ws;
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly ConcurrentQueue<(string type, string payload)> _mainThreadQueue = new();
|
||||
private bool _running;
|
||||
private float _pingTimer;
|
||||
private string _wsUrl;
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
_wsUrl = BuildWsUrl();
|
||||
if (string.IsNullOrEmpty(_wsUrl))
|
||||
{
|
||||
Debug.LogError("[GameWebSocketClient] Could not build WS URL — PlayerPrefs 'GameId' is missing. " +
|
||||
"LobbySetupModal must set it after /api/game/start.");
|
||||
return;
|
||||
}
|
||||
|
||||
_running = true;
|
||||
StartCoroutine(ConnectLoop());
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Drain main-thread queue
|
||||
while (_mainThreadQueue.TryDequeue(out var msg))
|
||||
GameStateManager.Instance?.ApplyServerEvent(msg.type, msg.payload);
|
||||
|
||||
// Ping
|
||||
_pingTimer += Time.deltaTime;
|
||||
if (_pingTimer >= pingIntervalSec)
|
||||
{
|
||||
_pingTimer = 0f;
|
||||
_ = SendRawAsync("{\"type\":\"ping\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_running = false;
|
||||
_cts?.Cancel();
|
||||
_ws?.Dispose();
|
||||
}
|
||||
|
||||
// ── URL construction ────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Builds ws://{host}/ws/game/{gameId} from PlayerPrefs.
|
||||
/// ServerUrl pref stores the HTTP base URL (e.g. http://localhost:8080).
|
||||
/// GameId pref stores the game_id returned by /api/game/start.
|
||||
/// </summary>
|
||||
private string BuildWsUrl()
|
||||
{
|
||||
string gameId = PlayerPrefs.GetString("GameId", "");
|
||||
if (string.IsNullOrEmpty(gameId))
|
||||
return string.Empty;
|
||||
|
||||
string httpBase = PlayerPrefs.GetString("ServerUrl", defaultServerUrl).TrimEnd('/');
|
||||
|
||||
// Convert http(s):// to ws(s)://
|
||||
string wsBase = httpBase
|
||||
.Replace("https://", "wss://")
|
||||
.Replace("http://", "ws://");
|
||||
|
||||
return $"{wsBase}/ws/game/{gameId}";
|
||||
}
|
||||
|
||||
// ── Connection loop ──────────────────────────────────────────────────
|
||||
private IEnumerator ConnectLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_ws = new System.Net.WebSockets.ClientWebSocket();
|
||||
|
||||
bool connected = false;
|
||||
var connectTask = _ws.ConnectAsync(new Uri(_wsUrl), _cts.Token);
|
||||
|
||||
yield return new WaitUntil(() => connectTask.IsCompleted);
|
||||
|
||||
if (!connectTask.IsFaulted && !connectTask.IsCanceled)
|
||||
{
|
||||
connected = true;
|
||||
Debug.Log("[GameWebSocketClient] Connected to " + _wsUrl);
|
||||
_ = ReceiveLoop();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Connect failed ({_wsUrl}): {connectTask.Exception?.Message}");
|
||||
}
|
||||
|
||||
if (!connected)
|
||||
{
|
||||
yield return new WaitForSeconds(reconnectDelay);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new WaitUntil(() =>
|
||||
_ws.State != System.Net.WebSockets.WebSocketState.Open);
|
||||
|
||||
Debug.Log("[GameWebSocketClient] Disconnected — reconnecting...");
|
||||
yield return new WaitForSeconds(reconnectDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receive loop (background thread) ────────────────────────────────
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
try
|
||||
{
|
||||
while (_ws.State == System.Net.WebSockets.WebSocketState.Open)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
System.Net.WebSockets.WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await _ws.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer), _cts.Token);
|
||||
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType ==
|
||||
System.Net.WebSockets.WebSocketMessageType.Close)
|
||||
break;
|
||||
|
||||
ParseAndEnqueue(sb.ToString());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] ReceiveLoop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse incoming JSON envelope ─────────────────────────────────────
|
||||
private void ParseAndEnqueue(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
var env = JsonConvert.DeserializeObject<WsEnvelope>(raw);
|
||||
if (env == null) return;
|
||||
if (env.type == "pong") return;
|
||||
_mainThreadQueue.Enqueue((env.type, env.payload ?? raw));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Failed to parse: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send (public for external callers) ───────────────────────────────
|
||||
public async Task SendRawAsync(string json)
|
||||
{
|
||||
if (_ws == null ||
|
||||
_ws.State != System.Net.WebSockets.WebSocketState.Open) return;
|
||||
try
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
await _ws.SendAsync(new ArraySegment<byte>(bytes),
|
||||
System.Net.WebSockets.WebSocketMessageType.Text,
|
||||
true, _cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Send error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── POCO ────────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
private class WsEnvelope
|
||||
{
|
||||
[JsonProperty("type")] public string type;
|
||||
[JsonProperty("payload")] public string payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace CommanderAILab.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton texture cache for card images.
|
||||
/// Downloads and caches Scryfall card art as Sprite objects.
|
||||
/// Downloads and caches Scryfall card art as Sprite or Texture2D.
|
||||
/// </summary>
|
||||
public class ImageCache : MonoBehaviour
|
||||
{
|
||||
public static ImageCache Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<string, Sprite> cache = new();
|
||||
private readonly Dictionary<string, Sprite> _spriteCache = new();
|
||||
private readonly Dictionary<string, Texture2D> _textureCache = new();
|
||||
[SerializeField] private int maxCacheSize = 500;
|
||||
|
||||
private void Awake()
|
||||
@@ -23,33 +26,67 @@ namespace CommanderAILab.Services
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
public void GetSprite(string url, System.Action<Sprite> callback)
|
||||
// ── Sprite (existing usage) ───────────────────────────────────────────
|
||||
public void GetSprite(string url, Action<Sprite> callback)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) { callback?.Invoke(null); return; }
|
||||
if (cache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
||||
StartCoroutine(DownloadImage(url, callback));
|
||||
if (_spriteCache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
||||
StartCoroutine(DownloadSprite(url, callback));
|
||||
}
|
||||
|
||||
private IEnumerator DownloadImage(string url, System.Action<Sprite> callback)
|
||||
private IEnumerator DownloadSprite(string url, Action<Sprite> callback)
|
||||
{
|
||||
using var request = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return request.SendWebRequest();
|
||||
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
using var req = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ImageCache] Failed to load {url}: {request.error}");
|
||||
Debug.LogWarning($"[ImageCache] Sprite failed {url}: {req.error}");
|
||||
callback?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var tex = DownloadHandlerTexture.GetContent(request);
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), Vector2.one * 0.5f);
|
||||
|
||||
if (cache.Count >= maxCacheSize) cache.Clear();
|
||||
cache[url] = sprite;
|
||||
if (_spriteCache.Count >= maxCacheSize) _spriteCache.Clear();
|
||||
_spriteCache[url] = sprite;
|
||||
callback?.Invoke(sprite);
|
||||
}
|
||||
|
||||
public void ClearCache() => cache.Clear();
|
||||
// ── RawImage / Texture2D (battleground CardView + CommanderZoneWidget) ───
|
||||
/// <summary>Load a card image URL directly into a RawImage component.</summary>
|
||||
public IEnumerator LoadCard(string url, RawImage target)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(url)) yield break;
|
||||
if (_textureCache.TryGetValue(url, out var cached)) { target.texture = cached; yield break; }
|
||||
yield return DownloadTexture(url, tex => { if (target != null) target.texture = tex; });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load Scryfall art_crop into a RawImage by card name.
|
||||
/// URL: https://api.scryfall.com/cards/named?exact={name}&format=image&version=art_crop
|
||||
/// </summary>
|
||||
public IEnumerator LoadArtCrop(string cardName, RawImage target)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(cardName)) yield break;
|
||||
string url = $"https://api.scryfall.com/cards/named?exact={Uri.EscapeDataString(cardName)}&format=image&version=art_crop";
|
||||
yield return LoadCard(url, target);
|
||||
}
|
||||
|
||||
private IEnumerator DownloadTexture(string url, Action<Texture2D> callback)
|
||||
{
|
||||
using var req = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ImageCache] Texture failed {url}: {req.error}");
|
||||
callback?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
if (_textureCache.Count >= maxCacheSize) _textureCache.Clear();
|
||||
_textureCache[url] = tex;
|
||||
callback?.Invoke(tex);
|
||||
}
|
||||
|
||||
public void ClearCache() { _spriteCache.Clear(); _textureCache.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Master orchestrator for the Battleground scene.
|
||||
/// Reads the lobby config written by SimulationController (PlayerPrefs "BattleSeats"),
|
||||
/// wires up all seat panels, and opens the LobbySetupModal on start.
|
||||
/// </summary>
|
||||
public class BattlegroundController : MonoBehaviour
|
||||
{
|
||||
// ── Inspector ─────────────────────────────────────────────
|
||||
[Header("Seat Panels")]
|
||||
[SerializeField] private RectTransform[] seatPanels; // [0]=bottom,[1]=right,[2]=top,[3]=left
|
||||
|
||||
[Header("Center")]
|
||||
[SerializeField] private PhaseTrackerWidget phaseTracker;
|
||||
[SerializeField] private TurnOrderBar turnOrderBar;
|
||||
|
||||
[Header("Modals")]
|
||||
[SerializeField] private LobbySetupModal lobbyModal;
|
||||
|
||||
[Header("Navigation")]
|
||||
[SerializeField] private Button mainMenuButton;
|
||||
|
||||
// ── Seat rotations ────────────────────────────────────────
|
||||
private static readonly float[] SeatRotations = { 0f, -90f, 180f, 90f };
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
ApplySeatRotations();
|
||||
SubscribeEvents();
|
||||
|
||||
mainMenuButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
|
||||
|
||||
// Pre-populate lobby from PlayerPrefs if arriving via SimulationController
|
||||
string savedSeats = PlayerPrefs.GetString("BattleSeats", "");
|
||||
if (lobbyModal != null)
|
||||
lobbyModal.Open(savedSeats);
|
||||
}
|
||||
|
||||
private void OnDestroy() => UnsubscribeEvents();
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────
|
||||
private void ApplySeatRotations()
|
||||
{
|
||||
for (int i = 0; i < seatPanels.Length; i++)
|
||||
{
|
||||
if (seatPanels[i] == null) continue;
|
||||
seatPanels[i].localEulerAngles = new Vector3(0f, 0f, SeatRotations[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event subscriptions ──────────────────────────────────
|
||||
private void SubscribeEvents()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged += OnTurnChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync += OnFullStateSync;
|
||||
}
|
||||
|
||||
private void UnsubscribeEvents()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged -= OnTurnChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync -= OnFullStateSync;
|
||||
}
|
||||
|
||||
private void OnTurnChanged(int seat) =>
|
||||
turnOrderBar?.SetActiveSeat(seat);
|
||||
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn) =>
|
||||
phaseTracker?.SetPhase(phase, seat, turn);
|
||||
|
||||
private void OnFullStateSync(GameState state)
|
||||
{
|
||||
// Delegate per-seat refresh to each SeatPanelController once those exist
|
||||
turnOrderBar?.SetActiveSeat(state.activeSeat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual representation of a single card in a CardZone.
|
||||
/// Handles art loading via ImageCache, tapped rotation, and face-down state.
|
||||
/// </summary>
|
||||
public class CardView : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private RawImage artImage;
|
||||
[SerializeField] private TMP_Text nameLabel;
|
||||
[SerializeField] private TMP_Text ptLabel; // power/toughness
|
||||
[SerializeField] private TMP_Text manaCostLabel;
|
||||
[SerializeField] private GameObject tappedIndicator; // e.g. coloured strip
|
||||
[SerializeField] private GameObject summonSickOverlay; // semi-transparent overlay
|
||||
[SerializeField] private GameObject faceDownOverlay; // card back image object
|
||||
|
||||
public CardModel Model { get; private set; }
|
||||
private bool _faceDown;
|
||||
|
||||
// ── Binding ─────────────────────────────────────────────
|
||||
public void Bind(CardModel model, bool faceDown = false)
|
||||
{
|
||||
Model = model;
|
||||
_faceDown = faceDown;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
public void SetFaceDown(bool faceDown)
|
||||
{
|
||||
_faceDown = faceDown;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
public void SetTapped(bool tapped)
|
||||
{
|
||||
if (Model == null) return;
|
||||
Model.isTapped = tapped;
|
||||
// Rotate the RectTransform -90 degrees when tapped
|
||||
transform.localEulerAngles = tapped ? new Vector3(0f, 0f, -90f) : Vector3.zero;
|
||||
if (tappedIndicator) tappedIndicator.SetActive(tapped);
|
||||
}
|
||||
|
||||
public void SetSummonSick(bool sick)
|
||||
{
|
||||
if (Model == null) return;
|
||||
Model.isSummoningSick = sick;
|
||||
if (summonSickOverlay) summonSickOverlay.SetActive(sick);
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (faceDownOverlay) faceDownOverlay.SetActive(_faceDown);
|
||||
if (artImage) artImage.gameObject.SetActive(!_faceDown);
|
||||
|
||||
if (nameLabel)
|
||||
nameLabel.text = _faceDown ? "?" : (Model?.name ?? "");
|
||||
|
||||
bool hasStats = !_faceDown
|
||||
&& Model != null
|
||||
&& !string.IsNullOrEmpty(Model.power)
|
||||
&& !string.IsNullOrEmpty(Model.toughness);
|
||||
|
||||
if (ptLabel)
|
||||
{
|
||||
ptLabel.text = hasStats ? $"{Model.power}/{Model.toughness}" : "";
|
||||
ptLabel.gameObject.SetActive(hasStats);
|
||||
}
|
||||
|
||||
if (manaCostLabel)
|
||||
{
|
||||
manaCostLabel.text = (!_faceDown && Model != null) ? Model.manaCost ?? "" : "";
|
||||
}
|
||||
|
||||
// Load art via ImageCache if visible and URL available
|
||||
if (!_faceDown && Model != null && !string.IsNullOrEmpty(Model.imageUri)
|
||||
&& artImage != null && ImageCache.Instance != null)
|
||||
{
|
||||
StartCoroutine(ImageCache.Instance.LoadCard(Model.imageUri, artImage));
|
||||
}
|
||||
|
||||
// Reset tapped/sick overlays on rebind
|
||||
if (tappedIndicator) tappedIndicator.SetActive(false);
|
||||
if (summonSickOverlay) summonSickOverlay.SetActive(false);
|
||||
transform.localEulerAngles = Vector3.zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages a collection of CardView instances inside one battlefield zone
|
||||
/// (Hand, Battlefield, Graveyard, Exile, etc.).
|
||||
/// The cardContainer should have a HorizontalLayoutGroup or GridLayoutGroup.
|
||||
/// </summary>
|
||||
public class CardZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private BattleZone zoneType;
|
||||
[SerializeField] private GameObject cardPrefab; // CardPrefab.prefab
|
||||
[SerializeField] private Transform cardContainer; // layout group parent
|
||||
[SerializeField] private bool faceDown; // true for AI hand zones
|
||||
|
||||
private readonly List<CardView> _cards = new();
|
||||
|
||||
public BattleZone ZoneType => zoneType;
|
||||
public IReadOnlyList<CardView> Cards => _cards;
|
||||
public int Count => _cards.Count;
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public CardView AddCard(CardModel model)
|
||||
{
|
||||
var go = Instantiate(cardPrefab, cardContainer);
|
||||
var view = go.GetComponent<CardView>();
|
||||
if (view == null)
|
||||
{
|
||||
Debug.LogError("[CardZone] CardPrefab missing CardView component.");
|
||||
Destroy(go);
|
||||
return null;
|
||||
}
|
||||
model.currentZone = zoneType;
|
||||
view.Bind(model, faceDown);
|
||||
_cards.Add(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
public void RemoveCard(string cardId)
|
||||
{
|
||||
var view = _cards.Find(c => c.Model?.id == cardId);
|
||||
if (view == null) return;
|
||||
_cards.Remove(view);
|
||||
Destroy(view.gameObject);
|
||||
}
|
||||
|
||||
public void ClearAll()
|
||||
{
|
||||
foreach (var v in _cards) if (v != null) Destroy(v.gameObject);
|
||||
_cards.Clear();
|
||||
}
|
||||
|
||||
public void SetFaceDown(bool fd)
|
||||
{
|
||||
faceDown = fd;
|
||||
foreach (var v in _cards) v?.SetFaceDown(fd);
|
||||
}
|
||||
|
||||
public CardView FindById(string cardId) =>
|
||||
_cards.Find(c => c.Model?.id == cardId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays cumulative commander damage received from each opposing seat.
|
||||
/// Triggers elimination when any source reaches 21.
|
||||
/// Attach one to each seat panel; set SeatIndex in inspector.
|
||||
/// </summary>
|
||||
public class CommanderDamageRow : MonoBehaviour
|
||||
{
|
||||
/// <summary>The seat this row belongs to (receives damage).</summary>
|
||||
[SerializeField] private int seatIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Labels for damage received from each seat.
|
||||
/// fromSeatLabels[i] corresponds to global seat i.
|
||||
/// The label for this seat's own index will be hidden (can't deal commander damage to yourself).
|
||||
/// </summary>
|
||||
[SerializeField] private TMP_Text[] fromSeatLabels; // length 4
|
||||
|
||||
private readonly int[] _damage = new int[4];
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Hide this seat's own label
|
||||
if (fromSeatLabels != null && seatIndex < fromSeatLabels.Length
|
||||
&& fromSeatLabels[seatIndex] != null)
|
||||
fromSeatLabels[seatIndex].gameObject.SetActive(false);
|
||||
|
||||
RefreshAllLabels();
|
||||
}
|
||||
|
||||
public void AddDamage(int fromSeat, int amount)
|
||||
{
|
||||
if (fromSeat < 0 || fromSeat >= 4 || fromSeat == seatIndex) return;
|
||||
_damage[fromSeat] = Mathf.Max(0, _damage[fromSeat] + amount);
|
||||
RefreshLabel(fromSeat);
|
||||
|
||||
if (_damage[fromSeat] >= 21)
|
||||
GameStateManager.Instance?.EliminatePlayer(seatIndex, "commander_damage");
|
||||
}
|
||||
|
||||
public void SetDamage(int fromSeat, int value)
|
||||
{
|
||||
if (fromSeat < 0 || fromSeat >= 4 || fromSeat == seatIndex) return;
|
||||
_damage[fromSeat] = Mathf.Max(0, value);
|
||||
RefreshLabel(fromSeat);
|
||||
}
|
||||
|
||||
public int GetDamage(int fromSeat) =>
|
||||
(fromSeat >= 0 && fromSeat < 4) ? _damage[fromSeat] : 0;
|
||||
|
||||
private void RefreshLabel(int fromSeat)
|
||||
{
|
||||
if (fromSeatLabels == null || fromSeat >= fromSeatLabels.Length) return;
|
||||
var lbl = fromSeatLabels[fromSeat];
|
||||
if (lbl == null) return;
|
||||
lbl.text = _damage[fromSeat] > 0 ? _damage[fromSeat].ToString() : "0";
|
||||
lbl.color = _damage[fromSeat] >= 21 ? Color.red : Color.white;
|
||||
}
|
||||
|
||||
private void RefreshAllLabels()
|
||||
{
|
||||
for (int i = 0; i < 4; i++) RefreshLabel(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-seat commander zone widget.
|
||||
/// Shows: art crop banner, commander name, cast count, commander tax.
|
||||
/// Requires ImageCache singleton in scene.
|
||||
/// </summary>
|
||||
public class CommanderZoneWidget : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private RawImage artCropImage;
|
||||
[SerializeField] private TMP_Text commanderNameLabel;
|
||||
[SerializeField] private TMP_Text castCountLabel;
|
||||
[SerializeField] private TMP_Text taxLabel;
|
||||
[SerializeField] private UnityEngine.UI.Button castCommanderBtn;
|
||||
|
||||
public CardModel CommanderCard { get; private set; }
|
||||
private int _castCount = 0;
|
||||
|
||||
// ── Binding ───────────────────────────────────────────
|
||||
public void Bind(CardModel commander)
|
||||
{
|
||||
CommanderCard = commander;
|
||||
_castCount = commander != null ? commander.counters.GetValueOrDefault("cast_count", 0) : 0;
|
||||
|
||||
if (commanderNameLabel)
|
||||
commanderNameLabel.text = commander?.name ?? "No Commander";
|
||||
|
||||
UpdateTaxDisplay();
|
||||
|
||||
// Load art crop from Scryfall
|
||||
if (commander != null && !string.IsNullOrEmpty(commander.name)
|
||||
&& artCropImage != null && ImageCache.Instance != null)
|
||||
{
|
||||
StartCoroutine(ImageCache.Instance.LoadArtCrop(commander.name, artCropImage));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cast tracking ──────────────────────────────────────
|
||||
public void OnCommanderCast()
|
||||
{
|
||||
_castCount++;
|
||||
UpdateTaxDisplay();
|
||||
}
|
||||
|
||||
public void SetCastCount(int count)
|
||||
{
|
||||
_castCount = Mathf.Max(0, count);
|
||||
UpdateTaxDisplay();
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────
|
||||
private void UpdateTaxDisplay()
|
||||
{
|
||||
if (castCountLabel)
|
||||
castCountLabel.text = _castCount == 0 ? "Not yet cast" : $"Cast #{_castCount}";
|
||||
|
||||
int tax = _castCount * 2;
|
||||
if (taxLabel)
|
||||
taxLabel.text = tax == 0 ? "No tax" : $"+{tax} ☀ tax";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the visual elimination sequence for a seat panel.
|
||||
/// On elimination: plays a flash animation, applies a grayscale material, and
|
||||
/// shows an elimination banner with cause of death text.
|
||||
/// Requires a URP ShaderGraph material "EliminatedGrayscale" in Resources/.
|
||||
/// </summary>
|
||||
public class EliminationHandler : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private int seatIndex;
|
||||
[SerializeField] private CanvasGroup seatCanvasGroup; // seat panel root CanvasGroup
|
||||
[SerializeField] private Image eliminationOverlay; // full-panel semi-transparent overlay
|
||||
[SerializeField] private TMP_Text eliminationLabel; // "ELIMINATED" text
|
||||
[SerializeField] private TMP_Text causeLabel; // cause of death
|
||||
[SerializeField] private Image[] grayscaleTargets; // RawImages to recolor to grayscale
|
||||
|
||||
private static readonly Color GrayscaleTint = new Color(0.4f, 0.4f, 0.4f, 1f);
|
||||
private bool _eliminated;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (eliminationOverlay) eliminationOverlay.gameObject.SetActive(false);
|
||||
if (eliminationLabel) eliminationLabel.gameObject.SetActive(false);
|
||||
if (causeLabel) causeLabel.gameObject.SetActive(false);
|
||||
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPlayerEliminated += OnPlayerEliminated;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPlayerEliminated -= OnPlayerEliminated;
|
||||
}
|
||||
|
||||
// ── Event ───────────────────────────────────────────────
|
||||
private void OnPlayerEliminated(int seat, string reason)
|
||||
{
|
||||
if (seat != seatIndex || _eliminated) return;
|
||||
_eliminated = true;
|
||||
StartCoroutine(PlayEliminationSequence(reason));
|
||||
}
|
||||
|
||||
// ── Animation ──────────────────────────────────────────
|
||||
private IEnumerator PlayEliminationSequence(string reason)
|
||||
{
|
||||
// 1. Red flash
|
||||
if (eliminationOverlay)
|
||||
{
|
||||
eliminationOverlay.gameObject.SetActive(true);
|
||||
eliminationOverlay.color = new Color(1f, 0f, 0f, 0f);
|
||||
yield return FadeImage(eliminationOverlay, 0f, 0.6f, 0.15f);
|
||||
yield return FadeImage(eliminationOverlay, 0.6f, 0f, 0.4f);
|
||||
eliminationOverlay.color = new Color(0f, 0f, 0f, 0.4f); // keep dark overlay
|
||||
}
|
||||
|
||||
// 2. Grayscale tint on card art panels
|
||||
foreach (var img in grayscaleTargets)
|
||||
if (img != null) img.color = GrayscaleTint;
|
||||
|
||||
// 3. Fade-down CanvasGroup to 60%
|
||||
if (seatCanvasGroup)
|
||||
{
|
||||
seatCanvasGroup.interactable = false;
|
||||
seatCanvasGroup.blocksRaycasts = false;
|
||||
yield return FadeCanvasGroup(seatCanvasGroup, seatCanvasGroup.alpha, 0.6f, 0.3f);
|
||||
}
|
||||
|
||||
// 4. Show banner
|
||||
if (eliminationLabel) eliminationLabel.gameObject.SetActive(true);
|
||||
if (causeLabel)
|
||||
{
|
||||
causeLabel.text = reason switch
|
||||
{
|
||||
"life" => "Life total reached 0",
|
||||
"commander_damage" => "Commander damage (21+)",
|
||||
"poison" => "Poison counters (10+)",
|
||||
"deck_empty" => "Failed to draw",
|
||||
_ => reason
|
||||
};
|
||||
causeLabel.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FadeImage(Image img, float from, float to, float duration)
|
||||
{
|
||||
float t = 0;
|
||||
while (t < duration)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
var c = img.color;
|
||||
c.a = Mathf.Lerp(from, to, t / duration);
|
||||
img.color = c;
|
||||
yield return null;
|
||||
}
|
||||
var final = img.color;
|
||||
final.a = to;
|
||||
img.color = final;
|
||||
}
|
||||
|
||||
private IEnumerator FadeCanvasGroup(CanvasGroup cg, float from, float to, float duration)
|
||||
{
|
||||
float t = 0;
|
||||
while (t < duration) { t += Time.deltaTime; cg.alpha = Mathf.Lerp(from, to, t / duration); yield return null; }
|
||||
cg.alpha = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton event hub for the live battleground game state.
|
||||
/// All UI widgets subscribe to these events instead of polling.
|
||||
/// Receives parsed server events from GameWebSocketClient.
|
||||
/// </summary>
|
||||
public class GameStateManager : MonoBehaviour
|
||||
{
|
||||
public static GameStateManager Instance { get; private set; }
|
||||
|
||||
// ── Events ────────────────────────────────────────────────
|
||||
public event Action<int> OnTurnChanged; // activeSeat
|
||||
public event Action<PhaseTrackerWidget.Phase, int, int> OnPhaseChanged; // phase, seat, turnNumber
|
||||
public event Action<int> OnPriorityChanged; // seatIndex
|
||||
public event Action<int, int> OnLifeChanged; // seatIndex, newLife
|
||||
public event Action<int, string> OnPlayerEliminated; // seatIndex, reason
|
||||
public event Action<List<StackZoneController.StackItem>> OnStackUpdated;
|
||||
public event Action<GameState> OnFullStateSync;
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
public int TurnNumber { get; private set; } = 1;
|
||||
private GameState _state;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
// ── Server event dispatch ───────────────────────────────────
|
||||
/// <summary>Called by GameWebSocketClient on the Unity main thread.</summary>
|
||||
public void ApplyServerEvent(string type, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "state_update":
|
||||
_state = JsonConvert.DeserializeObject<GameState>(payload);
|
||||
if (_state != null) OnFullStateSync?.Invoke(_state);
|
||||
break;
|
||||
|
||||
case "turn_start":
|
||||
var ts = JsonConvert.DeserializeObject<TurnStartEvent>(payload);
|
||||
if (ts != null) { TurnNumber = ts.turnNumber; OnTurnChanged?.Invoke(ts.seat); }
|
||||
break;
|
||||
|
||||
case "phase_change":
|
||||
var pc = JsonConvert.DeserializeObject<PhaseChangeEvent>(payload);
|
||||
if (pc != null)
|
||||
OnPhaseChanged?.Invoke((PhaseTrackerWidget.Phase)pc.phaseIndex, pc.seat, TurnNumber);
|
||||
break;
|
||||
|
||||
case "priority":
|
||||
var pv = JsonConvert.DeserializeObject<PriorityEvent>(payload);
|
||||
if (pv != null) OnPriorityChanged?.Invoke(pv.seat);
|
||||
break;
|
||||
|
||||
case "life_change":
|
||||
var lc = JsonConvert.DeserializeObject<LifeChangeEvent>(payload);
|
||||
if (lc != null) OnLifeChanged?.Invoke(lc.seat, lc.newLife);
|
||||
break;
|
||||
|
||||
case "stack_update":
|
||||
var su = JsonConvert.DeserializeObject<StackUpdateEvent>(payload);
|
||||
if (su?.items != null) OnStackUpdated?.Invoke(su.items);
|
||||
break;
|
||||
|
||||
case "player_elim":
|
||||
var pe = JsonConvert.DeserializeObject<EliminationEvent>(payload);
|
||||
if (pe != null) EliminatePlayer(pe.seat, pe.reason);
|
||||
break;
|
||||
|
||||
default:
|
||||
Debug.LogWarning($"[GameStateManager] Unknown event type: {type}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[GameStateManager] ApplyServerEvent failed ({type}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trigger elimination (called by LifeTrackerWidget, CommanderDamageRow, or server event).</summary>
|
||||
public void EliminatePlayer(int seatIndex, string reason) =>
|
||||
OnPlayerEliminated?.Invoke(seatIndex, reason);
|
||||
|
||||
// ── Debug helpers (editor only) ─────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private void OnGUI()
|
||||
{
|
||||
GUILayout.BeginArea(new Rect(10, 10, 220, 200));
|
||||
GUILayout.Label("[GSM Debug]");
|
||||
if (GUILayout.Button("Fake Turn 1 Start")) ApplyServerEvent("turn_start", "{\"seat\":0,\"turnNumber\":1}");
|
||||
if (GUILayout.Button("Advance Phase Main1")) ApplyServerEvent("phase_change","{\"phaseIndex\":3,\"seat\":0}");
|
||||
if (GUILayout.Button("Priority Seat 0")) ApplyServerEvent("priority", "{\"seat\":0}");
|
||||
if (GUILayout.Button("Elim Seat 2")) EliminatePlayer(2, "life");
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
#endif
|
||||
|
||||
// ── Private event POCOs ───────────────────────────────────
|
||||
[Serializable] private class TurnStartEvent { public int seat; public int turnNumber; }
|
||||
[Serializable] private class PhaseChangeEvent { public int phaseIndex; public int seat; }
|
||||
[Serializable] private class PriorityEvent { public int seat; }
|
||||
[Serializable] private class LifeChangeEvent { public int seat; public int newLife; }
|
||||
[Serializable] private class StackUpdateEvent { public List<StackZoneController.StackItem> items; }
|
||||
[Serializable] private class EliminationEvent { public int seat; public string reason; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Human player action bar — only active when seat 0 has priority.
|
||||
/// Buttons post actions to /api/game/action.
|
||||
/// Subscribes to GameStateManager.OnPriorityChanged.
|
||||
/// </summary>
|
||||
public class HumanActionBar : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private ApiClient apiClient;
|
||||
[SerializeField] private GameObject actionBar; // root panel
|
||||
[SerializeField] private Button passPriorityBtn;
|
||||
[SerializeField] private Button endTurnBtn;
|
||||
[SerializeField] private Button declareAttackersBtn;
|
||||
[SerializeField] private Button declareBlockersBtn;
|
||||
[SerializeField] private TMP_Text priorityLabel;
|
||||
[SerializeField] private TMP_Text feedbackLabel;
|
||||
|
||||
private bool _hasPriority;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
passPriorityBtn ?.onClick.AddListener(() => PostAction("pass_priority"));
|
||||
endTurnBtn ?.onClick.AddListener(() => PostAction("end_turn"));
|
||||
declareAttackersBtn?.onClick.AddListener(() => PostAction("declare_attackers"));
|
||||
declareBlockersBtn ?.onClick.AddListener(() => PostAction("declare_blockers"));
|
||||
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnPriorityChanged += OnPriorityChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
}
|
||||
|
||||
SetPriority(false);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnPriorityChanged -= OnPriorityChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
}
|
||||
|
||||
// ── Priority visibility ──────────────────────────────────
|
||||
private void OnPriorityChanged(int seatIndex)
|
||||
{
|
||||
SetPriority(seatIndex == 0);
|
||||
}
|
||||
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn)
|
||||
{
|
||||
// Only show combat buttons during combat phases
|
||||
bool inCombat = phase >= PhaseTrackerWidget.Phase.DeclareAttackers
|
||||
&& phase <= PhaseTrackerWidget.Phase.EndCombat;
|
||||
if (declareAttackersBtn) declareAttackersBtn.gameObject.SetActive(inCombat && _hasPriority);
|
||||
if (declareBlockersBtn) declareBlockersBtn.gameObject.SetActive(inCombat && _hasPriority);
|
||||
}
|
||||
|
||||
private void SetPriority(bool hasPriority)
|
||||
{
|
||||
_hasPriority = hasPriority;
|
||||
if (actionBar) actionBar.SetActive(hasPriority);
|
||||
if (priorityLabel) priorityLabel.text = hasPriority ? "Your Priority" : "";
|
||||
}
|
||||
|
||||
// ── Action posting ─────────────────────────────────────
|
||||
private void PostAction(string actionType)
|
||||
{
|
||||
var payload = JsonConvert.SerializeObject(new { action = actionType, seat = 0 });
|
||||
apiClient?.PostGameAction(payload,
|
||||
_ => ShowFeedback(actionType),
|
||||
err => ShowFeedback($"Error: {err}", isError: true));
|
||||
}
|
||||
|
||||
private void ShowFeedback(string msg, bool isError = false)
|
||||
{
|
||||
if (!feedbackLabel) return;
|
||||
StopCoroutine(nameof(FadeOutFeedback));
|
||||
feedbackLabel.text = msg;
|
||||
feedbackLabel.color = isError ? Color.red : Color.green;
|
||||
feedbackLabel.gameObject.SetActive(true);
|
||||
StartCoroutine(nameof(FadeOutFeedback));
|
||||
}
|
||||
|
||||
private IEnumerator FadeOutFeedback()
|
||||
{
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
if (feedbackLabel) feedbackLabel.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-seat life total widget with +/- buttons, danger flash, and history log.
|
||||
/// Calls GameStateManager.EliminatePlayer when life reaches 0.
|
||||
/// </summary>
|
||||
public class LifeTrackerWidget : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private TMP_Text lifeLabel;
|
||||
[SerializeField] private Button plusOneBtn;
|
||||
[SerializeField] private Button minusOneBtn;
|
||||
[SerializeField] private Button plusFiveBtn;
|
||||
[SerializeField] private Button minusFiveBtn;
|
||||
[SerializeField] private GameObject dangerFlash; // red glow overlay, shown at <=5 life
|
||||
|
||||
[Header("Config")]
|
||||
[SerializeField] private int startingLife = 40;
|
||||
public int SeatIndex { get; set; }
|
||||
|
||||
private int _life;
|
||||
private readonly List<LifeHistoryEntry> _history = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
SetLife(startingLife);
|
||||
plusOneBtn .onClick.AddListener(() => AdjustLife(+1, "manual"));
|
||||
minusOneBtn.onClick.AddListener(() => AdjustLife(-1, "manual"));
|
||||
plusFiveBtn .onClick.AddListener(() => AdjustLife(+5, "manual"));
|
||||
minusFiveBtn.onClick.AddListener(() => AdjustLife(-5, "manual"));
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public int Life => _life;
|
||||
|
||||
public void AdjustLife(int delta, string source)
|
||||
{
|
||||
_life = Mathf.Max(0, _life + delta);
|
||||
RefreshDisplay();
|
||||
|
||||
int turn = GameStateManager.Instance != null ? GameStateManager.Instance.TurnNumber : 0;
|
||||
_history.Add(new LifeHistoryEntry { delta = delta, source = source, turn = turn });
|
||||
|
||||
if (_life == 0)
|
||||
GameStateManager.Instance?.EliminatePlayer(SeatIndex, "life");
|
||||
}
|
||||
|
||||
public void SetLife(int value)
|
||||
{
|
||||
_life = Mathf.Max(0, value);
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
public List<LifeHistoryEntry> GetHistory() => _history;
|
||||
|
||||
// ── Internal ───────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (lifeLabel) lifeLabel.text = _life.ToString();
|
||||
if (dangerFlash) dangerFlash.SetActive(_life <= 5 && _life > 0);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LifeHistoryEntry
|
||||
{
|
||||
public int delta;
|
||||
public string source;
|
||||
public int turn;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Modal shown at Battleground scene start.
|
||||
/// Configures 4 seats (Human vs AI, deck choice, AI style).
|
||||
/// On Confirm:
|
||||
/// 1. POSTs seat config to POST /api/game/start
|
||||
/// 2. Saves game_id + ws_url to PlayerPrefs
|
||||
/// 3. Loads Battleground scene
|
||||
/// </summary>
|
||||
public class LobbySetupModal : MonoBehaviour
|
||||
{
|
||||
[Header("Seat Rows (4)")]
|
||||
[SerializeField] private LobbyRowUI[] seatRows; // length 4
|
||||
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private Button confirmButton;
|
||||
[SerializeField] private Button cancelButton;
|
||||
|
||||
[Header("Feedback")]
|
||||
[SerializeField] private TMP_Text errorLabel; // assign in Inspector; hidden by default
|
||||
|
||||
[SerializeField] private ApiClient apiClient;
|
||||
|
||||
private List<string> _deckNames = new();
|
||||
|
||||
// ── Open ────────────────────────────────────────────────
|
||||
public void Open(string savedSeatsJson)
|
||||
{
|
||||
gameObject.SetActive(true);
|
||||
SetError(null);
|
||||
|
||||
confirmButton.onClick.AddListener(OnConfirm);
|
||||
cancelButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
|
||||
|
||||
// Load deck list for dropdowns
|
||||
apiClient?.GetDecks(
|
||||
json =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var w = JsonConvert.DeserializeObject<DecksWrapper>(json);
|
||||
_deckNames = new List<string>();
|
||||
if (w?.decks != null)
|
||||
foreach (var d in w.decks) _deckNames.Add(d.name);
|
||||
}
|
||||
catch { _deckNames = new List<string>(); }
|
||||
PopulateRows(savedSeatsJson);
|
||||
},
|
||||
_ => PopulateRows(savedSeatsJson));
|
||||
}
|
||||
|
||||
private void PopulateRows(string savedJson)
|
||||
{
|
||||
List<SeatConfig> saved = null;
|
||||
try { saved = JsonConvert.DeserializeObject<List<SeatConfig>>(savedJson); } catch { }
|
||||
|
||||
for (int i = 0; i < seatRows.Length; i++)
|
||||
{
|
||||
if (seatRows[i] == null) continue;
|
||||
var cfg = (saved != null && i < saved.Count) ? saved[i] : null;
|
||||
seatRows[i].Populate(i, _deckNames, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirm → POST /api/game/start ──────────────────────
|
||||
private void OnConfirm()
|
||||
{
|
||||
SetError(null);
|
||||
confirmButton.interactable = false;
|
||||
|
||||
// Build local seat list
|
||||
var configs = new List<SeatConfig>();
|
||||
foreach (var row in seatRows)
|
||||
configs.Add(row?.GetConfig() ?? new SeatConfig());
|
||||
|
||||
// Save locally so Battleground scene can read seat metadata
|
||||
PlayerPrefs.SetString("BattleSeats", JsonConvert.SerializeObject(configs));
|
||||
PlayerPrefs.Save();
|
||||
|
||||
// Build backend request — maps local SeatConfig → StartGameRequest
|
||||
var apiSeats = new List<ApiSeatConfig>();
|
||||
for (int i = 0; i < configs.Count; i++)
|
||||
{
|
||||
var c = configs[i];
|
||||
apiSeats.Add(new ApiSeatConfig
|
||||
{
|
||||
seat_index = i,
|
||||
seat_type = c.isHuman ? "Human" : $"AI {c.aiStyle}",
|
||||
deck_name = c.deckName ?? "",
|
||||
player_name = c.isHuman ? "Player" : $"AI {i}",
|
||||
});
|
||||
}
|
||||
|
||||
var requestJson = JsonConvert.SerializeObject(new StartGameRequest { seats = apiSeats });
|
||||
|
||||
apiClient.PostGameStart(
|
||||
requestJson,
|
||||
onSuccess: responseJson =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = JsonConvert.DeserializeObject<StartGameResponse>(responseJson);
|
||||
if (resp == null || string.IsNullOrEmpty(resp.game_id))
|
||||
{
|
||||
OnStartError("Server returned an invalid response.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist game_id and ws_url so GameWebSocketClient can connect
|
||||
PlayerPrefs.SetString("GameId", resp.game_id);
|
||||
PlayerPrefs.SetString("WsUrl", resp.ws_url ?? $"/ws/game/{resp.game_id}");
|
||||
PlayerPrefs.Save();
|
||||
|
||||
gameObject.SetActive(false);
|
||||
SceneManager.LoadScene("Battleground");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnStartError($"Parse error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
onError: err => OnStartError($"Failed to start game: {err}"));
|
||||
}
|
||||
|
||||
private void OnStartError(string message)
|
||||
{
|
||||
Debug.LogError("[LobbySetupModal] " + message);
|
||||
SetError(message);
|
||||
confirmButton.interactable = true;
|
||||
}
|
||||
|
||||
private void SetError(string message)
|
||||
{
|
||||
if (errorLabel == null) return;
|
||||
errorLabel.text = message ?? string.Empty;
|
||||
errorLabel.gameObject.SetActive(!string.IsNullOrEmpty(message));
|
||||
}
|
||||
|
||||
// ── Models ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Local / PlayerPrefs representation of a seat.</summary>
|
||||
[Serializable]
|
||||
public class SeatConfig
|
||||
{
|
||||
public bool isHuman;
|
||||
public string deckName;
|
||||
public string aiStyle; // Aggro / Control / Combo / Political
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py SeatConfig schema.</summary>
|
||||
[Serializable]
|
||||
private class ApiSeatConfig
|
||||
{
|
||||
public int seat_index;
|
||||
public string seat_type;
|
||||
public string deck_name;
|
||||
public string player_name;
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py StartGameRequest schema.</summary>
|
||||
[Serializable]
|
||||
private class StartGameRequest
|
||||
{
|
||||
public List<ApiSeatConfig> seats;
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py StartGameResponse schema.</summary>
|
||||
[Serializable]
|
||||
private class StartGameResponse
|
||||
{
|
||||
public string game_id;
|
||||
public string status;
|
||||
public string ws_url;
|
||||
}
|
||||
|
||||
[Serializable] private class DecksWrapper { public List<DeckEntry> decks; }
|
||||
[Serializable] private class DeckEntry { public string name; }
|
||||
}
|
||||
|
||||
/// <summary>Per-seat row in the lobby modal (UI bindings are set in inspector).</summary>
|
||||
[Serializable]
|
||||
public class LobbyRowUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text seatLabel;
|
||||
[SerializeField] private Toggle humanToggle;
|
||||
[SerializeField] private TMP_Dropdown deckDropdown;
|
||||
[SerializeField] private TMP_Dropdown aiStyleDropdown;
|
||||
|
||||
private static readonly List<string> AiStyles = new() { "Aggro", "Control", "Combo", "Political" };
|
||||
|
||||
public void Populate(int seatIndex, List<string> deckNames, LobbySetupModal.SeatConfig saved)
|
||||
{
|
||||
if (seatLabel) seatLabel.text = seatIndex == 0 ? "Seat 0 (You)" : $"Seat {seatIndex} (AI)";
|
||||
|
||||
if (humanToggle)
|
||||
{
|
||||
humanToggle.isOn = saved?.isHuman ?? (seatIndex == 0);
|
||||
humanToggle.onValueChanged.AddListener(isHuman =>
|
||||
{
|
||||
if (aiStyleDropdown) aiStyleDropdown.gameObject.SetActive(!isHuman);
|
||||
});
|
||||
if (aiStyleDropdown) aiStyleDropdown.gameObject.SetActive(!humanToggle.isOn);
|
||||
}
|
||||
|
||||
if (deckDropdown)
|
||||
{
|
||||
deckDropdown.ClearOptions();
|
||||
var opts = new List<string> { "-- Select Deck --" };
|
||||
opts.AddRange(deckNames);
|
||||
deckDropdown.AddOptions(opts);
|
||||
if (!string.IsNullOrEmpty(saved?.deckName))
|
||||
{
|
||||
int idx = opts.IndexOf(saved.deckName);
|
||||
deckDropdown.value = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (aiStyleDropdown)
|
||||
{
|
||||
aiStyleDropdown.ClearOptions();
|
||||
aiStyleDropdown.AddOptions(AiStyles);
|
||||
if (!string.IsNullOrEmpty(saved?.aiStyle))
|
||||
{
|
||||
int idx = AiStyles.IndexOf(saved.aiStyle);
|
||||
aiStyleDropdown.value = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LobbySetupModal.SeatConfig GetConfig() => new()
|
||||
{
|
||||
isHuman = humanToggle?.isOn ?? false,
|
||||
deckName = deckDropdown != null && deckDropdown.value > 0
|
||||
? deckDropdown.options[deckDropdown.value].text : "",
|
||||
aiStyle = aiStyleDropdown != null
|
||||
? aiStyleDropdown.options[aiStyleDropdown.value].text : "Aggro"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays the mana pool for one seat as coloured symbol + count labels.
|
||||
/// Symbols are hidden when the mana amount is 0.
|
||||
/// Resets on each phase-change event from GameStateManager.
|
||||
/// </summary>
|
||||
public class ManaPoolDisplay : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int seatIndex;
|
||||
|
||||
[Header("Mana Labels (W U B R G C order)")]
|
||||
[SerializeField] private TMP_Text[] manaLabels; // length 6
|
||||
[SerializeField] private GameObject[] manaSymbols; // parent GO per mana type (contains Image)
|
||||
|
||||
private static readonly string[] ManaKeys = { "W", "U", "B", "R", "G", "C" };
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync += OnFullStateSync;
|
||||
}
|
||||
UpdatePool(null);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync -= OnFullStateSync;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void UpdatePool(Dictionary<string, int> pool)
|
||||
{
|
||||
for (int i = 0; i < ManaKeys.Length; i++)
|
||||
{
|
||||
int amt = 0;
|
||||
pool?.TryGetValue(ManaKeys[i], out amt);
|
||||
|
||||
bool show = amt > 0;
|
||||
if (i < manaLabels.Length && manaLabels[i] != null)
|
||||
manaLabels[i].text = show ? amt.ToString() : "";
|
||||
if (i < manaSymbols.Length && manaSymbols[i] != null)
|
||||
manaSymbols[i].SetActive(show);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn)
|
||||
{
|
||||
// Mana pool empties at the end of each phase
|
||||
if (phase == PhaseTrackerWidget.Phase.Cleanup)
|
||||
UpdatePool(null);
|
||||
}
|
||||
|
||||
private void OnFullStateSync(GameState state)
|
||||
{
|
||||
if (state?.seats == null || seatIndex >= state.seats.Count) return;
|
||||
UpdatePool(state.seats[seatIndex].manaPool);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single phase pill in the PhaseTrackerWidget strip.
|
||||
/// Changes color, font weight, and scale when activated.
|
||||
/// </summary>
|
||||
public class PhaseStepButton : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Image background;
|
||||
[SerializeField] private TMP_Text label;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color activeColor = new Color(1f, 0.85f, 0f); // amber/yellow
|
||||
[SerializeField] private Color inactiveColor = new Color(0.2f, 0.2f, 0.2f); // dark grey
|
||||
|
||||
private Vector3 _baseScale;
|
||||
|
||||
private void Awake() => _baseScale = transform.localScale;
|
||||
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (background) background.color = active ? activeColor : inactiveColor;
|
||||
if (label) label.fontStyle = active ? TMPro.FontStyles.Bold : TMPro.FontStyles.Normal;
|
||||
transform.localScale = active ? _baseScale * 1.1f : _baseScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Horizontal strip of 13 phase/step buttons in the BattlefieldCenter panel.
|
||||
/// Subscribes to GameStateManager.OnPhaseChanged.
|
||||
/// Test without backend: press Space in Play Mode (debug binding below).
|
||||
/// </summary>
|
||||
public class PhaseTrackerWidget : MonoBehaviour
|
||||
{
|
||||
public enum Phase
|
||||
{
|
||||
Untap = 0,
|
||||
Upkeep,
|
||||
Draw,
|
||||
Main1,
|
||||
BeginCombat,
|
||||
DeclareAttackers,
|
||||
DeclareBlockers,
|
||||
FirstStrikeDamage,
|
||||
Damage,
|
||||
EndCombat,
|
||||
Main2,
|
||||
EndStep,
|
||||
Cleanup
|
||||
}
|
||||
|
||||
[SerializeField] private PhaseStepButton[] stepButtons; // 13 buttons, one per Phase
|
||||
[SerializeField] private TMP_Text activePlayerLabel;
|
||||
[SerializeField] private TMP_Text turnNumberLabel;
|
||||
|
||||
private Phase _currentPhase;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPhaseChanged += SetPhase;
|
||||
|
||||
SetPhase(Phase.Untap, 0, 1);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPhaseChanged -= SetPhase;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetPhase(Phase phase, int seatIndex, int turnNumber)
|
||||
{
|
||||
_currentPhase = phase;
|
||||
|
||||
for (int i = 0; i < stepButtons.Length; i++)
|
||||
stepButtons[i]?.SetActive(i == (int)phase);
|
||||
|
||||
if (activePlayerLabel)
|
||||
activePlayerLabel.text = seatIndex == 0 ? "Your Turn" : $"Seat {seatIndex}'s Turn";
|
||||
|
||||
if (turnNumberLabel)
|
||||
turnNumberLabel.text = $"Turn {turnNumber}";
|
||||
}
|
||||
|
||||
public Phase CurrentPhase => _currentPhase;
|
||||
|
||||
// ── Debug (editor only) ──────────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Space))
|
||||
{
|
||||
int next = ((int)_currentPhase + 1) % System.Enum.GetValues(typeof(Phase)).Length;
|
||||
SetPhase((Phase)next, 0, GameStateManager.Instance?.TurnNumber ?? 1);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks poison counters for a seat (0-10).
|
||||
/// Activates pip toggles and triggers elimination at 10.
|
||||
/// </summary>
|
||||
public class PoisonCounter : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int seatIndex;
|
||||
[SerializeField] private Toggle[] pips; // 10 Toggle GameObjects
|
||||
[SerializeField] private TMP_Text countLabel;
|
||||
[SerializeField] private Button addPoisonBtn;
|
||||
[SerializeField] private Button removePoisonBtn;
|
||||
|
||||
private int _count = 0;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
addPoisonBtn?.onClick.AddListener(() => AddPoison(1));
|
||||
removePoisonBtn?.onClick.AddListener(() => AddPoison(-1));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public void AddPoison(int delta)
|
||||
{
|
||||
_count = Mathf.Clamp(_count + delta, 0, 10);
|
||||
Refresh();
|
||||
if (_count >= 10)
|
||||
GameStateManager.Instance?.EliminatePlayer(seatIndex, "poison");
|
||||
}
|
||||
|
||||
public void SetPoison(int value)
|
||||
{
|
||||
_count = Mathf.Clamp(value, 0, 10);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (countLabel) countLabel.text = _count.ToString();
|
||||
for (int i = 0; i < pips.Length; i++)
|
||||
if (pips[i] != null) pips[i].isOn = i < _count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual row for a single item on the stack.
|
||||
/// Shows card/ability name, caster seat color, stack position, and targets.
|
||||
/// </summary>
|
||||
public class StackItemView : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text cardNameLabel;
|
||||
[SerializeField] private TMP_Text casterLabel;
|
||||
[SerializeField] private TMP_Text targetsLabel;
|
||||
[SerializeField] private TMP_Text stackPositionLabel;
|
||||
[SerializeField] private Image seatColorStrip;
|
||||
|
||||
private static readonly Color[] SeatColors =
|
||||
{
|
||||
new Color(0.2f, 0.6f, 1f), // seat 0 — blue (human)
|
||||
new Color(1f, 0.4f, 0.4f), // seat 1 — red
|
||||
new Color(0.4f, 1f, 0.4f), // seat 2 — green
|
||||
new Color(1f, 0.85f, 0.2f) // seat 3 — gold
|
||||
};
|
||||
|
||||
public void Bind(StackZoneController.StackItem item, int displayPosition)
|
||||
{
|
||||
if (cardNameLabel)
|
||||
cardNameLabel.text = item.isAbility ? $"⚡ {item.cardName}" : item.cardName;
|
||||
|
||||
if (casterLabel)
|
||||
casterLabel.text = item.casterSeat == 0 ? "You" : $"Seat {item.casterSeat}";
|
||||
|
||||
if (seatColorStrip)
|
||||
{
|
||||
int seat = Mathf.Clamp(item.casterSeat, 0, SeatColors.Length - 1);
|
||||
seatColorStrip.color = SeatColors[seat];
|
||||
}
|
||||
|
||||
if (stackPositionLabel)
|
||||
stackPositionLabel.text = displayPosition == 0 ? "[TOP]" : $"[{displayPosition}]";
|
||||
|
||||
if (targetsLabel)
|
||||
{
|
||||
if (item.targets != null && item.targets.Count > 0)
|
||||
targetsLabel.text = "\u2192 " + string.Join(", ", item.targets);
|
||||
else
|
||||
targetsLabel.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays the active spell/ability stack (LIFO order, top of stack shown first).
|
||||
/// Hidden when stack is empty. Subscribes to GameStateManager.OnStackUpdated.
|
||||
/// </summary>
|
||||
public class StackZoneController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject stackPanel;
|
||||
[SerializeField] private Transform stackItemParent; // VerticalLayoutGroup
|
||||
[SerializeField] private GameObject stackItemPrefab;
|
||||
[SerializeField] private TMP_Text stackCountLabel;
|
||||
|
||||
private readonly List<GameObject> _itemGOs = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnStackUpdated += SetStack;
|
||||
SetStack(new List<StackItem>());
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnStackUpdated -= SetStack;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetStack(List<StackItem> items)
|
||||
{
|
||||
// Clear existing rows
|
||||
foreach (var go in _itemGOs) if (go) Destroy(go);
|
||||
_itemGOs.Clear();
|
||||
|
||||
bool hasItems = items != null && items.Count > 0;
|
||||
if (stackPanel) stackPanel.SetActive(hasItems);
|
||||
|
||||
if (stackCountLabel)
|
||||
stackCountLabel.text = hasItems ? $"Stack ({items.Count})" : "";
|
||||
|
||||
if (!hasItems) return;
|
||||
|
||||
// LIFO — iterate in reverse so top of stack appears first
|
||||
for (int i = items.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var go = Instantiate(stackItemPrefab, stackItemParent);
|
||||
go.SetActive(true);
|
||||
var view = go.GetComponent<StackItemView>();
|
||||
view?.Bind(items[i], items.Count - 1 - i);
|
||||
_itemGOs.Add(go);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serializable stack item (shared with GameState) ───────────
|
||||
[Serializable]
|
||||
public class StackItem
|
||||
{
|
||||
[JsonProperty("card_name")] public string cardName;
|
||||
[JsonProperty("caster_seat")] public int casterSeat;
|
||||
[JsonProperty("targets")] public List<string> targets;
|
||||
[JsonProperty("is_ability")] public bool isAbility;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# Battleground UI — Task Tracker
|
||||
> Created: 2026-03-28
|
||||
> Updated: 2026-03-29 — all 14 issues complete
|
||||
> Branch: `feat/battleground-ui`
|
||||
|
||||
All issues below are part of the **4-player Commander Battleground** Unity 3D UI build.
|
||||
Build in the numbered order — each step is independently testable before the next.
|
||||
|
||||
---
|
||||
|
||||
## Build Order Checklist
|
||||
|
||||
| # | Issue | Script(s) | Needs Backend? | Status |
|
||||
|---|-------|-----------|----------------|--------|
|
||||
| 1 | [#138 — Battleground.unity scene + 4-seat layout](https://github.com/KoalaTrapLord/commander-ai-lab/issues/138) | `BattlegroundController.cs`, `GameStateManager.cs` | No | ✅ |
|
||||
| 2 | [#139 — LifeTrackerWidget + CommanderDamageRow + Poison](https://github.com/KoalaTrapLord/commander-ai-lab/issues/139) | `LifeTrackerWidget.cs`, `CommanderDamageRow.cs`, `PoisonCounter.cs` | No | ✅ |
|
||||
| 3 | [#140 — PhaseTrackerWidget + PhaseStepButton](https://github.com/KoalaTrapLord/commander-ai-lab/issues/140) | `PhaseTrackerWidget.cs`, `PhaseStepButton.cs` | No | ✅ |
|
||||
| 4 | [#141 — TurnOrderBar + TurnOrderPill](https://github.com/KoalaTrapLord/commander-ai-lab/issues/141) | `TurnOrderBar.cs`, `TurnOrderPill.cs` | No | ✅ |
|
||||
| 5 | [#142 — CardZone + CardView](https://github.com/KoalaTrapLord/commander-ai-lab/issues/142) | `CardZone.cs`, `CardView.cs` + `CardModel` runtime fields | ImageCache only | ✅ |
|
||||
| 6 | [#143 — CommanderZoneWidget](https://github.com/KoalaTrapLord/commander-ai-lab/issues/143) | `CommanderZoneWidget.cs` + `ImageCache.LoadArtCrop()` | ImageCache only | ✅ |
|
||||
| 7 | [#144 — Token System](https://github.com/KoalaTrapLord/commander-ai-lab/issues/144) | `TokenModel.cs`, `TokenPanelController.cs`, `TokenRowView.cs` | No | ✅ |
|
||||
| 8 | [#146 — StackZoneController + ManaPoolDisplay](https://github.com/KoalaTrapLord/commander-ai-lab/issues/146) | `StackZoneController.cs`, `ManaPoolDisplay.cs` | No (events only) | ✅ |
|
||||
| 9 | [#147 — LobbySetupModal + ZoneViewerModal](https://github.com/KoalaTrapLord/commander-ai-lab/issues/147) | `LobbySetupModal.cs`, `ZoneViewerModal.cs` | `/api/decks`, `/api/game/start` | ✅ |
|
||||
| 10 | [#148 — GameStateManager + GameState POCOs](https://github.com/KoalaTrapLord/commander-ai-lab/issues/148) | `GameStateManager.cs`, `GameState.cs` | No | ✅ |
|
||||
| 11 | [#149 — GameWebSocketClient](https://github.com/KoalaTrapLord/commander-ai-lab/issues/149) | `GameWebSocketClient.cs` | Yes — `/ws/game` | ✅ |
|
||||
| 12 | [#145 — HumanActionBar](https://github.com/KoalaTrapLord/commander-ai-lab/issues/145) | `HumanActionBar.cs`, `ApiClient.PostGameAction()` | Yes — `/api/game/action` | ✅ |
|
||||
| 13 | [#150 — EliminationHandler + Grayscale Shader](https://github.com/KoalaTrapLord/commander-ai-lab/issues/150) | `EliminationHandler.cs`, `EliminatedGrayscale` ShaderGraph | No | ✅ |
|
||||
| 14 | [#151 — SimulationController: Play Live button](https://github.com/KoalaTrapLord/commander-ai-lab/issues/151) | `SimulationController.cs` (modify) | No | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## All Files Committed
|
||||
|
||||
### `Assets/Scripts/UI/Battleground/` (new folder)
|
||||
- ✅ `BattlegroundController.cs` — scene orchestrator, seat rotations, event subscriptions
|
||||
- ✅ `GameStateManager.cs` — singleton event hub
|
||||
- ✅ `LifeTrackerWidget.cs` — life +/- with history + danger flash
|
||||
- ✅ `CommanderDamageRow.cs` — 4-pip commander damage tracker per seat
|
||||
- ✅ `PoisonCounter.cs` — 10-pip poison tracker
|
||||
- ✅ `PhaseTrackerWidget.cs` — 13-step phase strip, Space key debug advance
|
||||
- ✅ `PhaseStepButton.cs` — individual phase pill (color + scale)
|
||||
- ✅ `TurnOrderBar.cs` — animated seat border slider
|
||||
- ✅ `TurnOrderPill.cs` — per-seat pill with name, life, eliminated state
|
||||
- ✅ `CardZone.cs` — zone container (add/remove/clear CardView children)
|
||||
- ✅ `CardView.cs` — card visual (art, tapped rotation, face-down, summoning sick)
|
||||
- ✅ `CommanderZoneWidget.cs` — art crop, cast count, tax (+2 per cast)
|
||||
- ✅ `TokenPanelController.cs` — token creation panel
|
||||
- ✅ `TokenRowView.cs` — token row (qty, tap, counters, delete)
|
||||
- ✅ `StackZoneController.cs` — LIFO stack display, hidden when empty
|
||||
- ✅ `StackItemView.cs` — individual stack item row
|
||||
- ✅ `ManaPoolDisplay.cs` — 6-pip mana display, hides zero labels
|
||||
- ✅ `LobbySetupModal.cs` — 4-seat lobby config, loads decks from API, POSTs to /api/game/start
|
||||
- ✅ `ZoneViewerModal.cs` — fullscreen graveyard/exile viewer with search filter
|
||||
- ✅ `HumanActionBar.cs` — priority-gated action buttons + targeting mode
|
||||
- ✅ `EliminationHandler.cs` — overlay + grayscale material on elimination
|
||||
|
||||
### `Assets/Scripts/Models/`
|
||||
- ✅ `GameState.cs` — `GameState` + `SeatState` POCOs
|
||||
- ✅ `TokenModel.cs` — token data model
|
||||
- ✅ `CardModel.cs` — modified: added `[NonSerialized]` runtime state + `BattleZone` enum
|
||||
|
||||
### `Assets/Scripts/Services/`
|
||||
- ✅ `ImageCache.cs` — modified: added `LoadCard(RawImage)` + `LoadArtCrop(name, RawImage)`
|
||||
- ✅ `GameWebSocketClient.cs` — WS connect/reconnect, ping, ConcurrentQueue main-thread dispatch
|
||||
- ✅ `ApiClient.cs` — modified: added `PostGameAction()` + `PostGameStart()`
|
||||
|
||||
### `Assets/Scenes/`
|
||||
- ✅ `Battleground.unity` — new scene
|
||||
|
||||
### `Assets/Materials/`
|
||||
- ✅ `EliminatedGrayscale` — URP Shader Graph
|
||||
|
||||
### Modified Existing Files
|
||||
- ✅ `SimulationController.cs` — Play Live button + `OnPlayLive()` + `BuildSeatsJson()`
|
||||
- ✅ `MainMenuController.cs` — Play Live nav button
|
||||
|
||||
---
|
||||
|
||||
## Backend Endpoints Required
|
||||
|
||||
These Python routes must exist before end-to-end testing:
|
||||
|
||||
| Endpoint | Used By | Status |
|
||||
|----------|---------|--------|
|
||||
| `GET /api/decks` | `LobbySetupModal` | verify |
|
||||
| `POST /api/game/start` | `LobbySetupModal` | verify |
|
||||
| `POST /api/game/action` | `HumanActionBar` | verify |
|
||||
| `WS /ws/game` | `GameWebSocketClient` | verify (PR #107) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **`GameStateManager`** is the singleton event hub — all UI widgets subscribe to its events, never poll
|
||||
- **`GameWebSocketClient`** receives server push messages and routes them to `GameStateManager.ApplyServerEvent()`
|
||||
- **Canvas mode**: Screen Space — Camera (not World Space) for crisp TMP rendering
|
||||
- **Seat rotation**: AI panels rotated -90°, 180°, 90° via `localEulerAngles` so text/buttons face the right direction
|
||||
- **Backend WS**: connect to `ws://localhost:8080/ws/game` (configurable via `PlayerPrefs["ServerUrl"]`)
|
||||
- **Space key** in Play Mode advances the phase strip for isolated testing without WebSocket
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Token creation and tracking panel in the BattlefieldCenter.
|
||||
/// Human creates tokens manually; AI tokens are added via AddToken() from server events.
|
||||
/// </summary>
|
||||
public class TokenPanelController : MonoBehaviour
|
||||
{
|
||||
[Header("Creation Row")]
|
||||
[SerializeField] private TMP_InputField nameInput;
|
||||
[SerializeField] private TMP_InputField powerInput;
|
||||
[SerializeField] private TMP_InputField toughnessInput;
|
||||
[SerializeField] private TMP_InputField qtyInput;
|
||||
[SerializeField] private Button createBtn;
|
||||
|
||||
[Header("List")]
|
||||
[SerializeField] private Transform tokenListParent;
|
||||
[SerializeField] private GameObject tokenRowPrefab;
|
||||
|
||||
private readonly List<TokenModel> _tokens = new();
|
||||
private readonly List<TokenRowView> _rowViews = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start() => createBtn.onClick.AddListener(OnCreateToken);
|
||||
|
||||
// ── Token management ────────────────────────────────────
|
||||
private void OnCreateToken()
|
||||
{
|
||||
string tokenName = nameInput?.text?.Trim();
|
||||
if (string.IsNullOrEmpty(tokenName)) return;
|
||||
|
||||
int.TryParse(qtyInput?.text, out int qty);
|
||||
qty = Mathf.Max(1, qty);
|
||||
|
||||
var token = new TokenModel
|
||||
{
|
||||
id = Guid.NewGuid().ToString(),
|
||||
name = tokenName,
|
||||
power = powerInput?.text?.Trim() ?? "",
|
||||
toughness = toughnessInput?.text?.Trim() ?? "",
|
||||
ownerSeat = 0,
|
||||
qty = qty
|
||||
};
|
||||
|
||||
AddToken(token);
|
||||
|
||||
// Clear inputs
|
||||
if (nameInput) nameInput.text = "";
|
||||
if (powerInput) powerInput.text = "";
|
||||
if (toughnessInput) toughnessInput.text = "";
|
||||
if (qtyInput) qtyInput.text = "1";
|
||||
}
|
||||
|
||||
public void AddToken(TokenModel token)
|
||||
{
|
||||
_tokens.Add(token);
|
||||
var go = Instantiate(tokenRowPrefab, tokenListParent);
|
||||
var view = go.GetComponent<TokenRowView>();
|
||||
if (view != null)
|
||||
{
|
||||
view.Bind(token, RemoveToken);
|
||||
_rowViews.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveToken(TokenModel token)
|
||||
{
|
||||
int idx = _tokens.IndexOf(token);
|
||||
if (idx < 0) return;
|
||||
|
||||
_tokens.RemoveAt(idx);
|
||||
var view = _rowViews[idx];
|
||||
_rowViews.RemoveAt(idx);
|
||||
if (view != null) Destroy(view.gameObject);
|
||||
}
|
||||
|
||||
public IReadOnlyList<TokenModel> Tokens => _tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single token row in TokenPanelController's list.
|
||||
/// Supports qty +/-, tap all, counter editing, and removal.
|
||||
/// </summary>
|
||||
public class TokenRowView : MonoBehaviour
|
||||
{
|
||||
[Header("Labels")]
|
||||
[SerializeField] private TMP_Text nameLabel;
|
||||
[SerializeField] private TMP_Text ptLabel;
|
||||
[SerializeField] private TMP_Text qtyLabel;
|
||||
[SerializeField] private TMP_Text tappedLabel;
|
||||
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private Button qtyPlusBtn;
|
||||
[SerializeField] private Button qtyMinusBtn;
|
||||
[SerializeField] private Button tapAllBtn;
|
||||
[SerializeField] private Button deleteBtn;
|
||||
|
||||
[Header("Counter Editor")]
|
||||
[SerializeField] private GameObject counterEditorPanel;
|
||||
[SerializeField] private TMP_Dropdown counterTypeDropdown;
|
||||
[SerializeField] private Button counterAddBtn;
|
||||
[SerializeField] private Button counterRemoveBtn;
|
||||
[SerializeField] private TMP_Text counterSummaryLabel;
|
||||
[SerializeField] private Button toggleCounterEditorBtn;
|
||||
|
||||
private static readonly List<string> CounterTypes =
|
||||
new() { "+1/+1", "-1/-1", "loyalty", "charge", "quest", "time" };
|
||||
|
||||
private TokenModel _token;
|
||||
private Action<TokenModel> _onDelete;
|
||||
|
||||
// ── Binding ───────────────────────────────────────────
|
||||
public void Bind(TokenModel token, Action<TokenModel> onDelete)
|
||||
{
|
||||
_token = token;
|
||||
_onDelete = onDelete;
|
||||
|
||||
// Wire buttons
|
||||
qtyPlusBtn ?.onClick.AddListener(() => AdjustQty(+1));
|
||||
qtyMinusBtn?.onClick.AddListener(() => AdjustQty(-1));
|
||||
tapAllBtn ?.onClick.AddListener(TapAll);
|
||||
deleteBtn ?.onClick.AddListener(() => _onDelete?.Invoke(_token));
|
||||
toggleCounterEditorBtn?.onClick.AddListener(ToggleCounterEditor);
|
||||
counterAddBtn ?.onClick.AddListener(() => AdjustCounter(+1));
|
||||
counterRemoveBtn?.onClick.AddListener(() => AdjustCounter(-1));
|
||||
|
||||
// Populate counter type dropdown
|
||||
if (counterTypeDropdown != null)
|
||||
{
|
||||
counterTypeDropdown.ClearOptions();
|
||||
counterTypeDropdown.AddOptions(CounterTypes);
|
||||
}
|
||||
|
||||
if (counterEditorPanel) counterEditorPanel.SetActive(false);
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────
|
||||
private void AdjustQty(int delta)
|
||||
{
|
||||
_token.qty = Mathf.Max(0, _token.qty + delta);
|
||||
if (_token.qty == 0) { _onDelete?.Invoke(_token); return; }
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
private void TapAll()
|
||||
{
|
||||
_token.isTapped = !_token.isTapped;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
private void ToggleCounterEditor()
|
||||
{
|
||||
if (counterEditorPanel)
|
||||
counterEditorPanel.SetActive(!counterEditorPanel.activeSelf);
|
||||
}
|
||||
|
||||
private void AdjustCounter(int delta)
|
||||
{
|
||||
if (counterTypeDropdown == null) return;
|
||||
string counterType = CounterTypes[Mathf.Clamp(counterTypeDropdown.value, 0, CounterTypes.Count - 1)];
|
||||
_token.counters.TryGetValue(counterType, out int current);
|
||||
int next = Mathf.Max(0, current + delta);
|
||||
if (next == 0) _token.counters.Remove(counterType);
|
||||
else _token.counters[counterType] = next;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── Display ────────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (nameLabel) nameLabel.text = _token.name;
|
||||
if (ptLabel) ptLabel.text = _token.PTString;
|
||||
if (qtyLabel) qtyLabel.text = $"×{_token.qty}";
|
||||
if (tappedLabel) tappedLabel.text = _token.isTapped ? "[Tapped]" : "";
|
||||
|
||||
// Counter summary
|
||||
if (counterSummaryLabel)
|
||||
{
|
||||
var parts = new System.Text.StringBuilder();
|
||||
foreach (var kv in _token.counters)
|
||||
parts.Append($"{kv.Key}:{kv.Value} ");
|
||||
counterSummaryLabel.text = parts.Length > 0 ? parts.ToString().Trim() : "No counters";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Horizontal strip of 4 TurnOrderPills at the top of BattlefieldCenter.
|
||||
/// Animates an active border sliding to the current seat's pill on turn change.
|
||||
/// Subscribes to GameStateManager.OnTurnChanged.
|
||||
/// </summary>
|
||||
public class TurnOrderBar : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TurnOrderPill[] pills; // length 4
|
||||
[SerializeField] private RectTransform activeBorder; // sliding highlight rect
|
||||
[SerializeField] private float slideDuration = 0.25f;
|
||||
|
||||
private int _activeSeat = 0;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnTurnChanged += SetActiveSeat;
|
||||
GameStateManager.Instance.OnLifeChanged += OnLifeChanged;
|
||||
GameStateManager.Instance.OnPlayerEliminated += OnPlayerEliminated;
|
||||
}
|
||||
SetActiveSeat(0);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged -= SetActiveSeat;
|
||||
GameStateManager.Instance.OnLifeChanged -= OnLifeChanged;
|
||||
GameStateManager.Instance.OnPlayerEliminated -= OnPlayerEliminated;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetActiveSeat(int seatIndex)
|
||||
{
|
||||
_activeSeat = seatIndex;
|
||||
for (int i = 0; i < pills.Length; i++)
|
||||
pills[i]?.SetActive(i == seatIndex);
|
||||
|
||||
AnimateBorderToPill(seatIndex);
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────
|
||||
private void AnimateBorderToPill(int seatIndex)
|
||||
{
|
||||
if (activeBorder == null || pills == null || seatIndex >= pills.Length) return;
|
||||
if (pills[seatIndex] == null) return;
|
||||
|
||||
var targetPos = (pills[seatIndex].transform as RectTransform)?.anchoredPosition
|
||||
?? pills[seatIndex].transform.localPosition;
|
||||
|
||||
StopAllCoroutines();
|
||||
StartCoroutine(SlideBorder(targetPos));
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator SlideBorder(Vector2 targetPos)
|
||||
{
|
||||
var start = activeBorder.anchoredPosition;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < slideDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = Mathf.SmoothStep(0f, 1f, elapsed / slideDuration);
|
||||
activeBorder.anchoredPosition = Vector2.Lerp(start, targetPos, t);
|
||||
yield return null;
|
||||
}
|
||||
activeBorder.anchoredPosition = targetPos;
|
||||
}
|
||||
|
||||
private void OnLifeChanged(int seatIndex, int newLife)
|
||||
{
|
||||
if (seatIndex < pills.Length) pills[seatIndex]?.UpdateLife(newLife);
|
||||
}
|
||||
|
||||
private void OnPlayerEliminated(int seatIndex, string reason)
|
||||
{
|
||||
if (seatIndex < pills.Length) pills[seatIndex]?.SetEliminated();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single seat pill inside TurnOrderBar.
|
||||
/// Shows player name, current life, and highlights when it is that seat's turn.
|
||||
/// </summary>
|
||||
public class TurnOrderPill : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text playerNameLabel;
|
||||
[SerializeField] private TMP_Text lifeLabel;
|
||||
[SerializeField] private Image pillBackground;
|
||||
[SerializeField] private GameObject youBadge; // "YOU" label, only visible on seat 0
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color activeColor = new Color(1f, 0.85f, 0f);
|
||||
[SerializeField] private Color inactiveColor = new Color(0.25f, 0.25f, 0.25f);
|
||||
[SerializeField] private Color eliminatedColor = new Color(0.4f, 0.4f, 0.4f, 0.5f);
|
||||
|
||||
private bool _isEliminated;
|
||||
|
||||
public void SetPlayerName(string playerName)
|
||||
{
|
||||
if (playerNameLabel) playerNameLabel.text = playerName;
|
||||
}
|
||||
|
||||
public void UpdateLife(int life)
|
||||
{
|
||||
if (lifeLabel) lifeLabel.text = life.ToString();
|
||||
}
|
||||
|
||||
public void ShowYouBadge(bool show)
|
||||
{
|
||||
if (youBadge) youBadge.SetActive(show);
|
||||
}
|
||||
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (_isEliminated) return;
|
||||
if (pillBackground) pillBackground.color = active ? activeColor : inactiveColor;
|
||||
}
|
||||
|
||||
public void SetEliminated()
|
||||
{
|
||||
_isEliminated = true;
|
||||
if (pillBackground) pillBackground.color = eliminatedColor;
|
||||
if (playerNameLabel)
|
||||
{
|
||||
playerNameLabel.fontStyle = TMPro.FontStyles.Strikethrough;
|
||||
playerNameLabel.color = Color.gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Full-screen overlay showing all cards in a particular zone (graveyard, exile, etc.).
|
||||
/// Opened by clicking zone count badges on each seat panel.
|
||||
/// </summary>
|
||||
public class ZoneViewerModal : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private TMP_Text titleLabel;
|
||||
[SerializeField] private Transform cardGrid; // GridLayoutGroup
|
||||
[SerializeField] private GameObject cardPrefab; // CardView prefab
|
||||
[SerializeField] private Button closeButton;
|
||||
|
||||
private readonly List<GameObject> _cardGOs = new();
|
||||
|
||||
private void Start() => closeButton?.onClick.AddListener(Close);
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────
|
||||
public void Open(string zoneName, CardZone zone)
|
||||
{
|
||||
if (titleLabel) titleLabel.text = $"{zoneName} ({zone.Count} cards)";
|
||||
|
||||
foreach (var go in _cardGOs) if (go) Destroy(go);
|
||||
_cardGOs.Clear();
|
||||
|
||||
foreach (var view in zone.Cards)
|
||||
{
|
||||
if (view?.Model == null) continue;
|
||||
var go = Instantiate(cardPrefab, cardGrid);
|
||||
go.SetActive(true);
|
||||
var cv = go.GetComponent<CardView>();
|
||||
cv?.Bind(view.Model, false);
|
||||
_cardGOs.Add(go);
|
||||
}
|
||||
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
// ── Close ───────────────────────────────────────────────
|
||||
public void Close()
|
||||
{
|
||||
foreach (var go in _cardGOs) if (go) Destroy(go);
|
||||
_cardGOs.Clear();
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace CommanderAILab.UI
|
||||
/// <summary>
|
||||
/// Simulator scene — configure and run multi-player simulations,
|
||||
/// poll status, display results per deck.
|
||||
/// Also provides a Play Live button that routes to the Battleground scene.
|
||||
/// </summary>
|
||||
public class SimulationController : MonoBehaviour
|
||||
{
|
||||
@@ -22,6 +23,8 @@ namespace CommanderAILab.UI
|
||||
[Header("Navigation")]
|
||||
[SerializeField] private Button backButton;
|
||||
[SerializeField] private Button startSimButton;
|
||||
/// <summary>Routes to the Battleground scene for a live 4-player game.</summary>
|
||||
[SerializeField] private Button playLiveButton;
|
||||
|
||||
// ── Config Panel ──────────────────────────────────────────────────────
|
||||
[Header("Config")]
|
||||
@@ -74,6 +77,9 @@ namespace CommanderAILab.UI
|
||||
startSimButton.onClick.AddListener(OnStartSim);
|
||||
retryButton.onClick.AddListener(OnStartSim);
|
||||
|
||||
// Play Live: save current deck selections and load Battleground scene
|
||||
playLiveButton?.onClick.AddListener(OnPlayLive);
|
||||
|
||||
numPlayersSlider.minValue = 2;
|
||||
numPlayersSlider.maxValue = 4;
|
||||
numPlayersSlider.wholeNumbers = true;
|
||||
@@ -83,12 +89,10 @@ namespace CommanderAILab.UI
|
||||
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
// Check for a deck exported from DeckBuilder
|
||||
string simDeck = PlayerPrefs.GetString("SimDeck", "");
|
||||
if (!string.IsNullOrEmpty(simDeck))
|
||||
{
|
||||
PlayerPrefs.DeleteKey("SimDeck");
|
||||
// Pre-populate deck dropdown 0 after decks load — stored for later
|
||||
PlayerPrefs.SetString("_SimDeckPending", simDeck);
|
||||
}
|
||||
|
||||
@@ -96,6 +100,29 @@ namespace CommanderAILab.UI
|
||||
LoadAiProfiles();
|
||||
}
|
||||
|
||||
// ── Play Live ─────────────────────────────────────────────────────────
|
||||
private void OnPlayLive()
|
||||
{
|
||||
// Persist current deck selections so LobbySetupModal can pre-populate
|
||||
var seats = new List<object>();
|
||||
void AddSeat(TMP_Dropdown dd, bool isHuman, int seatIdx)
|
||||
{
|
||||
string deckName = "";
|
||||
if (dd != null && dd.value > 0 && (dd.value - 1) < _decks.Count)
|
||||
deckName = _decks[dd.value - 1].name;
|
||||
seats.Add(new { isHuman, deckName, aiStyle = "Aggro" });
|
||||
}
|
||||
|
||||
AddSeat(deckDropdown0, true, 0);
|
||||
AddSeat(deckDropdown1, false, 1);
|
||||
AddSeat(deckDropdown2, false, 2);
|
||||
AddSeat(deckDropdown3, false, 3);
|
||||
|
||||
PlayerPrefs.SetString("BattleSeats", JsonConvert.SerializeObject(seats));
|
||||
PlayerPrefs.Save();
|
||||
SceneManager.LoadScene("Battleground");
|
||||
}
|
||||
|
||||
// ── Player Count ──────────────────────────────────────────────────────
|
||||
private void OnNumPlayersChanged(float value)
|
||||
{
|
||||
@@ -103,7 +130,6 @@ namespace CommanderAILab.UI
|
||||
if (numPlayersLabel != null)
|
||||
numPlayersLabel.text = $"Players: {count}";
|
||||
|
||||
// Show/hide optional deck dropdowns based on count
|
||||
if (deckDropdown2 != null) deckDropdown2.gameObject.SetActive(count >= 3);
|
||||
if (deckDropdown3 != null) deckDropdown3.gameObject.SetActive(count >= 4);
|
||||
}
|
||||
@@ -143,14 +169,12 @@ namespace CommanderAILab.UI
|
||||
Fill(deckDropdown2);
|
||||
Fill(deckDropdown3);
|
||||
|
||||
// Apply any pending deck from DeckBuilder export
|
||||
string pending = PlayerPrefs.GetString("_SimDeckPending", "");
|
||||
if (!string.IsNullOrEmpty(pending))
|
||||
{
|
||||
PlayerPrefs.DeleteKey("_SimDeckPending");
|
||||
try
|
||||
{
|
||||
// Try to match by name from the JSON
|
||||
var exported = JsonConvert.DeserializeObject<LabDeckEntry>(pending);
|
||||
if (exported != null && !string.IsNullOrEmpty(exported.name))
|
||||
{
|
||||
@@ -179,7 +203,6 @@ namespace CommanderAILab.UI
|
||||
},
|
||||
err =>
|
||||
{
|
||||
// Non-fatal: profiles may not be available yet
|
||||
_profiles = new List<AiProfile>();
|
||||
PopulateProfileDropdown();
|
||||
});
|
||||
@@ -203,7 +226,6 @@ namespace CommanderAILab.UI
|
||||
|
||||
int numPlayers = Mathf.RoundToInt(numPlayersSlider.value);
|
||||
|
||||
// Collect selected deck names (index 0 = "-- Select Deck --")
|
||||
var selectedDecks = new List<string>();
|
||||
void TryAdd(TMP_Dropdown dd)
|
||||
{
|
||||
@@ -292,7 +314,6 @@ namespace CommanderAILab.UI
|
||||
|
||||
bool done = false;
|
||||
string errorMsg = null;
|
||||
SimStatusModel latestStatus = null;
|
||||
|
||||
_api.GetSimStatus(batchId,
|
||||
json =>
|
||||
@@ -301,7 +322,6 @@ namespace CommanderAILab.UI
|
||||
{
|
||||
var status = JsonConvert.DeserializeObject<SimStatusModel>(json);
|
||||
if (status == null) { done = true; errorMsg = "Empty status response."; return; }
|
||||
latestStatus = status;
|
||||
|
||||
if (!string.IsNullOrEmpty(status.error))
|
||||
{
|
||||
@@ -316,10 +336,10 @@ namespace CommanderAILab.UI
|
||||
: status.gamesCompleted;
|
||||
|
||||
float progress = total > 0 ? (float)completed / total : 0f;
|
||||
if (progressBar) progressBar.value = progress;
|
||||
if (progressText) progressText.text = $"{Mathf.RoundToInt(progress * 100)}%";
|
||||
if (statusText) statusText.text = $"Running... {completed}/{total} games";
|
||||
if (elapsedText) elapsedText.text = $"Elapsed: {status.elapsedMs / 1000f:F1}s";
|
||||
if (progressBar) progressBar.value = progress;
|
||||
if (progressText) progressText.text = $"{Mathf.RoundToInt(progress * 100)}%";
|
||||
if (statusText) statusText.text = $"Running... {completed}/{total} games";
|
||||
if (elapsedText) elapsedText.text = $"Elapsed: {status.elapsedMs / 1000f:F1}s";
|
||||
if (simsPerSecText) simsPerSecText.text = $"{status.simsPerSec:F1} sims/s";
|
||||
|
||||
if (!status.running) done = true;
|
||||
@@ -332,7 +352,6 @@ namespace CommanderAILab.UI
|
||||
},
|
||||
err => { done = true; errorMsg = err; });
|
||||
|
||||
// Wait one frame so callbacks have fired
|
||||
yield return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(errorMsg))
|
||||
@@ -345,7 +364,7 @@ namespace CommanderAILab.UI
|
||||
|
||||
if (done)
|
||||
{
|
||||
if (statusText) statusText.text = "Simulation complete!";
|
||||
if (statusText) statusText.text = "Simulation complete!";
|
||||
if (progressBar) progressBar.value = 1f;
|
||||
if (progressText) progressText.text = "100%";
|
||||
startSimButton.interactable = true;
|
||||
@@ -412,9 +431,9 @@ namespace CommanderAILab.UI
|
||||
|
||||
private void SetResultsPanelVisible(bool visible)
|
||||
{
|
||||
if (resultsParent) resultsParent.gameObject.SetActive(visible);
|
||||
if (winnerText) winnerText.gameObject.SetActive(visible);
|
||||
if (historyButton) historyButton.gameObject.SetActive(visible);
|
||||
if (resultsParent) resultsParent.gameObject.SetActive(visible);
|
||||
if (winnerText) winnerText.gameObject.SetActive(visible);
|
||||
if (historyButton) historyButton.gameObject.SetActive(visible);
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────
|
||||
@@ -424,19 +443,26 @@ namespace CommanderAILab.UI
|
||||
errorText.text = msg;
|
||||
}
|
||||
|
||||
// ── Helper Models (private inner classes) ─────────────────────────────
|
||||
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
||||
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
||||
// ── Helper Models ─────────────────────────────────────────────────────
|
||||
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
||||
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
||||
[Serializable] private class ProfilesWrapper { public List<AiProfile> profiles; }
|
||||
[Serializable] private class AiProfile { public string name; public string description; }
|
||||
[Serializable] private class AiProfile { public string name; public string description; }
|
||||
[Serializable] private class StartRequest
|
||||
{
|
||||
public List<string> decks; public int numGames; public int threads;
|
||||
public bool useLearnedPolicy; public string policyStyle;
|
||||
public int seed; public int clock;
|
||||
}
|
||||
[Serializable] private class SimResult { public Summary summary; }
|
||||
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
||||
[Serializable] private class SimStartResponse { public string batchId; }
|
||||
[Serializable] private class SimStatusModel
|
||||
{
|
||||
public bool running; public int total; public int completed;
|
||||
public int totalGames; public int gamesCompleted;
|
||||
public float elapsedMs; public float simsPerSec; public string error;
|
||||
}
|
||||
[Serializable] private class SimResult { public Summary summary; }
|
||||
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
||||
[Serializable] private class ResultEntry { public string deckName; public float winRate; public int wins; public int losses; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user