champions-of-luneraria/Assets/Luneraria/UI/Model/LuneLaneThreat.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

192 lines
8.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using Luneraria.Core;
namespace Luneraria.UI
{
/// <summary>
/// Deterministic lane threat for one team (port of scripts/luneraria/ui/lune_lane_threat.gd),
/// read only from public state: building HP, the units on the battlefield (positions and HP,
/// which every client also sees) and the public announcements of this round. It never reads
/// drafts, gold, unannounced bookings or bot notes, so host and guest compute the same level.
/// Positions are projected onto the lane, because a network guest has positions but no lane
/// progress.
///
/// RUHIG nothing below applies
/// UMKÄMPFT a lane tower is below 85 %, an enemy attack champion is announced here, or
/// enemy units stand in the own half of the lane
/// UNTER DRUCK the Grenzwacht fell or is below 50 %, enemies attack the Grenzwacht, the enemy
/// has more than 1.5 times the own unit HP in the own half, or the enemy boss
/// is deployed on this lane
/// KRITISCH both lane towers fell (base open), the Bastion is below 50 % after the
/// Grenzwacht fell, enemies attack the Bastion, or the enemy boss comes while the
/// Grenzwacht is already down
/// </summary>
public static class LuneLaneThreat
{
public const int Calm = 0;
public const int Contested = 1;
public const int Pressure = 2;
public const int Critical = 3;
public static readonly string[] Names = { "RUHIG", "UMKÄMPFT", "UNTER DRUCK", "KRITISCH" };
public static readonly string[] Glyphs = { "check", "melee", "warning", "warning" };
public sealed class Result
{
public int Level;
public string Name = "";
public readonly List<string> Reasons = new List<string>();
}
public sealed class Battle
{
public double Length;
public int OwnCount;
public int EnemyCount;
public double OwnHp;
public double EnemyHp;
/// <summary>Deepest enemy position measured from the own base along the lane (infinity without enemies).</summary>
public double EnemyDepth = double.PositiveInfinity;
public double OwnFront;
}
static void Building(LuneMatch state, string id, out bool alive, out double fraction)
{
double maxHp = state.Map.BuildingDef(id)?.MaxHp ?? 1.0;
if (state.Phase == Phase.Combat && state.Combat != null && state.Combat.Buildings.TryGetValue(id, out LuneUnit unit))
{
alive = unit.Alive;
fraction = alive ? GdMath.Clamp(unit.Hp / GdMath.Max(1.0, maxHp), 0.0, 1.0) : 0.0;
return;
}
double hp = state.BuildingHp.GetValueOrDefault(id, 0.0);
alive = hp > 0.0;
fraction = GdMath.Clamp(hp / GdMath.Max(1.0, maxHp), 0.0, 1.0);
}
static string Percent(double value)
{
return LuneUiTheme.Round(value * 100.0) + " %";
}
public static Result Evaluate(LuneMatch state, int team, string lane)
{
var result = new Result();
int level = Calm;
string prefix = team == 0 ? "blau" : "rot";
int enemy = 1 - team;
Building(state, prefix + "_" + lane + "_outer", out bool outerAlive, out double outer);
Building(state, prefix + "_" + lane + "_inner", out bool innerAlive, out double inner);
if (!outerAlive && !innerAlive)
{
level = Critical;
result.Reasons.Add("Grenzwacht und Bastion gefallen – der Weg zur Basis ist offen");
}
else if (!outerAlive)
{
level = Pressure;
result.Reasons.Add("Grenzwacht gefallen");
if (inner < .5)
{
level = Critical;
result.Reasons.Add("Bastion bei " + Percent(inner));
}
else if (inner < .85)
{
result.Reasons.Add("Bastion bei " + Percent(inner));
}
}
else if (outer < .5)
{
level = Pressure;
result.Reasons.Add("Grenzwacht bei " + Percent(outer));
}
else if (outer < .85)
{
level = Contested;
result.Reasons.Add("Grenzwacht bei " + Percent(outer));
}
foreach (Announcement entry in state.Announcements())
{
if (entry.Team != enemy || entry.Lane != lane)
continue;
if (entry.Kind == "boss")
{
level = System.Math.Max(level, !outerAlive ? Critical : Pressure);
result.Reasons.Add("Gegnerische Schlundkrone greift hier an");
}
else
{
level = System.Math.Max(level, Contested);
result.Reasons.Add("Angriffschampion " + LuneContent.Champions[entry.ChampionId].Name + " angekündigt");
}
}
if (state.Phase == Phase.Combat && state.Combat != null)
{
Battle battle = Battlefield(state, team, lane);
double length = battle.Length;
if (battle.EnemyCount > 0)
{
double deepest = battle.EnemyDepth;
double innerAt = length * state.Balance.InnerTowerFraction;
double outerAt = length * state.Balance.OuterTowerFraction;
double reach = state.Balance.TowerRange * .8;
if (deepest <= innerAt + reach)
{
level = Critical;
result.Reasons.Add("Gegner greifen die Bastion oder die Basis an");
}
else if (deepest <= outerAt + reach && outerAlive)
{
level = System.Math.Max(level, Pressure);
result.Reasons.Add("Gegner greifen die Grenzwacht an");
}
else if (deepest <= length * .5)
{
level = System.Math.Max(level, Contested);
result.Reasons.Add("Gegner in der eigenen Lanehälfte");
}
if (battle.EnemyHp > battle.OwnHp * 1.5 && deepest <= length * .5)
{
level = System.Math.Max(level, Pressure);
result.Reasons.Add(string.Format("Übermacht: {0} gegen {1} Einheiten", battle.EnemyCount, battle.OwnCount));
}
}
}
result.Level = level;
result.Name = Names[level];
return result;
}
/// <summary>
/// Units of both sides on one lane: counts, HP sums and the deepest enemy position measured
/// from the own base along the lane (from positions only).
/// </summary>
public static Battle Battlefield(LuneMatch state, int team, string lane)
{
LanePath path = state.Map.LanePaths[lane];
var result = new Battle { Length = path.Length };
if (state.Combat == null)
return result;
foreach (LuneUnit unit in state.Combat.Units)
{
if (!unit.Alive || !unit.IsMobile() || unit.Zone != "lane" || unit.Lane != lane || unit.Team > 1)
continue;
double s = path.Project(unit.Position);
double fromOwnBase = team == 0 ? s : path.Length - s;
if (unit.Team == team)
{
result.OwnCount++;
result.OwnHp += unit.Hp;
result.OwnFront = GdMath.Max(result.OwnFront, fromOwnBase);
}
else
{
result.EnemyCount++;
result.EnemyHp += unit.Hp;
result.EnemyDepth = GdMath.Min(result.EnemyDepth, fromOwnBase);
}
}
return result;
}
}
}