很多更新,服务端连接,新UI和系统

This commit is contained in:
FloatGaming
2026-04-17 20:09:41 +08:00
parent 85dfff28dd
commit e0bc0bbf08
910 changed files with 65191 additions and 1291 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f5cf0e42b79b4a5aa40d1a98d52641c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,13 @@
namespace GameServer.Client
{
public enum ConnectionState
{
Disconnected,
Connecting,
Handshaking,
LoadingProfile,
LoadingLeaderboard,
Ready,
ConnectionFailed
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 30018fafe3957e743a4781b802f01860
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: afebae6e2cf96f345856c473879a4916
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.Compilation;
using GameServer.Client;
[InitializeOnLoad]
public static class ArenaRoomEditorExitGuard
{
static ArenaRoomEditorExitGuard()
{
EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged;
EditorApplication.playModeStateChanged += HandlePlayModeStateChanged;
AssemblyReloadEvents.beforeAssemblyReload -= HandleBeforeAssemblyReload;
AssemblyReloadEvents.beforeAssemblyReload += HandleBeforeAssemblyReload;
}
private static void HandlePlayModeStateChanged(PlayModeStateChange state)
{
if (state == PlayModeStateChange.ExitingPlayMode)
{
ArenaRoomService.Instance?.BestEffortLeaveRoomOnShutdown();
}
}
private static void HandleBeforeAssemblyReload()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
ArenaRoomService.Instance?.BestEffortLeaveRoomOnShutdown();
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a1deac595959bca44b546dc4f794c8b3
@@ -0,0 +1,167 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using GameServer.Client;
public class GameServerBridge : MonoBehaviour
{
public static GameServerBridge Instance { get; private set; }
[Header("引用")]
[SerializeField] private settlementController settlement;
[SerializeField] private ScoreManager scoreManager;
[SerializeField] private BeatmapManager beatmapManager;
[SerializeField] private GameManager gameManager;
[Header("配置")]
[SerializeField] private bool autoSubmitOnSettlement = true;
private const string HMAC_SECRET = "your_secret_key_change_this_in_production";
private bool _hasSubmitted = false;
private float _gameStartTime;
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
}
private void OnEnable()
{
_gameStartTime = Time.realtimeSinceStartup;
_hasSubmitted = false;
if (autoSubmitOnSettlement)
settlementController.OnSettlementCompleted += OnSettlementTriggered;
}
private void OnDisable()
{
settlementController.OnSettlementCompleted -= OnSettlementTriggered;
}
private void OnSettlementTriggered()
{
if (_hasSubmitted) return;
SubmitCurrentSettlement();
}
public async void SubmitCurrentSettlement()
{
if (_hasSubmitted) { Debug.Log("[Bridge] 本次已提交过,跳过"); return; }
var nm = NetworkManager.Instance;
if (nm == null) { Debug.LogWarning("[Bridge] NetworkManager 不存在"); return; }
_hasSubmitted = true;
try
{
// ── 采集当前歌曲数据 ──
string songId = "0";
string difficulty = "unknown";
if (beatmapManager != null)
{
SongData songData = beatmapManager.assignedSongData;
songId = songData != null ? songData.songID.ToString() : "0";
difficulty = ConvertDifficulty(beatmapManager.assignedDifficulty);
}
int chartScore = 0, idolScore = 0;
long totalScore = 0;
if (scoreManager != null)
{
chartScore = scoreManager.allSum_pmScore;
idolScore = scoreManager.allSum_idolScore;
totalScore = chartScore + idolScore;
}
string grade = GetGrade(totalScore);
double runtime = Time.realtimeSinceStartup - _gameStartTime;
if (gameManager != null) { float t = gameManager.GetPendingSessionDurationSeconds(); if (t > 0) runtime = t; }
string playedAt = DateTime.Now.ToString("yyyyMMdd HHmmss");
// ── 本地预校验 ──
if (difficulty != "in")
{
Debug.Log($"[Bridge] 难度={difficulty} 不是 IN,不上传");
_hasSubmitted = false; return;
}
if (totalScore < 1000000)
{
Debug.Log($"[Bridge] 总分={totalScore} < 100万,不上传");
_hasSubmitted = false; return;
}
if (runtime < 60)
{
Debug.Log($"[Bridge] 游玩时间={runtime:F1}秒 < 60秒,不上传");
_hasSubmitted = false; return;
}
// ── HMAC 签名 ──
string payload = $"{songId}|{difficulty}|{totalScore}|{chartScore}|{idolScore}";
string hmac = ComputeHmacSha256(payload, HMAC_SECRET);
// ── 上传日志(清晰可见) ──
Debug.Log("╔══════════════════════════════════════════════════════╗");
Debug.Log("║ 开始上传结算数据到服务器 ║");
Debug.Log("╠══════════════════════════════════════════════════════╣");
Debug.Log($"║ 歌曲ID: {songId}");
Debug.Log($"║ 难度: {difficulty}");
Debug.Log($"║ 谱面分数: {chartScore}");
Debug.Log($"║ 偶像分数: {idolScore}");
Debug.Log($"║ 总分: {totalScore}");
Debug.Log($"║ 评级: {grade}");
Debug.Log($"║ 游玩时长: {runtime:F1}秒");
Debug.Log($"║ 游玩时间: {playedAt}");
Debug.Log($"║ HMAC签名: {hmac.Substring(0, 16)}...");
Debug.Log("╚══════════════════════════════════════════════════════╝");
string result = await nm.PushSettlement(songId, difficulty, chartScore, idolScore,
totalScore, grade, runtime, playedAt, hmac);
// ── 结果日志 ──
if (result == "OK")
{
Debug.Log("╔══════════════════════════════════════════════════════╗");
Debug.Log("║ ✅✅✅ 上传成功!数据已写入服务器数据库 ✅✅✅ ║");
Debug.Log("╚══════════════════════════════════════════════════════╝");
}
else if (result == "QUEUED")
{
Debug.LogWarning("[Bridge] ⏳ 服务器繁忙,3秒后重试...");
_hasSubmitted = false;
await Task.Delay(3000);
SubmitCurrentSettlement();
}
else
{
Debug.LogWarning($"╔══════════════════════════════════════════════════════╗");
Debug.LogWarning($"║ ❌ 上传失败!服务器返回: {result}");
Debug.LogWarning($"╚══════════════════════════════════════════════════════╝");
_hasSubmitted = false;
}
}
catch (Exception ex)
{
Debug.LogError($"[Bridge] 上传异常: {ex.Message}");
_hasSubmitted = false;
}
}
private static string ComputeHmacSha256(string payload, string secret)
{
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
{
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var sb = new StringBuilder(hash.Length * 2);
foreach (byte b in hash) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
private static string ConvertDifficulty(int i) => i switch { 0 => "ez", 1 => "hd", 2 => "in", 3 => "im", _ => "unknown" };
private static string GetGrade(long s) => s >= 960000 ? "SSS" : s >= 920000 ? "SS" : s >= 880000 ? "S" : s >= 820000 ? "A" : s >= 720000 ? "B" : s >= 600000 ? "C" : s >= 400000 ? "D" : "F";
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 40b7e7063942e3641b595de0b106fa40
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace GameServer.Client
{
public sealed class LeaderboardCacheService : MonoBehaviour
{
public static LeaderboardCacheService Instance { get; private set; }
private readonly Dictionary<string, LeaderboardData> _cache = new Dictionary<string, LeaderboardData>();
private readonly Dictionary<string, Task<LeaderboardData>> _inflight = new Dictionary<string, Task<LeaderboardData>>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Bootstrap()
{
EnsureInstance();
}
public static LeaderboardCacheService EnsureInstance()
{
if (Instance != null)
{
return Instance;
}
GameObject host = new GameObject("__runtime_leaderboard_cache");
host.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector;
DontDestroyOnLoad(host);
Instance = host.AddComponent<LeaderboardCacheService>();
return Instance;
}
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void OnEnable()
{
SceneManager.sceneLoaded -= HandleSceneLoaded;
SceneManager.sceneLoaded += HandleSceneLoaded;
}
private void OnDisable()
{
SceneManager.sceneLoaded -= HandleSceneLoaded;
}
public bool TryGetCached(string songId, out LeaderboardData data)
{
data = null;
if (string.IsNullOrWhiteSpace(songId))
{
return false;
}
return _cache.TryGetValue(songId, out data) && data != null;
}
public async Task<LeaderboardData> GetOrFetch(string songId)
{
if (string.IsNullOrWhiteSpace(songId))
{
throw new ArgumentException("songId is required", nameof(songId));
}
if (TryGetCached(songId, out LeaderboardData cached))
{
return cached;
}
if (_inflight.TryGetValue(songId, out Task<LeaderboardData> pending) && pending != null)
{
return await pending;
}
NetworkManager nm = NetworkManager.Instance;
if (nm == null)
{
throw new InvalidOperationException("NetworkManager is not available.");
}
Task<LeaderboardData> task = FetchAndCache(songId, nm);
_inflight[songId] = task;
try
{
return await task;
}
finally
{
if (_inflight.TryGetValue(songId, out Task<LeaderboardData> current) && current == task)
{
_inflight.Remove(songId);
}
}
}
public void Invalidate(string songId)
{
if (string.IsNullOrWhiteSpace(songId))
{
return;
}
_cache.Remove(songId);
}
private async void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
{
await Task.Yield();
if (!TryResolveCurrentSongId(out string songId))
{
return;
}
if (TryGetCached(songId, out _))
{
return;
}
try
{
await GetOrFetch(songId);
Debug.Log($"[LeaderboardCacheService] Preloaded leaderboard for songId={songId} after scene '{scene.name}'.");
}
catch (Exception ex)
{
Debug.LogWarning($"[LeaderboardCacheService] Failed to preload leaderboard for songId={songId}: {ex.Message}");
}
}
private async Task<LeaderboardData> FetchAndCache(string songId, NetworkManager nm)
{
LeaderboardData data = await nm.FetchLeaderboardFromServer(songId);
if (data != null)
{
_cache[songId] = data;
}
return data;
}
private static bool TryResolveCurrentSongId(out string songId)
{
songId = null;
SongData selectedSong = SongDataHolder.SelectedSongData;
if (selectedSong != null)
{
songId = selectedSong.songID.ToString();
return true;
}
if (BeatmapManager.pendingSongData != null)
{
songId = BeatmapManager.pendingSongData.songID.ToString();
return true;
}
BeatmapManager beatmapManager = BeatmapManager.Instance;
if (beatmapManager != null && beatmapManager.assignedSongData != null)
{
songId = beatmapManager.assignedSongData.songID.ToString();
return true;
}
return false;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22b74214e8fd41ed9415535de7dee096
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,144 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace GameServer.Client
{
/// <summary>
/// Manages serialization, deserialization, and dispatch of WebSocket messages.
/// Thread-safe; pending request callbacks are invoked on receive.
/// </summary>
public class MessageHandler
{
private readonly WebSocketClient _client;
// type → list of callbacks
private readonly Dictionary<string, List<Action<JObject, string>>> _handlers
= new Dictionary<string, List<Action<JObject, string>>>();
// requestId → TaskCompletionSource for request-response pattern
private readonly ConcurrentDictionary<string, TaskCompletionSource<JObject>> _pending
= new ConcurrentDictionary<string, TaskCompletionSource<JObject>>();
public MessageHandler(WebSocketClient client)
{
_client = client;
_client.OnMessageReceived += OnRawMessage;
}
// ── Send ─────────────────────────────────────────────────────────────
public async Task SendMessage(string type, object data,
string requestId = null)
{
requestId ??= Guid.NewGuid().ToString();
var envelope = new
{
type,
data,
requestId
};
string json = JsonConvert.SerializeObject(envelope);
await _client.Send(json);
}
// ── Request-Response ─────────────────────────────────────────────────
/// <summary>
/// Sends a typed request and awaits the matching response by requestId.
/// </summary>
public async Task<JObject> SendRequest(string type, object data,
TimeSpan? timeout = null)
{
string requestId = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<JObject>(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending[requestId] = tcs;
await SendMessage(type, data, requestId);
var timeoutTask = Task.Delay(timeout ?? TimeSpan.FromSeconds(10));
var completedTask = await Task.WhenAny(tcs.Task, timeoutTask);
_pending.TryRemove(requestId, out _);
if (completedTask == timeoutTask)
throw new TimeoutException($"Request '{type}' timed out");
return await tcs.Task;
}
// ── Handler Registration ─────────────────────────────────────────────
public void RegisterHandler(string type, Action<JObject, string> callback)
{
lock (_handlers)
{
if (!_handlers.TryGetValue(type, out var list))
{
list = new List<Action<JObject, string>>();
_handlers[type] = list;
}
list.Add(callback);
}
}
public void UnregisterHandler(string type, Action<JObject, string> callback)
{
lock (_handlers)
{
if (_handlers.TryGetValue(type, out var list))
list.Remove(callback);
}
}
// ── Internal ─────────────────────────────────────────────────────────
private void OnRawMessage(string raw)
{
JObject envelope;
try
{
envelope = JObject.Parse(raw);
}
catch (Exception ex)
{
Debug.LogWarning("[MessageHandler] Failed to parse JSON: " + ex.Message);
return;
}
string type = envelope["type"]?.ToString();
string requestId = envelope["requestId"]?.ToString();
JObject data = envelope["data"] as JObject ?? new JObject();
// Resolve pending request
if (requestId != null && _pending.TryRemove(requestId, out var tcs))
{
tcs.TrySetResult(data);
return;
}
// Dispatch to registered handlers
List<Action<JObject, string>> callbacks = null;
lock (_handlers)
{
if (type != null && _handlers.TryGetValue(type, out var list))
callbacks = new List<Action<JObject, string>>(list);
}
if (callbacks == null) return;
foreach (var cb in callbacks)
{
try { cb(data, requestId); }
catch (Exception ex)
{
Debug.LogError($"[MessageHandler] Handler for '{type}' threw: {ex}");
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4876980774d12f84181c818ea913ae53
@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace GameServer.Client
{
// ── Generic message wrapper ──────────────────────────────────────────────
[Serializable]
public class WsMessage<T>
{
[JsonProperty("type")] public string type;
[JsonProperty("data")] public T data;
[JsonProperty("requestId")] public string requestId;
public string ToJson() => JsonConvert.SerializeObject(this);
public static WsMessage<T> FromJson(string json) =>
JsonConvert.DeserializeObject<WsMessage<T>>(json);
}
[Serializable]
public class WsMessage
{
[JsonProperty("type")] public string type;
[JsonProperty("data")] public object data;
[JsonProperty("requestId")] public string requestId;
public static WsMessage FromJson(string json) =>
JsonConvert.DeserializeObject<WsMessage>(json);
}
// ── Request / Response DTOs ──────────────────────────────────────────────
[Serializable]
public class HandshakeRequest
{
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("token")] public string token;
[JsonProperty("display_name")] public string display_name;
[JsonProperty("avatar_url")] public string avatar_url;
}
[Serializable]
public class HandshakeResponse
{
[JsonProperty("success")] public bool success;
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
}
[Serializable]
public class ProfileData
{
[JsonProperty("success")] public bool success;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("display_name")] public string display_name;
[JsonProperty("avatar_url")] public string avatar_url;
[JsonProperty("player_level")] public int player_level;
[JsonProperty("leaderboard_opt_out")] public bool leaderboard_opt_out;
[JsonProperty("total_play_seconds")] public double total_play_seconds;
[JsonProperty("total_plays")] public int total_plays;
[JsonProperty("actime")] public string actime;
[JsonProperty("u_reg_id")] public int u_reg_id;
}
[Serializable]
public class LeaderboardEntry
{
[JsonProperty("rank")] public int rank;
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("display_name")] public string display_name;
[JsonProperty("avatar_url")] public string avatar_url;
[JsonProperty("total_score")] public long total_score;
[JsonProperty("chart_score")] public int chart_score;
[JsonProperty("idol_score")] public int idol_score;
[JsonProperty("grade")] public string grade;
[JsonProperty("achieved_at")] public string achieved_at;
[JsonProperty("achieved_at_unix")] public long achieved_at_unix;
[JsonProperty("play_count")] public int play_count;
[JsonProperty("keep_on_time")] public int keep_on_time;
[JsonProperty("room_status_text")] public string room_status_text;
}
[Serializable]
public class LeaderboardData
{
[JsonProperty("song_id")] public string song_id;
[JsonProperty("cycle_id")] public int cycle_id;
[JsonProperty("is_locked")] public bool is_locked;
[JsonProperty("rankings")] public List<LeaderboardEntry> rankings;
}
[Serializable]
public class ScorePushRequest
{
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("song_id")] public string song_id;
[JsonProperty("difficulty")] public string difficulty;
[JsonProperty("chart_score")] public int chart_score;
[JsonProperty("idol_score")] public int idol_score;
[JsonProperty("total_score")] public long total_score;
[JsonProperty("grade")] public string grade;
[JsonProperty("runtime_seconds")] public double runtime_seconds;
[JsonProperty("played_at")] public string played_at;
[JsonProperty("hmac")] public string hmac;
}
[Serializable]
public class ScoreAckResponse
{
[JsonProperty("status")] public string status;
[JsonProperty("reason")] public string reason;
[JsonProperty("message")] public string message;
}
[Serializable]
public class ArenaCreateRequest
{
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("song_id")] public string song_id;
[JsonProperty("difficulty")] public string difficulty;
[JsonProperty("password")] public string password;
}
[Serializable]
public class ArenaCreateResponse
{
[JsonProperty("success")] public bool success;
[JsonProperty("room_code")] public string room_code;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
}
[Serializable]
public class ArenaJoinRequest
{
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("room_code")] public string room_code;
[JsonProperty("password")] public string password;
}
[Serializable]
public class ArenaJoinResponse
{
[JsonProperty("success")] public bool success;
[JsonProperty("room_code")] public string room_code;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
[JsonProperty("participant_count")] public int participant_count;
}
[Serializable]
public class ArenaStartRequest
{
[JsonProperty("steam_id")] public string steam_id;
}
[Serializable]
public class ArenaStartResponse
{
[JsonProperty("success")] public bool success;
[JsonProperty("room_code")] public string room_code;
[JsonProperty("song_id")] public string song_id;
[JsonProperty("difficulty")] public string difficulty;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
}
[Serializable]
public class ArenaSubmitRequest
{
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("room_code")] public string room_code;
[JsonProperty("total_score")] public long total_score;
[JsonProperty("grade")] public string grade;
}
[Serializable]
public class ArenaResultEntry
{
[JsonProperty("rank")] public int rank;
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("total_score")] public long total_score;
[JsonProperty("grade")] public string grade;
}
[Serializable]
public class ArenaResult
{
[JsonProperty("success")] public bool success;
[JsonProperty("finished")] public bool finished;
[JsonProperty("room_code")] public string room_code;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
[JsonProperty("rankings")] public List<ArenaResultEntry> rankings;
}
[Serializable]
public class HeartbeatResponse
{
[JsonProperty("success")] public bool success;
[JsonProperty("message")] public string message;
}
[Serializable]
public class ErrorResponse
{
[JsonProperty("code")] public string code;
[JsonProperty("message")] public string message;
}
[Serializable]
public class ArenaRoomParticipant
{
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("display_name")] public string display_name;
[JsonProperty("is_host")] public bool is_host;
[JsonProperty("is_ready")] public bool is_ready;
[JsonProperty("join_order")] public int join_order;
[JsonProperty("total_score")] public long? total_score;
[JsonProperty("grade")] public string grade;
[JsonProperty("rank")] public int? rank;
}
[Serializable]
public class ArenaRoomSnapshot
{
[JsonProperty("success")] public bool success;
[JsonProperty("room_code")] public string room_code;
[JsonProperty("host_steam_id")] public string host_steam_id;
[JsonProperty("song_id")] public string song_id;
[JsonProperty("song_name")] public string song_name;
[JsonProperty("difficulty")] public string difficulty;
[JsonProperty("has_password")] public bool has_password;
[JsonProperty("status")] public string status;
[JsonProperty("player_count")] public int player_count;
[JsonProperty("created_at_unix")] public long created_at_unix;
[JsonProperty("expires_at_unix")] public long expires_at_unix;
[JsonProperty("expire_seconds")] public int expire_seconds;
[JsonProperty("participants")] public List<ArenaRoomParticipant> participants;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
}
[Serializable]
public class ArenaSubmittedScore
{
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("display_name")] public string display_name;
[JsonProperty("total_score")] public long total_score;
[JsonProperty("grade")] public string grade;
[JsonProperty("rank")] public int rank;
[JsonProperty("submitted_at_unix")] public long submitted_at_unix;
[JsonProperty("room_status_text")] public string room_status_text;
[JsonProperty("has_submitted")] public bool has_submitted;
[JsonProperty("join_order")] public int join_order;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac89c509dffbbf94b8056f91b457552d
@@ -0,0 +1,30 @@
using UnityEngine;
namespace GameServer.Client
{
/// <summary>
/// 网络自动初始化器
/// 使用 [RuntimeInitializeOnLoadMethod] 在任何场景加载之前自动创建 NetworkManager
/// 这样无论从哪个场景启动,NetworkManager 都会存在且不会被场景切换销毁
/// </summary>
public static class NetworkBootstrap
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
// 如果已经存在就不重复创建
if (NetworkManager.Instance != null)
{
Debug.Log("[NetworkBootstrap] NetworkManager 已存在,跳过创建");
return;
}
// 创建物体并挂载 NetworkManager
GameObject go = new GameObject("[NetworkManager]");
go.AddComponent<NetworkManager>();
Object.DontDestroyOnLoad(go);
Debug.Log("[NetworkBootstrap] NetworkManager 已自动创建(DontDestroyOnLoad");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 50f3a4e57ae0e3d428fcf1c08c7f6fc1
@@ -0,0 +1,487 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine;
using UnityEngine.Networking;
using Bansonic;
#if !(UNITY_STANDALONE_WIN || UNITY_STANDALONE_LINUX || UNITY_STANDALONE_OSX || STEAMWORKS_WIN || STEAMWORKS_LIN_OSX)
#define NETWORK_DISABLE_STEAMWORKS
#endif
#if !NETWORK_DISABLE_STEAMWORKS
using Steamworks;
#endif
namespace GameServer.Client
{
public class NetworkManager : MonoBehaviour
{
public static NetworkManager Instance { get; private set; }
[Header("Server")]
[SerializeField] private string serverUrl = "http://47.112.187.172:8080";
[Header("Auth")]
public string steamId = "";
[SerializeField] private string authToken = "test_token";
[Header("Steam")]
[SerializeField] private string steamDisplayName = "";
[SerializeField] private string steamAvatarUrl = "";
public event Action<ConnectionState> OnStateChanged;
public event Action<HandshakeResponse> OnHandshakeResult;
public event Action<ProfileData> OnProfileLoaded;
public event Action<LeaderboardData> OnLeaderboardLoaded;
public event Action<ArenaCreateResponse> OnArenaCreated;
public event Action<ArenaJoinResponse> OnArenaJoined;
public event Action<ArenaResult> OnArenaResult;
private ConnectionState _state = ConnectionState.Disconnected;
private CancellationTokenSource _requestCts;
private bool _isApplicationQuitting;
public ConnectionState State => _state;
public MessageHandler MessageHandler => null;
public bool IsReady => _state == ConnectionState.Ready;
public bool IsConnectedAndHandshaked => _state == ConnectionState.Ready;
public string ServerUrl => serverUrl;
public string SteamId
{
get
{
RefreshSteamIdentity(false);
return steamId;
}
}
public string SteamDisplayName
{
get
{
RefreshSteamIdentity(false);
return steamDisplayName;
}
}
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
RefreshSteamIdentity(false);
Debug.Log("[NetworkManager] HTTP lazy mode singleton initialized");
}
private void Start()
{
RefreshSteamIdentity(false);
Debug.Log($"[NetworkManager] Lazy HTTP mode enabled. Settlement submit will use {BuildApiUrl("/api/submit")}");
}
private void OnApplicationQuit()
{
_isApplicationQuitting = true;
}
private void OnDestroy()
{
_requestCts?.Cancel();
if (!_isApplicationQuitting && Instance == this)
{
Instance = null;
}
}
public void Connect()
{
_requestCts?.Cancel();
_requestCts = new CancellationTokenSource();
ConnectWithErrorHandling(_requestCts.Token);
}
public void Disconnect()
{
_requestCts?.Cancel();
SetState(ConnectionState.Disconnected);
}
public void SkipToReady()
{
SetState(ConnectionState.Ready);
}
public async Task SendHandshake()
{
await PerformHandshakeAsync(CancellationToken.None);
}
public async Task LoadProfile()
{
try
{
SetState(ConnectionState.LoadingProfile);
ProfileData data = await GetJson<ProfileData>(BuildApiUrl($"/api/profile/{SteamId}"), CancellationToken.None);
OnProfileLoaded?.Invoke(data);
SetState(ConnectionState.Ready);
}
catch (Exception ex)
{
Debug.LogError($"[NetworkManager] Failed to load profile: {ex.Message}");
NotifyNetworkError(ex.Message);
SetState(ConnectionState.ConnectionFailed);
throw;
}
}
public Task LoadLeaderboard(string songId)
{
return GetLeaderboard(songId);
}
public async Task<string> PushSettlement(string songId, string difficulty,
int chartScore, int idolScore, long totalScore, string grade,
double runtimeSeconds, string playedAt, string hmac = "")
{
try
{
await PerformHandshakeAsync(CancellationToken.None);
var req = new ScorePushRequest
{
steam_id = SteamId,
song_id = songId,
difficulty = difficulty,
chart_score = chartScore,
idol_score = idolScore,
total_score = totalScore,
grade = grade,
runtime_seconds = runtimeSeconds,
played_at = playedAt,
hmac = hmac
};
ScoreAckResponse resp = await PostJson<ScoreAckResponse>(BuildApiUrl("/api/submit"), req, CancellationToken.None);
string status = string.IsNullOrEmpty(resp?.status) ? "ERROR" : resp.status;
if (status == "OK")
{
SetState(ConnectionState.Ready);
}
else
{
Debug.LogWarning($"[NetworkManager] Submit rejected: status={resp?.status} reason={resp?.reason} msg={resp?.message}");
NotifyNetworkError(resp?.message ?? resp?.reason ?? "Submit rejected");
}
return status;
}
catch (Exception ex)
{
Debug.LogError($"[NetworkManager] HTTP submit failed: {ex.Message}");
NotifyNetworkError(ex.Message);
SetState(ConnectionState.ConnectionFailed);
return "ERROR";
}
}
public async Task PushScore(string songId, string difficulty, long totalScore, string grade)
{
await PushSettlement(songId, difficulty, 0, 0, totalScore, grade, 0, "", "");
}
public async Task<ArenaCreateResponse> CreateArenaRoom(string songId, string difficulty, string password = null)
{
var req = new ArenaCreateRequest
{
steam_id = SteamId,
song_id = songId,
difficulty = difficulty,
password = password
};
ArenaCreateResponse resp = await PostJson<ArenaCreateResponse>(BuildApiUrl("/api/arena/create"), req, CancellationToken.None);
OnArenaCreated?.Invoke(resp);
return resp;
}
public async Task<ArenaJoinResponse> JoinArenaRoom(string roomCode, string password = null)
{
var req = new ArenaJoinRequest
{
steam_id = SteamId,
room_code = roomCode,
password = password
};
ArenaJoinResponse resp = await PostJson<ArenaJoinResponse>(BuildApiUrl("/api/arena/join"), req, CancellationToken.None);
OnArenaJoined?.Invoke(resp);
return resp;
}
public async Task StartArenaGame()
{
var req = new ArenaStartRequest
{
steam_id = SteamId
};
ArenaStartResponse resp = await PostJson<ArenaStartResponse>(BuildApiUrl("/api/arena/start"), req, CancellationToken.None);
if (!resp.success)
{
throw new Exception($"Arena start failed: {resp.error_code ?? resp.message}");
}
}
public async Task SubmitArenaScore(string roomCode, long totalScore, string grade)
{
var req = new ArenaSubmitRequest
{
steam_id = SteamId,
room_code = roomCode,
total_score = totalScore,
grade = grade
};
ArenaResult resp = await PostJson<ArenaResult>(BuildApiUrl("/api/arena/submit"), req, CancellationToken.None);
if (!resp.success)
{
throw new Exception($"Arena submit failed: {resp.error_code ?? resp.message}");
}
if (resp.finished)
{
OnArenaResult?.Invoke(resp);
}
}
public async Task SendHeartbeat()
{
HeartbeatResponse resp = await GetJson<HeartbeatResponse>(BuildApiUrl("/api/ping"), CancellationToken.None);
Debug.Log($"[NetworkManager] Ping: {resp.message}");
}
public async Task<LeaderboardData> GetLeaderboard(string songId)
{
if (string.IsNullOrWhiteSpace(songId))
{
throw new ArgumentException("songId is required", nameof(songId));
}
try
{
SetState(ConnectionState.LoadingLeaderboard);
LeaderboardCacheService cache = LeaderboardCacheService.Instance;
LeaderboardData data = cache != null
? await cache.GetOrFetch(songId)
: await FetchLeaderboardFromServer(songId);
OnLeaderboardLoaded?.Invoke(data);
SetState(ConnectionState.Ready);
return data;
}
catch (Exception ex)
{
Debug.LogError($"[NetworkManager] Failed to load leaderboard: {ex.Message}");
NotifyNetworkError(ex.Message);
SetState(ConnectionState.ConnectionFailed);
throw;
}
}
internal Task<LeaderboardData> FetchLeaderboardFromServer(string songId)
{
return GetJson<LeaderboardData>(BuildApiUrl($"/api/leaderboard/{songId}"), CancellationToken.None);
}
private async void ConnectWithErrorHandling(CancellationToken token)
{
try
{
await PerformHandshakeAsync(token);
}
catch (OperationCanceledException)
{
Debug.Log("[NetworkManager] HTTP connect cancelled");
}
catch (Exception ex)
{
Debug.LogError($"[NetworkManager] HTTP connect failed: {ex.Message}");
NotifyNetworkError(ex.Message);
SetState(ConnectionState.ConnectionFailed);
}
}
private async Task PerformHandshakeAsync(CancellationToken token)
{
RefreshSteamIdentity(true);
SetState(ConnectionState.Handshaking);
var req = new HandshakeRequest
{
steam_id = SteamId,
token = authToken,
display_name = steamDisplayName,
avatar_url = steamAvatarUrl
};
HandshakeResponse resp = await PostJson<HandshakeResponse>(BuildApiUrl("/api/handshake"), req, token);
OnHandshakeResult?.Invoke(resp);
if (resp != null && resp.success)
{
SetState(ConnectionState.Ready);
return;
}
string message = resp == null
? "empty handshake response"
: $"{resp.error_code} - {resp.message}";
throw new Exception("Handshake failed: " + message);
}
public bool RefreshSteamIdentity(bool logWarnings)
{
#if NETWORK_DISABLE_STEAMWORKS
if (logWarnings)
{
Debug.LogWarning("[NetworkManager] Steamworks is unavailable on this platform. Using current serialized network identity.");
}
return false;
#else
if (!SteamManager.Initialized)
{
if (logWarnings)
{
Debug.LogWarning("[NetworkManager] SteamManager is not initialized. Using current network identity.");
}
return false;
}
try
{
CSteamID currentSteamId = SteamUser.GetSteamID();
if (currentSteamId.m_SteamID != 0)
{
steamId = currentSteamId.m_SteamID.ToString();
}
string personaName = SteamFriends.GetPersonaName();
if (!string.IsNullOrWhiteSpace(personaName))
{
steamDisplayName = personaName;
}
return true;
}
catch (Exception ex)
{
if (logWarnings)
{
Debug.LogWarning($"[NetworkManager] Failed to read Steam identity: {ex.Message}");
}
return false;
}
#endif
}
private async Task<T> PostJson<T>(string url, object payload, CancellationToken token)
{
string json = JsonConvert.SerializeObject(payload);
using (UnityWebRequest request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST))
{
byte[] body = Encoding.UTF8.GetBytes(json);
request.uploadHandler = new UploadHandlerRaw(body);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.timeout = 15;
Debug.Log($"[NetworkManager] HTTP POST {url}");
var operation = request.SendWebRequest();
while (!operation.isDone)
{
token.ThrowIfCancellationRequested();
await Task.Yield();
}
string responseText = request.downloadHandler != null ? request.downloadHandler.text : string.Empty;
if (request.result != UnityWebRequest.Result.Success && string.IsNullOrWhiteSpace(responseText))
{
throw new Exception($"{request.result}: {request.error}");
}
try
{
return JsonConvert.DeserializeObject<T>(responseText);
}
catch (Exception ex)
{
throw new Exception($"Failed to parse response: {ex.Message}. Body={responseText}");
}
}
}
private async Task<T> GetJson<T>(string url, CancellationToken token)
{
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
request.timeout = 15;
Debug.Log($"[NetworkManager] HTTP GET {url}");
var operation = request.SendWebRequest();
while (!operation.isDone)
{
token.ThrowIfCancellationRequested();
await Task.Yield();
}
string responseText = request.downloadHandler != null ? request.downloadHandler.text : string.Empty;
if (request.result != UnityWebRequest.Result.Success && string.IsNullOrWhiteSpace(responseText))
{
throw new Exception($"{request.result}: {request.error}");
}
try
{
return JsonConvert.DeserializeObject<T>(responseText);
}
catch (Exception ex)
{
throw new Exception($"Failed to parse response: {ex.Message}. Body={responseText}");
}
}
}
private string BuildApiUrl(string apiPath)
{
Uri uri = new Uri(serverUrl);
string scheme = uri.Scheme;
if (string.Equals(scheme, "ws", StringComparison.OrdinalIgnoreCase))
{
scheme = "http";
}
else if (string.Equals(scheme, "wss", StringComparison.OrdinalIgnoreCase))
{
scheme = "https";
}
string authority = uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
return $"{scheme}://{authority}{apiPath}";
}
private void SetState(ConnectionState newState)
{
if (_state == newState)
{
return;
}
ConnectionState oldState = _state;
_state = newState;
Debug.Log($"[NetworkManager] State: {oldState} -> {newState}");
OnStateChanged?.Invoke(newState);
}
private static void NotifyNetworkError(string message)
{
if (!string.IsNullOrWhiteSpace(message))
{
gNotice.error.display(message);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7941755436422684d99a4f87847324e4
@@ -0,0 +1,350 @@
using System;
using UnityEngine;
using GameServer.Client;
/// <summary>
/// 网络功能测试面板
/// 用 OnGUI 画按钮,点击后触发各项功能
/// 测试完毕后可删除此脚本
/// </summary>
public class NetworkTestUI : MonoBehaviour
{
// ── 配置 ──
[Header("测试用参数")]
[SerializeField] private string testSongId = "song_001";
[SerializeField] private string testDifficulty = "master";
[SerializeField] private long testScore = 1500000;
[SerializeField] private string testGrade = "S";
[Header("演武场测试")]
[SerializeField] private string arenaPassword = "1234";
[SerializeField] private string joinRoomCode = "";
// ── 状态显示 ──
private string _statusText = "等待连接...";
private string _lastResponse = "";
private Vector2 _scrollPos;
// 把这个方法名从 OnEnable 改成 Start
private void Start()
{
// 订阅 NetworkManager 的事件
var nm = NetworkManager.Instance;
if (nm == null)
{
Debug.LogError("[NetworkTestUI] NetworkManager.Instance 为空!确认场景中有 NetworkManager 物体");
return;
}
nm.OnStateChanged += OnStateChanged;
nm.OnHandshakeResult += OnHandshake;
nm.OnProfileLoaded += OnProfile;
nm.OnLeaderboardLoaded += OnLeaderboard;
nm.OnArenaCreated += OnArenaCreated;
nm.OnArenaJoined += OnArenaJoined;
nm.OnArenaResult += OnArenaResult;
Debug.Log("[NetworkTestUI] 事件订阅完成");
}
private void OnDisable()
{
var nm = NetworkManager.Instance;
if (nm == null) return;
nm.OnStateChanged -= OnStateChanged;
nm.OnHandshakeResult -= OnHandshake;
nm.OnProfileLoaded -= OnProfile;
nm.OnLeaderboardLoaded -= OnLeaderboard;
nm.OnArenaCreated -= OnArenaCreated;
nm.OnArenaJoined -= OnArenaJoined;
nm.OnArenaResult -= OnArenaResult;
}
// ═══════════════════════════════════════════
// OnGUI —— 测试面板
// ═══════════════════════════════════════════
private void OnGUI()
{
GUILayout.BeginArea(new Rect(10, 10, 420, Screen.height - 20));
// ── 状态栏 ──
GUILayout.Label($"<b>连接状态:</b> {_statusText}", CreateRichStyle());
GUILayout.Space(5);
// ── 基础连接流程 ──
GUILayout.Label("<b>─── 基础连接流程 ───</b>", CreateRichStyle());
if (GUILayout.Button("1. 重新连接服务器", GUILayout.Height(35)))
{
_lastResponse = "正在连接...";
NetworkManager.Instance.Connect();
}
if (GUILayout.Button("2. 加载个人数据 (GET_PROFILE)", GUILayout.Height(35)))
{
_ = LoadProfileAsync();
}
if (GUILayout.Button("3. 加载排行榜 (GET_LEADERBOARD)", GUILayout.Height(35)))
{
_ = LoadLeaderboardAsync();
}
GUILayout.Space(10);
// ── 成绩推送 ──
GUILayout.Label("<b>─── 成绩推送 ───</b>", CreateRichStyle());
GUILayout.BeginHorizontal();
GUILayout.Label("歌曲ID:", GUILayout.Width(60));
testSongId = GUILayout.TextField(testSongId);
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("难度:", GUILayout.Width(60));
testDifficulty = GUILayout.TextField(testDifficulty);
GUILayout.Label("分数:", GUILayout.Width(40));
string scoreStr = GUILayout.TextField(testScore.ToString());
if (long.TryParse(scoreStr, out long parsed)) testScore = parsed;
GUILayout.Label("评级:", GUILayout.Width(40));
testGrade = GUILayout.TextField(testGrade, GUILayout.Width(40));
GUILayout.EndHorizontal();
if (GUILayout.Button("推送成绩 (PUSH_SCORE)", GUILayout.Height(35)))
{
_ = PushScoreAsync();
}
GUILayout.Space(10);
// ── 演武场 ──
GUILayout.Label("<b>─── 演武场 ───</b>", CreateRichStyle());
if (GUILayout.Button("创建演武房间 (ARENA_CREATE)", GUILayout.Height(35)))
{
_ = CreateArenaAsync();
}
GUILayout.BeginHorizontal();
GUILayout.Label("房间号:", GUILayout.Width(60));
joinRoomCode = GUILayout.TextField(joinRoomCode);
if (GUILayout.Button("加入房间", GUILayout.Width(80), GUILayout.Height(25)))
{
_ = JoinArenaAsync();
}
GUILayout.EndHorizontal();
if (GUILayout.Button("开始比赛 (ARENA_START)", GUILayout.Height(30)))
{
_ = NetworkManager.Instance.StartArenaGame();
_lastResponse = "已发送开始比赛指令";
}
if (GUILayout.Button("提交演武成绩 (ARENA_SUBMIT)", GUILayout.Height(30)))
{
_ = SubmitArenaAsync();
}
GUILayout.Space(10);
// ── 工具 ──
GUILayout.Label("<b>─── 工具 ───</b>", CreateRichStyle());
if (GUILayout.Button("发送心跳 (HEARTBEAT)", GUILayout.Height(30)))
{
_ = NetworkManager.Instance.SendHeartbeat();
_lastResponse = "已发送心跳";
}
if (GUILayout.Button("断开连接", GUILayout.Height(30)))
{
NetworkManager.Instance.Disconnect();
_lastResponse = "已断开连接";
}
GUILayout.Space(10);
// ── 日志区 ──
GUILayout.Label("<b>─── 服务器响应 ───</b>", CreateRichStyle());
_scrollPos = GUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200));
GUILayout.Label(_lastResponse);
GUILayout.EndScrollView();
GUILayout.EndArea();
}
// ═══════════════════════════════════════════
// 异步操作方法
// ═══════════════════════════════════════════
private async Awaitable LoadProfileAsync()
{
try
{
_lastResponse = "正在加载个人数据...";
await NetworkManager.Instance.LoadProfile();
}
catch (Exception ex)
{
_lastResponse = $"加载个人数据失败: {ex.Message}";
}
}
private async Awaitable LoadLeaderboardAsync()
{
try
{
_lastResponse = "正在加载排行榜...";
await NetworkManager.Instance.LoadLeaderboard(testSongId);
}
catch (Exception ex)
{
_lastResponse = $"加载排行榜失败: {ex.Message}";
}
}
private async Awaitable PushScoreAsync()
{
try
{
_lastResponse = "正在推送成绩...";
await NetworkManager.Instance.PushScore(testSongId, testDifficulty, testScore, testGrade);
_lastResponse = $"成绩推送成功! {testSongId} | {testDifficulty} | {testScore} | {testGrade}";
}
catch (Exception ex)
{
_lastResponse = $"推送成绩失败: {ex.Message}";
}
}
private async Awaitable CreateArenaAsync()
{
try
{
_lastResponse = "正在创建演武房间...";
var result = await NetworkManager.Instance.CreateArenaRoom(
testSongId, testDifficulty, arenaPassword);
if (result.success)
{
joinRoomCode = result.room_code;
_lastResponse = $"房间创建成功!房间号: {result.room_code}";
}
else
{
_lastResponse = $"创建房间失败: {result.error_code}";
}
}
catch (Exception ex)
{
_lastResponse = $"创建房间异常: {ex.Message}";
}
}
private async Awaitable JoinArenaAsync()
{
try
{
_lastResponse = $"正在加入房间 {joinRoomCode}...";
var result = await NetworkManager.Instance.JoinArenaRoom(joinRoomCode, arenaPassword);
_lastResponse = result.success
? $"加入成功!当前人数: {result.participant_count}"
: $"加入失败: {result.error_code}";
}
catch (Exception ex)
{
_lastResponse = $"加入房间异常: {ex.Message}";
}
}
private async Awaitable SubmitArenaAsync()
{
try
{
_lastResponse = "正在提交演武成绩...";
await NetworkManager.Instance.SubmitArenaScore(joinRoomCode, testScore, testGrade);
_lastResponse = "演武成绩已提交,等待结算...";
}
catch (Exception ex)
{
_lastResponse = $"提交演武成绩失败: {ex.Message}";
}
}
// ═══════════════════════════════════════════
// 事件回调
// ═══════════════════════════════════════════
private void OnStateChanged(ConnectionState state)
{
_statusText = state.ToString();
Debug.Log($"[NetworkTestUI] 状态变更: {state}");
}
private void OnHandshake(HandshakeResponse resp)
{
_lastResponse = resp.success
? $"✅ 握手成功!SteamID: {resp.steam_id}"
: $"❌ 握手失败: {resp.error_code} - {resp.message}";
Debug.Log($"[NetworkTestUI] 握手结果: {_lastResponse}");
}
private void OnProfile(ProfileData profile)
{
_lastResponse = $"✅ 个人数据已加载\n"
+ $" SteamID: {profile.steam_id}\n"
+ $" 昵称: {profile.display_name}\n"
+ $" 等级: {profile.player_level}\n"
+ $" 排行榜: {(profile.leaderboard_opt_out ? "" : "")}";
Debug.Log($"[NetworkTestUI] {_lastResponse}");
}
private void OnLeaderboard(LeaderboardData data)
{
_lastResponse = $"✅ 排行榜已加载 | 歌曲: {data.song_id} | 周期: {data.cycle_id}\n";
if (data.rankings == null || data.rankings.Count == 0)
{
_lastResponse += " (排行榜为空)";
}
else
{
foreach (var entry in data.rankings)
{
_lastResponse += $" #{entry.rank} {entry.display_name} - {entry.total_score} [{entry.grade}]\n";
}
}
Debug.Log($"[NetworkTestUI] {_lastResponse}");
}
private void OnArenaCreated(ArenaCreateResponse resp)
{
Debug.Log($"[NetworkTestUI] 演武房间创建: success={resp.success} code={resp.room_code}");
}
private void OnArenaJoined(ArenaJoinResponse resp)
{
Debug.Log($"[NetworkTestUI] 加入演武: success={resp.success}");
}
private void OnArenaResult(ArenaResult result)
{
_lastResponse = $"🏆 演武结果 | 房间: {result.room_code}\n";
if (result.rankings != null)
{
foreach (var entry in result.rankings)
{
_lastResponse += $" #{entry.rank} {entry.steam_id} - {entry.total_score} [{entry.grade}]\n";
}
}
Debug.Log($"[NetworkTestUI] {_lastResponse}");
}
// ── 工具 ──
private GUIStyle CreateRichStyle()
{
var style = new GUIStyle(GUI.skin.label);
style.richText = true;
style.fontSize = 14;
return style;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c09f6b5355d38644b45e2721d8d59ac
@@ -0,0 +1,208 @@
using System;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace GameServer.Client
{
public class WebSocketClient : IDisposable
{
public event Action OnConnected;
public event Action<string> OnMessageReceived;
public event Action<int, string> OnClosed;
public event Action<Exception> OnError;
private ClientWebSocket _ws;
private CancellationTokenSource _cts;
private readonly int _receiveBufferSize;
private readonly int _connectTimeoutMs;
private readonly object _closeLock = new object();
public bool IsConnected
{
get
{
lock (_closeLock)
return _ws != null && _ws.State == WebSocketState.Open;
}
}
public WebSocketClient(int receiveBufferSize = 65536, int connectTimeoutMs = 15000)
{
_receiveBufferSize = receiveBufferSize;
_connectTimeoutMs = connectTimeoutMs;
}
public async Task Connect(Uri uri)
{
_ws = new ClientWebSocket();
_cts = new CancellationTokenSource();
// 跳过系统代理
_ws.Options.Proxy = GlobalProxySelection.GetEmptyWebProxy();
_ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(20);
try
{
Debug.Log($"[WebSocketClient] 正在连接 {uri} (超时={_connectTimeoutMs}ms)...");
using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token))
{
connectCts.CancelAfter(_connectTimeoutMs);
try
{
await _ws.ConnectAsync(uri, connectCts.Token);
}
catch (OperationCanceledException)
when (connectCts.IsCancellationRequested && !_cts.IsCancellationRequested)
{
throw new TimeoutException(
$"连接超时 ({_connectTimeoutMs}ms),服务器 {uri} 无响应");
}
}
if (_ws.State == WebSocketState.Open)
{
Debug.Log($"[WebSocketClient] ✅ 连接成功!状态={_ws.State}");
OnConnected?.Invoke();
_ = ReceiveLoop();
}
else
{
string msg = $"连接后状态异常: {_ws.State}";
Debug.LogWarning($"[WebSocketClient] {msg}");
OnError?.Invoke(new Exception(msg));
}
}
catch (Exception ex)
{
// ★ 关键:打印所有层级的 InnerException,找到真正原因 ★
Debug.LogError($"[WebSocketClient] ========== 连接失败详细信息 ==========");
Debug.LogError($"[WebSocketClient] 目标地址: {uri}");
Debug.LogError($"[WebSocketClient] 异常类型: {ex.GetType().FullName}");
Debug.LogError($"[WebSocketClient] 异常信息: {ex.Message}");
Exception inner = ex.InnerException;
int depth = 1;
while (inner != null)
{
Debug.LogError($"[WebSocketClient] InnerException[{depth}] 类型: {inner.GetType().FullName}");
Debug.LogError($"[WebSocketClient] InnerException[{depth}] 信息: {inner.Message}");
inner = inner.InnerException;
depth++;
}
Debug.LogError($"[WebSocketClient] ==========================================");
OnError?.Invoke(ex);
throw;
}
}
public async Task Send(string message)
{
ClientWebSocket ws;
lock (_closeLock) { ws = _ws; }
if (ws == null || ws.State != WebSocketState.Open)
{
Debug.LogWarning("[WebSocketClient] Send called while not connected.");
return;
}
byte[] bytes = Encoding.UTF8.GetBytes(message);
var segment = new ArraySegment<byte>(bytes);
try
{
await ws.SendAsync(segment, WebSocketMessageType.Text,
endOfMessage: true, cancellationToken: _cts.Token);
}
catch (Exception ex)
{
OnError?.Invoke(ex);
}
}
private async Task ReceiveLoop()
{
var buffer = new byte[_receiveBufferSize];
var sb = new StringBuilder();
try
{
while (_ws.State == WebSocketState.Open && !_cts.IsCancellationRequested)
{
sb.Clear();
WebSocketReceiveResult result;
do
{
var segment = new ArraySegment<byte>(buffer);
result = await _ws.ReceiveAsync(segment, _cts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
await _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure,
string.Empty, CancellationToken.None);
int code = result.CloseStatus.HasValue
? (int)result.CloseStatus.Value : 1000;
OnClosed?.Invoke(code, result.CloseStatusDescription ?? string.Empty);
return;
}
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
}
while (!result.EndOfMessage);
string text = sb.ToString();
if (!string.IsNullOrEmpty(text))
OnMessageReceived?.Invoke(text);
}
}
catch (OperationCanceledException) { }
catch (WebSocketException ex)
when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
{
Debug.LogWarning("[WebSocketClient] 服务器断开了连接");
OnClosed?.Invoke(1006, "Connection closed prematurely");
}
catch (Exception ex)
{
OnError?.Invoke(ex);
}
}
public async Task Close()
{
ClientWebSocket wsToClose;
lock (_closeLock)
{
wsToClose = _ws;
_ws = null;
_cts?.Cancel();
}
if (wsToClose == null) return;
try
{
if (wsToClose.State == WebSocketState.Open)
await wsToClose.CloseAsync(WebSocketCloseStatus.NormalClosure,
"Client closing", CancellationToken.None);
}
catch (Exception ex)
{
Debug.LogWarning("[WebSocketClient] Error during close: " + ex.Message);
}
finally
{
wsToClose.Dispose();
}
}
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
_ws?.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28ff8f6aee1e54c4ca44e10a7a4af0f3