using System;
using System.Collections.Generic;
using System.Linq;
using Luneraria.Core;
using Luneraria.View;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
///
/// 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.
///
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;
/// Smallest logical area the layout is built for; the UI scale never shrinks below it.
public static readonly Vector2 MinArea = new Vector2(1560f, 880f);
/// Godot base size of the content scale (canvas_items, expand).
public static readonly Vector2Int Reference = new Vector2Int(1600, 960);
public static readonly Dictionary LaneKeys = new Dictionary { { "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 CommandRequested;
public event Action UndoRequested;
public event Action ReadyRequested;
public event Action StarterRequested;
public event Action SpeedRequested;
public event Action PauseRequested;
public event Action LaneFocusRequested;
public event Action MapFocusRequested;
public event Action 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; }
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 Lanes = new Dictionary();
public readonly Dictionary ShopCards = new Dictionary();
public readonly Dictionary Chips = new Dictionary();
public string SelectedUid { get; private set; } = "";
/// Counters for tests and the performance log.
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 _ancients = new List();
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 _knownAlive = new Dictionary();
readonly int[] _ancientLevel = new int[2];
readonly Dictionary _bossSeen = new Dictionary();
int _heroLevel;
Vector2 _area;
// ---------------------------------------------------------------- build
///
/// Builds the HUD for one seat. target: null for the screen, or a render texture (tests and
/// captures in batch mode, any resolution).
///
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();
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() ?? gameObject.AddComponent();
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);
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(evt => Tooltip.Pointer = evt.position, TrickleDown.TrickleDown);
_hud.RegisterCallback(evt => Tooltip.Pointer = evt.position, TrickleDown.TrickleDown);
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(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(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);
}
}
}
/// Ancient block: team emblem, name, HP bar and value, the two base towers, critical badge.
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(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(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(evt => evt.StopPropagation());
_right.Add(BossCard);
BossCard.FocusRequested += () => CameraRequested?.Invoke("grotto");
BossCard.LaneChosen += lane => CommandRequested?.Invoke(Command.BossLaneChoice(lane));
NextWave = new LuneNextWave();
NextWave.RegisterCallback(evt => evt.StopPropagation());
_right.Add(NextWave);
UnitDetail = new LuneUnitDetail();
UnitDetail.RegisterCallback(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(_ => 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();
}
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(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
/// Size of the render target in pixels (screen or texture).
public Vector2 TargetSize { get { return _target != null ? new Vector2(_target.width, _target.height) : new Vector2(Screen.width, Screen.height); } }
///
/// 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.
///
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();
}
/// Logical size of the HUD (area / UI scale).
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;
}
/// Rectangles of the main regions in logical pixels (layout tests).
public Dictionary Regions()
{
var result = new Dictionary
{
{ "top", _top }, { "system", _system }, { "left", _left }, { "right", _right }, { "bottom", _bottom }, { "ready", _readyBox }, { "minimap", _mapPanel },
};
var rects = new Dictionary();
foreach (KeyValuePair pair in result)
{
if (LuneUi.Visible(pair.Value))
rects[pair.Key] = _hud.WorldToLocal(pair.Value.worldBound);
}
return rects;
}
// ---------------------------------------------------------------- per frame
void Update()
{
if (Match == null)
return;
float delta = Time.unscaledDeltaTime;
Tick(delta);
}
/// One HUD frame (Godot _process); tests call it with simulated time.
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;
}
/// True for one second after a combat ended: Bereit (click or Space) is ignored meanwhile.
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 pair in Lanes)
pair.Value.Apply(Model.Lane(pair.Key), Model.Team, delta, reduced);
_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);
List 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 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();
var aliveLane = new Dictionary();
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();
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 roster, bool fighting)
{
var order = LuneMap.Lanes.Concat(new[] { LuneMatch.Grotto }).ToList();
List 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 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;
}
}
}
/// Largest chip edge (46 … 30 px) at which the whole army fits into the strip.
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(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(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;
}
/// Diffs public state for the event feed: fallen buildings, Ancient thresholds, the boss.
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; } }
/// The Ancient bar of a side (0 own, 1 enemy) pulses after turning critical (off with reduced motion).
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";
}
/// G2 compatibility: 0 = troops, 1 = hero, 2 = attack champions.
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;
}
/// Esc: closes the options, the hero window or the selection (in this order); false if nothing was open.
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(); }
///
/// 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).
///
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;
}
/// Panel coordinates (logical) of a point given in target pixels with origin top left (tests).
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");
}
LuneTip StarterTip()
{
List ids = Match.BaseIds(Model.Me.Bund);
int cost = 0;
var names = new List();
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" };
}
}
}