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; } /// Name tags of heroes, champions and the boss in the field, behind every region. 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 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; /// The own team panel (S2 team actions; tests). public LuneTeamPanel Team { get { return _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); 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(evt => Tooltip.Pointer = evt.position, TrickleDown.TrickleDown); _hud.RegisterCallback(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(_ => Layout()); _bottom.RegisterCallback(_ => Layout()); _system.RegisterCallback(_ => 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(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); _team.CommandRequested += command => CommandRequested?.Invoke(command); 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(); 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(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 /// Main-thread cost of the last frame's Tick (milliseconds; benchmark). 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; } /// 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.Blessing = Model.State.Extended ? Model.State.Boss.Blessing[Model.Team] : -1; _team.Apply(Model.Members(Model.Team), Model.Team, planning, Model.Networked); _team.ApplyActions(Model); 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 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" + (Model.IncomeBonus > 0 ? " (davon " + Model.IncomeBonus + " Söldner-Einkommen aus Angriffschampions)" : "")); } 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" }; } } }