champions-of-luneraria/Assets/Luneraria/UI/Game/LuneNetPlay.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

245 lines
7.9 KiB
C#

using System;
using Luneraria.Core;
using Luneraria.Net;
using Luneraria.View;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// Owns the session and the TCP link (port of lune_main.gd): title menu, match, return,
/// rematch. Offline is a local host with bot-filled seats. Host/Join uses LuneSession over
/// LuneTcp. Every setup from the host (first join, rejoin, rematch) relaunches the match.
/// </summary>
[DefaultExecutionOrder(-50)]
public sealed class LuneNetPlay : MonoBehaviour
{
public LuneGame Game;
public LuneMatchDriver Driver;
public LuneMatchView View;
public LuneCameraRig Rig;
public LuneUiAssets UiAssets;
public LuneSession Session { get; private set; }
public LuneMainMenu Menu { get; private set; }
LuneTcp _tcp;
MatchOptions _lastOptions;
bool _relaunch;
UIDocument _document;
void Awake()
{
if (Game == null)
Game = GetComponent<LuneGame>();
if (Driver == null)
Driver = GetComponent<LuneMatchDriver>();
if (View == null)
View = GetComponent<LuneMatchView>();
if (Rig == null)
Rig = GetComponent<LuneCameraRig>();
_document = GetComponent<UIDocument>();
}
void Start()
{
var root = new VisualElement();
root.style.position = Position.Absolute;
root.style.left = 0f;
root.style.top = 0f;
root.style.right = 0f;
root.style.bottom = 0f;
if (_document != null && _document.rootVisualElement != null)
{
_document.rootVisualElement.Add(root);
}
else
{
// Tests and captures build their own panel; a runtime fallback still shows the menu.
root.name = "lune-menu-root";
gameObject.AddComponent<UIDocument>();
}
Menu = new LuneMainMenu();
root.Add(Menu);
Menu.StartOffline += StartOffline;
Menu.HostRequested += HostMatch;
Menu.JoinRequested += JoinMatch;
Menu.QuitRequested += Quit;
Menu.Enter();
if (Game != null)
{
Game.Net = this;
Game.enabled = false;
}
if (Driver != null)
Driver.enabled = false;
}
void Update()
{
if (_tcp != null)
_tcp.Pump();
if (_relaunch)
{
_relaunch = false;
Launch();
}
}
void StartOffline(MatchOptions options)
{
ResetSession();
Session = new LuneSession();
Session.HostOffline(options.HumanSlot, options);
_lastOptions = options;
Menu.Leave();
Launch();
}
void HostMatch(int port, string displayName)
{
ResetSession();
_tcp = new LuneTcp();
Session = new LuneSession { Link = _tcp };
_tcp.Session = Session;
Session.SessionMessage += OnMessage;
if (!_tcp.Host(port))
{
Menu.Notify(_tcp.LastError, true);
ResetSession();
return;
}
var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = 0 };
for (int slot = 0; slot < 6; slot++)
options.Bunds.Add(slot == 0 ? Menu.Bund : DataCatalog.Factions[(slot + options.Seed) % DataCatalog.Factions.Length]);
if (!Session.HostNetwork(options, displayName))
{
Menu.Notify(Session.LastError, true);
ResetSession();
return;
}
_lastOptions = options;
Menu.Notify("Warte auf Gäste auf Port " + port + " …");
Menu.Leave();
Launch();
}
void JoinMatch(string address, int port, string displayName)
{
ResetSession();
_tcp = new LuneTcp();
Session = new LuneSession { Link = _tcp };
_tcp.Session = Session;
Session.SessionMessage += OnMessage;
Session.HostLost += () =>
{
string reason = Session != null ? Session.LastError : "";
ReturnToMenu();
if (Menu != null)
Menu.Notify(reason, true);
};
Session.MatchResetup += () =>
{
if (Session != null && Session.MatchState != null)
_relaunch = true;
};
if (!Session.JoinNetwork(displayName) || !_tcp.Join(address, port))
{
Menu.Notify(_tcp.LastError.Length > 0 ? _tcp.LastError : Session.LastError, true);
ResetSession();
return;
}
Menu.Notify("Verbinde mit " + address + ":" + port + " …");
}
void OnMessage(string text, bool isError)
{
if (Menu != null && LuneUi.Visible(Menu))
Menu.Notify(text, isError);
if (Game != null && Game.Hud != null && !isError)
Game.Hud.Notify(text, isError);
}
void Launch()
{
if (Game == null || Session == null || Session.MatchState == null)
return;
bool networked = Session.IsNetworked();
Game.Net = this;
Game.Session = networked ? Session : null;
Game.HumanSlot = Session.LocalSlot >= 0 ? Session.LocalSlot : 0;
if (Driver != null)
{
Driver.enabled = true;
if (networked)
Driver.Adopt(Session.MatchState, Session.LocalSlot, !Session.IsAuthority(), Session);
else
{
// Offline keeps the plain runner; the session already validated the setup path.
Driver.HumanSlot = Game.HumanSlot;
Driver.Bunds = _lastOptions != null && _lastOptions.Bunds != null ? _lastOptions.Bunds.ToArray() : null;
Driver.Seed = _lastOptions != null ? _lastOptions.Seed : Driver.Seed;
Driver.Restart();
}
}
Game.enabled = true;
Game.Launch();
}
/// <summary>Host rematch: a new match with the same seats; clients relaunch on setup.</summary>
public void Rematch()
{
if (Session == null || !Session.IsAuthority())
{
if (Game != null && Game.Hud != null)
Game.Hud.Notify("Eine Revanche startet der Host.", true);
return;
}
if (_lastOptions != null)
_lastOptions.Seed += 1;
MatchOptions options = _lastOptions ?? new MatchOptions();
Session.HostRematch(options);
_relaunch = true;
}
public void ReturnToMenu()
{
if (Game != null)
Game.enabled = false;
if (Driver != null)
Driver.enabled = false;
ResetSession();
if (Menu != null)
Menu.Enter();
}
void ResetSession()
{
if (_tcp != null)
{
_tcp.Close();
_tcp = null;
}
if (Session != null)
{
Session.Close();
Session = null;
}
}
void OnDestroy()
{
ResetSession();
}
static void Quit()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
}