champions-of-luneraria/Assets/Luneraria/UI/Model/LuneHudModel.cs
Martin Stickelbroeck 4dee9b18d2 U3A HUD-Grundlagen: UI Toolkit, Sichtmodell, menschlicher Sitz
Beginn des Ports des G3-HUD (Referenz luneraria/g3, 1b540f2).

- Entscheidung UI Toolkit statt uGUI nach kurzer Bewertung und einer Probe:
  Panel rendert im Batchmodus in eine 1920x1080-Textur (Skalierung wie Godot
  canvas_items/expand, 1600x960), gefaste Verlaufsplatten, alle 50 Symbole,
  Schriften aus den variablen Fonts der Referenz.
- Sichtmodell portiert (LuneHudModel, LuneLaneThreat, LuneStats) mit den
  Privatsphäre-Regeln der Referenz: Gold, Entwurf, Rückgängig und neue
  Buchungen nur für den eigenen Sitz; Lanes, Gebäude, Boss und Wellen aus
  öffentlichem Zustand.
- Menschlicher Sitz in LuneMatchRunner/LuneMatchDriver: Bots planen die
  übrigen fünf Sitze, die Planung wartet auf Bereit; Tempo 1x/2x/4x und Pause
  nur im Kampf. Bot-Partien unverändert.
- Theme-Grundlagen: Farben und Zahlenformate, Platten (forged_style.gd),
  Vektorsymbole (lune_glyphs.gd) mit Painter2D, statische SDF-Atlanten aus
  den benannten Instanzen von Cantarell und Cinzel (OFL).
- Tests: EditMode 23/23 (12 neue: gleiches öffentliches Bild für alle Sitze,
  fremde Entwürfe privat, private Boss-Lane-Wahl, Lane-Bedrohung, Bosszustände,
  nächste Welle, Bereit-Sperre, Formate, menschlicher Sitz); PlayMode 3/3,
  Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).
- Dokumentation: U3-UI (Bewertung, U3A), U5-VISUAL (Vorgaben des
  Projektinhabers: stilisierter Realismus für die ganze Welt, erst in U5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:57:45 +02:00

567 lines
25 KiB
C#

using System.Collections.Generic;
using System.Globalization;
using System.Text;
using Luneraria.Core;
namespace Luneraria.UI
{
/// <summary>Network state the HUD shows (role, connection); implemented by the session in U4.</summary>
public interface ILuneHudSession
{
bool IsNetworked { get; }
bool IsAuthority { get; }
}
/// <summary>
/// What one seat may see, prepared for display (port of scripts/luneraria/ui/lune_hud_model.gd).
/// Every HUD element reads through this model, so the privacy rules live in one place:
/// - gold, draft, undo and uncommitted bookings exist only for the viewer's own seat;
/// - of other players the model exposes names, lanes, readiness, human/bot/offline and the
/// committed hero (public since the last combat), never their gold or current plans;
/// - lanes, buildings, the boss and the waves come from public state only.
/// The model never changes the match; commands still go through the game controller.
/// </summary>
public sealed class LuneHudModel
{
public static readonly Dictionary<string, string> TowerNames = new Dictionary<string, string>
{ { "outer", "Grenzwacht" }, { "inner", "Bastion" }, { "base", "Kernwacht" }, { "core", "Ancient" } };
public static readonly Dictionary<string, string> TierText = new Dictionary<string, string>
{ { "outer", "Außenturm" }, { "inner", "Innenturm" }, { "base", "Basisturm" }, { "core", "Ancient" } };
public readonly LuneMatch State;
public readonly int Slot;
/// <summary>Optional network session; only read for the display (role, connection).</summary>
public ILuneHudSession Session;
readonly HashSet<int> _wasHuman = new HashSet<int>();
public LuneHudModel(LuneMatch state, int viewer, ILuneHudSession session = null)
{
State = state;
Slot = System.Math.Max(0, viewer);
Session = session;
}
// ---------------------------------------------------------------- phase and own seat
public bool Planning { get { return State.Phase == Phase.Planning; } }
public bool Fighting { get { return State.Phase == Phase.Combat && State.Combat != null; } }
public bool Over { get { return State.Phase == Phase.Over; } }
public Player Me { get { return State.Players[Slot]; } }
public int Team { get { return Me.Team; } }
public int Enemy { get { return 1 - Team; } }
public int OwnGold { get { return State.GoldOf(Slot); } }
/// <summary>Base income paid at the end of the current round's combat (rules: LuneBalance.RoundIncome).</summary>
public int Income { get { return State.Balance.RoundIncome(State.RoundNumber); } }
/// <summary>After Ready the plan is binding (the host refuses later commands).</summary>
public bool Locked { get { return Planning && Me.Ready; } }
public List<RosterEntry> OwnRoster { get { return Planning ? Me.Draft.Roster : Me.Roster; } }
/// <summary>The own hero: the draft in planning, the committed hero otherwise; null without a hero.</summary>
public HeroState OwnHero { get { return Planning ? Me.Draft.Hero : Me.Hero; } }
public bool CanUndo { get { return Planning && !Locked && Me.Undo.Count > 0; } }
public int SupplyCap { get { return State.Balance.SupplyCap; } }
public bool Networked { get { return Session != null && Session.IsNetworked; } }
public bool IsGuest { get { return Networked && !Session.IsAuthority; } }
public sealed class BookingView
{
public string Uid = "";
public string ChampionId = "";
public string Lane = "";
public int DeployRound;
public int Cost;
/// <summary>Booked in this planning (still refundable).</summary>
public bool Fresh;
public override string ToString() { return Uid + ":" + ChampionId + ":" + Lane + ":" + DeployRound + ":" + Fresh; }
}
public List<BookingView> OwnBookings()
{
var result = new List<BookingView>();
if (Planning)
{
foreach (Booking booking in Me.Draft.NewBookings)
result.Add(new BookingView { Uid = booking.Uid, ChampionId = booking.ChampionId, Lane = booking.Lane, Cost = booking.Cost, DeployRound = State.RoundNumber + 1, Fresh = true });
}
foreach (Booking booking in Me.Bookings)
{
if (booking.DeployRound >= State.RoundNumber)
result.Add(new BookingView { Uid = booking.Uid, ChampionId = booking.ChampionId, Lane = booking.Lane, Cost = booking.Cost, DeployRound = booking.DeployRound });
}
return result;
}
// ---------------------------------------------------------------- units and heroes
public string RoleOf(string typeId) { return State.RoleOf(typeId); }
public sealed class TroopInfo
{
public UnitDef Def;
public string Role = "";
public int Stage;
}
/// <summary>Catalogue definition of a troop with its role and stage.</summary>
public TroopInfo Troop(string typeId)
{
return new TroopInfo { Def = DataCatalog.GetUnit(typeId), Role = RoleOf(typeId), Stage = LuneStats.StageOf(typeId) };
}
/// <summary>Live combat unit for a persistent uid of the viewer (or null).</summary>
public LuneUnit LiveUnit(string uid)
{
if (!Fighting || string.IsNullOrEmpty(uid))
return null;
foreach (LuneUnit unit in State.Combat.Units)
{
if (unit.PersistentUid == uid)
return unit;
}
return null;
}
public UnitDef HeroDefinition(HeroState hero)
{
UnitDef definition = LuneContent.HeroDefinition(hero);
definition.Role = ContentData.HeroRoles.GetValueOrDefault(hero.HeroId, "front");
return definition;
}
// ---------------------------------------------------------------- buildings
public sealed class BuildingInfo
{
public string Id = "";
public int Team;
public string Tier = "outer";
public string Lane = "";
public bool Alive;
public double Hp;
public double MaxHp = 1.0;
public double Fraction;
public bool Attackable;
public BuildingInfo[] Base;
public override string ToString()
{
return Id + ":" + Alive + ":" + LuneUiTheme.Round(Hp) + ":" + Attackable;
}
}
public BuildingInfo Building(string id)
{
BuildingDef definition = State.Map.BuildingDef(id);
double maxHp = definition != null ? definition.MaxHp : 1.0;
double hp = State.BuildingHp.GetValueOrDefault(id, 0.0);
bool alive = hp > 0.0;
if (Fighting && State.Combat.Buildings.TryGetValue(id, out LuneUnit unit))
{
alive = unit.Alive;
hp = alive ? unit.Hp : 0.0;
}
return new BuildingInfo
{
Id = id, Team = definition != null ? definition.Team : 0, Tier = definition != null ? definition.Tier : "outer", Lane = definition != null ? definition.Lane : "",
Alive = alive, Hp = hp, MaxHp = maxHp, Fraction = GdMath.Clamp(hp / GdMath.Max(1.0, maxHp), 0.0, 1.0),
Attackable = alive && definition != null && LuneMap.IsAttackable(definition, AliveMap()),
};
}
Dictionary<string, bool> AliveMap()
{
if (Fighting)
return State.Combat.AliveMap();
var result = new Dictionary<string, bool>();
foreach (KeyValuePair<string, double> pair in State.BuildingHp)
result[pair.Key] = pair.Value > 0.0;
return result;
}
public static string BuildingTitle(string id)
{
string[] parts = id.Split('_');
if (parts[1] == "ancient")
return "Ancient";
if (parts[1] == "base")
return "Kernwacht";
return TowerNames.TryGetValue(parts[2], out string name) ? name : id;
}
public static string BuildingPlace(string id)
{
string[] parts = id.Split('_');
string team = parts[0] == "blau" ? "Blau" : "Rot";
if (parts[1] == "ancient")
return "Ancient " + team;
if (parts[1] == "base")
return string.Format("Basis {0} · Kernwacht {1}", team, parts[2]);
return string.Format("{0} · {1} {2}", LuneMap.LaneNames[parts[1]].ToUpperInvariant(), BuildingTitle(id), team);
}
/// <summary>A team's Ancient with its two base towers (Kernwacht) in Base.</summary>
public BuildingInfo Ancient(int team)
{
string prefix = team == 0 ? "blau" : "rot";
BuildingInfo core = Building(prefix + "_ancient");
core.Base = new[] { Building(prefix + "_base_1"), Building(prefix + "_base_2") };
return core;
}
// ---------------------------------------------------------------- players
public string PlayerName(int seat)
{
return seat == Slot ? "Du" : State.Players[seat].Name;
}
/// <summary>Seat status for display: self, human, bot, or offline (was human, a bot plays the seat).</summary>
public string StatusOf(int seat)
{
Player player = State.Players[seat];
if (player.IsHuman)
_wasHuman.Add(seat);
if (seat == Slot)
return "self";
if (player.IsHuman)
return "human";
return _wasHuman.Contains(seat) ? "offline" : "bot";
}
public sealed class MemberInfo
{
public int Slot;
public string Name = "";
public string Lane = "";
public bool IsSelf;
public bool Ready;
public string Status = "";
public string Bund = "";
public string HeroId = "";
public int HeroLevel;
public override string ToString()
{
return Slot + ":" + Name + ":" + Lane + ":" + IsSelf + ":" + Ready + ":" + Status + ":" + Bund + ":" + HeroId + ":" + HeroLevel;
}
}
public List<MemberInfo> Members(int team)
{
var result = new List<MemberInfo>();
for (int seat = team * 3; seat < team * 3 + 3; seat++)
{
Player player = State.Players[seat];
// Own seat: current draft. Everyone else: the committed hero, public since the last combat.
HeroState hero = seat == Slot ? OwnHero : player.Hero;
result.Add(new MemberInfo
{
Slot = seat, Name = PlayerName(seat), Lane = player.Lane, IsSelf = seat == Slot, Ready = player.Ready, Status = StatusOf(seat), Bund = player.Bund,
HeroId = hero != null ? hero.HeroId : "", HeroLevel = hero != null ? LuneContent.LevelFor(hero.Xp) : 0,
});
}
return result;
}
/// <summary>Ready humans and humans in the match.</summary>
public void ReadyCount(out int ready, out int humans)
{
ready = 0;
humans = 0;
foreach (Player player in State.Players)
{
if (!player.IsHuman)
continue;
humans++;
if (player.Ready)
ready++;
}
}
// ---------------------------------------------------------------- lanes
public sealed class HeroBrief
{
public string Model = "";
public string Name = "";
public int Level;
public override string ToString() { return Model + ":" + Level; }
}
public sealed class LaneInfo
{
public string Lane = "";
public string Title = "";
public string OwnPlayer = "";
public string EnemyPlayer = "";
public bool OwnIsMe;
/// <summary>Own inner, own outer, enemy outer, enemy inner: the chain from the own base to the enemy's.</summary>
public BuildingInfo[] Towers;
public LuneLaneThreat.Result Threat;
public readonly List<Announcement> Dangers = new List<Announcement>();
public HeroBrief OwnHero;
public HeroBrief EnemyHero;
/// <summary>Unit counts exist only in combat.</summary>
public bool HasCounts;
public int OwnCount;
public int EnemyCount;
}
public LaneInfo Lane(string lane)
{
int ownSeat = State.SlotOf(Team, lane);
int enemySeat = State.SlotOf(Enemy, lane);
string own = Team == 0 ? "blau" : "rot";
string enemy = Team == 0 ? "rot" : "blau";
var result = new LaneInfo
{
Lane = lane, Title = LuneMap.LaneNames[lane].ToUpperInvariant(), OwnPlayer = PlayerName(ownSeat), EnemyPlayer = PlayerName(enemySeat), OwnIsMe = ownSeat == Slot,
Towers = new[] { Building(own + "_" + lane + "_inner"), Building(own + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_inner") },
Threat = LuneLaneThreat.Evaluate(State, Team, lane), OwnHero = HeroOf(ownSeat), EnemyHero = HeroOf(enemySeat),
};
foreach (Announcement entry in State.Announcements())
{
if (entry.Lane == lane)
result.Dangers.Add(entry);
}
if (Fighting)
{
LuneLaneThreat.Battle battle = LuneLaneThreat.Battlefield(State, Team, lane);
result.HasCounts = true;
result.OwnCount = battle.OwnCount;
result.EnemyCount = battle.EnemyCount;
}
return result;
}
/// <summary>A seat's hero for display: the own draft for the viewer, the committed (public) hero for everyone else; null without a hero.</summary>
public HeroBrief HeroOf(int seat)
{
HeroState hero = seat == Slot ? OwnHero : State.Players[seat].Hero;
if (hero == null)
return null;
HeroSpec spec = LuneContent.Heroes[hero.HeroId];
return new HeroBrief { Model = spec.Model, Name = spec.Name, Level = LuneContent.LevelFor(hero.Xp) };
}
// ---------------------------------------------------------------- boss grotto
public sealed class BossInfo
{
/// <summary>available, contested, conquered, failed, deploy_next, deploying, defeated or cooldown.</summary>
public string State = "cooldown";
public int Team = -1;
public string Lane = "";
public int DeployRound;
public int WonRound;
public int Bounty;
public int RoundsLeft;
public bool Own;
public bool Chooser;
public int NextRound;
public bool HasHp;
public double Hp;
public double MaxHp = 1.0;
/// <summary>The public part (without the viewer's own/chooser flags).</summary>
public string PublicText()
{
return string.Format(CultureInfo.InvariantCulture, "{0}:{1}:{2}:{3}:{4}:{5}:{6}:{7}:{8}", State, Team, Lane, DeployRound, WonRound, Bounty, RoundsLeft, NextRound,
HasHp ? LuneUiTheme.Round(Hp) + "/" + LuneUiTheme.Round(MaxHp) : "-");
}
public override string ToString() { return PublicText() + ":" + Own + ":" + Chooser; }
}
/// <summary>
/// Public boss state: available, contested, conquered, failed, deploy_next, deploying,
/// defeated, cooldown. On a guest the grotto fight is read from the mirrored units. The
/// chooser sees the lane of the own draft; everyone else the public claim lane.
/// </summary>
public BossInfo Boss()
{
return Boss(false);
}
BossInfo Boss(bool publicOnly)
{
BossClaim claim = State.Boss.Claim;
int round = State.RoundNumber;
var result = new BossInfo { RoundsLeft = System.Math.Max(0, State.Boss.NextAvailableRound - round), NextRound = State.Boss.NextAvailableRound };
if (Fighting)
{
foreach (LuneUnit unit in State.Combat.Units)
{
if (unit.Kind != "boss")
continue;
if (unit.Zone == "grotto")
{
if (unit.Alive)
{
result.State = "contested";
return result;
}
int winner = -1;
foreach (LuneUnit other in State.Combat.Units)
{
if (other.Alive && other.Zone == "grotto" && other.Team <= 1)
winner = other.Team;
}
result.State = winner >= 0 ? "conquered" : "failed";
result.Team = winner;
result.Own = winner == Team;
result.DeployRound = round + 1;
return result;
}
if (unit.Zone == "lane")
{
result.State = unit.Alive ? "deploying" : "defeated";
result.Team = unit.Team;
result.Own = unit.Team == Team;
result.Lane = unit.Lane;
result.DeployRound = round;
result.Bounty = LuneContent.BossBountyFor(round);
result.HasHp = true;
result.Hp = unit.Hp;
result.MaxHp = unit.MaxHp;
return result;
}
}
}
if (claim != null && claim.DeployRound >= round)
{
result.State = Planning ? "deploy_next" : "deploying";
result.Team = claim.Team;
result.Own = claim.Team == Team;
result.WonRound = claim.WonRound;
result.DeployRound = claim.DeployRound;
result.Bounty = LuneContent.BossBountyFor(claim.DeployRound);
result.Chooser = claim.ChooserSlot == Slot;
// The chooser's lane change is private until combat; others see the public claim lane.
string chosen = !publicOnly && result.Chooser && Planning ? Me.Draft.BossLane ?? "" : "";
result.Lane = chosen.Length > 0 ? chosen : claim.Lane;
return result;
}
if (State.GrottoAvailable())
result.State = "available";
return result;
}
// ---------------------------------------------------------------- waves
public sealed class MonsterGroup
{
public string Id = "";
public string Name = "";
public int Count;
public double Hp;
public double Damage;
public double Range;
public bool New;
public int Armor;
public int Attack;
public override string ToString() { return Id + "x" + Count + (New ? "+" : ""); }
}
public sealed class ChampionNotice
{
public int Team;
public string Lane = "";
public string ChampionId = "";
public string Name = "";
public bool Own;
public override string ToString() { return Team + ":" + Lane + ":" + ChampionId; }
}
public sealed class BossNotice
{
public int Team;
public string Lane = "";
public int Bounty;
public bool Own;
public override string ToString() { return Team + ":" + Lane + ":" + Bounty; }
}
public sealed class WaveInfo
{
public int Round;
public readonly List<MonsterGroup> Monsters = new List<MonsterGroup>();
public int Count;
public readonly List<ChampionNotice> Champions = new List<ChampionNotice>();
public BossNotice Boss;
public override string ToString()
{
return Round + "|" + string.Join(",", Monsters) + "|" + Count + "|" + string.Join(",", Champions) + "|" + (Boss != null ? Boss.ToString() : "-");
}
}
/// <summary>
/// The wave of the coming combat (planning) or the one after (combat). Monsters are public
/// and identical for both sides; champions and the boss only once they are announced.
/// </summary>
public WaveInfo NextWave()
{
int round = Fighting ? State.RoundNumber + 1 : State.RoundNumber;
List<UnitDef> wave = LuneWaves.Build(State.Balance, round);
List<string> before = round > 1 ? LuneWaves.UnlockedTypes(State.Balance, round - 1) : new List<string>();
var result = new WaveInfo { Round = round, Count = wave.Count };
var groups = new Dictionary<string, MonsterGroup>();
foreach (UnitDef monster in wave)
{
if (!groups.TryGetValue(monster.Id, out MonsterGroup group))
{
group = new MonsterGroup
{
Id = monster.Id, Name = monster.DisplayName, Hp = monster.MaxHp, Damage = monster.Damage, Range = monster.AttackRange,
New = round > 1 && !before.Contains(monster.Id), Armor = monster.ArmorType, Attack = monster.AttackType,
};
groups[monster.Id] = group;
result.Monsters.Add(group);
}
group.Count++;
}
if (!Fighting)
{
foreach (Announcement entry in State.Announcements())
{
if (entry.Kind == "boss")
result.Boss = new BossNotice { Team = entry.Team, Lane = entry.Lane, Bounty = entry.Bounty, Own = entry.Team == Team };
else
result.Champions.Add(new ChampionNotice { Team = entry.Team, Lane = entry.Lane, ChampionId = entry.ChampionId, Name = LuneContent.Champions[entry.ChampionId].Name, Own = entry.Team == Team });
}
}
return result;
}
// ---------------------------------------------------------------- network test support
/// <summary>
/// Everything the HUD shows that must be identical for every seat (host, guest, bot views),
/// as one string. The boss uses the public claim lane: the chooser's draft lane is private.
/// </summary>
public string PublicDigest()
{
var text = new StringBuilder();
text.Append("round ").Append(State.RoundNumber).Append(" phase ").Append(State.Phase).Append('\n');
foreach (BuildingDef definition in State.Map.Buildings)
{
BuildingInfo entry = Building(definition.Id);
text.Append(definition.Id).Append('=').Append(entry.Alive).Append('/').Append(LuneUiTheme.Round(entry.Hp)).Append(' ');
}
text.Append('\n');
foreach (string name in LuneMap.Lanes)
{
for (int side = 0; side < 2; side++)
{
LuneLaneThreat.Result threat = LuneLaneThreat.Evaluate(State, side, name);
text.Append(name).Append('/').Append(side).Append('=').Append(threat.Level).Append(' ').Append(string.Join(";", threat.Reasons)).Append('\n');
}
}
foreach (Player player in State.Players)
{
text.Append(player.Slot).Append(':').Append(player.Lane).Append(':').Append(player.Ready).Append(':').Append(player.IsHuman).Append(':')
.Append(player.Hero != null ? player.Hero.HeroId + "@" + player.Hero.Xp : "-").Append(' ');
}
text.Append('\n').Append("boss ").Append(Boss(true).PublicText()).Append('\n');
text.Append("wave ").Append(NextWave()).Append('\n');
return text.ToString();
}
}
}