fix: improve minigame reconnect flow

This commit is contained in:
ud18010
2026-07-29 19:30:50 +08:00
parent 6a5e534d00
commit 42c3874d82
21 changed files with 979 additions and 52 deletions
@@ -23,6 +23,7 @@ namespace XWorld.Framework.Protocol
LobbyLeave = 15,
WorldChatSend = 16,
WorldChatMessage = 17,
MatchCancel = 18,
}
public sealed class HeartbeatMsg
@@ -47,19 +48,23 @@ namespace XWorld.Framework.Protocol
{
public string GameId; // null 编码为空字符串
public int Version;
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteString(GameId);
w.WriteVarInt(Version);
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static MatchRequestMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new MatchRequestMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
var m = new MatchRequestMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
if (r.HasMore) m.RequestId = r.ReadVarInt();
return m;
}
}
@@ -67,7 +72,9 @@ namespace XWorld.Framework.Protocol
{
public string RoomId; // null 编码为空字符串
public int Version;
public string GameId;
public int SelfPlayerId;
public int RequestId;
public List<PlayerInfo> Players = new List<PlayerInfo>();
public byte[] Encode()
@@ -91,6 +98,8 @@ namespace XWorld.Framework.Protocol
w.WriteVarInt(p.AppearanceType);
w.WriteVarInt(p.AvatarId);
}
w.WriteVarInt(RequestId);
w.WriteString(GameId);
return w.ToArray();
}
@@ -131,6 +140,14 @@ namespace XWorld.Framework.Protocol
}
}
}
if (r.HasMore)
{
m.RequestId = r.ReadVarInt();
}
if (r.HasMore)
{
m.GameId = r.ReadString();
}
return m;
}
}
@@ -144,6 +161,24 @@ namespace XWorld.Framework.Protocol
public int MaxPlayers;
}
public sealed class MatchControlMsg
{
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static MatchControlMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return r.HasMore ? new MatchControlMsg { RequestId = r.ReadVarInt() } : new MatchControlMsg();
}
}
public sealed class GameListResponseMsg
{
public List<GameInfoMsg> Games = new List<GameInfoMsg>();
@@ -187,19 +222,23 @@ namespace XWorld.Framework.Protocol
{
public string GameId;
public int Version;
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteString(GameId);
w.WriteVarInt(Version);
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static MatchAssignedMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new MatchAssignedMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
var m = new MatchAssignedMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
if (r.HasMore) m.RequestId = r.ReadVarInt();
return m;
}
}
@@ -268,19 +307,23 @@ namespace XWorld.Framework.Protocol
{
public int Code;
public string Message; // null 编码为空字符串
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(Code);
w.WriteString(Message);
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static ErrorMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new ErrorMsg { Code = r.ReadVarInt(), Message = r.ReadString() };
var m = new ErrorMsg { Code = r.ReadVarInt(), Message = r.ReadString() };
if (r.HasMore) m.RequestId = r.ReadVarInt();
return m;
}
}
@@ -1066,7 +1066,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 414, y: -333}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 720, y: 500}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2156710259985382035
@@ -1766,8 +1766,8 @@ RectTransform:
- {fileID: 6853786249801907381}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
+160 -6
View File
@@ -22,9 +22,11 @@ public class XWorldUtil
private const string LOCAL_SERVER_EXE = "../../Server/build-gateway-local3/Debug/AIProjectServer.exe";
private const string LOCAL_SERVER_DLL_DIR = "../../Server/build-local-sqlite/vcpkg_installed/x64-windows/debug/bin";
private const string LOCAL_SERVER_LOG_DIR = "../../Server/logs";
private const string MINI_GAME_GATEWAY_RUNNER_PROJECT = "../../Server/Gateway.Runner/Gateway.Runner.csproj";
private const string MINI_GAME_GATEWAY_RUNNER_DLL = "../../Server/Gateway.Runner/bin/Debug/net10.0/XWorld.Server.Gateway.Runner.dll";
private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame";
private const string MINI_GAME_GATEWAY_PID_FILE = "pc-minigame-gateway.pid";
private const string MINI_GAME_CDN_RUNNER_PROJECT = "../../Server/Cdn.Runner/Cdn.Runner.csproj";
private const string MINI_GAME_CDN_RUNNER_DLL = "../../Server/Cdn.Runner/bin/Debug/net10.0/XWorld.Server.Cdn.Runner.dll";
private const string MINI_GAME_CDN_ROOT = "../../CDN";
private const string MINI_GAME_CDN_PID_FILE = "pc-cdn.pid";
@@ -37,6 +39,7 @@ public class XWorldUtil
private const int LOCAL_INTERNAL_GAME_PORT = 7003;
private const int MINI_GAME_GATEWAY_PORT = 5005;
private const int MINI_GAME_TICK_MS = 100;
private const int MINI_GAME_RECONNECT_WINDOW_TICKS = 1800;
private const int MINI_GAME_MATCH_TIMEOUT_TICKS = 150;
private static readonly List<Process> LocalServerProcesses = new List<Process>();
@@ -323,6 +326,22 @@ public class XWorldUtil
{
StopExitedLocalServerProcesses();
SetLocalServerPrefs();
if (IsVisibleMiniGameGatewayConsoleRunning())
{
UnityEngine.Debug.Log("PC MiniGame Gateway console is already running; skipping build.");
return;
}
if (!ShouldBuildMiniGameLocalServer(IsMiniGameGatewayRunnerRunning()))
{
UnityEngine.Debug.Log("PC MiniGame Gateway is already running; skipping build.");
return;
}
if (!BuildMiniGameLocalServer())
return;
// 内网 CDN(HTTP 静态文件)独立于网关,先确保起来(端口已开则跳过)
StartMiniGameCdnRunnerProcess("XWorld PC CDN");
if (IsVisibleMiniGameGatewayConsoleRunning())
@@ -390,12 +409,26 @@ public class XWorldUtil
"OK");
}
[MenuItem("XWorld/Server/Build Local Server", false, 605)]
static public void BuildLocalServer()
{
if (BuildMiniGameLocalServer())
EditorUtility.DisplayDialog("XWorld Server", "Local server build finished.", "OK");
}
[MenuItem("XWorld/Server/Build CDN Runner", false, 606)]
static public void BuildCdnRunner()
{
if (BuildMiniGameRunnerProject("XWorld PC CDN", GetMiniGameCdnRunnerProjectPath(), GetMiniGameCdnRunnerPath()))
EditorUtility.DisplayDialog("XWorld Server", "CDN runner build finished.", "OK");
}
static public bool IsPcMiniGameGatewayReady()
{
return IsMiniGameGatewayRunnerRunning();
}
[MenuItem("XWorld/Server/Open Latest Log", false, 605)]
[MenuItem("XWorld/Server/Open Latest Log", false, 607)]
static public void OpenLatestLocalServerLog()
{
string logDir = Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_LOG_DIR));
@@ -422,7 +455,7 @@ public class XWorldUtil
[MenuItem("XWorld/Server/Open Local Server (Separate)", true)]
static public bool CanOpenLocalServer()
{
return File.Exists(GetMiniGameGatewayRunnerPath());
return File.Exists(GetMiniGameGatewayRunnerProjectPath());
}
[MenuItem("XWorld/Server/Stop Local Server", true)]
@@ -484,6 +517,11 @@ public class XWorldUtil
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_GATEWAY_RUNNER_DLL));
}
static private string GetMiniGameGatewayRunnerProjectPath()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_GATEWAY_RUNNER_PROJECT));
}
static private string GetMiniGameServerGamesRoot()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_SERVER_GAMES_ROOT));
@@ -501,6 +539,11 @@ public class XWorldUtil
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_RUNNER_DLL));
}
static private string GetMiniGameCdnRunnerProjectPath()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_RUNNER_PROJECT));
}
static private string GetMiniGameCdnRoot()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_ROOT));
@@ -523,6 +566,101 @@ public class XWorldUtil
return Path.Combine(logDir, safeTitle + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmss") + ".log");
}
static private bool BuildMiniGameLocalServer()
{
if (!BuildMiniGameRunnerProject("XWorld PC minigame gateway", GetMiniGameGatewayRunnerProjectPath(), GetMiniGameGatewayRunnerPath()))
return false;
return true;
}
static private bool ShouldBuildMiniGameLocalServer(bool isGatewayRunning)
{
return !isGatewayRunning;
}
static private bool BuildMiniGameRunnerProject(string title, string projectPath, string runnerPath)
{
if (!File.Exists(projectPath))
{
EditorUtility.DisplayDialog("XWorld Server", "Server project not found:\n" + projectPath, "OK");
return false;
}
string logPath = GetLocalServerLogPath(title + " build");
UnityEngine.Debug.Log("Building " + title + " from " + projectPath + "\nLog: " + logPath);
EditorUtility.DisplayProgressBar("XWorld Server", "Building " + title, 0.5f);
try
{
StringBuilder output = new StringBuilder();
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = "dotnet";
startInfo.Arguments = "build " + QuoteProcessArg(projectPath) + " -c Debug --no-incremental -m:1";
startInfo.WorkingDirectory = GetProjectRoot();
startInfo.UseShellExecute = false;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.CreateNoWindow = true;
using (Process process = new Process())
{
process.StartInfo = startInfo;
process.OutputDataReceived += (sender, args) =>
{
if (args.Data != null)
output.AppendLine(args.Data);
};
process.ErrorDataReceived += (sender, args) =>
{
if (args.Data != null)
output.AppendLine(args.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
process.WaitForExit(1000);
File.WriteAllText(logPath, output.ToString(), new UTF8Encoding(false));
if (process.ExitCode != 0)
{
EditorUtility.DisplayDialog("XWorld Server",
title + " build failed. Old DLL will not be started.\n\nLog:\n" + logPath,
"OK");
UnityEngine.Debug.LogError(title + " build failed with exit code " + process.ExitCode + ". Log: " + logPath);
return false;
}
}
}
catch (Exception ex)
{
File.WriteAllText(logPath, ex.ToString(), new UTF8Encoding(false));
EditorUtility.DisplayDialog("XWorld Server",
title + " build failed before launch. Old DLL will not be started.\n\n" + ex.Message + "\n\nLog:\n" + logPath,
"OK");
UnityEngine.Debug.LogError(title + " build failed: " + ex);
return false;
}
finally
{
EditorUtility.ClearProgressBar();
}
if (!IsDotnetRunnerLaunchable(runnerPath))
{
EditorUtility.DisplayDialog("XWorld Server",
title + " build finished, but runner output is incomplete:\n" + runnerPath +
"\n" + GetDotnetRunnerRuntimeConfigPath(runnerPath),
"OK");
return false;
}
UnityEngine.Debug.Log(title + " build finished. Runner: " + runnerPath);
return true;
}
static private void StartServerProcess(string arguments, string title)
{
string exePath = GetLocalServerExePath();
@@ -573,10 +711,12 @@ public class XWorldUtil
static private void StartMiniGameGatewayRunnerProcess(string title)
{
string runnerPath = GetMiniGameGatewayRunnerPath();
if (!File.Exists(runnerPath))
if (!IsDotnetRunnerLaunchable(runnerPath))
{
EditorUtility.DisplayDialog("XWorld Server",
"Gateway runner not found:\n" + runnerPath + "\n\nBuild Server/Gateway.Runner first.", "OK");
"Gateway runner output is incomplete:\n" + runnerPath +
"\n" + GetDotnetRunnerRuntimeConfigPath(runnerPath) +
"\n\nBuild Server/Gateway.Runner first.", "OK");
return;
}
@@ -612,6 +752,7 @@ public class XWorldUtil
"Write-Host ''\r\n" +
"& dotnet $runner --gamesRoot $gamesRoot --port " + MINI_GAME_GATEWAY_PORT +
" --tickMs " + MINI_GAME_TICK_MS +
" --reconnectWindowTicks " + MINI_GAME_RECONNECT_WINDOW_TICKS +
" --matchTimeoutTicks " + MINI_GAME_MATCH_TIMEOUT_TICKS +
" --lan --devToken " + DEV_DISCOVERY_TOKEN + // 开 DevDiscovery(UDP48923) 响应 + 广播 LAN IPCDN 地址由 --lan 兜底为 http://<lanip>:15081/
" *>&1 | Tee-Object -FilePath $logPath\r\n" +
@@ -645,9 +786,10 @@ public class XWorldUtil
static private void StartMiniGameCdnRunnerProcess(string title)
{
string runnerPath = GetMiniGameCdnRunnerPath();
if (!File.Exists(runnerPath))
if (!IsDotnetRunnerLaunchable(runnerPath))
{
UnityEngine.Debug.LogWarning("[CDN] 未找到 Cdn.Runner" + runnerPath + "\n请先构建 Server/Cdn.Runnerdotnet build Server/Server.sln)。");
UnityEngine.Debug.LogWarning("[CDN] Cdn.Runner output is incomplete; skipping CDN startup.\nDLL: " +
runnerPath + "\nRuntimeConfig: " + GetDotnetRunnerRuntimeConfigPath(runnerPath));
return;
}
@@ -722,6 +864,18 @@ public class XWorldUtil
return (text ?? string.Empty).Replace("'", "''");
}
static private string GetDotnetRunnerRuntimeConfigPath(string runnerPath)
{
string directory = Path.GetDirectoryName(runnerPath);
string fileName = Path.GetFileNameWithoutExtension(runnerPath) + ".runtimeconfig.json";
return Path.Combine(directory ?? string.Empty, fileName);
}
static private bool IsDotnetRunnerLaunchable(string runnerPath)
{
return File.Exists(runnerPath) && File.Exists(GetDotnetRunnerRuntimeConfigPath(runnerPath));
}
static private void StopExitedLocalServerProcesses()
{
LocalServerProcesses.RemoveAll(process =>
@@ -0,0 +1,30 @@
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
public sealed class MatchWaitingPrefabLayoutTests
{
private const string PrefabPath = "Assets/Game/Art/UI/Prefab/UI_MatchWaiting.prefab";
[Test]
public void WaitingPanel_IsCenteredInSafeArea()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
Assert.That(prefab, Is.Not.Null);
RectTransform root = prefab.GetComponent<RectTransform>();
Assert.That(root.anchorMin, Is.EqualTo(Vector2.zero));
Assert.That(root.anchorMax, Is.EqualTo(Vector2.one));
Assert.That(root.anchoredPosition, Is.EqualTo(Vector2.zero));
Assert.That(root.sizeDelta, Is.EqualTo(Vector2.zero));
Transform panel = prefab.transform.Find("SafeArea/Panel_Waiting");
Assert.That(panel, Is.Not.Null);
RectTransform rect = panel.GetComponent<RectTransform>();
Assert.That(rect.anchorMin, Is.EqualTo(new Vector2(0.5f, 0.5f)));
Assert.That(rect.anchorMax, Is.EqualTo(new Vector2(0.5f, 0.5f)));
Assert.That(rect.pivot, Is.EqualTo(new Vector2(0.5f, 0.5f)));
Assert.That(rect.anchoredPosition, Is.EqualTo(Vector2.zero));
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7b43c5afbe3e421488be033e25c0c28a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,7 @@
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using XGame;
using XGame.MiniGame;
public sealed class MiniGameStageIsolationTests
@@ -96,6 +98,51 @@ public sealed class MiniGameStageIsolationTests
Assert.IsTrue(behaviour.enabled);
}
[Test]
public void SuspendAndResume_AutoCapturesCharacterSwitchUiRoot()
{
_root = new GameObject("root");
GameObject characterObject = new GameObject("character-switch");
characterObject.transform.SetParent(_root.transform);
GameObject characterUi = new GameObject("UI_CharacterSwitch");
characterUi.transform.SetParent(_root.transform);
var controller = characterObject.AddComponent<CharacterSwitchController>();
typeof(CharacterSwitchController)
.GetField("uiPanel", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(controller, characterUi);
Assert.AreSame(characterUi, controller.StageIsolationUiRoot);
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.SuspendLobbyForMiniGame();
Assert.IsFalse(characterUi.activeSelf);
isolation.ResumeLobbyAfterMiniGame();
Assert.IsTrue(characterUi.activeSelf);
}
[Test]
public void CharacterSwitchUiLoadedWhileSuspended_IsHidden()
{
_root = new GameObject("root");
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.SuspendLobbyForMiniGame();
GameObject characterObject = new GameObject("character-switch");
characterObject.transform.SetParent(_root.transform);
GameObject characterUi = new GameObject("UI_CharacterSwitch");
characterUi.transform.SetParent(_root.transform);
var controller = characterObject.AddComponent<CharacterSwitchController>();
typeof(CharacterSwitchController)
.GetField("uiPanel", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(controller, characterUi);
controller.ApplyStageIsolationState();
Assert.IsFalse(characterUi.activeSelf);
}
[Test]
public void SuspendAndResume_IgnoreNullEntries()
{
@@ -1,3 +1,4 @@
using System.IO;
using System.Reflection;
using NUnit.Framework;
using UnityEditor;
@@ -33,3 +34,84 @@ public sealed class XWorldJsonPrefabAutoConvertTests
return (bool)method.Invoke(null, null);
}
}
public sealed class XWorldLocalServerBuildTests
{
[Test]
public void LocalServerMenuUsesGatewayProjectInsteadOfPrebuiltDll()
{
string gatewayProjectPath = InvokeStringPath("GetMiniGameGatewayRunnerProjectPath");
Assert.That(gatewayProjectPath.Replace('\\', '/'), Does.EndWith("/Server/Gateway.Runner/Gateway.Runner.csproj"));
Assert.That(File.Exists(gatewayProjectPath), Is.True);
Assert.That(XWorldUtil.CanOpenLocalServer(), Is.True);
}
[Test]
public void CdnRunnerBuildMenuUsesCdnProject()
{
string cdnProjectPath = InvokeStringPath("GetMiniGameCdnRunnerProjectPath");
Assert.That(cdnProjectPath.Replace('\\', '/'), Does.EndWith("/Server/Cdn.Runner/Cdn.Runner.csproj"));
Assert.That(File.Exists(cdnProjectPath), Is.True);
}
[Test]
public void LocalServerBuildIsSkippedWhenGatewayIsAlreadyRunning()
{
Assert.That(InvokeShouldBuildLocalServer(false), Is.True);
Assert.That(InvokeShouldBuildLocalServer(true), Is.False);
}
[Test]
public void DotnetRunnerLaunchableRequiresRuntimeConfigNextToDll()
{
string tempDir = Path.Combine(Path.GetTempPath(), "XWorldRunnerTest-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
string runnerPath = Path.Combine(tempDir, "Example.Runner.dll");
File.WriteAllText(runnerPath, string.Empty);
Assert.That(InvokeLaunchable(runnerPath), Is.False);
File.WriteAllText(Path.Combine(tempDir, "Example.Runner.runtimeconfig.json"), "{}");
Assert.That(InvokeLaunchable(runnerPath), Is.True);
}
finally
{
Directory.Delete(tempDir, true);
}
}
private static string InvokeStringPath(string methodName)
{
MethodInfo method = typeof(XWorldUtil).GetMethod(
methodName,
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
return (string)method.Invoke(null, null);
}
private static bool InvokeLaunchable(string runnerPath)
{
MethodInfo method = typeof(XWorldUtil).GetMethod(
"IsDotnetRunnerLaunchable",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
return (bool)method.Invoke(null, new object[] { runnerPath });
}
private static bool InvokeShouldBuildLocalServer(bool isGatewayRunning)
{
MethodInfo method = typeof(XWorldUtil).GetMethod(
"ShouldBuildMiniGameLocalServer",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
return (bool)method.Invoke(null, new object[] { isGatewayRunning });
}
}
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using XGame.MiniGame;
using UObject = UnityEngine.Object;
namespace XGame
@@ -35,6 +36,11 @@ namespace XGame
get { return currentConfig == null ? string.Empty : currentConfig.id; }
}
public GameObject StageIsolationUiRoot
{
get { return uiPanel; }
}
private void Start()
{
LoadConfig();
@@ -178,6 +184,15 @@ namespace XGame
CacheUI();
BuildCharacterButtons();
ApplyStageIsolationState();
}
public void ApplyStageIsolationState()
{
if (uiPanel != null && MiniGameStageIsolation.HasSuspendedLobby())
{
uiPanel.SetActive(false);
}
}
private void CacheUI()
@@ -5,6 +5,7 @@ using System.Globalization;
using System.IO;
using UnityEngine;
using XGame.MiniGame;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using UObject = UnityEngine.Object;
@@ -37,13 +38,19 @@ namespace XGame
private XWebSocket socket;
private MiniGameNetChannel lobbyNet;
private MiniGameHost gameHost;
private MiniGameStageIsolation stageIsolation;
private LobbyWorldController lobbyWorld;
private WorldChatModule worldChat;
private readonly List<GameInfoMsg> games = new List<GameInfoMsg>();
private readonly List<NetMessage> pendingGameMessages = new List<NetMessage>();
private readonly HashSet<int> canceledMatchRequestIds = new HashSet<int>();
private bool lobbyActive;
private bool openingLogin;
private bool openingLobby;
private bool openingWaiting;
private bool matchRequestActive;
private int nextMatchRequestId;
private int currentMatchRequestId;
private string matchMessage = string.Empty;
private string pendingGameId = string.Empty;
private int pendingGameVersion;
@@ -409,6 +416,7 @@ namespace XGame
socket = sock;
lobbyNet = new MiniGameNetChannel(sock);
lobbyNet.OnGameMessage = OnLobbyGameMessage;
lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame;
lobbyActive = true;
SetLobbyStatus("Lobby connected.");
@@ -455,6 +463,9 @@ namespace XGame
private void OnMatchClicked(string gameId)
{
matchRequestActive = true;
currentMatchRequestId = ++nextMatchRequestId;
canceledMatchRequestIds.Remove(currentMatchRequestId);
matchMessage = "Matching...";
pendingGameId = string.Empty;
pendingGameVersion = 0;
@@ -468,7 +479,7 @@ namespace XGame
if (string.IsNullOrEmpty(gameId))
{
lobbyNet.SendFramework(FrameworkOpcode.RandomMatchRequest, Array.Empty<byte>());
lobbyNet.SendFramework(FrameworkOpcode.RandomMatchRequest, new MatchControlMsg { RequestId = currentMatchRequestId }.Encode());
}
else
{
@@ -483,7 +494,7 @@ namespace XGame
}
pendingGameId = gameId;
pendingGameVersion = version;
lobbyNet.SendFramework(FrameworkOpcode.MatchRequest, new MatchRequestMsg { GameId = gameId, Version = version }.Encode());
lobbyNet.SendFramework(FrameworkOpcode.MatchRequest, new MatchRequestMsg { GameId = gameId, Version = version, RequestId = currentMatchRequestId }.Encode());
}
}
@@ -530,6 +541,8 @@ namespace XGame
private void OnCloseWaiting()
{
CancelCurrentMatchRequest();
matchRequestActive = false;
matchMessage = string.Empty;
pendingGameId = string.Empty;
pendingGameVersion = 0;
@@ -537,6 +550,34 @@ namespace XGame
OpenLobby();
}
private void CancelPendingMatch()
=> CancelPendingMatch(currentMatchRequestId);
private void CancelCurrentMatchRequest()
{
if (currentMatchRequestId != 0)
{
canceledMatchRequestIds.Add(currentMatchRequestId);
}
CancelPendingMatch(currentMatchRequestId);
}
private void CancelPendingMatch(int requestId)
{
if (lobbyNet == null || !lobbyNet.IsConnected)
{
return;
}
lobbyNet.SendFramework(FrameworkOpcode.MatchCancel, new MatchControlMsg { RequestId = requestId }.Encode());
}
private bool IsCurrentMatchResponse(int requestId)
=> matchRequestActive && (requestId == 0 || requestId == currentMatchRequestId);
private bool IsCanceledMatchResponse(int requestId)
=> requestId != 0 && canceledMatchRequestIds.Contains(requestId);
private void OnLobbyFrameworkFrame(Frame frame)
{
switch ((FrameworkOpcode)frame.Opcode)
@@ -549,6 +590,16 @@ namespace XGame
break;
case FrameworkOpcode.MatchAssigned:
MatchAssignedMsg assigned = MatchAssignedMsg.Decode(frame.Payload);
if (IsCanceledMatchResponse(assigned.RequestId))
{
Debug.Log("[CSharpClientApp] ignore canceled match assigned req=" + assigned.RequestId);
break;
}
if (!IsCurrentMatchResponse(assigned.RequestId))
{
Debug.Log("[CSharpClientApp] ignore stale match assigned req=" + assigned.RequestId);
break;
}
pendingGameId = assigned.GameId;
pendingGameVersion = assigned.Version;
Debug.Log("[CSharpClientApp] match assigned " + pendingGameId + "@" + pendingGameVersion);
@@ -556,10 +607,31 @@ namespace XGame
break;
case FrameworkOpcode.MatchFound:
MatchFoundMsg found = MatchFoundMsg.Decode(frame.Payload);
if (IsCanceledMatchResponse(found.RequestId))
{
Debug.Log("[CSharpClientApp] ignore canceled match found req=" + found.RequestId);
CancelPendingMatch(found.RequestId);
break;
}
if (!matchRequestActive && !string.IsNullOrEmpty(found.GameId))
{
Debug.Log("[CSharpClientApp] resume game room=" + found.RoomId + " game=" + found.GameId + "@" + found.Version);
EnterAssignedGame(found.GameId, found.Version, found);
break;
}
if (!IsCurrentMatchResponse(found.RequestId))
{
Debug.Log("[CSharpClientApp] ignore stale match found req=" + found.RequestId);
if (found.RequestId != 0)
{
CancelPendingMatch(found.RequestId);
}
break;
}
Debug.Log("[CSharpClientApp] match found room=" + found.RoomId + " self=" + found.SelfPlayerId);
if (string.IsNullOrEmpty(pendingGameId))
{
SetWaitingStatus("Room ready, but no game was assigned.", true);
AbortMatchAndReturnLobby("Match expired, please retry.", found.RequestId);
break;
}
SetWaitingStatus("Room ready.", false);
@@ -567,6 +639,16 @@ namespace XGame
break;
case FrameworkOpcode.Error:
ErrorMsg err = ErrorMsg.Decode(frame.Payload);
if (IsCanceledMatchResponse(err.RequestId))
{
Debug.Log("[CSharpClientApp] ignore canceled match error req=" + err.RequestId);
break;
}
if (!IsCurrentMatchResponse(err.RequestId))
{
Debug.Log("[CSharpClientApp] ignore stale match error req=" + err.RequestId);
break;
}
pendingGameId = string.Empty;
pendingGameVersion = 0;
SetWaitingStatus("Server error " + err.Code + ": " + err.Message, true);
@@ -590,11 +672,28 @@ namespace XGame
}
}
private void OnLobbyGameMessage(NetMessage message)
{
if (gameHost != null)
{
gameHost.QueueInitialMessage(message);
return;
}
pendingGameMessages.Add(message);
if (pendingGameMessages.Count > 16)
{
pendingGameMessages.RemoveAt(0);
}
}
private void EnterAssignedGame(string gameId, int version, MatchFoundMsg found)
{
matchRequestActive = false;
pendingGameId = string.Empty;
pendingGameVersion = 0;
lobbyActive = false;
SuspendLobbyStage();
lobbyWorld?.SendLeave();
CloseWorldChat();
lobbyNet = null;
@@ -607,17 +706,73 @@ namespace XGame
}
gameHost.OnGameExited = () =>
{
ResumeLobbyStage();
matchMessage = "Returned to lobby.";
lobbyNet = new MiniGameNetChannel(socket);
lobbyNet.OnGameMessage = OnLobbyGameMessage;
lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame;
lobbyActive = true;
OpenLobby();
JoinLobbyWorld();
RequestGameList();
};
gameHost.EnterGame(ResolvedCdnBase(), gameId, version, socket, false);
// 大厅链路 MatchFound 由大厅通道消费,宿主收不到,须显式注入房间信息(结算弹框判胜负用)
gameHost.SetMatchInfo(found.RoomId, found.SelfPlayerId);
gameHost.SetMatchInfo(found.RoomId, found.SelfPlayerId, found.Players);
gameHost.EnterGame(ResolvedCdnBase(), gameId, version, socket, false);
for (int i = 0; i < pendingGameMessages.Count; i++)
{
gameHost.QueueInitialMessage(pendingGameMessages[i]);
}
pendingGameMessages.Clear();
}
private void AbortMatchAndReturnLobby(string message, int requestId)
{
int cancelRequestId = requestId != 0 ? requestId : currentMatchRequestId;
if (cancelRequestId != 0)
{
canceledMatchRequestIds.Add(cancelRequestId);
}
CancelPendingMatch(cancelRequestId);
matchRequestActive = false;
pendingGameId = string.Empty;
pendingGameVersion = 0;
CloseWaiting();
OpenLobby();
SetLobbyStatus(message);
}
private MiniGameStageIsolation ResolveStageIsolation()
{
if (stageIsolation != null)
{
return stageIsolation;
}
stageIsolation = GetComponent<MiniGameStageIsolation>();
if (stageIsolation == null)
{
stageIsolation = gameObject.AddComponent<MiniGameStageIsolation>();
}
return stageIsolation;
}
private void SuspendLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.SuspendLobbyForMiniGame();
}
}
private void ResumeLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.ResumeLobbyAfterMiniGame();
}
}
private string ResolvedCdnBase()
@@ -22,6 +22,7 @@ namespace XGame.MiniGame
private GameClientCtx _ctx;
private MiniGameAssetLoader _assets;
private RoomPlayerService _players;
private readonly List<NetMessage> _queuedInitialMessages = new List<NetMessage>();
private bool _running;
private bool _waitingForResultConfirm;
private string _gameId;
@@ -38,10 +39,22 @@ namespace XGame.MiniGame
public void EnterGame(string cdnBase, string gameId, int version, XWebSocket sock, bool sendMatchRequest)
{
_gameId = gameId; _version = version;
_queuedInitialMessages.Clear();
// FIX: XWCoroutine.StartCo is a static method (not Instance.StartCo)
XWCoroutine.StartCo(CoEnter(cdnBase, gameId, version, sock, sendMatchRequest));
}
public void QueueInitialMessage(NetMessage message)
{
if (_client != null)
{
SafeCall(() => _client.OnNetMessage(message), "OnNetMessage");
return;
}
_queuedInitialMessages.Add(message);
}
// 大厅链路:MatchFound 由大厅通道消费(sendMatchRequest=false 时宿主收不到),
// 由外部把房间信息注入,否则结算弹框无法判断胜负(_selfPlayerId 恒 0)。
public void SetMatchInfo(string roomId, int selfPlayerId)
@@ -122,6 +135,7 @@ namespace XGame.MiniGame
try
{
_client.OnEnter(_ctx);
FlushQueuedInitialMessages();
}
catch (Exception e)
{
@@ -165,6 +179,7 @@ namespace XGame.MiniGame
public void ExitGame()
{
if (!_running && _client == null) return;
_queuedInitialMessages.Clear();
DestroyResultDialog();
_waitingForResultConfirm = false;
_running = false;
@@ -183,6 +198,7 @@ namespace XGame.MiniGame
private void FailEnterAndExit(string reason)
{
_queuedInitialMessages.Clear();
DestroyResultDialog();
_waitingForResultConfirm = false;
_running = false;
@@ -443,6 +459,18 @@ namespace XGame.MiniGame
return button;
}
private void FlushQueuedInitialMessages()
{
if (_client == null || _queuedInitialMessages.Count == 0) return;
NetMessage[] messages = _queuedInitialMessages.ToArray();
_queuedInitialMessages.Clear();
for (int i = 0; i < messages.Length; i++)
{
NetMessage message = messages[i];
SafeCall(() => _client.OnNetMessage(message), "OnNetMessage");
}
}
private static void SafeCall(Action a, string where)
{
try { a(); }
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using UnityEngine;
using XGame;
namespace XGame.MiniGame
{
@@ -17,6 +18,19 @@ namespace XGame.MiniGame
public bool IsSuspended { get; private set; }
public static bool HasSuspendedLobby()
{
MiniGameStageIsolation[] isolations = FindObjectsOfType<MiniGameStageIsolation>(true);
for (int i = 0; i < isolations.Length; i++)
{
if (isolations[i] != null && isolations[i].IsSuspended)
{
return true;
}
}
return false;
}
public void SuspendLobbyForMiniGame()
{
if (IsSuspended)
@@ -77,6 +91,7 @@ namespace XGame.MiniGame
_behaviourStates.Clear();
_canvasGroupStates.Clear();
CaptureGameObjects(LobbyUiRoots);
CaptureCharacterSwitchUiRoots();
CaptureGameObjects(LobbySceneRoots);
CaptureBehaviours(LobbyInputBehaviours);
CaptureBehaviours(LobbyTickBehaviours);
@@ -86,6 +101,7 @@ namespace XGame.MiniGame
private void ApplySuspendedState()
{
SetGameObjectsActive(LobbyUiRoots, false);
SetCharacterSwitchUiRootsActive(false);
SetGameObjectsActive(LobbySceneRoots, false);
SetBehavioursEnabled(LobbyInputBehaviours, false);
SetBehavioursEnabled(LobbyTickBehaviours, false);
@@ -105,6 +121,19 @@ namespace XGame.MiniGame
}
}
private void CaptureCharacterSwitchUiRoots()
{
CharacterSwitchController[] controllers = FindObjectsOfType<CharacterSwitchController>(true);
for (int i = 0; i < controllers.Length; i++)
{
GameObject target = controllers[i] != null ? controllers[i].StageIsolationUiRoot : null;
if (target != null)
{
_gameObjectStates.Add(new GameObjectState(target, target.activeSelf));
}
}
}
private void CaptureBehaviours(Behaviour[] targets)
{
if (targets == null) return;
@@ -143,6 +172,19 @@ namespace XGame.MiniGame
}
}
private static void SetCharacterSwitchUiRootsActive(bool active)
{
CharacterSwitchController[] controllers = FindObjectsOfType<CharacterSwitchController>(true);
for (int i = 0; i < controllers.Length; i++)
{
GameObject target = controllers[i] != null ? controllers[i].StageIsolationUiRoot : null;
if (target != null)
{
target.SetActive(active);
}
}
}
private static void SetBehavioursEnabled(Behaviour[] targets, bool enabled)
{
if (targets == null) return;