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;
public LuneHud Hud { get; private set; }
public LuneMatch Match { get { return 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()
{
_camera = View != null && View.ViewCamera != null ? View.ViewCamera : Camera.main;
// The benchmark keeps its bot match; otherwise the configured seat plays.
if (!LuneBenchmark.Running && (Driver.HumanSlot != HumanSlot || Driver.Runner == null || Driver.Runner.HumanSlot != Driver.HumanSlot))
{
Driver.HumanSlot = HumanSlot;
Driver.Restart();
}
FindPads();
Hud = gameObject.GetComponent() ?? gameObject.AddComponent();
Bind();
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;
}
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 += () => Feedback(Human ? Match.Undo(Seat) : new CommandResult(false, "Kein eigener Platz."));
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.");
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;
_runner.Ready();
Hud.MarkDirty();
}
void CycleSpeed()
{
Driver.CycleSpeed();
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
}
void TogglePause()
{
if (Driver.TogglePause())
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
}
void Rematch()
{
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 (stage == LuneMatchRunner.Stage.Planning)
{
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;
LuneUi.Label("Partie verlassen?", "HeadingLabel", column);
LuneUi.Wrap(LuneUi.Label("Ein Hauptmenü gibt es im Unity-Port noch nicht; Verlassen beendet das Spiel.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter;
VisualElement row = LuneUi.Box(false, 12f, column);
new LuneButton("SPIEL BEENDEN", LuneButtons.Primary, row).Clicked += 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
}
}
}