Compare commits

...
Author SHA1 Message Date
KoalaTrapLord 2c67222de1 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
2026-03-29 05:22:04 -04:00
KoalaTrapLord d8238ec11f 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
2026-03-29 05:17:48 -04:00
KoalaTrapLord 7aed64a660 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
2026-03-29 05:09:51 -04:00
KoalaTrapLord e3497a8f74 feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch 2026-03-28 20:05:35 -04:00
KoalaTrapLord 6a655628c8 feat(#151): add Play Live button to SimulationController 2026-03-28 20:04:42 -04:00
KoalaTrapLord e3d4eb5785 feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController 2026-03-28 20:03:43 -04:00
KoalaTrapLord abee551ab4 feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay 2026-03-28 20:02:23 -04:00
KoalaTrapLord 9fdb6d29fb chore: mark issues 1-8 as complete in task tracker 2026-03-28 19:58:00 -04:00
KoalaTrapLord 2c8c6c26bd feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView 2026-03-28 19:57:00 -04:00
KoalaTrapLord 9ffefd463c feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display 2026-03-28 19:56:24 -04:00
KoalaTrapLord 4c99957e76 feat(#142): add CardZone and CardView battlefield zone components 2026-03-28 19:56:09 -04:00
KoalaTrapLord e5e25a7553 feat(#141): add TurnOrderBar and TurnOrderPill 2026-03-28 19:55:40 -04:00
KoalaTrapLord 8d56feee80 feat(#140): add PhaseTrackerWidget and PhaseStepButton 2026-03-28 19:55:17 -04:00
KoalaTrapLord 837bb3d6f9 feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter 2026-03-28 19:54:56 -04:00
KoalaTrapLord 98b81ceab2 feat(#138): add BattlegroundController scene orchestrator 2026-03-28 19:54:24 -04:00
KoalaTrapLord fae04a9336 feat(#148): add GameStateManager singleton and GameState/SeatState POCOs 2026-03-28 19:54:01 -04:00
KoalaTrapLord 8fd155a925 feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache 2026-03-28 19:53:25 -04:00
KoalaTrapLord 34ce9bfbf7 feat(#142): add [NonSerialized] battleground runtime state to CardModel 2026-03-28 19:53:09 -04:00
KoalaTrapLord 07010a71ed chore: add battleground UI task tracker (2026-03-28) 2026-03-28 19:50:06 -04:00
31 changed files with 2526 additions and 46 deletions

No files matched your search

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