Files
KoalaTrapLord 3f5d6d12e8 feat: 4-player Commander Battleground UI (issues #138–#151)
* chore: add battleground UI task tracker (2026-03-28)

* feat(#142): add [NonSerialized] battleground runtime state to CardModel

* feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache

* feat(#148): add GameStateManager singleton and GameState/SeatState POCOs

* feat(#138): add BattlegroundController scene orchestrator

* feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter

* feat(#140): add PhaseTrackerWidget and PhaseStepButton

* feat(#141): add TurnOrderBar and TurnOrderPill

* feat(#142): add CardZone and CardView battlefield zone components

* feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display

* feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView

* chore: mark issues 1-8 as complete in task tracker

* feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay

* feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController

* feat(#151): add Play Live button to SimulationController

* feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch

* fix: add PostGameAction to ApiClient + update TODO.md all issues complete

- ApiClient.cs: add PostGameAction() wrapper → POST /api/game/action
  (fixes compile error in HumanActionBar.cs)
- TODO.md: mark issues #145, #146, #147, #149, #150, #151 as ✅
  all 14 battleground issues now committed

* feat(game): add routes/game.py + wire lab_api + fix GameWebSocketClient game_id URL

- routes/game.py: POST /api/game/start, POST /api/game/action (new)
- lab_api.py: import + register game_router
- GameWebSocketClient.cs: read game_id from PlayerPrefs, build
  ws://{host}/ws/game/{game_id} — fixes URL mismatch with ws_game.py

* fix(lobby): wire OnConfirm to POST /api/game/start, save game_id to PlayerPrefs

- OnConfirm() now calls ApiClient.PostGameStart() with 4-seat config
- On success: saves BattleSeats + GameId + WsUrl to PlayerPrefs,
  loads Battleground scene
- On failure: shows inline error label (red), re-enables Confirm button
- Added StartGameRequest/Response POCOs matching routes/game.py schema
- Added _errorLabel [SerializeField] for user-facing error feedback
2026-03-29 05:35:29 -04:00

250 lines
9.3 KiB
C#

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"
};
}
}