champions-of-luneraria/Assets/Luneraria/UI/Hud/LuneHud.cs
Martin Stickelbroeck 8e4b52f4ab S1 Strategie-Regeln nach Legion TD 2, Dota 2 und League of Legends
Hinter MatchOptions.Extended (Standard im Spiel, Schalter im Startbildschirm):
Söldner-Einkommen (Angriffschampion +Preis/12 Einkommen dauerhaft), Kasernen
(gefallener innerer Turm: Gegnerwellen +25 % und ein Monster mehr), Erschöpfung
und Rückkauf (60 % Leben, Rückkauf 60 + 20 x Stufe), Mondsegen (Grottensieg
+4 % je Stapel, Mondseele bei 4). Bots wählen mit erweiterten Regeln auch
Sylvara und kaufen Helden zurück. Netz überträgt Regeln und neue Zustände.
HUD: Einkommen, Kasernen-Symbol, Rückkauf, Segen im Teamkopf.
Kern 531/531 (s1 31), EditMode 31/31, PlayMode 16/16, Verify 718/0,
Selbsttest 30/30, Golden 0 Abweichungen, Fingerabdruck unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:18:47 +02:00

1475 lines
66 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;
using System.Collections.Generic;
using System.Linq;
using Luneraria.Core;
using Luneraria.View;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// Champions of Luneraria in-match HUD (port of scripts/luneraria/ui/lune_hud.gd, G3).
/// Presentation only: it reads the match through LuneHudModel (the privacy filter) and raises
/// the human player's commands as events for the game controller; no UI state creates
/// ownership or changes a rule.
///
/// Layout in logical pixels (1706×960 at 16:9, UI scale applied on top):
/// top match status: own Ancient, WELLE n and phase, enemy Ancient; options and menu
/// left lanes (threat, towers, players, announced dangers), own team, event feed
/// bottom gold, unit cards or attack champions, own army, hero
/// right context only: boss grotto, next wave, the selected unit
/// corner Bereit (planning) or the combat clock above the minimap
/// Updates follow changes: every component compares a signature and rebuilds only when its
/// data changed; the HUD polls at 10 Hz in planning and 5 Hz in combat, the minimap at 10 Hz.
/// </summary>
public sealed class LuneHud : MonoBehaviour
{
public const float Margin = 12f;
public const float LeftWidth = 312f;
public const float RightWidth = 322f;
public const float MapEdge = 224f;
public const float ReadyHeight = 84f;
public const float ReadyCombat = 54f;
public const float BarPlanning = 176f;
public const float BarCombat = 128f;
/// <summary>Smallest logical area the layout is built for; the UI scale never shrinks below it.</summary>
public static readonly Vector2 MinArea = new Vector2(1560f, 880f);
/// <summary>Godot base size of the content scale (canvas_items, expand).</summary>
public static readonly Vector2Int Reference = new Vector2Int(1600, 960);
public static readonly Dictionary<string, string> LaneKeys = new Dictionary<string, string> { { "top", "F2" }, { "mid", "F3" }, { "bot", "F4" } };
const float PlanningRate = .1f;
const float CombatRate = .2f;
const float MapRate = .1f;
const float FeedRate = .25f;
public event Action<Command> CommandRequested;
public event Action UndoRequested;
public event Action ReadyRequested;
public event Action StarterRequested;
public event Action SpeedRequested;
public event Action PauseRequested;
public event Action<string> LaneFocusRequested;
public event Action<Vec2> MapFocusRequested;
public event Action<string> CameraRequested;
public event Action MenuRequested;
public event Action RematchRequested;
public event Action SettingsRequested;
public event Action HeroCommandRequested;
public LuneMatch Match { get; private set; }
public int HumanSlot { get; private set; } = 1;
public LuneHudModel Model { get; private set; }
public UIDocument Document { get; private set; }
public PanelSettings Panel { get; private set; }
public VisualElement Root { get; private set; }
public float UiScale { get; private set; } = 1f;
public LuneMinimap Minimap { get; private set; }
/// <summary>Name tags of heroes, champions and the boss in the field, behind every region.</summary>
public LuneFieldTags FieldTags { get; private set; }
public LuneTooltip Tooltip { get; private set; }
public LuneHeroCard HeroCard { get; private set; }
public LuneHeroDetail HeroDetail { get; private set; }
public LuneChampionPanel ChampionPanel { get; private set; }
public LuneBossIndicator BossCard { get; private set; }
public LuneNextWave NextWave { get; private set; }
public LuneUnitDetail UnitDetail { get; private set; }
public LuneEventFeed Feed { get; private set; }
public LuneSettingsPanel Settings { get; private set; }
public readonly Dictionary<string, LuneLaneIndicator> Lanes = new Dictionary<string, LuneLaneIndicator>();
public readonly Dictionary<string, LuneUnitCard> ShopCards = new Dictionary<string, LuneUnitCard>();
public readonly Dictionary<string, LuneArmyChip> Chips = new Dictionary<string, LuneArmyChip>();
public string SelectedUid { get; private set; } = "";
/// <summary>Counters for tests and the performance log.</summary>
public int Syncs { get; private set; }
public double SyncMs { get; private set; }
sealed class AncientBlock
{
public VisualElement Box;
public LuneGlyphElement Emblem;
public Label Caption;
public LuneStatusBadge Badge;
public LuneHpBar Bar;
public Label Value;
public LuneTowerIndicator[] Towers;
public float Pulse;
public bool Own;
public LuneHudModel.BuildingInfo Data;
}
RenderTexture _target;
VisualElement _hud;
LunePanel _top;
readonly List<AncientBlock> _ancients = new List<AncientBlock>();
Label _wave;
LuneStatusBadge _phaseBadge;
VisualElement _system;
VisualElement _left;
LuneTeamPanel _team;
VisualElement _right;
LunePanel _bottom;
LuneResourceDisplay _gold;
LuneButton _undo;
LuneButton _starter;
VisualElement _purchase;
LuneTabBar _purchaseTabs;
VisualElement _troopRow;
VisualElement _army;
Label _armyTitle;
Label _armyLanes;
Label _armyValue;
VisualElement _strip;
string _chipSignature = "@";
LunePanel _readyBox;
LuneButton _readyButton;
LuneGlyphElement _readyCheck;
Label _readyNote;
VisualElement _combatRow;
Label _clock;
Label _auto;
float _readyBlockUntil;
LuneButton _pause;
LuneGlyphElement _pauseGlyph;
LuneButton _speed;
LunePanel _mapPanel;
VisualElement _banners;
string _bannerSignature = "@";
VisualElement _statusWrap;
LunePanel _status;
Label _report;
Label _toast;
float _toastLeft;
LuneModalPanel _modal;
VisualElement _over;
Label _overTitle;
Label _overBody;
float _tick;
float _mapTick;
float _feedTick;
bool _dirty = true;
int _lastPhase = -1;
int _layoutPhase = -1;
readonly Dictionary<string, bool> _knownAlive = new Dictionary<string, bool>();
readonly int[] _ancientLevel = new int[2];
readonly Dictionary<string, bool> _bossSeen = new Dictionary<string, bool>();
int _heroLevel;
Vector2 _area;
// ---------------------------------------------------------------- build
/// <summary>
/// Builds the HUD for one seat. target: null for the screen, or a render texture (tests and
/// captures in batch mode, any resolution).
/// </summary>
public void Init(LuneMatch match, int humanSlot, LuneUnitLibrary library, LuneUiAssets assets, RenderTexture target = null)
{
// A rebuild (rematch) starts with fresh listeners and a fresh panel.
CommandRequested = null;
UndoRequested = null;
ReadyRequested = null;
StarterRequested = null;
SpeedRequested = null;
PauseRequested = null;
LaneFocusRequested = null;
MapFocusRequested = null;
CameraRequested = null;
MenuRequested = null;
RematchRequested = null;
SettingsRequested = null;
HeroCommandRequested = null;
if (Panel != null)
Destroy(Panel);
Lanes.Clear();
ShopCards.Clear();
Chips.Clear();
_ancients.Clear();
_knownAlive.Clear();
_bossSeen.Clear();
_chipSignature = "@";
_bannerSignature = "@";
SelectedUid = "";
Match = match;
HumanSlot = Math.Max(0, humanSlot);
LuneUi.Assets = assets;
LunePortraits.Create(library);
Model = new LuneHudModel(match, HumanSlot);
_target = target;
Panel = ScriptableObject.CreateInstance<PanelSettings>();
Panel.name = "LuneHudPanel";
Panel.themeStyleSheet = assets != null ? assets.Theme : null;
Panel.scaleMode = PanelScaleMode.ScaleWithScreenSize;
Panel.referenceResolution = Reference;
Panel.screenMatchMode = PanelScreenMatchMode.Expand;
Panel.sortingOrder = 10;
if (target != null)
{
Panel.targetTexture = target;
Panel.clearColor = true;
Panel.colorClearValue = new Color(0f, 0f, 0f, 0f);
}
Document = gameObject.GetComponent<UIDocument>() ?? gameObject.AddComponent<UIDocument>();
Document.panelSettings = Panel;
Root = Document.rootVisualElement;
Root.pickingMode = PickingMode.Ignore;
Root.Clear();
_hud = new VisualElement { name = "lune-hud", pickingMode = PickingMode.Ignore };
_hud.style.position = Position.Absolute;
_hud.style.left = 0f;
_hud.style.top = 0f;
_hud.style.right = 0f;
_hud.style.bottom = 0f;
Root.Add(_hud);
FieldTags = new LuneFieldTags();
_hud.Add(FieldTags);
BuildTop();
BuildSystem();
BuildLeft();
BuildRight();
BuildBottom();
BuildReady();
BuildMap();
BuildBanner();
BuildStatus();
BuildModal();
BuildOver();
Settings = new LuneSettingsPanel();
_hud.Add(Settings);
Tooltip = new LuneTooltip();
_hud.Add(Tooltip);
_hud.RegisterCallback<PointerMoveEvent>(evt => Tooltip.Pointer = evt.position, TrickleDown.TrickleDown);
_hud.RegisterCallback<PointerDownEvent>(evt => Tooltip.Pointer = evt.position, TrickleDown.TrickleDown);
// Banner, status line and right column follow the real heights of the top panel, the
// bottom bar and the system row (Godot _layout on resize); before the first layout
// Layout() uses estimates.
_top.RegisterCallback<GeometryChangedEvent>(_ => Layout());
_bottom.RegisterCallback<GeometryChangedEvent>(_ => Layout());
_system.RegisterCallback<GeometryChangedEvent>(_ => Layout());
LunePreferences.Changed -= Fit;
LunePreferences.Changed += Fit;
Fit();
RememberBuildings();
_heroLevel = OwnHeroLevel();
_lastPhase = -1;
_dirty = true;
Sync(0f);
}
void OnDestroy()
{
LunePreferences.Changed -= Fit;
if (Panel != null)
Destroy(Panel);
}
LunePanel AddPanel(LunePlateStyle style)
{
var panel = new LunePanel(style);
panel.style.position = Position.Absolute;
panel.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
_hud.Add(panel);
return panel;
}
void BuildTop()
{
// A full-width row that centres the panel and lets clicks through elsewhere.
var wrap = new VisualElement { pickingMode = PickingMode.Ignore };
wrap.style.position = Position.Absolute;
wrap.style.left = 0f;
wrap.style.right = 0f;
wrap.style.top = Margin;
wrap.style.flexDirection = FlexDirection.Row;
wrap.style.justifyContent = Justify.Center;
_hud.Add(wrap);
_top = new LunePanel(LunePanels.Hud);
_top.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
wrap.Add(_top);
VisualElement row = LuneUi.Box(false, 20f, _top);
for (int side = 0; side < 2; side++)
{
AncientBlock block = AncientBlockFor(side == 0);
_ancients.Add(block);
row.Add(block.Box);
if (side == 0)
{
VisualElement center = LuneUi.Box(true, 2f, row);
center.style.minWidth = 190f;
center.style.alignItems = Align.Center;
center.style.justifyContent = Justify.Center;
center.pickingMode = PickingMode.Position;
_wave = LuneUi.Label("WELLE 1", "HudWave", center);
_phaseBadge = new LuneStatusBadge();
center.Add(_phaseBadge);
LuneTooltip.Attach(center, WaveTip);
}
}
}
/// <summary>Ancient block: team emblem, name, HP bar and value, the two base towers, critical badge.</summary>
AncientBlock AncientBlockFor(bool own)
{
var block = new AncientBlock { Own = own, Towers = new LuneTowerIndicator[2] };
block.Box = LuneUi.Box(false, 10f);
block.Box.style.minWidth = 300f;
block.Box.pickingMode = PickingMode.Position;
block.Emblem = LuneUi.Glyph("emblem_0", LuneUiTheme.Team[0], 36f);
var column = (LuneBox)LuneUi.Box(true, 3f);
column.style.flexGrow = 1f;
if (own)
{
block.Box.Add(block.Emblem);
block.Box.Add(column);
}
else
{
block.Box.Add(column);
block.Box.Add(block.Emblem);
}
VisualElement head = LuneUi.Box(false, 6f, column);
block.Caption = LuneUi.Label("", "HudCaps");
block.Badge = new LuneStatusBadge();
LuneUi.Show(block.Badge, false);
if (own)
{
head.Add(block.Caption);
LuneUi.Spacer(head);
head.Add(block.Badge);
}
else
{
head.Add(block.Badge);
LuneUi.Spacer(head);
head.Add(block.Caption);
}
block.Bar = new LuneHpBar(240f, 10f) { Ticks = 4 };
column.Add(block.Bar);
VisualElement row = LuneUi.Box(false, 5f, column);
block.Value = LuneUi.Label("", "HudValue");
Label baseLabel = LuneUi.Label("BASIS", "HudMuted");
for (int index = 0; index < 2; index++)
block.Towers[index] = new LuneTowerIndicator(18f, 22f) { Tier = "base" };
if (own)
{
row.Add(block.Value);
LuneUi.Spacer(row);
row.Add(baseLabel);
foreach (LuneTowerIndicator tower in block.Towers)
row.Add(tower);
}
else
{
foreach (LuneTowerIndicator tower in block.Towers)
row.Add(tower);
row.Add(baseLabel);
LuneUi.Spacer(row);
row.Add(block.Value);
}
LuneTooltip.Attach(block.Box, () => AncientTip(block));
for (int index = 0; index < 2; index++)
{
int captured = index;
LuneTooltip.Attach(block.Towers[index], () => BaseTip(block, captured));
}
return block;
}
void BuildSystem()
{
_system = LuneUi.Box(false, 6f, _hud);
_system.style.position = Position.Absolute;
_system.style.top = Margin;
_system.style.right = Margin;
// Pause and tempo live up here, far from Bereit: a late click at the end of a combat must
// never land on Bereit (found in the G3 playtest).
_pause = IconButton("pause", "P", _system, out _pauseGlyph);
LuneUi.Show(_pause, false);
_pause.Clicked += () => PauseRequested?.Invoke();
LuneTooltip.Attach(_pause, () => new LuneTip { Title = "Pause", Text = "Hält die Simulation an (offline). Im Netzspiel bestimmt der Host.", Note = "P oder Leertaste" });
_speed = new LuneButton("1×", LuneButtons.HudIconButton, _system);
_speed.style.minWidth = 46f;
_speed.style.height = 36f;
LuneUi.Show(_speed, false);
_speed.Clicked += () => SpeedRequested?.Invoke();
LuneTooltip.Attach(_speed, () => new LuneTip { Title = "Tempo", Text = "Wechselt 1× · 2× · 4×; die Regeln bleiben gleich." });
LuneButton options = IconButton("gear", "F1", _system, out _);
options.Clicked += () => SettingsRequested?.Invoke();
LuneTooltip.Attach(options, () => new LuneTip { Title = "Optionen", Text = "Lautstärke, Vollbild, Bewegung reduzieren, UI-Größe und alle Tasten.", Note = "F1" });
LuneButton menu = IconButton("menu", "Menü", _system, out _);
menu.Clicked += () => MenuRequested?.Invoke();
LuneTooltip.Attach(menu, () => new LuneTip { Title = "Hauptmenü", Text = "Verlässt die Partie." });
}
static LuneButton IconButton(string glyph, string text, VisualElement parent, out LuneGlyphElement glyphElement)
{
var button = new LuneButton(null, LuneButtons.HudIconButton, parent);
glyphElement = LuneUi.Glyph(glyph, LuneUiTheme.Text, 15f, button);
Label label = LuneUi.Label(text, "HudText", button);
label.style.marginLeft = 5f;
button.style.minWidth = Mathf.Max(40f, 30f + text.Length * 8.5f);
button.style.height = 36f;
return button;
}
void BuildLeft()
{
_left = LuneUi.Box(true, 8f, _hud);
_left.style.position = Position.Absolute;
_left.style.left = Margin;
_left.style.top = Margin;
_left.style.width = LeftWidth;
var lanePanel = new LunePanel(LunePanels.Hud);
lanePanel.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
_left.Add(lanePanel);
VisualElement column = LuneUi.Box(true, 5f, lanePanel);
VisualElement head = LuneUi.Box(false, 6f, column);
LuneUi.Label("LANES", "HudCaps", head);
LuneUi.Spacer(head);
LuneUi.Label("Klick oder F2–F4", "HudMuted", head);
foreach (string lane in LuneMap.Lanes)
{
var indicator = new LuneLaneIndicator(lane, LaneKeys[lane]);
column.Add(indicator);
string captured = lane;
indicator.Clicked += () => LaneFocusRequested?.Invoke(captured);
Lanes[lane] = indicator;
}
var teamPanel = new LunePanel(LunePanels.Hud);
teamPanel.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
_left.Add(teamPanel);
_team = new LuneTeamPanel();
teamPanel.Add(_team);
Feed = new LuneEventFeed();
_left.Add(Feed);
Feed.FocusRequested += FocusTarget;
}
void BuildRight()
{
_right = LuneUi.Box(true, 8f, _hud);
_right.style.position = Position.Absolute;
_right.style.right = Margin;
_right.style.top = Margin + 36f + 8f;
_right.style.width = RightWidth;
BossCard = new LuneBossIndicator();
BossCard.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
_right.Add(BossCard);
BossCard.FocusRequested += () => CameraRequested?.Invoke("grotto");
BossCard.LaneChosen += lane => CommandRequested?.Invoke(Command.BossLaneChoice(lane));
NextWave = new LuneNextWave();
NextWave.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
_right.Add(NextWave);
UnitDetail = new LuneUnitDetail();
UnitDetail.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
LuneUi.Show(UnitDetail, false);
_right.Add(UnitDetail);
UnitDetail.CommandRequested += command => CommandRequested?.Invoke(command);
UnitDetail.Closed += ClearSelection;
}
void BuildBottom()
{
_bottom = AddPanel(LunePanels.Hud);
_bottom.style.left = Margin;
_bottom.style.bottom = Margin;
_bottom.style.right = Margin + MapEdge + 16f + 10f;
_bottom.style.minHeight = BarPlanning;
VisualElement row = LuneUi.Box(false, 14f, _bottom);
row.style.alignItems = Align.FlexStart;
row.style.flexGrow = 1f;
// Gold and undo.
VisualElement goldColumn = LuneUi.Box(true, 4f, row);
goldColumn.style.minWidth = 150f;
LuneUi.Label("GOLD", "HudCaps", goldColumn);
_gold = new LuneResourceDisplay();
goldColumn.Add(_gold);
LuneTooltip.Attach(_gold, GoldTip);
_undo = new LuneButton("Rückgängig", LuneButtons.HudButton, goldColumn);
_undo.style.alignSelf = Align.FlexStart;
_undo.Clicked += () => UndoRequested?.Invoke();
LuneTooltip.Attach(_undo, () => new LuneTip { Title = "Rückgängig", Text = "Nimmt den letzten Befehl dieser Planung zurück (Kauf, Einsatz, Item, Buchung)." });
// Purchases: troops (1–5) or attack champions.
_purchase = LuneUi.Box(true, 4f, row);
_purchaseTabs = new LuneTabBar();
_purchase.Add(_purchaseTabs);
_purchaseTabs.AddTab("TRUPPEN · 1–5", "Dauerhafte Truppen", "Bleiben in deiner Armee, kämpfen jede Welle, lassen sich aufwerten.");
_purchaseTabs.AddTab("ANGRIFFSCHAMPIONS", "Angriffschampions", "Einmalige Söldner: jetzt kaufen, eine Welle später auf der Ziel-Lane angreifen, danach weg.");
_purchaseTabs.TabChanged += ShowPurchase;
_troopRow = LuneUi.Box(false, 6f, _purchase);
_troopRow.style.alignItems = Align.FlexStart;
for (int index = 0; index < 5; index++)
_troopRow.Add(new LuneUnitCard());
ChampionPanel = new LuneChampionPanel();
LuneUi.Show(ChampionPanel, false);
_purchase.Add(ChampionPanel);
ChampionPanel.CommandRequested += command => CommandRequested?.Invoke(command);
// Own army, ordered by lane.
_army = LuneUi.Box(true, 4f, row);
_army.style.flexGrow = 1f;
_army.style.flexShrink = 1f;
_army.style.minWidth = 0f;
_army.RegisterCallback<GeometryChangedEvent>(_ => MarkDirty());
VisualElement head = LuneUi.Box(false, 8f, _army);
_armyTitle = LuneUi.Label("ARMEE", "HudCaps", head);
_armyLanes = LuneUi.Clip(LuneUi.Label("", "HudMuted", head));
_armyLanes.style.flexGrow = 1f;
_armyValue = LuneUi.Label("", "HudMuted", head);
LuneTooltip.Attach(_armyValue, () => new LuneTip { Title = "Armeewert", Glyph = "coin", GlyphColor = LuneUiTheme.Gold,
Text = "Summe des Einsatzes aller eigenen Truppen (Kauf und Aufwertungen), wie der Armeewert in Legion TD 2." });
_strip = new VisualElement { pickingMode = PickingMode.Ignore };
_strip.style.flexDirection = FlexDirection.Row;
_strip.style.flexWrap = Wrap.Wrap;
_army.Add(_strip);
_starter = new LuneButton("Startaufstellung", LuneButtons.HudButton, _army);
_starter.style.alignSelf = Align.FlexStart;
_starter.Clicked += () => StarterRequested?.Invoke();
LuneTooltip.Attach(_starter, StarterTip);
HeroCard = new LuneHeroCard();
row.Add(HeroCard);
HeroCard.Clicked += () => OpenHero();
HeroCard.CommandRequested += () =>
{
HeroCommandRequested?.Invoke();
OpenHero(LuneHeroDetail.TabUltimate);
};
}
void BuildReady()
{
_readyBox = AddPanel(LunePanels.HudTight);
_readyBox.style.right = Margin;
_readyBox.style.bottom = Margin + MapEdge + 16f + 8f;
_readyBox.style.width = MapEdge + 16f;
_readyBox.style.height = ReadyHeight;
VisualElement column = LuneUi.Box(true, 2f, _readyBox);
column.style.flexGrow = 1f;
_readyButton = new LuneButton("BEREIT", LuneButtons.Primary, column);
_readyButton.style.height = 44f;
_readyButton.style.paddingTop = 0f;
_readyButton.style.paddingBottom = 0f;
_readyButton.Clicked += () =>
{
if (!ReadyBlocked())
ReadyRequested?.Invoke();
};
LuneTooltip.Attach(_readyButton, ReadyTip);
_readyCheck = LuneUi.Glyph("check", LuneUiTheme.Ink, 22f, _readyButton);
_readyCheck.style.position = Position.Absolute;
_readyCheck.style.left = 16f;
LuneUi.Show(_readyCheck, false);
_readyNote = LuneUi.Label("Leertaste", "HudMuted", column);
_readyNote.style.unityTextAlign = TextAnchor.MiddleCenter;
_combatRow = LuneUi.Box(false, 8f, column);
_combatRow.style.justifyContent = Justify.Center;
_combatRow.style.flexGrow = 1f;
LuneUi.Show(_combatRow, false);
LuneUi.Glyph("melee", LuneUiTheme.Lightened(LuneUiTheme.Danger, .15f), 16f, _combatRow);
_clock = LuneUi.Label("", "HudValue", _combatRow);
_auto = LuneUi.Clip(LuneUi.Label("", "HudMuted", _combatRow));
}
void BuildMap()
{
_mapPanel = AddPanel(LunePanels.HudTight);
_mapPanel.style.right = Margin;
_mapPanel.style.bottom = Margin;
_mapPanel.style.width = MapEdge + 16f;
_mapPanel.style.height = MapEdge + 16f;
Minimap = new LuneMinimap { Match = Match, HumanSlot = HumanSlot };
Minimap.style.flexGrow = 1f;
_mapPanel.Add(Minimap);
Minimap.FocusRequested += point => MapFocusRequested?.Invoke(point);
LuneTooltip.Attach(Minimap, () => new LuneTip
{
Title = "Minimap", Text = "Klicken oder ziehen bewegt die Kamera. Runde Marker: Blau · eckige Marker: Rot. Krone: Boss, Pfeil: Angriffschampion, Porträt im Goldring: Held.",
Note = "Die Minimap löst nie Käufe oder Befehle aus.",
});
}
void BuildBanner()
{
_banners = LuneUi.Box(true, 6f, _hud);
_banners.style.position = Position.Absolute;
}
void BuildStatus()
{
_statusWrap = new VisualElement { pickingMode = PickingMode.Ignore };
_statusWrap.style.position = Position.Absolute;
_statusWrap.style.left = Margin;
_statusWrap.style.right = Margin + MapEdge + 16f + 10f;
_statusWrap.style.flexDirection = FlexDirection.Row;
_statusWrap.style.justifyContent = Justify.Center;
_hud.Add(_statusWrap);
_status = new LunePanel(LunePanels.HudTight);
_statusWrap.Add(_status);
VisualElement column = LuneUi.Box(true, 0f, _status);
_report = LuneUi.Label("", "HudText", column);
_report.style.unityTextAlign = TextAnchor.MiddleCenter;
_toast = LuneUi.Label("", "HudMuted", column);
_toast.style.unityTextAlign = TextAnchor.MiddleCenter;
LuneUi.Show(_statusWrap, false);
}
void BuildModal()
{
_modal = new LuneModalPanel(new Vector2(1040f, 660f));
_hud.Add(_modal);
HeroDetail = new LuneHeroDetail();
_modal.Body.Add(HeroDetail);
HeroDetail.CommandRequested += command => CommandRequested?.Invoke(command);
}
void BuildOver()
{
_over = new VisualElement();
_over.style.position = Position.Absolute;
_over.style.left = 0f;
_over.style.top = 0f;
_over.style.right = 0f;
_over.style.bottom = 0f;
_over.style.backgroundColor = new Color(.02f, .03f, .04f, .82f);
_over.style.justifyContent = Justify.Center;
_over.style.alignItems = Align.Center;
_over.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
LuneUi.Show(_over, false);
_hud.Add(_over);
var panel = new LunePanel(LunePanels.Menu);
panel.style.minWidth = 620f;
_over.Add(panel);
VisualElement column = LuneUi.Box(true, 14f, panel);
column.style.alignItems = Align.Center;
_overTitle = LuneUi.Label("", "TitleLabel", column, 60f);
_overBody = LuneUi.Wrap(LuneUi.Label("", "BodyLabel", column));
_overBody.style.unityTextAlign = TextAnchor.MiddleCenter;
_overBody.style.maxWidth = 560f;
VisualElement row = LuneUi.Box(false, 12f, column);
row.style.justifyContent = Justify.Center;
new LuneButton("NEUE PARTIE", LuneButtons.Primary, row).Clicked += () => RematchRequested?.Invoke();
new LuneButton("Menü", LuneButtons.Secondary, row).Clicked += () => MenuRequested?.Invoke();
}
// ---------------------------------------------------------------- layout
/// <summary>Size of the render target in pixels (screen or texture).</summary>
public Vector2 TargetSize { get { return _target != null ? new Vector2(_target.width, _target.height) : new Vector2(Screen.width, Screen.height); } }
/// <summary>
/// Logical area at UI scale 1 (Godot: content scale canvas_items/expand from 1600×960) and
/// the UI scale: the preference, capped so the layout always has MinArea logical pixels.
/// </summary>
public static float ScaleFor(Vector2 pixels, float wanted, out Vector2 area)
{
float k = Mathf.Min(pixels.x / Reference.x, pixels.y / Reference.y);
area = pixels / Mathf.Max(.0001f, k);
float fits = Mathf.Min(area.x / MinArea.x, area.y / MinArea.y);
return Mathf.Clamp(Mathf.Min(Mathf.Clamp(wanted, .75f, 1.5f), fits), .6f, 1.5f);
}
void Fit()
{
if (Panel == null)
return;
UiScale = ScaleFor(TargetSize, LunePreferences.UiScale, out _area);
Panel.scale = UiScale;
Layout();
}
/// <summary>Logical size of the HUD (area / UI scale).</summary>
public Vector2 LogicalSize { get { return _area / Mathf.Max(.0001f, UiScale); } }
void Layout()
{
if (_over == null)
return;
Vector2 size = LogicalSize;
bool planning = Match == null || Match.Phase == Phase.Planning;
_readyBox.style.height = planning ? ReadyHeight : ReadyCombat;
_bottom.style.minHeight = planning ? BarPlanning : BarCombat;
float bannerWidth = Mathf.Min(700f, size.x - 2f * (LeftWidth + 2f * Margin));
_banners.style.width = bannerWidth;
_banners.style.left = Mathf.Floor((size.x - bannerWidth) * .5f);
float topHeight = float.IsNaN(_top.layout.height) || _top.layout.height <= 0f ? 96f : _top.layout.height;
_banners.style.top = Margin + topHeight + 8f;
float barHeight = float.IsNaN(_bottom.layout.height) || _bottom.layout.height <= 0f ? (planning ? BarPlanning : BarCombat) : _bottom.layout.height;
_statusWrap.style.bottom = Margin + barHeight + 8f;
float systemHeight = float.IsNaN(_system.layout.height) || _system.layout.height <= 0f ? 36f : _system.layout.height;
_right.style.top = Margin + systemHeight + 8f;
_layoutPhase = Match != null ? (int)Match.Phase : -1;
}
/// <summary>Rectangles of the main regions in logical pixels (layout tests).</summary>
public Dictionary<string, Rect> Regions()
{
var result = new Dictionary<string, VisualElement>
{
{ "top", _top }, { "system", _system }, { "left", _left }, { "right", _right }, { "bottom", _bottom }, { "ready", _readyBox }, { "minimap", _mapPanel },
};
var rects = new Dictionary<string, Rect>();
foreach (KeyValuePair<string, VisualElement> pair in result)
{
if (LuneUi.Visible(pair.Value))
rects[pair.Key] = _hud.WorldToLocal(pair.Value.worldBound);
}
return rects;
}
// ---------------------------------------------------------------- per frame
/// <summary>Main-thread cost of the last frame's Tick (milliseconds; benchmark).</summary>
public double LastTickMs { get; private set; }
readonly System.Diagnostics.Stopwatch _tickClock = new System.Diagnostics.Stopwatch();
void Update()
{
if (Match == null)
return;
_tickClock.Restart();
Tick(Time.unscaledDeltaTime);
LastTickMs = _tickClock.Elapsed.TotalMilliseconds;
}
/// <summary>One HUD frame (Godot _process); tests call it with simulated time.</summary>
public void Tick(float delta)
{
Tooltip.Tick(delta);
_gold.Tick(delta);
Feed.Tick(delta);
_toastLeft -= delta;
if (_toastLeft <= 0f && _toast.text.Length > 0)
{
_toast.text = "";
StatusVisibility();
}
foreach (AncientBlock block in _ancients)
{
if (block.Pulse <= 0f)
continue;
block.Pulse = Mathf.Max(0f, block.Pulse - delta);
block.Bar.style.opacity = block.Pulse > 0f ? .45f + .55f * Mathf.Abs(Mathf.Cos(block.Pulse * Mathf.PI * 2f)) : 1f;
}
_tick += delta;
_mapTick += delta;
_feedTick += delta;
bool fighting = Match.Phase == Phase.Combat;
if (fighting && _mapTick >= MapRate)
{
_mapTick = 0f;
Minimap.Refresh(Match.Announcements());
}
if (_feedTick >= FeedRate)
{
TrackEvents();
_feedTick = 0f;
}
if (_dirty || (int)Match.Phase != _lastPhase || _tick >= (fighting ? CombatRate : PlanningRate))
{
var watch = System.Diagnostics.Stopwatch.StartNew();
Sync(_tick);
_tick = 0f;
_dirty = false;
Syncs++;
SyncMs = SyncMs <= 0.0 ? watch.Elapsed.TotalMilliseconds : SyncMs + (watch.Elapsed.TotalMilliseconds - SyncMs) * .2;
}
}
public void MarkDirty()
{
_dirty = true;
}
/// <summary>True for one second after a combat ended: Bereit (click or Space) is ignored meanwhile.</summary>
public bool ReadyBlocked()
{
return Time.realtimeSinceStartup < _readyBlockUntil;
}
void Sync(float delta)
{
int phase = (int)Match.Phase;
if (phase != _lastPhase)
{
PhaseChanged(_lastPhase, phase);
_lastPhase = phase;
}
if (_layoutPhase != phase)
Layout();
bool planning = Model.Planning;
bool fighting = Model.Fighting;
SyncTop();
bool reduced = LunePreferences.ReducedMotion;
foreach (KeyValuePair<string, LuneLaneIndicator> pair in Lanes)
pair.Value.Apply(Model.Lane(pair.Key), Model.Team, delta, reduced);
_team.Blessing = Model.State.Extended ? Model.State.Boss.Blessing[Model.Team] : -1;
_team.Apply(Model.Members(Model.Team), Model.Team, planning, Model.Networked);
SyncRight(planning, fighting);
SyncBottom(planning, fighting);
SyncReady(planning, fighting);
SyncBanners();
if (!fighting)
{
Minimap.ViewerTeam = Model.Team;
Minimap.HumanSlot = HumanSlot;
Minimap.Refresh(Match.Announcements());
}
if (_modal.Open)
{
HeroDetail.Apply(Model);
_modal.Title.text = HeroDetail.TitleText(Model);
}
LuneUi.Show(_over, Model.Over);
if (Model.Over)
{
int own = Model.Team;
_overTitle.text = Match.Winner == LuneMatch.Draw ? "UNENTSCHIEDEN" : Match.Winner == own ? "SIEG" : "NIEDERLAGE";
_overBody.text = Match.EndNote + "\nNach " + Match.RoundNumber + " Wellen.";
}
}
void SyncTop()
{
for (int side = 0; side < 2; side++)
{
int team = side == 0 ? Model.Team : Model.Enemy;
AncientBlock block = _ancients[side];
LuneHudModel.BuildingInfo ancient = Model.Ancient(team);
block.Data = ancient;
Color tint = LuneUiTheme.TeamColor(team);
block.Emblem.Glyph = "emblem_" + team;
block.Emblem.Color = tint;
block.Caption.text = (side == 0 ? "DEIN ANCIENT · " : "GEGNER · ") + LuneUi.Upper(LuneUiTheme.TeamName(team));
block.Bar.Fraction = (float)ancient.Fraction;
block.Bar.Fill = LuneHpBar.HealthColor(block.Bar.Fraction, tint);
block.Value.text = LuneUiTheme.Number(ancient.Hp) + " / " + LuneUiTheme.Number(ancient.MaxHp);
for (int index = 0; index < 2; index++)
{
LuneHudModel.BuildingInfo baseTower = ancient.Base[index];
block.Towers[index].Team = team;
block.Towers[index].SetState(baseTower.Alive, (float)baseTower.Fraction, baseTower.Attackable);
}
int level = block.Bar.Fraction < .3f ? 2 : block.Bar.Fraction < .5f ? 1 : 0;
bool show = level > 0 || !ancient.Alive;
if (!ancient.Alive)
block.Badge.SetState("ZERSTÖRT", LuneUiTheme.Danger, "close", true);
else if (level == 2)
block.Badge.SetState("KRITISCH", LuneUiTheme.Danger, "warning", true);
else if (level == 1)
block.Badge.SetState("BESCHÄDIGT", LuneUiTheme.Warn);
else if (ancient.Attackable)
{
show = true;
block.Badge.SetState("ANGREIFBAR", LuneUiTheme.Warn);
}
LuneUi.Show(block.Badge, show);
}
_wave.text = "WELLE " + Match.RoundNumber;
if (Model.Over)
_phaseBadge.SetState("PARTIE BEENDET", LuneUiTheme.Muted);
else if (Model.Fighting)
{
int elapsed = (int)Match.Combat.Elapsed;
_phaseBadge.SetState(string.Format("KAMPF · {0:00}:{1:00}", elapsed / 60, elapsed % 60), LuneUiTheme.Lightened(LuneUiTheme.Danger, .1f), "melee");
}
else if (Model.Locked)
_phaseBadge.SetState("PLANUNG · BEREIT", LuneUiTheme.Good, "check");
else
_phaseBadge.SetState("PLANUNG", LuneUiTheme.BronzeLight, "clock");
}
void SyncRight(bool planning, bool fighting)
{
LuneHudModel.BossInfo boss = Model.Boss();
BossCard.Apply(boss, Model.Team, Match.RoundNumber, planning);
if (boss.HasHp)
BossCard.SetBossHp((float)(boss.Hp / Math.Max(1.0, boss.MaxHp)));
RosterEntry entry = SelectedEntry();
if (entry == null && SelectedUid.Length > 0)
SelectedUid = "";
LuneUi.Show(UnitDetail, entry != null);
BossCard.SetCompact(entry != null);
if (entry != null)
UnitDetail.Apply(entry, Model, Model.LiveUnit(SelectedUid));
bool wave = entry == null && !Model.Over;
LuneUi.Show(NextWave, wave);
if (wave)
NextWave.Apply(Model.NextWave(), Model.Team, fighting);
}
RosterEntry SelectedEntry()
{
if (SelectedUid.Length == 0)
return null;
return Model.OwnRoster.FirstOrDefault(e => e.Uid == SelectedUid);
}
void SyncBottom(bool planning, bool fighting)
{
_gold.SetValues(Model.OwnGold, Model.Income, !Model.Over, Model.IncomeBonus);
List<RosterEntry> roster = Model.OwnRoster;
bool locked = Model.Locked;
bool canAct = planning && !locked;
LuneUi.Show(_purchase, planning);
LuneUi.Show(_army, !planning || !LuneUi.Visible(ChampionPanel));
LuneUi.Show(_undo, planning);
_undo.Disabled = !canAct || Model.Me.Undo.Count == 0;
LuneUi.Show(_starter, planning && roster.Count == 0);
_starter.Disabled = !canAct;
if (planning)
{
List<string> ids = Match.BaseIds(Model.Me.Bund);
int gold = Model.OwnGold;
bool full = roster.Count >= Model.SupplyCap;
if (!ShopCards.ContainsKey(ids[0]))
{
ShopCards.Clear();
for (int index = 0; index < ids.Count; index++)
{
var card = (LuneUnitCard)_troopRow[index];
string id = ids[index];
UnitDef data = DataCatalog.GetUnit(id);
card.Setup(id, id, data.DisplayName, Model.RoleOf(id), data.Cost, (index + 1).ToString(), false, data);
if (card.userData == null)
{
card.userData = "wired";
card.Clicked += () => CommandRequested?.Invoke(Command.Buy(card.TypeId));
LuneTooltip.Attach(card, () => LuneStats.TroopTip(card.TypeId, Model.RoleOf(card.TypeId), card.Reason));
}
ShopCards[id] = card;
}
}
foreach (LuneUnitCard card in ShopCards.Values)
{
string why = "";
if (locked)
why = "Bereit gemeldet – die Planung ist verbindlich.";
else if (full)
why = "Armee voll (" + roster.Count + "/" + Model.SupplyCap + ").";
else if (gold < card.Cost)
why = "Es fehlen " + (card.Cost - gold) + " Gold.";
card.SetAvailable(why.Length == 0, why, locked, gold < card.Cost);
}
if (LuneUi.Visible(ChampionPanel))
ChampionPanel.Apply(Model);
}
_armyTitle.text = "ARMEE " + roster.Count + "/" + Model.SupplyCap;
var perLane = new Dictionary<string, int>();
var aliveLane = new Dictionary<string, int>();
int value = 0;
foreach (RosterEntry entry in roster)
{
perLane[entry.Lane] = perLane.TryGetValue(entry.Lane, out int count) ? count + 1 : 1;
value += entry.Invested;
if (fighting)
{
LuneUnit unit = Model.LiveUnit(entry.Uid);
if (unit != null && unit.Alive)
aliveLane[entry.Lane] = aliveLane.TryGetValue(entry.Lane, out int alive) ? alive + 1 : 1;
}
}
var parts = new List<string>();
foreach (string lane in LuneMap.Lanes.Concat(new[] { LuneMatch.Grotto }))
{
if (!perLane.ContainsKey(lane))
continue;
string shortName = lane == LuneMatch.Grotto ? "GROTTE" : LuneUi.Upper(LuneMap.LaneNames[lane]);
parts.Add(fighting ? shortName + " " + aliveLane.GetValueOrDefault(lane, 0) + "/" + perLane[lane] : shortName + " " + perLane[lane]);
}
_armyLanes.text = string.Join(" · ", parts);
_armyValue.text = roster.Count > 0 ? "Wert " + LuneUiTheme.Number(value) : "";
SyncChips(roster, fighting);
HeroState hero = Model.OwnHero;
HeroCard.Apply(hero, Model, hero != null ? Model.LiveUnit(hero.Uid) : null);
}
void SyncChips(List<RosterEntry> roster, bool fighting)
{
var order = LuneMap.Lanes.Concat(new[] { LuneMatch.Grotto }).ToList();
List<RosterEntry> ordered = roster.OrderBy(e => order.IndexOf(e.Lane)).ThenBy(e => e.Seq).ToList();
float edge = ChipEdge(ordered.Count, fighting);
string signature = string.Join(",", ordered.Select(e => e.Uid + ":" + e.TypeId + ":" + e.Lane)) + "|" + (int)edge;
if (signature != _chipSignature)
{
_chipSignature = signature;
_strip.Clear();
Chips.Clear();
foreach (RosterEntry entry in ordered)
Chips[entry.Uid] = MakeChip(entry, edge);
}
foreach (KeyValuePair<string, LuneArmyChip> pair in Chips)
{
pair.Value.On = pair.Key == SelectedUid;
if (fighting)
{
LuneUnit unit = Model.LiveUnit(pair.Key);
pair.Value.Fallen = unit == null || !unit.Alive;
}
else
{
pair.Value.Fallen = false;
}
}
}
/// <summary>Largest chip edge (46 … 30 px) at which the whole army fits into the strip.</summary>
float ChipEdge(int count, bool fighting)
{
float width = !float.IsNaN(_army.layout.width) && _army.layout.width > 60f ? _army.layout.width : 300f;
float height = (fighting ? BarCombat : BarPlanning) - 20f - 26f;
foreach (float edge in new[] { 46f, 42f, 38f, 34f, 30f })
{
int perRow = Math.Max(1, (int)Math.Floor((width + 4f) / (edge + 4f)));
int rows = (int)Math.Ceiling(count / (double)perRow);
if (rows * (edge + LuneArmyChip.Band + 4f) <= height)
return edge;
}
return 30f;
}
LuneArmyChip MakeChip(RosterEntry entry, float edge)
{
var chip = new LuneArmyChip();
chip.style.marginRight = 4f;
chip.style.marginBottom = 4f;
_strip.Add(chip);
chip.Setup(entry, edge);
string uid = entry.Uid;
chip.Clicked += () =>
{
if (SelectedUid == uid)
ClearSelection();
else
SelectUnit(uid);
};
string type = entry.TypeId;
string lane = entry.Lane;
LuneTooltip.Attach(chip, () =>
{
LuneTip tip = LuneStats.TroopTip(type, Model.RoleOf(type));
tip.Note = "Einsatz: " + (lane == LuneMatch.Grotto ? "Grotte" : LuneMap.LaneNames[lane]) + " · Klick: Details, Aufwerten, Einsatz";
if (chip.Fallen)
tip.Warn = "In diesem Kampf gefallen; kehrt zur nächsten Planung zurück.";
return tip;
});
return chip;
}
void SyncReady(bool planning, bool fighting)
{
LuneUi.Show(_readyBox, !Model.Over);
LuneUi.Show(_readyButton, planning);
LuneUi.Show(_readyNote, planning);
LuneUi.Show(_combatRow, fighting);
LuneUi.Show(_pause, fighting);
LuneUi.Show(_speed, fighting);
if (planning)
{
bool locked = Model.Locked;
_readyButton.Disabled = locked || ReadyBlocked();
_readyButton.Text = "BEREIT";
LuneUi.Show(_readyCheck, locked);
if (locked)
{
Model.ReadyCount(out int ready, out int humans);
_readyNote.text = humans > ready ? "Warte auf " + Math.Max(0, humans - ready) + " Mitspieler" : "Kampf beginnt …";
}
else
{
_readyNote.text = "Leertaste · startet Welle " + Match.RoundNumber;
}
}
if (fighting)
{
int elapsed = (int)Match.Combat.Elapsed;
_clock.text = string.Format("KAMPF {0:00}:{1:00}", elapsed / 60, elapsed % 60);
_auto.text = "Tempo " + _speed.Text;
bool guest = Model.IsGuest;
_pause.Disabled = guest;
_speed.Disabled = guest;
}
}
void SyncBanners()
{
var lines = new List<(string text, string glyph, bool own, LuneFocusTarget target)>();
LuneHudModel.BossInfo boss = Model.Boss();
if (boss.State == "deploy_next" || boss.State == "deploying")
{
bool own = boss.Own;
string lane = LuneUi.Upper(LuneMap.LaneNames.TryGetValue(boss.Lane, out string name) ? name : "?");
string text = own
? "Deine SCHLUNDKRONE greift in Welle " + boss.DeployRound + " auf " + lane + " an · Kopfgeld für den Gegner " + LuneUiTheme.Number(boss.Bounty) + " Gold"
: "SCHLUNDKRONE (" + LuneUiTheme.TeamName(boss.Team) + ") greift in Welle " + boss.DeployRound + " auf " + lane + " an · Kopfgeld für euch " + LuneUiTheme.Number(boss.Bounty) + " Gold";
lines.Add((text, "crown", own, LuneFocusTarget.ForLane(boss.Lane)));
}
LuneHudModel.BuildingInfo core = Model.Ancient(Model.Team);
if (core.Alive && core.Fraction < .3)
lines.Add(("Dein Ancient ist kritisch: " + LuneUiTheme.Round(core.Fraction * 100.0) + " % Leben", "warning", false, LuneFocusTarget.ForCamera("base")));
string signature = string.Join("|", lines.Select(l => l.text + l.glyph + l.own));
if (signature == _bannerSignature)
return;
_bannerSignature = signature;
_banners.Clear();
foreach (var line in lines)
{
var panel = new LunePanel(line.own ? LunePanels.BannerOwn : LunePanels.BannerDanger);
panel.RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
_banners.Add(panel);
VisualElement row = LuneUi.Box(false, 10f, panel);
row.style.justifyContent = Justify.Center;
LuneUi.Glyph(line.glyph, line.own ? LuneUiTheme.TeamColor(Model.Team) : LuneUiTheme.Danger, 20f, row);
Label label = LuneUi.Wrap(LuneUi.Label(line.text, "HudValue", row));
label.style.unityTextAlign = TextAnchor.MiddleCenter;
label.style.flexGrow = 1f;
LuneFocusTarget target = line.target;
panel.RegisterCallback<PointerDownEvent>(evt =>
{
if (evt.button == 0)
FocusTarget(target);
});
}
}
void StatusVisibility()
{
LuneUi.Show(_statusWrap, _report.text.Length > 0 || _toast.text.Length > 0);
LuneUi.Show(_report, _report.text.Length > 0);
LuneUi.Show(_toast, _toast.text.Length > 0);
}
// ---------------------------------------------------------------- phase changes and events
void PhaseChanged(int previous, int phase)
{
if (phase == (int)Phase.Combat)
{
CloseModal();
_report.text = "";
StatusVisibility();
foreach (Announcement entry in Match.Announcements())
{
bool own = entry.Team == Model.Team;
string lane = LuneUi.Upper(LuneMap.LaneNames[entry.Lane]);
if (entry.Kind == "boss")
Feed.Push(lane + " – Schlundkrone (" + LuneUiTheme.TeamName(entry.Team) + ") greift an", own ? "good" : "danger", "crown", LuneFocusTarget.ForLane(entry.Lane));
else
Feed.Push(lane + " – Angriffschampion " + LuneContent.Champions[entry.ChampionId].Name + " (" + LuneUiTheme.TeamName(entry.Team) + ") erschienen", own ? "info" : "warn", "champion", LuneFocusTarget.ForLane(entry.Lane));
}
}
else if (phase == (int)Phase.Planning && previous == (int)Phase.Combat)
{
_readyBlockUntil = Time.realtimeSinceStartup + 1f;
ReportSettlement();
}
else if (phase == (int)Phase.Planning && previous < 0 && Match.LastSettlement != null)
{
ReportSettlement();
}
_bossSeen.Clear();
Layout();
}
void ReportSettlement()
{
Settlement s = Match.LastSettlement;
if (s == null)
return;
int kills = s.Gains[HumanSlot];
int gained = kills + s.Income;
_report.text = string.Format("Welle {0} abgeschlossen ({1}, {2}s) · dein Ertrag +{3} Gold (Kills {4}, Einkommen {5})", s.Round, Reason(s.EndReason), (int)s.CombatTime, gained, kills, s.Income);
StatusVisibility();
Feed.Push("Welle " + s.Round + " abgeschlossen · +" + gained + " Gold", "info", "coin");
BossClaim claim = Match.Boss.Claim;
if (s.Boss == "won" && claim != null)
{
bool own = claim.Team == Model.Team;
Feed.Push("Boss in Welle " + claim.DeployRound + " auf " + LuneUi.Upper(LuneMap.LaneNames[claim.Lane]) + " (" + LuneUiTheme.TeamName(claim.Team) + ")", own ? "good" : "danger", "crown", LuneFocusTarget.ForLane(claim.Lane));
}
int level = OwnHeroLevel();
if (level > _heroLevel && _heroLevel > 0)
Feed.Push(LuneContent.Heroes[Model.OwnHero.HeroId].Name + " erreicht Stufe " + level, "good", "star", LuneFocusTarget.ForCamera("hero"));
_heroLevel = level;
}
int OwnHeroLevel()
{
HeroState hero = Model.OwnHero;
return hero == null ? 0 : LuneContent.LevelFor(hero.Xp);
}
void RememberBuildings()
{
_knownAlive.Clear();
foreach (BuildingDef definition in Match.Map.Buildings)
_knownAlive[definition.Id] = Model.Building(definition.Id).Alive;
}
/// <summary>Diffs public state for the event feed: fallen buildings, Ancient thresholds, the boss.</summary>
void TrackEvents()
{
if (Model.Over && _knownAlive.Count == 0)
return;
foreach (BuildingDef definition in Match.Map.Buildings)
{
string id = definition.Id;
bool alive = Model.Building(id).Alive;
if (_knownAlive.GetValueOrDefault(id, alive) && !alive)
{
bool own = definition.Team == Model.Team;
string place = definition.Lane == "base" ? "BASIS" : LuneUi.Upper(LuneMap.LaneNames.TryGetValue(definition.Lane, out string name) ? name : definition.Lane);
Feed.Push(place + " – " + LuneHudModel.BuildingTitle(id) + " (" + LuneUiTheme.TeamName(definition.Team) + ") zerstört", own ? "danger" : "good", "tower", LuneFocusTarget.ForPoint(definition.Position));
}
_knownAlive[id] = alive;
}
for (int side = 0; side < 2; side++)
{
int team = side == 0 ? Model.Team : Model.Enemy;
LuneHudModel.BuildingInfo core = Model.Ancient(team);
int level = core.Fraction < .3 ? 2 : core.Fraction < .5 ? 1 : 0;
if (level > _ancientLevel[side] && core.Alive && level == 2)
{
int percent = LuneUiTheme.Round(core.Fraction * 100.0);
Feed.Push(side == 0 ? "Dein Ancient kritisch (" + percent + " %)" : "Gegnerischer Ancient kritisch (" + percent + " %)", side == 0 ? "danger" : "good", "warning",
LuneFocusTarget.ForCamera(side == 0 ? "base" : "enemy_base"));
if (!LunePreferences.ReducedMotion)
_ancients[side].Pulse = 1.5f;
}
_ancientLevel[side] = level;
}
if (!Model.Fighting)
return;
foreach (LuneUnit unit in Match.Combat.Units)
{
if (unit.Kind != "boss")
continue;
string key = unit.Zone + "-" + unit.Id;
bool was = _bossSeen.GetValueOrDefault(key, unit.Alive);
if (was && !unit.Alive)
{
if (unit.Zone == "grotto")
{
LuneHudModel.BossInfo boss = Model.Boss();
if (boss.Team >= 0)
Feed.Push("Bossgrotte erobert – " + LuneUiTheme.TeamName(boss.Team), boss.Own ? "good" : "warn", "crown", LuneFocusTarget.ForCamera("grotto"));
}
else
{
Feed.Push("SCHLUNDKRONE besiegt (" + LuneUi.Upper(LuneMap.LaneNames.TryGetValue(unit.Lane, out string name) ? name : "") + ")", unit.Team != Model.Team ? "good" : "warn", "crown", LuneFocusTarget.ForLane(unit.Lane));
}
}
_bossSeen[key] = unit.Alive;
}
}
void FocusTarget(LuneFocusTarget target)
{
if (target == null)
return;
if (target.Lane.Length > 0 && Array.IndexOf(LuneMap.Lanes, target.Lane) >= 0)
LaneFocusRequested?.Invoke(target.Lane);
else if (target.Point.HasValue)
MapFocusRequested?.Invoke(target.Point.Value);
else if (target.Camera.Length > 0)
CameraRequested?.Invoke(target.Camera);
}
static string Reason(string reason)
{
return reason == "cleared" ? "alle Einheiten gefallen" : reason == "timeout" ? "Zeitfenster abgelaufen" : reason == "core" ? "Ancient zerstört" : reason;
}
// ---------------------------------------------------------------- public API
public void Notify(string message, bool error = false)
{
_toast.text = message ?? "";
LuneUi.SetColor(_toast, error ? LuneUiTheme.Danger : LuneUiTheme.Good);
_toastLeft = 5f;
StatusVisibility();
_dirty = true;
}
public string ToastText { get { return _toast.text; } }
/// <summary>The Ancient bar of a side (0 own, 1 enemy) pulses after turning critical (off with reduced motion).</summary>
public bool AncientPulsing(int side) { return _ancients[side].Pulse > 0f; }
public string ReportText { get { return _report.text; } }
public void SetSpeedLabel(int value, bool paused)
{
_speed.Text = value + "×";
_pauseGlyph.Glyph = paused ? "play" : "pause";
}
/// <summary>G2 compatibility: 0 = troops, 1 = hero, 2 = attack champions.</summary>
public void ShowPage(int index)
{
if (index == 1)
{
OpenHero();
return;
}
CloseModal();
if (index == 2)
OpenChampions();
else
OpenTroops();
}
public void OpenHero(int tab = LuneHeroDetail.TabValues)
{
HeroDetail.Tab = tab;
HeroDetail.ShowTab(tab);
HeroDetail.Apply(Model);
_modal.OpenWith(HeroDetail.TitleText(Model));
}
public void OpenItems() { OpenHero(LuneHeroDetail.TabItems); }
public void OpenTalents() { OpenHero(LuneHeroDetail.TabTalents); }
public void CloseModal() { _modal.Close(); }
public bool ModalOpen { get { return _modal.Open; } }
public void OpenChampions()
{
_purchaseTabs.Select(1);
ShowPurchase(1);
}
public void OpenTroops()
{
_purchaseTabs.Select(0);
ShowPurchase(0);
}
void ShowPurchase(int index)
{
LuneUi.Show(_troopRow, index == 0);
LuneUi.Show(ChampionPanel, index == 1);
LuneUi.Show(_army, index == 0);
_purchase.style.flexGrow = index == 1 ? 1f : 0f;
_dirty = true;
if (index == 1)
ChampionPanel.Apply(Model);
}
public void SelectUnit(string uid)
{
SelectedUid = uid ?? "";
_dirty = true;
}
public void ClearSelection()
{
SelectedUid = "";
_dirty = true;
}
/// <summary>Esc: closes the options, the hero window or the selection (in this order); false if nothing was open.</summary>
public bool HandleEscape()
{
if (Settings.Open)
{
Settings.Close();
return true;
}
if (_modal.Open)
{
CloseModal();
return true;
}
if (SelectedUid.Length > 0)
{
ClearSelection();
return true;
}
return false;
}
public string PublicDigest() { return Model.PublicDigest(); }
/// <summary>
/// Whether a screen point (pixels, origin bottom left as Input.mousePosition) lies on a HUD
/// element that takes the mouse: then the world must not react (wheel zoom, field clicks).
/// </summary>
public bool IsPointerOverHud(Vector2 screen)
{
if (Root == null || Root.panel == null)
return false;
// Pixels of the render target (screen or texture), y flipped to the panel's top-left origin.
Vector2 panelPoint = PixelToPanel(new Vector2(screen.x, TargetSize.y - screen.y));
VisualElement picked = Root.panel.Pick(panelPoint);
return picked != null && picked != Root && picked != _hud;
}
/// <summary>Panel coordinates (logical) of a point given in target pixels with origin top left (tests).</summary>
public Vector2 PixelToPanel(Vector2 pixel)
{
return pixel / (Mathf.Min(TargetSize.x / Reference.x, TargetSize.y / Reference.y) * UiScale);
}
// ---------------------------------------------------------------- tooltips
LuneTip WaveTip()
{
LuneHudModel.WaveInfo wave = Model.NextWave();
return new LuneTip
{
Title = "Welle " + Match.RoundNumber, Glyph = "clock",
Text = "Wellen sind unbegrenzt: Die Partie endet nur, wenn ein Ancient fällt. Monster werden jede Runde stärker, alle drei Runden kommt eines je Lane hinzu (bis 7).",
Note = "Kommende Welle " + wave.Round + ": " + wave.Count + " Monster je Lane.",
};
}
LuneTip AncientTip(AncientBlock block)
{
LuneHudModel.BuildingInfo ancient = block.Data;
if (ancient == null)
return null;
int team = ancient.Team;
int standing = (ancient.Base[0].Alive ? 1 : 0) + (ancient.Base[1].Alive ? 1 : 0);
return new LuneTip
{
Title = "Ancient " + LuneUiTheme.TeamName(team) + " · " + LuneUiTheme.TeamEmblems[team], Glyph = "emblem_" + team, GlyphColor = LuneUiTheme.TeamColor(team),
Text = "Fällt ein Ancient, ist die Partie vorbei. Er ist erst angreifbar, wenn beide Basistürme (Kernwacht) gefallen sind; die fallen erst, wenn eine Lane ganz offen ist.",
Note = block.Own ? "B: Kamera zum eigenen Ancient" : "",
}.Stat("heart", "Leben", LuneUiTheme.Number(ancient.Hp) + " / " + LuneUiTheme.Number(ancient.MaxHp)).Stat("tower", "Basistürme", standing + " von 2 stehen");
}
LuneTip BaseTip(AncientBlock block, int index)
{
LuneHudModel.BuildingInfo ancient = block.Data;
if (ancient == null)
return null;
LuneHudModel.BuildingInfo baseTower = ancient.Base[index];
return new LuneTip
{
Title = "Kernwacht " + (index + 1) + " · " + LuneUiTheme.TeamName(baseTower.Team), Glyph = "tower", GlyphColor = LuneUiTheme.TeamColor(baseTower.Team),
Text = baseTower.Alive ? "Basisturm vor dem Ancient. Angreifbar, sobald auf einer Lane Grenzwacht und Bastion gefallen sind." : "Zerstört.",
}.Stat("heart", "Leben", LuneLaneIndicator.TowerValue(baseTower));
}
LuneTip GoldTip()
{
return new LuneTip
{
Title = "Dein Gold", Glyph = "coin", GlyphColor = LuneUiTheme.Gold,
Text = "Dazu kommt Gold für besiegte Monster, Champions, Gebäude und den Boss auf der eigenen Seite (Teile gehen ans Team).",
Note = "Das Gold der anderen Spieler bleibt privat.",
}.Stat("coin", "Jetzt", LuneUiTheme.Number(Model.OwnGold)).Stat("clock", "Einkommen", "+" + LuneUiTheme.Number(Model.Income) + " nach dieser Welle" + (Model.IncomeBonus > 0 ? " (davon " + Model.IncomeBonus + " Söldner-Einkommen aus Angriffschampions)" : ""));
}
LuneTip StarterTip()
{
List<string> ids = Match.BaseIds(Model.Me.Bund);
int cost = 0;
var names = new List<string>();
foreach (int index in new[] { 1, 0, 2 })
{
UnitDef data = DataCatalog.GetUnit(ids[index]);
cost += data.Cost;
names.Add(data.DisplayName);
}
return new LuneTip { Title = "Startaufstellung · " + cost + " Gold", Text = "Kauft " + string.Join(", ", names) + " für deine Lane." };
}
LuneTip ReadyTip()
{
if (Model.Locked)
return new LuneTip { Title = "Bereit gemeldet", Text = "Deine Planung ist verbindlich. Der Kampf beginnt, sobald alle bereit sind." };
return new LuneTip { Title = "Bereit", Text = "Übernimmt deine Planung verbindlich. Der Kampf läuft automatisch über alle drei Lanes.", Note = "Leertaste" };
}
}
}