champions-of-luneraria/Assets/Luneraria/UI/Game/LuneGame.cs
Martin Stickelbroeck 66867f3332 U3D Abnahme: Prüfung, Feld um das HUD, Bilder, Leistung
Das G3-HUD ist abgenommen; der Meilenstein U3 ist damit fertig.

- Prüfung (Port von verify_g3, Teile A–H plus I): 718 Prüfungen ohne
  Fehler. Teil I prüft die Feld-Darstellung um das HUD (U3-Zugabe aus
  dem Motivvergleich): Aufstellungsflächen, Grotteneingang, ruhender
  Boss, Namensschilder, Eigentümer-Ring, Ancient-Balken.
- Feld um das HUD: Namensschilder für Helden (mit Stufe), Angriffs-
  champions und den eingesetzten Boss („für Blau“), Eigentümer-Ring,
  Ancient-Balken bei Schaden, ruhende Schlundkrone auf dem Podest,
  eigener Grotteneingang, Aufstellungsflächen als eigene Objekte.
- Schriften korrigiert: Die Referenz deklariert Cantarell 560/700–760
  und Cinzel 700, wendet die Gewichte aber nie an (variation_opentype
  mit dem String-Schlüssel „wght“ wird von Godot ignoriert). G3 zeigt
  Regular 400, der Port ebenso — Cinzel-Regular statt Bold, keine
  Kontur zur Aufdickung. Laufweite aus spacing_glyph umgerechnet;
  dunkler Saum als Schatten statt TextCore-Kontur.
- Werkzeuge: HudViewsTests (14 G3-Motive), HudPlaytest (Spieltest über
  die echte Oberfläche per Befehlsdateien), ui_ms_per_frame im
  LuneBenchmark. Layout folgt den echten Panelhöhen, Kamera startet wie
  Godot _ready auf der eigenen Lane.
- Tests: EditMode 23/23, PlayMode 13/13, Kerntests 402/402, Verify
  718/0. Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A,
  7B8E43AEFC0D60C5).
- Bilder: 14 Motive bei 1920×1080, Auflösungen 1152×720 bis 2560×1440,
  Prüf-Schlüsselbilder, Spieltest-Screenshots.
- Leistung (macOS-Player mit HUD, 116 Einheiten): 169 fps Übersicht,
  218 fps Mid nah; HUD-Skripte 0,090 bzw. 0,078 ms je Bild.
- Dokumentation: U3-UI (U3D, Schriftbefund), PORT-QA (U3), PORT-STATUS
  (U3 fertig, U4 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES.

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

603 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Linq;
using Luneraria.Core;
using Luneraria.View;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// 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.
/// </summary>
[DefaultExecutionOrder(200)]
public sealed class LuneGame : MonoBehaviour
{
public LuneMatchDriver Driver;
public LuneMatchView View;
public LuneCameraRig Rig;
public LuneUiAssets UiAssets;
/// <summary>The human seat (Godot default 1: Blau Mid). -1 makes the match a bot match watched from seat 0.</summary>
public int HumanSlot = 1;
/// <summary>Optional render target for tests and captures (null: the screen).</summary>
public RenderTexture Target;
public LuneHud Hud { get; private set; }
public LuneMatch Match { get { return Driver.Runner.Match; } }
/// <summary>The human seat of the running match (-1: a bot match, e.g. tests and the benchmark).</summary>
int Seat { get { return _runner != null ? _runner.HumanSlot : -1; } }
bool Human { get { return Seat >= 0; } }
/// <summary>Whose view the HUD shows: the human seat, or in a bot match seat max(0, HumanSlot) as in Godot.</summary>
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<LuneHud>() ?? gameObject.AddComponent<LuneHud>();
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;
}
/// <summary>(Re)builds the HUD for the driver's current match and wires its events.</summary>
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<string> 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;
}
}
/// <summary>The own hero: its planning preview or, in combat, the live unit.</summary>
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;
}
/// <summary>The first view of a match (Godot _ready): the own lane at planning distance, a bot match from above.</summary>
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
/// <summary>
/// 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.
/// </summary>
void FindPads()
{
_pads.Clear();
_grottoRings.Clear();
foreach (Renderer renderer in FindObjectsByType<Renderer>(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 = "";
}
/// <summary>
/// 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.
/// </summary>
void UpdateDeployment()
{
bool planning = Match.Phase == Phase.Planning && Human;
var used = new Dictionary<string, int>();
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);
}
}
/// <summary>
/// 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).
/// </summary>
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<string, int>();
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();
}
/// <summary>After the match view moved the figures: name tags and the minimap's camera frame.</summary>
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,
};
/// <summary>Game keys (Godot _input); they act even while a HUD button has focus.</summary>
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)); } }
/// <summary>One game key, as pressed by the player (also the entry point of the playtest).</summary>
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<string> ids = Match.BaseIds(Match.Players[Seat].Bund);
Feedback(Send(Command.Buy(ids[index])));
}
/// <summary>Left click in the field selects an own unit (details in the HUD); it never orders anything.</summary>
void HandleClicks()
{
if (Input.GetMouseButtonDown(0))
ClickField(Input.mousePosition);
}
/// <summary>A left click at a screen point (pixels, origin bottom left); HUD areas take it themselves.</summary>
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();
}
/// <summary>The own troop or hero whose figure is nearest to a screen point (within 34 px), or null.</summary>
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
}
}
}