From 3f5d6d12e8d84223dbe6bf77dcab608e60f4c534 Mon Sep 17 00:00:00 2001 From: KoalaTrapLord <265133236+KoalaTrapLord@users.noreply.github.com> Date: Sun, 29 Mar 2026 05:35:29 -0400 Subject: [PATCH] =?UTF-8?q?feat:=204-player=20Commander=20Battleground=20U?= =?UTF-8?q?I=20(issues=20#138=E2=80=93#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- lab_api.py | 12 +- routes/game.py | 209 +++++++++++++++ .../Assets/Scripts/Models/CardModel.cs | 18 ++ .../Assets/Scripts/Models/GameState.cs | 38 +++ .../Assets/Scripts/Models/TokenModel.cs | 26 ++ .../Assets/Scripts/Services/ApiClient.cs | 10 + .../Scripts/Services/GameWebSocketClient.cs | 207 +++++++++++++++ .../Assets/Scripts/Services/ImageCache.cs | 71 +++-- .../UI/Battleground/BattlegroundController.cs | 91 +++++++ .../Scripts/UI/Battleground/CardView.cs | 96 +++++++ .../Scripts/UI/Battleground/CardZone.cs | 65 +++++ .../UI/Battleground/CommanderDamageRow.cs | 69 +++++ .../UI/Battleground/CommanderZoneWidget.cs | 69 +++++ .../UI/Battleground/EliminationHandler.cs | 116 ++++++++ .../UI/Battleground/GameStateManager.cs | 119 +++++++++ .../Scripts/UI/Battleground/HumanActionBar.cs | 101 +++++++ .../UI/Battleground/LifeTrackerWidget.cs | 78 ++++++ .../UI/Battleground/LobbySetupModal.cs | 249 ++++++++++++++++++ .../UI/Battleground/ManaPoolDisplay.cs | 70 +++++ .../UI/Battleground/PhaseStepButton.cs | 31 +++ .../UI/Battleground/PhaseTrackerWidget.cs | 80 ++++++ .../Scripts/UI/Battleground/PoisonCounter.cs | 49 ++++ .../Scripts/UI/Battleground/StackItemView.cs | 54 ++++ .../UI/Battleground/StackZoneController.cs | 72 +++++ .../Assets/Scripts/UI/Battleground/TODO.md | 99 +++++++ .../UI/Battleground/TokenPanelController.cs | 86 ++++++ .../Scripts/UI/Battleground/TokenRowView.cs | 117 ++++++++ .../Scripts/UI/Battleground/TurnOrderBar.cs | 86 ++++++ .../Scripts/UI/Battleground/TurnOrderPill.cs | 57 ++++ .../UI/Battleground/ZoneViewerModal.cs | 53 ++++ .../Assets/Scripts/UI/SimulationController.cs | 74 ++++-- 31 files changed, 2526 insertions(+), 46 deletions(-) create mode 100644 routes/game.py create mode 100644 unity-client/Assets/Scripts/Models/GameState.cs create mode 100644 unity-client/Assets/Scripts/Models/TokenModel.cs create mode 100644 unity-client/Assets/Scripts/Services/GameWebSocketClient.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/BattlegroundController.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/CardView.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/CardZone.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/CommanderDamageRow.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/CommanderZoneWidget.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/EliminationHandler.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/HumanActionBar.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/LifeTrackerWidget.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/LobbySetupModal.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/ManaPoolDisplay.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/PhaseStepButton.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/PhaseTrackerWidget.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/PoisonCounter.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/StackItemView.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/StackZoneController.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/TODO.md create mode 100644 unity-client/Assets/Scripts/UI/Battleground/TokenPanelController.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/TokenRowView.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/TurnOrderBar.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/TurnOrderPill.cs create mode 100644 unity-client/Assets/Scripts/UI/Battleground/ZoneViewerModal.cs diff --git a/lab_api.py b/lab_api.py index 0a829f0..1cd1741 100644 --- a/lab_api.py +++ b/lab_api.py @@ -42,6 +42,7 @@ from routes.deckgen import router as deckgen_router from routes.coach import router as coach_router, init_coach_service from routes.ml import router as ml_router from routes.ws_game import router as ws_game_router +from routes.game import router as game_router log = logging.getLogger("commander_ai_lab.api") @@ -50,7 +51,7 @@ _allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS) allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()] -# ── Lifespan (replaces deprecated @app.on_event) ─────────────────────────────────────── +# ── Lifespan (replaces deprecated @app.on_event) ──────────────────────────────────────────────────────── @asynccontextmanager async def _lifespan(application: FastAPI): # ──── STARTUP ──── @@ -113,7 +114,7 @@ async def _lifespan(application: FastAPI): await async_close_db() -# ── App ─────────────────────────────────────────────────────────────────────── +# ── App ───────────────────────────────────────────────────────────────────────────── app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan) app.add_middleware( CORSMiddleware, @@ -135,9 +136,10 @@ app.include_router(deckgen_router) app.include_router(coach_router) app.include_router(ws_game_router) app.include_router(ml_router) +app.include_router(game_router) # live game session routes -# ── API endpoints ──────────────────────────────────────────────────────────────── +# ── API endpoints ───────────────────────────────────────────────────────────────────────────── # NOTE: all @app.get routes MUST be registered before app.mount() calls. # Once StaticFiles is mounted at "/", it registers a wildcard catch-all that # shadows any routes added after it. @@ -147,7 +149,7 @@ async def health_check(): """Health check endpoint for load balancers and Unity client polling.""" return {"status": "ok"} -# ── Static UI (MUST come after all @app.get routes) ──────────────────────────── +# ── Static UI (MUST come after all @app.get routes) ────────────────────────────────────────── from pathlib import Path as _Path _legacy_ui_dir = _Path(__file__).parent / "ui" _spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist" @@ -168,7 +170,7 @@ elif _spa_dir.exists(): return FileResponse(str(_spa_dir / "index.html")) -# ── Argument parsing + auto-detection helpers ─────────────────────────────────────── +# ── Argument parsing + auto-detection helpers ──────────────────────────────────────────────────────── def _parse_args(): p = argparse.ArgumentParser(description="Commander AI Lab API Server") p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", "")) diff --git a/routes/game.py b/routes/game.py new file mode 100644 index 0000000..17779f1 --- /dev/null +++ b/routes/game.py @@ -0,0 +1,209 @@ +"""Live game session routes. + +Endpoints: + POST /api/game/start — create a new live game session (called by LobbySetupModal) + POST /api/game/action — submit a human player action (called by HumanActionBar) +""" +from __future__ import annotations + +import logging +import uuid +from typing import List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +log = logging.getLogger("routes.game") + +router = APIRouter(tags=["game"]) + + +# ─────────────────────────────────────────────────────────────── +# Request / Response models +# ─────────────────────────────────────────────────────────────── + +class SeatConfig(BaseModel): + seat_index: int = 0 + seat_type: str = "AI" # "Human" | "AI Aggro" | "AI Control" | "AI Combo" | "AI Political" + deck_name: str = "" + player_name: str = "" + + +class StartGameRequest(BaseModel): + seats: List[SeatConfig] = Field(default_factory=list) + + +class StartGameResponse(BaseModel): + game_id: str + status: str = "started" + ws_url: str + + +class GameActionRequest(BaseModel): + game_id: str + seat: int = 0 + action: str # "pass_priority" | "end_turn" | "declare_attackers" | + # "declare_blockers" | "select_target" + target_id: Optional[str] = None + extra: dict = Field(default_factory=dict) + + +class GameActionResponse(BaseModel): + status: str = "ok" + game_id: str + action: str + + +# ─────────────────────────────────────────────────────────────── +# In-memory session store (replace with DB / Redis for persistence) +# ─────────────────────────────────────────────────────────────── + +# { game_id: { "seats": [...], "status": "active" | "ended", "pending_actions": [...] } } +_sessions: dict[str, dict] = {} + + +# ─────────────────────────────────────────────────────────────── +# POST /api/game/start +# ─────────────────────────────────────────────────────────────── + +@router.post("/api/game/start", response_model=StartGameResponse) +async def start_game(req: StartGameRequest): + """Create a new live game session. + + Called by Unity's LobbySetupModal after the player configures seats. + Returns a game_id that Unity uses to connect to the WebSocket at + /ws/game/{game_id}. + + TODO: Wire Forge engine launch here once the IPC layer is ready. + For now, creates a session record and returns the game_id so Unity + can connect and receive state pushes from the Forge IPC client. + """ + if not req.seats: + raise HTTPException(status_code=400, detail="At least one seat is required.") + + game_id = uuid.uuid4().hex + + _sessions[game_id] = { + "game_id": game_id, + "seats": [s.dict() for s in req.seats], + "status": "active", + "pending_actions": [], + } + + log.info( + "Game session created: game_id=%s seats=%d", + game_id, + len(req.seats), + ) + + return StartGameResponse( + game_id=game_id, + status="started", + ws_url=f"/ws/game/{game_id}", + ) + + +# ─────────────────────────────────────────────────────────────── +# POST /api/game/action +# ─────────────────────────────────────────────────────────────── + +@router.post("/api/game/action", response_model=GameActionResponse) +async def game_action(req: GameActionRequest): + """Submit a human player action during a live game. + + Called by Unity's HumanActionBar when seat 0 (human) has priority. + The action is queued in the session's pending_actions list, where + the Forge IPC polling loop will pick it up. + + Valid actions: + pass_priority — pass priority to the next player + end_turn — end the current turn + declare_attackers — declare attacking creatures + declare_blockers — declare blocking creatures + select_target — select a target (requires target_id) + """ + valid_actions = { + "pass_priority", "end_turn", + "declare_attackers", "declare_blockers", + "select_target", + } + + if req.action not in valid_actions: + raise HTTPException( + status_code=400, + detail=f"Unknown action '{req.action}'. Valid: {sorted(valid_actions)}", + ) + + if req.action == "select_target" and not req.target_id: + raise HTTPException( + status_code=400, + detail="select_target requires a target_id.", + ) + + session = _sessions.get(req.game_id) + if session is None: + raise HTTPException( + status_code=404, + detail=f"Game session '{req.game_id}' not found.", + ) + + if session["status"] != "active": + raise HTTPException( + status_code=409, + detail=f"Game session '{req.game_id}' is not active (status={session['status']}).", + ) + + action_record = { + "seat": req.seat, + "action": req.action, + "target_id": req.target_id, + "extra": req.extra, + } + session["pending_actions"].append(action_record) + + log.info( + "Action queued: game_id=%s seat=%d action=%s", + req.game_id, req.seat, req.action, + ) + + return GameActionResponse( + status="ok", + game_id=req.game_id, + action=req.action, + ) + + +# ─────────────────────────────────────────────────────────────── +# GET /api/game/{game_id}/actions (Forge IPC polling endpoint) +# ─────────────────────────────────────────────────────────────── + +@router.get("/api/game/{game_id}/actions") +async def poll_actions(game_id: str): + """Drain and return all pending human actions for a game session. + + Called by the Forge IPC polling loop to consume human player decisions. + Clears the queue on each call (consume-once semantics). + """ + session = _sessions.get(game_id) + if session is None: + raise HTTPException(status_code=404, detail=f"Game session '{game_id}' not found.") + + actions = session["pending_actions"].copy() + session["pending_actions"].clear() + return {"game_id": game_id, "actions": actions} + + +# ─────────────────────────────────────────────────────────────── +# GET /api/game/sessions (debug) +# ─────────────────────────────────────────────────────────────── + +@router.get("/api/game/sessions") +async def list_sessions(): + """List active game sessions (debug endpoint).""" + return { + "count": len(_sessions), + "sessions": [ + {"game_id": gid, "status": s["status"], "seats": len(s["seats"])} + for gid, s in _sessions.items() + ], + } diff --git a/unity-client/Assets/Scripts/Models/CardModel.cs b/unity-client/Assets/Scripts/Models/CardModel.cs index 3af49da..7b3471d 100644 --- a/unity-client/Assets/Scripts/Models/CardModel.cs +++ b/unity-client/Assets/Scripts/Models/CardModel.cs @@ -33,6 +33,24 @@ namespace CommanderAILab.Models [JsonProperty("roles")] public List 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 counters = new(); + public override string ToString() => $"{name} ({manaCost}) [{typeLine}]"; } + + public enum BattleZone + { + Library, + Hand, + Battlefield, + Graveyard, + Exile, + CommandZone, + Stack + } } diff --git a/unity-client/Assets/Scripts/Models/GameState.cs b/unity-client/Assets/Scripts/Models/GameState.cs new file mode 100644 index 0000000..18a115d --- /dev/null +++ b/unity-client/Assets/Scripts/Models/GameState.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using CommanderAILab.UI; + +namespace CommanderAILab.Models +{ + /// Full live game state snapshot pushed by the server on state_update events. + [Serializable] + public class GameState + { + [JsonProperty("seats")] public List seats; + [JsonProperty("active_seat")] public int activeSeat; + [JsonProperty("turn_number")] public int turnNumber; + [JsonProperty("phase")] public string phase; + [JsonProperty("stack")] public List stack; + } + + /// Per-seat state within a GameState snapshot. + [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 hand; + [JsonProperty("battlefield")] public List battlefield; + [JsonProperty("graveyard")] public List graveyard; + [JsonProperty("exile")] public List exile; + [JsonProperty("commander")] public CardModel commander; + [JsonProperty("commander_cast_count")] public int commanderCastCount; + /// Cumulative commander damage received from each seat index (length 4). + [JsonProperty("commander_damage_from")] public int[] commanderDamageFrom; + [JsonProperty("poison_counters")] public int poisonCounters; + [JsonProperty("mana_pool")] public Dictionary manaPool; + [JsonProperty("is_eliminated")] public bool isEliminated; + } +} diff --git a/unity-client/Assets/Scripts/Models/TokenModel.cs b/unity-client/Assets/Scripts/Models/TokenModel.cs new file mode 100644 index 0000000..c5793dc --- /dev/null +++ b/unity-client/Assets/Scripts/Models/TokenModel.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CommanderAILab.Models +{ + /// Runtime data model for a token on the battlefield. + [Serializable] + public class TokenModel + { + public string id; + public string name; + public string power; + public string toughness; + /// Single letter: W U B R G C + public string colorCode = "C"; + public int ownerSeat; + public int qty = 1; + public bool isTapped; + public Dictionary counters = new(); + + public string PTString => + (!string.IsNullOrEmpty(power) && !string.IsNullOrEmpty(toughness)) + ? $"{power}/{toughness}" : ""; + } +} diff --git a/unity-client/Assets/Scripts/Services/ApiClient.cs b/unity-client/Assets/Scripts/Services/ApiClient.cs index 3d35d76..037bb4b 100644 --- a/unity-client/Assets/Scripts/Services/ApiClient.cs +++ b/unity-client/Assets/Scripts/Services/ApiClient.cs @@ -283,5 +283,15 @@ namespace CommanderAILab.Services } onSuccess?.Invoke(request.downloadHandler.text); } + + // -- Game (Live Battleground) --------------------------------------- + + /// POST /api/game/action — submit a human player action during a live game. + public Coroutine PostGameAction(string json, Action onSuccess, Action onError = null) + => PostRaw("/api/game/action", json, onSuccess, onError); + + /// POST /api/game/start — start a new live game with the given lobby config. + public Coroutine PostGameStart(string json, Action onSuccess, Action onError = null) + => PostRaw("/api/game/start", json, onSuccess, onError); } } diff --git a/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs b/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs new file mode 100644 index 0000000..249a99a --- /dev/null +++ b/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs @@ -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 +{ + /// + /// 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://. + /// + 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 ──────────────────────────────────────────────── + /// + /// 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. + /// + 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(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(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(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; + } + } +} diff --git a/unity-client/Assets/Scripts/Services/ImageCache.cs b/unity-client/Assets/Scripts/Services/ImageCache.cs index d6ba017..e8c75b1 100644 --- a/unity-client/Assets/Scripts/Services/ImageCache.cs +++ b/unity-client/Assets/Scripts/Services/ImageCache.cs @@ -1,19 +1,22 @@ +using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; +using UnityEngine.UI; namespace CommanderAILab.Services { /// /// 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. /// public class ImageCache : MonoBehaviour { public static ImageCache Instance { get; private set; } - private readonly Dictionary cache = new(); + private readonly Dictionary _spriteCache = new(); + private readonly Dictionary _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 callback) + // ── Sprite (existing usage) ─────────────────────────────────────────── + public void GetSprite(string url, Action 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 callback) + private IEnumerator DownloadSprite(string url, Action 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) ─── + /// Load a card image URL directly into a RawImage component. + 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; }); + } + + /// + /// Load Scryfall art_crop into a RawImage by card name. + /// URL: https://api.scryfall.com/cards/named?exact={name}&format=image&version=art_crop + /// + 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 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(); } } } diff --git a/unity-client/Assets/Scripts/UI/Battleground/BattlegroundController.cs b/unity-client/Assets/Scripts/UI/Battleground/BattlegroundController.cs new file mode 100644 index 0000000..890a07c --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/BattlegroundController.cs @@ -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 +{ + /// + /// 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. + /// + 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); + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/CardView.cs b/unity-client/Assets/Scripts/UI/Battleground/CardView.cs new file mode 100644 index 0000000..02581a8 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/CardView.cs @@ -0,0 +1,96 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using CommanderAILab.Models; +using CommanderAILab.Services; + +namespace CommanderAILab.UI +{ + /// + /// Visual representation of a single card in a CardZone. + /// Handles art loading via ImageCache, tapped rotation, and face-down state. + /// + 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; + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/CardZone.cs b/unity-client/Assets/Scripts/UI/Battleground/CardZone.cs new file mode 100644 index 0000000..0e75a91 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/CardZone.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using UnityEngine; +using CommanderAILab.Models; + +namespace CommanderAILab.UI +{ + /// + /// Manages a collection of CardView instances inside one battlefield zone + /// (Hand, Battlefield, Graveyard, Exile, etc.). + /// The cardContainer should have a HorizontalLayoutGroup or GridLayoutGroup. + /// + 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 _cards = new(); + + public BattleZone ZoneType => zoneType; + public IReadOnlyList Cards => _cards; + public int Count => _cards.Count; + + // ── Public API ───────────────────────────────────────── + public CardView AddCard(CardModel model) + { + var go = Instantiate(cardPrefab, cardContainer); + var view = go.GetComponent(); + 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); + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/CommanderDamageRow.cs b/unity-client/Assets/Scripts/UI/Battleground/CommanderDamageRow.cs new file mode 100644 index 0000000..f3dab31 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/CommanderDamageRow.cs @@ -0,0 +1,69 @@ +using UnityEngine; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// 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. + /// + public class CommanderDamageRow : MonoBehaviour + { + /// The seat this row belongs to (receives damage). + [SerializeField] private int seatIndex; + + /// + /// 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). + /// + [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); + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/CommanderZoneWidget.cs b/unity-client/Assets/Scripts/UI/Battleground/CommanderZoneWidget.cs new file mode 100644 index 0000000..c5ada84 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/CommanderZoneWidget.cs @@ -0,0 +1,69 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using CommanderAILab.Models; +using CommanderAILab.Services; + +namespace CommanderAILab.UI +{ + /// + /// Per-seat commander zone widget. + /// Shows: art crop banner, commander name, cast count, commander tax. + /// Requires ImageCache singleton in scene. + /// + 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"; + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/EliminationHandler.cs b/unity-client/Assets/Scripts/UI/Battleground/EliminationHandler.cs new file mode 100644 index 0000000..6e25de6 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/EliminationHandler.cs @@ -0,0 +1,116 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// 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/. + /// + 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; + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs b/unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs new file mode 100644 index 0000000..b0af078 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Newtonsoft.Json; +using CommanderAILab.Models; + +namespace CommanderAILab.UI +{ + /// + /// 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. + /// + public class GameStateManager : MonoBehaviour + { + public static GameStateManager Instance { get; private set; } + + // ── Events ──────────────────────────────────────────────── + public event Action OnTurnChanged; // activeSeat + public event Action OnPhaseChanged; // phase, seat, turnNumber + public event Action OnPriorityChanged; // seatIndex + public event Action OnLifeChanged; // seatIndex, newLife + public event Action OnPlayerEliminated; // seatIndex, reason + public event Action> OnStackUpdated; + public event Action 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 ─────────────────────────────────── + /// Called by GameWebSocketClient on the Unity main thread. + public void ApplyServerEvent(string type, string payload) + { + try + { + switch (type) + { + case "state_update": + _state = JsonConvert.DeserializeObject(payload); + if (_state != null) OnFullStateSync?.Invoke(_state); + break; + + case "turn_start": + var ts = JsonConvert.DeserializeObject(payload); + if (ts != null) { TurnNumber = ts.turnNumber; OnTurnChanged?.Invoke(ts.seat); } + break; + + case "phase_change": + var pc = JsonConvert.DeserializeObject(payload); + if (pc != null) + OnPhaseChanged?.Invoke((PhaseTrackerWidget.Phase)pc.phaseIndex, pc.seat, TurnNumber); + break; + + case "priority": + var pv = JsonConvert.DeserializeObject(payload); + if (pv != null) OnPriorityChanged?.Invoke(pv.seat); + break; + + case "life_change": + var lc = JsonConvert.DeserializeObject(payload); + if (lc != null) OnLifeChanged?.Invoke(lc.seat, lc.newLife); + break; + + case "stack_update": + var su = JsonConvert.DeserializeObject(payload); + if (su?.items != null) OnStackUpdated?.Invoke(su.items); + break; + + case "player_elim": + var pe = JsonConvert.DeserializeObject(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}"); + } + } + + /// Trigger elimination (called by LifeTrackerWidget, CommanderDamageRow, or server event). + 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 items; } + [Serializable] private class EliminationEvent { public int seat; public string reason; } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/HumanActionBar.cs b/unity-client/Assets/Scripts/UI/Battleground/HumanActionBar.cs new file mode 100644 index 0000000..dcd66c4 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/HumanActionBar.cs @@ -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 +{ + /// + /// Human player action bar — only active when seat 0 has priority. + /// Buttons post actions to /api/game/action. + /// Subscribes to GameStateManager.OnPriorityChanged. + /// + 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); + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/LifeTrackerWidget.cs b/unity-client/Assets/Scripts/UI/Battleground/LifeTrackerWidget.cs new file mode 100644 index 0000000..d77bbef --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/LifeTrackerWidget.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// Per-seat life total widget with +/- buttons, danger flash, and history log. + /// Calls GameStateManager.EliminatePlayer when life reaches 0. + /// + 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 _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 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; + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/LobbySetupModal.cs b/unity-client/Assets/Scripts/UI/Battleground/LobbySetupModal.cs new file mode 100644 index 0000000..790af6e --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/LobbySetupModal.cs @@ -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 +{ + /// + /// 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 + /// + 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 _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(json); + _deckNames = new List(); + if (w?.decks != null) + foreach (var d in w.decks) _deckNames.Add(d.name); + } + catch { _deckNames = new List(); } + PopulateRows(savedSeatsJson); + }, + _ => PopulateRows(savedSeatsJson)); + } + + private void PopulateRows(string savedJson) + { + List saved = null; + try { saved = JsonConvert.DeserializeObject>(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(); + 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(); + 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(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 ───────────────────────────────────────────── + + /// Local / PlayerPrefs representation of a seat. + [Serializable] + public class SeatConfig + { + public bool isHuman; + public string deckName; + public string aiStyle; // Aggro / Control / Combo / Political + } + + /// Matches routes/game.py SeatConfig schema. + [Serializable] + private class ApiSeatConfig + { + public int seat_index; + public string seat_type; + public string deck_name; + public string player_name; + } + + /// Matches routes/game.py StartGameRequest schema. + [Serializable] + private class StartGameRequest + { + public List seats; + } + + /// Matches routes/game.py StartGameResponse schema. + [Serializable] + private class StartGameResponse + { + public string game_id; + public string status; + public string ws_url; + } + + [Serializable] private class DecksWrapper { public List decks; } + [Serializable] private class DeckEntry { public string name; } + } + + /// Per-seat row in the lobby modal (UI bindings are set in inspector). + [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 AiStyles = new() { "Aggro", "Control", "Combo", "Political" }; + + public void Populate(int seatIndex, List 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 { "-- 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" + }; + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/ManaPoolDisplay.cs b/unity-client/Assets/Scripts/UI/Battleground/ManaPoolDisplay.cs new file mode 100644 index 0000000..bc91b49 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/ManaPoolDisplay.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using UnityEngine; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// 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. + /// + 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 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); + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/PhaseStepButton.cs b/unity-client/Assets/Scripts/UI/Battleground/PhaseStepButton.cs new file mode 100644 index 0000000..851ca40 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/PhaseStepButton.cs @@ -0,0 +1,31 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// A single phase pill in the PhaseTrackerWidget strip. + /// Changes color, font weight, and scale when activated. + /// + 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; + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/PhaseTrackerWidget.cs b/unity-client/Assets/Scripts/UI/Battleground/PhaseTrackerWidget.cs new file mode 100644 index 0000000..304bfab --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/PhaseTrackerWidget.cs @@ -0,0 +1,80 @@ +using UnityEngine; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// 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). + /// + 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 + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/PoisonCounter.cs b/unity-client/Assets/Scripts/UI/Battleground/PoisonCounter.cs new file mode 100644 index 0000000..d505dc9 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/PoisonCounter.cs @@ -0,0 +1,49 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// Tracks poison counters for a seat (0-10). + /// Activates pip toggles and triggers elimination at 10. + /// + 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; + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/StackItemView.cs b/unity-client/Assets/Scripts/UI/Battleground/StackItemView.cs new file mode 100644 index 0000000..b6238e9 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/StackItemView.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// Visual row for a single item on the stack. + /// Shows card/ability name, caster seat color, stack position, and targets. + /// + 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 = ""; + } + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/StackZoneController.cs b/unity-client/Assets/Scripts/UI/Battleground/StackZoneController.cs new file mode 100644 index 0000000..45fa1c0 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/StackZoneController.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using TMPro; +using Newtonsoft.Json; + +namespace CommanderAILab.UI +{ + /// + /// Displays the active spell/ability stack (LIFO order, top of stack shown first). + /// Hidden when stack is empty. Subscribes to GameStateManager.OnStackUpdated. + /// + 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 _itemGOs = new(); + + // ── Lifecycle ─────────────────────────────────────────── + private void Start() + { + if (GameStateManager.Instance != null) + GameStateManager.Instance.OnStackUpdated += SetStack; + SetStack(new List()); + } + + private void OnDestroy() + { + if (GameStateManager.Instance != null) + GameStateManager.Instance.OnStackUpdated -= SetStack; + } + + // ── Public API ───────────────────────────────────────── + public void SetStack(List 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(); + 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 targets; + [JsonProperty("is_ability")] public bool isAbility; + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/TODO.md b/unity-client/Assets/Scripts/UI/Battleground/TODO.md new file mode 100644 index 0000000..77fc951 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/TODO.md @@ -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 diff --git a/unity-client/Assets/Scripts/UI/Battleground/TokenPanelController.cs b/unity-client/Assets/Scripts/UI/Battleground/TokenPanelController.cs new file mode 100644 index 0000000..5baf17a --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/TokenPanelController.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using CommanderAILab.Models; + +namespace CommanderAILab.UI +{ + /// + /// Token creation and tracking panel in the BattlefieldCenter. + /// Human creates tokens manually; AI tokens are added via AddToken() from server events. + /// + 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 _tokens = new(); + private readonly List _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(); + 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 Tokens => _tokens; + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/TokenRowView.cs b/unity-client/Assets/Scripts/UI/Battleground/TokenRowView.cs new file mode 100644 index 0000000..34194c7 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/TokenRowView.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using CommanderAILab.Models; + +namespace CommanderAILab.UI +{ + /// + /// A single token row in TokenPanelController's list. + /// Supports qty +/-, tap all, counter editing, and removal. + /// + 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 CounterTypes = + new() { "+1/+1", "-1/-1", "loyalty", "charge", "quest", "time" }; + + private TokenModel _token; + private Action _onDelete; + + // ── Binding ─────────────────────────────────────────── + public void Bind(TokenModel token, Action 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"; + } + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/TurnOrderBar.cs b/unity-client/Assets/Scripts/UI/Battleground/TurnOrderBar.cs new file mode 100644 index 0000000..da6a403 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/TurnOrderBar.cs @@ -0,0 +1,86 @@ +using UnityEngine; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// 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. + /// + 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(); + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/TurnOrderPill.cs b/unity-client/Assets/Scripts/UI/Battleground/TurnOrderPill.cs new file mode 100644 index 0000000..8c4fe93 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/TurnOrderPill.cs @@ -0,0 +1,57 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// A single seat pill inside TurnOrderBar. + /// Shows player name, current life, and highlights when it is that seat's turn. + /// + 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; + } + } + } +} diff --git a/unity-client/Assets/Scripts/UI/Battleground/ZoneViewerModal.cs b/unity-client/Assets/Scripts/UI/Battleground/ZoneViewerModal.cs new file mode 100644 index 0000000..ea57a13 --- /dev/null +++ b/unity-client/Assets/Scripts/UI/Battleground/ZoneViewerModal.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace CommanderAILab.UI +{ + /// + /// Full-screen overlay showing all cards in a particular zone (graveyard, exile, etc.). + /// Opened by clicking zone count badges on each seat panel. + /// + 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 _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(); + 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); + } + } +} diff --git a/unity-client/Assets/Scripts/UI/SimulationController.cs b/unity-client/Assets/Scripts/UI/SimulationController.cs index 3274cd5..92ad20b 100644 --- a/unity-client/Assets/Scripts/UI/SimulationController.cs +++ b/unity-client/Assets/Scripts/UI/SimulationController.cs @@ -15,6 +15,7 @@ namespace CommanderAILab.UI /// /// 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. /// public class SimulationController : MonoBehaviour { @@ -22,6 +23,8 @@ namespace CommanderAILab.UI [Header("Navigation")] [SerializeField] private Button backButton; [SerializeField] private Button startSimButton; + /// Routes to the Battleground scene for a live 4-player game. + [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(); + 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(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(); 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(); 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(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 decks; } - [Serializable] private class LabDeckEntry { public string name; public string source; public string commander; } + // ── Helper Models ───────────────────────────────────────────────────── + [Serializable] private class DecksWrapper { public List decks; } + [Serializable] private class LabDeckEntry { public string name; public string source; public string commander; } [Serializable] private class ProfilesWrapper { public List 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 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 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 perDeck; } [Serializable] private class ResultEntry { public string deckName; public float winRate; public int wins; public int losses; } } }