using System; using System.Collections.Generic; using System.Linq; using Luneraria.Core; using Luneraria.View; using UnityEngine; using UnityEngine.UIElements; namespace Luneraria.UI { /// /// One Champions of Luneraria match on screen with the HUD (port of the HUD side of /// scripts/luneraria/lune_game.gd): the human player's commands go through the same command /// interface as the bots (LuneMatch.Submit); Ready, tempo and pause go to the host loop; HUD /// clicks and hotkeys move the camera; a left click in the field selects an own unit. During /// planning the own draft army stands in its formation and the own deployment areas light up. /// Offline, planning waits for the human; pause and tempo are local. Nothing here decides a rule. /// Runs after the match view (execution order 200 after 100), so name tags follow the figures /// of the same frame. /// [DefaultExecutionOrder(200)] public sealed class LuneGame : MonoBehaviour { public LuneMatchDriver Driver; public LuneMatchView View; public LuneCameraRig Rig; public LuneUiAssets UiAssets; /// The human seat (Godot default 1: Blau Mid). -1 makes the match a bot match watched from seat 0. public int HumanSlot = 1; /// Optional render target for tests and captures (null: the screen). public RenderTexture Target; /// Network session (U4); null offline. Commands and Ready go through it. public LuneSession Session; /// Menu/shell that owns the session (U4; in the scene since the U4 addendum); null in tests and the benchmark. public LuneNetPlay Net; public LuneHud Hud { get; private set; } public LuneMatch Match { get { return _runner != null ? _runner.Match : Driver.Runner.Match; } } /// The human seat of the running match (-1: a bot match, e.g. tests and the benchmark). int Seat { get { return _runner != null ? _runner.HumanSlot : -1; } } bool Human { get { return Seat >= 0; } } /// Whose view the HUD shows: the human seat, or in a bot match seat max(0, HumanSlot) as in Godot. int Viewer { get { return Human ? Seat : Math.Max(0, HumanSlot); } } Camera _camera; LuneMatchRunner _runner; readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch(); double _updateMs; double _lateMs; LuneMatchRunner.Stage _lastStage = (LuneMatchRunner.Stage)(-1); int _laneCycle; bool _pausedBySettings; VisualElement _leave; readonly List<(Renderer renderer, int team, string lane)> _pads = new List<(Renderer, int, string)>(); readonly List<(GameObject ring, int team)> _grottoRings = new List<(GameObject, int)>(); string _deployment = ""; MaterialPropertyBlock _block; void Start() { if (Net != null) return; Launch(); } /// Builds the HUD for the current match (also called by the network shell). public void Launch() { _camera = View != null && View.ViewCamera != null ? View.ViewCamera : Camera.main; // The benchmark keeps its bot match; otherwise the configured seat plays. if (!LuneBenchmark.Running && Net == null && (Driver.HumanSlot != HumanSlot || Driver.Runner == null || Driver.Runner.HumanSlot != Driver.HumanSlot)) { Driver.HumanSlot = HumanSlot; Driver.Restart(); } FindPads(); Hud = gameObject.GetComponent() ?? gameObject.AddComponent(); Hud.enabled = true; Bind(); // After a return to the start screen (Close) the HUD shows again. LuneUi.Show(Hud.Root, true); LuneSettingsPanel.Apply(); if (Rig != null) { Rig.PointerBlocked = () => Hud != null && Hud.IsPointerOverHud(Input.mousePosition); Rig.KeysBlocked = () => Hud != null && Hud.Settings.Open; Rig.ResetRequested = () => ResetCamera(); } StartCamera(true); // The benchmark counts the HUD scripts: its Tick plus this controller's frame work. LuneBenchmark.UiMs = () => (Hud != null ? Hud.LastTickMs : 0.0) + _updateMs + _lateMs; } /// /// Leaves the match for the start screen (LuneNetPlay.ReturnToMenu; Godot frees the game /// node): the HUD and the deployment areas disappear and the controller stops until the /// next Launch. /// public void Close() { if (Hud != null && Hud.Root != null) { LuneUi.Show(Hud.Root, false); Hud.enabled = false; } foreach (var pad in _pads) pad.renderer.gameObject.SetActive(false); foreach (var entry in _grottoRings) entry.ring.SetActive(false); _deployment = ""; View.Preview = null; enabled = false; } bool Networked { get { return Session != null && Session.IsNetworked(); } } bool IsClient { get { return Networked && !Session.IsAuthority(); } } void OnDestroy() { LuneBenchmark.UiMs = null; } /// (Re)builds the HUD for the driver's current match and wires its events. void Bind() { _runner = Driver.Runner; _lastStage = (LuneMatchRunner.Stage)(-1); Hud.Init(_runner.Match, Viewer, View.Library, UiAssets, Target); Hud.CommandRequested += command => Feedback(Send(command)); Hud.UndoRequested += () => { if (!Human) { Feedback(new CommandResult(false, "Kein eigener Platz.")); return; } if (Networked) { SessionResult result = Session.Submit(new Command { Type = "undo" }); Feedback(new CommandResult(result.Ok, result.Message)); } else { Feedback(Match.Undo(Seat)); } }; Hud.ReadyRequested += OnReady; Hud.StarterRequested += Starter; Hud.SpeedRequested += CycleSpeed; Hud.PauseRequested += TogglePause; Hud.LaneFocusRequested += lane => FocusLane(lane); Hud.MapFocusRequested += point => Rig?.LookAt(point); Hud.CameraRequested += FocusTarget; Hud.MenuRequested += AskLeave; Hud.RematchRequested += Rematch; Hud.SettingsRequested += () => Hud.Settings.Show(); Hud.Settings.Opened += () => { // A running combat pauses offline while the options are open (lune_settings.gd). _pausedBySettings = _runner.Current == LuneMatchRunner.Stage.Combat && !Driver.Paused; if (_pausedBySettings) Driver.Paused = true; }; Hud.Settings.Closed += () => { if (_pausedBySettings) Driver.Paused = false; _pausedBySettings = false; Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused); }; Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused); BuildLeaveDialog(); } CommandResult Send(Command command) { if (!Human) return new CommandResult(false, "Zuschauer können nicht planen."); if (Networked) { SessionResult result = Session.Submit(command); return new CommandResult(result.Ok, result.Message); } return Match.Submit(Seat, command); } void Feedback(CommandResult result) { Hud.Notify(result.Message, !result.Ok); RebuildPreview(); Hud.MarkDirty(); } void Starter() { List ids = Match.BaseIds(Match.Players[Viewer].Bund); foreach (int index in new[] { 1, 0, 2 }) { CommandResult result = Send(Command.Buy(ids[index])); if (!result.Ok) { Feedback(result); return; } } Feedback(new CommandResult(true, "Startaufstellung: Front, Nahkampf und Fernkampf gekauft.")); } void OnReady() { if (Match.Phase != Phase.Planning || !Human) return; if (Networked) Session.ReadyUp(); else _runner.Ready(); Hud.MarkDirty(); } void CycleSpeed() { if (IsClient) { Hud.Notify("Tempo und Pause bestimmt der Host.", true); return; } Driver.CycleSpeed(); Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused); } void TogglePause() { if (IsClient) { Hud.Notify("Tempo und Pause bestimmt der Host.", true); return; } if (Driver.TogglePause()) Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused); } void Rematch() { if (Net != null) { Net.Rematch(); return; } Driver.Seed += 1; Driver.Restart(); View.Preview = null; Bind(); StartCamera(true); } // ---------------------------------------------------------------- camera targets int OwnTeam { get { return Match.Players[Viewer].Team; } } public void FocusLane(string lane, bool instant = false) { if (Rig == null) return; LanePath path = Match.Map.LanePaths[lane]; // Planning looks at the own deployment and inner tower, combat at the contested middle. double fraction = Match.Phase == Phase.Planning ? .2 : .42; Rig.LookAt(Match.Map.LanePoint(OwnTeam, lane, path.Length * fraction), 24f, instant); } public void FocusTarget(string target) { if (Rig == null) return; switch (target) { case "grotto": Rig.LookAt(Match.Map.GrottoCenter(), 22f); break; case "base": Rig.LookAt(Match.Map.CorePosition(OwnTeam), 26f); break; case "enemy_base": Rig.LookAt(Match.Map.CorePosition(1 - OwnTeam), 26f); break; case "hero": FocusHero(); break; default: if (Array.IndexOf(LuneMap.Lanes, target) >= 0) FocusLane(target); break; } } /// The own hero: its planning preview or, in combat, the live unit. LuneUnit OwnHeroUnit() { if (!Human) return null; LuneCombat source = Match.Phase == Phase.Combat ? Match.Combat : View.Preview; return source?.Units.FirstOrDefault(u => u.Kind == "hero" && u.OwnerSlot == Seat && u.Alive); } public bool FocusHero() { LuneUnit unit = OwnHeroUnit(); if (unit == null) { Hud.Notify("Kein Held im Feld – im Reiter „Held“ anwerben.", true); return false; } Rig?.LookAt(unit.Position, 16f); return true; } /// The first view of a match (Godot _ready): the own lane at planning distance, a bot match from above. public void StartCamera(bool instant = true) { if (Rig == null) return; if (Human) FocusLane(Match.Players[Seat].Lane, instant); else Rig.Overview(instant); } public void ResetCamera(bool instant = false) { if (Rig == null) return; string lane = Match.Players[Viewer].Lane; Rig.ResetView(Match.Map.LanePoint(OwnTeam, lane, Match.Map.LanePaths[lane].Length * .2), instant); } // ---------------------------------------------------------------- planning preview and deployment areas /// /// The formation areas (Deployment_team_lane) and grotto entrance rings (GrottoEntry_team) of /// the scene. They are built inactive, like Godot's hidden pads, so the search includes /// inactive objects. /// void FindPads() { _pads.Clear(); _grottoRings.Clear(); foreach (Renderer renderer in FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) { string[] parts = renderer.gameObject.name.Split('_'); if (parts.Length >= 3 && parts[0] == "Deployment" && int.TryParse(parts[1], out int team)) _pads.Add((renderer, team, parts[2])); else if (parts.Length == 2 && parts[0] == "GrottoEntry" && int.TryParse(parts[1], out int side)) _grottoRings.Add((renderer.gameObject, side)); } _deployment = ""; } /// /// Planning shows the own formation areas, lanes that hold own units light up, and the own /// grotto entrance while the grotto is open (Godot show_deployment). Checked every frame, /// applied only when something changed. /// void UpdateDeployment() { bool planning = Match.Phase == Phase.Planning && Human; var used = new Dictionary(); if (planning) { Draft draft = Match.Players[Seat].Draft; foreach (RosterEntry entry in draft.Roster) used[entry.Lane] = used.GetValueOrDefault(entry.Lane, 0) + 1; if (draft.Hero != null) used[draft.Hero.Lane] = used.GetValueOrDefault(draft.Hero.Lane, 0) + 1; } bool grotto = planning && Match.GrottoAvailable(); string signature = planning + "|" + OwnTeam + "|" + grotto + "|" + string.Join(",", LuneMap.Lanes.Select(lane => used.GetValueOrDefault(lane, 0) > 0 ? lane : "")); if (signature == _deployment) return; _deployment = signature; _block ??= new MaterialPropertyBlock(); foreach (var pad in _pads) { bool visible = planning && pad.team == OwnTeam; if (pad.renderer.gameObject.activeSelf != visible) pad.renderer.gameObject.SetActive(visible); if (!visible) continue; pad.renderer.GetPropertyBlock(_block); _block.SetFloat("_Highlight", used.GetValueOrDefault(pad.lane, 0) > 0 ? 1f : 0f); pad.renderer.SetPropertyBlock(_block); } foreach (var entry in _grottoRings) { bool visible = grotto && entry.team == OwnTeam; if (entry.ring.activeSelf != visible) entry.ring.SetActive(visible); } } /// /// The human player's current draft army in its formation (Godot _rebuild_preview); after /// every own command, and by fixtures that change the draft directly (g3_views). /// public void RebuildPreview() { UpdateDeployment(); if (Match.Phase != Phase.Planning || !Human) return; var preview = new LuneCombat(Match.Map, Match.Balance, Match.RoundNumber); Player player = Match.Players[Seat]; var perLane = new Dictionary(); var entries = new List<(UnitDef def, string lane, string uid, string kind)>(); foreach (RosterEntry entry in player.Draft.Roster) entries.Add((DataCatalog.GetUnit(entry.TypeId), entry.Lane, entry.Uid, "troop")); HeroState hero = player.Draft.Hero; if (hero != null) entries.Add((LuneContent.HeroDefinition(hero), hero.Lane, hero.Uid, "hero")); foreach (var entry in entries) { int index = perLane.GetValueOrDefault(entry.lane, 0); perLane[entry.lane] = index + 1; LuneUnit unit; if (entry.lane == LuneMatch.Grotto) { // Grotto orders preview at the team's grotto entrance. Vec2 at = Match.Map.GrottoEntry(player.Team) + new Vec2(index % 3 - 1.0, index / 3) * 1.1; unit = preview.SpawnAt(entry.kind, player.Team, "grotto", entry.def, at, Seat, entry.uid); } else { unit = preview.Spawn(entry.kind, player.Team, entry.lane, entry.def, Math.Max(.4, 7.2 - (index / 4) * 1.5), (index % 4 - 1.5) * 1.2, Seat, entry.uid); } unit.PreviousPosition = unit.Position; } View.Preview = preview; } // ---------------------------------------------------------------- frame void Update() { _clock.Restart(); FrameUpdate(); _updateMs = _clock.Elapsed.TotalMilliseconds; } void FrameUpdate() { if (Driver.Runner != _runner) { // Somebody restarted the driver (tests, rematch from elsewhere): follow it. View.Preview = null; Bind(); } TrackStage(); UpdateDeployment(); HandleKeys(); HandleClicks(); } /// After the match view moved the figures: name tags and the minimap's camera frame. void LateUpdate() { if (Hud == null || _camera == null) return; _clock.Restart(); Hud.FieldTags.Sync(View.Units, _camera, Hud); if (Rig != null) Hud.Minimap.SetCamera(Rig.Focus, Rig.Zoom, Rig.Footprint(_camera)); _lateMs = _clock.Elapsed.TotalMilliseconds; } void TrackStage() { LuneMatchRunner.Stage stage = _runner.Current; if (stage == _lastStage) return; LuneMatchRunner.Stage previous = _lastStage; _lastStage = stage; if (previous == LuneMatchRunner.Stage.Combat && Networked && Session.IsAuthority()) Session.HostAfterCombat(); if (stage == LuneMatchRunner.Stage.Planning) { if (Networked && Session.IsAuthority()) Session.PlanBotSeats(); RebuildPreview(); } else if (stage == LuneMatchRunner.Stage.Combat) { View.Preview = null; UpdateDeployment(); } else if (stage == LuneMatchRunner.Stage.Over && Rig != null && Match.Combat != null) { Driver.Paused = false; int fallen = Match.Combat.DestroyedCores.Contains(0) ? 0 : 1; Rig.LookAt(Match.Map.CorePosition(fallen), 26f); } Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused); } static readonly KeyCode[] GameKeys = { KeyCode.Escape, KeyCode.F1, KeyCode.M, KeyCode.F11, KeyCode.Space, KeyCode.P, KeyCode.Tab, KeyCode.F2, KeyCode.F3, KeyCode.F4, KeyCode.B, KeyCode.G, KeyCode.H, KeyCode.Alpha1, KeyCode.Alpha2, KeyCode.Alpha3, KeyCode.Alpha4, KeyCode.Alpha5, KeyCode.Keypad1, KeyCode.Keypad2, KeyCode.Keypad3, KeyCode.Keypad4, KeyCode.Keypad5, }; /// Game keys (Godot _input); they act even while a HUD button has focus. void HandleKeys() { foreach (KeyCode code in GameKeys) { if (Input.GetKeyDown(code)) Key(code); } } bool Blocked { get { return Hud.Settings.Open || (_leave != null && LuneUi.Visible(_leave)); } } /// One game key, as pressed by the player (also the entry point of the playtest). public void Key(KeyCode code) { switch (code) { case KeyCode.Escape: if (_leave != null && LuneUi.Visible(_leave)) LuneUi.Show(_leave, false); else Hud.HandleEscape(); return; case KeyCode.F1: if (Hud.Settings.Open) Hud.Settings.Close(); else Hud.Settings.Show(); return; case KeyCode.M: LunePreferences.Muted = !LunePreferences.Muted; LunePreferences.Commit(); LuneSettingsPanel.Apply(); return; case KeyCode.F11: LunePreferences.Fullscreen = !LunePreferences.Fullscreen; LunePreferences.Commit(); LuneSettingsPanel.Apply(); return; } if (Blocked) return; switch (code) { case KeyCode.Space: if (Match.Phase == Phase.Planning) { // A Space meant as pause at the very end of a combat must not skip the planning. if (!Hud.ReadyBlocked()) OnReady(); } else { TogglePause(); } return; case KeyCode.P: TogglePause(); return; case KeyCode.Tab: _laneCycle = (_laneCycle + 1) % 3; FocusLane(LuneMap.Lanes[_laneCycle]); return; case KeyCode.F2: case KeyCode.F3: case KeyCode.F4: // F1 stays the options key; F2–F4 jump to Top, Mid and Bottom. _laneCycle = code - KeyCode.F2; FocusLane(LuneMap.Lanes[_laneCycle]); return; case KeyCode.B: FocusTarget("base"); return; case KeyCode.G: FocusTarget("grotto"); return; case KeyCode.H: FocusHero(); return; } int index = code >= KeyCode.Alpha1 && code <= KeyCode.Alpha5 ? code - KeyCode.Alpha1 : code >= KeyCode.Keypad1 && code <= KeyCode.Keypad5 ? code - KeyCode.Keypad1 : -1; if (index < 0 || Match.Phase != Phase.Planning || !Human) return; if (Match.Players[Seat].Ready) { // After Ready the plan is binding (the host refuses); the keys do nothing. Hud.Notify("Bereit gemeldet – die Planung ist verbindlich.", true); return; } List ids = Match.BaseIds(Match.Players[Seat].Bund); Feedback(Send(Command.Buy(ids[index]))); } /// Left click in the field selects an own unit (details in the HUD); it never orders anything. void HandleClicks() { if (Input.GetMouseButtonDown(0)) ClickField(Input.mousePosition); } /// A left click at a screen point (pixels, origin bottom left); HUD areas take it themselves. public void ClickField(Vector2 screen) { if (_camera == null || Blocked || Hud.IsPointerOverHud(screen)) return; LuneUnit picked = PickOwnUnit(screen); if (picked != null && picked.Kind == "hero") Hud.OpenHero(); else if (picked != null) Hud.SelectUnit(picked.PersistentUid); else if (Hud.SelectedUid.Length > 0) Hud.ClearSelection(); } /// The own troop or hero whose figure is nearest to a screen point (within 34 px), or null. public LuneUnit PickOwnUnit(Vector2 screen) { if (!Human) return null; LuneUnit best = null; float bestDistance = 34f; foreach (LuneUnitView view in View.Units) { LuneUnit unit = view.Unit; if (unit == null || !unit.Alive || unit.OwnerSlot != Seat || (unit.Kind != "troop" && unit.Kind != "hero") || !view.gameObject.activeInHierarchy) continue; Vector3 at = _camera.WorldToScreenPoint(view.AimPoint); if (at.z <= 0f) continue; float distance = Vector2.Distance(new Vector2(at.x, at.y), screen); if (distance < bestDistance) { bestDistance = distance; best = unit; } } return best; } // ---------------------------------------------------------------- leaving the match void BuildLeaveDialog() { _leave = new VisualElement(); _leave.style.position = Position.Absolute; _leave.style.left = 0f; _leave.style.top = 0f; _leave.style.right = 0f; _leave.style.bottom = 0f; _leave.style.backgroundColor = new Color(.02f, .03f, .04f, .72f); _leave.style.justifyContent = Justify.Center; _leave.style.alignItems = Align.Center; var panel = new LunePanel(LunePanels.Menu); panel.style.minWidth = 520f; _leave.Add(panel); VisualElement column = LuneUi.Box(true, 14f, panel); column.style.alignItems = Align.Center; // Without the start screen (tests, benchmark) leaving ends the game. bool menu = Net != null; LuneUi.Label("Partie verlassen?", "HeadingLabel", column); LuneUi.Wrap(LuneUi.Label(menu ? "Zurück zum Hauptmenü mit Offline, Hosten und Beitreten." : "Ohne Startbildschirm wird das Spiel beendet.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter; VisualElement row = LuneUi.Box(false, 12f, column); new LuneButton(menu ? "ZUM MENÜ" : "BEENDEN", LuneButtons.Primary, row).Clicked += () => { LuneUi.Show(_leave, false); if (Net != null) Net.ReturnToMenu(); else Quit(); }; new LuneButton("Zurück", LuneButtons.Secondary, row).Clicked += () => LuneUi.Show(_leave, false); LuneUi.Show(_leave, false); Hud.Root.Add(_leave); } void AskLeave() { LuneUi.Show(_leave, true); _leave.BringToFront(); } static void Quit() { #if UNITY_EDITOR UnityEditor.EditorApplication.isPlaying = false; #else Application.Quit(); #endif } } }