Updating HUD, Gold Config, and finishing off Play flow for 9player map.

This commit is contained in:
Matt F 2026-05-22 12:18:23 -07:00
parent a7be12fa9b
commit 3dcc0e7edd
28 changed files with 2272 additions and 9601 deletions

View file

@ -59,6 +59,10 @@ namespace TD.UI
private Label goldLabel;
private Label waveLabel;
private Label livesLabel;
private Label nextWaveLabel; // prep countdown ("next: 0:12")
private Label leakedLabel; // local player's origin-leak count ("leaked: 3")
private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave")
private VisualElement playerListContainer; // right-panel scoreboard rows
private Label portraitName;
private Label levelLabel;
private VisualElement statLines;
@ -236,6 +240,10 @@ namespace TD.UI
goldLabel = Require<Label>(root, "gold-label");
waveLabel = Require<Label>(root, "wave-label");
livesLabel = Require<Label>(root, "lives-label");
nextWaveLabel = Require<Label>(root, "next-wave-label");
leakedLabel = Require<Label>(root, "leaked-label");
incomeLabel = Require<Label>(root, "income-label");
playerListContainer = Require<VisualElement>(root, "player-list");
portraitName = Require<Label>(root, "portrait-name");
levelLabel = Require<Label>(root, "level-label");
statLines = Require<VisualElement>(root, "stat-lines");
@ -438,17 +446,195 @@ namespace TD.UI
private void RefreshMatchStateDisplays()
{
var ms = MatchState.Instance;
var wm = WaveManager.Instance;
if (livesLabel != null)
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
if (waveLabel != null)
{
int total = WaveManager.Instance?.TotalWaves ?? 0;
int total = wm?.TotalWaves ?? 0;
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
? $"Wave {ms.CurrentWave} / {total}"
: "Wave --";
}
// Next-wave countdown. Shows during prep ("next: 0:12") and clears the
// moment the wave actually starts spawning. WaveManager.PrepCountdown
// is networked so this reads the same value on every peer.
if (nextWaveLabel != null)
{
float t = wm != null ? wm.PrepCountdown : 0f;
if (t > 0f)
{
// Ceiling so the user sees a full "0:01" tick before "0:00".
int seconds = Mathf.CeilToInt(t);
int mm = seconds / 60;
int ss = seconds % 60;
nextWaveLabel.text = $"next: {mm}:{ss:00}";
}
else
{
nextWaveLabel.text = "next: --:--";
}
}
// Top-bar "earned this wave" counter. Reads the LOCAL player's
// PlayerGoldManager.GoldEarnedThisWave — which the server resets to 0 at
// wave start and increments via AwardGold on every kill / completion /
// no-leak bonus. Spending doesn't decrement it.
if (incomeLabel != null)
{
var localGold = PlayerGoldManager.Local;
int earned = localGold != null ? localGold.GoldEarnedThisWave : 0;
incomeLabel.text = $"+{earned} g/wave";
}
// Local player's origin-leak count: how many enemies that spawned in MY
// zone escaped my maze. Resolves the local PlayerMatchState's slot then
// reads the per-slot counter from WaveManager (replicated NetworkList).
if (leakedLabel != null)
{
var local = PlayerMatchState.Local;
int leaks = 0;
if (wm != null && local != null && local.Slot != PlayerSlot.None)
leaks = wm.GetZoneLeakCount(local.Slot);
leakedLabel.text = $"leaked: {leaks}";
}
// Right-panel scoreboard rebuild — see RefreshScoreboard.
RefreshScoreboard();
}
// ----- Scoreboard --------------------------------------------------
// Snapshot of last-rebuilt scoreboard state so we only rebuild when something
// changes. Without this we'd destroy and recreate every row every frame —
// wasteful and (in line with LobbyController's player-list pattern) would
// also break any per-row pointer interaction we might add later.
private string lastScoreboardSignature = string.Empty;
private void RefreshScoreboard()
{
if (playerListContainer == null) return;
// Sort by slot for stable ordering. Counter-intuitively the underlying
// collection isn't slot-ordered (it's keyed by NGO clientId).
var players = new System.Collections.Generic.List<PlayerMatchState>();
foreach (var pms in PlayerMatchState.AllPlayers) players.Add(pms);
players.Sort((a, b) => ((int)a.Slot).CompareTo((int)b.Slot));
// Build a signature of everything the rendered rows depend on. Skip the
// rebuild when nothing has changed.
string sig = ComputeScoreboardSignature(players);
if (sig == lastScoreboardSignature) return;
lastScoreboardSignature = sig;
playerListContainer.Clear();
if (players.Count == 0)
{
var emptyLabel = new Label("(no players)");
emptyLabel.style.color = new Color(0.6f, 0.6f, 0.6f);
emptyLabel.style.unityFontStyleAndWeight = FontStyle.Italic;
playerListContainer.Add(emptyLabel);
return;
}
var wm = WaveManager.Instance;
foreach (var pms in players)
{
playerListContainer.Add(BuildScoreboardRow(pms, wm));
}
}
private VisualElement BuildScoreboardRow(PlayerMatchState pms, WaveManager wm)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.marginBottom = 2;
row.style.paddingLeft = 4;
row.style.paddingRight = 4;
// Layout model: three columns with explicit widths/flex so all three are
// guaranteed visible inside the right panel. Name has flexGrow:1 so it
// takes leftover space; gold and leaks have fixed widths and right-align
// their text so the numbers line up vertically across rows. The previous
// layout used flexGrow on name with justify-content space-between, which
// pushed the leaks column off the panel's right edge on narrow widths.
string name = string.IsNullOrEmpty(pms.DisplayName)
? $"P{(int)pms.Slot}"
: pms.DisplayName;
var nameLabel = new Label(name);
// Tint with the canonical player-slot color — same palette used by
// builders, minimap icons, and zone outlines for consistent identity.
// Colors were tuned for gizmos on Unity's gray scene view; they're still
// legible on the dark panel for all slots except P9 (dark gray), which is
// intentionally subdued relative to the others.
nameLabel.style.color = PlayerColors.Get(pms.Slot);
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
nameLabel.style.fontSize = 10;
nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line
nameLabel.style.flexGrow = 1; // takes all leftover width
nameLabel.style.flexShrink = 1;
nameLabel.style.overflow = Overflow.Hidden;
nameLabel.style.textOverflow = TextOverflow.Ellipsis;
row.Add(nameLabel);
// Gold column. Read from PlayerGoldManager by clientId. May be null briefly
// during spawn races; render "--" in that case rather than crashing.
// Width 46px: right-aligned numbers; fits 4-digit gold totals at 10px font.
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
string goldText = gm != null ? $"{gm.CurrentGold}" : "--";
var goldLabel = new Label(goldText);
goldLabel.style.color = new Color(1f, 0.85f, 0.35f); // gold-y
goldLabel.style.fontSize = 10;
goldLabel.style.width = 46;
goldLabel.style.flexShrink = 0;
goldLabel.style.unityTextAlign = TextAnchor.MiddleRight;
row.Add(goldLabel);
// Leaks column. Same NetworkList the local player's "leaked: N" top-bar
// label reads — keeps the two views in sync. Always rendered (including 0)
// so designers can see at a glance whether a player has clean runs so far.
// Width 30px: right-aligned; 2-digit leak counts fit comfortably at 10px font.
int leaks = (wm != null && pms.Slot != PlayerSlot.None)
? wm.GetZoneLeakCount(pms.Slot)
: 0;
var leaksLabel = new Label($"{leaks}");
leaksLabel.style.color = leaks == 0
? new Color(0.55f, 0.85f, 0.55f)
: new Color(0.95f, 0.6f, 0.4f);
leaksLabel.style.fontSize = 10;
leaksLabel.style.width = 30;
leaksLabel.style.flexShrink = 0;
leaksLabel.style.unityTextAlign = TextAnchor.MiddleRight;
row.Add(leaksLabel);
return row;
}
// Components: ordered slot sequence + their name / gold / leaks. Any change
// triggers a rebuild. Slot count itself rarely changes mid-match (joins are
// gated by the lobby), but the values do.
private static readonly System.Text.StringBuilder s_scoreboardSigBuf =
new System.Text.StringBuilder(64);
private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players)
{
s_scoreboardSigBuf.Clear();
var wm = WaveManager.Instance;
foreach (var pms in players)
{
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
int gold = gm != null ? gm.CurrentGold : 0;
int leaks = (wm != null && pms.Slot != PlayerSlot.None)
? wm.GetZoneLeakCount(pms.Slot) : 0;
s_scoreboardSigBuf.Append((int)pms.Slot).Append(':')
.Append(pms.DisplayName ?? string.Empty).Append(':')
.Append(gold).Append(':')
.Append(leaks).Append(';');
}
return s_scoreboardSigBuf.ToString();
}
// ----- Command grid -----------------------------------------------
@ -807,7 +993,13 @@ namespace TD.UI
if (def != null)
{
AddStatLine($"Speed: {def.MoveSpeed:0.0}");
AddStatLine($"Bounty: {def.GoldReward} g");
// Bounty is per-wave now (GoldConfig.Waves[N].GoldPerEnemy) rather than
// per-enemy-type. Read the current wave's value so the tooltip is accurate.
var wm = WaveManager.Instance;
int currentWave = MatchState.Instance != null ? MatchState.Instance.CurrentWave : 0;
var goldEntry = wm?.GoldConfig?.GetWaveEntry(currentWave);
if (goldEntry != null)
AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} g");
// (Weaknesses/resistances will go here once the resistance system lands.)
}
}