champions-of-luneraria/Assets/Luneraria/UI/Game/LuneGame.cs
Martin Stickelbroeck 769e622952 U4 Netzwerk: LuneSession, TCP, Host/Join, 43+12 Prüfungen
Der M4-Umfang ist als Protokoll 4 portiert, serverautoritativ. Der Host
besitzt LuneMatch und rechnet jeden Kampf; Clients senden nur Absichten
und zeigen den Kampf aus Host-Momentaufnahmen.

- LuneSession (Core/Net): Rollen offline/Host/Gast, sechs Sitze, Lobby,
  Hallo mit Protokollprüfung und Namenskürzung, Beitritt und Wieder-
  einstieg über den Anzeigenamen, private Planung mit Spoof-Schutz,
  Bereit ist verbindlich, Bots planen einmal je Runde, Disconnect gibt
  den Platz an einen Bot (nie Kernverlust), Übernahme verwirft den
  unbestätigten Bot-Entwurf, Revanche hält die Plätze, Hostverlust
  meldet und führt ins Menü zurück.
- Kampfsynchronisation: Beginn mit Einheitenätzen und Gebäude-HP, alle
  0,05 s neue Einheiten und ein Frame (13 Werte je Einheit), späte
  Frames über die Serial verworfen, Gast interpoliert (frame_alpha).
- Transport: LuneLoopback (In-Prozess) für die Tests, LuneTcp + LuneWire
  (Cursor-Codec) für Echtverbindungen statt ENet; ein Socket je Gast.
- Oberfläche: LuneMainMenu (Offline/Hosten/Beitreten, Bund, Lane, Name,
  Adresse, Port) als Startbildschirm, LuneNetPlay als Schale wie
  lune_main.gd. LuneGame leitet Befehle, Rückgängig und Bereit im
  Netzspiel über die Sitzung; Tempo und Pause bestimmt der Host.
- Tests: NetSuite 43/43 (Port von test_lune_net.gd), NetRoundsSuite
  12/12 (Port von net_rounds.py: 5 Runden, Prüfsummen gleich, Gast
  rechnet 0 Schritte, Drop und Wiedereinstieg auf denselben Sitz),
  LuneWireTests Round-trip. EditMode 29/29, PlayMode 13/13, Kerntests
  457/457. Fingerabdrücke der Bot-Partien unverändert.
- Dokumentation: U4-NETWORK (Abnahme, Grenzen), PORT-QA (U4), PORT-STATUS
  (U4 fertig, U5 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:14:42 +02:00

669 lines
26 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;
/// <summary>Network session (U4); null offline. Commands and Ready go through it.</summary>
public LuneSession Session;
/// <summary>Menu/shell that owns the session (U4); null in tests and the benchmark.</summary>
public LuneNetPlay Net;
public LuneHud Hud { get; private set; }
public LuneMatch Match { get { return _runner != null ? _runner.Match : 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()
{
if (Net != null)
return;
Launch();
}
/// <summary>Builds the HUD for the current match (also called by the network shell).</summary>
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<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;
}
bool Networked { get { return Session != null && Session.IsNetworked(); } }
bool IsClient { get { return Networked && !Session.IsAuthority(); } }
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 += () =>
{
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<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;
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;
}
}
/// <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 (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,
};
/// <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("Zurück zum Hauptmenü mit Offline, Hosten und Beitreten.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter;
VisualElement row = LuneUi.Box(false, 12f, column);
new LuneButton("ZUM MENÜ", 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
}
}
}