Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c67222de1 | ||
|
|
d8238ec11f | ||
|
|
7aed64a660 | ||
|
|
e3497a8f74 | ||
|
|
6a655628c8 | ||
|
|
e3d4eb5785 | ||
|
|
abee551ab4 | ||
|
|
9fdb6d29fb | ||
|
|
2c8c6c26bd | ||
|
|
9ffefd463c | ||
|
|
4c99957e76 | ||
|
|
e5e25a7553 | ||
|
|
8d56feee80 | ||
|
|
837bb3d6f9 | ||
|
|
98b81ceab2 | ||
|
|
fae04a9336 | ||
|
|
8fd155a925 | ||
|
|
34ce9bfbf7 | ||
|
|
07010a71ed |
No files matched your search
+7
-5
@@ -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.coach import router as coach_router, init_coach_service
|
||||||
from routes.ml import router as ml_router
|
from routes.ml import router as ml_router
|
||||||
from routes.ws_game import router as ws_game_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")
|
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()]
|
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
|
@asynccontextmanager
|
||||||
async def _lifespan(application: FastAPI):
|
async def _lifespan(application: FastAPI):
|
||||||
# ──── STARTUP ────
|
# ──── STARTUP ────
|
||||||
@@ -113,7 +114,7 @@ async def _lifespan(application: FastAPI):
|
|||||||
await async_close_db()
|
await async_close_db()
|
||||||
|
|
||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
# ── App ─────────────────────────────────────────────────────────────────────────────
|
||||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
|
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -135,9 +136,10 @@ app.include_router(deckgen_router)
|
|||||||
app.include_router(coach_router)
|
app.include_router(coach_router)
|
||||||
app.include_router(ws_game_router)
|
app.include_router(ws_game_router)
|
||||||
app.include_router(ml_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.
|
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
|
||||||
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
|
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
|
||||||
# shadows any routes added after it.
|
# shadows any routes added after it.
|
||||||
@@ -147,7 +149,7 @@ async def health_check():
|
|||||||
"""Health check endpoint for load balancers and Unity client polling."""
|
"""Health check endpoint for load balancers and Unity client polling."""
|
||||||
return {"status": "ok"}
|
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
|
from pathlib import Path as _Path
|
||||||
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
||||||
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
_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"))
|
return FileResponse(str(_spa_dir / "index.html"))
|
||||||
|
|
||||||
|
|
||||||
# ── Argument parsing + auto-detection helpers ───────────────────────────────────────
|
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
|
||||||
def _parse_args():
|
def _parse_args():
|
||||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||||
|
|||||||
+209
@@ -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("roles")] public List<string> roles;
|
||||||
[JsonProperty("source")] public string source;
|
[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 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);
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Networking;
|
using UnityEngine.Networking;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
namespace CommanderAILab.Services
|
namespace CommanderAILab.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Singleton texture cache for card images.
|
/// 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>
|
/// </summary>
|
||||||
public class ImageCache : MonoBehaviour
|
public class ImageCache : MonoBehaviour
|
||||||
{
|
{
|
||||||
public static ImageCache Instance { get; private set; }
|
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;
|
[SerializeField] private int maxCacheSize = 500;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -23,33 +26,67 @@ namespace CommanderAILab.Services
|
|||||||
DontDestroyOnLoad(gameObject);
|
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 (string.IsNullOrEmpty(url)) { callback?.Invoke(null); return; }
|
||||||
if (cache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
if (_spriteCache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
||||||
StartCoroutine(DownloadImage(url, callback));
|
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);
|
using var req = UnityWebRequestTexture.GetTexture(url);
|
||||||
yield return request.SendWebRequest();
|
yield return req.SendWebRequest();
|
||||||
|
if (req.result != UnityWebRequest.Result.Success)
|
||||||
if (request.result != UnityWebRequest.Result.Success)
|
|
||||||
{
|
{
|
||||||
Debug.LogWarning($"[ImageCache] Failed to load {url}: {request.error}");
|
Debug.LogWarning($"[ImageCache] Sprite failed {url}: {req.error}");
|
||||||
callback?.Invoke(null);
|
callback?.Invoke(null);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
var tex = DownloadHandlerTexture.GetContent(req);
|
||||||
var tex = DownloadHandlerTexture.GetContent(request);
|
|
||||||
var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), Vector2.one * 0.5f);
|
var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), Vector2.one * 0.5f);
|
||||||
|
if (_spriteCache.Count >= maxCacheSize) _spriteCache.Clear();
|
||||||
if (cache.Count >= maxCacheSize) cache.Clear();
|
_spriteCache[url] = sprite;
|
||||||
cache[url] = sprite;
|
|
||||||
callback?.Invoke(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>
|
/// <summary>
|
||||||
/// Simulator scene — configure and run multi-player simulations,
|
/// Simulator scene — configure and run multi-player simulations,
|
||||||
/// poll status, display results per deck.
|
/// poll status, display results per deck.
|
||||||
|
/// Also provides a Play Live button that routes to the Battleground scene.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SimulationController : MonoBehaviour
|
public class SimulationController : MonoBehaviour
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,8 @@ namespace CommanderAILab.UI
|
|||||||
[Header("Navigation")]
|
[Header("Navigation")]
|
||||||
[SerializeField] private Button backButton;
|
[SerializeField] private Button backButton;
|
||||||
[SerializeField] private Button startSimButton;
|
[SerializeField] private Button startSimButton;
|
||||||
|
/// <summary>Routes to the Battleground scene for a live 4-player game.</summary>
|
||||||
|
[SerializeField] private Button playLiveButton;
|
||||||
|
|
||||||
// ── Config Panel ──────────────────────────────────────────────────────
|
// ── Config Panel ──────────────────────────────────────────────────────
|
||||||
[Header("Config")]
|
[Header("Config")]
|
||||||
@@ -74,6 +77,9 @@ namespace CommanderAILab.UI
|
|||||||
startSimButton.onClick.AddListener(OnStartSim);
|
startSimButton.onClick.AddListener(OnStartSim);
|
||||||
retryButton.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.minValue = 2;
|
||||||
numPlayersSlider.maxValue = 4;
|
numPlayersSlider.maxValue = 4;
|
||||||
numPlayersSlider.wholeNumbers = true;
|
numPlayersSlider.wholeNumbers = true;
|
||||||
@@ -83,12 +89,10 @@ namespace CommanderAILab.UI
|
|||||||
|
|
||||||
errorPanel.SetActive(false);
|
errorPanel.SetActive(false);
|
||||||
|
|
||||||
// Check for a deck exported from DeckBuilder
|
|
||||||
string simDeck = PlayerPrefs.GetString("SimDeck", "");
|
string simDeck = PlayerPrefs.GetString("SimDeck", "");
|
||||||
if (!string.IsNullOrEmpty(simDeck))
|
if (!string.IsNullOrEmpty(simDeck))
|
||||||
{
|
{
|
||||||
PlayerPrefs.DeleteKey("SimDeck");
|
PlayerPrefs.DeleteKey("SimDeck");
|
||||||
// Pre-populate deck dropdown 0 after decks load — stored for later
|
|
||||||
PlayerPrefs.SetString("_SimDeckPending", simDeck);
|
PlayerPrefs.SetString("_SimDeckPending", simDeck);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +100,29 @@ namespace CommanderAILab.UI
|
|||||||
LoadAiProfiles();
|
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 ──────────────────────────────────────────────────────
|
// ── Player Count ──────────────────────────────────────────────────────
|
||||||
private void OnNumPlayersChanged(float value)
|
private void OnNumPlayersChanged(float value)
|
||||||
{
|
{
|
||||||
@@ -103,7 +130,6 @@ namespace CommanderAILab.UI
|
|||||||
if (numPlayersLabel != null)
|
if (numPlayersLabel != null)
|
||||||
numPlayersLabel.text = $"Players: {count}";
|
numPlayersLabel.text = $"Players: {count}";
|
||||||
|
|
||||||
// Show/hide optional deck dropdowns based on count
|
|
||||||
if (deckDropdown2 != null) deckDropdown2.gameObject.SetActive(count >= 3);
|
if (deckDropdown2 != null) deckDropdown2.gameObject.SetActive(count >= 3);
|
||||||
if (deckDropdown3 != null) deckDropdown3.gameObject.SetActive(count >= 4);
|
if (deckDropdown3 != null) deckDropdown3.gameObject.SetActive(count >= 4);
|
||||||
}
|
}
|
||||||
@@ -143,14 +169,12 @@ namespace CommanderAILab.UI
|
|||||||
Fill(deckDropdown2);
|
Fill(deckDropdown2);
|
||||||
Fill(deckDropdown3);
|
Fill(deckDropdown3);
|
||||||
|
|
||||||
// Apply any pending deck from DeckBuilder export
|
|
||||||
string pending = PlayerPrefs.GetString("_SimDeckPending", "");
|
string pending = PlayerPrefs.GetString("_SimDeckPending", "");
|
||||||
if (!string.IsNullOrEmpty(pending))
|
if (!string.IsNullOrEmpty(pending))
|
||||||
{
|
{
|
||||||
PlayerPrefs.DeleteKey("_SimDeckPending");
|
PlayerPrefs.DeleteKey("_SimDeckPending");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Try to match by name from the JSON
|
|
||||||
var exported = JsonConvert.DeserializeObject<LabDeckEntry>(pending);
|
var exported = JsonConvert.DeserializeObject<LabDeckEntry>(pending);
|
||||||
if (exported != null && !string.IsNullOrEmpty(exported.name))
|
if (exported != null && !string.IsNullOrEmpty(exported.name))
|
||||||
{
|
{
|
||||||
@@ -179,7 +203,6 @@ namespace CommanderAILab.UI
|
|||||||
},
|
},
|
||||||
err =>
|
err =>
|
||||||
{
|
{
|
||||||
// Non-fatal: profiles may not be available yet
|
|
||||||
_profiles = new List<AiProfile>();
|
_profiles = new List<AiProfile>();
|
||||||
PopulateProfileDropdown();
|
PopulateProfileDropdown();
|
||||||
});
|
});
|
||||||
@@ -203,7 +226,6 @@ namespace CommanderAILab.UI
|
|||||||
|
|
||||||
int numPlayers = Mathf.RoundToInt(numPlayersSlider.value);
|
int numPlayers = Mathf.RoundToInt(numPlayersSlider.value);
|
||||||
|
|
||||||
// Collect selected deck names (index 0 = "-- Select Deck --")
|
|
||||||
var selectedDecks = new List<string>();
|
var selectedDecks = new List<string>();
|
||||||
void TryAdd(TMP_Dropdown dd)
|
void TryAdd(TMP_Dropdown dd)
|
||||||
{
|
{
|
||||||
@@ -292,7 +314,6 @@ namespace CommanderAILab.UI
|
|||||||
|
|
||||||
bool done = false;
|
bool done = false;
|
||||||
string errorMsg = null;
|
string errorMsg = null;
|
||||||
SimStatusModel latestStatus = null;
|
|
||||||
|
|
||||||
_api.GetSimStatus(batchId,
|
_api.GetSimStatus(batchId,
|
||||||
json =>
|
json =>
|
||||||
@@ -301,7 +322,6 @@ namespace CommanderAILab.UI
|
|||||||
{
|
{
|
||||||
var status = JsonConvert.DeserializeObject<SimStatusModel>(json);
|
var status = JsonConvert.DeserializeObject<SimStatusModel>(json);
|
||||||
if (status == null) { done = true; errorMsg = "Empty status response."; return; }
|
if (status == null) { done = true; errorMsg = "Empty status response."; return; }
|
||||||
latestStatus = status;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(status.error))
|
if (!string.IsNullOrEmpty(status.error))
|
||||||
{
|
{
|
||||||
@@ -332,7 +352,6 @@ namespace CommanderAILab.UI
|
|||||||
},
|
},
|
||||||
err => { done = true; errorMsg = err; });
|
err => { done = true; errorMsg = err; });
|
||||||
|
|
||||||
// Wait one frame so callbacks have fired
|
|
||||||
yield return null;
|
yield return null;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(errorMsg))
|
if (!string.IsNullOrEmpty(errorMsg))
|
||||||
@@ -424,7 +443,7 @@ namespace CommanderAILab.UI
|
|||||||
errorText.text = msg;
|
errorText.text = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper Models (private inner classes) ─────────────────────────────
|
// ── Helper Models ─────────────────────────────────────────────────────
|
||||||
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
||||||
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
||||||
[Serializable] private class ProfilesWrapper { public List<AiProfile> profiles; }
|
[Serializable] private class ProfilesWrapper { public List<AiProfile> profiles; }
|
||||||
@@ -435,6 +454,13 @@ namespace CommanderAILab.UI
|
|||||||
public bool useLearnedPolicy; public string policyStyle;
|
public bool useLearnedPolicy; public string policyStyle;
|
||||||
public int seed; public int clock;
|
public int seed; public int clock;
|
||||||
}
|
}
|
||||||
|
[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 SimResult { public Summary summary; }
|
||||||
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
||||||
[Serializable] private class ResultEntry { public string deckName; public float winRate; public int wins; public int losses; }
|
[Serializable] private class ResultEntry { public string deckName; public float winRate; public int wins; public int losses; }
|
||||||
|
|||||||
Reference in new issue
Block a user